CVE-2020-28490 (JavaScript, published 2021-02-18T15:15Z)
Description: value: The package async-git before 1.13.2 are vulnerable to Command Injection via shell meta-characters (back-ticks). For example: git.reset('atouch HACKEDb')
Severity: HIGH (CVSS v3 base score: 9.8)
Weakness: CWE-78 \u2014 Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.
Repository: https://github.com/omrilotan/async-git
Files changed:
helpers/spawn/index.js
index.js
lib/index.js
lib/modified/index.js
lib/remote/index.js
lib/remote/spec.js
lib/reset/index.js
lib/reset/spec.js
lib/tag/index.js
lib/tag/spec.js
lib/unadded/index.js
lib/unadded/spec.js
package.json
spec.js
vulnerabilities/spec.js
Commit message: Use spawn with git to avoid shell script vulnerabilities (#14)



Vulnerable code:
const exec = require('async-execute');
	list,
	[ 'branch', 'git rev-parse --abbrev-ref HEAD' ],
	[ 'origin', 'git remote get-url origin' ],
	[ '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' ],
		date: async () => new Date(parseInt(await exec('git show -s --format=%at')) * 1000),
		version: async () => (await exec('git version')).split(' ').pop(),
		([ key, value ]) => ({ [key]: exec.bind(null, value) }),
		([ key, value ]) => ({ [key]: list.bind(null, value) }),
		([ key, value ]) => ({ [key]: exec.bind(null, `git show -s --format=%${value}`) }),
	list: require('./list'),
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)
	;
};
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);
	});
});
const exec = require('async-execute');
	const ts = await exec(`git log -1 --format="%at" -- ${path}`);
const exec = require('async-execute');
	const origin = await exec('git remote get-url origin');
	clean('async-execute');
			override('async-execute', () => format);
				override('async-execute', async () => format);
const exec = require('async-execute');
		return await exec(`git reset ${JSON.stringify(destination)} ${hard ? '--hard' : ''}`);
		return await exec(`git reset HEAD~${Math.abs(destination)} ${hard ? '--hard' : ''}`);
		override('async-execute', exec);
		reset('shaid');
		expect(exec.getCall(0).args[0]).to.equal('git reset "shaid" --hard');
		reset(1);
		expect(exec.getCall(0).args[0]).to.equal('git reset HEAD~1 --hard');
		reset(-3);
		expect(exec.getCall(0).args[0]).to.equal('git reset HEAD~3 --hard');
		reset('shaid', { hard: false });
		expect(exec.getCall(0).args[0].trim()).to.equal('git reset "shaid"');
const exec = require('async-execute');
		exec(`git config user.name "${await author}"`),
		exec(`git 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}`)}`);
		override('async-execute', (...args) => dummy.stub(...args));
		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('git tag -a "1.1.1" -m "this is a message"');
		expect(lines).to.include('git push origin "refs/tags/1.1.1"');
const exec = require('async-execute');
	const output = await exec('git add --dry-run .');
		override('async-execute', () => `remove '.gitattributes'
  "version": "1.13.1",
    "@does/exist": "^1.1.0",
    "async-execute": "^1.1.0"
const { clean, override } = abuser(__filename);
const list = stub();
		override('./lib/list', list);
		override('./lib/unadded', list);
		list.returns([ 'a', 'b', 'c' ]);
	afterEach(() => list.resetHistory());

	// 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');
	});

		'unadded',
	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');
	});


Fixed code:
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;
			}
		});
	},
);
const spawn = require('./helpers/spawn');
	[ 'branch', 'rev-parse --abbrev-ref HEAD' ],
	[ 'origin', 'remote get-url origin' ],
	[ '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' ],
		date: async () => new Date(parseInt(await spawn('show -s --format=%at')) * 1000),
		version: async () => (await spawn('version')).split(' ').pop(),
		([ key, value ]) => ({ [key]: spawn.bind(null, value) }),
		([ key, value ]) => ({ [key]: async () => (await spawn(value)).split('\n') }),
		([ key, value ]) => ({ [key]: spawn.bind(null, `show -s --format=%${value}`) }),
const spawn = require('../../helpers/spawn');
	const ts = await spawn(`log -1 --format="%at" -- ${path}`);
const spawn = require('../../helpers/spawn');
	const origin = await spawn('remote get-url origin');
	clean('../../helpers/spawn');
			override('../../helpers/spawn', () => format);
				override('../../helpers/spawn', async () => format);
const spawn = require('../../helpers/spawn');
		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' : ''}`);
		return await spawn(`reset HEAD~${Math.abs(destination)} ${hard ? '--hard' : ''}`);
		override('../../helpers/spawn', exec);
		await reset('shaid');
		expect(exec.getCall(1).args[0]).to.equal('reset shaid --hard');
		await reset(1);
		expect(exec.getCall(0).args[0]).to.equal('reset HEAD~1 --hard');
		await reset(-3);
		expect(exec.getCall(0).args[0]).to.equal('reset HEAD~3 --hard');
		await reset('shaid', { hard: false });
		expect(exec.getCall(1).args[0].trim()).to.equal('reset shaid');
const spawn = require('../../helpers/spawn');
		spawn(`config user.name "${await author}"`),
		spawn(`config user.email "${await email}"`),
	await spawn(`tag -a ${tag} -m "${await message}"`);
	await spawn(`push origin refs/tags/${tag}`);
		override('../../helpers/spawn', (...args) => dummy.stub(...args));
		expect(lines).to.include('config user.name "boaty mcboatface"');
		expect(lines).to.include('config user.email "boaty@boat.face"');
		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');
const spawn = require('../../helpers/spawn');
	const output = await spawn('add --dry-run .');
		override('../../helpers/spawn', () => `remove '.gitattributes'
  "version": "1.13.2",
    "pretest": "echo \"content\" > unadded.txt",
    "@does/exist": "^1.1.0"
    "async-execute": "^1.2.0",
const { clean } = abuser(__filename);
	before(async() => {
		await wait(100);
		await softly(unlink, 'HACKED');
	});
	it('shell injection in reset', async() => {
		await softly(git.reset, 'a`touch HACKED`b');
		expect(await exists('HACKED')).to.be.false;
	});