test(adapter-utils): pin the header scanner's matrices as tables
Promote the round-eight review matrices into repository tables so the scanner's contract is checked here rather than in a harness under /tmp. Opener kind by value kind by suffix kind by serialization depth 0 to 3, including the adjacent double-quoted segment whose first byte is a space that leaked at depths 2 and 3. Truncated tails of each quote kind after each root kind, including the escaped-quote tail whose remainder leaked. Even backslash runs of 2, 4, 6 and 8 before a quote, a plain character, a space and a line end. Serialized rows assert the marker is gone, the output is stable, and the output still parses as the JSON string it arrived as and decodes to the redacted shell text. Truncated rows assert removal and stability only: a serialized string cut inside the header argument loses its outer delimiter, which is the contract's known over-redaction and never keeps a credential. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
parent
85831e6a0b
commit
5ad32ccc87
|
|
@ -825,3 +825,163 @@ describe("redactCommandText header secrets", () => {
|
|||
expect(output).toContain("command failed:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactCommandText header scanner matrices", () => {
|
||||
const R = REDACTED_COMMAND_TEXT_VALUE;
|
||||
const serialize = (value: string, depth: number) => {
|
||||
let encoded = value;
|
||||
for (let index = 0; index < depth; index += 1) encoded = JSON.stringify(encoded);
|
||||
return encoded;
|
||||
};
|
||||
const parseDepth = (value: string, depth: number) => {
|
||||
let parsed: unknown = value;
|
||||
for (let index = 0; index < depth; index += 1) parsed = JSON.parse(parsed as string);
|
||||
return parsed as string;
|
||||
};
|
||||
|
||||
// Opener kind x value kind x suffix kind x serialization depth. Each row is
|
||||
// one shell word, so every suffix is credential material and the whole word
|
||||
// collapses to one placeholder. `double-leading-space` is the F1 reproduction
|
||||
// that leaked at depths 2 and 3 before the scanner.
|
||||
const roots = [
|
||||
["full-header", (value: string) => `curl -H "X-API-Key: ${value}"`, `curl -H "X-API-Key: ${R}"`],
|
||||
["value-only", (value: string) => `curl -H X-API-Key:"${value}"`, `curl -H X-API-Key:"${R}"`],
|
||||
] as const;
|
||||
const suffixes = [
|
||||
["plain", "TAILMARK"],
|
||||
["single-quoted", "'TAILMARK'"],
|
||||
["double-quoted", '"TAILMARK"'],
|
||||
["ansi-c", "$'TAILMARK'"],
|
||||
["escape-pair", "\\TAILMARK"],
|
||||
["escaped-space", "\\ TAILMARK"],
|
||||
["double-leading-space", '" TAILMARK"'],
|
||||
] as const;
|
||||
|
||||
it.each(roots)("redacts every %s suffix kind at depths 0-3", (_name, build, redacted) => {
|
||||
for (const [, suffix] of suffixes) {
|
||||
const base = `${build("SECRET")}${suffix};echo safe`;
|
||||
const expectedBase = `${redacted};echo safe`;
|
||||
for (let depth = 0; depth <= 3; depth += 1) {
|
||||
const input = serialize(base, depth);
|
||||
const output = redactCommandText(input);
|
||||
expect(output).not.toContain("SECRET");
|
||||
expect(output).not.toContain("TAILMARK");
|
||||
// The serializer's own layers survive the redaction, so the output is
|
||||
// still the same JSON string it arrived as.
|
||||
expect(output).toBe(serialize(expectedBase, depth));
|
||||
if (depth > 0) expect(parseDepth(output, depth)).toBe(expectedBase);
|
||||
expect(redactCommandText(output)).toBe(output);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// A tail cut by a run log after a closed value. The N6 reproduction is the
|
||||
// double-quoted tail that carries an escaped quote: the bytes after it belong
|
||||
// to the same shell word, so the whole tail goes. These rows lose the
|
||||
// enclosing serializer's delimiter under N4, so they assert removal and
|
||||
// stability rather than a round trip.
|
||||
const truncatedRoots = [
|
||||
["closed-escaped", String.raw`curl -H X-API-Key:\"SECRET\"`],
|
||||
["closed-double", `curl -H "X-API-Key: SECRET"`],
|
||||
["closed-single", `curl -H 'X-API-Key: SECRET'`],
|
||||
["closed-ansi", `curl -H $'X-API-Key: SECRET'`],
|
||||
["unquoted", `curl -H X-API-Key:SECRET`],
|
||||
] as const;
|
||||
const truncatedTails = [
|
||||
["double", '"TAILMARK'],
|
||||
["single", "'TAILMARK",],
|
||||
["ansi-c", "$'TAILMARK"],
|
||||
["double-escaped-quote", String.raw`"TAIL\"LEAK`],
|
||||
] as const;
|
||||
|
||||
it.each(truncatedRoots)("redacts a truncated tail after a %s root at depths 0-2", (_name, root) => {
|
||||
for (const [, tail] of truncatedTails) {
|
||||
for (let depth = 0; depth <= 2; depth += 1) {
|
||||
const output = redactCommandText(serialize(root + tail, depth));
|
||||
expect(output).not.toContain("SECRET");
|
||||
expect(output).not.toContain("TAILMARK");
|
||||
expect(output).not.toContain("LEAK");
|
||||
expect(redactCommandText(output)).toBe(output);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("loses a truncated escaped-quote tail after a closed escaped value", () => {
|
||||
// The N6 reproduction, spelled out. Bash reads the completed line as the
|
||||
// single word `X-API-Key:"SECRET"TAIL"LEAK`, so `LEAK` is credential text.
|
||||
const input = String.raw`curl -H X-API-Key:\"SECRET\""TAIL\"LEAK`;
|
||||
const output = redactCommandText(input);
|
||||
expect(output).not.toContain("SECRET");
|
||||
expect(output).not.toContain("LEAK");
|
||||
expect(output).toBe(String.raw`curl -H X-API-Key:\"` + R);
|
||||
expect(redactCommandText(output)).toBe(output);
|
||||
});
|
||||
|
||||
// Even backslash runs are escaped backslashes, so the byte after them is bare.
|
||||
const evenRunFollowers = [
|
||||
["quote", (run: string) => `curl -H X-API-Key:SECRET${run}"TAILMARK"MORE ;echo safe`, `curl -H X-API-Key:${R} ;echo safe`],
|
||||
["plain", (run: string) => `curl -H X-API-Key:SECRET${run}TAILMARK next`, `curl -H X-API-Key:${R} next`],
|
||||
] as const;
|
||||
|
||||
it.each(evenRunFollowers)("consumes an even backslash run before a %s at depths 0-2", (_name, build, expectedBase) => {
|
||||
for (const runLength of [2, 4, 6, 8]) {
|
||||
const base = build("\\".repeat(runLength));
|
||||
for (let depth = 0; depth <= 2; depth += 1) {
|
||||
const output = redactCommandText(serialize(base, depth));
|
||||
expect(output).not.toContain("SECRET");
|
||||
expect(output).not.toContain("TAILMARK");
|
||||
expect(output).toBe(serialize(expectedBase, depth));
|
||||
expect(redactCommandText(output)).toBe(output);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
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}`]) {
|
||||
for (let depth = 0; depth <= 2; depth += 1) {
|
||||
const output = redactCommandText(serialize(base, depth));
|
||||
expect(output).not.toContain("SECRET");
|
||||
expect(redactCommandText(output)).toBe(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps one pass a fixpoint over a serialized argument with a trailing delimiter run", () => {
|
||||
// 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 once = redactCommandText(input);
|
||||
expect(once).not.toContain("LEAK");
|
||||
expect(redactCommandText(once)).toBe(once);
|
||||
expect(redactDiagnosticText(once)).toBe(once);
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
expect(redactCommandText('X-API-Key: abc"')).toBe(`X-API-Key: ${R}"`);
|
||||
expect(redactCommandText(`X-API-Key: abc'`)).toBe(`X-API-Key: ${R}'`);
|
||||
});
|
||||
|
||||
it("redacts a value that opens with a blank inside an escaped delimiter", () => {
|
||||
// The escaped value delimiter owns its body, so a leading space no longer
|
||||
// leaves the credential in the clear.
|
||||
const output = redactCommandText(String.raw`X-API-Key:\" abc\" tail`);
|
||||
expect(output).not.toContain("abc");
|
||||
expect(output).toBe(String.raw`X-API-Key:\"` + R + String.raw`\" tail`);
|
||||
expect(redactCommandText(output)).toBe(output);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue