fix(rm-074): redact whole cookie headers, and stop distinct endpoints sharing a finding_id - #493
Conversation
What changed: - `_SCRUB_PATTERNS` handles `Cookie:` and `Set-Cookie:` with one function-backed rule that consumes the header to end of line and redacts per `;`-separated pair, keeping cookie names and the `Set-Cookie` attribute flags. - Added `_COOKIE_ATTRIBUTES` for the metadata that must survive. - Six tests: a session id in a later position, names preserved, attribute flags preserved, idempotency for both header forms, and an empty header left alone. Why: The pattern was `[^,\r\n;]+`, and `;` is a cookie header's internal pair delimiter rather than a field separator, so only the first pair was redacted: `Cookie: theme=dark; sessionid=SECRET` kept SECRET verbatim. Analytics and preference cookies are usually sent first, so the leaking position was the common one. `sessionid`/`PHPSESSID`/`JSESSIONID`/`connect.sid` are in no generic `name=value` pattern, so opaque session ids had no second line of defence, and `scrub_graybox_secrets` is the storage boundary — what it misses reaches the archive, the LLM input, the PDF and the exports. Redacting the whole line would have been wrong: `probes/misconfig.py` reports `missing_Secure`/`missing_HttpOnly`/`weak_SameSite`, so blanking a `Set-Cookie` would redact the evidence for the finding it just raised. The existing tests passed because both fixtures used single-pair headers. Checks: - pytest tests/test_findings_redaction.py -> 26 passed (4 new tests fail without this change). - pytest test_graybox_finding test_llm_input_isolation test_normalization test_evidence_hygiene -> 144 passed, 54 subtests.
… in emit_* What changed: - Moved `location_from_evidence` from `probes/base.py` into `graybox/findings.py` and applied it in `to_flat_finding` when `url`/`parameter` are unset. - `probes/base.py` imports it from there; the emission path is unchanged. - `affected_assets` is built from the resolved pair rather than the raw fields. - Seven tests covering directly-constructed findings: two endpoints stay distinct, `path=` recognised, explicit url still wins, same endpoint still dedups while the signature moves, no location key still means no asset, a secret in the derived URL is redacted before promotion, and an id stamped at production still wins on the read path. Why: `dedup_key` reads the location from `affected_assets[].url`, but 87 of the 93 `GrayboxFinding(...)` constructions never set `url` — `access_control`, `injection`, `misconfig` and `business_logic` append directly instead of going through `emit_*`, which is where the derivation lived. Their findings reached identity with an empty asset list, so it reduced to probe + scenario_id + classification and two endpoints exhibiting one scenario shared a `finding_id`. Triage keys on `finding_id` alone, so marking one endpoint remediated marked the other. Reproduced on the shape `injection.py` emits per vulnerable form, and on `access_control.py` PT-A01-02 per privileged endpoint. This is a regression, not a standing gap: the pre-RM-062 `to_flat_finding` folded the `endpoint=`/`path=` evidence prefixes into its id input directly. Deriving here rather than reading evidence back into `dedup_key` keeps identity free of free text — the prefixes are structured `key=value` pairs, and hashing the evidence string is what re-identified findings on a reworded title. Migrating the four probes to `emit_*` is the larger cleanup and belongs to RM-072 Phase 2. Checks: - pytest tests/test_graybox_finding.py -> 66 passed, 10 subtests (4 new tests fail without this change). - Full suite -> 2429 passed, 3 skipped, 275 subtests; same in randomised and `-p no:randomly` order. Baseline on develop was 2416 passed; +13 is exactly the tests added across this branch.
What changed: - The already-redacted guard matches a prefix, not the whole value, so an assembled curl line survives re-scrubbing intact. - `_COOKIE_ATTRIBUTES` is applied only to `Set-Cookie`, and only past the first pair. - A segment with no `=` is redacted whole instead of passed through. - Restored `\b` on the header match in place of the `(?<![-\w])` lookbehind. - Six tests, including one end-to-end through `_curl_reproduction`. Why: Review of the previous commit found three regressions against develop, all reproduced before fixing: 1. Data loss. `_curl_reproduction` scrubs each header before quoting it, so the assembled line already carries `<redacted>`; it is then scrubbed at emission and again at the storage boundary. Reading to end of line, the last pair's value has the rest of the command glued to it, so an equality check did not fire and the remaining headers, the URL and the closing quote were eaten - the exact failure `_ALREADY_REDACTED` exists to prevent, one level down. The bare-header idempotency test passes against this, which is why it missed it. 2. Leak. `path` and `secure` are attributes only in a `Set-Cookie` response; in a request header they are ordinary cookie names, and the allowlist handed their values a free pass. The first pair of a `Set-Cookie` is the cookie however it is named. 3. Leak. A segment with no `=` is an opaque value, and `partition` returned it verbatim: `Cookie: OPAQUETOKEN` was redacted on develop and not here. The lookbehind also dropped `X-Auth-Cookie:`, which develop matched; leftmost matching plus alternation order already keeps the bare `cookie` branch off the tail of `Set-Cookie`, so `\b` costs nothing. Fuzzed 6000 generated header shapes across both directions, opaque segments and assembled curl lines: no secret survives, and every result is a fixed point. `Set-Cookie` attribute values are kept by design so the cookie-hardening evidence stays readable; they still pass under the JWT, Bearer, named-secret and operator-configured patterns, and there is a test pinning that boundary. Checks: - pytest tests/test_findings_redaction.py tests/test_graybox_finding.py -> 99 passed, 13 subtests. - Reverting the guard alone fails the new end-to-end curl test while the bare-header idempotency test still passes. - Full suite -> 2436 passed, 3 skipped, 278 subtests, in both randomised and `-p no:randomly` order.
|
Pushed 1. The rule truncated the curl replay step — the failure The guard now matches a prefix, which is the same guarantee 2. The attribute allowlist applied to the request direction, where 3. A segment with no Fuzzed 6000 generated header shapes across both directions, opaque segments and assembled curl lines: no secret survives and every result is a fixed point. Suite: 2436 passed, 3 skipped, 278 subtests, both orders. |
What changed:
- `location_from_evidence` splits each evidence item on `;` and matches the
location and parameter prefixes per clause, taking that clause's value.
- Nine tests, including the eight composite evidence shapes the four probes
actually emit.
Why:
16 of the 65 location-bearing evidence literals in these probes are `;`-joined
composites. Reading to end of string swept the tail into `affected_assets[].url`,
and the tail is routinely target-derived: `probed_len={len(response.text)}` in
access_control, the reflected `Location` header in injection, a `repr()` of a
record owner field, a status code. Hashed into identity, that gave a finding a
new `finding_id` whenever the target's response moved by a byte:
probed_len=1873 -> 0d29b346fdf115d7
probed_len=1874 -> f2d36802a911895d
Triage never sticks and the timeline never accumulates, which is strictly worse
than the collision this derivation was added to fix - a colliding id is at least
stable enough to triage against. It is also the inverse of the property the
previous commit claimed, and it pushed injection payloads and target response
content into the LLM prompt, which llm_input_builder documents as carrying
"host/port/url only - no full request bodies".
Clause matching fixes two more things at the same site: a location key outside
the first position is now found (business_logic.py:331, access_control.py:1122
kept an empty asset before), and the parameter keys become live - no probe emits
`param=` first, so `_PARAMETER_EVIDENCE_KEYS` had never matched anything.
Checks:
- pytest test_graybox_finding.py test_findings_redaction.py -> 107 passed,
24 subtests. 12 of the new assertions fail without this change.
- Swept all 65 real location-bearing evidence literals in the four probes: no
derived url retains a clause tail.
…press evidence
What changed:
- The cookie header pattern requires whitespace after the colon.
- Two tests: the real PT-A02-04 evidence shape under target-controlled cookie
names, and the header forms that must still be redacted after the narrowing.
Why:
`misconfig.py:222-227` builds its cookie-hardening evidence from the target's
cookie *names* - `f"{cookie.name}:missing_Secure"` - and `_flat_evidence_summary`
joins the list with "; ". A cookie named `session-cookie`, or literally `cookie`,
matched the header rule, and reading to end of line then consumed the whole
joined string:
'session-cookie:missing_Secure; session-cookie:missing_HttpOnly; endpoint=/admin'
-> 'session-cookie:<redacted>; <redacted>; endpoint=<redacted>'
So a target could suppress its own PT-A02-04 finding by naming a cookie - the
evidence the attribute-preservation in 6b93213 exists to protect. develop
destroys one token here; this branch destroyed the line.
Every real header producer in this repo goes through `f"{name}: {value}"`, and
this evidence deliberately has no space, so the separator is a clean
discriminator. A spaceless `Cookie:` inside a response body is still covered by
the generic name=value, JWT and Bearer patterns.
Checks:
- pytest test_findings_redaction.py test_graybox_finding.py -> 109 passed,
31 subtests. Both new tests fail without this change.
- Re-fuzzed 6000 generated header shapes including tab and double-space
separators: 0 leaks, 0 non-idempotent results.
…tity reads What changed: - injection.py: the query-parameter SSRF finding records `parameter=ep.param` and `method="GET"`; the JSON body-field variant records `parameter=body_field` and `method="POST"`. Both record `url` explicitly. - business_logic.py: the workflow-bypass finding records `method=method` and `url` explicitly. - Four probe-driven tests, through the existing harnesses. Why: Two collisions survived the location derivation because the distinguishing value was in evidence but not in a field `canonical_asset_string` reads. `_test_ssrf` and `_test_ssrf_body_field` both emit under PT-API7-01, on the same endpoint, with the same CWE and OWASP id. Neither severity nor title is part of identity, so a target vulnerable through both the query parameter and the JSON body field produced two findings under one finding_id. business_logic's workflow loop builds `url` from the path alone and keeps the method only as evidence, while `GrayboxFinding.method` defaults to None - so the method slot in the asset was always empty and two configured endpoints on one path with different verbs shared an id. A missing guard on GET and on DELETE are two defects with two fixes. Fixed with the existing typed fields rather than by minting new scenario ids: `graybox/scenario_catalog.py` and the gate at `probes/base.py:263` both key on the current vocabulary, so a new id is a wider change than it looks. Checks: - pytest test_probes_injection.py test_probes_business.py -> 48 passed. All four new tests fail with the probe change stashed.
…lly produces What changed: - `test_an_id_stamped_at_production_still_wins_on_the_read_path` replaced by `test_the_persisted_form_carries_no_id_for_the_read_path_to_honour`, which asserts `to_dict()` emits no finding_id, that the recomputed id matches the production value, and that a stamped payload would still win. - Corrected the coverage-record docstring and added a test that a coverage record naming a location keeps its status. Why: The old test handed `flat_from_dict` a payload carrying a `finding_id` and asserted the stamp won. `GrayboxFinding` has no such field, so `to_dict()` - the persistence path - never emits one, and `mixins/risk.py` is the only production caller. The branch was dead for graybox and the fixture could not occur, yet the PR and RM-074 claimed on its strength that archived findings were unaffected. They are not. finding_id rotates once for every finding from these four probes; mixins/report.py keys first_seen/last_seen/pass_count on it, so the timeline resets one pass, and repositories/cstore.py keys triage on (job_id, finding_id). Accepted deliberately - the old ids collided across endpoints, so preserving them preserves the bug - and now documented instead of denied. The coverage docstring asserted that coverage records carry no location. Six sites do (misconfig 465/942/965/1058, injection 375, access_control 204) and now gain an asset. Harmless, because every coverage consumer keys on status via is_coverage_result, never on asset emptiness - but the docstring said otherwise. Checks: - Full suite -> 2451 passed, 3 skipped, 296 subtests, both randomised and `-p no:randomly` order.
What changed:
- The cookie match stops at `'` and the already-redacted guard is an equality
test again. An empty cookie value (`csrftoken=`) left a bare `=` that swallowed
the rest of an assembled curl line on the next pass.
- `_is_a_cookie_header_value` decides whether a match is really a header:
the first pair must be a `name=value` (or follow a separator), and its name
must not itself contain a colon.
- `comment` and `version` dropped from `_COOKIE_ATTRIBUTES`.
- `location_from_evidence` reads only the first `;` clause of each evidence item,
and derives nothing when the list names more than one location.
- business_logic no longer stamps a typed `method`.
- Six tests added; three tightened to fail against the mutants that passed them.
Why:
Four independent agents attacked the branch. Each finding below was reproduced
before being fixed.
Redaction. (1) `Cookie: sid=<redacted>; csrftoken=` is stable alone but not once
embedded in a curl argument: the trailing `=` glued to the rest of the command,
ate the URL and the closing quote, and left a replay step that will not parse -
the same root cause as the earlier truncation, which the prefix guard had only
masked for one shape. Bounding at the quote fixes it structurally and lets the
guard be an equality test again, which also closes (2) `sid=<redacted>SECRET`
walking through a prefix test on a value the target chooses. (3) Requiring a
separator stopped redacting `Cookie:sessionid=SECRET`, which RFC 7230 permits and
no other pattern covers. (4) `http.cookiejar` accepts a cookie named `Cookie: y`,
so `Cookie: y:missing_Secure` satisfied the separator rule and suppressed the
finding anyway - the target picks that name. (5) `urllib3` folds duplicate
Set-Cookie headers with ", ", so `Path=/, sessionid=SECRET` is two cookies in one
segment and the allowlist kept it whole. (6) `Comment=` holds free server text.
Identity. Scanning every clause let the target write its own identity:
`server_returned={body!r}` carries verbatim target output, nothing escapes `;`,
and a body containing `; param=...` injected a clause into the hash - rotating a
finding's id every scan, or merging two distinct findings. Reading only the
clause the probe opened closes it. Separately, several probes emit one finding
aggregating N locations; keying that on the first meant remediating one of three
rotated the id of the finding covering the other two, on every scan. Ambiguous
now derives nothing, as before.
business_logic's method stamp is withdrawn because its premise was false: the
loop issues POST only for POST and a plain GET otherwise, so a DELETE entry is
probed with GET. The evidence line and replay step already misreport that;
putting it in a typed field would have keyed identity on a request never sent.
A test pins the probe's real behaviour so whoever fixes it sees what moves.
Known residual, documented in the docstring rather than guessed at: a spaceless
`Cookie:` whose first pair has no `=` is not redacted. Separating it from
`cookie:missing_Secure` needs a signal neither string carries, and guessing wrong
destroys evidence whose name the target chose.
Checks:
- Full suite -> 2458 passed, 3 skipped, 305 subtests, both orders.
- 20000 generated realistic header shapes: 0 leaks, 0 non-fixed-points, with the
documented residual excluded.
|
Pushed Redaction
Identity
WithdrawnThe Test qualityThree tests passed against deliberately broken implementations and are tightened: the composite-derivation test asserted only "no Known residual, documented not guessed atA spaceless Suite: 2458 passed, 3 skipped, 305 subtests, both orders. 20,000 generated realistic header shapes: 0 leaks, 0 non-fixed-points, with that residual excluded. |
… any quote
What changed:
- `_SCRUB_PATTERNS`' cookie rule reads `(?:[^\r\n']|'(?!\s|$))*` instead of
`[^\r\n']*`, so only a `'` followed by whitespace or end of string ends the
header value.
- Two tests, both confirmed red against the previous bound.
- Rewrote the paragraph in `_redact_cookie_header` that justified the old bound.
Why:
The target picks its own cookie values and `'` is inside RFC 6265 `cookie-octet`,
so stopping at the first quote handed a target exactly the suppression
`_is_a_cookie_header_value` exists to deny. One cookie holding a quote ended the
match early and every pair after it reached the archive, the LLM input, the PDF
and the exports verbatim:
Cookie: junk=ab'cd; sessionid=SUPERSECRET
-> Cookie: junk=<redacted>'cd; sessionid=SUPERSECRET
The response direction needs no cookiejar round trip, since `urllib3` folds
duplicate `Set-Cookie` headers into one: `a=x', b=SESSIONSECRET` leaked `b`
straight out of `_artifact_from_response`.
Not a regression against develop, which leaks both shapes too - its rule stopped
at the first `;`. It is the last hole of the class this branch set out to close.
The bound still has to exist: `_curl_reproduction` embeds an already-scrubbed
header inside a `shlex.quote`d argument, and reading past the closing quote let a
trailing pair swallow the URL and the later `-H`s. `shlex.quote` renders an
embedded quote as '"'"', so an argument-closing `'` is always followed by
whitespace or end of string and one inside a value never is. That separates the
two cases without giving up the property. Residue is a cookie value holding `'`
immediately before a space, which `cookie-octet` excludes.
Checks:
- Full suite -> 2460 passed, 3 skipped, 307 subtests, in both randomised and
`-p no:randomly` order. Baseline on this branch was 2458 / 3 / 305.
- Both new tests fail against the previous bound; no test weakened or removed.
Two defects found while reviewing #492 (develop → main). Both were verified by execution against
developat5b5bd37b, both are fixed test-first, and every new test was confirmed red before its fix landed.Tracked as RM-074.
1.
Cookie:redaction stopped at the first;, so session cookies reached the archivescrub_graybox_secretsredacted cookie headers withr"(?i)\b(cookie)\s*:\s*[^,\r\n;]+". That character class terminates on;— a Cookie header's internal pair delimiter, not a field separator. Only the first pair was redacted:The session cookie survives verbatim whenever it is not the first pair — the common case, since analytics and preference cookies are usually sent first.
sessionid,PHPSESSID,JSESSIONIDandconnect.sidare in no genericname=valuepattern, so an opaque session id had no second line of defence. JWT-shaped values were caught by the separate JWT rule; opaque ones were not.scrub_graybox_secretsis the storage boundary: what it misses reaches the archive, the LLM input, the PDF and the exports.The pattern is not new, but
_artifact_from_response(added in #491) is — it snapshots raw request and response headers wholesale, where evidence was previously hand-authoredkey=valuestrings. That turns a latent weak pattern into a live path, against its own docstring's requirement that an artifact "must not become a new route for archiving what the scrubber removes elsewhere".Fix. One function-backed rule for both cookie headers, consuming to end of line and redacting per
;-separated pair.Whole-line redaction would have been wrong:
probes/misconfig.py:222-227reportsmissing_Secure/missing_HttpOnly/weak_SameSite, so blanking aSet-Cookiewould redact the evidence for the finding it just raised. The secret is the value; the flags are the finding. Cookie names are kept too — they are not secrets, and they are what makes the evidence readable.Redaction stays idempotent, which is a requirement rather than a nicety: the scrubber runs at assembly, at emission and again at the storage boundary, and
_ALREADY_REDACTEDexists because a rule that consumed its own placeholder corrupted a curl reproduction once already. Asserted for both header forms.The existing tests passed because both fixtures used single-pair headers.
2. Four probes never set
url, so distinct endpoints shared onefinding_idmodels/finding_identity.dedup_keyreads the location fromaffected_assets[].url— deliberately, and correctly. The problem is on the producing side: 92 of 93GrayboxFinding(...)constructions omiturl=, soaffected_assetsis[]and identity reduces to probe +scenario_id+ classification.Two findings from one probe, one scenario and one CWE therefore share an id:
This is a regression from #491. The previous
to_flat_findingfolded theendpoint=,path=,protected_path=,token_path=,flow=andtest_id=evidence prefixes into its id input directly. #491 replaced that with asset-based identity and shipped the migration shim —_location_from_evidence— but wired it only intoemit_*:api_abuse,api_access,api_auth,api_config,api_dataemit_*→_location_from_evidenceaccess_control,injection,misconfig,business_logicfindings.append(...)The gap is documented in the code that has it —
emit_vulnerable's docstring and theurlfield's own docstring both state that a probe omittingurlmakes two endpoints "collapse to one finding id".Collisions need same probe + same
scenario_id+ same CWE in one run, which happens at five emit-per-item loops:access_control.py:204(PT-A01-02, per privileged endpoint),injection.py:194(PT-A03-01, per vulnerable form),injection.py:353/:375/:425(PT-API7-01), andbusiness_logic.py:190(PT-A06-01).misconfig.py:120/:138does not collide — it emitsworst_findingonly.Impact. Nothing is lost from the report: report-layer dedup (
mixins/report.py:132) is a whole-dict JSON key, so two findings differing inevidenceboth survive. But triage keys onfinding_idalone (services/rulebook_assessment.py:421,get_job_triage(job_id, finding_id)), so marking one endpoint remediated marked the other remediated too. Triage is job-scoped, so there is no cross-target collision.Fix.
location_from_evidencemoves fromprobes/base.pyintograybox/findings.pyand is applied into_flat_findingwhenurl/parameterare unset — repairing all 87 direct constructions at one site. The emission path is unchanged.It reads the structured
key=valueprefixes only. Reading the evidence string back intodedup_keywould have reintroduced the wording-fork RM-062 removed, where re-phrasing a probe re-identified every finding it had ever produced. The derived value is scrubbed before promotion, asemit_vulnerablealready does, so a token in a query string does not become a typed field.Migrating the four probes onto
emit_*is the larger cleanup — 87 call sites, no behavioural need now — and is recorded against RM-072 Phase 2 rather than done here.One-time identity migration
Graybox findings from the four direct-construction probes now carry an asset, so their
finding_idchanges once. Archived findings are unaffected:flat_from_dicthonours the id stamped at production, and there is a test pinning it.Checks
pytest extensions/business/cybersec/red_mesh/tests/→ 2429 passed, 3 skipped, 275 subtests, in both randomised and-p no:randomlyorder.developis 2416 passed / 3 skipped / 275 subtests. The +13 is exactly the tests added here — 6 for cookie redaction, 7 for location derivation.url=wins, same endpoint still dedups, no location key still means no asset, stamped ids survive the read path).Note that neither repo runs tests on pull requests — there is no
pull_requesttrigger, and inedge-nodea merge todeveloppublishes Docker images in the same step that would have run them. That gap is filed as RM-073.