-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit c67f7c3
Showing
16 changed files
with
2,828 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
APP_ID="11" | ||
PRIVATE_KEY_PATH="gh_app_key.pem" | ||
WEBHOOK_SECRET="secret" | ||
|
||
ISSUE_ORG="octokit-demo" | ||
ISSUE_REPO="workflow-failures" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
name: Test | ||
|
||
on: | ||
push: | ||
branches: [ main ] | ||
pull_request: | ||
branches: [ main ] | ||
|
||
jobs: | ||
build: | ||
runs-on: ubuntu-latest | ||
|
||
strategy: | ||
matrix: | ||
node-version: [ 18 ] | ||
|
||
steps: | ||
- name: Checkout repository | ||
uses: actions/checkout@v4 | ||
|
||
- name: Use Node.js ${{ matrix.node-version }} | ||
uses: actions/setup-node@v4 | ||
with: | ||
node-version: ${{ matrix.node-version }} | ||
|
||
- name: Install NPM dependencies | ||
run: npm ci | ||
|
||
- name: Unit tests | ||
run: npm test | ||
|
||
- name: Check syntax and style | ||
run: npm run lint |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
# Logs | ||
logs | ||
.env | ||
*.log | ||
npm-debug.log* | ||
yarn-debug.log* | ||
yarn-error.log* | ||
pnpm-debug.log* | ||
lerna-debug.log* | ||
|
||
node_modules | ||
dist | ||
dist-ssr | ||
*.local | ||
|
||
coverage/ | ||
|
||
# Editor directories and files | ||
.vscode/* | ||
!.vscode/extensions.json | ||
.idea | ||
.DS_Store | ||
*.suo | ||
*.ntvs* | ||
*.njsproj | ||
*.sln | ||
*.sw? |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
MIT License | ||
|
||
Copyright GitHub | ||
Copyright 2024 Eric Bickle | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
# Sample GitHub App for AppSec Scaling | ||
|
||
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)". | ||
|
||
## Behaviors | ||
The app implements the following sample behaviors: | ||
|
||
* **Reopens GitHub Advanced Security alerts** | ||
|
||
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. | ||
|
||
* **Detects CodeQL default setup failures** | ||
|
||
When a workflow running CodeQL in default setup mode fails, a GitHub issue will be created so the security team can follow up. | ||
|
||
* **Adds customized advice to pull requests that have code scanning alerts** | ||
|
||
When a specific type of new CodeQL alert is detected in a pull request, adds a comment with custom information. | ||
|
||
## Requirements | ||
* Node.js 20 or higher. | ||
|
||
* 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: | ||
|
||
| Repository permission | Access | | ||
| ---------- | ------ | | ||
| Actions | Read-only | | ||
| Code scanning alerts | Read and write | | ||
| Dependabot alerts | Read and write | | ||
| Issues | Read and write | | ||
| Secret Scanning Alerts | Read and write | | ||
|
||
| Event | | ||
| ----- | | ||
| Code scanning alert | | ||
| Dependabot alert | | ||
| Secret scanning alert | | ||
| Workflow run | | ||
|
||
* 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. | ||
|
||
## Setup | ||
|
||
1. Clone this repository | ||
|
||
2. Create a `.env` file similar to `.env.sample` and set actual values. | ||
|
||
| Environment variable | Usage | Description | | ||
| -------------------- | ----- | ----------- | | ||
| `APP_ID` | Required | GitHub App id. | | ||
| `PRIVATE_KEY_PATH` | Required | Path to `.pem` file containign private key for GitHub app. Configured in GitHub app settings. | | ||
| `WEBHOOK_SECRET` | Required | Shared secret for webhooks. Configured in GitHub app settings. | | ||
| `ISSUE_ORG` | Required | Organization containing repository to create issues in. | | ||
| `ISSUE_REPO` | Required | Name of repository to create issues in. | | ||
| `PORT` | Optional | Listening port for server. Defaults to 3000. | | ||
| `ENTERPRISE_HOSTNAME` | Optional | Hostname of the GitHub Enterprise Server instance. If blank, GitHub Enterprise Cloud will be used. | | ||
|
||
3. Install dependencies using `npm ci`. | ||
|
||
4. Start the server with `npm run server`. | ||
|
||
## Security considerations | ||
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**. | ||
|
||
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. | ||
|
||
## References | ||
This repository is based on the sample code showcased in [github-app-js-sample](https://github.com/github/github-app-js-sample). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import dotenv from 'dotenv' | ||
import fs from 'fs' | ||
import http from 'http' | ||
import { Octokit, App } from 'octokit' | ||
import { createNodeMiddleware } from '@octokit/webhooks' | ||
|
||
import * as alertStatusAuthorization from './behaviors/alert-status-authorization.js' | ||
import * as workflowFailures from './behaviors/workflow-failures.js' | ||
|
||
// Load environment variables from .env file | ||
dotenv.config(); | ||
|
||
// Set configured values | ||
const appId = process.env.APP_ID; | ||
const privateKeyPath = process.env.PRIVATE_KEY_PATH; | ||
const privateKey = fs.readFileSync(privateKeyPath, 'utf8'); | ||
const secret = process.env.WEBHOOK_SECRET; | ||
const enterpriseHostname = process.env.ENTERPRISE_HOSTNAME; | ||
|
||
// Create an authenticated Octokit client authenticated as a GitHub App | ||
const app = new App({ | ||
appId, | ||
privateKey, | ||
webhooks: { | ||
secret | ||
}, | ||
log: console, | ||
...(enterpriseHostname && { | ||
Octokit: Octokit.defaults({ | ||
baseUrl: `https://${enterpriseHostname}/api/v3` | ||
}) | ||
}) | ||
}); | ||
|
||
// Verify the app can successfully authenticate | ||
const { data } = await app.octokit.request('/app'); | ||
app.octokit.log.debug(`Authenticated as '${data.name}'`); | ||
|
||
// Get an octokit instance authenticated with the organization containing the repository where issues will be created. | ||
const { data: issueOrgInstallation } = await app.octokit.rest.apps.getOrgInstallation({ org: process.env.ISSUE_ORG }); | ||
const issueOctokit = await app.getInstallationOctokit(issueOrgInstallation.id); | ||
|
||
// Register event handlers | ||
app.webhooks.on('code_scanning_alert.closed_by_user', (event) => alertStatusAuthorization.codeScanningAlertClosedByUser({ ...event, issueOctokit })); | ||
app.webhooks.on('dependabot_alert.dismissed', (event) => alertStatusAuthorization.dependabotAlertDismissed({ ...event, issueOctokit })); | ||
app.webhooks.on('secret_scanning_alert.resolved', (event) => alertStatusAuthorization.secretScanningAlertResolved({ ...event, issueOctokit })); | ||
app.webhooks.on('workflow_run.completed', (event) => workflowFailures.workflowRunCompleted({ ...event, issueOctokit })); | ||
|
||
// Optional: Handle errors | ||
app.webhooks.onError((error) => { | ||
if (error.name === 'AggregateError') { | ||
// Log Secret verification errors | ||
console.log(`Error processing request: ${error.event}`) | ||
} else { | ||
console.log(error) | ||
} | ||
}); | ||
|
||
// Launch a web server to listen for GitHub webhooks | ||
const port = process.env.PORT || 3000; | ||
const path = '/api/webhook'; | ||
|
||
const middleware = createNodeMiddleware(app.webhooks, { path }); | ||
|
||
http.createServer(middleware).listen(port, () => { | ||
console.log(`Server is listening for events at: http://localhost:${port}${path}`) | ||
console.log('Press Ctrl + C to quit.') | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import { isSecurityReviewer } from '../common/authorization.js'; | ||
import { createIssueOnce } from '../common/issues.js'; | ||
|
||
export async function dependabotAlertDismissed({ payload, octokit, issueOctokit }) { | ||
const { alert, repository } = payload; | ||
|
||
octokit.log.info(`User ${alert.dismissed_by.login} dismissed Dependabot alert ${alert.html_url}.`); | ||
|
||
if (!isSecurityReviewer(alert.dismissed_by.login)) { | ||
octokit.log.warn(`User ${alert.dismissed_by.login} is not authorized to resolve Dependabot alert ${alert.html_url}.`); | ||
|
||
// Reopen the Dependabot alert | ||
octokit.log.info(`Reopening Dependabot alert ${alert.html_url}.`); | ||
await octokit.rest.dependabot.updateAlert({ | ||
owner: repository.owner.login, | ||
repo: repository.name, | ||
alert_number: alert.number, | ||
state: 'open' | ||
}); | ||
|
||
// Create an issue so that the security team can follow up with the user | ||
await createIssueOnce( | ||
issueOctokit, | ||
process.env.ISSUE_ORG, | ||
process.env.ISSUE_REPO, | ||
`Dependabot alert reopened for ${repository.full_name} (#${alert.number})`, | ||
'A Dependabot alert was automatically reopened after a user attempted to dismiss it.\n\n' + | ||
`**Dismissed by:** ${alert.dismissed_by.login}\n` + | ||
`**Repository:** [${repository.full_name}](${repository.html_url})\n` + | ||
`**Alert:** ${alert.html_url}\n` + | ||
`**Summary:** ${alert.security_advisory?.summary}\n` + | ||
`**Severity:** ${alert.security_advisory?.severity}\n` + | ||
`**Package:** ${alert.dependency?.package?.name} (${alert.dependency?.package?.ecosystem})`, | ||
['alert reopened', 'dependabot']); | ||
} | ||
} | ||
|
||
export async function codeScanningAlertClosedByUser({ payload, octokit, issueOctokit }) { | ||
const { alert, repository } = payload; | ||
octokit.log.info(`User ${alert.dismissed_by.login} dismissed code scanning alert ${alert.html_url}.`); | ||
|
||
if (alert.state === 'dismissed' && !isSecurityReviewer(alert.dismissed_by.login)) { | ||
octokit.log.warn(`User ${alert.dismissed_by.login} is not authorized to resolve code scanning alert ${alert.html_url}.`); | ||
|
||
// Reopen the code scanning alert | ||
octokit.log.info(`Reopening code scanning alert ${alert.html_url}.`); | ||
await octokit.rest.codeScanning.updateAlert({ | ||
owner: repository.owner.login, | ||
repo: repository.name, | ||
alert_number: alert.number, | ||
state: 'open' | ||
}); | ||
|
||
// Create an issue so that the security team can follow up with the user | ||
await createIssueOnce( | ||
issueOctokit, | ||
process.env.ISSUE_ORG, | ||
process.env.ISSUE_REPO, | ||
`${alert.tool.name} alert reopened for ${repository.full_name} (#${alert.number})`, | ||
'A code scanning alert was automatically reopened after a user attempted to dismiss it.\n\n' + | ||
`**Dismissed by:** ${alert.dismissed_by.login}\n` + | ||
`**Repository:** [${repository.full_name}](${repository.html_url})\n` + | ||
`**Alert:** ${alert.html_url}\n` + | ||
`**Tool:** ${alert.tool.name}\n` + | ||
`**Rule:** ${alert.rule.id} - ${alert.rule.description}`, | ||
['alert reopened', 'code scanning']); | ||
} | ||
} | ||
|
||
export async function secretScanningAlertResolved({ payload, octokit, issueOctokit }) { | ||
const { alert, repository } = payload; | ||
|
||
// Editing a custom secret scanning alert pattern will close and reopen the alert. | ||
if (alert.resolution === 'pattern_edited') { | ||
return; | ||
} | ||
|
||
octokit.log.info(`User ${alert.resolved_by.login} resolved secret scanning alert ${alert.html_url}.`); | ||
|
||
if (alert.resolution === 'revoked' && alert.validity === 'inactive') { | ||
// All users are authorized to revoke alerts that the secret scanning partner | ||
// has verified as being inactive. | ||
octokit.log.info(`Alert ${alert.html_url} is verified as being revoked.`); | ||
} else if (!isSecurityReviewer(alert.resolved_by.login)) { | ||
octokit.log.warn(`User ${alert.resolved_by.login} is not authorized to dismiss secret scanning alert ${alert.html_url}.`); | ||
|
||
// Reopen the secret scanning alert | ||
octokit.log.info(`Reopening secret scanning alert ${alert.html_url}.`); | ||
await octokit.rest.secretScanning.updateAlert({ | ||
owner: repository.owner.login, | ||
repo: repository.name, | ||
alert_number: alert.number, | ||
state: 'open' | ||
}); | ||
|
||
// Create an issue so that the security team can follow up with the user | ||
await createIssueOnce( | ||
issueOctokit, | ||
process.env.ISSUE_ORG, | ||
process.env.ISSUE_REPO, | ||
`Secret scanning alert reopened for ${repository.full_name} (#${alert.number})`, | ||
'A secret scanning alert was automatically reopened after a user attempted to resolve it.\n\n' + | ||
`**Resolved by:** ${alert.resolved_by.login}\n` + | ||
`**Repository:** [${repository.full_name}](${repository.html_url})\n` + | ||
`**Alert:** ${alert.html_url}\n` + | ||
`**Secret type:** ${alert.secret_type}`, | ||
['alert reopened', 'secret scanning']); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { createIssueOnce } from '../common/issues.js'; | ||
|
||
export async function workflowRunCompleted({ payload, octokit, issueOctokit }) { | ||
const { workflow_run: workflowRun, workflow, repository } = payload; | ||
|
||
if (workflowRun.conclusion === 'failure' && | ||
workflowRun.head_repository.id === repository.id && | ||
workflowRun.head_branch === repository.default_branch | ||
) { | ||
const workflowPaths = getWorkflowPaths(workflowRun); | ||
if (workflowPaths.some(isCodeQLPath)) { | ||
octokit.log.info(`CodeQL workflow run ${workflowRun.html_url} failed.`); | ||
|
||
await createIssueOnce( | ||
issueOctokit, | ||
process.env.ISSUE_ORG, | ||
process.env.ISSUE_REPO, | ||
`CodeQL workflow failure for ${repository.full_name}`, | ||
'A CodeQL workflow failed to complete successfully.\n\n' + | ||
`**Repository:** [${repository.full_name}](${repository.html_url})\n` + | ||
`**Workflow:** [${workflow.path}](${workflow.html_url})\n` + | ||
`**Workflow name:** ${workflow.name}\n` + | ||
`**Workflow run:** ${workflowRun.html_url}`, | ||
['workflow failure','code scanning'] | ||
); | ||
} | ||
} | ||
} | ||
|
||
function getWorkflowPaths(workflowRun) { | ||
let paths = [workflowRun.path]; | ||
|
||
if (workflowRun.referenced_workflows) { | ||
workflowRun.referenced_workflows.forEach(r => paths.push(r.path.split('@')[0])); | ||
} | ||
|
||
return paths; | ||
} | ||
|
||
function isCodeQLPath(path) { | ||
// Default setup workflow | ||
if (path === 'dynamic/github-code-scanning/codeql') { | ||
return true; | ||
} | ||
|
||
// Custom CodeQL workflows | ||
return path.toLowerCase().includes('.github/workflows/codeql'); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export function isSecurityReviewer(username) { | ||
// TODO: Externalize to configuration | ||
const securityReviewers = ['octocat', 'monalisa']; | ||
return securityReviewers.includes(username); | ||
} |
Oops, something went wrong.