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
25 changes: 24 additions & 1 deletion packages/core/src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand Down
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
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 @@ -553,10 +553,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 @@ -565,8 +578,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
40 changes: 40 additions & 0 deletions packages/core/test/unit/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading