fix(ci): veria — shell-quote PR-gate resolver output written to $BASH_ENV

The version-resolver step echoes the resolved CLAUDE_CODE_VERSION into
$BASH_ENV unquoted; CircleCI sources $BASH_ENV at the start of every
subsequent step *before* any env -i wrapper can run, so the job env
(with provider credentials in scope) is live at that moment. A
malicious PR could make the resolver — which lives under
tests/claude_code/ and is therefore PR-controlled — print a value
containing a newline + shell snippet to exfiltrate ANTHROPIC_API_KEY
/ AWS_* / VERTEXAI_* / AZURE_FOUNDRY_* / GITHUB_TOKEN.

Two defenses:
- Reject anything that isn't a strict `N.N.N` semver via
  `[[ ... =~ ^N.N.N$ ]]` (whole-string match, not per-line grep).
- shell-quote on write via `printf 'export ...=%q\n'` so a bypass
  of the regex still cannot break out of the export assignment.

Pin both with a structural unit test alongside the existing scrub
pins.
This commit is contained in:
mateo-berri 2026-05-19 03:06:23 +00:00
parent 5d121fa697
commit 16f2eba313
No known key found for this signature in database
2 changed files with 54 additions and 1 deletions

View file

@ -2355,8 +2355,22 @@ jobs:
LC_ALL="${LC_ALL:-}" \
TMPDIR="${TMPDIR:-/tmp}" \
uv run --no-sync python -m tests.claude_code.pr_gate_version_resolver)
# The resolver lives under tests/claude_code/ and is therefore
# PR-controlled: a malicious PR could make it print a value
# containing a newline + shell snippet. Reject anything that
# is not a strict `N.N.N` semver before persisting, and
# shell-quote on write so a bypass of the regex still cannot
# break out of the `export` assignment when CircleCI sources
# $BASH_ENV at the start of every subsequent step (which
# happens before our `env -i` wrappers can run and would
# otherwise execute the injected snippet with provider
# credentials in scope).
if ! [[ "$CLAUDE_CODE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "pr_gate_version_resolver returned a non-semver value; refusing to persist" >&2
exit 1
fi
echo "Selected @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
echo "export CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" >> "$BASH_ENV"
printf 'export CLAUDE_CODE_VERSION=%q\n' "$CLAUDE_CODE_VERSION" >> "$BASH_ENV"
- run:
name: Install Node.js 20 + Claude Code CLI
command: |

View file

@ -286,6 +286,45 @@ def test_pr_gate_pytest_step_scrubs_secrets_from_env(
)
def test_pr_gate_resolver_output_safely_persisted_to_bash_env(
circleci_config: dict,
) -> None:
"""The resolver step writes the resolved version to `$BASH_ENV` so
later steps can interpolate it. `$BASH_ENV` is sourced by bash at
the start of every subsequent step *before* any `env -i` wrapper
we install can run, so the job env (with provider credentials in
scope) is live at that moment. The resolver lives under
`tests/claude_code/` and is therefore PR-controlled a malicious
PR could make it print a value containing a newline + shell
snippet to exfiltrate credentials.
Pin the two defenses so they cannot silently regress:
1. The persisted value must be shell-quoted via `printf '%q'`
(not unquoted via `echo`) so any bytes the resolver emits are
safely re-parsed as a literal `export` assignment.
2. The resolver output must be matched against a strict semver
regex and rejected otherwise, so anything that isn't a
`N.N.N` string never reaches `$BASH_ENV` in the first place.
"""
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 "printf 'export CLAUDE_CODE_VERSION=%q\\n'" in command, (
"Resolver step must persist CLAUDE_CODE_VERSION via `printf '%q'` "
"(shell-quoted) — a raw `echo \"export ...=$VAR\"` lets PR-controlled "
"resolver output inject shell commands into $BASH_ENV that run with "
"provider credentials in scope at the start of the next step."
)
assert "[[ \"$CLAUDE_CODE_VERSION\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]" in command, (
"Resolver step must validate CLAUDE_CODE_VERSION against a strict "
"whole-string semver regex (`[[ ... =~ ^N.N.N$ ]]`) before "
"persisting; a per-line grep would pass a multi-line resolver "
"output, and anything that isn't a `N.N.N` string should never "
"reach $BASH_ENV / `npm install`."
)
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