mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -07:00
d0bdcf1690
every scanner spun up its own &http.Client, so there was no single place to apply a proxy, custom headers, a cookie or a rate limit. add an internal/httpx package that builds one configured transport at startup and hand it to every scanner via httpx.Client(timeout), keeping behavior identical when nothing is set (plain client when Configure was never called). - httpx.Configure wires -proxy (http/https/socks5), -H/--header, -cookie and -rate-limit into a package-level RoundTripper that paces via a rate.Limiter and only sets headers the caller hasn't already, so a scanner's explicit api key still wins. - route the scan/wordlist downloads that used http.DefaultClient through the shared client too; ports tcp dialing is left untouched. - clamp -threads to a floor of 1: it feeds wg.Add across the scanners, so 0 was a silent no-op and a negative value panicked the waitgroup. document the new flags in the readme, usage docs and man page.
198 lines
6.2 KiB
Go
198 lines
6.2 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"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
charmlog "github.com/charmbracelet/log"
|
|
"github.com/dropalldatabases/sif/internal/httpx"
|
|
"github.com/dropalldatabases/sif/internal/logger"
|
|
"github.com/dropalldatabases/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
|
|
}
|
|
|
|
// DetectFramework runs all registered detectors against the target URL.
|
|
func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) {
|
|
log := output.Module("FRAMEWORK")
|
|
log.Start()
|
|
|
|
spin := output.NewSpinner("Detecting frameworks")
|
|
spin.Start()
|
|
|
|
client := httpx.Client(timeout)
|
|
|
|
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req) //nolint:bodyclose // closed via defer below
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
|
if err != nil {
|
|
spin.Stop()
|
|
return nil, err
|
|
}
|
|
bodyStr := string(body)
|
|
|
|
// Get all registered detectors
|
|
detectors := GetDetectors()
|
|
if len(detectors) == 0 {
|
|
spin.Stop()
|
|
log.Warn("No framework detectors registered")
|
|
return nil, nil //nolint:nilnil // no detectors registered is not an error
|
|
}
|
|
|
|
// 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)
|
|
}()
|
|
|
|
// Find the best match
|
|
// results arrive in goroutine-completion order; 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
|
|
}
|
|
}
|
|
|
|
spin.Stop()
|
|
|
|
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
|
|
}
|
|
|
|
// Get version match details
|
|
versionMatch := ExtractVersionOptimized(bodyStr, best.name)
|
|
cves, suggestions := getVulnerabilities(best.name, best.version)
|
|
|
|
result := NewFrameworkResult(best.name, best.version, best.confidence, versionMatch.Confidence)
|
|
result.WithVulnerabilities(cves, suggestions)
|
|
|
|
// Log results
|
|
if logdir != "" {
|
|
logEntry := fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f, version_confidence: %.2f)\n",
|
|
best.name, best.version, best.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(best.name), best.version, best.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)
|
|
}
|
|
}
|
|
|
|
log.Complete(1, "detected")
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// 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. the entry is a version prefix, matched only on dotted boundaries, so
|
|
// "4.2" covers 4.2 and 4.2.1 but not 4.20.
|
|
func versionAffected(version, affected string) bool {
|
|
return version == affected || strings.HasPrefix(version, affected+".")
|
|
}
|