Skip to content

Commit 830ad7b

Browse files
committed
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.
1 parent a38ba0a commit 830ad7b

6 files changed

Lines changed: 494 additions & 15 deletions

File tree

docs/modules.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ info:
6565

6666
### type (required)
6767

68-
module type. `http` and `tcp` are supported.
68+
module type. `http` (request and match), `tcp` (raw connection probe) and
69+
`fingerprint` (weighted technology detection) are supported.
6970

7071
```yaml
7172
type: http
@@ -392,6 +393,44 @@ extractors:
392393
- "data.version"
393394
```
394395

396+
## fingerprint modules
397+
398+
a `fingerprint` module identifies a technology by weighted signatures rather
399+
than a pass/fail match. each signature contributes its `weight` when it appears
400+
in the body (or, with `header: true`, in a response header name or value). the
401+
matched fraction of the total weight is the confidence; the module fires a
402+
single finding, carrying that confidence, once it reaches the threshold.
403+
404+
this is the same scoring the built-in framework detectors use, in the module
405+
format, so a custom technology fingerprint lives alongside your other modules.
406+
407+
```yaml
408+
id: acme-server
409+
info:
410+
name: ACME Server
411+
author: you
412+
severity: info
413+
tags: [tech, fingerprint]
414+
415+
type: fingerprint
416+
417+
fingerprint:
418+
path: / # request path, defaults to /
419+
confidence: 0.5 # minimum score to fire, defaults to 0.5
420+
signatures:
421+
- pattern: "acme" # matched against header name/value
422+
weight: 0.6
423+
header: true
424+
- pattern: "powered by acme"
425+
weight: 0.4 # body match; omit weight to default to 1
426+
version: # optional: pull a version out of the body
427+
regex: "acme/([0-9.]+)"
428+
group: 1
429+
```
430+
431+
a response with both signatures scores `1.0`; the header alone scores `0.6` and
432+
still clears the `0.5` threshold, while the body alone (`0.4`) does not.
433+
395434
## examples
396435

397436
### exposed git repository

internal/modules/fingerprint.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2026 vmfunc, xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package modules
14+
15+
import (
16+
"context"
17+
"fmt"
18+
"math"
19+
"net/http"
20+
"regexp"
21+
"strings"
22+
23+
"github.com/vmfunc/sif/internal/httpx"
24+
)
25+
26+
// FingerprintConfig defines a framework-fingerprint module: weighted body/header
27+
// signatures scored into a confidence, plus an optional version regex. It mirrors
28+
// the framework custom-detector format so user fingerprints and modules can share
29+
// one loader and directory.
30+
type FingerprintConfig struct {
31+
Path string `yaml:"path,omitempty"` // request path, default "/"
32+
Confidence float32 `yaml:"confidence,omitempty"` // min score to fire, default 0.5
33+
Signatures []FPSignature `yaml:"signatures"`
34+
Version *FPVersion `yaml:"version,omitempty"`
35+
}
36+
37+
// FPSignature is one weighted pattern. Header matches the response headers (name
38+
// or value, case-insensitive) instead of the body.
39+
type FPSignature struct {
40+
Pattern string `yaml:"pattern"`
41+
Weight float32 `yaml:"weight"`
42+
Header bool `yaml:"header"`
43+
}
44+
45+
// FPVersion pulls a version string out of the body via a capture group.
46+
type FPVersion struct {
47+
Regex string `yaml:"regex"`
48+
Group int `yaml:"group"`
49+
}
50+
51+
// defaultFingerprintConfidence is the score a fingerprint must reach to fire when
52+
// the module does not set its own threshold.
53+
const defaultFingerprintConfidence = 0.5
54+
55+
// validateFingerprint rejects a fingerprint config that can never produce a
56+
// meaningful score, so a broken module fails at load instead of silently never
57+
// matching. An omitted signature weight defaults to 1, so 0 is allowed.
58+
func validateFingerprint(cfg *FingerprintConfig) error {
59+
if cfg == nil {
60+
return fmt.Errorf("missing fingerprint configuration")
61+
}
62+
if len(cfg.Signatures) == 0 {
63+
return fmt.Errorf("fingerprint requires at least one signature")
64+
}
65+
for i, s := range cfg.Signatures {
66+
if s.Pattern == "" {
67+
return fmt.Errorf("signature %d has an empty pattern", i+1)
68+
}
69+
if s.Weight < 0 || math.IsInf(float64(s.Weight), 0) || math.IsNaN(float64(s.Weight)) {
70+
return fmt.Errorf("signature %q needs a non-negative, finite weight", s.Pattern)
71+
}
72+
}
73+
if cfg.Confidence < 0 || cfg.Confidence > 1 {
74+
return fmt.Errorf("confidence must be within [0, 1]")
75+
}
76+
if cfg.Version != nil {
77+
if cfg.Version.Group < 0 {
78+
return fmt.Errorf("version group must be >= 0")
79+
}
80+
if _, err := regexp.Compile(cfg.Version.Regex); err != nil {
81+
return fmt.Errorf("version regex: %w", err)
82+
}
83+
}
84+
return nil
85+
}
86+
87+
// ExecuteFingerprintModule fetches the target and scores it against the weighted
88+
// signatures, firing a single finding (with confidence and any version) once the
89+
// score reaches the threshold. The boolean matcher engine is not involved.
90+
func ExecuteFingerprintModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
91+
cfg := def.Fingerprint
92+
if cfg == nil {
93+
return nil, fmt.Errorf("no fingerprint configuration")
94+
}
95+
result := &Result{ModuleID: def.ID, Target: target, Findings: make([]Finding, 0)}
96+
97+
client := opts.Client
98+
if client == nil {
99+
client = &http.Client{Timeout: opts.Timeout}
100+
}
101+
102+
path := cfg.Path
103+
if path == "" {
104+
path = "/"
105+
}
106+
url := strings.TrimSuffix(target, "/") + path
107+
108+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
109+
if err != nil {
110+
return nil, err
111+
}
112+
resp, err := client.Do(req)
113+
if err != nil {
114+
// an unreachable target is simply no finding, not a module failure.
115+
return result, nil //nolint:nilerr // mirrors the http executor's swallow-per-request policy
116+
}
117+
defer resp.Body.Close()
118+
119+
body, err := httpx.ReadCappedBody(resp)
120+
if err != nil {
121+
return result, nil //nolint:nilerr // a body read error yields no finding, same as above
122+
}
123+
bodyStr := string(body)
124+
125+
score, version := scoreFingerprint(cfg, bodyStr, resp.Header)
126+
threshold := cfg.Confidence
127+
if threshold == 0 {
128+
threshold = defaultFingerprintConfidence
129+
}
130+
if score < threshold {
131+
return result, nil
132+
}
133+
134+
finding := Finding{
135+
URL: url,
136+
Severity: def.Info.Severity,
137+
Evidence: truncateEvidence(bodyStr),
138+
Confidence: score,
139+
}
140+
if version != "" {
141+
finding.Extracted = map[string]string{"version": version}
142+
}
143+
result.Findings = append(result.Findings, finding)
144+
return result, nil
145+
}
146+
147+
// scoreFingerprint returns the matched fraction of signature weight and, when a
148+
// version regex is set and the body matches, the captured version.
149+
func scoreFingerprint(cfg *FingerprintConfig, body string, headers http.Header) (float32, string) {
150+
var matched, total float32
151+
for _, s := range cfg.Signatures {
152+
w := s.Weight
153+
if w == 0 {
154+
w = 1
155+
}
156+
total += w
157+
if s.Header {
158+
if headerContains(headers, s.Pattern) {
159+
matched += w
160+
}
161+
} else if strings.Contains(body, s.Pattern) {
162+
matched += w
163+
}
164+
}
165+
if total == 0 {
166+
return 0, ""
167+
}
168+
score := matched / total
169+
170+
version := ""
171+
if cfg.Version != nil && score > 0 {
172+
if re, err := regexp.Compile(cfg.Version.Regex); err == nil {
173+
if g := re.FindStringSubmatch(body); len(g) > cfg.Version.Group {
174+
version = g[cfg.Version.Group]
175+
}
176+
}
177+
}
178+
return score, version
179+
}
180+
181+
// headerContains reports whether pattern appears in any header name or value,
182+
// case-insensitively, matching the framework detector's header semantics.
183+
func headerContains(headers http.Header, pattern string) bool {
184+
p := strings.ToLower(pattern)
185+
for name, values := range headers {
186+
if strings.Contains(strings.ToLower(name), p) {
187+
return true
188+
}
189+
for _, v := range values {
190+
if strings.Contains(strings.ToLower(v), p) {
191+
return true
192+
}
193+
}
194+
}
195+
return false
196+
}

0 commit comments

Comments
 (0)