feat(05-05): wire --verify into scan pipeline with consent gate

- Add --verify-timeout (default 10s) and --verify-workers (default 10) flags
- Refactor scan loop to collect findings, verify, then persist
- Gate verification behind verify.EnsureConsent(db, stdin, stderr)
- Route findings through verify.HTTPVerifier.VerifyAll with configurable
  timeout and worker pool, back-assign Result.Status/HTTPCode/Metadata
  onto engine.Finding by provider+masked-key tuple
- Persist verify_* columns via storage.SaveFinding after verification
This commit is contained in:
salvacybersec
2026-04-05 15:53:47 +03:00
parent d5370783d4
commit 6fc0abe8ae

View File

@@ -16,6 +16,7 @@ import (
"github.com/salvacybersec/keyhunter/pkg/output"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/storage"
"github.com/salvacybersec/keyhunter/pkg/verify"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -30,8 +31,10 @@ var (
flagURL string
flagClipboard bool
flagSince string
flagMaxFileSize int64
flagInsecure bool
flagMaxFileSize int64
flagInsecure bool
flagVerifyTimeout time.Duration
flagVerifyWorkers int
)
var scanCmd = &cobra.Command{
@@ -107,18 +110,62 @@ var scanCmd = &cobra.Command{
return fmt.Errorf("starting scan: %w", err)
}
// Collect findings first (no immediate save) so verification can populate
// verify_* fields before persistence.
var findings []engine.Finding
for f := range ch {
findings = append(findings, f)
// Persist to storage
}
// Phase 5: if --verify is set, gate on consent and route findings
// through the HTTPVerifier before persisting. Declined consent skips
// verification but still prints + persists the unverified findings.
if flagVerify && len(findings) > 0 {
granted, consentErr := verify.EnsureConsent(db, os.Stdin, os.Stderr)
if consentErr != nil {
return fmt.Errorf("consent check: %w", consentErr)
}
if !granted {
fmt.Fprintln(os.Stderr, "Verification skipped (consent not granted). Run `keyhunter legal` for details.")
} else {
verifier := verify.NewHTTPVerifier(flagVerifyTimeout)
resultsCh := verifier.VerifyAll(context.Background(), findings, reg, flagVerifyWorkers)
// Build an index keyed by provider+maskedKey so we can back-assign
// results onto the findings slice (VerifyAll preserves neither
// order nor identity — only the provider/masked tuple).
idx := make(map[string]int, len(findings))
for i, f := range findings {
idx[f.ProviderName+"|"+f.KeyMasked] = i
}
for r := range resultsCh {
if i, ok := idx[r.ProviderName+"|"+r.KeyMasked]; ok {
findings[i].Verified = true
findings[i].VerifyStatus = r.Status
findings[i].VerifyHTTPCode = r.HTTPCode
findings[i].VerifyMetadata = r.Metadata
if r.Error != "" {
findings[i].VerifyError = r.Error
}
}
}
}
}
// Persist all findings with verify_* fields populated (if verification ran).
for _, f := range findings {
storeFinding := storage.Finding{
ProviderName: f.ProviderName,
KeyValue: f.KeyValue,
KeyMasked: f.KeyMasked,
Confidence: f.Confidence,
SourcePath: f.Source,
SourceType: f.SourceType,
LineNumber: f.LineNumber,
ProviderName: f.ProviderName,
KeyValue: f.KeyValue,
KeyMasked: f.KeyMasked,
Confidence: f.Confidence,
SourcePath: f.Source,
SourceType: f.SourceType,
LineNumber: f.LineNumber,
Verified: f.Verified,
VerifyStatus: f.VerifyStatus,
VerifyHTTPCode: f.VerifyHTTPCode,
VerifyMetadata: f.VerifyMetadata,
}
if _, err := db.SaveFinding(storeFinding, encKey); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to save finding: %v\n", err)
@@ -288,5 +335,9 @@ func init() {
scanCmd.Flags().Int64Var(&flagMaxFileSize, "max-file-size", 0, "max file size in bytes to scan (0 = unlimited)")
scanCmd.Flags().BoolVar(&flagInsecure, "insecure", false, "for --url: skip TLS certificate verification")
// Phase 5 verification flags.
scanCmd.Flags().DurationVar(&flagVerifyTimeout, "verify-timeout", 10*time.Second, "per-key verification HTTP timeout (default 10s)")
scanCmd.Flags().IntVar(&flagVerifyWorkers, "verify-workers", 10, "parallel workers for key verification (default 10)")
_ = viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers"))
}