Configure and check committer email against GPG user address

This commit is contained in:
CrazyMax 2020-05-05 20:01:45 +02:00
parent 9c02eb15d9
commit aca1ab6f61
No known key found for this signature in database
GPG key ID: 3248E46B6BB8C7F7
12 changed files with 415 additions and 18 deletions

View file

@ -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)

View file

@ -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 `<committer_name>@users.noreply.github.com`) |
### environment variables

View file

@ -5,7 +5,6 @@ const userInfo = {
email: 'joe@foo.bar',
passphrase: 'with stupid passphrase',
keyID: 'D523BD50DD70B0BA',
userID: 'Joe Tester <joe@foo.bar>',
fingerprint: '27571A53B86AF0C799B38BA77D851EB72D73BDA0',
keygrip: 'BA83FC8947213477F28ADC019F6564A956456163',
pgp: `-----BEGIN PGP PRIVATE KEY BLOCK-----

View file

@ -5,7 +5,6 @@ const userInfo = {
email: 'joe@foo.bar',
passphrase: 'with stupid passphrase',
keyID: 'D523BD50DD70B0BA',
userID: 'Joe Tester <joe@foo.bar>',
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);
});
});

View file

@ -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'

338
dist/index.js generated vendored
View file

@ -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 <address@domain>'
*
* 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 <addresses>, (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:

5
package-lock.json generated
View file

@ -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",

View file

@ -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": {

24
src/addressparser.d.ts vendored Normal file
View file

@ -0,0 +1,24 @@
declare namespace addressparser {
interface Address {
name: string;
address: string;
}
}
/**
* Parses structured e-mail addresses from an address field
*
* Example:
*
* 'Name <address@domain>'
*
* 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;

View file

@ -16,3 +16,11 @@ export async function enableCommitGpgsign(): Promise<void> {
export async function setUserSigningkey(keyid: string): Promise<void> {
await git(['config', 'user.signingkey', keyid]);
}
export async function getConfig(key: string): Promise<string> {
return await git(['config', key]);
}
export async function setConfig(key: string, value: string): Promise<void> {
await git(['config', key, value]);
}

View file

@ -17,6 +17,12 @@ async function run(): Promise<void> {
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<void> {
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<void> {
});
}
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);

View file

@ -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<PrivateKey> =
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()
};
};