762 lines
32 KiB
TypeScript
762 lines
32 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
REDACTED_COMMAND_TEXT_VALUE,
|
|
redactCommandText,
|
|
redactDiagnosticText,
|
|
} from "./command-redaction.js";
|
|
|
|
describe("redactDiagnosticText", () => {
|
|
it("redacts a JSON secret field value", () => {
|
|
const input = '{"token":"opaque-value","status":"error"}';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("opaque-value");
|
|
expect(output).toContain(`"token":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
|
// The non-secret field keeps its value.
|
|
expect(output).toContain('"status":"error"');
|
|
});
|
|
|
|
it("redacts an api_key JSON field with whitespace around the colon", () => {
|
|
const input = '{ "api_key" : "sk-secret-123" }';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("sk-secret-123");
|
|
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
|
});
|
|
|
|
it("redacts an escaped-JSON secret field value", () => {
|
|
// A diagnostic can carry a JSON string, so the double quotes appear as `\"`.
|
|
const input = '{\\"token\\":\\"opaque-value\\"}';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("opaque-value");
|
|
expect(output).toContain(
|
|
`\\"token\\":\\"${REDACTED_COMMAND_TEXT_VALUE}\\"`,
|
|
);
|
|
});
|
|
|
|
it("still redacts a shell KEY=value secret", () => {
|
|
const input = "ANTHROPIC_API_KEY=super-secret-value claude --print";
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("super-secret-value");
|
|
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
|
});
|
|
|
|
it("redacts an escaped quoted assignment across a literal newline", () => {
|
|
const input = String.raw`authorization=\"Bearer first-line
|
|
second-line\" status=401`;
|
|
const expected = String.raw`authorization=\"***REDACTED***\" status=401`;
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).toBe(expected);
|
|
expect(redactDiagnosticText(output)).toBe(expected);
|
|
});
|
|
|
|
it("keeps non-secret text and non-secret JSON fields intact", () => {
|
|
const input = '{"status":"ok","message":"probe finished"}';
|
|
expect(redactDiagnosticText(input)).toBe(input);
|
|
});
|
|
|
|
it("redacts the secret but keeps a non-secret marker in the same string", () => {
|
|
const input = 'DIAGMARKER1234 said {"authorization":"Bearer opaque"}';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).toContain("DIAGMARKER1234");
|
|
expect(output).not.toContain("opaque");
|
|
});
|
|
|
|
it("redacts a JSON secret value that contains an escaped quote", () => {
|
|
// The value holds an escaped quote, so a naive matcher stops at the `\"` and
|
|
// leaves the rest of the credential. The marker sits after the escaped quote.
|
|
const input = '{"token":"pre\\"MARKERQUOTE_A"}';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("MARKERQUOTE_A");
|
|
expect(output).toContain(`"token":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
|
});
|
|
|
|
it("redacts a JSON secret value that contains an escaped backslash", () => {
|
|
const input = '{"secret":"pre\\\\MARKERBACKSLASH_A"}';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("MARKERBACKSLASH_A");
|
|
expect(output).toContain(`"secret":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
|
});
|
|
|
|
it("redacts an escaped-JSON secret value that contains an escaped quote", () => {
|
|
// A diagnostic can carry a serialized JSON string, so the whole JSON is
|
|
// escaped a second time. The inner value still holds an escaped quote.
|
|
const innerJson = '{"token":"pre\\"MARKERQUOTE_B"}';
|
|
const input = JSON.stringify(innerJson);
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("MARKERQUOTE_B");
|
|
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
|
});
|
|
|
|
it("redacts an escaped-JSON secret value that contains an escaped backslash", () => {
|
|
const innerJson = '{"password":"pre\\\\MARKERBACKSLASH_B"}';
|
|
const input = JSON.stringify(innerJson);
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("MARKERBACKSLASH_B");
|
|
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
|
});
|
|
});
|
|
|
|
describe("redactCommandText header secrets", () => {
|
|
it("redacts a double-quoted X-API-Key header value", () => {
|
|
const input = 'curl -H "X-API-Key: abc" https://example.test/api/agents/me';
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test/api/agents/me`,
|
|
);
|
|
});
|
|
|
|
it("redacts a single-quoted lowercase x-api-key header value", () => {
|
|
const input = "curl -H 'x-api-key: abc' https://example.test/api/agents/me";
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
`curl -H 'x-api-key: ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test/api/agents/me`,
|
|
);
|
|
});
|
|
|
|
it("redacts an unquoted header value and other credential header names", () => {
|
|
expect(redactCommandText("curl -H X-API-Key:abc https://example.test")).toBe(
|
|
`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} https://example.test`,
|
|
);
|
|
expect(redactCommandText('curl -H "Api-Key: abc"')).toBe(
|
|
`curl -H "Api-Key: ${REDACTED_COMMAND_TEXT_VALUE}"`,
|
|
);
|
|
expect(redactCommandText('curl -H "X-Auth-Token: abc"')).toBe(
|
|
`curl -H "X-Auth-Token: ${REDACTED_COMMAND_TEXT_VALUE}"`,
|
|
);
|
|
expect(redactCommandText('curl -H "X-Paperclip-Api-Key: abc"')).toBe(
|
|
`curl -H "X-Paperclip-Api-Key: ${REDACTED_COMMAND_TEXT_VALUE}"`,
|
|
);
|
|
});
|
|
|
|
it("keeps a non-secret header untouched", () => {
|
|
const input = 'curl -H "Content-Type: application/json" -H "Accept: application/json" https://example.test';
|
|
expect(redactCommandText(input)).toBe(input);
|
|
});
|
|
|
|
it("keeps the bearer header output byte for byte identical", () => {
|
|
// The bearer rule already redacted this shape. The header rule keeps the
|
|
// scheme, so the output must not change.
|
|
const input = 'curl -H "Authorization: Bearer abc" https://example.test';
|
|
expect(redactCommandText(input)).toBe(
|
|
`curl -H "Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("now redacts a basic authorization header value", () => {
|
|
const input = 'curl -H "Authorization: Basic dXNlcjpwdw==" https://example.test';
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("dXNlcjpwdw==");
|
|
expect(output).toBe(
|
|
`curl -H "Authorization: Basic ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts an escaped-quoted value and keeps its escaped quotes", () => {
|
|
// An outer shell writes quote syntax for an inner shell this way, and the
|
|
// caller's own authorization rules write the same shape. Keeping the
|
|
// escaped quotes makes both agree on the result.
|
|
const input = String.raw`prefix Authorization: \"Bearer nested\" suffix`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("nested");
|
|
expect(output).toBe(
|
|
String.raw`prefix Authorization: \"Bearer ` +
|
|
REDACTED_COMMAND_TEXT_VALUE +
|
|
String.raw`\" suffix`,
|
|
);
|
|
// This is exactly what the caller's chain feeds back in, so it must not
|
|
// move again.
|
|
const settled =
|
|
String.raw`prefix Authorization: \"` +
|
|
REDACTED_COMMAND_TEXT_VALUE +
|
|
String.raw`\" suffix`;
|
|
expect(redactCommandText(settled)).toBe(settled);
|
|
});
|
|
|
|
it("redacts an escaped-quoted value passed to a nested shell", () => {
|
|
const input = String.raw`sh -c "curl -H X-API-Key:\"abc123\" https://example.test"`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).toBe(
|
|
String.raw`sh -c "curl -H X-API-Key:\"` +
|
|
REDACTED_COMMAND_TEXT_VALUE +
|
|
String.raw`\" https://example.test"`,
|
|
);
|
|
});
|
|
|
|
it("redacts a truncated escaped-quoted value", () => {
|
|
const input = String.raw`X-API-Key:\"abc`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
String.raw`X-API-Key:\"` + REDACTED_COMMAND_TEXT_VALUE,
|
|
);
|
|
});
|
|
|
|
it("redacts a header secret inside a serialized command string", () => {
|
|
const input = String.raw`{"command":"curl -H \"X-API-Key: abc\" https://example.test"}`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
String.raw`{"command":"curl -H \"X-API-Key: ` +
|
|
REDACTED_COMMAND_TEXT_VALUE +
|
|
String.raw`\" https://example.test"}`,
|
|
);
|
|
});
|
|
|
|
it("is idempotent over a header secret", () => {
|
|
const input = 'curl -H "X-API-Key: abc" -H "Authorization: Bearer def"';
|
|
const once = redactCommandText(input);
|
|
expect(redactCommandText(once)).toBe(once);
|
|
expect(redactDiagnosticText(once)).toBe(once);
|
|
});
|
|
|
|
it("redacts an entire quoted digest credential, not just its first parameter", () => {
|
|
const input =
|
|
`curl -H 'Authorization: Digest username="alice", realm="r", nonce="n", uri="/x", response="deadbeef"' https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("alice");
|
|
expect(output).not.toContain("deadbeef");
|
|
expect(output).not.toContain("nonce");
|
|
expect(output).toBe(
|
|
`curl -H 'Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts an unquoted digest credential and stops at the next field", () => {
|
|
// A log line carries the header without shell quoting. The parameter list
|
|
// ends at the last comma-joined `key=value`, so the trailing status field
|
|
// survives.
|
|
const input =
|
|
'Authorization: Digest username="alice", nonce="n", response="deadbeef" status=401';
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("alice");
|
|
expect(output).not.toContain("deadbeef");
|
|
expect(output).toBe(
|
|
`Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
|
|
);
|
|
});
|
|
|
|
it("redacts an entire quoted sigv4 credential, not just the scheme name", () => {
|
|
const input =
|
|
'curl -H "Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260903/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123"';
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("AKIAEXAMPLE");
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).toBe(
|
|
`curl -H "Authorization: AWS4-HMAC-SHA256 ${REDACTED_COMMAND_TEXT_VALUE}"`,
|
|
);
|
|
});
|
|
|
|
it("redacts an unquoted sigv4 credential and stops at the next word", () => {
|
|
const input =
|
|
"Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260903/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123 retry";
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("AKIAEXAMPLE");
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).toBe(
|
|
`Authorization: AWS4-HMAC-SHA256 ${REDACTED_COMMAND_TEXT_VALUE} retry`,
|
|
);
|
|
});
|
|
|
|
it("keeps an already redacted unquoted header bounded", () => {
|
|
// The server redaction feeds this shape in after its own rules. The trailing
|
|
// word must survive.
|
|
const input = `prefix Authorization: ${REDACTED_COMMAND_TEXT_VALUE} suffix`;
|
|
expect(redactCommandText(input)).toBe(input);
|
|
});
|
|
|
|
it("keeps hint words that are not header names untouched", () => {
|
|
expect(redactCommandText("GET /v1/tokens:list")).toBe("GET /v1/tokens:list");
|
|
expect(redactCommandText("auth: failed")).toBe("auth: failed");
|
|
});
|
|
|
|
it("keeps a www-authenticate challenge untouched", () => {
|
|
// The challenge parameters are diagnostics, not credentials.
|
|
const input =
|
|
'WWW-Authenticate: Bearer realm="paperclip", error="invalid_token"';
|
|
expect(redactCommandText(input)).toBe(input);
|
|
});
|
|
|
|
it("keeps an empty quoted header argument untouched", () => {
|
|
// A quoted value must open with a non-blank character, so there is nothing
|
|
// to hide here and the argument stays byte for byte.
|
|
const input = 'curl -H "X-API-Key: " -H "X-Auth-Token:" https://example.test';
|
|
expect(redactCommandText(input)).toBe(input);
|
|
});
|
|
|
|
it("redacts an escaped-quoted value that follows a scheme word", () => {
|
|
const input = String.raw`Authorization: Basic \"abc\"defg retry`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
String.raw`Authorization: Basic \"` + REDACTED_COMMAND_TEXT_VALUE + String.raw`\" retry`,
|
|
);
|
|
expect(redactCommandText(output)).toBe(output);
|
|
});
|
|
|
|
it("reads an even backslash run before a quote as a bare quote", () => {
|
|
// `\\"` is an escaped backslash followed by a real quote, not an escaped
|
|
// quote, so the escaped branches decline it and the value still redacts.
|
|
const input = String.raw`foo\\"X-API-Key: abc" bar`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
String.raw`foo\\"X-API-Key: ` + REDACTED_COMMAND_TEXT_VALUE + '" bar',
|
|
);
|
|
});
|
|
|
|
it("consumes a suffix segment adjacent to a serialized quoted header argument", () => {
|
|
// The suffix is part of the same shell word as the header, so it is part
|
|
// of the credential at every serialization depth.
|
|
let text = 'curl -H "X-API-Key: SECRET"TAILMARK;echo safe';
|
|
for (let depth = 1; depth <= 3; depth += 1) {
|
|
text = JSON.stringify(text);
|
|
const output = redactCommandText(text);
|
|
expect(output).not.toContain("SECRET");
|
|
expect(output).not.toContain("TAILMARK");
|
|
expect(output).toContain(";echo safe");
|
|
expect(() => JSON.parse(output)).not.toThrow();
|
|
expect(redactCommandText(output)).toBe(output);
|
|
}
|
|
});
|
|
|
|
it("keeps a serializer's closing delimiter when the argument is truncated", () => {
|
|
// A run log can cut a serialized command inside the header argument. The
|
|
// truncated value stops before the enclosing string's own quote, even
|
|
// when the cut lands after a backslash, so the string stays well formed.
|
|
const cuts = [
|
|
'curl -H "X-API-Key: SECRET',
|
|
'curl -H X-API-Key:"SECRET',
|
|
'curl -H "X-API-Key: SECRET\\',
|
|
'curl -H "X-API-Key: SECRET\\\\',
|
|
'curl -H X-API-Key:"SECRET\\',
|
|
];
|
|
for (const cut of cuts) {
|
|
for (const text of [JSON.stringify(cut), JSON.stringify(JSON.stringify(cut))]) {
|
|
const output = redactCommandText(text);
|
|
expect(output).not.toContain("SECRET");
|
|
expect(() => JSON.parse(output)).not.toThrow();
|
|
expect(redactCommandText(output)).toBe(output);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("redacts a bare apikey header value", () => {
|
|
// Supabase sends the key under an unhyphenated `apikey` header.
|
|
expect(redactCommandText("apikey: abc")).toBe(
|
|
`apikey: ${REDACTED_COMMAND_TEXT_VALUE}`,
|
|
);
|
|
});
|
|
|
|
it("redacts a proxy-authorization header value", () => {
|
|
const input = 'curl -H "Proxy-Authorization: Basic dXNlcjpwdw=="';
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("dXNlcjpwdw==");
|
|
expect(output).toBe(
|
|
`curl -H "Proxy-Authorization: Basic ${REDACTED_COMMAND_TEXT_VALUE}"`,
|
|
);
|
|
});
|
|
|
|
it("is idempotent over a multi-part credential", () => {
|
|
const input =
|
|
`curl -H 'Authorization: Digest username="alice", response="deadbeef"' https://example.test`;
|
|
const once = redactCommandText(input);
|
|
expect(redactCommandText(once)).toBe(once);
|
|
expect(redactDiagnosticText(once)).toBe(once);
|
|
});
|
|
|
|
it("redacts past an escaped quote inside a double-quoted header value", () => {
|
|
// The shell escape does not end the argument, so the value runs on past it.
|
|
const input = String.raw`curl -H "X-API-Key: abc\"def" https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("def");
|
|
expect(output).toBe(
|
|
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts across a backslash-newline continuation inside a double-quoted value", () => {
|
|
// A shell line continuation inside double quotes is part of the argument.
|
|
const input = 'curl -H "X-API-Key: abc\\\ndef" https://example.test';
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("def");
|
|
expect(output).toBe(
|
|
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
const crlf = 'curl -H "X-API-Key: abc\\\r\ndef" https://example.test';
|
|
expect(redactCommandText(crlf)).toBe(
|
|
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts a backslash inside a single-quoted header value", () => {
|
|
// A shell single quote has no escapes, so the backslash is part of the value.
|
|
const input = String.raw`curl -H 'X-API-Key: abc\def' https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
`curl -H 'X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts a double-quoted value that is itself an escaped quoted string", () => {
|
|
const input = String.raw`curl -H "Authorization: \"Bearer nested\"" https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("nested");
|
|
expect(output).toBe(
|
|
`curl -H "Authorization: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts an entire serialized digest credential", () => {
|
|
// The header argument is escaped inside a JSON string, so its quotes read as
|
|
// `\"` and its own embedded quotes as `\\\"`. The value must still run to the
|
|
// end of the argument.
|
|
const input = String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("alice");
|
|
expect(output).not.toContain("deadbeef");
|
|
expect(output).toBe(
|
|
String.raw`{"command":"curl -H \"Authorization: Digest ` +
|
|
REDACTED_COMMAND_TEXT_VALUE +
|
|
String.raw`\" https://x"}`,
|
|
);
|
|
});
|
|
|
|
it("redacts past an embedded escaped quote in a serialized header value", () => {
|
|
const input = String.raw`{"command":"curl -H \"X-API-Key: abc\\\"def\" https://x"}`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("def");
|
|
expect(output).toBe(
|
|
String.raw`{"command":"curl -H \"X-API-Key: ` +
|
|
REDACTED_COMMAND_TEXT_VALUE +
|
|
String.raw`\" https://x"}`,
|
|
);
|
|
});
|
|
|
|
it("is idempotent over a serialized multi-part credential", () => {
|
|
const input = String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`;
|
|
const once = redactCommandText(input);
|
|
expect(redactCommandText(once)).toBe(once);
|
|
expect(redactDiagnosticText(once)).toBe(once);
|
|
});
|
|
|
|
it("redacts a value whose quotes cover only the value", () => {
|
|
// `X-API-Key:"abc123"` is one shell word, so the quoted part is the value.
|
|
// The value keeps its own delimiters, which makes a second pass a no-op.
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
const input = `curl -H X-API-Key:"abc123" https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).toBe(`curl -H X-API-Key:"${R}" https://example.test`);
|
|
expect(redactCommandText(`curl -H X-API-Key:'abc' https://x`)).toBe(
|
|
`curl -H X-API-Key:'${R}' https://x`,
|
|
);
|
|
expect(redactCommandText(`curl -H X-API-Key:$'abc' https://x`)).toBe(
|
|
`curl -H X-API-Key:$'${R}' https://x`,
|
|
);
|
|
});
|
|
|
|
it("is stable over a value-only quoted header with a following command", () => {
|
|
// The preserved delimiters keep the second pass from reading the
|
|
// placeholder as a bare token and eating the separator.
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
const once = redactCommandText(`curl -H X-API-Key:"abc"123;echo done`);
|
|
expect(once).toBe(`curl -H X-API-Key:"${R}";echo done`);
|
|
expect(redactCommandText(once)).toBe(once);
|
|
expect(redactDiagnosticText(once)).toBe(once);
|
|
});
|
|
|
|
it("redacts a segment adjacent to a quoted header argument", () => {
|
|
// The trailing `123` joins the same shell word, so it is part of the value.
|
|
const input = `curl -H "X-API-Key: abc"123 https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("123");
|
|
expect(output).toBe(
|
|
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts across an unquoted escape pair", () => {
|
|
// `\ ` escapes the space, so the word continues past it.
|
|
const input = String.raw`curl -H X-API-Key:abc\ 123 https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("123");
|
|
expect(output).toBe(
|
|
`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts an ANSI-C quoted header argument", () => {
|
|
const input = String.raw`curl -H $'X-API-Key: abc\'123' https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).not.toContain("123");
|
|
expect(output).toBe(
|
|
`curl -H $'X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("keeps an unterminated quote out of the value", () => {
|
|
// A lone quote does not open a segment, so the word ends before it.
|
|
const input = String.raw`X-API-Key: abc"tail`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toBe(
|
|
`X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}"tail`,
|
|
);
|
|
});
|
|
|
|
it("redacts a concealed credential parameter list", () => {
|
|
// RFC 9729 writes the proof and key identifier as authentication parameters.
|
|
const input =
|
|
"Authorization: Concealed k=YmFzZW1lbnQ, a=PUBLICKEY, s=2055, v=VERIFY, p=PROOFSECRET status=401";
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("PROOFSECRET");
|
|
expect(output).not.toContain("YmFzZW1lbnQ");
|
|
expect(output).toBe(
|
|
`Authorization: Concealed ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
|
|
);
|
|
});
|
|
|
|
it("redacts a quoted concealed credential to the closing quote", () => {
|
|
const input = `curl -H "Authorization: Concealed k=YmFzZW1lbnQ, p=PROOFSECRET" https://x`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("PROOFSECRET");
|
|
expect(output).toBe(
|
|
`curl -H "Authorization: Concealed ${REDACTED_COMMAND_TEXT_VALUE}" https://x`,
|
|
);
|
|
});
|
|
|
|
it("redacts a digest credential whose parameter carries a quoted-pair", () => {
|
|
// HTTP quoted-string syntax allows an escaped character inside a parameter.
|
|
const input = String.raw`Authorization: Digest username="al\"ice", nonce="n", response="abc123" status=401`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).not.toContain("al");
|
|
expect(output).toBe(
|
|
`Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
|
|
);
|
|
expect(redactDiagnosticText(input)).toBe(
|
|
`Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
|
|
);
|
|
});
|
|
|
|
it("redacts a quoted header argument whose closing quote never arrives", () => {
|
|
// A truncated run log ends the line mid-argument. The value runs to the end
|
|
// of the line instead of to a closing quote.
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
expect(redactCommandText(`curl -H "X-API-Key: abc`)).toBe(
|
|
`curl -H "X-API-Key: ${R}`,
|
|
);
|
|
expect(redactCommandText(`curl -H 'X-API-Key: abc`)).toBe(
|
|
`curl -H 'X-API-Key: ${R}`,
|
|
);
|
|
expect(redactCommandText(`curl -H $'X-API-Key: abc`)).toBe(
|
|
`curl -H $'X-API-Key: ${R}`,
|
|
);
|
|
// A lone trailing backslash is part of the truncated value.
|
|
expect(redactCommandText('curl -H "X-API-Key: abc\\')).toBe(
|
|
`curl -H "X-API-Key: ${R}`,
|
|
);
|
|
// The next line is a separate line, so it stays as it is.
|
|
expect(redactCommandText('curl -H "X-API-Key: abc\nsecond line')).toBe(
|
|
`curl -H "X-API-Key: ${R}\nsecond line`,
|
|
);
|
|
});
|
|
|
|
it("redacts a value that opens with an escape pair", () => {
|
|
// `X-API-Key:\ abc123` is one shell word whose first value byte is escaped.
|
|
const input = String.raw`curl -H X-API-Key:\ abc123 https://example.test`;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).toBe(
|
|
`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} https://example.test`,
|
|
);
|
|
});
|
|
|
|
it("redacts a raw header value that contains a shell metacharacter", () => {
|
|
// A raw HTTP diagnostic carries an opaque credential, so `;` inside the
|
|
// value is a credential byte and the whole token goes.
|
|
const input = "tool: X-API-Key: abc;def status=401";
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("def");
|
|
expect(output).toBe(
|
|
`tool: X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
|
|
);
|
|
expect(redactDiagnosticText(input)).toBe(
|
|
`tool: X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
|
|
);
|
|
});
|
|
|
|
it("takes the whole raw token when a command shares that shape", () => {
|
|
// The same bytes read as a shell command would end the word at `;`. The
|
|
// raw-token reading wins, which over-redacts here and never under-redacts.
|
|
expect(redactCommandText("X-API-Key:abc;echo done")).toBe(
|
|
`X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} done`,
|
|
);
|
|
});
|
|
|
|
it("stops a continuation segment at a shell metacharacter", () => {
|
|
// After a closing quote the word really does end at `;`, so the next
|
|
// command survives.
|
|
expect(redactCommandText(`curl -H "X-API-Key: abc"123;echo done`)).toBe(
|
|
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}";echo done`,
|
|
);
|
|
});
|
|
|
|
it("keeps a shell separator after a quoted header argument", () => {
|
|
// A metacharacter ends the shell word, so the pipeline and the next command
|
|
// survive the redaction.
|
|
expect(redactCommandText(`curl -H 'x-api-key: abc'|head`)).toBe(
|
|
`curl -H 'x-api-key: ${REDACTED_COMMAND_TEXT_VALUE}'|head`,
|
|
);
|
|
expect(
|
|
redactCommandText(`sh -c 'curl -H "X-API-Key: abc"; echo done'`),
|
|
).toBe(
|
|
`sh -c 'curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}"; echo done'`,
|
|
);
|
|
expect(redactCommandText(`(curl -H "X-API-Key: abc")`)).toBe(
|
|
`(curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}")`,
|
|
);
|
|
});
|
|
|
|
it("is stable and keeps serialized commands parseable", () => {
|
|
const shellWordForms = [
|
|
`curl -H X-API-Key:"abc123" https://example.test`,
|
|
`curl -H "X-API-Key: abc"123 https://example.test`,
|
|
String.raw`curl -H X-API-Key:abc\ 123 https://example.test`,
|
|
String.raw`curl -H $'X-API-Key: abc\'123' https://example.test`,
|
|
];
|
|
const pinnedForms = [
|
|
`curl -H X-API-Key:"abc"123;echo done`,
|
|
`curl -H X-API-Key:'abc' https://x`,
|
|
`curl -H X-API-Key:$'abc' https://x`,
|
|
String.raw`sh -c "curl -H X-API-Key:\"abc123\" https://example.test"`,
|
|
String.raw`X-API-Key:\"abc`,
|
|
`curl -H "Authorization: Bearer abc" https://example.test`,
|
|
`curl -H "X-API-Key: " -H "X-Auth-Token:" https://example.test`,
|
|
`prefix Authorization: ${REDACTED_COMMAND_TEXT_VALUE} suffix`,
|
|
String.raw`prefix Authorization: \"Bearer nested\" suffix`,
|
|
'Authorization: Digest username="alice", response="deadbeef" status=401',
|
|
"Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE, Signature=abc123 retry",
|
|
String.raw`X-API-Key: abc"tail`,
|
|
];
|
|
for (const input of [...shellWordForms, ...pinnedForms]) {
|
|
const once = redactCommandText(input);
|
|
expect(redactCommandText(once)).toBe(once);
|
|
expect(redactDiagnosticText(once)).toBe(once);
|
|
}
|
|
// The escaped-quoted branch keeps a serialized command valid JSON.
|
|
const serializedForms = [
|
|
String.raw`{"command":"curl -H \"X-API-Key: abc\" https://example.test"}`,
|
|
String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`,
|
|
];
|
|
for (const input of serializedForms) {
|
|
const once = redactCommandText(input);
|
|
expect(() => JSON.parse(once)).not.toThrow();
|
|
expect(redactCommandText(once)).toBe(once);
|
|
}
|
|
});
|
|
|
|
it("redacts a serializer-nested value-only escaped-quoted credential", () => {
|
|
// `JSON.stringify` writes the inner shell's `\"` delimiter as `\\\"`. The
|
|
// value is delimited by the whole backslash run, so the extra layer changes
|
|
// nothing about which bytes belong to the credential.
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
const input = JSON.stringify({
|
|
command: String.raw`sh -c "curl -H Authorization:\"Digest username=alice, response=SECRETTAIL\" https://example.test"`,
|
|
status: "safe",
|
|
});
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("alice");
|
|
expect(output).not.toContain("SECRETTAIL");
|
|
expect(output).toContain(
|
|
String.raw`Authorization:\\\"Digest ` + R + String.raw`\\\"`,
|
|
);
|
|
const parsed = JSON.parse(output) as { command: string; status: string };
|
|
expect(parsed.status).toBe("safe");
|
|
expect(parsed.command).toBe(
|
|
String.raw`sh -c "curl -H Authorization:\"Digest ` +
|
|
R +
|
|
String.raw`\" https://example.test"`,
|
|
);
|
|
expect(redactCommandText(output)).toBe(output);
|
|
expect(redactDiagnosticText(output)).toBe(output);
|
|
});
|
|
|
|
it("redacts a serialized escaped-quoted argument one layer deeper", () => {
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
const input = JSON.stringify({
|
|
command: String.raw`curl -H \"X-API-Key: abc\" https://example.test`,
|
|
status: "safe",
|
|
});
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).toContain(String.raw`\\\"X-API-Key: ` + R + String.raw`\\\"`);
|
|
const parsed = JSON.parse(output) as { command: string; status: string };
|
|
expect(parsed.status).toBe("safe");
|
|
expect(parsed.command).toBe(
|
|
String.raw`curl -H \"X-API-Key: ` + R + String.raw`\" https://example.test`,
|
|
);
|
|
expect(redactCommandText(output)).toBe(output);
|
|
expect(redactDiagnosticText(output)).toBe(output);
|
|
});
|
|
|
|
it("redacts an escaped-quoted argument three serialization layers deep", () => {
|
|
// Nothing in the rule counts layers, so a run of seven backslashes reads
|
|
// exactly like a run of one.
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
const input = JSON.stringify(
|
|
JSON.stringify({
|
|
command: String.raw`curl -H \"X-API-Key: abc\" https://example.test`,
|
|
status: "safe",
|
|
}),
|
|
);
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc");
|
|
const parsed = JSON.parse(JSON.parse(output) as string) as {
|
|
command: string;
|
|
status: string;
|
|
};
|
|
expect(parsed.status).toBe("safe");
|
|
expect(parsed.command).toBe(
|
|
String.raw`curl -H \"X-API-Key: ` + R + String.raw`\" https://example.test`,
|
|
);
|
|
expect(redactCommandText(output)).toBe(output);
|
|
expect(redactDiagnosticText(output)).toBe(output);
|
|
});
|
|
|
|
it("keeps a dangling trailing backslash inside an escaped-quoted value", () => {
|
|
// A truncated log can end mid-escape. The backslash does not begin the
|
|
// closer, so it belongs to the value.
|
|
const R = REDACTED_COMMAND_TEXT_VALUE;
|
|
const expected = String.raw`X-API-Key:\"` + R;
|
|
for (const tail of ["\\", "\\\\"]) {
|
|
const input = String.raw`X-API-Key:\"abc123` + tail;
|
|
const output = redactCommandText(input);
|
|
expect(output).not.toContain("abc123");
|
|
expect(output).toBe(expected);
|
|
expect(redactCommandText(output)).toBe(output);
|
|
expect(redactDiagnosticText(output)).toBe(output);
|
|
}
|
|
});
|
|
|
|
it("keeps an empty escaped-quoted header argument untouched", () => {
|
|
// The value must open with a non-blank character, so there is nothing to
|
|
// hide here and the argument stays byte for byte.
|
|
const input = String.raw`\"X-API-Key: \" https://example.test`;
|
|
expect(redactCommandText(input)).toBe(input);
|
|
expect(redactDiagnosticText(input)).toBe(input);
|
|
});
|
|
|
|
it("redacts a header secret inside a diagnostic and keeps a JSON secret field working", () => {
|
|
const input = 'command failed: curl -H "X-API-Key: abc" -> {"token":"opaque-value"}';
|
|
const output = redactDiagnosticText(input);
|
|
expect(output).not.toContain("abc");
|
|
expect(output).not.toContain("opaque-value");
|
|
expect(output).toContain("command failed:");
|
|
});
|
|
});
|