From 400f1be9d11d758d2acad80e7ed6722847e1c1b2 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:06:01 -0700 Subject: [PATCH 1/4] 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. --- internal/scan/frameworks/detectors/backend.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go index f9616041..9094e5d7 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}, From 5323d5de9234ae4d64a8afc222717fdbdf01b5d0 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:06:39 -0700 Subject: [PATCH 2/4] fix(detectors): tighten aspnet, angular, astro and nextjs markers --- internal/scan/frameworks/detectors/backend.go | 8 +++++--- internal/scan/frameworks/detectors/frontend.go | 4 +++- internal/scan/frameworks/detectors/meta.go | 12 ++++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go index 9094e5d7..3aead36e 100644 --- a/internal/scan/frameworks/detectors/backend.go +++ b/internal/scan/frameworks/detectors/backend.go @@ -174,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}, } } 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/meta.go b/internal/scan/frameworks/detectors/meta.go index ad30466d..8e9f9187 100644 --- a/internal/scan/frameworks/detectors/meta.go +++ b/internal/scan/frameworks/detectors/meta.go @@ -43,7 +43,11 @@ 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}, + // require the attribute-assignment form (href="/_next/static/... or + // src="/_next/static/...) so prose that merely names the directory + // can't match; a self-hosted app-router page often serves only this + // asset path, so weight it high enough to clear the threshold alone. + {Pattern: `="/_next/static/`, Weight: 0.6}, {Pattern: "__next", Weight: 0.3}, {Pattern: "x-nextjs", Weight: 0.3, HeaderOnly: true}, } @@ -166,7 +170,11 @@ func (d *astroDetector) Name() string { return "Astro" } func (d *astroDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: ` Date: Tue, 7 Jul 2026 12:07:23 -0700 Subject: [PATCH 3/4] 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. --- internal/scan/frameworks/detectors/backend.go | 8 +++- internal/scan/frameworks/version.go | 46 +++++++++++++++++-- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go index 3aead36e..849c8a52 100644 --- a/internal/scan/frameworks/detectors/backend.go +++ b/internal/scan/frameworks/detectors/backend.go @@ -190,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 } @@ -292,7 +294,9 @@ 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 + // Flask's version is often only visible as "Werkzeug/x.y.z" in the + // Server header, so search headers too, not just the body. + version = fw.ExtractVersionFromResponse(body, headers, d.Name()).Version } return confidence, version } diff --git a/internal/scan/frameworks/version.go b/internal/scan/frameworks/version.go index ee7dc9ec..5a8853d8 100644 --- a/internal/scan/frameworks/version.go +++ b/internal/scan/frameworks/version.go @@ -13,7 +13,9 @@ package frameworks import ( + "net/http" "regexp" + "strings" "unicode" ) @@ -56,9 +58,13 @@ func init() { {`"express":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"}, }, "ASP.NET": { - {`X-AspNet-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.95, "header"}, + // header names are matched case-insensitively: Go's http.Header + // canonicalizes "X-AspNet-Version" to "X-Aspnet-Version", so a + // case-sensitive literal here would never match the header line + // built by headerSearchText. + {`(?i:X-AspNet-Version):\s*(\d+\.\d+(?:\.\d+)?)`, 0.95, "header"}, {`ASP\.NET[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - {`X-AspNetMvc-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.9, "MVC header"}, + {`(?i:X-AspNetMvc-Version):\s*(\d+\.\d+(?:\.\d+)?)`, 0.9, "MVC header"}, }, "ASP.NET Core": { {`\.NET\s*(\d+\.\d+(?:\.\d+)?)`, 0.8, "dotnet version"}, @@ -159,9 +165,39 @@ func init() { } } -// ExtractVersionOptimized extracts version using pre-compiled patterns. -// This is exported for use by individual detector implementations. +// ExtractVersionOptimized extracts version using pre-compiled patterns, +// searching only the response body. This is exported for use by individual +// detector implementations. func ExtractVersionOptimized(body string, framework string) VersionMatch { + return extractVersion(body, framework) +} + +// ExtractVersionFromResponse is like ExtractVersionOptimized but also searches +// canonical header lines, so header-shaped patterns (e.g. ASP.NET's +// X-AspNet-Version) can match; use it for detectors with header-shaped patterns. +func ExtractVersionFromResponse(body string, headers http.Header, framework string) VersionMatch { + return extractVersion(body+"\n"+headerSearchText(headers), framework) +} + +// headerSearchText renders headers as canonical "Name: value" lines, one per +// value, so version regexes written against raw header text (e.g. +// "X-AspNet-Version: 4.0.30319") have something to match against. +func headerSearchText(headers http.Header) string { + var b strings.Builder + for name, values := range headers { + for _, v := range values { + b.WriteString(name) + b.WriteString(": ") + b.WriteString(v) + b.WriteString("\n") + } + } + return b.String() +} + +// extractVersion runs every pattern registered for framework against text and +// keeps the highest-confidence valid match. +func extractVersion(text string, framework string) VersionMatch { patterns, exists := frameworkVersionPatterns[framework] if !exists { return VersionMatch{Version: "unknown", Confidence: 0, Source: ""} @@ -169,7 +205,7 @@ func ExtractVersionOptimized(body string, framework string) VersionMatch { var bestMatch VersionMatch for _, p := range patterns { - matches := p.re.FindStringSubmatch(body) + matches := p.re.FindStringSubmatch(text) if len(matches) > 1 && p.confidence > bestMatch.Confidence { candidate := matches[1] if isValidVersionString(candidate) { From 86cb5679e1237ff19b8242d4fcad70776d782cab Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:08:28 -0700 Subject: [PATCH 4/4] 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 | 3 +- .../frameworks/detectors/hardening_test.go | 223 ++++++++++++++++++ internal/scan/frameworks/detectors/meta.go | 10 +- 3 files changed, 226 insertions(+), 10 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 849c8a52..e789d81a 100644 --- a/internal/scan/frameworks/detectors/backend.go +++ b/internal/scan/frameworks/detectors/backend.go @@ -294,8 +294,7 @@ func (d *flaskDetector) Detect(body string, headers http.Header) (float32, strin var version string if confidence > 0.5 { - // Flask's version is often only visible as "Werkzeug/x.y.z" in the - // Server header, so search headers too, not just the body. + // 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/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 8e9f9187..c335e1b1 100644 --- a/internal/scan/frameworks/detectors/meta.go +++ b/internal/scan/frameworks/detectors/meta.go @@ -43,10 +43,7 @@ func (d *nextjsDetector) Name() string { return "Next.js" } func (d *nextjsDetector) Signatures() []fw.Signature { return []fw.Signature{ {Pattern: "__NEXT_DATA__", Weight: 0.5}, - // require the attribute-assignment form (href="/_next/static/... or - // src="/_next/static/...) so prose that merely names the directory - // can't match; a self-hosted app-router page often serves only this - // asset path, so weight it high enough to clear the threshold alone. + // 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}, @@ -170,10 +167,7 @@ func (d *astroDetector) Name() string { return "Astro" } func (d *astroDetector) Signatures() []fw.Signature { return []fw.Signature{ - // the generator meta tag is already a definitive, quote-anchored - // structural marker (not a bare brand word); weight it high enough - // that a minimal static page carrying only this meta tag still - // clears the detection threshold on its own. + // definitive quote-anchored marker; same threshold rationale as Next.js above. {Pattern: `