Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ UPSTASH_REDIS_REST_TOKEN=
# Register at: https://nvd.nist.gov/developers/request-an-api-key
# Optional — scanner works without it, just slower on NVD lookups
NVD_API_KEY=

# GitHub App webhook secret — used to verify X-Hub-Signature-256 deliveries
GITHUB_APP_WEBHOOK_SECRET=
68 changes: 68 additions & 0 deletions dashboard/app/api/github/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { getRedis } from '@/lib/redis'
import { verifyGithubSignature, repositoryFromPayload, installationFromPayload } from '@/lib/github-webhook.mjs'

export const dynamic = 'force-dynamic'

const INVALIDATION_KEYS = (fullName: string) => {
const [owner, repo] = fullName.split('/')
return [
`cache:${owner}:${repo}`,
`security:${owner}:${repo}`,
`devlens:security:v3:${owner}/${repo}`,
`advisory:${owner}:${repo}`,
`dependencies:${owner}:${repo}`,
]
}

export async function POST(req: NextRequest) {
const secret = process.env.GITHUB_APP_WEBHOOK_SECRET
if (!secret) return NextResponse.json({ error: 'GitHub App webhook is not configured' }, { status: 503 })

const body = await req.text()
const signature = req.headers.get('x-hub-signature-256')
if (!verifyGithubSignature(body, signature, secret)) {
return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 401 })
}

const event = req.headers.get('x-github-event') ?? 'unknown'
const delivery = req.headers.get('x-github-delivery') ?? 'unknown'
const payload = JSON.parse(body)
const redis = getRedis()

if (redis && delivery !== 'unknown') {
const key = `github:webhook:delivery:${delivery}`
const first = await redis.set(key, '1', { nx: true, ex: 86400 })
if (first === null) {
return NextResponse.json({ ok: true, duplicate: true })
}
}

const installationId = installationFromPayload(payload)
if (redis && installationId && (event === 'installation' || event === 'installation_repositories')) {
const action = payload.action
if (action === 'deleted' || action === 'suspend') {
await redis.del(`github:app:installation:${installationId}`)
} else {
await redis.set(`github:app:installation:${installationId}`, JSON.stringify({
installationId,
account: payload.installation?.account?.login ?? null,
repositories: (payload.repositories ?? []).map((repo: any) => repo.full_name).filter(Boolean),
updatedAt: new Date().toISOString(),
}), { ex: 86400 * 30 })
}
}

const repo = repositoryFromPayload(payload)
if (redis && repo && ['push', 'pull_request', 'issues', 'issue_comment', 'release', 'workflow_run', 'repository'].includes(event)) {
await Promise.all(INVALIDATION_KEYS(repo).map(key => redis.del(key)))
}

return NextResponse.json({
ok: true,
event,
delivery,
installationId,
repository: repo,
})
}
19 changes: 19 additions & 0 deletions dashboard/lib/github-webhook.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyGithubSignature(payload, signature, secret) {
if (!secret || !signature || !signature.startsWith('sha256=')) return false
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(payload).digest('hex'))
const received = Buffer.from(signature)
return expected.length === received.length && timingSafeEqual(expected, received)
}

export function repositoryFromPayload(payload) {
const fullName = payload?.repository?.full_name
if (typeof fullName !== 'string' || !/^[^/]+\/[^/]+$/.test(fullName)) return null
return fullName
}

export function installationFromPayload(payload) {
const id = payload?.installation?.id
return Number.isInteger(id) ? id : null
}
18 changes: 18 additions & 0 deletions dashboard/scripts/github-webhook.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyGithubSignature, repositoryFromPayload, installationFromPayload } from '../lib/github-webhook.mjs'

test('verifies the GitHub HMAC-SHA256 reference vector', () => {
const secret = "It's a Secret to Everybody"
const payload = 'Hello, World!'
const signature = 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'
assert.equal(verifyGithubSignature(payload, signature, secret), true)
assert.equal(verifyGithubSignature(payload + '!', signature, secret), false)
})

test('extracts repository and installation identifiers safely', () => {
assert.equal(repositoryFromPayload({ repository: { full_name: 'SamoTech/devlens' } }), 'SamoTech/devlens')
assert.equal(repositoryFromPayload({ repository: { full_name: 'invalid' } }), null)
assert.equal(installationFromPayload({ installation: { id: 123 } }), 123)
assert.equal(installationFromPayload({ installation: { id: '123' } }), null)
})
Loading