From 36fc3681965c2a18ae42f3eb451542034dfa271e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 16:16:49 +0000 Subject: [PATCH] Milestone 18g: STIG Library (benchmark list + detail + rule/CCI lookup + XCCDF import) Co-Authored-By: Bryce Anglin --- e2e/tests/web.spec.ts | 52 +++++ web/src/app/pages/library/cci-lookup.tsx | 116 +++++++++++ web/src/app/pages/library/detail.tsx | 134 ++++++++++++ web/src/app/pages/library/import-dialog.tsx | 144 +++++++++++++ web/src/app/pages/library/list.tsx | 219 ++++++++++++++++++++ web/src/app/pages/library/rule-lookup.tsx | 124 +++++++++++ web/src/app/router.tsx | 17 +- web/src/lib/api/hooks.ts | 91 ++++++++ web/src/lib/api/index.ts | 169 +++++++++++++++ 9 files changed, 1060 insertions(+), 6 deletions(-) create mode 100644 web/src/app/pages/library/cci-lookup.tsx create mode 100644 web/src/app/pages/library/detail.tsx create mode 100644 web/src/app/pages/library/import-dialog.tsx create mode 100644 web/src/app/pages/library/list.tsx create mode 100644 web/src/app/pages/library/rule-lookup.tsx diff --git a/e2e/tests/web.spec.ts b/e2e/tests/web.spec.ts index 15d18e4..287b468 100644 --- a/e2e/tests/web.spec.ts +++ b/e2e/tests/web.spec.ts @@ -372,4 +372,56 @@ test.describe('Web SPA', () => { page.getByTestId('grants-table').getByText('Owner'), ).toBeVisible({ timeout: 10_000 }) }) + + test('library page renders with lookup cards (M18g)', async ({ page }) => { + await page.goto(`${urls.web}/library`) + await expect(page.getByTestId('library-list-page')).toBeVisible() + await expect(page.getByTestId('library-table')).toBeVisible() + await expect(page.getByTestId('library-search')).toBeVisible() + await expect(page.getByTestId('rule-lookup-card')).toBeVisible() + await expect(page.getByTestId('cci-lookup-card')).toBeVisible() + // Admin has stig-manager:stig so the Import button must render. + await expect(page.getByTestId('import-benchmark-button')).toBeVisible() + }) + + test('library rule lookup surfaces 404 for unknown rule (M18g)', async ({ + page, + }) => { + await page.goto(`${urls.web}/library`) + await expect(page.getByTestId('rule-lookup-card')).toBeVisible() + await page + .getByTestId('rule-lookup-input') + .fill('SV-99999999r1_rule') + await page.getByTestId('rule-lookup-submit').click() + // Either a result or an error must render — the API returns 404 + // for unknown rules, which our hook surfaces as an error. + await expect(page.getByTestId('rule-lookup-error')).toBeVisible({ + timeout: 10_000, + }) + }) + + test('library cci lookup surfaces 404 for unknown cci (M18g)', async ({ + page, + }) => { + await page.goto(`${urls.web}/library`) + await expect(page.getByTestId('cci-lookup-card')).toBeVisible() + await page.getByTestId('cci-lookup-input').fill('999999') + await page.getByTestId('cci-lookup-submit').click() + await expect(page.getByTestId('cci-lookup-error')).toBeVisible({ + timeout: 10_000, + }) + }) + + test('library import dialog opens and validates file input (M18g)', async ({ + page, + }) => { + await page.goto(`${urls.web}/library`) + await page.getByTestId('import-benchmark-button').click() + const dialog = page.getByTestId('import-stig-dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByTestId('import-file-input')).toBeVisible() + await expect(dialog.getByTestId('import-clobber-checkbox')).toBeVisible() + // Submit is disabled until a file is chosen. + await expect(dialog.getByTestId('import-stig-submit')).toBeDisabled() + }) }) diff --git a/web/src/app/pages/library/cci-lookup.tsx b/web/src/app/pages/library/cci-lookup.tsx new file mode 100644 index 0000000..655d20a --- /dev/null +++ b/web/src/app/pages/library/cci-lookup.tsx @@ -0,0 +1,116 @@ +// CCI lookup card on the Library landing page. +// +// Hits GET /stigs/ccis/{cci}. The API accepts either "CCI-000366" or +// the six-digit form ("000366") and normalises both, so the user can +// paste whichever form they have in front of them. + +import { Search } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { useCci } from '@/lib/api/hooks' + +function normaliseInput(raw: string): string { + const s = raw.trim() + if (!s) return '' + // Strip a "CCI-" prefix; the OpenAPI path param matches /^\d{6}$/. + return s.replace(/^cci-/i, '') +} + +export function CciLookup() { + const [input, setInput] = React.useState('') + const [target, setTarget] = React.useState(undefined) + const q = useCci(target) + + function onSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmed = normaliseInput(input) + setTarget(trimmed || undefined) + } + + return ( + + + CCI lookup + + Look up a CCI by its six-digit ID (e.g. 000366 or CCI-000366). + + + +
+ setInput(e.target.value)} + placeholder="000366 or CCI-000366" + data-testid="cci-lookup-input" + className="font-mono text-xs" + /> + +
+ + {target && q.isLoading && ( +

Loading…

+ )} + {target && q.isError && ( +

+ {q.error instanceof Error ? q.error.message : 'Lookup failed.'} +

+ )} + {target && q.data && ( +
+
+ {q.data.cci} + {q.data.status && ( + + {q.data.status} + + )} + {q.data.type && ( + + {q.data.type} + + )} +
+ {q.data.definition && ( +

{q.data.definition}

+ )} + {q.data.publishdate && ( +

+ Published: {new Date(q.data.publishdate).toLocaleDateString()} +

+ )} + {q.data.stigs && q.data.stigs.length > 0 && ( +
+ + Referenced by {q.data.stigs.length} STIG revision{q.data.stigs.length === 1 ? '' : 's'} + +
    + {q.data.stigs.map((s, idx) => ( +
  • + {s.benchmarkId} · {s.revisionStr} +
  • + ))} +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/web/src/app/pages/library/detail.tsx b/web/src/app/pages/library/detail.tsx new file mode 100644 index 0000000..5aeb4e1 --- /dev/null +++ b/web/src/app/pages/library/detail.tsx @@ -0,0 +1,134 @@ +// /library/:benchmarkId — STIG detail page. +// +// Shows the metadata projection for a single benchmark plus its +// revision list. Per-revision rule/group browsers (which require the +// not-yet-implemented /stigs/{benchmarkId}/revisions/{revisionStr}/rules +// endpoint) are intentionally not surfaced here. + +import { ArrowLeft, Loader2 } from 'lucide-react' +import { Link, useParams } from 'react-router-dom' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { useSTIG } from '@/lib/api/hooks' + +function formatDate(iso: string | null | undefined): string { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.valueOf())) return '—' + return d.toLocaleDateString() +} + +export function LibraryDetailPage() { + const params = useParams<{ benchmarkId: string }>() + const benchmarkId = params.benchmarkId ?? '' + const q = useSTIG(benchmarkId) + + return ( +
+
+
+ + Back to Library + +

+ {benchmarkId} +

+
+
+ + {q.isLoading && ( +
+ Loading benchmark… +
+ )} + {q.isError && ( + + +

+ {q.error instanceof Error + ? q.error.message + : 'Failed to load benchmark.'} +

+ +
+
+ )} + + {q.data && ( +
+ + + {q.data.title} + Benchmark metadata. + + +
+
Benchmark ID
+
{q.data.benchmarkId}
+
Latest revision
+
{q.data.lastRevisionStr ?? '—'}
+
Revision date
+
{formatDate(q.data.lastRevisionDate)}
+
Rule count
+
{q.data.ruleCount ?? '—'}
+ {q.data.marking && ( + <> +
Marking
+
{q.data.marking}
+ + )} + {q.data.status && ( + <> +
Status
+
{q.data.status}
+ + )} +
+
+
+ + + + Revisions + + {q.data.revisionStrs && q.data.revisionStrs.length > 0 + ? `${q.data.revisionStrs.length} revision${q.data.revisionStrs.length === 1 ? '' : 's'} imported.` + : 'No revisions imported yet.'} + + + + {q.data.revisionStrs && q.data.revisionStrs.length > 0 ? ( +
    + {q.data.revisionStrs.map((r) => ( +
  • + {r} +
  • + ))} +
+ ) : ( +

+ Revisions appear here once an XCCDF bundle is imported. +

+ )} +
+
+
+ )} +
+ ) +} diff --git a/web/src/app/pages/library/import-dialog.tsx b/web/src/app/pages/library/import-dialog.tsx new file mode 100644 index 0000000..0465d96 --- /dev/null +++ b/web/src/app/pages/library/import-dialog.tsx @@ -0,0 +1,144 @@ +// Import a STIG XCCDF Benchmark via multipart upload. + +import { Loader2 } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { + useImportBenchmark, + type ImportBenchmarkResult, +} from '@/lib/api/hooks' + +interface ImportStigDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ImportStigDialog({ + open, + onOpenChange, +}: ImportStigDialogProps) { + const importer = useImportBenchmark() + const [file, setFile] = React.useState(null) + const [clobber, setClobber] = React.useState(false) + const [error, setError] = React.useState(null) + const [result, setResult] = React.useState(null) + + React.useEffect(() => { + if (open) { + setFile(null) + setClobber(false) + setError(null) + setResult(null) + importer.reset() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const busy = importer.isPending + + async function onSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setResult(null) + if (!file) { + setError('Choose an XCCDF file to import.') + return + } + try { + const r = await importer.mutateAsync({ file, clobber }) + setResult(r) + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed.') + } + } + + return ( + + + + Import XCCDF Benchmark + + Upload a DISA XCCDF Benchmark file. Existing + (benchmark, revision) pairs are rejected unless you enable + clobber. + + +
+
+ + setFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-[var(--color-foreground)] file:mr-3 file:rounded-md file:border file:border-[var(--color-border)] file:bg-[var(--color-card)] file:px-2 file:py-1 file:text-sm file:font-medium hover:file:bg-[var(--color-accent)]/40" + data-testid="import-file-input" + /> + {file && ( +

+ {file.name} · {(file.size / 1024).toFixed(1)} KiB +

+ )} +
+ + {error && ( +

+ {error} +

+ )} + {result && ( +
+

Import succeeded

+

+ {result.benchmarkId} · revision {result.revisionStr} + {result.action ? ` · ${result.action}` : ''} +

+
+ )} + + + + + + +
+
+
+ ) +} diff --git a/web/src/app/pages/library/list.tsx b/web/src/app/pages/library/list.tsx new file mode 100644 index 0000000..0577c74 --- /dev/null +++ b/web/src/app/pages/library/list.tsx @@ -0,0 +1,219 @@ +// /library — STIG Library landing page. +// +// Lists every benchmark imported into the API with a substring title +// filter. Each row links to the per-STIG detail page; the right-rail +// renders a Rule lookup and a CCI lookup that hit the per-id endpoints +// directly. The `stig-manager:stig` write scope unlocks the XCCDF +// Import button. + +import { Loader2, Search, Trash2, Upload } from 'lucide-react' +import * as React from 'react' +import { Link } from 'react-router-dom' + +import { ImportStigDialog } from './import-dialog' +import { CciLookup } from './cci-lookup' +import { RuleLookup } from './rule-lookup' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { + useDeleteSTIG, + useSTIGs, + type STIGSummary, +} from '@/lib/api/hooks' +import { useAuth } from '@/lib/auth/auth-context' +import { hasScope } from '@/lib/auth/scopes' + +function formatDate(iso: string | null | undefined): string { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.valueOf())) return '—' + return d.toLocaleDateString() +} + +export function LibraryListPage() { + const { status } = useAuth() + const user = status.kind === 'signed-in' ? status.user : null + const canWrite = hasScope(user, 'stig-manager:stig') + + const [query, setQuery] = React.useState('') + const [debounced, setDebounced] = React.useState('') + React.useEffect(() => { + const id = window.setTimeout(() => setDebounced(query), 200) + return () => window.clearTimeout(id) + }, [query]) + + const stigs = useSTIGs(debounced ? { title: debounced } : undefined) + + const [importOpen, setImportOpen] = React.useState(false) + const del = useDeleteSTIG() + + async function onDelete(s: STIGSummary) { + const ok = window.confirm( + `Delete benchmark "${s.benchmarkId}" and all of its revisions? This cannot be undone.`, + ) + if (!ok) return + try { + await del.mutateAsync(s.benchmarkId) + } catch (err) { + window.alert(err instanceof Error ? err.message : 'Delete failed.') + } + } + + return ( +
+
+
+

+ STIG Library +

+

+ Browse imported benchmarks, rules, and CCIs. Import new XCCDF + bundles when authorised. +

+
+ {canWrite && ( + + )} +
+ +
+ + + Benchmarks + + {stigs.data ? `${stigs.data.length} benchmark${stigs.data.length === 1 ? '' : 's'}` : 'Loading…'} + + + +
+
+ + setQuery(e.target.value)} + data-testid="library-search" + /> +
+ {stigs.isFetching && !stigs.isLoading && ( + + )} +
+ +
+ + + + + + + + + {canWrite && + + + {stigs.isLoading && ( + + + + )} + {stigs.isError && ( + + + + )} + {stigs.data && stigs.data.length === 0 && ( + + + + )} + {stigs.data?.map((s) => ( + + + + + + + {canWrite && ( + + )} + + ))} + +
BenchmarkTitleRevisionDateRules} +
+ Loading benchmarks… +
+ {stigs.error instanceof Error + ? stigs.error.message + : 'Failed to load benchmarks.'} +
+ {debounced + ? 'No benchmarks match that title.' + : 'No benchmarks yet. Import an XCCDF bundle to populate the library.'} +
+ + {s.benchmarkId} + + {s.title} + {s.lastRevisionStr ?? '—'} + + {formatDate(s.lastRevisionDate)} + + {s.ruleCount ?? '—'} + + +
+
+
+
+ +
+ + +
+
+ + {canWrite && ( + + )} +
+ ) +} diff --git a/web/src/app/pages/library/rule-lookup.tsx b/web/src/app/pages/library/rule-lookup.tsx new file mode 100644 index 0000000..00448ec --- /dev/null +++ b/web/src/app/pages/library/rule-lookup.tsx @@ -0,0 +1,124 @@ +// Rule lookup card on the Library landing page. +// +// Hits GET /stigs/rules/{ruleId} and renders the projection inline. +// The user-facing flow is "paste the SV-… or V-… ID and see its +// definition / check / fix / CCIs without leaving the Library". + +import { Search } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { useRuleByRuleId } from '@/lib/api/hooks' + +export function RuleLookup() { + const [input, setInput] = React.useState('') + const [target, setTarget] = React.useState(undefined) + const q = useRuleByRuleId(target) + + function onSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmed = input.trim() + setTarget(trimmed || undefined) + } + + return ( + + + Rule lookup + + Look up a Rule by its ID (e.g. SV-1234r1_rule). + + + +
+ setInput(e.target.value)} + placeholder="SV-… or V-…" + data-testid="rule-lookup-input" + className="font-mono text-xs" + /> + +
+ + {target && q.isLoading && ( +

Loading…

+ )} + {target && q.isError && ( +

+ {q.error instanceof Error ? q.error.message : 'Lookup failed.'} +

+ )} + {target && q.data && ( +
+
+ {q.data.ruleId} + {q.data.severity && ( + + {q.data.severity} + + )} +
+

{q.data.title}

+ {q.data.version && ( +

+ Version: {q.data.version} +

+ )} + {q.data.detail?.vulnDiscussion && ( +
+ + Vulnerability Discussion + +
+                  {q.data.detail.vulnDiscussion}
+                
+
+ )} + {q.data.check?.content && ( +
+ + Check + +
+                  {q.data.check.content}
+                
+
+ )} + {q.data.fix?.text && ( +
+ + Fix + +
+                  {q.data.fix.text}
+                
+
+ )} + {q.data.ccis && q.data.ccis.length > 0 && ( +
+ CCIs:{' '} + {q.data.ccis.map((c) => c.cci).filter(Boolean).join(', ')} +
+ )} +
+ )} +
+
+ ) +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ffdc163..ac1d7a6 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -13,8 +13,9 @@ import { ReviewEditorPage } from './pages/assets/review-editor' import { CollectionDetailPage } from './pages/collections/detail' import { CollectionsListPage } from './pages/collections/list' import { DashboardPage } from './pages/dashboard' +import { LibraryDetailPage } from './pages/library/detail' +import { LibraryListPage } from './pages/library/list' import { NotFoundPage } from './pages/not-found' -import { Placeholder } from './pages/placeholder' import { SignInPage } from './pages/sign-in' export const router = createBrowserRouter([ @@ -67,11 +68,15 @@ export const router = createBrowserRouter([ path: 'library', element: ( - + + + ), + }, + { + path: 'library/:benchmarkId', + element: ( + + ), }, diff --git a/web/src/lib/api/hooks.ts b/web/src/lib/api/hooks.ts index a0f904c..889f74c 100644 --- a/web/src/lib/api/hooks.ts +++ b/web/src/lib/api/hooks.ts @@ -21,6 +21,7 @@ import { deleteCollectionGrant, deleteJob, deleteReviewHistory, + deleteSTIG, deleteUser, deleteUserGroup, fetchAppDataTables, @@ -29,6 +30,7 @@ import { fetchAsset, fetchAssets, fetchAssetStigs, + fetchCci, fetchCollection, fetchCollectionGrants, fetchCollections, @@ -46,11 +48,15 @@ import { fetchReviewHistory, fetchReviewHistoryStats, fetchReviewsByAsset, + fetchRuleByRuleId, fetchRulesByRevision, + fetchSTIG, + fetchSTIGs, fetchUser, fetchUserGroup, fetchUserGroups, fetchUsers, + importBenchmark, postCollectionGrants, postReviewBatch, putCollectionGrant, @@ -67,11 +73,14 @@ import { type AssetStig, type AssetUpdateInput, type CollectionGrant, + type CciDetail, type CollectionStig, type CollectionSummary, type CreateCollectionInput, type CurrentUser, type DeleteReviewHistoryInput, + type ImportBenchmarkInput, + type ImportBenchmarkResult, type GrantPostInput, type Job, type JobCreateInput, @@ -92,6 +101,10 @@ import { type ReviewResult, type ReviewStatusLabel, type Rule, + type RuleDetail, + type STIGDetail, + type STIGSummary, + type STIGsFilter, type UserCreateInput, type UserGroupCreateInput, type UserGroupPatchInput, @@ -110,9 +123,12 @@ export type { AssetForm, AssetStig, AssetUpdateInput, + CciDetail, CollectionGrant, CollectionStig, DeleteReviewHistoryInput, + ImportBenchmarkInput, + ImportBenchmarkResult, GrantPostInput, Job, JobCreateInput, @@ -133,6 +149,10 @@ export type { ReviewResult, ReviewStatusLabel, Rule, + RuleDetail, + STIGDetail, + STIGSummary, + STIGsFilter, UserCreateInput, UserGroupCreateInput, UserGroupPatchInput, @@ -183,6 +203,10 @@ export const QUERY_KEYS = { appInfoDetail: ['op', 'appinfo', 'detail'] as const, appDataTables: ['op', 'appdata', 'tables'] as const, collectionGrants: (cid: string) => ['collection', cid, 'grants'] as const, + stigs: (filter?: STIGsFilter) => ['stigs', filter ?? {}] as const, + stig: (benchmarkId: string) => ['stig', benchmarkId] as const, + rule: (ruleId: string) => ['rule', 'lookup', ruleId] as const, + cci: (cci: string) => ['cci', cci] as const, } as const export function useAppInfo(): UseQueryResult { @@ -833,3 +857,70 @@ export function useDeleteCollectionGrant(): UseMutationResult< }, }) } + +// ---- STIG Library (M18g) ---------------------------------------------------- + +export function useSTIGs( + filter?: STIGsFilter, +): UseQueryResult { + return useQuery({ + queryKey: QUERY_KEYS.stigs(filter), + queryFn: () => fetchSTIGs(filter), + }) +} + +export function useSTIG( + benchmarkId: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: benchmarkId ? QUERY_KEYS.stig(benchmarkId) : ['stig', 'noop'], + queryFn: () => fetchSTIG(benchmarkId as string), + enabled: Boolean(benchmarkId), + }) +} + +export function useRuleByRuleId( + ruleId: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: ruleId ? QUERY_KEYS.rule(ruleId) : ['rule', 'lookup', 'noop'], + queryFn: () => fetchRuleByRuleId(ruleId as string), + enabled: Boolean(ruleId), + retry: false, + }) +} + +export function useCci( + cci: string | undefined, +): UseQueryResult { + return useQuery({ + queryKey: cci ? QUERY_KEYS.cci(cci) : ['cci', 'noop'], + queryFn: () => fetchCci(cci as string), + enabled: Boolean(cci), + retry: false, + }) +} + +export function useImportBenchmark(): UseMutationResult< + ImportBenchmarkResult, + Error, + ImportBenchmarkInput +> { + const qc = useQueryClient() + return useMutation({ + mutationFn: importBenchmark, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['stigs'] }) + }, + }) +} + +export function useDeleteSTIG(): UseMutationResult { + const qc = useQueryClient() + return useMutation({ + mutationFn: deleteSTIG, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['stigs'] }) + }, + }) +} diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 3fe57bf..6fcbe25 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -1424,3 +1424,172 @@ export async function deleteCollectionGrant( } return result.data as unknown as CollectionGrant } + +// ---- STIG Library (M18g) ---------------------------------------------------- + +/** Summary projection for the STIG Library list. */ +export type STIGSummary = { + benchmarkId: string + title: string + lastRevisionStr?: string | null + lastRevisionDate?: string | null + marking?: string | null + status?: string | null + ruleCount?: number | null + revisionStrs?: string[] + collectionIds?: string[] +} + +export type STIGDetail = STIGSummary + +export type STIGsFilter = { + title?: string +} + +export async function fetchSTIGs( + filter?: STIGsFilter, +): Promise { + const query: Record = { + projection: 'revisions', + } + if (filter?.title && filter.title.trim().length > 0) { + query.title = filter.title.trim() + } + const result = await apiClient.GET('/stigs', { + params: { query: query as never }, + }) + if (!result.response.ok) { + throw new Error(`stigs: HTTP ${result.response.status}`) + } + return (result.data as unknown as STIGSummary[] | undefined) ?? [] +} + +export async function fetchSTIG(benchmarkId: string): Promise { + const result = await apiClient.GET('/stigs/{benchmarkId}', { + params: { path: { benchmarkId } }, + }) + if (!result.response.ok || !result.data) { + throw new Error(`stig: HTTP ${result.response.status}`) + } + return result.data as unknown as STIGDetail +} + +/** Detail row for /stigs/rules/{ruleId}. */ +export type RuleDetail = { + ruleId?: string + version?: string + title?: string + severity?: string + groupId?: string + groupTitle?: string + check?: { content?: string | null; system?: string | null } + fix?: { text?: string | null; fixref?: string | null } + ccis?: Array<{ cci?: string }> + detail?: { + vulnDiscussion?: string | null + documentable?: string | null + falseNegatives?: string | null + falsePositives?: string | null + mitigationControl?: string | null + mitigations?: string | null + potentialImpacts?: string | null + responsibility?: string | null + severityOverrideGuidance?: string | null + thirdPartyTools?: string | null + weight?: string | null + } + stigs?: Array<{ benchmarkId?: string; revisionStr?: string }> +} + +export async function fetchRuleByRuleId(ruleId: string): Promise { + const result = await apiClient.GET('/stigs/rules/{ruleId}', { + params: { path: { ruleId } }, + }) + if (!result.response.ok || !result.data) { + throw new Error(`rule: HTTP ${result.response.status}`) + } + return result.data as unknown as RuleDetail +} + +/** Detail row for /stigs/ccis/{cci}. */ +export type CciDetail = { + cci?: string + definition?: string + type?: string + status?: string + publishdate?: string + stigs?: Array<{ benchmarkId?: string; revisionStr?: string }> +} + +export async function fetchCci(cci: string): Promise { + const result = await apiClient.GET('/stigs/ccis/{cci}', { + params: { path: { cci } }, + }) + if (!result.response.ok || !result.data) { + throw new Error(`cci: HTTP ${result.response.status}`) + } + return result.data as unknown as CciDetail +} + +/** Response body shape for POST /stigs. */ +export type ImportBenchmarkResult = { + action?: string + benchmarkId?: string + revisionStr?: string + marking?: string +} + +export type ImportBenchmarkInput = { + file: File + clobber?: boolean +} + +/** + * Import a STIG XCCDF Benchmark. The server expects a multipart upload + * with a single `importFile` part — we do not use the openapi-fetch + * client here because multipart wiring through the typed client adds + * more friction than value for one endpoint. + */ +export async function importBenchmark( + input: ImportBenchmarkInput, +): Promise { + const url = new URL(`${API_BASE}/stigs`, window.location.origin) + if (input.clobber) url.searchParams.set('clobber', 'true') + url.searchParams.set('elevate', 'true') + + const form = new FormData() + form.append('importFile', input.file) + + const headers: Record = {} + const token = getAccessTokenForClient() + if (token) headers['Authorization'] = `Bearer ${token}` + + const resp = await fetch(url.toString(), { + method: 'POST', + headers, + body: form, + credentials: 'include', + }) + if (!resp.ok) { + let detail = '' + try { + detail = (await resp.text()).slice(0, 512) + } catch { + // ignore body-read failure; we still have status + } + throw new Error( + `import benchmark: HTTP ${resp.status}${detail ? ` — ${detail}` : ''}`, + ) + } + return (await resp.json()) as ImportBenchmarkResult +} + +export async function deleteSTIG(benchmarkId: string): Promise { + const query: Record = { elevate: 'true' } + const result = await apiClient.DELETE('/stigs/{benchmarkId}', { + params: { path: { benchmarkId }, query: query as never }, + }) + if (!result.response.ok) { + throw new Error(`delete stig: HTTP ${result.response.status}`) + } +}