CVE-2022-29220 (JavaScript, published 2022-05-31T16:15Z)
Description: value: github-action-merge-dependabot is an action that automatically approves and merges dependabot pull requests (PRs). Prior to version 3.2.0, github-action-merge-dependabot does not check if a commit created by dependabot is verified with the proper GPG key. There is just a check if the actor is set to `dependabot[bot]` to determine if the PR is a legit PR. Theoretically, an owner of a seemingly valid and legit action in the pipeline can check if the PR is created by dependabot and if their own action has enough permissions to modify the PR in the pipeline. If so, they can modify the PR by adding a second seemingly valid and legit commit to the PR, as they can set arbitrarily the username and email in for commits in git. Because the bot only checks if the actor is valid, it would pass the malicious changes through and merge the PR automatically, without getting noticed by project maintainers. It would probably not be possible to determine where the malicious commit came from, as it would only say `dependabot[bot]` and the corresponding email-address. Version 3.2.0 contains a patch for this issue.
Severity: MEDIUM (CVSS v3 base score: 6.5)
Weakness: CWE-345 \u2014 Insufficient Verification of Data Authenticity
The product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data.
Repository: https://github.com/fastify/github-action-merge-dependabot
Files changed:
dist/105.index.js
dist/index.js
src/action.js
src/getDependabotDetails.js
src/github-client.js
src/verifyCommitSignatures.js
test/action.test.js
test/github-client.test.js
test/verifyCommitSignatures.test.js
Commit message: Merge pull request from GHSA-v5vr-h3xq-8v6w

* fix: add pr commit author check

* chore: update prettier settings

* chore: update prettier settings

* feat: add verify commit signatures

* chore: revert prettier

* test: add action test

* refactor: remove commits fallback

* test: add verify test

* feat: add spawn gpg

* Fixing PR comments: Moved import from func to global; Iterate with hwp; Added GPG information to ReadMe

* Removing Key from files

* Fixing node_modules

* different approach to validating the commit. Checking that the commit is verified, the author is dependabot[bot], the committer is GitHub

* Adding fixes to tests

* Fix for the tests

* eslint, naming and removal of magic text

* chore: formatting

* Refactored tests

* Removed unecessary async function. Iterating through very small amounts of commits.Renamed file for consistency

* removed unecessary dependency

Co-authored-by: marceloFerreira90 <marcelo.ferreira@nearform.com>
Co-authored-by: marceloFerreira90 <marcyff2@gmail.com>
Co-authored-by: Simone Busoli <simone.busoli@gmail.com>

Vulnerable code:
    const isDependabotPR = pr.user.login === 'dependabot[bot]'
    const isDependabotPR = pr.user.login === 'dependabot[bot]'

Fixed code:
"use strict";
exports.id = 105;
exports.ids = [105];
exports.modules = {

/***/ 8770:
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {


const fs = __webpack_require__(7147);
const os = __webpack_require__(2037);

const tempDirectorySymbol = Symbol.for('__RESOLVED_TEMP_DIRECTORY__');

if (!global[tempDirectorySymbol]) {
	Object.defineProperty(global, tempDirectorySymbol, {
		value: fs.realpathSync(os.tmpdir())
	});
}

module.exports = global[tempDirectorySymbol];


/***/ }),

/***/ 4105:
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {

// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);

// EXPORTS
__webpack_require__.d(__webpack_exports__, {
  "rootTemporaryDirectory": () => (/* reexport */ temp_dir),
  "temporaryDirectory": () => (/* binding */ temporaryDirectory),
  "temporaryDirectoryTask": () => (/* binding */ temporaryDirectoryTask),
  "temporaryFile": () => (/* binding */ temporaryFile),
  "temporaryFileTask": () => (/* binding */ temporaryFileTask),
  "temporaryWrite": () => (/* binding */ temporaryWrite),
  "temporaryWriteSync": () => (/* binding */ temporaryWriteSync),
  "temporaryWriteTask": () => (/* binding */ temporaryWriteTask)
});

// EXTERNAL MODULE: external "node:fs"
var external_node_fs_ = __webpack_require__(7561);
// EXTERNAL MODULE: external "node:fs/promises"
var promises_ = __webpack_require__(3977);
// EXTERNAL MODULE: external "node:path"
var external_node_path_ = __webpack_require__(9411);
// EXTERNAL MODULE: external "node:stream"
var external_node_stream_ = __webpack_require__(4492);
// EXTERNAL MODULE: external "node:util"
var external_node_util_ = __webpack_require__(7261);
// EXTERNAL MODULE: external "util"
var external_util_ = __webpack_require__(3837);
// EXTERNAL MODULE: external "crypto"
var external_crypto_ = __webpack_require__(6113);
;// CONCATENATED MODULE: ./node_modules/crypto-random-string/index.js



const randomBytesAsync = (0,external_util_.promisify)(external_crypto_.randomBytes);

const urlSafeCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'.split('');
const numericCharacters = '0123456789'.split('');
const distinguishableCharacters = 'CDEHKMPRTUWXY012458'.split('');
const asciiPrintableCharacters = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'.split('');
const alphanumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('');

const generateForCustomCharacters = (length, characters) => {
	// Generating entropy is faster than complex math operations, so we use the simplest way
	const characterCount = characters.length;
	const maxValidSelector = (Math.floor(0x10000 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division
	const entropyLength = 2 * Math.ceil(1.1 * length); // Generating a bit more than required so chances we need more than one pass will be really low
	let string = '';
	let stringLength = 0;

	while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
		const entropy = external_crypto_.randomBytes(entropyLength);
		let entropyPosition = 0;

		while (entropyPosition < entropyLength && stringLength < length) {
			const entropyValue = entropy.readUInt16LE(entropyPosition);
			entropyPosition += 2;
			if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
				continue;
			}

			string += characters[entropyValue % characterCount];
			stringLength++;
		}
	}

	return string;
};

const generateForCustomCharactersAsync = async (length, characters) => {
	// Generating entropy is faster than complex math operations, so we use the simplest way
	const characterCount = characters.length;
	const maxValidSelector = (Math.floor(0x10000 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division
	const entropyLength = 2 * Math.ceil(1.1 * length); // Generating a bit more than required so chances we need more than one pass will be really low
	let string = '';
	let stringLength = 0;

	while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
		const entropy = await randomBytesAsync(entropyLength); // eslint-disable-line no-await-in-loop
		let entropyPosition = 0;

		while (entropyPosition < entropyLength && stringLength < length) {
			const entropyValue = entropy.readUInt16LE(entropyPosition);
			entropyPosition += 2;
			if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
				continue;
			}

			string += characters[entropyValue % characterCount];
			stringLength++;
		}
	}

	return string;
};

const generateRandomBytes = (byteLength, type, length) => external_crypto_.randomBytes(byteLength).toString(type).slice(0, length);

const generateRandomBytesAsync = async (byteLength, type, length) => {
	const buffer = await randomBytesAsync(byteLength);
	return buffer.toString(type).slice(0, length);
};

const allowedTypes = new Set([
	undefined,
	'hex',
	'base64',
	'url-safe',
	'numeric',
	'distinguishable',
	'ascii-printable',
	'alphanumeric'
]);

const createGenerator = (generateForCustomCharacters, generateRandomBytes) => ({length, type, characters}) => {
	if (!(length >= 0 && Number.isFinite(length))) {
		throw new TypeError('Expected a `length` to be a non-negative finite number');
	}

	if (type !== undefined && characters !== undefined) {
		throw new TypeError('Expected either `type` or `characters`');
	}

	if (characters !== undefined && typeof characters !== 'string') {
		throw new TypeError('Expected `characters` to be string');
	}

	if (!allowedTypes.has(type)) {
		throw new TypeError(`Unknown type: ${type}`);
	}

	if (type === undefined && characters === undefined) {
		type = 'hex';
	}

	if (type === 'hex' || (type === undefined && characters === undefined)) {
		return generateRandomBytes(Math.ceil(length * 0.5), 'hex', length); // Need 0.5 byte entropy per character
	}

	if (type === 'base64') {
		return generateRandomBytes(Math.ceil(length * 0.75), 'base64', length); // Need 0.75 byte of entropy per character
	}

	if (type === 'url-safe') {
		return generateForCustomCharacters(length, urlSafeCharacters);
	}

	if (type === 'numeric') {
		return generateForCustomCharacters(length, numericCharacters);
	}

	if (type === 'distinguishable') {
		return generateForCustomCharacters(length, distinguishableCharacters);
	}

	if (type === 'ascii-printable') {
		return generateForCustomCharacters(length, asciiPrintableCharacters);
	}

	if (type === 'alphanumeric') {
		return generateForCustomCharacters(length, alphanumericCharacters);
	}

	if (characters.length === 0) {
		throw new TypeError('Expected `characters` string length to be greater than or equal to 1');
	}

	if (characters.length > 0x10000) {
		throw new TypeError('Expected `characters` string length to be less or equal to 65536');
	}

	return generateForCustomCharacters(length, characters.split(''));
};

const cryptoRandomString = createGenerator(generateForCustomCharacters, generateRandomBytes);

cryptoRandomString.async = createGenerator(generateForCustomCharactersAsync, generateRandomBytesAsync);

/* harmony default export */ const crypto_random_string = (cryptoRandomString);

;// CONCATENATED MODULE: ./node_modules/unique-string/index.js


function uniqueString() {
	return crypto_random_string({length: 32});
}

// EXTERNAL MODULE: ./node_modules/temp-dir/index.js
var temp_dir = __webpack_require__(8770);
;// CONCATENATED MODULE: ./node_modules/tempy/node_modules/is-stream/index.js
function isStream(stream) {
	return stream !== null
		&& typeof stream === 'object'
		&& typeof stream.pipe === 'function';
}

function isWritableStream(stream) {
	return isStream(stream)
		&& stream.writable !== false
		&& typeof stream._write === 'function'
		&& typeof stream._writableState === 'object';
}

function isReadableStream(stream) {
	return isStream(stream)
		&& stream.readable !== false
		&& typeof stream._read === 'function'
		&& typeof stream._readableState === 'object';
}

function isDuplexStream(stream) {
	return isWritableStream(stream)
		&& isReadableStream(stream);
}

function isTransformStream(stream) {
	return isDuplexStream(stream)
		&& typeof stream._transform === 'function';
}

;// CONCATENATED MODULE: ./node_modules/tempy/index.js









const pipeline = (0,external_node_util_.promisify)(external_node_stream_.pipeline); // TODO: Use `node:stream/promises` when targeting Node.js 16.

const getPath = (prefix = '') => external_node_path_.join(temp_dir, prefix + uniqueString());

const writeStream = async (filePath, data) => pipeline(data, external_node_fs_.createWriteStream(filePath));

async function runTask(temporaryPath, callback) {
	try {
		return await callback(temporaryPath);
	} finally {
		await promises_.rm(temporaryPath, {recursive: true, force: true});
	}
}

function temporaryFile({name, extension} = {}) {
	if (name) {
		if (extension !== undefined && extension !== null) {
			throw new Error('The `name` and `extension` options are mutually exclusive');
		}

		return external_node_path_.join(temporaryDirectory(), name);
	}

	return getPath() + (extension === undefined || extension === null ? '' : '.' + extension.replace(/^\./, ''));
}

const temporaryFileTask = async (callback, options) => runTask(temporaryFile(options), callback);

function temporaryDirectory({prefix = ''} = {}) {
	const directory = getPath(prefix);
	external_node_fs_.mkdirSync(directory);
	return directory;
}

const temporaryDirectoryTask = async (callback, options) => runTask(temporaryDirectory(options), callback);

async function temporaryWrite(fileContent, options) {
	const filename = temporaryFile(options);
	const write = isStream(fileContent) ? writeStream : promises_.writeFile;
	await write(filename, fileContent);
	return filename;
}

const temporaryWriteTask = async (fileContent, callback, options) => runTask(await temporaryWrite(fileContent, options), callback);

function temporaryWriteSync(fileContent, options) {
	const filename = temporaryFile(options);
	external_node_fs_.writeFileSync(filename, fileContent);
	return filename;
}




/***/ })

};
;
const { verifyCommits } = __nccwpck_require__(3094)
const { dependabotAuthor } = __nccwpck_require__(500)
    const isDependabotPR = pr.user.login === dependabotAuthor
    const commits = await client.getPullRequestCommits(pr.number)

    if (!commits.every(commit => commit.author.login === dependabotAuthor)) {
      return logWarning('PR contains non dependabot commits, skipping.')
    }

    try {
      await verifyCommits(commits)
    } catch {
      return logWarning(
        'PR contains invalid dependabot commit signatures, skipping.'
      )
    }

/***/ }),

/***/ 500:
/***/ ((module) => {

"use strict";

const dependabotAuthor = 'dependabot[bot]'

const dependabotCommitter = 'GitHub'

module.exports = {
  dependabotAuthor,
  dependabotCommitter,
}



    async getPullRequestCommits(pullRequestNumber) {
      const { data } = await octokit.rest.pulls.listCommits({
        owner,
        repo: repoName,
        pull_number: pullRequestNumber,
      })

      return data
    },
/***/ }),

/***/ 3094:
/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {

"use strict";


const {
  dependabotAuthor,
  dependabotCommitter,
} = __nccwpck_require__(500)

function verifyCommits(commits) {
  commits.forEach(function (commit) {
    const {
      commit: {
        verification: { verified },
        committer,
        author,
      },
      sha,
    } = commit
    verifyCommitSignatureCommitterAndAuthor(sha, author, committer, verified)
  })
}

function verifyCommitSignatureCommitterAndAuthor(
  sha,
  author,
  committer,
  verified
) {
  if (
    !verified ||
    committer.name !== dependabotCommitter ||
    author.name !== dependabotAuthor
  ) {
    throw new Error(
      `Signature for commit ${sha} could not be verified - Not a dependabot commit`
    )
  }
}

module.exports = {
  verifyCommits,
  verifyCommitSignatureCommitterAndAuthor,
}


const { verifyCommits } = require('./verifyCommitSignatures')
const { dependabotAuthor } = require('./getDependabotDetails')
    const isDependabotPR = pr.user.login === dependabotAuthor
    const commits = await client.getPullRequestCommits(pr.number)

    if (!commits.every(commit => commit.author.login === dependabotAuthor)) {
      return logWarning('PR contains non dependabot commits, skipping.')
    }

    try {
      await verifyCommits(commits)
    } catch {
      return logWarning(
        'PR contains invalid dependabot commit signatures, skipping.'
      )
    }

'use strict'
const dependabotAuthor = 'dependabot[bot]'

const dependabotCommitter = 'GitHub'

module.exports = {
  dependabotAuthor,
  dependabotCommitter,
}

    async getPullRequestCommits(pullRequestNumber) {
      const { data } = await octokit.rest.pulls.listCommits({
        owner,
        repo: repoName,
        pull_number: pullRequestNumber,
      })

      return data
    },
'use strict'

const {
  dependabotAuthor,
  dependabotCommitter,
} = require('./getDependabotDetails')

function verifyCommits(commits) {
  commits.forEach(function (commit) {
    const {
      commit: {
        verification: { verified },
        committer,
        author,
      },
      sha,
    } = commit
    verifyCommitSignatureCommitterAndAuthor(sha, author, committer, verified)
  })
}

function verifyCommitSignatureCommitterAndAuthor(
  sha,
  author,
  committer,
  verified
) {
  if (
    !verified ||
    committer.name !== dependabotCommitter ||
    author.name !== dependabotAuthor
  ) {
    throw new Error(
      `Signature for commit ${sha} could not be verified - Not a dependabot commit`
    )
  }
}

module.exports = {
  verifyCommits,
  verifyCommitSignatureCommitterAndAuthor,
}
const verifyCommits = require('../src/verifyCommitSignatures')
  const prCommitsStub = sinon.stub()

    getPullRequestCommits: prCommitsStub.resolves([]),
  const verifyCommitsStub = sinon
    .stub(verifyCommits, 'verifyCommits')
    .returns(Promise.resolve())

      prCommitsStub,
      verifyCommitsStub,
tap.test('should skip PR with non dependabot commit', async () => {
  const PR_NUMBER = Math.random()
  const { action, stubs } = buildStubbedAction({
    payload: {
      pull_request: {
        user: {
          login: BOT_NAME,
        },
        number: PR_NUMBER,
      },
    },
    inputs: { PR_NUMBER },
  })

  stubs.prCommitsStub.resolves([
    {
      author: {
        login: 'not dependabot',
      },
    },
  ])

  await action()

  sinon.assert.calledOnce(stubs.prCommitsStub)
  sinon.assert.calledWithExactly(
    stubs.logStub.logWarning,
    'PR contains non dependabot commits, skipping.'
  )
  sinon.assert.notCalled(stubs.approveStub)
  sinon.assert.notCalled(stubs.mergeStub)
})

tap.test(
  'should skip PR if dependabot commit signatures cannot be verified',
  async () => {
    const PR_NUMBER = Math.random()
    const { action, stubs } = buildStubbedAction({
      payload: {
        pull_request: {
          user: {
            login: BOT_NAME,
          },
          number: PR_NUMBER,
        },
      },
      inputs: { PR_NUMBER },
    })

    stubs.prCommitsStub.resolves([
      {
        author: {
          login: 'dependabot[bot]',
        },
      },
    ])

    stubs.verifyCommitsStub.rejects()

    await action()

    sinon.assert.calledWithExactly(
      stubs.logStub.logWarning,
      'PR contains invalid dependabot commit signatures, skipping.'
    )
    sinon.assert.notCalled(stubs.approveStub)
    sinon.assert.notCalled(stubs.mergeStub)
  }
)

  listCommits: sinon.stub().returns(Promise.resolve({ data })),

  t.test('getPullRequestCommits', async () => {
    const result = await githubClient(TOKEN).getPullRequestCommits(PR_NUMBER)
    tap.equal(result, data)

    sinon.assert.calledWith(octokitStubs.listCommits, {
      owner: githubContext.repository.owner.login,
      repo: githubContext.repository.name,
      pull_number: PR_NUMBER,
    })
  })
'use strict'

const tap = require('tap')

const {
  dependabotAuthor,
  dependabotCommitter,
} = require('../src/getDependabotDetails')
const {
  verifyCommits,
  verifyCommitSignatureCommitterAndAuthor,
} = require('../src/verifyCommitSignatures')

const ValidAuthorValidCommitterVerifiedCommitMock = {
  commit: {
    author: { name: dependabotAuthor },
    committer: { name: dependabotCommitter },
    verification: { verified: true },
  },
  sha: 'sha',
}
const ValidAuthorValidCommitterUnverifiedCommitMock = {
  commit: {
    author: { name: dependabotAuthor },
    committer: { name: dependabotCommitter },
    verification: { verified: false },
  },
  sha: 'sha',
}
const InvalidAuthorValidCommitterVerifiedCommitMock = {
  commit: {
    author: { name: 'testUser' },
    committer: { name: dependabotCommitter },
    verification: { verified: true },
  },
  sha: 'sha',
}
const ValidAuthorInvalidCommitterVerifiedCommitMock = {
  commit: {
    author: { name: dependabotAuthor },
    committer: { name: 'testUser' },
    verification: { verified: true },
  },
  sha: 'sha',
}
const InvalidAuthorInvalidCommitterUnverifiedCommitMock = {
  commit: {
    author: { name: 'testUser' },
    committer: { name: 'testUser' },
    verification: { verified: false },
  },
  sha: 'sha',
}

const prCommitsValid = [
  ValidAuthorValidCommitterVerifiedCommitMock,
  ValidAuthorValidCommitterVerifiedCommitMock,
]
const prCommitsInvalid = [
  InvalidAuthorValidCommitterVerifiedCommitMock,
  InvalidAuthorValidCommitterVerifiedCommitMock,
]
const prCommitsValidAndInvalid = [
  ValidAuthorValidCommitterVerifiedCommitMock,
  InvalidAuthorValidCommitterVerifiedCommitMock,
]

tap.test('verifyCommitSignatureCommitterAndAuthor', async t => {
  t.test('shoud throw error when', async t => {
    t.test('the verified flag is set to false', async t => {
      const {
        commit: {
          author,
          committer,
          verification: { verified },
        },
        sha,
      } = ValidAuthorValidCommitterUnverifiedCommitMock
      t.throws(
        () =>
          verifyCommitSignatureCommitterAndAuthor(
            sha,
            author,
            committer,
            verified
          ),
        new Error(
          `Signature for commit ${sha} could not be verified - Not a dependabot commit`
        )
      )
    })

    t.test('the committer name is not GitHub', async t => {
      const {
        commit: {
          author,
          committer,
          verification: { verified },
        },
        sha,
      } = ValidAuthorInvalidCommitterVerifiedCommitMock
      t.throws(
        () =>
          verifyCommitSignatureCommitterAndAuthor(
            sha,
            author,
            committer,
            verified
          ),
        new Error(
          `Signature for commit ${sha} could not be verified - Not a dependabot commit`
        )
      )
    })

    t.test('the authot name is not dependabot[bot]', async t => {
      const {
        commit: {
          author,
          committer,
          verification: { verified },
        },
        sha,
      } = InvalidAuthorValidCommitterVerifiedCommitMock
      t.throws(
        () =>
          verifyCommitSignatureCommitterAndAuthor(
            sha,
            author,
            committer,
            verified
          ),
        new Error(
          `Signature for commit ${sha} could not be verified - Not a dependabot commit`
        )
      )
    })

    t.test(
      'the committer name is not Github, the author is not dependabot[bot] and is not verified',
      async t => {
        const {
          commit: {
            author,
            committer,
            verification: { verified },
          },
          sha,
        } = InvalidAuthorInvalidCommitterUnverifiedCommitMock
        t.throws(
          () =>
            verifyCommitSignatureCommitterAndAuthor(
              sha,
              author,
              committer,
              verified
            ),
          new Error(
            `Signature for commit ${sha} could not be verified - Not a dependabot commit`
          )
        )
      }
    )
  })

  t.test('should not throw an error when', async t => {
    t.test(
      'the committer name is Github, the author is dependabot[bot] and is verified',
      async t => {
        const {
          commit: {
            author,
            committer,
            verification: { verified },
          },
          sha,
        } = ValidAuthorValidCommitterVerifiedCommitMock
        t.doesNotThrow(
          () =>
            verifyCommitSignatureCommitterAndAuthor(
              sha,
              author,
              committer,
              verified
            ),
          {}
        )
      }
    )
  })
})

tap.test('VerifyCommits', async t => {
  t.test('Should throw error when', async t => {
    t.test('At least one commit does not match the requirements', async t => {
      t.throws(
        () => verifyCommits(prCommitsValidAndInvalid),
        new Error(
          `Signature for commit sha could not be verified - Not a dependabot commit`
        )
      )
    })

    t.test('At least one commit does not match the requirements', async t => {
      t.throws(
        () => verifyCommits(prCommitsInvalid),
        new Error(
          `Signature for commit sha could not be verified - Not a dependabot commit`
        )
      )
    })
  })
  t.test('Should not throw error when', async t => {
    t.test('All commits match the requirements', async t => {
      t.doesNotThrow(() => verifyCommits(prCommitsValid), {})
    })
  })
})