mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
framework detection collapsed to a single argmax winner, so a page built from several technologies (react behind a next.js server, say) reported only one and dropped the rest. add DetectFrameworks, which keeps every detector hit above the threshold ranked most-confident first, and wire the -framework path to emit one finding per detected framework. the single-winner DetectFramework stays for callers that want just the top match; both share one fetch through the new gatherDetections helper, so the extra coverage costs no additional request. Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
302 lines
10 KiB
Go
302 lines
10 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package frameworks
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
charmlog "github.com/charmbracelet/log"
|
|
"github.com/vmfunc/sif/internal/httpx"
|
|
"github.com/vmfunc/sif/internal/logger"
|
|
"github.com/vmfunc/sif/internal/output"
|
|
)
|
|
|
|
// detectionThreshold is the minimum confidence for a detection to be reported.
|
|
const detectionThreshold = 0.5
|
|
|
|
// maxBodySize limits response body to prevent memory exhaustion.
|
|
const maxBodySize = 5 * 1024 * 1024
|
|
|
|
// detectionResult holds the result from a single detector.
|
|
type detectionResult struct {
|
|
name string
|
|
confidence float32
|
|
version string
|
|
}
|
|
|
|
// loadCustomOnce loads the user signature directory the first time a scan runs,
|
|
// so config-defined detectors join the registry without a per-target re-read.
|
|
var loadCustomOnce sync.Once
|
|
|
|
// gatherDetections fetches the target once and runs every registered detector
|
|
// against the response, returning each detector's raw confidence. it owns the
|
|
// spinner so the single- and multi-result entry points share one fetch. an
|
|
// empty result slice means no detectors were registered.
|
|
func gatherDetections(url string, timeout time.Duration) ([]detectionResult, string, error) {
|
|
spin := output.NewSpinner("Detecting frameworks")
|
|
spin.Start()
|
|
defer spin.Stop()
|
|
|
|
client := httpx.Client(timeout)
|
|
|
|
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
resp, err := client.Do(req) //nolint:bodyclose // closed via defer below
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
bodyStr := string(body)
|
|
|
|
detectors := GetDetectors()
|
|
if len(detectors) == 0 {
|
|
return nil, bodyStr, nil
|
|
}
|
|
|
|
// Run all detectors concurrently
|
|
results := make(chan detectionResult, len(detectors))
|
|
var wg sync.WaitGroup
|
|
|
|
for _, detector := range detectors {
|
|
wg.Add(1)
|
|
go func(d Detector) {
|
|
defer wg.Done()
|
|
confidence, version := d.Detect(bodyStr, resp.Header)
|
|
results <- detectionResult{
|
|
name: d.Name(),
|
|
confidence: confidence,
|
|
version: version,
|
|
}
|
|
}(detector)
|
|
}
|
|
|
|
// Close results channel when all goroutines complete
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
out := make([]detectionResult, 0, len(detectors))
|
|
for r := range results {
|
|
out = append(out, r)
|
|
}
|
|
return out, bodyStr, nil
|
|
}
|
|
|
|
// DetectFramework runs all registered detectors against the target URL and
|
|
// returns the single highest-confidence match. see DetectFrameworks for the
|
|
// full set of detected technologies.
|
|
func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) {
|
|
loadCustomOnce.Do(loadCustomDetectors)
|
|
|
|
log := output.Module("FRAMEWORK")
|
|
log.Start()
|
|
|
|
results, bodyStr, err := gatherDetections(url, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(results) == 0 {
|
|
log.Warn("No framework detectors registered")
|
|
return nil, nil //nolint:nilnil // no detectors registered is not an error
|
|
}
|
|
|
|
// Find the best match. tie-break on name so the winner is deterministic when
|
|
// two detectors land on the same confidence.
|
|
var best detectionResult
|
|
for _, r := range results {
|
|
if r.confidence > best.confidence || (r.confidence == best.confidence && r.name < best.name) {
|
|
best = r
|
|
}
|
|
}
|
|
|
|
if best.confidence <= detectionThreshold {
|
|
log.Info("No framework detected with sufficient confidence")
|
|
log.Complete(0, "detected")
|
|
return nil, nil //nolint:nilnil // no framework detected is not an error
|
|
}
|
|
|
|
result := assembleResult(best, bodyStr, url, logdir, log)
|
|
log.Complete(1, "detected")
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// DetectFrameworks runs all registered detectors and returns every framework
|
|
// that clears the detection threshold, ranked most-confident first. unlike
|
|
// DetectFramework it does not collapse to a single winner, so a page built from
|
|
// several technologies (say react behind a next.js server) reports each.
|
|
func DetectFrameworks(url string, timeout time.Duration, logdir string) ([]*FrameworkResult, error) {
|
|
loadCustomOnce.Do(loadCustomDetectors)
|
|
|
|
log := output.Module("FRAMEWORK")
|
|
log.Start()
|
|
|
|
results, bodyStr, err := gatherDetections(url, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(results) == 0 {
|
|
log.Warn("No framework detectors registered")
|
|
return nil, nil //nolint:nilnil // no detectors registered is not an error
|
|
}
|
|
|
|
// keep everything above the threshold, most-confident first with a name
|
|
// tie-break so the order is stable across runs.
|
|
detected := make([]detectionResult, 0, len(results))
|
|
for _, r := range results {
|
|
if r.confidence > detectionThreshold {
|
|
detected = append(detected, r)
|
|
}
|
|
}
|
|
sort.Slice(detected, func(i, j int) bool {
|
|
if detected[i].confidence != detected[j].confidence {
|
|
return detected[i].confidence > detected[j].confidence
|
|
}
|
|
return detected[i].name < detected[j].name
|
|
})
|
|
|
|
if len(detected) == 0 {
|
|
log.Info("No framework detected with sufficient confidence")
|
|
log.Complete(0, "detected")
|
|
return nil, nil //nolint:nilnil // no framework detected is not an error
|
|
}
|
|
|
|
out := make([]*FrameworkResult, 0, len(detected))
|
|
for i := range detected {
|
|
out = append(out, assembleResult(detected[i], bodyStr, url, logdir, log))
|
|
}
|
|
log.Complete(len(out), "detected")
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// assembleResult turns one detector hit into a full FrameworkResult: it digs out
|
|
// the concrete version, looks up CVEs for it, and logs the detection. the
|
|
// detector's own version is often "unknown" (it fingerprints the framework, not
|
|
// always the version), while ExtractVersionOptimized reads the real version from
|
|
// the body; prefer that for both the reported version and the cve lookup, else
|
|
// CVEs that only match a concrete version are silently missed.
|
|
func assembleResult(d detectionResult, bodyStr, url, logdir string, log *output.ModuleLogger) *FrameworkResult {
|
|
versionMatch := ExtractVersionOptimized(bodyStr, d.name)
|
|
version := resolveVersion(d.version, versionMatch.Version)
|
|
cves, suggestions := getVulnerabilities(d.name, version)
|
|
|
|
result := NewFrameworkResult(d.name, version, d.confidence, versionMatch.Confidence)
|
|
result.WithVulnerabilities(cves, suggestions)
|
|
|
|
if logdir != "" {
|
|
logEntry := fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f, version_confidence: %.2f)\n",
|
|
d.name, version, d.confidence, 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)
|
|
}
|
|
|
|
log.Success("Detected %s framework (version: %s, confidence: %.2f)",
|
|
output.Highlight.Render(d.name), version, d.confidence)
|
|
|
|
if versionMatch.Confidence > 0 {
|
|
charmlog.Debugf("Version detected from: %s (confidence: %.2f)",
|
|
versionMatch.Source, versionMatch.Confidence)
|
|
}
|
|
|
|
if len(cves) > 0 {
|
|
log.Warn("Risk level: %s", output.SeverityHigh.Render(result.RiskLevel))
|
|
for _, cve := range cves {
|
|
log.Warn("Found potential vulnerability: %s", output.Highlight.Render(cve))
|
|
}
|
|
for _, suggestion := range suggestions {
|
|
log.Info("Recommendation: %s", suggestion)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// unknownVersion is the sentinel both detectors and the version extractor emit
|
|
// when no concrete version could be read from the response.
|
|
const unknownVersion = "unknown"
|
|
|
|
// resolveVersion picks the version to report and look CVEs up against. the
|
|
// detector's own value wins when it's concrete; otherwise we fall back to the
|
|
// version dug out of the body by ExtractVersionOptimized. either being
|
|
// "unknown"/empty means "no info", not a real version.
|
|
func resolveVersion(detectorVersion, extractedVersion string) string {
|
|
if detectorVersion != "" && detectorVersion != unknownVersion {
|
|
return detectorVersion
|
|
}
|
|
if extractedVersion != "" && extractedVersion != unknownVersion {
|
|
return extractedVersion
|
|
}
|
|
return unknownVersion
|
|
}
|
|
|
|
// getVulnerabilities returns CVEs and recommendations for a framework version.
|
|
func getVulnerabilities(framework, version string) ([]string, []string) {
|
|
entries, exists := knownCVEs[framework]
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
|
|
var cves []string
|
|
var recommendations []string
|
|
seenRecs := make(map[string]bool)
|
|
|
|
for _, entry := range entries {
|
|
for _, affectedVer := range entry.AffectedVersions {
|
|
if versionAffected(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
|
|
}
|
|
|
|
// versionAffected reports whether version falls under an affected-version
|
|
// entry. matching is on dotted component boundaries in either direction, so
|
|
// "4.2" covers 4.2 and 4.2.1 but not 4.20, and a coarser version covers the
|
|
// listed sub-versions. the latter matters because some detectors only recover
|
|
// a bare major (Drupal's generator emits "10"); without it a bare major never
|
|
// matches a dotted affected entry and the CVE is silently missed.
|
|
func versionAffected(version, affected string) bool {
|
|
return version == affected ||
|
|
strings.HasPrefix(version, affected+".") ||
|
|
strings.HasPrefix(affected, version+".")
|
|
}
|