From 4b317ca9be82a71540967972821c7a6d3086e651 Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 31 Jul 2026 13:23:18 +0300 Subject: [PATCH 1/7] fix(respect): secrets masking for encoded occurences in har-output --- .changeset/mask-encoded-secrets.md | 6 ++ .../logger-output/mask-secrets.test.ts | 45 ++++++++++++++ .../src/modules/logger-output/mask-secrets.ts | 58 ++++++++++++------- 3 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 .changeset/mask-encoded-secrets.md diff --git a/.changeset/mask-encoded-secrets.md b/.changeset/mask-encoded-secrets.md new file mode 100644 index 0000000000..a2f9f53860 --- /dev/null +++ b/.changeset/mask-encoded-secrets.md @@ -0,0 +1,6 @@ +--- +'@redocly/respect-core': patch +'@redocly/cli': patch +--- + +Fixed secrets masking to cover encoded occurrences of a secret in har-output. diff --git a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts index 76f8b8b3d7..724f6e4dd7 100644 --- a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts +++ b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts @@ -103,6 +103,51 @@ describe('maskSecrets', () => { expect(result).toEqual('Bearer ********'); }); + it('should mask a URL-encoded occurrence of a secret', () => { + const result = maskSecrets('password=p%40ss+word', new Set(['p@ss word'])); + expect(result).toEqual('password=********'); + }); + + it('should mask a JSON-escaped occurrence of a secret', () => { + const result = maskSecrets(JSON.stringify({ password: 'pa"ss' }), new Set(['pa"ss'])); + expect(result).toEqual('{"password":"********"}'); + }); + + it('should mask every occurrence of a secret within one value', () => { + const result = maskSecrets( + { body: '{"password":"hunter2","confirmPassword":"hunter2"}' }, + new Set(['hunter2']) + ); + expect(result).toEqual({ body: '{"password":"********","confirmPassword":"********"}' }); + }); + + it('should mask secrets everywhere in a HAR capture', () => { + const har = { + log: { + entries: [ + { + request: { + headers: [{ name: 'authorization', value: 'Bearer hunter2' }], + postData: { + mimeType: 'application/x-www-form-urlencoded', + text: 'password=p%40ss+word', + }, + }, + response: { + content: { mimeType: 'application/json', text: '{"token":"hunter2"}' }, + }, + }, + ], + }, + }; + + const result = maskSecrets(har, new Set(['hunter2', 'p@ss word'])); + + expect(result.log.entries[0].request.headers[0].value).toBe('Bearer ********'); + expect(result.log.entries[0].request.postData.text).toBe('password=********'); + expect(result.log.entries[0].response.content.text).toBe('{"token":"********"}'); + }); + it('should preserve ArrayBuffer objects without breaking them', () => { const originalArrayBuffer = new ArrayBuffer(8); const originalData = new Uint8Array(originalArrayBuffer); diff --git a/packages/respect-core/src/modules/logger-output/mask-secrets.ts b/packages/respect-core/src/modules/logger-output/mask-secrets.ts index fa17917746..216749f82d 100644 --- a/packages/respect-core/src/modules/logger-output/mask-secrets.ts +++ b/packages/respect-core/src/modules/logger-output/mask-secrets.ts @@ -10,39 +10,57 @@ export const POTENTIALLY_SECRET_FIELDS = [ 'client_secret', ]; +/** + * A secret can appear in a log or capture already encoded: a JSON body + * escapes quotes and backslashes, a form-urlencoded body or a URL + * percent-encodes special characters. Mask those variants along with the + * raw value. + */ +function collectSecretPatterns(secretsSet: Set): string[] { + const patterns: string[] = []; + for (const secret of secretsSet) { + if (!secret) continue; + const variants = [ + secret, + JSON.stringify(secret).slice(1, -1), + encodeURIComponent(secret), + // URLSearchParams serializes to `secret=value`; drop the key to keep + // the form-urlencoded variant (spaces become `+`, unlike encodeURIComponent). + new URLSearchParams([['secret', secret]]).toString().slice('secret='.length), + ]; + for (const variant of variants) { + if (!patterns.includes(variant)) { + patterns.push(variant); + } + } + } + return patterns; +} + export function maskSecrets( target: T, secretsSet: Set ): T { - const maskValue = (value: string, secret: string): string => { - return value.replace(secret, '*'.repeat(8)); - }; - - if (typeof target === 'string') { - let maskedString = target as string; - secretsSet.forEach((secret) => { - maskedString = maskedString.split(secret).join('*'.repeat(8)); - }); - return maskedString as T; - } - - const masked = deepCopy(target); - const maskIfContainsSecret = (value: string): string => { + const patterns = collectSecretPatterns(secretsSet); + const maskString = (value: string): string => { let maskedValue = value; - - for (const secret of secretsSet) { - if (maskedValue.includes(secret)) { - maskedValue = maskValue(maskedValue, secret); + for (const pattern of patterns) { + if (maskedValue.includes(pattern)) { + maskedValue = maskedValue.split(pattern).join('*'.repeat(8)); } } - return maskedValue; }; + if (typeof target === 'string') { + return maskString(target) as T; + } + + const masked = deepCopy(target); const maskRecursive = (current: any) => { for (const key in current) { if (typeof current[key] === 'string') { - current[key] = maskIfContainsSecret(current[key]); + current[key] = maskString(current[key]); } else if (isPlainObject(current[key]) || Array.isArray(current[key])) { // Skip special objects that should not be modified if ( From 1af0b731a4aa505cace14e589fb7394a84ed020c Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 31 Jul 2026 14:12:28 +0300 Subject: [PATCH 2/7] chore: changes after review --- .../src/modules/logger-output/mask-secrets.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/respect-core/src/modules/logger-output/mask-secrets.ts b/packages/respect-core/src/modules/logger-output/mask-secrets.ts index 216749f82d..fff4f3405a 100644 --- a/packages/respect-core/src/modules/logger-output/mask-secrets.ts +++ b/packages/respect-core/src/modules/logger-output/mask-secrets.ts @@ -17,24 +17,17 @@ export const POTENTIALLY_SECRET_FIELDS = [ * raw value. */ function collectSecretPatterns(secretsSet: Set): string[] { - const patterns: string[] = []; + const patterns = new Set(); for (const secret of secretsSet) { if (!secret) continue; - const variants = [ - secret, - JSON.stringify(secret).slice(1, -1), - encodeURIComponent(secret), - // URLSearchParams serializes to `secret=value`; drop the key to keep - // the form-urlencoded variant (spaces become `+`, unlike encodeURIComponent). - new URLSearchParams([['secret', secret]]).toString().slice('secret='.length), - ]; - for (const variant of variants) { - if (!patterns.includes(variant)) { - patterns.push(variant); - } - } + patterns.add(secret); + patterns.add(JSON.stringify(secret).slice(1, -1)); + patterns.add(encodeURIComponent(secret)); + // URLSearchParams serializes to `secret=value`; drop the key to keep + // the form-urlencoded variant (spaces become `+`, unlike encodeURIComponent). + patterns.add(new URLSearchParams([['secret', secret]]).toString().slice('secret='.length)); } - return patterns; + return Array.from(patterns); } export function maskSecrets( From e6f2022c9854cd6d39056c128029c199986248cb Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 31 Jul 2026 14:25:18 +0300 Subject: [PATCH 3/7] chore: update changelog --- .changeset/mask-encoded-secrets.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/mask-encoded-secrets.md b/.changeset/mask-encoded-secrets.md index a2f9f53860..c41265c9a4 100644 --- a/.changeset/mask-encoded-secrets.md +++ b/.changeset/mask-encoded-secrets.md @@ -1,6 +1,5 @@ --- '@redocly/respect-core': patch -'@redocly/cli': patch --- Fixed secrets masking to cover encoded occurrences of a secret in har-output. From 6e3e4916ab58773e9a5df3e38ac2dcb5fccdb514 Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 31 Jul 2026 15:01:13 +0300 Subject: [PATCH 4/7] chore: changes after review --- .../src/modules/__tests__/logger-output/mask-secrets.test.ts | 5 +++++ .../respect-core/src/modules/logger-output/mask-secrets.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts index 724f6e4dd7..55f00559c1 100644 --- a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts +++ b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts @@ -148,6 +148,11 @@ describe('maskSecrets', () => { expect(result.log.entries[0].response.content.text).toBe('{"token":"********"}'); }); + it('should mask a longer secret that starts with a shorter one', () => { + const result = maskSecrets('Bearer passphrase123', new Set(['pass', 'passphrase123'])); + expect(result).toEqual('Bearer ********'); + }); + it('should preserve ArrayBuffer objects without breaking them', () => { const originalArrayBuffer = new ArrayBuffer(8); const originalData = new Uint8Array(originalArrayBuffer); diff --git a/packages/respect-core/src/modules/logger-output/mask-secrets.ts b/packages/respect-core/src/modules/logger-output/mask-secrets.ts index fff4f3405a..ad918a284f 100644 --- a/packages/respect-core/src/modules/logger-output/mask-secrets.ts +++ b/packages/respect-core/src/modules/logger-output/mask-secrets.ts @@ -27,7 +27,9 @@ function collectSecretPatterns(secretsSet: Set): string[] { // the form-urlencoded variant (spaces become `+`, unlike encodeURIComponent). patterns.add(new URLSearchParams([['secret', secret]]).toString().slice('secret='.length)); } - return Array.from(patterns); + // Longest first: a short secret nested in a longer one would otherwise mask + // only the prefix and leave the rest of the longer secret visible. + return Array.from(patterns).sort((left, right) => right.length - left.length); } export function maskSecrets( From 4fc3373bc10b939556c49022a8c343c9bd014273 Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 31 Jul 2026 15:23:37 +0300 Subject: [PATCH 5/7] fix: handle empty secrets --- .../src/modules/__tests__/logger-output/mask-secrets.test.ts | 5 +++++ .../respect-core/src/modules/logger-output/mask-secrets.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts index 55f00559c1..d0563adf58 100644 --- a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts +++ b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts @@ -153,6 +153,11 @@ describe('maskSecrets', () => { expect(result).toEqual('Bearer ********'); }); + it('should ignore a whitespace-only secret', () => { + const result = maskSecrets('filter=a+b', new Set([' '])); + expect(result).toEqual('filter=a+b'); + }); + it('should preserve ArrayBuffer objects without breaking them', () => { const originalArrayBuffer = new ArrayBuffer(8); const originalData = new Uint8Array(originalArrayBuffer); diff --git a/packages/respect-core/src/modules/logger-output/mask-secrets.ts b/packages/respect-core/src/modules/logger-output/mask-secrets.ts index ad918a284f..fdb6099c87 100644 --- a/packages/respect-core/src/modules/logger-output/mask-secrets.ts +++ b/packages/respect-core/src/modules/logger-output/mask-secrets.ts @@ -19,7 +19,7 @@ export const POTENTIALLY_SECRET_FIELDS = [ function collectSecretPatterns(secretsSet: Set): string[] { const patterns = new Set(); for (const secret of secretsSet) { - if (!secret) continue; + if (!secret.trim()) continue; patterns.add(secret); patterns.add(JSON.stringify(secret).slice(1, -1)); patterns.add(encodeURIComponent(secret)); From 18b17ff31741e03dd4c9b4e75f655b6d23cd93a9 Mon Sep 17 00:00:00 2001 From: DmitryAnansky Date: Fri, 31 Jul 2026 15:35:10 +0300 Subject: [PATCH 6/7] chore: changes after review --- .../modules/__tests__/logger-output/mask-secrets.test.ts | 6 ++++++ .../respect-core/src/modules/logger-output/mask-secrets.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts index d0563adf58..71f3194269 100644 --- a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts +++ b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts @@ -158,6 +158,12 @@ describe('maskSecrets', () => { expect(result).toEqual('filter=a+b'); }); + it('should mask a secret containing a lone surrogate', () => { + const secret = `tok${String.fromCharCode(0xd800)}en`; + const result = maskSecrets({ access_token: secret }, new Set([secret])); + expect(result).toEqual({ access_token: '********' }); + }); + it('should preserve ArrayBuffer objects without breaking them', () => { const originalArrayBuffer = new ArrayBuffer(8); const originalData = new Uint8Array(originalArrayBuffer); diff --git a/packages/respect-core/src/modules/logger-output/mask-secrets.ts b/packages/respect-core/src/modules/logger-output/mask-secrets.ts index fdb6099c87..3bc5a7fc0e 100644 --- a/packages/respect-core/src/modules/logger-output/mask-secrets.ts +++ b/packages/respect-core/src/modules/logger-output/mask-secrets.ts @@ -22,7 +22,11 @@ function collectSecretPatterns(secretsSet: Set): string[] { if (!secret.trim()) continue; patterns.add(secret); patterns.add(JSON.stringify(secret).slice(1, -1)); - patterns.add(encodeURIComponent(secret)); + // A lone surrogate reaching us through a captured `\uD800` escape makes + // encodeURIComponent throw; URLSearchParams substitutes U+FFFD instead. + if (secret.isWellFormed()) { + patterns.add(encodeURIComponent(secret)); + } // URLSearchParams serializes to `secret=value`; drop the key to keep // the form-urlencoded variant (spaces become `+`, unlike encodeURIComponent). patterns.add(new URLSearchParams([['secret', secret]]).toString().slice('secret='.length)); From 1bec3dc76388cb847e1e553eb5a77c3e5fe3fa39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:29:10 +0200 Subject: [PATCH 7/7] Apply suggestion from @JLekawa --- .changeset/mask-encoded-secrets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mask-encoded-secrets.md b/.changeset/mask-encoded-secrets.md index c41265c9a4..5e5c51ecc7 100644 --- a/.changeset/mask-encoded-secrets.md +++ b/.changeset/mask-encoded-secrets.md @@ -2,4 +2,4 @@ '@redocly/respect-core': patch --- -Fixed secrets masking to cover encoded occurrences of a secret in har-output. +Fixed an issue where secrets masking did not cover encoded secrets in `har-output`.