diff --git a/.env.example b/.env.example index 36538f0..7c937cd 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DIRECTORY_ENCRYPTION_KEY=change-me-to-a-long-random-32-char-secret # Optional. Enables Have I Been Pwned breach lookups. HIBP_API_KEY= +# Optional. Enables email alerts to company admins on new breach exposures. +# Both must be set, otherwise notifications are skipped silently. +RESEND_API_KEY= +EMAIL_FROM=DataShield + # Optional. Override the seeded admin account (defaults shown). # SEED_ADMIN_EMAIL=admin@datashield.local # SEED_ADMIN_PASSWORD=ChangeMe123! diff --git a/prisma/migrations/20260615200752_add_webhooks/migration.sql b/prisma/migrations/20260615200752_add_webhooks/migration.sql new file mode 100644 index 0000000..464c613 --- /dev/null +++ b/prisma/migrations/20260615200752_add_webhooks/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "encryptedUrl" TEXT NOT NULL, + "urlHint" TEXT NOT NULL, + "minSeverity" "Severity" NOT NULL DEFAULT 'MEDIUM', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Webhook_companyId_idx" ON "Webhook"("companyId"); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5c4e21..88a806b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,7 @@ model Company { dashboardPresets DashboardPreset[] directoryConnections DirectoryConnection[] apiCredentials ApiCredential[] + webhooks Webhook[] } model DirectoryConnection { @@ -200,6 +201,22 @@ model Alert { breach Breach? @relation(fields: [breachId], references: [id], onDelete: SetNull) } +model Webhook { + id String @id @default(cuid()) + companyId String + label String + encryptedUrl String + urlHint String + minSeverity Severity @default(MEDIUM) + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + + @@index([companyId]) +} + enum Severity { CRITICAL HIGH diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx new file mode 100644 index 0000000..2da14f4 --- /dev/null +++ b/src/app/(dashboard)/alerts/page.tsx @@ -0,0 +1,20 @@ +import { auth } from "@/auth" +import { getAlerts } from "@/lib/alerts" +import { AlertTable } from "@/components/alerts/AlertTable" + +export default async function AlertsPage() { + const session = await auth() + const alerts = await getAlerts(session!.user.companyId) + + return ( +
+
+

Alerts

+

+ Triage breach detections, acknowledge and resolve incidents +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/data-api/page.tsx b/src/app/(dashboard)/data-api/page.tsx index d658471..9f677b5 100644 --- a/src/app/(dashboard)/data-api/page.tsx +++ b/src/app/(dashboard)/data-api/page.tsx @@ -1,6 +1,8 @@ import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { ApiCredentials } from "@/components/credentials/ApiCredentials" +import { Webhooks } from "@/components/credentials/Webhooks" +import { listWebhooks } from "@/lib/webhooks" export default async function DataApiPage() { const session = await auth() @@ -17,6 +19,8 @@ export default async function DataApiPage() { lastUsedAt: c.lastUsedAt?.toISOString() ?? null, })) + const webhooks = await listWebhooks(session!.user.companyId) + return (
@@ -26,8 +30,9 @@ export default async function DataApiPage() {

-
+
+
) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index effccec..7fe2686 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation" import { Sidebar } from "@/components/layout/Sidebar" import { Topbar } from "@/components/layout/Topbar" import { Providers } from "@/components/providers" +import { getOpenAlertCount } from "@/lib/alerts" export default async function DashboardLayout({ children, @@ -12,12 +13,15 @@ export default async function DashboardLayout({ const session = await auth() if (!session) redirect("/login") + const openAlerts = await getOpenAlertCount(session.user.companyId) + return (
diff --git a/src/app/api/alerts/[id]/route.ts b/src/app/api/alerts/[id]/route.ts new file mode 100644 index 0000000..af3c515 --- /dev/null +++ b/src/app/api/alerts/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import { requireAuth } from "@/lib/apiAuth" +import { prisma } from "@/lib/prisma" +import { AlertStatus } from "@prisma/client" + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireAuth() + if (error) return error + + const { id } = await params + const body = await req.json() + const status = body.status as AlertStatus + + if (!Object.values(AlertStatus).includes(status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }) + } + + const { count } = await prisma.alert.updateMany({ + where: { id, companyId: session.user.companyId }, + data: { status }, + }) + + if (count === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + return NextResponse.json({ id, status }) +} diff --git a/src/app/api/credentials/[id]/route.ts b/src/app/api/credentials/[id]/route.ts index 213fe1f..0ee7c88 100644 --- a/src/app/api/credentials/[id]/route.ts +++ b/src/app/api/credentials/[id]/route.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/credentials/route.ts b/src/app/api/credentials/route.ts index deeddb6..0a32dc1 100644 --- a/src/app/api/credentials/route.ts +++ b/src/app/api/credentials/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAuth, requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { encryptConfig } from "@/lib/directory/crypto" import { keyHint } from "@/lib/credentials/service" @@ -17,8 +17,8 @@ const SELECT = { } as const export async function GET() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const credentials = await prisma.apiCredential.findMany({ where: { companyId: session.user.companyId }, @@ -29,10 +29,8 @@ export async function GET() { } export async function POST(req: Request) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { provider, key } = (await req.json()) as { provider?: string; key?: string } if (!provider || !PROVIDER_IDS.has(provider as ApiProvider)) diff --git a/src/app/api/directory/[id]/route.ts b/src/app/api/directory/[id]/route.ts index 3f04433..24cfbb2 100644 --- a/src/app/api/directory/[id]/route.ts +++ b/src/app/api/directory/[id]/route.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/directory/[id]/sync/route.ts b/src/app/api/directory/[id]/sync/route.ts index bbcc708..5b3bf16 100644 --- a/src/app/api/directory/[id]/sync/route.ts +++ b/src/app/api/directory/[id]/sync/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { syncDirectoryConnection } from "@/lib/directory/sync" const runningSync = new Set() @@ -8,10 +8,8 @@ export async function POST( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/directory/[id]/test/route.ts b/src/app/api/directory/[id]/test/route.ts index 69a62df..de76233 100644 --- a/src/app/api/directory/[id]/test/route.ts +++ b/src/app/api/directory/[id]/test/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { decryptConfig } from "@/lib/directory/crypto" import { testAzureConnection } from "@/lib/directory/azure" @@ -13,10 +13,8 @@ export async function POST( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/directory/route.ts b/src/app/api/directory/route.ts index 3cec556..da69022 100644 --- a/src/app/api/directory/route.ts +++ b/src/app/api/directory/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { randomUUID } from "crypto" -import { auth } from "@/auth" +import { requireAuth, requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { encryptConfig } from "@/lib/directory/crypto" import type { DirectoryType } from "@prisma/client" @@ -16,8 +16,8 @@ const REQUIRED_FIELDS: Record = { } export async function GET() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const connections = await prisma.directoryConnection.findMany({ where: { companyId: session.user.companyId }, @@ -38,10 +38,8 @@ export async function GET() { } export async function POST(req: Request) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const body = await req.json() const { type, name, config } = body as { diff --git a/src/app/api/employees/scan/route.ts b/src/app/api/employees/scan/route.ts index 18f7341..9e0e98b 100644 --- a/src/app/api/employees/scan/route.ts +++ b/src/app/api/employees/scan/route.ts @@ -1,15 +1,23 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAuth } from "@/lib/apiAuth" +import { rateLimit } from "@/lib/rateLimit" import { loadActiveProviders, runScan } from "@/lib/scan/runner" const runningScans = new Set() export async function POST() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const companyId = session.user.companyId + if (!rateLimit(`scan:${companyId}`, 5, 60_000)) { + return NextResponse.json( + { error: "Too many scans. Try again in a minute." }, + { status: 429 } + ) + } + const providers = await loadActiveProviders(companyId) if (!providers.length) { return NextResponse.json( diff --git a/src/app/api/reports/export/route.ts b/src/app/api/reports/export/route.ts index f7b8bad..9be8b77 100644 --- a/src/app/api/reports/export/route.ts +++ b/src/app/api/reports/export/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/auth" +import { requireAuth } from "@/lib/apiAuth" import { getReportData } from "@/lib/reports" import { reportCsv, type CsvSection } from "@/lib/reports/csv" @@ -17,8 +17,8 @@ function isSection(value: string): value is CsvSection { } export async function GET(request: Request): Promise { - const session = await auth() - if (!session) return new Response("Unauthorized", { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const section = new URL(request.url).searchParams.get("section") ?? "all" if (!isSection(section)) return new Response("Invalid section", { status: 400 }) diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts new file mode 100644 index 0000000..0b77d7a --- /dev/null +++ b/src/app/api/webhooks/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { requireAdmin } from "@/lib/apiAuth" +import { prisma } from "@/lib/prisma" +import { Severity } from "@prisma/client" + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { session, error } = await requireAdmin() + if (error) return error + + const { id } = await params + const body = (await req.json()) as { enabled?: boolean; minSeverity?: string } + + const data: { enabled?: boolean; minSeverity?: Severity } = {} + if (typeof body.enabled === "boolean") data.enabled = body.enabled + if (body.minSeverity && body.minSeverity in Severity) + data.minSeverity = body.minSeverity as Severity + + const { count } = await prisma.webhook.updateMany({ + where: { id, companyId: session.user.companyId }, + data, + }) + if (count === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ id, ...data }) +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { session, error } = await requireAdmin() + if (error) return error + + const { id } = await params + const { count } = await prisma.webhook.deleteMany({ + where: { id, companyId: session.user.companyId }, + }) + if (count === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts new file mode 100644 index 0000000..64f8b23 --- /dev/null +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server" +import { requireAdmin } from "@/lib/apiAuth" +import { prisma } from "@/lib/prisma" +import { decryptConfig } from "@/lib/directory/crypto" +import { sendTestWebhook } from "@/lib/webhooks" + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { session, error } = await requireAdmin() + if (error) return error + + const { id } = await params + const webhook = await prisma.webhook.findFirst({ + where: { id, companyId: session.user.companyId }, + select: { encryptedUrl: true }, + }) + if (!webhook) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + const delivered = await sendTestWebhook(decryptConfig<{ url: string }>(webhook.encryptedUrl).url) + return NextResponse.json({ delivered }) +} diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts new file mode 100644 index 0000000..824234d --- /dev/null +++ b/src/app/api/webhooks/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { requireAuth, requireAdmin } from "@/lib/apiAuth" +import { prisma } from "@/lib/prisma" +import { encryptConfig } from "@/lib/directory/crypto" +import { listWebhooks, urlHint } from "@/lib/webhooks" +import { Severity } from "@prisma/client" + +export async function GET() { + const { session, error } = await requireAuth() + if (error) return error + return NextResponse.json(await listWebhooks(session.user.companyId)) +} + +export async function POST(req: Request) { + const { session, error } = await requireAdmin() + if (error) return error + + const { label, url, minSeverity } = (await req.json()) as { + label?: string + url?: string + minSeverity?: string + } + + if (!label?.trim()) return NextResponse.json({ error: "Missing label" }, { status: 400 }) + + let parsed: URL + try { + parsed = new URL(url ?? "") + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (parsed.protocol !== "https:") + return NextResponse.json({ error: "URL must use https" }, { status: 400 }) + + const severity = + minSeverity && minSeverity in Severity ? (minSeverity as Severity) : Severity.MEDIUM + + const webhook = await prisma.webhook.create({ + data: { + companyId: session.user.companyId, + label: label.trim(), + encryptedUrl: encryptConfig({ url: parsed.toString() }), + urlHint: urlHint(parsed.toString()), + minSeverity: severity, + }, + select: { id: true, label: true, urlHint: true, minSeverity: true, enabled: true }, + }) + return NextResponse.json(webhook, { status: 201 }) +} diff --git a/src/auth.ts b/src/auth.ts index af47553..85993b0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,7 @@ import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" import { authConfig } from "@/auth.config" import { prisma } from "@/lib/prisma" +import { rateLimit } from "@/lib/rateLimit" import bcrypt from "bcryptjs" export const { handlers, signIn, signOut, auth } = NextAuth({ @@ -15,6 +16,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null + if (!rateLimit(`login:${credentials.email}`, 10, 60_000)) return null + const user = await prisma.user.findUnique({ where: { email: credentials.email as string }, include: { company: true }, diff --git a/src/components/alerts/AlertTable.tsx b/src/components/alerts/AlertTable.tsx new file mode 100644 index 0000000..4e9fdb6 --- /dev/null +++ b/src/components/alerts/AlertTable.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useState, useMemo } from "react" +import { useRouter } from "next/navigation" +import { Search, Check, CheckCheck, Loader2, Download } from "lucide-react" +import { RiskBadge } from "@/components/ui/RiskBadge" +import type { AlertRow } from "@/lib/alerts" +import type { RiskLevel } from "@/lib/employees" +import { downloadCsv } from "@/lib/csv" +import { cn } from "@/lib/utils" + +const SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const +const STATUSES = ["OPEN", "ACKNOWLEDGED", "RESOLVED"] as const + +const borderBySeverity: Record = { + CRITICAL: "border-l-severity-critical", + HIGH: "border-l-severity-high", + MEDIUM: "border-l-severity-medium", + LOW: "border-l-severity-low", +} + +const statusLabel: Record = { + OPEN: "Open", + ACKNOWLEDGED: "Acknowledged", + RESOLVED: "Resolved", +} + +function timeAgo(date: Date): string { + const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000) + if (s < 60) return "just now" + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + return `${d}d ago` +} + +export function AlertTable({ data }: { data: AlertRow[] }) { + const router = useRouter() + const [search, setSearch] = useState("") + const [severity, setSeverity] = useState("") + const [status, setStatus] = useState("") + const [pending, setPending] = useState(null) + + const filtered = useMemo(() => { + return data.filter((a) => { + const q = search.toLowerCase() + const matchSearch = + !q || + a.message.toLowerCase().includes(q) || + (a.employeeEmail?.toLowerCase().includes(q) ?? false) || + (a.breachName?.toLowerCase().includes(q) ?? false) + const matchSeverity = !severity || a.severity === severity + const matchStatus = !status || a.status === status + return matchSearch && matchSeverity && matchStatus + }) + }, [data, search, severity, status]) + + function exportCsv() { + downloadCsv( + "datashield-alerts.csv", + ["severity", "status", "employee", "breach", "message", "created"], + filtered.map((a) => [ + a.severity, + a.status, + a.employeeEmail ?? "", + a.breachName ?? "", + a.message, + new Date(a.createdAt).toISOString(), + ]) + ) + } + + async function updateStatus(id: string, next: string) { + setPending(id) + const res = await fetch(`/api/alerts/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: next }), + }) + setPending(null) + if (res.ok) router.refresh() + } + + return ( + <> +
+
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-input bg-card py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20" + /> +
+ + + +
+ + {filtered.length === 0 ? ( +
+ No alerts found +
+ ) : ( +
+ {filtered.map((a) => ( +
+ +
+

{a.message}

+

+ {a.employeeEmail ?? "Unknown employee"} + {a.breachName ? ` - ${a.breachName}` : ""} - {timeAgo(a.createdAt)} +

+
+ {statusLabel[a.status]} +
+ {a.status === "OPEN" && ( + + )} + {a.status !== "RESOLVED" && ( + + )} +
+
+ ))} +
+ )} + + ) +} diff --git a/src/components/credentials/Webhooks.tsx b/src/components/credentials/Webhooks.tsx new file mode 100644 index 0000000..c1545b7 --- /dev/null +++ b/src/components/credentials/Webhooks.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useState } from "react" +import { Webhook, Trash2, Loader2, Send, Check, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import type { WebhookRow } from "@/lib/webhooks" + +const SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const + +type Props = { + initial: WebhookRow[] + isAdmin: boolean +} + +export function Webhooks({ initial, isAdmin }: Props) { + const [hooks, setHooks] = useState(initial) + const [label, setLabel] = useState("") + const [url, setUrl] = useState("") + const [minSeverity, setMinSeverity] = useState("MEDIUM") + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + const [tested, setTested] = useState>({}) + + async function add() { + setBusy("add") + setError(null) + const res = await fetch("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label, url, minSeverity }), + }) + const data = await res.json() + setBusy(null) + if (!res.ok) return setError(data.error ?? "Failed to add webhook") + setHooks((h) => [data, ...h]) + setLabel("") + setUrl("") + } + + async function toggle(id: string, enabled: boolean) { + setBusy(id) + await fetch(`/api/webhooks/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }) + setHooks((h) => h.map((w) => (w.id === id ? { ...w, enabled } : w))) + setBusy(null) + } + + async function remove(id: string) { + setBusy(id) + await fetch(`/api/webhooks/${id}`, { method: "DELETE" }) + setHooks((h) => h.filter((w) => w.id !== id)) + setBusy(null) + } + + async function test(id: string) { + setBusy(id) + const res = await fetch(`/api/webhooks/${id}/test`, { method: "POST" }) + const data = await res.json() + setTested((t) => ({ ...t, [id]: Boolean(data.delivered) })) + setBusy(null) + } + + return ( +
+
+ +

Notification webhooks

+
+

+ POST a JSON payload to Slack, Teams or any HTTPS endpoint when a new exposure is detected. +

+ +
+ {hooks.length === 0 && ( +

No webhooks configured

+ )} + {hooks.map((w) => ( +
+
+

{w.label}

+

+ {w.urlHint} - min {w.minSeverity} +

+
+ {tested[w.id] !== undefined && + (tested[w.id] ? ( + + ) : ( + + ))} + {isAdmin && ( + <> + {w.enabled ? "On" : "Off"} + + + + + )} +
+ ))} +
+ + {isAdmin && ( +
+ setLabel(e.target.value)} + className="w-32 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none" + /> + setUrl(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none" + /> + + +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/src/components/employees/EmployeeTable.tsx b/src/components/employees/EmployeeTable.tsx index 392796d..f65ed3e 100644 --- a/src/components/employees/EmployeeTable.tsx +++ b/src/components/employees/EmployeeTable.tsx @@ -11,10 +11,11 @@ import { createColumnHelper, type SortingState, } from "@tanstack/react-table" -import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, Search } from "lucide-react" +import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, Search, Download } from "lucide-react" import { RiskBadge } from "@/components/ui/RiskBadge" import { EmployeeDrawer } from "@/components/employees/EmployeeDrawer" import type { EmployeeRow, RiskLevel } from "@/lib/employees" +import { downloadCsv } from "@/lib/csv" import { cn } from "@/lib/utils" const col = createColumnHelper() @@ -82,6 +83,22 @@ export function EmployeeTable({ data }: { data: EmployeeRow[] }) { }) }, [data, search, department, riskFilter]) + function exportCsv() { + downloadCsv( + "datashield-employees.csv", + ["name", "email", "department", "risk", "breaches", "exposed data", "last detected"], + filtered.map((e) => [ + `${e.firstName} ${e.lastName}`, + e.email, + e.department ?? "", + e.riskLevel, + e.breachCount, + e.exposedDataTypes.join("; "), + e.lastDetectedAt ? new Date(e.lastDetectedAt).toISOString().slice(0, 10) : "", + ]) + ) + } + const table = useReactTable({ data: filtered, columns, @@ -123,6 +140,14 @@ export function EmployeeTable({ data }: { data: EmployeeRow[] }) { {RISK_LEVELS.map((r) => )} +
{/* Table */} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 0b215f9..3088ae3 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -26,9 +26,10 @@ const navItems = [ interface SidebarProps { companyName: string userEmail: string + openAlerts: number } -export function Sidebar({ companyName, userEmail }: SidebarProps) { +export function Sidebar({ companyName, userEmail, openAlerts }: SidebarProps) { const pathname = usePathname() return ( @@ -59,6 +60,11 @@ export function Sidebar({ companyName, userEmail }: SidebarProps) { )} /> {label} + {href === "/alerts" && openAlerts > 0 && ( + + {openAlerts > 99 ? "99+" : openAlerts} + + )} ) })} diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index c2389aa..5bb037c 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -1,7 +1,6 @@ "use client" import { usePathname } from "next/navigation" -import { Bell } from "lucide-react" const pageTitles: Record = { "/dashboard": "Dashboard", @@ -23,13 +22,8 @@ export function Topbar() { const title = getTitle(pathname) return ( -
+

{title}

-
- -
) } diff --git a/src/lib/alerts.ts b/src/lib/alerts.ts new file mode 100644 index 0000000..1f950d0 --- /dev/null +++ b/src/lib/alerts.ts @@ -0,0 +1,36 @@ +import { prisma } from "@/lib/prisma" +import type { Severity, AlertStatus } from "@prisma/client" + +export type AlertRow = { + id: string + severity: Severity + status: AlertStatus + message: string + employeeName: string | null + employeeEmail: string | null + breachName: string | null + createdAt: Date +} + +export function getOpenAlertCount(companyId: string): Promise { + return prisma.alert.count({ where: { companyId, status: "OPEN" } }) +} + +export async function getAlerts(companyId: string): Promise { + const alerts = await prisma.alert.findMany({ + where: { companyId }, + include: { employee: true, breach: true }, + orderBy: { createdAt: "desc" }, + }) + + return alerts.map((a) => ({ + id: a.id, + severity: a.severity, + status: a.status, + message: a.message, + employeeName: a.employee ? `${a.employee.firstName} ${a.employee.lastName}` : null, + employeeEmail: a.employee?.email ?? null, + breachName: a.breach?.name ?? null, + createdAt: a.createdAt, + })) +} diff --git a/src/lib/apiAuth.ts b/src/lib/apiAuth.ts new file mode 100644 index 0000000..f27e9b5 --- /dev/null +++ b/src/lib/apiAuth.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server" +import type { Session } from "next-auth" +import { auth } from "@/auth" + +// Single source of truth for API route authorization. +// Role model: ADMIN configures the workspace (API keys, directory +// connections, webhooks); VIEWER operates (reads data, runs scans, +// triages alerts). Callers do `const { session, error } = await requireX()` +// and return `error` when present. +type Guard = { session: Session; error: null } | { session: null; error: NextResponse } + +const unauthorized = () => NextResponse.json({ error: "Unauthorized" }, { status: 401 }) +const forbidden = () => NextResponse.json({ error: "Admin only" }, { status: 403 }) + +export async function requireAuth(): Promise { + const session = await auth() + if (!session) return { session: null, error: unauthorized() } + return { session, error: null } +} + +export async function requireAdmin(): Promise { + const session = await auth() + if (!session) return { session: null, error: unauthorized() } + if (session.user.role !== "ADMIN") return { session: null, error: forbidden() } + return { session, error: null } +} diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..b91bd08 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,16 @@ +type Cell = string | number + +function escapeCell(value: Cell): string { + const s = String(value) + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s +} + +export function downloadCsv(filename: string, headers: string[], rows: Cell[][]) { + const csv = [headers, ...rows].map((row) => row.map(escapeCell).join(",")).join("\n") + const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })) + const link = document.createElement("a") + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..ab08da8 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,44 @@ +const KEY = process.env.RESEND_API_KEY +const FROM = process.env.EMAIL_FROM +const APP_URL = process.env.AUTH_URL ?? "http://localhost:3000" + +export function emailEnabled(): boolean { + return Boolean(KEY && FROM) +} + +type BreachAlert = { + employeeName: string + breachName: string + dataTypes: string[] + severity: string +} + +function render(a: BreachAlert): string { + const types = a.dataTypes.length ? a.dataTypes.join(", ") : "unknown data" + return [ + `
`, + `

${a.severity} exposure detected.

`, + `

${a.employeeName} was found in the ${a.breachName} breach.

`, + `

Exposed data: ${types}.

`, + `

View the alert in DataShield

`, + `
`, + ].join("") +} + +export async function sendBreachAlert(recipients: string[], alert: BreachAlert): Promise { + if (!emailEnabled() || recipients.length === 0) return + try { + await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: FROM, + to: recipients, + subject: `New breach exposure: ${alert.employeeName}`, + html: render(alert), + }), + }) + } catch { + // Notification failures must never abort a scan. + } +} diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..f474b68 --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,18 @@ +type Entry = { count: number; reset: number } + +const buckets = new Map() + +// Fixed-window counter. Returns false once the limit is reached for the +// current window. In-memory and per-instance, sufficient for a single-node +// deployment; move to a shared store if the app is scaled horizontally. +export function rateLimit(key: string, limit: number, windowMs: number): boolean { + const now = Date.now() + const entry = buckets.get(key) + if (!entry || now > entry.reset) { + buckets.set(key, { count: 1, reset: now + windowMs }) + return true + } + if (entry.count >= limit) return false + entry.count++ + return true +} diff --git a/src/lib/scan/runner.ts b/src/lib/scan/runner.ts index 6c5b5a7..31804ce 100644 --- a/src/lib/scan/runner.ts +++ b/src/lib/scan/runner.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/prisma" import { decryptConfig } from "@/lib/directory/crypto" +import { emailEnabled, sendBreachAlert } from "@/lib/email" +import { dispatchWebhooks, loadActiveWebhooks } from "@/lib/webhooks" import { providerById } from "./registry" import { sleep } from "./normalize" import type { BreachProvider, Finding } from "./types" @@ -47,12 +49,18 @@ function severityFor(dataTypes: string[]): Severity { // Persist a finding: create the breach if needed, then the record and alert when // this employee was not already linked to it. Returns true if a record was created. +type Notify = { + recipients: string[] + webhooks: Awaited> +} + async function persistFinding( companyId: string, employee: EmployeeWithRecords, finding: Finding, source: BreachSource, - known: Set + known: Set, + notify: Notify ): Promise { const breach = await prisma.breach.upsert({ where: { name: finding.name }, @@ -66,6 +74,9 @@ async function persistFinding( }) if (known.has(breach.id)) return false + const severity = severityFor(finding.dataTypes) + const employeeName = `${employee.firstName} ${employee.lastName}` + await prisma.breachRecord.create({ data: { employeeId: employee.id, breachId: breach.id, exposedData: finding.dataTypes }, }) @@ -74,15 +85,29 @@ async function persistFinding( companyId, employeeId: employee.id, breachId: breach.id, - severity: severityFor(finding.dataTypes), + severity, status: "OPEN", - message: `${employee.firstName} ${employee.lastName} found in ${finding.name} breach.`, + message: `${employeeName} found in ${finding.name} breach.`, }, }) + + const event = { employeeName, breachName: finding.name, dataTypes: finding.dataTypes, severity } + await sendBreachAlert(notify.recipients, event) + await dispatchWebhooks(notify.webhooks, event) + known.add(breach.id) return true } +async function notifyRecipients(companyId: string): Promise { + if (!emailEnabled()) return [] + const admins = await prisma.user.findMany({ + where: { companyId, role: "ADMIN" }, + select: { email: true }, + }) + return admins.map((a) => a.email) +} + // Scan every employee against every active provider. A provider error is isolated // (we move on to the next one) so it never aborts the whole scan. export async function runScan( @@ -93,6 +118,10 @@ export async function runScan( where: { companyId }, include: { breachRecords: { select: { breachId: true } } }, }) + const notify: Notify = { + recipients: await notifyRecipients(companyId), + webhooks: await loadActiveWebhooks(companyId), + } let newRecords = 0 for (const employee of employees) { @@ -105,7 +134,7 @@ export async function runScan( continue } for (const finding of findings) { - if (await persistFinding(companyId, employee, finding, provider.source, known)) { + if (await persistFinding(companyId, employee, finding, provider.source, known, notify)) { newRecords++ } } diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts new file mode 100644 index 0000000..2659c80 --- /dev/null +++ b/src/lib/webhooks.ts @@ -0,0 +1,82 @@ +import { prisma } from "@/lib/prisma" +import { decryptConfig } from "@/lib/directory/crypto" +import type { Severity } from "@prisma/client" + +export type WebhookRow = { + id: string + label: string + urlHint: string + minSeverity: Severity + enabled: boolean +} + +export type WebhookEvent = { + employeeName: string + breachName: string + dataTypes: string[] + severity: Severity +} + +type ActiveWebhook = { url: string; minSeverity: Severity } + +const SEVERITY_RANK: Record = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 } + +export function urlHint(url: string): string { + try { + return new URL(url).host + } catch { + return "invalid-url" + } +} + +export function listWebhooks(companyId: string): Promise { + return prisma.webhook.findMany({ + where: { companyId }, + orderBy: { createdAt: "desc" }, + select: { id: true, label: true, urlHint: true, minSeverity: true, enabled: true }, + }) +} + +async function post(url: string, event: WebhookEvent): Promise { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `${event.severity} exposure: ${event.employeeName} found in ${event.breachName}`, + ...event, + }), + }) + return res.ok + } catch { + return false + } +} + +export function sendTestWebhook(url: string): Promise { + return post(url, { + employeeName: "Test User", + breachName: "Test Breach", + dataTypes: ["email"], + severity: "LOW", + }) +} + +export async function loadActiveWebhooks(companyId: string): Promise { + const hooks = await prisma.webhook.findMany({ + where: { companyId, enabled: true }, + select: { encryptedUrl: true, minSeverity: true }, + }) + return hooks.map((h) => ({ + url: decryptConfig<{ url: string }>(h.encryptedUrl).url, + minSeverity: h.minSeverity, + })) +} + +export async function dispatchWebhooks(hooks: ActiveWebhook[], event: WebhookEvent): Promise { + await Promise.all( + hooks + .filter((h) => SEVERITY_RANK[event.severity] >= SEVERITY_RANK[h.minSeverity]) + .map((h) => post(h.url, event)) + ) +}