Skip to content

Keep-rules follow-ups: unforgeable placeholders, CVV floor in text, card numbers in token text (0.17.1, #160102) - #72

Merged
jab3z merged 22 commits into
mainfrom
fix/160102-keep-rules-followups
Oct 2, 2026
Merged

jab3z merged 22 commits into
mainfrom
fix/160102-keep-rules-followups

Conversation

@jab3z

@jab3z jab3z commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

The library side of #160102: what the 0.17.0 keep-rules round left, closed after three review rounds. A patch release (0.17.1): every change is a fix or a tightening. Some wallet tokens that 0.17.0 kept are now masked (fails closed); none that 0.17.0 masked now ships.

Placeholders can't be forged or confused (M4, M3)

  • A kept value's placeholder is named from a blake2b digest of the value itself ([KEPT-<14 letters>-MASKED]), not its position.
  • A placeholder written into the text (an entity-encoded &#91;KEPT-A-MASKED&#93; in a KPay body) restores nothing.
  • Two different wallets inside one hashed credential get two different tokens. The same credential with the same wallet keeps the same token on every line.

The CVV/SAD floor in text (M2)

  • Text that names a CVV or SAD key, XML tag, or {name, value} pair label anywhere keeps no wallet in it. It uses the key walk's classification, with safe keys honoured.
  • The text rules read objects only three levels deep, and a real Apple Pay token is three deep itself, so {"cvv": [token]} can only be floored this way. Fails closed.
  • Ottu's real bodies still keep their tokens: KPay <udf9> beside <password>, Connect's SDK Apple Pay request beside cvv_required, and a Google Pay body.

The guard reads a card number inside a kept value's text (N3)

  • A text leaf of a keep match is read in one linear pass, as the pci rules read a value (holds_pan_run). These now refuse the keep: "4111…+cvv+123" in base64, "4111…ab" in hex, "1234 4111…", "4111… 12/25", 20 bare digits.
  • Three leaves are not read: epoch milliseconds (Google Pay's keyExpiration), a canonical UUID, and a whole hex id of 24+ characters holding a hex letter.
    • The hex-id case follows the rule Connect's own card scan already uses. Without it, 1.1% of Apple Pay tokens were refused, because a random 64-hex transactionId sometimes holds a Luhn run.
    • So a card number written into a whole hex id ships with a kept token. Accepted (Dacian, 2026-10-02): Apple writes these ids, and an Apple Pay token carries no PAN, so the token is logged in full.
  • A crafted 400 KB token is decided in about 0.4 s, linear (round 2's version took 6.5 s).

Ottu's matchers (N3, contrib)

  • Every slot must be text in its alphabet: base64 (never hex alone), hex, or digits.
  • signedMessage and signedKey must hold exactly their fields.
  • displayName is letters plus an optional last four, and never a CVV or SAD name ("CVV 1234").
  • network and type are letters alone, or empty, and never a CVV or SAD name ("network": "cvv=123" and an email in type were kept before; from the bot review).
  • Real-token keep rate: 59,999 of 60,000. The one miss is a 13-digit run occurring by chance in ciphertext; it fails closed.

Also

  • A label rule wins at any depth strictly inside a keep match (M8).
  • A label rule that raises inside a keep match refuses the keep, raising nothing.
  • A rule with both field_type and keep = True is refused as ambiguous (M6).
  • The README states measured costs: wallet lines cost 10-35% more than 0.17.0; lines without a wallet are unchanged. It also states what the text floor and the guard read and don't.

Verification

  • 6877 tests pass on Python 3.12 and 3.10 (5765 at 0.17.0). Each fix's tests failed first.
  • golden/masking_0156.json is unchanged.
  • Reviews: task review (CHANGES_REQUESTED), re-review of rounds 1-2 (CHANGES_REQUESTED: a quadratic guard), and re-review of round 3 (APPROVED). The round-3 review ran ~2.5M never-raises fuzz calls with 0 exceptions, plus linear-cost probes up to 1.6 MB. Its minors are fixed here: base64 slots that are hex alone, a CVV-named displayName, and docs.

Left for a follow-up

  • Generic keep rules only, not reachable through Ottu's WALLET_RULES:
    • a dict key that is a card number ships in text;
    • a JSON number inside JSON text is read by int_is_pan only;
    • a CVV key written with \uXXXX escapes is read by the key walk, not the text floor (documented).
  • A localised, non-Latin displayName fails the matcher; the token is then masked, which fails closed.
  • Samsung Pay still needs a real captured token.

Release

Patch, 0.17.1. Connect and Ottu PG then pin >=0.17.1.

Refs #160102

🤖 Generated with Claude Code

jab3z and others added 21 commits October 1, 2026 21:20
…olds

Placeholders were named by position, `[KEPT-A-MASKED]`, `[KEPT-B-MASKED]`.
An element whose entity-encoded text decoded to such a name got the kept
value beside it copied in by the outer restore (review M4), and a
credential hashed around a kept value hashed the placeholder, so every
value there gave one token where the key walk gives each its own (M3).

A placeholder is now named from a blake2b digest of the value as written:
fourteen digit-free letters, base 26. The same value has the same name in
any text (two identical kept values share one entry), a different value
another, and a name written by anyone without the value restores nothing.
Two values whose names collide are masked as without a keep rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y right before

In text the floor read only the key right before an object, so these were
kept where the key walk keeps nothing (review M2): `{"cvv": [ {…} ]}` (a
list between), `{"name": "cvv", "value": {…}}` (a pair labelled as one, which
the README said the floor covered), `"cvv" = {…}` and `'cvv' => {…}`.

`_find_kept` no longer looks into an object nothing matched whose parse has a
CVV or SAD key at its top level or is a pair labelled as one -- read as the
guard reads a pair, now `value_rules.pair_labelled` -- nor into one that does
not parse or names a key twice. It fails closed: a value under a sibling key
of such an object is masked, not kept. `_KEY_BEFORE` takes `=>`, and a quoted
key followed by `=` or `=>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With `[APPLE_PAY_RULE, KeepRule(lambda value: True)]`,
`{"outer": {"inner": <paymentData>}}` shipped the inner token unlabelled:
the keep rule matched the outer mapping first (review M8).

A keep match is now refused, walked as if nothing had matched, when a
label rule labels something strictly inside it (`keep_refused`:
`label_within` over its members, asked only when a label rule is among the
rules). The check sits where the guard's does, so the key walk, text, rule
2, bodies and `is_kept` all get it; `label_within` looks into a keep match
like any other mapping. For the value itself the first match still wins: a
keep rule listed first for the same value keeps it.

`test_an_inner_label_match_is_kept_with_the_kept_object` pinned 0.17.0's
reading and now pins this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, not read as a keep rule

Such a rule loaded as a keep rule, the less safe reading (review M6).
`load_value_rules` now reports it as a problem naming both forms, alone or
in a collection: `configure_masking_value_rules` raises, and from the
setting or env var it is dropped with a warning and listed by the Django
boot check, which surfaces those problems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t holds token text

The guard reads keys, pairs and whole leaves, so text inside a kept
token's string slot shipped with it: `"data": "cvv=123"` (review N3,
Redmine R3).

Each slot must now be text in its alphabet. Apple Pay `paymentData`:
`data`, `signature` and the header's `ephemeralPublicKey`, `publicKeyHash`
and `wrappedKey` standard base64, `transactionId` and `applicationData`
hex. PKPaymentToken: `transactionIdentifier` hex, `paymentMethod` short
text (64 characters at most). Google Pay: `signature` and every
`intermediateSigningKey.signatures` base64; `signedMessage` JSON text of
`encryptedMessage`, `ephemeralPublicKey` and `tag` in base64 and nothing
else, `signedKey` of `keyValue` in base64 and `keyExpiration` in digits and
nothing else, both read strictly (a key written twice refused). A token
that does not conform is masked as before. Every fixture token conforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d token, what never raises, what keep rules cost

- An entity-encoded wallet token is set aside by redact_body but not by
  the log processor's text pass: with pci about one entity-encoded Google
  Pay token in ten comes out altered, failing closed (review M5).
- is_kept and mask_outside_kept never raise of their own;
  mask_outside_kept passes on the caller's mask errors (M7). The cost
  paragraph says what keep rules cost a line, and a line with a wallet.
- A repr of a Google Pay ECv2 token inside JSON-encoded text is not kept:
  its escaped quotes no longer render back as written (N2).
- CLAUDE.md: placeholder names, the text floor's reading of an object
  read whole, a label rule winning inside a keep match, the refused
  ambiguous rule, Ottu's slot alphabets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text floor read only the object right before a key, or the objects the
text rules read whole, three levels deep. A wallet token's own nesting
takes two, so `{"cvv": [ <token> ]}`, `{"name": "cvv", "value": <token>}`
and a token deeper under `securityCode` were kept for every real Ottu
token, where the key walk keeps nothing (review I1).

`kept_spans` now keeps nothing in text that names a CVV or SAD key, XML
tag or `{name, value}` pair label anywhere (`floored_text`): a quoted key
then `:`, `=` or `=>`, a bare key then `:` or `=>`, a key and `=` where a
key starts (not base64 padding inside a string), an element's tag, or a
pair's label, read with `classify_key(..., ALL_PACKS)` and the service's
safe keys, as written and entity-decoded. It is read only once a span is
found. `redact_body` keeps nothing in an entity-encoded element of a body
that names one. It fails closed: a value beside such a key is not kept in
text, though the key walk keeps it.

The per-object checks this replaces (`_floored_before`, `_KEY_BEFORE`,
`_floors`) add nothing the scan does not, and are gone; an object that
does not parse or names a key twice is read member by member again, as in
0.17.0. Ottu's KPay body, Connect's SDK Apple Pay request and a Google Pay
PaymentData body still keep their tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… holds a card number

The guard read keys, pairs and whole leaves, so a card number written
inside a kept value's string shipped with it, even in a slot's own
alphabet: `"data": "4111…+cvv+123"` is base64, `"transactionId":
"4111…ab"` hex (review I2).

A text leaf that is no JSON text now holds card data when a run of 13 to
19 digits in it, joined by single separators at most and bounded by
non-digits, passes Luhn (`patterns.holds_card_run`), epoch milliseconds
(thirteen digits from a 1 or a 2) excepted as for a whole leaf. JSON text
is read by its own leaves, so Google Pay's `keyExpiration` stays exempt.
It is in the core guard, so every keep rule gets it. A CVV or other short
value inside a string still ships with it, as the README now says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the last four

`displayName` was any text of up to 64 characters, so `"Visa 1234 cvv
123"` shipped the CVV in a kept token (review I2). It is now at most 40
characters with no digit but one trailing group of exactly four after a
space (`"Visa 0492"`, `"MasterCard 1234"`, `"Amex"`); anything else is
no wallet's display name, and the token is not kept whole (its
`paymentData` still is). The README says what the slot checks and the
guard catch and what they cannot: a CVV written in a slot's own alphabet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… every field

`signedMessage` and `signedKey` were read as "at most" their fields, so
`"signedMessage": "{}"` was kept (review M7). `signedMessage` must now hold
`encryptedMessage`, `ephemeralPublicKey` and `tag`, and `signedKey`
`keyValue` and `keyExpiration`, and nothing else. The `GOOGLE_PAY_V1`
fixture held only `encryptedMessage`, which no ECv1 token does: it now
holds all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e keep, raising nothing

`keep_refused` asks the label rules about a keep match's members, which
0.17.0 never asked: a label matcher raising there made `redact_body` and
`mask_by_patterns` raise where 0.17.0 returned the value kept (review M5).
Its error now refuses the keep (fails closed): the value is masked as
with the label rules alone, and nothing is raised from that question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…allet in microseconds

Measured on a KPay body holding an Apple Pay token (7 KB), `floored_text`
took 0.9 ms as regexes tried at every character (a lookahead at each, and
a bare name backtracking through every base64 word), and the guard's
run check 74 µs a 3 KB leaf.

`floored_text` now finds each `:` and `=` and reads the key back from
there (a quote's opening found with `rfind`, a bare name matched on the
reversed text before it), and reads a pair's label forward from an
identifier key: about 20 µs. `holds_card_run` first marks digits and
separators with one bytes translate: ASCII text with neither thirteen
digits in a row nor a separator (base64, hex) holds no run, about 2.5 µs
a 3 KB leaf. A name longer than 128 characters is read by its end, after
`:` or `=>`, where it was not read at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de a keep match when it is the first match

- The cost paragraph gives the measured figures (review 3): with Ottu's
  WALLET_RULES a line with no wallet token costs 1-4% more than 0.16.0's
  label rules and 3-15% more than no value rule; a line with a token has
  no single figure (an MPGS paymentToken line, a Google Pay message, a
  KPay body, each labelled / kept / unconfigured), and this release's
  checks add 15-40% over 0.17.0 there.
- The M8 example needs the label rule listed before the catch-all keep
  rule, which otherwise is the inner value's first match too (review 4);
  CLAUDE.md and the value_rules docstring said "wherever it is listed".
- CLAUDE.md: the guard's card-number run, a label rule's error refusing
  a keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…characters or more

Read like other text, about one random 64-hex id in two hundred holds a
13-19 digit run that passes Luhn, and an Apple Pay token carries two
(`transactionId`, `transactionIdentifier`): 1.1% of tokens were refused
and masked instead of kept.

A run inside a whole hex token of 24 characters or more -- an ObjectId, a
32-hex gateway id, a 64-hex digest, nothing alphanumeric touching it -- is
no card number, as Connect's own card scan reads one. Shorter hex
(`4111…ab`) and hex glued to more letters are still read, and a whole-leaf
card number is still refused first. On 3000 random tokens, none is
refused now, Apple Pay or Google Pay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o, in one pass, skipping only a whole hex id

Round 2 skipped a Luhn run inside any whole hex token in a leaf, and found
the tokens again for every run: a crafted base64 `data` of
`/4111111111111111deadbeef` repeated kept its token and cost 6.5 s at
400 KB (review r2, 1). Bounded by `/`, the hex inside base64 also shipped
its card number (4). And the run was narrower than the pci rules': a card
beside another digit group (`1234 4111…`, `4111… 123`) and 20 bare digits
shipped (3).

A leaf that is a whole hex id of 24 characters or more -- Apple Pay's
`transactionId`, `transactionIdentifier`, `applicationData` -- is not read
for a run; any other text leaf is read once with `holds_pan_run`, as the
pci rules read a value: 12 digits or more joined by single separators. A
leaf that is epoch milliseconds stays exempt, and a whole-leaf card number
is still refused first. 400 KB of the crafted `data` is refused in 9 ms
(the keep decision on its text 33 ms), linear in its size; of 3000 random
Apple Pay tokens none is refused for a hex id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lly the last four

`displayName` accepted any non-digit text of up to 40 characters, so
`jane.roe@example.com` was kept (review r2, 6). It is now
`[A-Za-z][A-Za-z .&-]{0,39}`, optionally followed by one space and
exactly four digits (`Visa 0492`, `American Express`, `Amex`), and the
README says exactly that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…does

The README said the text floor reads a name "as the key walk reads a key"
(review r2, 2). It reads a quoted key, tag or pair label as written, up to
128 characters (a longer key by its last 128), with no `\uXXXX` escape
decoded; the key walk parses JSON and reads more, so a key written with
escapes, or longer with its CVV word first, floors there and not in text.
Through Ottu's WALLET_RULES that gap ships nothing but a token's own text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rd's one-pass read

Re-measured after the guard's read became one pass of `holds_pan_run`
(this round): a Google Pay message line kept costs ~300 µs where it cost
~341 µs, and this release's checks add 10-35% over 0.17.0 on a wallet
line, where the paragraph said 15-40%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A leaf of digits alone matched the hex-id exemption, so 24 digits or more
written into a hex slot (a card number and eight digits in Apple Pay's
`transactionId`) were not read for a card number. A hex id holds a hex
letter; digits alone are read like any other text (#160102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ame never names a CVV

A whole hex id of 24 characters or more is not read for a card number (the
guard's exemption for Apple Pay's ids), so a base64 slot written in hex
alone (`"data": "4111111111111111abcd1234"`) shipped a card number. A
wallet's base64 never is: Ottu's matchers refuse it. And a displayName
that reads as a CVV or other SAD ("CVV 1234", "Security Code 1234") is no
card's: a CID is four digits, the shape of the last four (#160102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the hex letter

The README names the canonical UUID as the guard's third exempt leaf and
rewrites that paragraph; the text floor reads an XML tag of any length; the
docstrings and ecsctx/CLAUDE.md say a skipped hex id holds a hex letter,
and the stale "Not holds_pan_run" line is gone (#160102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jab3z
jab3z requested a review from suliman-ottu as a code owner October 2, 2026 04:29
@jab3z

jab3z commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Claude finished @jab3z's task in 1m 6s —— View job


Review of #72 (keep-rules follow-ups, 0.17.1)

I read the code statically. I couldn't run the tests or my probes: the sandbox denied pytest, writing a probe script, and git fetch. The checkout has no merge base with origin/main, so I read the final files instead of the diff. I read masking/value_rules.py, contrib/ottu/masking.py, and the placeholder, floor and holds_card_run parts of masking/patterns.py. I did not read contrib/net/__init__.py or the tests.

Overall: the design holds up. Naming placeholders from a digest of the value, applying the CVV/SAD floor to the whole text, and reading card runs inside kept text are all sound, and every failure path fails closed. I found two gaps, both in places where the PR text claims more than the code does. Neither blocks the PR, but I'd resolve both before release or add them to the "Left for a follow-up" list.

Findings

1. network and type in paymentMethod are not alphabet-checked (ecsctx/contrib/ottu/masking.py, _is_pk_payment_token)

  • The PR says "Every slot must be text in its alphabet".
  • network and type are only required to be a str of at most 64 characters (_PAYMENT_METHOD_TEXT_LIMIT).
  • holds_card_data documents that it does not read a short CVV inside a longer string and leaves that to the matcher. This matcher doesn't check, so these values ship as sent:
    • {"network": "cvv=123"}
    • {"type": "john@example.com"}
  • I could not run a probe, so this comes from reading the code: the guard only sees a key, a pair, a whole PAN, or a 12+ digit run.
  • The README's "CVV written in a slot's own alphabet cannot be told from ciphertext" caveat doesn't cover this, because these slots have no alphabet.
  • Fix: give network and type a letters-only pattern, such as [A-Za-z][A-Za-z .&_-]{0,31}, and run classify_key over them as _is_display_name does.

2. The hex-id exemption lets a card number ship inside a hex-alphabet slot (ecsctx/masking/patterns.py:826, _HEX_ID)

  • A leaf of 24+ hex characters that holds at least one hex letter is never read for a card run.
  • Ottu's hex slots (transactionId, applicationData, transactionIdentifier) accept any hex text. A value like 4111111111111111abcdefabcdefabcd is accepted by the matcher.
    • It is not pan_shaped as a whole.
    • holds_card_run returns False.
    • It ships with the PAN in it.
  • A real Apple Pay transactionId is a random 64-hex digest. The cost is about 1.1% of real tokens refused, and the PR accepts it for the benefit of Connect's own scan.
  • The PR notes this rule follows Connect's scan, but not that it opens a way to ship a PAN. A cheap tightening is to exempt only a leaf of exactly 64 hex characters (SHA-256) for these slots. Alternatively, document it as an accepted residual next to the "Left for a follow-up" list.

Minor

  • _BASE64 rejects any string made only of hex characters. That is fail-closed and correct for real tokens, but a short tag could in principle be refused by chance. The probability is negligible at the real lengths, so no action is needed.
  • Placeholders use an unkeyed 64-bit blake2b digest, so forging one needs the value itself. The collision path (stash_kept returns None, KeptStash.hold returns the value) fails closed. Fine.
  • floored_text is only evaluated once a span is found, which keeps the cost off lines with no wallet. Good.

Not verified

  • The claims of 6797 passing tests, the fuzz runs and the 0.4 s linear-time probe. I did not rerun any of them.
  • contrib/net (redact_body changes) and the new tests.
    · branch fix/160102-keep-rules-followups

… display name

A PKPaymentToken's paymentMethod network and type were any text of 64
characters at most, so `"network": "cvv=123"` or an email in `type`
shipped with the kept token: the guard reads keys, pairs and card numbers, not
a CVV written in a string. Both are now words alone or nothing (letters,
spaces, `.`, `&`, `-`, from a letter, 40 at most) that read as no CVV or
other SAD, as the display name already was (bot review on #72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jab3z
jab3z merged commit 20dbd5a into main Oct 2, 2026
2 of 6 checks passed
@jab3z
jab3z deleted the fix/160102-keep-rules-followups branch October 2, 2026 05:20
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