Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/core/src/percy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/secretPatterns.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 16 additions & 6 deletions packages/core/src/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,10 +615,23 @@
}
}

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'));

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp Warning

RegExp() called with a p function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.
}
return _compiledSecretPatterns;
}

export function redactSecrets(data) {
if (Array.isArray(data)) {
// Process each item in the array
return data.map(item => redactSecrets(item));
Expand All @@ -627,8 +640,8 @@
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;
Expand Down
11 changes: 11 additions & 0 deletions packages/core/test/unit/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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==');
Expand Down
Loading