Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/core/src/__tests__/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,84 @@ describe('redactSecrets', () => {
);
});

test('masks sensitive assignments nested in a harmless assignment value', () => {
const cases: Array<[string, string]> = [
[
'Config excerpt: password=FAKE-not-a-real-password-000',
'Config excerpt: password=[redacted]',
],
['note: token=FAKE-token-value', 'note: token=[redacted]'],
['summary=api_key: FAKE-api-key-value', 'summary=api_key: [redacted]'],
['user=alice;password=FAKE-password', 'user=alice;password=[redacted]'],
['excerpt: "client_secret=FAKE-secret" done', 'excerpt: "client_secret=[redacted]" done'],
['a=b:c=password=FAKE-password', 'a=b:c=password=[redacted]'],
['flags: --password=FAKE-password', 'flags: --password=[redacted]'],
['note: password=token=FAKE-token', 'note: password=[redacted]'],
];
for (const [input, expected] of cases) {
assert.equal(redactSecrets(input), expected);
assert.equal(redactSecrets(expected), expected);
}
});

test('keeps harmless assignment values without a nested sensitive assignment', () => {
for (const text of [
'excerpt: plain text',
'ratio=1:2',
'time=12:30:00 mode=a:b=c',
'url=https://example.com/docs:intro?page=2',
'note: cache-key=cached-result issue_key=ISSUE-1359',
]) {
assert.equal(redactSecrets(text), text);
}
});

test('leaves a sensitive key without a value unchanged', () => {
for (const text of [
'password=',
'note: token: ',
'excerpt: api_key="',
'password=\nnext line',
]) {
assert.equal(redactSecrets(text), text);
}
});

test('masks the assignment that a sensitive key without a value takes as its value', () => {
const cases: Array<[string, string]> = [
[
'env: API_TOKEN= DB_PASSWORD="FAKE-not-a-real-password-000"',
'env: API_TOKEN= [redacted]"[redacted]"',
],
[
"Usage: --token= --password='FAKE-not-a-real-password-000'",
"Usage: --token= [redacted]'[redacted]'",
],
['note: password: token: FAKE-not-a-real-token-000', 'note: password: [redacted] [redacted]'],
];
for (const [input, expected] of cases) {
assert.equal(redactSecrets(input), expected);
assert.equal(redactSecrets(expected), expected);
}
});

test('scans long harmless assignment values in bounded time', () => {
const started = Date.now();
for (const text of [
`note: ${'a:'.repeat(100_000)}`,
`data=${'a-'.repeat(100_000)}`,
`blob=${'Z'.repeat(200_000)}==`,
'a-'.repeat(100_000),
]) {
assert.equal(redactSecrets(text), text);
}
const elapsed = Date.now() - started;
// Rescanning the rest of the value per nested key or per hyphen, retrying a
// key at every hyphen of a bare run, or splitting a long uppercase key with
// backtracking takes seconds to tens of seconds.
assert.ok(elapsed < 5_000, `scanned in ${elapsed}ms, which must not rescan the value`);
});

test('preserves own __proto__ data properties while redacting serialized JSON', () => {
const redacted = JSON.parse(
redactSecrets(
Expand Down
56 changes: 50 additions & 6 deletions packages/core/src/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,21 @@ const AWS_SECRET_ACCESS_KEY_FLAG_SOURCE = posixContinuedTokenSource('--secret-ac
const AWS_SECRET_ACCESS_KEY_ENV_SOURCE = posixContinuedTokenSource('AWS_SECRET_ACCESS_KEY');

const QUOTED_SECRET_KEY_VALUE_PATTERN = /((?:"([^"\\]+)"\s*:\s*"))(?:\\.|[^"\\])*/g;
const ASSIGNED_SECRET_KEY_VALUE_PATTERN =
/\b(([A-Za-z][A-Za-z0-9_-]*)(?:[ \t]|\\\r?\n)*[:=](?:[ \t]|\\\r?\n)*['"]?)(?:\\\r?\n|[^\s"'&<>])+/g;
// The prefix lookahead and the value pattern share this source, so the sticky
// value pattern always matches where a prefix ends.
const ASSIGNED_SECRET_VALUE_CHARACTER_SOURCE = `${POSIX_LINE_CONTINUATION_SOURCE}|[^\\s"'&<>]`;
// The prefix matches a whole key-character run once, and the key starts at the
// run's first word-initial letter. Trying each such letter as its own start
// would rescan a long hyphenated run (base64url) once per hyphen.
const ASSIGNED_SECRET_PREFIX_PATTERN = new RegExp(
`(?<![A-Za-z0-9_-])([A-Za-z0-9_-]+)${OPTIONAL_SHELL_SEPARATOR_SOURCE}[:=]${OPTIONAL_SHELL_SEPARATOR_SOURCE}['"]?(?=${ASSIGNED_SECRET_VALUE_CHARACTER_SOURCE})`,
'g',
);
const ASSIGNED_SECRET_KEY_PATTERN = /(?<![A-Za-z0-9_])[A-Za-z][A-Za-z0-9_-]*/;
const ASSIGNED_SECRET_VALUE_PATTERN = new RegExp(
`(?:${ASSIGNED_SECRET_VALUE_CHARACTER_SOURCE})+`,
'y',
);
const AUTHORIZATION_HEADER_PATTERN =
/(^|[^A-Za-z0-9_])(['"]?(?:proxy[-_]?authorization|authorization)['"]?\s*:\s*['"]?(?:bearer|basic|token)\s+)[^\s"'<>]+/gim;
const AWS_CLI_SPACE_SECRET_PATTERN = new RegExp(
Expand Down Expand Up @@ -88,9 +101,7 @@ function redactTextSecrets(value: string): string {
AWS_SECRET_ASSIGNMENT_PATTERN,
(_match, prefix: string) => `${prefix}[redacted]`,
);
next = next.replace(ASSIGNED_SECRET_KEY_VALUE_PATTERN, (match, prefix: string, key: string) =>
isAssignmentSensitiveKey(key) ? `${prefix}[redacted]` : match,
);
next = redactAssignedSecrets(next);
for (const pattern of SECRET_PATTERNS) {
// Each pattern's single capture group matches only the secret token, so the
// replacement is always the full redaction marker. Never echo any part of
Expand All @@ -101,6 +112,36 @@ function redactTextSecrets(value: string): string {
return next;
}

function redactAssignedSecrets(value: string): string {
let next = '';
let copied = 0;
ASSIGNED_SECRET_PREFIX_PATTERN.lastIndex = 0;
for (
let match = ASSIGNED_SECRET_PREFIX_PATTERN.exec(value);
match;
match = ASSIGNED_SECRET_PREFIX_PATTERN.exec(value)
) {
// The prefix stops where the value starts, so every value is still searched
// for a nested sensitive assignment (`excerpt: password=…`). This includes a
// redacted value: a key with an empty value takes the next `KEY=` as its
// value (`token= password="…"`), and that key's own value follows the quote.
const key = ASSIGNED_SECRET_KEY_PATTERN.exec(match[1] ?? '')?.[0] ?? '';
if (!isAssignmentSensitiveKey(key)) continue;
const valueStart = ASSIGNED_SECRET_PREFIX_PATTERN.lastIndex;
// A value that starts inside the last redacted value ends with it.
if (valueStart < copied) continue;
ASSIGNED_SECRET_VALUE_PATTERN.lastIndex = valueStart;
const valueMatch = ASSIGNED_SECRET_VALUE_PATTERN.exec(value);
// The prefix lookahead promises a value here. Should the two patterns ever
// disagree, skip: a failed sticky match resets lastIndex, and copying from
// there would echo the value after its marker.
if (!valueMatch) continue;
next += `${value.slice(copied, valueStart)}[redacted]`;
copied = valueStart + valueMatch[0].length;
}
return next + value.slice(copied);
}

function posixContinuedTokenSource(token: string): string {
return [...token]
.map((character) => escapeRegExpLiteral(character))
Expand Down Expand Up @@ -207,9 +248,12 @@ export function isSensitiveKey(key: string): boolean {
}

function sensitiveKeySegments(key: string): string[] {
// The second split marks one capital per step, which keeps a long uppercase
// run (zero-filled base64) linear; `([A-Z]+)([A-Z][a-z])` backtracks through
// the run from every capital.
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([A-Z])(?=[A-Z][a-z])/g, '$1 ')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
Expand Down