Skip to content

fix(rm-074): redact whole cookie headers, and stop distinct endpoints sharing a finding_id - #493

Merged
toderian merged 9 commits into
developfrom
fix/rm-074-graybox-identity-and-cookie-redaction
Sep 7, 2026
Merged

toderian merged 9 commits into
developfrom
fix/rm-074-graybox-identity-and-cookie-redaction

Conversation

@toderian

@toderian toderian commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Two defects found while reviewing #492 (develop → main). Both were verified by execution against develop at 5b5bd37b, 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 archive

scrub_graybox_secrets redacted cookie headers with r"(?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:

IN : Cookie: theme=dark; sessionid=s3cr3tSESSIONVALUE; csrftoken=AbCdEf123456
OUT: Cookie: <redacted>; sessionid=s3cr3tSESSIONVALUE; csrftoken=AbCdEf123456

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, JSESSIONID and connect.sid are in no generic name=value pattern, 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_secrets is 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-authored key=value strings. 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.

Cookie: theme=dark; sessionid=SECRET  ->  Cookie: theme=<redacted>; sessionid=<redacted>
Set-Cookie: sessionid=SECRET; Path=/; HttpOnly; Secure; SameSite=Lax
                              ->  Set-Cookie: sessionid=<redacted>; Path=/; HttpOnly; Secure; SameSite=Lax

Whole-line redaction would have been wrong: probes/misconfig.py:222-227 reports missing_Secure / missing_HttpOnly / weak_SameSite, so blanking a Set-Cookie would 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_REDACTED exists 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 one finding_id

models/finding_identity.dedup_key reads the location from affected_assets[].url — deliberately, and correctly. The problem is on the producing side: 92 of 93 GrayboxFinding(...) constructions omit url=, so affected_assets is [] and identity reduces to probe + scenario_id + classification.

Two findings from one probe, one scenario and one CWE therefore share an id:

PT-A03-01, two vulnerable forms, same CWE:
  A fa1993af37904128  /admin/users/search
  B fa1993af37904128  /admin/settings/search   COLLIDE

This is a regression from #491. The previous to_flat_finding folded the endpoint=, path=, protected_path=, token_path=, flow= and test_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 into emit_*:

Probes Emissions Path Identity
api_abuse, api_access, api_auth, api_config, api_data 103 emit_*_location_from_evidence correct
access_control, injection, misconfig, business_logic 87 direct findings.append(...) collides

The gap is documented in the code that has it — emit_vulnerable's docstring and the url field's own docstring both state that a probe omitting url makes 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), and business_logic.py:190 (PT-A06-01). misconfig.py:120/:138 does not collide — it emits worst_finding only.

Impact. Nothing is lost from the report: report-layer dedup (mixins/report.py:132) is a whole-dict JSON key, so two findings differing in evidence both survive. But triage keys on finding_id alone (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_evidence moves from probes/base.py into graybox/findings.py and is applied in to_flat_finding when url/parameter are unset — repairing all 87 direct constructions at one site. The emission path is unchanged.

It reads the structured key=value prefixes only. Reading the evidence string back into dedup_key would 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, as emit_vulnerable already 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_id changes once. Archived findings are unaffected: flat_from_dict honours 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:randomly order.
  • Baseline on develop is 2416 passed / 3 skipped / 275 subtests. The +13 is exactly the tests added here — 6 for cookie redaction, 7 for location derivation.
  • 8 of the 13 fail without their fix; the other 5 are guard tests that already held (explicit url= wins, same endpoint still dedups, no location key still means no asset, stamped ids survive the read path).
  • No test weakened, skipped or deleted.

Note that neither repo runs tests on pull requests — there is no pull_request trigger, and in edge-node a merge to develop publishes Docker images in the same step that would have run them. That gap is filed as RM-073.

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.
@toderian

toderian commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 503f7272, correcting three defects a review pass found in the cookie rule from 6b93213a. All three were regressions against develop, and all three were reproduced before being fixed.

1. The rule truncated the curl replay step — the failure _ALREADY_REDACTED exists to prevent, one level down. _curl_reproduction scrubs each header before shlex.quoteing 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 against the placeholder did not fire:

ASSEMBLED : curl -i -H 'Cookie: theme=<redacted>; sessionid=<redacted>' -H 'Accept: */*' 'https://app.test/a?x=1'
WAS       : curl -i -H 'Cookie: theme=<redacted>; sessionid=<redacted>

The guard now matches a prefix, which is the same guarantee _ALREADY_REDACTED gives the patterns around it. The bare-header idempotency test passes against the broken version, so the regression test goes end-to-end through _curl_reproduction instead.

2. The attribute allowlist applied to the request direction, where path and secure are ordinary cookie names — Cookie: secure=SECRET; path=SECRET2 passed through untouched. It is now gated on Set-Cookie and on non-first position, since the first pair of a Set-Cookie is the cookie however it is named.

3. A segment with no = was passed through verbatimCookie: OPAQUETOKEN is redacted on develop and was not here. Such a segment is an opaque value and is now redacted whole. Relatedly the (?<![-\w]) lookbehind 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 is back at no cost.

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 (Path=/admin) 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 now a test pinning that boundary explicitly.

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.
@toderian

toderian commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 92357a1b. I ran a four-agent adversarial review round on the branch diff (redaction / identity / blast radius / test quality). It found six more defects, and I reproduced every one before fixing it. Two were introduced by my own previous fix in this PR.

Redaction

  1. An empty cookie value destroyed the curl replay step. Cookie: sid=<redacted>; csrftoken= is a fixed point alone, but not once embedded in a shlex.quoted curl argument — the trailing = glued to the rest of the command and ate the URL and the closing quote. http.cookiejar emits name= for any cleared cookie, so this was common. Same root cause as the truncation fixed in 503f7272; the prefix guard had only masked it for one shape. The match now stops at ', which fixes it structurally and lets the guard be an equality test again — which in turn closes:
  2. sid=<redacted>SECRET123 walked through the prefix guard. The target picks its own cookie values.
  3. Requiring a separator stopped redacting Cookie:sessionid=SECRET, which RFC 7230 permits and no other pattern covers — sessionid/PHPSESSID/connect.sid are in none of them. My comment claiming the generic patterns covered it was simply wrong.
  4. The separator rule did not actually close the suppression it was added for. http.cookiejar accepts a cookie named Cookie: y, so Cookie: y:missing_Secure satisfied it and suppressed the PT-A02-04 finding anyway. The name is target-chosen, so the check now also requires that the first pair's name contain no colon.
  5. Comma-folded Set-Cookie hid the second cookie. urllib3 folds duplicates with ", ", so Path=/, sessionid=SECRET is two cookies in one segment and the attribute allowlist kept it whole.
  6. Comment=/Version= held free server text and were kept verbatim. Both dropped from the allowlist.

Identity

  1. My clause-splitting let the target write its own identity. server_returned={body!r} carries verbatim target output and nothing escapes ;, so a body containing ; param=… injected a clause straight into the hash — rotating a finding's id every scan, or merging two distinct findings. Derivation now reads only the clause the probe itself opened. The cost: a location written after another clause is not seen, so those findings keep the empty asset they had before — status quo, not a regression.
  2. Aggregate findings were keyed on whichever location came first. Several probes emit one finding covering N reachable paths; remediating one of three would rotate the id of the finding covering the other two, on every scan. An ambiguous list now derives nothing.

Withdrawn

The business_logic method stamp is reverted — its premise was false. _test_workflow_bypass issues POST only for POST and a plain GET otherwise, so a configured DELETE entry is probed with GET. The evidence line and replay step already misreport this; my typed method= would have keyed identity on a request that was never sent. Two config entries producing the same request really are one finding. A test now pins the probe's actual behaviour so whoever fixes it sees what has to move with it.

Test quality

Three tests passed against deliberately broken implementations and are tightened: the composite-derivation test asserted only "no ;" (truncating at ? also passed); the scrub-before-promote test checked the output string, which _scrub_flat_finding cleans anyway, instead of the identity; and secret_field_names was covered by a fixture the generic patterns caught regardless.

Known residual, documented not guessed at

A spaceless Cookie: whose first pair has no = is not redacted, though Cookie: OPAQUETOKEN is. Separating it from cookie:missing_Secure needs a signal neither string carries; it is a malformed header reachable only inside a response body, and guessing wrong destroys evidence whose name the target chose. It is in the docstring.

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.
@toderian
toderian merged commit b22711f into develop Sep 7, 2026
@toderian
toderian deleted the fix/rm-074-graybox-identity-and-cookie-redaction branch September 7, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant