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 ( + +
{ + event.preventDefault(); + void loadAdvisories(); + }} + > +
+ + +
+
+ + +
+
+ +
+
+ +
+ {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} + + + + + + ))} + + + )} +
+ + + {selectedAdvisory ? ( + <> +

Update advisory status

+

+ {selectedAdvisory.ghsa_id} — {selectedAdvisory.summary} +

+ { + if (!submitting) { + void handleStatusUpdate(state, reason); + } + }} + /> +
+ +
+ + ) : null} +
+
+ ); +} + +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 ( + +
{ + event.preventDefault(); + void loadAuditLog(); + }} + > +
+ + setPhrase(event.target.value)} + placeholder="Search actions, actors, targets…" + /> +
+
+ + +
+
+ + setAfter(event.target.value)} + /> +
+
+ + setBefore(event.target.value)} + /> +
+
+ + +
+
+ +
+ {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 ( + +
+

{access.message}

+
+
+ ); + } + + 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.

+ ) : ( + + + + + + + + + + + + {scanJobs.map((job) => ( + + + + + + + + ))} + +
Job IDTypeStatusStartedFinished
{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 }; +}