Files
sif/internal/finding/finding.go
TigahandGitHub f570dceadc 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.
2026-07-22 12:50:08 -07:00

740 lines
21 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package finding is the one normalization layer between the scan results and
// the consumers that don't want to know about ~two dozen result structs: notify
// (later) gates and renders on it, diff (later) keys runs off it. Flatten is the
// single type-switch; adding a scanner without teaching Flatten about it trips
// the guard test in flatten_test.go, on purpose.
package finding
import (
"fmt"
"strings"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/vmfunc/sif/internal/modules"
"github.com/vmfunc/sif/internal/scan"
"github.com/vmfunc/sif/internal/scan/frameworks"
"github.com/vmfunc/sif/internal/scan/js"
)
// Finding is the normalized shape every scanner result collapses to. one
// Finding is one underlying item (a single header, one cors hit, one nuclei
// 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
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
// -silent plain sink: "[severity] target module title". no styling, no color -
// a downstream pipe (notify, grep, awk) keys off the bracketed severity and the
// fixed field order, so the shape stays frozen. pointer receiver: Finding is
// wide enough that copying it per line is wasteful.
func (f *Finding) Line() string {
return fmt.Sprintf("[%s] %s %s %s", f.Severity, f.Target, f.Module, f.Title)
}
// static per-module severities for results that carry no severity field of
// their own. these are the editorial baseline; a scanner that emits its own
// severity (cors, xss, nuclei, ...) overrides this on a per-item basis.
const (
// a live admin panel / takeover / public bucket is high on its own.
sevTakeover = SeverityHigh
sevPublicS3 = SeverityHigh
sevAdminPanel = SeverityHigh
// disclosure-grade signals: dberrors, secrets, supabase keys.
sevDBError = SeverityMedium
sevSecret = SeverityMedium
// pure recon/inventory: headers, crawl urls, passive hosts, ports.
sevRecon = SeverityInfo
)
// keySep joins the module id and the per-item identifier into a Key. kept as a
// const so the diff layer can split on it without re-deriving the separator.
const keySep = ":"
// key builds a stable per-item identity: module:identifier. identifier is
// whatever uniquely names the item within its module (a url, a header name, a
// subdomain) so the same finding across two runs produces the same Key.
func key(module, identifier string) string {
return module + keySep + identifier
}
// Flatten normalizes one module's result into zero or more Findings. result is
// the raw data carried in a ModuleResult; the type switch covers every scan
// result struct. an unrecognized type yields a single SeverityUnknown finding
// keyed "module:unhandled" so a new scanner surfaces loudly instead of
// vanishing - the guard test asserts this never happens for a known type.
func Flatten(target, module string, result any) []Finding {
switch r := result.(type) {
case *scan.ShodanResult:
return flattenShodan(target, r)
case *scan.SQLResult:
return flattenSQL(target, r)
case *scan.LFIResult:
return flattenLFI(target, r)
case *scan.JWTResult:
return flattenJWT(target, r)
case *scan.OpenAPIResult:
return flattenOpenAPI(target, r)
case *scan.FaviconResult:
return flattenFavicon(target, r)
case *scan.CMSResult:
return flattenCMS(target, r)
case *scan.SecurityTrailsResult:
return flattenSecurityTrails(target, r)
case *scan.CORSResult:
return flattenCORS(target, r)
case *scan.RedirectResult:
return flattenRedirect(target, r)
case *scan.XSSResult:
return flattenXSS(target, r)
case *scan.CrawlResult:
return flattenCrawl(target, r)
case *scan.PassiveResult:
return flattenPassive(target, r)
case *scan.ProbeResult:
return flattenProbe(target, r)
case scan.HeaderResults:
return flattenHeaders(target, r)
case []scan.HeaderResult:
// the headers module appends a literal []HeaderResult, not the named
// slice type; both reach here so cover both.
return flattenHeaders(target, r)
case scan.SecurityHeaderResults:
return flattenSecurityHeaders(target, r)
case []scan.SecurityHeaderResult:
return flattenSecurityHeaders(target, r)
case scan.DirectoryResults:
return flattenDirlist(target, r)
case []scan.DirectoryResult:
return flattenDirlist(target, r)
case scan.CloudStorageResults:
return flattenCloudStorage(target, r)
case []scan.CloudStorageResult:
return flattenCloudStorage(target, r)
case scan.DorkResults:
return flattenDork(target, r)
case []scan.DorkResult:
return flattenDork(target, r)
case scan.SubdomainTakeoverResults:
return flattenTakeover(target, r)
case []scan.SubdomainTakeoverResult:
return flattenTakeover(target, r)
case *frameworks.FrameworkResult:
return flattenFramework(target, r)
case *js.JavascriptScanResult:
return flattenJS(target, r)
case *modules.Result:
// yaml/builtin modules carry their own module id; honor it over the
// passed-in module so per-module findings stay attributed correctly.
return flattenModule(target, r)
case []output.ResultEvent:
return flattenNuclei(target, r)
case []string:
// dnslist/portscan/git all hand back a bare []string of discovered
// items; module disambiguates which inventory it is.
return flattenStrings(target, module, r)
default:
// unknown type: emit a loud placeholder rather than dropping it.
return []Finding{{
Target: target,
Module: module,
Severity: SeverityUnknown,
Key: key(module, "unhandled"),
Title: fmt.Sprintf("unhandled result type %T", result),
Raw: fmt.Sprintf("%T", result),
}}
}
}
func flattenShodan(target string, r *scan.ShodanResult) []Finding {
if r == nil {
return nil
}
// one host snapshot -> one inventory finding; vulns are the interesting bit
// so they bump severity and ride along in the evidence string.
sev := sevRecon
if len(r.Vulns) > 0 {
sev = SeverityHigh
}
raw := fmt.Sprintf("%d ports", len(r.Ports))
if len(r.Vulns) > 0 {
raw = fmt.Sprintf("%s, %d vulns", raw, len(r.Vulns))
}
return []Finding{{
Target: target,
Module: "shodan",
Severity: sev,
Key: key("shodan", r.IP),
Title: "shodan host " + r.IP,
Raw: raw,
}}
}
func flattenSQL(target string, r *scan.SQLResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.AdminPanels)+len(r.DatabaseErrors)+len(r.ExposedPorts))
for i := 0; i < len(r.AdminPanels); i++ {
p := r.AdminPanels[i]
out = append(out, Finding{
Target: target,
Module: "sql",
Severity: sevAdminPanel,
Key: key("sql", "admin:"+p.URL),
Title: p.Type + " admin panel",
Raw: fmt.Sprintf("%s (%d)", p.URL, p.Status),
})
}
for i := 0; i < len(r.DatabaseErrors); i++ {
e := r.DatabaseErrors[i]
out = append(out, Finding{
Target: target,
Module: "sql",
Severity: sevDBError,
Key: key("sql", "dberr:"+e.URL+":"+e.DatabaseType),
Title: e.DatabaseType + " error disclosure",
Raw: e.ErrorPattern,
})
}
for i := 0; i < len(r.ExposedPorts); i++ {
p := r.ExposedPorts[i]
out = append(out, Finding{
Target: target,
Module: "sql",
Severity: SeverityMedium,
Key: key("sql", fmt.Sprintf("port:%d", p)),
Title: fmt.Sprintf("exposed db port %d", p),
Raw: fmt.Sprintf("%d", p),
})
}
return out
}
func flattenLFI(target string, r *scan.LFIResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Vulnerabilities))
for i := 0; i < len(r.Vulnerabilities); i++ {
v := r.Vulnerabilities[i]
out = append(out, Finding{
Target: target,
Module: "lfi",
Severity: ParseSeverity(v.Severity),
Key: key("lfi", v.URL+":"+v.Parameter),
Title: "lfi via " + v.Parameter,
Raw: v.Evidence,
})
}
return out
}
func flattenJWT(target string, r *scan.JWTResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Tokens))
for i := 0; i < len(r.Tokens); i++ {
t := r.Tokens[i]
// one finding per weakness, not per token: a token with alg:none and a
// weak key is two distinct issues a consumer wants to diff separately.
for j := 0; j < len(t.Issues); j++ {
iss := t.Issues[j]
out = append(out, Finding{
Target: target,
Module: "jwt",
Severity: ParseSeverity(iss.Severity),
Key: key("jwt", t.Source+":"+iss.Kind),
Title: "jwt " + iss.Kind,
Raw: iss.Detail,
})
}
}
return out
}
func flattenOpenAPI(target string, r *scan.OpenAPIResult) []Finding {
if r == nil {
return nil
}
return []Finding{{
Target: target,
Module: "openapi",
Severity: ParseSeverity(r.Severity),
Key: key("openapi", r.SpecURL),
Title: "openapi spec exposed",
Raw: fmt.Sprintf("%s (%d endpoints)", r.SpecURL, len(r.Endpoints)),
}}
}
func flattenFavicon(target string, r *scan.FaviconResult) []Finding {
if r == nil {
return nil
}
// a matched fingerprint is a real signal; an unmatched hash is just inventory
// (still useful as a shodan pivot, so we keep it at recon).
sev := sevRecon
title := fmt.Sprintf("favicon hash %d", r.Hash)
if r.Tech != "" {
sev = SeverityLow
title = r.Tech + " (favicon)"
}
return []Finding{{
Target: target,
Module: "favicon",
Severity: sev,
Key: key("favicon", fmt.Sprintf("%d", r.Hash)),
Title: title,
Raw: r.ShodanQ,
}}
}
func flattenCMS(target string, r *scan.CMSResult) []Finding {
if r == nil || r.Name == "" {
return nil
}
return []Finding{{
Target: target,
Module: "cms",
Severity: sevRecon,
Key: key("cms", r.Name),
Title: r.Name + " detected",
Raw: strings.TrimSpace(r.Name + " " + r.Version),
}}
}
func flattenSecurityTrails(target string, r *scan.SecurityTrailsResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Subdomains)+len(r.AssociatedDomains))
for i := 0; i < len(r.Subdomains); i++ {
d := r.Subdomains[i]
out = append(out, Finding{
Target: target,
Module: "securitytrails",
Severity: sevRecon,
Key: key("securitytrails", "sub:"+d),
Title: "subdomain " + d,
Raw: d,
})
}
for i := 0; i < len(r.AssociatedDomains); i++ {
d := r.AssociatedDomains[i]
out = append(out, Finding{
Target: target,
Module: "securitytrails",
Severity: sevRecon,
Key: key("securitytrails", "assoc:"+d),
Title: "associated domain " + d,
Raw: d,
})
}
return out
}
func flattenCORS(target string, r *scan.CORSResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: "cors",
Severity: ParseSeverity(f.Severity),
Key: key("cors", f.URL+":"+f.OriginTested),
Title: f.Note,
Raw: "allow-origin: " + f.AllowOrigin,
})
}
return out
}
func flattenRedirect(target string, r *scan.RedirectResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: "redirect",
Severity: ParseSeverity(f.Severity),
Key: key("redirect", f.URL+":"+f.Parameter+":"+f.Via),
Title: "open redirect via " + f.Parameter,
Raw: f.Location,
})
}
return out
}
func flattenXSS(target string, r *scan.XSSResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: "xss",
Severity: ParseSeverity(f.Severity),
Key: key("xss", f.URL+":"+f.Parameter+":"+f.Context),
Title: "reflected xss in " + f.Parameter,
Raw: strings.Join(f.SurvivedRaw, " "),
})
}
return out
}
func flattenCrawl(target string, r *scan.CrawlResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.URLs))
for i := 0; i < len(r.URLs); i++ {
u := r.URLs[i]
out = append(out, Finding{
Target: target,
Module: "crawl",
Severity: sevRecon,
Key: key("crawl", u),
Title: "crawled url",
Raw: u,
})
}
return out
}
func flattenPassive(target string, r *scan.PassiveResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Subdomains)+len(r.URLs))
for i := 0; i < len(r.Subdomains); i++ {
s := r.Subdomains[i]
out = append(out, Finding{
Target: target,
Module: "passive",
Severity: sevRecon,
Key: key("passive", "sub:"+s),
Title: "passive subdomain " + s,
Raw: s,
})
}
for i := 0; i < len(r.URLs); i++ {
u := r.URLs[i]
out = append(out, Finding{
Target: target,
Module: "passive",
Severity: sevRecon,
Key: key("passive", "url:"+u),
Title: "passive url",
Raw: u,
})
}
return out
}
func flattenProbe(target string, r *scan.ProbeResult) []Finding {
if r == nil || !r.Alive {
// a dead probe isn't a finding, just an absent host.
return nil
}
return []Finding{{
Target: target,
Module: "probe",
Severity: sevRecon,
Key: key("probe", r.URL),
Title: fmt.Sprintf("alive %d", r.StatusCode),
Raw: strings.TrimSpace(fmt.Sprintf("%d %s", r.StatusCode, r.Title)),
}}
}
func flattenHeaders(target string, rs []scan.HeaderResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
h := rs[i]
out = append(out, Finding{
Target: target,
Module: "headers",
Severity: sevRecon,
Key: key("headers", h.Name),
Title: h.Name,
Raw: h.Value,
})
}
return out
}
func flattenSecurityHeaders(target string, rs []scan.SecurityHeaderResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
h := rs[i]
out = append(out, Finding{
Target: target,
Module: "security_headers",
Severity: ParseSeverity(h.Severity),
Key: key("security_headers", h.Header),
Title: h.Header,
Raw: h.Note,
})
}
return out
}
// dirInteresting bounds the "noteworthy" 3xx range for a listed directory; a
// redirect (>=300) or anything past it is worth more than a plain 200 hit.
const dirRedirectFloor = 300
func flattenDirlist(target string, rs []scan.DirectoryResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
d := rs[i]
sev := sevRecon
if d.StatusCode >= dirRedirectFloor {
sev = SeverityLow
}
out = append(out, Finding{
Target: target,
Module: "dirlist",
Severity: sev,
Key: key("dirlist", d.Url),
Title: fmt.Sprintf("%s [%d]", d.Url, d.StatusCode),
Raw: fmt.Sprintf("status=%d size=%d", d.StatusCode, d.Size),
})
}
return out
}
func flattenCloudStorage(target string, rs []scan.CloudStorageResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
b := rs[i]
sev := sevRecon
if b.IsPublic {
sev = sevPublicS3
}
title := "bucket " + b.BucketName
if b.IsPublic {
title = "public bucket " + b.BucketName
}
out = append(out, Finding{
Target: target,
Module: "cloudstorage",
Severity: sev,
Key: key("cloudstorage", b.BucketName),
Title: title,
Raw: fmt.Sprintf("public=%t", b.IsPublic),
})
}
return out
}
func flattenDork(target string, rs []scan.DorkResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
d := rs[i]
out = append(out, Finding{
Target: target,
Module: "dork",
Severity: sevRecon,
Key: key("dork", d.Url),
Title: fmt.Sprintf("dork hit [%s]", d.Dork),
Raw: d.Url,
})
}
return out
}
func flattenTakeover(target string, rs []scan.SubdomainTakeoverResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
t := rs[i]
// only the vulnerable ones are findings; a safe cname is noise here.
if !t.Vulnerable {
continue
}
// a "potential" result only matched a body fingerprint and could not be
// checked against the cname (lookup unavailable), so it does not earn
// the same severity as a cname-confirmed takeover.
sev := sevTakeover
if t.Confidence == "potential" {
sev = SeverityMedium
}
out = append(out, Finding{
Target: target,
Module: "subdomain_takeover",
Severity: sev,
Key: key("subdomain_takeover", t.Subdomain),
Title: "takeover: " + t.Subdomain,
Raw: t.Service,
})
}
return out
}
func flattenFramework(target string, r *frameworks.FrameworkResult) []Finding {
if r == nil || r.Name == "" {
return nil
}
// framework risk maps onto severity; an unset risk falls back to recon.
sev := ParseSeverity(r.RiskLevel)
if sev == SeverityUnknown {
sev = sevRecon
}
raw := strings.TrimSpace(r.Name + " " + r.Version)
if len(r.CVEs) > 0 {
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,
Confidence: r.Confidence,
}}
}
func flattenJS(target string, r *js.JavascriptScanResult) []Finding {
if r == nil {
return nil
}
supabase := r.SupabaseFindings()
out := make([]Finding, 0, len(r.SecretMatches)+len(supabase)+len(r.Endpoints)+len(r.FoundEnvironmentVars))
for i := 0; i < len(r.SecretMatches); i++ {
s := r.SecretMatches[i]
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sevSecret,
Key: key("js", "secret:"+s.Rule+":"+s.Source),
Title: "secret: " + s.Rule,
Raw: s.Source,
})
}
for i := 0; i < len(supabase); i++ {
s := supabase[i]
// a non-anon role on an exposed key is the real bug; anon is just recon.
sev := sevRecon
if s.Role != "" && s.Role != "anon" {
sev = SeverityHigh
}
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sev,
Key: key("js", "supabase:"+s.ProjectId),
Title: "supabase project " + s.ProjectId,
Raw: fmt.Sprintf("role=%s collections=%d", s.Role, s.Collections),
})
}
for i := 0; i < len(r.Endpoints); i++ {
e := r.Endpoints[i]
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sevRecon,
Key: key("js", "endpoint:"+e),
Title: "js endpoint",
Raw: e,
})
}
// env vars are a map; sort-free since the Key carries the name, and diff
// keys on the Key not on iteration order.
for name, value := range r.FoundEnvironmentVars {
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sevSecret,
Key: key("js", "env:"+name),
Title: "env var " + name,
Raw: value,
})
}
return out
}
func flattenModule(target string, r *modules.Result) []Finding {
if r == nil {
return nil
}
module := r.ResultType()
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: module,
Severity: ParseSeverity(f.Severity),
Key: key(module, f.URL),
Title: module + " finding",
Raw: f.Evidence,
})
}
return out
}
func flattenNuclei(target string, events []output.ResultEvent) []Finding {
out := make([]Finding, 0, len(events))
for i := 0; i < len(events); i++ {
e := events[i]
// host is the most reliable per-hit identifier; matched-at sharpens it
// when several templates fire on one host.
ident := e.TemplateID + ":" + e.Host
if e.Matched != "" {
ident = e.TemplateID + ":" + e.Matched
}
out = append(out, Finding{
Target: target,
Module: "nuclei",
Severity: ParseSeverity(e.Info.SeverityHolder.Severity.String()),
Key: key("nuclei", ident),
Title: e.Info.Name,
Raw: e.Matched,
})
}
return out
}
func flattenStrings(target, module string, items []string) []Finding {
out := make([]Finding, 0, len(items))
for i := 0; i < len(items); i++ {
v := items[i]
out = append(out, Finding{
Target: target,
Module: module,
Severity: sevRecon,
Key: key(module, v),
Title: module + " item",
Raw: v,
})
}
return out
}