Critical severity9.8NVD Advisory· Published Apr 25, 2026· Updated Apr 29, 2026
CVE-2026-6951
CVE-2026-6951
Description
Versions of the package simple-git before 3.36.0 are vulnerable to Remote Code Execution (RCE) due to an incomplete fix for CVE-2022-25912 that blocks the -c option but not the equivalent --config form. If untrusted input can reach the options argument passed to simple-git, an attacker may still achieve remote code execution by enabling protocol.ext.allow=always and using an ext:: clone source.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
simple-gitnpm | < 3.36.0 | 3.36.0 |
Affected products
1Patches
189a2294febedEnvironment Parsing (#1156)
29 files changed · +1093 −279
.changeset/config.json+2 −2 modified@@ -1,10 +1,10 @@ { - "$schema": "https://unpkg.com/@changesets/config@1.6.4/schema.json", + "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "linked": [], "access": "public", - "baseBranch": "main", + "baseBranch": "origin/main", "updateInternalDependencies": "patch", "ignore": [ "@simple-git/test-utils"
.changeset/deep-ducks-care.md+11 −0 added@@ -0,0 +1,11 @@ +--- +"@simple-git/argv-parser": minor +simple-git: minor +--- + +Extend known exploitable configuration keys and per-task environment variables. + +Note - `ParsedVulnerabilities` from `argv-parser` is removed in favour of a readonly array of `Vulnerability` to match usage in `simple-git`, rolled into the new `vulnerabilityCheck` for simpler access to the identified issues. + +Thanks to @zebbern for identifying the need to block `core.fsmonitor`. +Thanks to @kodareef5 for identifying the need to block `GIT_CONFIG_COUNT` environment variables and `--template` / `merge` related config.
docs/PLUGIN-UNSAFE-ACTIONS.md+407 −14 modified@@ -1,37 +1,44 @@ ## Unsafe Actions -As `simple-git` passes generated arguments through to a child process of the calling node.js process, it is recommended -that any parameter sourced from user input is validated before being passed to the `simple-git` API. +As `simple-git` passes generated arguments directly to a `git` child process, **all parameters sourced from +user input must be validated and sanitised** before being passed to any `simple-git` API, regardless of which +command is being called. There is no command that is inherently safe to call with unsanitised user data. -In some cases where there is an elevated potential for harm `simple-git` will throw an exception unless you have -explicitly opted in to the potentially unsafe action. +In cases where there is a heightened potential for harm — where a single unsanitised argument could allow +arbitrary command execution or the disclosure of sensitive credentials — `simple-git` will additionally throw +a `GitPluginError` unless you have explicitly opted in to the potentially unsafe behaviour. -### Enabling custom upload and receive packs +These blocks are a safety net, not a substitute for input validation. They cover known high-risk patterns, +but they do not protect against every possible injection or misuse of the `git` command line. -Instead of using the default `git-receive-pack` and `git-upload-pack` binaries to parse incoming and outgoing -data, `git` can be configured to use _any_ arbitrary binary or evaluable script. +### Custom upload and receive packs -To avoid accidentally triggering the evaluation of a malicious script when merging user provided parameters -into command executed by `simple-git`, custom pack options (usually with the `--receive-pack` and `--upload-pack`) -are blocked without explicitly opting into their use +Instead of using the default `git-receive-pack` and `git-upload-pack` binaries to parse incoming and outgoing +data, `git` can be configured to use _any_ arbitrary binary or evaluable script. This applies whether the +binary is set via the `--upload-pack` / `--receive-pack` flags or through per-remote configuration +(`remote.<name>.uploadpack` / `remote.<name>.receivepack`). ```typescript import { simpleGit } from 'simple-git'; -// throws +// throws — via flag await simpleGit() .raw('push', '--receive-pack=git-receive-pack-custom'); -// allows calling clone with a helper transport +// throws — via per-remote configuration +await simpleGit() + .raw('-c', 'remote.origin.uploadpack=/custom/upload-pack', 'fetch'); + +// opt in to using custom pack binaries await simpleGit({ unsafe: { allowUnsafePack: true } }) .raw('push', '--receive-pack=git-receive-pack-custom'); ``` ### Overriding allowed protocols -A standard installation of `git` permits `file`, `http` and `ssh` protocols for a remote. A range of +A standard installation of `git` permits `file`, `http` and `ssh` protocols for a remote. A range of [git remote helpers](https://git-scm.com/docs/gitremote-helpers) other than these default few can be -used by referring to te helper name in the remote protocol - for example the git file descriptor transport +used by referring to the helper name in the remote protocol - for example the git file descriptor transport [git-remote-fd](https://git-scm.com/docs/git-remote-fd) would be used in a remote protocol such as: ``` @@ -57,3 +64,389 @@ await simpleGit({ unsafe: { allowUnsafeProtocolOverride: true } }) > *Be advised* helper transports can be used to call arbitrary binaries on the host machine. > Do not allow them in applications where you are not in control of the input parameters. +### Command aliases + +Git allows defining shorthand aliases for any command or external shell script via `alias.*` configuration. +Passing an unsanitised value as an alias target could cause `git` to execute an arbitrary command. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws +await simpleGit() + .raw('-c', 'alias.ls=!ls -la', 'ls'); + +// opt in to defining aliases +await simpleGit({ unsafe: { allowUnsafeAlias: true } }) + .raw('-c', 'alias.ls=!ls -la', 'ls'); +``` + +### Credential helpers + +Git credential helpers are external binaries or scripts that store and retrieve authentication credentials. +An attacker-controlled `credential.helper` value could direct `git` to run an arbitrary binary with access +to any credentials passed through. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws +await simpleGit() + .raw('-c', 'credential.helper=/path/to/malicious-script', 'clone', '--', 'https://example.com/repo'); + +// opt in to using a custom credential helper +await simpleGit({ unsafe: { allowUnsafeCredentialHelper: true } }) + .raw('-c', 'credential.helper=/path/to/custom-helper', 'clone', '--', 'https://example.com/repo'); +``` + +### Ask-pass programs + +The `core.askPass` configuration and the `GIT_ASKPASS` / `SSH_ASKPASS` environment variables define an +external binary that `git` will call to prompt for passwords. Controlling this value allows an attacker to +intercept credentials by substituting their own binary. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — via config flag +await simpleGit() + .raw('-c', 'core.askPass=/path/to/capture-credentials', 'clone', '--', 'https://example.com/repo'); + +// throws — via environment variable +await simpleGit() + .env('GIT_ASKPASS', '/path/to/capture-credentials') + .clone('https://example.com/repo'); + +// opt in to setting a custom ask-pass program +await simpleGit({ unsafe: { allowUnsafeAskPass: true } }) + .raw('-c', 'core.askPass=/usr/lib/git-core/git-gui--askpass', 'clone', '--', 'https://example.com/repo'); +``` + +### SSH command + +The `core.sshCommand` configuration and the `GIT_SSH` / `GIT_SSH_COMMAND` environment variables define the +binary used by `git` when making SSH connections. An attacker-controlled value could substitute an arbitrary +binary for the SSH transport. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — via config flag +await simpleGit() + .raw('-c', 'core.sshCommand=malicious-binary', 'clone', 'git@example.com:repo.git'); + +// throws — via environment variable +await simpleGit() + .env('GIT_SSH_COMMAND', 'malicious-binary') + .clone('git@example.com:repo.git'); + +// opt in to using a custom SSH binary +await simpleGit({ unsafe: { allowUnsafeSshCommand: true } }) + .env('GIT_SSH_COMMAND', 'ssh -i ~/.ssh/deploy_key') + .clone('git@example.com:repo.git'); +``` + +### Git proxy command + +The `core.gitProxy` configuration and `GIT_PROXY_COMMAND` environment variable define a command to be +executed as a proxy for the `git://` transport. Passing an attacker-controlled value here can result in +arbitrary command execution on each remote operation. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — via config flag +await simpleGit() + .raw('-c', 'core.gitProxy=malicious-binary', 'fetch'); + +// throws — via environment variable +await simpleGit() + .env('GIT_PROXY_COMMAND', 'malicious-binary') + .fetch(); + +// opt in to using a custom git proxy +await simpleGit({ unsafe: { allowUnsafeGitProxy: true } }) + .env('GIT_PROXY_COMMAND', 'socks5proxywrapper') + .fetch(); +``` + +### Text editor + +The `core.editor` and `sequence.editor` configurations and the `EDITOR` / `GIT_EDITOR` / `GIT_SEQUENCE_EDITOR` +environment variables define the text editor binary that `git` will open for interactive operations. +`core.editor` is used for commit messages and similar prompts; `sequence.editor` and `GIT_SEQUENCE_EDITOR` +are used specifically for the interactive rebase todo list. A malicious value in any of these can substitute +an arbitrary binary. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — general editor via config +await simpleGit() + .raw('-c', 'core.editor=malicious-binary', 'commit', '--amend'); + +// throws — rebase sequence editor via config +await simpleGit() + .raw('-c', 'sequence.editor=malicious-binary', 'rebase', '-i', 'HEAD~3'); + +// throws — via environment variable +await simpleGit() + .env('GIT_SEQUENCE_EDITOR', 'malicious-binary') + .raw('rebase', '-i', 'HEAD~3'); + +// opt in to using a custom editor +await simpleGit({ unsafe: { allowUnsafeEditor: true } }) + .env('GIT_EDITOR', '/usr/bin/nano') + .raw('commit', '--amend'); +``` + +### Pager + +The `core.pager` configuration and the `GIT_PAGER` / `PAGER` environment variables control the binary used +to page output from `git` commands. Substituting a malicious binary here provides an execution path that +runs for any paged output. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — via config flag +await simpleGit() + .raw('-c', 'core.pager=malicious-binary', 'log'); + +// throws — via environment variable +await simpleGit() + .env('GIT_PAGER', 'malicious-binary') + .log(); + +// opt in to using a custom pager +await simpleGit({ unsafe: { allowUnsafePager: true } }) + .env('GIT_PAGER', 'less -R') + .log(); +``` + +### Hooks path + +The `core.hooksPath` configuration redirects `git` to load its event hooks from a location other than the +default `.git/hooks` directory. Controlling this path allows an attacker to cause arbitrary scripts to be +run automatically on standard git operations such as `commit` and `merge`. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws +await simpleGit() + .raw('-c', 'core.hooksPath=/attacker/controlled/hooks', 'commit', '-m', 'message'); + +// opt in to using a custom hooks path +await simpleGit({ unsafe: { allowUnsafeHooksPath: true } }) + .raw('-c', 'core.hooksPath=/custom/shared/hooks', 'commit', '-m', 'message'); +``` + +### Template directory + +The `init.templateDir` configuration, `--template` flag, and `GIT_TEMPLATE_DIR` environment variable +define a directory whose contents are copied into a newly initialised `.git` directory. An attacker-controlled +template directory can plant hooks or configuration into every repository initialised by the process. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — via config flag +await simpleGit() + .raw('-c', 'init.templateDir=/attacker/controlled/template', 'init', 'new-repo'); + +// throws — via flag +await simpleGit() + .raw('init', '--template=/attacker/controlled/template', 'new-repo'); + +// throws — via environment variable +await simpleGit() + .env('GIT_TEMPLATE_DIR', '/attacker/controlled/template') + .init('new-repo'); + +// opt in to using a custom template directory +await simpleGit({ unsafe: { allowUnsafeTemplateDir: true } }) + .raw('init', '--template=/custom/template', 'new-repo'); +``` + +### External diff tool + +The `diff.external` configuration, per-driver `diff.<driver>.command`, and `GIT_EXTERNAL_DIFF` environment +variable define an external binary that `git` calls to generate diffs. Substituting an attacker-controlled +binary gives it read access to every file involved in a diff operation. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — global external diff via config +await simpleGit() + .raw('-c', 'diff.external=malicious-diff-tool', 'diff'); + +// throws — per-driver diff command via config +await simpleGit() + .raw('-c', 'diff.pdf.command=malicious-diff-tool', 'diff', 'document.pdf'); + +// throws — via environment variable +await simpleGit() + .env('GIT_EXTERNAL_DIFF', 'malicious-diff-tool') + .diff(); + +// opt in to using a custom diff tool +await simpleGit({ unsafe: { allowUnsafeDiffExternal: true } }) + .env('GIT_EXTERNAL_DIFF', '/usr/local/bin/my-diff-tool') + .diff(); +``` + +### Diff text conversion + +The `diff.textconv` configuration (set per driver via `diff.<driver>.textconv`) defines a binary that converts +file content to text before generating a diff. This binary is called automatically whenever git diffs a file +with a matching driver and has read access to the file's content. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws +await simpleGit() + .raw('-c', 'diff.pdf.textconv=malicious-converter', 'diff', 'document.pdf'); + +// opt in to using a custom text converter +await simpleGit({ unsafe: { allowUnsafeDiffTextConv: true } }) + .raw('-c', 'diff.pdf.textconv=pdftotext', 'diff', 'document.pdf'); +``` + +### Filter operations + +The `filter.<driver>.clean` and `filter.<driver>.smudge` configuration values define binaries that transform +file content when checking out (`smudge`) and staging (`clean`). Controlling either value allows an attacker +to read or modify every file that passes through the filter. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — clean filter +await simpleGit() + .raw('-c', 'filter.lfs.clean=malicious-binary', 'add', '.'); + +// throws — smudge filter +await simpleGit() + .raw('-c', 'filter.lfs.smudge=malicious-binary', 'checkout', 'main'); + +// opt in to using custom filter binaries +await simpleGit({ unsafe: { allowUnsafeFilter: true } }) + .raw('-c', 'filter.lfs.clean=git-lfs clean -- %f', '-c', 'filter.lfs.smudge=git-lfs smudge -- %f', 'checkout', 'main'); +``` + +### File system monitor + +The `core.fsmonitor` configuration specifies an external binary that `git` uses to track file system changes. +This binary is invoked automatically in the background during many common operations, making it a persistent +execution path if an attacker can control the value. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws +await simpleGit() + .raw('-c', 'core.fsmonitor=malicious-monitor', 'status'); + +// opt in to using a custom file system monitor +await simpleGit({ unsafe: { allowUnsafeFsMonitor: true } }) + .raw('-c', 'core.fsmonitor=true', 'status'); +``` + +### GPG signing program + +The `gpg.program` configuration defines the binary used to sign commits and tags. Per-format variants +`gpg.ssh.program` and `gpg.x509.program` select the signing binary for SSH and X.509 signatures +respectively. All three are matched by a single block on `gpg.*.program`. Controlling any of these values +allows an attacker to run an arbitrary binary whenever a signed commit or tag is created. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — default GPG program +await simpleGit() + .raw('-c', 'gpg.program=malicious-binary', 'commit', '-S', '-m', 'signed commit'); + +// throws — SSH signing program +await simpleGit() + .raw('-c', 'gpg.ssh.program=malicious-binary', 'commit', '-S', '-m', 'signed commit'); + +// opt in to using a custom GPG binary +await simpleGit({ unsafe: { allowUnsafeGpgProgram: true } }) + .raw('-c', 'gpg.program=/usr/local/bin/gpg2', 'commit', '-S', '-m', 'signed commit'); +``` + +### Merge drivers + +The `merge.driver`, `mergetool.cmd`, and `mergetool.path` configurations define external binaries used to +resolve merge conflicts. Controlling any of these values allows an attacker to run arbitrary code whenever +a merge conflict occurs. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — custom merge driver +await simpleGit() + .raw('-c', 'merge.union.driver=malicious-merger %O %A %B', 'merge', 'feature-branch'); + +// throws — merge tool command +await simpleGit() + .raw('-c', 'mergetool.custom.cmd=malicious-binary $MERGED', 'mergetool'); + +// opt in to using custom merge drivers +await simpleGit({ unsafe: { allowUnsafeMergeDriver: true } }) + .raw('-c', 'mergetool.vimdiff.path=/usr/bin/vim', 'mergetool'); +``` + +### Configuration paths via environment variables + +The `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG`, `GIT_EXEC_PATH`, and `PREFIX` environment +variables override the paths `git` uses to locate its configuration files and built-in commands. Controlling +these paths allows an attacker to supply an entirely malicious git configuration or replace git's built-in +commands with arbitrary binaries. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws +await simpleGit() + .env('GIT_CONFIG_GLOBAL', '/attacker/controlled/gitconfig') + .clone('https://example.com/repo'); + +// opt in to overriding git configuration paths +await simpleGit({ unsafe: { allowUnsafeConfigPaths: true } }) + .env('GIT_CONFIG_GLOBAL', '/custom/global/gitconfig') + .clone('https://example.com/repo'); +``` + +### Environment-based configuration + +Git supports injecting configuration values at runtime through a set of numbered environment variables: +`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`, and `GIT_CONFIG_VALUE_n`. When `GIT_CONFIG_COUNT` is set to `N`, +git reads `N` key/value pairs from the corresponding environment variables and treats them as the highest +priority configuration. Because this mechanism can set any configuration value, the injected keys are +subject to the same block-listing checks as values passed via `-c` flags. + +```typescript +import { simpleGit } from 'simple-git'; + +// throws — GIT_CONFIG_COUNT triggers the check; the injected key is also evaluated +await simpleGit() + .env({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: '/attacker/hooks', + }) + .commit('message'); + +// opt in to using environment-based configuration injection +await simpleGit({ unsafe: { allowUnsafeConfigEnvCount: true } }) + .env({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'user.email', + GIT_CONFIG_VALUE_0: 'ci-bot@example.com', + }) + .commit('CI build commit'); +```
packages/argv-parser/index.ts+5 −3 modified@@ -1,13 +1,15 @@ -export { parseArgv } from './src/parse-argv'; +export { parseArgv } from './src/args/parse-argv'; export type { ConfigRead, ConfigScope, ConfigWrite, ParsedArgv, ParsedFlag, -} from './src/parse-argv.types'; +} from './src/args/parse-argv.types'; +export { parseEnv } from './src/env/parse-env'; export type { Vulnerability, VulnerabilityCategory, + VulnerabilityCategoryFlags, } from './src/vulnerabilities/vulnerability.types'; -export { vulnerabilityAnalysis } from './src/vulnerabilities/vulnerability-analysis'; +export { vulnerabilityCheck } from './src/vulnerabilities/vulnerability-check';
packages/argv-parser/src/args/parse-argv.ts+5 −5 renamed@@ -1,9 +1,9 @@ -import { collectConfigAccess } from './config/analyse-config'; -import type { Flag } from './flags/flags.helpers'; -import { parseGlobalFlags } from './flags/parse-global-flags'; -import { parseTaskFlags } from './flags/parse-task-flags'; +import { collectConfigAccess } from '../config/analyse-config'; +import type { Flag } from '../flags/flags.helpers'; +import { parseGlobalFlags } from '../flags/parse-global-flags'; +import { parseTaskFlags } from '../flags/parse-task-flags'; +import { vulnerabilityAnalysis } from '../vulnerabilities/vulnerability-analysis'; import type { ParsedArgv, ParsedFlag } from './parse-argv.types'; -import { vulnerabilityAnalysis } from './vulnerabilities/vulnerability-analysis'; /** * Parse the tokens that would be forwarded to a `git` child-process and
packages/argv-parser/src/args/parse-argv.types.ts+2 −2 renamed@@ -1,4 +1,4 @@ -import type { ParsedVulnerabilities } from './vulnerabilities/vulnerability.types'; +import type { Vulnerability } from '../vulnerabilities/vulnerability.types'; /** Where a config value originates / which scope it targets. */ export type ConfigScope = @@ -72,5 +72,5 @@ export interface ParsedArgv { /** * Attack vectors discovered in the arguments */ - vulnerabilities: ParsedVulnerabilities; + readonly vulnerabilities: Vulnerability[]; }
packages/argv-parser/src/config/analyse-config.ts+1 −1 modified@@ -1,5 +1,5 @@ +import type { ConfigScope, ConfigWrite, ParsedConfigActivity } from '../args/parse-argv.types'; import { type Flag, scopedFlags } from '../flags/flags.helpers'; -import type { ConfigScope, ConfigWrite, ParsedConfigActivity } from '../parse-argv.types'; import type { ConfigOperation } from './config.types'; import { detectConfigAction, toOperation } from './detect-config-action';
packages/argv-parser/src/config/detect-config-action.ts+1 −1 modified@@ -1,5 +1,5 @@ +import type { ConfigScope } from '../args/parse-argv.types'; import { type Flag, scopedFlags } from '../flags/flags.helpers'; -import type { ConfigScope } from '../parse-argv.types'; import type { ConfigOperation } from './config.types'; import { CONFIG_READ_FLAGS,
packages/argv-parser/src/env/parse-env.ts+84 −0 added@@ -0,0 +1,84 @@ +import type { ConfigWrite, ParsedConfigActivity } from '../args/parse-argv.types'; +import type { Vulnerability, VulnerabilityCategory } from '../vulnerabilities/vulnerability.types'; +import { vulnerabilityAnalysis } from '../vulnerabilities/vulnerability-analysis'; + +const GitEnvKeys = { + 'editor': 'allowUnsafeEditor', + 'git_askpass': 'allowUnsafeAskPass', + 'git_config_global': 'allowUnsafeConfigPaths', + 'git_config_system': 'allowUnsafeConfigPaths', + 'git_config_count': 'allowUnsafeConfigEnvCount', + 'git_config': 'allowUnsafeConfigPaths', + 'git_editor': 'allowUnsafeEditor', + 'git_exec_path': 'allowUnsafeConfigPaths', + 'git_external_diff': 'allowUnsafeDiffExternal', + 'git_pager': 'allowUnsafePager', + 'git_proxy_command': 'allowUnsafeGitProxy', + 'git_template_dir': 'allowUnsafeTemplateDir', + 'git_sequence_editor': 'allowUnsafeEditor', + 'git_ssh': 'allowUnsafeSshCommand', + 'git_ssh_command': 'allowUnsafeSshCommand', + 'pager': 'allowUnsafePager', + 'prefix': 'allowUnsafeConfigPaths', + 'ssh_askpass': 'allowUnsafeAskPass', +} as const satisfies Record<string, VulnerabilityCategory>; + +type GitEnv = Record<string, string> & { + git_config_count?: string; +}; + +function* collectConfigByCount(env: GitEnv): Generator<ConfigWrite> { + const count = parseInt(env.git_config_count ?? '0', 10); + for (let index = 0; index < count; index++) { + const key = env[`git_config_key_${index}`]; + const value = env[`git_config_value_${index}`]; + + if (key !== undefined) { + yield { key: key.toLowerCase().trim(), value, scope: 'env' }; + } + } +} + +function* collectConfigVulnerabilities(env: GitEnv): Generator<Vulnerability> { + for (const key of Object.keys(env)) { + if (isGitEnvKey(key)) { + const category = GitEnvKeys[key]; + yield { + category, + message: `Use of "${key.toUpperCase()}" is not permitted without enabling ${category}`, + }; + } + } +} + +function isGitEnvKey(key: string): key is keyof typeof GitEnvKeys { + return Object.hasOwn(GitEnvKeys, key); +} + +function prepareEnv(env: Record<string, unknown>): GitEnv { + const gitEnv: GitEnv = {}; + for (const [key, value] of Object.entries(env)) { + const envKey = key.toLowerCase().trim(); + if (isGitEnvKey(envKey) || envKey.startsWith('git')) { + gitEnv[envKey] = String(value); + } + } + return gitEnv; +} + +export function parseEnv(raw: Record<string, unknown>) { + const env = prepareEnv(raw); + const config: ParsedConfigActivity = { + read: [], + write: [...collectConfigByCount(env)], + }; + const vulnerabilities = [ + ...collectConfigVulnerabilities(env), + ...vulnerabilityAnalysis(null, [], config), + ]; + + return { + config, + vulnerabilities, + }; +}
packages/argv-parser/src/tokens/flag-specs.ts+5 −1 modified@@ -52,7 +52,7 @@ const COMMANDS: Record<string, FlagSpec> = { ['s', false], // -s shared ['u', true], // -u <upload-pack> ]), - long: new Set(['branch', 'config', 'jobs', 'origin', 'upload-pack', 'u']), + long: new Set(['branch', 'config', 'jobs', 'origin', 'upload-pack', 'u', 'template']), }, commit: { short: new Map([ @@ -76,6 +76,10 @@ const COMMANDS: Record<string, FlagSpec> = { short: new Map(), long: new Set(['upload-pack']), }, + init: { + short: new Map(), + long: new Set(['template']), + }, pull: { short: new Map(), long: new Set(['upload-pack']),
packages/argv-parser/src/vulnerabilities/detect-config-writes.ts+0 −43 removed@@ -1,43 +0,0 @@ -import type { ParsedConfigActivity } from '../parse-argv.types'; -import type { Vulnerability, VulnerabilityCategory } from './vulnerability.types'; - -export function* detectConfigWrites({ write }: ParsedConfigActivity): Generator<Vulnerability> { - for (const config of write) { - for (const helper of preventUnsafeConfig) { - const vulnerability = helper(config.key); - if (vulnerability) { - yield vulnerability; - } - } - } -} - -function preventConfigBuilder( - config: string | RegExp, - category: VulnerabilityCategory, - message = String(config) -) { - const regex = typeof config === 'string' ? new RegExp(`\\s*${config}`, 'i') : config; - - return function preventCommand(key: string): Vulnerability | void { - if (regex.test(key)) { - return { - category, - message: `Configuring ${message} is not permitted without enabling ${category}`, - }; - } - }; -} - -const preventUnsafeConfig = [ - preventConfigBuilder( - /^\s*protocol(.[a-z]+)?.allow/i, - 'allowUnsafeProtocolOverride', - 'protocol.allow' - ), - preventConfigBuilder('core.sshCommand', 'allowUnsafeSshCommand'), - preventConfigBuilder('core.fsmonitor', 'allowUnsafeFsMonitor'), - preventConfigBuilder('core.gitProxy', 'allowUnsafeGitProxy'), - preventConfigBuilder('core.hooksPath', 'allowUnsafeHooksPath'), - preventConfigBuilder('diff.external', 'allowUnsafeDiffExternal'), -];
packages/argv-parser/src/vulnerabilities/detect-upload-pack.ts+0 −28 removed@@ -1,28 +0,0 @@ -import type { Flag } from '../flags/flags.helpers'; -import type { Vulnerability } from './vulnerability.types'; - -export function* detectUploadPack(task: null | string, flags: Flag[]): Generator<Vulnerability> { - for (const flag of flags) { - if (/^--(upload|receive)-pack/.test(flag.name)) { - yield { - category: 'allowUnsafePack', - message: - 'Use of --upload-pack or --receive-pack is not permitted without enabling allowUnsafePack', - }; - } - if (task === 'clone' && (/^-\w*u/.test(flag.name) || flag.name === '--u')) { - yield { - category: 'allowUnsafePack', - message: - 'Use of clone with option -u is not permitted without enabling allowUnsafePack', - }; - } - if (task === 'push' && /^--exec/.test(flag.name)) { - yield { - category: 'allowUnsafePack', - message: - 'Use of push with option --exec is not permitted without enabling allowUnsafePack', - }; - } - } -}
packages/argv-parser/src/vulnerabilities/detect-vulnerable-config-writes.ts+63 −0 added@@ -0,0 +1,63 @@ +import type { ParsedConfigActivity } from '../args/parse-argv.types'; +import type { Vulnerability, VulnerabilityCategory } from './vulnerability.types'; + +export function* detectVulnerableConfigWrites({ + write, +}: ParsedConfigActivity): Generator<Vulnerability> { + for (const config of write) { + for (const helper of preventUnsafeConfig) { + const vulnerability = helper(config.key); + if (vulnerability) { + yield vulnerability; + } + } + } +} + +function preventConfigBuilder( + config: string | RegExp, + category: VulnerabilityCategory, + message = String(config) +) { + const regex = typeof config === 'string' ? new RegExp(`\\s*${config.toLowerCase()}`) : config; + + return function preventCommand(key: string): Vulnerability | void { + if (regex.test(key)) { + return { + category, + message: `Configuring ${message} is not permitted without enabling ${category}`, + }; + } + }; +} + +function preventExpandedConfigBuilder(config: string, category: VulnerabilityCategory) { + const regex = new RegExp(`\\s*${config.toLowerCase().replace(/\./g, '(\..+)?.')}`); + return preventConfigBuilder(regex, category, config); +} + +const preventUnsafeConfig = [ + preventConfigBuilder('alias', 'allowUnsafeAlias'), + preventConfigBuilder('core.askPass', 'allowUnsafeAskPass'), + preventConfigBuilder('core.editor', 'allowUnsafeEditor'), + preventConfigBuilder('core.fsmonitor', 'allowUnsafeFsMonitor'), + preventConfigBuilder('core.gitProxy', 'allowUnsafeGitProxy'), + preventConfigBuilder('core.hooksPath', 'allowUnsafeHooksPath'), + preventConfigBuilder('core.pager', 'allowUnsafePager'), + preventConfigBuilder('core.sshCommand', 'allowUnsafeSshCommand'), + preventExpandedConfigBuilder('credential.helper', 'allowUnsafeCredentialHelper'), + preventExpandedConfigBuilder('diff.command', 'allowUnsafeDiffExternal'), + preventConfigBuilder('diff.external', 'allowUnsafeDiffExternal'), + preventExpandedConfigBuilder('diff.textconv', 'allowUnsafeDiffTextConv'), + preventExpandedConfigBuilder('filter.clean', 'allowUnsafeFilter'), + preventExpandedConfigBuilder('filter.smudge', 'allowUnsafeFilter'), + preventExpandedConfigBuilder('gpg.program', 'allowUnsafeGpgProgram'), + preventConfigBuilder('init.templateDir', 'allowUnsafeTemplateDir'), + preventExpandedConfigBuilder('merge.driver', 'allowUnsafeMergeDriver'), + preventExpandedConfigBuilder('mergetool.path', 'allowUnsafeMergeDriver'), + preventExpandedConfigBuilder('mergetool.cmd', 'allowUnsafeMergeDriver'), + preventExpandedConfigBuilder('protocol.allow', 'allowUnsafeProtocolOverride'), + preventExpandedConfigBuilder('remote.receivepack', 'allowUnsafePack'), + preventExpandedConfigBuilder('remote.uploadpack', 'allowUnsafePack'), + preventConfigBuilder('sequence.editor', 'allowUnsafeEditor'), +];
packages/argv-parser/src/vulnerabilities/detect-vulnerable-flags.ts+48 −0 added@@ -0,0 +1,48 @@ +import type { Flag } from '../flags/flags.helpers'; +import type { Vulnerability, VulnerabilityCategory } from './vulnerability.types'; + +export function* detectVulnerableFlags( + task: null | string, + flags: Flag[] +): Generator<Vulnerability> { + for (const flag of flags) { + for (const helper of preventUnsafeFlags) { + const vulnerability = helper(task, flag.name); + if (vulnerability) { + yield vulnerability; + } + } + } +} + +function preventFlagBuilder( + task: string | null, + flag: string | RegExp, + category: VulnerabilityCategory, + name = String(flag) +) { + const regex = typeof flag === 'string' ? new RegExp(`\\s*${flag.toLowerCase()}`) : flag; + const message = `Use of ${task ? `${task} with option ` : ''}${name} is not permitted without enabling ${category}`; + + return function preventFlag(currentTask: string | null, flagName: string): Vulnerability | void { + if ((!task || currentTask === task) && regex.test(flagName)) { + return { + category, + message, + }; + } + }; +} + +const preventUnsafeFlags = [ + preventFlagBuilder( + null, + /--(upload|receive)-pack/, + 'allowUnsafePack', + '--upload-pack or --receive-pack' + ), + preventFlagBuilder('clone', /^-\w*u/, 'allowUnsafePack'), + preventFlagBuilder('clone', '--u', 'allowUnsafePack'), + preventFlagBuilder('push', '--exec', 'allowUnsafePack'), + preventFlagBuilder(null, '--template', 'allowUnsafeTemplateDir'), +];
packages/argv-parser/src/vulnerabilities/vulnerability-analysis.ts+6 −21 modified@@ -1,28 +1,13 @@ +import type { ParsedConfigActivity } from '../args/parse-argv.types'; import type { Flag } from '../flags/flags.helpers'; -import type { ParsedConfigActivity } from '../parse-argv.types'; -import { detectConfigWrites } from './detect-config-writes'; -import { detectUploadPack } from './detect-upload-pack'; -import type { - ParsedVulnerabilities, - Vulnerability, - VulnerabilityCategory, -} from './vulnerability.types'; +import { detectVulnerableConfigWrites } from './detect-vulnerable-config-writes'; +import { detectVulnerableFlags } from './detect-vulnerable-flags'; +import type { Vulnerability } from './vulnerability.types'; export function vulnerabilityAnalysis( task: null | string, flags: Flag[], config: ParsedConfigActivity -): ParsedVulnerabilities { - const vulnerabilities: Vulnerability[] = [ - ...detectUploadPack(task, flags), - ...detectConfigWrites(config), - ]; - const categories = vulnerabilities.reduce((all, vulnerability) => { - return all.add(vulnerability.category); - }, new Set<VulnerabilityCategory>()); - - return { - categories, - vulnerabilities, - }; +): Vulnerability[] { + return [...detectVulnerableFlags(task, flags), ...detectVulnerableConfigWrites(config)]; }
packages/argv-parser/src/vulnerabilities/vulnerability-check.ts+10 −0 added@@ -0,0 +1,10 @@ +import { parseArgv } from '../args/parse-argv'; +import { parseEnv } from '../env/parse-env'; + +/** + * Retrieves just the vulnerabilities identified in the supplied varargs tokens + * and environment variables. + */ +export function vulnerabilityCheck(tokens: readonly string[], env: Record<string, unknown>) { + return [...parseArgv(...tokens).vulnerabilities, ...parseEnv(env).vulnerabilities]; +}
packages/argv-parser/src/vulnerabilities/vulnerability.types.ts+117 −11 modified@@ -1,18 +1,124 @@ -export type VulnerabilityCategory = - | 'allowUnsafeDiffExternal' - | 'allowUnsafeFsMonitor' - | 'allowUnsafeGitProxy' - | 'allowUnsafeHooksPath' - | 'allowUnsafePack' - | 'allowUnsafeProtocolOverride' - | 'allowUnsafeSshCommand'; +export type VulnerabilityCategory = keyof VulnerabilityCategoryFlags; export interface Vulnerability { category: VulnerabilityCategory; message: string; } -export interface ParsedVulnerabilities { - categories: Set<VulnerabilityCategory>; - vulnerabilities: Vulnerability[]; +export interface VulnerabilityCategoryFlags { + /** + * Use of the `alias.*` configuration settings in simple-git tasks + */ + allowUnsafeAlias: boolean; + + /** + * Use of the `core.askPass` configuration setting and environment variables in simple-git tasks + */ + allowUnsafeAskPass: boolean; + + /** + * Allows using environment variables to set configuration paths in simple-git tasks + */ + allowUnsafeConfigPaths: boolean; + + /** + * Allows setting configuration fields from environment variables in simple-git tasks. Any + * configuration set in this way will still be subject to the same block-listing checks and + * may require other unsafe flags to be enabled for use. + */ + allowUnsafeConfigEnvCount: boolean; + + /** + * Allows setting credential helper in simple-git tasks + */ + allowUnsafeCredentialHelper: boolean; + + /** + * Allows setting path to the text editor utility in simple-git tasks + */ + allowUnsafeEditor: boolean; + + /** + * Allows use of setting paths for merge tools in simple-git tasks + */ + allowUnsafeMergeDriver: boolean; + + /** + * Allows setting path to the pager utility in simple-git tasks + */ + allowUnsafePager: boolean; + + /** + * By default, `simple-git` prevents the use of inline configuration + * options to override the protocols available for the `git` child + * process to prevent accidental security vulnerabilities when + * unsanitised user data is passed directly into operations such as + * `git.addRemote`, `git.clone` or `git.raw`. + * + * Enable this override to use the `ext::` protocol (see examples on + * [git-scm.com](https://git-scm.com/docs/git-remote-ext#_examples)). + */ + allowUnsafeProtocolOverride: boolean; + + /** + * Given the possibility of using `--upload-pack` and `--receive-pack` as + * attack vectors, the use of these in any command (or the shorthand + * `-u` option in a `clone` operation) are blocked by default. + * + * Enable this override to permit the use of these arguments. + */ + allowUnsafePack: boolean; + + /** + * Using a `-c` switch to enable custom SSH commands opens up a potential + * attack vector for running arbitrary commands. + */ + allowUnsafeSshCommand: boolean; + + /** + * Using a `-c` switch to enable custom proxy command for the `git://` transport + * exposes and attack vector for running arbitrary commands. + */ + allowUnsafeGitProxy: boolean; + + /** + * Using a `-c` switch to enable custom hooks path commands to be run automatically + * exposes and attack vector for running arbitrary commands. + */ + allowUnsafeHooksPath: boolean; + + /** + * Using a `-c` switch to enable setting binary for processing diffs + * exposes and attack vector for running arbitrary commands. + */ + allowUnsafeDiffExternal: boolean; + + /** + * Using a `-c` switch to enable setting binary for retrieving text content of a file + */ + allowUnsafeDiffTextConv: boolean; + + /** + * Using a `-c` switch to enable setting binary for `smudge` and `clean` operations + * which can add and remove content to a file during checkout and commit. + */ + allowUnsafeFilter: boolean; + + /** + * Using a `-c` switch to enable setting the binary to which `git` will delegate + * file content change detection. + */ + allowUnsafeFsMonitor: boolean; + + /** + * Using a `-c` switch to configure the GPG signing program (`gpg.program`) or a + * per-format variant (`gpg.ssh.program`, `gpg.x509.program`). Controlling the signing + * binary allows an attacker to run arbitrary code whenever a commit or tag is signed. + */ + allowUnsafeGpgProgram: boolean; + + /** + * Allows overriding template directory either by environment variable or configuration in simple-git tasks + */ + allowUnsafeTemplateDir: boolean; }
packages/argv-parser/test/attack-vectors.spec.ts+13 −2 modified@@ -1,7 +1,7 @@ -import { parseArgv } from '@simple-git/argv-parser'; +import { parseArgv, parseEnv } from '@simple-git/argv-parser'; import { describe, expect, it } from 'vitest'; -import { aWriteConfig } from './__fixtures__/mocks'; +import { aWriteConfig, oneVulnerability } from './__fixtures__/mocks'; describe('security edge cases', () => { it('detects core.sshCommand injection via -c on any sub-command', () => { @@ -71,4 +71,15 @@ describe('security edge cases', () => { expect(flags).toEqual([]); expect(paths).toContain('-not-a-flag'); }); + + it('detects use of template directory', () => { + const expected = oneVulnerability('allowUnsafeTemplateDir'); + + expect(parseArgv('any-cmd', '-c', 'init.templateDir=/foo')).toHaveProperty( + 'vulnerabilities', + expected + ); + expect(parseArgv('any-cmd', '--template=/foo')).toHaveProperty('vulnerabilities', expected); + expect(parseEnv({ 'Git_Template_Dir': '/foo' })).toHaveProperty('vulnerabilities', expected); + }); });
packages/argv-parser/test/__fixtures__/mocks.ts+19 −1 modified@@ -1,4 +1,7 @@ -import type { ConfigRead, ConfigWrite, ParsedFlag } from '../../src/parse-argv.types'; +import { expect } from 'vitest'; + +import type { ConfigRead, ConfigWrite, ParsedFlag } from '../../src/args/parse-argv.types'; +import type { VulnerabilityCategory } from '../../src/vulnerabilities/vulnerability.types'; export function aParsedFlag(name: string, value?: string): ParsedFlag { return value !== undefined ? { name: name, value } : { name: name }; @@ -15,3 +18,18 @@ export function aWriteConfig( export function aReadConfig(key: string, scope: ConfigRead['scope']): ConfigRead { return { key, scope }; } + +export function aVulnerability(category: VulnerabilityCategory) { + return { + category, + message: expect.stringContaining(`enabling ${category}`), + }; +} + +export function oneVulnerability(category: VulnerabilityCategory) { + return [aVulnerability(category)]; +} + +export function noVulnerabilities() { + return []; +}
packages/argv-parser/test/parse-argv.spec.ts+4 −4 modified@@ -17,7 +17,7 @@ describe('full ParsedArgv shape', () => { flags: [aParsedFlag('-m', 'initial')], paths: [], config: { write: [], read: [] }, - vulnerabilities: { categories: new Set(), vulnerabilities: [] }, + vulnerabilities: [], }); }); @@ -27,7 +27,7 @@ describe('full ParsedArgv shape', () => { flags: [aParsedFlag('--force')], paths: [], config: { write: [], read: [] }, - vulnerabilities: { categories: new Set(), vulnerabilities: [] }, + vulnerabilities: [], }); }); @@ -37,7 +37,7 @@ describe('full ParsedArgv shape', () => { flags: [], paths: [], config: { write: [], read: [] }, - vulnerabilities: { categories: new Set(), vulnerabilities: [] }, + vulnerabilities: [], }); }); @@ -47,7 +47,7 @@ describe('full ParsedArgv shape', () => { flags: [], paths: [], config: { write: [], read: [] }, - vulnerabilities: { categories: new Set(), vulnerabilities: [] }, + vulnerabilities: [], }); });
packages/argv-parser/test/parse-env.spec.ts+71 −0 added@@ -0,0 +1,71 @@ +import { parseEnv, type VulnerabilityCategory } from '@simple-git/argv-parser'; +import { describe, expect, it } from 'vitest'; + +import { + aVulnerability, + aWriteConfig, + noVulnerabilities, + oneVulnerability, +} from './__fixtures__/mocks'; + +describe('parseEnv', () => { + describe('permitted settings', () => { + it('allows empty environment variables', () => { + expect(parseEnv({})).toHaveProperty('vulnerabilities', noVulnerabilities()); + }); + + it('allows innocuous environment variables', () => { + expect(parseEnv({ 'PATH': '...', LANG: 'C', LC_ALL: 'C' })).toHaveProperty( + 'vulnerabilities', + noVulnerabilities() + ); + }); + }); + + it.each<[string, string, VulnerabilityCategory | null]>([ + ['EDITOR', 'malicious', 'allowUnsafeEditor'], + ['GIT_ASKPASS', 'malicious', 'allowUnsafeAskPass'], + ['GIT_CONFIG_GLOBAL', '/tmp/malicious', 'allowUnsafeConfigPaths'], + ['GIT_CONFIG_SYSTEM', '/tmp/malicious', 'allowUnsafeConfigPaths'], + ['GIT_CONFIG_COUNT', '1', 'allowUnsafeConfigEnvCount'], + ['GIT_CONFIG', '/tmp/malicious', 'allowUnsafeConfigPaths'], + ['GIT_EDITOR', '/tmp/malicious', 'allowUnsafeEditor'], + ['GIT_SEQUENCE_EDITOR', '/tmp/malicious', 'allowUnsafeEditor'], + ['GIT_EXEC_PATH', '/tmp/malicious', 'allowUnsafeConfigPaths'], + ['GIT_EXTERNAL_DIFF', '/tmp/malicious', 'allowUnsafeDiffExternal'], + ['GIT_PAGER', '/tmp/malicious', 'allowUnsafePager'], + ['GIT_PROXY_COMMAND', '/tmp/malicious', 'allowUnsafeGitProxy'], + ['GIT_SSH', '/tmp/malicious', 'allowUnsafeSshCommand'], + ['GIT_SSH_COMMAND', '/tmp/malicious', 'allowUnsafeSshCommand'], + ['PAGER', 'malicious', 'allowUnsafePager'], + ['PREFIX', 'malicious', 'allowUnsafeConfigPaths'], + ['SSH_ASKPASS', 'malicious', 'allowUnsafeAskPass'], + ])('with environment variable %s = %s', (key, value, category) => { + const expected = category ? oneVulnerability(category) : noVulnerabilities(); + const parsed = parseEnv({ [key]: value }); + + expect(parsed.vulnerabilities).toEqual(expected); + }); + + it('triggers configuration warnings when using environment variables', () => { + const parsed = parseEnv({ + 'git_config_count': '2', + git_config_key_0: 'CORE.FSMonitor', + git_config_value_0: 'malicious', + git_config_key_1: 'user.name', + git_config_value_1: 'bob', + }); + + expect(parsed).toHaveProperty('config', { + read: [], + write: [ + aWriteConfig('core.fsmonitor', 'env', 'malicious'), + aWriteConfig('user.name', 'env', 'bob'), + ], + }); + expect(parsed).toHaveProperty('vulnerabilities', [ + aVulnerability('allowUnsafeConfigEnvCount'), + aVulnerability('allowUnsafeFsMonitor'), + ]); + }); +});
packages/argv-parser/test/vulnerability-analysis.spec.ts+109 −53 modified@@ -1,26 +1,107 @@ import { parseArgv, type VulnerabilityCategory } from '@simple-git/argv-parser'; import { describe, expect, it } from 'vitest'; -function oneVulnerability(category: VulnerabilityCategory) { - return { - categories: new Set([category]), - vulnerabilities: [ - { - category, - message: expect.stringContaining(`enabling ${category}`), - }, - ], - }; -} - -function noVulnerabilities() { - return { - categories: new Set(), - vulnerabilities: [], - }; -} +import { noVulnerabilities, oneVulnerability } from './__fixtures__/mocks'; describe('VulnerabilityAnalysis', () => { + describe('permitted settings', () => { + it.skip.each<[string, string]>([ + ['alias.status', 'safe'], + ['core.fsmonitor', 'false'], + ['core.gitproxy', 'none'], + ['credential.helper', 'osxkeychain'], + ])('allows writing %s = %s to the git config', (key, value) => { + expect(parseArgv('config', key, value)).toHaveProperty( + 'vulnerabilities', + noVulnerabilities() + ); + }); + + it('allows local cloning', () => { + const parsed = parseArgv('clone', '--no-checkout', '--', './first', 'second'); + expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); + }); + + it('allows local cloning without checkout', () => { + const parsed = parseArgv('clone', '--no-checkout', '--', './first', 'second'); + expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); + }); + + it('clone non-default branch is allowed (#1137)', async () => { + const parsed = parseArgv( + 'clone', + '-b', + 'non-default-branch', + '--', + 'https://github.com/example/bruno.git', + '/tmp/target' + ); + expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); + }); + + it('allows -u for non-clone commands', async () => { + const parsed = parseArgv('push', '-u', 'origin/main'); + expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); + }); + + it('uses pathspec protection for -u in remote', async () => { + const parsed = parseArgv('clone', '--', '-u touch /tmp/pwn', 'file:///tmp/zero12'); + expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); + }); + }); + + it.each<[string, string, VulnerabilityCategory | null]>([ + ['alias.status', '!malicious', 'allowUnsafeAlias'], + ['core.askPass', '!malicious', 'allowUnsafeAskPass'], + ['core.editor', 'malicious', 'allowUnsafeEditor'], + ['core.fsmonitor', 'malicious', 'allowUnsafeFsMonitor'], + ['core.gitproxy', 'malicious', 'allowUnsafeGitProxy'], + ['core.hooksPath', '/malicious', 'allowUnsafeHooksPath'], + ['core.pager', 'malicious', 'allowUnsafePager'], + ['core.sshCommand', 'malicious', 'allowUnsafeSshCommand'], + ['credential.helper', '/tmp/evil', 'allowUnsafeCredentialHelper'], + ['credential.https://example.com.helper', '!malicious', 'allowUnsafeCredentialHelper'], + ['credential.helper', '!malicious', 'allowUnsafeCredentialHelper'], + ['diff.command', 'malicious', 'allowUnsafeDiffExternal'], + ['diff.driver.command', 'malicious', 'allowUnsafeDiffExternal'], + ['diff.external', 'malicious', 'allowUnsafeDiffExternal'], + ['diff.textconv', 'malicious', 'allowUnsafeDiffTextConv'], + ['diff.driver.textconv', 'malicious', 'allowUnsafeDiffTextConv'], + ['filter.clean', 'malicious', 'allowUnsafeFilter'], + ['filter.driver.clean', 'malicious', 'allowUnsafeFilter'], + ['filter.smudge', 'malicious', 'allowUnsafeFilter'], + ['filter.driver.smudge', 'malicious', 'allowUnsafeFilter'], + ['gpg.program', 'malicious', 'allowUnsafeGpgProgram'], + ['gpg.type.program', 'malicious', 'allowUnsafeGpgProgram'], + ['init.templateDir', '/tmp/evil', 'allowUnsafeTemplateDir'], + ['merge.driver', '/tmp/evil', 'allowUnsafeMergeDriver'], + ['merge.foo.driver', '/tmp/evil', 'allowUnsafeMergeDriver'], + ['mergetool.path', '/tmp/evil', 'allowUnsafeMergeDriver'], + ['mergetool.foo.path', '/tmp/evil', 'allowUnsafeMergeDriver'], + ['mergetool.cmd', '/tmp/evil', 'allowUnsafeMergeDriver'], + ['mergetool.foo.cmd', '/tmp/evil', 'allowUnsafeMergeDriver'], + ['remote.receivepack', 'malicious', 'allowUnsafePack'], + ['remote.https://example.com.receivepack', 'malicious', 'allowUnsafePack'], + ['remote.uploadpack', 'malicious', 'allowUnsafePack'], + ['remote.https://example.com.uploadpack', 'malicious', 'allowUnsafePack'], + ['sequence.editor', 'malicious', 'allowUnsafeEditor'], + ])('writing %s = %s to the git config', (key, value, category) => { + const expected = category ? oneVulnerability(category) : noVulnerabilities(); + + // writing local config + expect(parseArgv('config', key, value).vulnerabilities).toEqual(expected); + + // trailing inline config + expect( + parseArgv('clone', 'remote', 'local', '-c', `${key}=${value}`).vulnerabilities + ).toEqual(expected); + + // leading inline config + expect( + parseArgv('-c', `${key}=${value}`, 'clone', 'remote', 'local').vulnerabilities + ).toEqual(expected); + }); + it.each([ ['protocol.allow=always'], ['PROTOCOL.ALLOW=always'], @@ -30,7 +111,7 @@ describe('VulnerabilityAnalysis', () => { ['protocol.ssh.ALLOW=always'], ['protocol.ext.allow=always'], ['protocol.git.ALLOW=never'], - ])('detects protocol.allow in format %s', async (cmd) => { + ])('Case insensitive configuration captures protocol.allow in format "%s"', async (cmd) => { const parsed = parseArgv('config', '-c', cmd, 'config', '--list'); expect(parsed.vulnerabilities).toEqual(oneVulnerability('allowUnsafeProtocolOverride')); @@ -52,7 +133,16 @@ describe('VulnerabilityAnalysis', () => { it.each<[VulnerabilityCategory, string]>([ ['allowUnsafeDiffExternal', `diff.external=sh -c 'id > pwned'`], + ['allowUnsafeDiffTextConv', `diff.evil.textconv=touch /tmp/proof`], + ['allowUnsafeDiffExternal', `diff.evil.command=sh -c 'id > pwned'`], + ['allowUnsafeFilter', `filter.evil.clean=touch /tmp/proof`], + ['allowUnsafeFilter', `filter.evil.smudge=touch /tmp/proof`], ['allowUnsafeGitProxy', `core.gitProxy=sh -c 'id > pwned'`], + ['allowUnsafeGpgProgram', `gpg.program=malicious`], + ['allowUnsafeGpgProgram', `gpg.ssh.program=malicious`], + ['allowUnsafeGpgProgram', `gpg.x509.program=malicious`], + ['allowUnsafePack', `remote.origin.uploadpack=malicious`], + ['allowUnsafePack', `remote.origin.receivepack=malicious`], ['allowUnsafeHooksPath', `core.hooksPath=sh -c 'id > pwned'`], ['allowUnsafeFsMonitor', `core.FsMonitor=CMD`], ['allowUnsafeSshCommand', `core.sshCommand=sh -c 'id > pwned'`], @@ -93,38 +183,4 @@ describe('VulnerabilityAnalysis', () => { ).toEqual(oneVulnerability('allowUnsafePack')); }); }); - - describe('not-vulnerable', () => { - it('allows local cloning', () => { - const parsed = parseArgv('clone', '--no-checkout', '--', './first', 'second'); - expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); - }); - - it('allows local cloning without checkout', () => { - const parsed = parseArgv('clone', '--no-checkout', '--', './first', 'second'); - expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); - }); - - it('clone non-default branch is allowed (#1137)', async () => { - const parsed = parseArgv( - 'clone', - '-b', - 'non-default-branch', - '--', - 'https://github.com/example/bruno.git', - '/tmp/target' - ); - expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); - }); - - it('allows -u for non-clone commands', async () => { - const parsed = parseArgv('push', '-u', 'origin/main'); - expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); - }); - - it('uses pathspec protection for -u in remote', async () => { - const parsed = parseArgv('clone', '--', '-u touch /tmp/pwn', 'file:///tmp/zero12'); - expect(parsed.vulnerabilities).toEqual(noVulnerabilities()); - }); - }); });
packages/argv-parser/test/vulnerability-check.spec.ts+36 −0 added@@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { vulnerabilityCheck } from '../src/vulnerabilities/vulnerability-check'; +import { aVulnerability, noVulnerabilities } from './__fixtures__/mocks'; + +describe('vulnerability-check', () => { + describe('permitted settings', () => { + it('allows empty environment variables', () => { + expect(vulnerabilityCheck(['config', 'user.name', 'bob'], {})).toEqual( + noVulnerabilities() + ); + }); + + it('allows innocuous environment variables', () => { + expect(vulnerabilityCheck([], { 'PATH': '...', LANG: 'C', LC_ALL: 'C' })).toEqual( + noVulnerabilities() + ); + }); + }); + + it('triggers configuration warnings when using environment variables', () => { + const parsed = vulnerabilityCheck(['config', 'filter.clean', 'malicious'], { + 'git_config_count': '2', + git_config_key_0: 'CORE.FSMonitor', + git_config_value_0: 'malicious', + git_config_key_1: 'user.name', + git_config_value_1: 'bob', + }); + + expect(parsed).toEqual([ + aVulnerability('allowUnsafeFilter'), + aVulnerability('allowUnsafeConfigEnvCount'), + aVulnerability('allowUnsafeFsMonitor'), + ]); + }); +});
simple-git/src/lib/plugins/block-unsafe-operations-plugin.ts+4 −6 modified@@ -1,18 +1,16 @@ -import type { SimpleGitPlugin } from './simple-git-plugin'; +import { vulnerabilityCheck } from '@simple-git/argv-parser'; import { GitPluginError } from '../errors/git-plugin-error'; import type { SimpleGitPluginConfig } from '../types'; -import { parseArgv } from '@simple-git/argv-parser'; +import type { SimpleGitPlugin } from './simple-git-plugin'; export function blockUnsafeOperationsPlugin( options: SimpleGitPluginConfig['unsafe'] = {} ): SimpleGitPlugin<'spawn.args'> { return { type: 'spawn.args', - action(args) { - const parsed = parseArgv(...args); - - for (const vulnerability of parsed.vulnerabilities.vulnerabilities) { + action(args, { env }) { + for (const vulnerability of vulnerabilityCheck(args, env)) { if (options[vulnerability.category] !== true) { throw new GitPluginError(undefined, 'unsafe', vulnerability.message); }
simple-git/src/lib/plugins/simple-git-plugin.ts+3 −1 modified@@ -9,7 +9,9 @@ type SimpleGitTaskPluginContext = { export interface SimpleGitPluginTypes { 'spawn.args': { data: string[]; - context: SimpleGitTaskPluginContext & {}; + context: SimpleGitTaskPluginContext & { + env: Record<string, string | undefined>; + }; }; 'spawn.binary': { data: string;
simple-git/src/lib/runners/git-executor-chain.ts+4 −5 modified@@ -81,11 +81,10 @@ export class GitExecutorChain implements SimpleGitExecutor { private async attemptRemoteTask<R>(task: RunnableTask<R>, logger: OutputLogger) { const binary = this._plugins.exec('spawn.binary', '', pluginContext(task, task.commands)); - const args = this._plugins.exec( - 'spawn.args', - [...task.commands], - pluginContext(task, task.commands) - ); + const args = this._plugins.exec('spawn.args', [...task.commands], { + ...pluginContext(task, task.commands), + env: { ...this.env }, + }); const raw = await this.gitResponse( task,
simple-git/src/lib/types/index.ts+10 −58 modified@@ -2,6 +2,7 @@ import type { SpawnOptions } from 'child_process'; import type { SimpleGitTask } from './tasks'; import type { SimpleGitProgressEvent } from './handlers'; +import { VulnerabilityCategoryFlags } from '@simple-git/argv-parser'; export * from './handlers'; export * from './tasks'; @@ -129,64 +130,15 @@ export interface SimpleGitPluginConfig { spawnOptions: Pick<SpawnOptions, 'uid' | 'gid'>; - unsafe: { - /** - * Allows potentially unsafe values to be supplied in the `binary` configuration option and - * `git.customBinary()` method call. - */ - allowUnsafeCustomBinary?: boolean; - - /** - * By default, `simple-git` prevents the use of inline configuration - * options to override the protocols available for the `git` child - * process to prevent accidental security vulnerabilities when - * unsanitised user data is passed directly into operations such as - * `git.addRemote`, `git.clone` or `git.raw`. - * - * Enable this override to use the `ext::` protocol (see examples on - * [git-scm.com](https://git-scm.com/docs/git-remote-ext#_examples)). - */ - allowUnsafeProtocolOverride?: boolean; - - /** - * Given the possibility of using `--upload-pack` and `--receive-pack` as - * attack vectors, the use of these in any command (or the shorthand - * `-u` option in a `clone` operation) are blocked by default. - * - * Enable this override to permit the use of these arguments. - */ - allowUnsafePack?: boolean; - - /** - * Using a `-c` switch to enable custom SSH commands opens up a potential - * attack vector for running arbitrary commands. - */ - allowUnsafeSshCommand?: boolean; - - /** - * Using a `-c` switch to enable custom proxy command for the `git://` transport - * exposes and attack vector for running arbitrary commands. - */ - allowUnsafeGitProxy?: boolean; - - /** - * Using a `-c` switch to enable custom hooks path commands to be run automatically - * exposes and attack vector for running arbitrary commands. - */ - allowUnsafeHooksPath?: boolean; - - /** - * Using a `-c` switch to enable setting binary for processing diffs - * exposes and attack vector for running arbitrary commands. - */ - allowUnsafeDiffExternal?: boolean; - - /** - * Using a `-c` switch to enable setting the binary to which `git` will delegate - * file content change detection. - */ - allowUnsafeFsMonitor?: boolean; - }; + unsafe: Partial< + VulnerabilityCategoryFlags & { + /** + * Allows potentially unsafe values to be supplied in the `binary` configuration option and + * `git.customBinary()` method call. + */ + allowUnsafeCustomBinary: boolean; + } + >; } /**
simple-git/test/integration/plugin.unsafe.spec.ts+47 −0 modified@@ -8,10 +8,57 @@ import {GitPluginError} from '../..'; describe('plugin.unsafe', () => { let context: SimpleGitTestContext; + function pwnedPath() { + return join(context.root, 'pwn', 'touched'); + } + + function isPwned() { + return exists(pwnedPath()); + } + process.env.DEBUG = 'simple-git,simple-git:*'; beforeEach(async () => (context = await createTestContext())); + describe('Command injection through .env', () => { + beforeEach( () => context.git.init()); + beforeEach(() => context.dir('pwn')); + + it('blocks core.fsmonitor by default', async () => { + const result = await promiseResult( + newSimpleGit(context.root).raw( + '-c', `core.fsmonitor=touch ${pwnedPath()}`, 'status' + ) + ); + + assertGitError(result.error, 'allowUnsafeFsMonitor') + expect(isPwned()).toBe(false); + + + const unsafeResult = await promiseResult( + newSimpleGit(context.root, { unsafe: { allowUnsafeFsMonitor: true }}).raw( + '-c', `core.fsmonitor=touch ${pwnedPath()}`, 'status' + ) + ); + + expect(unsafeResult.threw).toBe(false); + expect(isPwned()).toBe(true); + }); + + it('blocks filter.clean by default', async () => { + await context.file('file'); + + const result = await promiseResult( + newSimpleGit(context.root).raw( + '-c', `filter.evil.clean=touch ${pwnedPath()}`, 'add', 'file.txt' + ) + ); + + assertGitError(result.error, 'allowUnsafeFilter') + expect(isPwned()).toBe(false); + }); + }); + describe('CVE-2022-25860: command execution using clone -u', () => { function pwnedPath() { return join(context.root, 'pwn', 'touched');
simple-git/test/unit/clone.spec.ts+6 −17 modified@@ -1,12 +1,7 @@ -import { promiseError } from '@kwsites/promise-result'; -import { SimpleGit, TaskOptions } from 'typings'; -import { - assertExecutedCommands, - assertGitError, - closeWithSuccess, - newSimpleGit, -} from './__fixtures__'; -import { pathspec } from '@simple-git/args-pathspec'; +import {promiseError} from '@kwsites/promise-result'; +import {SimpleGit, TaskOptions} from 'typings'; +import {assertExecutedCommands, assertGitError, closeWithSuccess, newSimpleGit,} from './__fixtures__'; +import {pathspec} from '@simple-git/args-pathspec'; describe('clone', () => { let git: SimpleGit; @@ -39,13 +34,13 @@ describe('clone', () => { ], ['mirror', 'explicitly set', ['r', 'l'], ['clone', '--mirror', '--', 'r', 'l']], ['clone', 'kitchen sink', ['https://abcdefghijklmnopqrstuvwxyz01234567890.repo', 'dir', - ['--template=<template-directory>', '-l', '-s', '--no-hardlinks', '-q', '-n', '--bare', '--mirror', + ['-l', '-s', '--no-hardlinks', '-q', '-n', '--bare', '--mirror', '-o', 'alternative-origin', '-b', 'specific-branch', '--separate-git-dir', 'other-path', '--depth', '1', '--no-single-branch', '--no-tags', '--recurse-submodules=foo', '--no-shallow-submodules', '--no-remote-submodules', '--jobs', '2', '--sparse', '--no-reject-shallow', '--filter=sub-path', '--also-filter-submodules']], - ['clone', '--template=<template-directory>', '-l', '-s', '--no-hardlinks', '-q', '-n', '--bare', '--mirror', + ['clone', '-l', '-s', '--no-hardlinks', '-q', '-n', '--bare', '--mirror', '-o', 'alternative-origin', '-b', 'specific-branch', '--separate-git-dir', 'other-path', '--depth', '1', '--no-single-branch', '--no-tags', '--recurse-submodules=foo', '--no-shallow-submodules', '--no-remote-submodules', '--jobs', '2', '--sparse', @@ -91,12 +86,6 @@ describe('clone', () => { }); describe('failures', () => { - it('disallows upload-pack as remote/branch', async () => { - const error = await promiseError(git.clone('origin', '--upload-pack=touch ./foo')); - - assertGitError(error, 'allowUnsafePack'); - }); - it('disallows upload-pack as varargs', async () => { const error = await promiseError( git.clone('origin', 'main', {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.