Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: validate gpg releasers signatures #760

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bin/ncu-team.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ yargs(hideBin(process.argv))
},
handler
})
.command({
command: 'check-gpg',
desc: 'Check that all the team members have a valid GPG key',
builder: (yargs) => {
yargs
.option('org', {
describe: 'Name of the organization',
type: 'string',
default: 'nodejs'
});
yargs
.option('team', {
describe: 'Name of the team',
type: 'string',
default: 'releasers'
});
},
handler
})
.demandCommand(1, 'must provide a valid command')
.help()
.parse();
Expand All @@ -70,6 +89,10 @@ async function main(argv) {
case 'sync':
await TeamInfo.syncFile(cli, request, argv.file);
break;
case 'check-gpg':
const info = new TeamInfo(cli, request, argv.org, argv.team);
await info.checkTeamPGPKeys();
break;
default:
throw new Error(`Unknown command ${command}`);
}
Expand Down
6 changes: 6 additions & 0 deletions docs/ncu-team.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,9 @@ will update the file with text like this:

<!-- ncu-team-sync end -->
```

### Check GPG Releasers Signature

```
$ ncu-team check-gpg
```
42 changes: 41 additions & 1 deletion lib/team_info.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, writeFile } from './file.js';
import { ascending } from './utils.js';
import { ascending, extractReleasersFromReadme, checkReleaserDiscrepancies } from './utils.js';

const TEAM_QUERY = 'Team';

Expand Down Expand Up @@ -37,6 +37,13 @@ export default class TeamInfo {
return sorted;
}

async getGpgPublicKey(login) {
const { request } = this;
const url = `https://api.github.com/users/${login}/gpg_keys`;
const result = await request.json(url);
return result;
}

async getMemberContacts() {
const members = await this.getMembers();
return members.map(getContact).join('\n');
Expand All @@ -46,6 +53,39 @@ export default class TeamInfo {
const contacts = await this.getMemberContacts();
this.cli.log(contacts);
}

async checkTeamPGPKeys() {
const { cli } = this;
cli.startSpinner(`Collecting Members details of ${this.org}/${this.team}`);
const members = await this.getMembers();
cli.stopSpinner(`Collecting Members details of ${this.org}/${this.team}`);

cli.startSpinner(`Collecting PGP keys of ${this.org}/${this.team}`);
const keys = await Promise.all(members.map(member => this.getGpgPublicKey(member.login)));
// Add keys to members
members.forEach((member, index) => {
member.keys = keys[index];
});
cli.stopSpinner(`Collecting PGP keys of ${this.org}/${this.team}`);

cli.startSpinner('Collecting Release members from Readme.md');
const readmeTxt = await this.request.text('https://raw.githubusercontent.com/nodejs/node/main/README.md');
const extractedMembers = extractReleasersFromReadme(readmeTxt);
cli.stopSpinner('Collecting Release members from Readme.md');

// Checks per member
cli.startSpinner('Checking discrepancies between members and readme.md');

for (const member of members) {
if (!member.keys || !member.keys.length) {
console.error(`The releaser ${member.name} (${member.login}) has no keys associated with their account`);
}
checkReleaserDiscrepancies(member, extractedMembers);
// @TODO: Check if the GPG key is available in https://keys.openpgp.org/
}

cli.stopSpinner('Checking discrepancies between members and readme.md');
}
}

TeamInfo.syncFile = async function(cli, request, input, output) {
Expand Down
61 changes: 61 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,64 @@ export async function getEditor(options = {}) {

return process.env.VISUAL || process.env.EDITOR || null;
};

/**
* Extracts the releasers' information from the provided markdown txt.
* Each releaser's information includes their name, email, and GPG key.
*
* @param {string} txt - The README content.
* @returns {Array<Array<string>>} An array of releaser information arrays.
* Each sub-array contains the name, email,
* and GPG key of a releaser.
*/
export function extractReleasersFromReadme(txt) {
const regex = /\* \*\*(.*)\*\*.*<<(.*)>>\n.*`(.*)`/gm;
let match;
const result = [];
while ((match = regex.exec(txt)) !== null) {
// This is necessary to avoid infinite loops with zero-width matches
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
const [, name, email, key] = match;
result.push([name, email, key]);
}
return result;
}

export function checkReleaserDiscrepancies(member, extractedMembers) {
let releaseKey, extractedMember;
member.keys.forEach(key => {
extractedMembers.filter(eMember => {
if (eMember[2].includes(key.key_id)) {
extractedMember = eMember;
releaseKey = key;
}
});
});

if (!extractedMember || !releaseKey) {
console.error(`The releaser ${member.name} (${member.login}) is not listed or any of the current profile GPG keys are listed in README.md`);
return;
}

if (!releaseKey.emails.some(({ email }) => email === extractedMember[1])) {
console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that is not associated with their email address ${extractedMember[1]} in the README.md`);
}

if (!releaseKey.can_sign) {
console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that cannot sign`);
}

if (!releaseKey.can_certify) {
console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that cannot certify`);
}

if (!releaseKey.expires_at) {
console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that cannot expire`);
}

if (releaseKey.revoked) {
console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that has been revoked`);
}
}