diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go index f9616041..e789d81a 100644 --- a/internal/scan/frameworks/detectors/backend.go +++ b/internal/scan/frameworks/detectors/backend.go @@ -86,7 +86,10 @@ func (d *djangoDetector) Name() string { return "Django" } func (d *djangoDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "csrfmiddlewaretoken", Weight: 0.4, HeaderOnly: true}, + // csrfmiddlewaretoken is a hidden form BODY field Django templates + // render (``), + // never a header, so this must not be HeaderOnly. + {Pattern: "csrfmiddlewaretoken", Weight: 0.4}, {Pattern: "csrftoken", Weight: 0.3, HeaderOnly: true}, {Pattern: "django.contrib", Weight: 0.3}, {Pattern: "django.core", Weight: 0.3}, @@ -171,9 +174,11 @@ func (d *aspnetDetector) Signatures() []fw.Signature { {Pattern: "__VIEWSTATE", Weight: 0.4}, {Pattern: "__EVENTVALIDATION", Weight: 0.3}, {Pattern: "__VIEWSTATEGENERATOR", Weight: 0.3}, - {Pattern: ".aspx", Weight: 0.2}, - {Pattern: ".ashx", Weight: 0.2}, - {Pattern: ".asmx", Weight: 0.2}, + // .aspx/.ashx/.asmx path-extension signatures were dropped: they are + // weak (any page can link to one) and their combined weight diluted + // the canonical X-AspNet-Version/X-Powered-By headers below the + // detection threshold on a plain ASP.NET response that carries no + // body markers at all (e.g. a JSON API reply). {Pattern: "asp.net_sessionid", Weight: 0.4, HeaderOnly: true}, } } @@ -185,7 +190,9 @@ func (d *aspnetDetector) Detect(body string, headers http.Header) (float32, stri var version string if confidence > 0.5 { - version = fw.ExtractVersionOptimized(body, d.Name()).Version + // ASP.NET's strongest version signal is header-shaped + // (X-AspNet-Version), so search headers too, not just the body. + version = fw.ExtractVersionFromResponse(body, headers, d.Name()).Version } return confidence, version } @@ -287,7 +294,8 @@ func (d *flaskDetector) Detect(body string, headers http.Header) (float32, strin var version string if confidence > 0.5 { - version = fw.ExtractVersionOptimized(body, d.Name()).Version + // same header-search rationale as ASP.NET above. + version = fw.ExtractVersionFromResponse(body, headers, d.Name()).Version } return confidence, version } diff --git a/internal/scan/frameworks/detectors/frontend.go b/internal/scan/frameworks/detectors/frontend.go index 488cfa46..1ff4c0a5 100644 --- a/internal/scan/frameworks/detectors/frontend.go +++ b/internal/scan/frameworks/detectors/frontend.go @@ -99,7 +99,9 @@ func (d *angularDetector) Name() string { return "Angular" } func (d *angularDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "ng-version", Weight: 0.5}, + // require the attribute-assignment form, not the bare word, so prose + // discussing ng-version can't match; weighted to clear the threshold alone. + {Pattern: `ng-version="`, Weight: 1.2}, {Pattern: "ng-app", Weight: 0.4}, {Pattern: "ng-controller", Weight: 0.4}, {Pattern: "angular.js", Weight: 0.4}, diff --git a/internal/scan/frameworks/detectors/hardening_test.go b/internal/scan/frameworks/detectors/hardening_test.go new file mode 100644 index 00000000..981a7261 --- /dev/null +++ b/internal/scan/frameworks/detectors/hardening_test.go @@ -0,0 +1,223 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +/* + + BSD 3-Clause License + (c) 2022-2026 vmfunc, xyzeva & contributors + +*/ + +// this file pins the fp/fn corpus behind the framework-detector hardening +// pass: for each fixed defect it asserts BOTH the real-product positive +// still detects and the prose/other-product negative does not. +package detectors + +import ( + "net/http" + "testing" + + fw "github.com/vmfunc/sif/internal/scan/frameworks" +) + +func hdr(pairs ...string) http.Header { + h := http.Header{} + for i := 0; i+1 < len(pairs); i += 2 { + h.Add(pairs[i], pairs[i+1]) + } + return h +} + +func detect(t *testing.T, name, body string, headers http.Header) (float32, string) { + t.Helper() + d, ok := fw.GetDetector(name) + if !ok { + t.Fatalf("detector %q not registered", name) + } + if headers == nil { + headers = http.Header{} + } + return d.Detect(body, headers) +} + +// --- Django --- + +func TestDjango_BodyFieldPlusCookieDetects(t *testing.T) { + body := `
` + conf, _ := detect(t, "Django", body, hdr("Set-Cookie", "csrftoken=xyz; Path=/")) + if conf <= 0.5 { + t.Errorf("real Django page (csrf body field + csrftoken cookie) confidence = %.3f, want > 0.5", conf) + } +} + +func TestDjango_RandomPageNoMatch(t *testing.T) { + conf, _ := detect(t, "Django", "hello", http.Header{}) + if conf > 0.5 { + t.Errorf("random page with no django markers confidence = %.3f, want <= 0.5", conf) + } +} + +// --- ASP.NET --- + +func TestASPNET_CanonicalHeadersAloneDetects(t *testing.T) { + headers := hdr( + "X-AspNet-Version", "4.0.30319", + "X-Powered-By", "ASP.NET", + "Server", "Microsoft-IIS/10.0", + ) + conf, _ := detect(t, "ASP.NET", `{"status":"ok"}`, headers) + if conf <= 0.5 { + t.Errorf("ASP.NET canonical headers (no body markers) confidence = %.3f, want > 0.5", conf) + } +} + +func TestASPNET_VersionExtractedFromHeader(t *testing.T) { + headers := hdr("X-AspNet-Version", "4.0.30319", "X-Powered-By", "ASP.NET") + _, version := detect(t, "ASP.NET", `{"status":"ok"}`, headers) + if version != "4.0.30319" { + t.Errorf("ASP.NET version = %q, want %q (extracted from X-AspNet-Version header)", version, "4.0.30319") + } +} + +func TestASPNET_NoVersionWithoutHeader(t *testing.T) { + // body markers alone push confidence over threshold but there is no + // version anywhere in the response; must not fabricate one. + body := `
` + + `
` + conf, version := detect(t, "ASP.NET", body, http.Header{}) + if conf <= 0.5 { + t.Fatalf("setup: expected ASP.NET body markers to detect, confidence = %.3f", conf) + } + if version != "" && version != "unknown" { + t.Errorf("ASP.NET version = %q, want empty/unknown with no version marker present", version) + } +} + +// --- Flask --- + +func TestFlask_VersionExtractedFromServerHeader(t *testing.T) { + _, version := detect(t, "Flask", "", hdr("Server", "Werkzeug/2.3.0 Python/3.11")) + if version != "2.3.0" { + t.Errorf("Flask version = %q, want %q (extracted from Werkzeug Server header)", version, "2.3.0") + } +} + +// --- CakePHP --- + +func TestCakePHP_CookieDetects(t *testing.T) { + conf, _ := detect(t, "CakePHP", "Home", hdr("Set-Cookie", "CAKEPHP=abc123; path=/")) + if conf <= 0.5 { + t.Errorf("CakePHP CAKEPHP cookie confidence = %.3f, want > 0.5", conf) + } +} + +// --- Angular --- + +func TestAngular_NgVersionAloneDetects(t *testing.T) { + body := `` + conf, _ := detect(t, "Angular", body, nil) + if conf <= 0.5 { + t.Errorf("Angular ng-version attribute alone confidence = %.3f, want > 0.5", conf) + } +} + +func TestAngular_ProseMentionDoesNotDetect(t *testing.T) { + body := `
Angular automatically stamps an ng-version marker + onto the root element for diagnostics purposes; see the ng-version docs for details.
` + conf, _ := detect(t, "Angular", body, nil) + if conf > 0.5 { + t.Errorf("prose mention of ng-version confidence = %.3f, want <= 0.5", conf) + } +} + +// --- Astro --- + +func TestAstro_GeneratorMetaAloneDetects(t *testing.T) { + body := `` + conf, _ := detect(t, "Astro", body, nil) + if conf <= 0.5 { + t.Errorf("Astro generator meta alone confidence = %.3f, want > 0.5", conf) + } +} + +func TestAstro_ProseMentionDoesNotDetect(t *testing.T) { + body := `
Astro is a popular static site generator that + many teams evaluate alongside Next.js and SvelteKit.
` + conf, _ := detect(t, "Astro", body, nil) + if conf > 0.5 { + t.Errorf("prose mention of Astro confidence = %.3f, want <= 0.5", conf) + } +} + +// --- Next.js --- + +func TestNextJS_StaticAssetAloneDetects(t *testing.T) { + body := `` + + `` + conf, _ := detect(t, "Next.js", body, hdr("Server", "nginx")) + if conf <= 0.5 { + t.Errorf("self-hosted app-router Next.js (only /_next/static) confidence = %.3f, want > 0.5", conf) + } +} + +func TestNextJS_ProseMentionDoesNotDetect(t *testing.T) { + body := `

Static assets live under /_next/static/ by Next.js convention.

` + conf, _ := detect(t, "Next.js", body, nil) + if conf > 0.5 { + t.Errorf("prose mention of /_next/static/ confidence = %.3f, want <= 0.5", conf) + } +} + +// --- Full-corpus sweep --- + +func TestSweep_GenericPagesNoFire(t *testing.T) { + cases := []struct { + name string + body string + hdr http.Header + }{ + { + name: "plain nginx welcome", + body: `Welcome to nginx!

Welcome to nginx!

If you see this page, the nginx web server is successfully installed.

`, + hdr: hdr("Server", "nginx/1.24.0", "Content-Type", "text/html"), + }, + { + name: "apache php site", + body: `

My PHP Site

`, + hdr: hdr("Server", "Apache/2.4.57", "X-Powered-By", "PHP/8.2.0", "Set-Cookie", "PHPSESSID=abc; path=/"), + }, + { + name: "tech blog article listing frameworks", + body: `
2026 framework roundup: we cover Joomla, Magento, Drupal, WordPress, Ghost, Symfony, Meteor, and Angular in depth.
`, + hdr: hdr("Server", "nginx"), + }, + { + name: "generic java tomcat app", + body: `Login`, + hdr: hdr("Server", "Apache-Coyote/1.1", "Set-Cookie", "JSESSIONID=1A2B3C; Path=/; HttpOnly"), + }, + } + + dets := fw.GetDetectors() + for _, c := range cases { + headers := c.hdr + if headers == nil { + headers = http.Header{} + } + for name, d := range dets { + conf, ver := d.Detect(c.body, headers) + if conf > 0.5 { + t.Errorf("%s: detector %q false-fired confidence=%.4f version=%q", c.name, name, conf, ver) + } + } + } +} diff --git a/internal/scan/frameworks/detectors/meta.go b/internal/scan/frameworks/detectors/meta.go index ad30466d..c335e1b1 100644 --- a/internal/scan/frameworks/detectors/meta.go +++ b/internal/scan/frameworks/detectors/meta.go @@ -43,7 +43,8 @@ func (d *nextjsDetector) Name() string { return "Next.js" } func (d *nextjsDetector) Signatures() []fw.Signature { return []fw.Signature{ {Pattern: "__NEXT_DATA__", Weight: 0.5}, - {Pattern: "_next/static", Weight: 0.4}, + // same attribute-form-vs-prose rationale as Angular's ng-version marker. + {Pattern: `="/_next/static/`, Weight: 0.6}, {Pattern: "__next", Weight: 0.3}, {Pattern: "x-nextjs", Weight: 0.3, HeaderOnly: true}, } @@ -166,7 +167,8 @@ func (d *astroDetector) Name() string { return "Astro" } func (d *astroDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: ` 1 && p.confidence > bestMatch.Confidence { candidate := matches[1] if isValidVersionString(candidate) {