Skip to content

Commit

Permalink
feat: repo configuration and secrets check
Browse files Browse the repository at this point in the history
  • Loading branch information
mshanemc committed Sep 13, 2022
1 parent 29d18c9 commit ba43837
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 12 deletions.
4 changes: 2 additions & 2 deletions messages/configure.repo.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# summary

Summary of a command.
Configure github repo for Actions pipeline.

# description

Description of a command.
Sets up labels and exempts the CLI bot for branch protection and PR rules

# flags.repository.summary

Expand Down
21 changes: 21 additions & 0 deletions messages/configure.secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# summary

Ensures a repo has correct access to secrets based on its workflows

# description

Inspects a repo's yaml files and verifies that secrets required are available for the repo (either set at the repo level or shared via organization-level secrets).

This command requires scope:admin permissions to inspect the org secrets and admin access to the repo to inspect the repo secrets

# flags.repository.summary

The github owner/repo

# flags.dryRun.summary

Make no changes

# examples

- <%= config.bin %> <%= command.id %> -r salesforcecli/testPackageRelease
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@salesforce/sf-plugins-core": "^1.13.0",
"change-case": "^4.1.2",
"got": "^11.8.5",
"js-yaml": "^4.1.0",
"octokit": "^2.0.7",
"replace-in-file": "^6.3.2",
"shelljs": "^0.8.5",
Expand All @@ -28,6 +29,7 @@
"@salesforce/plugin-command-reference": "^2.1.1",
"@salesforce/prettier-config": "^0.0.2",
"@salesforce/ts-sinon": "1.3.21",
"@types/js-yaml": "^4.0.5",
"@types/shelljs": "^0.8.10",
"@types/yeoman-generator": "^5.2.10",
"@types/yosay": "^2.0.1",
Expand Down
1 change: 1 addition & 0 deletions plugin-env
Submodule plugin-env added at acfc63
1 change: 1 addition & 0 deletions sfdx-core
Submodule sfdx-core added at 3ee7b2
30 changes: 21 additions & 9 deletions src/commands/configure/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ const messages = Messages.load('@salesforce/plugin-dev', 'configure.repo', [
]);

export type ConfigureRepoResult = {
path: string;
botAccess: boolean;
labels: boolean;
prRestrictions: boolean;
prBypass: boolean;
};

const BOT_LOGIN = 'SF-CLI-BOT';
Expand All @@ -43,6 +46,12 @@ export default class ConfigureRepo extends SfCommand<ConfigureRepoResult> {

public async run(): Promise<ConfigureRepoResult> {
const { flags } = await this.parse(ConfigureRepo);
const output: ConfigureRepoResult = {
botAccess: false,
labels: false,
prRestrictions: false,
prBypass: false,
};
// TODO: nice error if no token exists
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const repoBase = {
Expand All @@ -58,11 +67,11 @@ export default class ConfigureRepo extends SfCommand<ConfigureRepoResult> {
);
} else {
this.logSuccess(`✓ ${BOT_LOGIN} has necessary access to ${flags.repository}`);
output.botAccess = true;
}

try {
await octokit.rest.repos.getBranchProtection({ ...repoBase, branch: 'main' });
// this.styledJSON(protectedBranch);
} catch (e) {
if (e.response.data) {
if (!flags['dry-run'] && e.response.data.message === 'Branch not protected') {
Expand Down Expand Up @@ -135,9 +144,11 @@ export default class ConfigureRepo extends SfCommand<ConfigureRepoResult> {
branch: 'main',
bypass_pull_request_allowances: updatePayload,
});
output.prBypass = true;
}
} else {
this.logSuccess(`✓ ${BOT_LOGIN} can bypass pull request requirements`);
output.prBypass = true;
}

protectedBranch = (
Expand All @@ -158,26 +169,27 @@ export default class ConfigureRepo extends SfCommand<ConfigureRepoResult> {
users: [BOT_LOGIN],
});
}
output.prRestrictions = true;
} else {
this.logSuccess(`✓ ${BOT_LOGIN} can push directly to main`);
output.prRestrictions = true;
}

//// secret stuff--there's not a good way to ask, "what organizational secrets are shared with this repo?"
// has secrets for dependabot
// has secrets for npm publish
// if sign, has aws secrets
// if nuts, has nuts auth secrets

// labels setup: labels should have dependencies and the corresponding git2gus labels for bug and feature
// TODO: git2gus labels
const { data: labels } = await octokit.rest.issues.listLabelsForRepo({ ...repoBase });
if (!labels.find((l) => l.name === 'dependencies')) {
if (flags['dry-run']) {
this.warn('missing dependencies label');
} else {
this.log(`creating dependencies label`);
await octokit.rest.issues.createLabel({ ...repoBase, name: 'dependencies' });
output.labels = true;
}
} else {
output.labels = true;
}
return { path: '' };

return output;
}
}
215 changes: 215 additions & 0 deletions src/commands/configure/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
//// secret stuff--there's not a good way to ask, "what organizational secrets are shared with this repo?"
// has secrets for dependabot
// has secrets for npm publish
// if sign, has aws secrets
// if nuts, has nuts auth secrets

/*
* Copyright (c) 2022, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';

import { Octokit } from 'octokit';
import { exec } from 'shelljs';
import * as yaml from 'js-yaml';
import * as fs from 'fs';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.load('@salesforce/plugin-dev', 'configure.secrets', [
'summary',
'description',
'examples',
'flags.repository.summary',
'flags.dryRun.summary',
]);

type SecretClassification =
| 'not needed'
| 'overridden by repo'
| 'shared to repo by org'
| 'exists, but not shared with repo'
| 'does not exist in org'
| 'unable to check';

type GhaJob = {
uses: string;
with: {
ctc: boolean;
sign: boolean;
};
};
export type SecretsResult = {
AWS_ACCESS_KEY_ID: SecretClassification;
AWS_SECRET_ACCESS_KEY: SecretClassification;
NPM_TOKEN: SecretClassification;
TESTKIT_AUTH_URL: SecretClassification;
SF_CLI_BOT_GITHUB_TOKEN: SecretClassification;
SF_CHANGE_CASE_SFDX_AUTH_URL: SecretClassification;
};

export default class ConfigureSecrets extends SfCommand<SecretsResult> {
public static summary = messages.getMessage('summary');
public static description = messages.getMessage('description');
public static examples = messages.getMessages('examples');

public static flags = {
repository: Flags.string({
summary: messages.getMessage('flags.repository.summary'),
char: 'r',
required: true,
}),
'dry-run': Flags.boolean({
summary: messages.getMessage('flags.dryRun.summary'),
char: 'c',
}),
};

public async run(): Promise<SecretsResult> {
const { flags } = await this.parse(ConfigureSecrets);
const output: SecretsResult = {
AWS_ACCESS_KEY_ID: 'not needed',
AWS_SECRET_ACCESS_KEY: 'not needed',
NPM_TOKEN: 'not needed',
TESTKIT_AUTH_URL: 'not needed',
SF_CLI_BOT_GITHUB_TOKEN: 'not needed',
SF_CHANGE_CASE_SFDX_AUTH_URL: 'not needed',
};

// TODO: nice error if no token exists
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

const repoBase = {
owner: flags.repository.split('/')[0],
repo: flags.repository.split('/')[1],
};

// clone repo so we can review its yaml
exec(`git clone https://github.com/${flags.repository}`);

// part 1: what secrets does this repo need?
const publish = yaml.load(
await fs.promises.readFile(`${repoBase.repo}/.github/workflows/onRelease.yml`, 'utf8')
) as {
jobs: {
[key: string]: GhaJob;
};
};

const publishJob = Object.values(publish.jobs).find((job) =>
job.uses.includes('salesforcecli/github-workflows/.github/workflows/npmPublish')
);

if (!publishJob) {
return output;
} else {
// NPM: if uses npm publish
output.NPM_TOKEN = await secretCheck(octokit, repoBase, 'NPM_TOKEN');

// AWS: if sign:true on publish
if (publishJob.with.sign) {
output.AWS_ACCESS_KEY_ID = await secretCheck(octokit, repoBase, 'AWS_ACCESS_KEY_ID');
output.AWS_SECRET_ACCESS_KEY = await secretCheck(octokit, repoBase, 'AWS_SECRET_ACCESS_KEY');
}

if (publishJob.with.ctc) {
output.SF_CHANGE_CASE_SFDX_AUTH_URL = await secretCheck(octokit, repoBase, 'SF_CHANGE_CASE_SFDX_AUTH_URL');
}
}

const release = yaml.load(
await fs.promises.readFile(`${repoBase.repo}/.github/workflows/onPushToMain.yml`, 'utf8')
) as {
jobs: {
[key: string]: GhaJob;
};
};

const releaseJob = Object.values(release.jobs).find((job) =>
job.uses.includes('salesforcecli/github-workflows/.github/workflows/githubRelease')
);

if (releaseJob) {
output.SF_CLI_BOT_GITHUB_TOKEN = await secretCheck(octokit, repoBase, 'SF_CLI_BOT_GITHUB_TOKEN');
}

const test = yaml.load(await fs.promises.readFile(`${repoBase.repo}/.github/workflows/test.yml`, 'utf8')) as {
jobs: {
[key: string]: GhaJob;
};
};
const testJob = Object.values(test.jobs).find(
(job) =>
job.uses.includes('salesforcecli/github-workflows/.github/workflows/nut') ||
job.uses.includes('salesforcecli/github-workflows/.github/workflows/externalNut')
);
if (testJob) {
output.TESTKIT_AUTH_URL = await secretCheck(octokit, repoBase, 'TESTKIT_AUTH_URL');
}

this.styledJSON(output);

exec(`rm -rf ${repoBase.repo}`);
return output;
}
}

const secretCheck = async (
octokit: Octokit,
repoBase: { owner: string; repo: string },
secretName: string
): Promise<SecretClassification> => {
// is it overridden locally?
try {
const { data: localSecret } = await octokit.rest.actions.getRepoSecret({
...repoBase,
secret_name: secretName,
});

if (localSecret) {
return 'overridden by repo';
}
} catch (e) {
// if (e.response.data) {
// console.log(`check repo secrets for ${secretName}: ${e.response.data.message}`);
// }
// secret doesn't exist locally, keep looking.
}

// is it in the org?
try {
const { data: secret } = await octokit.rest.actions.getOrgSecret({
org: repoBase.owner,
secret_name: secretName,
});
if (secret.visibility === 'all') {
return 'shared to repo by org';
}

const { data: repositoriesForSecret } = await octokit.rest.actions.listSelectedReposForOrgSecret({
org: repoBase.owner,
secret_name: secretName,
per_page: 100,
});

if (repositoriesForSecret) {
if (repositoriesForSecret.repositories.some((r) => r.name === repoBase.repo)) {
// if so, is it shared with this repo?
return 'shared to repo by org';
} else {
return 'exists, but not shared with repo';
}
}
} catch (e) {
if (e.response.data) {
console.log(`check org secrets for ${secretName}: ${e.response.data.message}`);
}
return 'does not exist in org';
}

return 'does not exist in org';
};
7 changes: 6 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,11 @@
dependencies:
"@types/through" "*"

"@types/js-yaml@^4.0.5":
version "4.0.5"
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.5.tgz#738dd390a6ecc5442f35e7f03fa1431353f7e138"
integrity sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==

"@types/jsforce@^1.9.41":
version "1.9.43"
resolved "https://registry.yarnpkg.com/@types/jsforce/-/jsforce-1.9.43.tgz#407907aac838b1828133958ef326ce649d03138f"
Expand Down Expand Up @@ -5207,7 +5212,7 @@ js-tokens@^4.0.0:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==

[email protected]:
[email protected], js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
Expand Down

0 comments on commit ba43837

Please sign in to comment.