Skip to content

Commit 9f9201b

Browse files
committed
feat(frameworks): add config-defined custom detectors
load yaml-defined detectors from ~/.config/sif/signatures (AppData\Local on windows), mirroring the user-modules convention, so a framework sif does not ship can be detected without a rebuild. they load lazily once per run from DetectFramework and register alongside the built-ins. each file is one detector, scored by the same weighted signature match as the built-ins. confidence is linear rather than their sigmoid (importing it would cycle), so a detector clears the 0.5 threshold once its matched weights pass half. a name matching a built-in overrides it and inherits that built-in's version patterns and cves, the same as a user module. a single unparseable file warns and is skipped rather than failing the scan. implements the custom signature support help-wanted item in contributing.
1 parent aa22e69 commit 9f9201b

4 files changed

Lines changed: 404 additions & 0 deletions

File tree

docs/configuration.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,38 @@ info:
9292
# ...
9393
```
9494

95+
## custom signatures
96+
97+
framework detection (`-framework`) also loads user-defined detectors from yaml
98+
files, so a framework sif does not ship can be detected without rebuilding:
99+
100+
- linux/macos: `~/.config/sif/signatures/`
101+
- windows: `%LOCALAPPDATA%\sif\signatures\`
102+
103+
each file defines one detector; place them directly in the directory, as
104+
subdirectories are not scanned. `header: true` matches a response header name or
105+
value (case-insensitive) instead of the body; the optional `version` block pulls
106+
a version out of the body.
107+
108+
```yaml
109+
# ~/.config/sif/signatures/ghost.yaml
110+
name: Ghost
111+
signatures:
112+
- pattern: 'content="Ghost'
113+
weight: 0.6
114+
- pattern: 'X-Ghost-Cache'
115+
weight: 0.4
116+
header: true
117+
version:
118+
regex: 'content="Ghost ([0-9.]+)'
119+
group: 1
120+
```
121+
122+
a detector reports a match once its matched signature weights sum past half, so
123+
weight your signatures to total about `1.0`. a name matching a built-in detector
124+
overrides it and inherits that built-in's version patterns and known cves, the
125+
same as user modules.
126+
95127
## performance tuning
96128

97129
### fast scans

internal/scan/frameworks/custom.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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+
/*
14+
15+
BSD 3-Clause License
16+
(c) 2022-2026 vmfunc, xyzeva & contributors
17+
18+
*/
19+
20+
package frameworks
21+
22+
import (
23+
"fmt"
24+
"math"
25+
"net/http"
26+
"os"
27+
"path/filepath"
28+
"regexp"
29+
"runtime"
30+
"strings"
31+
32+
charmlog "github.com/charmbracelet/log"
33+
"github.com/dropalldatabases/sif/internal/output"
34+
"gopkg.in/yaml.v3"
35+
)
36+
37+
// customDetector is a Detector defined in a user yaml file rather than compiled
38+
// in. it scores with the same weighted signature match as the built-ins and
39+
// optionally pulls a version out of the body.
40+
type customDetector struct {
41+
BaseDetector
42+
versionRe *regexp.Regexp
43+
versionGroup int
44+
}
45+
46+
// Detect returns the weighted signature confidence and, when a version regex is
47+
// set and matches, the captured version. confidence is the matched-weight
48+
// fraction directly (not the built-ins' sigmoid), so it clears 0.5 only past half.
49+
func (d *customDetector) Detect(body string, headers http.Header) (float32, string) {
50+
confidence := d.MatchSignatures(body, headers)
51+
if confidence == 0 || d.versionRe == nil {
52+
return confidence, ""
53+
}
54+
matches := d.versionRe.FindStringSubmatch(body)
55+
if len(matches) > d.versionGroup {
56+
return confidence, matches[d.versionGroup]
57+
}
58+
return confidence, ""
59+
}
60+
61+
// signatureSpec / versionSpec / customDetectorSpec mirror the yaml on disk.
62+
type signatureSpec struct {
63+
Pattern string `yaml:"pattern"`
64+
Weight float32 `yaml:"weight"`
65+
Header bool `yaml:"header"`
66+
}
67+
68+
type versionSpec struct {
69+
Regex string `yaml:"regex"`
70+
Group int `yaml:"group"`
71+
}
72+
73+
type customDetectorSpec struct {
74+
Name string `yaml:"name"`
75+
Signatures []signatureSpec `yaml:"signatures"`
76+
Version *versionSpec `yaml:"version"`
77+
}
78+
79+
// build validates the parsed spec and turns it into a Detector, so a broken
80+
// file fails loudly instead of registering a detector that can never match.
81+
func (spec customDetectorSpec) build() (Detector, error) {
82+
name := strings.TrimSpace(spec.Name)
83+
if name == "" {
84+
return nil, fmt.Errorf("missing name")
85+
}
86+
if len(spec.Signatures) == 0 {
87+
return nil, fmt.Errorf("%q has no signatures", name)
88+
}
89+
90+
sigs := make([]Signature, 0, len(spec.Signatures))
91+
for i, s := range spec.Signatures {
92+
if s.Pattern == "" {
93+
return nil, fmt.Errorf("%q: signature %d has an empty pattern", name, i+1)
94+
}
95+
if s.Weight <= 0 || math.IsInf(float64(s.Weight), 0) || math.IsNaN(float64(s.Weight)) {
96+
return nil, fmt.Errorf("%q: signature %q needs a positive, finite weight", name, s.Pattern)
97+
}
98+
sigs = append(sigs, Signature{Pattern: s.Pattern, Weight: s.Weight, HeaderOnly: s.Header})
99+
}
100+
101+
d := &customDetector{BaseDetector: NewBaseDetector(name, sigs)}
102+
if spec.Version != nil {
103+
if spec.Version.Group < 0 {
104+
return nil, fmt.Errorf("%q: version group must be >= 0", name)
105+
}
106+
re, err := regexp.Compile(spec.Version.Regex)
107+
if err != nil {
108+
return nil, fmt.Errorf("%q: version regex: %w", name, err)
109+
}
110+
d.versionRe = re
111+
d.versionGroup = spec.Version.Group
112+
}
113+
return d, nil
114+
}
115+
116+
// parseCustomDetector reads and validates one signature file.
117+
func parseCustomDetector(path string) (Detector, error) {
118+
data, err := os.ReadFile(path)
119+
if err != nil {
120+
return nil, fmt.Errorf("read: %w", err)
121+
}
122+
var spec customDetectorSpec
123+
if err := yaml.Unmarshal(data, &spec); err != nil {
124+
return nil, fmt.Errorf("parse: %w", err)
125+
}
126+
return spec.build()
127+
}
128+
129+
// customSignaturesDir is the per-user directory that holds yaml-defined
130+
// detectors, alongside the user modules directory.
131+
func customSignaturesDir() (string, error) {
132+
home, err := os.UserHomeDir()
133+
if err != nil {
134+
return "", err
135+
}
136+
if runtime.GOOS == "windows" {
137+
return filepath.Join(home, "AppData", "Local", "sif", "signatures"), nil
138+
}
139+
return filepath.Join(home, ".config", "sif", "signatures"), nil
140+
}
141+
142+
// loadCustomDetectors registers every signature file under the user directory.
143+
// it is driven once, lazily, from DetectFramework.
144+
func loadCustomDetectors() {
145+
dir, err := customSignaturesDir()
146+
if err != nil {
147+
return
148+
}
149+
loadCustomDetectorsFromDir(dir)
150+
}
151+
152+
// loadCustomDetectorsFromDir registers every signature file in dir and returns
153+
// how many loaded. a custom detector whose name matches a built-in overrides
154+
// it, matching the user-module convention.
155+
func loadCustomDetectorsFromDir(dir string) int {
156+
detectors := collectCustomDetectors(dir)
157+
for _, d := range detectors {
158+
Register(d)
159+
}
160+
if len(detectors) > 0 {
161+
output.Module("FRAMEWORK").Info("Loaded %d custom signatures", len(detectors))
162+
}
163+
return len(detectors)
164+
}
165+
166+
// collectCustomDetectors parses (without registering) the .yaml/.yml detectors
167+
// in dir, so discovery and validation stay pure and testable. a missing dir is
168+
// fine; an unparseable file warns and is skipped rather than failing the scan.
169+
func collectCustomDetectors(dir string) []Detector {
170+
entries, err := os.ReadDir(dir)
171+
if err != nil {
172+
return nil
173+
}
174+
175+
var detectors []Detector
176+
for _, e := range entries {
177+
if e.IsDir() {
178+
continue
179+
}
180+
switch filepath.Ext(e.Name()) {
181+
case ".yaml", ".yml":
182+
default:
183+
continue
184+
}
185+
d, err := parseCustomDetector(filepath.Join(dir, e.Name()))
186+
if err != nil {
187+
charmlog.Warnf("custom signature %s: %v", e.Name(), err)
188+
continue
189+
}
190+
detectors = append(detectors, d)
191+
}
192+
return detectors
193+
}

0 commit comments

Comments
 (0)