mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
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:
parent
a1f0ef2a99
commit
c941291567
2 changed files with 145 additions and 5 deletions
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue