Skip to content
41 changes: 40 additions & 1 deletion docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions internal/modules/bridge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package modules

import (
"net/http"
"regexp"

"github.com/charmbracelet/log"
"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 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
}
}

// 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, ""
}

// 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 and this 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, 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
}
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
}
196 changes: 196 additions & 0 deletions internal/modules/fingerprint.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading