diff --git a/data/compliance-audits.json b/data/compliance-audits.json new file mode 100644 index 00000000..dfa50d5e --- /dev/null +++ b/data/compliance-audits.json @@ -0,0 +1,98 @@ +[ + { + "id": "audit-001", + "framework": "GDPR", + "controlId": "GDPR-ART30", + "controlName": "Records of Processing Activities", + "status": "pass", + "evidence": "https://foragents.dev/evidence/gdpr-art30-records", + "auditor": "Maya Patel", + "auditDate": "2026-01-10", + "nextReviewDate": "2026-07-10", + "notes": "Processing records are complete and updated quarterly." + }, + { + "id": "audit-002", + "framework": "GDPR", + "controlId": "GDPR-ART32", + "controlName": "Security of Processing", + "status": "partial", + "evidence": "https://foragents.dev/evidence/gdpr-art32-security", + "auditor": "Maya Patel", + "auditDate": "2026-01-12", + "nextReviewDate": "2026-04-12", + "notes": "Encryption at rest is enabled; key rotation policy needs formal sign-off." + }, + { + "id": "audit-003", + "framework": "SOC2", + "controlId": "SOC2-CC6.1", + "controlName": "Logical and Physical Access Controls", + "status": "pass", + "evidence": "https://foragents.dev/evidence/soc2-cc6-1-access", + "auditor": "Jonah Wu", + "auditDate": "2026-01-15", + "nextReviewDate": "2026-07-15", + "notes": "RBAC and MFA enforcement verified for production systems." + }, + { + "id": "audit-004", + "framework": "SOC2", + "controlId": "SOC2-CC7.2", + "controlName": "Monitoring for Security Events", + "status": "fail", + "evidence": "https://foragents.dev/evidence/soc2-cc7-2-monitoring", + "auditor": "Jonah Wu", + "auditDate": "2026-01-17", + "nextReviewDate": "2026-03-01", + "notes": "Alert escalation runbook is missing after-hours ownership definitions." + }, + { + "id": "audit-005", + "framework": "HIPAA", + "controlId": "HIPAA-164.312(a)(1)", + "controlName": "Access Control", + "status": "pass", + "evidence": "https://foragents.dev/evidence/hipaa-access-control", + "auditor": "Elena Rossi", + "auditDate": "2026-01-20", + "nextReviewDate": "2026-07-20", + "notes": "Unique user identification and emergency access procedures documented." + }, + { + "id": "audit-006", + "framework": "HIPAA", + "controlId": "HIPAA-164.312(b)", + "controlName": "Audit Controls", + "status": "na", + "evidence": "https://foragents.dev/evidence/hipaa-audit-controls", + "auditor": "Elena Rossi", + "auditDate": "2026-01-20", + "nextReviewDate": "2026-07-20", + "notes": "Control marked not applicable for this service boundary; covered by upstream provider." + }, + { + "id": "audit-007", + "framework": "ISO27001", + "controlId": "ISO-A.12.6.1", + "controlName": "Management of Technical Vulnerabilities", + "status": "partial", + "evidence": "https://foragents.dev/evidence/iso-a-12-6-1-vuln", + "auditor": "Derrick Cole", + "auditDate": "2026-01-24", + "nextReviewDate": "2026-04-24", + "notes": "Monthly scanning is in place; remediation SLA tracking is inconsistent." + }, + { + "id": "audit-008", + "framework": "ISO27001", + "controlId": "ISO-A.16.1.5", + "controlName": "Response to Information Security Incidents", + "status": "pass", + "evidence": "https://foragents.dev/evidence/iso-a-16-1-5-response", + "auditor": "Derrick Cole", + "auditDate": "2026-01-24", + "nextReviewDate": "2026-07-24", + "notes": "Incident response workflow tested in tabletop exercise with documented outcomes." + } +] diff --git a/src/app/api/compliance/audit/route.ts b/src/app/api/compliance/audit/route.ts new file mode 100644 index 00000000..ed4da989 --- /dev/null +++ b/src/app/api/compliance/audit/route.ts @@ -0,0 +1,255 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const AUDIT_DATA_PATH = path.join(process.cwd(), "data", "compliance-audits.json"); +const FRAMEWORKS = ["GDPR", "SOC2", "HIPAA", "ISO27001"] as const; +const STATUSES = ["pass", "fail", "partial", "na"] as const; + +type AuditFramework = (typeof FRAMEWORKS)[number]; +type AuditStatus = (typeof STATUSES)[number]; + +interface ComplianceAudit { + id: string; + framework: AuditFramework; + controlId: string; + controlName: string; + status: AuditStatus; + evidence: string; + auditor: string; + auditDate: string; + nextReviewDate: string; + notes: string; +} + +interface FrameworkScore { + framework: AuditFramework; + score: number; + passed: number; + partial: number; + failed: number; + notApplicable: number; + totalApplicable: number; +} + +function isAuditFramework(value: unknown): value is AuditFramework { + return typeof value === "string" && FRAMEWORKS.includes(value as AuditFramework); +} + +function isAuditStatus(value: unknown): value is AuditStatus { + return typeof value === "string" && STATUSES.includes(value as AuditStatus); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isValidDateString(value: unknown): value is string { + if (!isNonEmptyString(value)) { + return false; + } + + return !Number.isNaN(new Date(value).getTime()); +} + +function isComplianceAudit(value: unknown): value is ComplianceAudit { + if (!value || typeof value !== "object") { + return false; + } + + const entry = value as Record; + + return ( + isNonEmptyString(entry.id) && + isAuditFramework(entry.framework) && + isNonEmptyString(entry.controlId) && + isNonEmptyString(entry.controlName) && + isAuditStatus(entry.status) && + isNonEmptyString(entry.evidence) && + isNonEmptyString(entry.auditor) && + isValidDateString(entry.auditDate) && + isValidDateString(entry.nextReviewDate) && + isNonEmptyString(entry.notes) + ); +} + +async function readAuditData(): Promise { + const raw = await fs.readFile(AUDIT_DATA_PATH, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + + if (!Array.isArray(parsed) || !parsed.every(isComplianceAudit)) { + throw new Error("Invalid compliance audit dataset"); + } + + return parsed; +} + +async function writeAuditData(audits: ComplianceAudit[]): Promise { + await fs.writeFile(AUDIT_DATA_PATH, JSON.stringify(audits, null, 2)); +} + +function formatDateOnly(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function nextAuditId(audits: ComplianceAudit[]): string { + const maxId = audits.reduce((max, audit) => { + const parts = audit.id.match(/audit-(\d+)/i); + if (!parts) { + return max; + } + + const value = Number(parts[1]); + return Number.isFinite(value) ? Math.max(max, value) : max; + }, 0); + + return `audit-${String(maxId + 1).padStart(3, "0")}`; +} + +function calculateScores(audits: ComplianceAudit[]): FrameworkScore[] { + return FRAMEWORKS.map((framework) => { + const frameworkAudits = audits.filter((audit) => audit.framework === framework); + + const passed = frameworkAudits.filter((audit) => audit.status === "pass").length; + const partial = frameworkAudits.filter((audit) => audit.status === "partial").length; + const failed = frameworkAudits.filter((audit) => audit.status === "fail").length; + const notApplicable = frameworkAudits.filter((audit) => audit.status === "na").length; + + const totalApplicable = passed + partial + failed; + const weightedPass = passed + partial * 0.5; + const score = totalApplicable > 0 ? Math.round((weightedPass / totalApplicable) * 100) : 0; + + return { + framework, + score, + passed, + partial, + failed, + notApplicable, + totalApplicable, + }; + }); +} + +function matchesSearch(audit: ComplianceAudit, searchTerm: string): boolean { + if (!searchTerm) { + return true; + } + + const haystack = [ + audit.id, + audit.framework, + audit.controlId, + audit.controlName, + audit.evidence, + audit.auditor, + audit.notes, + ] + .join(" ") + .toLowerCase(); + + return haystack.includes(searchTerm); +} + +export async function GET(request: NextRequest) { + try { + const allAudits = await readAuditData(); + const frameworkParam = request.nextUrl.searchParams.get("framework")?.trim().toUpperCase(); + const statusParam = request.nextUrl.searchParams.get("status")?.trim().toLowerCase(); + const search = request.nextUrl.searchParams.get("search")?.trim().toLowerCase() ?? ""; + + const filtered = allAudits.filter((audit) => { + const frameworkMatch = frameworkParam ? audit.framework === frameworkParam : true; + const statusMatch = statusParam ? audit.status === statusParam : true; + const searchMatch = matchesSearch(audit, search); + return frameworkMatch && statusMatch && searchMatch; + }); + + return NextResponse.json( + { + audits: filtered, + total: filtered.length, + frameworkScores: calculateScores(filtered), + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); + } catch { + return NextResponse.json({ error: "Unable to read compliance audits" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Record; + + const framework = typeof body.framework === "string" ? body.framework.toUpperCase() : ""; + const status = typeof body.status === "string" ? body.status.toLowerCase() : ""; + + const auditDateInput = isNonEmptyString(body.auditDate) ? new Date(body.auditDate) : new Date(); + const nextReviewInput = isNonEmptyString(body.nextReviewDate) + ? new Date(body.nextReviewDate) + : new Date(auditDateInput.getTime() + 180 * 24 * 60 * 60 * 1000); + + const validationErrors: string[] = []; + + if (!isAuditFramework(framework)) validationErrors.push("framework must be GDPR, SOC2, HIPAA, or ISO27001"); + if (!isAuditStatus(status)) validationErrors.push("status must be pass, fail, partial, or na"); + if (!isNonEmptyString(body.controlId)) validationErrors.push("controlId is required"); + if (!isNonEmptyString(body.controlName)) validationErrors.push("controlName is required"); + if (!isNonEmptyString(body.evidence)) validationErrors.push("evidence is required"); + if (!isNonEmptyString(body.auditor)) validationErrors.push("auditor is required"); + if (!isNonEmptyString(body.notes)) validationErrors.push("notes is required"); + if (Number.isNaN(auditDateInput.getTime())) validationErrors.push("auditDate is invalid"); + if (Number.isNaN(nextReviewInput.getTime())) validationErrors.push("nextReviewDate is invalid"); + + if (validationErrors.length > 0) { + return NextResponse.json( + { + error: "Validation failed", + details: validationErrors, + }, + { status: 400 } + ); + } + + const audits = await readAuditData(); + const controlId = (body.controlId as string).trim(); + const controlName = (body.controlName as string).trim(); + const evidence = (body.evidence as string).trim(); + const auditor = (body.auditor as string).trim(); + const notes = (body.notes as string).trim(); + + const newAudit: ComplianceAudit = { + id: nextAuditId(audits), + framework: framework as AuditFramework, + controlId, + controlName, + status: status as AuditStatus, + evidence, + auditor, + auditDate: formatDateOnly(auditDateInput), + nextReviewDate: formatDateOnly(nextReviewInput), + notes, + }; + + const updatedAudits = [...audits, newAudit]; + await writeAuditData(updatedAudits); + + return NextResponse.json( + { + audit: newAudit, + total: updatedAudits.length, + }, + { status: 201 } + ); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/compliance/audit/audit-compliance-client.tsx b/src/app/compliance/audit/audit-compliance-client.tsx new file mode 100644 index 00000000..6b88a416 --- /dev/null +++ b/src/app/compliance/audit/audit-compliance-client.tsx @@ -0,0 +1,549 @@ +/* eslint-disable react/no-unescaped-entities */ +"use client"; + +import Link from "next/link"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { AlertTriangle, CheckCircle2, CircleDashed, CircleSlash2, CircleX } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; + +type AuditFramework = "GDPR" | "SOC2" | "HIPAA" | "ISO27001"; +type AuditStatus = "pass" | "fail" | "partial" | "na"; + +interface ComplianceAudit { + id: string; + framework: AuditFramework; + controlId: string; + controlName: string; + status: AuditStatus; + evidence: string; + auditor: string; + auditDate: string; + nextReviewDate: string; + notes: string; +} + +interface FrameworkScore { + framework: AuditFramework; + score: number; + passed: number; + partial: number; + failed: number; + notApplicable: number; + totalApplicable: number; +} + +interface AuditApiResponse { + audits: ComplianceAudit[]; + total: number; + frameworkScores: FrameworkScore[]; +} + +interface AuditFormState { + framework: AuditFramework; + controlId: string; + controlName: string; + status: AuditStatus; + evidence: string; + auditor: string; + auditDate: string; + nextReviewDate: string; + notes: string; +} + +const INITIAL_FORM_STATE: AuditFormState = { + framework: "GDPR", + controlId: "", + controlName: "", + status: "pass", + evidence: "", + auditor: "", + auditDate: "", + nextReviewDate: "", + notes: "", +}; + +function scoreBarClass(score: number): string { + if (score >= 85) return "bg-emerald-400"; + if (score >= 70) return "bg-amber-400"; + return "bg-rose-400"; +} + +function formatDate(dateInput: string): string { + const date = new Date(dateInput); + + if (Number.isNaN(date.getTime())) { + return dateInput; + } + + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function statusBadgeClass(status: AuditStatus): string { + if (status === "pass") { + return "bg-emerald-500/15 text-emerald-300 border-emerald-500/40"; + } + + if (status === "partial") { + return "bg-amber-500/15 text-amber-300 border-amber-500/40"; + } + + if (status === "na") { + return "bg-slate-500/20 text-slate-300 border-slate-400/30"; + } + + return "bg-rose-500/15 text-rose-300 border-rose-500/40"; +} + +function frameworkBadgeClass(framework: AuditFramework): string { + if (framework === "GDPR") return "bg-sky-500/15 text-sky-300 border-sky-500/35"; + if (framework === "SOC2") return "bg-violet-500/15 text-violet-300 border-violet-500/35"; + if (framework === "HIPAA") return "bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/35"; + return "bg-cyan-500/15 text-cyan-300 border-cyan-500/35"; +} + +function statusIcon(status: AuditStatus) { + if (status === "pass") return ; + if (status === "partial") return ; + if (status === "na") return ; + return ; +} + +export default function AuditComplianceClient() { + const [frameworkFilter, setFrameworkFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [form, setForm] = useState(INITIAL_FORM_STATE); + const [submitting, setSubmitting] = useState(false); + const [submitMessage, setSubmitMessage] = useState(null); + const [submitError, setSubmitError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadAuditData() { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + + if (frameworkFilter !== "all") { + params.set("framework", frameworkFilter); + } + + if (statusFilter !== "all") { + params.set("status", statusFilter); + } + + if (search.trim()) { + params.set("search", search.trim()); + } + + const query = params.toString(); + const url = query ? `/api/compliance/audit?${query}` : "/api/compliance/audit"; + + const response = await fetch(url, { cache: "no-store" }); + + if (!response.ok) { + throw new Error("Unable to load audit records"); + } + + const payload = (await response.json()) as AuditApiResponse; + + if (!cancelled) { + setData(payload); + } + } catch { + if (!cancelled) { + setError("Unable to load compliance audits right now. Please try again."); + setData(null); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadAuditData(); + + return () => { + cancelled = true; + }; + }, [frameworkFilter, search, statusFilter]); + + const totalDisplayed = useMemo(() => data?.audits.length ?? 0, [data]); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setSubmitError(null); + setSubmitMessage(null); + + try { + const response = await fetch("/api/compliance/audit", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + if (!response.ok) { + const payload = (await response.json()) as { details?: string[] }; + const details = payload.details?.length ? payload.details.join(", ") : "Validation failed"; + throw new Error(details); + } + + setSubmitMessage("Audit finding saved successfully."); + setForm(INITIAL_FORM_STATE); + + const refresh = await fetch("/api/compliance/audit", { cache: "no-store" }); + if (refresh.ok) { + const payload = (await refresh.json()) as AuditApiResponse; + setData(payload); + setFrameworkFilter("all"); + setStatusFilter("all"); + setSearch(""); + } + } catch (submitErr) { + const message = submitErr instanceof Error ? submitErr.message : "Unable to submit finding"; + setSubmitError(message); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+
+
+ +
+ + ← Back to Compliance Hub + + Persistent Audit Data +

Compliance Audit Tracker

+

+ Review framework controls, filter findings, and submit new audit entries backed by persistent data. +

+
+
+ + + +
+ + + Filter controls + + +
+ + +
+ +
+ + +
+ +
+ + setSearch(event.target.value)} + placeholder="Try: CC7.2, encryption, Patel" + className="bg-black/25" + /> +
+
+
+ + {loading && ( + + Loading audit findings... + + )} + + {error && ( + + + + {error} + + + )} + + {!loading && !error && data && ( + <> +
+ {data.frameworkScores.map((frameworkScore) => ( + + + + {frameworkScore.framework} + + {frameworkScore.score}% + + + + +
+
+
+

+ Pass: {frameworkScore.passed} · Partial: {frameworkScore.partial} · Fail: {frameworkScore.failed} · N/A: {frameworkScore.notApplicable} +

+ + + ))} +
+ + + + Audit table ({totalDisplayed}) + + + {data.audits.length === 0 ? ( +

No audit findings match the current filters.

+ ) : ( +
+ + + + + + + + + + + + + + + {data.audits.map((audit) => ( + + + + + + + + + + + ))} + +
FrameworkControlStatusEvidenceAuditorAudit DateNext ReviewNotes
+ + {audit.framework} + + +
{audit.controlName}
+
{audit.controlId}
+
+ + + {statusIcon(audit.status)} + {audit.status.toUpperCase()} + + + + + View evidence + + {audit.auditor}{formatDate(audit.auditDate)}{formatDate(audit.nextReviewDate)}{audit.notes}
+
+ )} +
+
+ + )} + + + + Submit audit finding + + +
+
+ + +
+ +
+ + +
+ +
+ + setForm((prev) => ({ ...prev, controlId: event.target.value }))} + placeholder="e.g. SOC2-CC6.1" + className="bg-black/25" + /> +
+ +
+ + setForm((prev) => ({ ...prev, controlName: event.target.value }))} + placeholder="Control title" + className="bg-black/25" + /> +
+ +
+ + setForm((prev) => ({ ...prev, auditor: event.target.value }))} + placeholder="Auditor name" + className="bg-black/25" + /> +
+ +
+ + setForm((prev) => ({ ...prev, evidence: event.target.value }))} + placeholder="https://..." + className="bg-black/25" + /> +
+ +
+ + setForm((prev) => ({ ...prev, auditDate: event.target.value }))} + className="bg-black/25" + /> +
+ +
+ + setForm((prev) => ({ ...prev, nextReviewDate: event.target.value }))} + className="bg-black/25" + /> +
+ +
+ +