CVE-2020-28490 (published 2021-02-18T15:15Z)

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 v2 base score: 7.5 (AV:N/AC:L/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-78
cwe_name: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
description: 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.
extended_description: 
		   This weakness can lead to a vulnerability in environments in which the attacker does not have direct access to the operating system, such as in web applications. Alternately, if the weakness occurs in a privileged program, it could allow the attacker to specify commands that normally would not be accessible, or to call alternate commands with privileges that the attacker does not have. The problem is exacerbated if the compromised process does not follow the principle of least privilege, because the attacker-controlled commands may run with special system privileges that increases the amount of damage.
            There are at least two subtypes of OS command injection:
               
				 The application intends to execute a single, fixed program that is under its own control. It intends to use externally-supplied inputs as arguments to that program. For example, the program might use system("nslookup [HOSTNAME]") to run nslookup and allow the user to supply a HOSTNAME, which is used as an argument. Attackers cannot prevent nslookup from executing. However, if the program does not remove command separators from the HOSTNAME argument, attackers could place the separators into the arguments, which allows them to execute their own program after nslookup has finished executing.
				 The application accepts an input that it uses to fully select which program to run, as well as which commands to use. The application simply redirects this entire command to the operating system. For example, the program might use "exec([COMMAND])" to execute the [COMMAND] that was supplied by the user. If the COMMAND is under attacker control, then the attacker can execute arbitrary commands or programs. If the command is being executed using functions like exec() and CreateProcess(), the attacker might not be able to combine multiple commands together in the same line.
               
            From a weakness standpoint, these variants represent distinct programmer errors. In the first variant, the programmer clearly intends that input from untrusted parties will be part of the arguments in the command to be executed. In the second variant, the programmer does not intend for the command to be accessible to any untrusted party, but the programmer probably has not accounted for alternate ways in which malicious attackers can provide input.
         
         

Fix information:
repo_url: https://github.com/omrilotan/async-git
commit_details: repo_url: https://github.com/omrilotan/async-git
author: omrilotan
author_date: 2021-01-24 20:52:00+02:00
msg: Use spawn with git to avoid shell script vulnerabilities (#14)
repository: repo_url: https://github.com/omrilotan/async-git
repo_name: omrilotan/async-git
description: 👾 Retrieve data from current git repository
repo_language: JavaScript
owner: omrilotan
file_changes: filename: index.js
change_type: ModificationType.ADD
diff: @@ -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;
+			}
+		});
+	},
+);

programming_language: JavaScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,6 @@
-const exec = require('async-execute');
+const spawn = require('./helpers/spawn');
 
 const {
-	list,
 	modified,
 	remote,
 	reset,
@@ -30,8 +29,8 @@ 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' ],
 ];
 
 /**
@@ -39,11 +38,11 @@ const outputs = [
  * @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' ],
 ];
 
 /**
@@ -51,20 +50,20 @@ const lists = [
  */
 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}`) }),
 	),
 );
 

programming_language: TypeScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,4 @@
 module.exports = {
-	list: require('./list'),
 	modified: require('./modified'),
 	remote: require('./remote'),
 	reset: require('./reset'),

programming_language: CoffeeScript
filename: index.js
change_type: ModificationType.DELETE
diff: @@ -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)
-	;
-};

programming_language: unknown
filename: spec.js
change_type: ModificationType.DELETE
diff: @@ -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);
-	});
-});

programming_language: unknown
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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);
 };

programming_language: TypeScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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 ];
 

programming_language: JavaScript
filename: spec.js
change_type: ModificationType.MODIFY
diff: @@ -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();

programming_language: JavaScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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})`);

programming_language: JavaScript
filename: spec.js
change_type: ModificationType.MODIFY
diff: @@ -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');
 	});
 });

programming_language: TypeScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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}`);
 };

programming_language: TypeScript
filename: spec.js
change_type: ModificationType.MODIFY
diff: @@ -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,8 +22,8 @@ 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() => {
@@ -31,7 +31,7 @@ describe('lib/tag', async() => {
 		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');
 	});
 });

programming_language: TypeScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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')

programming_language: JavaScript
filename: spec.js
change_type: ModificationType.MODIFY
diff: @@ -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'
 `);

programming_language: JavaScript
filename: package.json
change_type: ModificationType.MODIFY
diff: @@ -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",

programming_language: JavaScript
filename: spec.js
change_type: ModificationType.MODIFY
diff: @@ -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,18 +93,10 @@ 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];
@@ -118,11 +104,6 @@ describe('async-git', async() => {
 		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');

programming_language: TypeScript
filename: spec.js
change_type: ModificationType.MODIFY
diff: @@ -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;

programming_language: TypeScript