From 5d5b53a60f085ceaee48067d78cf97b8c021ffc4 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:43:11 -0700 Subject: [PATCH 1/4] refactor: share one helper for the per-user sif config directory the modules loader and the framework custom-signature loader each hand-rolled the same ~/.config/sif/ (and %LocalAppData%\sif\ on windows) path. extract internal/sifpath.UserSubdir and route both through it so the per-user layout is defined in one place. no behavior change: the helper reproduces the existing paths exactly. --- internal/modules/loader.go | 18 ++++------- internal/scan/frameworks/custom.go | 11 ++----- internal/sifpath/sifpath.go | 36 ++++++++++++++++++++++ internal/sifpath/sifpath_test.go | 48 ++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 internal/sifpath/sifpath.go create mode 100644 internal/sifpath/sifpath_test.go diff --git a/internal/modules/loader.go b/internal/modules/loader.go index 42906a6f..342e0af4 100644 --- a/internal/modules/loader.go +++ b/internal/modules/loader.go @@ -16,10 +16,10 @@ import ( "fmt" "os" "path/filepath" - "runtime" "github.com/charmbracelet/log" "github.com/vmfunc/sif/internal/output" + "github.com/vmfunc/sif/internal/sifpath" ) // Loader handles module discovery and loading. @@ -33,11 +33,6 @@ type Loader struct { // It automatically detects the built-in modules directory and sets up // the user modules directory based on the operating system. func NewLoader() (*Loader, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("get home dir: %w", err) - } - // Find built-in modules relative to executable execPath, err := os.Executable() if err != nil { @@ -50,13 +45,10 @@ func NewLoader() (*Loader, error) { builtinDir = "modules" } - // User modules directory based on OS - var userDir string - switch runtime.GOOS { - case "windows": - userDir = filepath.Join(home, "AppData", "Local", "sif", "modules") - default: - userDir = filepath.Join(home, ".config", "sif", "modules") + // User modules directory (can override built-ins) + userDir, err := sifpath.UserSubdir("modules") + if err != nil { + return nil, fmt.Errorf("resolve user modules dir: %w", err) } return &Loader{ diff --git a/internal/scan/frameworks/custom.go b/internal/scan/frameworks/custom.go index c9693c79..104bc6ed 100644 --- a/internal/scan/frameworks/custom.go +++ b/internal/scan/frameworks/custom.go @@ -26,11 +26,11 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strings" charmlog "github.com/charmbracelet/log" "github.com/vmfunc/sif/internal/output" + "github.com/vmfunc/sif/internal/sifpath" "gopkg.in/yaml.v3" ) @@ -129,14 +129,7 @@ func parseCustomDetector(path string) (Detector, error) { // customSignaturesDir is the per-user directory that holds yaml-defined // detectors, alongside the user modules directory. func customSignaturesDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - if runtime.GOOS == "windows" { - return filepath.Join(home, "AppData", "Local", "sif", "signatures"), nil - } - return filepath.Join(home, ".config", "sif", "signatures"), nil + return sifpath.UserSubdir("signatures") } // loadCustomDetectors registers every signature file under the user directory. diff --git a/internal/sifpath/sifpath.go b/internal/sifpath/sifpath.go new file mode 100644 index 00000000..dee5e9c2 --- /dev/null +++ b/internal/sifpath/sifpath.go @@ -0,0 +1,36 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +// Package sifpath resolves the per-user sif directories so every subsystem +// agrees on where user-supplied files live. +package sifpath + +import ( + "os" + "path/filepath" + "runtime" +) + +// UserSubdir returns the per-user sif configuration subdirectory for name (for +// example "modules" or "signatures"). It preserves sif's historical layout: +// ~/.config/sif/ on unix-like systems and %LOCALAPPDATA%\sif\ on +// windows. +func UserSubdir(name string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if runtime.GOOS == "windows" { + return filepath.Join(home, "AppData", "Local", "sif", name), nil + } + return filepath.Join(home, ".config", "sif", name), nil +} diff --git a/internal/sifpath/sifpath_test.go b/internal/sifpath/sifpath_test.go new file mode 100644 index 00000000..52c8e829 --- /dev/null +++ b/internal/sifpath/sifpath_test.go @@ -0,0 +1,48 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package sifpath + +import ( + "path/filepath" + "testing" +) + +func TestUserSubdirLayout(t *testing.T) { + got, err := UserSubdir("modules") + if err != nil { + t.Fatalf("UserSubdir returned error: %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("UserSubdir(%q) = %q, want an absolute path", "modules", got) + } + if base := filepath.Base(got); base != "modules" { + t.Errorf("UserSubdir(%q) base = %q, want %q", "modules", base, "modules") + } + if parent := filepath.Base(filepath.Dir(got)); parent != "sif" { + t.Errorf("UserSubdir(%q) parent = %q, want %q", "modules", parent, "sif") + } +} + +func TestUserSubdirSiblings(t *testing.T) { + mods, err := UserSubdir("modules") + if err != nil { + t.Fatalf("UserSubdir(modules): %v", err) + } + sigs, err := UserSubdir("signatures") + if err != nil { + t.Fatalf("UserSubdir(signatures): %v", err) + } + if filepath.Dir(mods) != filepath.Dir(sigs) { + t.Errorf("modules dir %q and signatures dir %q should share a parent", mods, sigs) + } +} From b886eac629fcaccd3e08d73fdc58370c802f5402 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:06:45 -0700 Subject: [PATCH 2/4] refactor: read capped response bodies through one httpx helper the frameworks detector and the module executor each defined their own 5 MB body cap and ran the same io.ReadAll(io.LimitReader(...)) read. move the cap and the read into httpx.MaxBodySize / httpx.ReadCappedBody so both scanners share one ceiling instead of two consts that can drift. no behavior change: the cap value and read semantics are unchanged. --- internal/httpx/httpx.go | 12 ++++++++++++ internal/modules/executor.go | 6 ++---- internal/scan/frameworks/detect.go | 6 +----- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go index 4ea20bcc..fd4ca092 100644 --- a/internal/httpx/httpx.go +++ b/internal/httpx/httpx.go @@ -68,6 +68,11 @@ const ( // conn, so we cap the read and let the conn be discarded instead. const drainCap = 16 << 10 +// MaxBodySize is the shared ceiling on how much of a response body the scanners +// read into memory, so a hostile or accidental multi-gigabyte response can't +// exhaust the process. +const MaxBodySize = 5 << 20 + // Options carries the runtime knobs that apply to every outbound request. // RateLimit is requests/sec (0 = unlimited); Headers are "Key: Value" strings. type Options struct { @@ -213,6 +218,13 @@ func DrainClose(resp *http.Response) { resp.Body.Close() } +// ReadCappedBody reads resp.Body up to MaxBodySize, the shared cap every scanner +// uses so one runaway response can't exhaust memory. It does not close the body; +// pair it with DrainClose or an explicit Close. +func ReadCappedBody(resp *http.Response) ([]byte, error) { + return io.ReadAll(io.LimitReader(resp.Body, MaxBodySize)) +} + // parseHeaders splits each "Key: Value" entry on the first ": ". Entries // without the separator are rejected so a typo fails loud instead of silently. // The returned map is always non-nil so callers can range it unconditionally. diff --git a/internal/modules/executor.go b/internal/modules/executor.go index f969581a..40c1d963 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -26,11 +26,9 @@ import ( "time" "github.com/tidwall/gjson" + "github.com/vmfunc/sif/internal/httpx" ) -// MaxBodySize limits response body to prevent memory exhaustion. -const MaxBodySize = 5 * 1024 * 1024 - // ErrUnsupportedModuleType signals an executor for a module type that is not // yet implemented. Returning it (rather than an empty result) keeps callers // from mistaking "not implemented" for "scanned, found nothing". @@ -297,7 +295,7 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest defer resp.Body.Close() // Read body with limit - respBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodySize)) + respBody, err := httpx.ReadCappedBody(resp) if err != nil { return Finding{}, false } diff --git a/internal/scan/frameworks/detect.go b/internal/scan/frameworks/detect.go index bf2dd2a0..763bbb2c 100644 --- a/internal/scan/frameworks/detect.go +++ b/internal/scan/frameworks/detect.go @@ -15,7 +15,6 @@ package frameworks import ( "context" "fmt" - "io" "net/http" "strings" "sync" @@ -30,9 +29,6 @@ import ( // 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 @@ -68,7 +64,7 @@ func DetectFramework(url string, timeout time.Duration, logdir string) (*Framewo } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize)) + body, err := httpx.ReadCappedBody(resp) if err != nil { spin.Stop() return nil, err From 55d82d6dd66094868d84ebeebd4500bc9c5c8b54 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:32:28 -0700 Subject: [PATCH 3/4] feat(modules): add fingerprint module type a `fingerprint` module identifies a technology by weighted body/header signatures scored into a confidence, with an optional version regex, rather than a boolean match. it fires one finding carrying the score once it reaches the threshold (default 0.5). this is the framework detectors' scoring in the module format, so a custom tech fingerprint lives alongside other modules. validated at load (signatures present, non-empty patterns, finite weights, confidence in [0,1], version regex compiles) and covered by yaml round-trip, validation, header, version and default-threshold tests. documented in docs/modules.md. shares the response body cap via httpx.ReadCappedBody. --- docs/modules.md | 41 ++++- internal/modules/fingerprint.go | 196 +++++++++++++++++++++ internal/modules/fingerprint_parse_test.go | 182 +++++++++++++++++++ internal/modules/fingerprint_type_test.go | 72 ++++++++ internal/modules/module.go | 18 +- internal/modules/yaml.go | 24 ++- 6 files changed, 518 insertions(+), 15 deletions(-) create mode 100644 internal/modules/fingerprint.go create mode 100644 internal/modules/fingerprint_parse_test.go create mode 100644 internal/modules/fingerprint_type_test.go diff --git a/docs/modules.md b/docs/modules.md index 52af7473..23e5346f 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -65,7 +65,8 @@ info: ### type (required) -module type. currently only `http` is supported. +module type. `http` (request and match) and `fingerprint` (weighted technology +detection) are supported. ```yaml type: http @@ -340,6 +341,44 @@ extractors: - "data.version" ``` +## fingerprint modules + +a `fingerprint` module identifies a technology by weighted signatures rather +than a pass/fail match. each signature contributes its `weight` when it appears +in the body (or, with `header: true`, in a response header name or value). the +matched fraction of the total weight is the confidence; the module fires a +single finding, carrying that confidence, once it reaches the threshold. + +this is the same scoring the built-in framework detectors use, in the module +format, so a custom technology fingerprint lives alongside your other modules. + +```yaml +id: acme-server +info: + name: ACME Server + author: you + severity: info + tags: [tech, fingerprint] + +type: fingerprint + +fingerprint: + path: / # request path, defaults to / + confidence: 0.5 # minimum score to fire, defaults to 0.5 + signatures: + - pattern: "acme" # matched against header name/value + weight: 0.6 + header: true + - pattern: "powered by acme" + weight: 0.4 # body match; omit weight to default to 1 + version: # optional: pull a version out of the body + regex: "acme/([0-9.]+)" + group: 1 +``` + +a response with both signatures scores `1.0`; the header alone scores `0.6` and +still clears the `0.5` threshold, while the body alone (`0.4`) does not. + ## examples ### exposed git repository diff --git a/internal/modules/fingerprint.go b/internal/modules/fingerprint.go new file mode 100644 index 00000000..ab464cd4 --- /dev/null +++ b/internal/modules/fingerprint.go @@ -0,0 +1,196 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "fmt" + "math" + "net/http" + "regexp" + "strings" + + "github.com/vmfunc/sif/internal/httpx" +) + +// FingerprintConfig defines a framework-fingerprint module: weighted body/header +// signatures scored into a confidence, plus an optional version regex. It mirrors +// the framework custom-detector format so user fingerprints and modules can share +// one loader and directory. +type FingerprintConfig struct { + Path string `yaml:"path,omitempty"` // request path, default "/" + Confidence float32 `yaml:"confidence,omitempty"` // min score to fire, default 0.5 + Signatures []FPSignature `yaml:"signatures"` + Version *FPVersion `yaml:"version,omitempty"` +} + +// FPSignature is one weighted pattern. Header matches the response headers (name +// or value, case-insensitive) instead of the body. +type FPSignature struct { + Pattern string `yaml:"pattern"` + Weight float32 `yaml:"weight"` + Header bool `yaml:"header"` +} + +// FPVersion pulls a version string out of the body via a capture group. +type FPVersion struct { + Regex string `yaml:"regex"` + Group int `yaml:"group"` +} + +// defaultFingerprintConfidence is the score a fingerprint must reach to fire when +// the module does not set its own threshold. +const defaultFingerprintConfidence = 0.5 + +// validateFingerprint rejects a fingerprint config that can never produce a +// meaningful score, so a broken module fails at load instead of silently never +// matching. An omitted signature weight defaults to 1, so 0 is allowed. +func validateFingerprint(cfg *FingerprintConfig) error { + if cfg == nil { + return fmt.Errorf("missing fingerprint configuration") + } + if len(cfg.Signatures) == 0 { + return fmt.Errorf("fingerprint requires at least one signature") + } + for i, s := range cfg.Signatures { + if s.Pattern == "" { + return fmt.Errorf("signature %d has an empty pattern", i+1) + } + if s.Weight < 0 || math.IsInf(float64(s.Weight), 0) || math.IsNaN(float64(s.Weight)) { + return fmt.Errorf("signature %q needs a non-negative, finite weight", s.Pattern) + } + } + if cfg.Confidence < 0 || cfg.Confidence > 1 { + return fmt.Errorf("confidence must be within [0, 1]") + } + if cfg.Version != nil { + if cfg.Version.Group < 0 { + return fmt.Errorf("version group must be >= 0") + } + if _, err := regexp.Compile(cfg.Version.Regex); err != nil { + return fmt.Errorf("version regex: %w", err) + } + } + return nil +} + +// ExecuteFingerprintModule fetches the target and scores it against the weighted +// signatures, firing a single finding (with confidence and any version) once the +// score reaches the threshold. The boolean matcher engine is not involved. +func ExecuteFingerprintModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { + cfg := def.Fingerprint + if cfg == nil { + return nil, fmt.Errorf("no fingerprint configuration") + } + result := &Result{ModuleID: def.ID, Target: target, Findings: make([]Finding, 0)} + + client := opts.Client + if client == nil { + client = &http.Client{Timeout: opts.Timeout} + } + + path := cfg.Path + if path == "" { + path = "/" + } + url := strings.TrimSuffix(target, "/") + path + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + // an unreachable target is simply no finding, not a module failure. + return result, nil //nolint:nilerr // mirrors the http executor's swallow-per-request policy + } + defer resp.Body.Close() + + body, err := httpx.ReadCappedBody(resp) + if err != nil { + return result, nil //nolint:nilerr // a body read error yields no finding, same as above + } + bodyStr := string(body) + + score, version := scoreFingerprint(cfg, bodyStr, resp.Header) + threshold := cfg.Confidence + if threshold == 0 { + threshold = defaultFingerprintConfidence + } + if score < threshold { + return result, nil + } + + finding := Finding{ + URL: url, + Severity: def.Info.Severity, + Evidence: truncateEvidence(bodyStr), + Confidence: score, + } + if version != "" { + finding.Extracted = map[string]string{"version": version} + } + result.Findings = append(result.Findings, finding) + return result, nil +} + +// scoreFingerprint returns the matched fraction of signature weight and, when a +// version regex is set and the body matches, the captured version. +func scoreFingerprint(cfg *FingerprintConfig, body string, headers http.Header) (float32, string) { + var matched, total float32 + for _, s := range cfg.Signatures { + w := s.Weight + if w == 0 { + w = 1 + } + total += w + if s.Header { + if headerContains(headers, s.Pattern) { + matched += w + } + } else if strings.Contains(body, s.Pattern) { + matched += w + } + } + if total == 0 { + return 0, "" + } + score := matched / total + + version := "" + if cfg.Version != nil && score > 0 { + if re, err := regexp.Compile(cfg.Version.Regex); err == nil { + if g := re.FindStringSubmatch(body); len(g) > cfg.Version.Group { + version = g[cfg.Version.Group] + } + } + } + return score, version +} + +// headerContains reports whether pattern appears in any header name or value, +// case-insensitively, matching the framework detector's header semantics. +func headerContains(headers http.Header, pattern string) bool { + p := strings.ToLower(pattern) + for name, values := range headers { + if strings.Contains(strings.ToLower(name), p) { + return true + } + for _, v := range values { + if strings.Contains(strings.ToLower(v), p) { + return true + } + } + } + return false +} diff --git a/internal/modules/fingerprint_parse_test.go b/internal/modules/fingerprint_parse_test.go new file mode 100644 index 00000000..a6f778b3 --- /dev/null +++ b/internal/modules/fingerprint_parse_test.go @@ -0,0 +1,182 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeFingerprintFile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "fp.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write module: %v", err) + } + return p +} + +const fingerprintYAML = ` +id: acme-server +info: + name: ACME Server + severity: info +type: fingerprint +fingerprint: + path: / + confidence: 0.5 + signatures: + - pattern: "acme" + weight: 0.6 + header: true + - pattern: "powered by acme" + weight: 0.4 + version: + regex: "acme/([0-9.]+)" + group: 1 +` + +// Parse a fingerprint module from real YAML and run it through the module +// wrapper's dispatch, exercising parse -> validate -> Execute end to end. +func TestFingerprintParseRoundTrip(t *testing.T) { + def, err := ParseYAMLModule(writeFingerprintFile(t, fingerprintYAML)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if def.Type != TypeFingerprint { + t.Fatalf("type = %q, want fingerprint", def.Type) + } + if def.Fingerprint == nil || len(def.Fingerprint.Signatures) != 2 { + t.Fatalf("fingerprint config not parsed: %+v", def.Fingerprint) + } + if !def.Fingerprint.Signatures[0].Header { + t.Errorf("first signature should be header-scoped") + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Server", "acme/3.1") + _, _ = w.Write([]byte("powered by acme/3.1")) + })) + defer srv.Close() + + mod := newYAMLModuleWrapper(def, "fp.yaml") + res, err := mod.Execute(context.Background(), srv.URL, Options{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("want 1 finding, got %d", len(res.Findings)) + } + if got := res.Findings[0].Confidence; got < 0.99 { + t.Errorf("confidence = %v, want ~1.0 (both signatures match)", got) + } + if got := res.Findings[0].Extracted["version"]; got != "3.1" { + t.Errorf("version = %q, want 3.1", got) + } +} + +func TestFingerprintValidation(t *testing.T) { + cases := map[string]string{ + "no signatures": `id: m +type: fingerprint +fingerprint: + signatures: []`, + "empty pattern": `id: m +type: fingerprint +fingerprint: + signatures: + - pattern: "" + weight: 1`, + "negative weight": `id: m +type: fingerprint +fingerprint: + signatures: + - pattern: x + weight: -1`, + "confidence over 1": `id: m +type: fingerprint +fingerprint: + confidence: 1.5 + signatures: + - pattern: x + weight: 1`, + "bad version regex": `id: m +type: fingerprint +fingerprint: + signatures: + - pattern: x + weight: 1 + version: + regex: "([0-9" + group: 1`, + "negative version group": `id: m +type: fingerprint +fingerprint: + signatures: + - pattern: x + weight: 1 + version: + regex: "(x)" + group: -1`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + if _, err := ParseYAMLModule(writeFingerprintFile(t, body)); err == nil { + t.Fatalf("expected a validation error for %q, got nil", name) + } + }) + } +} + +// An omitted confidence threshold falls back to defaultFingerprintConfidence. +func TestFingerprintDefaultConfidence(t *testing.T) { + cfg := &FingerprintConfig{ + Signatures: []FPSignature{ + {Pattern: "hit", Weight: 1}, + {Pattern: "miss-me", Weight: 1}, + }, + } + def := &YAMLModule{ID: "m", Type: TypeFingerprint, Info: YAMLModuleInfo{Severity: "info"}, Fingerprint: cfg} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("only hit is here")) // 1 of 2 -> score 0.5 == default threshold + })) + defer srv.Close() + + res, err := ExecuteFingerprintModule(context.Background(), srv.URL, def, Options{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("score 0.5 should clear the default 0.5 threshold; got %d findings", len(res.Findings)) + } + + // A target matching nothing scores 0 and stays silent. + blank := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("x", 8))) + })) + defer blank.Close() + res2, err := ExecuteFingerprintModule(context.Background(), blank.URL, def, Options{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("execute blank: %v", err) + } + if len(res2.Findings) != 0 { + t.Fatalf("no signatures match -> want 0 findings, got %d", len(res2.Findings)) + } +} diff --git a/internal/modules/fingerprint_type_test.go b/internal/modules/fingerprint_type_test.go new file mode 100644 index 00000000..ad319d89 --- /dev/null +++ b/internal/modules/fingerprint_type_test.go @@ -0,0 +1,72 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// fpTypeModule is a fingerprint expressed as its own module type: three weighted +// signatures (0.5 + 0.3 + 0.2) plus a version regex. +func fpTypeModule(confidence float32) *YAMLModule { + return &YAMLModule{ + ID: "fp-test", + Type: TypeFingerprint, + Info: YAMLModuleInfo{Severity: "info"}, + Fingerprint: &FingerprintConfig{ + Confidence: confidence, + Signatures: []FPSignature{ + {Pattern: "alpha", Weight: 0.5}, + {Pattern: "beta", Weight: 0.3}, + {Pattern: "gamma", Weight: 0.2}, + }, + Version: &FPVersion{Regex: `alpha/(\d+\.\d+)`, Group: 1}, + }, + } +} + +func TestFingerprintTypeScoring(t *testing.T) { + // body matches alpha (0.5) + beta (0.3) = 0.8 of total weight; gamma absent. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("banner: alpha/2.4 and beta present")) + })) + defer srv.Close() + + opts := Options{Timeout: 5 * time.Second, Threads: 1} + + res, err := ExecuteFingerprintModule(context.Background(), srv.URL, fpTypeModule(0.5), opts) + if err != nil { + t.Fatalf("execute at 0.5: %v", err) + } + if len(res.Findings) != 1 { + t.Fatalf("threshold 0.5: want 1 finding, got %d", len(res.Findings)) + } + if got := res.Findings[0].Confidence; got < 0.79 || got > 0.81 { + t.Errorf("confidence = %v, want ~0.8", got) + } + if got := res.Findings[0].Extracted["version"]; got != "2.4" { + t.Errorf("version = %q, want 2.4", got) + } + + res2, err := ExecuteFingerprintModule(context.Background(), srv.URL, fpTypeModule(0.9), opts) + if err != nil { + t.Fatalf("execute at 0.9: %v", err) + } + if len(res2.Findings) != 0 { + t.Fatalf("threshold 0.9: want 0 findings (score 0.8 < 0.9), got %d", len(res2.Findings)) + } +} diff --git a/internal/modules/module.go b/internal/modules/module.go index 1a45e5c4..4ad5f25b 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -25,10 +25,11 @@ import ( type ModuleType string const ( - TypeHTTP ModuleType = "http" - TypeDNS ModuleType = "dns" - TypeTCP ModuleType = "tcp" - TypeScript ModuleType = "script" + TypeHTTP ModuleType = "http" + TypeDNS ModuleType = "dns" + TypeTCP ModuleType = "tcp" + TypeScript ModuleType = "script" + TypeFingerprint ModuleType = "fingerprint" ) // Module is the interface all modules implement. @@ -77,10 +78,11 @@ func (r *Result) ResultType() string { // Finding represents a discovered issue. type Finding struct { - URL string `json:"url,omitempty"` - Severity string `json:"severity"` - Evidence string `json:"evidence,omitempty"` - Extracted map[string]string `json:"extracted,omitempty"` + URL string `json:"url,omitempty"` + Severity string `json:"severity"` + Evidence string `json:"evidence,omitempty"` + Extracted map[string]string `json:"extracted,omitempty"` + Confidence float32 `json:"confidence,omitempty"` // set only for fingerprint modules } // Matcher defines matching logic for module responses. diff --git a/internal/modules/yaml.go b/internal/modules/yaml.go index d4042392..80304310 100644 --- a/internal/modules/yaml.go +++ b/internal/modules/yaml.go @@ -34,12 +34,13 @@ import ( // YAMLModule represents a parsed YAML module file type YAMLModule struct { - ID string `yaml:"id"` - Info YAMLModuleInfo `yaml:"info"` - Type ModuleType `yaml:"type"` - HTTP *HTTPConfig `yaml:"http,omitempty"` - DNS *DNSConfig `yaml:"dns,omitempty"` - TCP *TCPConfig `yaml:"tcp,omitempty"` + ID string `yaml:"id"` + Info YAMLModuleInfo `yaml:"info"` + Type ModuleType `yaml:"type"` + HTTP *HTTPConfig `yaml:"http,omitempty"` + DNS *DNSConfig `yaml:"dns,omitempty"` + TCP *TCPConfig `yaml:"tcp,omitempty"` + Fingerprint *FingerprintConfig `yaml:"fingerprint,omitempty"` } // YAMLModuleInfo contains module metadata @@ -123,6 +124,12 @@ func ParseYAMLModule(path string) (*YAMLModule, error) { return nil, fmt.Errorf("module %q: %w", ym.ID, err) } + if ym.Type == TypeFingerprint { + if err := validateFingerprint(ym.Fingerprint); err != nil { + return nil, fmt.Errorf("module %q: %w", ym.ID, err) + } + } + return &ym, nil } @@ -172,6 +179,11 @@ func (m *yamlModuleWrapper) Execute(ctx context.Context, target string, opts Opt return nil, fmt.Errorf("TCP module missing tcp configuration") } return ExecuteTCPModule(ctx, target, m.def, opts) + case TypeFingerprint: + if m.def.Fingerprint == nil { + return nil, fmt.Errorf("fingerprint module missing fingerprint configuration") + } + return ExecuteFingerprintModule(ctx, target, m.def, opts) default: return nil, fmt.Errorf("unsupported module type: %s", m.def.Type) } From 9eb7a0220340f2a722be53f7ded48d56fb8dbe64 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:23:21 -0700 Subject: [PATCH 4/4] fix(scoring): clamp fraction scorers to [0, 1] scoreFingerprint and MatchSignatures divide matched weight by total weight with no bound on the result. validation rejects negative weights on real configs, but the math itself has no invariant, so any caller that skips validation gets a score outside [0, 1] used directly as reported confidence. clamp both to guard the pathological path; normal validated inputs already fall inside [0, 1] and are unaffected. --- internal/modules/fingerprint.go | 21 +++++- internal/modules/fingerprint_clamp_test.go | 70 +++++++++++++++++++ internal/scan/frameworks/detector.go | 21 +++++- .../scan/frameworks/detector_clamp_test.go | 66 +++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 internal/modules/fingerprint_clamp_test.go create mode 100644 internal/scan/frameworks/detector_clamp_test.go diff --git a/internal/modules/fingerprint.go b/internal/modules/fingerprint.go index ab464cd4..5b2de0c0 100644 --- a/internal/modules/fingerprint.go +++ b/internal/modules/fingerprint.go @@ -165,7 +165,7 @@ func scoreFingerprint(cfg *FingerprintConfig, body string, headers http.Header) if total == 0 { return 0, "" } - score := matched / total + score := clampUnit(matched / total) version := "" if cfg.Version != nil && score > 0 { @@ -178,6 +178,25 @@ func scoreFingerprint(cfg *FingerprintConfig, body string, headers http.Header) return score, version } +// clampUnit bounds a fraction to [0, 1]. Weights are validated non-negative +// and finite at load time so this is a no-op on any real config; it only +// guards a caller that skips validation and feeds scoreFingerprint a +// negative or NaN weight. NaN needs its own check: every ordered comparison +// against NaN is false, so the < 0 and > 1 checks below would silently let +// it through otherwise. +func clampUnit(f float32) float32 { + if math.IsNaN(float64(f)) { + return 0 + } + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f +} + // headerContains reports whether pattern appears in any header name or value, // case-insensitively, matching the framework detector's header semantics. func headerContains(headers http.Header, pattern string) bool { diff --git a/internal/modules/fingerprint_clamp_test.go b/internal/modules/fingerprint_clamp_test.go new file mode 100644 index 00000000..badc4453 --- /dev/null +++ b/internal/modules/fingerprint_clamp_test.go @@ -0,0 +1,70 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "math" + "net/http" + "testing" +) + +// scoreFingerprint trusts validateFingerprint to keep weights non-negative; +// fed a negative weight directly (as any caller bypassing load validation +// would), the matched fraction must still be clamped to [0, 1] rather than +// escaping it. +func TestScoreFingerprintClampsOutOfRangeWeights(t *testing.T) { + // weights 1 and -0.9 -> total 0.1; only the +1 sig matches: raw 1/0.1 = 10. + cfgHigh := &FingerprintConfig{Signatures: []FPSignature{ + {Pattern: "yes", Weight: 1}, + {Pattern: "absent", Weight: -0.9}, + }} + if score, _ := scoreFingerprint(cfgHigh, "yes only", make(http.Header)); score != 1 { + t.Fatalf("score = %v, want clamped to 1", score) + } + + // only the -0.9 sig matches: raw -0.9/0.1 = -9. + cfgLow := &FingerprintConfig{Signatures: []FPSignature{ + {Pattern: "absent", Weight: 1}, + {Pattern: "neg", Weight: -0.9}, + }} + if score, _ := scoreFingerprint(cfgLow, "neg only", make(http.Header)); score != 0 { + t.Fatalf("score = %v, want clamped to 0", score) + } +} + +// a NaN weight (unreachable through validateFingerprint, but reachable by +// any caller that builds a FingerprintConfig directly) must clamp to 0, not +// pass through: every ordered comparison against NaN is false, so a naive +// clamp of "< 0 -> 0, > 1 -> 1" silently returns NaN unchanged. +func TestScoreFingerprintClampsNaNWeight(t *testing.T) { + cfg := &FingerprintConfig{Signatures: []FPSignature{ + {Pattern: "yes", Weight: float32(math.NaN())}, + }} + score, _ := scoreFingerprint(cfg, "yes only", make(http.Header)) + if score != 0 { + t.Fatalf("score = %v, want NaN clamped to 0", score) + } +} + +// the clamp must be a no-op on the validated domain: any signature set that +// validateFingerprint would accept already scores within [0, 1]. +func TestScoreFingerprintClampIsNoopOnValidatedScore(t *testing.T) { + cfg := &FingerprintConfig{Signatures: []FPSignature{ + {Pattern: "alpha", Weight: 1}, + {Pattern: "beta", Weight: 1}, + }} + score, _ := scoreFingerprint(cfg, "only alpha here", make(http.Header)) + if score != 0.5 { + t.Fatalf("score = %v, want unchanged 0.5", score) + } +} diff --git a/internal/scan/frameworks/detector.go b/internal/scan/frameworks/detector.go index 4d48aabd..52b093ce 100644 --- a/internal/scan/frameworks/detector.go +++ b/internal/scan/frameworks/detector.go @@ -20,6 +20,7 @@ package frameworks import ( + "math" "net/http" "strings" "sync" @@ -128,7 +129,25 @@ func (b BaseDetector) MatchSignatures(body string, headers http.Header) float32 return 0 } - return weightedScore / totalWeight + return clampUnit(weightedScore / totalWeight) +} + +// clampUnit bounds a fraction to [0, 1]. Signature weights are expected +// non-negative and finite, so this is a no-op on any real detector; it only +// guards a caller that feeds MatchSignatures a negative or NaN weight. NaN +// needs its own check: every ordered comparison against NaN is false, so +// the < 0 and > 1 checks below would silently let it through otherwise. +func clampUnit(f float32) float32 { + if math.IsNaN(float64(f)) { + return 0 + } + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f } // headerValueContains reports whether the named header's value contains the signature. diff --git a/internal/scan/frameworks/detector_clamp_test.go b/internal/scan/frameworks/detector_clamp_test.go new file mode 100644 index 00000000..e7fbe83b --- /dev/null +++ b/internal/scan/frameworks/detector_clamp_test.go @@ -0,0 +1,66 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package frameworks + +import ( + "math" + "net/http" + "testing" +) + +// MatchSignatures shares scoreFingerprint's math and the same trust-the-caller +// gap: a negative weight must not push the score outside [0, 1]. +func TestMatchSignaturesClampsOutOfRangeWeights(t *testing.T) { + // weights 1 and -0.9 -> total 0.1; only the +1 sig matches: raw 1/0.1 = 10. + high := NewBaseDetector("x", []Signature{ + {Pattern: "yes", Weight: 1}, + {Pattern: "absent", Weight: -0.9}, + }) + if score := high.MatchSignatures("yes only", http.Header{}); score != 1 { + t.Fatalf("score = %v, want clamped to 1", score) + } + + // only the -0.9 sig matches: raw -0.9/0.1 = -9. + low := NewBaseDetector("x", []Signature{ + {Pattern: "absent", Weight: 1}, + {Pattern: "neg", Weight: -0.9}, + }) + if score := low.MatchSignatures("neg only", http.Header{}); score != 0 { + t.Fatalf("score = %v, want clamped to 0", score) + } +} + +// a NaN weight (unreachable through the validating custom-detector loader, +// but reachable by any caller that builds a Signature directly, as the +// exported BaseDetector/NewBaseDetector allow) must clamp to 0, not pass +// through: every ordered comparison against NaN is false, so a naive clamp +// of "< 0 -> 0, > 1 -> 1" silently returns NaN unchanged. +func TestMatchSignaturesClampsNaNWeight(t *testing.T) { + d := NewBaseDetector("x", []Signature{ + {Pattern: "yes", Weight: float32(math.NaN())}, + }) + if score := d.MatchSignatures("yes only", http.Header{}); score != 0 { + t.Fatalf("score = %v, want NaN clamped to 0", score) + } +} + +// the clamp must not touch scores already inside [0, 1]. +func TestMatchSignaturesClampIsNoopOnValidatedScore(t *testing.T) { + d := NewBaseDetector("x", []Signature{ + {Pattern: "alpha", Weight: 1}, + {Pattern: "beta", Weight: 1}, + }) + if score := d.MatchSignatures("only alpha here", http.Header{}); score != 0.5 { + t.Fatalf("score = %v, want unchanged 0.5", score) + } +}