Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
ebickle committed Oct 4, 2024
0 parents commit c67f7c3
Show file tree
Hide file tree
Showing 16 changed files with 2,828 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .env.sample
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"
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
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
27 changes: 27 additions & 0 deletions .gitignore
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?
22 changes: 22 additions & 0 deletions LICENSE
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.
68 changes: 68 additions & 0 deletions README.md
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).
68 changes: 68 additions & 0 deletions app.js
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.')
});
109 changes: 109 additions & 0 deletions behaviors/alert-status-authorization.js
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']);
}
}
48 changes: 48 additions & 0 deletions behaviors/workflow-failures.js
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');
}
5 changes: 5 additions & 0 deletions common/authorization.js
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);
}
Loading

0 comments on commit c67f7c3

Please sign in to comment.