diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index f80d07a..37d6a21 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -79,7 +79,7 @@ jobs: - name: Run Trivy vulnerability scanner if: github.event_name != 'pull_request' - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 continue-on-error: true with: image-ref: ${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} diff --git a/SECURITY-ACCEPTED-RISKS.md b/SECURITY-ACCEPTED-RISKS.md new file mode 100644 index 0000000..d015e1f --- /dev/null +++ b/SECURITY-ACCEPTED-RISKS.md @@ -0,0 +1,31 @@ +# Accepted Security Risks + +The following moderate-severity advisories are accepted as low-risk for the dispatch project. +Fixing them would require major version downgrades that break functionality. + +## 1. `next` → bundled `postcss` (XSS via unescaped `` — GHSA-qx2v-qp2m-jg93) + +- **Affected:** `next@16.2.7` bundles `postcss@8.4.31` (< 8.5.10) +- **Impact:** Moderate (CVSS 6.1) — XSS requires user interaction (UI:R in CVSS) +- **Why not fix:** Latest stable Next.js (16.2.7) still bundles vulnerable postcss. + Upgrading to a patched version would require a major downgrade to `next@9.3.3`, + which is not viable. The attack surface requires user-supplied CSS with crafted + `` tags — unlikely in our self-hosted deployment model. + +## 2. `prisma` → `@prisma/dev` → `@hono/node-server` (Middleware bypass — GHSA-92pp-h63x-v22m) + +- **Affected:** `prisma@7.8.0` depends on `@prisma/dev` ≤ 0.24.8, which depends + on `@hono/node-server` < 1.19.13 (middleware bypass via repeated slashes in serveStatic) +- **Impact:** Moderate (CVSS 5.3) — path traversal in static file serving +- **Why not fix:** The only fix available is downgrading to `prisma@6.19.3` (major downgrade). + Our deployment does not use `serveStatic` with user-controlled paths, and Prisma's + dev tools are not exposed in production builds. + +## Resolution + +| Advisory | Status | Action | +|---|---|---| +| Trivy action pinned to SHA | ✅ Resolved | `aquasecurity/trivy-action@ed142fd` (v0.36.0) | +| `.npmrc` invalid omit config | ✅ Resolved | Fixed `omit=` → `omit=dev` | +| next/postcss XSS | 🟡 Accepted risk | Monitor for Next.js patch; no viable upgrade path | +| prisma/@hono/node-server bypass | 🟡 Accepted risk | Monitor for Prisma patch; no viable upgrade path | diff --git a/docs/smoke-checklist.md b/docs/smoke-checklist.md index 6a61edb..aca3abf 100644 --- a/docs/smoke-checklist.md +++ b/docs/smoke-checklist.md @@ -30,10 +30,12 @@ Run all checks against the target instance (local dev, staging, or production) b { "ok": true, "database": "ok", - "version": "0.1.13" + "version": "" } ``` +**Note:** The `version` field is dynamically resolved by `getAppVersion()` — it reflects the current app version from `package.json` (or `NEXT_PUBLIC_DISPATCH_VERSION` at build time). + **Status code:** `200 OK` **Failure signal:** Any response with `ok: false`, `database: "error"`, or status `503`. This means the PostgreSQL database is unreachable but Dispatch itself is still running. diff --git a/package-lock.json b/package-lock.json index 9ceecc7..ddd0bda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,15 +13,15 @@ "@dnd-kit/utilities": "^3.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.0.0", + "@prisma/client": "^7.8.0", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "effect": "^3.20.0", "lucide-react": "^1.0.0", - "next": "16.2.7", + "next": "^16.2.7", "next-auth": "^5.0.0-beta.31", - "prisma": "^7.0.0", + "prisma": "^7.8.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.0.0", diff --git a/package.json b/package.json index a9f8f02..8f26aa9 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,15 @@ "@dnd-kit/utilities": "^3.2.2", "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.0.0", + "@prisma/client": "^7.8.0", "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "effect": "^3.20.0", "lucide-react": "^1.0.0", - "next": "16.2.7", + "next": "^16.2.7", "next-auth": "^5.0.0-beta.31", - "prisma": "^7.0.0", + "prisma": "^7.8.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.0.0", diff --git a/src/app/api/automation/sync/route.ts b/src/app/api/automation/sync/route.ts index ea60055..7580ec8 100644 --- a/src/app/api/automation/sync/route.ts +++ b/src/app/api/automation/sync/route.ts @@ -74,36 +74,33 @@ async function syncRepo(repoFullName: string) { }, }); - for (const wf of workflows) { - await prisma.githubWorkflow.upsert({ - where: { workflowId: wf.id }, - create: { - repoId: repo.id, - workflowId: wf.id, - name: wf.name, - path: wf.path, - state: wf.state, - createdAt: new Date(wf.created_at), - updatedAt: new Date(wf.updated_at), - lastRunAt: wf.last_run ? new Date(wf.last_run.created_at) : null, - }, - update: { - name: wf.name, - path: wf.path, - state: wf.state, - updatedAt: new Date(wf.updated_at), - lastRunAt: wf.last_run ? new Date(wf.last_run.created_at) : null, - }, - }); - } - - const workflowMap = new Map(); - const dbWorkflows = await prisma.githubWorkflow.findMany({ - where: { repoId: repo.id }, - select: { id: true, name: true }, - }); - for (const wf of dbWorkflows) { - workflowMap.set(wf.name, wf.id); + // Batch workflow upserts in a single transaction + if (workflows.length > 0) { + await prisma.$transaction( + workflows.map((wf) => + prisma.githubWorkflow.upsert({ + where: { workflowId: wf.id }, + create: { + repoId: repo.id, + workflowId: wf.id, + name: wf.name, + path: wf.path, + state: wf.state, + createdAt: new Date(wf.created_at), + updatedAt: new Date(wf.updated_at), + lastRunAt: wf.last_run ? new Date(wf.last_run.created_at) : null, + }, + update: { + name: wf.name, + path: wf.path, + state: wf.state, + updatedAt: new Date(wf.updated_at), + lastRunAt: wf.last_run ? new Date(wf.last_run.created_at) : null, + }, + }) + ), + { timeout: 30_000 } + ); } await prisma.automationSyncRun.update({ @@ -111,84 +108,160 @@ async function syncRepo(repoFullName: string) { data: { workflowsFetched: workflows.length }, }); - for (const run of runs) { - const prUrl = run.pull_requests?.[0]?.url || null; + // Batch run upserts in a single transaction + if (runs.length > 0) { + const runUpserts = runs.map((run) => { + const prUrl = run.pull_requests?.[0]?.url || null; + let durationSecs: number | null = null; + if (run.status === "completed" && run.run_started_at) { + const start = new Date(run.run_started_at).getTime(); + const end = new Date(run.updated_at).getTime(); + durationSecs = Math.round((end - start) / 1000); + } + return prisma.githubWorkflowRun.upsert({ + where: { runId: run.id }, + create: { + workflowId: "placeholder", // filled below + runId: run.id, + name: run.name, + status: run.status, + conclusion: run.conclusion, + branch: run.head_branch, + headSha: run.head_sha, + actor: run.actor.login, + runStartedAt: new Date(run.run_started_at), + updatedAt: new Date(run.updated_at), + durationSecs, + pullRequestUrl: prUrl, + }, + update: { + status: run.status, + conclusion: run.conclusion, + branch: run.head_branch, + actor: run.actor.login, + updatedAt: new Date(run.updated_at), + durationSecs, + pullRequestUrl: prUrl, + }, + }); + }); + + // First, resolve workflow IDs for runs that reference unknown workflows + const dbWorkflows = await prisma.githubWorkflow.findMany({ + where: { repoId: repo.id }, + select: { id: true, name: true }, + }); + const workflowMap = new Map(); + for (const wf of dbWorkflows) { + workflowMap.set(wf.name, wf.id); + } - let durationSecs: number | null = null; - if (run.status === "completed" && run.run_started_at) { - const start = new Date(run.run_started_at).getTime(); - const end = new Date(run.updated_at).getTime(); - durationSecs = Math.round((end - start) / 1000); + // Create placeholder workflows for runs with unknown workflow names + const unknownRuns = runs.filter((run) => !workflowMap.has(run.name)); + if (unknownRuns.length > 0) { + const placeholders = await prisma.$transaction( + unknownRuns.map((run) => + prisma.githubWorkflow.create({ + data: { + repoId: repo.id, + workflowId: BigInt(run.id), + name: run.name, + path: "unknown", + state: "unknown", + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + ) + ); + for (const ph of placeholders) { + workflowMap.set(ph.name, ph.id); + } } - let wfId = workflowMap.get(run.name); - if (!wfId) { - const placeholderWf = await prisma.githubWorkflow.create({ - data: { - repoId: repo.id, - workflowId: BigInt(run.id), + // Now batch all run upserts with correct workflow IDs + const resolvedUpserts = runs.map((run) => { + let wfId = workflowMap.get(run.name); + if (!wfId) { + // Shouldn't happen after placeholder creation, but guard anyway + return null; + } + const prUrl = run.pull_requests?.[0]?.url || null; + let durationSecs: number | null = null; + if (run.status === "completed" && run.run_started_at) { + const start = new Date(run.run_started_at).getTime(); + const end = new Date(run.updated_at).getTime(); + durationSecs = Math.round((end - start) / 1000); + } + return prisma.githubWorkflowRun.upsert({ + where: { runId: run.id }, + create: { + workflowId: wfId, + runId: run.id, name: run.name, - path: "unknown", - state: "unknown", - createdAt: new Date(), - updatedAt: new Date(), + status: run.status, + conclusion: run.conclusion, + branch: run.head_branch, + headSha: run.head_sha, + actor: run.actor.login, + runStartedAt: new Date(run.run_started_at), + updatedAt: new Date(run.updated_at), + durationSecs, + pullRequestUrl: prUrl, + }, + update: { + status: run.status, + conclusion: run.conclusion, + branch: run.head_branch, + actor: run.actor.login, + updatedAt: new Date(run.updated_at), + durationSecs, + pullRequestUrl: prUrl, }, }); - wfId = placeholderWf.id; - workflowMap.set(run.name, wfId); + }).filter((item): item is any => item !== null); + + if (resolvedUpserts.length > 0) { + await prisma.$transaction(resolvedUpserts, { timeout: 60_000 }); } - await prisma.githubWorkflowRun.upsert({ - where: { runId: run.id }, - create: { - workflowId: wfId, - runId: run.id, - name: run.name, - status: run.status, - conclusion: run.conclusion, - branch: run.head_branch, - headSha: run.head_sha, - actor: run.actor.login, - runStartedAt: new Date(run.run_started_at), - updatedAt: new Date(run.updated_at), - durationSecs, - pullRequestUrl: prUrl, - }, - update: { - status: run.status, - conclusion: run.conclusion, - branch: run.head_branch, - actor: run.actor.login, - updatedAt: new Date(run.updated_at), - durationSecs, - pullRequestUrl: prUrl, - }, - }).catch(() => {}); - - const savedRun = await prisma.githubWorkflowRun.findUnique({ - where: { runId: run.id }, - }); - if (savedRun && run.status === "completed") { - const jobs = await fetchRunJobs(repoFullName, run.id).catch(() => []); - for (const job of jobs) { - await prisma.githubWorkflowJob.upsert({ - where: { runId_jobId: { runId: savedRun.id, jobId: job.id } }, - create: { - runId: savedRun.id, - jobId: job.id, - name: job.name, - status: job.status, - conclusion: job.conclusion, - startedAt: job.started_at ? new Date(job.started_at) : null, - completedAt: job.completed_at ? new Date(job.completed_at) : null, - }, - update: { - status: job.status, - conclusion: job.conclusion, - startedAt: job.started_at ? new Date(job.started_at) : null, - completedAt: job.completed_at ? new Date(job.completed_at) : null, - }, + // Batch job upserts for completed runs — only fetch jobs for completed runs once + const completedRuns = runs.filter((run) => run.status === "completed"); + if (completedRuns.length > 0) { + for (const run of completedRuns) { + const jobs = await fetchRunJobs(repoFullName, run.id).catch(() => []); + if (jobs.length === 0) continue; + + // Get the run record we just upserted (no redundant findUnique needed) + const savedRun = await prisma.githubWorkflowRun.findUnique({ + where: { runId: run.id }, }); + if (!savedRun) continue; + + // Batch job upserts in a single transaction + await prisma.$transaction( + jobs.map((job) => + prisma.githubWorkflowJob.upsert({ + where: { runId_jobId: { runId: savedRun.id, jobId: job.id } }, + create: { + runId: savedRun.id, + jobId: job.id, + name: job.name, + status: job.status, + conclusion: job.conclusion, + startedAt: job.started_at ? new Date(job.started_at) : null, + completedAt: job.completed_at ? new Date(job.completed_at) : null, + }, + update: { + status: job.status, + conclusion: job.conclusion, + startedAt: job.started_at ? new Date(job.started_at) : null, + completedAt: job.completed_at ? new Date(job.completed_at) : null, + }, + }) + ), + { timeout: 30_000 } + ); } } } @@ -198,83 +271,101 @@ async function syncRepo(repoFullName: string) { data: { runsFetched: runs.length }, }); - for (const rel of releases) { - await prisma.githubRelease.upsert({ - where: { repoId_releaseId: { repoId: repo.id, releaseId: rel.id } }, - create: { - repoId: repo.id, - releaseId: rel.id, - tagName: rel.tag_name, - name: rel.name, - draft: rel.draft, - prerelease: rel.prerelease, - targetCommit: rel.target_commitish, - url: rel.html_url, - publishedAt: new Date(rel.published_at), - }, - update: { - tagName: rel.tag_name, - name: rel.name, - draft: rel.draft, - prerelease: rel.prerelease, - targetCommit: rel.target_commitish, - url: rel.html_url, - publishedAt: new Date(rel.published_at), - }, - }); + // Batch release upserts in a single transaction + if (releases.length > 0) { + await prisma.$transaction( + releases.map((rel) => + prisma.githubRelease.upsert({ + where: { repoId_releaseId: { repoId: repo.id, releaseId: rel.id } }, + create: { + repoId: repo.id, + releaseId: rel.id, + tagName: rel.tag_name, + name: rel.name, + draft: rel.draft, + prerelease: rel.prerelease, + targetCommit: rel.target_commitish, + url: rel.html_url, + publishedAt: new Date(rel.published_at), + }, + update: { + tagName: rel.tag_name, + name: rel.name, + draft: rel.draft, + prerelease: rel.prerelease, + targetCommit: rel.target_commitish, + url: rel.html_url, + publishedAt: new Date(rel.published_at), + }, + }) + ), + { timeout: 30_000 } + ); } + // Batch PR upserts in a single transaction const prsToUpsert = prs.slice(0, 50); - for (const pr of prsToUpsert) { - await prisma.githubPullRequest.upsert({ - where: { repoId_number: { repoId: repo.id, number: pr.number } }, - create: { - repoId: repo.id, - number: pr.number, - url: pr.url, - title: pr.title, - state: pr.state, - author: pr.user.login, - branch: pr.head.ref, - baseBranch: pr.base.ref, - createdAt: new Date(pr.created_at), - updatedAt: new Date(pr.updated_at), - mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, - isDraft: pr.draft, - }, - update: { - title: pr.title, - state: pr.state, - author: pr.user.login, - branch: pr.head.ref, - baseBranch: pr.base.ref, - updatedAt: new Date(pr.updated_at), - mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, - isDraft: pr.draft, - }, - }); + if (prsToUpsert.length > 0) { + await prisma.$transaction( + prsToUpsert.map((pr) => + prisma.githubPullRequest.upsert({ + where: { repoId_number: { repoId: repo.id, number: pr.number } }, + create: { + repoId: repo.id, + number: pr.number, + url: pr.url, + title: pr.title, + state: pr.state, + author: pr.user.login, + branch: pr.head.ref, + baseBranch: pr.base.ref, + createdAt: new Date(pr.created_at), + updatedAt: new Date(pr.updated_at), + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + isDraft: pr.draft, + }, + update: { + title: pr.title, + state: pr.state, + author: pr.user.login, + branch: pr.head.ref, + baseBranch: pr.base.ref, + updatedAt: new Date(pr.updated_at), + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + isDraft: pr.draft, + }, + }) + ), + { timeout: 30_000 } + ); } - for (const pkg of packages) { - const latestTag = pkg.metadata?.container?.tags?.[0] || null; - await prisma.githubPackage.upsert({ - where: { repoId_name: { repoId: repo.id, name: pkg.name } }, - create: { - repoId: repo.id, - packageType: pkg.package_type, - name: pkg.name, - visibility: pkg.visibility, - url: pkg.html_url, - latestTag, - updatedAt: new Date(pkg.updated_at), - }, - update: { - latestTag, - visibility: pkg.visibility, - url: pkg.html_url, - updatedAt: new Date(pkg.updated_at), - }, - }); + // Batch package upserts in a single transaction + if (packages.length > 0) { + await prisma.$transaction( + packages.map((pkg) => { + const latestTag = pkg.metadata?.container?.tags?.[0] || null; + return prisma.githubPackage.upsert({ + where: { repoId_name: { repoId: repo.id, name: pkg.name } }, + create: { + repoId: repo.id, + packageType: pkg.package_type, + name: pkg.name, + visibility: pkg.visibility, + url: pkg.html_url, + latestTag, + updatedAt: new Date(pkg.updated_at), + }, + update: { + latestTag, + visibility: pkg.visibility, + url: pkg.html_url, + updatedAt: new Date(pkg.updated_at), + }, + }); + }), + { timeout: 30_000 } + ); } await prisma.automationEvent.create({ diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 05200b5..aa6bcc8 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from "next/server"; +import { getAppVersion } from "@/lib/version"; import { prisma } from "@/lib/prisma"; export async function GET() { - const version = process.env.npm_package_version || "0.1.1"; + const version = getAppVersion(); try { await prisma.$queryRaw`SELECT 1`; @@ -18,4 +19,4 @@ export async function GET() { version, }, { status: 503 }); } -} \ No newline at end of file +}