From 6575c2e5f70bcd91c5bd5e5f1dd70a1c2c4ac976 Mon Sep 17 00:00:00 2001 From: Tigah <88289044+TBX3D@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:55:34 -0700 Subject: [PATCH] fix(frameworks): stop false positives and version mis-extraction (#247) a detector accuracy audit surfaced two classes of bug in the framework detectors. bare-brand header false positives: header-only signatures matched a brand name as a substring across every header name and value, so a detector fired on any response that merely referenced the brand (a vendor cdn named in a link or csp value, a cookie sharing the prefix). add an optional Header field to Signature that scopes a header-only match to one named header's value, and apply it (or a structural anchor) per detector: - express: "Express" scoped to x-powered-by, was firing on an express_checkout cookie. - flask: "Werkzeug" scoped to the server header. - symfony: dropped the bare "symfony" word (symfony sets no such header, it fired on symfony.com links); the x-debug-token header is the marker. - shopify: key on the x-shopify response headers instead of the bare "Shopify" word, which fired on a cdn.shopify.com link. - remix: dropped the bare "remix"/"_remix" substrings that fired on a track_remix.mp3 asset; window.__remixContext is the definitive marker. - spring boot: anchor the whitelabel title in its h1 tag context so a tutorial discussing the error does not fire. the gin and fastapi detectors are removed: gin keyed on the "gin-gonic" import-path string (appears in tutorials, never in a real gin response) and fastapi on bare words matching the projects' doc domains. neither framework advertises itself in a response header or a non-prose body marker, so there is no clean passive signal to anchor on. version mis-extraction: drop the low-confidence ".*?" version fallbacks (rails, django, laravel, spring), whose unbounded gap grabbed the first version-shaped number after the framework word and reported an unrelated asset's cache-buster when no real version was present. let isValidVersionString accept a single integer so a bare major such as drupal's "Drupal 10" is no longer rejected as "unknown". each false positive and version bug is covered by a regression test. --- internal/scan/frameworks/detect_test.go | 33 -------- internal/scan/frameworks/detector.go | 22 ++++- .../frameworks/detectors/accuracy_test.go | 81 +++++++++++++++++++ internal/scan/frameworks/detectors/backend.go | 59 ++------------ internal/scan/frameworks/detectors/cms.go | 2 +- internal/scan/frameworks/detectors/meta.go | 2 - internal/scan/frameworks/version.go | 27 ++----- internal/scan/frameworks/version_test.go | 38 +++++++++ 8 files changed, 153 insertions(+), 111 deletions(-) create mode 100644 internal/scan/frameworks/detectors/accuracy_test.go create mode 100644 internal/scan/frameworks/version_test.go diff --git a/internal/scan/frameworks/detect_test.go b/internal/scan/frameworks/detect_test.go index 9f166b5..96906d3 100644 --- a/internal/scan/frameworks/detect_test.go +++ b/internal/scan/frameworks/detect_test.go @@ -770,39 +770,6 @@ func TestDetectFramework_Htmx(t *testing.T) { } } -func TestDetectFramework_GinFalsePositive(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`Welcome`)) - })) - defer server.Close() - - result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != nil && result.Name == "Gin" { - t.Errorf("false positive: detected Gin (confidence %.2f) on a CORS header", result.Confidence) - } -} - -func TestDetectFramework_Gin(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`404 page not found - powered by gin-gonic`)) - })) - defer server.Close() - - result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result == nil || result.Name != "Gin" { - t.Errorf("expected framework 'Gin', got '%v'", result) - } -} - func TestDetectFramework_MeteorFalsePositive(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/scan/frameworks/detector.go b/internal/scan/frameworks/detector.go index 013240f..4d48aab 100644 --- a/internal/scan/frameworks/detector.go +++ b/internal/scan/frameworks/detector.go @@ -30,6 +30,9 @@ type Signature struct { Pattern string Weight float32 HeaderOnly bool + // Header, when set, scopes a HeaderOnly match to the named header's value + // (canonical form, e.g. "X-Powered-By"). Empty matches across all headers. + Header string } // Detector is the interface for framework detection plugins. @@ -107,7 +110,13 @@ func (b BaseDetector) MatchSignatures(body string, headers http.Header) float32 totalWeight += sig.Weight if sig.HeaderOnly { - if containsHeader(headers, sig.Pattern) { + var matched bool + if sig.Header != "" { + matched = headerValueContains(headers, sig.Header, sig.Pattern) + } else { + matched = containsHeader(headers, sig.Pattern) + } + if matched { weightedScore += sig.Weight } } else if strings.Contains(body, sig.Pattern) { @@ -122,6 +131,17 @@ func (b BaseDetector) MatchSignatures(body string, headers http.Header) float32 return weightedScore / totalWeight } +// headerValueContains reports whether the named header's value contains the signature. +func headerValueContains(headers http.Header, name, signature string) bool { + sigLower := strings.ToLower(signature) + for _, value := range headers.Values(name) { + if strings.Contains(strings.ToLower(value), sigLower) { + return true + } + } + return false +} + // containsHeader checks if a signature pattern exists in headers. func containsHeader(headers http.Header, signature string) bool { sigLower := strings.ToLower(signature) diff --git a/internal/scan/frameworks/detectors/accuracy_test.go b/internal/scan/frameworks/detectors/accuracy_test.go new file mode 100644 index 0000000..0a70168 --- /dev/null +++ b/internal/scan/frameworks/detectors/accuracy_test.go @@ -0,0 +1,81 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package detectors + +import ( + "net/http" + "testing" + + fw "github.com/vmfunc/sif/internal/scan/frameworks" +) + +func accHeader(name, value string) http.Header { + h := http.Header{} + h.Set(name, value) + return h +} + +func TestDetectorAccuracy_FalsePositives(t *testing.T) { + cases := []struct { + name string + det fw.Detector + body string + h http.Header + }{ + {"Express checkout cookie", &expressDetector{}, "", accHeader("Set-Cookie", "express_checkout=1; path=/")}, + {"Flask werkzeug docs link", &flaskDetector{}, "", accHeader("Link", "; rel=help")}, + {"Symfony domain link", &symfonyDetector{}, "", accHeader("Link", "; rel=help")}, + {"Shopify cdn link header", &shopifyDetector{}, "", accHeader("Link", "; rel=preload")}, + {"Shopify cdn body only", &shopifyDetector{}, ``, http.Header{}}, + {"Spring Boot tutorial prose", &springBootDetector{}, `
To fix the Whitelabel Error Page in Spring Boot, add a controller.
`, http.Header{}}, + {"Remix audio asset", &remixDetector{}, ``, http.Header{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if conf, _ := c.det.Detect(c.body, c.h); conf > 0.5 { + t.Errorf("false positive: confidence = %.3f, want <= 0.5", conf) + } + }) + } +} + +func TestDetectorAccuracy_TruePositives(t *testing.T) { + cases := []struct { + name string + det fw.Detector + body string + h http.Header + }{ + {"Express powered-by", &expressDetector{}, "", accHeader("X-Powered-By", "Express")}, + {"Flask werkzeug server", &flaskDetector{}, "", accHeader("Server", "Werkzeug/2.3.0 Python/3.11")}, + {"Symfony debug token", &symfonyDetector{}, "", accHeader("X-Debug-Token", "a1b2c3")}, + {"Shopify storefront header", &shopifyDetector{}, "", accHeader("X-Shopify-Stage", "production")}, + {"Spring Boot whitelabel page", &springBootDetector{}, `

Whitelabel Error Page

This application has no explicit mapping for /error

There was an unexpected error (type=Not Found, status=404).
`, http.Header{}}, + {"Remix context", &remixDetector{}, ``, http.Header{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if conf, _ := c.det.Detect(c.body, c.h); conf <= 0.5 { + t.Errorf("missed: confidence = %.3f, want > 0.5", conf) + } + }) + } +} + +func TestDetectorAccuracy_RemovedDetectors(t *testing.T) { + for _, name := range []string{"Gin", "FastAPI"} { + if _, ok := fw.GetDetector(name); ok { + t.Errorf("%s detector should have been removed (no clean passive signal)", name) + } + } +} diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go index 18923d4..f961604 100644 --- a/internal/scan/frameworks/detectors/backend.go +++ b/internal/scan/frameworks/detectors/backend.go @@ -38,8 +38,6 @@ func init() { fw.Register(&springBootDetector{}) fw.Register(&flaskDetector{}) fw.Register(&symfonyDetector{}) - fw.Register(&fastapiDetector{}) - fw.Register(&ginDetector{}) fw.Register(&phoenixDetector{}) fw.Register(&strapiDetector{}) fw.Register(&adonisDetector{}) @@ -143,7 +141,7 @@ func (d *expressDetector) Name() string { return "Express.js" } func (d *expressDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "Express", Weight: 0.5, HeaderOnly: true}, + {Pattern: "Express", Weight: 0.5, HeaderOnly: true, Header: "X-Powered-By"}, {Pattern: "connect.sid", Weight: 0.3, HeaderOnly: true}, } } @@ -251,8 +249,8 @@ func (d *springBootDetector) Name() string { return "Spring Boot" } func (d *springBootDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "Whitelabel Error Page", Weight: 0.5}, - {Pattern: "This application has no explicit mapping for /error", Weight: 0.4}, + {Pattern: ">Whitelabel Error Page<", Weight: 0.5}, + {Pattern: "This application has no explicit mapping for /error", Weight: 0.3}, {Pattern: "There was an unexpected error (type=", Weight: 0.3}, } } @@ -276,7 +274,7 @@ func (d *flaskDetector) Name() string { return "Flask" } func (d *flaskDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "Werkzeug", Weight: 0.4, HeaderOnly: true}, + {Pattern: "Werkzeug", Weight: 0.4, HeaderOnly: true, Header: "Server"}, {Pattern: "flask", Weight: 0.3, HeaderOnly: true}, {Pattern: "jinja2", Weight: 0.3}, } @@ -301,7 +299,7 @@ func (d *symfonyDetector) Name() string { return "Symfony" } func (d *symfonyDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "symfony", Weight: 0.4, HeaderOnly: true}, + {Pattern: "X-Debug-Token", Weight: 0.4, HeaderOnly: true}, {Pattern: "sf_", Weight: 0.3, HeaderOnly: true}, {Pattern: "_sf2_", Weight: 0.3, HeaderOnly: true}, } @@ -319,53 +317,6 @@ func (d *symfonyDetector) Detect(body string, headers http.Header) (float32, str return confidence, version } -// fastapiDetector detects FastAPI framework. -type fastapiDetector struct{} - -func (d *fastapiDetector) Name() string { return "FastAPI" } - -func (d *fastapiDetector) Signatures() []fw.Signature { - return []fw.Signature{ - {Pattern: "fastapi", Weight: 0.4, HeaderOnly: true}, - {Pattern: "starlette", Weight: 0.3, HeaderOnly: true}, - } -} - -func (d *fastapiDetector) Detect(body string, headers http.Header) (float32, string) { - base := fw.NewBaseDetector(d.Name(), d.Signatures()) - score := base.MatchSignatures(body, headers) - confidence := sigmoidConfidence(score) - - var version string - if confidence > 0.5 { - version = fw.ExtractVersionOptimized(body, d.Name()).Version - } - return confidence, version -} - -// ginDetector detects Gin framework. -type ginDetector struct{} - -func (d *ginDetector) Name() string { return "Gin" } - -func (d *ginDetector) Signatures() []fw.Signature { - return []fw.Signature{ - {Pattern: "gin-gonic", Weight: 0.4}, - } -} - -func (d *ginDetector) Detect(body string, headers http.Header) (float32, string) { - base := fw.NewBaseDetector(d.Name(), d.Signatures()) - score := base.MatchSignatures(body, headers) - confidence := sigmoidConfidence(score) - - var version string - if confidence > 0.5 { - version = fw.ExtractVersionOptimized(body, d.Name()).Version - } - return confidence, version -} - // phoenixDetector detects Phoenix framework. type phoenixDetector struct{} diff --git a/internal/scan/frameworks/detectors/cms.go b/internal/scan/frameworks/detectors/cms.go index 858bbfc..fd03051 100644 --- a/internal/scan/frameworks/detectors/cms.go +++ b/internal/scan/frameworks/detectors/cms.go @@ -147,7 +147,7 @@ func (d *shopifyDetector) Name() string { return "Shopify" } func (d *shopifyDetector) Signatures() []fw.Signature { return []fw.Signature{ - {Pattern: "Shopify", Weight: 0.5, HeaderOnly: true}, + {Pattern: "x-shopify", Weight: 0.5, HeaderOnly: true}, {Pattern: "cdn.shopify.com", Weight: 0.4}, {Pattern: "shopify-section", Weight: 0.4}, {Pattern: "myshopify.com", Weight: 0.3}, diff --git a/internal/scan/frameworks/detectors/meta.go b/internal/scan/frameworks/detectors/meta.go index 46f49a1..ad30466 100644 --- a/internal/scan/frameworks/detectors/meta.go +++ b/internal/scan/frameworks/detectors/meta.go @@ -144,8 +144,6 @@ func (d *remixDetector) Name() string { return "Remix" } func (d *remixDetector) Signatures() []fw.Signature { return []fw.Signature{ {Pattern: "__remixContext", Weight: 0.5}, - {Pattern: "remix", Weight: 0.3}, - {Pattern: "_remix", Weight: 0.4}, } } diff --git a/internal/scan/frameworks/version.go b/internal/scan/frameworks/version.go index 3481eec..ee7dc9e 100644 --- a/internal/scan/frameworks/version.go +++ b/internal/scan/frameworks/version.go @@ -44,15 +44,12 @@ func init() { }{ "Laravel": { {`Laravel\s+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - {`laravel/framework.*?(\d+\.\d+(?:\.\d+)?)`, 0.8, "composer.json"}, }, "Django": { {`Django[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - {`django.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "package reference"}, }, "Ruby on Rails": { {`Rails[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - {`rails.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "gem reference"}, }, "Express.js": { {`Express[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, @@ -68,10 +65,6 @@ func init() { }, "Spring": { {`Spring[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - {`spring-core.*?(\d+\.\d+(?:\.\d+)?)`, 0.8, "maven"}, - }, - "Spring Boot": { - {`spring-boot.*?(\d+\.\d+(?:\.\d+)?)`, 0.9, "maven"}, }, "Flask": { {`Flask[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, @@ -126,18 +119,9 @@ func init() { "Magento": { {`Magento[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, }, - "Shopify": { - {`Shopify\.theme.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "theme version"}, - }, "Symfony": { {`Symfony[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, }, - "FastAPI": { - {`FastAPI[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - }, - "Gin": { - {`Gin[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, - }, "Phoenix": { {`Phoenix[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, }, @@ -204,22 +188,25 @@ func ExtractVersionOptimized(body string, framework string) VersionMatch { return bestMatch } -// isValidVersionString checks if a version string looks like a valid semver func isValidVersionString(v string) bool { if v == "" || len(v) > 20 { return false } dotCount := 0 + digitCount := 0 for _, c := range v { - if c == '.' { + switch { + case c == '.': dotCount++ if dotCount > 3 { return false } - } else if !unicode.IsDigit(c) { + case unicode.IsDigit(c): + digitCount++ + default: return false } } - return dotCount >= 1 + return digitCount > 0 } diff --git a/internal/scan/frameworks/version_test.go b/internal/scan/frameworks/version_test.go new file mode 100644 index 0000000..ca1baa1 --- /dev/null +++ b/internal/scan/frameworks/version_test.go @@ -0,0 +1,38 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package frameworks_test + +import ( + "testing" + + frameworks "github.com/vmfunc/sif/internal/scan/frameworks" +) + +func TestExtractVersion_NoDecoyMisextraction(t *testing.T) { + got := frameworks.ExtractVersionOptimized(``, "Ruby on Rails").Version + if got == "7.8.9" { + t.Errorf("Rails: mis-extracted decoy asset version %q", got) + } + + got = frameworks.ExtractVersionOptimized(`Server: Rails/7.1.3`, "Ruby on Rails").Version + if got != "7.1.3" { + t.Errorf("Rails: version = %q, want 7.1.3", got) + } +} + +func TestExtractVersion_SingleInteger(t *testing.T) { + got := frameworks.ExtractVersionOptimized(``, "Drupal").Version + if got != "10" { + t.Errorf("Drupal: version = %q, want 10", got) + } +}