Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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. currently only `http` is supported.
module type. `http` (request and match) and `fingerprint` (weighted technology
detection) are supported.

```yaml
type: http
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/httpx/httpx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 2 additions & 4 deletions internal/modules/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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
}
Expand Down
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