Skip to content

Commit c67f7c3

Browse files
committed
Initial commit
0 parents  commit c67f7c3

16 files changed

+2828
-0
lines changed

.env.sample

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
APP_ID="11"
2+
PRIVATE_KEY_PATH="gh_app_key.pem"
3+
WEBHOOK_SECRET="secret"
4+
5+
ISSUE_ORG="octokit-demo"
6+
ISSUE_REPO="workflow-failures"

.github/workflows/test.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
node-version: [ 18 ]
16+
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v4
20+
21+
- name: Use Node.js ${{ matrix.node-version }}
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: ${{ matrix.node-version }}
25+
26+
- name: Install NPM dependencies
27+
run: npm ci
28+
29+
- name: Unit tests
30+
run: npm test
31+
32+
- name: Check syntax and style
33+
run: npm run lint

.gitignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Logs
2+
logs
3+
.env
4+
*.log
5+
npm-debug.log*
6+
yarn-debug.log*
7+
yarn-error.log*
8+
pnpm-debug.log*
9+
lerna-debug.log*
10+
11+
node_modules
12+
dist
13+
dist-ssr
14+
*.local
15+
16+
coverage/
17+
18+
# Editor directories and files
19+
.vscode/*
20+
!.vscode/extensions.json
21+
.idea
22+
.DS_Store
23+
*.suo
24+
*.ntvs*
25+
*.njsproj
26+
*.sln
27+
*.sw?

LICENSE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
MIT License
2+
3+
Copyright GitHub
4+
Copyright 2024 Eric Bickle
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Sample GitHub App for AppSec Scaling
2+
3+
This sample service demonstrates how to to implement a custom GitHub app to efficiently scale application security. Presented at the GitHub Universe 2024 session "[Go big! Efficiently deploy and customize security tooling at enterprise scale](https://reg.githubuniverse.com/flow/github/universe24/attendee-portal/page/sessioncatalog/session/1715360127550001lOUA)".
4+
5+
## Behaviors
6+
The app implements the following sample behaviors:
7+
8+
* **Reopens GitHub Advanced Security alerts**
9+
10+
When an unauthorized user dismisses a security alert (Dependabot, code scanning, or secret scanning), the alert will be reopened and a GitHub issue created so that the team can follow up.
11+
12+
* **Detects CodeQL default setup failures**
13+
14+
When a workflow running CodeQL in default setup mode fails, a GitHub issue will be created so the security team can follow up.
15+
16+
* **Adds customized advice to pull requests that have code scanning alerts**
17+
18+
When a specific type of new CodeQL alert is detected in a pull request, adds a comment with custom information.
19+
20+
## Requirements
21+
* Node.js 20 or higher.
22+
23+
* A [GitHub app](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) subscribed to the following permissions and events:
24+
25+
| Repository permission | Access |
26+
| ---------- | ------ |
27+
| Actions | Read-only |
28+
| Code scanning alerts | Read and write |
29+
| Dependabot alerts | Read and write |
30+
| Issues | Read and write |
31+
| Secret Scanning Alerts | Read and write |
32+
33+
| Event |
34+
| ----- |
35+
| Code scanning alert |
36+
| Dependabot alert |
37+
| Secret scanning alert |
38+
| Workflow run |
39+
40+
* The GitHub app's webhook server (this source code) must be configured to receive events at a URL that is accessible from the internet unless GitHub Enterprise Server is being used.
41+
42+
## Setup
43+
44+
1. Clone this repository
45+
46+
2. Create a `.env` file similar to `.env.sample` and set actual values.
47+
48+
| Environment variable | Usage | Description |
49+
| -------------------- | ----- | ----------- |
50+
| `APP_ID` | Required | GitHub App id. |
51+
| `PRIVATE_KEY_PATH` | Required | Path to `.pem` file containign private key for GitHub app. Configured in GitHub app settings. |
52+
| `WEBHOOK_SECRET` | Required | Shared secret for webhooks. Configured in GitHub app settings. |
53+
| `ISSUE_ORG` | Required | Organization containing repository to create issues in. |
54+
| `ISSUE_REPO` | Required | Name of repository to create issues in. |
55+
| `PORT` | Optional | Listening port for server. Defaults to 3000. |
56+
| `ENTERPRISE_HOSTNAME` | Optional | Hostname of the GitHub Enterprise Server instance. If blank, GitHub Enterprise Cloud will be used. |
57+
58+
3. Install dependencies using `npm ci`.
59+
60+
4. Start the server with `npm run server`.
61+
62+
## Security considerations
63+
To keep things simple for this example, the GitHub application's private key (`PRIVATE_KEY_PATH`) and webhook secret (`WEBHOK_SECRET`) from the environment. Storing secrets in the environment variables and unencrypted files is **insecure**.
64+
65+
The secure and recommended approach is to use a secrets management system like Vault, or one offered by major cloud providers: Azure Key Vault, AWS Secrets Manager, Google Secret Manager, etc.
66+
67+
## References
68+
This repository is based on the sample code showcased in [github-app-js-sample](https://github.com/github/github-app-js-sample).

app.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import dotenv from 'dotenv'
2+
import fs from 'fs'
3+
import http from 'http'
4+
import { Octokit, App } from 'octokit'
5+
import { createNodeMiddleware } from '@octokit/webhooks'
6+
7+
import * as alertStatusAuthorization from './behaviors/alert-status-authorization.js'
8+
import * as workflowFailures from './behaviors/workflow-failures.js'
9+
10+
// Load environment variables from .env file
11+
dotenv.config();
12+
13+
// Set configured values
14+
const appId = process.env.APP_ID;
15+
const privateKeyPath = process.env.PRIVATE_KEY_PATH;
16+
const privateKey = fs.readFileSync(privateKeyPath, 'utf8');
17+
const secret = process.env.WEBHOOK_SECRET;
18+
const enterpriseHostname = process.env.ENTERPRISE_HOSTNAME;
19+
20+
// Create an authenticated Octokit client authenticated as a GitHub App
21+
const app = new App({
22+
appId,
23+
privateKey,
24+
webhooks: {
25+
secret
26+
},
27+
log: console,
28+
...(enterpriseHostname && {
29+
Octokit: Octokit.defaults({
30+
baseUrl: `https://${enterpriseHostname}/api/v3`
31+
})
32+
})
33+
});
34+
35+
// Verify the app can successfully authenticate
36+
const { data } = await app.octokit.request('/app');
37+
app.octokit.log.debug(`Authenticated as '${data.name}'`);
38+
39+
// Get an octokit instance authenticated with the organization containing the repository where issues will be created.
40+
const { data: issueOrgInstallation } = await app.octokit.rest.apps.getOrgInstallation({ org: process.env.ISSUE_ORG });
41+
const issueOctokit = await app.getInstallationOctokit(issueOrgInstallation.id);
42+
43+
// Register event handlers
44+
app.webhooks.on('code_scanning_alert.closed_by_user', (event) => alertStatusAuthorization.codeScanningAlertClosedByUser({ ...event, issueOctokit }));
45+
app.webhooks.on('dependabot_alert.dismissed', (event) => alertStatusAuthorization.dependabotAlertDismissed({ ...event, issueOctokit }));
46+
app.webhooks.on('secret_scanning_alert.resolved', (event) => alertStatusAuthorization.secretScanningAlertResolved({ ...event, issueOctokit }));
47+
app.webhooks.on('workflow_run.completed', (event) => workflowFailures.workflowRunCompleted({ ...event, issueOctokit }));
48+
49+
// Optional: Handle errors
50+
app.webhooks.onError((error) => {
51+
if (error.name === 'AggregateError') {
52+
// Log Secret verification errors
53+
console.log(`Error processing request: ${error.event}`)
54+
} else {
55+
console.log(error)
56+
}
57+
});
58+
59+
// Launch a web server to listen for GitHub webhooks
60+
const port = process.env.PORT || 3000;
61+
const path = '/api/webhook';
62+
63+
const middleware = createNodeMiddleware(app.webhooks, { path });
64+
65+
http.createServer(middleware).listen(port, () => {
66+
console.log(`Server is listening for events at: http://localhost:${port}${path}`)
67+
console.log('Press Ctrl + C to quit.')
68+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { isSecurityReviewer } from '../common/authorization.js';
2+
import { createIssueOnce } from '../common/issues.js';
3+
4+
export async function dependabotAlertDismissed({ payload, octokit, issueOctokit }) {
5+
const { alert, repository } = payload;
6+
7+
octokit.log.info(`User ${alert.dismissed_by.login} dismissed Dependabot alert ${alert.html_url}.`);
8+
9+
if (!isSecurityReviewer(alert.dismissed_by.login)) {
10+
octokit.log.warn(`User ${alert.dismissed_by.login} is not authorized to resolve Dependabot alert ${alert.html_url}.`);
11+
12+
// Reopen the Dependabot alert
13+
octokit.log.info(`Reopening Dependabot alert ${alert.html_url}.`);
14+
await octokit.rest.dependabot.updateAlert({
15+
owner: repository.owner.login,
16+
repo: repository.name,
17+
alert_number: alert.number,
18+
state: 'open'
19+
});
20+
21+
// Create an issue so that the security team can follow up with the user
22+
await createIssueOnce(
23+
issueOctokit,
24+
process.env.ISSUE_ORG,
25+
process.env.ISSUE_REPO,
26+
`Dependabot alert reopened for ${repository.full_name} (#${alert.number})`,
27+
'A Dependabot alert was automatically reopened after a user attempted to dismiss it.\n\n' +
28+
`**Dismissed by:** ${alert.dismissed_by.login}\n` +
29+
`**Repository:** [${repository.full_name}](${repository.html_url})\n` +
30+
`**Alert:** ${alert.html_url}\n` +
31+
`**Summary:** ${alert.security_advisory?.summary}\n` +
32+
`**Severity:** ${alert.security_advisory?.severity}\n` +
33+
`**Package:** ${alert.dependency?.package?.name} (${alert.dependency?.package?.ecosystem})`,
34+
['alert reopened', 'dependabot']);
35+
}
36+
}
37+
38+
export async function codeScanningAlertClosedByUser({ payload, octokit, issueOctokit }) {
39+
const { alert, repository } = payload;
40+
octokit.log.info(`User ${alert.dismissed_by.login} dismissed code scanning alert ${alert.html_url}.`);
41+
42+
if (alert.state === 'dismissed' && !isSecurityReviewer(alert.dismissed_by.login)) {
43+
octokit.log.warn(`User ${alert.dismissed_by.login} is not authorized to resolve code scanning alert ${alert.html_url}.`);
44+
45+
// Reopen the code scanning alert
46+
octokit.log.info(`Reopening code scanning alert ${alert.html_url}.`);
47+
await octokit.rest.codeScanning.updateAlert({
48+
owner: repository.owner.login,
49+
repo: repository.name,
50+
alert_number: alert.number,
51+
state: 'open'
52+
});
53+
54+
// Create an issue so that the security team can follow up with the user
55+
await createIssueOnce(
56+
issueOctokit,
57+
process.env.ISSUE_ORG,
58+
process.env.ISSUE_REPO,
59+
`${alert.tool.name} alert reopened for ${repository.full_name} (#${alert.number})`,
60+
'A code scanning alert was automatically reopened after a user attempted to dismiss it.\n\n' +
61+
`**Dismissed by:** ${alert.dismissed_by.login}\n` +
62+
`**Repository:** [${repository.full_name}](${repository.html_url})\n` +
63+
`**Alert:** ${alert.html_url}\n` +
64+
`**Tool:** ${alert.tool.name}\n` +
65+
`**Rule:** ${alert.rule.id} - ${alert.rule.description}`,
66+
['alert reopened', 'code scanning']);
67+
}
68+
}
69+
70+
export async function secretScanningAlertResolved({ payload, octokit, issueOctokit }) {
71+
const { alert, repository } = payload;
72+
73+
// Editing a custom secret scanning alert pattern will close and reopen the alert.
74+
if (alert.resolution === 'pattern_edited') {
75+
return;
76+
}
77+
78+
octokit.log.info(`User ${alert.resolved_by.login} resolved secret scanning alert ${alert.html_url}.`);
79+
80+
if (alert.resolution === 'revoked' && alert.validity === 'inactive') {
81+
// All users are authorized to revoke alerts that the secret scanning partner
82+
// has verified as being inactive.
83+
octokit.log.info(`Alert ${alert.html_url} is verified as being revoked.`);
84+
} else if (!isSecurityReviewer(alert.resolved_by.login)) {
85+
octokit.log.warn(`User ${alert.resolved_by.login} is not authorized to dismiss secret scanning alert ${alert.html_url}.`);
86+
87+
// Reopen the secret scanning alert
88+
octokit.log.info(`Reopening secret scanning alert ${alert.html_url}.`);
89+
await octokit.rest.secretScanning.updateAlert({
90+
owner: repository.owner.login,
91+
repo: repository.name,
92+
alert_number: alert.number,
93+
state: 'open'
94+
});
95+
96+
// Create an issue so that the security team can follow up with the user
97+
await createIssueOnce(
98+
issueOctokit,
99+
process.env.ISSUE_ORG,
100+
process.env.ISSUE_REPO,
101+
`Secret scanning alert reopened for ${repository.full_name} (#${alert.number})`,
102+
'A secret scanning alert was automatically reopened after a user attempted to resolve it.\n\n' +
103+
`**Resolved by:** ${alert.resolved_by.login}\n` +
104+
`**Repository:** [${repository.full_name}](${repository.html_url})\n` +
105+
`**Alert:** ${alert.html_url}\n` +
106+
`**Secret type:** ${alert.secret_type}`,
107+
['alert reopened', 'secret scanning']);
108+
}
109+
}

behaviors/workflow-failures.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createIssueOnce } from '../common/issues.js';
2+
3+
export async function workflowRunCompleted({ payload, octokit, issueOctokit }) {
4+
const { workflow_run: workflowRun, workflow, repository } = payload;
5+
6+
if (workflowRun.conclusion === 'failure' &&
7+
workflowRun.head_repository.id === repository.id &&
8+
workflowRun.head_branch === repository.default_branch
9+
) {
10+
const workflowPaths = getWorkflowPaths(workflowRun);
11+
if (workflowPaths.some(isCodeQLPath)) {
12+
octokit.log.info(`CodeQL workflow run ${workflowRun.html_url} failed.`);
13+
14+
await createIssueOnce(
15+
issueOctokit,
16+
process.env.ISSUE_ORG,
17+
process.env.ISSUE_REPO,
18+
`CodeQL workflow failure for ${repository.full_name}`,
19+
'A CodeQL workflow failed to complete successfully.\n\n' +
20+
`**Repository:** [${repository.full_name}](${repository.html_url})\n` +
21+
`**Workflow:** [${workflow.path}](${workflow.html_url})\n` +
22+
`**Workflow name:** ${workflow.name}\n` +
23+
`**Workflow run:** ${workflowRun.html_url}`,
24+
['workflow failure','code scanning']
25+
);
26+
}
27+
}
28+
}
29+
30+
function getWorkflowPaths(workflowRun) {
31+
let paths = [workflowRun.path];
32+
33+
if (workflowRun.referenced_workflows) {
34+
workflowRun.referenced_workflows.forEach(r => paths.push(r.path.split('@')[0]));
35+
}
36+
37+
return paths;
38+
}
39+
40+
function isCodeQLPath(path) {
41+
// Default setup workflow
42+
if (path === 'dynamic/github-code-scanning/codeql') {
43+
return true;
44+
}
45+
46+
// Custom CodeQL workflows
47+
return path.toLowerCase().includes('.github/workflows/codeql');
48+
}

common/authorization.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function isSecurityReviewer(username) {
2+
// TODO: Externalize to configuration
3+
const securityReviewers = ['octocat', 'monalisa'];
4+
return securityReviewers.includes(username);
5+
}

0 commit comments

Comments
 (0)