From 221a56414c3027e7a0ae3a2ce41a4766fb2affd2 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 1/8] 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 7b5836d8..a29ba570 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -65,7 +65,8 @@ info: ### type (required) -module type. `http` and `tcp` are supported. +module type. `http` (request and match), `tcp` (connect and match on the +banner) and `fingerprint` (weighted technology detection) are supported. ```yaml type: http @@ -392,6 +393,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 29d4d3d3..f7bcffc7 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 ab34489c..8d2cb72b 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 @@ -165,6 +166,12 @@ func ParseYAMLModuleBytes(data []byte) (*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 } @@ -214,6 +221,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 cddc1c1d748f32a13de0ed9e43052b755064af73 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 2/8] 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 c7019f810733f9ca3fe1499c4374fe9d1fd5874f 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 3/8] 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 c8b8f658e3e53a394154c239775430e3129dd997 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 4/8] 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 177edef558a6a0a0d5c9109f4c6ddfd2abb00757 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 5/8] 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 4e0e1ea3b308d52db41aca5a95668751ad3465e4 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 6/8] 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 8d2cb72b..424a0e2d 100644 --- a/internal/modules/yaml.go +++ b/internal/modules/yaml.go @@ -186,6 +186,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 25224b54a817db08482e9a709efa8fa94a85f4dc 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 7/8] 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 +} From 7c75c6e1dae28330918fb0a87402f5d47865e433 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:38:36 -0700 Subject: [PATCH 8/8] feat(cli): wire fingerprint bridging into the run and stop double-reporting BridgeFingerprints had no caller. nothing ever reached the framework registry at runtime, so the bridge was scaffolding rather than the feature the title claims. call it before the target loop, gated on -framework. it has to sit up there: the per-target framework scan runs long before the module loader would otherwise be touched, so bridging any later has no effect on the detection it is meant to feed. bridging alone would then name a technology twice, once from the module engine and once from the framework engine, since the module keeps running natively. BridgeFingerprints now returns the ids it bridged and an implicit module run (-all-modules, -module-tags) skips them. naming a module with -modules is a direct request and still runs it. the two engines disagree on one boundary, the module engine fires at score >= confidence and the framework engine at score > threshold, so a bridged module sitting exactly at 0.5 is reported by neither in an implicit run. that is the residual bridgeableToFramework already documents. also refuse to bridge onto a name that is already registered. frameworks.Register clobbers by name and the bridged name is the module id, so a module called "WordPress" silently replaced the builtin detector. add frameworks.RegisterIfAbsent for the check and the insert under one lock. module loading is now behind app.loadModules, which loads once per run rather than once per target. --- internal/modules/bridge.go | 32 ++++-- internal/modules/fingerprint_bridge_test.go | 76 ++++++++++++++ internal/scan/frameworks/detector.go | 16 +++ sif.go | 109 ++++++++++++++++---- 4 files changed, 205 insertions(+), 28 deletions(-) diff --git a/internal/modules/bridge.go b/internal/modules/bridge.go index 0a31c4a3..e5e75dfc 100644 --- a/internal/modules/bridge.go +++ b/internal/modules/bridge.go @@ -16,6 +16,7 @@ import ( "net/http" "regexp" + "github.com/charmbracelet/log" "github.com/vmfunc/sif/internal/scan/frameworks" ) @@ -69,7 +70,12 @@ func bridgeFingerprint(def *YAMLModule) (bool, string) { } } - frameworks.Register(d) + // the name comes from a user-controlled module id and frameworks.Register + // clobbers by name, so a module id matching a builtin ("WordPress", + // "Django") would silently replace that detector. refuse instead. + if !frameworks.RegisterIfAbsent(d) { + return false, "detector name already registered" + } return true, "" } @@ -98,15 +104,29 @@ func (d *bridgedDetector) Detect(body string, headers http.Header) (float32, str } // 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() { +// as a framework detector where bridgeableToFramework allows it, and returns +// the set of module ids it bridged. 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. +// +// the returned set is what the caller needs to avoid reporting one technology +// from both engines: a bridged module is covered by framework detection, so the +// caller can leave it out of an implicit module run. see the call site in +// sif.go. +func BridgeFingerprints() map[string]bool { + bridged := make(map[string]bool) for _, m := range ByType(TypeFingerprint) { w, ok := m.(*yamlModuleWrapper) if !ok { continue } - bridgeFingerprint(w.definition()) + def := w.definition() + ok, reason := bridgeFingerprint(def) + if ok { + bridged[def.ID] = true + continue + } + log.Debugf("fingerprint %s not bridged to framework detection: %s", def.ID, reason) } + return bridged } diff --git a/internal/modules/fingerprint_bridge_test.go b/internal/modules/fingerprint_bridge_test.go index 59575a7a..a010123f 100644 --- a/internal/modules/fingerprint_bridge_test.go +++ b/internal/modules/fingerprint_bridge_test.go @@ -179,3 +179,79 @@ func TestBridgeFingerprint_ZeroWeightSignature_Refuses(t *testing.T) { t.Fatalf("frameworks.GetDetector(%q) found a detector that should have been refused", def.ID) } } + +// bridged names come from user-controlled module ids and frameworks.Register +// clobbers by name, so a module id that collides with a builtin detector would +// silently replace the builtin. the bridge must refuse instead. +func TestBridgeFingerprint_ExistingDetectorName_Refuses(t *testing.T) { + const name = "fp-bridge-collision" + builtin := &bridgedDetector{BaseDetector: frameworks.NewBaseDetector(name, []frameworks.Signature{ + {Pattern: "the-builtin-marker", Weight: 1}, + })} + frameworks.Register(builtin) + + def := okFingerprintDef(name) + registered, reason := bridgeFingerprint(def) + if registered { + t.Fatal("bridgeFingerprint overwrote an already-registered detector name") + } + if reason == "" { + t.Fatal("expected a non-empty refusal reason") + } + + // the original detector must still be the one in the registry. + got, ok := frameworks.GetDetector(name) + if !ok { + t.Fatal("the pre-existing detector was removed from the registry") + } + if conf, _ := got.Detect("the-builtin-marker", http.Header{}); conf == 0 { + t.Error("the registered detector no longer matches the builtin marker, it was clobbered") + } +} + +// BridgeFingerprints must report what it bridged so callers can tell which +// modules the framework engine now covers. +func TestBridgeFingerprintsReportsBridgedIDs(t *testing.T) { + ok := okFingerprintDef("fp-bridge-reported") + refused := okFingerprintDef("fp-bridge-not-reported") + refused.Fingerprint.Path = "/admin" + Register(&yamlModuleWrapper{def: ok}) + Register(&yamlModuleWrapper{def: refused}) + + bridged := BridgeFingerprints() + if !bridged[ok.ID] { + t.Errorf("bridged set is missing %q", ok.ID) + } + if bridged[refused.ID] { + t.Errorf("bridged set contains refused module %q", refused.ID) + } +} + +// the bridge is only worth anything if a bridged fingerprint actually +// participates in framework detection, which is what a registered detector +// buys. run the registered detector through the same scoring the framework +// engine uses and require it to clear the reporting floor on a real body. +func TestBridgedFingerprintScoresInFrameworkEngine(t *testing.T) { + def := okFingerprintDef("fp-bridge-scores") + Register(&yamlModuleWrapper{def: def}) + + if bridged := BridgeFingerprints(); !bridged[def.ID] { + t.Fatalf("%q was not bridged", def.ID) + } + + det, ok := frameworks.GetDetector(def.ID) + if !ok { + t.Fatalf("no detector registered for %q", def.ID) + } + + // both signatures present: the framework engine must see a full score. + conf, _ := det.Detect("served by nginx", http.Header{"X-Powered-By": {"PHP"}}) + if conf != 1 { + t.Errorf("bridged detector scored %v on a full match, want 1", conf) + } + + // nothing present: it must not invent a detection. + if conf, _ := det.Detect("static site", http.Header{}); conf != 0 { + t.Errorf("bridged detector scored %v on an unrelated response, want 0", conf) + } +} diff --git a/internal/scan/frameworks/detector.go b/internal/scan/frameworks/detector.go index 4d48aabd..0c05452e 100644 --- a/internal/scan/frameworks/detector.go +++ b/internal/scan/frameworks/detector.go @@ -59,6 +59,22 @@ func Register(d Detector) { registry[d.Name()] = d } +// RegisterIfAbsent registers d only when its name is free, and reports whether +// it did. Register clobbers by name, which is fine for the builtin detectors +// (each owns its name at init) but not for names derived from user-supplied +// module ids: a module called "WordPress" would silently replace the builtin. +// the check and the insert share one lock so two callers cannot both see the +// name as free. +func RegisterIfAbsent(d Detector) bool { + registryMu.Lock() + defer registryMu.Unlock() + if _, exists := registry[d.Name()]; exists { + return false + } + registry[d.Name()] = d + return true +} + // GetDetectors returns all registered detectors. func GetDetectors() map[string]Detector { registryMu.RLock() diff --git a/sif.go b/sif.go index 481e4fb2..91eb0817 100644 --- a/sif.go +++ b/sif.go @@ -48,6 +48,42 @@ type App struct { settings *config.Settings targets []string logFiles []string + + // both are written once in Run before any scanTarget goroutine starts and + // only read afterwards, so the worker pool sees them without a lock. + modulesLoaded bool + bridgedFingerprints map[string]bool +} + +// loadModules populates the module registry the first time it is called and is +// a no-op afterwards. it must not be called from scanTarget: that runs on the +// -concurrency worker pool, and modulesLoaded is not guarded. +// +// a loader that cannot be constructed is returned as an error so -list-modules +// can stay fatal; a scan downgrades it to a warning and carries on, which is +// the behaviour both paths had before they shared this helper. +func (app *App) loadModules() error { + if app.modulesLoaded { + return nil + } + loader, err := modules.NewLoader() + if err != nil { + return fmt.Errorf("failed to create module loader: %w", err) + } + if err := loader.LoadAll(); err != nil { + log.Warnf("Failed to load modules: %v", err) + } + builtin.Register() + app.modulesLoaded = true + return nil +} + +// wantsModules reports whether this run needs the module registry populated, +// either to run modules directly or to bridge fingerprints into framework +// detection. +func (app *App) wantsModules() bool { + return app.settings.Framework || app.settings.AllModules || + app.settings.Modules != "" || app.settings.ModuleTags != "" } // Version is set by main to the resolved build version and shown on the banner. @@ -237,17 +273,10 @@ func normalizeTarget(target string) (string, error) { func (app *App) Run() error { // Handle --list-modules before any other processing if app.settings.ListModules { - loader, err := modules.NewLoader() - if err != nil { - return fmt.Errorf("failed to create module loader: %w", err) - } - if err := loader.LoadAll(); err != nil { - log.Warnf("Failed to load modules: %v", err) + if err := app.loadModules(); err != nil { + return err } - // Register built-in Go modules - builtin.Register() - fmt.Println("Available modules:") for _, m := range modules.All() { info := m.Info() @@ -323,6 +352,22 @@ func (app *App) Run() error { } } + // load the module set once for the whole run, then register the fingerprint + // modules the framework engine can reproduce exactly as detectors, so + // framework detection covers yaml-defined technologies too. + // + // both have to happen before scanAllTargets: the per-target framework scan + // runs inside the worker pool, so bridging any later would miss it, and + // loading here keeps the pool off the shared registry write path. + if app.wantsModules() { + if err := app.loadModules(); err != nil { + log.Warnf("%v", err) + } + } + if app.settings.Framework { + app.bridgedFingerprints = modules.BridgeFingerprints() + } + results, err := app.scanAllTargets(storeDir, wantReport) if err != nil { return err @@ -717,26 +762,46 @@ func (app *App) scanTarget(url, storeDir string, wantReport bool) (targetScan, e // Load and run modules if app.settings.AllModules || app.settings.Modules != "" || app.settings.ModuleTags != "" { - loader, err := modules.NewLoader() - if err != nil { - log.Warnf("Failed to create module loader: %v", err) - } else { - if err := loader.LoadAll(); err != nil { - log.Warnf("Failed to load modules: %v", err) - } - - // Register built-in Go modules - builtin.Register() - - // Determine which modules to run + { + // the registry was populated once in Run; loading it per target + // would re-walk the module dirs on every url and, under + // -concurrency, from several workers at once. + + // Determine which modules to run. + // + // a bridged fingerprint is already reported by the framework + // engine, so running it here too would name the same technology + // twice. drop it from the implicit selections only: naming a module + // with -modules is a direct request and still runs it. + // + // the two engines disagree on one boundary, the module engine fires + // at score >= confidence and the framework engine at + // score > threshold, so a bridged module sitting exactly at 0.5 is + // reported by neither in an implicit run. that is the residual + // bridgeableToFramework already documents. var toRun []modules.Module + keep := func(ms []modules.Module) []modules.Module { + if len(app.bridgedFingerprints) == 0 { + return ms + } + out := make([]modules.Module, 0, len(ms)) + for _, m := range ms { + if id := m.Info().ID; app.bridgedFingerprints[id] { + log.Debugf("Skipping %s: covered by framework detection", id) + continue + } + out = append(out, m) + } + return out + } switch { case app.settings.AllModules: - toRun = modules.All() + toRun = keep(modules.All()) case app.settings.ModuleTags != "": for _, tag := range strings.Split(app.settings.ModuleTags, ",") { toRun = append(toRun, modules.ByTag(strings.TrimSpace(tag))...) } + toRun = keep(toRun) case app.settings.Modules != "": for _, id := range strings.Split(app.settings.Modules, ",") { if m, ok := modules.Get(strings.TrimSpace(id)); ok {