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
122 changes: 122 additions & 0 deletions packages/core/src/__tests__/display-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
redactReversibleStreamingSuffix,
redactSecrets,
redactStableStreamingSuffix,
} from '../display-redaction.js';

const USERINFO_CASES: Array<[string, string]> = [
[
'https://myuser:glpat-AbCdEf12345XyZ@gitlab.com/team/repo.git',
'https://<redacted>@gitlab.com/team/repo.git',
],
[
'https://alice:hunter2@internal.example.com/repo.git',
'https://<redacted>@internal.example.com/repo.git',
],
[
'https://alice:ATBBxyz123abc456@bitbucket.org/team/repo.git',
'https://<redacted>@bitbucket.org/team/repo.git',
],
[
'fatal: unable to access https://deploy:s3cretP@ss@git.corp.example/x.git/: 403',
'fatal: unable to access https://<redacted>@git.corp.example/x.git/: 403',
],
['https://user@host.example/team/repo.git', 'https://<redacted>@host.example/team/repo.git'],
[
'origin https://alice:hunter2@internal.example.com/repo.git (fetch)',
'origin https://<redacted>@internal.example.com/repo.git (fetch)',
],
[
'see https://alice:hunter2@internal.example.com/repo.git.',
'see https://<redacted>@internal.example.com/repo.git.',
],
[
'clone (https://alice:hunter2@internal.example.com/repo.git)',
'clone (https://<redacted>@internal.example.com/repo.git)',
],
];

describe('display redactSecrets', () => {
test('masks URL userinfo credentials without swallowing host or path', () => {
for (const [input, expected] of USERINFO_CASES) {
assert.equal(redactSecrets(input), expected);
}
assert.equal(
redactSecrets('https://api.example.com/v1?token=abc123'),
'https://api.example.com/v1?token=<redacted>',
);
assert.equal(
redactSecrets('https://ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@github.com/o/r.git'),
'https://<redacted>@github.com/o/r.git',
);
assert.equal(
redactSecrets('https://alice:hunter2@api.example.com/v1?token=abc123'),
'https://<redacted>@api.example.com/v1?token=<redacted>',
);
// Negatives: bare https://host must not swallow a later @ across spaces/newlines/quotes.
assert.equal(
redactSecrets('see https://example.com and mail bob@corp.com'),
'see https://example.com and mail bob@corp.com',
);
assert.equal(
redactSecrets('Fetching https://registry.example.com\nContact: support@example.com for help'),
'Fetching https://registry.example.com\nContact: support@example.com for help',
);
assert.equal(
redactSecrets('{"url":"https://example.com","contact":"me@corp.com"}'),
'{"url":"https://example.com","contact":"me@corp.com"}',
);
});
});

describe('display streaming suffix redactors', () => {
test('keeps a stable userinfo suffix compacted until the authority ends', () => {
const suffix = redactStableStreamingSuffix(
'fatal: unable to access https://deploy:s3cretP@ss@',
);
assert.ok(suffix);
assert.equal(suffix.text, 'fatal: unable to access https://<redacted>@');
assert.equal(suffix.settledPrefixText, 'fatal: unable to access ');
assert.equal(suffix.compactedSuffix, 'https://deploy:s3cretP@ss@');
assert.equal(suffix.terminator.test('/'), true);
assert.equal(suffix.terminator.test('?'), true);
assert.equal(redactSecrets(suffix.settledPrefixText + suffix.compactedSuffix), suffix.text);
});

test('does not treat a completed userinfo URL as a streaming suffix', () => {
for (const [input] of USERINFO_CASES) {
const suffix = redactStableStreamingSuffix(input);
assert.equal(suffix, undefined, input);
assert.equal(redactReversibleStreamingSuffix(input), undefined, input);
}
});

test('still shortens a reversible provider token that reaches end-of-input', () => {
const token = `ghp_${'A'.repeat(200)}`;
const reversible = redactReversibleStreamingSuffix(token);
assert.ok(reversible);
assert.equal(redactSecrets(reversible.compactedInput), redactSecrets(token));
assert.equal(reversible.compactedToken.length < token.length, true);
});
});
62 changes: 62 additions & 0 deletions packages/core/src/__tests__/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,68 @@ describe('redactSecrets', () => {
assert.equal(text.includes('secret-value'), false);
});

test('masks URL userinfo credentials without swallowing host or path', () => {
const cases: Array<[string, string]> = [
[
'https://myuser:glpat-AbCdEf12345XyZ@gitlab.com/team/repo.git',
'https://[redacted]@gitlab.com/team/repo.git',
],
[
'https://alice:hunter2@internal.example.com/repo.git',
'https://[redacted]@internal.example.com/repo.git',
],
[
'https://alice:ATBBxyz123abc456@bitbucket.org/team/repo.git',
'https://[redacted]@bitbucket.org/team/repo.git',
],
[
'fatal: unable to access https://deploy:s3cretP@ss@git.corp.example/x.git/: 403',
'fatal: unable to access https://[redacted]@git.corp.example/x.git/: 403',
],
['https://user@host.example/team/repo.git', 'https://[redacted]@host.example/team/repo.git'],
[
'origin https://alice:hunter2@internal.example.com/repo.git (fetch)',
'origin https://[redacted]@internal.example.com/repo.git (fetch)',
],
[
'see https://alice:hunter2@internal.example.com/repo.git.',
'see https://[redacted]@internal.example.com/repo.git.',
],
[
'clone (https://alice:hunter2@internal.example.com/repo.git)',
'clone (https://[redacted]@internal.example.com/repo.git)',
],
];
for (const [input, expected] of cases) {
assert.equal(redactSecrets(input), expected);
}
assert.equal(
redactSecrets('https://api.example.com/v1?token=abc123'),
'https://api.example.com/v1?token=[redacted]',
);
assert.equal(
redactSecrets('https://ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@github.com/o/r.git'),
'https://[redacted]@github.com/o/r.git',
);
assert.equal(
redactSecrets('https://alice:hunter2@api.example.com/v1?token=abc123'),
'https://[redacted]@api.example.com/v1?token=[redacted]',
);
// Negatives: bare https://host must not swallow a later @ across spaces/newlines/quotes.
assert.equal(
redactSecrets('see https://example.com and mail bob@corp.com'),
'see https://example.com and mail bob@corp.com',
);
assert.equal(
redactSecrets('Fetching https://registry.example.com\nContact: support@example.com for help'),
'Fetching https://registry.example.com\nContact: support@example.com for help',
);
assert.equal(
redactSecrets('{"url":"https://example.com","contact":"me@corp.com"}'),
'{"url":"https://example.com","contact":"me@corp.com"}',
);
});

test('masks quoted sensitive object keys in serialized JSON', () => {
const text = redactSecrets(
JSON.stringify({
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/display-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@ const PATTERNS: Pattern[] = [
streamingTerminator: /[\s"'<>]/,
streamingValueGroup: 3,
},
// URL userinfo: https://user:pass@host / https://token@host
// Structural — any authority that contains `@` is credential-bearing, so
// this does not depend on a provider prefix list. Runs before the query
// rule so only the userinfo is replaced and host/path survive.
// Character class matches streamingTerminator so a bare `https://host`
// cannot swallow later `@` across whitespace/quotes/angle brackets.
// Known boundary: punctuation like commas can still join a bare URL to a
// later `@`; a proper fix would restrict userinfo to the RFC 3986 set.
// http(s) only for now. Streaming cannot recognize userinfo before `@`
// arrives (`https://user:pa` stays clear until then); tightening earlier
// would eat `https://host:8080/`.
{
label: 'url userinfo',
regex: /(https?:\/\/)([^\s"'<>/?#]*@)/gi,
replacement: (m) => `${m[1]}<redacted>@`,
streamingTerminator: /[/?#\s"'<>]/,
streamingValueGroup: 2,
},
// URL query secrets: ?key=[redacted] ?token=[redacted] ?api_key=[redacted] &access_token=[redacted]
// (runs before the api-key-header rule so the URL form isn't mangled.)
{
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function redactSecrets(value: string): string {

function redactTextSecrets(value: string): string {
let next = value;
next = redactUrlUserinfoSecrets(next);
next = redactUrlQuerySecrets(next);
next = next.replace(QUOTED_SECRET_KEY_VALUE_PATTERN, (match, prefix: string, key: string) =>
isSensitiveKey(key) ? `${prefix}[redacted]` : match,
Expand Down Expand Up @@ -170,6 +171,18 @@ function redactJsonValue(value: unknown): { value: unknown; changed: boolean } {
return { value: next, changed };
}

function redactUrlUserinfoSecrets(value: string): string {
// Authority runs through the first `/`, `?`, `#`, whitespace, quote, or
// angle bracket. If it contains `@`, everything from the host-start through
// the last `@` is userinfo. The class matches display-redaction's
// streamingTerminator so a bare `https://host` followed later by an
// email/`@package` (including across JSON quotes) cannot swallow the gap.
// Known boundary: punctuation like commas can still join a bare URL to a
// later `@` into one fake credentialed match; a proper fix would restrict
// userinfo to the RFC 3986 set instead of exclusion. http(s) only for now.
return value.replace(/(https?:\/\/)[^\s"'<>/?#]*@/gi, '$1[redacted]@');
}

function redactUrlQuerySecrets(value: string): string {
return value.replace(/([?&])([^=\s&?#]+)=([^&\s#]*)/g, (match, sep: string, key: string) => {
if (!isSensitiveKey(key)) return match;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('streaming display redaction', () => {
`Authorization:${' '.repeat(2_048)}Bearer arbitrary-secret-value tail`,
'Authorization:\n\nBearer newline-secret-value tail',
'x-api-key\n:\nnewline-api-key-value tail',
'https://alice:hunter2@internal.example.com/repo.git tail',
];
for (const input of cases) {
for (const sizes of [[1], [3], [7], [20], [64], [1, 31, 2, 127, 5]]) {
Expand Down