Skip to content

Commit c7b0388

Browse files
committed
fix(finding): key repeated headers by occurrence, not by value
folding the value into the header key gave every multi-valued header a distinct key, but it also destabilized the single-valued volatile ones. Date rides on nearly every response, alongside ETag, Age, CF-Ray and X-Request-Id, so each scan minted a fresh key and the diff/notify layer reported them added+removed every run. Key is documented to be run-stable (finding.go:41), so that was the wrong axis. count occurrences of the header name instead. the two Set-Cookies still split, Date and friends stay put. the count is per name rather than the slice index: headers.go ranges over resp.Header, so a name's slice position is randomized per run while the order of values within one name is preserved.
1 parent befd27a commit c7b0388

2 files changed

Lines changed: 62 additions & 5 deletions

File tree

internal/finding/finding.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package finding
2020
import (
2121
"fmt"
2222
"sort"
23+
"strconv"
2324
"strings"
2425

2526
"github.com/projectdiscovery/nuclei/v3/pkg/output"
@@ -481,16 +482,29 @@ func flattenProbe(target string, r *scan.ProbeResult) []Finding {
481482

482483
func flattenHeaders(target string, rs []scan.HeaderResult) []Finding {
483484
out := make([]Finding, 0, len(rs))
485+
// a multi-valued header (Set-Cookie is the canonical case) emits one
486+
// HeaderResult per value, and keying on the name alone collapses every
487+
// value but the first onto one dedup Key. disambiguate by how many times
488+
// the name has been seen rather than by the value: the value would
489+
// destabilize every volatile single-valued header (Date, ETag, Age, CF-Ray)
490+
// against the run-stable Key contract.
491+
//
492+
// the count is per name, not the slice index. headers.go ranges over
493+
// resp.Header, so a name's position in the slice is randomized per run
494+
// while the order of values within one name is preserved.
495+
seen := make(map[string]int, len(rs))
484496
for i := 0; i < len(rs); i++ {
485497
h := rs[i]
486-
// a multi-valued header (Set-Cookie is the canonical case) emits one
487-
// HeaderResult per value; the value must ride in the identifier or
488-
// every value but the first collapses onto one dedup Key.
498+
identifier := h.Name
499+
if n := seen[h.Name]; n > 0 {
500+
identifier += "#" + strconv.Itoa(n)
501+
}
502+
seen[h.Name]++
489503
out = append(out, Finding{
490504
Target: target,
491505
Module: "headers",
492506
Severity: sevRecon,
493-
Key: key("headers", h.Name+":"+h.Value),
507+
Key: key("headers", identifier),
494508
Title: h.Name,
495509
Raw: h.Value,
496510
})

internal/finding/finding_test.go

Lines changed: 44 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:nginx",
317+
wantKey: "headers:Server",
318318
wantSev: SeverityInfo,
319319
},
320320
{
@@ -396,6 +396,49 @@ func TestMultiValuedHeaderGetsDistinctKeys(t *testing.T) {
396396
}
397397
}
398398

399+
// Key is documented to be run-stable, so a header whose value changes every
400+
// response (Date is on nearly all of them, plus ETag/Age/CF-Ray/X-Request-Id)
401+
// must keep the same Key across scans. folding the value into the key would
402+
// churn a fresh key each run and make the diff/notify layer report every such
403+
// header as added+removed on every scan of every target.
404+
func TestVolatileHeaderKeyIsRunStable(t *testing.T) {
405+
run1 := Flatten(target, "headers", []scan.HeaderResult{{Name: "Date", Value: "Mon, 07 Jul 2026 12:00:00 GMT"}})
406+
run2 := Flatten(target, "headers", []scan.HeaderResult{{Name: "Date", Value: "Mon, 07 Jul 2026 12:00:01 GMT"}})
407+
if run1[0].Key != run2[0].Key {
408+
t.Fatalf("Date key churned across runs: %q then %q", run1[0].Key, run2[0].Key)
409+
}
410+
}
411+
412+
// the slice position of a header is randomized per run, so the disambiguator
413+
// must count occurrences within a name rather than use the slice index.
414+
func TestHeaderKeyIndependentOfEmissionOrder(t *testing.T) {
415+
cookieA := scan.HeaderResult{Name: "Set-Cookie", Value: "session=aaa"}
416+
cookieB := scan.HeaderResult{Name: "Set-Cookie", Value: "tracking=bbb"}
417+
server := scan.HeaderResult{Name: "Server", Value: "nginx"}
418+
date := scan.HeaderResult{Name: "Date", Value: "Mon, 07 Jul 2026 12:00:00 GMT"}
419+
420+
keysFor := func(rs []scan.HeaderResult) map[string]string {
421+
got := make(map[string]string)
422+
for _, f := range Flatten(target, "headers", rs) {
423+
got[f.Raw] = f.Key
424+
}
425+
return got
426+
}
427+
428+
// same headers, the map handed them to us in a different order.
429+
first := keysFor([]scan.HeaderResult{server, cookieA, cookieB, date})
430+
second := keysFor([]scan.HeaderResult{date, cookieA, cookieB, server})
431+
432+
for value, key := range first {
433+
if second[value] != key {
434+
t.Errorf("header %q key changed with emission order: %q then %q", value, key, second[value])
435+
}
436+
}
437+
if first["session=aaa"] == first["tracking=bbb"] {
438+
t.Fatalf("the two Set-Cookie values still share key %q", first["session=aaa"])
439+
}
440+
}
441+
399442
func TestJSEnvVarOrderingIsStable(t *testing.T) {
400443
res := &js.JavascriptScanResult{
401444
FoundEnvironmentVars: map[string]string{

0 commit comments

Comments
 (0)