Skip to content

Commit 57089a9

Browse files
fix(truthbench): match digit-only canaries within one numeric run (#408)
The digit-only variant projected a whole leaf to its concatenated digits, so in long prose or HTML a 7-digit canary could match digits from unrelated tokens ("a555b01c23" -> "5550123") and report a leak that no value holds. Digit-only patterns of _MIN_DIGIT_ONLY_LENGTH digits or more now match only inside a numeric run: digit groups joined by at most three whitespace, ".", "-", "/" or parenthesis characters, taken from the literal, HTML-unescaped, URL-decoded, NFKC and zero-width-removed forms. Reformatted numbers such as "555 0123", "(555) 0123" and "555 0123" are still found; the last one was previously missed because the entity's digits joined the stream.
1 parent cac855e commit 57089a9

2 files changed

Lines changed: 74 additions & 0 deletions

File tree

‎benchmarks/truthbench/privacy.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ def _digit_only(value: str) -> str:
7070
return "".join(char for char in value if char.isdigit())
7171

7272

73+
#: A run of digit groups joined only by the short separators numeric
74+
#: identifiers use: whitespace, ".", "-", "/" and parentheses. Digits split by
75+
#: letters, markup or any other character belong to different runs.
76+
_NUMERIC_RUN = re.compile(r"\d+(?:[\s./()\-]{1,3}\d+)*")
77+
78+
#: Text forms whose numeric runs are searched for digit-only canaries.
79+
_NUMERIC_RUN_FORMS = ("literal", "html-unescaped", "url-decoded", "nfkc", "zero-width-removed")
80+
81+
82+
def _numeric_digit_runs(forms: Mapping[str, str]) -> frozenset[str]:
83+
"""Return the digits of every numeric run in the searchable text forms."""
84+
85+
return frozenset(
86+
_digit_only(run)
87+
for name in _NUMERIC_RUN_FORMS
88+
for run in _NUMERIC_RUN.findall(forms.get(name, ""))
89+
)
90+
91+
7392
Transform = Callable[[str], str]
7493

7594
# Public names make the normalization contract inspectable and testable.
@@ -165,6 +184,12 @@ def _is_redacted_typed_mapping(value: Mapping[Any, Any]) -> bool:
165184
#: number); every other normalized variant still matches such canaries in
166185
#: full everywhere, so real leaks — including reformatted ones — remain
167186
#: detected.
187+
#:
188+
#: Longer digit-only patterns match inside a single numeric run of the leaf
189+
#: (see ``_NUMERIC_RUN``), never inside the leaf's whole concatenated digit
190+
#: stream. In long prose or HTML that stream joins digits from unrelated
191+
#: tokens ("a555b01c23" -> "5550123"), so a 7-digit phone projection would
192+
#: occasionally match by chance.
168193
_MIN_DIGIT_ONLY_LENGTH = 6
169194

170195

@@ -265,6 +290,7 @@ def _scan_text(self, value: Any, path: str) -> Leak | None:
265290
forms = _text_forms(value)
266291
literal_form = forms.get("literal", "")
267292
prose = any(char.isalpha() for char in literal_form)
293+
digit_runs: frozenset[str] | None = None
268294
for identifier in self._canaries:
269295
if isinstance(value, bytes):
270296
byte_patterns = {
@@ -289,6 +315,14 @@ def _scan_text(self, value: Any, path: str) -> Leak | None:
289315
if not prose and same_form == pattern:
290316
return Leak(identifier, variant, path)
291317
continue
318+
if variant == "digit-only":
319+
# Match within one numeric run only, so digits from
320+
# separate tokens are never joined into a canary.
321+
if digit_runs is None:
322+
digit_runs = _numeric_digit_runs(forms)
323+
if any(pattern in run for run in digit_runs):
324+
return Leak(identifier, variant, path)
325+
continue
292326
if same_form is not None and pattern in same_form:
293327
return Leak(identifier, variant, path)
294328
# Byte and escape forms do not have corresponding normalizers

‎tests/truthbench/test_privacy.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,46 @@ def test_scanner_accepts_exact_typed_redaction_without_scanning_digest():
8585
assert scanner.scan(typed) == []
8686

8787

88+
PHONE = "555-0123"
89+
_PROSE = "lorem ipsum 12 dolor " * 40
90+
91+
92+
@pytest.mark.parametrize(
93+
"text",
94+
[
95+
"555-0123",
96+
"5550123",
97+
"555 0123",
98+
"(555) 0123",
99+
"555.0123",
100+
"555/0123",
101+
"call 555\u00a00123 today",
102+
"call 555\u200b0123 today",
103+
"call 555 0123 today",
104+
"token 3fa5550123c9",
105+
],
106+
)
107+
def test_digit_only_canary_is_found_in_a_reformatted_number(text):
108+
scanner = SinkScanner.from_canaries({"crm-phone": PHONE})
109+
leaks = scanner.scan({"note": f"{_PROSE}{text} {_PROSE}"})
110+
assert [(leak.canary_id, leak.path) for leak in leaks] == [("crm-phone", "$.note")]
111+
112+
113+
@pytest.mark.parametrize(
114+
"text",
115+
[
116+
"token 3fa555b01c23",
117+
"v555 r01 n23",
118+
"<td>555</td><td>01</td><td>23</td>",
119+
"width:555px;top:01px;z-index:23",
120+
"555 ....... 0123",
121+
],
122+
)
123+
def test_digit_only_canary_ignores_digits_from_separate_tokens(text):
124+
scanner = SinkScanner.from_canaries({"crm-phone": PHONE})
125+
assert scanner.scan({"html": f"<p>{_PROSE}{text} {_PROSE}</p>"}) == []
126+
127+
88128
def test_mapping_keys_are_scanned_without_echoing_sensitive_key():
89129
scanner = SinkScanner.from_canaries({"short": "7"})
90130
leaks = scanner.scan({"7": "safe"})

0 commit comments

Comments
 (0)