/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · 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 }