Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions internal/scan/frameworks/detectors/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ func (d *djangoDetector) Name() string { return "Django" }

func (d *djangoDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "csrfmiddlewaretoken", Weight: 0.4, HeaderOnly: true},
// csrfmiddlewaretoken is a hidden form BODY field Django templates
// render (`<input type="hidden" name="csrfmiddlewaretoken" ...>`),
// never a header, so this must not be HeaderOnly.
{Pattern: "csrfmiddlewaretoken", Weight: 0.4},
{Pattern: "csrftoken", Weight: 0.3, HeaderOnly: true},
{Pattern: "django.contrib", Weight: 0.3},
{Pattern: "django.core", Weight: 0.3},
Expand Down Expand Up @@ -171,9 +174,11 @@ func (d *aspnetDetector) Signatures() []fw.Signature {
{Pattern: "__VIEWSTATE", Weight: 0.4},
{Pattern: "__EVENTVALIDATION", Weight: 0.3},
{Pattern: "__VIEWSTATEGENERATOR", Weight: 0.3},
{Pattern: ".aspx", Weight: 0.2},
{Pattern: ".ashx", Weight: 0.2},
{Pattern: ".asmx", Weight: 0.2},
// .aspx/.ashx/.asmx path-extension signatures were dropped: they are
// weak (any page can link to one) and their combined weight diluted
// the canonical X-AspNet-Version/X-Powered-By headers below the
// detection threshold on a plain ASP.NET response that carries no
// body markers at all (e.g. a JSON API reply).
{Pattern: "asp.net_sessionid", Weight: 0.4, HeaderOnly: true},
}
}
Expand All @@ -185,7 +190,9 @@ func (d *aspnetDetector) Detect(body string, headers http.Header) (float32, stri

var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
// ASP.NET's strongest version signal is header-shaped
// (X-AspNet-Version), so search headers too, not just the body.
version = fw.ExtractVersionFromResponse(body, headers, d.Name()).Version
}
return confidence, version
}
Expand Down Expand Up @@ -287,7 +294,8 @@ func (d *flaskDetector) Detect(body string, headers http.Header) (float32, strin

var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
// same header-search rationale as ASP.NET above.
version = fw.ExtractVersionFromResponse(body, headers, d.Name()).Version
}
return confidence, version
}
Expand Down
4 changes: 3 additions & 1 deletion internal/scan/frameworks/detectors/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ func (d *angularDetector) Name() string { return "Angular" }

func (d *angularDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "ng-version", Weight: 0.5},
// require the attribute-assignment form, not the bare word, so prose
// discussing ng-version can't match; weighted to clear the threshold alone.
{Pattern: `ng-version="`, Weight: 1.2},
{Pattern: "ng-app", Weight: 0.4},
{Pattern: "ng-controller", Weight: 0.4},
{Pattern: "angular.js", Weight: 0.4},
Expand Down
223 changes: 223 additions & 0 deletions internal/scan/frameworks/detectors/hardening_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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

*/

// this file pins the fp/fn corpus behind the framework-detector hardening
// pass: for each fixed defect it asserts BOTH the real-product positive
// still detects and the prose/other-product negative does not.
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 detect(t *testing.T, name, body string, headers http.Header) (float32, string) {
t.Helper()
d, ok := fw.GetDetector(name)
if !ok {
t.Fatalf("detector %q not registered", name)
}
if headers == nil {
headers = http.Header{}
}
return d.Detect(body, headers)
}

// --- Django ---

func TestDjango_BodyFieldPlusCookieDetects(t *testing.T) {
body := `<form><input type="hidden" name="csrfmiddlewaretoken" value="abc"></form>`
conf, _ := detect(t, "Django", body, hdr("Set-Cookie", "csrftoken=xyz; Path=/"))
if conf <= 0.5 {
t.Errorf("real Django page (csrf body field + csrftoken cookie) confidence = %.3f, want > 0.5", conf)
}
}

func TestDjango_RandomPageNoMatch(t *testing.T) {
conf, _ := detect(t, "Django", "<html><body>hello</body></html>", http.Header{})
if conf > 0.5 {
t.Errorf("random page with no django markers confidence = %.3f, want <= 0.5", conf)
}
}

// --- ASP.NET ---

func TestASPNET_CanonicalHeadersAloneDetects(t *testing.T) {
headers := hdr(
"X-AspNet-Version", "4.0.30319",
"X-Powered-By", "ASP.NET",
"Server", "Microsoft-IIS/10.0",
)
conf, _ := detect(t, "ASP.NET", `{"status":"ok"}`, headers)
if conf <= 0.5 {
t.Errorf("ASP.NET canonical headers (no body markers) confidence = %.3f, want > 0.5", conf)
}
}

func TestASPNET_VersionExtractedFromHeader(t *testing.T) {
headers := hdr("X-AspNet-Version", "4.0.30319", "X-Powered-By", "ASP.NET")
_, version := detect(t, "ASP.NET", `{"status":"ok"}`, headers)
if version != "4.0.30319" {
t.Errorf("ASP.NET version = %q, want %q (extracted from X-AspNet-Version header)", version, "4.0.30319")
}
}

func TestASPNET_NoVersionWithoutHeader(t *testing.T) {
// body markers alone push confidence over threshold but there is no
// version anywhere in the response; must not fabricate one.
body := `<form><input type="hidden" name="__VIEWSTATE" value="x"><input type="hidden" name="__EVENTVALIDATION" value="y">` +
`<input type="hidden" name="__VIEWSTATEGENERATOR" value="z"></form>`
conf, version := detect(t, "ASP.NET", body, http.Header{})
if conf <= 0.5 {
t.Fatalf("setup: expected ASP.NET body markers to detect, confidence = %.3f", conf)
}
if version != "" && version != "unknown" {
t.Errorf("ASP.NET version = %q, want empty/unknown with no version marker present", version)
}
}

// --- Flask ---

func TestFlask_VersionExtractedFromServerHeader(t *testing.T) {
_, version := detect(t, "Flask", "<html></html>", hdr("Server", "Werkzeug/2.3.0 Python/3.11"))
if version != "2.3.0" {
t.Errorf("Flask version = %q, want %q (extracted from Werkzeug Server header)", version, "2.3.0")
}
}

// --- CakePHP ---

func TestCakePHP_CookieDetects(t *testing.T) {
conf, _ := detect(t, "CakePHP", "<html><body>Home</body></html>", hdr("Set-Cookie", "CAKEPHP=abc123; path=/"))
if conf <= 0.5 {
t.Errorf("CakePHP CAKEPHP cookie confidence = %.3f, want > 0.5", conf)
}
}

// --- Angular ---

func TestAngular_NgVersionAloneDetects(t *testing.T) {
body := `<app-root ng-version="17.3.0"></app-root>`
conf, _ := detect(t, "Angular", body, nil)
if conf <= 0.5 {
t.Errorf("Angular ng-version attribute alone confidence = %.3f, want > 0.5", conf)
}
}

func TestAngular_ProseMentionDoesNotDetect(t *testing.T) {
body := `<html><body><article>Angular automatically stamps an ng-version marker
onto the root element for diagnostics purposes; see the ng-version docs for details.</article></body></html>`
conf, _ := detect(t, "Angular", body, nil)
if conf > 0.5 {
t.Errorf("prose mention of ng-version confidence = %.3f, want <= 0.5", conf)
}
}

// --- Astro ---

func TestAstro_GeneratorMetaAloneDetects(t *testing.T) {
body := `<meta name="generator" content="Astro v4.5.0">`
conf, _ := detect(t, "Astro", body, nil)
if conf <= 0.5 {
t.Errorf("Astro generator meta alone confidence = %.3f, want > 0.5", conf)
}
}

func TestAstro_ProseMentionDoesNotDetect(t *testing.T) {
body := `<html><body><article>Astro is a popular static site generator that
many teams evaluate alongside Next.js and SvelteKit.</article></body></html>`
conf, _ := detect(t, "Astro", body, nil)
if conf > 0.5 {
t.Errorf("prose mention of Astro confidence = %.3f, want <= 0.5", conf)
}
}

// --- Next.js ---

func TestNextJS_StaticAssetAloneDetects(t *testing.T) {
body := `<html><head><link rel="stylesheet" href="/_next/static/css/abc.css">` +
`<script src="/_next/static/chunks/main.js"></script></head><body></body></html>`
conf, _ := detect(t, "Next.js", body, hdr("Server", "nginx"))
if conf <= 0.5 {
t.Errorf("self-hosted app-router Next.js (only /_next/static) confidence = %.3f, want > 0.5", conf)
}
}

func TestNextJS_ProseMentionDoesNotDetect(t *testing.T) {
body := `<html><body><p>Static assets live under /_next/static/ by Next.js convention.</p></body></html>`
conf, _ := detect(t, "Next.js", body, nil)
if conf > 0.5 {
t.Errorf("prose mention of /_next/static/ confidence = %.3f, want <= 0.5", conf)
}
}

// --- Full-corpus sweep ---

func TestSweep_GenericPagesNoFire(t *testing.T) {
cases := []struct {
name string
body string
hdr http.Header
}{
{
name: "plain nginx welcome",
body: `<html><head><title>Welcome to nginx!</title></head><body><h1>Welcome to nginx!</h1><p>If you see this page, the nginx web server is successfully installed.</p></body></html>`,
hdr: hdr("Server", "nginx/1.24.0", "Content-Type", "text/html"),
},
{
name: "apache php site",
body: `<html><body><h1>My PHP Site</h1><form method="post"><input name="q"></form></body></html>`,
hdr: hdr("Server", "Apache/2.4.57", "X-Powered-By", "PHP/8.2.0", "Set-Cookie", "PHPSESSID=abc; path=/"),
},
{
name: "tech blog article listing frameworks",
body: `<html><body><article>2026 framework roundup: we cover Joomla, Magento, Drupal, WordPress, Ghost, Symfony, Meteor, and Angular in depth.</article></body></html>`,
hdr: hdr("Server", "nginx"),
},
{
name: "generic java tomcat app",
body: `<html><body>Login</body></html>`,
hdr: hdr("Server", "Apache-Coyote/1.1", "Set-Cookie", "JSESSIONID=1A2B3C; Path=/; HttpOnly"),
},
}

dets := fw.GetDetectors()
for _, c := range cases {
headers := c.hdr
if headers == nil {
headers = http.Header{}
}
for name, d := range dets {
conf, ver := d.Detect(c.body, headers)
if conf > 0.5 {
t.Errorf("%s: detector %q false-fired confidence=%.4f version=%q", c.name, name, conf, ver)
}
}
}
}
6 changes: 4 additions & 2 deletions internal/scan/frameworks/detectors/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ func (d *nextjsDetector) Name() string { return "Next.js" }
func (d *nextjsDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__NEXT_DATA__", Weight: 0.5},
{Pattern: "_next/static", Weight: 0.4},
// same attribute-form-vs-prose rationale as Angular's ng-version marker.
{Pattern: `="/_next/static/`, Weight: 0.6},
{Pattern: "__next", Weight: 0.3},
{Pattern: "x-nextjs", Weight: 0.3, HeaderOnly: true},
}
Expand Down Expand Up @@ -166,7 +167,8 @@ func (d *astroDetector) Name() string { return "Astro" }

func (d *astroDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: `<meta name="generator" content="Astro`, Weight: 0.5},
// definitive quote-anchored marker; same threshold rationale as Next.js above.
{Pattern: `<meta name="generator" content="Astro`, Weight: 1.1},
{Pattern: "astro-island", Weight: 0.5},
{Pattern: "data-astro-cid-", Weight: 0.4},
{Pattern: "/_astro/", Weight: 0.4},
Expand Down
46 changes: 41 additions & 5 deletions internal/scan/frameworks/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
package frameworks

import (
"net/http"
"regexp"
"strings"
"unicode"
)

Expand Down Expand Up @@ -56,9 +58,13 @@ func init() {
{`"express":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"ASP.NET": {
{`X-AspNet-Version:\s*(\d+\.\d+(?:\.\d+)?)`, 0.95, "header"},
// header names are matched case-insensitively: Go's http.Header
// canonicalizes "X-AspNet-Version" to "X-Aspnet-Version", so a
// case-sensitive literal here would never match the header line
// built by headerSearchText.
{`(?i: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"},
{`(?i:X-AspNetMvc-Version):\s*(\d+\.\d+(?:\.\d+)?)`, 0.9, "MVC header"},
},
"ASP.NET Core": {
{`\.NET\s*(\d+\.\d+(?:\.\d+)?)`, 0.8, "dotnet version"},
Expand Down Expand Up @@ -159,17 +165,47 @@ func init() {
}
}

// ExtractVersionOptimized extracts version using pre-compiled patterns.
// This is exported for use by individual detector implementations.
// ExtractVersionOptimized extracts version using pre-compiled patterns,
// searching only the response body. This is exported for use by individual
// detector implementations.
func ExtractVersionOptimized(body string, framework string) VersionMatch {
return extractVersion(body, framework)
}

// ExtractVersionFromResponse is like ExtractVersionOptimized but also searches
// canonical header lines, so header-shaped patterns (e.g. ASP.NET's
// X-AspNet-Version) can match; use it for detectors with header-shaped patterns.
func ExtractVersionFromResponse(body string, headers http.Header, framework string) VersionMatch {
return extractVersion(body+"\n"+headerSearchText(headers), framework)
}

// headerSearchText renders headers as canonical "Name: value" lines, one per
// value, so version regexes written against raw header text (e.g.
// "X-AspNet-Version: 4.0.30319") have something to match against.
func headerSearchText(headers http.Header) string {
var b strings.Builder
for name, values := range headers {
for _, v := range values {
b.WriteString(name)
b.WriteString(": ")
b.WriteString(v)
b.WriteString("\n")
}
}
return b.String()
}

// extractVersion runs every pattern registered for framework against text and
// keeps the highest-confidence valid match.
func extractVersion(text 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)
matches := p.re.FindStringSubmatch(text)
if len(matches) > 1 && p.confidence > bestMatch.Confidence {
candidate := matches[1]
if isValidVersionString(candidate) {
Expand Down
Loading