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{}, `
`}, + {"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{}, `
`}, + {"Alpine max-data substring", &alpineDetector{}, `x
`}, + {"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{}, ``}, + {"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{}, ``}, + {"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{}, ``}, + } + + 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