diff --git a/pkg/storage/db.go b/pkg/storage/db.go new file mode 100644 index 0000000..4bb905d --- /dev/null +++ b/pkg/storage/db.go @@ -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 +} diff --git a/pkg/storage/findings.go b/pkg/storage/findings.go new file mode 100644 index 0000000..172b700 --- /dev/null +++ b/pkg/storage/findings.go @@ -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() +} diff --git a/pkg/storage/schema.sql b/pkg/storage/schema.sql new file mode 100644 index 0000000..8817d8c --- /dev/null +++ b/pkg/storage/schema.sql @@ -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);