"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { apiBaseUrl, GENERIC_SERVER_ERROR_MESSAGE, getAuthHeaders, getErrorMessage, } from "@/lib"; import { COMPLIANCE_REPORT_DISPLAY_NAMES, type ComplianceReportType, } from "@/lib/compliance/compliance-report-types"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { SCAN_STATES } from "@/types/attack-paths"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; interface OrganizationScanResource { id: string; type: string; } interface OrganizationScansSuccessResponse { data: OrganizationScanResource[]; } interface OrganizationScansErrorResponse { error: unknown; status?: number; } type OrganizationScansResponse = | OrganizationScansSuccessResponse | OrganizationScansErrorResponse; const isOrganizationScanResource = ( value: unknown, ): value is OrganizationScanResource => typeof value === "object" && value !== null && "id" in value && typeof value.id === "string" && "type" in value && value.type === "scans"; export const getScans = async ({ page = 1, query = "", sort = "", filters = {}, pageSize = 10, fields = {}, include = "", }) => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/scans"); const url = new URL(`${apiBaseUrl}/scans`); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); if (include) url.searchParams.append("include", include); // Handle fields parameters Object.entries(fields).forEach(([key, value]) => { url.searchParams.append(`fields[${key}]`, String(value)); }); appendSanitizedProviderTypeFilters(url, filters); try { const response = await fetch(url.toString(), { headers }); return handleApiResponse(response); } catch (error) { console.error("Error fetching scans:", error); return undefined; } }; export const getScansByState = async () => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans`); // Request only the necessary fields to optimize the response url.searchParams.append("fields[scans]", "state"); // Only need to know whether at least one completed scan exists; filter server-side // and cap to a single row so the answer is correct regardless of total scan count. url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED); url.searchParams.append("page[size]", "1"); try { const response = await fetch(url.toString(), { headers, }); return handleApiResponse(response); } catch (error) { console.error("Error fetching scans by state:", error); return undefined; } }; export const getScan = async ( scanId: string, { include }: { include?: string } = {}, ) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}`); if (include) { url.searchParams.set("include", include); } try { const response = await fetch(url.toString(), { headers, }); return handleApiResponse(response); } catch (error) { return handleApiError(error); } }; export const scanOnDemand = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); const providerId = formData.get("providerId"); const scanName = formData.get("scanName") || undefined; if (!providerId) { return { error: "Provider ID is required" }; } addScanOperation("create", undefined, { provider_id: String(providerId), scan_name: scanName ? String(scanName) : undefined, }); const url = new URL(`${apiBaseUrl}/scans`); try { const requestBody = { data: { type: "scans", attributes: scanName ? { name: scanName } : {}, relationships: { provider: { data: { type: "providers", id: providerId, }, }, }, }, }; const response = await fetch(url.toString(), { method: "POST", headers: headers, body: JSON.stringify(requestBody), }); const result = await handleApiResponse(response, "/scans"); if (result?.data?.id) { addScanOperation("start", result.data.id); revalidatePath("/scans"); } return result; } catch (error) { addScanOperation("create"); return handleApiError(error); } }; export const scheduleDaily = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); const providerId = formData.get("providerId"); const url = new URL(`${apiBaseUrl}/schedules/daily`); const body = { data: { type: "daily-schedules", attributes: { provider_id: providerId, }, }, }; try { const response = await fetch(url.toString(), { method: "POST", headers, body: JSON.stringify(body), }); return handleApiResponse(response, "/scans"); } catch (error) { return handleApiError(error); } }; export const launchOrganizationScans = async ( organizationId: string, ): Promise => { if (!organizationId) { return { error: "Organization ID is required" }; } const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/scans/bulk`); try { const response = await fetch(url.toString(), { method: "POST", headers, body: JSON.stringify({ data: { type: "scans-bulk", relationships: { organization: { data: { type: "organizations", id: organizationId, }, }, }, }, }), }); const result = await handleApiResponse(response, "/scans"); if (result?.error !== undefined) { return { error: result.error, status: result.status }; } const scans: unknown = result?.data; if (!Array.isArray(scans) || !scans.every(isOrganizationScanResource)) { return { error: "The bulk scan response did not contain a scan collection.", }; } addScanOperation("start", undefined, { organization_id: organizationId, bulk: true, scan_count: scans.length, scan_ids: scans.map((scan) => scan.id).join(","), }); return { data: scans }; } catch (error) { return handleApiError(error); } }; export const scheduleOrganizationDailyScans = async (providerIds: string[]) => { const validProviderIds = providerIds.filter(Boolean); if (validProviderIds.length === 0) { return { successCount: 0, failureCount: 0, totalCount: 0, }; } const launchResults = await runWithConcurrencyLimit( validProviderIds, ORGANIZATION_SCAN_CONCURRENCY_LIMIT, async (providerId) => { try { const formData = new FormData(); formData.set("providerId", providerId); const result = await scheduleDaily(formData); return { providerId, ok: !result?.error, error: result?.error ? String(result.error) : null, }; } catch (error) { return { providerId, ok: false, error: error instanceof Error ? error.message : "Failed to launch scan.", }; } }, ); const summary = launchResults.reduce( (acc, item) => { if (item.ok) { acc.successCount += 1; return acc; } acc.failureCount += 1; acc.errors.push({ providerId: item.providerId, error: item.error || "Failed to launch scan.", }); return acc; }, { successCount: 0, failureCount: 0, totalCount: validProviderIds.length, errors: [] as Array<{ providerId: string; error: string }>, }, ); return summary; }; async function getScanReportErrorMessage( response: Response, fallbackMessage: string, ): Promise { const contentType = response.headers.get("content-type")?.toLowerCase() || ""; if (contentType.includes("text/html")) { return GENERIC_SERVER_ERROR_MESSAGE; } const errorData = await response.json().catch(() => null); return ( errorData?.errors?.[0]?.detail || errorData?.errors?.detail || errorData?.error || errorData?.message || (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) ); } export const updateScan = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); const scanId = formData.get("scanId"); const scanName = formData.get("scanName"); const url = new URL(`${apiBaseUrl}/scans/${scanId}`); try { const response = await fetch(url.toString(), { method: "PATCH", headers, body: JSON.stringify({ data: { type: "scans", id: scanId, attributes: { name: scanName, }, }, }), }); return handleApiResponse(response, "/scans"); } catch (error) { return handleApiError(error); } }; export const getExportsZip = async (scanId: string) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}/report`); try { const response = await fetch(url.toString(), { headers, }); if (response.status === 202) { const json = await response.json(); const taskId = json?.data?.id; const state = json?.data?.attributes?.state; return { pending: true, state, taskId, }; } if (!response.ok) { throw new Error( await getScanReportErrorMessage( response, "Unable to fetch scan report. Contact support if the issue continues.", ), ); } // Get the blob data as an array buffer const arrayBuffer = await response.arrayBuffer(); // Convert to base64 const base64 = Buffer.from(arrayBuffer).toString("base64"); return { success: true, data: base64, filename: `scan-${scanId}-report.zip`, }; } catch (error) { return { error: getErrorMessage(error), }; } }; /** * Discriminated union returned by {@link _fetchScanBinary}. * * Exported so `ui/lib/helper.ts::downloadFile` can type-narrow on the * `success` / `pending` / `error` tags without resorting to `any`. */ export type ScanBinaryResult = | { success: true; data: string; filename: string } | { pending: true; state: string | undefined; taskId: string | undefined } | { error: string }; /** * Shared binary-report fetcher used by CSV and PDF report downloads. * * All report endpoints (`/scans/{id}/compliance/{name}`, * `/scans/{id}/{reportType}`) speak the same protocol: Bearer auth, 202 * ACCEPTED while the generation task is still running, 2xx with a binary * body when the artifact is ready, JSON error body otherwise. This helper * encapsulates all of that so the public wrappers only have to build the * URL and pick a filename. * * @param urlPath Path segment under `{apiBaseUrl}/scans/{scanId}/`. * @param filename Download filename to surface to the user. * @param errorLabel Friendly label used when the backend error body is empty. * @returns A ``{ success, data, filename }`` object on 2xx, a * ``{ pending, state, taskId }`` object on 202, or * ``{ error }`` on any failure. */ const _fetchScanBinary = async ( scanId: string, urlPath: string, filename: string, errorLabel: string, ): Promise => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}/${urlPath}`); try { const response = await fetch(url.toString(), { headers }); if (response.status === 202) { const json = await response.json(); return { pending: true, state: json?.data?.attributes?.state, taskId: json?.data?.id, }; } if (!response.ok) { throw new Error( await getScanReportErrorMessage( response, `Unable to retrieve ${errorLabel}. Contact support if the issue continues.`, ), ); } const arrayBuffer = await response.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString("base64"); return { success: true, data: base64, filename }; } catch (error) { return { error: getErrorMessage(error) }; } }; export const getComplianceCsv = async (scanId: string, complianceId: string) => _fetchScanBinary( scanId, `compliance/${complianceId}`, `scan-${scanId}-compliance-${complianceId}.csv`, "compliance report", ); /** * Get the OCSF JSON export for a universal compliance framework. * * Only universal frameworks that declare an ``outputs`` block (today: DORA, * CSA CCM 4.0) produce a per-framework OCSF artifact. For any other framework * the backend returns 404; callers should gate this download via * ``isOcsfSupported(framework)``. * * NOTE: this is a dedicated path (``compliance/{id}/ocsf``), not a query * param. The API's JSON:API ``QueryParameterValidationFilter`` rejects any * non-JSON:API query param with 400, so ``?type=`` / ``?format=`` is not an * option — the format must be encoded in the route. */ export const getComplianceOcsf = async (scanId: string, complianceId: string) => _fetchScanBinary( scanId, `compliance/${complianceId}/ocsf`, `scan-${scanId}-compliance-${complianceId}.ocsf.json`, "compliance OCSF report", ); /** * Get a compliance PDF report for any supported framework. * * For frameworks with multiple variants per provider (currently CIS) the * backend generates a single PDF for the highest available version, so * callers only need to pass the generic report type. * * @param scanId - The scan ID * @param reportType - Type of report (from COMPLIANCE_REPORT_TYPES) * @returns Promise with the PDF data or error */ export const getCompliancePdfReport = async ( scanId: string, reportType: ComplianceReportType, ) => { const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType]; return _fetchScanBinary( scanId, reportType, `scan-${scanId}-${reportType}.pdf`, `${reportName} PDF report`, ); };