Compare commits

...
20 Commits
Author SHA1 Message Date
Mzack9999 40321dd68a Merge dev 2026-10-27 16:09:42 +01:00
Mzack9999 55d17f5a29 Pin baseline 2026-10-26 16:29:39 +01:00
Mzack9999 b7e66ae57c Fix test 2026-10-26 15:57:03 +01:00
Mzack9999 9aef358094 Score llm 2026-10-26 12:26:43 +01:00
Mzack9999 b62180c641 Merge dev 2026-10-26 12:04:12 +01:00
Mzack9999 1d3f1f5bc2 Gate interactsh 2026-10-26 11:32:06 +01:00
Mzack9999 4b3c1633ee Merge dev 2026-10-26 01:00:32 +01:00
Mzack9999 d2f862edf5 Merge origin 2026-10-26 00:33:21 +01:00
Mzack9999 717062fd0e fix confidence 2026-09-23 21:58:12 +02:00
Mzack9999 6c2ea4edcb merge dev 2026-09-23 20:23:17 +02:00
Mzack9999 1898b4aec4 merge dev 2026-09-14 22:34:30 +02:00
Mzack9999 e3864b60f2 Resolve conflicts 2026-07-15 19:02:06 +04:00
Mzack9999 187d855145 allowlist openvas 2026-06-26 01:48:22 +02:00
Mzack9999 1f276227ff report confidence 2026-06-26 01:31:23 +02:00
Mzack9999 e0fd39a862 protocol parts 2026-06-26 01:24:31 +02:00
Mzack9999 e0ed203765 negative evidence 2026-06-26 01:14:08 +02:00
Mzack9999 3ae786749d tune scoring 2026-06-26 01:08:50 +02:00
Mzack9999 b3881248ca refine scoring 2026-06-26 01:00:51 +02:00
Mzack9999 1ddd00efef baseline detection 2026-06-26 00:57:58 +02:00
Mzack9999 bc776d29a8 score confidence 2026-06-26 00:57:58 +02:00
22 changed files with 1415 additions and 18 deletions

No files matched your search

+2
View File
@@ -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
+1
View File
@@ -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"),
)
+5
View File
@@ -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()
+300
View File
@@ -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
}
}
+470
View File
@@ -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)
})
}
}
+19
View File
@@ -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
View File
@@ -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"]),
+43
View File
@@ -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) {
+75
View File
@@ -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)
}
+189
View File
@@ -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)
}
+38
View File
@@ -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))
}
+20
View File
@@ -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) {
+8
View File
@@ -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 {
+50
View File
@@ -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)
+12
View File
@@ -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{}))
}
+5
View File
@@ -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)
+9 -1
View File
@@ -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)
+42 -15
View File
@@ -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"`)
}
+15 -1
View File
@@ -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 ""
}
+29
View File
@@ -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{
+3
View File
@@ -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