diff --git a/__tests__/app/admin/security-pages.test.tsx b/__tests__/app/admin/security-pages.test.tsx
new file mode 100644
index 00000000..cbc761a0
--- /dev/null
+++ b/__tests__/app/admin/security-pages.test.tsx
@@ -0,0 +1,183 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import SecurityAdvisoriesPage from "@/app/admin/security/advisories/page";
+import SecurityAuditLogPage from "@/app/admin/security/audit-log/page";
+
+const mockPush = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+ useSearchParams: () => new URLSearchParams("org=acme-corp"),
+}));
+
+vi.mock("@/lib/auth", () => ({
+ useAuth: () => ({
+ token: "test-token",
+ isAuthenticated: true,
+ login: vi.fn(),
+ logout: vi.fn(),
+ }),
+}));
+
+vi.mock("@/components/ui/toast", () => ({
+ useToast: () => ({
+ success: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+function mockAdminFetch() {
+ return vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+
+ if (url.endsWith("/api/v3/user")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ login: "alice" }),
+ };
+ }
+
+ if (url.endsWith("/api/v3/orgs/acme-corp/members")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => [{ login: "alice", role: "admin" }],
+ };
+ }
+
+ if (url.includes("/api/v3/orgs/acme-corp/security-advisories")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => [
+ {
+ id: "1",
+ ghsa_id: "GHSA-xxxx-yyyy-zzzz",
+ severity: "high",
+ summary: "Example advisory",
+ state: "open",
+ affected_package: "example-pkg",
+ repository: {
+ owner: { login: "acme-corp" },
+ name: "demo",
+ },
+ },
+ ],
+ };
+ }
+
+ if (url.includes("/api/v3/orgs/acme-corp/audit-log/export")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ job_id: "export-123" }),
+ };
+ }
+
+ if (
+ url.includes("/api/v3/orgs/acme-corp/audit-log") &&
+ !url.includes("/export")
+ ) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => [
+ {
+ id: "log-1",
+ actor_login: "alice",
+ action: "repo.delete",
+ ip_address: "192.168.1.10",
+ created_at: "2024-01-01T00:00:00Z",
+ },
+ ],
+ };
+ }
+
+ return {
+ ok: false,
+ status: 404,
+ json: async () => ({}),
+ };
+ });
+}
+
+describe("Security admin pages", () => {
+ beforeEach(() => {
+ mockPush.mockClear();
+ vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080");
+ });
+
+ it("renders advisories for org admins", async () => {
+ vi.stubGlobal("fetch", mockAdminFetch());
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("GHSA-xxxx-yyyy-zzzz")).toBeInTheDocument();
+ });
+ });
+
+ it("shows access denied for non-admin members on advisories page", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+
+ if (url.endsWith("/api/v3/user")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ login: "bob" }),
+ };
+ }
+
+ if (url.endsWith("/api/v3/orgs/acme-corp/members")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => [{ login: "bob", role: "member" }],
+ };
+ }
+
+ return { ok: false, status: 404, json: async () => ({}) };
+ }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Access Denied")).toBeInTheDocument();
+ });
+ });
+
+ it("uses action filter param and masks IP addresses on audit log page", async () => {
+ const fetchMock = mockAdminFetch();
+ vi.stubGlobal("fetch", fetchMock);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("repo.delete")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("192.168.*.*")).toBeInTheDocument();
+
+ await userEvent.selectOptions(
+ screen.getByLabelText("Action"),
+ "repo.delete",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Search" }));
+
+ await waitFor(() => {
+ expect(
+ fetchMock.mock.calls.some(([url]) => {
+ const parsed = new URL(String(url));
+ return parsed.searchParams.get("action") === "repo.delete";
+ }),
+ ).toBe(true);
+ });
+ });
+});
diff --git a/__tests__/lib/admin/security.test.ts b/__tests__/lib/admin/security.test.ts
new file mode 100644
index 00000000..a43420fa
--- /dev/null
+++ b/__tests__/lib/admin/security.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildOrgQueryString,
+ datetimeLocalToIso,
+ getSeverityBadgeClass,
+ isAdminOrgRole,
+ maskIpAddress,
+ sanitizeAuditSearchPhrase,
+ sanitizeOrgLogin,
+} from "@/lib/admin/security";
+
+describe("sanitizeOrgLogin", () => {
+ it("accepts valid org logins", () => {
+ expect(sanitizeOrgLogin("acme-corp")).toBe("acme-corp");
+ expect(sanitizeOrgLogin(" acme ")).toBe("acme");
+ });
+
+ it("rejects invalid org logins", () => {
+ expect(sanitizeOrgLogin("acme/evil")).toBeNull();
+ expect(sanitizeOrgLogin("https://evil.example")).toBeNull();
+ expect(sanitizeOrgLogin("")).toBeNull();
+ expect(sanitizeOrgLogin(null)).toBeNull();
+ });
+});
+
+describe("buildOrgQueryString", () => {
+ it("builds a safe org query string", () => {
+ expect(buildOrgQueryString("acme-corp")).toBe("?org=acme-corp");
+ expect(buildOrgQueryString("acme/evil")).toBe("");
+ });
+});
+
+describe("isAdminOrgRole", () => {
+ it("returns true for admin and owner roles", () => {
+ expect(isAdminOrgRole("admin")).toBe(true);
+ expect(isAdminOrgRole("owner")).toBe(true);
+ expect(isAdminOrgRole("OWNER")).toBe(true);
+ });
+
+ it("returns false for non-admin roles", () => {
+ expect(isAdminOrgRole("member")).toBe(false);
+ expect(isAdminOrgRole("read")).toBe(false);
+ });
+});
+
+describe("sanitizeAuditSearchPhrase", () => {
+ it("trims, strips control characters, and caps length", () => {
+ expect(sanitizeAuditSearchPhrase(" repo.delete ")).toBe("repo.delete");
+ expect(sanitizeAuditSearchPhrase("a\u0000b")).toBe("ab");
+ expect(sanitizeAuditSearchPhrase("x".repeat(300))).toHaveLength(256);
+ });
+});
+
+describe("datetimeLocalToIso", () => {
+ it("converts datetime-local values using local time", () => {
+ const iso = datetimeLocalToIso("2024-06-15T10:30");
+ expect(iso).toMatch(/^2024-06-15T/);
+ expect(iso.endsWith("Z") || iso.includes("+") || iso.includes("-")).toBe(true);
+ });
+
+ it("returns empty string for invalid values", () => {
+ expect(datetimeLocalToIso("")).toBe("");
+ expect(datetimeLocalToIso("not-a-date")).toBe("");
+ });
+});
+
+describe("maskIpAddress", () => {
+ it("masks IPv4 addresses", () => {
+ expect(maskIpAddress("192.168.1.42")).toBe("192.168.*.*");
+ });
+
+ it("masks IPv6 addresses", () => {
+ expect(maskIpAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe(
+ "2001:****",
+ );
+ });
+
+ it("returns em dash for missing values", () => {
+ expect(maskIpAddress(null)).toBe("—");
+ });
+});
+
+describe("getSeverityBadgeClass", () => {
+ it("returns a class for known severities", () => {
+ expect(getSeverityBadgeClass("critical")).toContain("bg-[#cf222e]");
+ });
+
+ it("falls back to low severity styling", () => {
+ expect(getSeverityBadgeClass("unknown")).toBe(getSeverityBadgeClass("low"));
+ });
+});
diff --git a/app/admin/security/advisories/page.tsx b/app/admin/security/advisories/page.tsx
new file mode 100644
index 00000000..faa6a3ec
--- /dev/null
+++ b/app/admin/security/advisories/page.tsx
@@ -0,0 +1,443 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import {
+ Suspense,
+ useCallback,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+
+import { AdvisoryStatusForm } from "@/components/admin/AdvisoryStatusForm";
+import {
+ SecurityAccessDenied,
+ SecurityPageLayout,
+} from "@/components/admin/SecurityPageLayout";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRoot,
+ TableRow,
+} from "@/components/ui/table";
+import { useToast } from "@/components/ui/toast";
+import {
+ checkClientOrgAdminAccess,
+ genericActionError,
+ getSecurityApiBase,
+ getSeverityBadgeClass,
+} from "@/lib/admin/security";
+import { useAuth } from "@/lib/auth";
+import type {
+ AdvisoryState,
+ DismissedReason,
+ SecurityAdvisory,
+} from "@/lib/api-types";
+import { cn } from "@/lib/utils";
+
+type AdvisoryListItem = SecurityAdvisory & {
+ repository?: {
+ owner: { login: string };
+ name: string;
+ };
+};
+
+const STATE_OPTIONS: { value: string; label: string }[] = [
+ { value: "", label: "All states" },
+ { value: "open", label: "Open" },
+ { value: "acknowledged", label: "Acknowledged" },
+ { value: "resolved", label: "Resolved" },
+ { value: "dismissed", label: "Dismissed" },
+];
+
+const SEVERITY_OPTIONS: { value: string; label: string }[] = [
+ { value: "", label: "All severities" },
+ { value: "critical", label: "Critical" },
+ { value: "high", label: "High" },
+ { value: "medium", label: "Medium" },
+ { value: "low", label: "Low" },
+];
+
+function Dialog({
+ open,
+ onOpenChange,
+ children,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ children: ReactNode;
+}) {
+ if (!open) {
+ return null;
+ }
+
+ return (
+
+
+ );
+}
+
+function resolveRepoScope(advisory: AdvisoryListItem): {
+ owner: string;
+ repo: string;
+} | null {
+ if (advisory.repository?.owner.login && advisory.repository.name) {
+ return {
+ owner: advisory.repository.owner.login,
+ repo: advisory.repository.name,
+ };
+ }
+ return null;
+}
+
+function SecurityAdvisoriesContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { token } = useAuth();
+ const toast = useToast();
+ const apiBase = getSecurityApiBase();
+
+ const [advisories, setAdvisories] = useState([]);
+ const [stateFilter, setStateFilter] = useState("");
+ const [severityFilter, setSeverityFilter] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [accessDenied, setAccessDenied] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedAdvisory, setSelectedAdvisory] =
+ useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const orgParam = searchParams.get("org");
+
+ const loadAdvisories = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ setAccessDenied(false);
+
+ const access = await checkClientOrgAdminAccess({
+ token,
+ orgParam,
+ onUnauthenticated: () => {
+ router.push("/login");
+ },
+ });
+
+ if (access.status === "unauthenticated") {
+ setLoading(false);
+ return;
+ }
+
+ if (access.status === "access_denied") {
+ setAccessDenied(true);
+ setAdvisories([]);
+ setLoading(false);
+ return;
+ }
+
+ if (access.status === "error") {
+ setError(access.message);
+ setLoading(false);
+ return;
+ }
+
+ const resolvedOrg = access.org;
+
+ try {
+ const params = new URLSearchParams({ per_page: "100" });
+ if (stateFilter) {
+ params.set("state", stateFilter);
+ }
+ if (severityFilter) {
+ params.set("severity", severityFilter);
+ }
+
+ const response = await fetch(
+ `${apiBase}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/security-advisories?${params.toString()}`,
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ },
+ );
+
+ if (response.status === 401) {
+ router.push("/login");
+ return;
+ }
+
+ if (response.status === 403) {
+ setAccessDenied(true);
+ setAdvisories([]);
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(genericActionError("load advisories"));
+ }
+
+ const data = (await response.json()) as AdvisoryListItem[];
+ setAdvisories(data);
+ } catch {
+ setError(genericActionError("load advisories"));
+ } finally {
+ setLoading(false);
+ }
+ }, [
+ token,
+ router,
+ orgParam,
+ stateFilter,
+ severityFilter,
+ apiBase,
+ ]);
+
+ useEffect(() => {
+ void loadAdvisories();
+ }, [loadAdvisories]);
+
+ const handleStatusUpdate = async (
+ state: AdvisoryState,
+ dismissedReason?: DismissedReason,
+ ) => {
+ if (!selectedAdvisory || !token) {
+ return;
+ }
+
+ const repoScope = resolveRepoScope(selectedAdvisory);
+ if (!repoScope) {
+ toast.error("Repository scope is missing for this advisory.");
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const body: { state: AdvisoryState; dismissed_reason?: DismissedReason } =
+ { state };
+ if (state === "dismissed" && dismissedReason) {
+ body.dismissed_reason = dismissedReason;
+ }
+
+ const response = await fetch(
+ `${apiBase}/api/v3/repos/${encodeURIComponent(repoScope.owner)}/${encodeURIComponent(repoScope.repo)}/security-advisories/${encodeURIComponent(selectedAdvisory.ghsa_id)}`,
+ {
+ method: "PATCH",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ },
+ );
+
+ if (response.status === 401) {
+ router.push("/login");
+ return;
+ }
+
+ if (response.status === 403) {
+ toast.error("Admin access is required to update advisory status.");
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(genericActionError("update advisory status"));
+ }
+
+ toast.success("Advisory status updated");
+ setDialogOpen(false);
+ setSelectedAdvisory(null);
+ await loadAdvisories();
+ } catch {
+ toast.error(genericActionError("update advisory status"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDialogOpenChange = (open: boolean) => {
+ setDialogOpen(open);
+ if (!open) {
+ setSelectedAdvisory(null);
+ }
+ };
+
+ if (accessDenied) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {loading ? (
+
Loading…
+ ) : error ? (
+
{error}
+ ) : advisories.length === 0 ? (
+
No advisories found.
+ ) : (
+
+
+
+ GHSA ID
+ Severity
+ Summary
+ State
+ Package
+ Actions
+
+
+
+ {advisories.map((advisory) => (
+
+
+ {advisory.ghsa_id}
+
+
+
+ {advisory.severity}
+
+
+ {advisory.summary}
+ {advisory.state}
+
+ {advisory.affected_package}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default function SecurityAdvisoriesPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/security/audit-log/page.tsx b/app/admin/security/audit-log/page.tsx
new file mode 100644
index 00000000..9e10634d
--- /dev/null
+++ b/app/admin/security/audit-log/page.tsx
@@ -0,0 +1,380 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useCallback, useEffect, useState } from "react";
+
+import {
+ SecurityAccessDenied,
+ SecurityPageLayout,
+} from "@/components/admin/SecurityPageLayout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRoot,
+ TableRow,
+} from "@/components/ui/table";
+import { useToast } from "@/components/ui/toast";
+import {
+ checkClientOrgAdminAccess,
+ datetimeLocalToIso,
+ genericActionError,
+ getSecurityApiBase,
+ maskIpAddress,
+ resolveOrgLogin,
+ sanitizeAuditSearchPhrase,
+} from "@/lib/admin/security";
+import { useAuth } from "@/lib/auth";
+
+type OrgAuditLogEntry = {
+ id: string;
+ actor_login: string;
+ action: string;
+ ip_address: string | null;
+ created_at: string;
+};
+
+type ExportResponse = {
+ job_id: string;
+ download_url?: string;
+};
+
+const ACTION_OPTIONS = [
+ { value: "", label: "All actions" },
+ { value: "repo.delete", label: "repo.delete" },
+ { value: "member.add", label: "member.add" },
+ { value: "member.remove", label: "member.remove" },
+ { value: "token.issue", label: "token.issue" },
+ { value: "token.revoke", label: "token.revoke" },
+ { value: "advisory.state_change", label: "advisory.state_change" },
+ { value: "settings.update", label: "settings.update" },
+] as const;
+
+function SecurityAuditLogContent() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { token } = useAuth();
+ const toast = useToast();
+ const apiBase = getSecurityApiBase();
+
+ const [org, setOrg] = useState("");
+ const [entries, setEntries] = useState([]);
+ const [phrase, setPhrase] = useState("");
+ const [action, setAction] = useState("");
+ const [after, setAfter] = useState("");
+ const [before, setBefore] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [exporting, setExporting] = useState(false);
+ const [accessDenied, setAccessDenied] = useState(false);
+ const [error, setError] = useState(null);
+
+ const orgParam = searchParams.get("org");
+
+ const loadAuditLog = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ setAccessDenied(false);
+
+ const access = await checkClientOrgAdminAccess({
+ token,
+ orgParam,
+ onUnauthenticated: () => {
+ router.push("/login");
+ },
+ });
+
+ if (access.status === "unauthenticated") {
+ setLoading(false);
+ return;
+ }
+
+ if (access.status === "access_denied") {
+ setAccessDenied(true);
+ setEntries([]);
+ setLoading(false);
+ return;
+ }
+
+ if (access.status === "error") {
+ setError(access.message);
+ setLoading(false);
+ return;
+ }
+
+ const resolvedOrg = access.org;
+ setOrg(resolvedOrg);
+
+ try {
+ const params = new URLSearchParams({ per_page: "50" });
+ const sanitizedPhrase = sanitizeAuditSearchPhrase(phrase);
+ if (sanitizedPhrase) {
+ params.set("phrase", sanitizedPhrase);
+ }
+ if (action) {
+ params.set("action", action);
+ }
+ const afterIso = datetimeLocalToIso(after);
+ if (afterIso) {
+ params.set("after", afterIso);
+ }
+ const beforeIso = datetimeLocalToIso(before);
+ if (beforeIso) {
+ params.set("before", beforeIso);
+ }
+
+ const response = await fetch(
+ `${apiBase}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/audit-log?${params.toString()}`,
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ },
+ );
+
+ if (response.status === 401) {
+ router.push("/login");
+ return;
+ }
+
+ if (response.status === 403) {
+ setAccessDenied(true);
+ setEntries([]);
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(genericActionError("load audit log"));
+ }
+
+ const data = (await response.json()) as OrgAuditLogEntry[];
+ setEntries(data);
+ } catch {
+ setError(genericActionError("load audit log"));
+ } finally {
+ setLoading(false);
+ }
+ }, [token, router, orgParam, phrase, action, after, before, apiBase]);
+
+ useEffect(() => {
+ void loadAuditLog();
+ }, [loadAuditLog]);
+
+ const handleExport = async () => {
+ if (!token) {
+ router.push("/login");
+ return;
+ }
+
+ setExporting(true);
+ try {
+ let resolvedOrg = org;
+ if (!resolvedOrg) {
+ const orgResult = await resolveOrgLogin(token, orgParam);
+ if (!orgResult.ok) {
+ toast.error(orgResult.message);
+ return;
+ }
+ resolvedOrg = orgResult.org;
+ setOrg(resolvedOrg);
+ }
+
+ const access = await checkClientOrgAdminAccess({
+ token,
+ orgParam: resolvedOrg,
+ onUnauthenticated: () => {
+ router.push("/login");
+ },
+ });
+
+ if (access.status !== "ok") {
+ toast.error("Admin access is required to export audit logs.");
+ return;
+ }
+
+ const params = new URLSearchParams({ format: "csv" });
+ const sanitizedPhrase = sanitizeAuditSearchPhrase(phrase);
+ if (sanitizedPhrase) {
+ params.set("phrase", sanitizedPhrase);
+ }
+ if (action) {
+ params.set("action", action);
+ }
+ const afterIso = datetimeLocalToIso(after);
+ if (afterIso) {
+ params.set("after", afterIso);
+ }
+ const beforeIso = datetimeLocalToIso(before);
+ if (beforeIso) {
+ params.set("before", beforeIso);
+ }
+
+ const response = await fetch(
+ `${apiBase}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/audit-log/export?${params.toString()}`,
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
+
+ if (response.status === 401) {
+ router.push("/login");
+ return;
+ }
+
+ if (response.status === 403) {
+ toast.error("Admin access is required to export audit logs.");
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(genericActionError("export audit log"));
+ }
+
+ const data = (await response.json()) as ExportResponse;
+ toast.success(`Export job queued: ${data.job_id}`);
+ } catch {
+ toast.error(genericActionError("export audit log"));
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ if (accessDenied) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {loading ? (
+
Loading…
+ ) : error ? (
+
{error}
+ ) : entries.length === 0 ? (
+
No audit log entries found.
+ ) : (
+
+
+
+ Actor
+ Action
+ IP address
+ Created at
+
+
+
+ {entries.map((entry) => (
+
+
+ {entry.actor_login}
+
+
+ {entry.action}
+
+
+ {maskIpAddress(entry.ip_address)}
+
+
+ {entry.created_at}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+export default function SecurityAuditLogPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/security/page.tsx b/app/admin/security/page.tsx
new file mode 100644
index 00000000..40df4b33
--- /dev/null
+++ b/app/admin/security/page.tsx
@@ -0,0 +1,203 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+
+import { SecuritySummaryCard } from "@/components/admin/SecuritySummaryCard";
+import {
+ SecurityAccessDenied,
+ SecurityPageLayout,
+} from "@/components/admin/SecurityPageLayout";
+import type {
+ AdvisorySeverity,
+ ScanJob,
+ SecurityAdvisory,
+} from "@/lib/api-types";
+import {
+ buildOrgQueryString,
+ getSecurityApiBase,
+} from "@/lib/admin/security";
+import { requireOrgAdminAccess } from "@/lib/admin/security-server";
+
+const SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"];
+
+const SEVERITY_LABELS: Record = {
+ critical: "Critical advisories",
+ high: "High advisories",
+ medium: "Medium advisories",
+ low: "Low advisories",
+};
+
+function countBySeverity(
+ advisories: SecurityAdvisory[],
+): Record {
+ const counts: Record = {
+ critical: 0,
+ high: 0,
+ medium: 0,
+ low: 0,
+ };
+
+ for (const advisory of advisories) {
+ if (advisory.severity in counts) {
+ counts[advisory.severity] += 1;
+ }
+ }
+
+ return counts;
+}
+
+export default async function SecurityDashboardPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ org?: string }>;
+}) {
+ const { org: orgParam } = await searchParams;
+ const access = await requireOrgAdminAccess(orgParam);
+
+ if (access.status === "unauthenticated") {
+ redirect("/login");
+ }
+
+ if (access.status === "access_denied") {
+ return ;
+ }
+
+ if (access.status === "error") {
+ return (
+
+
+
+ );
+ }
+
+ const { token, org } = access;
+ const apiBase = getSecurityApiBase();
+ const orgQuery = buildOrgQueryString(orgParam);
+
+ const advisoriesResponse = await fetch(
+ `${apiBase}/api/v3/orgs/${encodeURIComponent(org)}/security-advisories?per_page=100`,
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ },
+ );
+
+ if (advisoriesResponse.status === 401) {
+ redirect("/login");
+ }
+
+ if (advisoriesResponse.status === 403) {
+ return ;
+ }
+
+ if (!advisoriesResponse.ok) {
+ return (
+
+
+
+ Unable to load security advisories. Please try again.
+
+
+
+ );
+ }
+
+ const advisories =
+ (await advisoriesResponse.json()) as SecurityAdvisory[];
+ const severityCounts = countBySeverity(advisories);
+
+ const scanJobsResponse = await fetch(
+ `${apiBase}/api/v3/orgs/${encodeURIComponent(org)}/scan-jobs?per_page=10`,
+ {
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ },
+ );
+
+ let scanJobs: ScanJob[] = [];
+ if (scanJobsResponse.ok) {
+ scanJobs = (await scanJobsResponse.json()) as ScanJob[];
+ }
+
+ return (
+
+
+
+
+ Advisories
+
+
+ Audit log
+
+
+
+
+
+ {SEVERITIES.map((severity) => (
+
+ ))}
+
+
+
+ Recent scan jobs
+
+ {scanJobs.length === 0 ? (
+
No scan jobs found.
+ ) : (
+
+
+
+ | Job ID |
+ Type |
+ Status |
+ Started |
+ Finished |
+
+
+
+ {scanJobs.map((job) => (
+
+ | {job.id} |
+ {job.type} |
+ {job.status} |
+
+ {job.started_at ?? "—"}
+ |
+
+ {job.finished_at ?? "—"}
+ |
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/components/admin/SecurityPageLayout.tsx b/components/admin/SecurityPageLayout.tsx
new file mode 100644
index 00000000..574ab631
--- /dev/null
+++ b/components/admin/SecurityPageLayout.tsx
@@ -0,0 +1,82 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+type SecurityPageLayoutProps = {
+ title: string;
+ breadcrumbSuffix?: string;
+ backHref?: string;
+ backLabel?: string;
+ children: ReactNode;
+};
+
+export function SecurityPageLayout({
+ title,
+ breadcrumbSuffix,
+ backHref,
+ backLabel,
+ children,
+}: SecurityPageLayoutProps) {
+ return (
+
+
+
+ 🐙
+ OpenHub
+
+ {backHref && backLabel ? (
+
+ {backLabel}
+
+ ) : null}
+
+
+
+
+
+ Dashboard
+ {" "}
+ /{" "}
+ {breadcrumbSuffix ? (
+ <>
+
+ Admin / Security
+ {" "}
+ / {breadcrumbSuffix}
+ >
+ ) : (
+ "Admin / Security"
+ )}
+
+
{title}
+ {children}
+
+
+ );
+}
+
+type SecurityAccessDeniedProps = {
+ title: string;
+ breadcrumbSuffix?: string;
+ message?: string;
+};
+
+export function SecurityAccessDenied({
+ title,
+ breadcrumbSuffix,
+ message = "You do not have permission to view this page. Admin access is required.",
+}: SecurityAccessDeniedProps) {
+ return (
+
+
+
Access Denied
+
{message}
+
+
+ );
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index c0c13223..9d36b384 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -12,6 +12,7 @@ export default tseslint.config(
'coverage/**',
'next-env.d.ts',
'docs/**',
+ 'test/load/**',
],
},
js.configs.recommended,
diff --git a/lib/admin/security-server.ts b/lib/admin/security-server.ts
new file mode 100644
index 00000000..9ec22d81
--- /dev/null
+++ b/lib/admin/security-server.ts
@@ -0,0 +1,54 @@
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+
+import {
+ genericActionError,
+ resolveOrgLogin,
+ verifyOrgAdminRole,
+} from "@/lib/admin/security";
+
+export type OrgAdminAccessResult =
+ | { status: "ok"; token: string; org: string }
+ | { status: "unauthenticated" }
+ | { status: "access_denied" }
+ | { status: "error"; message: string };
+
+export async function getAuthTokenFromCookies(): Promise {
+ const cookieStore = await cookies();
+ return cookieStore.get("authToken")?.value ?? null;
+}
+
+export async function requireAuthToken(): Promise {
+ const token = await getAuthTokenFromCookies();
+ if (!token) {
+ redirect("/login");
+ }
+ return token;
+}
+
+export async function requireOrgAdminAccess(
+ orgParam?: string | null,
+): Promise {
+ const token = await getAuthTokenFromCookies();
+ if (!token) {
+ return { status: "unauthenticated" };
+ }
+
+ const orgResult = await resolveOrgLogin(token, orgParam);
+ if (!orgResult.ok) {
+ return { status: "error", message: orgResult.message };
+ }
+
+ const roleCheck = await verifyOrgAdminRole(token, orgResult.org);
+ if (roleCheck === "unauthorized") {
+ return { status: "unauthenticated" };
+ }
+ if (roleCheck === "forbidden") {
+ return { status: "access_denied" };
+ }
+ if (roleCheck === "error") {
+ return { status: "error", message: genericActionError("verify admin access") };
+ }
+
+ return { status: "ok", token, org: orgResult.org };
+}
diff --git a/lib/admin/security.ts b/lib/admin/security.ts
new file mode 100644
index 00000000..9985ea03
--- /dev/null
+++ b/lib/admin/security.ts
@@ -0,0 +1,225 @@
+import type { AdvisorySeverity } from "@/lib/api-types";
+
+export function getSecurityApiBase(): string {
+ return (
+ process.env.NEXT_PUBLIC_API_BASE_URL ??
+ process.env.NEXT_PUBLIC_API_URL ??
+ ""
+ );
+}
+
+const ORG_LOGIN_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?$/;
+
+export function sanitizeOrgLogin(org: string | null | undefined): string | null {
+ if (!org) {
+ return null;
+ }
+
+ const trimmed = org.trim();
+ if (!ORG_LOGIN_PATTERN.test(trimmed)) {
+ return null;
+ }
+
+ return trimmed;
+}
+
+export function buildOrgQueryString(org: string | null | undefined): string {
+ const safeOrg = sanitizeOrgLogin(org);
+ return safeOrg ? `?org=${encodeURIComponent(safeOrg)}` : "";
+}
+
+export function isAdminOrgRole(role: string): boolean {
+ const normalized = role.toLowerCase();
+ return normalized === "admin" || normalized === "owner";
+}
+
+export function sanitizeAuditSearchPhrase(phrase: string): string {
+ return phrase
+ .trim()
+ // eslint-disable-next-line no-control-regex -- intentionally strip ASCII control characters
+ .replace(/[\u0000-\u001f\u007f]/g, "")
+ .slice(0, 256);
+}
+
+export function datetimeLocalToIso(value: string): string {
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
+ if (!match) {
+ return "";
+ }
+
+ const [, year, month, day, hour, minute] = match;
+ const date = new Date(
+ Number(year),
+ Number(month) - 1,
+ Number(day),
+ Number(hour),
+ Number(minute),
+ );
+
+ if (Number.isNaN(date.getTime())) {
+ return "";
+ }
+
+ return date.toISOString();
+}
+
+export function maskIpAddress(ip: string | null): string {
+ if (!ip) {
+ return "—";
+ }
+
+ const ipv4Match = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+ if (ipv4Match) {
+ return `${ipv4Match[1]}.${ipv4Match[2]}.*.*`;
+ }
+
+ if (ip.includes(":")) {
+ const [firstSegment] = ip.split(":");
+ return firstSegment ? `${firstSegment}:****` : "—";
+ }
+
+ return "—";
+}
+
+export function genericActionError(action: string): string {
+ return `Unable to ${action}. Please try again.`;
+}
+
+const severityBadgeClass: Record = {
+ critical: "border-transparent bg-[#cf222e] text-white",
+ high: "border-transparent bg-[#ffebe9] text-[#cf222e]",
+ medium: "border-transparent bg-[#fff8c5] text-[#9a6700]",
+ low: "border-transparent bg-[#eaeef2] text-[#656d76]",
+};
+
+export function getSeverityBadgeClass(
+ severity: string,
+): string {
+ if (severity in severityBadgeClass) {
+ return severityBadgeClass[severity as AdvisorySeverity];
+ }
+
+ return severityBadgeClass.low;
+}
+
+async function authFetch(
+ token: string,
+ path: string,
+ init?: RequestInit,
+): Promise {
+ return fetch(`${getSecurityApiBase()}${path}`, {
+ ...init,
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ ...(init?.headers ?? {}),
+ },
+ cache: "no-store",
+ });
+}
+
+export async function resolveOrgLogin(
+ token: string,
+ orgParam?: string | null,
+): Promise<{ ok: true; org: string } | { ok: false; message: string }> {
+ const sanitized = sanitizeOrgLogin(orgParam);
+ if (sanitized) {
+ return { ok: true, org: sanitized };
+ }
+
+ try {
+ const response = await authFetch(token, "/api/v3/user/orgs");
+ if (response.status === 401) {
+ return { ok: false, message: "Authentication required" };
+ }
+ if (!response.ok) {
+ return {
+ ok: false,
+ message: `Failed to load organizations (${response.status})`,
+ };
+ }
+
+ const orgs = (await response.json()) as { login: string }[];
+ if (orgs.length === 0) {
+ return { ok: false, message: "No organization found" };
+ }
+
+ return { ok: true, org: orgs[0].login };
+ } catch {
+ return { ok: false, message: "Failed to load organizations" };
+ }
+}
+
+export async function verifyOrgAdminRole(
+ token: string,
+ org: string,
+): Promise<"admin" | "forbidden" | "unauthorized" | "error"> {
+ try {
+ const [userResponse, membersResponse] = await Promise.all([
+ authFetch(token, "/api/v3/user"),
+ authFetch(token, `/api/v3/orgs/${encodeURIComponent(org)}/members`),
+ ]);
+
+ if (userResponse.status === 401 || membersResponse.status === 401) {
+ return "unauthorized";
+ }
+
+ if (membersResponse.status === 403 || membersResponse.status === 404) {
+ return "forbidden";
+ }
+
+ if (!userResponse.ok || !membersResponse.ok) {
+ return "error";
+ }
+
+ const user = (await userResponse.json()) as { login: string };
+ const members = (await membersResponse.json()) as {
+ login: string;
+ role: string;
+ }[];
+ const membership = members.find((member) => member.login === user.login);
+
+ if (!membership || !isAdminOrgRole(membership.role)) {
+ return "forbidden";
+ }
+
+ return "admin";
+ } catch {
+ return "error";
+ }
+}
+
+export async function checkClientOrgAdminAccess(options: {
+ token: string | null;
+ orgParam: string | null;
+ onUnauthenticated: () => void;
+}): Promise<
+ | { status: "ok"; org: string }
+ | { status: "unauthenticated" }
+ | { status: "access_denied" }
+ | { status: "error"; message: string }
+> {
+ if (!options.token) {
+ options.onUnauthenticated();
+ return { status: "unauthenticated" };
+ }
+
+ const orgResult = await resolveOrgLogin(options.token, options.orgParam);
+ if (!orgResult.ok) {
+ return { status: "error", message: orgResult.message };
+ }
+
+ const roleCheck = await verifyOrgAdminRole(options.token, orgResult.org);
+ if (roleCheck === "unauthorized") {
+ options.onUnauthenticated();
+ return { status: "unauthenticated" };
+ }
+ if (roleCheck === "forbidden") {
+ return { status: "access_denied" };
+ }
+ if (roleCheck === "error") {
+ return { status: "error", message: genericActionError("verify admin access") };
+ }
+
+ return { status: "ok", org: orgResult.org };
+}