From f570dceadc8a026350121a1a83fe097e79f082e7 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:50:08 -0700 Subject: [PATCH] feat(report): add json findings report with -json (#313) sarif and markdown were the only structured exports and both drop the per-item detail the normalized finding model already holds. add a -json flag that writes the run's findings as a json array, one object per finding with its target, module, severity, key, title and evidence. carry detection confidence onto the finding model too so framework detections report their score. it rides into the json report and is omitted for scanners that carry none. the -silent line format and the snapshot on-disk shape stay unchanged, since the report uses a dedicated json view rather than marshalling the finding struct directly. --- internal/config/config.go | 2 + internal/finding/finding.go | 26 +++++++------ internal/finding/finding_test.go | 21 +++++++++++ internal/finding/json.go | 50 +++++++++++++++++++++++++ internal/finding/json_test.go | 63 ++++++++++++++++++++++++++++++++ sif.go | 21 +++++++++++ 6 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 internal/finding/json.go create mode 100644 internal/finding/json_test.go 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