Skip to content

Withhold what masking cannot bound - #216

Merged
patchstackdave merged 1 commit into
mainfrom
fix/withhold-what-redaction-cannot-bound
Sep 4, 2026
Merged

patchstackdave merged 1 commit into
mainfrom
fix/withhold-what-redaction-cannot-bound

Conversation

@patchstackdave

@patchstackdave patchstackdave commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

The rule

redact masks the span a pattern matched and serves the rest of the page. It is correct only where the
match is the whole disclosure. block withholds the response and replaces it with a generic error,
and is used where a pattern can identify a disclosure but not delimit it.

The shipped response set splits on that line: five mask, five withhold.

policy action response a client receives
AWS access key id redact 200, the key replaced by [REDACTED], rest of the body unchanged
Google API key redact 200, as above
Vendor API key / token redact 200, as above
Supabase secret key redact 200, as above
Supabase service_role key redact 200, as above
Private key block 500, {"error":"Response withheld by Patchstack (sensitive data detected)"}
Database connection string block 500, as above
Node stack trace block 500, as above
SQL / ORM error block 500, as above
Backend exception trace block 500, as above

The five that withhold each identify the start of a disclosure: the key material follows the BEGIN
line; the file names, line numbers, query text and remaining frames follow a frame, an error signature
or an exception header; and for a connection URI the host, port, database name and query are the rest of
the disclosure. Masking any of them leaves nearly all of it in a response that reports itself protected.

Why the connection URI withholds rather than masks

A URI's own grammar admits commas, parentheses and semicolons, so no end-of-URI character class
delimits one in free text. A class that excludes them stops inside a real URI and leaves the host and
database name; one that permits them consumes the punctuation around it —
connect via (postgres://…/app) today comes back as ([REDACTED] today, with the sentence broken and
the disclosure only partly removed. Withholding is the only contract that is both complete and
non-destructive, and it is consistent with the four signature-identified policies.

The pattern requires credentials, so a URL without them is not matched: a sentence containing
postgres://db.internal:5432/app is returned byte for byte.

Both userinfo components are the complete representable userinfo grammar, with the separator
disambiguated: the username is that grammar without raw :, the password is the same grammar
with it.

[A-Za-z0-9._~%!$&'()*+,;=-]+:[A-Za-z0-9._~%!$&'()*+,;=:-]+@

That asymmetry is the grammar, not a concession. In user:password@ the first raw colon is the
separator, so a raw colon cannot belong to the username — a username holding one carries it as %3A.
A password may hold as many raw colons as it likes. Every other character either class admits appears
raw in real credentials, so a narrower class turns a live credential into a rule that says nothing.

What terminates a candidate is everything the grammar excludes — whitespace, /, ?, #, @,
quotes, backslashes, angle and square brackets, braces — and that is what keeps a candidate inside one
value. Written as "anything but :, @, / and whitespace" it crosses structure instead, so

{"docs":"postgres://db.internal","contact":"user@example.com"}

matches across the two properties and withholds a response that discloses nothing.

The separator is what keeps matching linear. The first raw colon separates username from
password, so excluding it from the username makes the separator unambiguous; the password continues to
admit raw colons, and a username holding one carries it as %3A. Both classes admitting : would
leave every colon available as the separator, so a candidate run with no @ is re-split at every
position — and the screening cap does not bound that, since max_bytes raises it and bypass_limit
removes it.

Two tests hold the property: one reads the username class and fails if it admits a colon, the other
screens a cap-sized adversarial candidate and fails outside the bound. The structural one is necessary
because the engine's expression guard accepts either form.

The remaining trade-off is explicit. Punctuation-joined text that parses as a credential URI is
treated as one: postgres://db.internal;contact:admin@example.com has username db.internal;contact,
password admin and host example.com, which nothing distinguishes from a leak, so it is withheld.
Ordinary prose separates with whitespace, which terminates the candidate. The negative controls are
only bodies whose separator is outside the grammar — a quote between two properties, two array
elements, whitespace in prose. Pinning a semicolon- or comma-joined body as safe would tell the rule
to ignore a real disclosure.

The private-key grammar

Matches a PEM BEGIN line whose label contains PRIVATE KEY, with up to 32 further label characters
— uppercase letters, digits, spaces and hyphens — on either side of it:

/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/

Covered: the enumerated types (RSA, EC, OPENSSH, DSA, ENCRYPTED); a label carrying words
after PRIVATE KEY, which is the real ASCII-armored form -----BEGIN PGP PRIVATE KEY BLOCK-----;
a hyphenated or otherwise unlisted type; and a bare -----BEGIN PRIVATE KEY-----. No footer is
required — a truncated response or an absent END marker does not make the material above it less of a
key.

Not matched: PUBLIC KEY, CERTIFICATE, prose that mentions a private key without a BEGIN line, a
lowercase label, and a label longer than 32 characters on either side. The last is a limitation, not
a guarantee
, and is asserted as such.

Two bounded character classes rather than a repeated group: the engine's expression guard refuses a
quantifier inside a quantified group, and a refused pattern is a rule that never fires.

The stack-frame pattern

Accepts a real newline and a JSON-escaped one, since most traces reach a client inside a JSON error
body where the newline is the two characters \ and n.

Tests

tests/protect/response-policy-wire-behaviour.test.ts (33 cases) states every contract as the response
a client receives, not as an action name:

  • masked — no eight-character prefix or tail of the value survives, the rest of the body is
    unchanged, and JSON still parses;
  • withheld — the status, the exact withheld body, and the absence of each field the disclosure
    carried (index.js, 42:15, users_email_unique, SQLSTATE, app.py, line 42, key material,
    db.internal, 5432, sslmode);
  • private key — complete PEM, CRLF endings, missing footer, PGP key block, hyphenated label, a
    label at the accepted boundary, bare marker; against prose, a heading, a public key and a
    certificate;
  • connection URI — plain text, JSON, inside parentheses, before a comma, mongodb+srv, twice in
    one body; plus a credential-free URL whose surrounding sentence is returned byte for byte.

default-policy-fixtures.test.ts asserts that the named policy fired, rather than that its sample
is absent: a withheld response removes the sample, and so does JSON escaping a sample containing a
newline. Its benign near-miss is screened on its own request, since a withheld response would otherwise
take the control value with it.

Known limitations

  • A private-key label longer than 32 characters on either side of PRIVATE KEY, or a lowercase label,
    does not fire this policy.
  • A response containing a credential-bearing connection URI is withheld whole; there is no partial
    form that serves the page.

Verification

2419 tests pass, 154 files. Typecheck and the template typecheck harness clean.

wire() releases each protection instance through the public stop() in a finally, so the tests
hold the documented lifecycle even if these guards later start something that needs releasing.

@coderbuds

coderbuds Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Well-documented response rules with focused regex improvements and comprehensive tests.

🎯 Quality: 100% Elite · 📦 Size: Large — consider splitting if possible

🛡️ Standards: no pre-flight fit check ran for this change — wire assess-change-fit into your coding agents to catch size before opening.

📈 This month: Your 182nd PR — above team average · Averaging Excellent

See how your team is trending →

@patchstackdave
patchstackdave force-pushed the fix/withhold-what-redaction-cannot-bound branch 3 times, most recently from 4c6a85a to c1d41b7 Compare September 4, 2026 12:06
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave force-pushed the fix/withhold-what-redaction-cannot-bound branch 2 times, most recently from f9bf75d to f233e4b Compare September 4, 2026 12:52
`redact` masks the span a pattern matched and serves the rest of the page. It is the right action only
where the match IS the whole disclosure, which holds for a credential with a grammar: a provider token
has a prefix, an alphabet and a length, so matching it matches all of it. Five of the shipped response
policies are of that kind and mask.

The other five identify a disclosure they cannot delimit, and they withhold the response instead:

  - a private key, whose material follows the BEGIN line;
  - a Node stack frame, a database error signature and an exception header, each of which opens a
    disclosure whose file names, line numbers, query text and remaining frames come after it;
  - a connection URI carrying credentials, where the host, port, database name and query are the rest
    of the disclosure and a URI's own grammar admits commas, parentheses and semicolons — so no
    end-of-URI character class delimits one in free text without either stopping inside a real URI or
    consuming the punctuation around it.

That URI's username is the userinfo grammar without `:`, and its password is the same grammar with it.
Every other character either class admits may appear raw in a real credential, so a narrower one turns
a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.

The first raw colon separates username from password. Excluding it from the username makes the
separator unambiguous and keeps matching linear; the password continues to admit raw colons, and a
username that contains one carries it as `%3A`.

Both classes admitting `:` would leave every colon available as the separator, so a candidate run with
no `@` is re-split at every position. The screening cap does not bound that, since `max_bytes` raises
it and `bypass_limit` removes it. Two tests hold the property: one reads the username class and fails
if it admits a colon, the other screens a cap-sized adversarial candidate and fails outside the bound.
The structural one is necessary because the engine's expression guard accepts either form.

What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`, quotes,
backslashes, angle and square brackets and braces. That keeps a candidate inside one value. Expressed
as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead, reaching the punctuation of
an unrelated field, so a body carrying a documentation URL in one property and an address in another is
withheld while disclosing nothing.

The consequence is deliberate. Punctuation-joined text that parses as a credential URI is treated as
one: `postgres://db.internal;contact:admin@example.com` has username `db.internal;contact`, password
`admin` and host `example.com`, which nothing distinguishes from a leak. Ordinary prose separates with
whitespace, which terminates the candidate.

The private-key pattern matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32
further label characters — uppercase letters, digits, spaces and hyphens — on either side of it. That
covers the enumerated types, a label carrying words after `PRIVATE KEY` such as `PGP PRIVATE KEY
BLOCK`, a hyphenated or unlisted type, and a bare `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and
`CERTIFICATE` do not match, nor does prose mentioning a private key. No footer is required: a
truncated response or an absent END marker does not make the material above it less of a key. Two
bounded character classes rather than a repeated group, which the engine's expression guard refuses as
a backtracking risk — a refused pattern is a rule that never fires. A lowercase label, or one longer
than 32 characters on either side, is not matched.

The stack-frame pattern accepts a real newline and a JSON-escaped one, since most traces reach a client
inside a JSON error body where the newline is two characters.

`tests/protect/response-policy-wire-behaviour.test.ts` states each contract as the response a client
receives. For a masked credential: no eight-character prefix or tail of the value survives, the rest of
the body is unchanged, and JSON still parses. For a withheld one: the status, the exact withheld body,
and the absence of every field the disclosure carried. The private-key cases cover a complete PEM, CRLF
endings, a missing footer, a PGP key block, a hyphenated label, a label at the accepted boundary and a
bare marker, against four near-misses. The URI cases cover plain text, JSON, parentheses, a trailing
comma, `mongodb+srv`, two occurrences in one body, credentials carrying percent-encoded characters, and
passwords holding one and several raw colons, a semicolon, a comma, parentheses and sub-delimiters, and
a username carrying `%3A`. The negatives are the
bodies whose separator is outside the grammar — a quote or whitespace between the scheme and an
unrelated `:` and `@` — each asserted byte-identical at 200, alongside a credential-free URL whose
surrounding sentence is returned unchanged.

`default-policy-fixtures.test.ts` asserts that the named policy fired, rather than that its sample is
absent from the body: a withheld response removes the sample, and so does JSON escaping a sample that
contains a newline. Its benign near-miss is screened on its own request, because a withheld response
would otherwise take the control value with it.
@patchstackdave
patchstackdave force-pushed the fix/withhold-what-redaction-cannot-bound branch from f233e4b to 620ff99 Compare September 4, 2026 13:04
@patchstackdave
patchstackdave merged commit bfefb4d into main Sep 4, 2026
14 checks passed
@patchstackdave
patchstackdave deleted the fix/withhold-what-redaction-cannot-bound branch September 4, 2026 13:41
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.

2 participants