fix(adapter-utils): consume a serialized header's adjacent segment and keep a truncated string well formed

A suffix segment adjacent to a serialized quoted header argument belongs to
the same shell word, so the serialized branch now takes the shell
continuation after its closer. A truncated serialized argument has no
closer on its line; its value then stops before a bare quote, one preceded
by an even run of backslashes, which can only be the enclosing serializer's
delimiter, so that string stays parseable. The closer of an escaped-quoted
value must itself be unescaped, so backtracking cannot read an escaped
backslash as the closer.

Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
Michel Tomas 2026-09-05 23:18:10 +02:00
parent c152eed16a
commit f675a08bbd
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 51 additions and 4 deletions

View File

@ -306,6 +306,42 @@ describe("redactCommandText header secrets", () => {
);
});
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(

View File

@ -185,16 +185,27 @@ const commandSecretHeaderEscapedOpener = (run: string) =>
String.raw`(?<!\\)(?<${run}>(?:\\\\)*\\)"`;
const commandSecretHeaderEscapedBody = (run: string) =>
String.raw`(?:(?!(?<!\\)\k<${run}>")[^\s\r\n])(?:(?!(?<!\\)\k<${run}>")[^\r\n])*`;
const commandSecretHeaderEscapedCloser = (run: string, close: string) =>
String.raw`(?:(?<${close}>\k<${run}>")|(?=[\r\n]|$))`;
// A truncated argument has no closer on its line. Its body then also stops
// before a bare quote, one preceded by an even run of backslashes, which can
// only be an enclosing serializer's delimiter; that delimiter survives and the
// enclosing string stays well formed. The closer itself is unescaped, so
// backtracking never reads an escaped backslash as the closer.
const COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN = String.raw`(?<=(?<!\\)(?:\\\\)*)"`;
const commandSecretHeaderEscapedTruncatedBody = (run: string) =>
String.raw`(?:(?!(?<!\\)\k<${run}>")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\s\r\n])` +
String.raw`(?:(?!(?<!\\)\k<${run}>")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\r\n])*`;
const commandSecretHeaderEscapedValue = (run: string, close: string) =>
`(?:${commandSecretHeaderEscapedBody(run)}(?<!\\\\)(?<${close}>\\k<${run}>")` +
`|${commandSecretHeaderEscapedTruncatedBody(run)}` +
String.raw`(?=[\r\n]|$|${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN}))`;
const COMMAND_SECRET_HEADER_RE = new RegExp(
String.raw`(?<!\\)(?<dqPrefix>"${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s"\\])(?:\\\r?\n|\\.|[^"\\\r\n])*\\?(?:(?<dqClose>")|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<sqPrefix>'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s'][^'\r\n]*(?:(?<sqClose>')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<ansiPrefix>\$'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s'\\])(?:\\.|[^'\\\r\n])*\\?(?:(?<ansiClose>')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
`|(?<serPrefix>${commandSecretHeaderEscapedOpener("serRun")}${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` +
`${commandSecretHeaderEscapedBody("serRun")}${commandSecretHeaderEscapedCloser("serRun", "serClose")}` +
`${commandSecretHeaderEscapedValue("serRun", "serClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<![A-Za-z0-9_-])(?<evPrefix>\b${COMMAND_SECRET_HEADER_NAME_PATTERN}${COMMAND_SECRET_HEADER_COLON_PATTERN}${COMMAND_SECRET_HEADER_SCHEME_PATTERN}${commandSecretHeaderEscapedOpener("evRun")}${COMMAND_SECRET_HEADER_SCHEME_PATTERN})` +
`${commandSecretHeaderEscapedBody("evRun")}${commandSecretHeaderEscapedCloser("evRun", "evClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
`${commandSecretHeaderEscapedValue("evRun", "evClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<![A-Za-z0-9_-])(?<!(?<!\\)["'])(?<uqPrefix>\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}`,
"gi",
);