diff --git a/index.js b/index.js index e010f56..7cb7e17 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,7 @@ import express from 'express' -import {App, createNodeMiddleware, Octokit} from 'octokit' +import * as crypto from 'crypto' +import {App, Octokit} from 'octokit' +import {rateLimit} from 'express-rate-limit' const port = process.env.OSST_ACTIONS_BOT_PORT || process.env.PORT || 8080 const appID = process.env.OSST_ACTIONS_BOT_APP_ID @@ -30,10 +32,6 @@ const octokit = new App({ } }) -const middleware = createNodeMiddleware(octokit) -const app = express() -app.use(middleware) - const retrieveRequiredChecks = async (properties) => { const requiredChecks = [] for (const [_key, value] of Object.entries(properties)) { @@ -106,33 +104,96 @@ const processRerunRequiredWorkflows = async (octokit, metadata, owner, repo, num } } -octokit.webhooks.on('issue_comment.created', async ({octokit, payload}) => { - try { - const body = payload.comment.body.trim().toLowerCase() - const owner = payload.repository.owner.login - const repo = payload.repository.name - const issueNumber = payload.issue.number - const actor = payload.comment.user.login - const commentID = payload.comment.id - const metadata = `${actor}:${owner}:${repo}:${issueNumber}:${commentID}` +const verifyGitHubWebhook = (req, res, next) => { + const payload = JSON.stringify(req.body) + if (!payload) { + return next('Request body empty') + } - if (!payload.issue.pull_request) { - return console.log(`[${metadata}] Issue is not a pull request`) + const sig = req.get('X-Hub-Signature-256') || '' + const hmac = crypto.createHmac('sha256', appSecret) + const digest = Buffer.from('sha256=' + hmac.update(payload).digest('hex'), 'utf8') + const checksum = Buffer.from(sig, 'utf8') + if (checksum.length !== digest.length || !crypto.timingSafeEqual(digest, checksum)) { + return next(`Request body digest (${digest}) did not match X-Hub-Signature-256 (${checksum})`) + } + return next() +} +const verifyIssueCommentCreatedEvent = (req, res, next) => { + if (req.get('X-GitHub-Event') === 'issue_comment') { + if (req.body.action === 'created') { + return next() } - if (!body.startsWith('/actions-bot') || !body.includes('rerun-required-workflows')) { - return console.log(`[${metadata}] Not a command: '${body}'`) - } + } + return next(`X-GitHub-Event is not issue_comment.created`) +} + +const verifyIsPR = async (req, res, next) => { + const isPR = req.body.issue.pull_request + if (isPR) { + return next() + } + return next(`Issue is not a pull request`) +} - console.log(`[${metadata}] Processing command '${body}'`) - const properties = payload.repository.custom_properties - console.log(`[${metadata}] Processing properties: ${JSON.stringify(properties)}`) +const verifyCommand = (req, res, next) => { + const command = req.body.comment.body.trim().toLowerCase() + if (command.startsWith('/actions-bot') && command.includes('rerun-required-workflows')) { + return next() + } + return next(`Not a command: '${command}'`) + +} + +const hydrateKey = (req, res, next) => { + const actor = req.body.comment.user.login + const owner = req.body.repository.owner.login + const repo = req.body.repository.name + const pr = req.body.issue.number + const commentID = req.body.comment.id + const commentNodeID = req.body.comment.node_id + req.key = `${actor}:${owner}:${repo}:${pr}:${commentID}:${commentNodeID}` + return next() +} + +const hydrateOctokit = async (req, res, next) => { + req.octokit = await octokit.getInstallationOctokit(req.body.installation.id) + return next() +} + +const limiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + limit: 1, // limit each IP to 1 requests per windowMs + keyGenerator: (req) => req.body.issue.node_id, + handler: (req, res, next, options) => { + console.log(`[${req.key}] Rate limit exceeded`) + return res.status(options.statusCode).send(options.message) + } +}) + +const app = express() +app.use(express.json()) +app.use(hydrateKey) +app.use(limiter) +app.use(verifyGitHubWebhook) +app.use(verifyIsPR) +app.use(verifyIssueCommentCreatedEvent) +app.use(verifyCommand) +app.use(hydrateOctokit) + +app.post('/api/github/webhooks', async (req) => { + try { + const command = req.body.comment.body.trim().toLowerCase() + console.log(`[${req.key}] Processing command '${command}'`) + const properties = req.body.repository.custom_properties + console.log(`[${req.key}] Processing properties: ${JSON.stringify(properties)}`) const checks = await retrieveRequiredChecks(properties) if (checks.length === 0) { - return console.log(`[${metadata}] No required checks found`) + return console.log(`[${req.key}] No required checks found`) } - console.log(`[${metadata}] Processing rerun-required-workflows`) - await processRerunRequiredWorkflows(octokit, metadata, owner, repo, issueNumber, checks) + console.log(`[${req.key}] Processing rerun-required-workflows`) + await processRerunRequiredWorkflows(req.octokit, req.key, req.body.repository.owner.login, req.body.repository.name, req.body.issue.number, checks) } catch (e) { console.error(`Error: ${e.message}`) } diff --git a/package-lock.json b/package-lock.json index d7d2267..d2d3326 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "actions-bot", - "version": "1.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "actions-bot", - "version": "1.1.0", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "@octokit/app": "^14.0.2",