Skip to content

Commit 9eb7a02

Browse files
committed
fix(scoring): clamp fraction scorers to [0, 1]
scoreFingerprint and MatchSignatures divide matched weight by total weight with no bound on the result. validation rejects negative weights on real configs, but the math itself has no invariant, so any caller that skips validation gets a score outside [0, 1] used directly as reported confidence. clamp both to guard the pathological path; normal validated inputs already fall inside [0, 1] and are unaffected.
1 parent 55d82d6 commit 9eb7a02

4 files changed

Lines changed: 176 additions & 2 deletions

File tree

internal/modules/fingerprint.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func scoreFingerprint(cfg *FingerprintConfig, body string, headers http.Header)
165165
if total == 0 {
166166
return 0, ""
167167
}
168-
score := matched / total
168+
score := clampUnit(matched / total)
169169

170170
version := ""
171171
if cfg.Version != nil && score > 0 {
@@ -178,6 +178,25 @@ func scoreFingerprint(cfg *FingerprintConfig, body string, headers http.Header)
178178
return score, version
179179
}
180180

181+
// clampUnit bounds a fraction to [0, 1]. Weights are validated non-negative
182+
// and finite at load time so this is a no-op on any real config; it only
183+
// guards a caller that skips validation and feeds scoreFingerprint a
184+
// negative or NaN weight. NaN needs its own check: every ordered comparison
185+
// against NaN is false, so the < 0 and > 1 checks below would silently let
186+
// it through otherwise.
187+
func clampUnit(f float32) float32 {
188+
if math.IsNaN(float64(f)) {
189+
return 0
190+
}
191+
if f < 0 {
192+
return 0
193+
}
194+
if f > 1 {
195+
return 1
196+
}
197+
return f
198+
}
199+
181200
// headerContains reports whether pattern appears in any header name or value,
182201
// case-insensitively, matching the framework detector's header semantics.
183202
func headerContains(headers http.Header, pattern string) bool {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
"math"
17+
"net/http"
18+
"testing"
19+
)
20+
21+
// scoreFingerprint trusts validateFingerprint to keep weights non-negative;
22+
// fed a negative weight directly (as any caller bypassing load validation
23+
// would), the matched fraction must still be clamped to [0, 1] rather than
24+
// escaping it.
25+
func TestScoreFingerprintClampsOutOfRangeWeights(t *testing.T) {
26+
// weights 1 and -0.9 -> total 0.1; only the +1 sig matches: raw 1/0.1 = 10.
27+
cfgHigh := &FingerprintConfig{Signatures: []FPSignature{
28+
{Pattern: "yes", Weight: 1},
29+
{Pattern: "absent", Weight: -0.9},
30+
}}
31+
if score, _ := scoreFingerprint(cfgHigh, "yes only", make(http.Header)); score != 1 {
32+
t.Fatalf("score = %v, want clamped to 1", score)
33+
}
34+
35+
// only the -0.9 sig matches: raw -0.9/0.1 = -9.
36+
cfgLow := &FingerprintConfig{Signatures: []FPSignature{
37+
{Pattern: "absent", Weight: 1},
38+
{Pattern: "neg", Weight: -0.9},
39+
}}
40+
if score, _ := scoreFingerprint(cfgLow, "neg only", make(http.Header)); score != 0 {
41+
t.Fatalf("score = %v, want clamped to 0", score)
42+
}
43+
}
44+
45+
// a NaN weight (unreachable through validateFingerprint, but reachable by
46+
// any caller that builds a FingerprintConfig directly) must clamp to 0, not
47+
// pass through: every ordered comparison against NaN is false, so a naive
48+
// clamp of "< 0 -> 0, > 1 -> 1" silently returns NaN unchanged.
49+
func TestScoreFingerprintClampsNaNWeight(t *testing.T) {
50+
cfg := &FingerprintConfig{Signatures: []FPSignature{
51+
{Pattern: "yes", Weight: float32(math.NaN())},
52+
}}
53+
score, _ := scoreFingerprint(cfg, "yes only", make(http.Header))
54+
if score != 0 {
55+
t.Fatalf("score = %v, want NaN clamped to 0", score)
56+
}
57+
}
58+
59+
// the clamp must be a no-op on the validated domain: any signature set that
60+
// validateFingerprint would accept already scores within [0, 1].
61+
func TestScoreFingerprintClampIsNoopOnValidatedScore(t *testing.T) {
62+
cfg := &FingerprintConfig{Signatures: []FPSignature{
63+
{Pattern: "alpha", Weight: 1},
64+
{Pattern: "beta", Weight: 1},
65+
}}
66+
score, _ := scoreFingerprint(cfg, "only alpha here", make(http.Header))
67+
if score != 0.5 {
68+
t.Fatalf("score = %v, want unchanged 0.5", score)
69+
}
70+
}

internal/scan/frameworks/detector.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package frameworks
2121

2222
import (
23+
"math"
2324
"net/http"
2425
"strings"
2526
"sync"
@@ -128,7 +129,25 @@ func (b BaseDetector) MatchSignatures(body string, headers http.Header) float32
128129
return 0
129130
}
130131

131-
return weightedScore / totalWeight
132+
return clampUnit(weightedScore / totalWeight)
133+
}
134+
135+
// clampUnit bounds a fraction to [0, 1]. Signature weights are expected
136+
// non-negative and finite, so this is a no-op on any real detector; it only
137+
// guards a caller that feeds MatchSignatures a negative or NaN weight. NaN
138+
// needs its own check: every ordered comparison against NaN is false, so
139+
// the < 0 and > 1 checks below would silently let it through otherwise.
140+
func clampUnit(f float32) float32 {
141+
if math.IsNaN(float64(f)) {
142+
return 0
143+
}
144+
if f < 0 {
145+
return 0
146+
}
147+
if f > 1 {
148+
return 1
149+
}
150+
return f
132151
}
133152

134153
// headerValueContains reports whether the named header's value contains the signature.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 frameworks
14+
15+
import (
16+
"math"
17+
"net/http"
18+
"testing"
19+
)
20+
21+
// MatchSignatures shares scoreFingerprint's math and the same trust-the-caller
22+
// gap: a negative weight must not push the score outside [0, 1].
23+
func TestMatchSignaturesClampsOutOfRangeWeights(t *testing.T) {
24+
// weights 1 and -0.9 -> total 0.1; only the +1 sig matches: raw 1/0.1 = 10.
25+
high := NewBaseDetector("x", []Signature{
26+
{Pattern: "yes", Weight: 1},
27+
{Pattern: "absent", Weight: -0.9},
28+
})
29+
if score := high.MatchSignatures("yes only", http.Header{}); score != 1 {
30+
t.Fatalf("score = %v, want clamped to 1", score)
31+
}
32+
33+
// only the -0.9 sig matches: raw -0.9/0.1 = -9.
34+
low := NewBaseDetector("x", []Signature{
35+
{Pattern: "absent", Weight: 1},
36+
{Pattern: "neg", Weight: -0.9},
37+
})
38+
if score := low.MatchSignatures("neg only", http.Header{}); score != 0 {
39+
t.Fatalf("score = %v, want clamped to 0", score)
40+
}
41+
}
42+
43+
// a NaN weight (unreachable through the validating custom-detector loader,
44+
// but reachable by any caller that builds a Signature directly, as the
45+
// exported BaseDetector/NewBaseDetector allow) must clamp to 0, not pass
46+
// through: every ordered comparison against NaN is false, so a naive clamp
47+
// of "< 0 -> 0, > 1 -> 1" silently returns NaN unchanged.
48+
func TestMatchSignaturesClampsNaNWeight(t *testing.T) {
49+
d := NewBaseDetector("x", []Signature{
50+
{Pattern: "yes", Weight: float32(math.NaN())},
51+
})
52+
if score := d.MatchSignatures("yes only", http.Header{}); score != 0 {
53+
t.Fatalf("score = %v, want NaN clamped to 0", score)
54+
}
55+
}
56+
57+
// the clamp must not touch scores already inside [0, 1].
58+
func TestMatchSignaturesClampIsNoopOnValidatedScore(t *testing.T) {
59+
d := NewBaseDetector("x", []Signature{
60+
{Pattern: "alpha", Weight: 1},
61+
{Pattern: "beta", Weight: 1},
62+
})
63+
if score := d.MatchSignatures("only alpha here", http.Header{}); score != 0.5 {
64+
t.Fatalf("score = %v, want unchanged 0.5", score)
65+
}
66+
}

0 commit comments

Comments
 (0)