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鈥檒l 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 23 additions & 0 deletions bin/ncu-team.js
Expand Up @@ -45,6 +45,25 @@
},
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 @@
case 'sync':
await TeamInfo.syncFile(cli, request, argv.file);
break;
case 'check-gpg':
const info = new TeamInfo(cli, request, argv.org, argv.team);

Check failure on line 93 in bin/ncu-team.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

Unexpected lexical declaration in case block
await info.checkTeamPGPKeys();
break;
default:
throw new Error(`Unknown command ${command}`);
}
Expand Down
6 changes: 6 additions & 0 deletions docs/ncu-team.md
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
@@ -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 @@
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 @@
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`);

Check failure on line 81 in lib/team_info.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 113. Maximum allowed is 100
}
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
Expand Up @@ -64,3 +64,64 @@

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 => {

Check failure on line 95 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

Array.prototype.filter() expects a return value from arrow function
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`);

Check failure on line 104 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 144. Maximum allowed is 100
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`);

Check failure on line 109 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 187. Maximum allowed is 100
}

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

Check failure on line 113 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 117. Maximum allowed is 100
}

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

Check failure on line 117 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 120. Maximum allowed is 100
}

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

Check failure on line 121 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 119. Maximum allowed is 100
}

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

Check failure on line 125 in lib/utils.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 122. Maximum allowed is 100
}
}