Skip to content

Commit 046eebe

Browse files
committed
fix(finding): key header findings by value and sort findings deterministically
multi-valued headers (set-cookie) emitted one finding per value but keyed on name alone, so store.Diff collapsed every value but the first onto one key. fold the value into the header key so distinct values stay distinct. several flatten funcs source findings off randomized go map iteration (js env vars, header names), leaving report output order non-deterministic across identical scans. sort the flattened slice on the stable key once in Flatten so json/sarif/markdown/-silent all render a stable order.
1 parent 7ea1cd2 commit 046eebe

2 files changed

Lines changed: 83 additions & 36 deletions

File tree

internal/finding/finding.go

Lines changed: 46 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package finding
1919

2020
import (
2121
"fmt"
22+
"sort"
2223
"strings"
2324

2425
"github.com/projectdiscovery/nuclei/v3/pkg/output"
@@ -82,78 +83,79 @@ func key(module, identifier string) string {
8283
// keyed "module:unhandled" so a new scanner surfaces loudly instead of
8384
// vanishing - the guard test asserts this never happens for a known type.
8485
func Flatten(target, module string, result any) []Finding {
86+
var out []Finding
8587
switch r := result.(type) {
8688
case *scan.ShodanResult:
87-
return flattenShodan(target, r)
89+
out = flattenShodan(target, r)
8890
case *scan.SQLResult:
89-
return flattenSQL(target, r)
91+
out = flattenSQL(target, r)
9092
case *scan.LFIResult:
91-
return flattenLFI(target, r)
93+
out = flattenLFI(target, r)
9294
case *scan.JWTResult:
93-
return flattenJWT(target, r)
95+
out = flattenJWT(target, r)
9496
case *scan.OpenAPIResult:
95-
return flattenOpenAPI(target, r)
97+
out = flattenOpenAPI(target, r)
9698
case *scan.FaviconResult:
97-
return flattenFavicon(target, r)
99+
out = flattenFavicon(target, r)
98100
case *scan.CMSResult:
99-
return flattenCMS(target, r)
101+
out = flattenCMS(target, r)
100102
case *scan.SecurityTrailsResult:
101-
return flattenSecurityTrails(target, r)
103+
out = flattenSecurityTrails(target, r)
102104
case *scan.CORSResult:
103-
return flattenCORS(target, r)
105+
out = flattenCORS(target, r)
104106
case *scan.RedirectResult:
105-
return flattenRedirect(target, r)
107+
out = flattenRedirect(target, r)
106108
case *scan.XSSResult:
107-
return flattenXSS(target, r)
109+
out = flattenXSS(target, r)
108110
case *scan.CrawlResult:
109-
return flattenCrawl(target, r)
111+
out = flattenCrawl(target, r)
110112
case *scan.PassiveResult:
111-
return flattenPassive(target, r)
113+
out = flattenPassive(target, r)
112114
case *scan.ProbeResult:
113-
return flattenProbe(target, r)
115+
out = flattenProbe(target, r)
114116
case scan.HeaderResults:
115-
return flattenHeaders(target, r)
117+
out = flattenHeaders(target, r)
116118
case []scan.HeaderResult:
117119
// the headers module appends a literal []HeaderResult, not the named
118120
// slice type; both reach here so cover both.
119-
return flattenHeaders(target, r)
121+
out = flattenHeaders(target, r)
120122
case scan.SecurityHeaderResults:
121-
return flattenSecurityHeaders(target, r)
123+
out = flattenSecurityHeaders(target, r)
122124
case []scan.SecurityHeaderResult:
123-
return flattenSecurityHeaders(target, r)
125+
out = flattenSecurityHeaders(target, r)
124126
case scan.DirectoryResults:
125-
return flattenDirlist(target, r)
127+
out = flattenDirlist(target, r)
126128
case []scan.DirectoryResult:
127-
return flattenDirlist(target, r)
129+
out = flattenDirlist(target, r)
128130
case scan.CloudStorageResults:
129-
return flattenCloudStorage(target, r)
131+
out = flattenCloudStorage(target, r)
130132
case []scan.CloudStorageResult:
131-
return flattenCloudStorage(target, r)
133+
out = flattenCloudStorage(target, r)
132134
case scan.DorkResults:
133-
return flattenDork(target, r)
135+
out = flattenDork(target, r)
134136
case []scan.DorkResult:
135-
return flattenDork(target, r)
137+
out = flattenDork(target, r)
136138
case scan.SubdomainTakeoverResults:
137-
return flattenTakeover(target, r)
139+
out = flattenTakeover(target, r)
138140
case []scan.SubdomainTakeoverResult:
139-
return flattenTakeover(target, r)
141+
out = flattenTakeover(target, r)
140142
case *frameworks.FrameworkResult:
141-
return flattenFramework(target, r)
143+
out = flattenFramework(target, r)
142144
case *js.JavascriptScanResult:
143-
return flattenJS(target, r)
145+
out = flattenJS(target, r)
144146
case *modules.Result:
145147
// yaml/builtin modules carry their own module id; honor it over the
146148
// passed-in module so per-module findings stay attributed correctly.
147-
return flattenModule(target, r)
149+
out = flattenModule(target, r)
148150
case []output.ResultEvent:
149-
return flattenNuclei(target, r)
151+
out = flattenNuclei(target, r)
150152
case []string:
151153
// dnslist/portscan/git all hand back a bare []string of discovered
152154
// items; module disambiguates which inventory it is.
153-
return flattenStrings(target, module, r)
155+
out = flattenStrings(target, module, r)
154156
default:
155157
// unknown type: emit a loud placeholder rather than dropping it.
156-
return []Finding{{
158+
out = []Finding{{
157159
Target: target,
158160
Module: module,
159161
Severity: SeverityUnknown,
@@ -162,6 +164,13 @@ func Flatten(target, module string, result any) []Finding {
162164
Raw: fmt.Sprintf("%T", result),
163165
}}
164166
}
167+
// some flatten* funcs source findings off Go map iteration (js env vars,
168+
// header names upstream in the headers scanner), which is randomized per
169+
// run. sort on the stable Key here, once, so every report surface (json,
170+
// sarif, markdown, -silent) renders the same order across identical scans
171+
// instead of churning on nothing but map-order luck.
172+
sort.SliceStable(out, func(i, j int) bool { return out[i].Key < out[j].Key })
173+
return out
165174
}
166175

167176
func flattenShodan(target string, r *scan.ShodanResult) []Finding {
@@ -477,11 +486,14 @@ func flattenHeaders(target string, rs []scan.HeaderResult) []Finding {
477486
out := make([]Finding, 0, len(rs))
478487
for i := 0; i < len(rs); i++ {
479488
h := rs[i]
489+
// a multi-valued header (Set-Cookie is the canonical case) emits one
490+
// HeaderResult per value; the value must ride in the identifier or
491+
// every value but the first collapses onto one dedup Key.
480492
out = append(out, Finding{
481493
Target: target,
482494
Module: "headers",
483495
Severity: sevRecon,
484-
Key: key("headers", h.Name),
496+
Key: key("headers", h.Name+":"+h.Value),
485497
Title: h.Name,
486498
Raw: h.Value,
487499
})
@@ -656,8 +668,7 @@ func flattenJS(target string, r *js.JavascriptScanResult) []Finding {
656668
Raw: e,
657669
})
658670
}
659-
// env vars are a map; sort-free since the Key carries the name, and diff
660-
// keys on the Key not on iteration order.
671+
// map order is random here; Flatten sorts by Key (see comment above sort.SliceStable).
661672
for name, value := range r.FoundEnvironmentVars {
662673
out = append(out, Finding{
663674
Target: target,

internal/finding/finding_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ func TestFlattenStableKeysAndSeverities(t *testing.T) {
314314
name: "header is recon info",
315315
value: scan.HeaderResults{{Name: "Server", Value: "nginx"}},
316316
module: "headers",
317-
wantKey: "headers:Server",
317+
wantKey: "headers:Server:nginx",
318318
wantSev: SeverityInfo,
319319
},
320320
{
@@ -381,3 +381,39 @@ func TestDeadProbeIsNotAFinding(t *testing.T) {
381381
t.Errorf("dead probe produced %d findings, want 0", len(findings))
382382
}
383383
}
384+
385+
func TestMultiValuedHeaderGetsDistinctKeys(t *testing.T) {
386+
hdrs := []scan.HeaderResult{
387+
{Name: "Set-Cookie", Value: "session=aaa; HttpOnly"},
388+
{Name: "Set-Cookie", Value: "tracking=bbb"},
389+
}
390+
fs := Flatten(target, "headers", hdrs)
391+
if len(fs) != 2 {
392+
t.Fatalf("flatten len = %d, want 2", len(fs))
393+
}
394+
if fs[0].Key == fs[1].Key {
395+
t.Fatalf("distinct header values %q and %q share dedup Key %q", fs[0].Raw, fs[1].Raw, fs[0].Key)
396+
}
397+
}
398+
399+
func TestJSEnvVarOrderingIsStable(t *testing.T) {
400+
res := &js.JavascriptScanResult{
401+
FoundEnvironmentVars: map[string]string{
402+
"A_KEY": "1", "B_KEY": "2", "C_KEY": "3",
403+
"D_KEY": "4", "E_KEY": "5", "F_KEY": "6",
404+
},
405+
}
406+
407+
orderings := make(map[string]struct{})
408+
for i := 0; i < 200; i++ {
409+
fs := Flatten(target, "js", res)
410+
keys := make([]string, 0, len(fs))
411+
for _, f := range fs {
412+
keys = append(keys, f.Key)
413+
}
414+
orderings[strings.Join(keys, ",")] = struct{}{}
415+
}
416+
if len(orderings) != 1 {
417+
t.Errorf("Flatten produced %d distinct env-var orderings over 200 runs, want 1", len(orderings))
418+
}
419+
}

0 commit comments

Comments
 (0)