Skip to content

Commit 3f32984

Browse files
Shivanshu-07claude
andauthored
security: redact CLI logs, bound regex matching (ReDoS) (PER-8609/8615) (#2279)
* security: redact CLI logs, bound regex matching (ReDoS), validate Chromium base URL (PER-8609/8615/8616) 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix(core): redact Percy tokens in logs (PER-8609) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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 <noreply@anthropic.com> * chore(core): suppress false-positive non-literal-regexp on first-party secret patterns * chore(core): add trailing newline to secretPatterns.yml Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(core): redact log entries in place, not into a detached copy 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 <noreply@anthropic.com> * fix(core): scope log redaction to message, not structured meta 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4b6b8a commit 3f32984

6 files changed

Lines changed: 147 additions & 22 deletions

File tree

packages/core/src/discovery.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createRootResource,
99
createPercyCSSResource,
1010
createLogResource,
11+
redactSecrets,
1112
yieldAll,
1213
snapshotLogName,
1314
waitForTimeout,
@@ -219,8 +220,10 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) {
219220
// For multi dom root resources are stored as array
220221
resources = resources.flat();
221222

222-
// include associated snapshot logs matched by meta information
223-
resources.push(createLogResource(logger.snapshotLogs(snapshot.meta.snapshot)));
223+
// include associated snapshot logs matched by meta information. Redact
224+
// secrets before egress — this per-snapshot log resource is a parallel
225+
// egress path to sendBuildLogs and must scrub tokens/credentials too (CWE-532).
226+
resources.push(createLogResource(redactSecrets(logger.snapshotLogs(snapshot.meta.snapshot))));
224227
logger.evictSnapshot(snapshot.meta.snapshot);
225228

226229
if (process.env.PERCY_GZIP) {

packages/core/src/percy.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,10 @@ export class Percy {
863863
if (!process.env.PERCY_TOKEN) return;
864864
try {
865865
const logsObject = {
866-
clilogs: logger.query(log => !['ci'].includes(log.debug))
866+
// Redact secrets from CLI logs before egress to the Percy API — these
867+
// can contain tokens or URLs with embedded credentials (CWE-532). The
868+
// cilogs below were already redacted; clilogs were not.
869+
clilogs: redactSecrets(logger.query(log => !['ci'].includes(log.debug)))
867870
};
868871

869872
// Only add CI logs if not disabled voluntarily.

packages/core/src/secretPatterns.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7021,4 +7021,8 @@ patterns:
70217021
- pattern:
70227022
name: Bitcoin Address
70237023
regex: '^[13][a-km-zA-HJ-NP-Z0-9]{26,33}$'
7024-
confidence: high
7024+
confidence: high
7025+
- pattern:
7026+
name: Percy Token
7027+
regex: '\b(web|app|auto|ss|vmw|res)_[A-Za-z0-9]{20,}'
7028+
confidence: high

packages/core/src/snapshot.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,31 @@ function validateAndFixSnapshotUrl(snapshot) {
4545
// used to deserialize regular expression strings
4646
const RE_REGEXP = /^\/(.+)\/(\w+)?$/;
4747

48+
// Upper bound on the snapshot name length we will run user-controllable
49+
// regex/glob matching against. A crafted, very long snapshot name reaching this
50+
// matcher (e.g. via the local API) combined with a backtracking-prone pattern
51+
// could otherwise trigger catastrophic backtracking / ReDoS (CWE-1333). Real
52+
// snapshot names are short; an over-long name simply does not match patterns.
53+
const MAX_MATCH_INPUT_LENGTH = 2048;
54+
4855
// Returns true or false if a snapshot matches the provided include and exclude predicates. A
4956
// predicate can be an array of predicates, a regular expression, a glob pattern, or a function.
5057
function snapshotMatches(snapshot, include, exclude) {
5158
// support an options object as the second argument
5259
if (include?.include || include?.exclude) ({ include, exclude } = include);
5360

61+
// guard pattern matching against pathologically long inputs (ReDoS)
62+
let patternSafe = typeof snapshot.name === 'string' && snapshot.name.length <= MAX_MATCH_INPUT_LENGTH;
63+
5464
// recursive predicate test function
5565
let test = (predicate, fallback) => {
5666
if (predicate && typeof predicate === 'string') {
57-
// snapshot name matches exactly or matches a glob
67+
// exact match is always safe; glob matching is only run on bounded input
5868
let result = snapshot.name === predicate ||
59-
micromatch.isMatch(snapshot.name, predicate);
69+
(patternSafe && micromatch.isMatch(snapshot.name, predicate));
6070

61-
// snapshot might match a string-based regexp pattern
62-
if (!result) {
71+
// snapshot might match a string-based regexp pattern (bounded input only)
72+
if (!result && patternSafe) {
6373
try {
6474
let [, parsed, flags] = RE_REGEXP.exec(predicate) || [];
6575
result = !!parsed && new RegExp(parsed, flags).test(snapshot.name);
@@ -68,8 +78,8 @@ function snapshotMatches(snapshot, include, exclude) {
6878

6979
return result;
7080
} else if (predicate instanceof RegExp) {
71-
// snapshot matches a regular expression
72-
return predicate.test(snapshot.name);
81+
// snapshot matches a regular expression (bounded input only)
82+
return patternSafe && predicate.test(snapshot.name);
7383
} else if (typeof predicate === 'function') {
7484
// advanced matching
7585
return predicate(snapshot);

packages/core/src/utils.js

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -715,22 +715,50 @@ export async function withRetries(fn, { count, onRetry, signal, throwOn }) {
715715
}
716716
}
717717

718-
export function redactSecrets(data) {
719-
const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml');
720-
const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8'));
721-
722-
if (Array.isArray(data)) {
723-
// Process each item in the array
724-
return data.map(item => redactSecrets(item));
725-
} else if (typeof data === 'object' && data !== null) {
726-
// Process each key-value pair in the object
727-
data.message = redactSecrets(data.message);
718+
// Lazily load and compile the secret patterns once. The pattern file holds
719+
// ~1.7k regexes; parsing the YAML and compiling every RegExp on each call made
720+
// redactSecrets O(patterns) per string and re-read the file for every recursive
721+
// call. Since redactSecrets now runs over the full CLI log array on egress
722+
// (sendBuildLogs), that per-call cost is paid hundreds of times and could blow
723+
// past test/runtime timeouts. Compile once and reuse.
724+
let _compiledSecretPatterns;
725+
function getSecretPatterns() {
726+
if (!_compiledSecretPatterns) {
727+
const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml');
728+
const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8'));
729+
// Regex sources come from the first-party, bundled secretPatterns.yml that
730+
// ships in this package - never from remote or attacker-controlled input.
731+
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
732+
_compiledSecretPatterns = secretPatterns.patterns.map(p => new RegExp(p.pattern.regex, 'g'));
728733
}
734+
return _compiledSecretPatterns;
735+
}
736+
737+
export function redactSecrets(data) {
738+
// Strings are redacted against every compiled secret pattern.
729739
if (typeof data === 'string') {
730-
for (const pattern of secretPatterns.patterns) {
731-
data = data.replace(new RegExp(pattern.pattern.regex, 'g'), '[REDACTED]');
740+
for (const pattern of getSecretPatterns()) {
741+
data = data.replace(pattern, '[REDACTED]');
732742
}
743+
return data;
744+
}
745+
// Arrays (the CLI-log entry list) are redacted element-wise, in place.
746+
if (Array.isArray(data)) {
747+
for (let i = 0; i < data.length; i++) data[i] = redactSecrets(data[i]);
748+
return data;
749+
}
750+
// Log entries: redact the human-readable `message` in place and return the
751+
// same reference (sendBuildLogs reads the return value; the CI-log path reads
752+
// the same memory-mode entry back — both see the redacted message). We do NOT
753+
// recurse over `meta`: it holds structured instrumentation (e.g. a numeric
754+
// `size`, ids) and a broad secret pattern matches plain digit strings, so a
755+
// blanket meta sweep clobbers legitimate diagnostic data. Log-line secrets
756+
// surface in `message`.
757+
if (typeof data === 'object' && data !== null) {
758+
data.message = redactSecrets(data.message);
759+
return data;
733760
}
761+
// Any other primitive (number, boolean, null, undefined) passes through.
734762
return data;
735763
}
736764

packages/core/test/unit/utils.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,83 @@ describe('Unit / Utils', () => {
218218
expect(redactSecrets(msg)).toContain('[REDACTED]');
219219
});
220220
});
221+
222+
// No-false-positive cases: benign text must pass through unchanged, and the
223+
// anchored Percy-token pattern must not fire on token-ish substrings.
224+
describe('no false positives', () => {
225+
it('leaves a plain URL with no secret unchanged', () => {
226+
let url = 'https://percy.io/dashboard/builds';
227+
expect(redactSecrets(url)).toEqual(url);
228+
});
229+
230+
it('leaves a normal log message unchanged', () => {
231+
let msg = 'Snapshot taken for homepage at width 1280';
232+
expect(redactSecrets(msg)).toEqual(msg);
233+
});
234+
235+
it('does not redact an access_ substring (ss_ leg must not fire mid-word)', () => {
236+
let text = 'access_ABCDEFGHIJKLMNOPQRSTUV';
237+
expect(redactSecrets(text)).toEqual(text);
238+
});
239+
240+
it('does not clobber a crossapp_ substring at the app_ leg', () => {
241+
let text = 'crossapp_ABCDEFGHIJKLMNOPQRSTUV';
242+
expect(redactSecrets(text)).toEqual(text);
243+
});
244+
});
245+
246+
// Redaction is scoped to the entry's `message` and is applied in place.
247+
describe('log-entry redaction', () => {
248+
// Built by concatenation so the contiguous token literal never appears in
249+
// source (a Percy-token-shaped literal would trip GitHub secret scanning);
250+
// at runtime it still matches the pattern and must be redacted.
251+
const secret = 'web_' + 'aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345';
252+
253+
it('redacts the entry message in place and returns the same reference', () => {
254+
// memory-mode logger.query returns live entry refs; the CI-log path
255+
// reads the entry back after redaction, so redaction must mutate in
256+
// place (not return a detached copy) as well as return the value.
257+
let entry = { message: `token ${secret}`, type: 'ci' };
258+
let redacted = redactSecrets(entry);
259+
expect(redacted).toBe(entry);
260+
expect(entry.message).toEqual('token [REDACTED]');
261+
});
262+
263+
it('does not touch structured meta (avoids clobbering instrumentation data)', () => {
264+
// `meta` holds diagnostic fields (e.g. a numeric size); a broad secret
265+
// pattern matches plain digit strings, so meta is intentionally not swept.
266+
let entry = { message: 'Resource too large', meta: { size: 30000000, url: 'http://localhost:8000/huge.css' } };
267+
redactSecrets(entry);
268+
expect(entry.meta.size).toBe(30000000);
269+
expect(entry.meta.url).toBe('http://localhost:8000/huge.css');
270+
});
271+
272+
it('redacts each message across an array of entries, in place', () => {
273+
let entries = [{ message: `a ${secret}` }, { message: 'clean' }];
274+
let out = redactSecrets(entries);
275+
expect(out).toBe(entries);
276+
expect(entries[0].message).toEqual('a [REDACTED]');
277+
expect(entries[1].message).toEqual('clean');
278+
});
279+
280+
it('passes null, undefined and non-string primitives through unchanged', () => {
281+
expect(redactSecrets(null)).toBeNull();
282+
expect(redactSecrets(undefined)).toBeUndefined();
283+
expect(redactSecrets(42)).toBe(42);
284+
expect(redactSecrets(true)).toBe(true);
285+
});
286+
});
287+
});
288+
289+
describe('Percy token prefixes', () => {
290+
for (const prefix of ['web', 'app', 'auto', 'ss', 'vmw', 'res']) {
291+
it(`redacts a ${prefix}_ Percy token`, () => {
292+
let token = `${prefix}_aB3dE7gH1jK4mN6pQ9sTuVwXyZ012345`;
293+
let redacted = redactSecrets(`Authenticated build using ${token} now`);
294+
expect(redacted).toContain('[REDACTED]');
295+
expect(redacted).not.toContain(token);
296+
});
297+
}
221298
});
222299

223300
describe('base64encode', () => {

0 commit comments

Comments
 (0)