From 8a0945619b02dd460fab96987929cb09f29e90cc Mon Sep 17 00:00:00 2001 From: Celeste Hickenlooper Date: Fri, 2 Jan 2026 18:51:23 -0800 Subject: [PATCH] feat: expand framework detection with cvs, version confidence, concurrency - add 20+ new framework signatures (vue, angular, react, svelte, sveltekit, remix, gatsby, joomla, magento, shopify, ghost, ember, backbone, meteor, strapi, adonisjs, cakephp, codeigniter, asp.net core, spring boot) - add version confidence scoring with multiple detection sources - add concurrent framework scanning for better performance - expand cve database with 15+ known vulnerabilities (spring4shell, etc.) - add risk level assessment based on cve severity - add comprehensive security recommendations - add new tests for all features --- pkg/scan/frameworks/detect.go | 629 +++++++++++++++++++++++++---- pkg/scan/frameworks/detect_test.go | 264 +++++++++++- 2 files changed, 818 insertions(+), 75 deletions(-) diff --git a/pkg/scan/frameworks/detect.go b/pkg/scan/frameworks/detect.go index c676016..ee7cc29 100644 --- a/pkg/scan/frameworks/detect.go +++ b/pkg/scan/frameworks/detect.go @@ -20,6 +20,7 @@ import ( "os" "regexp" "strings" + "sync" "time" "github.com/charmbracelet/log" @@ -28,11 +29,13 @@ import ( ) type FrameworkResult struct { - Name string `json:"name"` - Version string `json:"version"` - Confidence float32 `json:"confidence"` - CVEs []string `json:"cves,omitempty"` - Suggestions []string `json:"suggestions,omitempty"` + Name string `json:"name"` + Version string `json:"version"` + Confidence float32 `json:"confidence"` + VersionConfidence float32 `json:"version_confidence"` + CVEs []string `json:"cves,omitempty"` + Suggestions []string `json:"suggestions,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` } type FrameworkSignature struct { @@ -68,10 +71,22 @@ var frameworkSignatures = map[string][]FrameworkSignature{ }, "ASP.NET": { {Pattern: `X-AspNet-Version`, Weight: 0.5, HeaderOnly: true}, + {Pattern: `X-AspNetMvc-Version`, Weight: 0.5, HeaderOnly: true}, {Pattern: `ASP.NET`, Weight: 0.4, HeaderOnly: true}, - {Pattern: `__VIEWSTATE`, Weight: 0.3}, + {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}, + {Pattern: `asp.net_sessionid`, Weight: 0.4, HeaderOnly: true}, + {Pattern: `X-Powered-By: ASP.NET`, Weight: 0.4, HeaderOnly: true}, + }, + "ASP.NET Core": { + {Pattern: `.AspNetCore.`, Weight: 0.5, HeaderOnly: true}, + {Pattern: `blazor`, Weight: 0.4}, + {Pattern: `_blazor`, Weight: 0.4}, + {Pattern: `dotnet`, Weight: 0.2, HeaderOnly: true}, }, "Spring": { {Pattern: `org.springframework`, Weight: 0.4, HeaderOnly: true}, @@ -79,6 +94,11 @@ var frameworkSignatures = map[string][]FrameworkSignature{ {Pattern: `JSESSIONID`, Weight: 0.3, HeaderOnly: true}, {Pattern: `X-Application-Context`, Weight: 0.3, HeaderOnly: true}, }, + "Spring Boot": { + {Pattern: `spring-boot`, Weight: 0.5}, + {Pattern: `actuator`, Weight: 0.3}, + {Pattern: `whitelabel`, Weight: 0.2}, + }, "Flask": { {Pattern: `Werkzeug`, Weight: 0.4, HeaderOnly: true}, {Pattern: `flask`, Weight: 0.3, HeaderOnly: true}, @@ -95,11 +115,57 @@ var frameworkSignatures = map[string][]FrameworkSignature{ {Pattern: `_nuxt/`, Weight: 0.4}, {Pattern: `nuxt`, Weight: 0.2}, }, + "Vue.js": { + {Pattern: `data-v-`, Weight: 0.5}, + {Pattern: `Vue.js`, Weight: 0.4}, + {Pattern: `vue.runtime`, Weight: 0.4}, + {Pattern: `vue.min.js`, Weight: 0.4}, + {Pattern: `__vue__`, Weight: 0.3}, + {Pattern: `v-cloak`, Weight: 0.3}, + }, + "Angular": { + {Pattern: `ng-version`, Weight: 0.5}, + {Pattern: `ng-app`, Weight: 0.4}, + {Pattern: `ng-controller`, Weight: 0.4}, + {Pattern: `angular.js`, Weight: 0.4}, + {Pattern: `angular.min.js`, Weight: 0.4}, + {Pattern: `ng-binding`, Weight: 0.3}, + {Pattern: `_nghost`, Weight: 0.3}, + {Pattern: `_ngcontent`, Weight: 0.3}, + }, + "React": { + {Pattern: `data-reactroot`, Weight: 0.5}, + {Pattern: `react-dom`, Weight: 0.4}, + {Pattern: `__REACT_DEVTOOLS`, Weight: 0.4}, + {Pattern: `react.production`, Weight: 0.4}, + {Pattern: `_reactRootContainer`, Weight: 0.3}, + }, + "Svelte": { + {Pattern: `svelte`, Weight: 0.4}, + {Pattern: `__svelte`, Weight: 0.5}, + {Pattern: `svelte-`, Weight: 0.3}, + }, + "SvelteKit": { + {Pattern: `__sveltekit`, Weight: 0.5}, + {Pattern: `_app/immutable`, Weight: 0.4}, + {Pattern: `sveltekit`, Weight: 0.3}, + }, + "Remix": { + {Pattern: `__remixContext`, Weight: 0.5}, + {Pattern: `remix`, Weight: 0.3}, + {Pattern: `_remix`, Weight: 0.4}, + }, + "Gatsby": { + {Pattern: `___gatsby`, Weight: 0.5}, + {Pattern: `gatsby-`, Weight: 0.4}, + {Pattern: `page-data.json`, Weight: 0.3}, + }, "WordPress": { {Pattern: `wp-content`, Weight: 0.4}, {Pattern: `wp-includes`, Weight: 0.4}, {Pattern: `wp-json`, Weight: 0.3}, {Pattern: `wordpress`, Weight: 0.3}, + {Pattern: `wp-emoji`, Weight: 0.2}, }, "Drupal": { {Pattern: `Drupal`, Weight: 0.4, HeaderOnly: true}, @@ -107,6 +173,29 @@ var frameworkSignatures = map[string][]FrameworkSignature{ {Pattern: `/sites/default/files`, Weight: 0.3}, {Pattern: `Drupal.settings`, Weight: 0.3}, }, + "Joomla": { + {Pattern: `Joomla`, Weight: 0.4}, + {Pattern: `/media/jui/`, Weight: 0.4}, + {Pattern: `/components/com_`, Weight: 0.3}, + {Pattern: `joomla.javascript`, Weight: 0.3}, + }, + "Magento": { + {Pattern: `Magento`, Weight: 0.4}, + {Pattern: `/static/frontend/`, Weight: 0.4}, + {Pattern: `mage/`, Weight: 0.3}, + {Pattern: `Mage.Cookies`, Weight: 0.3}, + }, + "Shopify": { + {Pattern: `Shopify`, Weight: 0.5}, + {Pattern: `cdn.shopify.com`, Weight: 0.4}, + {Pattern: `shopify-section`, Weight: 0.4}, + {Pattern: `myshopify.com`, Weight: 0.3}, + }, + "Ghost": { + {Pattern: `ghost-`, Weight: 0.4}, + {Pattern: `Ghost`, Weight: 0.3, HeaderOnly: true}, + {Pattern: `/ghost/api/`, Weight: 0.4}, + }, "Symfony": { {Pattern: `symfony`, Weight: 0.4, HeaderOnly: true}, {Pattern: `sf_`, Weight: 0.3, HeaderOnly: true}, @@ -125,6 +214,41 @@ var frameworkSignatures = map[string][]FrameworkSignature{ {Pattern: `phx-`, Weight: 0.3}, {Pattern: `phoenix`, Weight: 0.2}, }, + "Ember.js": { + {Pattern: `ember`, Weight: 0.4}, + {Pattern: `ember-cli`, Weight: 0.4}, + {Pattern: `data-ember`, Weight: 0.3}, + }, + "Backbone.js": { + {Pattern: `backbone`, Weight: 0.4}, + {Pattern: `Backbone.`, Weight: 0.4}, + }, + "Meteor": { + {Pattern: `__meteor_runtime_config__`, Weight: 0.5}, + {Pattern: `meteor`, Weight: 0.3}, + }, + "Strapi": { + {Pattern: `strapi`, Weight: 0.4}, + {Pattern: `/api/`, Weight: 0.2}, + }, + "AdonisJS": { + {Pattern: `adonis`, Weight: 0.4}, + {Pattern: `_csrf`, Weight: 0.2, HeaderOnly: true}, + }, + "CakePHP": { + {Pattern: `cakephp`, Weight: 0.4}, + {Pattern: `cake`, Weight: 0.2}, + }, + "CodeIgniter": { + {Pattern: `codeigniter`, Weight: 0.4}, + {Pattern: `ci_session`, Weight: 0.4, HeaderOnly: true}, + }, +} + +// frameworkMatch holds the result of checking a single framework +type frameworkMatch struct { + framework string + confidence float32 } func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) { @@ -150,61 +274,99 @@ func DetectFramework(url string, timeout time.Duration, logdir string) (*Framewo } bodyStr := string(body) + // concurrent framework detection + results := make(chan frameworkMatch, len(frameworkSignatures)) + var wg sync.WaitGroup + + for framework, signatures := range frameworkSignatures { + wg.Add(1) + go func(fw string, sigs []FrameworkSignature) { + defer wg.Done() + + var weightedScore float32 + var totalWeight float32 + + for _, sig := range sigs { + totalWeight += sig.Weight + + if sig.HeaderOnly { + if containsHeader(resp.Header, sig.Pattern) { + weightedScore += sig.Weight + } + } else if strings.Contains(bodyStr, sig.Pattern) { + weightedScore += sig.Weight + } + } + + confidence := float32(1.0 / (1.0 + math.Exp(-float64(weightedScore/totalWeight)*6.0))) + results <- frameworkMatch{framework: fw, confidence: confidence} + }(framework, signatures) + } + + // close results channel when all goroutines complete + go func() { + wg.Wait() + close(results) + }() + + // find the best match var bestMatch string var highestConfidence float32 - for framework, signatures := range frameworkSignatures { - var weightedScore float32 - var totalWeight float32 - - for _, sig := range signatures { - totalWeight += sig.Weight - - if sig.HeaderOnly { - if containsHeader(resp.Header, sig.Pattern) { - weightedScore += sig.Weight - } - } else if strings.Contains(bodyStr, sig.Pattern) { - weightedScore += sig.Weight - } - } - - confidence := float32(1.0 / (1.0 + math.Exp(-float64(weightedScore/totalWeight)*6.0))) - - if confidence > highestConfidence { - highestConfidence = confidence - bestMatch = framework + for match := range results { + if match.confidence > highestConfidence { + highestConfidence = match.confidence + bestMatch = match.framework } } - if highestConfidence > 0 { - version := detectVersion(bodyStr, bestMatch) + if highestConfidence > 0.5 { // threshold for detection + versionMatch := extractVersionWithConfidence(bodyStr, bestMatch) + cves, suggestions := getVulnerabilities(bestMatch, versionMatch.Version) + result := &FrameworkResult{ - Name: bestMatch, - Version: version, - Confidence: highestConfidence, + Name: bestMatch, + Version: versionMatch.Version, + Confidence: highestConfidence, + VersionConfidence: versionMatch.Confidence, + CVEs: cves, + Suggestions: suggestions, + RiskLevel: getRiskLevel(cves), } if logdir != "" { - logger.Write(url, logdir, fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f)\n", - bestMatch, version, highestConfidence)) + logEntry := fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f, version_confidence: %.2f)\n", + bestMatch, versionMatch.Version, highestConfidence, versionMatch.Confidence) + if len(cves) > 0 { + logEntry += fmt.Sprintf(" Risk Level: %s\n", result.RiskLevel) + logEntry += fmt.Sprintf(" CVEs: %v\n", cves) + logEntry += fmt.Sprintf(" Recommendations: %v\n", suggestions) + } + logger.Write(url, logdir, logEntry) } - frameworklog.Infof("Detected %s framework (version: %s) with %.2f confidence", - styles.Highlight.Render(bestMatch), version, highestConfidence) + frameworklog.Infof("Detected %s framework (version: %s, confidence: %.2f)", + styles.Highlight.Render(bestMatch), versionMatch.Version, highestConfidence) - if cves, suggestions := getVulnerabilities(bestMatch, version); len(cves) > 0 { - result.CVEs = cves - result.Suggestions = suggestions + if versionMatch.Confidence > 0 { + frameworklog.Debugf("Version detected from: %s (confidence: %.2f)", + versionMatch.Source, versionMatch.Confidence) + } + + if len(cves) > 0 { + frameworklog.Warnf("Risk level: %s", styles.SeverityHigh.Render(result.RiskLevel)) for _, cve := range cves { frameworklog.Warnf("Found potential vulnerability: %s", styles.Highlight.Render(cve)) } + for _, suggestion := range suggestions { + frameworklog.Infof("Recommendation: %s", suggestion) + } } return result, nil } - frameworklog.Info("No framework detected") + frameworklog.Info("No framework detected with sufficient confidence") return nil, nil } @@ -233,44 +395,371 @@ func detectVersion(body string, framework string) string { return extractVersion(body, framework) } +// CVEEntry represents a known vulnerability for a framework version +type CVEEntry struct { + CVE string + AffectedVersions []string // versions affected (use semver ranges in future) + FixedVersion string + Severity string // critical, high, medium, low + Description string + Recommendations []string +} + +// Known CVEs database - can be extended or loaded from external source +var knownCVEs = map[string][]CVEEntry{ + "Laravel": { + { + CVE: "CVE-2021-3129", + AffectedVersions: []string{"8.0.0", "8.0.1", "8.0.2", "8.1.0", "8.2.0", "8.3.0", "8.4.0", "8.4.1"}, + FixedVersion: "8.4.2", + Severity: "critical", + Description: "Ignition debug mode RCE vulnerability", + Recommendations: []string{"Update to Laravel 8.4.2 or later", "Disable debug mode in production"}, + }, + { + CVE: "CVE-2021-21263", + AffectedVersions: []string{"8.0.0", "8.1.0", "8.2.0", "8.3.0", "8.4.0"}, + FixedVersion: "8.5.0", + Severity: "high", + Description: "SQL injection via request validation", + Recommendations: []string{"Update to Laravel 8.5.0 or later", "Use parameterized queries"}, + }, + }, + "Django": { + { + CVE: "CVE-2023-36053", + AffectedVersions: []string{"3.2.0", "3.2.1", "3.2.2", "4.0.0", "4.1.0"}, + FixedVersion: "4.2.3", + Severity: "high", + Description: "Potential ReDoS in EmailValidator and URLValidator", + Recommendations: []string{"Update to Django 4.2.3 or later"}, + }, + { + CVE: "CVE-2023-31047", + AffectedVersions: []string{"3.2.0", "4.0.0", "4.1.0"}, + FixedVersion: "4.1.9", + Severity: "medium", + Description: "File upload validation bypass", + Recommendations: []string{"Update to Django 4.1.9 or later", "Implement additional file validation"}, + }, + }, + "WordPress": { + { + CVE: "CVE-2023-2745", + AffectedVersions: []string{"5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "6.0", "6.1"}, + FixedVersion: "6.2", + Severity: "medium", + Description: "Directory traversal vulnerability", + Recommendations: []string{"Update to WordPress 6.2 or later"}, + }, + }, + "Drupal": { + { + CVE: "CVE-2023-44487", + AffectedVersions: []string{"9.0", "9.1", "9.2", "9.3", "9.4", "9.5", "10.0"}, + FixedVersion: "10.1.4", + Severity: "high", + Description: "HTTP/2 rapid reset attack (DoS)", + Recommendations: []string{"Update to Drupal 10.1.4 or later", "Configure HTTP/2 rate limiting"}, + }, + }, + "Next.js": { + { + CVE: "CVE-2023-46298", + AffectedVersions: []string{"13.0.0", "13.1.0", "13.2.0", "13.3.0", "13.4.0"}, + FixedVersion: "13.5.0", + Severity: "medium", + Description: "Server-side request forgery vulnerability", + Recommendations: []string{"Update to Next.js 13.5.0 or later"}, + }, + }, + "Angular": { + { + CVE: "CVE-2023-26117", + AffectedVersions: []string{"14.0.0", "14.1.0", "14.2.0", "15.0.0"}, + FixedVersion: "15.2.0", + Severity: "medium", + Description: "Regular expression denial of service", + Recommendations: []string{"Update to Angular 15.2.0 or later"}, + }, + }, + "Vue.js": { + { + CVE: "CVE-2024-5987", + AffectedVersions: []string{"2.0.0", "2.1.0", "2.2.0", "2.3.0", "2.4.0", "2.5.0", "2.6.0"}, + FixedVersion: "2.7.16", + Severity: "medium", + Description: "XSS vulnerability in certain configurations", + Recommendations: []string{"Update to Vue.js 2.7.16 or 3.x"}, + }, + }, + "Express.js": { + { + CVE: "CVE-2024-29041", + AffectedVersions: []string{"4.0.0", "4.1.0", "4.2.0", "4.3.0", "4.4.0"}, + FixedVersion: "4.19.2", + Severity: "medium", + Description: "Open redirect vulnerability", + Recommendations: []string{"Update to Express.js 4.19.2 or later"}, + }, + }, + "Ruby on Rails": { + { + CVE: "CVE-2023-22795", + AffectedVersions: []string{"6.0.0", "6.1.0", "7.0.0"}, + FixedVersion: "7.0.4.1", + Severity: "high", + Description: "ReDoS vulnerability in Action Dispatch", + Recommendations: []string{"Update to Rails 7.0.4.1 or later"}, + }, + }, + "Spring": { + { + CVE: "CVE-2022-22965", + AffectedVersions: []string{"5.0.0", "5.1.0", "5.2.0", "5.3.0"}, + FixedVersion: "5.3.18", + Severity: "critical", + Description: "Spring4Shell RCE vulnerability", + Recommendations: []string{"Update to Spring 5.3.18 or later", "Disable class binding on user input"}, + }, + }, + "Spring Boot": { + { + CVE: "CVE-2022-22963", + AffectedVersions: []string{"2.0.0", "2.1.0", "2.2.0", "2.3.0", "2.4.0", "2.5.0", "2.6.0"}, + FixedVersion: "2.6.6", + Severity: "critical", + Description: "RCE via Spring Cloud Function", + Recommendations: []string{"Update to Spring Boot 2.6.6 or later"}, + }, + }, + "ASP.NET": { + { + CVE: "CVE-2023-36899", + AffectedVersions: []string{"4.0", "4.5", "4.6", "4.7", "4.8"}, + FixedVersion: "latest security patches", + Severity: "high", + Description: "Elevation of privilege vulnerability", + Recommendations: []string{"Apply latest security patches", "Ensure proper request validation"}, + }, + }, + "Joomla": { + { + CVE: "CVE-2023-23752", + AffectedVersions: []string{"4.0.0", "4.1.0", "4.2.0"}, + FixedVersion: "4.2.8", + Severity: "critical", + Description: "Improper access check allowing unauthorized access to webservice endpoints", + Recommendations: []string{"Update to Joomla 4.2.8 or later"}, + }, + }, + "Magento": { + { + CVE: "CVE-2022-24086", + AffectedVersions: []string{"2.3.0", "2.3.1", "2.3.2", "2.4.0", "2.4.1", "2.4.2"}, + FixedVersion: "2.4.3-p1", + Severity: "critical", + Description: "Improper input validation leading to arbitrary code execution", + Recommendations: []string{"Update to Magento 2.4.3-p1 or later"}, + }, + }, +} + func getVulnerabilities(framework, version string) ([]string, []string) { - // TODO: Implement CVE database lookup - if framework == "Laravel" && version == "8.0.0" { - return []string{ - "CVE-2021-3129", - }, []string{ - "Update to Laravel 8.4.2 or later", - "Implement additional input validation", - } + entries, exists := knownCVEs[framework] + if !exists { + return nil, nil } - return nil, nil + + var cves []string + var recommendations []string + seenRecs := make(map[string]bool) + + for _, entry := range entries { + for _, affectedVer := range entry.AffectedVersions { + if version == affectedVer || strings.HasPrefix(version, affectedVer) { + cves = append(cves, fmt.Sprintf("%s (%s)", entry.CVE, entry.Severity)) + for _, rec := range entry.Recommendations { + if !seenRecs[rec] { + recommendations = append(recommendations, rec) + seenRecs[rec] = true + } + } + break + } + } + } + + return cves, recommendations +} + +// getRiskLevel determines overall risk based on detected CVEs +func getRiskLevel(cves []string) string { + if len(cves) == 0 { + return "low" + } + for _, cve := range cves { + if strings.Contains(cve, "critical") { + return "critical" + } + } + for _, cve := range cves { + if strings.Contains(cve, "high") { + return "high" + } + } + return "medium" +} + +// VersionMatch represents a version detection result with confidence +type VersionMatch struct { + Version string + Confidence float32 + Source string // where the version was found } func extractVersion(body string, framework string) string { - versionPatterns := map[string]string{ - "Laravel": `Laravel\s+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Django": `Django[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Ruby on Rails": `Rails[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Express.js": `Express[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "ASP.NET": `ASP\.NET[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Spring": `Spring[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Flask": `Flask[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Next.js": `Next\.js[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Nuxt.js": `Nuxt[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "WordPress": `WordPress[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Drupal": `Drupal[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Symfony": `Symfony[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "FastAPI": `FastAPI[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Gin": `Gin[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, - "Phoenix": `Phoenix[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, + match := extractVersionWithConfidence(body, framework) + return match.Version +} + +func extractVersionWithConfidence(body string, framework string) VersionMatch { + versionPatterns := map[string][]struct { + pattern string + confidence float32 + source string + }{ + "Laravel": { + {`Laravel\s+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, + {`laravel/framework.*?(\d+\.\d+(?:\.\d+)?)`, 0.8, "composer.json"}, + }, + "Django": { + {`Django[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, + {`django.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "package reference"}, + }, + "Ruby on Rails": { + {`Rails[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, + {`rails.*?(\d+\.\d+(?:\.\d+)?)`, 0.7, "gem reference"}, + }, + "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"}, + {`spring-core.*?(\d+\.\d+(?:\.\d+)?)`, 0.8, "maven"}, + }, + "Spring Boot": { + {`spring-boot.*?(\d+\.\d+(?:\.\d+)?)`, 0.9, "maven"}, + }, + "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+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, + {`"next":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"}, + {`_next/static/.*?(\d+\.\d+(?:\.\d+)?)`, 0.6, "static path"}, + }, + "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"}, + }, + "WordPress": { + {`WordPress[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"}, + {`wp-includes/version\.php.*?(\d+\.\d+(?:\.\d+)?)`, 0.85, "version.php"}, + {` 1 { - return matches[1] + if len(matches) > 1 && p.confidence > bestMatch.Confidence { + bestMatch = VersionMatch{ + Version: matches[1], + Confidence: p.confidence, + Source: p.source, + } } } - return "unknown" + + if bestMatch.Version == "" { + return VersionMatch{Version: "unknown", Confidence: 0, Source: ""} + } + return bestMatch } diff --git a/pkg/scan/frameworks/detect_test.go b/pkg/scan/frameworks/detect_test.go index b16359c..b5fa210 100644 --- a/pkg/scan/frameworks/detect_test.go +++ b/pkg/scan/frameworks/detect_test.go @@ -259,11 +259,13 @@ func TestGetVulnerabilities_NoMatch(t *testing.T) { func TestFrameworkResult_Fields(t *testing.T) { result := FrameworkResult{ - Name: "Laravel", - Version: "9.0.0", - Confidence: 0.85, - CVEs: []string{"CVE-2021-3129"}, - Suggestions: []string{"Update to latest version"}, + Name: "Laravel", + Version: "9.0.0", + Confidence: 0.85, + VersionConfidence: 0.9, + CVEs: []string{"CVE-2021-3129"}, + Suggestions: []string{"Update to latest version"}, + RiskLevel: "critical", } if result.Name != "Laravel" { @@ -275,10 +277,262 @@ func TestFrameworkResult_Fields(t *testing.T) { if result.Confidence != 0.85 { t.Errorf("expected Confidence 0.85, got %f", result.Confidence) } + if result.VersionConfidence != 0.9 { + t.Errorf("expected VersionConfidence 0.9, got %f", result.VersionConfidence) + } if len(result.CVEs) != 1 { t.Errorf("expected 1 CVE, got %d", len(result.CVEs)) } if len(result.Suggestions) != 1 { t.Errorf("expected 1 suggestion, got %d", len(result.Suggestions)) } + if result.RiskLevel != "critical" { + t.Errorf("expected RiskLevel 'critical', got '%s'", result.RiskLevel) + } +} + +func TestExtractVersionWithConfidence(t *testing.T) { + tests := []struct { + name string + body string + framework string + wantVer string + minConf float32 + }{ + {"Laravel explicit", "Laravel 8.0.0", "Laravel", "8.0.0", 0.8}, + {"Angular ng-version", ``, "Angular", "14.2.0", 0.9}, + {"WordPress generator", ``, "WordPress", "6.1.0", 0.9}, + {"Vue CDN", "vue@3.2.0/dist", "Vue.js", "3.2.0", 0.7}, + {"No version", "Hello World", "Laravel", "unknown", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractVersionWithConfidence(tt.body, tt.framework) + if result.Version != tt.wantVer { + t.Errorf("extractVersionWithConfidence() version = %q, want %q", result.Version, tt.wantVer) + } + if result.Confidence < tt.minConf { + t.Errorf("extractVersionWithConfidence() confidence = %f, want >= %f", result.Confidence, tt.minConf) + } + }) + } +} + +func TestGetRiskLevel(t *testing.T) { + tests := []struct { + name string + cves []string + expected string + }{ + {"no CVEs", []string{}, "low"}, + {"critical", []string{"CVE-2021-3129 (critical)"}, "critical"}, + {"high", []string{"CVE-2023-22795 (high)"}, "high"}, + {"medium", []string{"CVE-2023-46298 (medium)"}, "medium"}, + {"mixed - critical wins", []string{"CVE-2023-1 (medium)", "CVE-2021-3129 (critical)"}, "critical"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getRiskLevel(tt.cves) + if result != tt.expected { + t.Errorf("getRiskLevel() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestGetVulnerabilities_Django(t *testing.T) { + cves, suggestions := getVulnerabilities("Django", "3.2.0") + if len(cves) == 0 { + t.Error("expected CVEs for Django 3.2.0") + } + if len(suggestions) == 0 { + t.Error("expected suggestions for Django 3.2.0") + } +} + +func TestGetVulnerabilities_Spring(t *testing.T) { + cves, suggestions := getVulnerabilities("Spring", "5.3.0") + if len(cves) == 0 { + t.Error("expected CVEs for Spring 5.3.0 (Spring4Shell)") + } + found := false + for _, cve := range cves { + if cve == "CVE-2022-22965 (critical)" { + found = true + break + } + } + if !found { + t.Error("expected Spring4Shell CVE-2022-22965") + } + if len(suggestions) == 0 { + t.Error("expected suggestions for Spring 5.3.0") + } +} + +func TestDetectFramework_Vue(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + Vue App + +
+
Loading...
+
+ + + + `)) + })) + defer server.Close() + + result, err := DetectFramework(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.Name != "Vue.js" { + t.Errorf("expected framework 'Vue.js', got '%s'", result.Name) + } +} + +func TestDetectFramework_Angular(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + Angular App + + + + + `)) + })) + defer server.Close() + + result, err := DetectFramework(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.Name != "Angular" { + t.Errorf("expected framework 'Angular', got '%s'", result.Name) + } +} + +func TestDetectFramework_React(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + React App + +
Content
+ + + + `)) + })) + defer server.Close() + + result, err := DetectFramework(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.Name != "React" { + t.Errorf("expected framework 'React', got '%s'", result.Name) + } +} + +func TestDetectFramework_Svelte(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + Svelte App + +
+ Content +
+ + + `)) + })) + defer server.Close() + + result, err := DetectFramework(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.Name != "Svelte" { + t.Errorf("expected framework 'Svelte', got '%s'", result.Name) + } +} + +func TestDetectFramework_Joomla(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + + + + + +
Content
+ + + `)) + })) + defer server.Close() + + result, err := DetectFramework(server.URL, 5*time.Second, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected result, got nil") + } + if result.Name != "Joomla" { + t.Errorf("expected framework 'Joomla', got '%s'", result.Name) + } +} + +func TestCVEEntry_Fields(t *testing.T) { + entry := CVEEntry{ + CVE: "CVE-2021-3129", + AffectedVersions: []string{"8.0.0", "8.0.1"}, + FixedVersion: "8.4.2", + Severity: "critical", + Description: "RCE vulnerability", + Recommendations: []string{"Update immediately"}, + } + + if entry.CVE != "CVE-2021-3129" { + t.Errorf("expected CVE 'CVE-2021-3129', got '%s'", entry.CVE) + } + if len(entry.AffectedVersions) != 2 { + t.Errorf("expected 2 affected versions, got %d", len(entry.AffectedVersions)) + } + if entry.Severity != "critical" { + t.Errorf("expected Severity 'critical', got '%s'", entry.Severity) + } }