VYPR
Critical severityNVD Advisory· Published Feb 18, 2021· Updated Sep 17, 2024

Command Injection

CVE-2020-28490

Description

The async-git npm package before 1.13.2 is vulnerable to command injection via back-ticks in git commands, allowing arbitrary OS command execution.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

The async-git npm package before 1.13.2 is vulnerable to command injection via back-ticks in git commands, allowing arbitrary OS command execution.

Root

Cause The async-git npm package, designed to retrieve data from a local git repository, used the async-execute library to run git commands. This library invoked a shell environment to execute commands, meaning that any user-supplied input passed to git methods (e.g., git.reset()) was interpreted by the shell. As a result, shell meta-characters such as back-ticks could be injected, allowing an attacker to execute arbitrary commands. [1][3][4]

Exploitation

Attackers can exploit this vulnerability by providing input containing back-ticks to any async-git method that accepts user-controlled arguments. For example, calling git.reset('atouch HACKEDb') would execute the command touch HACKEDb in the shell after the git reset command. No authentication is required if the application exposes these methods to untrusted input. [1][2][4]

Impact

Successful exploitation leads to arbitrary OS command execution with the privileges of the application running the async-git package. This can result in full system compromise, data exfiltration, or lateral movement within the network. The vulnerability has a CVSS score of 9.8 (Critical) per the NVD. [2][4]

Mitigation

The vulnerability is fixed in async-git version 1.13.2. The fix replaced exec calls with spawn from the child_process module, which does not invoke a shell and thus prevents shell injection. Users should upgrade to version 1.13.2 or later immediately. [1][3][4]

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
async-gitnpm
< 1.13.21.13.2

Affected products

2

Patches

1
d1950a5021f4

Use spawn with git to avoid shell script vulnerabilities (#14)

https://github.com/omrilotan/async-gitomrilotanJan 24, 2021via ghsa
17 files changed · +102 136
  • helpers/spawn/index.js+38 0 added
    @@ -0,0 +1,38 @@
    +const { spawn } = require('child_process');
    +
    +/**
    + * Executes git with given arguments string. resolves with the output message
    + * @param  {String} string
    + * @return {Promise}
    + */
    +module.exports = string => new Promise(
    +	(resolve, reject) => {
    +		const git = spawn(
    +			'git',
    +			string.split(' '),
    +		);
    +
    +		const outputs = { stdout: [], stderr: [] };
    +
    +		git.stdout.on(
    +			'data',
    +			data => outputs.stdout.push(data.toString()),
    +		);
    +
    +		git.stderr.on(
    +			'data',
    +			data => outputs.stderr.push(data.toString()),
    +		);
    +
    +		git.on('exit', code => {
    +			switch (code) {
    +				case 0:
    +					resolve(outputs.stdout.join('').trim());
    +					break;
    +				default:
    +					reject(outputs.stderr.join('').trim());
    +					break;
    +			}
    +		});
    +	},
    +);
    
  • index.js+13 14 modified
    @@ -1,7 +1,6 @@
    -const exec = require('async-execute');
    +const spawn = require('./helpers/spawn');
     
     const {
    -	list,
     	modified,
     	remote,
     	reset,
    @@ -30,41 +29,41 @@ const formats = [
      * @type {Array[]}
      */
     const outputs = [
    -	[ 'branch', 'git rev-parse --abbrev-ref HEAD' ],
    -	[ 'origin', 'git remote get-url origin' ],
    +	[ 'branch', 'rev-parse --abbrev-ref HEAD' ],
    +	[ 'origin', 'remote get-url origin' ],
     ];
     
     /**
      * Lists to get from multi-line terminal output
      * @type {Array[]}
      */
     const lists = [
    -	[ 'changed', 'git diff-tree --no-commit-id --name-only -r HEAD' ],
    -	[ 'staged', 'git diff --name-only --cached' ],
    -	[ 'tags', 'git tag' ],
    -	[ 'unstaged', 'git diff --name-only' ],
    -	[ 'untracked', 'git ls-files -o --exclude-standard' ],
    +	[ 'changed', 'diff-tree --no-commit-id --name-only -r HEAD' ],
    +	[ 'staged', 'diff --name-only --cached' ],
    +	[ 'tags', 'tag' ],
    +	[ 'unstaged', 'diff --name-only' ],
    +	[ 'untracked', 'ls-files -o --exclude-standard' ],
     ];
     
     /**
      * Properties which will become (async) getters
      */
     const getters = Object.assign(
     	{
    -		date: async () => new Date(parseInt(await exec('git show -s --format=%at')) * 1000),
    +		date: async () => new Date(parseInt(await spawn('show -s --format=%at')) * 1000),
     		name: async () => (await remote()).name,
     		owner: async () => (await remote()).owner,
     		unadded,
    -		version: async () => (await exec('git version')).split(' ').pop(),
    +		version: async () => (await spawn('version')).split(' ').pop(),
     	},
     	...outputs.map(
    -		([ key, value ]) => ({ [key]: exec.bind(null, value) }),
    +		([ key, value ]) => ({ [key]: spawn.bind(null, value) }),
     	),
     	...lists.map(
    -		([ key, value ]) => ({ [key]: list.bind(null, value) }),
    +		([ key, value ]) => ({ [key]: async () => (await spawn(value)).split('\n') }),
     	),
     	...formats.map(
    -		([ key, value ]) => ({ [key]: exec.bind(null, `git show -s --format=%${value}`) }),
    +		([ key, value ]) => ({ [key]: spawn.bind(null, `show -s --format=%${value}`) }),
     	),
     );
     
    
  • lib/index.js+0 1 modified
    @@ -1,5 +1,4 @@
     module.exports = {
    -	list: require('./list'),
     	modified: require('./modified'),
     	remote: require('./remote'),
     	reset: require('./reset'),
    
  • lib/list/index.js+0 15 removed
    @@ -1,15 +0,0 @@
    -const exec = require('async-execute');
    -
    -/**
    - * List the output lines from command
    - * @return {string[]}
    - */
    -module.exports = async function list(command) {
    -	const output = await exec(command);
    -
    -	return output
    -		.split('\n')
    -		.map(item => item.trim())
    -		.filter(Boolean)
    -	;
    -};
    
  • lib/list/spec.js+0 51 removed
    @@ -1,51 +0,0 @@
    -const { clean, override } = abuser(__filename);
    -
    -const diff = `
    -		line 1
    -		line 2
    -line 3
    -
    -line 5
    -`;
    -
    -describe('lib/list', async() => {
    -	const exec = stub();
    -	let list;
    -
    -	before(() => {
    -		clean('.');
    -		override('async-execute', exec);
    -		exec.returns(diff);
    -		list = require('.').bind(null, 'some command');
    -	});
    -	afterEach(() => exec.resetHistory());
    -	after(() => clean('.'));
    -
    -	it('Should call exec with passed command', async() => {
    -		await list();
    -		expect(exec).to.have.been.calledWith('some command');
    -	});
    -	it('Should hard reset to a given sha', async() => {
    -		const result = await list();
    -		expect(result).to.deep.equal([
    -			'line 1',
    -			'line 2',
    -			'line 3',
    -			'line 5',
    -		]);
    -	});
    -	it('Should return an empty array for empty output', async() => {
    -		exec.returns('');
    -		const result = await list();
    -		expect(result).to.be.an('array');
    -		expect(result).to.have.lengthOf(0);
    -	});
    -	it('Should filter out empty lines', async() => {
    -		exec.returns(`
    -
    -		`);
    -		const result = await list();
    -		expect(result).to.be.an('array');
    -		expect(result).to.have.lengthOf(0);
    -	});
    -});
    
  • lib/modified/index.js+2 2 modified
    @@ -1,6 +1,6 @@
     const { resolve } = require('path');
    -const exec = require('async-execute');
     const exist = require('@does/exist');
    +const spawn = require('../../helpers/spawn');
     
     /**
      * Create and push a git tag with the last commit message
    @@ -18,7 +18,7 @@ module.exports = async function(path) {
     		throw new Error(`Could not find file at path "${absolute}"`);
     	}
     
    -	const ts = await exec(`git log -1 --format="%at" -- ${path}`);
    +	const ts = await spawn(`log -1 --format="%at" -- ${path}`);
     
     	return new Date(Number(ts) * 1000);
     };
    
  • lib/remote/index.js+2 2 modified
    @@ -1,11 +1,11 @@
    -const exec = require('async-execute');
    +const spawn = require('../../helpers/spawn');
     
     /**
      * Get remote repo name and owner
      * @return {string{}}
      */
     module.exports = async function remote() {
    -	const origin = await exec('git remote get-url origin');
    +	const origin = await spawn('remote get-url origin');
     	const nosuffix = origin.replace(/\.git$/, '');
     	const [ match ] = nosuffix.match(/[\w-]*\/[\w-]+$/) || [ nosuffix ];
     
    
  • lib/remote/spec.js+3 3 modified
    @@ -2,7 +2,7 @@ const { clean, override } = abuser(__filename);
     
     function cleanup() {
     	clean('.');
    -	clean('async-execute');
    +	clean('../../helpers/spawn');
     }
     
     describe('lib/remote', () => {
    @@ -23,7 +23,7 @@ describe('lib/remote', () => {
     		'ssh://[username@]repo_owner1/repo-name',
     	].forEach(
     		format => it(`should find owner and repo name in ${format}`, async () => {
    -			override('async-execute', () => format);
    +			override('../../helpers/spawn', () => format);
     
     			const remote = require('.');
     			const { owner, name } = await remote();
    @@ -40,7 +40,7 @@ describe('lib/remote', () => {
     		format => it(
     			`Still returns reponame when no owner is found (${format})`,
     			async () => {
    -				override('async-execute', async () => format);
    +				override('../../helpers/spawn', async () => format);
     
     				const remote = require('.');
     				const { owner, name } = await remote();
    
  • lib/reset/index.js+9 3 modified
    @@ -1,4 +1,4 @@
    -const exec = require('async-execute');
    +const spawn = require('../../helpers/spawn');
     
     /**
      * Reset current HEAD to the specified state
    @@ -8,11 +8,17 @@ const exec = require('async-execute');
      */
     module.exports = async function(destination, { hard = true } = {}) {
     	if (destination && typeof destination === 'string') {
    -		return await exec(`git reset ${JSON.stringify(destination)} ${hard ? '--hard' : ''}`);
    +		try {
    +			await spawn(`check-ref-format ${destination}`);
    +		} catch (error) {
    +			throw new RangeError('can not reset to illegal ref "${destination}"');
    +		}
    +
    +		return await spawn(`reset ${destination} ${hard ? '--hard' : ''}`);
     	}
     
     	if (destination && typeof destination === 'number') {
    -		return await exec(`git reset HEAD~${Math.abs(destination)} ${hard ? '--hard' : ''}`);
    +		return await spawn(`reset HEAD~${Math.abs(destination)} ${hard ? '--hard' : ''}`);
     	}
     
     	throw new TypeError(`No case for handling destination ${destination} (${typeof destination})`);
    
  • lib/reset/spec.js+9 9 modified
    @@ -6,7 +6,7 @@ describe('lib/reset', async() => {
     
     	before(() => {
     		clean('.');
    -		override('async-execute', exec);
    +		override('../../helpers/spawn', exec);
     		reset = require('.');
     	});
     	afterEach(() => exec.resetHistory());
    @@ -31,22 +31,22 @@ describe('lib/reset', async() => {
     	});
     
     	it('Should hard reset to a given sha', async() => {
    -		reset('shaid');
    -		expect(exec.getCall(0).args[0]).to.equal('git reset "shaid" --hard');
    +		await reset('shaid');
    +		expect(exec.getCall(1).args[0]).to.equal('reset shaid --hard');
     	});
     
     	it('Should hard reset to n commits back', async() => {
    -		reset(1);
    -		expect(exec.getCall(0).args[0]).to.equal('git reset HEAD~1 --hard');
    +		await reset(1);
    +		expect(exec.getCall(0).args[0]).to.equal('reset HEAD~1 --hard');
     	});
     
     	it('Should hard reset to n commits back with negative value as well', async() => {
    -		reset(-3);
    -		expect(exec.getCall(0).args[0]).to.equal('git reset HEAD~3 --hard');
    +		await reset(-3);
    +		expect(exec.getCall(0).args[0]).to.equal('reset HEAD~3 --hard');
     	});
     
     	it('Should reset w/o hard argument', async() => {
    -		reset('shaid', { hard: false });
    -		expect(exec.getCall(0).args[0].trim()).to.equal('git reset "shaid"');
    +		await reset('shaid', { hard: false });
    +		expect(exec.getCall(1).args[0].trim()).to.equal('reset shaid');
     	});
     });
    
  • lib/tag/index.js+5 5 modified
    @@ -1,4 +1,4 @@
    -const exec = require('async-execute');
    +const spawn = require('../../helpers/spawn');
     
     /**
      * Create and push a git tag with the last commit message
    @@ -13,9 +13,9 @@ module.exports = async function(tag) {
     	const { message, author, email } = this;
     
     	await Promise.all([
    -		exec(`git config user.name "${await author}"`),
    -		exec(`git config user.email "${await email}"`),
    +		spawn(`config user.name "${await author}"`),
    +		spawn(`config user.email "${await email}"`),
     	]);
    -	await exec(`git tag -a ${JSON.stringify(tag)} -m "${await message}"`);
    -	await exec(`git push origin ${JSON.stringify(`refs/tags/${tag}`)}`);
    +	await spawn(`tag -a ${tag} -m "${await message}"`);
    +	await spawn(`push origin refs/tags/${tag}`);
     };
    
  • lib/tag/spec.js+5 5 modified
    @@ -6,7 +6,7 @@ describe('lib/tag', async() => {
     
     	before(() => {
     		clean('.');
    -		override('async-execute', (...args) => dummy.stub(...args));
    +		override('../../helpers/spawn', (...args) => dummy.stub(...args));
     
     		gitTag = require('.').bind({
     			message: 'this is a message',
    @@ -22,16 +22,16 @@ describe('lib/tag', async() => {
     		dummy.stub = command => lines.push(command);
     
     		await gitTag('1.1.1');
    -		expect(lines).to.include('git config user.name "boaty mcboatface"');
    -		expect(lines).to.include('git config user.email "boaty@boat.face"');
    +		expect(lines).to.include('config user.name "boaty mcboatface"');
    +		expect(lines).to.include('config user.email "boaty@boat.face"');
     	});
     
     	it('Should create a git tag with given message and push it', async() => {
     		const lines = [];
     		dummy.stub = command => lines.push(command);
     
     		await gitTag('1.1.1');
    -		expect(lines).to.include('git tag -a "1.1.1" -m "this is a message"');
    -		expect(lines).to.include('git push origin "refs/tags/1.1.1"');
    +		expect(lines).to.include('tag -a 1.1.1 -m "this is a message"');
    +		expect(lines).to.include('push origin refs/tags/1.1.1');
     	});
     });
    
  • lib/unadded/index.js+2 2 modified
    @@ -1,11 +1,11 @@
    -const exec = require('async-execute');
    +const spawn = require('../../helpers/spawn');
     
     /**
      * List all files that would be added by "add ."
      * @return {string[]}
      */
     module.exports = async function unadded() {
    -	const output = await exec('git add --dry-run .');
    +	const output = await spawn('add --dry-run .');
     
     	return output
     		.split('\n')
    
  • lib/unadded/spec.js+1 1 modified
    @@ -5,7 +5,7 @@ describe('lib/unadded', () => {
     
     	before(() => {
     		clean('.');
    -		override('async-execute', () => `remove '.gitattributes'
    +		override('../../helpers/spawn', () => `remove '.gitattributes'
     add 'apps/post/index.js'
     add 'new file.txt'
     `);
    
  • package.json+4 3 modified
    @@ -1,6 +1,6 @@
     {
       "name": "async-git",
    -  "version": "1.13.1",
    +  "version": "1.13.2",
       "description": "👾 Retrieve data from current git repository",
       "keywords": [
         "git",
    @@ -25,17 +25,18 @@
       "homepage": "https://omrilotan.com/async-git/",
       "main": "index.js",
       "scripts": {
    +    "pretest": "echo \"content\" > unadded.txt",
         "test": "mocha '**/spec.js' --require .mocha.js --recursive --exclude 'node_modules'",
         "lint": "eslint . --ext .js"
       },
       "dependencies": {
    -    "@does/exist": "^1.1.0",
    -    "async-execute": "^1.1.0"
    +    "@does/exist": "^1.1.0"
       },
       "devDependencies": {
         "@lets/wait": "^2.0.2",
         "@omrilotan/eslint-config": "^1.1.0",
         "abuser": "^2.0.2",
    +    "async-execute": "^1.2.0",
         "chai": "^4.2.0",
         "chai-as-promised": "^7.1.1",
         "deep-equal-in-any-order": "^1.0.21",
    
  • spec.js+1 20 modified
    @@ -1,7 +1,6 @@
     const exec = require('async-execute');
     
    -const { clean, override } = abuser(__filename);
    -const list = stub();
    +const { clean } = abuser(__filename);
     let git;
     
     const is = Object.defineProperty(
    @@ -18,9 +17,6 @@ describe('async-git', async() => {
     	before(async() => {
     		clean('.');
     		clean('./lib');
    -		override('./lib/list', list);
    -		override('./lib/unadded', list);
    -		list.returns([ 'a', 'b', 'c' ]);
     		git = require('.');
     
     		if (is.ci) { return; }
    @@ -32,8 +28,6 @@ describe('async-git', async() => {
     		await exec('echo "content" > unadded.txt');
     	});
     
    -	afterEach(() => list.resetHistory());
    -
     	after(async() => {
     		clean('.');
     
    @@ -99,30 +93,17 @@ describe('async-git', async() => {
     		);
     	});
     
    -	// Lists
    -
    -	it('Should call list with relevant git command', async() => {
    -		await git.changed;
    -		expect(list).to.have.been.calledWith('git diff-tree --no-commit-id --name-only -r HEAD');
    -	});
    -
     	[
     		'changed',
     		'staged',
     		'tags',
    -		'unadded',
     		'untracked',
     	].forEach(member => it(`${member} should retrieve an array`, async () => {
     		const value = await git[member];
     		expect(value).to.be.an('array');
     		expect(value).to.have.lengthOf.at.least(1);
     	}));
     
    -	it('changed should call list with relevant git command', async() => {
    -		await git.changed;
    -		expect(list).to.have.been.calledWith('git diff-tree --no-commit-id --name-only -r HEAD');
    -	});
    -
     	it('changed should retrieve an array of strings', async () => {
     		const value = await git.changed;
     		expect(value).to.be.an('array');
    
  • vulnerabilities/spec.js+8 0 modified
    @@ -31,6 +31,10 @@ async function softly(fn, ...args) {
     }
     
     describe('vulnerabilities', async() => {
    +	before(async() => {
    +		await wait(100);
    +		await softly(unlink, 'HACKED');
    +	});
     	afterEach(async() => {
     		await wait(100);
     		await softly(unlink, 'HACKED');
    @@ -39,6 +43,10 @@ describe('vulnerabilities', async() => {
     		await softly(git.reset, '; touch HACKED #');
     		expect(await exists('HACKED')).to.be.false;
     	});
    +	it('shell injection in reset', async() => {
    +		await softly(git.reset, 'a`touch HACKED`b');
    +		expect(await exists('HACKED')).to.be.false;
    +	});
     	it('shell injection in tag', async() => {
     		await softly(git.tag, '; touch HACKED #');
     		expect(await exists('HACKED')).to.be.false;
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.