Files
keyhunter/pkg/storage/findings.go
salvacybersec aec559d2aa feat(05-01): migrate findings schema with verify_* columns
- schema.sql: new findings columns verified, verify_status, verify_http_code, verify_metadata_json
- db.go: migrateFindingsVerifyColumns runs on Open() for legacy DBs using PRAGMA table_info + ALTER TABLE
- findings.go: Finding struct gains Verified/VerifyStatus/VerifyHTTPCode/VerifyMetadata
- SaveFinding serializes verify metadata as JSON (NULL when nil)
- ListFindings round-trips all verify fields
2026-04-05 15:42:53 +03:00

147 lines
4.3 KiB
Go

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