From ee48f702dc46a111ef5bfdbe06f4aa3a5f23db85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 3 Sep 2026 09:22:21 +0800 Subject: [PATCH 1/2] fix(core): redact URL userinfo credentials in shared redactors Co-authored-by: Cursor --- .../src/__tests__/display-redaction.test.ts | 115 ++++++++++++++++++ packages/core/src/__tests__/redaction.test.ts | 52 ++++++++ packages/core/src/display-redaction.ts | 11 ++ packages/core/src/redaction.ts | 7 ++ .../streaming-display-redaction.test.ts | 1 + 5 files changed, 186 insertions(+) create mode 100644 packages/core/src/__tests__/display-redaction.test.ts 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..e11b6b19ed --- /dev/null +++ b/packages/core/src/__tests__/display-redaction.test.ts @@ -0,0 +1,115 @@ +/* + * 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..cfc21c5d9a 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -84,6 +84,58 @@ 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]]) { From eb946878756d13aaf73352f027db395d547619f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 3 Sep 2026 09:33:10 +0800 Subject: [PATCH 2/2] style(core): format URL userinfo redaction tests Co-authored-by: Cursor --- packages/core/src/__tests__/display-redaction.test.ts | 10 ++-------- packages/core/src/__tests__/redaction.test.ts | 5 +---- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/core/src/__tests__/display-redaction.test.ts b/packages/core/src/__tests__/display-redaction.test.ts index e11b6b19ed..180806eaff 100644 --- a/packages/core/src/__tests__/display-redaction.test.ts +++ b/packages/core/src/__tests__/display-redaction.test.ts @@ -42,10 +42,7 @@ const USERINFO_CASES: Array<[string, string]> = [ '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', - ], + ['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)', @@ -91,10 +88,7 @@ describe('display streaming suffix redactors', () => { 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, - ); + assert.equal(redactSecrets(suffix.settledPrefixText + suffix.compactedSuffix), suffix.text); }); test('does not treat a completed userinfo URL as a streaming suffix', () => { diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index cfc21c5d9a..91f03f5f6a 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -102,10 +102,7 @@ describe('redactSecrets', () => { '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', - ], + ['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)',