feat(01-foundation-03): implement SQLite storage with Finding CRUD and encryption
- schema.sql: CREATE TABLE for findings, scans, settings with indexes - db.go: Open() with WAL mode, foreign keys, embedded schema migration - findings.go: SaveFinding encrypts key_value before INSERT, ListFindings decrypts after SELECT - MaskKey: first8...last4 masking helper - Fix: NULL scan_id handling for findings without parent scan
This commit is contained in:
57
pkg/storage/db.go
Normal file
57
pkg/storage/db.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed schema.sql
|
||||||
|
var schemaSQLBytes []byte
|
||||||
|
|
||||||
|
// DB wraps the sql.DB connection with KeyHunter-specific behavior.
|
||||||
|
type DB struct {
|
||||||
|
sql *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens or creates a SQLite database at path, runs embedded schema migrations,
|
||||||
|
// and enables WAL mode for better concurrent read performance.
|
||||||
|
// Use ":memory:" for tests.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
sqlDB, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable WAL mode for concurrent reads
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("enabling WAL mode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("enabling foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run schema migrations
|
||||||
|
if _, err := sqlDB.Exec(string(schemaSQLBytes)); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("running schema migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{sql: sqlDB}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying database connection.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.sql.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL returns the underlying sql.DB for advanced use cases.
|
||||||
|
func (db *DB) SQL() *sql.DB {
|
||||||
|
return db.sql
|
||||||
|
}
|
||||||
103
pkg/storage/findings.go
Normal file
103
pkg/storage/findings.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding represents a detected API key with metadata.
|
||||||
|
// KeyValue is always plaintext in this struct — encryption happens at the storage boundary.
|
||||||
|
type Finding struct {
|
||||||
|
ID int64
|
||||||
|
ScanID int64
|
||||||
|
ProviderName string
|
||||||
|
KeyValue string // plaintext — encrypted before storage, decrypted after retrieval
|
||||||
|
KeyMasked string // first8...last4, stored plaintext
|
||||||
|
Confidence string
|
||||||
|
SourcePath string
|
||||||
|
SourceType string
|
||||||
|
LineNumber int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskKey returns the masked form of a key: first 8 chars + "..." + last 4 chars.
|
||||||
|
// If the key is too short (< 12 chars), returns the full key masked with asterisks.
|
||||||
|
func MaskKey(key string) string {
|
||||||
|
if len(key) < 12 {
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
return key[:8] + "..." + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFinding encrypts the finding's KeyValue and persists the finding to the database.
|
||||||
|
// encKey must be a 32-byte AES-256 key (from DeriveKey).
|
||||||
|
func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error) {
|
||||||
|
encrypted, err := Encrypt([]byte(f.KeyValue), encKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("encrypting key value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
masked := f.KeyMasked
|
||||||
|
if masked == "" {
|
||||||
|
masked = MaskKey(f.KeyValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use NULL for scan_id when not set (zero value) to satisfy FK constraint
|
||||||
|
var scanID interface{}
|
||||||
|
if f.ScanID != 0 {
|
||||||
|
scanID = f.ScanID
|
||||||
|
} else {
|
||||||
|
scanID = sql.NullInt64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.sql.Exec(
|
||||||
|
`INSERT INTO findings (scan_id, provider_name, key_value, key_masked, confidence, source_path, source_type, line_number)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
scanID, f.ProviderName, encrypted, masked, f.Confidence, f.SourcePath, f.SourceType, f.LineNumber,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("inserting finding: %w", err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFindings retrieves all findings, decrypting key values using encKey.
|
||||||
|
// encKey must be the same 32-byte key used during SaveFinding.
|
||||||
|
func (db *DB) ListFindings(encKey []byte) ([]Finding, error) {
|
||||||
|
rows, err := db.sql.Query(
|
||||||
|
`SELECT id, scan_id, provider_name, key_value, key_masked, confidence,
|
||||||
|
source_path, source_type, line_number, created_at
|
||||||
|
FROM findings ORDER BY created_at DESC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying findings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var findings []Finding
|
||||||
|
for rows.Next() {
|
||||||
|
var f Finding
|
||||||
|
var encrypted []byte
|
||||||
|
var createdAt string
|
||||||
|
var scanID sql.NullInt64
|
||||||
|
err := rows.Scan(
|
||||||
|
&f.ID, &scanID, &f.ProviderName, &encrypted, &f.KeyMasked,
|
||||||
|
&f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber, &createdAt,
|
||||||
|
)
|
||||||
|
if scanID.Valid {
|
||||||
|
f.ScanID = scanID.Int64
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning finding row: %w", err)
|
||||||
|
}
|
||||||
|
plain, err := Decrypt(encrypted, encKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypting finding %d: %w", f.ID, err)
|
||||||
|
}
|
||||||
|
f.KeyValue = string(plain)
|
||||||
|
f.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
findings = append(findings, f)
|
||||||
|
}
|
||||||
|
return findings, rows.Err()
|
||||||
|
}
|
||||||
35
pkg/storage/schema.sql
Normal file
35
pkg/storage/schema.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- KeyHunter database schema
|
||||||
|
-- Version: 1
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME,
|
||||||
|
source_path TEXT,
|
||||||
|
finding_count INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scan_id INTEGER REFERENCES scans(id),
|
||||||
|
provider_name TEXT NOT NULL,
|
||||||
|
key_value BLOB NOT NULL,
|
||||||
|
key_masked TEXT NOT NULL,
|
||||||
|
confidence TEXT NOT NULL,
|
||||||
|
source_path TEXT,
|
||||||
|
source_type TEXT,
|
||||||
|
line_number INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_scan_id ON findings(scan_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_provider ON findings(provider_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_findings_created ON findings(created_at DESC);
|
||||||
Reference in New Issue
Block a user