feat(ui): rename scan configuration endpoint (#11710)

This commit is contained in:
Pedro Martín
2026-06-29 13:36:38 +02:00
committed by GitHub
parent 36a609f2ee
commit d850349a1c
15 changed files with 331 additions and 305 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added ### 🚀 Added
- Add `Scan Config` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695) - 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-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) - 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) - 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) - 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) - CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700)
-1
View File
@@ -1 +0,0 @@
export * from "./scan-configs";
+1
View File
@@ -0,0 +1 @@
export * from "./scan-configurations";
@@ -5,20 +5,21 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { scanConfigFormSchema } from "@/types/formSchemas"; import { scanConfigurationFormSchema } from "@/types/formSchemas";
import { import {
DeleteScanConfigActionState, DeleteScanConfigurationActionState,
ScanConfigActionState, ScanConfigurationActionState,
ScanConfigData, ScanConfigurationData,
ScanConfigErrors, ScanConfigurationErrors,
ScanConfigRequestBody, ScanConfigurationRequestBody,
} from "@/types/scan-configs"; } from "@/types/scan-configurations";
const SCAN_CONFIG_PATH = "/scan-config"; const SCAN_CONFIGURATION_PATH = "/scan-configurations";
// Scan Config IDs are UUIDs. Validate before interpolating into request URLs so // Scan Configuration IDs are UUIDs. Validate before interpolating into request
// a malformed/crafted value can't inject path segments (SSRF / path injection). // URLs so a malformed/crafted value can't inject path segments (SSRF / path
const scanConfigIdSchema = z.uuid(); // injection).
const scanConfigurationIdSchema = z.uuid();
const parseConfiguration = (value: string): Record<string, unknown> => { const parseConfiguration = (value: string): Record<string, unknown> => {
// Backend (YamlOrJsonField) accepts either a YAML string or a JSON object. // Backend (YamlOrJsonField) accepts either a YAML string or a JSON object.
@@ -37,10 +38,10 @@ const collectProviderIds = (formData: FormData): string[] => {
.filter(Boolean); .filter(Boolean);
}; };
export const createScanConfig = async ( export const createScanConfiguration = async (
_prevState: ScanConfigActionState, _prevState: ScanConfigurationActionState,
formData: FormData, formData: FormData,
): Promise<ScanConfigActionState> => { ): Promise<ScanConfigurationActionState> => {
const headers = await getAuthHeaders({ contentType: true }); const headers = await getAuthHeaders({ contentType: true });
const formDataObject = { const formDataObject = {
name: formData.get("name"), name: formData.get("name"),
@@ -48,7 +49,7 @@ export const createScanConfig = async (
provider_ids: collectProviderIds(formData), provider_ids: collectProviderIds(formData),
}; };
const validated = scanConfigFormSchema.safeParse(formDataObject); const validated = scanConfigurationFormSchema.safeParse(formDataObject);
if (!validated.success) { if (!validated.success) {
const fieldErrors = validated.error.flatten().fieldErrors; const fieldErrors = validated.error.flatten().fieldErrors;
return { return {
@@ -75,10 +76,10 @@ export const createScanConfig = async (
} }
try { try {
const url = new URL(`${apiBaseUrl}/scan-configs`); const url = new URL(`${apiBaseUrl}/scan-configurations`);
const bodyData: ScanConfigRequestBody = { const bodyData: ScanConfigurationRequestBody = {
data: { data: {
type: "scan-configs", type: "scan-configurations",
attributes: { attributes: {
name, name,
configuration: parsedConfig, configuration: parsedConfig,
@@ -97,11 +98,11 @@ export const createScanConfig = async (
const detail = const detail =
errorData?.errors?.[0]?.detail || errorData?.errors?.[0]?.detail ||
errorData?.message || errorData?.message ||
`Failed to create Scan Config: ${response.statusText}`; `Failed to create Scan Configuration: ${response.statusText}`;
const pointer = errorData?.errors?.[0]?.source?.pointer as const pointer = errorData?.errors?.[0]?.source?.pointer as
| string | string
| undefined; | undefined;
const errors: ScanConfigErrors = {}; const errors: ScanConfigurationErrors = {};
if (pointer?.includes("name")) errors.name = detail; if (pointer?.includes("name")) errors.name = detail;
else if (pointer?.includes("configuration")) else if (pointer?.includes("configuration"))
errors.configuration = detail; errors.configuration = detail;
@@ -111,35 +112,37 @@ export const createScanConfig = async (
} }
const data = await response.json(); const data = await response.json();
revalidatePath(SCAN_CONFIG_PATH); revalidatePath(SCAN_CONFIGURATION_PATH);
return { return {
success: "Scan Config created successfully!", success: "Scan Configuration created successfully!",
data: data.data as ScanConfigData, data: data.data as ScanConfigurationData,
}; };
} catch (error) { } catch (error) {
console.error("Error creating Scan Config:", error); console.error("Error creating Scan Configuration:", error);
return { return {
errors: { errors: {
general: general:
error instanceof Error error instanceof Error
? error.message ? error.message
: "Error creating Scan Config. Please try again.", : "Error creating Scan Configuration. Please try again.",
}, },
}; };
} }
}; };
export const updateScanConfig = async ( export const updateScanConfiguration = async (
_prevState: ScanConfigActionState, _prevState: ScanConfigurationActionState,
formData: FormData, formData: FormData,
): Promise<ScanConfigActionState> => { ): Promise<ScanConfigurationActionState> => {
const id = formData.get("id"); const id = formData.get("id");
if (!id) { if (!id) {
return { errors: { general: "Scan Config ID is required for update" } }; return {
errors: { general: "Scan Configuration ID is required for update" },
};
} }
const idResult = scanConfigIdSchema.safeParse(String(id)); const idResult = scanConfigurationIdSchema.safeParse(String(id));
if (!idResult.success) { if (!idResult.success) {
return { errors: { general: "Invalid Scan Config ID" } }; return { errors: { general: "Invalid Scan Configuration ID" } };
} }
const validId = idResult.data; const validId = idResult.data;
const headers = await getAuthHeaders({ contentType: true }); const headers = await getAuthHeaders({ contentType: true });
@@ -149,7 +152,7 @@ export const updateScanConfig = async (
provider_ids: collectProviderIds(formData), provider_ids: collectProviderIds(formData),
}; };
const validated = scanConfigFormSchema.safeParse(formDataObject); const validated = scanConfigurationFormSchema.safeParse(formDataObject);
if (!validated.success) { if (!validated.success) {
const fieldErrors = validated.error.flatten().fieldErrors; const fieldErrors = validated.error.flatten().fieldErrors;
return { return {
@@ -176,10 +179,10 @@ export const updateScanConfig = async (
} }
try { try {
const url = new URL(`${apiBaseUrl}/scan-configs/${validId}`); const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`);
const bodyData: ScanConfigRequestBody = { const bodyData: ScanConfigurationRequestBody = {
data: { data: {
type: "scan-configs", type: "scan-configurations",
id: validId, id: validId,
attributes: { attributes: {
name, name,
@@ -199,35 +202,35 @@ export const updateScanConfig = async (
const detail = const detail =
errorData?.errors?.[0]?.detail || errorData?.errors?.[0]?.detail ||
errorData?.message || errorData?.message ||
`Failed to update Scan Config: ${response.statusText}`; `Failed to update Scan Configuration: ${response.statusText}`;
return { errors: { general: detail } }; return { errors: { general: detail } };
} }
const data = await response.json(); const data = await response.json();
revalidatePath(SCAN_CONFIG_PATH); revalidatePath(SCAN_CONFIGURATION_PATH);
return { return {
success: "Scan Config updated successfully!", success: "Scan Configuration updated successfully!",
data: data.data as ScanConfigData, data: data.data as ScanConfigurationData,
}; };
} catch (error) { } catch (error) {
console.error("Error updating Scan Config:", error); console.error("Error updating Scan Configuration:", error);
return { return {
errors: { errors: {
general: general:
error instanceof Error error instanceof Error
? error.message ? error.message
: "Error updating Scan Config. Please try again.", : "Error updating Scan Configuration. Please try again.",
}, },
}; };
} }
}; };
export const getScanConfigSchema = async (): Promise<Record< export const getScanConfigurationSchema = async (): Promise<Record<
string, string,
unknown unknown
> | null> => { > | null> => {
const headers = await getAuthHeaders({ contentType: false }); const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scan-configs/schema`); const url = new URL(`${apiBaseUrl}/scan-configurations/schema`);
try { try {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "GET", method: "GET",
@@ -235,7 +238,7 @@ export const getScanConfigSchema = async (): Promise<Record<
}); });
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to fetch Scan Config schema: ${response.statusText}`, `Failed to fetch Scan Configuration schema: ${response.statusText}`,
); );
} }
const json = await response.json(); const json = await response.json();
@@ -244,14 +247,16 @@ export const getScanConfigSchema = async (): Promise<Record<
| undefined; | undefined;
return schema ?? null; return schema ?? null;
} catch (error) { } catch (error) {
console.error("Error fetching Scan Config schema:", error); console.error("Error fetching Scan Configuration schema:", error);
return null; return null;
} }
}; };
export const listScanConfigs = async (): Promise<ScanConfigData[]> => { export const listScanConfigurations = async (): Promise<
ScanConfigurationData[]
> => {
const headers = await getAuthHeaders({ contentType: false }); const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scan-configs`); const url = new URL(`${apiBaseUrl}/scan-configurations`);
try { try {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
@@ -259,23 +264,28 @@ export const listScanConfigs = async (): Promise<ScanConfigData[]> => {
headers, headers,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to list Scan Configs: ${response.statusText}`); throw new Error(
`Failed to list Scan Configurations: ${response.statusText}`,
);
} }
const json = await response.json(); const json = await response.json();
return (json.data || []) as ScanConfigData[]; return (json.data || []) as ScanConfigurationData[];
} catch (error) { } catch (error) {
console.error("Error listing Scan Configs:", error); // Re-throw so callers can distinguish a fetch/auth failure from an empty
return []; // result. Collapsing errors into `[]` would render a false "no scan
// configurations" state and overwrite the table on a failed refresh.
console.error("Error listing Scan Configurations:", error);
throw error;
} }
}; };
export const getScanConfig = async ( export const getScanConfiguration = async (
id: string, id: string,
): Promise<ScanConfigData | undefined> => { ): Promise<ScanConfigurationData | undefined> => {
const idResult = scanConfigIdSchema.safeParse(id); const idResult = scanConfigurationIdSchema.safeParse(id);
if (!idResult.success) return undefined; if (!idResult.success) return undefined;
const headers = await getAuthHeaders({ contentType: false }); const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scan-configs/${idResult.data}`); const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`);
try { try {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
@@ -284,28 +294,30 @@ export const getScanConfig = async (
}); });
if (!response.ok) return undefined; if (!response.ok) return undefined;
const json = await response.json(); const json = await response.json();
return json.data as ScanConfigData; return json.data as ScanConfigurationData;
} catch (error) { } catch (error) {
console.error("Error fetching Scan Config:", error); console.error("Error fetching Scan Configuration:", error);
return undefined; return undefined;
} }
}; };
export const deleteScanConfig = async ( export const deleteScanConfiguration = async (
_prevState: DeleteScanConfigActionState, _prevState: DeleteScanConfigurationActionState,
formData: FormData, formData: FormData,
): Promise<DeleteScanConfigActionState> => { ): Promise<DeleteScanConfigurationActionState> => {
const headers = await getAuthHeaders({ contentType: true }); const headers = await getAuthHeaders({ contentType: true });
const id = formData.get("id"); const id = formData.get("id");
if (!id) { if (!id) {
return { errors: { general: "Scan Config ID is required for deletion" } }; return {
errors: { general: "Scan Configuration ID is required for deletion" },
};
} }
const idResult = scanConfigIdSchema.safeParse(String(id)); const idResult = scanConfigurationIdSchema.safeParse(String(id));
if (!idResult.success) { if (!idResult.success) {
return { errors: { general: "Invalid Scan Config ID" } }; return { errors: { general: "Invalid Scan Configuration ID" } };
} }
try { try {
const url = new URL(`${apiBaseUrl}/scan-configs/${idResult.data}`); const url = new URL(`${apiBaseUrl}/scan-configurations/${idResult.data}`);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "DELETE", method: "DELETE",
headers, headers,
@@ -314,19 +326,19 @@ export const deleteScanConfig = async (
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error( throw new Error(
errorData.errors?.[0]?.detail || errorData.errors?.[0]?.detail ||
`Failed to delete Scan Config: ${response.statusText}`, `Failed to delete Scan Configuration: ${response.statusText}`,
); );
} }
revalidatePath(SCAN_CONFIG_PATH); revalidatePath(SCAN_CONFIGURATION_PATH);
return { success: "Scan Config deleted successfully!" }; return { success: "Scan Configuration deleted successfully!" };
} catch (error) { } catch (error) {
console.error("Error deleting Scan Config:", error); console.error("Error deleting Scan Configuration:", error);
return { return {
errors: { errors: {
general: general:
error instanceof Error error instanceof Error
? error.message ? error.message
: "Error deleting Scan Config. Please try again.", : "Error deleting Scan Configuration. Please try again.",
}, },
}; };
} }
-34
View File
@@ -1,34 +0,0 @@
import { redirect } from "next/navigation";
import { getAllProviders } from "@/actions/providers";
import { getScanConfigSchema, listScanConfigs } from "@/actions/scan-configs";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
import { ScanConfigsManager } from "./_components/scan-configs-manager";
export default async function ScanConfigPage() {
// Scan Config is a Prowler Cloud-only feature; the OSS API has no
// /scan-configs endpoints, so guard the route before hitting them.
if (!isCloud()) {
redirect("/");
}
const [configs, providersResponse, schema] = await Promise.all([
listScanConfigs(),
getAllProviders({}),
getScanConfigSchema(),
]);
const richProviders = providersResponse?.data ?? [];
return (
<ContentLayout title="Scan Config" icon="lucide:sliders">
<ScanConfigsManager
initialConfigs={configs}
richProviders={richProviders}
schema={schema}
/>
</ContentLayout>
);
}
@@ -5,7 +5,10 @@ import { useRef } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { createScanConfig, updateScanConfig } from "@/actions/scan-configs"; import {
createScanConfiguration,
updateScanConfiguration,
} from "@/actions/scan-configurations";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { import {
Button, Button,
@@ -21,44 +24,44 @@ import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts"; import { fontMono } from "@/config/fonts";
import { import {
convertToYaml, convertToYaml,
defaultScanConfigYaml, defaultScanConfigurationYaml,
validateScanConfigPayload, validateScanConfigurationPayload,
} from "@/lib/yaml"; } from "@/lib/yaml";
import { scanConfigFormSchema } from "@/types/formSchemas"; import { scanConfigurationFormSchema } from "@/types/formSchemas";
import { ProviderProps } from "@/types/providers"; import { ProviderProps } from "@/types/providers";
import { ScanConfigData } from "@/types/scan-configs"; import { ScanConfigurationData } from "@/types/scan-configurations";
interface ScanConfigEditorProps { interface ScanConfigurationEditorProps {
open: boolean; open: boolean;
onClose: (saved: boolean) => void; onClose: (saved: boolean) => void;
richProviders: ProviderProps[]; richProviders: ProviderProps[];
existingConfigs: ScanConfigData[]; existingConfigs: ScanConfigurationData[];
config: ScanConfigData | null; config: ScanConfigurationData | null;
schema: Record<string, unknown> | null; schema: Record<string, unknown> | null;
} }
interface ScanConfigFormProps { interface ScanConfigurationFormProps {
onClose: (saved: boolean) => void; onClose: (saved: boolean) => void;
richProviders: ProviderProps[]; richProviders: ProviderProps[];
existingConfigs: ScanConfigData[]; existingConfigs: ScanConfigurationData[];
config: ScanConfigData | null; config: ScanConfigurationData | null;
schema: Record<string, unknown> | null; schema: Record<string, unknown> | null;
} }
// `provider_ids` has a zod `.default([])`, so the resolver's input and output // `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. // types differ — type the form with both so RHF and zodResolver line up.
type ScanConfigFormInput = z.input<typeof scanConfigFormSchema>; type ScanConfigurationFormInput = z.input<typeof scanConfigurationFormSchema>;
type ScanConfigFormValues = z.output<typeof scanConfigFormSchema>; type ScanConfigurationFormValues = z.output<typeof scanConfigurationFormSchema>;
const MAX_ERRORS_SHOWN = 10; const MAX_ERRORS_SHOWN = 10;
function ScanConfigForm({ function ScanConfigurationForm({
onClose, onClose,
richProviders, richProviders,
existingConfigs, existingConfigs,
config, config,
schema, schema,
}: ScanConfigFormProps) { }: ScanConfigurationFormProps) {
const isEdit = !!config; const isEdit = !!config;
const { toast } = useToast(); const { toast } = useToast();
const errorPanelRef = useRef<HTMLDivElement | null>(null); const errorPanelRef = useRef<HTMLDivElement | null>(null);
@@ -66,8 +69,12 @@ function ScanConfigForm({
// The form is remounted every time the modal opens (Radix unmounts the // The form is remounted every time the modal opens (Radix unmounts the
// dialog content on close), so deriving the defaults from `config` here is // dialog content on close), so deriving the defaults from `config` here is
// enough to reset the form — no `useEffect` needed. // enough to reset the form — no `useEffect` needed.
const form = useForm<ScanConfigFormInput, unknown, ScanConfigFormValues>({ const form = useForm<
resolver: zodResolver(scanConfigFormSchema), ScanConfigurationFormInput,
unknown,
ScanConfigurationFormValues
>({
resolver: zodResolver(scanConfigurationFormSchema),
defaultValues: config defaultValues: config
? { ? {
name: config.attributes.name, name: config.attributes.name,
@@ -84,7 +91,7 @@ function ScanConfigForm({
// form state because it's derived purely from the current YAML text — skip it // 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. // while the field is empty so we don't flag an error before the user types.
const yamlValidation = configText.trim() const yamlValidation = configText.trim()
? validateScanConfigPayload(configText, schema) ? validateScanConfigurationPayload(configText, schema)
: { isValid: true, errors: [] }; : { isValid: true, errors: [] };
// A provider can only be attached to one config at a time. We exclude // A provider can only be attached to one config at a time. We exclude
@@ -104,16 +111,9 @@ function ScanConfigForm({
const lockedCount = richProviders.length - selectableProviders.length; const lockedCount = richProviders.length - selectableProviders.length;
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
// zod validates name length and YAML *syntax*; richer schema violations // The inline panel already lists every schema/syntax error in real time, so
// (ranges/enums) surface through `yamlValidation` and must block here too. // we don't duplicate them in a toast — just bring the panel into view.
if (yamlValidation.errors.length > 0) { 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({ errorPanelRef.current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "center", block: "center",
@@ -133,18 +133,22 @@ function ScanConfigForm({
try { try {
const result = config const result = config
? await updateScanConfig(null, formData) ? await updateScanConfiguration(null, formData)
: await createScanConfig(null, formData); : await createScanConfiguration(null, formData);
if (result?.success) { if (result?.success) {
toast({ toast({
title: isEdit ? "Scan Config updated" : "Scan Config created", title: isEdit
? "Scan Configuration updated"
: "Scan Configuration created",
description: result.success, description: result.success,
}); });
onClose(true); onClose(true);
return; return;
} }
// Field-level errors render inline next to each input; only a general
// error (no field to anchor it to) falls back to a toast.
const errors = result?.errors || {}; const errors = result?.errors || {};
if (errors.name) form.setError("name", { message: errors.name }); if (errors.name) form.setError("name", { message: errors.name });
if (errors.configuration) if (errors.configuration)
@@ -157,16 +161,6 @@ function ScanConfigForm({
title: "Oops! Something went wrong", title: "Oops! Something went wrong",
description: errors.general, 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) { } catch (e) {
toast({ toast({
@@ -186,9 +180,9 @@ function ScanConfigForm({
return ( return (
<form onSubmit={onSubmit} className="flex flex-col gap-5"> <form onSubmit={onSubmit} className="flex flex-col gap-5">
<Field> <Field>
<FieldLabel htmlFor="scan-config-name">Name</FieldLabel> <FieldLabel htmlFor="scan-configuration-name">Name</FieldLabel>
<Input <Input
id="scan-config-name" id="scan-configuration-name"
placeholder="e.g. stricter-iam-aws" placeholder="e.g. stricter-iam-aws"
aria-invalid={!!nameError} aria-invalid={!!nameError}
{...form.register("name")} {...form.register("name")}
@@ -197,7 +191,9 @@ function ScanConfigForm({
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="scan-config-yaml">Configuration (YAML)</FieldLabel> <FieldLabel htmlFor="scan-configuration-yaml">
Configuration (YAML)
</FieldLabel>
<p className="text-default-500 text-tiny"> <p className="text-default-500 text-tiny">
Follows the structure of{" "} Follows the structure of{" "}
<CustomLink <CustomLink
@@ -210,8 +206,8 @@ function ScanConfigForm({
are listed below in real time. are listed below in real time.
</p> </p>
<Textarea <Textarea
id="scan-config-yaml" id="scan-configuration-yaml"
placeholder={defaultScanConfigYaml} placeholder={defaultScanConfigurationYaml}
rows={14} rows={14}
aria-invalid={!!configError || !yamlValidation.isValid} aria-invalid={!!configError || !yamlValidation.isValid}
className={fontMono.className + " text-sm"} className={fontMono.className + " text-sm"}
@@ -219,10 +215,12 @@ function ScanConfigForm({
/> />
<div aria-live="polite" className="mt-1" ref={errorPanelRef}> <div aria-live="polite" className="mt-1" ref={errorPanelRef}>
{yamlValidation.errors.length === 0 && configText.trim() ? ( {yamlValidation.errors.length === 0 && configText.trim() ? (
<p className="text-tiny text-success">Configuration valid</p> <p className="text-tiny text-text-success-primary">
Configuration valid
</p>
) : yamlValidation.errors.length > 0 ? ( ) : yamlValidation.errors.length > 0 ? (
<div className="border-danger-200 bg-danger-50 rounded-md border p-3"> <div className="border-border-error rounded-md border bg-red-50 p-3 dark:bg-red-950/50">
<p className="text-tiny text-danger mb-1 font-medium"> <p className="text-tiny text-text-error-primary mb-1 font-medium">
{yamlValidation.errors.length} validation{" "} {yamlValidation.errors.length} validation{" "}
{yamlValidation.errors.length === 1 ? "error" : "errors"}: {yamlValidation.errors.length === 1 ? "error" : "errors"}:
</p> </p>
@@ -250,15 +248,16 @@ function ScanConfigForm({
</Field> </Field>
<Field> <Field>
<FieldLabel>Attach to accounts</FieldLabel> <FieldLabel>Attach to providers</FieldLabel>
<p className="text-default-500 text-tiny"> <p className="text-default-500 text-tiny">
Pick the cloud accounts that should use this configuration on their Pick the cloud providers that should use this configuration on their
next scan. next scan.
{lockedCount > 0 && ( {lockedCount > 0 && (
<> <>
{" "} {" "}
{lockedCount} {lockedCount === 1 ? "account is" : "accounts are"}{" "} {lockedCount}{" "}
hidden because they are already attached to another Scan Config. {lockedCount === 1 ? "provider is" : "providers are"} hidden
because they are already attached to another Scan Configuration.
</> </>
)} )}
</p> </p>
@@ -266,7 +265,7 @@ function ScanConfigForm({
<p className="text-default-500 text-tiny italic"> <p className="text-default-500 text-tiny italic">
{richProviders.length === 0 {richProviders.length === 0
? "No providers available in this tenant." ? "No providers available in this tenant."
: "All providers are already attached to other Scan Configs."} : "All providers are already attached to other Scan Configurations."}
</p> </p>
) : ( ) : (
<AccountsSelector <AccountsSelector
@@ -276,8 +275,8 @@ function ScanConfigForm({
} }
selectedValues={selectedProviders} selectedValues={selectedProviders}
search={{ search={{
placeholder: "Search accounts...", placeholder: "Search providers...",
emptyMessage: "No accounts found.", emptyMessage: "No providers found.",
}} }}
/> />
)} )}
@@ -302,14 +301,14 @@ function ScanConfigForm({
); );
} }
export function ScanConfigEditor({ export function ScanConfigurationEditor({
open, open,
onClose, onClose,
richProviders, richProviders,
existingConfigs, existingConfigs,
config, config,
schema, schema,
}: ScanConfigEditorProps) { }: ScanConfigurationEditorProps) {
const isEdit = !!config; const isEdit = !!config;
return ( return (
@@ -318,10 +317,10 @@ export function ScanConfigEditor({
onOpenChange={(o) => { onOpenChange={(o) => {
if (!o) onClose(false); if (!o) onClose(false);
}} }}
title={isEdit ? "Edit Scan Config" : "New Scan Config"} title={isEdit ? "Edit Scan Configuration" : "New Scan Configuration"}
size="2xl" size="2xl"
> >
<ScanConfigForm <ScanConfigurationForm
key={config?.id ?? "new"} key={config?.id ?? "new"}
onClose={onClose} onClose={onClose}
richProviders={richProviders} richProviders={richProviders}
@@ -6,12 +6,12 @@ import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/shadcn"; import { Button } from "@/components/shadcn";
import { DateWithTime } from "@/components/ui/entities"; import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table"; import { DataTableColumnHeader } from "@/components/ui/table";
import { ScanConfigData } from "@/types/scan-configs"; import { ScanConfigurationData } from "@/types/scan-configurations";
export const createScanConfigsColumns = ( export const createScanConfigurationsColumns = (
onEdit: (config: ScanConfigData) => void, onEdit: (config: ScanConfigurationData) => void,
onDelete: (config: ScanConfigData) => void, onDelete: (config: ScanConfigurationData) => void,
): ColumnDef<ScanConfigData>[] => [ ): ColumnDef<ScanConfigurationData>[] => [
{ {
accessorKey: "attributes.name", accessorKey: "attributes.name",
header: ({ column }) => ( header: ({ column }) => (
@@ -28,7 +28,7 @@ export const createScanConfigsColumns = (
{ {
id: "providers_count", id: "providers_count",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title="Accounts" /> <DataTableColumnHeader column={column} title="Providers" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const count = row.original.attributes.providers?.length ?? 0; const count = row.original.attributes.providers?.length ?? 0;
@@ -36,10 +36,10 @@ export const createScanConfigsColumns = (
<span className="text-text-neutral-primary text-sm"> <span className="text-text-neutral-primary text-sm">
{count === 0 ? ( {count === 0 ? (
<span className="text-text-neutral-tertiary italic"> <span className="text-text-neutral-tertiary italic">
No accounts No providers
</span> </span>
) : ( ) : (
`${count} ${count === 1 ? "account" : "accounts"}` `${count} ${count === 1 ? "provider" : "providers"}`
)} )}
</span> </span>
); );
@@ -3,7 +3,10 @@
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { deleteScanConfig, listScanConfigs } from "@/actions/scan-configs"; import {
deleteScanConfiguration,
listScanConfigurations,
} from "@/actions/scan-configurations";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout"; import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
@@ -12,43 +15,51 @@ import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui"; import { useToast } from "@/components/ui";
import { DataTable } from "@/components/ui/table"; import { DataTable } from "@/components/ui/table";
import { ProviderProps } from "@/types/providers"; import { ProviderProps } from "@/types/providers";
import { ScanConfigData } from "@/types/scan-configs"; import { ScanConfigurationData } from "@/types/scan-configurations";
import { ScanConfigEditor } from "./scan-config-editor"; import { ScanConfigurationEditor } from "./scan-configuration-editor";
import { createScanConfigsColumns } from "./scan-configs-columns"; import { createScanConfigurationsColumns } from "./scan-configurations-columns";
// Same column basis classes as `FindingsFilters` so the controls align across // Same column basis classes as `FindingsFilters` so the controls align across
// breakpoints with the rest of the product. // breakpoints with the rest of the product.
const FILTER_CONTROL_COLUMN_CLASS = const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]"; "min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
interface ScanConfigsManagerProps { interface ScanConfigurationsManagerProps {
initialConfigs: ScanConfigData[]; initialConfigs: ScanConfigurationData[];
richProviders: ProviderProps[]; richProviders: ProviderProps[];
schema: Record<string, unknown> | null; schema: Record<string, unknown> | null;
} }
export function ScanConfigsManager({ export function ScanConfigurationsManager({
initialConfigs, initialConfigs,
richProviders, richProviders,
schema, schema,
}: ScanConfigsManagerProps) { }: ScanConfigurationsManagerProps) {
const [configs, setConfigs] = useState<ScanConfigData[]>(initialConfigs); const [configs, setConfigs] =
useState<ScanConfigurationData[]>(initialConfigs);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ScanConfigData | null>( const [editingConfig, setEditingConfig] =
null, useState<ScanConfigurationData | null>(null);
); const [pendingDelete, setPendingDelete] =
const [pendingDelete, setPendingDelete] = useState<ScanConfigData | null>( useState<ScanConfigurationData | null>(null);
null,
);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [accountFilter, setAccountFilter] = useState<string[]>([]); const [providerFilter, setProviderFilter] = useState<string[]>([]);
const [nameSearch, setNameSearch] = useState<string>(""); const [nameSearch, setNameSearch] = useState<string>("");
const { toast } = useToast(); const { toast } = useToast();
const refresh = async () => { const refresh = async () => {
const fresh = await listScanConfigs(); try {
setConfigs(fresh); const fresh = await listScanConfigurations();
setConfigs(fresh);
} catch {
// Keep the current table on a failed reload instead of clearing it.
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "Failed to reload Scan Configurations. Please try again.",
});
}
}; };
const openCreate = () => { const openCreate = () => {
@@ -56,7 +67,7 @@ export function ScanConfigsManager({
setEditorOpen(true); setEditorOpen(true);
}; };
const openEdit = (config: ScanConfigData) => { const openEdit = (config: ScanConfigurationData) => {
setEditingConfig(config); setEditingConfig(config);
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -76,10 +87,10 @@ export function ScanConfigsManager({
formData.append("id", pendingDelete.id); formData.append("id", pendingDelete.id);
try { try {
const result = await deleteScanConfig(null, formData); const result = await deleteScanConfiguration(null, formData);
if (result?.success) { if (result?.success) {
toast({ toast({
title: "Scan Config deleted", title: "Scan Configuration deleted",
description: result.success, description: result.success,
}); });
await refresh(); await refresh();
@@ -94,7 +105,7 @@ export function ScanConfigsManager({
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Oops! Something went wrong", title: "Oops! Something went wrong",
description: "Error deleting Scan Config. Please try again.", description: "Error deleting Scan Configuration. Please try again.",
}); });
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
@@ -102,15 +113,15 @@ export function ScanConfigsManager({
} }
}; };
const columns = createScanConfigsColumns( const columns = createScanConfigurationsColumns(
(cfg) => openEdit(cfg), (cfg) => openEdit(cfg),
(cfg) => setPendingDelete(cfg), (cfg) => setPendingDelete(cfg),
); );
const filteredConfigs = configs.filter((c) => { const filteredConfigs = configs.filter((c) => {
if (accountFilter.length > 0) { if (providerFilter.length > 0) {
const attached = c.attributes.providers || []; const attached = c.attributes.providers || [];
const overlaps = accountFilter.some((pid) => attached.includes(pid)); const overlaps = providerFilter.some((pid) => attached.includes(pid));
if (!overlaps) return false; if (!overlaps) return false;
} }
if (nameSearch) { if (nameSearch) {
@@ -120,17 +131,20 @@ export function ScanConfigsManager({
return true; return true;
}); });
const noMatchForAccount = const noMatchForProvider =
accountFilter.length > 0 && filteredConfigs.length === 0 && !nameSearch; providerFilter.length > 0 &&
filteredConfigs.length === 0 &&
!nameSearch.trim();
const hasAnyFilter = accountFilter.length > 0 || nameSearch.length > 0; const hasAnyFilter =
providerFilter.length > 0 || nameSearch.trim().length > 0;
const handleAccountsChange = (_filterKey: string, values: string[]) => { const handleProvidersChange = (_filterKey: string, values: string[]) => {
setAccountFilter(values); setProviderFilter(values);
}; };
const clearFilters = () => { const clearFilters = () => {
setAccountFilter([]); setProviderFilter([]);
setNameSearch(""); setNameSearch("");
}; };
@@ -138,46 +152,46 @@ export function ScanConfigsManager({
<> <>
<div className="mb-6"> <div className="mb-6">
<BatchFiltersLayout <BatchFiltersLayout
testIdPrefix="scan-config" testIdPrefix="scan-configuration"
controlsClassName="gap-3" controlsClassName="gap-3"
controls={ controls={
<> <>
<div className={FILTER_CONTROL_COLUMN_CLASS}> <div className={FILTER_CONTROL_COLUMN_CLASS}>
<AccountsSelector <AccountsSelector
providers={richProviders} providers={richProviders}
onBatchChange={handleAccountsChange} onBatchChange={handleProvidersChange}
selectedValues={accountFilter} selectedValues={providerFilter}
/> />
</div> </div>
{hasAnyFilter && ( {hasAnyFilter && (
<ClearFiltersButton <ClearFiltersButton
showCount showCount
pendingCount={ pendingCount={
accountFilter.length + (nameSearch.trim() ? 1 : 0) providerFilter.length + (nameSearch.trim() ? 1 : 0)
} }
onClear={clearFilters} onClear={clearFilters}
/> />
)} )}
<Button size="lg" onClick={openCreate} className="md:ml-auto"> <Button size="lg" onClick={openCreate} className="md:ml-auto">
<Plus className="size-4" /> <Plus className="size-4" />
New Scan Config New Scan Configuration
</Button> </Button>
</> </>
} }
/> />
</div> </div>
{noMatchForAccount ? ( {noMatchForProvider ? (
<Card variant="base" className="p-8 text-center"> <Card variant="base" className="p-8 text-center">
<p className="text-default-700 text-sm font-medium"> <p className="text-default-700 text-sm font-medium">
{accountFilter.length === 1 {providerFilter.length === 1
? "No Scan Config is attached to this account." ? "No Scan Configuration is attached to this provider."
: "No Scan Config is attached to any of the selected accounts."} : "No Scan Configuration is attached to any of the selected providers."}
</p> </p>
<p className="text-default-500 mt-1 text-sm"> <p className="text-default-500 mt-1 text-sm">
The next scan{accountFilter.length === 1 ? "" : "s"} will use the The next scan{providerFilter.length === 1 ? "" : "s"} will use the
built-in defaults shipped with Prowler. Attach a Scan Config from built-in defaults shipped with Prowler. Attach a Scan Configuration
the editor to override them. from the editor to override them.
</p> </p>
</Card> </Card>
) : ( ) : (
@@ -195,7 +209,7 @@ export function ScanConfigsManager({
/> />
)} )}
<ScanConfigEditor <ScanConfigurationEditor
open={editorOpen} open={editorOpen}
onClose={handleEditorClose} onClose={handleEditorClose}
richProviders={richProviders} richProviders={richProviders}
@@ -207,14 +221,15 @@ export function ScanConfigsManager({
<Modal <Modal
open={!!pendingDelete} open={!!pendingDelete}
onOpenChange={(open) => !open && setPendingDelete(null)} onOpenChange={(open) => !open && setPendingDelete(null)}
title="Delete Scan Config" title="Delete Scan Configuration"
size="md" size="md"
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-default-600 text-sm"> <p className="text-default-600 text-sm">
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<strong>{pendingDelete?.attributes.name}</strong>? Attached accounts <strong>{pendingDelete?.attributes.name}</strong>? Attached
will fall back to the built-in scan defaults on their next scan. providers will fall back to the built-in scan defaults on their next
scan.
</p> </p>
<div className="flex w-full justify-end gap-4"> <div className="flex w-full justify-end gap-4">
<Button <Button
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { getAllProviders } from "@/actions/providers";
import {
getScanConfigurationSchema,
listScanConfigurations,
} from "@/actions/scan-configurations";
import { ContentLayout } from "@/components/ui";
import { isCloud } from "@/lib/shared/env";
import { ScanConfigurationsManager } from "./_components/scan-configurations-manager";
export default async function ScanConfigPage() {
// Scan Configuration is a Prowler Cloud-only feature; the OSS API has no
// /scan-configurations endpoints, so guard the route before hitting them.
if (!isCloud()) {
redirect("/");
}
// A failure here propagates to the `(prowler)/error.tsx` boundary instead of
// rendering a false "no scan configurations" empty table during SSR.
const [configs, providersResponse, schema] = await Promise.all([
listScanConfigurations(),
getAllProviders({}),
getScanConfigurationSchema(),
]);
const richProviders = providersResponse?.data ?? [];
return (
<ContentLayout title="Scan Configuration" icon="lucide:sliders">
<ScanConfigurationsManager
initialConfigs={configs}
richProviders={richProviders}
schema={schema}
/>
</ContentLayout>
);
}
+7 -7
View File
@@ -92,14 +92,14 @@ describe("getMenuList", () => {
); );
}); });
it("should show Scan Config as disabled Cloud-only in OSS when Cloud is disabled", () => { it("should show Scan Configuration as disabled Cloud-only in OSS when Cloud is disabled", () => {
// Given / When // Given / When
const scanConfig = findSubmenu("Scan Config"); const scanConfig = findSubmenu("Scan Configuration");
// Then // Then
expect(scanConfig).toEqual( expect(scanConfig).toEqual(
expect.objectContaining({ expect.objectContaining({
href: "/scan-config", href: "/scan-configurations",
disabled: true, disabled: true,
cloudOnly: true, cloudOnly: true,
highlight: true, highlight: true,
@@ -108,20 +108,20 @@ describe("getMenuList", () => {
); );
}); });
it("should show Scan Config as new under Configuration when Cloud is enabled", () => { it("should show Scan Configuration as new under Configuration when Cloud is enabled", () => {
// Given // Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true"; process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When // When
const scanConfig = getMenuList({ pathname: "/scan-config" }) const scanConfig = getMenuList({ pathname: "/scan-configurations" })
.flatMap((group) => group.menus) .flatMap((group) => group.menus)
.flatMap((menu) => menu.submenus ?? []) .flatMap((menu) => menu.submenus ?? [])
.find((submenu) => submenu.label === "Scan Config"); .find((submenu) => submenu.label === "Scan Configuration");
// Then // Then
expect(scanConfig).toEqual( expect(scanConfig).toEqual(
expect.objectContaining({ expect.objectContaining({
href: "/scan-config", href: "/scan-configurations",
active: true, active: true,
highlight: true, highlight: true,
}), }),
+3 -3
View File
@@ -133,10 +133,10 @@ export const getMenuList = ({
active: pathname === "/mutelist", active: pathname === "/mutelist",
}, },
{ {
href: "/scan-config", href: "/scan-configurations",
label: "Scan Config", label: "Scan Configuration",
icon: SlidersHorizontal, icon: SlidersHorizontal,
active: isCloudEnv && pathname.startsWith("/scan-config"), active: isCloudEnv && pathname.startsWith("/scan-configurations"),
highlight: true, highlight: true,
disabled: !isCloudEnv, disabled: !isCloudEnv,
cloudOnly: !isCloudEnv, cloudOnly: !isCloudEnv,
+8 -8
View File
@@ -275,14 +275,14 @@ Mutelist:
Tags: Tags:
- "Name=aws-controltower-VPC"`; - "Name=aws-controltower-VPC"`;
export interface ScanConfigValidationError { export interface ScanConfigurationValidationError {
path: string; path: string;
message: string; message: string;
} }
export interface ScanConfigValidationResult { export interface ScanConfigurationValidationResult {
isValid: boolean; isValid: boolean;
errors: ScanConfigValidationError[]; errors: ScanConfigurationValidationError[];
} }
// Compile the JSON Schema with ajv at most once per schema reference. // Compile the JSON Schema with ajv at most once per schema reference.
@@ -316,16 +316,16 @@ const formatAjvPath = (err: ErrorObject): string => {
}; };
/** /**
* Validate a YAML string against the aggregated Scan Config JSON Schema. * Validate a YAML string against the aggregated Scan Configuration JSON Schema.
* *
* If `schema` is null (e.g. backend unreachable), it falls back to only * If `schema` is null (e.g. backend unreachable), it falls back to only
* checking that the YAML parses to a mapping — so the user is never blocked * checking that the YAML parses to a mapping — so the user is never blocked
* from saving when the schema endpoint is down. * from saving when the schema endpoint is down.
*/ */
export const validateScanConfigPayload = ( export const validateScanConfigurationPayload = (
val: string, val: string,
schema: Record<string, unknown> | null, schema: Record<string, unknown> | null,
): ScanConfigValidationResult => { ): ScanConfigurationValidationResult => {
const yamlCheck = validateYaml(val); const yamlCheck = validateYaml(val);
if (!yamlCheck.isValid) { if (!yamlCheck.isValid) {
return { return {
@@ -377,10 +377,10 @@ export const validateScanConfigPayload = (
return { isValid: false, errors }; return { isValid: false, errors };
}; };
export const defaultScanConfigYaml = `# Scan Config overrides the per-tenant defaults documented in export const defaultScanConfigurationYaml = `# Scan Configuration overrides the per-tenant defaults documented in
# prowler/config/config.yaml. Add only the keys you want to override. # prowler/config/config.yaml. Add only the keys you want to override.
# Allowed ranges and enums are described by the server-side JSON Schema # Allowed ranges and enums are described by the server-side JSON Schema
# served at /api/v1/scan-configs/schema; invalid values are flagged below. # served at /api/v1/scan-configurations/schema; invalid values are flagged below.
aws: aws:
max_unused_access_keys_days: 45 max_unused_access_keys_days: 45
+7 -14
View File
@@ -723,10 +723,12 @@ export const mutedFindingsConfigFormSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
}); });
// The schema-driven (ranges/enums/types) validation lives in the editor via // The editor owns content validation: live YAML syntax + schema (ranges/enums)
// `validateScanConfigPayload(yamlString, schema)` in `lib/yaml.ts`. Here we // checks run through `validateScanConfigurationPayload(yamlString, schema)` in
// only enforce the form-level shape: a name and a YAML string that parses. // `lib/yaml.ts` and surface in a single inline panel. Here we only enforce the
export const scanConfigFormSchema = z.object({ // form-level shape (a name and a non-empty configuration) so we don't render the
// same YAML error twice.
export const scanConfigurationFormSchema = z.object({
name: z name: z
.string() .string()
.trim() .trim()
@@ -735,16 +737,7 @@ export const scanConfigFormSchema = z.object({
configuration: z configuration: z
.string() .string()
.trim() .trim()
.min(1, { message: "Configuration is required" }) .min(1, { error: "Configuration is required" }),
.superRefine((val, ctx) => {
const yamlValidation = validateYaml(val);
if (!yamlValidation.isValid) {
ctx.addIssue({
code: "custom",
message: `Invalid YAML format: ${yamlValidation.error}`,
});
}
}),
provider_ids: z.array(z.uuid()).optional().default([]), provider_ids: z.array(z.uuid()).optional().default([]),
id: z.string().optional(), id: z.string().optional(),
}); });
-53
View File
@@ -1,53 +0,0 @@
export interface ScanConfigAttributes {
inserted_at: string;
updated_at: string;
name: string;
configuration: string | Record<string, unknown>;
providers: string[];
}
export interface ScanConfigData {
type: "scan-configs";
id: string;
attributes: ScanConfigAttributes;
}
export interface ScanConfigListResponse {
data: ScanConfigData[];
}
export interface ScanConfigErrors {
name?: string;
configuration?: string;
provider_ids?: string;
general?: string;
}
export interface ScanConfigRequestAttributes {
name: string;
configuration: Record<string, unknown>;
provider_ids: string[];
}
export interface ScanConfigRequestData {
type: "scan-configs";
id?: string;
attributes: ScanConfigRequestAttributes;
}
export interface ScanConfigRequestBody {
data: ScanConfigRequestData;
}
export type ScanConfigActionState = {
errors?: ScanConfigErrors;
success?: string;
data?: ScanConfigData;
} | null;
export type DeleteScanConfigActionState = {
errors?: {
general?: string;
};
success?: string;
} | null;
+55
View File
@@ -0,0 +1,55 @@
export interface ScanConfigurationAttributes {
inserted_at: string;
updated_at: string;
name: string;
configuration: string | Record<string, unknown>;
providers: string[];
}
export interface ScanConfigurationData {
type: "scan-configurations";
id: string;
attributes: ScanConfigurationAttributes;
}
export interface ScanConfigurationListResponse {
data: ScanConfigurationData[];
}
export interface ScanConfigurationErrors {
name?: string;
configuration?: string;
provider_ids?: string;
general?: string;
}
export interface ScanConfigurationRequestAttributes {
name: string;
configuration: Record<string, unknown>;
provider_ids: string[];
}
export interface ScanConfigurationRequestData {
type: "scan-configurations";
id?: string;
attributes: ScanConfigurationRequestAttributes;
}
export interface ScanConfigurationRequestBody {
data: ScanConfigurationRequestData;
}
export type ScanConfigurationActionState = {
errors?: ScanConfigurationErrors;
success?: string;
data?: ScanConfigurationData;
} | null;
export interface DeleteScanConfigurationErrors {
general?: string;
}
export type DeleteScanConfigurationActionState = {
errors?: DeleteScanConfigurationErrors;
success?: string;
} | null;