package storage import ( "database/sql" "encoding/json" "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 // Verification fields populated by the Phase 5 verifier. Zero values mean // the finding has not been verified. Verified bool VerifyStatus string // "live", "dead", "rate_limited", "error", "unknown" VerifyHTTPCode int VerifyMetadata map[string]string } // 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{} } // Serialize verify metadata as JSON (NULL when nil) to match schema. var metaJSON interface{} if f.VerifyMetadata != nil { b, err := json.Marshal(f.VerifyMetadata) if err != nil { return 0, fmt.Errorf("marshaling verify metadata: %w", err) } metaJSON = string(b) } else { metaJSON = sql.NullString{} } verifiedInt := 0 if f.Verified { verifiedInt = 1 } res, err := db.sql.Exec( `INSERT INTO findings ( scan_id, provider_name, key_value, key_masked, confidence, source_path, source_type, line_number, verified, verify_status, verify_http_code, verify_metadata_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, scanID, f.ProviderName, encrypted, masked, f.Confidence, f.SourcePath, f.SourceType, f.LineNumber, verifiedInt, f.VerifyStatus, f.VerifyHTTPCode, metaJSON, ) 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, verified, verify_status, verify_http_code, verify_metadata_json, 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 var verifiedInt int var metaJSON sql.NullString if err := rows.Scan( &f.ID, &scanID, &f.ProviderName, &encrypted, &f.KeyMasked, &f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber, &verifiedInt, &f.VerifyStatus, &f.VerifyHTTPCode, &metaJSON, &createdAt, ); err != nil { return nil, fmt.Errorf("scanning finding row: %w", err) } if scanID.Valid { f.ScanID = scanID.Int64 } f.Verified = verifiedInt != 0 if metaJSON.Valid && metaJSON.String != "" { m := map[string]string{} if err := json.Unmarshal([]byte(metaJSON.String), &m); err != nil { return nil, fmt.Errorf("unmarshaling verify metadata for finding %d: %w", f.ID, err) } f.VerifyMetadata = m } 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() }