diff --git a/internal/scan/frameworks/detect_test.go b/internal/scan/frameworks/detect_test.go
index 96906d3a..70036e8f 100644
--- a/internal/scan/frameworks/detect_test.go
+++ b/internal/scan/frameworks/detect_test.go
@@ -709,8 +709,25 @@ func TestDetectorRegistry(t *testing.T) {
t.Fatal("expected registered detectors, got none")
}
- // Check that some expected detectors are registered
- expectedDetectors := []string{"Laravel", "Django", "React", "Vue.js", "Angular", "Next.js", "WordPress", "Astro"}
+ // Check that expected detectors are registered: a spot-check of the
+ // originals plus every detector added to the backend, cms, and meta sets.
+ expectedDetectors := []string{
+ "Laravel", "Django", "React", "Vue.js", "Angular", "Next.js", "WordPress", "Astro",
+ "Tornado", "CherryPy", "Play Framework", "Sails.js", "Beego",
+ "JavaServer Faces", "Google Web Toolkit", "Vaadin", "ColdFusion",
+ "TYPO3", "Contao", "Wix", "Webflow", "HubSpot", "PrestaShop",
+ "Sitecore", "OpenCart", "DotNetNuke", "Liferay",
+ "Hugo", "Jekyll", "Docusaurus", "MkDocs",
+ "Alpine.js", "Qwik", "jQuery",
+ "Squarespace", "WooCommerce", "Craft CMS", "Concrete CMS", "Bitrix", "Blogger",
+ "Eleventy", "Hexo", "VuePress", "Sphinx",
+ "MediaWiki", "Discourse", "XenForo", "Moodle", "Plone", "Grav",
+ "Textpattern", "October CMS", "Statamic", "Livewire",
+ "Stimulus", "Turbo", "Knockout.js", "Unpoly", "Flarum", "NodeBB",
+ "XWiki", "Bolt CMS", "Nikola", "Publii", "ExpressionEngine",
+ "Vercel", "Netlify", "GitHub Pages", "Cloudflare",
+ "Amazon CloudFront", "Akamai", "Fly.io", "Amazon S3",
+ }
for _, name := range expectedDetectors {
if _, ok := frameworks.GetDetector(name); !ok {
t.Errorf("expected detector %q to be registered", name)
@@ -842,8 +859,9 @@ func TestDetectFramework_Backbone(t *testing.T) {
func TestDetectFramework_CakePHPFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
- w.Write([]byte(`
our cupcake and cheesecake recipes,
- plus the best pancake stack in town.
`))
+ // a Q&A/listicle page that merely names the framework, as on the live
+ // stackoverflow homepage that the bare body substring used to misfire on
+ w.Write([]byte(`cakephp `))
}))
defer server.Close()
@@ -852,7 +870,7 @@ func TestDetectFramework_CakePHPFalsePositive(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "CakePHP" {
- t.Errorf("false positive: detected CakePHP (confidence %.2f) on prose about cakes", result.Confidence)
+ t.Errorf("false positive: detected CakePHP (confidence %.2f) on prose naming cakephp", result.Confidence)
}
}
@@ -893,7 +911,8 @@ func TestDetectFramework_SvelteFalsePositive(t *testing.T) {
func TestDetectFramework_StrapiFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
- w.Write([]byte(``))
+ // prose naming the CMS plus a plain /api/ path: neither is the powered-by header
+ w.Write([]byte(`built with Strapi
`))
}))
defer server.Close()
@@ -902,14 +921,16 @@ func TestDetectFramework_StrapiFalsePositive(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Strapi" {
- t.Errorf("false positive: detected Strapi (confidence %.2f) on a plain /api/ path", result.Confidence)
+ t.Errorf("false positive: detected Strapi (confidence %.2f) on prose naming strapi", result.Confidence)
}
}
func TestDetectFramework_Strapi(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // the default poweredBy middleware sets this header on every response
+ w.Header().Set("X-Powered-By", "Strapi ")
w.WriteHeader(http.StatusOK)
- w.Write([]byte(`powered by strapi
`))
+ w.Write([]byte(`welcome
`))
}))
defer server.Close()
diff --git a/internal/scan/frameworks/detectors/backend.go b/internal/scan/frameworks/detectors/backend.go
index f9616041..80bda9d7 100644
--- a/internal/scan/frameworks/detectors/backend.go
+++ b/internal/scan/frameworks/detectors/backend.go
@@ -22,6 +22,7 @@ package detectors
import (
"math"
"net/http"
+ "strings"
fw "github.com/vmfunc/sif/internal/scan/frameworks"
)
@@ -43,6 +44,15 @@ func init() {
fw.Register(&adonisDetector{})
fw.Register(&cakephpDetector{})
fw.Register(&codeigniterDetector{})
+ fw.Register(&tornadoDetector{})
+ fw.Register(&cherrypyDetector{})
+ fw.Register(&playDetector{})
+ fw.Register(&sailsDetector{})
+ fw.Register(&beegoDetector{})
+ fw.Register(&jsfDetector{})
+ fw.Register(&gwtDetector{})
+ fw.Register(&vaadinDetector{})
+ fw.Register(&coldfusionDetector{})
}
// sigmoidConfidence maps the matched-weight fraction to a 0-1 confidence,
@@ -52,7 +62,22 @@ func sigmoidConfidence(score float32) float32 {
return float32(1.0 / (1.0 + math.Exp(-(float64(score)-0.3)*10.0)))
}
-// laravelDetector detects Laravel framework.
+// serverVersion pulls the trailing version out of a "/" Server
+// header, e.g. "TornadoServer/6.4.1" -> "6.4.1".
+func serverVersion(headers http.Header, product string) string {
+ server := headers.Get("Server")
+ i := strings.Index(server, product+"/")
+ if i < 0 {
+ return ""
+ }
+ rest := server[i+len(product)+1:]
+ end := 0
+ for end < len(rest) && (rest[end] == '.' || (rest[end] >= '0' && rest[end] <= '9')) {
+ end++
+ }
+ return rest[:end]
+}
+
type laravelDetector struct {
fw.BaseDetector
}
@@ -79,7 +104,6 @@ func (d *laravelDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// djangoDetector detects Django framework.
type djangoDetector struct{}
func (d *djangoDetector) Name() string { return "Django" }
@@ -106,7 +130,6 @@ func (d *djangoDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// railsDetector detects Ruby on Rails framework.
type railsDetector struct{}
func (d *railsDetector) Name() string { return "Ruby on Rails" }
@@ -134,7 +157,6 @@ func (d *railsDetector) Detect(body string, headers http.Header) (float32, strin
return confidence, version
}
-// expressDetector detects Express.js framework.
type expressDetector struct{}
func (d *expressDetector) Name() string { return "Express.js" }
@@ -158,7 +180,6 @@ func (d *expressDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// aspnetDetector detects ASP.NET framework.
type aspnetDetector struct{}
func (d *aspnetDetector) Name() string { return "ASP.NET" }
@@ -190,7 +211,6 @@ func (d *aspnetDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// aspnetCoreDetector detects ASP.NET Core framework.
type aspnetCoreDetector struct{}
func (d *aspnetCoreDetector) Name() string { return "ASP.NET Core" }
@@ -216,7 +236,6 @@ func (d *aspnetCoreDetector) Detect(body string, headers http.Header) (float32,
return confidence, version
}
-// springDetector detects Spring framework.
type springDetector struct{}
func (d *springDetector) Name() string { return "Spring" }
@@ -242,7 +261,6 @@ func (d *springDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// springBootDetector detects Spring Boot framework.
type springBootDetector struct{}
func (d *springBootDetector) Name() string { return "Spring Boot" }
@@ -267,7 +285,6 @@ func (d *springBootDetector) Detect(body string, headers http.Header) (float32,
return confidence, version
}
-// flaskDetector detects Flask framework.
type flaskDetector struct{}
func (d *flaskDetector) Name() string { return "Flask" }
@@ -292,7 +309,6 @@ func (d *flaskDetector) Detect(body string, headers http.Header) (float32, strin
return confidence, version
}
-// symfonyDetector detects Symfony framework.
type symfonyDetector struct{}
func (d *symfonyDetector) Name() string { return "Symfony" }
@@ -300,7 +316,9 @@ func (d *symfonyDetector) Name() string { return "Symfony" }
func (d *symfonyDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "X-Debug-Token", Weight: 0.4, HeaderOnly: true},
- {Pattern: "sf_", Weight: 0.3, HeaderOnly: true},
+ // the bare "sf_" substring also matches unrelated cookie names such as
+ // "misfa_sf_token", so key on the "_sf2_" session-cookie prefix, which
+ // is specific to Symfony's own cookie naming instead.
{Pattern: "_sf2_", Weight: 0.3, HeaderOnly: true},
}
}
@@ -317,7 +335,6 @@ func (d *symfonyDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// phoenixDetector detects Phoenix framework.
type phoenixDetector struct{}
func (d *phoenixDetector) Name() string { return "Phoenix" }
@@ -342,14 +359,13 @@ func (d *phoenixDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// strapiDetector detects Strapi framework.
type strapiDetector struct{}
func (d *strapiDetector) Name() string { return "Strapi" }
func (d *strapiDetector) Signatures() []fw.Signature {
return []fw.Signature{
- {Pattern: "strapi", Weight: 0.4},
+ {Pattern: "Strapi", Weight: 0.4, HeaderOnly: true, Header: "X-Powered-By"},
}
}
@@ -365,7 +381,6 @@ func (d *strapiDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// adonisDetector detects AdonisJS framework.
type adonisDetector struct{}
func (d *adonisDetector) Name() string { return "AdonisJS" }
@@ -388,14 +403,12 @@ func (d *adonisDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// cakephpDetector detects CakePHP framework.
type cakephpDetector struct{}
func (d *cakephpDetector) Name() string { return "CakePHP" }
func (d *cakephpDetector) Signatures() []fw.Signature {
return []fw.Signature{
- {Pattern: "cakephp", Weight: 0.4},
{Pattern: "CAKEPHP", Weight: 0.4, HeaderOnly: true},
}
}
@@ -412,7 +425,6 @@ func (d *cakephpDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// codeigniterDetector detects CodeIgniter framework.
type codeigniterDetector struct{}
func (d *codeigniterDetector) Name() string { return "CodeIgniter" }
@@ -434,3 +446,214 @@ func (d *codeigniterDetector) Detect(body string, headers http.Header) (float32,
}
return confidence, version
}
+
+type tornadoDetector struct{}
+
+func (d *tornadoDetector) Name() string { return "Tornado" }
+
+func (d *tornadoDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "TornadoServer", Weight: 0.6, HeaderOnly: true, Header: "Server"},
+ }
+}
+
+func (d *tornadoDetector) 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 = serverVersion(headers, "TornadoServer")
+ }
+ return confidence, version
+}
+
+type cherrypyDetector struct{}
+
+func (d *cherrypyDetector) Name() string { return "CherryPy" }
+
+func (d *cherrypyDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "CherryPy", Weight: 0.6, HeaderOnly: true, Header: "Server"},
+ }
+}
+
+func (d *cherrypyDetector) 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 = serverVersion(headers, "CherryPy")
+ }
+ return confidence, version
+}
+
+// playDetector detects the Play Framework (Scala/Java).
+type playDetector struct{}
+
+func (d *playDetector) Name() string { return "Play Framework" }
+
+func (d *playDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "PLAY_SESSION", Weight: 0.5, HeaderOnly: true},
+ {Pattern: "PLAY_FLASH", Weight: 0.3, HeaderOnly: true},
+ {Pattern: "PLAY_LANG", Weight: 0.3, HeaderOnly: true},
+ }
+}
+
+func (d *playDetector) 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
+}
+
+type sailsDetector struct{}
+
+func (d *sailsDetector) Name() string { return "Sails.js" }
+
+func (d *sailsDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "sails.sid", Weight: 0.5, HeaderOnly: true},
+ }
+}
+
+func (d *sailsDetector) 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
+}
+
+type beegoDetector struct{}
+
+func (d *beegoDetector) Name() string { return "Beego" }
+
+func (d *beegoDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "beegosessionID", Weight: 0.5, HeaderOnly: true},
+ }
+}
+
+func (d *beegoDetector) 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
+}
+
+// jsfDetector detects JavaServer Faces / Jakarta Faces.
+type jsfDetector struct{}
+
+func (d *jsfDetector) Name() string { return "JavaServer Faces" }
+
+func (d *jsfDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `javax.faces.ViewState"`, Weight: 0.5},
+ {Pattern: `jakarta.faces.ViewState"`, Weight: 0.5},
+ }
+}
+
+func (d *jsfDetector) 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
+}
+
+// gwtDetector detects Google Web Toolkit.
+type gwtDetector struct{}
+
+func (d *gwtDetector) Name() string { return "Google Web Toolkit" }
+
+func (d *gwtDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `.nocache.js"`, Weight: 0.4},
+ {Pattern: "gwt:", Weight: 0.3},
+ {Pattern: "__gwt_", Weight: 0.3},
+ }
+}
+
+func (d *gwtDetector) 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
+}
+
+type vaadinDetector struct{}
+
+func (d *vaadinDetector) Name() string { return "Vaadin" }
+
+func (d *vaadinDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `"Vaadin-Security-Key"`, Weight: 0.5},
+ {Pattern: "window.Vaadin", Weight: 0.3},
+ {Pattern: "/VAADIN/", Weight: 0.3},
+ }
+}
+
+func (d *vaadinDetector) 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
+}
+
+// coldfusionDetector detects Adobe ColdFusion / Lucee (CFML).
+type coldfusionDetector struct{}
+
+func (d *coldfusionDetector) Name() string { return "ColdFusion" }
+
+func (d *coldfusionDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "CFTOKEN", Weight: 0.4, HeaderOnly: true},
+ {Pattern: "CFID", Weight: 0.3, HeaderOnly: true},
+ {Pattern: "CFAUTHORIZATION", Weight: 0.3, HeaderOnly: true},
+ }
+}
+
+func (d *coldfusionDetector) 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
+}
diff --git a/internal/scan/frameworks/detectors/backend_test.go b/internal/scan/frameworks/detectors/backend_test.go
new file mode 100644
index 00000000..e507bd31
--- /dev/null
+++ b/internal/scan/frameworks/detectors/backend_test.go
@@ -0,0 +1,126 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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 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 TestWebFrameworkDetectors_Positive(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ headers http.Header
+ }{
+ {"Tornado", &tornadoDetector{}, "", hdr("Server", "TornadoServer/6.4.1")},
+ {"CherryPy", &cherrypyDetector{}, "", hdr("Server", "CherryPy/18.8.0")},
+ {"Play session", &playDetector{}, "", hdr("Set-Cookie", "PLAY_SESSION=eyJhbGci; Path=/; HTTPOnly")},
+ {"Sails.js", &sailsDetector{}, "", hdr("Set-Cookie", "sails.sid=s%3Aabc.def; Path=/; HttpOnly")},
+ {"Beego", &beegoDetector{}, "", hdr("Set-Cookie", "beegosessionID=8f2a1c; Path=/; HttpOnly")},
+ {"JSF javax", &jsfDetector{}, ` `, http.Header{}},
+ {"JSF jakarta", &jsfDetector{}, ` `, http.Header{}},
+ {"GWT", &gwtDetector{}, ``, http.Header{}},
+ {"GWT meta combo", &gwtDetector{}, ` `, http.Header{}},
+ {"Vaadin security key", &vaadinDetector{}, `{"appConfig":{"uidl":{"Vaadin-Security-Key":"f0ef03d7-0cf4-4f32-834d-47b88a1034b7"}}}`, http.Header{}},
+ {"Vaadin Flow deferred", &vaadinDetector{}, ``, http.Header{}},
+ {"ColdFusion pair", &coldfusionDetector{}, "", hdr("Set-Cookie", "CFID=2401; CFTOKEN=55be99e7c")},
+ {"ColdFusion token only", &coldfusionDetector{}, "", hdr("Set-Cookie", "CFTOKEN=55be99e7c2f8b2a1")},
+ {"Strapi powered-by", &strapiDetector{}, "", hdr("X-Powered-By", "Strapi ")},
+ {"CakePHP cookie", &cakephpDetector{}, "", hdr("Set-Cookie", "CAKEPHP=8f2a1c4e; path=/; HttpOnly")},
+ {"Express powered-by", &expressDetector{}, "", hdr("X-Powered-By", "Express")},
+ {"Flask werkzeug server", &flaskDetector{}, "", hdr("Server", "Werkzeug/2.3.0 Python/3.11")},
+ {"Symfony debug token", &symfonyDetector{}, "", hdr("X-Debug-Token", "a1b2c3")},
+ {"Symfony sf2 session cookie", &symfonyDetector{}, "", hdr("Set-Cookie", "_sf2_ses=8f2a1c4e9d3b; path=/; HttpOnly")},
+ {"Spring Boot whitelabel", &springBootDetector{}, `Whitelabel Error Page This application has no explicit mapping for /error
There was an unexpected error (type=Not Found, status=404).
`, http.Header{}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, tt.headers)
+ if conf <= 0.5 {
+ t.Errorf("%s: confidence = %.3f, want > 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestWebFrameworkDetectors_Negative(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ headers http.Header
+ }{
+ {"JSF prose", &jsfDetector{}, "In JSF, the javax.faces.ViewState field stores view state.
", http.Header{}},
+ {"GWT prose", &gwtDetector{}, "GWT writes a .nocache.js bootstrap that is never cached.
", http.Header{}},
+ {"Vaadin prose", &vaadinDetector{}, "Vaadin is a Java web framework with a Vaadin-Security-Key concept.
", http.Header{}},
+ {"Vaadin path only", &vaadinDetector{}, ` `, http.Header{}},
+ {"Vaadin global only", &vaadinDetector{}, ``, http.Header{}},
+ {"ColdFusion id only", &coldfusionDetector{}, "", hdr("Set-Cookie", "CFID=2401; Path=/")},
+ {"CakePHP prose", &cakephpDetector{}, `cakephp tag `, http.Header{}},
+ {"Strapi prose", &strapiDetector{}, "Strapi is an open source headless CMS built on Node.
", http.Header{}},
+ {"Strapi domain link header", &strapiDetector{}, "", hdr("Link", "; rel=help")},
+ {"Express checkout cookie", &expressDetector{}, "", hdr("Set-Cookie", "express_checkout=1; path=/")},
+ {"Flask werkzeug docs link", &flaskDetector{}, "", hdr("Link", "; rel=help")},
+ {"CherryPy domain link", &cherrypyDetector{}, "", hdr("Link", "; rel=help")},
+ {"Tornado via header", &tornadoDetector{}, "", hdr("Via", "1.1 proxy-fronting-tornadoserver")},
+ {"Symfony domain link", &symfonyDetector{}, "", hdr("Link", "; rel=help")},
+ {"Symfony near-miss cookie", &symfonyDetector{}, "", hdr("Set-Cookie", "misfa_sf_token=abc123; path=/")},
+ {"Spring Boot tutorial prose", &springBootDetector{}, `To fix the Whitelabel Error Page in Spring Boot, add a controller. `, http.Header{}},
+ {"plain page Tornado", &tornadoDetector{}, "hello", hdr("Server", "nginx/1.25.3")},
+ {"plain page Play", &playDetector{}, "", hdr("Set-Cookie", "sessionid=abc; Path=/")},
+ {"plain page ColdFusion", &coldfusionDetector{}, "", hdr("Set-Cookie", "JSESSIONID=abc; Path=/")},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, tt.headers)
+ if conf > 0.5 {
+ t.Errorf("%s: confidence = %.3f, want <= 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestWebFrameworkDetectors_Version(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ headers http.Header
+ want string
+ }{
+ {"Tornado", &tornadoDetector{}, hdr("Server", "TornadoServer/6.4.1"), "6.4.1"},
+ {"CherryPy", &cherrypyDetector{}, hdr("Server", "CherryPy/18.8.0"), "18.8.0"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, version := tt.detector.Detect("", tt.headers)
+ if version != tt.want {
+ t.Errorf("%s: version = %q, want %q", tt.name, version, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/scan/frameworks/detectors/cms.go b/internal/scan/frameworks/detectors/cms.go
index fd030512..7206da25 100644
--- a/internal/scan/frameworks/detectors/cms.go
+++ b/internal/scan/frameworks/detectors/cms.go
@@ -33,9 +33,38 @@ func init() {
fw.Register(&magentoDetector{})
fw.Register(&shopifyDetector{})
fw.Register(&ghostDetector{})
+ fw.Register(&bitrixDetector{})
+ fw.Register(&bloggerDetector{})
+ fw.Register(&boltDetector{})
+ fw.Register(&concreteDetector{})
+ fw.Register(&contaoDetector{})
+ fw.Register(&craftDetector{})
+ fw.Register(&discourseDetector{})
+ fw.Register(&dnnDetector{})
+ fw.Register(&expressionengineDetector{})
+ fw.Register(&flarumDetector{})
+ fw.Register(&gravDetector{})
+ fw.Register(&hubspotDetector{})
+ fw.Register(&liferayDetector{})
+ fw.Register(&mediawikiDetector{})
+ fw.Register(&moodleDetector{})
+ fw.Register(&nodebbDetector{})
+ fw.Register(&octoberDetector{})
+ fw.Register(&opencartDetector{})
+ fw.Register(&ploneDetector{})
+ fw.Register(&prestashopDetector{})
+ fw.Register(&sitecoreDetector{})
+ fw.Register(&squarespaceDetector{})
+ fw.Register(&statamicDetector{})
+ fw.Register(&textpatternDetector{})
+ fw.Register(&typo3Detector{})
+ fw.Register(&webflowDetector{})
+ fw.Register(&wixDetector{})
+ fw.Register(&woocommerceDetector{})
+ fw.Register(&xenforoDetector{})
+ fw.Register(&xwikiDetector{})
}
-// wordpressDetector detects WordPress CMS.
type wordpressDetector struct{}
func (d *wordpressDetector) Name() string { return "WordPress" }
@@ -62,7 +91,6 @@ func (d *wordpressDetector) Detect(body string, headers http.Header) (float32, s
return confidence, version
}
-// drupalDetector detects Drupal CMS.
type drupalDetector struct{}
func (d *drupalDetector) Name() string { return "Drupal" }
@@ -88,7 +116,6 @@ func (d *drupalDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// joomlaDetector detects Joomla CMS.
type joomlaDetector struct{}
func (d *joomlaDetector) Name() string { return "Joomla" }
@@ -114,7 +141,6 @@ func (d *joomlaDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// magentoDetector detects Magento CMS.
type magentoDetector struct{}
func (d *magentoDetector) Name() string { return "Magento" }
@@ -140,7 +166,6 @@ func (d *magentoDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// shopifyDetector detects Shopify platform.
type shopifyDetector struct{}
func (d *shopifyDetector) Name() string { return "Shopify" }
@@ -166,7 +191,6 @@ func (d *shopifyDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// ghostDetector detects Ghost CMS.
type ghostDetector struct{}
func (d *ghostDetector) Name() string { return "Ghost" }
@@ -190,3 +214,690 @@ func (d *ghostDetector) Detect(body string, headers http.Header) (float32, strin
}
return confidence, version
}
+
+type typo3Detector struct{}
+
+func (d *typo3Detector) Name() string { return "TYPO3" }
+
+func (d *typo3Detector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="TYPO3`, Weight: 0.6},
+ }
+}
+
+func (d *typo3Detector) 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
+}
+
+type contaoDetector struct{}
+
+func (d *contaoDetector) Name() string { return "Contao" }
+
+func (d *contaoDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `content="Contao Open Source CMS"`, Weight: 0.6},
+ }
+}
+
+func (d *contaoDetector) 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
+}
+
+type wixDetector struct{}
+
+func (d *wixDetector) Name() string { return "Wix" }
+
+func (d *wixDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "X-Wix-Request-Id", Weight: 0.5, HeaderOnly: true},
+ {Pattern: `content="Wix.com Website Builder"`, Weight: 0.5},
+ }
+}
+
+func (d *wixDetector) 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
+}
+
+type webflowDetector struct{}
+
+func (d *webflowDetector) Name() string { return "Webflow" }
+
+func (d *webflowDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "data-wf-page", Weight: 0.6},
+ {Pattern: `content="Webflow" name="generator"`, Weight: 0.6},
+ }
+}
+
+func (d *webflowDetector) 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
+}
+
+type hubspotDetector struct{}
+
+func (d *hubspotDetector) Name() string { return "HubSpot" }
+
+func (d *hubspotDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="HubSpot"`, Weight: 0.6},
+ }
+}
+
+func (d *hubspotDetector) 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
+}
+
+type prestashopDetector struct{}
+
+func (d *prestashopDetector) Name() string { return "PrestaShop" }
+
+func (d *prestashopDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "var prestashop = {", Weight: 0.6},
+ }
+}
+
+func (d *prestashopDetector) 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
+}
+
+type sitecoreDetector struct{}
+
+func (d *sitecoreDetector) Name() string { return "Sitecore" }
+
+func (d *sitecoreDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "SC_ANALYTICS_GLOBAL_COOKIE", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *sitecoreDetector) 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
+}
+
+type opencartDetector struct{}
+
+func (d *opencartDetector) Name() string { return "OpenCart" }
+
+func (d *opencartDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "OCSESSID", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *opencartDetector) 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
+}
+
+// dnnDetector detects the DNN (DotNetNuke) platform.
+type dnnDetector struct{}
+
+func (d *dnnDetector) Name() string { return "DotNetNuke" }
+
+func (d *dnnDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "DNNPersonalization", Weight: 0.6, HeaderOnly: true},
+ {Pattern: "dnn_IsMobile", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *dnnDetector) 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
+}
+
+type liferayDetector struct{}
+
+func (d *liferayDetector) Name() string { return "Liferay" }
+
+func (d *liferayDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "X-Liferay", Weight: 0.6, HeaderOnly: true},
+ {Pattern: "Liferay.ThemeDisplay", Weight: 0.5},
+ }
+}
+
+func (d *liferayDetector) 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
+}
+
+type squarespaceDetector struct{}
+
+func (d *squarespaceDetector) Name() string { return "Squarespace" }
+
+func (d *squarespaceDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "SQUARESPACE_CONTEXT", Weight: 0.6},
+ }
+}
+
+func (d *squarespaceDetector) 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
+}
+
+// woocommerceDetector detects the WooCommerce WordPress plugin.
+type woocommerceDetector struct{}
+
+func (d *woocommerceDetector) Name() string { return "WooCommerce" }
+
+func (d *woocommerceDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "/plugins/woocommerce/", Weight: 0.6},
+ {Pattern: "woocommerce_params", Weight: 0.5},
+ {Pattern: "wc-ajax", Weight: 0.4},
+ {Pattern: "woocommerce-page", Weight: 0.4},
+ }
+}
+
+func (d *woocommerceDetector) 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
+}
+
+type craftDetector struct{}
+
+func (d *craftDetector) Name() string { return "Craft CMS" }
+
+func (d *craftDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "Craft CMS", Weight: 0.6, HeaderOnly: true},
+ {Pattern: "CRAFT_CSRF_TOKEN", Weight: 0.5, HeaderOnly: true},
+ {Pattern: "CraftSessionId", Weight: 0.4, HeaderOnly: true},
+ }
+}
+
+func (d *craftDetector) 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
+}
+
+// concreteDetector detects Concrete CMS (formerly concrete5).
+type concreteDetector struct{}
+
+func (d *concreteDetector) Name() string { return "Concrete CMS" }
+
+func (d *concreteDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Concrete CMS"`, Weight: 0.6},
+ {Pattern: `generator" content="concrete5`, Weight: 0.6},
+ }
+}
+
+func (d *concreteDetector) 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
+}
+
+// bitrixDetector detects the 1C-Bitrix platform.
+type bitrixDetector struct{}
+
+func (d *bitrixDetector) Name() string { return "Bitrix" }
+
+func (d *bitrixDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "/bitrix/js/", Weight: 0.6},
+ {Pattern: "/bitrix/templates/", Weight: 0.5},
+ {Pattern: "BITRIX_SM_", Weight: 0.4},
+ {Pattern: "/bitrix/cache/", Weight: 0.3},
+ }
+}
+
+func (d *bitrixDetector) 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
+}
+
+// bloggerDetector detects Google's Blogger platform.
+type bloggerDetector struct{}
+
+func (d *bloggerDetector) Name() string { return "Blogger" }
+
+func (d *bloggerDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "content='blogger' name='generator'", Weight: 0.6},
+ }
+}
+
+func (d *bloggerDetector) 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
+}
+
+type mediawikiDetector struct{}
+
+func (d *mediawikiDetector) Name() string { return "MediaWiki" }
+
+func (d *mediawikiDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="MediaWiki`, Weight: 0.6},
+ }
+}
+
+func (d *mediawikiDetector) 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
+}
+
+type discourseDetector struct{}
+
+func (d *discourseDetector) Name() string { return "Discourse" }
+
+func (d *discourseDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Discourse`, Weight: 0.6},
+ }
+}
+
+func (d *discourseDetector) 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
+}
+
+type xenforoDetector struct{}
+
+func (d *xenforoDetector) Name() string { return "XenForo" }
+
+func (d *xenforoDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "data-xf-init", Weight: 0.5},
+ {Pattern: "/js/xf/", Weight: 0.4},
+ {Pattern: "data-xf-key", Weight: 0.3},
+ }
+}
+
+func (d *xenforoDetector) 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
+}
+
+type moodleDetector struct{}
+
+func (d *moodleDetector) Name() string { return "Moodle" }
+
+func (d *moodleDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "MoodleSession", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *moodleDetector) 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
+}
+
+type ploneDetector struct{}
+
+func (d *ploneDetector) Name() string { return "Plone" }
+
+func (d *ploneDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Plone`, Weight: 0.6},
+ }
+}
+
+func (d *ploneDetector) 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
+}
+
+// gravDetector detects the Grav flat-file CMS.
+type gravDetector struct{}
+
+func (d *gravDetector) Name() string { return "Grav" }
+
+func (d *gravDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `content="GravCMS"`, Weight: 0.6},
+ }
+}
+
+func (d *gravDetector) 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
+}
+
+type textpatternDetector struct{}
+
+func (d *textpatternDetector) Name() string { return "Textpattern" }
+
+func (d *textpatternDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Textpattern`, Weight: 0.6},
+ }
+}
+
+func (d *textpatternDetector) 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
+}
+
+// octoberDetector detects the October CMS (Laravel-based).
+type octoberDetector struct{}
+
+func (d *octoberDetector) Name() string { return "October CMS" }
+
+func (d *octoberDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "october_session", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *octoberDetector) 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
+}
+
+// statamicDetector detects the Statamic CMS (Laravel-based).
+type statamicDetector struct{}
+
+func (d *statamicDetector) Name() string { return "Statamic" }
+
+func (d *statamicDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "statamic_", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *statamicDetector) 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
+}
+
+type flarumDetector struct{}
+
+func (d *flarumDetector) Name() string { return "Flarum" }
+
+func (d *flarumDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `id="flarum-`, Weight: 0.6},
+ }
+}
+
+func (d *flarumDetector) 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
+}
+
+type nodebbDetector struct{}
+
+func (d *nodebbDetector) Name() string { return "NodeBB" }
+
+func (d *nodebbDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "assets/nodebb", Weight: 0.6},
+ }
+}
+
+func (d *nodebbDetector) 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
+}
+
+type xwikiDetector struct{}
+
+func (d *xwikiDetector) Name() string { return "XWiki" }
+
+func (d *xwikiDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "data-xwiki-", Weight: 0.5},
+ {Pattern: "/xwiki/bin/", Weight: 0.4},
+ }
+}
+
+func (d *xwikiDetector) 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
+}
+
+// boltDetector detects the Bolt CMS (Symfony-based).
+type boltDetector struct{}
+
+func (d *boltDetector) Name() string { return "Bolt CMS" }
+
+func (d *boltDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Bolt"`, Weight: 0.6},
+ }
+}
+
+func (d *boltDetector) 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
+}
+
+type expressionengineDetector struct{}
+
+func (d *expressionengineDetector) Name() string { return "ExpressionEngine" }
+
+func (d *expressionengineDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "exp_csrf", Weight: 0.5},
+ {Pattern: "exp_sessionid", Weight: 0.4, HeaderOnly: true},
+ {Pattern: "exp_last_visit", Weight: 0.4, HeaderOnly: true},
+ }
+}
+
+func (d *expressionengineDetector) 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
+}
diff --git a/internal/scan/frameworks/detectors/cms_test.go b/internal/scan/frameworks/detectors/cms_test.go
new file mode 100644
index 00000000..a8027a63
--- /dev/null
+++ b/internal/scan/frameworks/detectors/cms_test.go
@@ -0,0 +1,161 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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 TestPlatformDetectors_Positive(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ headers http.Header
+ }{
+ {"TYPO3", &typo3Detector{}, ` `, http.Header{}},
+ {"TYPO3 4.x versioned", &typo3Detector{}, ` `, http.Header{}},
+ {"Contao", &contaoDetector{}, ` `, http.Header{}},
+ {"Wix header", &wixDetector{}, "", hdr("X-Wix-Request-Id", "1782416012.749990137421329")},
+ {"Wix generator", &wixDetector{}, ` `, http.Header{}},
+ {"Webflow reversed attrs", &webflowDetector{}, ` `, http.Header{}},
+ {"Webflow html attrs", &webflowDetector{}, ``, http.Header{}},
+ {"HubSpot", &hubspotDetector{}, ` `, http.Header{}},
+ {"PrestaShop global", &prestashopDetector{}, ``, http.Header{}},
+ {"Sitecore cookie", &sitecoreDetector{}, "", hdr("Set-Cookie", "SC_ANALYTICS_GLOBAL_COOKIE=8f2; path=/; HttpOnly")},
+ {"OpenCart cookie", &opencartDetector{}, "", hdr("Set-Cookie", "OCSESSID=2a1c; path=/; HttpOnly")},
+ {"DotNetNuke cookie", &dnnDetector{}, "", hdr("Set-Cookie", "DNNPersonalization=; expires=Mon; path=/")},
+ {"DotNetNuke mobile cookie", &dnnDetector{}, "", hdr("Set-Cookie", "dnn_IsMobile=False; path=/; HttpOnly")},
+ {"Liferay header", &liferayDetector{}, "", hdr("X-Liferay-Request-Guest-User", "true")},
+ {"Liferay body behind CDN", &liferayDetector{}, ``, http.Header{}},
+ {"Squarespace context", &squarespaceDetector{}, ``, http.Header{}},
+ {"WooCommerce store", &woocommerceDetector{}, ` `, http.Header{}},
+ {"Shopify storefront header", &shopifyDetector{}, "", hdr("X-Shopify-Stage", "production")},
+ {"Craft header", &craftDetector{}, "", hdr("X-Powered-By", "Craft CMS,SEOmatic")},
+ {"Craft csrf cookie", &craftDetector{}, "", hdr("Set-Cookie", "CRAFT_CSRF_TOKEN=a1b2c3; path=/; HttpOnly")},
+ {"Concrete CMS 9", &concreteDetector{}, ` `, http.Header{}},
+ {"concrete5 legacy", &concreteDetector{}, ` `, http.Header{}},
+ {"Bitrix assets", &bitrixDetector{}, ``, http.Header{}},
+ {"Blogger generator", &bloggerDetector{}, ` `, http.Header{}},
+ {"MediaWiki generator", &mediawikiDetector{}, ` `, http.Header{}},
+ {"Discourse generator", &discourseDetector{}, ` `, http.Header{}},
+ {"XenForo attrs", &xenforoDetector{}, ``, http.Header{}},
+ {"Moodle cookie", &moodleDetector{}, "", hdr("Set-Cookie", "MoodleSession=2a1c; path=/; HttpOnly")},
+ {"Plone generator", &ploneDetector{}, ` `, http.Header{}},
+ {"Grav generator", &gravDetector{}, ` `, http.Header{}},
+ {"Textpattern generator", &textpatternDetector{}, ` `, http.Header{}},
+ {"October cookie", &octoberDetector{}, "", hdr("Set-Cookie", "october_session=eyJpdiI6Ijk; path=/; httponly")},
+ {"Statamic session cookie", &statamicDetector{}, "", hdr("Set-Cookie", "statamic_session=eyJpdiI6Ijd; path=/; secure; httponly")},
+ {"Statamic branded cookie", &statamicDetector{}, "", hdr("Set-Cookie", "delicious_statamic_cookies=eyJpdiI6Ijh; path=/; secure")},
+ {"Flarum bootstrap", &flarumDetector{}, `
`, http.Header{}},
+ {"NodeBB assets", &nodebbDetector{}, ``, http.Header{}},
+ {"XWiki attrs", &xwikiDetector{}, `home `, http.Header{}},
+ {"Bolt generator", &boltDetector{}, ` `, http.Header{}},
+ {"ExpressionEngine csrf field", &expressionengineDetector{}, ` `, http.Header{}},
+ {"ExpressionEngine cookie", &expressionengineDetector{}, "", hdr("Set-Cookie", "exp_last_visit=1782; path=/; httponly")},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, tt.headers)
+ if conf <= 0.5 {
+ t.Errorf("%s: confidence = %.3f, want > 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestPlatformDetectors_Negative(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ headers http.Header
+ }{
+ {"Contao description", &contaoDetector{}, ` `, http.Header{}},
+ {"PrestaShop keywords", &prestashopDetector{}, ` `, http.Header{}},
+ {"TYPO3 prose", &typo3Detector{}, `TYPO3 CMS is a popular enterprise CMS.
`, http.Header{}},
+ {"Webflow review", &webflowDetector{}, ` `, http.Header{}},
+ {"HubSpot pricing", &hubspotDetector{}, ` `, http.Header{}},
+ {"Webflow brand og", &webflowDetector{}, ` `, http.Header{}},
+ {"HubSpot brand og", &hubspotDetector{}, ` `, http.Header{}},
+ {"Wix mention", &wixDetector{}, `I built my first site on Wix.com years ago.
`, http.Header{}},
+ {"Sitecore plain cookie", &sitecoreDetector{}, "", hdr("Set-Cookie", "sessionid=abc; path=/")},
+ {"OpenCart plain cookie", &opencartDetector{}, "", hdr("Set-Cookie", "PHPSESSID=abc; path=/")},
+ {"DotNetNuke plain cookie", &dnnDetector{}, "", hdr("Set-Cookie", "ASP.NET_SessionId=abc; path=/")},
+ {"Liferay plain header", &liferayDetector{}, "", hdr("Server", "nginx/1.25.3")},
+ {"Liferay prose", &liferayDetector{}, "Liferay is a Java portal platform.
", http.Header{}},
+ {"Squarespace prose", &squarespaceDetector{}, `Squarespace is a hosted website builder.
`, http.Header{}},
+ {"Shopify cdn link header", &shopifyDetector{}, "", hdr("Link", "; rel=preload")},
+ {"Shopify cdn body only", &shopifyDetector{}, ``, http.Header{}},
+ {"WooCommerce plain WP", &woocommerceDetector{}, ` `, http.Header{}},
+ {"WooCommerce class only", &woocommerceDetector{}, ``, http.Header{}},
+ {"Craft unrelated header", &craftDetector{}, "", hdr("Server", "nginx/1.25.3")},
+ {"Concrete brand og", &concreteDetector{}, ` `, http.Header{}},
+ {"Bitrix prose", &bitrixDetector{}, `We migrated from Bitrix to Shopify last year.
`, http.Header{}},
+ {"Blogger comparison prose", &bloggerDetector{}, ` `, http.Header{}},
+ {"MediaWiki brand og", &mediawikiDetector{}, ` `, http.Header{}},
+ {"Discourse comparison og", &discourseDetector{}, ` `, http.Header{}},
+ {"XenForo prose", &xenforoDetector{}, `XenForo is a commercial forum platform.
`, http.Header{}},
+ {"Moodle unrelated cookie", &moodleDetector{}, "", hdr("Set-Cookie", "sessionid=abc; path=/")},
+ {"Moodle minified collision", &moodleDetector{}, ``, http.Header{}},
+ {"Plone brand og", &ploneDetector{}, ` `, http.Header{}},
+ {"Grav prose", &gravDetector{}, `Grav is a flat-file CMS.
`, http.Header{}},
+ {"Textpattern brand og", &textpatternDetector{}, ` `, http.Header{}},
+ {"Statamic unrelated header", &statamicDetector{}, "", hdr("Server", "nginx/1.25.3")},
+ {"Statamic domain in link header", &statamicDetector{}, "", hdr("Link", "; rel=preload")},
+ {"Flarum prose", &flarumDetector{}, `Flarum is a delightfully simple forum platform.
`, http.Header{}},
+ {"Flarum near miss id", &flarumDetector{}, `
`, http.Header{}},
+ {"Flarum generic forum asset", &flarumDetector{}, ` `, http.Header{}},
+ {"XWiki prose", &xwikiDetector{}, `XWiki is an enterprise wiki platform.
`, http.Header{}},
+ {"Bolt brand og", &boltDetector{}, ` `, http.Header{}},
+ {"ExpressionEngine prose", &expressionengineDetector{}, `ExpressionEngine is a flexible PHP CMS.
`, http.Header{}},
+ {"ExpressionEngine exp prefix collision", &expressionengineDetector{}, "", hdr("Set-Cookie", "exp_date=2026; path=/")},
+ {"NodeBB prose", &nodebbDetector{}, `We run NodeBB for our community forum.
`, http.Header{}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, tt.headers)
+ if conf > 0.5 {
+ t.Errorf("%s: confidence = %.3f, want <= 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestPlatformDetectors_Version(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ want string
+ }{
+ {"TYPO3 4.x", &typo3Detector{}, ` `, "4.5"},
+ {"TYPO3 modern", &typo3Detector{}, ` `, "unknown"},
+ {"MediaWiki", &mediawikiDetector{}, ` `, "1.47.0"},
+ {"Discourse", &discourseDetector{}, ` `, "3.2.0"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, version := tt.detector.Detect(tt.body, http.Header{})
+ if version != tt.want {
+ t.Errorf("%s: version = %q, want %q", tt.name, version, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/scan/frameworks/detectors/frontend.go b/internal/scan/frameworks/detectors/frontend.go
index 488cfa46..46a9596c 100644
--- a/internal/scan/frameworks/detectors/frontend.go
+++ b/internal/scan/frameworks/detectors/frontend.go
@@ -35,9 +35,16 @@ func init() {
fw.Register(&backboneDetector{})
fw.Register(&meteorDetector{})
fw.Register(&htmxDetector{})
+ fw.Register(&alpineDetector{})
+ fw.Register(&jqueryDetector{})
+ fw.Register(&knockoutDetector{})
+ fw.Register(&livewireDetector{})
+ fw.Register(&qwikDetector{})
+ fw.Register(&stimulusDetector{})
+ fw.Register(&turboDetector{})
+ fw.Register(&unpolyDetector{})
}
-// reactDetector detects React framework.
type reactDetector struct{}
func (d *reactDetector) Name() string { return "React" }
@@ -64,7 +71,6 @@ func (d *reactDetector) Detect(body string, headers http.Header) (float32, strin
return confidence, version
}
-// vueDetector detects Vue.js framework.
type vueDetector struct{}
func (d *vueDetector) Name() string { return "Vue.js" }
@@ -92,7 +98,6 @@ func (d *vueDetector) Detect(body string, headers http.Header) (float32, string)
return confidence, version
}
-// angularDetector detects Angular framework.
type angularDetector struct{}
func (d *angularDetector) Name() string { return "Angular" }
@@ -122,7 +127,6 @@ func (d *angularDetector) Detect(body string, headers http.Header) (float32, str
return confidence, version
}
-// svelteDetector detects Svelte framework.
type svelteDetector struct{}
func (d *svelteDetector) Name() string { return "Svelte" }
@@ -147,7 +151,6 @@ func (d *svelteDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// emberDetector detects Ember.js framework.
type emberDetector struct{}
func (d *emberDetector) Name() string { return "Ember.js" }
@@ -175,7 +178,6 @@ func (d *emberDetector) Detect(body string, headers http.Header) (float32, strin
return confidence, version
}
-// backboneDetector detects Backbone.js framework.
type backboneDetector struct{}
func (d *backboneDetector) Name() string { return "Backbone.js" }
@@ -202,7 +204,6 @@ func (d *backboneDetector) Detect(body string, headers http.Header) (float32, st
return confidence, version
}
-// htmxDetector detects the htmx library.
type htmxDetector struct{}
func (d *htmxDetector) Name() string { return "htmx" }
@@ -230,7 +231,208 @@ func (d *htmxDetector) Detect(body string, headers http.Header) (float32, string
return confidence, version
}
-// meteorDetector detects Meteor framework.
+type alpineDetector struct{}
+
+func (d *alpineDetector) Name() string { return "Alpine.js" }
+
+func (d *alpineDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: " x-data", Weight: 0.6},
+ {Pattern: "alpinejs", Weight: 0.5},
+ {Pattern: "x-cloak", Weight: 0.4},
+ {Pattern: "x-transition", Weight: 0.4},
+ }
+}
+
+func (d *alpineDetector) 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
+}
+
+type qwikDetector struct{}
+
+func (d *qwikDetector) Name() string { return "Qwik" }
+
+func (d *qwikDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "q:container", Weight: 0.5},
+ {Pattern: "q:version", Weight: 0.4},
+ {Pattern: "q:base", Weight: 0.3},
+ {Pattern: "qwikloader", Weight: 0.3},
+ }
+}
+
+func (d *qwikDetector) 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
+}
+
+type jqueryDetector struct{}
+
+func (d *jqueryDetector) Name() string { return "jQuery" }
+
+func (d *jqueryDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "jquery.min.js", Weight: 0.5},
+ {Pattern: "jquery-", Weight: 0.5},
+ {Pattern: "jQuery.fn.jquery", Weight: 0.4},
+ }
+}
+
+func (d *jqueryDetector) 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
+}
+
+// livewireDetector detects Laravel Livewire.
+type livewireDetector struct{}
+
+func (d *livewireDetector) Name() string { return "Livewire" }
+
+func (d *livewireDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "wire:id", Weight: 0.5},
+ {Pattern: "wire:snapshot", Weight: 0.4},
+ {Pattern: "wire:model", Weight: 0.4},
+ {Pattern: "wire:click", Weight: 0.4},
+ }
+}
+
+func (d *livewireDetector) 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
+}
+
+// stimulusDetector detects the Stimulus controller framework (part of Hotwire, the Rails 7 default).
+type stimulusDetector struct{}
+
+func (d *stimulusDetector) Name() string { return "Stimulus" }
+
+func (d *stimulusDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ // data-controller= alone is a common enough attribute name in
+ // unrelated hand-rolled JS that it must not clear the threshold by
+ // itself; neither must data-action= alone. each needs the other (or
+ // the definitive @hotwired/stimulus import) alongside it to fire.
+ {Pattern: "data-controller=", Weight: 0.2},
+ {Pattern: "data-action=", Weight: 0.2},
+ {Pattern: "@hotwired/stimulus", Weight: 0.6},
+ }
+}
+
+func (d *stimulusDetector) 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
+}
+
+// turboDetector detects Turbo (part of Hotwire, the Rails 7 default).
+type turboDetector struct{}
+
+func (d *turboDetector) Name() string { return "Turbo" }
+
+func (d *turboDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: " 0.5 {
+ version = fw.ExtractVersionOptimized(body, d.Name()).Version
+ }
+ return confidence, version
+}
+
+type knockoutDetector struct{}
+
+func (d *knockoutDetector) Name() string { return "Knockout.js" }
+
+func (d *knockoutDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "data-bind=", Weight: 0.5},
+ {Pattern: "ko.applyBindings", Weight: 0.5},
+ {Pattern: "knockout-", Weight: 0.4},
+ }
+}
+
+func (d *knockoutDetector) 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
+}
+
+type unpolyDetector struct{}
+
+func (d *unpolyDetector) Name() string { return "Unpoly" }
+
+func (d *unpolyDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "unpoly.min.js", Weight: 0.5},
+ {Pattern: "unpoly.js", Weight: 0.4},
+ {Pattern: "unpoly@", Weight: 0.4},
+ }
+}
+
+func (d *unpolyDetector) 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
+}
+
type meteorDetector struct{}
func (d *meteorDetector) Name() string { return "Meteor" }
diff --git a/internal/scan/frameworks/detectors/frontend_test.go b/internal/scan/frameworks/detectors/frontend_test.go
new file mode 100644
index 00000000..f1ed86b9
--- /dev/null
+++ b/internal/scan/frameworks/detectors/frontend_test.go
@@ -0,0 +1,108 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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 TestFrontendLibDetectors_Positive(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ }{
+ {"Alpine x-data only", &alpineDetector{}, `
`},
+ {"Alpine cdn plus cloak", &alpineDetector{}, `
`},
+ {"Qwik container only", &qwikDetector{}, ``},
+ {"Qwik bootstrap", &qwikDetector{}, ``},
+ {"jQuery cdn", &jqueryDetector{}, ``},
+ {"jQuery googleapis", &jqueryDetector{}, ``},
+ {"jQuery wp bundled", &jqueryDetector{}, ``},
+ {"Livewire component", &livewireDetector{}, `
`},
+ {"Stimulus controller", &stimulusDetector{}, `x
`},
+ {"Stimulus script reference", &stimulusDetector{}, ``},
+ {"Turbo frame", &turboDetector{}, `x `},
+ {"Turbo track only", &turboDetector{}, ` `},
+ {"Knockout bindings", &knockoutDetector{}, ` `},
+ {"Unpoly script", &unpolyDetector{}, ``},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, http.Header{})
+ if conf <= 0.5 {
+ t.Errorf("%s: confidence = %.3f, want > 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestFrontendLibDetectors_Negative(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ }{
+ {"Alpine prose", &alpineDetector{}, `Alpine.js is a lightweight framework.
`},
+ {"Vue at-click not Alpine", &alpineDetector{}, `go
`},
+ {"Alpine max-data substring", &alpineDetector{}, ``},
+ {"Qwik prose", &qwikDetector{}, `Qwik is a resumable framework, see qwik.dev for details.
`},
+ {"jQuery prose", &jqueryDetector{}, `migrating off jquery this year.
`},
+ {"Livewire single directive", &livewireDetector{}, `save `},
+ {"Livewire prose", &livewireDetector{}, `Livewire is a full-stack framework for Laravel.
`},
+ {"Stimulus prose", &stimulusDetector{}, `Stimulus is a modest JavaScript framework for the HTML you already have.
`},
+ {"Stimulus generic data-action", &stimulusDetector{}, `Buy now `},
+ {"Stimulus generic data-controller only", &stimulusDetector{}, `2 items
`},
+ {"Turbo prose og", &turboDetector{}, `Turbo Drive is great.
`},
+ {"Turbo vs legacy turbolinks", &turboDetector{}, ` `},
+ {"Knockout prose", &knockoutDetector{}, `Knockout.js is an MVVM library for JavaScript.
`},
+ {"Unpoly attr substring collision", &unpolyDetector{}, `x
`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, http.Header{})
+ if conf > 0.5 {
+ t.Errorf("%s: confidence = %.3f, want <= 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestFrontendLibDetectors_Version(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ want string
+ }{
+ {"jQuery filename", &jqueryDetector{}, ``, "3.6.0"},
+ {"jQuery googleapis path", &jqueryDetector{}, ``, "3.7.1"},
+ {"Alpine cdn", &alpineDetector{}, `
`, "3.13.0"},
+ {"Qwik attr", &qwikDetector{}, ``, "1.5.0"},
+ {"Knockout filename", &knockoutDetector{}, ` `, "3.5.1"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, version := tt.detector.Detect(tt.body, http.Header{})
+ if version != tt.want {
+ t.Errorf("%s: version = %q, want %q", tt.name, version, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/scan/frameworks/detectors/hosting.go b/internal/scan/frameworks/detectors/hosting.go
new file mode 100644
index 00000000..2aae2c1a
--- /dev/null
+++ b/internal/scan/frameworks/detectors/hosting.go
@@ -0,0 +1,174 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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
+
+*/
+
+package detectors
+
+import (
+ "net/http"
+
+ fw "github.com/vmfunc/sif/internal/scan/frameworks"
+)
+
+// Detectors in this file identify the hosting platform or edge/CDN provider in
+// front of a target rather than its application framework.
+
+func init() {
+ fw.Register(&vercelDetector{})
+ fw.Register(&netlifyDetector{})
+ fw.Register(&githubPagesDetector{})
+ fw.Register(&cloudflareDetector{})
+ fw.Register(&cloudfrontDetector{})
+ fw.Register(&akamaiDetector{})
+ fw.Register(&flyDetector{})
+ fw.Register(&amazonS3Detector{})
+}
+
+type vercelDetector struct{}
+
+func (d *vercelDetector) Name() string { return "Vercel" }
+
+func (d *vercelDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "x-vercel", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *vercelDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+type netlifyDetector struct{}
+
+func (d *netlifyDetector) Name() string { return "Netlify" }
+
+func (d *netlifyDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "x-nf-request-id", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *netlifyDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+// githubPagesDetector detects GitHub Pages (and GitHub-hosted infrastructure).
+type githubPagesDetector struct{}
+
+func (d *githubPagesDetector) Name() string { return "GitHub Pages" }
+
+func (d *githubPagesDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "x-github-request-id", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *githubPagesDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+type cloudflareDetector struct{}
+
+func (d *cloudflareDetector) Name() string { return "Cloudflare" }
+
+func (d *cloudflareDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "cf-ray", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *cloudflareDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+// cloudfrontDetector detects the Amazon CloudFront CDN.
+type cloudfrontDetector struct{}
+
+func (d *cloudfrontDetector) Name() string { return "Amazon CloudFront" }
+
+func (d *cloudfrontDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "x-amz-cf-id", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *cloudfrontDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+type akamaiDetector struct{}
+
+func (d *akamaiDetector) Name() string { return "Akamai" }
+
+func (d *akamaiDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "Akamai-GRN", Weight: 0.5, HeaderOnly: true},
+ {Pattern: "x-akamai", Weight: 0.4, HeaderOnly: true},
+ {Pattern: "AkamaiGHost", Weight: 0.4, HeaderOnly: true},
+ }
+}
+
+func (d *akamaiDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+type flyDetector struct{}
+
+func (d *flyDetector) Name() string { return "Fly.io" }
+
+func (d *flyDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "fly-request-id", Weight: 0.6, HeaderOnly: true},
+ }
+}
+
+func (d *flyDetector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
+
+// amazonS3Detector detects content served from an Amazon S3 bucket.
+type amazonS3Detector struct{}
+
+func (d *amazonS3Detector) Name() string { return "Amazon S3" }
+
+func (d *amazonS3Detector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "AmazonS3", Weight: 0.6, HeaderOnly: true},
+ {Pattern: "x-amz-bucket-region", Weight: 0.4, HeaderOnly: true},
+ }
+}
+
+func (d *amazonS3Detector) Detect(body string, headers http.Header) (float32, string) {
+ base := fw.NewBaseDetector(d.Name(), d.Signatures())
+ score := base.MatchSignatures(body, headers)
+ return sigmoidConfidence(score), ""
+}
diff --git a/internal/scan/frameworks/detectors/hosting_test.go b/internal/scan/frameworks/detectors/hosting_test.go
new file mode 100644
index 00000000..7221b475
--- /dev/null
+++ b/internal/scan/frameworks/detectors/hosting_test.go
@@ -0,0 +1,73 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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 TestHostingDetectors_Positive(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ headers http.Header
+ }{
+ {"Vercel", &vercelDetector{}, hdr("x-vercel-id", "sfo1::pdx1::dmmpr-1782439760448-608ebdfc")},
+ {"Netlify", &netlifyDetector{}, hdr("x-nf-request-id", "01KW0V0MYRFJKYYNDRP7KC5DAJ")},
+ {"GitHub Pages", &githubPagesDetector{}, hdr("x-github-request-id", "8714:20C798:191901:19DD8B:6A3DDD67")},
+ {"Cloudflare", &cloudflareDetector{}, hdr("cf-ray", "a118ab5d9e7867e8-SJC")},
+ {"CloudFront", &cloudfrontDetector{}, hdr("x-amz-cf-id", "0MsbJpMBovZpIG2KNmafF4RVM4GXD_iKAnm9friazwXUpC")},
+ {"Akamai GRN", &akamaiDetector{}, hdr("Akamai-GRN", "0.1ea7cb17.1782439763.4a41c389")},
+ {"Akamai server", &akamaiDetector{}, hdr("Server", "AkamaiGHost")},
+ {"Fly.io", &flyDetector{}, hdr("fly-request-id", "01KW0V0QBEWMQ51YPTNZKE3EYJ-sjc")},
+ {"Amazon S3 server", &amazonS3Detector{}, hdr("Server", "AmazonS3")},
+ {"Amazon S3 region", &amazonS3Detector{}, hdr("x-amz-bucket-region", "us-east-2")},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect("", tt.headers)
+ if conf <= 0.5 {
+ t.Errorf("%s: confidence = %.3f, want > 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestHostingDetectors_Negative(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ headers http.Header
+ }{
+ {"Vercel plain", &vercelDetector{}, hdr("Server", "nginx/1.25.3")},
+ {"Netlify plain", &netlifyDetector{}, hdr("Server", "nginx")},
+ {"Cloudflare plain", &cloudflareDetector{}, hdr("Server", "nginx")},
+ {"GitHub link header", &githubPagesDetector{}, hdr("Link", "; rel=canonical")},
+ {"Akamai csp asset", &akamaiDetector{}, hdr("Content-Security-Policy", "img-src https://example.akamaihd.net")},
+ {"S3 generic amz id", &amazonS3Detector{}, hdr("x-amz-request-id", "4Y0WES8AVK3ZQ98N")},
+ {"Fly plain", &flyDetector{}, hdr("Server", "Cowboy")},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect("", tt.headers)
+ if conf > 0.5 {
+ t.Errorf("%s: confidence = %.3f, want <= 0.5", tt.name, conf)
+ }
+ })
+ }
+}
diff --git a/internal/scan/frameworks/detectors/meta.go b/internal/scan/frameworks/detectors/meta.go
index ad30466d..b4e0f5ae 100644
--- a/internal/scan/frameworks/detectors/meta.go
+++ b/internal/scan/frameworks/detectors/meta.go
@@ -33,9 +33,18 @@ func init() {
fw.Register(&gatsbyDetector{})
fw.Register(&remixDetector{})
fw.Register(&astroDetector{})
+ fw.Register(&hugoDetector{})
+ fw.Register(&jekyllDetector{})
+ fw.Register(&docusaurusDetector{})
+ fw.Register(&mkdocsDetector{})
+ fw.Register(&eleventyDetector{})
+ fw.Register(&hexoDetector{})
+ fw.Register(&vuepressDetector{})
+ fw.Register(&sphinxDetector{})
+ fw.Register(&nikolaDetector{})
+ fw.Register(&publiiDetector{})
}
-// nextjsDetector detects Next.js framework.
type nextjsDetector struct{}
func (d *nextjsDetector) Name() string { return "Next.js" }
@@ -61,7 +70,6 @@ func (d *nextjsDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// nuxtDetector detects Nuxt.js framework.
type nuxtDetector struct{}
func (d *nuxtDetector) Name() string { return "Nuxt.js" }
@@ -86,7 +94,6 @@ func (d *nuxtDetector) Detect(body string, headers http.Header) (float32, string
return confidence, version
}
-// sveltekitDetector detects SvelteKit framework.
type sveltekitDetector struct{}
func (d *sveltekitDetector) Name() string { return "SvelteKit" }
@@ -111,7 +118,6 @@ func (d *sveltekitDetector) Detect(body string, headers http.Header) (float32, s
return confidence, version
}
-// gatsbyDetector detects Gatsby framework.
type gatsbyDetector struct{}
func (d *gatsbyDetector) Name() string { return "Gatsby" }
@@ -136,7 +142,6 @@ func (d *gatsbyDetector) Detect(body string, headers http.Header) (float32, stri
return confidence, version
}
-// remixDetector detects Remix framework.
type remixDetector struct{}
func (d *remixDetector) Name() string { return "Remix" }
@@ -159,7 +164,6 @@ func (d *remixDetector) Detect(body string, headers http.Header) (float32, strin
return confidence, version
}
-// astroDetector detects Astro framework.
type astroDetector struct{}
func (d *astroDetector) Name() string { return "Astro" }
@@ -187,3 +191,233 @@ func (d *astroDetector) Detect(body string, headers http.Header) (float32, strin
}
return confidence, version
}
+
+// The generator detectors below anchor on the content=" value: real
+// sites minify and reorder the meta, dropping the name="generator" prefix.
+
+type hugoDetector struct{}
+
+func (d *hugoDetector) Name() string { return "Hugo" }
+
+func (d *hugoDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `content="Hugo 0.`, Weight: 0.6},
+ }
+}
+
+func (d *hugoDetector) 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
+}
+
+type jekyllDetector struct{}
+
+func (d *jekyllDetector) Name() string { return "Jekyll" }
+
+func (d *jekyllDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `content="Jekyll v`, Weight: 0.6},
+ }
+}
+
+func (d *jekyllDetector) 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
+}
+
+type docusaurusDetector struct{}
+
+func (d *docusaurusDetector) Name() string { return "Docusaurus" }
+
+func (d *docusaurusDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `content="Docusaurus v`, Weight: 0.6},
+ }
+}
+
+func (d *docusaurusDetector) 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
+}
+
+// mkdocsDetector detects MkDocs (including the Material theme).
+type mkdocsDetector struct{}
+
+func (d *mkdocsDetector) Name() string { return "MkDocs" }
+
+func (d *mkdocsDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `content="mkdocs-`, Weight: 0.6},
+ }
+}
+
+func (d *mkdocsDetector) 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
+}
+
+// The generator detectors below anchor on the generator-attribute prefix
+// (generator" content=") rather than a bare brand value.
+
+// eleventyDetector detects the Eleventy (11ty) static site generator.
+type eleventyDetector struct{}
+
+func (d *eleventyDetector) Name() string { return "Eleventy" }
+
+func (d *eleventyDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Eleventy`, Weight: 0.6},
+ }
+}
+
+func (d *eleventyDetector) 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
+}
+
+type hexoDetector struct{}
+
+func (d *hexoDetector) Name() string { return "Hexo" }
+
+func (d *hexoDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Hexo`, Weight: 0.6},
+ }
+}
+
+func (d *hexoDetector) 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
+}
+
+type vuepressDetector struct{}
+
+func (d *vuepressDetector) Name() string { return "VuePress" }
+
+func (d *vuepressDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="VuePress`, Weight: 0.6},
+ }
+}
+
+func (d *vuepressDetector) 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
+}
+
+type sphinxDetector struct{}
+
+func (d *sphinxDetector) Name() string { return "Sphinx" }
+
+func (d *sphinxDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: "_static/documentation_options.js", Weight: 0.6},
+ {Pattern: "sphinx-doc.org", Weight: 0.3},
+ {Pattern: "_static/doctools.js", Weight: 0.3},
+ }
+}
+
+func (d *sphinxDetector) 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
+}
+
+type nikolaDetector struct{}
+
+func (d *nikolaDetector) Name() string { return "Nikola" }
+
+func (d *nikolaDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Nikola`, Weight: 0.6},
+ }
+}
+
+func (d *nikolaDetector) 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
+}
+
+type publiiDetector struct{}
+
+func (d *publiiDetector) Name() string { return "Publii" }
+
+func (d *publiiDetector) Signatures() []fw.Signature {
+ return []fw.Signature{
+ {Pattern: `generator" content="Publii`, Weight: 0.6},
+ }
+}
+
+func (d *publiiDetector) 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
+}
diff --git a/internal/scan/frameworks/detectors/meta_test.go b/internal/scan/frameworks/detectors/meta_test.go
new file mode 100644
index 00000000..62567661
--- /dev/null
+++ b/internal/scan/frameworks/detectors/meta_test.go
@@ -0,0 +1,109 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · 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 TestSiteGeneratorDetectors_Positive(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ }{
+ {"Hugo minified", &hugoDetector{}, `x `},
+ {"Jekyll", &jekyllDetector{}, ` `},
+ {"Docusaurus minified", &docusaurusDetector{}, ` `},
+ {"MkDocs Material", &mkdocsDetector{}, ` `},
+ {"Eleventy default", &eleventyDetector{}, ` `},
+ {"Eleventy custom label", &eleventyDetector{}, ` `},
+ {"Hexo", &hexoDetector{}, ` `},
+ {"VuePress", &vuepressDetector{}, ` `},
+ {"Sphinx assets", &sphinxDetector{}, ``},
+ {"Nikola", &nikolaDetector{}, ` `},
+ {"Publii", &publiiDetector{}, ` `},
+ {"Remix context", &remixDetector{}, ``},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, http.Header{})
+ if conf <= 0.5 {
+ t.Errorf("%s: confidence = %.3f, want > 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestSiteGeneratorDetectors_Negative(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ }{
+ {"Hugo Boss", &hugoDetector{}, `Hugo is a designer.
`},
+ {"Jekyll novel", &jekyllDetector{}, ` `},
+ {"Docusaurus tutorial", &docusaurusDetector{}, ` `},
+ {"Docusaurus brand og", &docusaurusDetector{}, ` `},
+ {"MkDocs guide", &mkdocsDetector{}, `MkDocs is great.
`},
+ {"plain migration prose", &hugoDetector{}, `We migrated from Jekyll to Hugo last year.
`},
+ {"Eleventy brand og", &eleventyDetector{}, ` `},
+ {"Hexo brand og", &hexoDetector{}, ` `},
+ {"VuePress release prose", &vuepressDetector{}, ` `},
+ {"Sphinx link only", &sphinxDetector{}, `Built with Sphinx .
`},
+ {"Sphinx doctools only", &sphinxDetector{}, ``},
+ {"Nikola Tesla og", &nikolaDetector{}, ` `},
+ {"Publii brand og", &publiiDetector{}, ` `},
+ {"Remix audio asset", &remixDetector{}, ` `},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ conf, _ := tt.detector.Detect(tt.body, http.Header{})
+ if conf > 0.5 {
+ t.Errorf("%s: confidence = %.3f, want <= 0.5", tt.name, conf)
+ }
+ })
+ }
+}
+
+func TestSiteGeneratorDetectors_Version(t *testing.T) {
+ tests := []struct {
+ name string
+ detector fw.Detector
+ body string
+ want string
+ }{
+ {"Hugo", &hugoDetector{}, ` `, "0.163.3"},
+ {"Jekyll", &jekyllDetector{}, ` `, "4.4.1"},
+ {"Docusaurus", &docusaurusDetector{}, ` `, "3.10.1"},
+ {"MkDocs", &mkdocsDetector{}, ` `, "1.6.1"},
+ {"Eleventy", &eleventyDetector{}, ` `, "3.0.0"},
+ {"Eleventy custom label", &eleventyDetector{}, ` `, "4.0.0"},
+ {"Hexo", &hexoDetector{}, ` `, "8.1.1"},
+ {"VuePress", &vuepressDetector{}, ` `, "2.0.0"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, version := tt.detector.Detect(tt.body, http.Header{})
+ if version != tt.want {
+ t.Errorf("%s: version = %q, want %q", tt.name, version, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/scan/frameworks/version.go b/internal/scan/frameworks/version.go
index ee7dc9ec..f28062dc 100644
--- a/internal/scan/frameworks/version.go
+++ b/internal/scan/frameworks/version.go
@@ -32,7 +32,6 @@ type compiledVersionPattern struct {
}
// frameworkVersionPatterns maps framework names to their pre-compiled version patterns.
-// Patterns are compiled once at package initialization for optimal performance.
var frameworkVersionPatterns map[string][]compiledVersionPattern
func init() {
@@ -142,6 +141,51 @@ func init() {
{`Astro[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"astro":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
+ "Hugo": {
+ {`content="Hugo (\d+\.\d+(?:\.\d+)?)`, 0.95, "generator meta"},
+ },
+ "Jekyll": {
+ {`content="Jekyll v(\d+\.\d+(?:\.\d+)?)`, 0.95, "generator meta"},
+ },
+ "Docusaurus": {
+ {`content="Docusaurus v(\d+\.\d+(?:\.\d+)?)`, 0.95, "generator meta"},
+ },
+ "MkDocs": {
+ {`content="mkdocs-(\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "TYPO3": {
+ {`content="TYPO3 (\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "Eleventy": {
+ {`content="Eleventy[^"]*?v(\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "Hexo": {
+ {`content="Hexo (\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "VuePress": {
+ {`content="VuePress (\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "jQuery": {
+ {`jquery-(\d+\.\d+(?:\.\d+)?)(?:\.min)?\.js`, 0.9, "script filename"},
+ {`jquery@(\d+\.\d+(?:\.\d+)?)`, 0.85, "CDN reference"},
+ {`/jquery/(\d+\.\d+(?:\.\d+)?)/`, 0.85, "CDN path"},
+ {`jQuery v(\d+\.\d+(?:\.\d+)?)`, 0.9, "library banner"},
+ },
+ "Alpine.js": {
+ {`alpinejs@(\d+\.\d+(?:\.\d+)?)`, 0.85, "CDN reference"},
+ },
+ "Qwik": {
+ {`q:version="(\d+\.\d+(?:\.\d+)?)"`, 0.9, "container attribute"},
+ },
+ "MediaWiki": {
+ {`content="MediaWiki (\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "Discourse": {
+ {`content="Discourse (\d+\.\d+(?:\.\d+)?)`, 0.9, "generator meta"},
+ },
+ "Knockout.js": {
+ {`knockout-(\d+\.\d+(?:\.\d+)?)`, 0.9, "script filename"},
+ },
}
// Compile all patterns
@@ -160,7 +204,6 @@ func init() {
}
// ExtractVersionOptimized extracts version using pre-compiled patterns.
-// This is exported for use by individual detector implementations.
func ExtractVersionOptimized(body string, framework string) VersionMatch {
patterns, exists := frameworkVersionPatterns[framework]
if !exists {
@@ -188,6 +231,8 @@ func ExtractVersionOptimized(body string, framework string) VersionMatch {
return bestMatch
}
+// isValidVersionString checks if a version string is digits and dots only, with
+// at most three dots.
func isValidVersionString(v string) bool {
if v == "" || len(v) > 20 {
return false