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.
This commit is contained in:
Tigah
2026-07-02 12:55:34 -07:00
committed by GitHub
parent 0caca05467
commit 6575c2e5f7
8 changed files with 153 additions and 111 deletions
-33
View File
@@ -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(`<!DOCTYPE html><html><body>Welcome</body></html>`))
}))
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)
+21 -1
View File
@@ -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)
@@ -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", "<https://werkzeug.palletsprojects.com>; rel=help")},
{"Symfony domain link", &symfonyDetector{}, "", accHeader("Link", "<https://symfony.com/x>; rel=help")},
{"Shopify cdn link header", &shopifyDetector{}, "", accHeader("Link", "<https://cdn.shopify.com/s/x.js>; rel=preload")},
{"Shopify cdn body only", &shopifyDetector{}, `<script src="https://cdn.shopify.com/s/buy-button.js"></script>`, http.Header{}},
{"Spring Boot tutorial prose", &springBootDetector{}, `<article>To fix the Whitelabel Error Page in Spring Boot, add a controller.</article>`, http.Header{}},
{"Remix audio asset", &remixDetector{}, `<audio src="/audio/track_remix.mp3"></audio>`, 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{}, `<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error</p><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>`, http.Header{}},
{"Remix context", &remixDetector{}, `<script>window.__remixContext = {"state":{}};</script>`, 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)
}
}
}
+5 -54
View File
@@ -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{}
+1 -1
View File
@@ -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},
@@ -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},
}
}
+7 -20
View File
@@ -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
}
+38
View File
@@ -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(`<!-- rails app --><link href="/app-7.8.9.css">`, "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(`<meta name="Generator" content="Drupal 10 (https://www.drupal.org)">`, "Drupal").Version
if got != "10" {
t.Errorf("Drupal: version = %q, want 10", got)
}
}