From 3477abd9fc02c510bb1c9a727894c2d5fa17e19a 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/9] 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 | 36 ++++++++++++++++++++++++++++++ 4 files changed, 79 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..c64f6cb8 --- /dev/null +++ b/internal/sifpath/sifpath_test.go @@ -0,0 +1,36 @@ +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 0e4b502e5c97fa2c1dd2e3a48606845f0a5aacad 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/9] 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 0c1528b63d2dae37360b7061e03cbe737bd72386 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/9] 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 | 170 ++++++++++++++++++ internal/modules/fingerprint_type_test.go | 60 +++++++ internal/modules/module.go | 18 +- internal/modules/yaml.go | 24 ++- 6 files changed, 494 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..c5c87471 --- /dev/null +++ b/internal/modules/fingerprint_parse_test.go @@ -0,0 +1,170 @@ +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..13bb8bab --- /dev/null +++ b/internal/modules/fingerprint_type_test.go @@ -0,0 +1,60 @@ +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 0edcb13bc9a5d217aecb454106f0615f0bbfe942 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:30:10 -0700 Subject: [PATCH 4/9] test(modules): prove fingerprint and framework scorers agree --- .../modules/fingerprint_equivalence_test.go | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 internal/modules/fingerprint_equivalence_test.go diff --git a/internal/modules/fingerprint_equivalence_test.go b/internal/modules/fingerprint_equivalence_test.go new file mode 100644 index 00000000..0d248e73 --- /dev/null +++ b/internal/modules/fingerprint_equivalence_test.go @@ -0,0 +1,55 @@ +package modules + +import ( + "math/rand" + "net/http" + "testing" + + "github.com/vmfunc/sif/internal/scan/frameworks" +) + +// pairedSigs builds a frameworks signature list and the matching fingerprint +// signature list from one source, so the two scorers see identical inputs. +func pairedSigs(src []FPSignature) ([]frameworks.Signature, *FingerprintConfig) { + fw := make([]frameworks.Signature, len(src)) + for i, s := range src { + fw[i] = frameworks.Signature{Pattern: s.Pattern, Weight: s.Weight, HeaderOnly: s.Header} + } + return fw, &FingerprintConfig{Signatures: src} +} + +func TestScorerEquivalenceSharedDomain(t *testing.T) { + r := rand.New(rand.NewSource(1)) + tokens := []string{"nginx", "PHPSESSID", "wp-content", "X-Powered-By", "cloudflare", "django"} + for iter := 0; iter < 2000; iter++ { + n := 1 + r.Intn(len(tokens)) + src := make([]FPSignature, n) + for i := 0; i < n; i++ { + src[i] = FPSignature{ + Pattern: tokens[r.Intn(len(tokens))], + Weight: 0.1 + r.Float32()*5, // strictly > 0: the shared domain + Header: r.Intn(2) == 0, + } + } + fwSigs, fpCfg := pairedSigs(src) + + body := "" + for _, tk := range tokens { + if r.Intn(2) == 0 { + body += tk + " " + } + } + headers := http.Header{} + for _, tk := range tokens { + if r.Intn(2) == 0 { + headers.Add(tk, "v") + } + } + + want := frameworks.NewBaseDetector("x", fwSigs).MatchSignatures(body, headers) + got, _ := scoreFingerprint(fpCfg, body, headers) + if got != want { + t.Fatalf("iter %d: scoreFingerprint=%v MatchSignatures=%v sigs=%+v", iter, got, want, src) + } + } +} From c679088a16ffcf524f84929ad82900701846a015 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:30:37 -0700 Subject: [PATCH 5/9] test(modules): pin zero-weight divergence and strict detection threshold --- .../modules/fingerprint_equivalence_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/modules/fingerprint_equivalence_test.go b/internal/modules/fingerprint_equivalence_test.go index 0d248e73..1ca99fdd 100644 --- a/internal/modules/fingerprint_equivalence_test.go +++ b/internal/modules/fingerprint_equivalence_test.go @@ -53,3 +53,41 @@ func TestScorerEquivalenceSharedDomain(t *testing.T) { } } } + +func TestScorerDivergesAtZeroWeight(t *testing.T) { + // one zero-weight sig that misses, one positive sig that hits. + src := []FPSignature{ + {Pattern: "absent", Weight: 0, Header: false}, + {Pattern: "present", Weight: 1, Header: false}, + } + fwSigs, fpCfg := pairedSigs(src) + body := "present" + + fw := frameworks.NewBaseDetector("x", fwSigs).MatchSignatures(body, http.Header{}) + fp, _ := scoreFingerprint(fpCfg, body, http.Header{}) + + // framework: matched 1 / total 1 = 1.0 (zero-weight sig contributes nothing). + if fw != 1 { + t.Fatalf("framework score = %v, want 1", fw) + } + // fingerprint: zero weight remapped to 1, absent sig misses: matched 1 / total 2 = 0.5. + if fp != 0.5 { + t.Fatalf("fingerprint score = %v, want 0.5", fp) + } + if fw == fp { + t.Fatal("expected divergence at weight==0, got equality") + } +} + +func TestFrameworkThresholdIsStrict(t *testing.T) { + // a signature set that scores exactly 0.5 must not clear the > 0.5 gate. + src := []FPSignature{ + {Pattern: "hit", Weight: 1, Header: false}, + {Pattern: "miss", Weight: 1, Header: false}, + } + fwSigs, _ := pairedSigs(src) + score := frameworks.NewBaseDetector("x", fwSigs).MatchSignatures("hit", http.Header{}) + if score != 0.5 { + t.Fatalf("score = %v, want exactly 0.5", score) + } +} From 5f61889d1558ae03d0b67a8981d05341cf23767a Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:31:47 -0700 Subject: [PATCH 6/9] test(frameworks): freeze legacy custom-signature load and score --- .../scan/frameworks/custom_backcompat_test.go | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 internal/scan/frameworks/custom_backcompat_test.go diff --git a/internal/scan/frameworks/custom_backcompat_test.go b/internal/scan/frameworks/custom_backcompat_test.go new file mode 100644 index 00000000..805810c8 --- /dev/null +++ b/internal/scan/frameworks/custom_backcompat_test.go @@ -0,0 +1,32 @@ +package frameworks + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestLegacyCustomSignatureStillLoads(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "acme.yaml") + yaml := "name: acme\n" + + "signatures:\n" + + " - pattern: \"X-Acme\"\n" + + " weight: 1\n" + + " header: true\n" + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + + det, err := parseCustomDetector(path) + if err != nil { + t.Fatalf("parseCustomDetector: %v", err) + } + h := http.Header{} + h.Add("X-Acme", "1") + score, _ := det.Detect("", h) + if score != 1 { + t.Fatalf("score = %v, want 1", score) + } +} From dc5ca399c873468fd839b0306cff74edb8ca749c Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:46:23 -0700 Subject: [PATCH 7/9] test(modules): trap non-root and confidence fingerprints from framework bridge --- internal/modules/fingerprint_bridge_test.go | 94 +++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 internal/modules/fingerprint_bridge_test.go diff --git a/internal/modules/fingerprint_bridge_test.go b/internal/modules/fingerprint_bridge_test.go new file mode 100644 index 00000000..a3b89943 --- /dev/null +++ b/internal/modules/fingerprint_bridge_test.go @@ -0,0 +1,94 @@ +package modules + +import ( + "net/http" + "testing" + + "github.com/vmfunc/sif/internal/scan/frameworks" +) + +// okFingerprintDef builds a root-path, default-confidence, all-positive-weight +// fingerprint module: the one shape the framework engine can reproduce exactly +// (C2's shared domain). +func okFingerprintDef(id string) *YAMLModule { + return &YAMLModule{ + ID: id, + Type: TypeFingerprint, + Fingerprint: &FingerprintConfig{ + Signatures: []FPSignature{ + {Pattern: "nginx", Weight: 1}, + {Pattern: "X-Powered-By", Weight: 2, Header: true}, + }, + }, + } +} + +func TestBridgeFingerprint_RootDefaultPositiveWeight_Registers(t *testing.T) { + def := okFingerprintDef("fp-bridge-ok") + + registered, reason := bridgeFingerprint(def) + if !registered { + t.Fatalf("bridgeFingerprint registered = false, reason %q, want true", reason) + } + + det, ok := frameworks.GetDetector(def.ID) + if !ok { + t.Fatalf("frameworks.GetDetector(%q) not found after bridging", def.ID) + } + + body := "served by nginx" + headers := http.Header{"X-Powered-By": {"PHP"}} + got, _ := det.Detect(body, headers) + want, _ := scoreFingerprint(def.Fingerprint, body, headers) + if got != want { + t.Fatalf("bridged Detect = %v, scoreFingerprint = %v, want equal", got, want) + } +} + +func TestBridgeFingerprint_NonRootPath_Refuses(t *testing.T) { + def := okFingerprintDef("fp-bridge-path") + def.Fingerprint.Path = "/admin" + + registered, reason := bridgeFingerprint(def) + if registered { + t.Fatal("bridgeFingerprint registered a non-root fingerprint") + } + if reason == "" { + t.Fatal("expected a non-empty refusal reason") + } + if _, ok := frameworks.GetDetector(def.ID); ok { + t.Fatalf("frameworks.GetDetector(%q) found a detector that should have been refused", def.ID) + } +} + +func TestBridgeFingerprint_CustomConfidence_Refuses(t *testing.T) { + def := okFingerprintDef("fp-bridge-conf") + def.Fingerprint.Confidence = 0.7 + + registered, reason := bridgeFingerprint(def) + if registered { + t.Fatal("bridgeFingerprint registered a custom-confidence fingerprint") + } + if reason == "" { + t.Fatal("expected a non-empty refusal reason") + } + if _, ok := frameworks.GetDetector(def.ID); ok { + t.Fatalf("frameworks.GetDetector(%q) found a detector that should have been refused", def.ID) + } +} + +func TestBridgeFingerprint_ZeroWeightSignature_Refuses(t *testing.T) { + def := okFingerprintDef("fp-bridge-zero") + def.Fingerprint.Signatures = append(def.Fingerprint.Signatures, FPSignature{Pattern: "extra", Weight: 0}) + + registered, reason := bridgeFingerprint(def) + if registered { + t.Fatal("bridgeFingerprint registered a zero-weight-signature fingerprint") + } + if reason == "" { + t.Fatal("expected a non-empty refusal reason") + } + if _, ok := frameworks.GetDetector(def.ID); ok { + t.Fatalf("frameworks.GetDetector(%q) found a detector that should have been refused", def.ID) + } +} From 3b2a65e31b79faa6bbef4615ad2c62f84cca3af8 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:48:01 -0700 Subject: [PATCH 8/9] feat(modules): bridge root fingerprints into framework detectors --- internal/modules/bridge.go | 112 ++++++++++++++++++++ internal/modules/fingerprint_bridge_test.go | 87 +++++++++++++++ internal/modules/yaml.go | 6 ++ 3 files changed, 205 insertions(+) create mode 100644 internal/modules/bridge.go diff --git a/internal/modules/bridge.go b/internal/modules/bridge.go new file mode 100644 index 00000000..cb804b9b --- /dev/null +++ b/internal/modules/bridge.go @@ -0,0 +1,112 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "net/http" + "regexp" + + "github.com/vmfunc/sif/internal/scan/frameworks" +) + +// bridgeableToFramework reports whether def is a fingerprint whose semantics +// the framework engine can reproduce exactly: root path, default confidence, +// all weights > 0 (the C2 shared domain). anything else stays module-only. +// +// even on this domain the firing boundary still differs at score == 0.5: the +// module engine fires at score >= confidence, the framework engine at +// score > detectionThreshold. that residual is intentional, see bridgeFingerprint. +func bridgeableToFramework(def *YAMLModule) (bool, string) { + if def.Type != TypeFingerprint || def.Fingerprint == nil { + return false, "not a fingerprint module" + } + cfg := def.Fingerprint + if cfg.Path != "" && cfg.Path != "/" { + return false, "non-root path" + } + if cfg.Confidence != 0 { + return false, "custom confidence" + } + for _, s := range cfg.Signatures { + if s.Weight <= 0 { + return false, "zero-weight signature" + } + } + return true, "" +} + +// bridgeFingerprint registers def as a framework detector when +// bridgeableToFramework allows it, and reports whether it registered. a guard +// failure is not an error: the module still runs natively in the module +// engine, so bridging is pure upside or a no-op. +func bridgeFingerprint(def *YAMLModule) (bool, string) { + ok, reason := bridgeableToFramework(def) + if !ok { + return false, reason + } + + cfg := def.Fingerprint + sigs := make([]frameworks.Signature, len(cfg.Signatures)) + for i, s := range cfg.Signatures { + sigs[i] = frameworks.Signature{Pattern: s.Pattern, Weight: s.Weight, HeaderOnly: s.Header} + } + + d := &bridgedDetector{BaseDetector: frameworks.NewBaseDetector(def.ID, sigs)} + if cfg.Version != nil { + if re, err := regexp.Compile(cfg.Version.Regex); err == nil { + d.versionRe = re + d.versionGroup = cfg.Version.Group + } + } + + frameworks.Register(d) + return true, "" +} + +// bridgedDetector adapts a bridgeable fingerprint module into a +// frameworks.Detector. structurally the same as frameworks' own (unexported) +// customDetector; kept as a small local copy here rather than exporting that +// type, since frameworks cannot import modules (1.5, avoids an import cycle). +type bridgedDetector struct { + frameworks.BaseDetector + versionRe *regexp.Regexp + versionGroup int +} + +// Detect mirrors customDetector.Detect (custom.go): the weighted signature +// score plus an optional version capture. +func (d *bridgedDetector) Detect(body string, headers http.Header) (float32, string) { + confidence := d.MatchSignatures(body, headers) + if confidence == 0 || d.versionRe == nil { + return confidence, "" + } + matches := d.versionRe.FindStringSubmatch(body) + if len(matches) > d.versionGroup { + return confidence, matches[d.versionGroup] + } + return confidence, "" +} + +// BridgeFingerprints registers every already-loaded type: fingerprint module +// as a framework detector where bridgeableToFramework allows it. modules +// outside that domain are left untouched and keep running only in the module +// engine; bridging never removes or alters a module's native execution. +func BridgeFingerprints() { + for _, m := range ByType(TypeFingerprint) { + w, ok := m.(*yamlModuleWrapper) + if !ok { + continue + } + bridgeFingerprint(w.definition()) + } +} diff --git a/internal/modules/fingerprint_bridge_test.go b/internal/modules/fingerprint_bridge_test.go index a3b89943..63ff3a7a 100644 --- a/internal/modules/fingerprint_bridge_test.go +++ b/internal/modules/fingerprint_bridge_test.go @@ -1,6 +1,7 @@ package modules import ( + "math/rand" "net/http" "testing" @@ -77,6 +78,92 @@ func TestBridgeFingerprint_CustomConfidence_Refuses(t *testing.T) { } } +// TestBridgeFingerprint_MatchesNativeAcrossSampledInputs proves the bridged +// detector and the native module scorer agree on the shared domain, mirroring +// C2's TestScorerEquivalenceSharedDomain but through the bridge itself. +func TestBridgeFingerprint_MatchesNativeAcrossSampledInputs(t *testing.T) { + def := okFingerprintDef("fp-bridge-sampled") + registered, reason := bridgeFingerprint(def) + if !registered { + t.Fatalf("bridgeFingerprint registered = false, reason %q", reason) + } + det, ok := frameworks.GetDetector(def.ID) + if !ok { + t.Fatalf("frameworks.GetDetector(%q) not found after bridging", def.ID) + } + + tokens := []string{"nginx", "X-Powered-By", "cloudflare", "wp-content"} + r := rand.New(rand.NewSource(2)) + for iter := 0; iter < 500; iter++ { + body := "" + for _, tk := range tokens { + if r.Intn(2) == 0 { + body += tk + " " + } + } + headers := http.Header{} + for _, tk := range tokens { + if r.Intn(2) == 0 { + headers.Add(tk, "v") + } + } + got, _ := det.Detect(body, headers) + want, _ := scoreFingerprint(def.Fingerprint, body, headers) + if got != want { + t.Fatalf("iter %d: bridged=%v native=%v body=%q headers=%v", iter, got, want, body, headers) + } + } +} + +// TestBridgeFingerprint_ExactlyHalfBoundaryDiverges pins the one documented +// residual (3.4): on the shared domain the scores agree, but the module +// engine fires at score >= threshold (inclusive) while the framework engine's +// gate (detectionThreshold, applied by the caller as best.confidence <= +// detectionThreshold) is exclusive of exactly 0.5. this test only pins the +// score-equality half from inside the bridge; the exclusivity of the +// framework gate itself is proven by TestFrameworkThresholdIsStrict (C3). +func TestBridgeFingerprint_ExactlyHalfBoundaryDiverges(t *testing.T) { + def := okFingerprintDef("fp-bridge-half") + def.Fingerprint.Signatures = []FPSignature{ + {Pattern: "hit", Weight: 1}, + {Pattern: "miss", Weight: 1}, + } + registered, reason := bridgeFingerprint(def) + if !registered { + t.Fatalf("bridgeFingerprint registered = false, reason %q", reason) + } + det, ok := frameworks.GetDetector(def.ID) + if !ok { + t.Fatalf("frameworks.GetDetector(%q) not found after bridging", def.ID) + } + + body := "hit" + bridgedScore, _ := det.Detect(body, http.Header{}) + if bridgedScore != 0.5 { + t.Fatalf("bridged score = %v, want exactly 0.5", bridgedScore) + } + + nativeScore, _ := scoreFingerprint(def.Fingerprint, body, http.Header{}) + if nativeScore != 0.5 { + t.Fatalf("native score = %v, want exactly 0.5", nativeScore) + } + + // module engine: score >= threshold(0.5) fires (fingerprint.go:130). + moduleFires := nativeScore >= defaultFingerprintConfidence + if !moduleFires { + t.Fatal("module engine should fire at score == 0.5 (inclusive gate)") + } + + // framework engine: DetectFramework/DetectFrameworks require + // confidence > detectionThreshold(0.5) (detect.go:133/168), so an exact + // 0.5 does not clear it even though the score itself matches. + const detectionThreshold = 0.5 + frameworkFires := bridgedScore > detectionThreshold + if frameworkFires { + t.Fatal("framework engine should not fire at score == 0.5 (exclusive gate)") + } +} + func TestBridgeFingerprint_ZeroWeightSignature_Refuses(t *testing.T) { def := okFingerprintDef("fp-bridge-zero") def.Fingerprint.Signatures = append(def.Fingerprint.Signatures, FPSignature{Pattern: "extra", Weight: 0}) diff --git a/internal/modules/yaml.go b/internal/modules/yaml.go index 80304310..7a1bf17a 100644 --- a/internal/modules/yaml.go +++ b/internal/modules/yaml.go @@ -144,6 +144,12 @@ func newYAMLModuleWrapper(def *YAMLModule, path string) *yamlModuleWrapper { return &yamlModuleWrapper{def: def, path: path} } +// definition returns the wrapped YAMLModule so same-package callers (the +// fingerprint bridge) can reach fields the Module interface does not expose. +func (m *yamlModuleWrapper) definition() *YAMLModule { + return m.def +} + // Info returns the module metadata func (m *yamlModuleWrapper) Info() Info { return Info{ From 76ae3044e8ec39c0fa08fae89dbefd690ce78ccc Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:48:49 -0700 Subject: [PATCH 9/9] feat(frameworks): deprecate signatures dir in favor of fingerprint modules --- internal/modules/bridge.go | 4 +- internal/modules/fingerprint_bridge_test.go | 12 +-- internal/scan/frameworks/custom.go | 1 + .../frameworks/custom_deprecation_test.go | 76 +++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 internal/scan/frameworks/custom_deprecation_test.go diff --git a/internal/modules/bridge.go b/internal/modules/bridge.go index cb804b9b..0a31c4a3 100644 --- a/internal/modules/bridge.go +++ b/internal/modules/bridge.go @@ -21,7 +21,7 @@ import ( // bridgeableToFramework reports whether def is a fingerprint whose semantics // the framework engine can reproduce exactly: root path, default confidence, -// all weights > 0 (the C2 shared domain). anything else stays module-only. +// all weights > 0 (the shared domain). anything else stays module-only. // // even on this domain the firing boundary still differs at score == 0.5: the // module engine fires at score >= confidence, the framework engine at @@ -76,7 +76,7 @@ func bridgeFingerprint(def *YAMLModule) (bool, string) { // bridgedDetector adapts a bridgeable fingerprint module into a // frameworks.Detector. structurally the same as frameworks' own (unexported) // customDetector; kept as a small local copy here rather than exporting that -// type, since frameworks cannot import modules (1.5, avoids an import cycle). +// type, since frameworks cannot import modules and this avoids an import cycle. type bridgedDetector struct { frameworks.BaseDetector versionRe *regexp.Regexp diff --git a/internal/modules/fingerprint_bridge_test.go b/internal/modules/fingerprint_bridge_test.go index 63ff3a7a..59575a7a 100644 --- a/internal/modules/fingerprint_bridge_test.go +++ b/internal/modules/fingerprint_bridge_test.go @@ -10,7 +10,7 @@ import ( // okFingerprintDef builds a root-path, default-confidence, all-positive-weight // fingerprint module: the one shape the framework engine can reproduce exactly -// (C2's shared domain). +// (the shared domain). func okFingerprintDef(id string) *YAMLModule { return &YAMLModule{ ID: id, @@ -80,7 +80,7 @@ func TestBridgeFingerprint_CustomConfidence_Refuses(t *testing.T) { // TestBridgeFingerprint_MatchesNativeAcrossSampledInputs proves the bridged // detector and the native module scorer agree on the shared domain, mirroring -// C2's TestScorerEquivalenceSharedDomain but through the bridge itself. +// TestScorerEquivalenceSharedDomain but through the bridge itself. func TestBridgeFingerprint_MatchesNativeAcrossSampledInputs(t *testing.T) { def := okFingerprintDef("fp-bridge-sampled") registered, reason := bridgeFingerprint(def) @@ -116,12 +116,12 @@ func TestBridgeFingerprint_MatchesNativeAcrossSampledInputs(t *testing.T) { } // TestBridgeFingerprint_ExactlyHalfBoundaryDiverges pins the one documented -// residual (3.4): on the shared domain the scores agree, but the module -// engine fires at score >= threshold (inclusive) while the framework engine's -// gate (detectionThreshold, applied by the caller as best.confidence <= +// residual: on the shared domain the scores agree, but the module engine +// fires at score >= threshold (inclusive) while the framework engine's gate +// (detectionThreshold, applied by the caller as best.confidence <= // detectionThreshold) is exclusive of exactly 0.5. this test only pins the // score-equality half from inside the bridge; the exclusivity of the -// framework gate itself is proven by TestFrameworkThresholdIsStrict (C3). +// framework gate itself is proven by TestFrameworkThresholdIsStrict. func TestBridgeFingerprint_ExactlyHalfBoundaryDiverges(t *testing.T) { def := okFingerprintDef("fp-bridge-half") def.Fingerprint.Signatures = []FPSignature{ diff --git a/internal/scan/frameworks/custom.go b/internal/scan/frameworks/custom.go index 104bc6ed..65b391ae 100644 --- a/internal/scan/frameworks/custom.go +++ b/internal/scan/frameworks/custom.go @@ -152,6 +152,7 @@ func loadCustomDetectorsFromDir(dir string) int { } if len(detectors) > 0 { output.Module("FRAMEWORK").Info("Loaded %d custom signatures", len(detectors)) + output.Module("FRAMEWORK").Info("~/.config/sif/signatures is deprecated; write type: fingerprint modules under ~/.config/sif/modules instead") } return len(detectors) } diff --git a/internal/scan/frameworks/custom_deprecation_test.go b/internal/scan/frameworks/custom_deprecation_test.go new file mode 100644 index 00000000..90b52812 --- /dev/null +++ b/internal/scan/frameworks/custom_deprecation_test.go @@ -0,0 +1,76 @@ +package frameworks + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/vmfunc/sif/internal/output" +) + +// TestLegacyCustomSignatureDeprecationLogged extends the backcompat proof in +// TestLegacyCustomSignatureStillLoads (custom_backcompat_test.go): the legacy +// signatures/ dir must keep loading AND now also emit one deprecation notice +// steering users at the unified fingerprint-module surface. it must not fail, +// stop loading, or migrate anything. +func TestLegacyCustomSignatureDeprecationLogged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "acme.yaml") + yamlSrc := "name: acme\n" + + "signatures:\n" + + " - pattern: \"X-Acme\"\n" + + " weight: 1\n" + + " header: true\n" + if err := os.WriteFile(path, []byte(yamlSrc), 0o600); err != nil { + t.Fatal(err) + } + + var n int + stdout := captureStdout(t, func() { + n = loadCustomDetectorsFromDir(dir) + }) + + if n != 1 { + t.Fatalf("loadCustomDetectorsFromDir loaded %d detectors, want 1", n) + } + if _, ok := GetDetector("acme"); !ok { + t.Fatal("acme detector did not register") + } + if !strings.Contains(stdout, "Loaded 1 custom signatures") { + t.Fatalf("missing load-count line, got: %q", stdout) + } + if !strings.Contains(stdout, "deprecated") { + t.Fatalf("missing deprecation notice, got: %q", stdout) + } +} + +// captureStdout swaps os.Stdout for a pipe and repoints output's sink at it +// via SetSilent(false), which reads os.Stdout at call time (see +// internal/output/silent_test.go for the same idiom), then runs fn and +// returns everything written. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + saved := os.Stdout + os.Stdout = w + output.SetSilent(false) + + ch := make(chan string, 1) + go func() { + data, _ := io.ReadAll(r) + ch <- string(data) + }() + + fn() + + os.Stdout = saved + output.SetSilent(false) + w.Close() + return <-ch +}