mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 11:01:24 -07:00
ab731d0562
jwt fetches the target once then analyzes every harvested token offline: flags alg:none, the rs256->hs256 confusion surface, missing/expired exp and plaintext sensitive claims, and cracks a small bundled weak-hmac list. openapi probes the conventional spec paths, parses json/yaml and enumerates paths plus unauthenticated operations. favicon computes the shodan-style mmh3 hash (python base64.encodebytes chunking, signed int32) for tech fingerprinting and the http.favicon.hash pivot, pinned by a golden test.
384 lines
14 KiB
Go
384 lines
14 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · 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.JWTResult{Tokens: []scan.JWTToken{{
|
|
Source: "header:Authorization",
|
|
Alg: "none",
|
|
Issues: []scan.JWTIssue{
|
|
{Kind: "alg:none", Severity: "critical", Detail: "no signature"},
|
|
{Kind: "missing exp", Severity: "medium", Detail: "no expiry"},
|
|
},
|
|
}}},
|
|
typed: &scan.JWTResult{},
|
|
module: "jwt",
|
|
wantItems: 2,
|
|
},
|
|
{
|
|
value: &scan.OpenAPIResult{
|
|
SpecURL: "http://x/openapi.json",
|
|
Severity: "high",
|
|
Endpoints: []scan.OpenAPIEndpoint{{Path: "/users", Method: "GET", Unauth: true}},
|
|
},
|
|
typed: &scan.OpenAPIResult{},
|
|
module: "openapi",
|
|
wantItems: 1,
|
|
},
|
|
{
|
|
value: &scan.FaviconResult{Hash: 116323821, Tech: "Apache Tomcat", ShodanQ: "http.favicon.hash:116323821"},
|
|
typed: &scan.FaviconResult{},
|
|
module: "favicon",
|
|
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", "jwt", "openapi", "favicon", "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))
|
|
}
|
|
}
|