From e64930dbec09421e2fea86687d6f687bf0cb47bc Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:36:26 +0000 Subject: [PATCH 1/7] 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 "." or "." - 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 --- lib/redact-patterns.ts | 81 ++++++++++++++++++-- test/fixtures/redact-diagram/flow.excalidraw | 1 + test/fixtures/redact-diagram/flow.mmd | 11 +++ test/fixtures/redact-diagram/flow.svg | 1 + test/redact-context-exemptions.test.ts | 80 +++++++++++++++++++ 5 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/redact-diagram/flow.excalidraw create mode 100644 test/fixtures/redact-diagram/flow.mmd create mode 100644 test/fixtures/redact-diagram/flow.svg create mode 100644 test/redact-context-exemptions.test.ts diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 92b9fe78e..127b4de0e 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -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: `.` sits + * immediately before it or `.` 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: "", // 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: "", - // 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", diff --git a/test/fixtures/redact-diagram/flow.excalidraw b/test/fixtures/redact-diagram/flow.excalidraw new file mode 100644 index 000000000..61556526f --- /dev/null +++ b/test/fixtures/redact-diagram/flow.excalidraw @@ -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"}],"updated":1791059590859,"link":null,"locked":false},{"id":"RZLjDS-aGR6M6Iha_fygU","type":"arrow","x":350.969,"y":68,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a8","roundness":{"type":2},"seed":1398742881,"version":4,"versionNonce":1881261185,"isDeleted":false,"boundElements":null,"updated":1791059590857,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"FBmcfSimwEXPzs4bhpTjb","focus":-0.0000024986335597435903,"gap":1},"endBinding":{"elementId":"MgBMziDzzZsUR_ubuPqT3","focus":-0.0050511686670878676,"gap":4.039322351892704},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"QXtppr3wxF8aWGavgvHCg","type":"arrow","x":288.561,"y":253.467,"width":174.95899999999997,"height":138.408,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a9","roundness":{"type":2},"seed":1831715631,"version":4,"versionNonce":282503201,"isDeleted":false,"boundElements":[{"type":"text","id":"5Js4htRk7mzP1AQKCICD1"}],"updated":1791059590858,"link":null,"locked":false,"points":[[0,0.5],[-174.95899999999997,102.40799999999999],[-174.95899999999997,137.908]],"lastCommittedPoint":null,"startBinding":{"elementId":"MgBMziDzzZsUR_ubuPqT3","focus":0.002951591773886098,"gap":1},"endBinding":{"elementId":"GLaB0IIg5dTU6wbqLh57a","focus":0.0000055434567413702695,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"QkuvMiAt5owwKfVGV76Xe","type":"arrow","x":344.273,"y":309.179,"width":3.390000000000043,"height":82.69600000000003,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aA","roundness":{"type":2},"seed":400984897,"version":4,"versionNonce":1741598657,"isDeleted":false,"boundElements":[{"type":"text","id":"uNyUkUh0TW9WyIOln5owP"}],"updated":1791059590858,"link":null,"locked":false,"points":[[0,0.5],[-3.390000000000043,46.696000000000026],[-3.390000000000043,82.19600000000003]],"lastCommittedPoint":null,"startBinding":{"elementId":"MgBMziDzzZsUR_ubuPqT3","focus":0.005046348522088459,"gap":1},"endBinding":{"elementId":"oWWs0W96aNRHAgR5oWl8u","focus":0.000003503138227555864,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"LwAd4VAY-m3kpx0mf8IZQ","type":"arrow","x":403.22,"y":263.624,"width":129.20799999999997,"height":492.34399999999994,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aB","roundness":{"type":2},"seed":671843663,"version":4,"versionNonce":2091670369,"isDeleted":false,"boundElements":[{"type":"text","id":"HBXZNbW_us_3SilFvl1Ku"}],"updated":1791059590859,"link":null,"locked":false,"points":[[0.5,0.5],[103.24899999999997,92.25099999999998],[103.24899999999997,162.25099999999998],[103.24899999999997,232.25099999999998],[103.24899999999997,343.9309999999999],[103.24899999999997,455.61],[128.70799999999997,491.84399999999994]],"lastCommittedPoint":null,"startBinding":{"elementId":"MgBMziDzzZsUR_ubuPqT3","focus":0.004525597549146976,"gap":1},"endBinding":{"elementId":"302jWUU1dMxH1SbQS0qE1","focus":-0.000008344829791920963,"gap":3.266375000000039},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"m0batfkyDXiNEcdJd9Z-4","type":"arrow","x":340.883,"y":455.875,"width":0,"height":76,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aC","roundness":{"type":2},"seed":1414888225,"version":4,"versionNonce":1082373921,"isDeleted":false,"boundElements":null,"updated":1791059590859,"link":null,"locked":false,"points":[[0,0.5],[0,40],[0,75.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"oWWs0W96aNRHAgR5oWl8u","focus":-0.000003503138227555864,"gap":1},"endBinding":{"elementId":"dF2UjK6ZPpz78dWKN5evK","focus":-0.0069728610354226036,"gap":4.083210136109215},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"pRAl45BZ1BxcuUmJ9HD9b","type":"arrow","x":388.062,"y":632.055,"width":167.87599999999998,"height":123.17900000000009,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aD","roundness":{"type":2},"seed":1647051631,"version":4,"versionNonce":1061297857,"isDeleted":false,"boundElements":[{"type":"text","id":"gZonuQK2JVzn--uH9Balh"}],"updated":1791059590859,"link":null,"locked":false,"points":[[0,0.5],[167.87599999999998,87.17900000000009],[167.87599999999998,122.67900000000009]],"lastCommittedPoint":null,"startBinding":{"elementId":"dF2UjK6ZPpz78dWKN5evK","focus":0.003620750502041193,"gap":1},"endBinding":{"elementId":"302jWUU1dMxH1SbQS0qE1","focus":0.000005170046044732246,"gap":4.000374999999963},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"blZCFHehmGZ-PV5jiIJ5p","type":"arrow","x":297.433,"y":635.784,"width":128.445,"height":120.58799999999997,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aE","roundness":{"type":2},"seed":689663745,"version":4,"versionNonce":1090660961,"isDeleted":false,"boundElements":[{"type":"text","id":"fhiU4S4Oz_eDFhhMs04hg"}],"updated":1791059590859,"link":null,"locked":false,"points":[[0.5,0.5],[-128.445,83.45000000000005],[-92.68799999999999,120.08799999999997]],"lastCommittedPoint":null,"startBinding":{"elementId":"dF2UjK6ZPpz78dWKN5evK","focus":0.004528975068900496,"gap":1},"endBinding":{"elementId":"gnjCVoiODEbhYmxyLsgeZ","focus":-0.0000034847457537809272,"gap":2.8623750000000427},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"Xolrx0c730eIssJQTIHut","type":"arrow","x":300.5,"y":769.601,"width":162.063,"height":494.677,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aF","roundness":{"type":2},"seed":467123599,"version":4,"versionNonce":921814561,"isDeleted":false,"boundElements":null,"updated":1791059590859,"link":null,"locked":false,"points":[[-0.5,-0.5],[162.063,-50.36699999999996],[162.063,-162.04600000000005],[162.063,-273.726],[162.063,-343.726],[162.063,-413.726],[97.543,-494.177]],"lastCommittedPoint":null,"startBinding":{"elementId":"gnjCVoiODEbhYmxyLsgeZ","focus":-0.000008183393622074867,"gap":1},"endBinding":{"elementId":"MgBMziDzzZsUR_ubuPqT3","focus":0.005059953186157659,"gap":3.5784906428898093},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"XUcATSTOjMVv5YvlIR8XP","type":"arrow","x":113.602,"y":455.875,"width":0,"height":117.67999999999995,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aG","roundness":{"type":2},"seed":2092426977,"version":4,"versionNonce":1459450337,"isDeleted":false,"boundElements":null,"updated":1791059590859,"link":null,"locked":false,"points":[[0,0.5],[0,40],[0,117.17999999999995]],"lastCommittedPoint":null,"startBinding":{"elementId":"GLaB0IIg5dTU6wbqLh57a","focus":-0.0000055434567413702695,"gap":1},"endBinding":{"elementId":"qYkvsRVNy2LSpOyxA5FwA","focus":0.00000414293112354401,"gap":3.99968750000005},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"Xqf-Ryhsfa4wicqw8j_Y5","type":"arrow","x":136.539,"y":637.555,"width":82.436,"height":118.17100000000005,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aH","roundness":{"type":2},"seed":944190383,"version":4,"versionNonce":895561121,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"points":[[0.5,0.5],[62.44900000000001,81.67900000000009],[81.936,117.67100000000005]],"lastCommittedPoint":null,"startBinding":{"elementId":"qYkvsRVNy2LSpOyxA5FwA","focus":-0.0000016613407085052204,"gap":1},"endBinding":{"elementId":"gnjCVoiODEbhYmxyLsgeZ","focus":-0.000003561724373496304,"gap":3.508375000000001},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"Lk54Qp2D8qFO6X1AMxkXn","type":"text","x":290.7001953125,"y":25.5,"width":120.537109375,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aI","roundness":null,"seed":1630335393,"version":3,"versionNonce":51171713,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Push requested","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"FBmcfSimwEXPzs4bhpTjb","originalText":"Push requested","autoResize":true,"lineHeight":1.25},{"id":"DfnkKci8j5sTfUPTqNM_V","type":"text","x":310.9580078125,"y":191.96875,"width":81.083984375,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aJ","roundness":null,"seed":1220547969,"version":3,"versionNonce":503563023,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Redaction\nscan","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"MgBMziDzzZsUR_ubuPqT3","originalText":"Redaction scan","autoResize":true,"lineHeight":1.25},{"id":"gqq0ycD8jE4DlXZeLikWp","type":"text","x":68.3232421875,"y":413.375,"width":90.556640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aK","roundness":null,"seed":1040432481,"version":3,"versionNonce":123271521,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Block push","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"GLaB0IIg5dTU6wbqLh57a","originalText":"Block push","autoResize":true,"lineHeight":1.25},{"id":"Xgc-Z9fR_xJzSZi552hrg","type":"text","x":319.4765625,"y":413.375,"width":42.8125,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aL","roundness":null,"seed":1078421825,"version":3,"versionNonce":1909885231,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Warn","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"oWWs0W96aNRHAgR5oWl8u","originalText":"Warn","autoResize":true,"lineHeight":1.25},{"id":"uP6KE-R9_gMd_Z2R1Prra","type":"text","x":498.447265625,"y":776.734375,"width":114.98046875,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aM","roundness":null,"seed":954898721,"version":3,"versionNonce":596976961,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Push proceeds","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"302jWUU1dMxH1SbQS0qE1","originalText":"Push proceeds","autoResize":true,"lineHeight":1.25},{"id":"RXs9Ti_5xCHis2KHx4duA","type":"text","x":310.99609375,"y":582.71484375,"width":61.09375,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aN","roundness":null,"seed":962383105,"version":3,"versionNonce":1739295567,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Review\ner","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"dF2UjK6ZPpz78dWKN5evK","originalText":"Reviewer","autoResize":true,"lineHeight":1.25},{"id":"VAHHwE9eJhL_zD4TcnCdz","type":"text","x":203.9052734375,"y":776.734375,"width":66.845703125,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aO","roundness":null,"seed":2031977697,"version":3,"versionNonce":1663096097,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Edit diff","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"gnjCVoiODEbhYmxyLsgeZ","originalText":"Edit diff","autoResize":true,"lineHeight":1.25},{"id":"PUslv2xht6rBVOKD0qYiZ","type":"text","x":45.5791015625,"y":595.0546875,"width":136.044921875,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aP","roundness":null,"seed":331104449,"version":3,"versionNonce":2077968751,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"Rotate credential","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"qYkvsRVNy2LSpOyxA5FwA","originalText":"Rotate credential","autoResize":true,"lineHeight":1.25},{"id":"5Js4htRk7mzP1AQKCICD1","type":"text","x":88.6068828125,"y":343.375,"width":49.990234375,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aQ","roundness":null,"seed":190286945,"version":3,"versionNonce":963348737,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"HIGH","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"QXtppr3wxF8aWGavgvHCg","originalText":"HIGH","autoResize":true,"lineHeight":1.25},{"id":"uNyUkUh0TW9WyIOln5owP","type":"text","x":299.2179609375,"y":343.375,"width":83.330078125,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aR","roundness":null,"seed":1995373569,"version":3,"versionNonce":195969935,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"MEDIUM","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"QkuvMiAt5owwKfVGV76Xe","originalText":"MEDIUM","autoResize":true,"lineHeight":1.25},{"id":"HBXZNbW_us_3SilFvl1Ku","type":"text","x":485.37525,"y":483.375,"width":42.1875,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aS","roundness":null,"seed":751112097,"version":3,"versionNonce":2146746593,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"clean","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"LwAd4VAY-m3kpx0mf8IZQ","originalText":"clean","autoResize":true,"lineHeight":1.25},{"id":"gZonuQK2JVzn--uH9Balh","type":"text","x":530.4057734375,"y":706.734,"width":51.064453125,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aT","roundness":null,"seed":1814178561,"version":3,"versionNonce":928840111,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"accept","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"pRAl45BZ1BxcuUmJ9HD9b","originalText":"accept","autoResize":true,"lineHeight":1.25},{"id":"fhiU4S4Oz_eDFhhMs04hg","type":"text","x":157.8796015625,"y":706.734,"width":22.216796875,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aU","roundness":null,"seed":1106419361,"version":3,"versionNonce":877395137,"isDeleted":false,"boundElements":null,"updated":1791059590860,"link":null,"locked":false,"text":"fix","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"blZCFHehmGZ-PV5jiIJ5p","originalText":"fix","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}} \ No newline at end of file diff --git a/test/fixtures/redact-diagram/flow.mmd b/test/fixtures/redact-diagram/flow.mmd new file mode 100644 index 000000000..d530d0967 --- /dev/null +++ b/test/fixtures/redact-diagram/flow.mmd @@ -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 diff --git a/test/fixtures/redact-diagram/flow.svg b/test/fixtures/redact-diagram/flow.svg new file mode 100644 index 000000000..de8584015 --- /dev/null +++ b/test/fixtures/redact-diagram/flow.svg @@ -0,0 +1 @@ +HIGHMEDIUMcleanacceptfixPush requestedRedaction scanBlock pushWarnPush proceedsReviewerEdit diffRotate credential \ No newline at end of file diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts new file mode 100644 index 000000000..d0967b735 --- /dev/null +++ b/test/redact-context-exemptions.test.ts @@ -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: [] }); + } + }); +}); From 606bfcb771a842c1fa8bcc1eb6c7d5d3d10ca226 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:37:11 +0000 Subject: [PATCH 2/7] H #2899: env.kv skips a function call assigned to a credential name `session = _FlakySession(...)`, `token = make_token(user, ...)` and `password = getpass.getpass()` raised env.kv MEDIUM, which blocks the fail-closed decision log. The exemption needs the line's evidence, not the span's shape: the value is unquoted, is an identifier or dotted path followed by "(", and the call closes on the line or opens a multi-line argument list. Quoted values, unclosed "(" inside an unquoted secret (API_SECRET=Abc123xyz(9Qm) and calls carrying a high-entropy literal argument still report. The literal check is shared with the #2912 env-read exemption. Co-authored-by: Yugz --- lib/redact-patterns.ts | 36 +++++++++++++++++++++++--- test/redact-context-exemptions.test.ts | 27 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 127b4de0e..858ade4f2 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -482,10 +482,35 @@ const ENV_READ_SPAN = /^(?:os\.environ\[|os\.environ\.get\(|os\.getenv\(|getenv\(|ENV\[|process\.env\.[A-Za-z_$][\w$]*[;,)]?)$/; function isBareEnvRead(span: string, match: RegExpExecArray): boolean { if (!ENV_READ_SPAN.test(span)) return false; - const rest = match.input.slice(match.index + match[0].length).split("\n", 1)[0]; - for (const [, literal] of rest.matchAll(/["']([^\s'"]{8,})["']/g)) - if (!isPlaceholderSpan(literal) && shannonEntropy(literal) >= 3.0) return false; - return true; + return !carriesSecretLiteral(match.input.slice(match.index + match[0].length).split("\n", 1)[0]); +} + +/** True when `text` holds a quoted, non-placeholder, high-entropy literal. */ +function carriesSecretLiteral(text: string): boolean { + for (const [, literal] of text.matchAll(/["']([^\s'"]{8,})["']/g)) + if (!isPlaceholderSpan(literal) && shannonEntropy(literal) >= 3.0) return true; + return false; +} + +/** + * #2899: the value capture is any non-space run, so it swallows code: + * `session = _FlakySession(responses=[...])`, `token = make_token(user,`, + * `password = getpass.getpass()`. A bare credential name plus mixed-case code + * clears the entropy gate. The span alone cannot tell `Abc123(xyz` (a real + * password) from a call, so the exemption needs the line's evidence: the + * value is unquoted, starts with an identifier or dotted path followed by + * `(`, and that call closes on the line or opens a multi-line argument list. + * A quoted value is a literal, and a call whose arguments carry a + * high-entropy literal (`decrypt("")`) still reports. + */ +const CALL_SHAPED_VALUE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(/; +function isCallExpression(span: string, match: RegExpExecArray): boolean { + if (!CALL_SHAPED_VALUE.test(span)) return false; + const { start } = spanBounds(match); + if (match.input[start - 1] === '"' || match.input[start - 1] === "'") return false; + const call = match.input.slice(start + span.indexOf("(")).split("\n", 1)[0]; + if (!call.includes(")") && call.trim() !== "(") return false; + return !carriesSecretLiteral(call); } export const PATTERNS: RedactPattern[] = [ @@ -760,11 +785,14 @@ export const PATTERNS: RedactPattern[] = [ // holding one, unless the rest of its line carries a high-entropy quoted // literal (`os.getenv("X", "")`). A literal appended to the read // itself (`process.env.X||"…"`) is not an exact read and still fires. + // #2899: a function call assigned to the name is code, not a value (see + // isCallExpression). validate: (span, match) => isCredentialShapedEnvName(match[0]) && !isPlaceholderSpan(span) && !/^\$\{?[A-Za-z_]/.test(span) && !isBareEnvRead(span, match) && + !isCallExpression(span, match) && shannonEntropy(span) >= 3.0, }, { diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index d0967b735..b4b3b588e 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -78,3 +78,30 @@ describe("pii.phone.e164 / pii.cc: vector geometry is not PII (#2885, #2827)", ( } }); }); + +describe("env.kv: a function call assigned to a credential name is code (#2899)", () => { + table( + "env.kv", + "MEDIUM", + [ + ' session = _FlakySession(requests.ReadTimeout("Read timed out"))', + " session = _FlakySession(responses=[1])", + " session = requests.Session()", + " session = find_session(client, args.session_id, now=now, state=state)", + ' token = make_token(user, scopes=["read"])', + " password = getpass.getpass()", + " session = build_http_session(", + ], + [ + 'session = "Xk9pQ2mLr7Tz4vBn"', + "SESSION_SECRET=9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c", + 'password = "Tr0ub4dor&3!xyz"', + "PASSWORD=Tr0ub4dor&3!xyz#", + "password = correct.horse.battery.staple", + 'api_key: "Zx9Qw8Er7Ty6Ui5Op4As3Df2"', + "API_SECRET=Abc123xyz(9Qm", + 'password = "getPassword(x)Q9z"', + 'token = decrypt("Zx9Qw8Er7Ty6Ui5Op4As3Df2")', + ], + ); +}); From 907717d7cfc88ed4c0bb9bf05ccc736702eada0b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:37:41 +0000 Subject: [PATCH 3/7] H #2962: per-machine config filenames are not internal hostnames `CLAUDE.local.md` and `settings.local.json` reported internal.hostname MEDIUM (the pattern's \b stops at the dot before the extension), which fails closed in the decision log. A span immediately followed by a known config extension (.md .json .jsonc .yaml .yml .toml .ini .conf .txt) and a word boundary is a filename. Controls: `ssh build.local`, `printer.local.` at a sentence end, `printer.local/md`, `printer.local.mdx`, a host with a port, `api.corp`, `build-7.internal`. The `.env.local` vectors (#2740) pass on main already via isDotenvFilename and are pinned here as regressions. Co-authored-by: Florian --- lib/redact-patterns.ts | 27 ++++++++++++++++++++++++-- test/redact-context-exemptions.test.ts | 25 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 858ade4f2..7007d49ef 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -392,6 +392,28 @@ export function isDotenvFilename(match: RegExpExecArray): boolean { return spanStart > 0 && input[spanStart - 1] === "."; } +/** Extensions that mark a `.local.` span as a file. Closed on + * purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front + * of a public one, so after `.local` it can only be an extension. */ +const LOCAL_CONFIG_EXTENSIONS = /^\.(?:md|json|jsonc|ya?ml|toml|ini|conf|txt)\b/i; + +/** + * True when an `internal.hostname` span is the stem of a per-machine config + * FILENAME (`CLAUDE.local.md`, `settings.local.json`, `values.staging.yaml`) + * rather than a host (#2962). The pattern's `\b` stops at the dot before the + * extension, so `CLAUDE.local.md` reported `CLAUDE.local`. Claude Code's own + * per-user files are named this way, so any decision or doc that mentions + * them failed closed in the non-interactive stores. + * + * Exempts ONLY a span immediately followed by `.` and a word + * boundary. `printer.local.` at the end of a sentence still reports, and so + * do `printer.local`, `printer.local/md` and `printer.local.mdx`. + */ +export function isLocalConfigFilename(match: RegExpExecArray): boolean { + const { end } = spanBounds(match); + return LOCAL_CONFIG_EXTENSIONS.test((match.input ?? "").slice(end, end + 8)); +} + /** * True when the matched span sits ENTIRELY inside a UUID. * @@ -894,8 +916,9 @@ export const PATTERNS: RedactPattern[] = [ category: "internal", description: "Internal hostname (*.internal/.corp/.local/.prod/.staging)", regex: /\b([a-z0-9][a-z0-9\-]*\.(?:internal|corp|local|lan|prod|staging))\b/i, - // `.env.local` and friends are filenames, not hosts. See isDotenvFilename. - validate: (_span, match) => !isDotenvFilename(match), + // `.env.local`, `CLAUDE.local.md` and friends are filenames, not hosts. + // See isDotenvFilename and isLocalConfigFilename. + validate: (_span, match) => !isDotenvFilename(match) && !isLocalConfigFilename(match), }, { id: "internal.url_private", diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index b4b3b588e..895d259f3 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -105,3 +105,28 @@ describe("env.kv: a function call assigned to a credential name is code (#2899)" ], ); }); + +describe("internal.hostname: per-machine config filenames are not hosts (#2962, #2740)", () => { + table( + "internal.hostname", + "MEDIUM", + [ + "Edit CLAUDE.local.md and settings.local.json", + "no `CLAUDE.local.md`, no @import", + "edit .claude/settings.local.json", + "helm -f values.staging.yaml", + "config.prod.toml is read first", + "npm run dev -- --env-file=.env.local", + "copy .env.local to .env.staging", + ], + [ + "ssh build.local", + "ping printer.local.", + "http://printer.local/md", + "printer.local.mdx is odd", + "curl http://settings.local:8080/health", + "the api.corp gateway", + "connect to build-7.internal", + ], + ); +}); From 5999ee3bad5c01b9623e8e51ec595acc2cbca493 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:38:34 +0000 Subject: [PATCH 4/7] H #2784: four-part versions need a version declaration, not a shape `"version": "1.128.1.0"`, ``, `AssemblyVersion("1.0.0.0")` and Keep a Changelog headings raised pii.ip_public MEDIUM on every release, because a four-part version is byte-for-byte a dotted quad. Exempt only when the same line declares it a version immediately before the value (a version key or word, an XML tag, an attribute call, or a `## [x]` heading). The same digits after host:/server =/"upstream", bare on a line, or after prose that merely mentions a version still report. Unlike #2784, this does not exempt a value because the same string is declared a version elsewhere in the text: that let a real address sharing a diff with a matching version slip. The pre-push hook's VERSION-file rule (#2856) is unchanged. Co-authored-by: cdecook23 --- lib/redact-patterns.ts | 24 ++++++++++++++++++++- test/redact-context-exemptions.test.ts | 30 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 7007d49ef..b496bb5e3 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -320,6 +320,28 @@ export function isNumericMetadataValue(span: string, match: RegExpExecArray): bo return NUMERIC_METADATA_KEY.test(input.slice(Math.max(lineStart, start - 80), start)); } +/** + * A four-part version (MAJOR.MINOR.PATCH.BUILD: .NET assembly versions, + * gstack's own VERSION) is byte-for-byte a dotted quad, and `1.128.1.0` is a + * public address, so `"version": "1.128.1.0"` raised pii.ip_public on every + * release (#2784). The value cannot decide it, so only the declaration + * immediately before it on the same line can: a `version` key or word + * (`"version": "`, `app_version = `, ``, `AssemblyVersion("`, + * `version `), or a Keep a Changelog heading `## [1.2.3.4]`. The same digits + * after `host:`, `server = ` or in prose that merely mentions a version still + * report, and the pre-push hook's VERSION-file rule (#2856) is unchanged. + */ +const VERSION_DECLARATION_BEFORE = + /(?:\b[Vv]ersion|\bVERSION|[a-z0-9_]Version|[_-][Vv]ersion|_VERSION)["'\]]?(?:[ \t]*[:=(>][ \t]*|[ \t]+)["']?$/; +const CHANGELOG_HEADING_BEFORE = /^#{1,6}[ \t]+\[$/; +export function isDeclaredVersion(match: RegExpExecArray): boolean { + const input = match.input ?? ""; + const { start, end } = spanBounds(match); + const lineStart = input.lastIndexOf("\n", start - 1) + 1; + if (VERSION_DECLARATION_BEFORE.test(input.slice(Math.max(lineStart, start - 48), start))) return true; + return CHANGELOG_HEADING_BEFORE.test(input.slice(lineStart, start)) && input[end] === "]"; +} + // ── Placeholder suppression (per-matched-span, NOT per-line) ───────────────── /** @@ -898,7 +920,7 @@ export const PATTERNS: RedactPattern[] = [ category: "pii", description: "Public IPv4 address", regex: /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/, - validate: (span) => isPublicIPv4(span), + validate: (span, match) => isPublicIPv4(span) && !isDeclaredVersion(match), }, { id: "pii.wallet", diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index 895d259f3..874a08279 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -130,3 +130,33 @@ describe("internal.hostname: per-machine config filenames are not hosts (#2962, ], ); }); + +describe("pii.ip_public: a four-part version needs a version declaration (#2784)", () => { + table( + "pii.ip_public", + "MEDIUM", + [ + ' "version": "1.128.1.0",', + "app_version = 1.128.1.0", + "schema-version: 1.128.1.0", + "APP_VERSION=1.128.1.0", + "Released version 1.128.1.0 today", + "## [1.128.1.0] - 2026-09-02", + "1.128.1.0", + '[assembly: AssemblyVersion("1.0.0.0")]', + "released v1.128.1.0 today", + ], + [ + "1.128.1.0", + "host: 1.128.1.0", + "server = 1.128.1.0", + '"upstream": "1.128.1.0"', + "resolver = 8.8.8.8", + "version: 1.2.3.4 via 8.8.8.8", + "the version endpoint moved to 1.128.1.0", + "conversion 1.128.1.0", + "## 1.128.1.0 is our egress", + "## [1.128.1.0 mirror]", + ], + ); +}); From 178724034bc597c0cbdd043108396b28b4bed12c Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:39:18 +0000 Subject: [PATCH 5/7] H #2913: postgres:postgres is exempt only on a loopback host The postgres image's default pair in compose files and CI DATABASE_URLs blocked pushes as db.url_with_password HIGH. It is now exempt only for postgres/postgresql URLs with exactly postgres:postgres on localhost or 127.0.0.1 (optional port). A bare single-label host (`db`, `postgres`), any other host, a host that merely starts with localhost (`localhost.evil...`, `localhost@prod-db`), any other user or password, and other schemes still block. #2913 exempted the pair on any host; this keeps the plan's narrower rule. Co-authored-by: mb1810 --- lib/redact-patterns.ts | 17 ++++++++++++-- test/redact-context-exemptions.test.ts | 31 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index b496bb5e3..cc065464a 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -521,6 +521,18 @@ function urlPasswordIsPlaceholder(span: string): boolean { return PLACEHOLDER_STRUCTURAL.some((re) => re.test(pw)); } +/** + * #2913: `postgres:postgres` is the official postgres image's default pair, + * committed in compose files and CI DATABASE_URLs. It is exempt only on a + * loopback host (`localhost` or `127.0.0.1`, optional port), never on a + * single-label host such as `db`: a compose service name says nothing about + * where the same URL is also deployed, and a weak default on a reachable host + * is exactly the leak to report. Exact user and password, case-sensitive; + * the host must end at a port, path, query, quote or the span's end. + */ +const POSTGRES_LOOPBACK_DEFAULT = + /^postgres(?:ql)?:\/\/postgres:postgres@(?:localhost|127\.0\.0\.1)(?::\d{1,5})?(?![\w.:@%-])/; + /** A value span that is only an environment-variable read expression (#2912). */ const ENV_READ_SPAN = /^(?:os\.environ\[|os\.environ\.get\(|os\.getenv\(|getenv\(|ENV\[|process\.env\.[A-Za-z_$][\w$]*[;,)]?)$/; @@ -767,8 +779,9 @@ export const PATTERNS: RedactPattern[] = [ category: "secret", description: "Database URL with embedded password", regex: /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@[^\s/]+)/, - // Skip when the password segment is itself a placeholder/interpolation. - validate: (span) => !urlPasswordIsPlaceholder(span), + // Skip when the password segment is itself a placeholder/interpolation, + // or the URL is postgres's default pair on a loopback host. + validate: (span) => !urlPasswordIsPlaceholder(span) && !POSTGRES_LOOPBACK_DEFAULT.test(span), }, { id: "creds.basic_auth_url", diff --git a/test/redact-context-exemptions.test.ts b/test/redact-context-exemptions.test.ts index 874a08279..449355442 100644 --- a/test/redact-context-exemptions.test.ts +++ b/test/redact-context-exemptions.test.ts @@ -160,3 +160,34 @@ describe("pii.ip_public: a four-part version needs a version declaration (#2784) ], ); }); + +describe("db.url_with_password: postgres:postgres only on a loopback host (#2913)", () => { + // Assembled from parts so this file's own source never carries a + // credential-bearing URL for the repo's secret scan to block. + const url = (userinfo: string, rest: string, scheme = "postgresql") => `${scheme}://${userinfo}@${rest}`; + const pg = ["postgres", "postgres"].join(":"); + table( + "db.url_with_password", + "HIGH", + [ + `DATABASE_URL=${url(pg, "localhost:5432/app_test")}`, + `DATABASE_URL=${url(pg, "127.0.0.1:5432/app", "postgres")}`, + `DIRECT_URL="${url(pg, "localhost/app")}"`, + `url: ${url(pg, "localhost", "postgres")}`, + url(pg, "localhost:5432?sslmode=disable"), + ], + [ + `DATABASE_URL=${url(pg, "db:5432/app")}`, + `DATABASE_URL=${url(pg, "postgres:5432/app")}`, + `DATABASE_URL=${url(pg, "prod-host:5432/app")}`, + `DATABASE_URL=${url(pg, "localhost.evil.example.net/app")}`, + `DATABASE_URL=${url(pg, "127.0.0.1.nip.io/app")}`, + `DATABASE_URL=${url(pg, "localhost@prod-db/app")}`, + `DATABASE_URL=${url(["user", "s3cretPassw0rd"].join(":"), "prod-host:5432/app")}`, + `DATABASE_URL=${url(["postgres", "hunter2x"].join(":"), "localhost:5432/app")}`, + `DATABASE_URL=${url(["admin", "postgres"].join(":"), "localhost:5432/app")}`, + `DATABASE_URL=${url(["postgres", "Postgres"].join(":"), "localhost:5432/app")}`, + `DATABASE_URL=${url(pg, "localhost:3306/app", "mysql")}`, + ], + ); +}); From e6676ea080eae22453e856fa25608919d8bd9388 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:40:07 +0000 Subject: [PATCH 6/7] H #2949: eval credential screen checks every name segment The hermetic eval env admits GITHUB_* and EVALS_* metadata but rejects credential-shaped names, judged only by the last underscore segment. A trailing qualifier carried a credential through: GITHUB_APP_PRIVATE_KEY_BASE64, GITHUB_TOKEN_1, EVALS_API_KEY_FALLBACK reached eval children. The screen now rejects a name when any segment is a credential word; segments, not substrings, so GITHUB_PATH / GITHUB_TOKENIZER / GITHUB_KEYRING stay metadata, and explicit runner admissions still win. Co-authored-by: Nguyen Thanh Dat --- test/helpers/hermetic-env.test.ts | 22 ++++++++++++++++++++++ test/helpers/hermetic-env.ts | 19 +++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/test/helpers/hermetic-env.test.ts b/test/helpers/hermetic-env.test.ts index 6d18f7faf..2518f688e 100644 --- a/test/helpers/hermetic-env.test.ts +++ b/test/helpers/hermetic-env.test.ts @@ -144,6 +144,28 @@ describe('buildHermeticEnv allowlist', () => { ]) expect(result[name]).toBe(base[name]); }); + test('a trailing qualifier does not carry a credential past the prefix rule (#2949)', () => { + const qualified = { + GITHUB_APP_PRIVATE_KEY_BASE64: 'synthetic-pem-base64', + GITHUB_PRIVATE_KEY_PEM: 'synthetic-pem', + GITHUB_TOKEN_1: 'synthetic-first-token', + GITHUB_TOKEN_GHES: 'synthetic-enterprise-token', + GITHUB_CLIENT_SECRET_VALUE: 'synthetic-client-secret', + EVALS_API_KEY_FALLBACK: 'synthetic-eval-key', + }; + const result = buildHermeticEnv({ ...CONTAMINATED, ...qualified }, HERMETIC_VARS); + for (const name of Object.keys(qualified)) expect(result[name]).toBeUndefined(); + const serialized = JSON.stringify(result); + for (const value of Object.values(qualified)) expect(serialized.includes(value)).toBe(false); + + // Segments, not substrings: names that merely contain a credential word + // stay metadata, and an explicit runner admission still wins. + const metadata = { GITHUB_PATH: '/tmp/p', GITHUB_TOKENIZER: 't', GITHUB_KEYRING: 'k', GITHUB_REF_PROTECTED: 'false', EVALS_RUN_ID: 'r' }; + const kept = buildHermeticEnv({ ...CONTAMINATED, ...metadata, ...qualified }, HERMETIC_VARS, undefined, { extraAllow: ['GITHUB_TOKEN_1'] }); + for (const [name, value] of Object.entries(metadata)) expect(kept[name]).toBe(value); + expect(kept.GITHUB_TOKEN_1).toBe(qualified.GITHUB_TOKEN_1); + }); + test('explicit provider auth, runner admissions, and overrides still win', () => { const base = { ...CONTAMINATED, diff --git a/test/helpers/hermetic-env.ts b/test/helpers/hermetic-env.ts index cb6c8c1c2..6473f7828 100644 --- a/test/helpers/hermetic-env.ts +++ b/test/helpers/hermetic-env.ts @@ -71,15 +71,27 @@ const ALLOW_EXACT = new Set([ * VOYAGE_API_KEY) — CI doesn't have them and eval children have no business * using them. A test that legitimately needs one opts in via its own env * override; a provider runner (codex/gemini) re-admits its auth vars via - * opts.extraAllow. Prefix matches reject credential-shaped suffixes; exact + * opts.extraAllow. Prefix matches reject credential-shaped segments; exact * and explicit runner admissions still win. */ const ALLOW_PREFIXES = ['EVALS_', 'GITHUB_']; -const CREDENTIAL_SUFFIXES = new Set([ +const CREDENTIAL_SEGMENTS = new Set([ 'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'SECRET', 'SECRETS', 'PASSWORD', 'PASSWD', 'PASS', 'CREDENTIAL', 'CREDENTIALS', 'AUTH', 'PAT', 'DSN', 'COOKIE', 'SESSION', 'PRIVATE', ]); +/** + * True when any underscore-separated segment of `name` is a credential word. + * Every segment, not just the last: a trailing qualifier moves the credential + * word off the end (`GITHUB_APP_PRIVATE_KEY_BASE64` is the PEM itself, + * `GITHUB_TOKEN_1` is a token). Segments, not substrings: `GITHUB_PATH` + * contains "PAT" and `GITHUB_TOKENIZER` contains "TOKEN", and both are + * metadata. + */ +function isCredentialShapedName(name: string): boolean { + return name.toUpperCase().split('_').some((segment) => CREDENTIAL_SEGMENTS.has(segment)); +} + export interface HermeticEnvOpts { /** Per-runner additional allowed names (exact match) or prefixes (entries * ending in '*'). Example: codex runner passes ['OPENAI_API_KEY', 'CODEX_*']. */ @@ -126,8 +138,7 @@ export function buildHermeticEnv( const allowed = ALLOW_EXACT.has(k) || extraExact.has(k) || - (ALLOW_PREFIXES.some((p) => k.startsWith(p)) && - !CREDENTIAL_SUFFIXES.has(k.slice(k.lastIndexOf('_') + 1).toUpperCase())) || + (ALLOW_PREFIXES.some((p) => k.startsWith(p)) && !isCredentialShapedName(k)) || extraPrefixes.some((p) => k.startsWith(p)); if (allowed) out[k] = v; } From 1cd1911ac2244f281aef8e2a741dc6a5129de29e Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:40:39 +0000 Subject: [PATCH 7/7] H #2915: cover chunked scanning of large multi-file push diffs Test-only coverage for behavior already on main: a clean two-file pushed diff over the engine's 1 MiB cap passes without engine.input_too_large, and a credential at the end of the second file (a later scan chunk) still blocks the push. Co-authored-by: pneumorea-ai --- test/redact-prepush-hook.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index f0cb450ac..247d16694 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -118,6 +118,32 @@ describe("pre-push hook gating", () => { const RELEASE = ["1", "84", "1", "0"].join("."); const PUBLIC_IP = ["8", "8", "8", "8"].join("."); +describe("pushed diffs larger than the engine cap are scanned in chunks (#2915)", () => { + const commitLarge = (tail: string): { base: string; head: string } => { + const base = git(["rev-parse", "HEAD"]); + fs.writeFileSync(path.join(repo, "large-a.txt"), "clean-a ".repeat(90_000)); + fs.writeFileSync(path.join(repo, "large-b.txt"), "clean-b ".repeat(90_000) + tail); + git(["add", "large-a.txt", "large-b.txt"]); + git(["commit", "-q", "-m", "large diff"]); + return { base, head: git(["rev-parse", "HEAD"]) }; + }; + + test("a clean two-file diff over the cap passes without input_too_large", () => { + const { base, head } = commitLarge(""); + const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(0); + expect(stderr).not.toContain("engine.input_too_large"); + }); + + test("a credential in a later file's chunk still blocks", () => { + const { base, head } = commitLarge("\n" + FAKE_AWS_KEY + "\n"); + const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(1); + expect(stderr).toContain("aws.access_key"); + expect(stderr).not.toContain("engine.input_too_large"); + }); +}); + describe("release version exemption (#2856)", () => { const mediums = (stderr: string): number => Number(/(\d+) MEDIUM finding/.exec(stderr)?.[1] ?? 0); const commitAll = (files: Record, msg: string): string => {