mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-04 03:45:08 -07:00
6575c2e5f7
a detector accuracy audit surfaced two classes of bug in the framework detectors. bare-brand header false positives: header-only signatures matched a brand name as a substring across every header name and value, so a detector fired on any response that merely referenced the brand (a vendor cdn named in a link or csp value, a cookie sharing the prefix). add an optional Header field to Signature that scopes a header-only match to one named header's value, and apply it (or a structural anchor) per detector: - express: "Express" scoped to x-powered-by, was firing on an express_checkout cookie. - flask: "Werkzeug" scoped to the server header. - symfony: dropped the bare "symfony" word (symfony sets no such header, it fired on symfony.com links); the x-debug-token header is the marker. - shopify: key on the x-shopify response headers instead of the bare "Shopify" word, which fired on a cdn.shopify.com link. - remix: dropped the bare "remix"/"_remix" substrings that fired on a track_remix.mp3 asset; window.__remixContext is the definitive marker. - spring boot: anchor the whitelabel title in its h1 tag context so a tutorial discussing the error does not fire. the gin and fastapi detectors are removed: gin keyed on the "gin-gonic" import-path string (appears in tutorials, never in a real gin response) and fastapi on bare words matching the projects' doc domains. neither framework advertises itself in a response header or a non-prose body marker, so there is no clean passive signal to anchor on. version mis-extraction: drop the low-confidence ".*?" version fallbacks (rails, django, laravel, spring), whose unbounded gap grabbed the first version-shaped number after the framework word and reported an unrelated asset's cache-buster when no real version was present. let isValidVersionString accept a single integer so a bare major such as drupal's "Drupal 10" is no longer rejected as "unknown". each false positive and version bug is covered by a regression test.
213 lines
7.3 KiB
Go
213 lines
7.3 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package frameworks
|
|
|
|
import (
|
|
"regexp"
|
|
"unicode"
|
|
)
|
|
|
|
// VersionMatch represents a version detection result with confidence.
|
|
type VersionMatch struct {
|
|
Version string
|
|
Confidence float32
|
|
Source string // where the version was found
|
|
}
|
|
|
|
// compiledVersionPattern holds a pre-compiled regex for version extraction
|
|
type compiledVersionPattern struct {
|
|
re *regexp.Regexp
|
|
confidence float32
|
|
source string
|
|
}
|
|
|
|
// 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() {
|
|
// Raw patterns to be compiled
|
|
rawPatterns := map[string][]struct {
|
|
pattern string
|
|
confidence float32
|
|
source string
|
|
}{
|
|
"Laravel": {
|
|
{`Laravel\s+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Django": {
|
|
{`Django[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Ruby on Rails": {
|
|
{`Rails[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Express.js": {
|
|
{`Express[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"express":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"ASP.NET": {
|
|
{`X-AspNet-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.95, "header"},
|
|
{`ASP\.NET[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`X-AspNetMvc-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.9, "MVC header"},
|
|
},
|
|
"ASP.NET Core": {
|
|
{`\.NET\s*(\d+\.\d+(?:\.\d+)?)`, 0.8, "dotnet version"},
|
|
},
|
|
"Spring": {
|
|
{`Spring[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Flask": {
|
|
{`Flask[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`Werkzeug[/\s]+(\d+\.\d+(?:\.\d+)?)`, 0.7, "werkzeug version"},
|
|
},
|
|
"Next.js": {
|
|
{`Next\.js[/\s]+[Vv]?(\d{1,2}\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"next":\s*"[~^]?(\d{1,2}\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"Nuxt.js": {
|
|
{`Nuxt[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"nuxt":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"Vue.js": {
|
|
{`Vue\.js[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"vue":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
{`vue@(\d+\.\d+(?:\.\d+)?)`, 0.8, "CDN reference"},
|
|
},
|
|
"Angular": {
|
|
{`ng-version="(\d+\.\d+(?:\.\d+)?)"`, 0.95, "ng-version attribute"},
|
|
{`Angular[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"@angular/core":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"React": {
|
|
{`React[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"react":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
{`react@(\d+\.\d+(?:\.\d+)?)`, 0.8, "CDN reference"},
|
|
},
|
|
"Svelte": {
|
|
{`Svelte[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"svelte":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"SvelteKit": {
|
|
{`"@sveltejs/kit":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"htmx": {
|
|
{`htmx(?:\.org)?@(\d+\.\d+(?:\.\d+)?)`, 0.85, "CDN reference"},
|
|
{`"htmx\.org":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
"WordPress": {
|
|
{`<meta name="generator" content="WordPress (\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
|
|
{`WordPress (\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Drupal": {
|
|
{`Drupal[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`<meta name="Generator" content="Drupal (\d+)`, 0.9, "generator meta"},
|
|
},
|
|
"Joomla": {
|
|
{`Joomla[!/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`<meta name="generator" content="Joomla! - Open Source Content Management - Version (\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
|
|
},
|
|
"Magento": {
|
|
{`Magento[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Symfony": {
|
|
{`Symfony[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Phoenix": {
|
|
{`Phoenix[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Ember.js": {
|
|
{`Ember[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Backbone.js": {
|
|
{`Backbone[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Meteor": {
|
|
{`Meteor[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Ghost": {
|
|
{`Ghost[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
},
|
|
"Astro": {
|
|
{`<meta name="generator" content="Astro v?(\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
|
|
{`Astro[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
|
|
{`"astro":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
|
|
},
|
|
}
|
|
|
|
// Compile all patterns
|
|
frameworkVersionPatterns = make(map[string][]compiledVersionPattern, len(rawPatterns))
|
|
for framework, patterns := range rawPatterns {
|
|
compiled := make([]compiledVersionPattern, len(patterns))
|
|
for i, p := range patterns {
|
|
compiled[i] = compiledVersionPattern{
|
|
re: regexp.MustCompile(p.pattern),
|
|
confidence: p.confidence,
|
|
source: p.source,
|
|
}
|
|
}
|
|
frameworkVersionPatterns[framework] = compiled
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return VersionMatch{Version: "unknown", Confidence: 0, Source: ""}
|
|
}
|
|
|
|
var bestMatch VersionMatch
|
|
for _, p := range patterns {
|
|
matches := p.re.FindStringSubmatch(body)
|
|
if len(matches) > 1 && p.confidence > bestMatch.Confidence {
|
|
candidate := matches[1]
|
|
if isValidVersionString(candidate) {
|
|
bestMatch = VersionMatch{
|
|
Version: candidate,
|
|
Confidence: p.confidence,
|
|
Source: p.source,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if bestMatch.Version == "" {
|
|
return VersionMatch{Version: "unknown", Confidence: 0, Source: ""}
|
|
}
|
|
return bestMatch
|
|
}
|
|
|
|
func isValidVersionString(v string) bool {
|
|
if v == "" || len(v) > 20 {
|
|
return false
|
|
}
|
|
|
|
dotCount := 0
|
|
digitCount := 0
|
|
for _, c := range v {
|
|
switch {
|
|
case c == '.':
|
|
dotCount++
|
|
if dotCount > 3 {
|
|
return false
|
|
}
|
|
case unicode.IsDigit(c):
|
|
digitCount++
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return digitCount > 0
|
|
}
|