import { NextResponse } from "next/server"; import { prisma } from "@capakraken/db"; import { createNotificationsForUsers } from "@capakraken/api"; import { logger } from "@capakraken/api/lib/logger"; import { readFileSync } from "fs"; import { join } from "path"; import { verifyCronSecret } from "~/lib/cron-auth.js"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; /** * Known CVEs / minimum safe versions for critical dependencies. * Update this map when new advisories are published. */ const MINIMUM_SAFE_VERSIONS: Record = { next: { minVersion: "15.0.0", advisory: "Keep Next.js on latest 15.x" }, prisma: { minVersion: "6.0.0", advisory: "Prisma 6.x for latest security patches" }, "@sentry/nextjs": { minVersion: "8.0.0", advisory: "Sentry v8 for latest fixes" }, ioredis: { minVersion: "5.4.0", advisory: "ioredis 5.4+ for connection security" }, "next-auth": { minVersion: "5.0.0", advisory: "Auth.js v5 for security hardening" }, }; interface Finding { package: string; currentVersion: string; minimumVersion: string; severity: "high" | "medium" | "low"; advisory: string; } /** * Simple semver comparison: returns true if `current` >= `minimum`. * Handles standard x.y.z versions and beta/rc suffixes. */ function isVersionSafe(current: string, minimum: string): boolean { const normalize = (v: string) => v .replace(/^[~^>=<]*/, "") .split("-")[0]! .split(".") .map((n) => parseInt(n, 10) || 0); const cur = normalize(current); const min = normalize(minimum); for (let i = 0; i < 3; i++) { const c = cur[i] ?? 0; const m = min[i] ?? 0; if (c > m) return true; if (c < m) return false; } return true; // equal } function scanPackageJson(): Finding[] { const findings: Finding[] = []; try { // Read root package.json and web app package.json const paths = [ join(process.cwd(), "package.json"), join(process.cwd(), "../../package.json"), // monorepo root from apps/web ]; const allDeps: Record = {}; for (const p of paths) { try { const content = JSON.parse(readFileSync(p, "utf-8")); Object.assign(allDeps, content.dependencies ?? {}, content.devDependencies ?? {}); } catch { // File not found — skip } } for (const [pkg, rule] of Object.entries(MINIMUM_SAFE_VERSIONS)) { const installed = allDeps[pkg]; if (!installed) continue; if (!isVersionSafe(installed, rule.minVersion)) { findings.push({ package: pkg, currentVersion: installed, minimumVersion: rule.minVersion, severity: "high", advisory: rule.advisory ?? "Update recommended", }); } } } catch (error) { logger.error( { error, route: "/api/cron/security-audit" }, "Failed to scan package manifests for security audit", ); } return findings; } /** * GET /api/cron/security-audit * * Checks installed dependency versions against known minimum safe versions. * Creates a CRITICAL notification for ADMIN users if high-severity issues found. * * Protected by CRON_SECRET environment variable. * When set, requests must include `Authorization: Bearer `. */ export async function GET(request: Request) { const deny = verifyCronSecret(request); if (deny) return deny; try { const findings = scanPackageJson(); const highSeverity = findings.filter((f) => f.severity === "high"); // Alert admins if high-severity findings exist if (highSeverity.length > 0) { const adminUsers = await prisma.user.findMany({ where: { systemRole: "ADMIN" }, select: { id: true }, }); if (adminUsers.length > 0) { const details = highSeverity .map((f) => `${f.package}@${f.currentVersion} (need >=${f.minimumVersion})`) .join(", "); await createNotificationsForUsers({ db: prisma, userIds: adminUsers.map((u) => u.id), type: "SYSTEM_ALERT", title: `Security Audit: ${highSeverity.length} high-severity finding(s)`, body: `Outdated packages detected: ${details}. Run \`pnpm update\` and review advisories.`, category: "system", priority: "CRITICAL", link: "/admin/settings", }); } } return NextResponse.json({ ok: highSeverity.length === 0, totalFindings: findings.length, highSeverity: highSeverity.length, findings, scannedAt: new Date().toISOString(), }); } catch (error) { logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed"); return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 }); } }