From b9cc277da337e937a6b224e7464369b19e730508 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/3] 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 81b05cce04cd2168a0f9c06980cfe19af257a21b 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/3] 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 5f8090014b212e191431f0444c8e0b1d66b51e66 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/3] 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) }