fix(adapter-utils): read an even backslash run before a quote as a bare quote

An escape-pair segment is either an even backslash run followed by a
character other than a quote, the form a serialized escaped space takes,
or an odd run followed by any character, a true shell escape. An even run
before a quote is an escaped backslash and the quote opens a further
segment of the same word, which the value consumes. A truncated
escaped-quoted value runs to the end of its line again: a bare quote there
may be a further segment of the word, so it is redacted rather than kept
as an enclosing delimiter.

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

View File

@ -321,10 +321,11 @@ describe("redactCommandText header secrets", () => {
}
});
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.
it("redacts a truncated serialized argument to the end of its line", () => {
// A run log can cut a serialized command inside the header argument. With
// no closer on the line, the value runs to the end of the line: a bare
// quote there may be a further segment of the same shell word, so the rule
// redacts it rather than keeping it as the enclosing string's delimiter.
const cuts = [
'curl -H "X-API-Key: SECRET',
'curl -H X-API-Key:"SECRET',
@ -336,12 +337,32 @@ describe("redactCommandText header secrets", () => {
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("keeps an even backslash run before a quote out of the escape pair", () => {
// Two backslashes are an escaped backslash; the quote after them opens a
// further segment of the same word, which is consumed with the value.
const input = 'curl -H X-API-Key:SECRET\\\\"TAILMARK"MORE ;echo safe';
const output = redactCommandText(input);
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(output).not.toContain("MORE");
expect(output).toBe(`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} ;echo safe`);
expect(redactCommandText(output)).toBe(output);
});
it("redacts a truncated quoted tail after an escaped-quoted value", () => {
const input = 'curl -H X-API-Key:\\"SECRET"TAILMARK';
const output = redactCommandText(input);
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(output).toBe(`curl -H X-API-Key:\\"${REDACTED_COMMAND_TEXT_VALUE}`);
expect(redactCommandText(output)).toBe(output);
});
it("consumes an escaped-space continuation at every serialization depth", () => {
// A shell escape pair doubles its backslash with each serialization
// layer; the continuation reads the whole run as one pair.

View File

@ -135,13 +135,13 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [
String.raw`'[^'\r\n]*'`,
String.raw`\$'(?:\\.|[^'\\\r\n])*'`,
] as const;
const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\+[^\r\n]`;
const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^\r\n])`;
// An opening escape pair carries the first byte of an unquoted value, as in
// `X-API-Key:\ abc`. The backslash run may be longer inside a serialized
// command, where each layer doubles it. A run followed by a quote is excluded,
// so a `\"` opener at any depth falls to the escaped branches, which precede
// the unquoted one in the alternation.
const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`\\+[^"\r\n]`;
const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^"\r\n])`;
// The first segment of an unquoted value is a raw token, bounded only by
// whitespace, a quote, a backtick, or a backslash. A raw HTTP diagnostic
// carries an opaque credential the same way, so a `;`, `|`, or `&` inside it
@ -185,19 +185,14 @@ const commandSecretHeaderEscapedOpener = (run: string) =>
String.raw`(?<!\\)(?<${run}>(?:\\\\)*\\)"`;
const commandSecretHeaderEscapedBody = (run: string) =>
String.raw`(?:(?!(?<!\\)\k<${run}>")[^\s\r\n])(?:(?!(?<!\\)\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])*`;
// The closer itself is unescaped, so backtracking never reads an escaped
// backslash as the closer. A truncated argument has no closer on its line and
// the value then runs to the end of the line: a bare quote there may be a
// further segment of the same shell word, so it is redacted rather than kept.
const commandSecretHeaderEscapedValue = (run: string, close: string) =>
`(?:${commandSecretHeaderEscapedBody(run)}(?<!\\\\)(?<${close}>\\k<${run}>")` +
`|${commandSecretHeaderEscapedTruncatedBody(run)}` +
String.raw`(?=[\r\n]|$|${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN}))`;
`|${commandSecretHeaderEscapedBody(run)}` +
String.raw`(?=[\r\n]|$))`;
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}` +