[APS-19009] security(cli): --ignore-scripts + validate npm_dependencies (lifecycle-script RCE) - #1172
[APS-19009] security(cli): --ignore-scripts + validate npm_dependencies (lifecycle-script RCE)#1172Rohannagariya1 wants to merge 12 commits into
Conversation
…proxy Ships the low-blast-radius subset of the CLI critical findings. APS-19010 — env-var API redirect: only honour BSTACK_CYPRESS_NODE_ENV url overrides (RAILS_HOST/UPLOAD_URL/DASHBOARD_URL/USAGE_REPORTING_URL) when they point at *.browserstack.com / *.bsstag.com / localhost; otherwise warn and fall back to the production defaults. APS-19011 — validate the API-supplied upload_url host before using it for the tests.zip upload; warn (do NOT cert-pin) when an HTTP(S) proxy routes all API traffic incl. credentials; structural-only JWT check on the TestHub token (defence-in-depth — the CLI has no key to verify the signature). APS-19008 (browserstack.json half) — read browserstack.json via JSON.parse(fs.readFileSync) instead of require() so a .js config cannot execute arbitrary code; require a .json extension and project-root path containment. New bin/helpers/securityValidation.js (stdlib-only): isAllowedBrowserstackUrl, isPathInsideBase, isWellFormedJwt, covered by test/unit/.../securityValidation.js (13 tests, both paths). Deliberately NOT changed (accepted-risk / opt-in — needs product decision): - cypress.config.js is legitimately JS that imports plugins; NOT sandboxed by default (a vm sandbox breaks real configs). APS-19008 cypress-config half. - npm_dependencies install still runs lifecycle scripts (APS-19009): NOT fixed here. Note: PR #1128's repo .npmrc (ignore-scripts=true) does NOT protect end users — packageInstaller copies the *user's* .npmrc into the temp install dir, not the CLI's. APS-19009's real fix (--ignore-scripts + package-name/version validation + shell:false) remains OPEN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es names/versions browserstack.json's npm_dependencies were merged into a temp package.json and installed with `npm install` and no `--ignore-scripts`, so a PR-supplied malicious package's lifecycle script (postinstall) executed on CI (RCE, credential theft). - Add `--ignore-scripts` to both npm install invocations (the RCE fix). npm_dependencies is documented pure-JS only. - Validate each dependency name (standard npm package-name regex) and version (semver/dist-tag charset only) before writing package.json, rejecting git-url / file: / path / alternate-registry specs (dependency confusion / code-exec via spec). - shell:true is retained deliberately: the command line is fully static (names live in package.json data, never on the command line -> no injection surface) and it is required for the output redirection and for invoking npm.cmd on Windows. Tested: validation rejects shell-metachar/git-url/file/$() specs, accepts normal semver; --ignore-scripts present in both installs; syntax clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| logger.debug(`Running NPM install command: npm install --legacy-peer-deps --loglevel verbose > ../npm_install_debug.log`); | ||
| nodeProcess = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['install', '--legacy-peer-deps', '--loglevel', 'verbose', '>', '../npm_install_debug.log', '2>&1'], {cwd: packageDir, shell: true}); | ||
| logger.debug(`Running NPM install command: npm install --legacy-peer-deps --ignore-scripts --loglevel verbose > ../npm_install_debug.log`); | ||
| nodeProcess = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['install', '--legacy-peer-deps', '--ignore-scripts', '--loglevel', 'verbose', '>', '../npm_install_debug.log', '2>&1'], {cwd: packageDir, shell: true}); |
| logger.debug(`Running NPM install command: 'npm install --loglevel verbose > ../npm_install_debug.log'`); | ||
| nodeProcess = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['install', '--loglevel', 'verbose', '>', '../npm_install_debug.log', '2>&1'], {cwd: packageDir, shell: true}); | ||
| logger.debug(`Running NPM install command: 'npm install --ignore-scripts --loglevel verbose > ../npm_install_debug.log'`); | ||
| nodeProcess = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['install', '--ignore-scripts', '--loglevel', 'verbose', '>', '../npm_install_debug.log', '2>&1'], {cwd: packageDir, shell: true}); |
| return false; | ||
| } | ||
| const base = path.resolve(baseDir || process.cwd()); | ||
| const resolved = path.resolve(base, candidatePath); |
| return false; | ||
| } | ||
| const base = path.resolve(baseDir || process.cwd()); | ||
| const resolved = path.resolve(base, candidatePath); |
| // PR-supplied .js config would run arbitrary code, APS-19008). Also require | ||
| // a .json extension and that the file resolves inside the project root so a | ||
| // crafted --config-file cannot point outside the project or at a script. | ||
| const resolvedPath = path.resolve(bsConfigPath); |
…ndencies Locks the two security invariants the fix introduces: - packageInstall passes --ignore-scripts to the npm spawn (lifecycle-script RCE guard) - setupPackageFolder rejects a non-semver/git-url npm_dependencies version and never writes package.json (dependency-confusion / spec smuggling guard) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add justified nosemgrep for spawn-shell-true on both npm-install spawns (static argv, shell needed only for '>' redirect + npm.cmd on Windows). - Sync securityValidation.js/utils.js with the path-join nosemgrep suppressions from the #1141 branch so the (false-positive) path-traversal findings clear. - NPM_NAME_RE: allow A-Z so legacy registry names (e.g. JSONStream) are not rejected; still blocks git-url/file:/path/alternate-registry specs. Test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…st fix) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| return false; | ||
| } | ||
| // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- these resolves ARE the traversal guard: the value is normalized here only so the containment check below can reject anything outside `base`. | ||
| const base = path.resolve(baseDir || process.cwd()); |
…ng honors same-line) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…npm-deps-hardening
…me-install fallback (APS-19009) The npm_dependencies validation added for APS-19009 was being caught by packageSetupAndInstaller's generic install-error catch and downgraded to the "dependencies will be installed in runtime" fallback. As a result a malicious or invalid dependency spec (git-url, file:, path, alternate-registry) was never actually blocked — the run proceeded, tests were uploaded, and the bad dep was deferred to the runtime install path. Mark the validation error (isNpmDependencyValidationError) and re-reject it from packageSetupAndInstaller so runs.js aborts before upload with a clear error and a non-zero exit code. Genuine install failures (network, registry, peer-deps) still fall back to runtime install unchanged, so the legitimate flow is untouched. Adds a regression test asserting packageSetupAndInstaller rejects (does not resolve) on a validation error. Verified live via `browserstack-cypress run`: malicious dep -> exit 1, no build created; valid deps (incl. scoped + legacy upper-case names) -> build created with --ignore-scripts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ep --ignore-scripts (APS-19009) The prior commit (8441042) hard-aborted a run whenever an npm_dependencies spec failed a strict name+version regex. BigQuery analysis of 221,133 real cypress-cli builds over 90 days showed this would abort 7,446 builds (3.4%): - 6,914 BrowserStack's OWN Cypress SDK CI (installs the CLI from its git branches, e.g. browserstack-cypress-cli@github.com/browserstack...#master) - 405 real enterprise customers using legitimate non-registry specs (private Artifactory tarball URLs, file: vendored modules) - 127 actual attack/CTF payloads (the only ones we want to block) i.e. ~57 legitimate builds broken per attack caught. The documented RCE (lifecycle-script execution) is already fully closed by --ignore-scripts for EVERY spec, so the hard abort added large blast radius for no real security gain. Changes: - Keep --ignore-scripts (the actual RCE remediation) unchanged. - Drop version-spec validation entirely: git / file: / tarball-url / private-registry versions are legitimate and must run. - Validate only the package NAME; an invalid name (shell-metacharacter / command-injection payload) is SKIPPED with a warning, and the session continues with the remaining valid deps -- never a hard abort. - Revert runs.js abort/try-catch; packageSetupAndInstaller no longer rejects. - Tests updated: allow git-url spec, skip bad name without aborting, and assert packageSetupAndInstaller resolves (never blocks a session). 22/22. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…npm-deps-hardening
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note on the red
|
…npm-deps-hardening
Rohannagariya1
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 1 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| const safeDependencies = {}; | ||
| for (const depName of Object.keys(combinedDependencies || {})) { | ||
| const depVersion = combinedDependencies[depName]; | ||
| if (!NPM_NAME_RE.test(depName) || typeof depVersion !== 'string') { |
There was a problem hiding this comment.
[Low] PR description overstates version-spec validation
The PR body says the fix validates version specs and rejects git+ssh:// / file: / path / alternate-registry specs. The code intentionally does NOT — it only checks typeof depVersion === 'string', and a new test asserts a git-URL spec is accepted. This is not a security gap (the version is written as JSON data and --ignore-scripts blocks install/prepare execution), but the description is stale.
Suggestion: No code change needed. Update the PR body to match the code: package names are validated; version specs are deliberately left unvalidated because git/file/tarball specs are legitimate and RCE is closed by --ignore-scripts.
Reviewer: stack:code-review
Claude Code PR ReviewPR: #1172 • Head: ecc9674 • Reviewers: stack:code-review SummaryHardens the Cypress CLI against the Review Table
Findings
What's Done Well
Verdict: PASS |
Issue (APS-19009, Critical)
browserstack.jsonnpm_dependenciesare merged into a temppackage.jsonand installed vianpm installwithout--ignore-scripts. A PR-supplied malicious package'spostinstalltherefore executes on the CI runner → RCE + theft ofBROWSERSTACK_ACCESS_KEY/GITHUB_TOKEN.Fix (minimal)
--ignore-scriptsadded to bothnpm installinvocations — the core RCE fix (blocks lifecycle scripts).npm_dependenciesis documented pure-JS only.package.json: standard npm package-name regex + a semver/dist-tag-only version charset, rejectinggit+ssh:///file:/ path / alternate-registry specs (dependency confusion / code-exec via spec).shell:trueretained deliberately (documented in code): the command line is fully static — dependency names live inpackage.jsondata, never on the command line, so there is no injection surface — andshell:trueis required for the>output redirection and to invokenpm.cmdon Windows. Flipping toshell:falsewould risk breaking Windows for zero security gain.Testing
evil; curl|sh,git+ssh://...,file:../../etc,$(env); accepts^4.17.21,~2.0.0,13.6.0.--ignore-scriptspresent in both install arg arrays;node --checkclean.Refs: APS-19009 (INJ-007 / INF-006)