mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): scan configuration management page (#11695)
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./scan-configs";
|
||||
@@ -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<string, unknown> => {
|
||||
// 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<string, unknown>;
|
||||
};
|
||||
|
||||
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<ScanConfigActionState> => {
|
||||
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<string, unknown>;
|
||||
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<ScanConfigActionState> => {
|
||||
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<string, unknown>;
|
||||
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<Record<
|
||||
string,
|
||||
unknown
|
||||
> | 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<string, unknown>
|
||||
| undefined;
|
||||
return schema ?? null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching Scan Config schema:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const listScanConfigs = async (): Promise<ScanConfigData[]> => {
|
||||
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<ScanConfigData | undefined> => {
|
||||
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<DeleteScanConfigActionState> => {
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface ScanConfigFormProps {
|
||||
onClose: (saved: boolean) => void;
|
||||
richProviders: ProviderProps[];
|
||||
existingConfigs: ScanConfigData[];
|
||||
config: ScanConfigData | null;
|
||||
schema: Record<string, unknown> | 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<typeof scanConfigFormSchema>;
|
||||
type ScanConfigFormValues = z.output<typeof scanConfigFormSchema>;
|
||||
|
||||
const MAX_ERRORS_SHOWN = 10;
|
||||
|
||||
function ScanConfigForm({
|
||||
onClose,
|
||||
richProviders,
|
||||
existingConfigs,
|
||||
config,
|
||||
schema,
|
||||
}: ScanConfigFormProps) {
|
||||
const isEdit = !!config;
|
||||
const { toast } = useToast();
|
||||
const errorPanelRef = useRef<HTMLDivElement | null>(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<ScanConfigFormInput, unknown, ScanConfigFormValues>({
|
||||
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<string, string>();
|
||||
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 (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-5">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="scan-config-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="scan-config-name"
|
||||
placeholder="e.g. stricter-iam-aws"
|
||||
aria-invalid={!!nameError}
|
||||
{...form.register("name")}
|
||||
/>
|
||||
{nameError && <FieldError>{nameError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="scan-config-yaml">Configuration (YAML)</FieldLabel>
|
||||
<p className="text-default-500 text-tiny">
|
||||
Follows the structure of{" "}
|
||||
<CustomLink
|
||||
size="sm"
|
||||
href="https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml"
|
||||
>
|
||||
prowler/config/config.yaml
|
||||
</CustomLink>
|
||||
. Allowed ranges and enums come from the server schema; invalid values
|
||||
are listed below in real time.
|
||||
</p>
|
||||
<Textarea
|
||||
id="scan-config-yaml"
|
||||
placeholder={defaultScanConfigYaml}
|
||||
rows={14}
|
||||
aria-invalid={!!configError || !yamlValidation.isValid}
|
||||
className={fontMono.className + " text-sm"}
|
||||
{...form.register("configuration")}
|
||||
/>
|
||||
<div aria-live="polite" className="mt-1" ref={errorPanelRef}>
|
||||
{yamlValidation.errors.length === 0 && configText.trim() ? (
|
||||
<p className="text-tiny text-success">Configuration valid</p>
|
||||
) : yamlValidation.errors.length > 0 ? (
|
||||
<div className="border-danger-200 bg-danger-50 rounded-md border p-3">
|
||||
<p className="text-tiny text-danger mb-1 font-medium">
|
||||
{yamlValidation.errors.length} validation{" "}
|
||||
{yamlValidation.errors.length === 1 ? "error" : "errors"}:
|
||||
</p>
|
||||
<ul className="text-default-700 text-tiny list-disc space-y-1 pl-5">
|
||||
{yamlValidation.errors
|
||||
.slice(0, MAX_ERRORS_SHOWN)
|
||||
.map((err, idx) => (
|
||||
<li key={`${err.path}-${idx}`}>
|
||||
<code className="text-tiny">{err.path}</code>:{" "}
|
||||
<span>{err.message}</span>
|
||||
</li>
|
||||
))}
|
||||
{yamlValidation.errors.length > MAX_ERRORS_SHOWN && (
|
||||
<li>
|
||||
+ {yamlValidation.errors.length - MAX_ERRORS_SHOWN} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{configError && (
|
||||
<FieldError className="mt-1">{configError}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Attach to accounts</FieldLabel>
|
||||
<p className="text-default-500 text-tiny">
|
||||
Pick the cloud accounts that should use this configuration on their
|
||||
next scan.
|
||||
{lockedCount > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
{lockedCount} {lockedCount === 1 ? "account is" : "accounts are"}{" "}
|
||||
hidden because they are already attached to another Scan Config.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{selectableProviders.length === 0 ? (
|
||||
<p className="text-default-500 text-tiny italic">
|
||||
{richProviders.length === 0
|
||||
? "No providers available in this tenant."
|
||||
: "All providers are already attached to other Scan Configs."}
|
||||
</p>
|
||||
) : (
|
||||
<AccountsSelector
|
||||
providers={selectableProviders}
|
||||
onBatchChange={(_filterKey, values) =>
|
||||
form.setValue("provider_ids", values, { shouldValidate: true })
|
||||
}
|
||||
selectedValues={selectedProviders}
|
||||
search={{
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{providersError && <FieldError>{providersError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onClose(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : isEdit ? "Update" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScanConfigEditor({
|
||||
open,
|
||||
onClose,
|
||||
richProviders,
|
||||
existingConfigs,
|
||||
config,
|
||||
schema,
|
||||
}: ScanConfigEditorProps) {
|
||||
const isEdit = !!config;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose(false);
|
||||
}}
|
||||
title={isEdit ? "Edit Scan Config" : "New Scan Config"}
|
||||
size="2xl"
|
||||
>
|
||||
<ScanConfigForm
|
||||
key={config?.id ?? "new"}
|
||||
onClose={onClose}
|
||||
richProviders={richProviders}
|
||||
existingConfigs={existingConfigs}
|
||||
config={config}
|
||||
schema={schema}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { ScanConfigData } from "@/types/scan-configs";
|
||||
|
||||
export const createScanConfigsColumns = (
|
||||
onEdit: (config: ScanConfigData) => void,
|
||||
onDelete: (config: ScanConfigData) => void,
|
||||
): ColumnDef<ScanConfigData>[] => [
|
||||
{
|
||||
accessorKey: "attributes.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[260px]">
|
||||
<p className="text-text-neutral-primary truncate text-sm font-medium">
|
||||
{row.original.attributes.name}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "providers_count",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Accounts" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const count = row.original.attributes.providers?.length ?? 0;
|
||||
return (
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
{count === 0 ? (
|
||||
<span className="text-text-neutral-tertiary italic">
|
||||
No accounts
|
||||
</span>
|
||||
) : (
|
||||
`${count} ${count === 1 ? "account" : "accounts"}`
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "attributes.updated_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Updated" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="w-[160px]">
|
||||
<DateWithTime dateTime={row.original.attributes.updated_at} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(row.original)}
|
||||
aria-label={`Edit ${row.original.attributes.name}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(row.original)}
|
||||
aria-label={`Delete ${row.original.attributes.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { deleteScanConfig, listScanConfigs } from "@/actions/scan-configs";
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { Button, Card } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
import { ScanConfigData } from "@/types/scan-configs";
|
||||
|
||||
import { ScanConfigEditor } from "./scan-config-editor";
|
||||
import { createScanConfigsColumns } from "./scan-configs-columns";
|
||||
|
||||
// Same column basis classes as `FindingsFilters` so the controls align across
|
||||
// breakpoints with the rest of the product.
|
||||
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)]";
|
||||
|
||||
interface ScanConfigsManagerProps {
|
||||
initialConfigs: ScanConfigData[];
|
||||
richProviders: ProviderProps[];
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function ScanConfigsManager({
|
||||
initialConfigs,
|
||||
richProviders,
|
||||
schema,
|
||||
}: ScanConfigsManagerProps) {
|
||||
const [configs, setConfigs] = useState<ScanConfigData[]>(initialConfigs);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ScanConfigData | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingDelete, setPendingDelete] = useState<ScanConfigData | null>(
|
||||
null,
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [accountFilter, setAccountFilter] = useState<string[]>([]);
|
||||
const [nameSearch, setNameSearch] = useState<string>("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const refresh = async () => {
|
||||
const fresh = await listScanConfigs();
|
||||
setConfigs(fresh);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingConfig(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (config: ScanConfigData) => {
|
||||
setEditingConfig(config);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEditorClose = (saved: boolean) => {
|
||||
setEditorOpen(false);
|
||||
setEditingConfig(null);
|
||||
if (saved) {
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setIsDeleting(true);
|
||||
const formData = new FormData();
|
||||
formData.append("id", pendingDelete.id);
|
||||
|
||||
try {
|
||||
const result = await deleteScanConfig(null, formData);
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: "Scan Config deleted",
|
||||
description: result.success,
|
||||
});
|
||||
await refresh();
|
||||
} else if (result?.errors?.general) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.errors.general,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: "Error deleting Scan Config. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = createScanConfigsColumns(
|
||||
(cfg) => openEdit(cfg),
|
||||
(cfg) => setPendingDelete(cfg),
|
||||
);
|
||||
|
||||
const filteredConfigs = configs.filter((c) => {
|
||||
if (accountFilter.length > 0) {
|
||||
const attached = c.attributes.providers || [];
|
||||
const overlaps = accountFilter.some((pid) => attached.includes(pid));
|
||||
if (!overlaps) return false;
|
||||
}
|
||||
if (nameSearch) {
|
||||
const needle = nameSearch.trim().toLowerCase();
|
||||
if (!c.attributes.name.toLowerCase().includes(needle)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const noMatchForAccount =
|
||||
accountFilter.length > 0 && filteredConfigs.length === 0 && !nameSearch;
|
||||
|
||||
const hasAnyFilter = accountFilter.length > 0 || nameSearch.length > 0;
|
||||
|
||||
const handleAccountsChange = (_filterKey: string, values: string[]) => {
|
||||
setAccountFilter(values);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setAccountFilter([]);
|
||||
setNameSearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<BatchFiltersLayout
|
||||
testIdPrefix="scan-config"
|
||||
controlsClassName="gap-3"
|
||||
controls={
|
||||
<>
|
||||
<div className={FILTER_CONTROL_COLUMN_CLASS}>
|
||||
<AccountsSelector
|
||||
providers={richProviders}
|
||||
onBatchChange={handleAccountsChange}
|
||||
selectedValues={accountFilter}
|
||||
/>
|
||||
</div>
|
||||
{hasAnyFilter && (
|
||||
<ClearFiltersButton
|
||||
showCount
|
||||
pendingCount={
|
||||
accountFilter.length + (nameSearch.trim() ? 1 : 0)
|
||||
}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
)}
|
||||
<Button size="lg" onClick={openCreate} className="md:ml-auto">
|
||||
<Plus className="size-4" />
|
||||
New Scan Config
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{noMatchForAccount ? (
|
||||
<Card variant="base" className="p-8 text-center">
|
||||
<p className="text-default-700 text-sm font-medium">
|
||||
{accountFilter.length === 1
|
||||
? "No Scan Config is attached to this account."
|
||||
: "No Scan Config is attached to any of the selected accounts."}
|
||||
</p>
|
||||
<p className="text-default-500 mt-1 text-sm">
|
||||
The next scan{accountFilter.length === 1 ? "" : "s"} will use the
|
||||
built-in defaults shipped with Prowler. Attach a Scan Config from
|
||||
the editor to override them.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredConfigs}
|
||||
showSearch
|
||||
controlledSearch={nameSearch}
|
||||
onSearchChange={setNameSearch}
|
||||
// No-op commit: presence of this prop disables the 500ms debounce
|
||||
// inside DataTableSearch so the local filter applies on every
|
||||
// keystroke instead of half a second after typing.
|
||||
onSearchCommit={() => undefined}
|
||||
searchPlaceholder="Search by config name..."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScanConfigEditor
|
||||
open={editorOpen}
|
||||
onClose={handleEditorClose}
|
||||
richProviders={richProviders}
|
||||
existingConfigs={configs}
|
||||
config={editingConfig}
|
||||
schema={schema}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={!!pendingDelete}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Delete Scan Config"
|
||||
size="md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-default-600 text-sm">
|
||||
Are you sure you want to delete{" "}
|
||||
<strong>{pendingDelete?.attributes.name}</strong>? Attached accounts
|
||||
will fall back to the built-in scan defaults on their next scan.
|
||||
</p>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => setPendingDelete(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -8,9 +9,11 @@ import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
} from "@/components/findings/table";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
@@ -199,6 +202,13 @@ export const ClientAccordionContent = ({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{requirement.invalid_config && (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
<AlertTriangle />
|
||||
<AlertDescription>{INVALID_CONFIG_NOTE}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{renderDetails()}
|
||||
|
||||
{checks.length > 0 && (
|
||||
|
||||
+5
@@ -1,15 +1,19 @@
|
||||
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
|
||||
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
|
||||
interface ComplianceAccordionRequirementTitleProps {
|
||||
type: string;
|
||||
name: string;
|
||||
status: FindingStatus;
|
||||
invalidConfig?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceAccordionRequirementTitle = ({
|
||||
type,
|
||||
name,
|
||||
status,
|
||||
invalidConfig = false,
|
||||
}: ComplianceAccordionRequirementTitleProps) => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
@@ -20,6 +24,7 @@ export const ComplianceAccordionRequirementTitle = ({
|
||||
</span>
|
||||
)}
|
||||
<span>{name}</span>
|
||||
{invalidConfig && <InfoTooltip content={INVALID_CONFIG_NOTE} />}
|
||||
</div>
|
||||
<StatusFindingBadge status={status} />
|
||||
</div>
|
||||
|
||||
@@ -367,6 +367,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-06-17T11:28:12.866Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "ajv",
|
||||
"from": "8.17.1",
|
||||
"to": "8.18.0",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-06-25T07:13:59.855Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "class-variance-authority",
|
||||
|
||||
@@ -91,6 +91,7 @@ export const mapComplianceData = (
|
||||
description: description,
|
||||
status: status,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -150,6 +151,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={control.label}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -69,6 +69,7 @@ export const mapComplianceData = (
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -132,6 +133,7 @@ export const toAccordionItems = (
|
||||
requirement.name
|
||||
}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -72,6 +72,7 @@ export const mapComplianceData = (
|
||||
description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
...getStatusCounters(finalStatus),
|
||||
type: attrs.Type,
|
||||
about_criteria: attrs.AboutCriteria,
|
||||
@@ -101,6 +102,7 @@ const createRequirementItem = (
|
||||
type={requirement.type as string}
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -93,6 +93,7 @@ const createRequirement = (itemData: ProcessedItem): Requirement => {
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -179,6 +180,7 @@ const createRequirementAccordionItem = (
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -80,6 +80,7 @@ export const mapComplianceData = (
|
||||
description: attrs.Description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -138,6 +139,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={control.label}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
// Note shown when a requirement fails only because the applied scan config
|
||||
// does not satisfy its requirements, even though every finding passed.
|
||||
export const INVALID_CONFIG_NOTE =
|
||||
"Marked as FAIL because the applied scan configuration does not meet this requirement, even though all findings passed.";
|
||||
|
||||
// Type for the internal map used in getTopFailedSections
|
||||
interface FailedSectionData {
|
||||
total: number;
|
||||
|
||||
@@ -78,6 +78,7 @@ export const mapComplianceData = (
|
||||
description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
...getStatusCounters(finalStatus),
|
||||
ccm_lite: attrs.CCMLite,
|
||||
iaas: attrs.IaaS,
|
||||
@@ -122,6 +123,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -77,6 +77,7 @@ export const mapComplianceData = (
|
||||
description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
...getStatusCounters(finalStatus),
|
||||
pillar: attrs.Pillar,
|
||||
article: attrs.Article,
|
||||
@@ -133,6 +134,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -91,6 +91,7 @@ export const mapComplianceData = (
|
||||
status: finalStatus,
|
||||
type,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -158,6 +159,7 @@ export const toAccordionItems = (
|
||||
type={requirement.type as string}
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -42,6 +42,7 @@ const createRequirement = (itemData: ProcessedItem): Requirement => {
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -163,6 +164,7 @@ const createRequirementAccordionItem = (
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
@@ -249,6 +251,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={control.label}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -67,6 +67,7 @@ export const mapComplianceData = (
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -116,6 +117,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={(requirement.control_label as string) || requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -66,6 +66,7 @@ export const mapComplianceData = (
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -125,6 +126,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={requirement.section as string}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -71,6 +71,7 @@ export const mapComplianceData = (
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -133,6 +134,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={requirement.name}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -78,6 +78,7 @@ export const mapComplianceData = (
|
||||
description: description,
|
||||
status: finalStatus,
|
||||
check_ids: checks,
|
||||
invalid_config: requirementData.attributes.invalid_config || false,
|
||||
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
|
||||
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
|
||||
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
|
||||
@@ -213,6 +214,7 @@ export const toAccordionItems = (
|
||||
type=""
|
||||
name={`${requirement.name} - ${requirement.title || requirement.description}`}
|
||||
status={requirement.status as FindingStatus}
|
||||
invalidConfig={requirement.invalid_config}
|
||||
/>
|
||||
),
|
||||
content: (
|
||||
|
||||
@@ -92,6 +92,42 @@ describe("getMenuList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should show Scan Config as disabled Cloud-only in OSS when Cloud is disabled", () => {
|
||||
// Given / When
|
||||
const scanConfig = findSubmenu("Scan Config");
|
||||
|
||||
// Then
|
||||
expect(scanConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/scan-config",
|
||||
disabled: true,
|
||||
cloudOnly: true,
|
||||
highlight: true,
|
||||
active: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show Scan Config as new under Configuration when Cloud is enabled", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const scanConfig = getMenuList({ pathname: "/scan-config" })
|
||||
.flatMap((group) => group.menus)
|
||||
.flatMap((menu) => menu.submenus ?? [])
|
||||
.find((submenu) => submenu.label === "Scan Config");
|
||||
|
||||
// Then
|
||||
expect(scanConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/scan-config",
|
||||
active: true,
|
||||
highlight: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove the new highlight from Attack Paths", () => {
|
||||
// Given / When
|
||||
const attackPaths = findMenu("Attack Paths");
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Puzzle,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
SquareChartGantt,
|
||||
Tag,
|
||||
Timer,
|
||||
@@ -131,6 +132,15 @@ export const getMenuList = ({
|
||||
icon: VolumeX,
|
||||
active: pathname === "/mutelist",
|
||||
},
|
||||
{
|
||||
href: "/scan-config",
|
||||
label: "Scan Config",
|
||||
icon: SlidersHorizontal,
|
||||
active: isCloudEnv && pathname.startsWith("/scan-config"),
|
||||
highlight: true,
|
||||
disabled: !isCloudEnv,
|
||||
cloudOnly: !isCloudEnv,
|
||||
},
|
||||
{ href: "/scans", label: "Scan Jobs", icon: Timer },
|
||||
{ href: "/integrations", label: "Integrations", icon: Puzzle },
|
||||
{ href: "/roles", label: "Roles", icon: UserCog },
|
||||
|
||||
+115
@@ -1,3 +1,4 @@
|
||||
import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
||||
@@ -273,3 +274,117 @@ Mutelist:
|
||||
- "*"
|
||||
Tags:
|
||||
- "Name=aws-controltower-VPC"`;
|
||||
|
||||
export interface ScanConfigValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ScanConfigValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ScanConfigValidationError[];
|
||||
}
|
||||
|
||||
// Compile the JSON Schema with ajv at most once per schema reference.
|
||||
const _ajv = new Ajv({ allErrors: true, strict: false });
|
||||
const _validatorCache = new WeakMap<object, ValidateFunction>();
|
||||
|
||||
const getValidator = (schema: Record<string, unknown>): ValidateFunction => {
|
||||
const cached = _validatorCache.get(schema);
|
||||
if (cached) return cached;
|
||||
const compiled = _ajv.compile(schema);
|
||||
_validatorCache.set(schema, compiled);
|
||||
return compiled;
|
||||
};
|
||||
|
||||
const formatAjvPath = (err: ErrorObject): string => {
|
||||
// instancePath is a JSON Pointer (/aws/max_unused_access_keys_days or /aws/ec2_high_risk_ports/1).
|
||||
// Convert to a dotted form with list indices as [n].
|
||||
const raw = err.instancePath.replace(/^\//, "");
|
||||
if (!raw) {
|
||||
const extra = (err.params as { additionalProperty?: string })
|
||||
?.additionalProperty;
|
||||
return extra ? extra : "<root>";
|
||||
}
|
||||
return raw
|
||||
.split("/")
|
||||
.map((piece) => piece.replace(/~1/g, "/").replace(/~0/g, "~"))
|
||||
.reduce<string>((acc, piece) => {
|
||||
if (/^\d+$/.test(piece)) return `${acc}[${piece}]`;
|
||||
return acc ? `${acc}.${piece}` : piece;
|
||||
}, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a YAML string against the aggregated Scan Config JSON Schema.
|
||||
*
|
||||
* 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
|
||||
* from saving when the schema endpoint is down.
|
||||
*/
|
||||
export const validateScanConfigPayload = (
|
||||
val: string,
|
||||
schema: Record<string, unknown> | null,
|
||||
): ScanConfigValidationResult => {
|
||||
const yamlCheck = validateYaml(val);
|
||||
if (!yamlCheck.isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [{ path: "<root>", message: `Invalid YAML: ${yamlCheck.error}` }],
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = yaml.load(val);
|
||||
} catch (e) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
path: "<root>",
|
||||
message: e instanceof Error ? e.message : "Failed to parse YAML",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
path: "<root>",
|
||||
message:
|
||||
"YAML must be a mapping with provider sections (aws, azure, ...).",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
|
||||
const validate = getValidator(schema);
|
||||
if (validate(parsed)) {
|
||||
return { isValid: true, errors: [] };
|
||||
}
|
||||
const errors = (validate.errors ?? []).map((err) => ({
|
||||
path: formatAjvPath(err),
|
||||
message: err.message ?? "Invalid value",
|
||||
}));
|
||||
return { isValid: false, errors };
|
||||
};
|
||||
|
||||
export const defaultScanConfigYaml = `# Scan Config overrides the per-tenant defaults documented in
|
||||
# prowler/config/config.yaml. Add only the keys you want to override.
|
||||
# Allowed ranges and enums are described by the server-side JSON Schema
|
||||
# served at /api/v1/scan-configs/schema; invalid values are flagged below.
|
||||
|
||||
aws:
|
||||
max_unused_access_keys_days: 45
|
||||
|
||||
# azure:
|
||||
# php_latest_version: "8.2"
|
||||
`;
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"@uiw/react-codemirror": "4.25.8",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"ai": "6.0.203",
|
||||
"ajv": "8.18.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
|
||||
Generated
+4
-1
@@ -173,6 +173,9 @@ importers:
|
||||
ai:
|
||||
specifier: 6.0.203
|
||||
version: 6.0.203(zod@4.4.3)
|
||||
ajv:
|
||||
specifier: 8.18.0
|
||||
version: 8.18.0
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -8625,7 +8628,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider@3.0.10':
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface Requirement {
|
||||
fail: number;
|
||||
manual: number;
|
||||
check_ids: string[];
|
||||
// True when the FAIL is caused solely by an invalid scan config.
|
||||
invalid_config?: boolean;
|
||||
// This is to allow any key to be added to the requirement object
|
||||
// because each compliance has different keys
|
||||
[key: string]: string | string[] | number | boolean | object[] | undefined;
|
||||
@@ -440,6 +442,8 @@ export interface RequirementItemData {
|
||||
version: string;
|
||||
description: string;
|
||||
status: RequirementStatus;
|
||||
// True when the FAIL is caused solely by an invalid scan config.
|
||||
invalid_config?: boolean;
|
||||
// For Threat compliance:
|
||||
passed_findings?: number;
|
||||
total_findings?: number;
|
||||
|
||||
@@ -722,3 +722,29 @@ export const mutedFindingsConfigFormSchema = z.object({
|
||||
}),
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
// The schema-driven (ranges/enums/types) validation lives in the editor via
|
||||
// `validateScanConfigPayload(yamlString, schema)` in `lib/yaml.ts`. Here we
|
||||
// only enforce the form-level shape: a name and a YAML string that parses.
|
||||
export const scanConfigFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: "Name must be at least 3 characters" })
|
||||
.max(100, { message: "Name must be at most 100 characters" }),
|
||||
configuration: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: "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([]),
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user