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..180806eaff --- /dev/null +++ b/packages/core/src/__tests__/display-redaction.test.ts @@ -0,0 +1,109 @@ +/* + * 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=', + ); + }); +}); + +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 4e593790a5..91f03f5f6a 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -84,6 +84,55 @@ 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]', + ); + }); + 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..5892322dca 100644 --- a/packages/core/src/display-redaction.ts +++ b/packages/core/src/display-redaction.ts @@ -56,6 +56,17 @@ 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. + { + label: 'url userinfo', + regex: /(https?:\/\/)([^/?#]*@)/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 9165533c73..12aa051c42 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -68,6 +68,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, @@ -168,6 +169,12 @@ function redactJsonValue(value: unknown): { value: unknown; changed: boolean } { return { value: next, changed }; } +function redactUrlUserinfoSecrets(value: string): string { + // Authority runs through the first `/`, `?`, or `#`. If it contains `@`, + // everything from the host-start through the last `@` is userinfo. + return value.replace(/(https?:\/\/)[^/?#]*@/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]]) {