16g-egress-recursion: mask the value under a masked key, not only its binary - #11
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes a live leak at the MCP egress boundary.
Egress.mask/1routed a key in@masked_fieldstomask_value/1, whose second clause returned any non-binary verbatim with no recursion — while a key not in@masked_fieldsrouted tomask/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
268a83ebefore any edit:No test could catch it — every fixture in
egress_funnel_test.exsplaced 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/1recurses through maps, lists and structs, applyingPrivacyMask.mask/1at 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 throughmask/1would have left%{"src" => %{"addr" => "10.0.0.4"}}exposed.Red before fix, reproduced independently in both rounds
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
MapSetcounterexample — and that split is recorded rather than resolved.Out of scope, filed not fixed
PrivacyMaskrecognises only RFC1918 and loopback IPv4Suite 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