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.
This commit is contained in:
Tigah
2026-07-22 12:50:08 -07:00
committed by GitHub
parent 5972cc9272
commit f570dceadc
6 changed files with 171 additions and 12 deletions
+2
View File
@@ -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 <user-config>/sif/state)"),
+14 -12
View File
@@ -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,
}}
}
+21
View File
@@ -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
+50
View File
@@ -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, "", " ")
}
+63
View File
@@ -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"])
}
}
+21
View File
@@ -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