diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e77057779d..8ab6f12eee 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,9 +6,6 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added -- Add `Scan Configuration` 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-configurations`) to create, edit, and manage scan configurations 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) - CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700) diff --git a/ui/actions/scan-configurations/scan-configurations.ts b/ui/actions/scan-configurations/scan-configurations.ts index 1ecdb3256b..946fdeca82 100644 --- a/ui/actions/scan-configurations/scan-configurations.ts +++ b/ui/actions/scan-configurations/scan-configurations.ts @@ -14,13 +14,17 @@ import { ScanConfigurationRequestBody, } from "@/types/scan-configurations"; -const SCAN_CONFIGURATION_PATH = "/scan-configurations"; +const SCAN_CONFIGURATION_PATH = "/scans/config"; // Scan Configuration IDs are UUIDs. Validate before interpolating into request // URLs so a malformed/crafted value can't inject path segments (SSRF / path // injection). const scanConfigurationIdSchema = z.uuid(); +// Provider IDs are UUIDs too. Validate the whole array at the action boundary so +// a malformed/crafted id fails here instead of relying on API-side validation. +const providerIdsSchema = z.array(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. @@ -38,6 +42,48 @@ const collectProviderIds = (formData: FormData): string[] => { .filter(Boolean); }; +interface ApiErrorSource { + pointer?: string; +} + +interface ApiError { + detail?: string; + title?: string; + source?: ApiErrorSource; +} + +// Route each JSON:API error to the matching form field via its `source.pointer` +// so it renders inline next to the offending input. Only errors we can't anchor +// to a field fall back to `general` (surfaced as a toast). Shared by create and +// update so both flows present validation errors identically — otherwise a +// config error shows inline on create but as a toast on update. +const mapApiErrorsToFields = ( + errorData: { errors?: ApiError[]; message?: string } | null | undefined, + fallbackMessage: string, +): ScanConfigurationErrors => { + const apiErrors = Array.isArray(errorData?.errors) ? errorData!.errors! : []; + + if (apiErrors.length === 0) { + return { general: errorData?.message || fallbackMessage }; + } + + const errors: ScanConfigurationErrors = {}; + const append = (key: keyof ScanConfigurationErrors, detail: string) => { + errors[key] = errors[key] ? `${errors[key]}\n${detail}` : detail; + }; + + for (const err of apiErrors) { + const detail = err?.detail || err?.title || fallbackMessage; + const pointer = err?.source?.pointer; + if (pointer?.includes("name")) append("name", detail); + else if (pointer?.includes("configuration")) + append("configuration", detail); + else if (pointer?.includes("provider_ids")) append("provider_ids", detail); + else append("general", detail); + } + return errors; +}; + export const createScanConfiguration = async ( _prevState: ScanConfigurationActionState, formData: FormData, @@ -95,20 +141,12 @@ export const createScanConfiguration = async ( if (!response.ok) { const errorData = await response.json().catch(() => ({})); - const detail = - errorData?.errors?.[0]?.detail || - errorData?.message || - `Failed to create Scan Configuration: ${response.statusText}`; - const pointer = errorData?.errors?.[0]?.source?.pointer as - | string - | undefined; - const errors: ScanConfigurationErrors = {}; - 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 }; + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to create Scan Configuration: ${response.statusText}`, + ), + }; } const data = await response.json(); @@ -199,11 +237,12 @@ export const updateScanConfiguration = async ( if (!response.ok) { const errorData = await response.json().catch(() => ({})); - const detail = - errorData?.errors?.[0]?.detail || - errorData?.message || - `Failed to update Scan Configuration: ${response.statusText}`; - return { errors: { general: detail } }; + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to update Scan Configuration: ${response.statusText}`, + ), + }; } const data = await response.json(); @@ -225,30 +264,67 @@ export const updateScanConfiguration = async ( } }; -export const getScanConfigurationSchema = async (): Promise | null> => { - const headers = await getAuthHeaders({ contentType: false }); - const url = new URL(`${apiBaseUrl}/scan-configurations/schema`); +// Attach/detach providers on a scan configuration without touching its name or +// YAML — a partial PATCH of `provider_ids` only. Used by the provider row to +// associate/disassociate a config (editing the config itself lives in the Scan +// Config view). The backend's `(tenant, provider)` uniqueness means attaching a +// provider here moves it off any other config automatically. +export const setScanConfigurationProviders = async ( + configId: string, + providerIds: string[], +): Promise => { + const idResult = scanConfigurationIdSchema.safeParse(configId); + if (!idResult.success) { + return { errors: { general: "Invalid Scan Configuration ID" } }; + } + const validId = idResult.data; + const providerIdsResult = providerIdsSchema.safeParse(providerIds); + if (!providerIdsResult.success) { + return { errors: { provider_ids: "Invalid provider ID" } }; + } + const validProviderIds = providerIdsResult.data; + const headers = await getAuthHeaders({ contentType: true }); + try { + const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`); + // Partial update: only provider_ids (name/configuration are optional on the + // backend update serializer), so we don't type this as the full request body. + const bodyData = { + data: { + type: "scan-configurations" as const, + id: validId, + attributes: { provider_ids: validProviderIds }, + }, + }; const response = await fetch(url.toString(), { - method: "GET", + method: "PATCH", headers, + body: JSON.stringify(bodyData), }); + if (!response.ok) { - throw new Error( - `Failed to fetch Scan Configuration schema: ${response.statusText}`, - ); + const errorData = await response.json().catch(() => ({})); + return { + errors: mapApiErrorsToFields( + errorData, + `Failed to update Scan Configuration: ${response.statusText}`, + ), + }; } - const json = await response.json(); - const schema = json?.data?.attributes?.schema as - | Record - | undefined; - return schema ?? null; + + revalidatePath(SCAN_CONFIGURATION_PATH); + revalidatePath("/providers"); + return { success: "Scan Configuration updated successfully!" }; } catch (error) { - console.error("Error fetching Scan Configuration schema:", error); - return null; + console.error("Error updating Scan Configuration providers:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error updating Scan Configuration. Please try again.", + }, + }; } }; diff --git a/ui/app/(prowler)/providers/page.test.ts b/ui/app/(prowler)/providers/page.test.ts index 96612380df..293ce578ce 100644 --- a/ui/app/(prowler)/providers/page.test.ts +++ b/ui/app/(prowler)/providers/page.test.ts @@ -43,4 +43,13 @@ describe("providers page", () => { expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV"); expect(source).toContain("{isCloudEnvironment && { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE"); + expect(source).not.toContain("catch {\n return [];"); + }); }); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 72b0af9d7f..ec9c0a7798 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; +import { listScanConfigurations } from "@/actions/scan-configurations"; import { ProvidersAccountsView } from "@/components/providers"; import { SkeletonTableProviders } from "@/components/providers/table"; import { CliImportBanner } from "@/components/scans"; @@ -7,6 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { SearchParamsProps } from "@/types"; +import { + SCAN_CONFIGURATION_LIST_STATUS, + type ScanConfigurationListState, +} from "@/types/scan-configurations"; import { ProviderGroupsContent } from "./provider-groups-content"; import { ProviderPageTabs } from "./provider-page-tabs"; @@ -103,24 +108,45 @@ const ProviderGroupsFallback = () => { ); }; +const loadScanConfigs = async ( + isCloud: boolean, +): Promise => { + if (!isCloud) { + return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] }; + } + + try { + return { + status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, + data: await listScanConfigurations(), + }; + } catch (error) { + console.error("Error loading provider scan configurations:", error); + return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] }; + } +}; + const ProvidersTabContent = async ({ searchParams, }: { searchParams: SearchParamsProps; }) => { - const providersView = await loadProvidersAccountsViewData({ - searchParams, - isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true", - }); + const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const [providersView, scanConfigsState] = await Promise.all([ + loadProvidersAccountsViewData({ searchParams, isCloud }), + loadScanConfigs(isCloud), + ]); return ( ); }; diff --git a/ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx similarity index 73% rename from ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx rename to ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx index e74115a58b..f6dcbacb3e 100644 --- a/ui/app/(prowler)/scan-configurations/_components/scan-configuration-editor.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx @@ -21,11 +21,10 @@ import { 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, defaultScanConfigurationYaml, - validateScanConfigurationPayload, + validateYaml, } from "@/lib/yaml"; import { scanConfigurationFormSchema } from "@/types/formSchemas"; import { ProviderProps } from "@/types/providers"; @@ -37,7 +36,6 @@ interface ScanConfigurationEditorProps { richProviders: ProviderProps[]; existingConfigs: ScanConfigurationData[]; config: ScanConfigurationData | null; - schema: Record | null; } interface ScanConfigurationFormProps { @@ -45,7 +43,6 @@ interface ScanConfigurationFormProps { richProviders: ProviderProps[]; existingConfigs: ScanConfigurationData[]; config: ScanConfigurationData | null; - schema: Record | null; } // `provider_ids` has a zod `.default([])`, so the resolver's input and output @@ -53,14 +50,11 @@ interface ScanConfigurationFormProps { type ScanConfigurationFormInput = z.input; type ScanConfigurationFormValues = z.output; -const MAX_ERRORS_SHOWN = 10; - function ScanConfigurationForm({ onClose, richProviders, existingConfigs, config, - schema, }: ScanConfigurationFormProps) { const isEdit = !!config; const { toast } = useToast(); @@ -87,12 +81,13 @@ function ScanConfigurationForm({ 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() - ? validateScanConfigurationPayload(configText, schema) - : { isValid: true, errors: [] }; + // Mirror the Mutelist editor: the client validates YAML *syntax* live (that it + // parses to a mapping); the API validates the configuration values + // (ranges/enums) on save and returns them inline. Skip while empty so we don't + // flag an error before the user types. + const yamlSyntax = configText.trim() + ? validateYaml(configText) + : { isValid: true as const }; // 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 @@ -111,9 +106,9 @@ function ScanConfigurationForm({ const lockedCount = richProviders.length - selectableProviders.length; const onSubmit = form.handleSubmit(async (values) => { - // The inline panel already lists every schema/syntax error in real time, so - // we don't duplicate them in a toast — just bring the panel into view. - if (yamlValidation.errors.length > 0) { + // Block on a YAML syntax error (the inline message already explains it); the + // API validates the values on save and returns any errors inline. + if (!yamlSyntax.isValid) { errorPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "center", @@ -194,64 +189,64 @@ function ScanConfigurationForm({ 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. -

+
    +
  • + Follows the structure of{" "} + + prowler/config/config.yaml + + ; include only the keys you want to override. +
  • +
  • The configuration is validated on save.
  • +
  • + Learn more about configuring scans{" "} + + here + + . +
  • +