diff --git a/internal/config/config.go b/internal/config/config.go index 091d107..b5d4d3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,6 +70,7 @@ type Settings struct { Probe bool SARIF string // path to write a sarif 2.1.0 report to ("" = off) Markdown string // path to write a markdown report to ("" = off) + JSONReport string // path to write a json findings report to ("" = off) Silent bool // route chrome to stderr, print one finding per line to stdout Diff bool // surface only findings added/removed vs the last snapshot Store string // snapshot dir for diff mode ("" = default state dir) @@ -187,6 +188,7 @@ func registerFlags(settings *Settings) *goflags.FlagSet { flagSet.CreateGroup("output", "Output", flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"), flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"), + flagSet.StringVar(&settings.JSONReport, "json", "", "Write a json findings report to this file"), flagSet.BoolVar(&settings.Silent, "silent", false, "Plain output: chrome to stderr, one finding per line to stdout (for pipelines)"), flagSet.BoolVar(&settings.Diff, "diff", false, "Diff mode: surface only findings added/removed since the last snapshot of each target"), flagSet.StringVar(&settings.Store, "store", "", "Snapshot directory for -diff (default: log dir, else /sif/state)"), diff --git a/internal/finding/finding.go b/internal/finding/finding.go index daa5fab..e4bd12b 100644 --- a/internal/finding/finding.go +++ b/internal/finding/finding.go @@ -33,12 +33,13 @@ import ( // match) rather than a whole module's blob, so consumers diff and notify at // item granularity. type Finding struct { - Target string // the url/host the scan ran against - Module string // the ResultType() of the source scanner - Severity Severity // ranked severity, SeverityUnknown when the source has none - Key string // stable identity for dedup/diff: module + ":" + identifier - Title string // short human label - Raw string // short evidence string, not the full body + Target string // the url/host the scan ran against + Module string // the ResultType() of the source scanner + Severity Severity // ranked severity, SeverityUnknown when the source has none + Key string // stable identity for dedup/diff: module + ":" + identifier + Title string // short human label + Raw string // short evidence string, not the full body + Confidence float32 // detection confidence 0..1; zero when the source has none } // Line renders a finding as one stable, terse, machine-friendly line for the @@ -610,12 +611,13 @@ func flattenFramework(target string, r *frameworks.FrameworkResult) []Finding { raw = fmt.Sprintf("%s, %d cves", raw, len(r.CVEs)) } return []Finding{{ - Target: target, - Module: "framework", - Severity: sev, - Key: key("framework", r.Name), - Title: r.Name + " detected", - Raw: raw, + Target: target, + Module: "framework", + Severity: sev, + Key: key("framework", r.Name), + Title: r.Name + " detected", + Raw: raw, + Confidence: r.Confidence, }} } diff --git a/internal/finding/finding_test.go b/internal/finding/finding_test.go index 17c23d1..ae82372 100644 --- a/internal/finding/finding_test.go +++ b/internal/finding/finding_test.go @@ -257,6 +257,27 @@ func TestFlattenCoversEveryResultType(t *testing.T) { } } +// TestFlattenFrameworkCarriesConfidence asserts the detector's confidence rides +// onto the framework finding, while a scanner with no confidence stays at zero. +func TestFlattenFrameworkCarriesConfidence(t *testing.T) { + fw := Flatten(target, "framework", &frameworks.FrameworkResult{Name: "Laravel", Version: "9.0", Confidence: 0.82}) + if len(fw) != 1 { + t.Fatalf("framework: got %d findings, want 1", len(fw)) + } + if fw[0].Confidence != 0.82 { + t.Errorf("framework confidence = %v, want 0.82", fw[0].Confidence) + } + + // a scanner without a confidence signal leaves the field at its zero value. + hdr := Flatten(target, "headers", []scan.HeaderResult{{Name: "Server", Value: "nginx"}}) + if len(hdr) != 1 { + t.Fatalf("headers: got %d findings, want 1", len(hdr)) + } + if hdr[0].Confidence != 0 { + t.Errorf("headers confidence = %v, want 0", hdr[0].Confidence) + } +} + // TestEveryResultTypeIsInCoverageTable cross-checks the table against the actual // ResultType() registry: if a scanner type exists whose ResultType() isn't in // the table, the coverage guard above would never exercise it. enumerate the diff --git a/internal/finding/json.go b/internal/finding/json.go new file mode 100644 index 0000000..7d44502 --- /dev/null +++ b/internal/finding/json.go @@ -0,0 +1,50 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import "encoding/json" + +// reportFinding is the stable json shape for a finding in the -json report. it's +// a dedicated view, not the Finding struct itself, so severity renders as its +// string name and the on-disk snapshot format (which marshals Finding directly) +// stays untouched. confidence is omitted when zero so recon findings without a +// detection score don't carry a misleading 0. +type reportFinding struct { + Target string `json:"target"` + Module string `json:"module"` + Severity string `json:"severity"` + Key string `json:"key"` + Title string `json:"title"` + Evidence string `json:"evidence,omitempty"` + Confidence float32 `json:"confidence,omitempty"` +} + +// JSONReport serializes a run's normalized findings to an indented json array. +// it never returns a nil body: an empty run marshals to "[]" so consumers can +// always parse the output. +func JSONReport(findings []Finding) ([]byte, error) { + out := make([]reportFinding, 0, len(findings)) + for i := 0; i < len(findings); i++ { + f := findings[i] + out = append(out, reportFinding{ + Target: f.Target, + Module: f.Module, + Severity: f.Severity.String(), + Key: f.Key, + Title: f.Title, + Evidence: f.Raw, + Confidence: f.Confidence, + }) + } + return json.MarshalIndent(out, "", " ") +} diff --git a/internal/finding/json_test.go b/internal/finding/json_test.go new file mode 100644 index 0000000..77407f0 --- /dev/null +++ b/internal/finding/json_test.go @@ -0,0 +1,63 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import ( + "encoding/json" + "testing" +) + +func TestJSONReportEmptyIsArray(t *testing.T) { + data, err := JSONReport(nil) + if err != nil { + t.Fatalf("JSONReport: %v", err) + } + if string(data) != "[]" { + t.Errorf("empty report = %q, want %q", string(data), "[]") + } +} + +func TestJSONReportShapeAndFields(t *testing.T) { + findings := []Finding{ + {Target: "https://x", Module: "framework", Severity: SeverityHigh, Key: "framework:Laravel", Title: "Laravel detected", Raw: "Laravel 9.0", Confidence: 0.82}, + {Target: "https://x", Module: "headers", Severity: SeverityInfo, Key: "headers:Server", Title: "Server", Raw: "nginx"}, + } + data, err := JSONReport(findings) + if err != nil { + t.Fatalf("JSONReport: %v", err) + } + + var got []map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("report is not valid json: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want 2", len(got)) + } + + // framework finding: severity rendered as its string name, confidence present. + if got[0]["severity"] != "high" { + t.Errorf("severity = %v, want \"high\"", got[0]["severity"]) + } + if got[0]["confidence"] != 0.82 { + t.Errorf("confidence = %v, want 0.82", got[0]["confidence"]) + } + + // recon finding: zero confidence is omitted rather than serialized as 0. + if _, ok := got[1]["confidence"]; ok { + t.Errorf("zero confidence should be omitted, got %v", got[1]["confidence"]) + } + if got[1]["severity"] != "info" { + t.Errorf("severity = %v, want \"info\"", got[1]["severity"]) + } +} diff --git a/sif.go b/sif.go index a6c4a3f..6b88188 100644 --- a/sif.go +++ b/sif.go @@ -856,6 +856,12 @@ func (app *App) finishRun(scansRun []string, allFindings []finding.Finding, repo } } + if path := app.settings.JSONReport; path != "" { + if err := app.writeJSONReport(path, allFindings); err != nil { + return err + } + } + if !app.settings.ApiMode { output.PrintSummary(scansRun, app.logFiles) } @@ -1015,6 +1021,21 @@ func (app *App) writeReports(results []report.Result) error { return nil } +// writeJSONReport serializes the run's normalized findings to a json file. it +// works off allFindings (not the raw report blobs) so the output carries the +// same severity and confidence the -silent stream and notify path see. +func (app *App) writeJSONReport(path string, findings []finding.Finding) error { + data, err := finding.JSONReport(findings) + if err != nil { + return fmt.Errorf("build json report: %w", err) + } + if err := os.WriteFile(path, data, reportFileMode); err != nil { + return fmt.Errorf("write json report %q: %w", path, err) + } + output.Success("json report written to %s", path) + return nil +} + // notifyFindings filters the run's findings to the -notify-severity floor and // ships the survivors to every configured provider. an unrecognized severity // string parses to SeverityUnknown, which would let everything through; guard