From 1237f3f09e5c90cc8460da47a48f71cc24f25754 Mon Sep 17 00:00:00 2001 From: vmfunc Date: Wed, 10 Jun 2026 15:28:21 -0700 Subject: [PATCH] feat(finding): normalized finding layer for notify and diff scan results live in ~two dozen structs with no shared shape, so every consumer that wants "what did this run turn up" reimplements the type-switch. add internal/finding: an ordered Severity (info 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 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: "dork hit", + 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 + } + out = append(out, Finding{ + Target: target, + Module: "subdomain_takeover", + Severity: sevTakeover, + 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, + }} +} + +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 +} diff --git a/internal/finding/finding_test.go b/internal/finding/finding_test.go new file mode 100644 index 0000000..596fcf5 --- /dev/null +++ b/internal/finding/finding_test.go @@ -0,0 +1,354 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import ( + "strings" + "testing" + + "github.com/dropalldatabases/sif/internal/modules" + "github.com/dropalldatabases/sif/internal/scan" + "github.com/dropalldatabases/sif/internal/scan/frameworks" + "github.com/dropalldatabases/sif/internal/scan/js" + "github.com/projectdiscovery/nuclei/v3/pkg/model" + "github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity" + "github.com/projectdiscovery/nuclei/v3/pkg/output" +) + +// scanResultType mirrors the minimal interface the scan packages implement; the +// coverage table below carries a value per ResultType() so a new scanner whose +// ResultType isn't represented (or isn't handled by Flatten) trips a failure. +type scanResultType interface { + ResultType() string +} + +// coverageCase is one representative, non-empty instance of a result type plus +// its expected module attribution. wantItems is how many findings Flatten must +// emit for the populated instance, proving the per-item fan-out works. +type coverageCase struct { + value any // the result as it reaches Flatten + typed scanResultType // same value when it implements ResultType(), else nil + module string // module id Flatten should stamp + wantItems int // findings the populated instance must produce +} + +// coverageCases is the registry the guard checks against. there must be one +// entry per distinct ResultType() in the scan tree (plus the raw []string and +// nuclei []ResultEvent that flow through the report without a ResultType). add a +// scanner without adding it here and TestFlattenCoversEveryResultType fails. +func coverageCases() []coverageCase { + return []coverageCase{ + { + value: &scan.ShodanResult{IP: "1.2.3.4", Ports: []int{80}, Vulns: []string{"CVE-1"}}, + typed: &scan.ShodanResult{}, + module: "shodan", + wantItems: 1, + }, + { + value: &scan.SQLResult{ + AdminPanels: []scan.SQLAdminPanel{{URL: "http://x/pma", Type: "phpMyAdmin", Status: 200}}, + DatabaseErrors: []scan.SQLDatabaseError{{URL: "http://x", DatabaseType: "mysql", ErrorPattern: "syntax"}}, + ExposedPorts: []int{3306}, + }, + typed: &scan.SQLResult{}, + module: "sql", + wantItems: 3, + }, + { + value: &scan.LFIResult{Vulnerabilities: []scan.LFIVulnerability{ + {URL: "http://x", Parameter: "file", Evidence: "root:x", Severity: "high"}, + }}, + typed: &scan.LFIResult{}, + module: "lfi", + wantItems: 1, + }, + { + value: &scan.CMSResult{Name: "WordPress", Version: "6.1"}, + typed: &scan.CMSResult{}, + module: "cms", + wantItems: 1, + }, + { + value: &scan.SecurityTrailsResult{Domain: "x.com", Subdomains: []string{"a.x.com"}, AssociatedDomains: []string{"y.com"}}, + typed: &scan.SecurityTrailsResult{}, + module: "securitytrails", + wantItems: 2, + }, + { + value: &scan.CORSResult{Findings: []scan.CORSFinding{{URL: "http://x", OriginTested: "null", AllowOrigin: "null", Severity: "medium", Note: "null origin"}}}, + typed: &scan.CORSResult{}, + module: "cors", + wantItems: 1, + }, + { + value: &scan.RedirectResult{Findings: []scan.RedirectFinding{{URL: "http://x", Parameter: "next", Location: "http://evil", Via: "header", Severity: "medium"}}}, + typed: &scan.RedirectResult{}, + module: "redirect", + wantItems: 1, + }, + { + value: &scan.XSSResult{Findings: []scan.XSSFinding{{URL: "http://x", Parameter: "q", Context: "html", SurvivedRaw: []string{"<"}, Severity: "high"}}}, + typed: &scan.XSSResult{}, + module: "xss", + wantItems: 1, + }, + { + value: &scan.CrawlResult{URLs: []string{"http://x/a"}}, + typed: &scan.CrawlResult{}, + module: "crawl", + wantItems: 1, + }, + { + value: &scan.PassiveResult{Subdomains: []string{"a.x.com"}, URLs: []string{"http://x/old"}}, + typed: &scan.PassiveResult{}, + module: "passive", + wantItems: 2, + }, + { + value: &scan.ProbeResult{URL: "http://x", Alive: true, StatusCode: 200, Title: "home"}, + typed: &scan.ProbeResult{}, + module: "probe", + wantItems: 1, + }, + { + value: scan.HeaderResults{{Name: "Server", Value: "nginx"}}, + typed: scan.HeaderResults{}, + module: "headers", + wantItems: 1, + }, + { + value: scan.SecurityHeaderResults{{Header: "Content-Security-Policy", Present: false, Severity: "medium", Note: "missing"}}, + typed: scan.SecurityHeaderResults{}, + module: "security_headers", + wantItems: 1, + }, + { + value: scan.DirectoryResults{{Url: "http://x/admin", StatusCode: 301, Size: 10, Words: 2}}, + typed: scan.DirectoryResults{}, + module: "dirlist", + wantItems: 1, + }, + { + value: scan.CloudStorageResults{{BucketName: "x-assets", IsPublic: true}}, + typed: scan.CloudStorageResults{}, + module: "cloudstorage", + wantItems: 1, + }, + { + value: scan.DorkResults{{Url: "http://x/leak", Count: 1}}, + typed: scan.DorkResults{}, + module: "dork", + wantItems: 1, + }, + { + value: scan.SubdomainTakeoverResults{{Subdomain: "old.x.com", Vulnerable: true, Service: "GitHub Pages"}}, + typed: scan.SubdomainTakeoverResults{}, + module: "subdomain_takeover", + wantItems: 1, + }, + { + value: &frameworks.FrameworkResult{Name: "Laravel", Version: "9.0", RiskLevel: "high", CVEs: []string{"CVE-2"}}, + typed: &frameworks.FrameworkResult{}, + module: "framework", + wantItems: 1, + }, + { + value: &js.JavascriptScanResult{ + SecretMatches: []js.SecretMatch{{Rule: "aws-key", Match: "AKIA...", Source: "http://x/app.js"}}, + Endpoints: []string{"/api/v1"}, + }, + typed: &js.JavascriptScanResult{}, + module: "js", + wantItems: 2, + }, + { + value: &modules.Result{ModuleID: "custom-mod", Target: "http://x", Findings: []modules.Finding{{URL: "http://x", Severity: "low", Evidence: "hit"}}}, + typed: &modules.Result{ModuleID: "custom-mod"}, + module: "custom-mod", + wantItems: 1, + }, + { + // nuclei results aren't ScanResult-typed; they ride through the report + // as a raw []ResultEvent, so cover that shape explicitly. + value: []output.ResultEvent{{TemplateID: "t1", Host: "x", Matched: "http://x", Info: model.Info{Name: "n", SeverityHolder: severity.Holder{Severity: severity.High}}}}, + module: "nuclei", + wantItems: 1, + }, + { + // dnslist/portscan/git all hand Flatten a bare []string keyed only by + // the module argument. + value: []string{"sub.x.com"}, + module: "dnslist", + wantItems: 1, + }, + } +} + +const target = "http://target.example" + +// TestFlattenCoversEveryResultType is the guard: every result type in the +// coverage table must flatten into the expected module without hitting the +// "unhandled" fallback. a new scanner that skips both the table and Flatten's +// switch trips this loudly. +func TestFlattenCoversEveryResultType(t *testing.T) { + for _, tc := range coverageCases() { + findings := Flatten(target, tc.module, tc.value) + + if len(findings) != tc.wantItems { + t.Errorf("module %q: got %d findings, want %d", tc.module, len(findings), tc.wantItems) + } + for i := 0; i < len(findings); i++ { + f := findings[i] + if strings.HasSuffix(f.Key, keySep+"unhandled") { + t.Errorf("module %q: Flatten has no case, fell through to unhandled (key=%q)", tc.module, f.Key) + } + if f.Target != target { + t.Errorf("module %q: target=%q, want %q", tc.module, f.Target, target) + } + if f.Module != tc.module { + t.Errorf("module %q: finding stamped module=%q, want %q", tc.module, f.Module, tc.module) + } + if f.Key == "" { + t.Errorf("module %q: empty Key", tc.module) + } + if !strings.HasPrefix(f.Key, tc.module+keySep) { + t.Errorf("module %q: Key %q not prefixed with module", tc.module, f.Key) + } + } + } +} + +// 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 +// known typed entries and assert each ResultType() string is present. +func TestEveryResultTypeIsInCoverageTable(t *testing.T) { + covered := make(map[string]struct{}) + for _, tc := range coverageCases() { + if tc.typed == nil { + continue + } + covered[tc.typed.ResultType()] = struct{}{} + } + + // the full set of ResultType() strings the scan tree exposes. keep this in + // lockstep with the ScanResult implementers; a missing entry means the table + // (and very likely Flatten) skipped a scanner. + want := []string{ + "shodan", "sql", "lfi", "cms", "securitytrails", + "cors", "redirect", "xss", "crawl", "passive", "probe", + "headers", "security_headers", "dirlist", "cloudstorage", + "dork", "subdomain_takeover", "framework", "js", "custom-mod", + } + for _, rt := range want { + if _, ok := covered[rt]; !ok { + t.Errorf("ResultType %q has no entry in coverageCases; Flatten coverage unverified", rt) + } + } +} + +// TestFlattenStableKeysAndSeverities pins the keys and severities for a couple +// of representative items so a refactor that quietly reshuffles them is caught. +func TestFlattenStableKeysAndSeverities(t *testing.T) { + tests := []struct { + name string + value any + module string + wantKey string + wantSev Severity + }{ + { + name: "cors honors source severity", + value: &scan.CORSResult{Findings: []scan.CORSFinding{{URL: "http://x", OriginTested: "null", AllowOrigin: "null", Severity: "high", Note: "n"}}}, + module: "cors", + wantKey: "cors:http://x:null", + wantSev: SeverityHigh, + }, + { + name: "public bucket is high", + value: scan.CloudStorageResults{{BucketName: "b", IsPublic: true}}, + module: "cloudstorage", + wantKey: "cloudstorage:b", + wantSev: SeverityHigh, + }, + { + name: "header is recon info", + value: scan.HeaderResults{{Name: "Server", Value: "nginx"}}, + module: "headers", + wantKey: "headers:Server", + wantSev: SeverityInfo, + }, + { + name: "vulnerable takeover is high", + value: scan.SubdomainTakeoverResults{{Subdomain: "old.x.com", Vulnerable: true, Service: "GitHub Pages"}}, + module: "subdomain_takeover", + wantKey: "subdomain_takeover:old.x.com", + wantSev: SeverityHigh, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + findings := Flatten(target, tt.module, tt.value) + if len(findings) != 1 { + t.Fatalf("got %d findings, want 1", len(findings)) + } + f := findings[0] + if f.Key != tt.wantKey { + t.Errorf("Key = %q, want %q", f.Key, tt.wantKey) + } + if f.Severity != tt.wantSev { + t.Errorf("Severity = %v, want %v", f.Severity, tt.wantSev) + } + }) + } +} + +// TestFlattenUnhandledTypeIsLoud asserts the fallback fires for a type Flatten +// doesn't know - this is what makes the guard above meaningful. +func TestFlattenUnhandledTypeIsLoud(t *testing.T) { + type bogus struct{} + findings := Flatten(target, "mystery", bogus{}) + if len(findings) != 1 { + t.Fatalf("got %d findings, want 1 placeholder", len(findings)) + } + if !strings.HasSuffix(findings[0].Key, keySep+"unhandled") { + t.Errorf("unhandled type should key on :unhandled, got %q", findings[0].Key) + } + if findings[0].Severity != SeverityUnknown { + t.Errorf("unhandled severity = %v, want SeverityUnknown", findings[0].Severity) + } +} + +// TestSubdomainTakeoverSkipsSafe confirms a non-vulnerable cname produces no +// finding; only the real takeover is a finding. +func TestSubdomainTakeoverSkipsSafe(t *testing.T) { + value := scan.SubdomainTakeoverResults{ + {Subdomain: "safe.x.com", Vulnerable: false}, + {Subdomain: "bad.x.com", Vulnerable: true, Service: "Heroku"}, + } + findings := Flatten(target, "subdomain_takeover", value) + if len(findings) != 1 { + t.Fatalf("got %d findings, want 1 (only the vulnerable one)", len(findings)) + } + if findings[0].Key != "subdomain_takeover:bad.x.com" { + t.Errorf("Key = %q, want subdomain_takeover:bad.x.com", findings[0].Key) + } +} + +// TestDeadProbeIsNotAFinding confirms a host that didn't answer yields nothing. +func TestDeadProbeIsNotAFinding(t *testing.T) { + findings := Flatten(target, "probe", &scan.ProbeResult{URL: "http://x", Alive: false}) + if len(findings) != 0 { + t.Errorf("dead probe produced %d findings, want 0", len(findings)) + } +} diff --git a/internal/finding/severity.go b/internal/finding/severity.go new file mode 100644 index 0000000..141821b --- /dev/null +++ b/internal/finding/severity.go @@ -0,0 +1,78 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import "strings" + +// Severity is an ordered severity rank shared by every normalized finding. +// the order matters: notify gates on a threshold and diff sorts by it, so the +// underlying ints have to compare info < low < medium < high < critical. +type Severity int + +// severity ranks, lowest to highest. SeverityUnknown sorts below everything so +// an unrecognized scanner string never silently outranks a real critical. +const ( + SeverityUnknown Severity = iota + SeverityInfo + SeverityLow + SeverityMedium + SeverityHigh + SeverityCritical +) + +// severityNames maps each rank to its canonical lowercase string. the wire +// format scanners emit ("info"/"low"/...) round-trips through this table. +var severityNames = map[Severity]string{ + SeverityUnknown: "unknown", + SeverityInfo: "info", + SeverityLow: "low", + SeverityMedium: "medium", + SeverityHigh: "high", + SeverityCritical: "critical", +} + +// String renders the canonical lowercase name for the rank. +func (s Severity) String() string { + if name, ok := severityNames[s]; ok { + return name + } + return severityNames[SeverityUnknown] +} + +// ParseSeverity maps a scanner's free-form severity string onto a rank. it's +// case/space insensitive and folds the common synonyms ("informational", +// "warning", "moderate") so the dozen scanners that each picked their own +// spelling all land on the same ladder. an empty or unrecognized value is +// SeverityUnknown rather than a guess. +func ParseSeverity(raw string) Severity { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "critical": + return SeverityCritical + case "high": + return SeverityHigh + case "medium", "moderate", "warning": + return SeverityMedium + case "low": + return SeverityLow + case "info", "informational", "information", "none": + return SeverityInfo + default: + return SeverityUnknown + } +} + +// AtLeast reports whether s is at or above threshold; notify uses it to drop +// findings below the configured floor. +func (s Severity) AtLeast(threshold Severity) bool { + return s >= threshold +} diff --git a/internal/finding/severity_test.go b/internal/finding/severity_test.go new file mode 100644 index 0000000..6b4c681 --- /dev/null +++ b/internal/finding/severity_test.go @@ -0,0 +1,84 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package finding + +import "testing" + +func TestParseSeverity(t *testing.T) { + tests := []struct { + in string + want Severity + }{ + {"critical", SeverityCritical}, + {"CRITICAL", SeverityCritical}, + {" high ", SeverityHigh}, + {"medium", SeverityMedium}, + {"moderate", SeverityMedium}, + {"warning", SeverityMedium}, + {"low", SeverityLow}, + {"info", SeverityInfo}, + {"informational", SeverityInfo}, + {"none", SeverityInfo}, + {"", SeverityUnknown}, + {"bogus", SeverityUnknown}, + } + for _, tt := range tests { + if got := ParseSeverity(tt.in); got != tt.want { + t.Errorf("ParseSeverity(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestSeverityOrdering(t *testing.T) { + // the ladder must be strictly increasing for AtLeast/sort to behave. + ordered := []Severity{ + SeverityUnknown, SeverityInfo, SeverityLow, + SeverityMedium, SeverityHigh, SeverityCritical, + } + for i := 1; i < len(ordered); i++ { + if ordered[i-1] >= ordered[i] { + t.Errorf("severity ladder not increasing at %d: %v !< %v", i, ordered[i-1], ordered[i]) + } + } +} + +func TestSeverityAtLeast(t *testing.T) { + tests := []struct { + sev Severity + threshold Severity + want bool + }{ + {SeverityHigh, SeverityMedium, true}, + {SeverityMedium, SeverityMedium, true}, + {SeverityLow, SeverityMedium, false}, + {SeverityCritical, SeverityInfo, true}, + {SeverityUnknown, SeverityInfo, false}, + } + for _, tt := range tests { + if got := tt.sev.AtLeast(tt.threshold); got != tt.want { + t.Errorf("%v.AtLeast(%v) = %v, want %v", tt.sev, tt.threshold, got, tt.want) + } + } +} + +func TestSeverityStringRoundTrip(t *testing.T) { + // every named rank renders to a string ParseSeverity maps back to the same + // rank, so the wire format is lossless for known severities. + for _, sev := range []Severity{ + SeverityInfo, SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical, + } { + if got := ParseSeverity(sev.String()); got != sev { + t.Errorf("round-trip %v -> %q -> %v", sev, sev.String(), got) + } + } +} diff --git a/internal/scan/js/scan.go b/internal/scan/js/scan.go index 2cc3981..fd95233 100644 --- a/internal/scan/js/scan.go +++ b/internal/scan/js/scan.go @@ -39,6 +39,31 @@ type JavascriptScanResult struct { // ResultType implements the ScanResult interface. func (r *JavascriptScanResult) ResultType() string { return "js" } +// SupabaseFinding is the exported view of one discovered supabase project. the +// raw supabaseScanResult stays package-private (it carries scan internals), so +// downstream normalizers consume this projection instead. +type SupabaseFinding struct { + ProjectId string + Role string + Collections int +} + +// SupabaseFindings projects the package-private supabase results into a stable +// exported shape for the finding normalizer; role is what makes one interesting +// (a non-anon key is the real bug). +func (r *JavascriptScanResult) SupabaseFindings() []SupabaseFinding { + out := make([]SupabaseFinding, 0, len(r.SupabaseResults)) + for i := 0; i < len(r.SupabaseResults); i++ { + s := r.SupabaseResults[i] + out = append(out, SupabaseFinding{ + ProjectId: s.ProjectId, + Role: s.Role, + Collections: len(s.Collections), + }) + } + return out +} + func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) (*JavascriptScanResult, error) { log := output.Module("JS") log.Start() diff --git a/sif.go b/sif.go index 1d5e5d2..8bdbf9c 100644 --- a/sif.go +++ b/sif.go @@ -25,6 +25,7 @@ import ( "github.com/charmbracelet/log" "github.com/dropalldatabases/sif/internal/config" + "github.com/dropalldatabases/sif/internal/finding" "github.com/dropalldatabases/sif/internal/httpx" "github.com/dropalldatabases/sif/internal/logger" "github.com/dropalldatabases/sif/internal/modules" @@ -216,6 +217,11 @@ func (app *App) Run() error { wantReport := app.settings.SARIF != "" || app.settings.Markdown != "" reportResults := make([]report.Result, 0, 16) + // normalized findings for the whole run; the single Flatten-driven view that + // notify and diff (later) consume. collected alongside the report so both + // describe the same scanners from one pass. + allFindings := make([]finding.Finding, 0, 16) + for _, url := range app.targets { output.Info("Starting scan on %s", output.Highlight.Render(url)) @@ -543,11 +549,18 @@ func (app *App) Run() error { fmt.Println(string(marshalled)) } + allFindings = append(allFindings, collectFindings(url, moduleResults)...) + // the report carries raw blobs and is only built when an export flag is + // set, so the common path skips the marshalling entirely. if wantReport { reportResults = append(reportResults, collectReportResults(url, moduleResults)...) } } + // the normalized findings are the handoff point for notify/diff; surface the + // count now so the path is live and observable without changing output. + log.Debugf("normalized %d findings across %d targets", len(allFindings), len(app.targets)) + if wantReport { if err := app.writeReports(reportResults); err != nil { return err @@ -561,6 +574,18 @@ func (app *App) Run() error { return nil } +// collectFindings normalizes one target's module results through finding.Flatten +// - the single normalization path that notify and diff (later bundles) build on. +// every scan result struct collapses to flat, severity-ranked findings here so a +// scanner is described once, not once per consumer. +func collectFindings(target string, moduleResults []ModuleResult) []finding.Finding { + out := make([]finding.Finding, 0, len(moduleResults)) + for _, mr := range moduleResults { + out = append(out, finding.Flatten(target, mr.Id, mr.Data)...) + } + return out +} + // collectReportResults flattens one target's module results into the report // model, carrying each finding as raw json so the report package stays free of // scan types. a result that won't marshal is skipped rather than failing the run.