From 69ae98abacd9b318fc43b566af6fcc276231cbe7 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/4] 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 57094a2c32..edd2ee5ebc 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -86,6 +86,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 add461e25a..73dc19cad8 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,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 da11e15196de09ddb9f446165e6bd26ef293b8ff 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/4] 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 edd2ee5ebc..b529efd34d 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -104,10 +104,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)', From a6e6a27dbf95283348ae746e4a2593c635690935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Sat, 5 Sep 2026 10:05:36 +0800 Subject: [PATCH 3/4] fix(core): tighten URL userinfo redaction character class Exclude whitespace/quotes from the authority match so a bare https://host cannot swallow a later @ across spaces or newlines (and wipe PTY screens). Align display regex with streamingTerminator and add negative assertions. Co-authored-by: Cursor --- packages/core/src/__tests__/display-redaction.test.ts | 9 +++++++++ packages/core/src/__tests__/redaction.test.ts | 9 +++++++++ packages/core/src/display-redaction.ts | 6 +++++- packages/core/src/redaction.ts | 8 +++++--- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/display-redaction.test.ts b/packages/core/src/__tests__/display-redaction.test.ts index 180806eaff..42ded14a0a 100644 --- a/packages/core/src/__tests__/display-redaction.test.ts +++ b/packages/core/src/__tests__/display-redaction.test.ts @@ -74,6 +74,15 @@ describe('display redactSecrets', () => { 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. + 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', + ); }); }); diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index b529efd34d..dcd850f1f0 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -133,6 +133,15 @@ describe('redactSecrets', () => { 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. + 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', + ); }); test('masks quoted sensitive object keys in serialized JSON', () => { diff --git a/packages/core/src/display-redaction.ts b/packages/core/src/display-redaction.ts index 5892322dca..b3c432841b 100644 --- a/packages/core/src/display-redaction.ts +++ b/packages/core/src/display-redaction.ts @@ -60,9 +60,13 @@ const PATTERNS: Pattern[] = [ // 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. 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?:\/\/)([^/?#]*@)/gi, + regex: /(https?:\/\/)([^\s"'<>/?#]*@)/gi, replacement: (m) => `${m[1]}@`, streamingTerminator: /[/?#\s"'<>]/, streamingValueGroup: 2, diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts index 73dc19cad8..2da01f1204 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -172,9 +172,11 @@ function redactJsonValue(value: unknown): { value: unknown; changed: boolean } { } 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]@'); + // Authority runs through the first `/`, `?`, `#`, or whitespace. If it + // contains `@`, everything from the host-start through the last `@` is + // userinfo. Whitespace is excluded so a bare `https://host` followed later + // by an email/`@package` on the same or next line cannot swallow the gap. + return value.replace(/(https?:\/\/)[^\s/?#]*@/gi, '$1[redacted]@'); } function redactUrlQuerySecrets(value: string): string { From 9aed90fabd2feb53b894a8fa2c36de4be30e6a89 Mon Sep 17 00:00:00 2001 From: Rangsh <2570024918@qq.com> Date: Mon, 7 Sep 2026 12:54:13 +0800 Subject: [PATCH 4/4] fix(core): align persisted URL userinfo character class with display Exclude quotes and angle brackets from the log-side authority match so a bare https://host in JSON cannot swallow a later @ and delete adjacent fields. Document the remaining punctuation/RFC-3986 boundary on both sides. Co-authored-by: Cursor --- .../core/src/__tests__/display-redaction.test.ts | 6 +++++- packages/core/src/__tests__/redaction.test.ts | 6 +++++- packages/core/src/display-redaction.ts | 9 ++++++--- packages/core/src/redaction.ts | 14 +++++++++----- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/display-redaction.test.ts b/packages/core/src/__tests__/display-redaction.test.ts index 42ded14a0a..98e0c19a8d 100644 --- a/packages/core/src/__tests__/display-redaction.test.ts +++ b/packages/core/src/__tests__/display-redaction.test.ts @@ -74,7 +74,7 @@ describe('display redactSecrets', () => { 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. + // 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', @@ -83,6 +83,10 @@ describe('display redactSecrets', () => { 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"}', + ); }); }); diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index dcd850f1f0..de055f4352 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -133,7 +133,7 @@ describe('redactSecrets', () => { 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. + // 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', @@ -142,6 +142,10 @@ describe('redactSecrets', () => { 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', () => { diff --git a/packages/core/src/display-redaction.ts b/packages/core/src/display-redaction.ts index b3c432841b..18740abfd0 100644 --- a/packages/core/src/display-redaction.ts +++ b/packages/core/src/display-redaction.ts @@ -61,9 +61,12 @@ const PATTERNS: Pattern[] = [ // 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. Streaming cannot - // recognize userinfo before `@` arrives (`https://user:pa` stays clear - // until then); tightening earlier would eat `https://host:8080/`. + // 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, diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts index 2da01f1204..94096b8991 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -172,11 +172,15 @@ function redactJsonValue(value: unknown): { value: unknown; changed: boolean } { } function redactUrlUserinfoSecrets(value: string): string { - // Authority runs through the first `/`, `?`, `#`, or whitespace. If it - // contains `@`, everything from the host-start through the last `@` is - // userinfo. Whitespace is excluded so a bare `https://host` followed later - // by an email/`@package` on the same or next line cannot swallow the gap. - return value.replace(/(https?:\/\/)[^\s/?#]*@/gi, '$1[redacted]@'); + // 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 {