diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aaa284..7f78e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.1.0 (2020/05/05) + +* Configure and check committer email against GPG user address + ## 1.0.0 (2020/05/04) * Enable signing for Git commits and tags (#4) diff --git a/README.md b/README.md index 343d012..484a7ae 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ If you are interested, [check out](https://git.io/Je09Y) my other :octocat: GitH * Works on Linux and MacOS [virtual environments](https://help.github.com/en/articles/virtual-environments-for-github-actions#supported-virtual-environments-and-hardware-resources) * Allow to seed the internal cache of `gpg-agent` with provided passphrase -* Purge imported GPG key and cache information from runner (security) * Enable signing for Git commits and tags +* Configure and check committer info against GPG key +* Purge imported GPG key and cache information from runner ## Usage @@ -51,9 +52,11 @@ jobs: Following inputs can be used as `step.with` keys -| Name | Type | Description | -|----------------------|---------|----------------------------------------------------------| -| `git_gpgsign` | Bool | Enable signing for this Git repository (default `false`) | +| Name | Type | Description | +|------------------------|---------|----------------------------------------------------------| +| `git_gpgsign` | Bool | Enable signing for this Git repository (default `false`) | +| `git_committer_name` | String | Commit author's name (default [GITHUB_ACTOR](https://help.github.com/en/github/automating-your-workflow-with-github-actions/using-environment-variables#default-environment-variables) or `github-actions`) | +| `git_committer_email` | String | Commit author's email (default `@users.noreply.github.com`) | ### environment variables diff --git a/__tests__/gpg.test.ts b/__tests__/gpg.test.ts index 34c34f0..4387105 100644 --- a/__tests__/gpg.test.ts +++ b/__tests__/gpg.test.ts @@ -5,7 +5,6 @@ const userInfo = { email: 'joe@foo.bar', passphrase: 'with stupid passphrase', keyID: 'D523BD50DD70B0BA', - userID: 'Joe Tester ', fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0', keygrip: 'BA83FC8947213477F28ADC019F6564A956456163', pgp: `-----BEGIN PGP PRIVATE KEY BLOCK----- diff --git a/__tests__/openpgp.test.ts b/__tests__/openpgp.test.ts index 578d9f9..43045bf 100644 --- a/__tests__/openpgp.test.ts +++ b/__tests__/openpgp.test.ts @@ -5,7 +5,6 @@ const userInfo = { email: 'joe@foo.bar', passphrase: 'with stupid passphrase', keyID: 'D523BD50DD70B0BA', - userID: 'Joe Tester ', fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0', pgp: `-----BEGIN PGP PRIVATE KEY BLOCK----- @@ -120,7 +119,8 @@ describe('openpgp', () => { it('returns a PGP private key', async () => { await openpgp.readPrivateKey(userInfo.pgp).then(privateKey => { expect(privateKey.keyID).toEqual(userInfo.keyID); - expect(privateKey.userID).toEqual(userInfo.userID); + expect(privateKey.name).toEqual(userInfo.name); + expect(privateKey.email).toEqual(userInfo.email); expect(privateKey.fingerprint).toEqual(userInfo.fingerprint); }); }); diff --git a/action.yml b/action.yml index 9305321..b0515e6 100644 --- a/action.yml +++ b/action.yml @@ -10,6 +10,10 @@ inputs: git_gpgsign: description: 'Enable signing for this Git repository' default: 'false' + git_committer_name: + description: 'Commit author''s name' + git_committer_email: + description: 'Commit author''s email' runs: using: 'node12' diff --git a/dist/index.js b/dist/index.js index ad77582..955aca1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1031,6 +1031,9 @@ function run() { core.setFailed('Signing key required'); return; } + const git_gpgsign = /true/i.test(core.getInput('git_gpgsign')); + const git_committer_name = core.getInput('git_committer_name') || process.env['GITHUB_ACTOR'] || 'github-actions'; + const git_committer_email = core.getInput('git_committer_email') || `${git_committer_name}@users.noreply.github.com`; core.info('📣 GnuPG info'); const version = yield gpg.getVersion(); const dirs = yield gpg.getDirs(); @@ -1043,7 +1046,8 @@ function run() { const privateKey = yield openpgp.readPrivateKey(process.env.SIGNING_KEY); core.debug(`Fingerprint : ${privateKey.fingerprint}`); core.debug(`KeyID : ${privateKey.keyID}`); - core.debug(`UserID : ${privateKey.userID}`); + core.debug(`Name : ${privateKey.name}`); + core.debug(`Email : ${privateKey.email}`); core.debug(`CreationTime : ${privateKey.creationTime}`); core.info('🔑 Importing secret key'); yield gpg.importKey(process.env.SIGNING_KEY).then(stdout => { @@ -1060,7 +1064,14 @@ function run() { core.debug(stdout); }); } - if (/true/i.test(core.getInput('git_gpgsign'))) { + if (git_gpgsign) { + core.info(`🔨 Configuring git committer to be ${git_committer_name} <${git_committer_email}>`); + if (git_committer_email != privateKey.email) { + core.setFailed('Committer email does not match GPG key user address'); + return; + } + yield git.setConfig('user.name', git_committer_name); + yield git.setConfig('user.email', git_committer_email); core.info('💎 Enable signing for this Git repository'); yield git.enableCommitGpgsign(); yield git.setUserSigningkey(privateKey.keyID); @@ -1429,6 +1440,18 @@ function setUserSigningkey(keyid) { }); } exports.setUserSigningkey = setUserSigningkey; +function getConfig(key) { + return __awaiter(this, void 0, void 0, function* () { + return yield git(['config', key]); + }); +} +exports.getConfig = getConfig; +function setConfig(key, value) { + return __awaiter(this, void 0, void 0, function* () { + yield git(['config', key, value]); + }); +} +exports.setConfig = setConfig; /***/ }), @@ -45642,22 +45665,28 @@ var __importStar = (this && this.__importStar) || function (mod) { result["default"] = mod; return result; }; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); const openpgp = __importStar(__webpack_require__(724)); +const addressparser_1 = __importDefault(__webpack_require__(977)); exports.readPrivateKey = (armoredText) => __awaiter(void 0, void 0, void 0, function* () { const { keys: [privateKey], err: err } = yield openpgp.key.readArmored(armoredText); if (err === null || err === void 0 ? void 0 : err.length) { throw err[0]; } + const address = yield privateKey.getPrimaryUser().then(primaryUser => { + return addressparser_1.default(primaryUser.user.userId.userid)[0]; + }); return { fingerprint: privateKey.getFingerprint().toUpperCase(), keyID: yield privateKey.getEncryptionKey().then(encKey => { // @ts-ignore return encKey === null || encKey === void 0 ? void 0 : encKey.getKeyId().toHex().toUpperCase(); }), - userID: yield privateKey.getPrimaryUser().then(primaryUser => { - return primaryUser.user.userId.userid; - }), + name: address.name, + email: address.address, creationTime: privateKey.getCreationTime() }; }); @@ -45723,6 +45752,305 @@ exports.exec = (command, args = [], silent) => __awaiter(void 0, void 0, void 0, }); +/***/ }), + +/***/ 977: +/***/ (function(module) { + +"use strict"; + + +// expose to the world +module.exports = addressparser; + +/** + * Parses structured e-mail addresses from an address field + * + * Example: + * + * 'Name ' + * + * will be converted to + * + * [{name: 'Name', address: 'address@domain'}] + * + * @param {String} str Address field + * @return {Array} An array of address objects + */ +function addressparser(str) { + var tokenizer = new Tokenizer(str); + var tokens = tokenizer.tokenize(); + + var addresses = []; + var address = []; + var parsedAddresses = []; + + tokens.forEach(function (token) { + if (token.type === 'operator' && (token.value === ',' || token.value === ';')) { + if (address.length) { + addresses.push(address); + } + address = []; + } else { + address.push(token); + } + }); + + if (address.length) { + addresses.push(address); + } + + addresses.forEach(function (address) { + address = _handleAddress(address); + if (address.length) { + parsedAddresses = parsedAddresses.concat(address); + } + }); + + return parsedAddresses; +} + +/** + * Converts tokens for a single address into an address object + * + * @param {Array} tokens Tokens object + * @return {Object} Address object + */ +function _handleAddress(tokens) { + var token; + var isGroup = false; + var state = 'text'; + var address; + var addresses = []; + var data = { + address: [], + comment: [], + group: [], + text: [] + }; + var i; + var len; + + // Filter out , (comments) and regular text + for (i = 0, len = tokens.length; i < len; i++) { + token = tokens[i]; + if (token.type === 'operator') { + switch (token.value) { + case '<': + state = 'address'; + break; + case '(': + state = 'comment'; + break; + case ':': + state = 'group'; + isGroup = true; + break; + default: + state = 'text'; + } + } else if (token.value) { + if (state === 'address') { + // handle use case where unquoted name includes a "<" + // Apple Mail truncates everything between an unexpected < and an address + // and so will we + token.value = token.value.replace(/^[^<]*<\s*/, ''); + } + data[state].push(token.value); + } + } + + // If there is no text but a comment, replace the two + if (!data.text.length && data.comment.length) { + data.text = data.comment; + data.comment = []; + } + + if (isGroup) { + // http://tools.ietf.org/html/rfc2822#appendix-A.1.3 + data.text = data.text.join(' '); + addresses.push({ + name: data.text || (address && address.name), + group: data.group.length ? addressparser(data.group.join(',')) : [] + }); + } else { + // If no address was found, try to detect one from regular text + if (!data.address.length && data.text.length) { + for (i = data.text.length - 1; i >= 0; i--) { + if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) { + data.address = data.text.splice(i, 1); + break; + } + } + + var _regexHandler = function (address) { + if (!data.address.length) { + data.address = [address.trim()]; + return ' '; + } else { + return address; + } + }; + + // still no address + if (!data.address.length) { + for (i = data.text.length - 1; i >= 0; i--) { + // fixed the regex to parse email address correctly when email address has more than one @ + data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim(); + if (data.address.length) { + break; + } + } + } + } + + // If there's still is no text but a comment exixts, replace the two + if (!data.text.length && data.comment.length) { + data.text = data.comment; + data.comment = []; + } + + // Keep only the first address occurence, push others to regular text + if (data.address.length > 1) { + data.text = data.text.concat(data.address.splice(1)); + } + + // Join values with spaces + data.text = data.text.join(' '); + data.address = data.address.join(' '); + + if (!data.address && isGroup) { + return []; + } else { + address = { + address: data.address || data.text || '', + name: data.text || data.address || '' + }; + + if (address.address === address.name) { + if ((address.address || '').match(/@/)) { + address.name = ''; + } else { + address.address = ''; + } + + } + + addresses.push(address); + } + } + + return addresses; +} + +/** + * Creates a Tokenizer object for tokenizing address field strings + * + * @constructor + * @param {String} str Address field string + */ +function Tokenizer(str) { + this.str = (str || '').toString(); + this.operatorCurrent = ''; + this.operatorExpecting = ''; + this.node = null; + this.escaped = false; + + this.list = []; +} + +/** + * Operator tokens and which tokens are expected to end the sequence + */ +Tokenizer.prototype.operators = { + '"': '"', + '(': ')', + '<': '>', + ',': '', + ':': ';', + // Semicolons are not a legal delimiter per the RFC2822 grammar other + // than for terminating a group, but they are also not valid for any + // other use in this context. Given that some mail clients have + // historically allowed the semicolon as a delimiter equivalent to the + // comma in their UI, it makes sense to treat them the same as a comma + // when used outside of a group. + ';': '' +}; + +/** + * Tokenizes the original input string + * + * @return {Array} An array of operator|text tokens + */ +Tokenizer.prototype.tokenize = function () { + var chr, list = []; + for (var i = 0, len = this.str.length; i < len; i++) { + chr = this.str.charAt(i); + this.checkChar(chr); + } + + this.list.forEach(function (node) { + node.value = (node.value || '').toString().trim(); + if (node.value) { + list.push(node); + } + }); + + return list; +}; + +/** + * Checks if a character is an operator or text and acts accordingly + * + * @param {String} chr Character from the address field + */ +Tokenizer.prototype.checkChar = function (chr) { + if ((chr in this.operators || chr === '\\') && this.escaped) { + this.escaped = false; + } else if (this.operatorExpecting && chr === this.operatorExpecting) { + this.node = { + type: 'operator', + value: chr + }; + this.list.push(this.node); + this.node = null; + this.operatorExpecting = ''; + this.escaped = false; + return; + } else if (!this.operatorExpecting && chr in this.operators) { + this.node = { + type: 'operator', + value: chr + }; + this.list.push(this.node); + this.node = null; + this.operatorExpecting = this.operators[chr]; + this.escaped = false; + return; + } + + if (!this.escaped && chr === '\\') { + this.escaped = true; + return; + } + + if (!this.node) { + this.node = { + type: 'text', + value: '' + }; + this.list.push(this.node); + } + + if (this.escaped && chr !== '\\') { + this.node.value += '\\'; + } + + this.node.value += chr; + this.escaped = false; +}; + + /***/ }), /***/ 986: diff --git a/package-lock.json b/package-lock.json index 2803447..60dd531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -973,6 +973,11 @@ "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", "dev": true }, + "addressparser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", + "integrity": "sha1-R6++GiqSYhkdtoOOT9HTm0CCF0Y=" + }, "ajv": { "version": "6.12.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz", diff --git a/package.json b/package.json index 7aba938..41ba967 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@actions/core": "^1.2.3", "@actions/exec": "^1.0.4", "@actions/github": "^2.1.1", + "addressparser": "^1.0.1", "openpgp": "^4.10.4" }, "devDependencies": { diff --git a/src/addressparser.d.ts b/src/addressparser.d.ts new file mode 100644 index 0000000..a1ff443 --- /dev/null +++ b/src/addressparser.d.ts @@ -0,0 +1,24 @@ +declare namespace addressparser { + interface Address { + name: string; + address: string; + } +} + +/** + * Parses structured e-mail addresses from an address field + * + * Example: + * + * 'Name ' + * + * will be converted to + * + * [{name: 'Name', address: 'address@domain'}] + * + * @param str Address field + * @return An array of address objects + */ +declare function addressparser(address: string): addressparser.Address[]; + +export = addressparser; diff --git a/src/git.ts b/src/git.ts index 4fe6e44..5fd97b9 100644 --- a/src/git.ts +++ b/src/git.ts @@ -16,3 +16,11 @@ export async function enableCommitGpgsign(): Promise { export async function setUserSigningkey(keyid: string): Promise { await git(['config', 'user.signingkey', keyid]); } + +export async function getConfig(key: string): Promise { + return await git(['config', key]); +} + +export async function setConfig(key: string, value: string): Promise { + await git(['config', key, value]); +} diff --git a/src/main.ts b/src/main.ts index 93f4ffc..49642e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,12 @@ async function run(): Promise { return; } + const git_gpgsign = /true/i.test(core.getInput('git_gpgsign')); + const git_committer_name: string = + core.getInput('git_committer_name') || process.env['GITHUB_ACTOR'] || 'github-actions'; + const git_committer_email: string = + core.getInput('git_committer_email') || `${git_committer_name}@users.noreply.github.com`; + core.info('📣 GnuPG info'); const version = await gpg.getVersion(); const dirs = await gpg.getDirs(); @@ -30,7 +36,8 @@ async function run(): Promise { const privateKey = await openpgp.readPrivateKey(process.env.SIGNING_KEY); core.debug(`Fingerprint : ${privateKey.fingerprint}`); core.debug(`KeyID : ${privateKey.keyID}`); - core.debug(`UserID : ${privateKey.userID}`); + core.debug(`Name : ${privateKey.name}`); + core.debug(`Email : ${privateKey.email}`); core.debug(`CreationTime : ${privateKey.creationTime}`); core.info('🔑 Importing secret key'); @@ -52,7 +59,16 @@ async function run(): Promise { }); } - if (/true/i.test(core.getInput('git_gpgsign'))) { + if (git_gpgsign) { + core.info(`🔨 Configuring git committer to be ${git_committer_name} <${git_committer_email}>`); + if (git_committer_email != privateKey.email) { + core.setFailed('Committer email does not match GPG key user address'); + return; + } + + await git.setConfig('user.name', git_committer_name); + await git.setConfig('user.email', git_committer_email); + core.info('💎 Enable signing for this Git repository'); await git.enableCommitGpgsign(); await git.setUserSigningkey(privateKey.keyID); diff --git a/src/openpgp.ts b/src/openpgp.ts index 7bc2e40..2ab3bda 100644 --- a/src/openpgp.ts +++ b/src/openpgp.ts @@ -1,9 +1,11 @@ import * as openpgp from 'openpgp'; +import addressparser from 'addressparser'; export interface PrivateKey { fingerprint: string; keyID: string; - userID: string; + name: string; + email: string; creationTime: Date; } @@ -21,15 +23,18 @@ export const readPrivateKey = async (armoredText: string): Promise = throw err[0]; } + const address = await privateKey.getPrimaryUser().then(primaryUser => { + return addressparser(primaryUser.user.userId.userid)[0]; + }); + return { fingerprint: privateKey.getFingerprint().toUpperCase(), keyID: await privateKey.getEncryptionKey().then(encKey => { // @ts-ignore return encKey?.getKeyId().toHex().toUpperCase(); }), - userID: await privateKey.getPrimaryUser().then(primaryUser => { - return primaryUser.user.userId.userid; - }), + name: address.name, + email: address.address, creationTime: privateKey.getCreationTime() }; };