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() }