From e04d4e1b59a411782e9d54e7fde45c09fcd0b898 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:54:49 -0700 Subject: [PATCH] fix(detectors): framework detector fp/fn corpus pass (#341) * fix(detectors): let django csrf body field detect csrfmiddlewaretoken is a hidden form body field django templates render, never a header, so marking it HeaderOnly meant it could never match and a real django form page (csrf field plus csrftoken cookie) went undetected. * fix(detectors): tighten aspnet, angular, astro and nextjs markers * fix(frameworks): surface header version patterns to extraction version extraction only ever searched the response body, so header-shaped patterns like ASP.NET's "X-AspNet-Version: x.y.z" or Flask's "Werkzeug/x.y.z" Server header could never match even when the detector itself fired off that same header. add ExtractVersionFromResponse, which also searches canonical header lines, and point aspnet and flask at it. fix the aspnet header regexes to match case-insensitively, since Go canonicalizes header names ("X-AspNet-Version" becomes "X-Aspnet-Version") and the old literal pattern never matched the canonical form. * test(detectors): pin fp/fn corpus for framework detection promote the ad-hoc probe/sweep scratch tests used to find these defects into a proper regression file: for each fix, assert both the real-product positive still detects and the prose/other-product negative does not, plus a sweep of unrelated pages against every registered detector. --- internal/scan/frameworks/detectors/backend.go | 20 +- .../scan/frameworks/detectors/frontend.go | 4 +- .../frameworks/detectors/hardening_test.go | 223 ++++++++++++++++++ internal/scan/frameworks/detectors/meta.go | 6 +- internal/scan/frameworks/version.go | 46 +++- 5 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 internal/scan/frameworks/detectors/hardening_test.go diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go index f961604..e789d81 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 ec1bff2..ed761d5 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 0000000..981a726 --- /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 f14d08f..55054c6 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}, } @@ -168,7 +169,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) {