Skip to content

16g-egress-recursion: mask the value under a masked key, not only its binary - #11

Merged
HackTuah merged 1 commit into
mainfrom
slice/16g-egress-recursion
Sep 5, 2026
Merged

16g-egress-recursion: mask the value under a masked key, not only its binary#11
HackTuah merged 1 commit into
mainfrom
slice/16g-egress-recursion

Conversation

@HackTuah

@HackTuah HackTuah commented Sep 5, 2026

Copy link
Copy Markdown
Member

Fixes a live leak at the MCP egress boundary. Egress.mask/1 routed a key in @masked_fields to mask_value/1, whose second clause returned any non-binary verbatim with no recursion — while a key not in @masked_fields routed to mask/1, which did recurse.

The polarity was inverted: host identity nested under a masked key survived, while the same bytes under an unmasked key were masked. Measured at 268a83e before any edit:

flat   : %{"src" => "10.0.0.4"}               -> %{"src" => "[LOCAL_HOST]"}
nested : %{"src" => %{"host" => "10.0.0.4"}}  -> unchanged   LEAK
list   : %{"src" => ["10.0.0.4"]}             -> unchanged   LEAK
unmask : %{"other" => %{"src" => "10.0.0.4"}} -> masked

No test could catch it — every fixture in egress_funnel_test.exs placed a flat binary under the masked key, so the funnel test passed while the funnel leaked. The moduledoc claimed it "walks maps and lists"; it walked them except under a masked key.

The change

mask_value/1 recurses through maps, lists and structs, applying PrivacyMask.mask/1 at every binary leaf. A masked key has already declared its subtree identity-bearing, so masking every leaf beneath it is the conservative reading — routing back through mask/1 would have left %{"src" => %{"addr" => "10.0.0.4"}} exposed.

Red before fix, reproduced independently in both rounds

fixtures added, production untouched   5 tests, 2 failures
after the fix                          5 tests, 0 failures

Round 2 replaced refute-only assertions with positive ones. An implementation returning [] and %{} — silently deleting every masked subtree — passed round 1's tests and fails round 2's. Both lanes measured that flip by restoring the round-1 test file. Deleting either recursion clause kills exactly its own test.

Review

Two rounds, two independent reviewers each on an index checkout. Round 1 PASS/PASS with acceptance criterion 6 unmet; round 2 bounded to three items, PASS/PASS. Nine residuals recorded, none blocking. The lanes differ on whether criterion 6 is now fully met — r1 yes, r2 not quite, on an unreachable MapSet counterexample — and that split is recorded rather than resolved.

Out of scope, filed not fixed

  • SCR-67 — PrivacyMask recognises only RFC1918 and loopback IPv4
  • SCR-194 — tuples and keyword lists are not walked
  • SCR-195 — a charlist is walked by the clause this PR adds and reassembled unchanged; binary map keys are never inspected

Suite 256 tests / 0 failures. credo held at 76, dialyzer held at 43. Evidence in internal/slices/16g-egress-recursion/FINDINGS.md.

Issue: SCR-193.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VNdY6wVS773UJgrHUeBN3Y

… binary

Egress.mask/1 routed a key in @masked_fields to mask_value/1, whose second clause
returned any non-binary verbatim with no recursion. A key NOT in @masked_fields
routed to mask/1, which did recurse. The polarity was inverted: host identity
nested under a masked key survived, while the same bytes under an unmasked key
were masked. Measured at 268a83e, before any edit:

  flat   : %{"src" => "10.0.0.4"}               -> %{"src" => "[LOCAL_HOST]"}
  nested : %{"src" => %{"host" => "10.0.0.4"}}  -> unchanged   LEAK
  list   : %{"src" => ["10.0.0.4"]}             -> unchanged   LEAK
  unmask : %{"other" => %{"src" => "10.0.0.4"}} -> masked

No test could catch it: every fixture in egress_funnel_test.exs placed a flat
binary under the masked key, so the funnel test passed while the funnel leaked.

mask_value/1 now recurses through maps, lists and structs, applying
PrivacyMask.mask/1 at every binary leaf. A masked key has already declared its
subtree identity-bearing, so masking every leaf beneath it is the conservative
reading; routing back through mask/1 would have left %{"src" => %{"addr" => ...}}
exposed.

Red before fix, both rounds' reviewers reproducing it independently:

  fixtures added, production untouched   5 tests, 2 failures
  after the fix                          5 tests, 0 failures

Round 2 replaced refute-only assertions with positive ones. An implementation
returning [] and %{} -- silently deleting every masked subtree -- passed round 1's
tests and fails round 2's; both lanes measured that flip by restoring the round-1
test file. Deleting either recursion clause kills exactly its own test.

Two rounds, two independent reviewers each on an index checkout. Round 1
PASS/PASS with acceptance criterion 6 unmet; round 2 bounded to three items and
PASS/PASS. Nine residuals recorded, none blocking. The lanes differ on whether
criterion 6 is now fully met -- r1 yes, r2 not quite, on an unreachable MapSet
counterexample -- and that split is recorded rather than resolved.

Out of scope and filed, not fixed: PrivacyMask's RFC1918/loopback-IPv4-only
recognition (SCR-67); tuples and keyword lists (SCR-194); charlists and binary
map keys (SCR-195). A charlist is walked by the clause this commit adds and
reassembled unchanged, which is why SCR-195 exists.

Suite 256 tests 0 failures; credo held at 76; dialyzer held at 43.
Evidence in internal/slices/16g-egress-recursion/FINDINGS.md.

Issue: SCR-193.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNdY6wVS773UJgrHUeBN3Y

Reviewed-diff: sha256:cea5f8288d0257efda76fbc0f0e19283a8ec1022180ece73b10f84f7205542d5
@HackTuah
HackTuah merged commit 778accb into main Sep 5, 2026
20 of 22 checks passed
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