fix(ci): scrub provider secrets from env around PR-gate resolver + npm install

Address two related Veria comments on the claude_code_compat_pr_gate
job:

1. (line ~2320) The PR-gate version resolver is PR-controlled Python
   that runs in the same CircleCI job as the provider secrets injected
   later into the proxy container. A malicious PR could modify
   tests/claude_code/pr_gate_version_resolver.py to read
   ANTHROPIC_API_KEY / AWS_* / VERTEXAI_* / AZURE_FOUNDRY_* /
   GITHUB_TOKEN out of os.environ and exfiltrate them over the
   resolver's outbound npm registry HTTPS call.

2. (line ~2335) `npm install -g @anthropic-ai/claude-code` runs the
   package's `postinstall: node install.cjs` script (verified
   against the npm registry metadata for @anthropic-ai/claude-code),
   which executes arbitrary code from npm with the full job env.
   `claude --version` on the next line also runs package code. A
   compromised package release (or transitive registry hijack) could
   exfiltrate the same provider credentials. --ignore-scripts is not
   viable: the postinstall is the step that fetches the platform
   binary, so skipping it would leave the install unusable.

Mitigation:

- Wrap both invocations in `env -i` with a minimal allowlist
  (PATH / HOME / USER / TERM / LANG / LC_ALL / TMPDIR — plus
  NVM_DIR + CLAUDE_CODE_VERSION on the npm step). BASH_ENV is
  intentionally NOT passed through so the scrubbed subshell can't
  re-source prior steps' exports.

- Pin the scrub with two new unit tests in
  test_circleci_pr_gate_wiring.py so a future YAML refactor cannot
  silently drop the env -i wrapper and revert the mitigation. The
  tests verify both that `env -i` is present in each step and that
  it precedes the actual at-risk invocation in the command body.

Verified locally that `env -i PATH=$PATH HOME=$HOME ... uv run
--no-sync python -m tests.claude_code.pr_gate_version_resolver` still
resolves and prints a CLI version successfully.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Cursor Agent 2026-05-18 00:00:35 +00:00
parent a1f0ef2a99
commit c941291567
No known key found for this signature in database
2 changed files with 145 additions and 5 deletions

View file

@ -2315,9 +2315,26 @@ jobs:
- run:
name: Resolve Claude Code CLI version (newest published >= 3 days ago)
command: |
# Run the resolver from the PR's code; capture the version
# to BASH_ENV so subsequent steps see CLAUDE_CODE_VERSION.
CLAUDE_CODE_VERSION=$(uv run --no-sync python -m tests.claude_code.pr_gate_version_resolver)
# The resolver is PR-controlled Python code: a malicious PR
# could otherwise modify this module to read ANTHROPIC_API_KEY
# / AWS_* / VERTEXAI_* / AZURE_FOUNDRY_* / GITHUB_TOKEN out of
# the job env (CircleCI injects project + context env vars
# into every step) and exfiltrate them via the outbound npm
# HTTPS call. Run the resolver under `env -i` with a
# minimal allowlist so it sees only PATH/HOME/TMPDIR/locale
# vars — enough for uv, Python's TLS stack, and the npm
# registry HTTPS call to function, but no credentials.
# BASH_ENV is intentionally NOT passed through, so the
# resolver subshell skips sourcing any prior step's exports.
CLAUDE_CODE_VERSION=$(env -i \
PATH="$PATH" \
HOME="$HOME" \
USER="${USER:-circleci}" \
TERM="${TERM:-dumb}" \
LANG="${LANG:-C.UTF-8}" \
LC_ALL="${LC_ALL:-}" \
TMPDIR="${TMPDIR:-/tmp}" \
uv run --no-sync python -m tests.claude_code.pr_gate_version_resolver)
echo "Selected @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
echo "export CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" >> "$BASH_ENV"
- run:
@ -2332,8 +2349,32 @@ jobs:
nvm use 20
NODE_BIN_DIR="$(dirname "$(command -v node)")"
echo "export PATH=\"${NODE_BIN_DIR}:\$PATH\"" >> "$BASH_ENV"
npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
claude --version
# `npm install -g @anthropic-ai/claude-code` runs the
# package's `postinstall: node install.cjs` script, which
# downloads the platform-specific `claude` binary — it
# executes arbitrary code from npm with the full job env.
# `claude --version` on the next line also runs package
# code. Both must not see provider credentials: a
# compromised package release (or transitive registry
# hijack) could exfiltrate ANTHROPIC_API_KEY / AWS_* /
# VERTEXAI_* / AZURE_FOUNDRY_* / GITHUB_TOKEN. Run them
# under `env -i` with the same allowlist as the resolver
# step plus CLAUDE_CODE_VERSION (for package-spec
# interpolation) and the Node toolchain bits. We cannot
# use `--ignore-scripts`: the postinstall is the step that
# fetches the platform binary, so skipping it would leave
# the install unusable.
env -i \
PATH="$PATH" \
HOME="$HOME" \
USER="${USER:-circleci}" \
TERM="${TERM:-dumb}" \
LANG="${LANG:-C.UTF-8}" \
LC_ALL="${LC_ALL:-}" \
TMPDIR="${TMPDIR:-/tmp}" \
NVM_DIR="$NVM_DIR" \
CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" \
bash -c 'npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" && claude --version'
- run:
name: Run LiteLLM proxy from PR's code
command: |

View file

@ -156,6 +156,105 @@ def test_pr_gate_job_persists_compat_result_artifacts(
)
def _find_step_command(job: dict, name_substring: str) -> str:
"""Return the `command` text of the first run-step whose `name`
contains `name_substring`; empty string if no match.
The PR-gate job has many run-steps; matching by a substring of
`name:` keeps this helper resilient to non-load-bearing renames
(e.g. "Resolve Claude Code CLI version (newest published >= 3 days
ago)" -> "Resolve Claude Code CLI version") without coupling the
test to the full step name.
"""
for step in job.get("steps", []):
if not (isinstance(step, dict) and "run" in step):
continue
run = step["run"]
if not isinstance(run, dict):
continue
if name_substring in (run.get("name") or ""):
return run.get("command") or ""
return ""
def test_pr_gate_resolver_step_scrubs_secrets_from_env(
circleci_config: dict,
) -> None:
"""The version resolver is PR-controlled Python code that runs in
the same CircleCI job as the provider secrets injected later into
the proxy container. If the resolver step doesn't scrub the env,
a malicious PR can edit `tests/claude_code/pr_gate_version_resolver`
to read ANTHROPIC_API_KEY / AWS_* / VERTEXAI_* / AZURE_FOUNDRY_* /
GITHUB_TOKEN out of `os.environ` and exfiltrate them over the
outbound npm registry HTTPS call.
Pin the `env -i` scrub here so the mitigation cannot silently
regress in a future YAML refactor.
"""
job = circleci_config["jobs"][JOB_NAME]
command = _find_step_command(job, "Resolve Claude Code CLI version")
assert command, "PR gate must have a step that resolves the CLI version."
assert "env -i" in command, (
"The version-resolver step must wrap the resolver invocation in "
"`env -i` so PR-controlled Python cannot read provider secrets "
"from the CircleCI job env."
)
# The actual resolver invocation must be downstream of `env -i` —
# i.e. they must appear in that order in the command body. Use
# `rindex` for the resolver match in case the surrounding comment
# block also mentions the module in prose; the actual `python -m`
# invocation is always the last occurrence.
env_i_idx = command.index("env -i")
resolver_idx = command.rindex("tests.claude_code.pr_gate_version_resolver")
assert env_i_idx < resolver_idx, (
"`env -i` must precede the resolver invocation; otherwise the "
"resolver still sees the unscrubbed env."
)
def test_pr_gate_npm_install_step_scrubs_secrets_from_env(
circleci_config: dict,
) -> None:
"""`npm install -g @anthropic-ai/claude-code` runs the package's
`postinstall: node install.cjs` script, which executes arbitrary
code from npm with the full job env and the job env carries
provider credentials. `claude --version` on the next line is also
package code. A compromised package release (or a transitive
registry hijack) could exfiltrate ANTHROPIC_API_KEY / AWS_* /
VERTEXAI_* / AZURE_FOUNDRY_* / GITHUB_TOKEN.
Pin the `env -i` scrub on the install step so the mitigation
cannot silently regress.
"""
job = circleci_config["jobs"][JOB_NAME]
command = _find_step_command(job, "Install Node.js")
assert command, "PR gate must have a step that installs Node + the CLI."
assert "env -i" in command, (
"The `npm install -g @anthropic-ai/claude-code` step must wrap "
"the install + `claude --version` invocations in `env -i` so "
"package install/postinstall code cannot read provider secrets "
"from the CircleCI job env."
)
# Both load-bearing invocations must be downstream of `env -i`. We
# use `rindex` because the surrounding comment block also mentions
# `claude --version` and `npm install` in prose; what we care about
# is the *actual* shell invocation, which is always the last
# occurrence of each string in the step body.
env_i_idx = command.index("env -i")
npm_install_idx = command.rindex(
'npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"'
)
claude_version_idx = command.rindex("claude --version")
assert env_i_idx < npm_install_idx, (
"`env -i` must precede `npm install -g`; otherwise the install/"
"postinstall scripts still see the unscrubbed env."
)
assert env_i_idx < claude_version_idx, (
"`env -i` must precede `claude --version`; otherwise the CLI "
"still sees the unscrubbed env on its first invocation."
)
def test_existing_proxy_e2e_anthropic_job_unchanged(circleci_config: dict) -> None:
"""No regression to the existing `proxy_e2e_anthropic_messages_tests`
job (acceptance criterion). We don't lock its full body, but we do