H #2885 #2827: vector geometry is not phone or card PII

pii.phone.e164 accepts "." as a group separator, so a single float
(viewBox="0 0 581.66796875 ...") and a run of path coordinates
("37.6188 101.694") read as phone numbers. pii.cc's \b stops at a decimal
point, so the long fraction of an .excalidraw float is a Luhn candidate.
Excalidraw's seed/versionNonce/updated integers read as bare phone numbers.
A rendered /diagram triplet raised 18 (svg) and 97 (excalidraw) MEDIUM
findings on main; it now raises none.

- looksLikeDecimalCoordinates (ported from #2885, extended to a single
  decimal and refusing a leading "+", the E.164 marker)
- insideDecimalNumber: a cc span glued to "<digit>." or ".<digit>"
- isNumericMetadataValue: an unquoted JSON value under a seed, nonce or
  timestamp key; the same digits under "phone" still report

Controls: E.164 in prose, dotted and spaced phones, "+"-prefixed runs,
"phone"/"contact" JSON numbers, Luhn-valid cards in prose and at sentence end.

Co-authored-by: Lewis <lewispeel@hotmail.com>
This commit is contained in:
garrytanandLewis committed 2026-10-03 20:36:26 +00:00
1 parent ff5fbd09ee
commit e64930dbec
5 files changed
+169 -5

No files matched your search

+76 -5
View File
@@ -253,6 +253,73 @@ export function looksLikeParcelId(span: string, match: RegExpExecArray): boolean
return false;
}
/**
* Vector geometry reads as a phone number to pii.phone.e164 (#2885, #2827).
* The pattern accepts `.` as a group separator, so a single float
* (`viewBox="0 0 581.66796875 695.65625"`) parses as 581 · 6679 · 6875, and a
* run of path coordinates (`37.6188 101.694`, `100 64.5326 100`) as four
* spaced groups. One rendered /diagram SVG carried 18 of these; a Figma icon
* carries dozens per path.
*
* Phone conventions put a separator between EVERY group, so a token with
* digits, one dot and digits is a decimal number, never a dotted phone
* (415.555.0123 has two dots in one token and stays flagged). Exempt a span
* whose space-separated tokens include at least one decimal and none with two
* dots. A leading `+` is the E.164 marker, so a span starting with it is a
* phone context and never exempt.
*/
export function looksLikeDecimalCoordinates(span: string): boolean {
if (span.startsWith("+")) return false;
const tokens = span.split(" ");
if (tokens.some((t) => t.split(".").length > 2)) return false;
return tokens.some((t) => /^\d+\.\d+$/.test(t));
}
/** Span start/end in `match.input`, derived exactly as redact-engine.ts does. */
function spanBounds(match: RegExpExecArray): { start: number; end: number } {
const spanStartInMatch = match[1] !== undefined ? match[0].indexOf(match[1]) : 0;
const start = match.index + Math.max(0, spanStartInMatch);
return { start, end: start + (match[1] ?? match[0]).length };
}
/**
* True when a digit span is one side of a decimal number: `<digit>.` sits
* immediately before it or `.<digit>` immediately after. pii.cc's `\b` stops
* at the dot, so the 14-digit fraction of `492.34399999999994` in an
* .excalidraw scene is a Luhn candidate, and a random digit run passes Luhn
* about one time in ten (#2827). A card number in prose is never glued to a
* decimal point; a sentence-ending period has no digit after it and stays
* flagged.
*/
export function insideDecimalNumber(match: RegExpExecArray): boolean {
const input = match.input ?? "";
const { start, end } = spanBounds(match);
const digit = (i: number) => i >= 0 && i < input.length && input[i] >= "0" && input[i] <= "9";
return (input[start - 1] === "." && digit(start - 2)) || (input[end] === "." && digit(end + 1));
}
/**
* JSON keys whose unquoted integer value is a random seed, nonce or epoch
* timestamp. Excalidraw writes three per element (`"seed":1808177121`,
* `"versionNonce":1365644783`, `"updated":1791059590857`), and pii.phone.e164
* reads each as a bare 10-13 digit number: 92 per /diagram scene (#2827).
*/
const NUMERIC_METADATA_KEY =
/"[A-Za-z0-9_]*(?:seed|nonce|updated|created|timestamp)(?:at|_at|ms|_ms)?"[ \t]*:[ \t]*$/i;
/**
* True when a digit-only span is the unquoted JSON value of a seed, nonce or
* timestamp key. The evidence is the key, not the digits: the same number
* under `"phone":` still reports.
*/
export function isNumericMetadataValue(span: string, match: RegExpExecArray): boolean {
if (!/^\d+$/.test(span)) return false;
const input = match.input ?? "";
const { start } = spanBounds(match);
const lineStart = input.lastIndexOf("\n", start - 1) + 1;
return NUMERIC_METADATA_KEY.test(input.slice(Math.max(lineStart, start - 80), start));
}
// ── Placeholder suppression (per-matched-span, NOT per-line) ─────────────────
/**
@@ -738,12 +805,16 @@ export const PATTERNS: RedactPattern[] = [
autoRedactable: true,
redactToken: "<REDACTED-PHONE>",
// A digit-only UUID's hyphen groups read as national phone formatting, and
// so does a county tax-map parcel ID (see looksLikeParcelId).
// so do a county tax-map parcel ID (see looksLikeParcelId), vector
// coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON
// values (isNumericMetadataValue).
validate: (span, match) =>
!insideUuid(match) &&
span.replace(/\D/g, "").length >= 10 &&
!looksLikeCompactTimestamp(span) &&
!looksLikeParcelId(span, match),
!looksLikeParcelId(span, match) &&
!looksLikeDecimalCoordinates(span) &&
!isNumericMetadataValue(span, match),
},
{
id: "pii.ssn",
@@ -767,9 +838,9 @@ export const PATTERNS: RedactPattern[] = [
regex: /\b((?:\d[ \-]?){13,19})\b/,
autoRedactable: true,
redactToken: "<REDACTED-CC>",
// A 13-19 digit slice of a digit-only UUID passes Luhn often enough to
// matter; the enclosing-UUID check runs first so it never reaches Luhn.
validate: (span, match) => !insideUuid(match) && luhnValid(span),
// A 13-19 digit slice of a digit-only UUID or of a decimal number passes
// Luhn often enough to matter; both context checks run before Luhn.
validate: (span, match) => !insideUuid(match) && !insideDecimalNumber(match) && luhnValid(span),
},
{
id: "pii.ip_public",
+1
View File
@@ -0,0 +1 @@
{"type":"excalidraw","version":2,"source":"gstack-diagram-render","elements":[{"id":"FBmcfSimwEXPzs4bhpTjb","type":"rectangle","x":250.9140625,"y":8,"width":200.109375,"height":60,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,"seed":1808177121,"version":3,"versionNonce":1365644783,"isDeleted":false,"boundElements":[{"type":"text","id":"Lk54Qp2D8qFO6X1AMxkXn"},{"id":"RZLjDS-aGR6M6Iha_fygU","type":"arrow"}],"updated":1791059590857,"link":null,"locked":false},{"id":"MgBMziDzzZsUR_ubuPqT3","type":"diamond","x":252.53125,"y":118,"width":197.875,"height":197.875,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":null,"seed":192418479,"version":7,"versionNonce":1049487471,"isDeleted":false,"boundElements":[{"type":"text","id":"DfnkKci8j5sTfUPTqNM_V"},{"id":"RZLjDS-aGR6M6Iha_fygU","type":"arrow"},{"id":"QXtppr3wxF8aWGavgvHCg","type":"arrow"},{"id":"QkuvMiAt5owwKfVGV76Xe","type":"arrow"},{"id":"LwAd4VAY-m3kpx0mf8IZQ","type":"arrow"},{"id":"Xolrx0c730eIssJQTIHut","type":"arrow"}],"updated":1791059590859,"link":null,"locked":false},{"id":"GLaB0IIg5dTU6wbqLh57a","type":"rectangle","x":34.6796875,"y":395.875,"width":157.84375,"height":60,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":null,"seed":365622209,"version":4,"versionNonce":1018495631,"isDeleted":false,"boundElements":[{"type":"text","id":"gqq0ycD8jE4DlXZeLikWp"},{"id":"QXtppr3wxF8aWGavgvHCg","type":"arrow"},{"id":"XUcATSTOjMVv5YvlIR8XP","type":"arrow"}],"updated":1791059590859,"link":null,"locked":false},{"id":"oWWs0W96aNRHAgR5oWl8u","type":"rectangle","x":287.359375,"y":395.875,"width":107.046875,"height":60,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a3","roundness":null,"seed":929031375,"version":4,"versionNonce":1091537231,"isDeleted":false,"boundElements":[{"type":"text","id":"Xgc-Z9fR_xJzSZi552hrg"},{"id":"QkuvMiAt5owwKfVGV76Xe","type":"arrow"},{"id":"m0batfkyDXiNEcdJd9Z-4","type":"arrow"}],"updated":1791059590859,"link":null,"locked":false},{"id":"302jWUU1dMxH1SbQS0qE1","type":"rectangle","x":459.2265625,"y":759.234375,"width":193.421875,"height":60,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a4","roundness":null,"seed":1519965089,"version":4,"versionNonce":1686432207,"isDeleted":false,"boundElements":[{"type":"text","id":"uP6KE-R9_gMd_Z2R1Prra"},{"id":"LwAd4VAY-m3kpx0mf8IZQ","type":"arrow"},{"id":"pRAl45BZ1BxcuUmJ9HD9b","type":"arrow"}],"updated":1791059590859,"link":null,"locked":false},{"id":"dF2UjK6ZPpz78dWKN5evK","type":"diamond","x":269.703125,"y":535.875,"width":143.359375,"height":143.359375,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a5","roundness":null,"seed":1455977199,"version":5,"versionNonce":1134690831,"isDeleted":false,"boundElements":[{"type":"text","id":"RXs9Ti_5xCHis2KHx4duA"},{"id":"m0batfkyDXiNEcdJd9Z-4","type":"arrow"},{"id":"pRAl45BZ1BxcuUmJ9HD9b","type":"arrow"},{"id":"blZCFHehmGZ-PV5jiIJ5p","type":"arrow"}],"updated":1791059590859,"link":null,"locked":false},{"id":"gnjCVoiODEbhYmxyLsgeZ","type":"rectangle","x":174.15625,"y":759.234375,"width":126.34375,"height":60,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a6","roundness":null,"seed":778024833,"version":5,"versionNonce":785037551,"isDeleted":false,"boundElements":[{"type":"text","id":"VAHHwE9eJhL_zD4TcnCdz"},{"id":"blZCFHehmGZ-PV5jiIJ5p","type":"arrow"},{"id":"Xolrx0c730eIssJQTIHut","type":"arrow"},{"id":"Xqf-Ryhsfa4wicqw8j_Y5","type":"arrow"}],"updated":1791059590860,"link":null,"locked":false},{"id":"qYkvsRVNy2LSpOyxA5FwA","type":"rectangle","x":8,"y":577.5546875,"width":211.203125,"height":60,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a7","roundness":null,"seed":842677519,"version":4,"versionNonce":1135798991,"isDeleted":false,"boundElements":[{"type":"text","id":"PUslv2xht6rBVOKD0qYiZ"},{"id":"XUcATSTOjMVv5YvlIR8XP","type":"arrow"},{"id":"Xqf-Ryhsfa4wicqw8j_Y5","type":"arrow"}],"updatedLine truncated
+11
View File
@@ -0,0 +1,11 @@
flowchart TD
A[Push requested] --> B{Redaction scan}
B -->|HIGH| C[Block push]
B -->|MEDIUM| D[Warn]
B -->|clean| E[Push proceeds]
D --> F{Reviewer}
F -->|accept| E
F -->|fix| G[Edit diff]
G --> B
C --> H[Rotate credential]
H --> G
+1
View File
@@ -0,0 +1 @@
<svg id="diagram-1" width="100%" xmlns="http://www.w3.org/2000/svg" class="flowchart" style="max-width: 581.66796875px;" viewBox="0 0 581.66796875 695.65625" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#diagram-1{font-family:Helvetica,"Liberation Sans",Arial,"Hiragino Kaku Gothic ProN","Noto Sans CJK JP","Microsoft YaHei","Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;font-size:16px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#diagram-1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#diagram-1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#diagram-1 .error-icon{fill:#552222;}#diagram-1 .error-text{fill:#552222;stroke:#552222;}#diagram-1 .edge-thickness-normal{stroke-width:1px;}#diagram-1 .edge-thickness-thick{stroke-width:3.5px;}#diagram-1 .edge-pattern-solid{stroke-dasharray:0;}#diagram-1 .edge-thickness-invisible{stroke-width:0;fill:none;}#diagram-1 .edge-pattern-dashed{stroke-dasharray:3;}#diagram-1 .edge-pattern-dotted{stroke-dasharray:2;}#diagram-1 .marker{fill:#666;stroke:#666;}#diagram-1 .marker.cross{stroke:#666;}#diagram-1 svg{font-family:Helvetica,"Liberation Sans",Arial,"Hiragino Kaku Gothic ProN","Noto Sans CJK JP","Microsoft YaHei","Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;font-size:16px;}#diagram-1 p{margin:0;}#diagram-1 .label{font-family:Helvetica,"Liberation Sans",Arial,"Hiragino Kaku Gothic ProN","Noto Sans CJK JP","Microsoft YaHei","Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;color:#000000;}#diagram-1 .cluster-label text{fill:#333;}#diagram-1 .cluster-label span{color:#333;}#diagram-1 .cluster-label span p{background-color:transparent;}#diagram-1 .label text,#diagram-1 span{fill:#000000;color:#000000;}#diagram-1 .node rect,#diagram-1 .node circle,#diagram-1 .node ellipse,#diagram-1 .node polygon,#diagram-1 .node path{fill:#eee;stroke:#999;stroke-width:1px;}#diagram-1 .rough-node .label text,#diagram-1 .node .label text,#diagram-1 .image-shape .label,#diagram-1 .icon-shape .label{text-anchor:middle;}#diagram-1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#diagram-1 .rough-node .label,#diagram-1 .node .label,#diagram-1 .image-shape .label,#diagram-1 .icon-shape .label{text-align:center;}#diagram-1 .node.clickable{cursor:pointer;}#diagram-1 .root .anchor path{fill:#666!important;stroke-width:0;stroke:#666;}#diagram-1 .arrowheadPath{fill:#333333;}#diagram-1 .edgePath .path{stroke:#666;stroke-width:1px;}#diagram-1 .flowchart-link{stroke:#666;fill:none;}#diagram-1 .edgeLabel{background-color:white;text-align:center;}#diagram-1 .edgeLabel p{background-color:white;}#diagram-1 .edgeLabel rect{opacity:0.5;background-color:white;fill:white;}#diagram-1 .labelBkg{background-color:rgba(255, 255, 255, 0.5);}#diagram-1 .cluster rect{fill:hsl(0, 0%, 98.9215686275%);stroke:#707070;stroke-width:1px;}#diagram-1 .cluster text{fill:#333;}#diagram-1 .cluster span{color:#333;}#diagram-1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Helvetica,"Liberation Sans",Arial,"Hiragino Kaku Gothic ProN","Noto Sans CJK JP","Microsoft YaHei","Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;font-size:12px;background:hsl(-160, 0%, 93.3333333333%);border:1px solid #707070;border-radius:2px;pointer-events:none;z-index:100;}#diagram-1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#diagram-1 rect.text{fill:none;stroke-width:0;}#diagram-1 .icon-shape,#diagram-1 .image-shape{background-color:white;text-align:center;}#diagram-1 .icon-shape p,#diagram-1 .image-shape p{background-color:white;padding:2px;}#diagram-1 .icon-shape .label rect,#diagram-1 .image-shape .label rect{opacity:0.5;background-color:white;fill:white;}#diagram-1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#diagram-1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#diagram-1 .node .neo-node{stroke:#999;}#diagram-1 [data-look="neo"].node rect,#diagram-1 [data-look="neo"].cluster rect,#diagram-1 [data-look="neo"].node polygon{stroke:url(#diagram-1-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#diagram-1 [data-look="neo"].swimlane.cluster rect{filter:none;}#diagram-1 [data-look="neo"].node path{stroke:url(#diagram-1-gradient);stroke-width:1px;}#diagram-1 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#diagram-1 [data-look="neo"].node .neo-line path{stroke:#999;filter:none;}#diagram-1 [data-look="neo"].node circle{stroke:url(#diagram-1-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#diagram-1 [data-look="neo"].node circle .state-start{fill:#000000;}#diagram-1 [data-look="neo"].icon-shape .icon{Line truncated

After

Width:  |  Height:  |  Size: 25 KiB

+80
View File
@@ -0,0 +1,80 @@
/**
* Redaction false positives that blocked pushes and decision logs, each paired
* with true-positive controls that must still report at the same tier.
*
* Every exemption below is decided by context, so each table carries the
* benign vector AND the same (or same-shaped) value in a sensitive context.
* A guard that passes the benign half by gutting the pattern fails the
* control half.
*/
import { describe, test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { scan, type Finding } from "../lib/redact-engine";
const FIXTURES = path.join(import.meta.dir, "fixtures", "redact-diagram");
function findings(text: string): Finding[] {
return scan(text, { repoVisibility: "private" }).findings;
}
function tierOf(text: string, id: string): string | undefined {
return findings(text).find((f) => f.id === id)?.severity;
}
function table(id: string, tier: string, benign: string[], controls: string[]): void {
for (const input of benign) {
test(`clean: ${input.slice(0, 70)}`, () => {
expect(findings(input).map((f) => f.id)).not.toContain(id);
});
}
for (const input of controls) {
test(`still ${tier}: ${input.slice(0, 70)}`, () => {
expect(tierOf(input, id)).toBe(tier);
});
}
}
describe("pii.phone.e164 / pii.cc: vector geometry is not PII (#2885, #2827)", () => {
table(
"pii.phone.e164",
"MEDIUM",
[
"M 37.6188 101.694 L 100 64.5326 100",
"M50 1C1 2 37.6188 101.694 51.6863 99.4363Z",
'viewBox="0 0 581.66796875 695.65625"',
'transform="translate(296.1484375, 298.796875)"',
"fill:hsl(0, 0%, 98.9215686275%)",
"v 12.3456 100.125 7.5",
'{"type":"rectangle","x":250.9140625,"y":8}',
'{"seed":1808177121,"version":3,"versionNonce":1365644783}',
'"updated": 1791059590857,',
],
[
"call me at +1 415 555 0123 tomorrow",
"tel: +14155550123",
"p 415.555.0123",
"tel +1 415.555.0123",
"ring +44 20 7946 0958",
"phone: (415) 555-0123",
"+37.6188 101.694",
'{"phone":1808177121}',
'"contact": 14155550123,',
"seed 1808177121",
],
);
table(
"pii.cc",
"MEDIUM",
['"height":492.34399999999994,', '"gap":3.266375000000039}', "0.4111111111111111"],
["card 4111 1111 1111 1111", "card 4111111111111111.", "4111111111111111"],
);
test("a rendered /diagram triplet produces no PII findings", () => {
for (const name of ["flow.svg", "flow.excalidraw"]) {
const text = fs.readFileSync(path.join(FIXTURES, name), "utf8");
const pii = findings(text).filter((f) => f.id === "pii.phone.e164" || f.id === "pii.cc");
expect({ name, pii: pii.map((f) => f.id) }).toEqual({ name, pii: [] });
}
});
});