mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-07 12:57:09 -07:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user