Files
sif/internal/scan/frameworks/version.go
T
Tigah 6575c2e5f7 fix(frameworks): stop false positives and version mis-extraction (#247)
a detector accuracy audit surfaced two classes of bug in the framework
detectors.

bare-brand header false positives: header-only signatures matched a
brand name as a substring across every header name and value, so a
detector fired on any response that merely referenced the brand (a
vendor cdn named in a link or csp value, a cookie sharing the prefix).
add an optional Header field to Signature that scopes a header-only
match to one named header's value, and apply it (or a structural
anchor) per detector:

- express: "Express" scoped to x-powered-by, was firing on an
  express_checkout cookie.
- flask: "Werkzeug" scoped to the server header.
- symfony: dropped the bare "symfony" word (symfony sets no such
  header, it fired on symfony.com links); the x-debug-token header is
  the marker.
- shopify: key on the x-shopify response headers instead of the bare
  "Shopify" word, which fired on a cdn.shopify.com link.
- remix: dropped the bare "remix"/"_remix" substrings that fired on a
  track_remix.mp3 asset; window.__remixContext is the definitive
  marker.
- spring boot: anchor the whitelabel title in its h1 tag context so a
  tutorial discussing the error does not fire.

the gin and fastapi detectors are removed: gin keyed on the
"gin-gonic" import-path string (appears in tutorials, never in a real
gin response) and fastapi on bare words matching the projects' doc
domains. neither framework advertises itself in a response header or a
non-prose body marker, so there is no clean passive signal to anchor
on.

version mis-extraction: drop the low-confidence ".*?" version
fallbacks (rails, django, laravel, spring), whose unbounded gap
grabbed the first version-shaped number after the framework word and
reported an unrelated asset's cache-buster when no real version was
present. let isValidVersionString accept a single integer so a bare
major such as drupal's "Drupal 10" is no longer rejected as "unknown".

each false positive and version bug is covered by a regression test.
2026-07-02 12:55:34 -07:00

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
}