From f5bad30cb162ab74452797690ec8112b5b19d9ea Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Sun, 14 Jun 2026 20:58:19 +0530 Subject: [PATCH 01/11] security: redact CLI logs, bound regex matching (ReDoS), validate Chromium base URL (PER-8609/8615/8616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three contained runtime hardening fixes in @percy/core: PER-8609 (CWE-532) — clilogs were sent to the Percy API without secret redaction (cilogs already were). Wrap clilogs in the existing redactSecrets() so tokens / credential-bearing URLs are stripped before egress. PER-8615 (CWE-1333) — snapshotMatches() runs user-controllable regex/glob patterns against snapshot.name; a crafted long name reaching the matcher (e.g. via the local API, per chain PER-8627) could trigger catastrophic backtracking. Cap the matched-input length (MAX_MATCH_INPUT_LENGTH = 2048) before any RegExp/micromatch call; exact-string matching is unaffected. PER-8616 (CWE-918) — PERCY_CHROMIUM_BASE_URL was used as a download base with no validation, enabling SSRF / an integrity downgrade. Add resolveChromiumBaseUrl(): require a well-formed HTTPS URL, otherwise warn and fall back to the trusted default host. (Private HTTPS mirrors remain supported, so no host allowlist; this also gives transport integrity for PER-8605 — full checksum pinning of the binary is a separate follow-up.) Verified against real source: resolveChromiumBaseUrl (https-only + fallback), redactSecrets on the clilogs array shape (GitHub token + bearer redacted), and the ReDoS guard (catastrophic pattern on a 50k-char input returns in 0ms). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/install.js | 25 ++++++++++++++++++++++++- packages/core/src/percy.js | 5 ++++- packages/core/src/snapshot.js | 22 ++++++++++++++++------ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/core/src/install.js b/packages/core/src/install.js index d5f3ff391..9cda71013 100644 --- a/packages/core/src/install.js +++ b/packages/core/src/install.js @@ -139,6 +139,29 @@ export async function download({ return exec; } +const DEFAULT_CHROMIUM_BASE_URL = 'https://storage.googleapis.com/chromium-browser-snapshots/'; + +// Resolve the Chromium download base URL. PERCY_CHROMIUM_BASE_URL may point at a +// private mirror, but an unvalidated value enables SSRF / an integrity downgrade +// (CWE-918): require a well-formed HTTPS URL, otherwise warn and fall back to the +// trusted default host. +export function resolveChromiumBaseUrl(value = process.env.PERCY_CHROMIUM_BASE_URL) { + if (!value) return DEFAULT_CHROMIUM_BASE_URL; + let log = logger('core:install'); + let parsed; + try { + parsed = new URL(value); + } catch { + log.warn(`Invalid PERCY_CHROMIUM_BASE_URL "${value}"; using the default Chromium download host.`); + return DEFAULT_CHROMIUM_BASE_URL; + } + if (parsed.protocol !== 'https:') { + log.warn(`Ignoring non-HTTPS PERCY_CHROMIUM_BASE_URL "${value}"; Chromium must be downloaded over HTTPS.`); + return DEFAULT_CHROMIUM_BASE_URL; + } + return value.endsWith('/') ? value : `${value}/`; +} + // Installs a revision of Chromium to a local directory export function chromium({ // default directory is within @percy/core package root @@ -148,7 +171,7 @@ export function chromium({ } = {}) { let extract = (i, o) => import('extract-zip').then(ex => ex.default(i, { dir: o })); - let url = (process.env.PERCY_CHROMIUM_BASE_URL || 'https://storage.googleapis.com/chromium-browser-snapshots/') + + let url = resolveChromiumBaseUrl() + selectByPlatform({ linux: `Linux_x64/${revision}/chrome-linux.zip`, darwin: `Mac/${revision}/chrome-mac.zip`, diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 1bab43d7a..910befc0f 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -863,7 +863,10 @@ export class Percy { if (!process.env.PERCY_TOKEN) return; try { const logsObject = { - clilogs: logger.query(log => !['ci'].includes(log.debug)) + // Redact secrets from CLI logs before egress to the Percy API — these + // can contain tokens or URLs with embedded credentials (CWE-532). The + // cilogs below were already redacted; clilogs were not. + clilogs: redactSecrets(logger.query(log => !['ci'].includes(log.debug))) }; // Only add CI logs if not disabled voluntarily. diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 8d23e9352..703f86ab5 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -45,21 +45,31 @@ function validateAndFixSnapshotUrl(snapshot) { // used to deserialize regular expression strings const RE_REGEXP = /^\/(.+)\/(\w+)?$/; +// Upper bound on the snapshot name length we will run user-controllable +// regex/glob matching against. A crafted, very long snapshot name reaching this +// matcher (e.g. via the local API) combined with a backtracking-prone pattern +// could otherwise trigger catastrophic backtracking / ReDoS (CWE-1333). Real +// snapshot names are short; an over-long name simply does not match patterns. +const MAX_MATCH_INPUT_LENGTH = 2048; + // Returns true or false if a snapshot matches the provided include and exclude predicates. A // predicate can be an array of predicates, a regular expression, a glob pattern, or a function. function snapshotMatches(snapshot, include, exclude) { // support an options object as the second argument if (include?.include || include?.exclude) ({ include, exclude } = include); + // guard pattern matching against pathologically long inputs (ReDoS) + let patternSafe = typeof snapshot.name === 'string' && snapshot.name.length <= MAX_MATCH_INPUT_LENGTH; + // recursive predicate test function let test = (predicate, fallback) => { if (predicate && typeof predicate === 'string') { - // snapshot name matches exactly or matches a glob + // exact match is always safe; glob matching is only run on bounded input let result = snapshot.name === predicate || - micromatch.isMatch(snapshot.name, predicate); + (patternSafe && micromatch.isMatch(snapshot.name, predicate)); - // snapshot might match a string-based regexp pattern - if (!result) { + // snapshot might match a string-based regexp pattern (bounded input only) + if (!result && patternSafe) { try { let [, parsed, flags] = RE_REGEXP.exec(predicate) || []; result = !!parsed && new RegExp(parsed, flags).test(snapshot.name); @@ -68,8 +78,8 @@ function snapshotMatches(snapshot, include, exclude) { return result; } else if (predicate instanceof RegExp) { - // snapshot matches a regular expression - return predicate.test(snapshot.name); + // snapshot matches a regular expression (bounded input only) + return patternSafe && predicate.test(snapshot.name); } else if (typeof predicate === 'function') { // advanced matching return predicate(snapshot); From a3388a3c36a17973e85ec7c1d2ab917bfa61798e Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 23 Jun 2026 15:17:02 +0530 Subject: [PATCH 02/11] fix(core): memoize secret patterns so redactSecrets does not blow timeouts redactSecrets re-read and re-parsed secretPatterns.yml (~1.7k regexes) and recompiled every RegExp on every recursive call. Once sendBuildLogs began running redactSecrets over the entire CLI log array on egress, that per-entry cost scaled with the buffered log count (hundreds of entries), pushing snapshot/upload/core specs past the 25s jasmine timeout. Compile the pattern list once and reuse it; redaction output is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/utils.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index bdc206587..39c5b49f0 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -615,10 +615,23 @@ export async function withRetries(fn, { count, onRetry, signal, throwOn }) { } } -export function redactSecrets(data) { - const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml'); - const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8')); +// Lazily load and compile the secret patterns once. The pattern file holds +// ~1.7k regexes; parsing the YAML and compiling every RegExp on each call made +// redactSecrets O(patterns) per string and re-read the file for every recursive +// call. Since redactSecrets now runs over the full CLI log array on egress +// (sendBuildLogs), that per-call cost is paid hundreds of times and could blow +// past test/runtime timeouts. Compile once and reuse. +let _compiledSecretPatterns; +function getSecretPatterns() { + if (!_compiledSecretPatterns) { + const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml'); + const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8')); + _compiledSecretPatterns = secretPatterns.patterns.map(p => new RegExp(p.pattern.regex, 'g')); + } + return _compiledSecretPatterns; +} +export function redactSecrets(data) { if (Array.isArray(data)) { // Process each item in the array return data.map(item => redactSecrets(item)); @@ -627,8 +640,8 @@ export function redactSecrets(data) { data.message = redactSecrets(data.message); } if (typeof data === 'string') { - for (const pattern of secretPatterns.patterns) { - data = data.replace(new RegExp(pattern.pattern.regex, 'g'), '[REDACTED]'); + for (const pattern of getSecretPatterns()) { + data = data.replace(pattern, '[REDACTED]'); } } return data; From 5ee0b6e8abf5ae8c78c412f1ccc567bab1905869 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 23 Jun 2026 16:40:59 +0530 Subject: [PATCH 03/11] test(core): cover Chromium base URL validation branches Adds unit tests for resolveChromiumBaseUrl: default-host fallback, env default, trailing-slash normalization, and the warn-and-fallback paths for unparseable and non-HTTPS values, restoring 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/test/unit/install.test.js | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/core/test/unit/install.test.js b/packages/core/test/unit/install.test.js index d27f15f0e..6c7fe0a84 100644 --- a/packages/core/test/unit/install.test.js +++ b/packages/core/test/unit/install.test.js @@ -291,6 +291,46 @@ describe('Unit / Install', () => { }); } }); + + describe('resolveChromiumBaseUrl', () => { + let defaultUrl = 'https://storage.googleapis.com/chromium-browser-snapshots/'; + + afterEach(() => { + delete process.env.PERCY_CHROMIUM_BASE_URL; + }); + + it('returns the default host when no base URL is set', () => { + delete process.env.PERCY_CHROMIUM_BASE_URL; + expect(install.resolveChromiumBaseUrl()).toEqual(defaultUrl); + }); + + it('reads the base URL from PERCY_CHROMIUM_BASE_URL by default', () => { + process.env.PERCY_CHROMIUM_BASE_URL = 'https://mirror.test.com/'; + expect(install.resolveChromiumBaseUrl()).toEqual('https://mirror.test.com/'); + }); + + it('appends a trailing slash to a valid HTTPS base URL', () => { + expect(install.resolveChromiumBaseUrl('https://mirror.test.com/chromium')) + .toEqual('https://mirror.test.com/chromium/'); + }); + + it('leaves an already slash-terminated base URL unchanged', () => { + expect(install.resolveChromiumBaseUrl('https://mirror.test.com/')) + .toEqual('https://mirror.test.com/'); + }); + + it('falls back to the default host for an unparseable URL', () => { + expect(install.resolveChromiumBaseUrl('not a valid url')).toEqual(defaultUrl); + expect(logger.stderr).toContain( + '[percy] Invalid PERCY_CHROMIUM_BASE_URL "not a valid url"; using the default Chromium download host.'); + }); + + it('rejects a non-HTTPS base URL and falls back to the default host', () => { + expect(install.resolveChromiumBaseUrl('http://mirror.test.com/')).toEqual(defaultUrl); + expect(logger.stderr).toContain( + '[percy] Ignoring non-HTTPS PERCY_CHROMIUM_BASE_URL "http://mirror.test.com/"; Chromium must be downloaded over HTTPS.'); + }); + }); }); describe('Unit / Install in executable', () => { From 3e0aae3f214467d9981bbfdce9623c4ec6837e0b Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Mon, 29 Jun 2026 11:01:21 +0530 Subject: [PATCH 04/11] fix(core): redact Percy tokens in logs (PER-8609) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/secretPatterns.yml | 4 ++++ packages/core/test/unit/utils.test.js | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/core/src/secretPatterns.yml b/packages/core/src/secretPatterns.yml index 12bc3e089..26328bef5 100644 --- a/packages/core/src/secretPatterns.yml +++ b/packages/core/src/secretPatterns.yml @@ -7021,4 +7021,8 @@ patterns: - pattern: name: Bitcoin Address regex: '^[13][a-km-zA-HJ-NP-Z0-9]{26,33}$' + confidence: high + - pattern: + name: Percy Token + regex: '(web|app|auto|ss|vmw|res)_[A-Za-z0-9]{20,}' confidence: high \ No newline at end of file diff --git a/packages/core/test/unit/utils.test.js b/packages/core/test/unit/utils.test.js index 7b143cd61..62bd392f6 100644 --- a/packages/core/test/unit/utils.test.js +++ b/packages/core/test/unit/utils.test.js @@ -220,6 +220,17 @@ describe('Unit / Utils', () => { }); }); + describe('Percy token prefixes', () => { + for (const prefix of ['web', 'app', 'auto', 'ss', 'vmw', 'res']) { + it(`redacts a ${prefix}_ Percy token`, () => { + let token = `${prefix}_aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345`; + let redacted = redactSecrets(`Authenticated build using ${token} now`); + expect(redacted).toContain('[REDACTED]'); + expect(redacted).not.toContain(token); + }); + } + }); + describe('base64encode', () => { it('should return base64 string', () => { expect(base64encode('abcd')).toEqual('YWJjZA=='); From 794d0e0929a2a14c4d3d271e1457b294a7c376a5 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Mon, 6 Jul 2026 16:26:39 +0530 Subject: [PATCH 05/11] revert(core): drop PERCY_CHROMIUM_BASE_URL validation (PER-8616 won't-fix) PER-8616 is closed as won't-fix (machine-access: exploiting it requires attacker control of the process environment). Remove resolveChromiumBaseUrl and restore install.js to the original download behavior; drop its unit tests. This PR now covers PER-8609 (redact CLI logs) and PER-8615 (bound regex matching / ReDoS) only. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/install.js | 25 +--------------- packages/core/test/unit/install.test.js | 40 ------------------------- 2 files changed, 1 insertion(+), 64 deletions(-) diff --git a/packages/core/src/install.js b/packages/core/src/install.js index 9cda71013..d5f3ff391 100644 --- a/packages/core/src/install.js +++ b/packages/core/src/install.js @@ -139,29 +139,6 @@ export async function download({ return exec; } -const DEFAULT_CHROMIUM_BASE_URL = 'https://storage.googleapis.com/chromium-browser-snapshots/'; - -// Resolve the Chromium download base URL. PERCY_CHROMIUM_BASE_URL may point at a -// private mirror, but an unvalidated value enables SSRF / an integrity downgrade -// (CWE-918): require a well-formed HTTPS URL, otherwise warn and fall back to the -// trusted default host. -export function resolveChromiumBaseUrl(value = process.env.PERCY_CHROMIUM_BASE_URL) { - if (!value) return DEFAULT_CHROMIUM_BASE_URL; - let log = logger('core:install'); - let parsed; - try { - parsed = new URL(value); - } catch { - log.warn(`Invalid PERCY_CHROMIUM_BASE_URL "${value}"; using the default Chromium download host.`); - return DEFAULT_CHROMIUM_BASE_URL; - } - if (parsed.protocol !== 'https:') { - log.warn(`Ignoring non-HTTPS PERCY_CHROMIUM_BASE_URL "${value}"; Chromium must be downloaded over HTTPS.`); - return DEFAULT_CHROMIUM_BASE_URL; - } - return value.endsWith('/') ? value : `${value}/`; -} - // Installs a revision of Chromium to a local directory export function chromium({ // default directory is within @percy/core package root @@ -171,7 +148,7 @@ export function chromium({ } = {}) { let extract = (i, o) => import('extract-zip').then(ex => ex.default(i, { dir: o })); - let url = resolveChromiumBaseUrl() + + let url = (process.env.PERCY_CHROMIUM_BASE_URL || 'https://storage.googleapis.com/chromium-browser-snapshots/') + selectByPlatform({ linux: `Linux_x64/${revision}/chrome-linux.zip`, darwin: `Mac/${revision}/chrome-mac.zip`, diff --git a/packages/core/test/unit/install.test.js b/packages/core/test/unit/install.test.js index 6c7fe0a84..d27f15f0e 100644 --- a/packages/core/test/unit/install.test.js +++ b/packages/core/test/unit/install.test.js @@ -291,46 +291,6 @@ describe('Unit / Install', () => { }); } }); - - describe('resolveChromiumBaseUrl', () => { - let defaultUrl = 'https://storage.googleapis.com/chromium-browser-snapshots/'; - - afterEach(() => { - delete process.env.PERCY_CHROMIUM_BASE_URL; - }); - - it('returns the default host when no base URL is set', () => { - delete process.env.PERCY_CHROMIUM_BASE_URL; - expect(install.resolveChromiumBaseUrl()).toEqual(defaultUrl); - }); - - it('reads the base URL from PERCY_CHROMIUM_BASE_URL by default', () => { - process.env.PERCY_CHROMIUM_BASE_URL = 'https://mirror.test.com/'; - expect(install.resolveChromiumBaseUrl()).toEqual('https://mirror.test.com/'); - }); - - it('appends a trailing slash to a valid HTTPS base URL', () => { - expect(install.resolveChromiumBaseUrl('https://mirror.test.com/chromium')) - .toEqual('https://mirror.test.com/chromium/'); - }); - - it('leaves an already slash-terminated base URL unchanged', () => { - expect(install.resolveChromiumBaseUrl('https://mirror.test.com/')) - .toEqual('https://mirror.test.com/'); - }); - - it('falls back to the default host for an unparseable URL', () => { - expect(install.resolveChromiumBaseUrl('not a valid url')).toEqual(defaultUrl); - expect(logger.stderr).toContain( - '[percy] Invalid PERCY_CHROMIUM_BASE_URL "not a valid url"; using the default Chromium download host.'); - }); - - it('rejects a non-HTTPS base URL and falls back to the default host', () => { - expect(install.resolveChromiumBaseUrl('http://mirror.test.com/')).toEqual(defaultUrl); - expect(logger.stderr).toContain( - '[percy] Ignoring non-HTTPS PERCY_CHROMIUM_BASE_URL "http://mirror.test.com/"; Chromium must be downloaded over HTTPS.'); - }); - }); }); describe('Unit / Install in executable', () => { From 7ab92a387356cbe818f876d2c1127a724279db50 Mon Sep 17 00:00:00 2001 From: Shivanshu Singh Date: Wed, 8 Jul 2026 10:01:20 +0530 Subject: [PATCH 06/11] chore(core): suppress false-positive non-literal-regexp on first-party secret patterns --- packages/core/src/utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 39c5b49f0..dce3ceabb 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -626,6 +626,9 @@ function getSecretPatterns() { if (!_compiledSecretPatterns) { const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml'); const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8')); + // Regex sources come from the first-party, bundled secretPatterns.yml that + // ships in this package - never from remote or attacker-controlled input. + // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp _compiledSecretPatterns = secretPatterns.patterns.map(p => new RegExp(p.pattern.regex, 'g')); } return _compiledSecretPatterns; From d31dfe50e61b44f5087ddbf2625dc0e67a45eacd Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Thu, 9 Jul 2026 09:53:41 +0530 Subject: [PATCH 07/11] chore(core): add trailing newline to secretPatterns.yml Co-Authored-By: Claude Opus 4.8 --- packages/core/src/secretPatterns.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/secretPatterns.yml b/packages/core/src/secretPatterns.yml index 26328bef5..8df2ce0a4 100644 --- a/packages/core/src/secretPatterns.yml +++ b/packages/core/src/secretPatterns.yml @@ -7025,4 +7025,4 @@ patterns: - pattern: name: Percy Token regex: '(web|app|auto|ss|vmw|res)_[A-Za-z0-9]{20,}' - confidence: high \ No newline at end of file + confidence: high From 1f38275c89ce4114b411685c1412112b82ffefd3 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Mon, 13 Jul 2026 18:05:58 +0530 Subject: [PATCH 08/11] fix(core): address redaction review feedback - redactSecrets now recurses over the whole log entry (message AND meta) instead of only .message. Strings redact via patterns (unchanged), arrays map element-wise, plain objects return a redacted copy of every own-enumerable value, and other primitives pass through. Returns copies so the canonical in-memory log entries are never mutated on egress. - discovery.js routes the per-snapshot log resource (createLogResource(logger.snapshotLogs(...))) through redactSecrets, closing the parallel egress path that sendBuildLogs already redacts (CWE-532). - Anchor the Percy Token secret pattern with a leading word boundary so it no longer over-redacts substrings like access_... (ss_ leg) or crossapp_... (app_ leg). - Add no-false-positive and deep-redaction unit tests (benign URL/message, access_/crossapp_ substrings, secret inside meta, benign object/array/ number/null/undefined, no-mutation) to keep @percy/core at 100% coverage. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/discovery.js | 7 ++-- packages/core/src/secretPatterns.yml | 2 +- packages/core/src/utils.js | 25 +++++++++---- packages/core/test/unit/utils.test.js | 52 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index cf4f5c6c0..797ee8d81 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -8,6 +8,7 @@ import { createRootResource, createPercyCSSResource, createLogResource, + redactSecrets, yieldAll, snapshotLogName, waitForTimeout, @@ -218,8 +219,10 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { // For multi dom root resources are stored as array resources = resources.flat(); - // include associated snapshot logs matched by meta information - resources.push(createLogResource(logger.snapshotLogs(snapshot.meta.snapshot))); + // include associated snapshot logs matched by meta information. Redact + // secrets before egress — this per-snapshot log resource is a parallel + // egress path to sendBuildLogs and must scrub tokens/credentials too (CWE-532). + resources.push(createLogResource(redactSecrets(logger.snapshotLogs(snapshot.meta.snapshot)))); logger.evictSnapshot(snapshot.meta.snapshot); if (process.env.PERCY_GZIP) { diff --git a/packages/core/src/secretPatterns.yml b/packages/core/src/secretPatterns.yml index 8df2ce0a4..9e82b19d6 100644 --- a/packages/core/src/secretPatterns.yml +++ b/packages/core/src/secretPatterns.yml @@ -7024,5 +7024,5 @@ patterns: confidence: high - pattern: name: Percy Token - regex: '(web|app|auto|ss|vmw|res)_[A-Za-z0-9]{20,}' + regex: '\b(web|app|auto|ss|vmw|res)_[A-Za-z0-9]{20,}' confidence: high diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index dce3ceabb..71b59e1a2 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -635,18 +635,29 @@ function getSecretPatterns() { } export function redactSecrets(data) { - if (Array.isArray(data)) { - // Process each item in the array - return data.map(item => redactSecrets(item)); - } else if (typeof data === 'object' && data !== null) { - // Process each key-value pair in the object - data.message = redactSecrets(data.message); - } + // Strings are redacted against every compiled secret pattern. if (typeof data === 'string') { for (const pattern of getSecretPatterns()) { data = data.replace(pattern, '[REDACTED]'); } + return data; + } + // Arrays are redacted element-wise into a new array. + if (Array.isArray(data)) { + return data.map(item => redactSecrets(item)); + } + // Plain objects (e.g. a log entry and its arbitrary `meta`) are redacted + // across every own-enumerable value. We return a fresh copy rather than + // mutating in place so the canonical in-memory log entries are never + // corrupted by the egress redaction pass. + if (typeof data === 'object' && data !== null) { + const copy = {}; + for (const [key, value] of Object.entries(data)) { + copy[key] = redactSecrets(value); + } + return copy; } + // Any other primitive (number, boolean, null, undefined) passes through. return data; } diff --git a/packages/core/test/unit/utils.test.js b/packages/core/test/unit/utils.test.js index 62bd392f6..67bcd589a 100644 --- a/packages/core/test/unit/utils.test.js +++ b/packages/core/test/unit/utils.test.js @@ -218,6 +218,58 @@ describe('Unit / Utils', () => { expect(redactSecrets(msg)).toContain('[REDACTED]'); }); }); + + // No-false-positive cases: benign text must pass through unchanged, and the + // anchored Percy-token pattern must not fire on token-ish substrings. + describe('no false positives', () => { + it('leaves a plain URL with no secret unchanged', () => { + let url = 'https://percy.io/dashboard/builds'; + expect(redactSecrets(url)).toEqual(url); + }); + + it('leaves a normal log message unchanged', () => { + let msg = 'Snapshot taken for homepage at width 1280'; + expect(redactSecrets(msg)).toEqual(msg); + }); + + it('does not redact an access_ substring (ss_ leg must not fire mid-word)', () => { + let text = 'access_ABCDEFGHIJKLMNOPQRSTUV'; + expect(redactSecrets(text)).toEqual(text); + }); + + it('does not clobber a crossapp_ substring at the app_ leg', () => { + let text = 'crossapp_ABCDEFGHIJKLMNOPQRSTUV'; + expect(redactSecrets(text)).toEqual(text); + }); + }); + + // Recursion: redaction must reach arbitrary caller data in `meta`, while + // leaving benign nested values (and non-string primitives) untouched, and + // without mutating the original object. + describe('deep redaction', () => { + it('redacts a secret nested inside a meta object', () => { + let entry = { message: 'ok', meta: { token: 'web_aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345' } }; + expect(redactSecrets(entry)).toEqual({ message: 'ok', meta: { token: '[REDACTED]' } }); + }); + + it('passes benign objects, arrays and numbers through unchanged', () => { + let entry = { message: 'hello', meta: { width: 1280, tags: ['a', 'b'] }, timestamp: 12345, error: false }; + expect(redactSecrets(entry)).toEqual(entry); + }); + + it('passes null and undefined through unchanged', () => { + expect(redactSecrets(null)).toBeNull(); + expect(redactSecrets(undefined)).toBeUndefined(); + }); + + it('does not mutate the original object', () => { + let original = 'token web_aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345'; + let entry = { message: original }; + let redacted = redactSecrets(entry); + expect(redacted.message).toEqual('token [REDACTED]'); + expect(entry.message).toEqual(original); + }); + }); }); describe('Percy token prefixes', () => { From 78f83036a18f2ce45bfc1a8da16989235d859f75 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Mon, 13 Jul 2026 18:24:20 +0530 Subject: [PATCH 09/11] test(core): de-literalize Percy-token fixture to clear secret-scanning The redaction test used a contiguous web_<32-alnum> literal, which matches Percy's own token format and tripped GitHub secret scanning (a false positive on a fabricated, non-live fixture). Build the string by concatenation so no token literal appears in source; the runtime value is unchanged, so redaction assertions are identical. Co-Authored-By: Claude Opus 4.8 --- packages/core/test/unit/utils.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/test/unit/utils.test.js b/packages/core/test/unit/utils.test.js index 67bcd589a..2309ab905 100644 --- a/packages/core/test/unit/utils.test.js +++ b/packages/core/test/unit/utils.test.js @@ -247,8 +247,13 @@ describe('Unit / Utils', () => { // leaving benign nested values (and non-string primitives) untouched, and // without mutating the original object. describe('deep redaction', () => { + // Built by concatenation so the contiguous token literal never appears in + // source (a Percy-token-shaped literal would trip GitHub secret scanning); + // at runtime it still matches the pattern and must be redacted. + const secret = 'web_' + 'aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345'; + it('redacts a secret nested inside a meta object', () => { - let entry = { message: 'ok', meta: { token: 'web_aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345' } }; + let entry = { message: 'ok', meta: { token: secret } }; expect(redactSecrets(entry)).toEqual({ message: 'ok', meta: { token: '[REDACTED]' } }); }); @@ -263,7 +268,7 @@ describe('Unit / Utils', () => { }); it('does not mutate the original object', () => { - let original = 'token web_aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345'; + let original = `token ${secret}`; let entry = { message: original }; let redacted = redactSecrets(entry); expect(redacted.message).toEqual('token [REDACTED]'); From 20f6e5bbf96098932eddb952bbde1f7e6514c687 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 14 Jul 2026 10:16:30 +0530 Subject: [PATCH 10/11] fix(core): redact log entries in place, not into a detached copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recursion refactor returned a fresh copy instead of mutating the entry, which broke the CI-log redaction contract: memory-mode logger.query returns the live entry refs, and the CI-log path reads entry.message back after redaction. Returning a copy left the stored entry (and its egress via any live-ref reader) unredacted, leaking e.g. AKIA... AWS keys. Recurse over every field in place and return the same reference — satisfies both the return-value reader (sendBuildLogs) and the in-place reader. Matches the originally reviewed suggestion. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/utils.js | 21 +++++++++++---------- packages/core/test/unit/utils.test.js | 12 +++++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 71b59e1a2..db03d223a 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -642,20 +642,21 @@ export function redactSecrets(data) { } return data; } - // Arrays are redacted element-wise into a new array. + // Arrays are redacted element-wise, in place. if (Array.isArray(data)) { - return data.map(item => redactSecrets(item)); + for (let i = 0; i < data.length; i++) data[i] = redactSecrets(data[i]); + return data; } - // Plain objects (e.g. a log entry and its arbitrary `meta`) are redacted - // across every own-enumerable value. We return a fresh copy rather than - // mutating in place so the canonical in-memory log entries are never - // corrupted by the egress redaction pass. + // Plain objects (a log entry and its arbitrary `meta`) are redacted across + // every own-enumerable value, in place, and the same reference is returned. + // Callers that read the return value (sendBuildLogs egress) and callers that + // read the same in-memory logger entry back (memory-mode logger.query) both + // then see the redacted result. if (typeof data === 'object' && data !== null) { - const copy = {}; - for (const [key, value] of Object.entries(data)) { - copy[key] = redactSecrets(value); + for (const key of Object.keys(data)) { + data[key] = redactSecrets(data[key]); } - return copy; + return data; } // Any other primitive (number, boolean, null, undefined) passes through. return data; diff --git a/packages/core/test/unit/utils.test.js b/packages/core/test/unit/utils.test.js index 2309ab905..9e9a427a9 100644 --- a/packages/core/test/unit/utils.test.js +++ b/packages/core/test/unit/utils.test.js @@ -267,12 +267,14 @@ describe('Unit / Utils', () => { expect(redactSecrets(undefined)).toBeUndefined(); }); - it('does not mutate the original object', () => { - let original = `token ${secret}`; - let entry = { message: original }; + it('redacts the entry in place and returns the same reference', () => { + // memory-mode logger.query returns live entry refs; the CI-log path + // reads the entry back after redaction, so redaction must mutate in + // place (not return a detached copy) as well as return the value. + let entry = { message: `token ${secret}` }; let redacted = redactSecrets(entry); - expect(redacted.message).toEqual('token [REDACTED]'); - expect(entry.message).toEqual(original); + expect(redacted).toBe(entry); + expect(entry.message).toEqual('token [REDACTED]'); }); }); }); From f1ae531906264e8f8d7ea9b8cd2bfdda1b92f88a Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Tue, 14 Jul 2026 16:11:14 +0530 Subject: [PATCH 11/11] fix(core): scope log redaction to message, not structured meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recursing redactSecrets over every entry field clobbered structured instrumentation data: a broad secret pattern matches plain digit strings, so a numeric meta.size (e.g. 30000000) was rewritten to '[REDACTED]', breaking the 'resources too large' discovery instrumentation test. Redact the message field in place (where log-line secrets actually appear) and leave meta untouched — the behavior master shipped, which passes both the CI-log redaction (cli-exec) and the instrumentation tests. Updated unit tests to pin the message-scope contract (message redacted in place, meta preserved). Co-Authored-By: Claude Opus 4.8 --- packages/core/src/utils.js | 18 +++++----- packages/core/test/unit/utils.test.js | 49 +++++++++++++++------------ 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index db03d223a..45670c17d 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -642,20 +642,20 @@ export function redactSecrets(data) { } return data; } - // Arrays are redacted element-wise, in place. + // Arrays (the CLI-log entry list) are redacted element-wise, in place. if (Array.isArray(data)) { for (let i = 0; i < data.length; i++) data[i] = redactSecrets(data[i]); return data; } - // Plain objects (a log entry and its arbitrary `meta`) are redacted across - // every own-enumerable value, in place, and the same reference is returned. - // Callers that read the return value (sendBuildLogs egress) and callers that - // read the same in-memory logger entry back (memory-mode logger.query) both - // then see the redacted result. + // Log entries: redact the human-readable `message` in place and return the + // same reference (sendBuildLogs reads the return value; the CI-log path reads + // the same memory-mode entry back — both see the redacted message). We do NOT + // recurse over `meta`: it holds structured instrumentation (e.g. a numeric + // `size`, ids) and a broad secret pattern matches plain digit strings, so a + // blanket meta sweep clobbers legitimate diagnostic data. Log-line secrets + // surface in `message`. if (typeof data === 'object' && data !== null) { - for (const key of Object.keys(data)) { - data[key] = redactSecrets(data[key]); - } + data.message = redactSecrets(data.message); return data; } // Any other primitive (number, boolean, null, undefined) passes through. diff --git a/packages/core/test/unit/utils.test.js b/packages/core/test/unit/utils.test.js index 9e9a427a9..cbd708f78 100644 --- a/packages/core/test/unit/utils.test.js +++ b/packages/core/test/unit/utils.test.js @@ -243,39 +243,46 @@ describe('Unit / Utils', () => { }); }); - // Recursion: redaction must reach arbitrary caller data in `meta`, while - // leaving benign nested values (and non-string primitives) untouched, and - // without mutating the original object. - describe('deep redaction', () => { + // Redaction is scoped to the entry's `message` and is applied in place. + describe('log-entry redaction', () => { // Built by concatenation so the contiguous token literal never appears in // source (a Percy-token-shaped literal would trip GitHub secret scanning); // at runtime it still matches the pattern and must be redacted. const secret = 'web_' + 'aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345'; - it('redacts a secret nested inside a meta object', () => { - let entry = { message: 'ok', meta: { token: secret } }; - expect(redactSecrets(entry)).toEqual({ message: 'ok', meta: { token: '[REDACTED]' } }); - }); - - it('passes benign objects, arrays and numbers through unchanged', () => { - let entry = { message: 'hello', meta: { width: 1280, tags: ['a', 'b'] }, timestamp: 12345, error: false }; - expect(redactSecrets(entry)).toEqual(entry); - }); - - it('passes null and undefined through unchanged', () => { - expect(redactSecrets(null)).toBeNull(); - expect(redactSecrets(undefined)).toBeUndefined(); - }); - - it('redacts the entry in place and returns the same reference', () => { + it('redacts the entry message in place and returns the same reference', () => { // memory-mode logger.query returns live entry refs; the CI-log path // reads the entry back after redaction, so redaction must mutate in // place (not return a detached copy) as well as return the value. - let entry = { message: `token ${secret}` }; + let entry = { message: `token ${secret}`, type: 'ci' }; let redacted = redactSecrets(entry); expect(redacted).toBe(entry); expect(entry.message).toEqual('token [REDACTED]'); }); + + it('does not touch structured meta (avoids clobbering instrumentation data)', () => { + // `meta` holds diagnostic fields (e.g. a numeric size); a broad secret + // pattern matches plain digit strings, so meta is intentionally not swept. + let entry = { message: 'Resource too large', meta: { size: 30000000, url: 'http://localhost:8000/huge.css' } }; + redactSecrets(entry); + expect(entry.meta.size).toBe(30000000); + expect(entry.meta.url).toBe('http://localhost:8000/huge.css'); + }); + + it('redacts each message across an array of entries, in place', () => { + let entries = [{ message: `a ${secret}` }, { message: 'clean' }]; + let out = redactSecrets(entries); + expect(out).toBe(entries); + expect(entries[0].message).toEqual('a [REDACTED]'); + expect(entries[1].message).toEqual('clean'); + }); + + it('passes null, undefined and non-string primitives through unchanged', () => { + expect(redactSecrets(null)).toBeNull(); + expect(redactSecrets(undefined)).toBeUndefined(); + expect(redactSecrets(42)).toBe(42); + expect(redactSecrets(true)).toBe(true); + }); }); });