diff --git a/packages/core/src/__tests__/display-redaction.test.ts b/packages/core/src/__tests__/display-redaction.test.ts new file mode 100644 index 0000000000..98e0c19a8d --- /dev/null +++ b/packages/core/src/__tests__/display-redaction.test.ts @@ -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://@gitlab.com/team/repo.git', + ], + [ + 'https://alice:hunter2@internal.example.com/repo.git', + 'https://@internal.example.com/repo.git', + ], + [ + 'https://alice:ATBBxyz123abc456@bitbucket.org/team/repo.git', + 'https://@bitbucket.org/team/repo.git', + ], + [ + 'fatal: unable to access https://deploy:s3cretP@ss@git.corp.example/x.git/: 403', + 'fatal: unable to access https://@git.corp.example/x.git/: 403', + ], + ['https://user@host.example/team/repo.git', 'https://@host.example/team/repo.git'], + [ + 'origin https://alice:hunter2@internal.example.com/repo.git (fetch)', + 'origin https://@internal.example.com/repo.git (fetch)', + ], + [ + 'see https://alice:hunter2@internal.example.com/repo.git.', + 'see https://@internal.example.com/repo.git.', + ], + [ + 'clone (https://alice:hunter2@internal.example.com/repo.git)', + 'clone (https://@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=', + ); + assert.equal( + redactSecrets('https://ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@github.com/o/r.git'), + 'https://@github.com/o/r.git', + ); + assert.equal( + redactSecrets('https://alice:hunter2@api.example.com/v1?token=abc123'), + 'https://@api.example.com/v1?token=', + ); + // 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://@'); + 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); + }); +}); diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index 57094a2c32..de055f4352 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -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({ diff --git a/packages/core/src/display-redaction.ts b/packages/core/src/display-redaction.ts index 0de83dd961..18740abfd0 100644 --- a/packages/core/src/display-redaction.ts +++ b/packages/core/src/display-redaction.ts @@ -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]}@`, + 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.) { diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts index add461e25a..94096b8991 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -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, @@ -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; diff --git a/packages/ui/src/__tests__/streaming-display-redaction.test.ts b/packages/ui/src/__tests__/streaming-display-redaction.test.ts index 3f9ebc1ffd..a66383e499 100644 --- a/packages/ui/src/__tests__/streaming-display-redaction.test.ts +++ b/packages/ui/src/__tests__/streaming-display-redaction.test.ts @@ -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]]) {