From aa3f378d6283ea26c7f3715279c3977530419d63 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:43:19 +0300 Subject: [PATCH 1/5] feat: add GitHub webhook verification helpers --- dashboard/lib/github-webhook.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 dashboard/lib/github-webhook.mjs diff --git a/dashboard/lib/github-webhook.mjs b/dashboard/lib/github-webhook.mjs new file mode 100644 index 0000000..331ec3b --- /dev/null +++ b/dashboard/lib/github-webhook.mjs @@ -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 +} From b1a59f04cfac0ad62ff8617a2563a572f706fcb3 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:43:30 +0300 Subject: [PATCH 2/5] test: validate GitHub webhook signatures --- dashboard/scripts/github-webhook.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 dashboard/scripts/github-webhook.test.mjs diff --git a/dashboard/scripts/github-webhook.test.mjs b/dashboard/scripts/github-webhook.test.mjs new file mode 100644 index 0000000..2d14831 --- /dev/null +++ b/dashboard/scripts/github-webhook.test.mjs @@ -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) +}) From df739d115bd2b30882f0e3b38d40aec061192fc0 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:43:47 +0300 Subject: [PATCH 3/5] feat: add signed GitHub App webhook endpoint --- dashboard/app/api/github/webhook/route.ts | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 dashboard/app/api/github/webhook/route.ts diff --git a/dashboard/app/api/github/webhook/route.ts b/dashboard/app/api/github/webhook/route.ts new file mode 100644 index 0000000..3e9caad --- /dev/null +++ b/dashboard/app/api/github/webhook/route.ts @@ -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 || first === false) { + 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, + }) +} From 613e6b3dbc550873074133e7125e0da9580cbcd4 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:43:54 +0300 Subject: [PATCH 4/5] docs: add GitHub App webhook secret configuration --- dashboard/.env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dashboard/.env.example b/dashboard/.env.example index a3cc929..d5657d4 100644 --- a/dashboard/.env.example +++ b/dashboard/.env.example @@ -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= From 54a6965fe07c9ef8e48a4522ac19c1739262f478 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:45:00 +0300 Subject: [PATCH 5/5] fix: align Redis webhook deduplication result type --- dashboard/app/api/github/webhook/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/app/api/github/webhook/route.ts b/dashboard/app/api/github/webhook/route.ts index 3e9caad..0bd3aeb 100644 --- a/dashboard/app/api/github/webhook/route.ts +++ b/dashboard/app/api/github/webhook/route.ts @@ -33,7 +33,7 @@ export async function POST(req: NextRequest) { if (redis && delivery !== 'unknown') { const key = `github:webhook:delivery:${delivery}` const first = await redis.set(key, '1', { nx: true, ex: 86400 }) - if (first === null || first === false) { + if (first === null) { return NextResponse.json({ ok: true, duplicate: true }) } }