mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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:
@@ -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 (`<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: "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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: `<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: "data-astro-cid-", Weight: 0.4},
|
||||
{Pattern: "/_astro/", Weight: 0.4},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user