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)
+ }
+}