mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-08 02:08:33 -10:00
Compare commits
20
Commits
dev
...
nuclei-confidence
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40321dd68a | ||
|
|
55d17f5a29 | ||
|
|
b7e66ae57c | ||
|
|
9aef358094 | ||
|
|
b62180c641 | ||
|
|
1d3f1f5bc2 | ||
|
|
4b3c1633ee | ||
|
|
d2f862edf5 | ||
|
|
717062fd0e | ||
|
|
6c2ea4edcb | ||
|
|
1898b4aec4 | ||
|
|
e3864b60f2 | ||
|
|
187d855145 | ||
|
|
1f276227ff | ||
|
|
e0fd39a862 | ||
|
|
e0ed203765 | ||
|
|
3ae786749d | ||
|
|
b3881248ca | ||
|
|
1ddd00efef | ||
|
|
bc776d29a8 |
No files matched your search
@@ -23,6 +23,8 @@ extend-exclude = [
|
||||
]
|
||||
|
||||
[default.extend-identifiers]
|
||||
# Proper noun (OpenVAS scanner, source of the QoD confidence model)
|
||||
OpenVAS = "OpenVAS"
|
||||
# Go identifiers that cannot be renamed without breaking API
|
||||
MisMatched = "MisMatched"
|
||||
IsMisMatchedCert = "IsMisMatchedCert" # tlsx clients API
|
||||
|
||||
@@ -469,6 +469,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVarP(&options.DisableHTTPProbe, "no-httpx", "nh", false, "disable httpx probing for non-url input"),
|
||||
flagSet.BoolVar(&options.PreflightPortScan, "preflight-portscan", false, "run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)"),
|
||||
flagSet.BoolVar(&options.DisableStdin, "no-stdin", false, "disable stdin processing"),
|
||||
flagSet.BoolVarP(&options.ConfidenceBaseline, "confidence-baseline", "cob", false, "send a per-host control request to lower confidence on catch-all (false-positive) matches"),
|
||||
flagSet.BoolVar(&options.EnableHTTPCache, "http-cache", false, "enable HTTP cache (RFC 9111) for HTTP requests"),
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/projectfile"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/automaticscan"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/baseline"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/globalmatchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/honeypotdetector"
|
||||
@@ -695,6 +696,10 @@ func (r *Runner) RunEnumeration() error {
|
||||
executorOpts.ExportReqURLPattern = true
|
||||
}
|
||||
|
||||
if r.options.ConfidenceBaseline {
|
||||
executorOpts.BaselineCache = baseline.New()
|
||||
}
|
||||
|
||||
if len(r.options.SecretsFile) > 0 && !r.options.Validate {
|
||||
// Clone options so GetAuthTmplStore can modify them without affecting the original
|
||||
authOptions := r.options.Copy()
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package operators
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
|
||||
)
|
||||
|
||||
// Confidence tiers reported alongside a result. They express how reliable the
|
||||
// detection is, independent of severity (how bad the issue is). The split
|
||||
// follows the Burp "tentative/firm/certain" and OpenVAS QoD models.
|
||||
const (
|
||||
ConfidenceLow = "low"
|
||||
ConfidenceMedium = "medium"
|
||||
ConfidenceHigh = "high"
|
||||
)
|
||||
|
||||
// confidence is reported on a 0-100 scale modelled on OpenVAS "Quality of
|
||||
// Detection" (QoD). Tier thresholds align with the scale; OpenVAS uses 70 as its
|
||||
// default trustworthy cutoff, which sits inside the medium band here.
|
||||
const (
|
||||
confidenceMediumThreshold = 50
|
||||
confidenceHighThreshold = 80
|
||||
// ConfidenceCertain is the score for out-of-band confirmed detections
|
||||
// (e.g. an interactsh interaction): unambiguous proof, so the maximum.
|
||||
ConfidenceCertain = 100
|
||||
// ConfidenceCatchAll is the clamped score for matches that also fire against
|
||||
// the host's catch-all baseline: a strong false-positive signal.
|
||||
ConfidenceCatchAll = 10
|
||||
)
|
||||
|
||||
// Base reliability of each evidence class. The ordering follows the black-box
|
||||
// scanner literature and QoD detection methods: matching on a status code or
|
||||
// response size is the most false-positive prone form of inference (an HTTP 500
|
||||
// can be any error, a banner can be backported), matching content with a
|
||||
// word is moderate, and matching content with a regex/dsl/binary/xpath is the
|
||||
// strongest in-band signal. Out-of-band confirmation (handled by the caller)
|
||||
// outranks all of these.
|
||||
const (
|
||||
weightStatusOrSize = 25
|
||||
// weightLLM scores an llm matcher. A model verdict is a judgement rather
|
||||
// than a deterministic check of the response, so it sits below word
|
||||
// matching and needs deterministic matchers to reach a higher tier.
|
||||
weightLLM = 40
|
||||
weightWordHeader = 45
|
||||
weightStrongHeader = 55
|
||||
weightWordContent = 60
|
||||
weightStrongContent = 75
|
||||
|
||||
// corroboration adds confidence only across DISTINCT, AND-combined evidence
|
||||
// classes (independent confirmation), with diminishing returns.
|
||||
corroborationPerClass = 10
|
||||
corroborationCap = 20
|
||||
negativeMatcherBonus = 5
|
||||
extractorBonus = 3
|
||||
// multiPatternBonus rewards a single matcher that requires several distinct
|
||||
// patterns to all be present (condition: and / match-all). Needing many
|
||||
// independent strings is far harder to trigger by accident than one.
|
||||
multiPatternBonus = 8
|
||||
)
|
||||
|
||||
// dslWeakIndicators are DSL variables that only inspect response metadata.
|
||||
// dslContentIndicators signal that a DSL expression actually inspects the
|
||||
// response body/headers, which is a far stronger detection method.
|
||||
var (
|
||||
dslWeakIndicators = []string{"status_code", "content_length", "duration"}
|
||||
dslContentIndicators = []string{"body", "header", "all_headers", "contains", "regex", "len(", "tolower", "toupper", "title", "hash", "md5", "sha", "base64", "html", "response", "data"}
|
||||
)
|
||||
|
||||
// dslWeakOnly reports whether a DSL matcher only inspects status/size metadata.
|
||||
// Such a matcher is a "strong" matcher type but carries status-level reliability
|
||||
// (an HTTP 500 can be any error), so it must not be scored as body evidence.
|
||||
func dslWeakOnly(m *matchers.Matcher) bool {
|
||||
if len(m.DSL) == 0 {
|
||||
return false
|
||||
}
|
||||
weak := false
|
||||
for _, expr := range m.DSL {
|
||||
e := strings.ToLower(expr)
|
||||
for _, c := range dslContentIndicators {
|
||||
if strings.Contains(e, c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, w := range dslWeakIndicators {
|
||||
if strings.Contains(e, w) {
|
||||
weak = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return weak
|
||||
}
|
||||
|
||||
// requiresAllPatterns reports whether every pattern in the matcher must be
|
||||
// present (condition: and or match-all), rather than any single one.
|
||||
func requiresAllPatterns(m *matchers.Matcher) bool {
|
||||
return m.MatchAll || strings.EqualFold(m.Condition, "and")
|
||||
}
|
||||
|
||||
// patternCount counts the independent content patterns a matcher carries.
|
||||
// Status/size codes are intentionally excluded: matching any of several status
|
||||
// codes broadens rather than narrows a match.
|
||||
func patternCount(m *matchers.Matcher) int {
|
||||
return len(m.Words) + len(m.Regex) + len(m.Binary) + len(m.DSL) + len(m.XPath)
|
||||
}
|
||||
|
||||
// metadataParts are response parts that carry weak, false-positive-prone signal
|
||||
// across every protocol: protocol metadata (status/size/rcode/duration), HTTP
|
||||
// response headers (the generic part and the common header fields used directly)
|
||||
// and request-side echoes. Matching these is more backportable / accidental than
|
||||
// matching real response content. Everything not listed here (response bodies,
|
||||
// DNS answer/ns/extra sections, TLS certificate fields, command stdout/stderr,
|
||||
// websocket/whois/javascript payloads, ...) is treated as content, so non-HTTP
|
||||
// protocols are scored on the strength of what they actually inspect.
|
||||
var metadataParts = map[string]struct{}{
|
||||
"header": {}, "all_headers": {}, "headers": {},
|
||||
"status": {}, "status_code": {}, "size": {}, "content_length": {}, "duration": {},
|
||||
"rcode": {},
|
||||
"request": {}, "host": {}, "ip": {}, "port": {}, "matched": {}, "type": {},
|
||||
// common HTTP response header fields that templates match on directly
|
||||
"content_type": {}, "content-type": {}, "server": {}, "location": {},
|
||||
"set_cookie": {}, "set-cookie": {}, "www_authenticate": {}, "www-authenticate": {},
|
||||
"x_powered_by": {}, "x-powered-by": {}, "cache_control": {}, "cache-control": {},
|
||||
}
|
||||
|
||||
// stripIndexSuffix removes the _<n> suffix that flow / multi-request templates
|
||||
// append to response parts (body_2, header_3, content_type_2) so they classify
|
||||
// the same as their base part.
|
||||
func stripIndexSuffix(p string) string {
|
||||
i := strings.LastIndexByte(p, '_')
|
||||
if i <= 0 || i == len(p)-1 {
|
||||
return p
|
||||
}
|
||||
for j := i + 1; j < len(p); j++ {
|
||||
if p[j] < '0' || p[j] > '9' {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return p[:i]
|
||||
}
|
||||
|
||||
// isContentPart reports whether a matcher part inspects response content rather
|
||||
// than protocol metadata or headers. Content is the default: only the known
|
||||
// weak metadata parts are excluded. Content matches are harder to trigger by
|
||||
// accident, so they carry higher confidence weight.
|
||||
func isContentPart(part string) bool {
|
||||
p := stripIndexSuffix(strings.ToLower(strings.TrimSpace(part)))
|
||||
_, meta := metadataParts[p]
|
||||
return !meta
|
||||
}
|
||||
|
||||
// evidenceClass identifies an independent line of evidence. Corroboration is
|
||||
// rewarded across distinct classes, not redundant repeats of the same weak
|
||||
// matcher, so three status matchers do not masquerade as strong evidence.
|
||||
type evidenceClass struct {
|
||||
kind string
|
||||
content bool
|
||||
}
|
||||
|
||||
// classify returns the evidence class and base reliability weight for a matcher.
|
||||
func classify(m *matchers.Matcher) (evidenceClass, int) {
|
||||
content := isContentPart(m.Part)
|
||||
switch m.Type.MatcherType {
|
||||
case matchers.StatusMatcher:
|
||||
return evidenceClass{kind: "status"}, weightStatusOrSize
|
||||
case matchers.SizeMatcher:
|
||||
return evidenceClass{kind: "size"}, weightStatusOrSize
|
||||
case matchers.WordsMatcher:
|
||||
if content {
|
||||
return evidenceClass{kind: "word", content: true}, weightWordContent
|
||||
}
|
||||
return evidenceClass{kind: "word"}, weightWordHeader
|
||||
case matchers.LLMMatcher:
|
||||
// one class whatever part it reads: several llm matchers are not
|
||||
// independent evidence, but an llm matcher AND-combined with a
|
||||
// deterministic matcher still counts as corroboration.
|
||||
return evidenceClass{kind: "llm"}, weightLLM
|
||||
case matchers.DSLMatcher:
|
||||
if dslWeakOnly(m) {
|
||||
// a dsl that only checks status_code/content_length is metadata
|
||||
// inference, not body evidence, despite being a "strong" type.
|
||||
return evidenceClass{kind: "status"}, weightStatusOrSize
|
||||
}
|
||||
if content {
|
||||
return evidenceClass{kind: "strong", content: true}, weightStrongContent
|
||||
}
|
||||
return evidenceClass{kind: "strong"}, weightStrongHeader
|
||||
default: // regex, binary, xpath
|
||||
if content {
|
||||
return evidenceClass{kind: "strong", content: true}, weightStrongContent
|
||||
}
|
||||
return evidenceClass{kind: "strong"}, weightStrongHeader
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence computes a static, deterministic detection-confidence score (0-100)
|
||||
// and tier from the template's matcher composition. It rewards strong matcher
|
||||
// techniques, content-level matching, independent corroboration across distinct
|
||||
// AND-combined evidence classes and negative matchers, while capping redundant
|
||||
// weak matchers. Hidden (internal) helper matchers are ignored. The score is
|
||||
// derived from the template definition alone and sends no additional traffic;
|
||||
// out-of-band confirmation is folded in by the caller.
|
||||
func (operators *Operators) Confidence() (int, string) {
|
||||
if operators == nil || len(operators.Matchers) == 0 {
|
||||
// extraction-only or matcher-less detections carry the weakest signal
|
||||
return 0, ConfidenceLow
|
||||
}
|
||||
|
||||
// positive (direct) evidence and negative (absence) evidence are tracked
|
||||
// separately: a negative matcher behaves as a false-positive guard when it
|
||||
// accompanies positive evidence, but is the detection itself when it stands
|
||||
// alone (e.g. token-spray validity checks, "setting reverted" CVE steps).
|
||||
classes := make(map[evidenceClass]struct{})
|
||||
best := 0
|
||||
hasMultiPattern := false
|
||||
|
||||
negClasses := make(map[evidenceClass]struct{})
|
||||
negBest := 0
|
||||
negMultiPattern := false
|
||||
negCount := 0
|
||||
|
||||
for _, m := range operators.Matchers {
|
||||
if m == nil || m.Internal {
|
||||
// internal matchers feed dynamic extraction chains and are hidden
|
||||
// from output, so they are not evidence for the user-facing match.
|
||||
continue
|
||||
}
|
||||
class, weight := classify(m)
|
||||
// a single matcher that requires several distinct content patterns to
|
||||
// all hold is highly specific. Status/size and weak-dsl matchers are
|
||||
// excluded (weight gate) since matching any of several codes broadens.
|
||||
// An llm matcher gets no bonus: its inputs are not independent patterns.
|
||||
multi := weight > weightStatusOrSize && class.kind != "llm" && requiresAllPatterns(m) && patternCount(m) > 1
|
||||
if m.Negative {
|
||||
negCount++
|
||||
negClasses[class] = struct{}{}
|
||||
if weight > negBest {
|
||||
negBest = weight
|
||||
}
|
||||
if multi {
|
||||
negMultiPattern = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
classes[class] = struct{}{}
|
||||
if weight > best {
|
||||
best = weight
|
||||
}
|
||||
if multi {
|
||||
hasMultiPattern = true
|
||||
}
|
||||
}
|
||||
|
||||
// a negative matcher is a guard only when it backs up positive evidence.
|
||||
negativeAsGuard := best > 0 && negCount > 0
|
||||
if best == 0 {
|
||||
// no positive evidence: the absence assertions are the detection. Score
|
||||
// them by their own weight rather than discarding the match entirely.
|
||||
best, classes, hasMultiPattern = negBest, negClasses, negMultiPattern
|
||||
}
|
||||
if best == 0 {
|
||||
return 0, ConfidenceLow
|
||||
}
|
||||
|
||||
score := best
|
||||
if hasMultiPattern {
|
||||
score += multiPatternBonus
|
||||
}
|
||||
// Independent corroboration raises confidence, but only when every matcher
|
||||
// must hold (AND) and only across distinct evidence classes.
|
||||
if operators.matchersCondition == matchers.ANDCondition && len(classes) > 1 {
|
||||
bonus := (len(classes) - 1) * corroborationPerClass
|
||||
if bonus > corroborationCap {
|
||||
bonus = corroborationCap
|
||||
}
|
||||
score += bonus
|
||||
}
|
||||
if negativeAsGuard {
|
||||
score += negativeMatcherBonus
|
||||
}
|
||||
if len(operators.Extractors) > 0 {
|
||||
score += extractorBonus
|
||||
}
|
||||
if score > ConfidenceCertain {
|
||||
score = ConfidenceCertain
|
||||
}
|
||||
return score, ScoreToTier(score)
|
||||
}
|
||||
|
||||
// ScoreToTier maps a 0-100 confidence score to its tier.
|
||||
func ScoreToTier(score int) string {
|
||||
switch {
|
||||
case score >= confidenceHighThreshold:
|
||||
return ConfidenceHigh
|
||||
case score >= confidenceMediumThreshold:
|
||||
return ConfidenceMedium
|
||||
default:
|
||||
return ConfidenceLow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package operators
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func matcher(t matchers.MatcherType, part string) *matchers.Matcher {
|
||||
return &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: t}, Part: part}
|
||||
}
|
||||
|
||||
func TestConfidenceTier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operators *Operators
|
||||
wantTier string
|
||||
}{
|
||||
{
|
||||
name: "nil operators",
|
||||
operators: nil,
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "no matchers",
|
||||
operators: &Operators{},
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "single status matcher is low",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.StatusMatcher, "")},
|
||||
},
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "redundant status matchers stay low",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
},
|
||||
},
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "single body word is medium",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "body")},
|
||||
},
|
||||
wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "single body regex is medium",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.RegexMatcher, "body")},
|
||||
},
|
||||
wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "or condition takes strongest matcher",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ORCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
matcher(matchers.DSLMatcher, "body"),
|
||||
},
|
||||
},
|
||||
wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "distinct and-combined classes reach high",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.WordsMatcher, "body"),
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
},
|
||||
},
|
||||
wantTier: ConfidenceHigh,
|
||||
},
|
||||
{
|
||||
name: "status with extractor stays low",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.StatusMatcher, "")},
|
||||
Extractors: []*extractors.Extractor{{}},
|
||||
},
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, tier := tt.operators.Confidence()
|
||||
require.Equal(t, tt.wantTier, tier)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfidenceCorroboration verifies that confidence rises only with distinct
|
||||
// independent evidence, not with redundant repeats of the same matcher class.
|
||||
func TestConfidenceCorroboration(t *testing.T) {
|
||||
distinct := &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
matcher(matchers.WordsMatcher, "header"),
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
},
|
||||
}
|
||||
redundant := &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
},
|
||||
}
|
||||
distinctScore, _ := distinct.Confidence()
|
||||
redundantScore, _ := redundant.Confidence()
|
||||
require.Greater(t, distinctScore, redundantScore, "distinct evidence classes must outscore redundant repeats of one class")
|
||||
}
|
||||
|
||||
// TestConfidenceNegativeMatcherBonus verifies a negative matcher (explicit
|
||||
// exclusion of generic/error noise) raises confidence over an identical match
|
||||
// without it.
|
||||
func TestConfidenceNegativeMatcherBonus(t *testing.T) {
|
||||
base := &Operators{Matchers: []*matchers.Matcher{matcher(matchers.RegexMatcher, "body")}}
|
||||
withNegative := &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Negative: true},
|
||||
},
|
||||
}
|
||||
baseScore, _ := base.Confidence()
|
||||
negScore, _ := withNegative.Confidence()
|
||||
require.Greater(t, negScore, baseScore, "negative matcher should add a specificity bonus")
|
||||
}
|
||||
|
||||
// TestConfidenceNegativeMatcherIsNotPositiveEvidence verifies that a negative
|
||||
// (absence) guard does not contribute its matcher weight as positive evidence.
|
||||
// A status-only match guarded by "body must not contain an error string" is
|
||||
// still only a status code match and must stay low confidence.
|
||||
func TestConfidenceNegativeMatcherIsNotPositiveEvidence(t *testing.T) {
|
||||
guarded := &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.RegexMatcher}, Part: "body", Negative: true},
|
||||
},
|
||||
}
|
||||
score, tier := guarded.Confidence()
|
||||
require.Equal(t, ConfidenceLow, tier, "a strong negative guard must not lift a status-only match out of low")
|
||||
require.LessOrEqual(t, score, weightStatusOrSize+negativeMatcherBonus, "negative matcher weight must not count as positive evidence")
|
||||
}
|
||||
|
||||
// TestConfidenceIgnoresInternalMatchers verifies hidden helper matchers used for
|
||||
// dynamic extraction do not count as user-facing evidence.
|
||||
func TestConfidenceIgnoresInternalMatchers(t *testing.T) {
|
||||
ops := &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, Part: "body", Internal: true},
|
||||
},
|
||||
}
|
||||
score, tier := ops.Confidence()
|
||||
require.Equal(t, weightStatusOrSize, score, "internal matcher must be ignored, leaving only the status weight")
|
||||
require.Equal(t, ConfidenceLow, tier)
|
||||
}
|
||||
|
||||
// neg builds a negative matcher of the given type/part.
|
||||
func neg(t matchers.MatcherType, part string) *matchers.Matcher {
|
||||
return &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: t}, Part: part, Negative: true}
|
||||
}
|
||||
|
||||
// TestConfidenceRealWorldScenarios pins the score band and tier for matcher
|
||||
// compositions that mirror real nuclei templates, so the scorer stays sensible
|
||||
// as it evolves.
|
||||
func TestConfidenceRealWorldScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operators *Operators
|
||||
minScore int
|
||||
maxScore int
|
||||
wantTier string
|
||||
}{
|
||||
{
|
||||
name: "host-up status probe",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.StatusMatcher, "")},
|
||||
},
|
||||
minScore: 1, maxScore: weightStatusOrSize, wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "tech detection single header banner",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "header")},
|
||||
},
|
||||
minScore: weightWordHeader, maxScore: weightWordHeader, wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "exposed panel title word",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "body")},
|
||||
},
|
||||
minScore: weightWordContent, maxScore: weightWordContent, wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "favicon hash dsl",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{matcher(matchers.DSLMatcher, "")},
|
||||
},
|
||||
minScore: weightStrongContent, maxScore: weightStrongContent, wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "cve body regex and status with extractor",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
matcher(matchers.StatusMatcher, ""),
|
||||
},
|
||||
Extractors: []*extractors.Extractor{{}},
|
||||
},
|
||||
minScore: confidenceHighThreshold, maxScore: ConfidenceCertain, wantTier: ConfidenceHigh,
|
||||
},
|
||||
{
|
||||
name: "strong body match guarded by negative error page",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.WordsMatcher, "body"),
|
||||
neg(matchers.WordsMatcher, "body"),
|
||||
},
|
||||
},
|
||||
minScore: weightWordContent, maxScore: weightWordContent + negativeMatcherBonus, wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "fingerprint or any of several strong signals",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ORCondition,
|
||||
Matchers: []*matchers.Matcher{
|
||||
matcher(matchers.RegexMatcher, "body"),
|
||||
matcher(matchers.DSLMatcher, "body"),
|
||||
matcher(matchers.WordsMatcher, "header"),
|
||||
},
|
||||
},
|
||||
minScore: weightStrongContent, maxScore: weightStrongContent, wantTier: ConfidenceMedium,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, tier := tt.operators.Confidence()
|
||||
require.GreaterOrEqual(t, score, tt.minScore, "score below expected band")
|
||||
require.LessOrEqual(t, score, tt.maxScore, "score above expected band")
|
||||
require.Equal(t, tt.wantTier, tier)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfidenceNegativeOnlyIsEvidence verifies that when a detection has no
|
||||
// positive matchers, a sole negative (absence) matcher is scored as the
|
||||
// detection itself rather than discarded. This mirrors token-spray validity
|
||||
// checks and "setting reverted" CVE steps.
|
||||
func TestConfidenceNegativeOnlyIsEvidence(t *testing.T) {
|
||||
negativeOnly := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"Access Token Invalid"}, Negative: true},
|
||||
}}
|
||||
positive := &Operators{Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "body")}}
|
||||
|
||||
negScore, negTier := negativeOnly.Confidence()
|
||||
posScore, _ := positive.Confidence()
|
||||
|
||||
require.Equal(t, weightWordContent, negScore, "a sole negative body matcher should score as a body match")
|
||||
require.Equal(t, ConfidenceMedium, negTier)
|
||||
require.Equal(t, posScore, negScore, "negative-only evidence should match the equivalent positive matcher weight")
|
||||
}
|
||||
|
||||
// TestConfidenceMultiPatternBonus verifies a single matcher requiring several
|
||||
// distinct content patterns (condition: and) outscores the same matcher with a
|
||||
// single pattern, while a broad any-of (or) matcher gets no bonus.
|
||||
func TestConfidenceMultiPatternBonus(t *testing.T) {
|
||||
single := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a"}},
|
||||
}}
|
||||
multiAnd := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a", "b", "c"}, Condition: "and"},
|
||||
}}
|
||||
multiOr := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a", "b", "c"}, Condition: "or"},
|
||||
}}
|
||||
matchAll := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a", "b"}, MatchAll: true},
|
||||
}}
|
||||
|
||||
singleScore, _ := single.Confidence()
|
||||
multiAndScore, _ := multiAnd.Confidence()
|
||||
multiOrScore, _ := multiOr.Confidence()
|
||||
matchAllScore, _ := matchAll.Confidence()
|
||||
|
||||
require.Greater(t, multiAndScore, singleScore, "all-of multi-pattern matcher should outscore single pattern")
|
||||
require.Equal(t, singleScore, multiOrScore, "any-of multi-pattern matcher should not earn the bonus")
|
||||
require.Greater(t, matchAllScore, singleScore, "match-all matcher should earn the multi-pattern bonus")
|
||||
}
|
||||
|
||||
// TestConfidenceDSLWeakOnly verifies a dsl matcher that only inspects status or
|
||||
// size metadata is scored as weak as a status match, while a dsl that inspects
|
||||
// the body keeps strong-content reliability.
|
||||
func TestConfidenceDSLWeakOnly(t *testing.T) {
|
||||
weak := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, DSL: []string{"status_code == 200"}},
|
||||
}}
|
||||
weakCombo := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, DSL: []string{"status_code == 200 && content_length > 10"}},
|
||||
}}
|
||||
strong := &Operators{Matchers: []*matchers.Matcher{
|
||||
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, DSL: []string{"contains(body, 'SecretMarker') && status_code == 200"}},
|
||||
}}
|
||||
|
||||
weakScore, weakTier := weak.Confidence()
|
||||
weakComboScore, _ := weakCombo.Confidence()
|
||||
strongScore, strongTier := strong.Confidence()
|
||||
|
||||
require.Equal(t, weightStatusOrSize, weakScore, "metadata-only dsl must score as a status match")
|
||||
require.Equal(t, ConfidenceLow, weakTier)
|
||||
require.Equal(t, weightStatusOrSize, weakComboScore, "status+size-only dsl is still metadata inference")
|
||||
require.Equal(t, weightStrongContent, strongScore, "body-inspecting dsl keeps strong-content weight")
|
||||
require.Equal(t, ConfidenceMedium, strongTier)
|
||||
}
|
||||
|
||||
// TestIsContentPartAcrossProtocols verifies the part classifier recognizes
|
||||
// content vs metadata parts for every protocol, including numbered suffixes
|
||||
// emitted by multi-request / flow templates.
|
||||
func TestIsContentPartAcrossProtocols(t *testing.T) {
|
||||
content := []string{
|
||||
"", "body", "all", "response", "raw", "data", // http / generic
|
||||
"body_2", "body_5", "response_2", // multi-request numbered
|
||||
"answer", "question", "ns", "extra", "trace", "a", "aaaa", "cname", // dns
|
||||
"stderr", "code_1_response", // code
|
||||
"subject_dn", "issuer_cn", "fingerprint", "cipher", "domains", // ssl
|
||||
"history", // headless
|
||||
"domain", "registrar", // whois
|
||||
}
|
||||
metadata := []string{
|
||||
"header", "all_headers", "header_2", "header_3", // http headers
|
||||
"content_type", "content_type_2", "server", "location", "set_cookie", // header fields
|
||||
"status", "status_code", "size", "content_length", "duration", // http metadata
|
||||
"rcode", // dns metadata
|
||||
"request", "host", "ip", "port", "matched", "type", // request-side
|
||||
}
|
||||
for _, p := range content {
|
||||
require.True(t, isContentPart(p), "part %q should be content", p)
|
||||
}
|
||||
for _, p := range metadata {
|
||||
require.False(t, isContentPart(p), "part %q should be metadata", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfidenceNonHTTPContentParts verifies that content matches on non-HTTP
|
||||
// protocols (dns answer, code stderr, tls cert fields) are scored as content,
|
||||
// not weak metadata.
|
||||
func TestConfidenceNonHTTPContentParts(t *testing.T) {
|
||||
for _, part := range []string{"answer", "stderr", "subject_dn", "code_1_response"} {
|
||||
ops := &Operators{Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, part)}}
|
||||
score, tier := ops.Confidence()
|
||||
require.Equal(t, weightWordContent, score, "word match on %q should score as content", part)
|
||||
require.Equal(t, ConfidenceMedium, tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreToTier(t *testing.T) {
|
||||
require.Equal(t, ConfidenceLow, ScoreToTier(0))
|
||||
require.Equal(t, ConfidenceLow, ScoreToTier(confidenceMediumThreshold-1))
|
||||
require.Equal(t, ConfidenceMedium, ScoreToTier(confidenceMediumThreshold))
|
||||
require.Equal(t, ConfidenceHigh, ScoreToTier(confidenceHighThreshold))
|
||||
require.Equal(t, ConfidenceHigh, ScoreToTier(ConfidenceCertain))
|
||||
}
|
||||
|
||||
func TestConfidenceLLMMatcher(t *testing.T) {
|
||||
llm := func() *matchers.Matcher {
|
||||
m := matcher(matchers.LLMMatcher, "body")
|
||||
m.Prompt = "is this an exposed admin panel?"
|
||||
return m
|
||||
}
|
||||
llmMulti := func() *matchers.Matcher {
|
||||
m := llm()
|
||||
m.Condition = "and"
|
||||
m.MatchAll = true
|
||||
m.Words = []string{"admin", "panel"}
|
||||
return m
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operators *Operators
|
||||
wantScore int
|
||||
wantTier string
|
||||
}{
|
||||
{
|
||||
name: "single llm matcher",
|
||||
operators: &Operators{Matchers: []*matchers.Matcher{llm()}},
|
||||
wantScore: weightLLM,
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "llm matcher gets no multi-pattern bonus",
|
||||
operators: &Operators{Matchers: []*matchers.Matcher{llmMulti()}},
|
||||
wantScore: weightLLM,
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "two llm matchers are one evidence class",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{llm(), llm()},
|
||||
},
|
||||
wantScore: weightLLM,
|
||||
wantTier: ConfidenceLow,
|
||||
},
|
||||
{
|
||||
name: "llm AND body word adds corroboration",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{llm(), matcher(matchers.WordsMatcher, "body")},
|
||||
},
|
||||
wantScore: weightWordContent + corroborationPerClass,
|
||||
wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "llm AND status adds corroboration",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{llm(), matcher(matchers.StatusMatcher, "")},
|
||||
},
|
||||
wantScore: weightLLM + corroborationPerClass,
|
||||
wantTier: ConfidenceMedium,
|
||||
},
|
||||
{
|
||||
name: "llm AND body regex AND status caps corroboration",
|
||||
operators: &Operators{
|
||||
matchersCondition: matchers.ANDCondition,
|
||||
Matchers: []*matchers.Matcher{llm(), matcher(matchers.RegexMatcher, "body"), matcher(matchers.StatusMatcher, "")},
|
||||
},
|
||||
wantScore: weightStrongContent + corroborationCap,
|
||||
wantTier: ConfidenceHigh,
|
||||
},
|
||||
{
|
||||
name: "llm OR body word takes the best class only",
|
||||
operators: &Operators{
|
||||
Matchers: []*matchers.Matcher{llm(), matcher(matchers.WordsMatcher, "body")},
|
||||
},
|
||||
wantScore: weightWordContent,
|
||||
wantTier: ConfidenceMedium,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
score, tier := tc.operators.Confidence()
|
||||
require.Equal(t, tc.wantScore, score)
|
||||
require.Equal(t, tc.wantTier, tier)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,18 @@ import (
|
||||
mapsutil "github.com/projectdiscovery/utils/maps"
|
||||
)
|
||||
|
||||
// confidenceColor colorizes confidence text using the tier color.
|
||||
func (w *StandardWriter) confidenceColor(text, tier string) string {
|
||||
switch tier {
|
||||
case "high":
|
||||
return w.aurora.BrightGreen(text).String()
|
||||
case "medium":
|
||||
return w.aurora.BrightYellow(text).String()
|
||||
default:
|
||||
return w.aurora.BrightBlack(text).String()
|
||||
}
|
||||
}
|
||||
|
||||
// formatScreen formats the output for showing on screen.
|
||||
func (w *StandardWriter) formatScreen(output *ResultEvent) []byte {
|
||||
builder := &bytes.Buffer{}
|
||||
@@ -51,6 +63,13 @@ func (w *StandardWriter) formatScreen(output *ResultEvent) []byte {
|
||||
builder.WriteString("[")
|
||||
builder.WriteString(w.severityColors(output.Info.SeverityHolder.Severity))
|
||||
builder.WriteString("] ")
|
||||
|
||||
if output.Confidence != "" {
|
||||
label := output.Confidence + " (" + strconv.Itoa(output.ConfidenceScore) + ")"
|
||||
builder.WriteString("[")
|
||||
builder.WriteString(w.confidenceColor(label, output.Confidence))
|
||||
builder.WriteString("] ")
|
||||
}
|
||||
}
|
||||
if output.Matched != "" {
|
||||
builder.WriteString(output.Matched)
|
||||
|
||||
+13
-1
@@ -213,6 +213,12 @@ type ResultEvent struct {
|
||||
CURLCommand string `json:"curl-command,omitempty"`
|
||||
// MatcherStatus is the status of the match
|
||||
MatcherStatus bool `json:"matcher-status"`
|
||||
// Confidence is a heuristic detection-reliability tier (low/medium/high),
|
||||
// derived from matcher composition and out-of-band confirmation, and is
|
||||
// independent of severity.
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
// ConfidenceScore is the 0-100 score (QoD-style) behind Confidence.
|
||||
ConfidenceScore int `json:"confidence-score"`
|
||||
// Lines is the line count for the specified match
|
||||
Lines []int `json:"matched-line,omitempty"`
|
||||
// GlobalMatchers identifies whether the matches was detected in the response
|
||||
@@ -564,6 +570,10 @@ func (w *StandardWriter) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// ConfidenceNone is the confidence of a failure event: no matcher fired, so
|
||||
// there is no detection and its confidence score is 0.
|
||||
const ConfidenceNone = "none"
|
||||
|
||||
// WriteFailure writes the failure event for template to file and/or screen.
|
||||
func (w *StandardWriter) WriteFailure(wrappedEvent *InternalWrappedEvent) error {
|
||||
if !w.matcherStatus {
|
||||
@@ -614,7 +624,9 @@ func (w *StandardWriter) WriteFailure(wrappedEvent *InternalWrappedEvent) error
|
||||
Request: types.ToString(event["request"]),
|
||||
Response: types.ToString(event["response"]),
|
||||
MatcherStatus: false,
|
||||
Timestamp: time.Now(),
|
||||
// nothing matched, so there is no detection to be confident about
|
||||
Confidence: ConfidenceNone,
|
||||
Timestamp: time.Now(),
|
||||
//FIXME: this is workaround to encode the template when no results were found
|
||||
TemplateEncoded: w.encodeTemplate(types.ToString(event["template-path"])),
|
||||
Error: types.ToString(event["error"]),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -54,6 +55,48 @@ func TestStandardWriterRequest(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatScreenIncludesConfidenceScore(t *testing.T) {
|
||||
w, err := NewStandardWriter(&types.Options{NoColor: true})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { w.Close() })
|
||||
|
||||
formatted := string(w.formatScreen(&ResultEvent{
|
||||
TemplateID: "test-template",
|
||||
Type: "http",
|
||||
Host: "example.com",
|
||||
Confidence: "low",
|
||||
ConfidenceScore: 0,
|
||||
}))
|
||||
require.Contains(t, formatted, "[low (0)]")
|
||||
}
|
||||
|
||||
func TestResultEventSerializesZeroConfidenceScore(t *testing.T) {
|
||||
data, err := json.Marshal(&ResultEvent{Confidence: "low", ConfidenceScore: 0})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), `"confidence-score":0`)
|
||||
}
|
||||
|
||||
func TestWriteFailureReportsNoConfidence(t *testing.T) {
|
||||
w, err := NewStandardWriter(&types.Options{JSONL: true, MatcherStatus: true})
|
||||
require.NoError(t, err)
|
||||
out := &testWriteCloser{}
|
||||
w.outputFile = out
|
||||
w.DisableStdout = true
|
||||
|
||||
err = w.WriteFailure(&InternalWrappedEvent{InternalEvent: InternalEvent{
|
||||
"template-id": "failed-template",
|
||||
"host": "https://example.com",
|
||||
"type": "http",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
var event map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &event))
|
||||
require.Equal(t, false, event["matcher-status"])
|
||||
require.Equal(t, ConfidenceNone, event["confidence"])
|
||||
require.Equal(t, float64(0), event["confidence-score"])
|
||||
}
|
||||
|
||||
// Only the fields filled from a response are redacted; text the template wrote
|
||||
// stays as it is, even next to a filled field.
|
||||
func TestStandardWriterRedactsFilledInfo(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package baseline implements per-host catch-all detection used to lower the
|
||||
// confidence of likely false-positive matches.
|
||||
//
|
||||
// Many hosts answer every request with the same "catch-all" page (custom 404s,
|
||||
// SPA shells, WAF interstitials, login walls). Templates whose matchers fire on
|
||||
// such a page produce false positives. The approach here follows the black-box
|
||||
// scanner literature (x8, Metasploit Differential, ReScan): learn the host's
|
||||
// response to a request that almost certainly does not exist, then check whether
|
||||
// a template's matchers ALSO fire on that baseline. Unlike body-diffing schemes,
|
||||
// replaying the operators is binary and robust: if a matcher matches a known
|
||||
// non-existent path, the detection is not specific to the real target.
|
||||
package baseline
|
||||
|
||||
import "sync"
|
||||
|
||||
// maxHosts bounds the number of cached baselines to keep memory predictable on
|
||||
// very large scans; beyond it, baselines are still fetched but not retained.
|
||||
const maxHosts = 10000
|
||||
|
||||
// Map is a learned baseline: the DSL field map produced from the control
|
||||
// response, suitable for replaying a template's compiled operators against.
|
||||
type Map = map[string]interface{}
|
||||
|
||||
// Cache learns and stores a per-host catch-all baseline so it can be replayed
|
||||
// against every template without re-fetching. Each host is fetched at most once,
|
||||
// even under concurrent access.
|
||||
type Cache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*entry
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
once sync.Once
|
||||
data Map
|
||||
ok bool
|
||||
}
|
||||
|
||||
// New returns an empty baseline cache.
|
||||
func New() *Cache {
|
||||
return &Cache{entries: make(map[string]*entry)}
|
||||
}
|
||||
|
||||
// GetOrFetch returns the baseline for host, invoking fetch exactly once per host
|
||||
// to learn it. fetch should return the DSL map of the control response; a nil
|
||||
// map or error marks the host as having no usable baseline. The second return
|
||||
// value reports whether a usable baseline is available.
|
||||
func (c *Cache) GetOrFetch(host string, fetch func() (Map, error)) (Map, bool) {
|
||||
if c == nil || host == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
e, found := c.entries[host]
|
||||
if !found {
|
||||
// Don't grow the cache without bound. Over the cap we fetch directly so
|
||||
// detection still works, we just don't retain the baseline.
|
||||
if len(c.entries) >= maxHosts {
|
||||
c.mu.Unlock()
|
||||
data, err := fetch()
|
||||
return data, err == nil && data != nil
|
||||
}
|
||||
e = &entry{}
|
||||
c.entries[host] = e
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
e.once.Do(func() {
|
||||
data, err := fetch()
|
||||
if err == nil && data != nil {
|
||||
e.data = data
|
||||
e.ok = true
|
||||
}
|
||||
})
|
||||
return e.data, e.ok
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package baseline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetOrFetchCachesPerHost(t *testing.T) {
|
||||
cache := New()
|
||||
var calls atomic.Int32
|
||||
fetch := func() (Map, error) {
|
||||
calls.Add(1)
|
||||
return Map{"status_code": 200}, nil
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
data, ok := cache.GetOrFetch("https://example.com", fetch)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 200, data["status_code"])
|
||||
}
|
||||
require.Equal(t, int32(1), calls.Load(), "baseline should be fetched once per host")
|
||||
|
||||
cache.GetOrFetch("https://other.com", fetch)
|
||||
require.Equal(t, int32(2), calls.Load(), "distinct host should trigger a new fetch")
|
||||
}
|
||||
|
||||
func TestGetOrFetchFetchesOnceUnderConcurrency(t *testing.T) {
|
||||
cache := New()
|
||||
var calls atomic.Int32
|
||||
fetch := func() (Map, error) {
|
||||
calls.Add(1)
|
||||
return Map{"ok": true}, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cache.GetOrFetch("https://example.com", fetch)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.Equal(t, int32(1), calls.Load(), "concurrent access must fetch the baseline exactly once")
|
||||
}
|
||||
|
||||
func TestGetOrFetchErrorNotCached(t *testing.T) {
|
||||
cache := New()
|
||||
_, ok := cache.GetOrFetch("https://example.com", func() (Map, error) {
|
||||
return nil, errors.New("boom")
|
||||
})
|
||||
require.False(t, ok, "failed fetch should report no usable baseline")
|
||||
}
|
||||
|
||||
func TestGetOrFetchNilReceiverOrEmptyHost(t *testing.T) {
|
||||
var cache *Cache
|
||||
_, ok := cache.GetOrFetch("https://example.com", func() (Map, error) { return Map{}, nil })
|
||||
require.False(t, ok)
|
||||
|
||||
cache = New()
|
||||
_, ok = cache.GetOrFetch("", func() (Map, error) { return Map{}, nil })
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/baseline"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
httpUtils "github.com/projectdiscovery/utils/http"
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
)
|
||||
|
||||
// ErrNoClient is returned when no http client is available to fetch the baseline.
|
||||
var ErrNoClient = errors.New("no http client available for baseline request")
|
||||
|
||||
// BaselineMatchedKey marks an event whose match also fired against the host's
|
||||
// catch-all baseline, signalling the scorer to lower confidence.
|
||||
const BaselineMatchedKey = "baseline_matched"
|
||||
|
||||
// baselineMatched reports whether this template's matchers also fire against the
|
||||
// host's catch-all baseline: a control request to a path that almost certainly
|
||||
// does not exist. When they do, the match is not specific to the target (the
|
||||
// host answers everything the same way) and is very likely a false positive, so
|
||||
// the caller lowers the reported confidence.
|
||||
//
|
||||
// Replaying the compiled operators against the baseline is deliberately binary
|
||||
// and needs no body normalization: we are not diffing responses, only asking
|
||||
// "would this template have matched a non-existent path too?".
|
||||
func (request *Request) baselineMatched(input *contextargs.Context, generatedRequest *generatedRequest) bool {
|
||||
if request.options.BaselineCache == nil || request.CompiledOperators == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
target := ""
|
||||
if generatedRequest != nil {
|
||||
target = generatedRequest.URL()
|
||||
}
|
||||
if target == "" && input != nil {
|
||||
target = input.MetaInput.Input
|
||||
}
|
||||
parsed, err := urlutil.Parse(target)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
baseURL := parsed.Scheme + "://" + parsed.Host
|
||||
|
||||
baselineRequest, client, cacheKey, err := request.prepareBaseline(input, baseURL, parsed.Host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ctx := context.Background()
|
||||
if input != nil {
|
||||
ctx = input.Context()
|
||||
}
|
||||
baselineMap, ok := request.options.BaselineCache.GetOrFetch(cacheKey, func() (baseline.Map, error) {
|
||||
return request.fetchBaseline(ctx, client, baselineRequest, baseURL)
|
||||
})
|
||||
if !ok || baselineMap == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
result, matched := request.CompiledOperators.Execute(baselineMap, request.Match, request.Extract, false)
|
||||
return matched && result != nil && result.Matched
|
||||
}
|
||||
|
||||
// prepareBaseline creates a control request with the same effective custom
|
||||
// headers, authentication, and per-input cookie jar as a normal request.
|
||||
func (request *Request) prepareBaseline(input *contextargs.Context, baseURL, host string) (*retryablehttp.Request, *retryablehttp.Client, string, error) {
|
||||
target := strings.TrimRight(baseURL, "/") + "/" + randomBaselinePath()
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
generated := &generatedRequest{original: request, request: req}
|
||||
request.setCustomHeaders(generated)
|
||||
if !request.SkipSecretFile {
|
||||
generated.ApplyAuth(request.options.AuthProvider)
|
||||
}
|
||||
|
||||
connConfig := request.connConfiguration
|
||||
if input != nil && input.CookieJar != nil && !request.DisableCookie {
|
||||
connConfig = connConfig.Clone()
|
||||
connConfig.Connection.SetCookieJar(input.CookieJar)
|
||||
}
|
||||
// a pinned target gets its own client, like the normal request path, so a
|
||||
// keep-alive connection to one ip never serves a baseline pinned to another
|
||||
poolKey := host
|
||||
if input != nil && input.MetaInput != nil && input.MetaInput.CustomIP != "" {
|
||||
poolKey += "@" + input.MetaInput.CustomIP
|
||||
}
|
||||
client, err := httpclientpool.Get(request.options.Options, connConfig, poolKey)
|
||||
if err != nil {
|
||||
client, err = httpclientpool.Get(request.options.Options, connConfig, "")
|
||||
}
|
||||
if err != nil || client == nil {
|
||||
return nil, nil, "", ErrNoClient
|
||||
}
|
||||
return req, client, baselineContextKey(baseURL, req, input), nil
|
||||
}
|
||||
|
||||
// baselineContextKey prevents authenticated baselines from being shared across
|
||||
// different header, query-auth, host-header, or cookie contexts.
|
||||
func baselineContextKey(baseURL string, req *retryablehttp.Request, input *contextargs.Context) string {
|
||||
hash := sha256.New()
|
||||
_, _ = fmt.Fprintf(hash, "host=%q\nquery=%q\n", req.Host, req.RawQuery)
|
||||
if input != nil && input.MetaInput != nil {
|
||||
_, _ = fmt.Fprintf(hash, "customip=%q\n", input.MetaInput.CustomIP)
|
||||
}
|
||||
keys := make([]string, 0, len(req.Header))
|
||||
for key := range req.Header {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
values := append([]string(nil), req.Header.Values(key)...)
|
||||
sort.Strings(values)
|
||||
_, _ = fmt.Fprintf(hash, "header=%q:%q\n", key, values)
|
||||
}
|
||||
if input != nil && input.CookieJar != nil && req.Request != nil && req.Request.URL != nil {
|
||||
cookies := input.CookieJar.Cookies(req.Request.URL)
|
||||
values := make([]string, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
values = append(values, cookie.String())
|
||||
}
|
||||
sort.Strings(values)
|
||||
for _, cookie := range values {
|
||||
_, _ = fmt.Fprintf(hash, "cookie=%q\n", cookie)
|
||||
}
|
||||
}
|
||||
return baseURL + "#" + hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
// fetchBaseline sends the prepared control request and converts the response
|
||||
// into a DSL map a template's operators can replay against.
|
||||
func (request *Request) fetchBaseline(ctx context.Context, client *retryablehttp.Client, req *retryablehttp.Request, baseURL string) (baseline.Map, error) {
|
||||
// respect user rate limiting and host backoff for the extra control request,
|
||||
// keyed on the full URL like the normal requests to the same host
|
||||
hostKey := req.String()
|
||||
if err := request.rateLimitTake(ctx, hostKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
request.observeHostBackoff(hostKey, resp, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
maxBodyLimit := int64(MaxBodyRead)
|
||||
if request.MaxSize > 0 {
|
||||
maxBodyLimit = int64(request.MaxSize)
|
||||
}
|
||||
if request.options.Options.ResponseReadSize != 0 {
|
||||
maxBodyLimit = int64(request.options.Options.ResponseReadSize)
|
||||
}
|
||||
|
||||
respChain := httpUtils.NewResponseChain(resp, maxBodyLimit)
|
||||
defer respChain.Close()
|
||||
if err := respChain.Fill(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dslMap := request.responseToDSLMap(respChain.Response(), baseURL, req.String(), "", respChain.FullResponseString(), respChain.BodyString(), respChain.HeadersString(), 0, nil)
|
||||
return baseline.Map(dslMap), nil
|
||||
}
|
||||
|
||||
// randomBaselinePath returns a high-entropy path that is almost certainly absent
|
||||
// on the target, so a 200/match against it indicates catch-all behavior.
|
||||
func randomBaselinePath() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "nuclei-baseline-control-path"
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaselineContextKeyScopesAuthentication(t *testing.T) {
|
||||
const baseURL = "https://example.com"
|
||||
input := contextargs.NewWithInput(t.Context(), baseURL)
|
||||
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, baseURL+"/control", nil)
|
||||
require.NoError(t, err)
|
||||
plainKey := baselineContextKey(baseURL, req, input)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
authKey := baselineContextKey(baseURL, req, input)
|
||||
require.NotEqual(t, plainKey, authKey)
|
||||
|
||||
input.CookieJar.SetCookies(req.Request.URL, []*http.Cookie{{Name: "session", Value: "one"}})
|
||||
cookieKey := baselineContextKey(baseURL, req, input)
|
||||
require.NotEqual(t, authKey, cookieKey)
|
||||
|
||||
req.RawQuery = "api_key=secret"
|
||||
queryKey := baselineContextKey(baseURL, req, input)
|
||||
require.NotEqual(t, cookieKey, queryKey)
|
||||
|
||||
input.MetaInput.CustomIP = "192.0.2.10"
|
||||
pinnedKey := baselineContextKey(baseURL, req, input)
|
||||
require.NotEqual(t, queryKey, pinnedKey)
|
||||
|
||||
input.MetaInput.CustomIP = "192.0.2.11"
|
||||
require.NotEqual(t, pinnedKey, baselineContextKey(baseURL, req, input))
|
||||
}
|
||||
@@ -302,6 +302,26 @@ func TestHTTPMakeResult(t *testing.T) {
|
||||
require.Equal(t, 1, len(finalEvent.Results), "could not get correct number of results")
|
||||
require.Equal(t, "test", finalEvent.Results[0].MatcherName, "could not get correct matcher name of results")
|
||||
require.Equal(t, "1.1.1.1", finalEvent.Results[0].ExtractedResults[0], "could not get correct extracted results")
|
||||
require.Equal(t, operators.ConfidenceMedium, finalEvent.Results[0].Confidence, "could not get correct confidence tier")
|
||||
require.Equal(t, 63, finalEvent.Results[0].ConfidenceScore, "could not get correct confidence score")
|
||||
|
||||
// interactsh_protocol alone does not pin confidence: the template has no
|
||||
// interactsh matcher, so the field may come from a header the target sets.
|
||||
event["interactsh_protocol"] = "dns"
|
||||
oobEvent := &output.InternalWrappedEvent{InternalEvent: event, OperatorsResult: finalEvent.OperatorsResult}
|
||||
oobResults := request.MakeResultEvent(oobEvent)
|
||||
require.NotEmpty(t, oobResults, "expected a result event")
|
||||
require.Equal(t, operators.ConfidenceMedium, oobResults[0].Confidence, "a spoofable interactsh field must not raise confidence")
|
||||
require.Equal(t, 63, oobResults[0].ConfidenceScore, "a spoofable interactsh field must not change the score")
|
||||
|
||||
// a catch-all baseline match clamps confidence down for in-band matches.
|
||||
delete(event, "interactsh_protocol")
|
||||
event[BaselineMatchedKey] = true
|
||||
baselineEvent := &output.InternalWrappedEvent{InternalEvent: event, OperatorsResult: finalEvent.OperatorsResult}
|
||||
baselineResults := request.MakeResultEvent(baselineEvent)
|
||||
require.NotEmpty(t, baselineResults, "expected a baseline result event")
|
||||
require.Equal(t, operators.ConfidenceLow, baselineResults[0].Confidence, "catch-all baseline match should be low confidence")
|
||||
require.Equal(t, operators.ConfidenceCatchAll, baselineResults[0].ConfidenceScore, "catch-all baseline match should use the clamped score")
|
||||
}
|
||||
|
||||
func TestHTTPMakeResultRendersInfo(t *testing.T) {
|
||||
|
||||
@@ -1639,6 +1639,14 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
|
||||
if internalWrappedEvent.InternalEvent["response"] == "" || internalWrappedEvent.InternalEvent["response"] == nil {
|
||||
internalWrappedEvent.InternalEvent["response"] = getFullResponse()
|
||||
}
|
||||
// only worth a control request when there is an actual match; if the
|
||||
// same matchers also fire against the host's catch-all baseline the
|
||||
// finding is likely a false positive, so flag it for the scorer.
|
||||
if request.options.BaselineCache != nil && internalWrappedEvent.OperatorsResult.Matched {
|
||||
if request.baselineMatched(input, generatedRequest) {
|
||||
internalWrappedEvent.InternalEvent[BaselineMatchedKey] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if hasInteractMatchers {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/progress"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/projectfile"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/baseline"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/globalmatchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hostbackoff"
|
||||
@@ -118,6 +119,10 @@ type ExecutorOptions struct {
|
||||
InteractshScope *interactsh.RequestScope
|
||||
// HostErrorsCache is an optional cache for handling host errors
|
||||
HostErrorsCache hosterrorscache.CacheInterface
|
||||
// BaselineCache is an optional per-host catch-all baseline cache used to
|
||||
// lower confidence on likely false-positive matches. Enabled via the
|
||||
// ConfidenceBaseline option.
|
||||
BaselineCache *baseline.Cache
|
||||
// HostBackoff paces a host that signals it is being overloaded, so a scan
|
||||
// slows down for it instead of being blocked outright. Nil disables it.
|
||||
HostBackoff *hostbackoff.Governor
|
||||
@@ -397,6 +402,7 @@ func (e *ExecutorOptions) Copy() *ExecutorOptions {
|
||||
Browser: e.Browser,
|
||||
Interactsh: e.Interactsh,
|
||||
HostErrorsCache: e.HostErrorsCache,
|
||||
BaselineCache: e.BaselineCache,
|
||||
InteractshScope: e.InteractshScope,
|
||||
HostBackoff: e.HostBackoff,
|
||||
StopAtFirstMatch: e.StopAtFirstMatch,
|
||||
@@ -555,11 +561,27 @@ func MakeDefaultResultEvent(request Request, wrapped *output.InternalWrappedEven
|
||||
|
||||
results := make([]*output.ResultEvent, 0, len(wrapped.OperatorsResult.Matches)+1)
|
||||
|
||||
// confidence is a static, deterministic signal derived from the matcher
|
||||
// composition, so it is identical for every result event of this match.
|
||||
confidenceScore, confidenceTier := wrapped.OperatorsResult.Operators.Confidence()
|
||||
// out-of-band confirmation (an interactsh interaction) is unambiguous proof
|
||||
// of the issue, so it outranks any in-band static score. A catch-all baseline
|
||||
// match (the same matchers fire on a non-existent path) only clamps in-band
|
||||
// matches: real out-of-band proof is unaffected.
|
||||
switch {
|
||||
case isOutOfBandConfirmed(wrapped.OperatorsResult.Operators, wrapped.InternalEvent):
|
||||
confidenceScore, confidenceTier = operators.ConfidenceCertain, operators.ConfidenceHigh
|
||||
case isCatchAllBaselineMatch(wrapped.InternalEvent):
|
||||
confidenceScore, confidenceTier = operators.ConfidenceCatchAll, operators.ConfidenceLow
|
||||
}
|
||||
|
||||
// If we have multiple matchers with names, write each of them separately.
|
||||
if len(wrapped.OperatorsResult.Matches) > 0 {
|
||||
for matcherNames := range wrapped.OperatorsResult.Matches {
|
||||
data := request.MakeResultEventItem(wrapped)
|
||||
data.MatcherName = matcherNames
|
||||
data.Confidence = confidenceTier
|
||||
data.ConfidenceScore = confidenceScore
|
||||
results = append(results, data)
|
||||
}
|
||||
} else if len(wrapped.OperatorsResult.Extracts) > 0 {
|
||||
@@ -567,10 +589,14 @@ func MakeDefaultResultEvent(request Request, wrapped *output.InternalWrappedEven
|
||||
data := request.MakeResultEventItem(wrapped)
|
||||
data.ExtractorName = k
|
||||
data.ExtractedResults = v
|
||||
data.Confidence = confidenceTier
|
||||
data.ConfidenceScore = confidenceScore
|
||||
results = append(results, data)
|
||||
}
|
||||
} else {
|
||||
data := request.MakeResultEventItem(wrapped)
|
||||
data.Confidence = confidenceTier
|
||||
data.ConfidenceScore = confidenceScore
|
||||
results = append(results, data)
|
||||
}
|
||||
// Every protocol builds its results here, so attaching the llm audit and
|
||||
@@ -581,6 +607,30 @@ func MakeDefaultResultEvent(request Request, wrapped *output.InternalWrappedEven
|
||||
return renderInfo(results, wrapped.InternalEvent)
|
||||
}
|
||||
|
||||
// isOutOfBandConfirmed reports whether the match was corroborated by an
|
||||
// out-of-band interaction (interactsh), which the interactsh client records on
|
||||
// the event before re-running the result builder. A response header named
|
||||
// Interactsh-Protocol also lands on this key, so only templates that match on
|
||||
// interactsh data, whose results come from the interactsh client, count.
|
||||
func isOutOfBandConfirmed(ops *operators.Operators, event output.InternalEvent) bool {
|
||||
if event == nil || !interactsh.HasMatchers(ops) {
|
||||
return false
|
||||
}
|
||||
return types.ToString(event["interactsh_protocol"]) != ""
|
||||
}
|
||||
|
||||
// isCatchAllBaselineMatch reports whether the match also fired against the
|
||||
// host's catch-all baseline (set by the HTTP baseline detector), indicating a
|
||||
// likely false positive. The key mirrors http.BaselineMatchedKey; it is not
|
||||
// referenced directly to avoid an import cycle.
|
||||
func isCatchAllBaselineMatch(event output.InternalEvent) bool {
|
||||
if event == nil {
|
||||
return false
|
||||
}
|
||||
matched, _ := event["baseline_matched"].(bool)
|
||||
return matched
|
||||
}
|
||||
|
||||
// MakeDefaultExtractFunc performs extracting operation for an extractor on model and returns true or false.
|
||||
func MakeDefaultExtractFunc(data map[string]interface{}, extractor *extractors.Extractor) map[string]struct{} {
|
||||
return MakeDefaultExtractFuncWithOptions(data, extractor, nil)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -145,3 +146,14 @@ func TestExecutorOptionsApplyNewEngineOptionsReplacesInteractshScope(t *testing.
|
||||
|
||||
require.Same(t, next, reused.InteractshScope)
|
||||
}
|
||||
|
||||
func TestIsOutOfBandConfirmedNeedsInteractshMatchers(t *testing.T) {
|
||||
event := output.InternalEvent{"interactsh_protocol": "dns"}
|
||||
bodyOnly := &operators.Operators{Matchers: []*matchers.Matcher{{Part: "body", Words: []string{"hi"}}}}
|
||||
oob := &operators.Operators{Matchers: []*matchers.Matcher{{Part: "interactsh_protocol", Words: []string{"dns"}}}}
|
||||
|
||||
// a response header named Interactsh-Protocol sets the same key
|
||||
require.False(t, isOutOfBandConfirmed(bodyOnly, event))
|
||||
require.True(t, isOutOfBandConfirmed(oob, event))
|
||||
require.False(t, isOutOfBandConfirmed(oob, output.InternalEvent{}))
|
||||
}
|
||||
@@ -194,6 +194,11 @@ func renderFindings(doc *fpdf.Fpdf, results []output.ResultEvent) {
|
||||
doc.CellFormat(0, 5, safeStr(r.Host), "0", 1, "", false, 0, "")
|
||||
doc.CellFormat(30, 5, "Template:", "0", 0, "", false, 0, "")
|
||||
doc.CellFormat(0, 5, safeStr(r.TemplateID), "0", 1, "", false, 0, "")
|
||||
if r.Confidence != "" {
|
||||
confidence := fmt.Sprintf("%s (%d)", capitalize(r.Confidence), r.ConfidenceScore)
|
||||
doc.CellFormat(30, 5, "Confidence:", "0", 0, "", false, 0, "")
|
||||
doc.CellFormat(0, 5, safeStr(confidence), "0", 1, "", false, 0, "")
|
||||
}
|
||||
if r.Info.Description != "" {
|
||||
doc.SetFont("Helvetica", "I", 8)
|
||||
doc.SetTextColor(60, 60, 60)
|
||||
|
||||
@@ -162,6 +162,14 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
|
||||
}
|
||||
|
||||
location := buildLocation(event, resultTarget)
|
||||
resultProperties := buildResultProperties(event, resultTarget)
|
||||
if event.Confidence != "" {
|
||||
if resultProperties == nil {
|
||||
resultProperties = map[string]interface{}{}
|
||||
}
|
||||
resultProperties["confidence"] = event.Confidence
|
||||
resultProperties["confidence-score"] = event.ConfidenceScore
|
||||
}
|
||||
|
||||
// vulnerability report/result
|
||||
result := &sarif.Result{
|
||||
@@ -174,7 +182,7 @@ func (exporter *Exporter) Export(event *output.ResultEvent) error {
|
||||
Rule: sarif.ReportingDescriptorReference{
|
||||
Id: ruleID,
|
||||
},
|
||||
Properties: buildResultProperties(event, resultTarget),
|
||||
Properties: resultProperties,
|
||||
}
|
||||
|
||||
exporter.sarif.RegisterResult(*result)
|
||||
|
||||
@@ -20,22 +20,22 @@ import (
|
||||
// makeEvent returns a fully populated ResultEvent suitable for the integration tests.
|
||||
func makeEvent() *output.ResultEvent {
|
||||
return &output.ResultEvent{
|
||||
TemplateID: "test-template",
|
||||
TemplateURL: "https://templates.example.com/test-template.yaml",
|
||||
Template: "/tmp/nuclei-templates/cves/test-template.yaml",
|
||||
TemplatePath: "/tmp/nuclei-templates/cves/test-template.yaml",
|
||||
Type: "http",
|
||||
Host: "example.com",
|
||||
URL: "https://example.com",
|
||||
Path: "/admin",
|
||||
Matched: "https://example.com/admin",
|
||||
IP: "93.184.216.34",
|
||||
MatcherName: "status-200",
|
||||
ExtractorName: "version",
|
||||
TemplateID: "test-template",
|
||||
TemplateURL: "https://templates.example.com/test-template.yaml",
|
||||
Template: "/tmp/nuclei-templates/cves/test-template.yaml",
|
||||
TemplatePath: "/tmp/nuclei-templates/cves/test-template.yaml",
|
||||
Type: "http",
|
||||
Host: "example.com",
|
||||
URL: "https://example.com",
|
||||
Path: "/admin",
|
||||
Matched: "https://example.com/admin",
|
||||
IP: "93.184.216.34",
|
||||
MatcherName: "status-200",
|
||||
ExtractorName: "version",
|
||||
ExtractedResults: []string{"v1.2.3", "v1.2.4"},
|
||||
CURLCommand: "curl -X GET https://example.com/admin",
|
||||
Lines: []int{0, 42},
|
||||
Timestamp: time.Now(),
|
||||
CURLCommand: "curl -X GET https://example.com/admin",
|
||||
Lines: []int{0, 42},
|
||||
Timestamp: time.Now(),
|
||||
Info: model.Info{
|
||||
Name: "Test Finding",
|
||||
Authors: stringslice.StringSlice{Value: "tester"},
|
||||
@@ -549,3 +549,30 @@ func TestExport_Concurrency(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(bin, &log))
|
||||
require.Len(t, log.Runs[0].Result, workers)
|
||||
}
|
||||
|
||||
func TestSarifExportIncludesConfidence(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "out.sarif")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "test-template",
|
||||
Host: "example.com",
|
||||
Path: "/x",
|
||||
Info: model.Info{
|
||||
Name: "Test Template",
|
||||
Description: "desc",
|
||||
SeverityHolder: severity.Holder{Severity: severity.High},
|
||||
},
|
||||
Confidence: "high",
|
||||
ConfidenceScore: 85,
|
||||
}
|
||||
require.NoError(t, exporter.Export(event))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
data, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "confidence")
|
||||
require.Contains(t, string(data), "confidence-score")
|
||||
require.Contains(t, string(data), `"target"`)
|
||||
}
|
||||
@@ -47,10 +47,13 @@ func CreateReportDescription(event *output.ResultEvent, formatter ResultFormatte
|
||||
builder := &bytes.Buffer{}
|
||||
fmt.Fprintf(builder, "%s: %s matched at %s\n\n", formatter.MakeBold("Details"), formatter.MakeBold(template), event.Host)
|
||||
|
||||
attributes := utils.NewEmptyInsertionOrderedStringMap(3)
|
||||
attributes := utils.NewEmptyInsertionOrderedStringMap(4)
|
||||
attributes.Set("Protocol", strings.ToUpper(event.Type))
|
||||
attributes.Set("Full URL", event.Matched)
|
||||
attributes.Set("Timestamp", event.Timestamp.Format("Mon Jan 2 15:04:05 -0700 MST 2006"))
|
||||
if event.Confidence != "" {
|
||||
attributes.Set("Confidence", formatConfidence(event))
|
||||
}
|
||||
attributes.ForEach(func(key string, data interface{}) {
|
||||
fmt.Fprintf(builder, "%s: %s\n\n", formatter.MakeBold(key), types.ToString(data))
|
||||
})
|
||||
@@ -250,3 +253,14 @@ func generateCVECWEIDLinksFromClassification(classification *model.Classificatio
|
||||
func lineBreakToHTML(text string) string {
|
||||
return strings.ReplaceAll(text, "\n", "<br>")
|
||||
}
|
||||
|
||||
// formatConfidence renders the detection-confidence tier and 0-100 score, e.g.
|
||||
// "High (85)".
|
||||
func formatConfidence(event *output.ResultEvent) string {
|
||||
tier := event.Confidence
|
||||
if tier != "" {
|
||||
tier = strings.ToUpper(tier[:1]) + tier[1:]
|
||||
return fmt.Sprintf("%s (%d)", tier, event.ConfidenceScore)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -49,6 +49,35 @@ func TestToMarkdownTableString(t *testing.T) {
|
||||
require.ElementsMatch(t, expectedDynamicAttributes, actualAttributeSlice[dynamicAttributeIndex:]) // dynamic parameters are not ordered
|
||||
}
|
||||
|
||||
func TestCreateReportDescription_Confidence(t *testing.T) {
|
||||
base := &output.ResultEvent{
|
||||
TemplateID: "test-template",
|
||||
Host: "example.com",
|
||||
Matched: "https://example.com/x",
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: "Test Template",
|
||||
SeverityHolder: severity.Holder{Severity: severity.High},
|
||||
},
|
||||
}
|
||||
|
||||
withConfidence := *base
|
||||
withConfidence.Confidence = "high"
|
||||
withConfidence.ConfidenceScore = 85
|
||||
result := CreateReportDescription(&withConfidence, &util.MarkdownFormatter{}, true)
|
||||
require.Contains(t, result, "Confidence")
|
||||
require.Contains(t, result, "High (85)")
|
||||
|
||||
zeroConfidence := *base
|
||||
zeroConfidence.Confidence = "low"
|
||||
zeroResult := CreateReportDescription(&zeroConfidence, &util.MarkdownFormatter{}, true)
|
||||
require.Contains(t, zeroResult, "Low (0)")
|
||||
|
||||
// confidence row is omitted entirely when no score was computed
|
||||
without := CreateReportDescription(base, &util.MarkdownFormatter{}, true)
|
||||
require.NotContains(t, without, "Confidence")
|
||||
}
|
||||
|
||||
func TestCreateReportDescription_MarkdownInjection(t *testing.T) {
|
||||
// Setup a mock result event with malicious payload in various fields
|
||||
event := &output.ResultEvent{
|
||||
|
||||
@@ -320,6 +320,9 @@ type Options struct {
|
||||
EnvironmentVariables bool
|
||||
// MatcherStatus displays optional status for the failed matches as well
|
||||
MatcherStatus bool
|
||||
// ConfidenceBaseline sends a per-host control request to detect catch-all
|
||||
// responses and lower the confidence of likely false-positive matches.
|
||||
ConfidenceBaseline bool
|
||||
// ClientCertFile client certificate file (PEM-encoded) used for authenticating against scanned hosts
|
||||
ClientCertFile string
|
||||
// ClientKeyFile client key file (PEM-encoded) used for authenticating against scanned hosts
|
||||
|
||||
Reference in new issue
Block a user