diff --git a/pkg/storage/queries.go b/pkg/storage/queries.go new file mode 100644 index 0000000..d4d85df --- /dev/null +++ b/pkg/storage/queries.go @@ -0,0 +1,157 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" +) + +// Filters selects a subset of findings for ListFindingsFiltered. +// Empty Provider means "any provider". Nil Verified means "any verified state". +// Limit <= 0 disables pagination (Offset is then ignored). +type Filters struct { + Provider string + Verified *bool + Limit int + Offset int +} + +// ListFindingsFiltered returns findings matching the given filters, newest first. +// Key values are decrypted before return. encKey must match the key used at save time. +func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error) { + var ( + where []string + args []interface{} + ) + if f.Provider != "" { + where = append(where, "provider_name = ?") + args = append(args, f.Provider) + } + if f.Verified != nil { + where = append(where, "verified = ?") + if *f.Verified { + args = append(args, 1) + } else { + args = append(args, 0) + } + } + q := `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` + if len(where) > 0 { + q += " WHERE " + strings.Join(where, " AND ") + } + q += " ORDER BY created_at DESC, id DESC" + if f.Limit > 0 { + q += " LIMIT ? OFFSET ?" + args = append(args, f.Limit, f.Offset) + } + + rows, err := db.sql.Query(q, args...) + if err != nil { + return nil, fmt.Errorf("querying findings: %w", err) + } + defer rows.Close() + + var out []Finding + for rows.Next() { + finding, err := scanFindingRow(rows, encKey) + if err != nil { + return nil, err + } + out = append(out, finding) + } + return out, rows.Err() +} + +// GetFinding returns a single finding by id. Returns sql.ErrNoRows when no +// finding with the given id exists; callers can detect this with errors.Is. +func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error) { + row := db.sql.QueryRow( + `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 WHERE id = ?`, id) + f, err := scanFindingRowFromRow(row, encKey) + if err != nil { + return nil, err + } + return &f, nil +} + +// DeleteFinding removes the finding with the given id. +// Returns the number of rows affected (0 if no such id). A missing id is not +// an error — the caller decides whether to surface it. +func (db *DB) DeleteFinding(id int64) (int64, error) { + res, err := db.sql.Exec(`DELETE FROM findings WHERE id = ?`, id) + if err != nil { + return 0, fmt.Errorf("deleting finding %d: %w", id, err) + } + return res.RowsAffected() +} + +// scanFindingRow reads one Finding from *sql.Rows and decrypts its key value. +func scanFindingRow(rows *sql.Rows, encKey []byte) (Finding, error) { + 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 f, fmt.Errorf("scanning finding row: %w", err) + } + return hydrateFinding(f, encrypted, scanID, verifiedInt, metaJSON, createdAt, encKey) +} + +// scanFindingRowFromRow reads one Finding from a *sql.Row. Propagates +// sql.ErrNoRows unchanged so callers can use errors.Is to detect a miss. +func scanFindingRowFromRow(row *sql.Row, encKey []byte) (Finding, error) { + var f Finding + var encrypted []byte + var createdAt string + var scanID sql.NullInt64 + var verifiedInt int + var metaJSON sql.NullString + if err := row.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 f, err // includes sql.ErrNoRows — let caller detect + } + return hydrateFinding(f, encrypted, scanID, verifiedInt, metaJSON, createdAt, encKey) +} + +// hydrateFinding decrypts the key value and fills derived fields. +func hydrateFinding(f Finding, encrypted []byte, scanID sql.NullInt64, verifiedInt int, metaJSON sql.NullString, createdAt string, encKey []byte) (Finding, error) { + 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 f, fmt.Errorf("unmarshaling verify metadata for finding %d: %w", f.ID, err) + } + f.VerifyMetadata = m + } + plain, err := Decrypt(encrypted, encKey) + if err != nil { + return f, fmt.Errorf("decrypting finding %d: %w", f.ID, err) + } + f.KeyValue = string(plain) + f.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + return f, nil +}