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.
This commit is contained in:
Tigah
2026-07-22 12:54:49 -07:00
committed by GitHub
parent 598d85e501
commit e04d4e1b59
5 changed files with 285 additions and 14 deletions
+14 -6
View File
@@ -86,7 +86,10 @@ func (d *djangoDetector) Name() string { return "Django" }
func (d *djangoDetector) Signatures() []fw.Signature { func (d *djangoDetector) Signatures() []fw.Signature {
return []fw.Signature{ return []fw.Signature{
{Pattern: "csrfmiddlewaretoken", Weight: 0.4, HeaderOnly: true}, // csrfmiddlewaretoken is a hidden form BODY field Django templates
// render (`<input type="hidden" name="csrfmiddlewaretoken" ...>`),
// never a header, so this must not be HeaderOnly.
{Pattern: "csrfmiddlewaretoken", Weight: 0.4},
{Pattern: "csrftoken", Weight: 0.3, HeaderOnly: true}, {Pattern: "csrftoken", Weight: 0.3, HeaderOnly: true},
{Pattern: "django.contrib", Weight: 0.3}, {Pattern: "django.contrib", Weight: 0.3},
{Pattern: "django.core", 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: "__VIEWSTATE", Weight: 0.4},
{Pattern: "__EVENTVALIDATION", Weight: 0.3}, {Pattern: "__EVENTVALIDATION", Weight: 0.3},
{Pattern: "__VIEWSTATEGENERATOR", Weight: 0.3}, {Pattern: "__VIEWSTATEGENERATOR", Weight: 0.3},
{Pattern: ".aspx", Weight: 0.2}, // .aspx/.ashx/.asmx path-extension signatures were dropped: they are
{Pattern: ".ashx", Weight: 0.2}, // weak (any page can link to one) and their combined weight diluted
{Pattern: ".asmx", Weight: 0.2}, // 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}, {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 var version string
if confidence > 0.5 { 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 return confidence, version
} }
@@ -287,7 +294,8 @@ func (d *flaskDetector) Detect(body string, headers http.Header) (float32, strin
var version string var version string
if confidence > 0.5 { 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 return confidence, version
} }
@@ -99,7 +99,9 @@ func (d *angularDetector) Name() string { return "Angular" }
func (d *angularDetector) Signatures() []fw.Signature { func (d *angularDetector) Signatures() []fw.Signature {
return []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-app", Weight: 0.4},
{Pattern: "ng-controller", Weight: 0.4}, {Pattern: "ng-controller", Weight: 0.4},
{Pattern: "angular.js", Weight: 0.4}, {Pattern: "angular.js", Weight: 0.4},
@@ -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 := `<form><input type="hidden" name="csrfmiddlewaretoken" value="abc"></form>`
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", "<html><body>hello</body></html>", 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 := `<form><input type="hidden" name="__VIEWSTATE" value="x"><input type="hidden" name="__EVENTVALIDATION" value="y">` +
`<input type="hidden" name="__VIEWSTATEGENERATOR" value="z"></form>`
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", "<html></html>", 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", "<html><body>Home</body></html>", 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 := `<app-root ng-version="17.3.0"></app-root>`
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 := `<html><body><article>Angular automatically stamps an ng-version marker
onto the root element for diagnostics purposes; see the ng-version docs for details.</article></body></html>`
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 := `<meta name="generator" content="Astro v4.5.0">`
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 := `<html><body><article>Astro is a popular static site generator that
many teams evaluate alongside Next.js and SvelteKit.</article></body></html>`
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 := `<html><head><link rel="stylesheet" href="/_next/static/css/abc.css">` +
`<script src="/_next/static/chunks/main.js"></script></head><body></body></html>`
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 := `<html><body><p>Static assets live under /_next/static/ by Next.js convention.</p></body></html>`
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: `<html><head><title>Welcome to nginx!</title></head><body><h1>Welcome to nginx!</h1><p>If you see this page, the nginx web server is successfully installed.</p></body></html>`,
hdr: hdr("Server", "nginx/1.24.0", "Content-Type", "text/html"),
},
{
name: "apache php site",
body: `<html><body><h1>My PHP Site</h1><form method="post"><input name="q"></form></body></html>`,
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: `<html><body><article>2026 framework roundup: we cover Joomla, Magento, Drupal, WordPress, Ghost, Symfony, Meteor, and Angular in depth.</article></body></html>`,
hdr: hdr("Server", "nginx"),
},
{
name: "generic java tomcat app",
body: `<html><body>Login</body></html>`,
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)
}
}
}
}
+4 -2
View File
@@ -43,7 +43,8 @@ func (d *nextjsDetector) Name() string { return "Next.js" }
func (d *nextjsDetector) Signatures() []fw.Signature { func (d *nextjsDetector) Signatures() []fw.Signature {
return []fw.Signature{ return []fw.Signature{
{Pattern: "__NEXT_DATA__", Weight: 0.5}, {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: "__next", Weight: 0.3},
{Pattern: "x-nextjs", Weight: 0.3, HeaderOnly: true}, {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 { func (d *astroDetector) Signatures() []fw.Signature {
return []fw.Signature{ return []fw.Signature{
{Pattern: `<meta name="generator" content="Astro`, Weight: 0.5}, // definitive quote-anchored marker; same threshold rationale as Next.js above.
{Pattern: `<meta name="generator" content="Astro`, Weight: 1.1},
{Pattern: "astro-island", Weight: 0.5}, {Pattern: "astro-island", Weight: 0.5},
{Pattern: "data-astro-cid-", Weight: 0.4}, {Pattern: "data-astro-cid-", Weight: 0.4},
{Pattern: "/_astro/", Weight: 0.4}, {Pattern: "/_astro/", Weight: 0.4},
+41 -5
View File
@@ -13,7 +13,9 @@
package frameworks package frameworks
import ( import (
"net/http"
"regexp" "regexp"
"strings"
"unicode" "unicode"
) )
@@ -56,9 +58,13 @@ func init() {
{`"express":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"}, {`"express":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
}, },
"ASP.NET": { "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"}, {`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": { "ASP.NET Core": {
{`\.NET\s*(\d+\.\d+(?:\.\d+)?)`, 0.8, "dotnet version"}, {`\.NET\s*(\d+\.\d+(?:\.\d+)?)`, 0.8, "dotnet version"},
@@ -159,9 +165,39 @@ func init() {
} }
} }
// ExtractVersionOptimized extracts version using pre-compiled patterns. // ExtractVersionOptimized extracts version using pre-compiled patterns,
// This is exported for use by individual detector implementations. // searching only the response body. This is exported for use by individual
// detector implementations.
func ExtractVersionOptimized(body string, framework string) VersionMatch { 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] patterns, exists := frameworkVersionPatterns[framework]
if !exists { if !exists {
return VersionMatch{Version: "unknown", Confidence: 0, Source: ""} return VersionMatch{Version: "unknown", Confidence: 0, Source: ""}
@@ -169,7 +205,7 @@ func ExtractVersionOptimized(body string, framework string) VersionMatch {
var bestMatch VersionMatch var bestMatch VersionMatch
for _, p := range patterns { for _, p := range patterns {
matches := p.re.FindStringSubmatch(body) matches := p.re.FindStringSubmatch(text)
if len(matches) > 1 && p.confidence > bestMatch.Confidence { if len(matches) > 1 && p.confidence > bestMatch.Confidence {
candidate := matches[1] candidate := matches[1]
if isValidVersionString(candidate) { if isValidVersionString(candidate) {