Safeguard
AppSec

simple-git: Command Injection CVEs and Safe Usage Patterns

The npm simple-git library went through a chain of argument injection CVEs in 2022, each an incomplete fix of the last. The history is a case study in why wrapping a CLI safely is hard.

Marcus Chen
DevSecOps Engineer
7 min read

The npm simple-git package — the most popular way to run git from Node.js — carries an instructive security history: a chain of four related argument injection CVEs disclosed across 2022, where each fix was bypassed by a variation the previous patch did not cover. None of this makes simple-git a bad library today; current versions addressed the reported vectors, and the maintainer shipped fixes promptly each time. But the pattern of the chain — incomplete fix, new CVE, incomplete fix, new CVE — is the canonical illustration of why wrapping a command-line tool is an adversarial parsing problem, not a string-formatting problem. If your code passes user input into simple git operations, this post is the checklist.

What simple-git is and where input enters

simple-git provides a fluent async API over the system git binary:

const simpleGit = require('simple-git');
const git = simpleGit('/path/to/repo');

await git.clone(repoUrl, targetDir);
await git.pull('origin', branch);
const log = await git.log({ maxCount: 20 });

It does not reimplement git; it spawns the real binary with constructed argument lists. That design inherits git's full behavior — including the behaviors that make argument injection dangerous. The inputs that typically come from users or external systems in real applications: repository URLs (multi-tenant CI, "import your repo" features), branch and tag names (webhook payloads), and file paths. Every one of those crossed a trust boundary in the 2022 vulnerability chain.

The CVE chain, in order

CVE-2022-24433 started it: argument injection in fetch. A remote or ref beginning with -- was passed through to git as an option rather than a value, and git options like --upload-pack=<command> tell git which program to execute to talk to the remote. An attacker who controlled the remote string could therefore achieve command execution. The fix blocked that vector on fetch.

CVE-2022-24066 (fixed in 3.5.0) showed the fix was incomplete: clone supported the same --upload-pack behavior, and the original patch had only covered the fetch path. Same class, adjacent method.

CVE-2022-25912 (fixed in 3.15.0) reached the same outcome through a different door: the ext:: transport protocol, a git feature where the "URL" itself specifies an external command to run. A hostile URL passed to clone() produced remote code execution without any -- option syntax at all.

CVE-2022-25860 (fixed in 3.16.0) closed the remaining gaps, covering clone(), pull(), push(), and listRemote() — an explicitly acknowledged completion of the previous incomplete fix.

Three lessons fall out of the timeline. First, fixing the reported vector is not fixing the vulnerability class — the class here was "attacker-controlled strings reach git's option and URL parsers," and it took four iterations to cover the parser's surface. Second, the dangerous git features (--upload-pack, ext::, -c config injection, --exec) are documented, legitimate functionality — a blocklist mindset loses to git's feature set. Third, version floors matter more than package names in your scanner: an application on simple-git 3.14.x in 2023 passed a "do we use simple-git?" check while remaining vulnerable to two of the four CVEs. This is exactly the kind of version-range reasoning that automated SCA — Safeguard or any competent tool — does mechanically and humans do badly under deadline.

Safe usage patterns that survive the next CVE

Upgrading past 3.16.0 addressed the published vectors. The stronger position is arranging your code so the class has no purchase, because the 2022 chain is unlikely to be the last word for any CLI wrapper.

Validate inputs as identifiers, not strings. Branch names, remotes, and tags have known-good shapes. Enforce them before the value touches git:

const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;

function assertSafeRef(ref) {
  if (!SAFE_REF.test(ref) || ref.includes('..') || ref.startsWith('-')) {
    throw new Error(`Rejected unsafe ref: ${JSON.stringify(ref)}`);
  }
  return ref;
}

The startsWith('-') check is the load-bearing line: it makes option injection structurally impossible regardless of which git flag is dangerous this year. Git itself rejects most hostile ref shapes too (git check-ref-format encodes the rules), but validating before the spawn keeps the failure in your error handling rather than git's.

Allowlist URL schemes for anything remote. Accept https:// (and ssh:// where you truly need it) against a parsed URL, not a substring check. That kills ext::, file:// tricks, and local-path smuggling in one move:

function assertSafeRemote(raw) {
  const url = new URL(raw);           // throws on ext:: pseudo-URLs
  if (!['https:', 'ssh:'].includes(url.protocol)) {
    throw new Error(`Unsupported protocol: ${url.protocol}`);
  }
  return url.toString();
}

Use the API's structured forms. simple-git accepts options as objects and arrays; prefer those over assembling raw argument strings, and never route user input through git.raw([...]) without the identifier validation above.

Constrain the blast radius. Code that clones untrusted repositories should run as an unprivileged worker with no ambient credentials — in CI-adjacent systems, the process doing clone() often holds tokens worth far more than the box itself. Treat "git wrapper with network-supplied input" as an input-handling component in threat models, the same tier as a file-upload parser. Runtime testing earns its keep here too: a DAST pass that feeds hostile repo URLs and branch names through your actual API routes verifies the validation exists where the traffic flows, not just where the code review looked.

Keep the floor current and monitored. Pin simple-git at or above 3.16.0 everywhere, and let your dependency tooling alert on new advisories for it specifically — a package with this history and this download volume (it sits under CI tools, release scripts, and platforms across the ecosystem) is a permanent point of interest. Our dependency upgrade workflow guide covers how to make that kind of floor-raising routine instead of an emergency.

If you cannot upgrade immediately

Real codebases sometimes sit behind a framework that pins an old simple-git transitively. Interim measures, in order of value: apply the ref and URL validation above at your trust boundary (it defends the old version too, since the CVEs all require hostile strings to arrive intact); use your package manager's override mechanism (overrides in npm, resolutions in Yarn) to force the patched version under the pinning dependency, then run the depending tool's test suite; and if neither is possible, isolate the vulnerable path — many applications only pass user input to a subset of git operations, and the fix can be scoped to those call sites while the upgrade is scheduled.

FAQ

What were the simple-git command injection CVEs?

A 2022 chain of argument injection flaws: CVE-2022-24433 (fetch vector), CVE-2022-24066 (clone via --upload-pack, fixed in 3.5.0), CVE-2022-25912 (ext:: transport RCE, fixed in 3.15.0), and CVE-2022-25860 (remaining clone/pull/push/listRemote vectors, fixed in 3.16.0). Each was an incomplete fix of its predecessor.

Is simple-git safe to use now?

Yes, on current versions with sane input handling. Versions 3.16.0 and later address the published 2022 vectors. The durable defense is validating refs and remotes before they reach the library — reject values starting with - and allowlist URL schemes.

How does argument injection differ from shell injection?

No shell is involved. The library spawns git directly with an argument array, but a value like --upload-pack=cmd is interpreted by git itself as an instruction to execute a program. Escaping shell metacharacters does nothing against it; the defense is ensuring user input can only ever occupy value positions, never option positions.

What is the minimum simple-git version I should enforce?

Treat 3.16.0 as the floor for the 2022 chain, and prefer simply tracking the latest 3.x. Enforce it with a lockfile audit or an SCA policy gate rather than memory — transitive pins reintroduce old versions silently.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.