Skip to content
Open
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
112 changes: 112 additions & 0 deletions internal/modules/bridge.go
Original file line number Diff line number Diff line change
@@ -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 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 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. 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())
}
}
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
Loading
Loading