From 9d013910e6e939a36bd1da5e20226f06b146447f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 26 Jun 2026 12:55:31 +0200 Subject: [PATCH] feat(ui): scan configuration management page (#11695) --- ui/CHANGELOG.md | 3 + ui/actions/scan-configs/index.ts | 1 + ui/actions/scan-configs/scan-configs.ts | 333 +++++++++++++++++ .../_components/scan-config-editor.tsx | 334 ++++++++++++++++++ .../_components/scan-configs-columns.tsx | 88 +++++ .../_components/scan-configs-manager.tsx | 244 +++++++++++++ ui/app/(prowler)/scan-config/page.tsx | 34 ++ .../client-accordion-content.tsx | 10 + ...compliance-accordion-requeriment-title.tsx | 5 + ui/dependency-log.json | 8 + ui/lib/compliance/asd-essential-eight.tsx | 2 + ui/lib/compliance/aws-well-architected.tsx | 2 + ui/lib/compliance/c5.tsx | 2 + ui/lib/compliance/ccc.tsx | 2 + ui/lib/compliance/cis.tsx | 2 + ui/lib/compliance/commons.tsx | 5 + ui/lib/compliance/csa.tsx | 2 + ui/lib/compliance/dora.tsx | 2 + ui/lib/compliance/ens.tsx | 2 + ui/lib/compliance/generic.tsx | 3 + ui/lib/compliance/iso.tsx | 2 + ui/lib/compliance/kisa.tsx | 2 + ui/lib/compliance/mitre.tsx | 2 + ui/lib/compliance/threat.tsx | 2 + ui/lib/menu-list.test.ts | 36 ++ ui/lib/menu-list.ts | 10 + ui/lib/yaml.ts | 115 ++++++ ui/package.json | 1 + ui/pnpm-lock.yaml | 5 +- ui/types/compliance.ts | 4 + ui/types/formSchemas.ts | 26 ++ ui/types/scan-configs.ts | 53 +++ 32 files changed, 1341 insertions(+), 1 deletion(-) create mode 100644 ui/actions/scan-configs/index.ts create mode 100644 ui/actions/scan-configs/scan-configs.ts create mode 100644 ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx create mode 100644 ui/app/(prowler)/scan-config/_components/scan-configs-columns.tsx create mode 100644 ui/app/(prowler)/scan-config/_components/scan-configs-manager.tsx create mode 100644 ui/app/(prowler)/scan-config/page.tsx create mode 100644 ui/types/scan-configs.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 275c8ef1d9..c5bef7b060 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- Add `Scan Config` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) +- Scan configuration management page (`/scan-config`) to create, edit, and manage scan configs with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) +- Surface an "invalid scan configuration" note on compliance requirements that fail solely because the applied scan config does not meet them [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) - Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659) --- diff --git a/ui/actions/scan-configs/index.ts b/ui/actions/scan-configs/index.ts new file mode 100644 index 0000000000..2c80b2f490 --- /dev/null +++ b/ui/actions/scan-configs/index.ts @@ -0,0 +1 @@ +export * from "./scan-configs"; diff --git a/ui/actions/scan-configs/scan-configs.ts b/ui/actions/scan-configs/scan-configs.ts new file mode 100644 index 0000000000..ace7f2814e --- /dev/null +++ b/ui/actions/scan-configs/scan-configs.ts @@ -0,0 +1,333 @@ +"use server"; + +import yaml from "js-yaml"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { scanConfigFormSchema } from "@/types/formSchemas"; +import { + DeleteScanConfigActionState, + ScanConfigActionState, + ScanConfigData, + ScanConfigErrors, + ScanConfigRequestBody, +} from "@/types/scan-configs"; + +const SCAN_CONFIG_PATH = "/scan-config"; + +// Scan Config IDs are UUIDs. Validate before interpolating into request URLs so +// a malformed/crafted value can't inject path segments (SSRF / path injection). +const scanConfigIdSchema = z.uuid(); + +const parseConfiguration = (value: string): Record => { + // Backend (YamlOrJsonField) accepts either a YAML string or a JSON object. + // We parse client-side so failures surface as form errors, not 500s. + const parsed = yaml.load(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Configuration must be a mapping with provider sections."); + } + return parsed as Record; +}; + +const collectProviderIds = (formData: FormData): string[] => { + return formData + .getAll("provider_ids") + .map((v) => String(v)) + .filter(Boolean); +}; + +export const createScanConfig = async ( + _prevState: ScanConfigActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = { + name: formData.get("name"), + configuration: formData.get("configuration"), + provider_ids: collectProviderIds(formData), + }; + + const validated = scanConfigFormSchema.safeParse(formDataObject); + if (!validated.success) { + const fieldErrors = validated.error.flatten().fieldErrors; + return { + errors: { + name: fieldErrors?.name?.[0], + configuration: fieldErrors?.configuration?.[0], + provider_ids: fieldErrors?.provider_ids?.[0], + }, + }; + } + + const { name, configuration, provider_ids } = validated.data; + + let parsedConfig: Record; + try { + parsedConfig = parseConfiguration(configuration); + } catch (e) { + return { + errors: { + configuration: + e instanceof Error ? e.message : "Failed to parse configuration", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/scan-configs`); + const bodyData: ScanConfigRequestBody = { + data: { + type: "scan-configs", + attributes: { + name, + configuration: parsedConfig, + provider_ids, + }, + }, + }; + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const detail = + errorData?.errors?.[0]?.detail || + errorData?.message || + `Failed to create Scan Config: ${response.statusText}`; + const pointer = errorData?.errors?.[0]?.source?.pointer as + | string + | undefined; + const errors: ScanConfigErrors = {}; + if (pointer?.includes("name")) errors.name = detail; + else if (pointer?.includes("configuration")) + errors.configuration = detail; + else if (pointer?.includes("provider_ids")) errors.provider_ids = detail; + else errors.general = detail; + return { errors }; + } + + const data = await response.json(); + revalidatePath(SCAN_CONFIG_PATH); + return { + success: "Scan Config created successfully!", + data: data.data as ScanConfigData, + }; + } catch (error) { + console.error("Error creating Scan Config:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error creating Scan Config. Please try again.", + }, + }; + } +}; + +export const updateScanConfig = async ( + _prevState: ScanConfigActionState, + formData: FormData, +): Promise => { + const id = formData.get("id"); + if (!id) { + return { errors: { general: "Scan Config ID is required for update" } }; + } + const idResult = scanConfigIdSchema.safeParse(String(id)); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Config ID" } }; + } + const validId = idResult.data; + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = { + name: formData.get("name"), + configuration: formData.get("configuration"), + provider_ids: collectProviderIds(formData), + }; + + const validated = scanConfigFormSchema.safeParse(formDataObject); + if (!validated.success) { + const fieldErrors = validated.error.flatten().fieldErrors; + return { + errors: { + name: fieldErrors?.name?.[0], + configuration: fieldErrors?.configuration?.[0], + provider_ids: fieldErrors?.provider_ids?.[0], + }, + }; + } + + const { name, configuration, provider_ids } = validated.data; + + let parsedConfig: Record; + try { + parsedConfig = parseConfiguration(configuration); + } catch (e) { + return { + errors: { + configuration: + e instanceof Error ? e.message : "Failed to parse configuration", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/scan-configs/${validId}`); + const bodyData: ScanConfigRequestBody = { + data: { + type: "scan-configs", + id: validId, + attributes: { + name, + configuration: parsedConfig, + provider_ids, + }, + }, + }; + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const detail = + errorData?.errors?.[0]?.detail || + errorData?.message || + `Failed to update Scan Config: ${response.statusText}`; + return { errors: { general: detail } }; + } + + const data = await response.json(); + revalidatePath(SCAN_CONFIG_PATH); + return { + success: "Scan Config updated successfully!", + data: data.data as ScanConfigData, + }; + } catch (error) { + console.error("Error updating Scan Config:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating Scan Config. Please try again.", + }, + }; + } +}; + +export const getScanConfigSchema = async (): Promise | null> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configs/schema`); + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch Scan Config schema: ${response.statusText}`, + ); + } + const json = await response.json(); + const schema = json?.data?.attributes?.schema as + | Record + | undefined; + return schema ?? null; + } catch (error) { + console.error("Error fetching Scan Config schema:", error); + return null; + } +}; + +export const listScanConfigs = async (): Promise => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configs`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) { + throw new Error(`Failed to list Scan Configs: ${response.statusText}`); + } + const json = await response.json(); + return (json.data || []) as ScanConfigData[]; + } catch (error) { + console.error("Error listing Scan Configs:", error); + return []; + } +}; + +export const getScanConfig = async ( + id: string, +): Promise => { + const idResult = scanConfigIdSchema.safeParse(id); + if (!idResult.success) return undefined; + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/scan-configs/${idResult.data}`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!response.ok) return undefined; + const json = await response.json(); + return json.data as ScanConfigData; + } catch (error) { + console.error("Error fetching Scan Config:", error); + return undefined; + } +}; + +export const deleteScanConfig = async ( + _prevState: DeleteScanConfigActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const id = formData.get("id"); + if (!id) { + return { errors: { general: "Scan Config ID is required for deletion" } }; + } + const idResult = scanConfigIdSchema.safeParse(String(id)); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Config ID" } }; + } + try { + const url = new URL(`${apiBaseUrl}/scan-configs/${idResult.data}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to delete Scan Config: ${response.statusText}`, + ); + } + revalidatePath(SCAN_CONFIG_PATH); + return { success: "Scan Config deleted successfully!" }; + } catch (error) { + console.error("Error deleting Scan Config:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error deleting Scan Config. Please try again.", + }, + }; + } +}; diff --git a/ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx b/ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx new file mode 100644 index 0000000000..0c50be25a1 --- /dev/null +++ b/ui/app/(prowler)/scan-config/_components/scan-config-editor.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { createScanConfig, updateScanConfig } from "@/actions/scan-configs"; +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { + Button, + Field, + FieldError, + FieldLabel, + Input, + Textarea, +} from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { useToast } from "@/components/ui"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { fontMono } from "@/config/fonts"; +import { + convertToYaml, + defaultScanConfigYaml, + validateScanConfigPayload, +} from "@/lib/yaml"; +import { scanConfigFormSchema } from "@/types/formSchemas"; +import { ProviderProps } from "@/types/providers"; +import { ScanConfigData } from "@/types/scan-configs"; + +interface ScanConfigEditorProps { + open: boolean; + onClose: (saved: boolean) => void; + richProviders: ProviderProps[]; + existingConfigs: ScanConfigData[]; + config: ScanConfigData | null; + schema: Record | null; +} + +interface ScanConfigFormProps { + onClose: (saved: boolean) => void; + richProviders: ProviderProps[]; + existingConfigs: ScanConfigData[]; + config: ScanConfigData | null; + schema: Record | null; +} + +// `provider_ids` has a zod `.default([])`, so the resolver's input and output +// types differ — type the form with both so RHF and zodResolver line up. +type ScanConfigFormInput = z.input; +type ScanConfigFormValues = z.output; + +const MAX_ERRORS_SHOWN = 10; + +function ScanConfigForm({ + onClose, + richProviders, + existingConfigs, + config, + schema, +}: ScanConfigFormProps) { + const isEdit = !!config; + const { toast } = useToast(); + const errorPanelRef = useRef(null); + + // The form is remounted every time the modal opens (Radix unmounts the + // dialog content on close), so deriving the defaults from `config` here is + // enough to reset the form — no `useEffect` needed. + const form = useForm({ + resolver: zodResolver(scanConfigFormSchema), + defaultValues: config + ? { + name: config.attributes.name, + configuration: convertToYaml(config.attributes.configuration || ""), + provider_ids: config.attributes.providers || [], + } + : { name: "", configuration: "", provider_ids: [] }, + }); + + const configText = form.watch("configuration") || ""; + const selectedProviders = form.watch("provider_ids") || []; + + // Real-time validation against the server schema (ranges/enums). Kept out of + // form state because it's derived purely from the current YAML text — skip it + // while the field is empty so we don't flag an error before the user types. + const yamlValidation = configText.trim() + ? validateScanConfigPayload(configText, schema) + : { isValid: true, errors: [] }; + + // A provider can only be attached to one config at a time. We exclude + // providers that are owned by *other* configs from the selector so the user + // can't double-attach them. (AccountsSelector doesn't expose a per-option + // disabled state, so filtering out is the cleanest contract here.) + const ownerByProvider = new Map(); + for (const c of existingConfigs) { + if (config && c.id === config.id) continue; + for (const pid of c.attributes.providers || []) { + ownerByProvider.set(pid, c.attributes.name); + } + } + const selectableProviders = richProviders.filter( + (p) => !ownerByProvider.has(p.id), + ); + const lockedCount = richProviders.length - selectableProviders.length; + + const onSubmit = form.handleSubmit(async (values) => { + // zod validates name length and YAML *syntax*; richer schema violations + // (ranges/enums) surface through `yamlValidation` and must block here too. + if (yamlValidation.errors.length > 0) { + toast({ + variant: "destructive", + title: "Cannot save", + description: `${yamlValidation.errors.length} validation ${ + yamlValidation.errors.length === 1 ? "error" : "errors" + } in the configuration. Fix them before saving.`, + }); + errorPanelRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + return; + } + + const formData = new FormData(); + formData.append("name", values.name.trim()); + formData.append("configuration", values.configuration); + values.provider_ids.forEach((pid) => { + formData.append("provider_ids", pid); + }); + if (config) { + formData.append("id", config.id); + } + + try { + const result = config + ? await updateScanConfig(null, formData) + : await createScanConfig(null, formData); + + if (result?.success) { + toast({ + title: isEdit ? "Scan Config updated" : "Scan Config created", + description: result.success, + }); + onClose(true); + return; + } + + const errors = result?.errors || {}; + if (errors.name) form.setError("name", { message: errors.name }); + if (errors.configuration) + form.setError("configuration", { message: errors.configuration }); + if (errors.provider_ids) + form.setError("provider_ids", { message: errors.provider_ids }); + if (errors.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errors.general, + }); + } else if (errors.configuration || errors.name || errors.provider_ids) { + toast({ + variant: "destructive", + title: "Validation failed", + description: + errors.configuration || + errors.name || + errors.provider_ids || + "Please review the form.", + }); + } + } catch (e) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: + e instanceof Error ? e.message : "Unexpected error. Please retry.", + }); + } + }); + + const isSubmitting = form.formState.isSubmitting; + const nameError = form.formState.errors.name?.message; + const configError = form.formState.errors.configuration?.message; + const providersError = form.formState.errors.provider_ids?.message; + + return ( +
+ + Name + + {nameError && {nameError}} + + + + Configuration (YAML) +

+ Follows the structure of{" "} + + prowler/config/config.yaml + + . Allowed ranges and enums come from the server schema; invalid values + are listed below in real time. +

+