test(adapter-utils): exact oracles for the scanner's tables

Add the two matrices whose absence hid the leaks: single-quoted and ANSI-C
roots, full-header and value-only, with an adjacent serialized double-quoted
segment at depths 0 to 3; and line continuations with LF and CRLF after an
unquoted value, after each closed quoted argument, inside a double-quoted
value, and on the value's first byte.

Turn the permissive tables into transformation oracles. The truncated-tail,
even-run space and line-end, and fixpoint tables now assert exact output
where it is determinate. Rows stay on removal and stability only where the
union makes the exact output a policy artifact rather than a fact about the
credential: every serialized truncated row, which loses its outer delimiter,
and the adjacent segment carrying an escaped quote, where scanning the tail
at every layer runs a deeper reading to the line end. Each of those carries
the reason inline.

Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
Michel Tomas 2026-09-06 02:45:31 +02:00
parent e03a30dfac
commit 831ff2036b
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
1 changed files with 150 additions and 13 deletions

View File

@ -889,13 +889,31 @@ describe("redactCommandText header scanner matrices", () => {
] as const;
const truncatedTails = [
["double", '"TAILMARK'],
["single", "'TAILMARK",],
["single", "'TAILMARK"],
["ansi-c", "$'TAILMARK"],
["double-escaped-quote", String.raw`"TAIL\"LEAK`],
] as const;
// Depth-0 output is determinate, so it is pinned exactly, in tail order. A
// tail opening on a bare double quote reopens the closer under the depth-0
// reading, which wins and drops the closer; a single or ANSI-C tail is
// consumed by the delimiter's own reading, which keeps it.
const truncatedExpectations: Record<string, readonly string[]> = {
"closed-escaped": [
String.raw`curl -H X-API-Key:\"` + R,
String.raw`curl -H X-API-Key:\"` + R + String.raw`\"`,
String.raw`curl -H X-API-Key:\"` + R + String.raw`\"`,
String.raw`curl -H X-API-Key:\"` + R,
],
"closed-double": Array(4).fill(`curl -H "X-API-Key: ${R}"`),
"closed-single": Array(4).fill(`curl -H 'X-API-Key: ${R}'`),
"closed-ansi": Array(4).fill(`curl -H $'X-API-Key: ${R}'`),
unquoted: Array(4).fill(`curl -H X-API-Key:${R}`),
};
it.each(truncatedRoots)("redacts a truncated tail after a %s root at depths 0-2", (_name, root) => {
for (const [, tail] of truncatedTails) {
it.each(truncatedRoots)("redacts a truncated tail after a %s root at depths 0-2", (name, root) => {
truncatedTails.forEach(([, tail], index) => {
// Depth 0: exact.
expect(redactCommandText(root + tail)).toBe(truncatedExpectations[name]![index]);
for (let depth = 0; depth <= 2; depth += 1) {
const output = redactCommandText(serialize(root + tail, depth));
expect(output).not.toContain("SECRET");
@ -903,7 +921,11 @@ describe("redactCommandText header scanner matrices", () => {
expect(output).not.toContain("LEAK");
expect(redactCommandText(output)).toBe(output);
}
}
// Depths 1 and 2 stay marker-and-stability only: a serialized string cut
// inside the header argument loses its outer delimiter under N4, so the
// exact output there is a policy artifact of the union, not a fact about
// the credential.
});
});
it("loses a truncated escaped-quote tail after a closed escaped value", () => {
@ -938,10 +960,17 @@ describe("redactCommandText header scanner matrices", () => {
it("redacts an even backslash run before a space or a line end at depths 0-2", () => {
// These two followers over-redact: the readings disagree about whether the
// run escapes what comes next, so the union takes the longer span.
// run escapes what comes next, so the union takes the longer span. Depth 0
// is determinate and pinned exactly; the serialized rows drop the outer
// delimiter under N4, so the exact output there is a union artifact.
for (const runLength of [2, 4, 6, 8]) {
const run = "\\".repeat(runLength);
for (const base of [`curl -H X-API-Key:SECRET${run} next safe`, `curl -H X-API-Key:SECRET${run}`]) {
const followers = [
[`curl -H X-API-Key:SECRET${run} next safe`, `curl -H X-API-Key:${R} safe`],
[`curl -H X-API-Key:SECRET${run}`, `curl -H X-API-Key:${R}`],
] as const;
for (const [base, expected] of followers) {
expect(redactCommandText(base)).toBe(expected);
for (let depth = 0; depth <= 2; depth += 1) {
const output = redactCommandText(serialize(base, depth));
expect(output).not.toContain("SECRET");
@ -955,20 +984,128 @@ describe("redactCommandText header scanner matrices", () => {
// A second pass reads the placeholder, which carries no quote and no
// separator to stop an unquoted scan before the closer. The first pass
// consumes whatever that scan would, so the output never moves again.
const inputs = [
String.raw`"curl -H \"X-API-Key: LEAK\"TAILMARK\" --next \"safe\""`,
String.raw`"curl -H \"X-API-Key: LEAK \"TAILMARK\" --next \"safe\""`,
String.raw`"curl -H X-API-Key:\"LEAK\"TAILMARK\" --next \"safe\""`,
String.raw`foo\\"X-API-Key: a b" bar`,
];
for (const input of inputs) {
const rows = [
[
String.raw`"curl -H \"X-API-Key: LEAK\"TAILMARK\" --next \"safe\""`,
String.raw`"curl -H \"X-API-Key: ` + R + String.raw`\""`,
],
[
String.raw`"curl -H \"X-API-Key: LEAK \"TAILMARK\" --next \"safe\""`,
String.raw`"curl -H \"X-API-Key: ` + R + String.raw`\""`,
],
[
String.raw`"curl -H X-API-Key:\"LEAK\"TAILMARK\" --next \"safe\""`,
String.raw`"curl -H X-API-Key:\"` + R + String.raw`\""`,
],
[String.raw`foo\\"X-API-Key: a b" bar`, String.raw`foo\\"X-API-Key: ` + R],
] as const;
for (const [input, expected] of rows) {
const once = redactCommandText(input);
expect(once).not.toContain("LEAK");
expect(once).toBe(expected);
expect(redactCommandText(once)).toBe(once);
expect(redactDiagnosticText(once)).toBe(once);
}
});
// A single quote and an ANSI-C `$'` are what JSON serialization leaves alone,
// so they name no layer: an adjacent double-quoted segment after such an
// argument is a further segment of the same shell word at every depth. Bash
// reads each decoded row as one word, `X-API-Key: SECRET TAILMARK`.
const layerInvariantRoots = [
["single-quoted full-header", `curl -H 'X-API-Key: SECRET'`, `curl -H 'X-API-Key: ${R}'`],
["single-quoted value-only", `curl -H X-API-Key:'SECRET'`, `curl -H X-API-Key:'${R}'`],
["ansi-c full-header", `curl -H $'X-API-Key: SECRET'`, `curl -H $'X-API-Key: ${R}'`],
["ansi-c value-only", `curl -H X-API-Key:$'SECRET'`, `curl -H X-API-Key:$'${R}'`],
] as const;
const adjacentDoubleQuoted = ['"TAILMARK"', '" TAILMARK"'] as const;
it.each(layerInvariantRoots)(
"consumes a serialized adjacent segment after a %s argument at depths 0-3",
(_name, root, redacted) => {
for (const suffix of adjacentDoubleQuoted) {
const base = `${root}${suffix} --next safe`;
const expectedBase = `${redacted} --next safe`;
for (let depth = 0; depth <= 3; depth += 1) {
const output = redactCommandText(serialize(base, depth));
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(output).toBe(serialize(expectedBase, depth));
if (depth > 0) expect(parseDepth(output, depth)).toBe(expectedBase);
expect(redactCommandText(output)).toBe(output);
}
}
},
);
it.each(layerInvariantRoots)(
"consumes an adjacent segment carrying an escaped quote after a %s argument",
(_name, root, redacted) => {
const base = `${root}${String.raw`"TAIL\"MARK"`} --next safe`;
// Depth 0 is determinate. At depth 1 and deeper the tail is scanned at
// every layer, because a single quote names none, and a deeper layer
// reads this shape as an argument that never closes, so the union runs to
// the line end. That is a union artifact, not a fact about the
// credential, so these rows assert removal and stability only. Seeding
// fewer layers is not available: a tail that opens on plain bytes before
// its serialized quote leaks under a depth-0-only tail, which
// `808854d9a` demonstrates on
// `"curl -H 'X-API-Key: SECRET'TAIL\" MORE\" --next safe"`.
expect(redactCommandText(base)).toBe(`${redacted} --next safe`);
for (let depth = 0; depth <= 3; depth += 1) {
const output = redactCommandText(serialize(base, depth));
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(output).not.toContain("MARK");
expect(redactCommandText(output)).toBe(output);
}
},
);
it("consumes a serialized adjacent segment that opens after plain bytes", () => {
// The tail's serialized quote is not its first byte, so the layer cannot be
// read off the tail. Scanning every layer is what covers it.
const input = JSON.stringify(
`curl -H 'X-API-Key: SECRET'TAIL" MORE" --next safe`,
);
const output = redactCommandText(input);
expect(output).not.toContain("SECRET");
expect(output).not.toContain("MORE");
expect(output).toBe(JSON.stringify(`curl -H 'X-API-Key: ${R}' --next safe`));
expect(redactCommandText(output)).toBe(output);
});
// A backslash-newline is a line continuation: the shell removes it and joins
// the next physical line to the same word, so the credential runs on past it.
const lineContinuations = [
["an unquoted value", (nl: string) => `curl -H X-API-Key:SECRET\\${nl}TAILMARK --next safe`, `curl -H X-API-Key:${R} --next safe`],
["a closed single-quoted argument", (nl: string) => `curl -H 'X-API-Key: SECRET'\\${nl}TAILMARK --next safe`, `curl -H 'X-API-Key: ${R}' --next safe`],
["a closed double-quoted argument", (nl: string) => `curl -H "X-API-Key: SECRET"\\${nl}TAILMARK --next safe`, `curl -H "X-API-Key: ${R}" --next safe`],
["a closed ANSI-C argument", (nl: string) => `curl -H $'X-API-Key: SECRET'\\${nl}TAILMARK --next safe`, `curl -H $'X-API-Key: ${R}' --next safe`],
["a double-quoted value", (nl: string) => `curl -H "X-API-Key: SECRET\\${nl}TAILMARK" --next safe`, `curl -H "X-API-Key: ${R}" --next safe`],
["the value's first byte", (nl: string) => `curl -H X-API-Key:\\${nl}SECRET --next safe`, `curl -H X-API-Key:${R} --next safe`],
] as const;
it.each(lineContinuations)(
"follows a line continuation after %s",
(_name, build, expected) => {
for (const newline of ["\n", "\r\n"]) {
expect(redactCommandText(build(newline))).toBe(expected);
expect(redactCommandText(expected)).toBe(expected);
// A serializer writes the continuation as a backslash run and a
// two-byte `\n` escape, which the reader carries as an escaped
// character, so the word still runs on. The outer delimiter is lost
// under N4, so these rows assert removal and stability only.
for (let depth = 1; depth <= 2; depth += 1) {
const output = redactCommandText(serialize(build(newline), depth));
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(redactCommandText(output)).toBe(output);
}
}
},
);
it("keeps an empty truncated segment out of the redaction", () => {
// A cut that leaves a segment with no bytes hides nothing, so the word ends
// before the quote and the quote survives.