mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): improve scan config ux (#11731)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -6,9 +6,6 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Add `Scan Configuration` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
|
||||
- Scan configuration management page (`/scan-configurations`) to create, edit, and manage scan configurations with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
|
||||
- Surface an "invalid scan configuration" note on compliance requirements that fail solely because the applied scan config does not meet them [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
|
||||
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
|
||||
- CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700)
|
||||
|
||||
|
||||
@@ -14,13 +14,17 @@ import {
|
||||
ScanConfigurationRequestBody,
|
||||
} from "@/types/scan-configurations";
|
||||
|
||||
const SCAN_CONFIGURATION_PATH = "/scan-configurations";
|
||||
const SCAN_CONFIGURATION_PATH = "/scans/config";
|
||||
|
||||
// Scan Configuration IDs are UUIDs. Validate before interpolating into request
|
||||
// URLs so a malformed/crafted value can't inject path segments (SSRF / path
|
||||
// injection).
|
||||
const scanConfigurationIdSchema = z.uuid();
|
||||
|
||||
// Provider IDs are UUIDs too. Validate the whole array at the action boundary so
|
||||
// a malformed/crafted id fails here instead of relying on API-side validation.
|
||||
const providerIdsSchema = z.array(z.uuid());
|
||||
|
||||
const parseConfiguration = (value: string): Record<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.
|
||||
@@ -38,6 +42,48 @@ const collectProviderIds = (formData: FormData): string[] => {
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
interface ApiErrorSource {
|
||||
pointer?: string;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
detail?: string;
|
||||
title?: string;
|
||||
source?: ApiErrorSource;
|
||||
}
|
||||
|
||||
// Route each JSON:API error to the matching form field via its `source.pointer`
|
||||
// so it renders inline next to the offending input. Only errors we can't anchor
|
||||
// to a field fall back to `general` (surfaced as a toast). Shared by create and
|
||||
// update so both flows present validation errors identically — otherwise a
|
||||
// config error shows inline on create but as a toast on update.
|
||||
const mapApiErrorsToFields = (
|
||||
errorData: { errors?: ApiError[]; message?: string } | null | undefined,
|
||||
fallbackMessage: string,
|
||||
): ScanConfigurationErrors => {
|
||||
const apiErrors = Array.isArray(errorData?.errors) ? errorData!.errors! : [];
|
||||
|
||||
if (apiErrors.length === 0) {
|
||||
return { general: errorData?.message || fallbackMessage };
|
||||
}
|
||||
|
||||
const errors: ScanConfigurationErrors = {};
|
||||
const append = (key: keyof ScanConfigurationErrors, detail: string) => {
|
||||
errors[key] = errors[key] ? `${errors[key]}\n${detail}` : detail;
|
||||
};
|
||||
|
||||
for (const err of apiErrors) {
|
||||
const detail = err?.detail || err?.title || fallbackMessage;
|
||||
const pointer = err?.source?.pointer;
|
||||
if (pointer?.includes("name")) append("name", detail);
|
||||
else if (pointer?.includes("configuration"))
|
||||
append("configuration", detail);
|
||||
else if (pointer?.includes("provider_ids")) append("provider_ids", detail);
|
||||
else append("general", detail);
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const createScanConfiguration = async (
|
||||
_prevState: ScanConfigurationActionState,
|
||||
formData: FormData,
|
||||
@@ -95,20 +141,12 @@ export const createScanConfiguration = async (
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const detail =
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.message ||
|
||||
`Failed to create Scan Configuration: ${response.statusText}`;
|
||||
const pointer = errorData?.errors?.[0]?.source?.pointer as
|
||||
| string
|
||||
| undefined;
|
||||
const errors: ScanConfigurationErrors = {};
|
||||
if (pointer?.includes("name")) errors.name = detail;
|
||||
else if (pointer?.includes("configuration"))
|
||||
errors.configuration = detail;
|
||||
else if (pointer?.includes("provider_ids")) errors.provider_ids = detail;
|
||||
else errors.general = detail;
|
||||
return { errors };
|
||||
return {
|
||||
errors: mapApiErrorsToFields(
|
||||
errorData,
|
||||
`Failed to create Scan Configuration: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -199,11 +237,12 @@ export const updateScanConfiguration = async (
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const detail =
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.message ||
|
||||
`Failed to update Scan Configuration: ${response.statusText}`;
|
||||
return { errors: { general: detail } };
|
||||
return {
|
||||
errors: mapApiErrorsToFields(
|
||||
errorData,
|
||||
`Failed to update Scan Configuration: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -225,30 +264,67 @@ export const updateScanConfiguration = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getScanConfigurationSchema = async (): Promise<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/scan-configurations/schema`);
|
||||
// Attach/detach providers on a scan configuration without touching its name or
|
||||
// YAML — a partial PATCH of `provider_ids` only. Used by the provider row to
|
||||
// associate/disassociate a config (editing the config itself lives in the Scan
|
||||
// Config view). The backend's `(tenant, provider)` uniqueness means attaching a
|
||||
// provider here moves it off any other config automatically.
|
||||
export const setScanConfigurationProviders = async (
|
||||
configId: string,
|
||||
providerIds: string[],
|
||||
): Promise<ScanConfigurationActionState> => {
|
||||
const idResult = scanConfigurationIdSchema.safeParse(configId);
|
||||
if (!idResult.success) {
|
||||
return { errors: { general: "Invalid Scan Configuration ID" } };
|
||||
}
|
||||
const validId = idResult.data;
|
||||
const providerIdsResult = providerIdsSchema.safeParse(providerIds);
|
||||
if (!providerIdsResult.success) {
|
||||
return { errors: { provider_ids: "Invalid provider ID" } };
|
||||
}
|
||||
const validProviderIds = providerIdsResult.data;
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`);
|
||||
// Partial update: only provider_ids (name/configuration are optional on the
|
||||
// backend update serializer), so we don't type this as the full request body.
|
||||
const bodyData = {
|
||||
data: {
|
||||
type: "scan-configurations" as const,
|
||||
id: validId,
|
||||
attributes: { provider_ids: validProviderIds },
|
||||
},
|
||||
};
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Scan Configuration schema: ${response.statusText}`,
|
||||
);
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
errors: mapApiErrorsToFields(
|
||||
errorData,
|
||||
`Failed to update Scan Configuration: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
const json = await response.json();
|
||||
const schema = json?.data?.attributes?.schema as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return schema ?? null;
|
||||
|
||||
revalidatePath(SCAN_CONFIGURATION_PATH);
|
||||
revalidatePath("/providers");
|
||||
return { success: "Scan Configuration updated successfully!" };
|
||||
} catch (error) {
|
||||
console.error("Error fetching Scan Configuration schema:", error);
|
||||
return null;
|
||||
console.error("Error updating Scan Configuration providers:", error);
|
||||
return {
|
||||
errors: {
|
||||
general:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error updating Scan Configuration. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -43,4 +43,13 @@ describe("providers page", () => {
|
||||
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
|
||||
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
|
||||
});
|
||||
|
||||
it("does not collapse scan config loading failures into an empty list", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(pagePath, "utf8");
|
||||
|
||||
expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE");
|
||||
expect(source).not.toContain("catch {\n return [];");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { listScanConfigurations } from "@/actions/scan-configurations";
|
||||
import { ProvidersAccountsView } from "@/components/providers";
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
import { CliImportBanner } from "@/components/scans";
|
||||
@@ -7,6 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
type ScanConfigurationListState,
|
||||
} from "@/types/scan-configurations";
|
||||
|
||||
import { ProviderGroupsContent } from "./provider-groups-content";
|
||||
import { ProviderPageTabs } from "./provider-page-tabs";
|
||||
@@ -103,24 +108,45 @@ const ProviderGroupsFallback = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const loadScanConfigs = async (
|
||||
isCloud: boolean,
|
||||
): Promise<ScanConfigurationListState> => {
|
||||
if (!isCloud) {
|
||||
return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
data: await listScanConfigurations(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading provider scan configurations:", error);
|
||||
return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const ProvidersTabContent = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const providersView = await loadProvidersAccountsViewData({
|
||||
searchParams,
|
||||
isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true",
|
||||
});
|
||||
const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const [providersView, scanConfigsState] = await Promise.all([
|
||||
loadProvidersAccountsViewData({ searchParams, isCloud }),
|
||||
loadScanConfigs(isCloud),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProvidersAccountsView
|
||||
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
|
||||
isCloud={isCloud}
|
||||
filters={providersView.filters}
|
||||
providers={providersView.providers}
|
||||
providerGroups={providersView.providerGroups}
|
||||
metadata={providersView.metadata}
|
||||
rows={providersView.rows}
|
||||
scanConfigs={scanConfigsState.data}
|
||||
scanConfigStatus={scanConfigsState.status}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+68
-64
@@ -21,11 +21,10 @@ import {
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
import {
|
||||
convertToYaml,
|
||||
defaultScanConfigurationYaml,
|
||||
validateScanConfigurationPayload,
|
||||
validateYaml,
|
||||
} from "@/lib/yaml";
|
||||
import { scanConfigurationFormSchema } from "@/types/formSchemas";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
@@ -37,7 +36,6 @@ interface ScanConfigurationEditorProps {
|
||||
richProviders: ProviderProps[];
|
||||
existingConfigs: ScanConfigurationData[];
|
||||
config: ScanConfigurationData | null;
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface ScanConfigurationFormProps {
|
||||
@@ -45,7 +43,6 @@ interface ScanConfigurationFormProps {
|
||||
richProviders: ProviderProps[];
|
||||
existingConfigs: ScanConfigurationData[];
|
||||
config: ScanConfigurationData | null;
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// `provider_ids` has a zod `.default([])`, so the resolver's input and output
|
||||
@@ -53,14 +50,11 @@ interface ScanConfigurationFormProps {
|
||||
type ScanConfigurationFormInput = z.input<typeof scanConfigurationFormSchema>;
|
||||
type ScanConfigurationFormValues = z.output<typeof scanConfigurationFormSchema>;
|
||||
|
||||
const MAX_ERRORS_SHOWN = 10;
|
||||
|
||||
function ScanConfigurationForm({
|
||||
onClose,
|
||||
richProviders,
|
||||
existingConfigs,
|
||||
config,
|
||||
schema,
|
||||
}: ScanConfigurationFormProps) {
|
||||
const isEdit = !!config;
|
||||
const { toast } = useToast();
|
||||
@@ -87,12 +81,13 @@ function ScanConfigurationForm({
|
||||
const configText = form.watch("configuration") || "";
|
||||
const selectedProviders = form.watch("provider_ids") || [];
|
||||
|
||||
// Real-time validation against the server schema (ranges/enums). Kept out of
|
||||
// form state because it's derived purely from the current YAML text — skip it
|
||||
// while the field is empty so we don't flag an error before the user types.
|
||||
const yamlValidation = configText.trim()
|
||||
? validateScanConfigurationPayload(configText, schema)
|
||||
: { isValid: true, errors: [] };
|
||||
// Mirror the Mutelist editor: the client validates YAML *syntax* live (that it
|
||||
// parses to a mapping); the API validates the configuration values
|
||||
// (ranges/enums) on save and returns them inline. Skip while empty so we don't
|
||||
// flag an error before the user types.
|
||||
const yamlSyntax = configText.trim()
|
||||
? validateYaml(configText)
|
||||
: { isValid: true as const };
|
||||
|
||||
// A provider can only be attached to one config at a time. We exclude
|
||||
// providers that are owned by *other* configs from the selector so the user
|
||||
@@ -111,9 +106,9 @@ function ScanConfigurationForm({
|
||||
const lockedCount = richProviders.length - selectableProviders.length;
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
// The inline panel already lists every schema/syntax error in real time, so
|
||||
// we don't duplicate them in a toast — just bring the panel into view.
|
||||
if (yamlValidation.errors.length > 0) {
|
||||
// Block on a YAML syntax error (the inline message already explains it); the
|
||||
// API validates the values on save and returns any errors inline.
|
||||
if (!yamlSyntax.isValid) {
|
||||
errorPanelRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
@@ -194,64 +189,64 @@ function ScanConfigurationForm({
|
||||
<FieldLabel htmlFor="scan-configuration-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>
|
||||
<ul className="text-default-500 text-tiny mb-1 list-disc pl-5">
|
||||
<li>
|
||||
Follows the structure of{" "}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/cli/tutorials/configuration_file"
|
||||
>
|
||||
prowler/config/config.yaml
|
||||
</CustomLink>
|
||||
; include only the keys you want to override.
|
||||
</li>
|
||||
<li>The configuration is validated on save.</li>
|
||||
<li>
|
||||
Learn more about configuring scans{" "}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
|
||||
>
|
||||
here
|
||||
</CustomLink>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
<Textarea
|
||||
id="scan-configuration-yaml"
|
||||
placeholder={defaultScanConfigurationYaml}
|
||||
rows={14}
|
||||
aria-invalid={!!configError || !yamlValidation.isValid}
|
||||
className={fontMono.className + " text-sm"}
|
||||
{...form.register("configuration")}
|
||||
aria-invalid={!!configError || !yamlSyntax.isValid}
|
||||
font="mono"
|
||||
{...form.register("configuration", {
|
||||
// A server-side validation error becomes stale the moment the user
|
||||
// edits the YAML — clear it so it can't linger next to the live
|
||||
// client-side syntax check.
|
||||
onChange: () => form.clearErrors("configuration"),
|
||||
})}
|
||||
/>
|
||||
<div aria-live="polite" className="mt-1" ref={errorPanelRef}>
|
||||
{yamlValidation.errors.length === 0 && configText.trim() ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="mt-1 flex flex-col gap-1"
|
||||
ref={errorPanelRef}
|
||||
>
|
||||
{!yamlSyntax.isValid ? (
|
||||
<FieldError>{`Invalid YAML format: ${yamlSyntax.error}`}</FieldError>
|
||||
) : configText.trim() && !configError ? (
|
||||
<p className="text-tiny text-text-success-primary">
|
||||
Configuration valid
|
||||
Valid YAML format
|
||||
</p>
|
||||
) : yamlValidation.errors.length > 0 ? (
|
||||
<div className="border-border-error rounded-md border bg-red-50 p-3 dark:bg-red-950/50">
|
||||
<p className="text-tiny text-text-error-primary 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>
|
||||
)}
|
||||
{configError && <FieldError multiline>{configError}</FieldError>}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Attach to providers</FieldLabel>
|
||||
<FieldLabel>Attach to providers (optional)</FieldLabel>
|
||||
<p className="text-default-500 text-tiny">
|
||||
Pick the cloud providers that should use this configuration on their
|
||||
next scan.
|
||||
Pick the providers that should use this configuration on their next
|
||||
scan. You can save it without any and attach providers later — it just
|
||||
won't apply to a scan until one is attached.
|
||||
{lockedCount > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -274,6 +269,12 @@ function ScanConfigurationForm({
|
||||
form.setValue("provider_ids", values, { shouldValidate: true })
|
||||
}
|
||||
selectedValues={selectedProviders}
|
||||
// Here an empty selection means "no providers attached" (the field
|
||||
// is optional), not the filter default of "all providers". Override
|
||||
// the filter-oriented labels so the control reads correctly.
|
||||
placeholder="No providers selected"
|
||||
emptySelectionLabel="No providers selected"
|
||||
clearSelectionLabel="Clear selection"
|
||||
search={{
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
@@ -293,7 +294,11 @@ function ScanConfigurationForm({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isSubmitting || !yamlSyntax.isValid}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : isEdit ? "Update" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -307,7 +312,6 @@ export function ScanConfigurationEditor({
|
||||
richProviders,
|
||||
existingConfigs,
|
||||
config,
|
||||
schema,
|
||||
}: ScanConfigurationEditorProps) {
|
||||
const isEdit = !!config;
|
||||
|
||||
@@ -319,6 +323,7 @@ export function ScanConfigurationEditor({
|
||||
}}
|
||||
title={isEdit ? "Edit Scan Configuration" : "New Scan Configuration"}
|
||||
size="2xl"
|
||||
scrollable
|
||||
>
|
||||
<ScanConfigurationForm
|
||||
key={config?.id ?? "new"}
|
||||
@@ -326,7 +331,6 @@ export function ScanConfigurationEditor({
|
||||
richProviders={richProviders}
|
||||
existingConfigs={existingConfigs}
|
||||
config={config}
|
||||
schema={schema}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
+22
-20
@@ -3,7 +3,11 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
@@ -61,26 +65,24 @@ export const createScanConfigurationsColumns = (
|
||||
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}`}
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
ariaLabel={`Open actions menu for ${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>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit"
|
||||
onSelect={() => onEdit(row.original)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete"
|
||||
destructive
|
||||
onSelect={() => onDelete(row.original)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
+19
-18
@@ -28,13 +28,11 @@ const FILTER_CONTROL_COLUMN_CLASS =
|
||||
interface ScanConfigurationsManagerProps {
|
||||
initialConfigs: ScanConfigurationData[];
|
||||
richProviders: ProviderProps[];
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function ScanConfigurationsManager({
|
||||
initialConfigs,
|
||||
richProviders,
|
||||
schema,
|
||||
}: ScanConfigurationsManagerProps) {
|
||||
const [configs, setConfigs] =
|
||||
useState<ScanConfigurationData[]>(initialConfigs);
|
||||
@@ -172,27 +170,31 @@ export function ScanConfigurationsManager({
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
)}
|
||||
<Button size="lg" onClick={openCreate} className="md:ml-auto">
|
||||
<Plus className="size-4" />
|
||||
New Scan Configuration
|
||||
</Button>
|
||||
<div className="md:ml-auto">
|
||||
<Button size="lg" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
New Scan Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{noMatchForProvider ? (
|
||||
<Card variant="base" className="p-8 text-center">
|
||||
<p className="text-default-700 text-sm font-medium">
|
||||
{providerFilter.length === 1
|
||||
? "No Scan Configuration is attached to this provider."
|
||||
: "No Scan Configuration is attached to any of the selected providers."}
|
||||
</p>
|
||||
<p className="text-default-500 mt-1 text-sm">
|
||||
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
|
||||
built-in defaults shipped with Prowler. Attach a Scan Configuration
|
||||
from the editor to override them.
|
||||
</p>
|
||||
<Card variant="base" padding="xl">
|
||||
<div className="text-center">
|
||||
<p className="text-default-700 text-sm font-medium">
|
||||
{providerFilter.length === 1
|
||||
? "No Scan Configuration is attached to this provider."
|
||||
: "No Scan Configuration is attached to any of the selected providers."}
|
||||
</p>
|
||||
<p className="text-default-500 mt-1 text-sm">
|
||||
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
|
||||
built-in defaults shipped with Prowler. Attach a Scan
|
||||
Configuration from the editor to override them.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<DataTable
|
||||
@@ -215,7 +217,6 @@ export function ScanConfigurationsManager({
|
||||
richProviders={richProviders}
|
||||
existingConfigs={configs}
|
||||
config={editingConfig}
|
||||
schema={schema}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("scan config page", () => {
|
||||
it("does not block SSR on the full providers crawl", () => {
|
||||
// Given
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(path.join(currentDir, "page.tsx"), "utf8");
|
||||
|
||||
// Then
|
||||
expect(source).not.toContain("getAllProviders");
|
||||
expect(source).toContain("getProviders({ pageSize: 100 })");
|
||||
expect(source).toContain("throw new Error");
|
||||
});
|
||||
});
|
||||
+10
-11
@@ -1,10 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
getScanConfigurationSchema,
|
||||
listScanConfigurations,
|
||||
} from "@/actions/scan-configurations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { listScanConfigurations } from "@/actions/scan-configurations";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
@@ -19,20 +16,22 @@ export default async function ScanConfigPage() {
|
||||
|
||||
// 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([
|
||||
const [configs, providersResponse] = await Promise.all([
|
||||
listScanConfigurations(),
|
||||
getAllProviders({}),
|
||||
getScanConfigurationSchema(),
|
||||
getProviders({ pageSize: 100 }),
|
||||
]);
|
||||
|
||||
const richProviders = providersResponse?.data ?? [];
|
||||
if (!providersResponse) {
|
||||
throw new Error("Failed to load Providers for Scan Configuration.");
|
||||
}
|
||||
|
||||
const richProviders = providersResponse.data;
|
||||
|
||||
return (
|
||||
<ContentLayout title="Scan Configuration" icon="lucide:sliders">
|
||||
<ContentLayout title="Configuration" icon="lucide:sliders">
|
||||
<ScanConfigurationsManager
|
||||
initialConfigs={configs}
|
||||
richProviders={richProviders}
|
||||
schema={schema}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
@@ -221,7 +221,7 @@ export default async function Scans({
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Scan Jobs"
|
||||
title="Scans"
|
||||
icon="lucide:timer"
|
||||
onboardingAction={onboardingAction}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
type ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
type ScanConfigurationData,
|
||||
} from "@/types/scan-configurations";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({
|
||||
@@ -40,6 +44,7 @@ vi.mock("./table", () => ({
|
||||
|
||||
import {
|
||||
computeSelectedScheduleProviders,
|
||||
createScanConfigIdByProviderId,
|
||||
ProvidersAccountsTable,
|
||||
} from "./providers-accounts-table";
|
||||
|
||||
@@ -90,6 +95,17 @@ const createProviderRow = (
|
||||
const providerOne = createProviderRow("provider-1", "111111111111", "Prod");
|
||||
const providerTwo = createProviderRow("provider-2", "222222222222", "Stage");
|
||||
const providerThree = createProviderRow("provider-3", "333333333333", "Dev");
|
||||
const scanConfig: ScanConfigurationData = {
|
||||
type: "scan-configurations",
|
||||
id: "config-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
name: "Strict AWS",
|
||||
configuration: {},
|
||||
providers: ["provider-1"],
|
||||
},
|
||||
};
|
||||
|
||||
const organizationRow: ProvidersTableRow = {
|
||||
id: "org-1",
|
||||
@@ -147,9 +163,44 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes populated scan configs to provider row action columns", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsTable
|
||||
isCloud
|
||||
metadata={metadata}
|
||||
rows={[]}
|
||||
scanConfigs={[scanConfig]}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const call = getColumnProvidersMock.mock.calls.at(-1);
|
||||
expect(call?.[8]).toEqual([scanConfig]);
|
||||
expect(call?.[9]).toBe(SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE);
|
||||
expect(call?.[10]).toBeInstanceOf(Map);
|
||||
expect((call?.[10] as Map<string, string>).get("provider-1")).toBe(
|
||||
"config-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("precomputes scan config ids by provider id once for row actions", () => {
|
||||
// Given/When
|
||||
const lookup = createScanConfigIdByProviderId([scanConfig]);
|
||||
|
||||
// Then
|
||||
expect(lookup.get("provider-1")).toBe("config-1");
|
||||
});
|
||||
|
||||
describe("schedule provider selection", () => {
|
||||
it("uses the selected provider id for provider rows", () => {
|
||||
// Given
|
||||
@@ -268,6 +319,9 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -301,6 +355,9 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
isProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
@@ -26,6 +31,10 @@ interface ProvidersAccountsTableProps {
|
||||
metadata?: MetaDataProps;
|
||||
rows: ProvidersTableRow[];
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
/** All scan configurations in the tenant, for the provider row's associate/
|
||||
* disassociate action (Cloud-only). */
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
}
|
||||
@@ -154,11 +163,27 @@ export function computeSelectedScheduleProviders(
|
||||
return { providerIds, providers };
|
||||
}
|
||||
|
||||
export function createScanConfigIdByProviderId(
|
||||
scanConfigs: ScanConfigurationData[],
|
||||
): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
for (const config of scanConfigs) {
|
||||
for (const providerId of config.attributes.providers) {
|
||||
lookup.set(providerId, config.id);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function ProvidersAccountsTableContent({
|
||||
isCloud,
|
||||
metadata,
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
scanConfigs,
|
||||
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
}: ProvidersAccountsTableProps) {
|
||||
@@ -170,6 +195,9 @@ function ProvidersAccountsTableContent({
|
||||
rowSelection,
|
||||
);
|
||||
const selectedScheduleProviderIds = selectedScheduleProviders.providerIds;
|
||||
const scanConfigIdByProviderId = createScanConfigIdByProviderId(
|
||||
scanConfigs ?? [],
|
||||
);
|
||||
|
||||
const clearSelection = () => setRowSelection({});
|
||||
|
||||
@@ -182,6 +210,9 @@ function ProvidersAccountsTableContent({
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
scanScheduleCapability,
|
||||
scanConfigs ?? [],
|
||||
scanConfigStatus,
|
||||
scanConfigIdByProviderId,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
import type {
|
||||
ScanConfigurationData,
|
||||
ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
const addProviderFlow = getFlowById("add-provider")!;
|
||||
@@ -56,6 +60,10 @@ interface ProvidersAccountsViewProps {
|
||||
rows: ProvidersTableRow[];
|
||||
/** Cloud overlay seam for provider-creation scan launch. */
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
/** All scan configurations in the tenant, for the provider row's associate/
|
||||
* disassociate action (Cloud-only). */
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
isScanLimitReached?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,8 @@ export function ProvidersAccountsView({
|
||||
providerGroups = [],
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
scanConfigs,
|
||||
scanConfigStatus,
|
||||
isScanLimitReached,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -157,6 +167,8 @@ export function ProvidersAccountsView({
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
import { ManageScanConfigModal } from "./manage-scan-config-modal";
|
||||
|
||||
const { setScanConfigurationProvidersMock, toastMock } = vi.hoisted(() => ({
|
||||
setScanConfigurationProvidersMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scan-configurations", () => ({
|
||||
setScanConfigurationProviders: setScanConfigurationProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Radix Select relies on pointer-capture and scrollIntoView, which jsdom does
|
||||
// not implement. Polyfill them so the dropdown can open in tests.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const makeConfig = (
|
||||
id: string,
|
||||
name: string,
|
||||
providers: string[],
|
||||
): ScanConfigurationData => ({
|
||||
type: "scan-configurations",
|
||||
id,
|
||||
attributes: {
|
||||
inserted_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
name,
|
||||
configuration: {},
|
||||
providers,
|
||||
},
|
||||
});
|
||||
|
||||
const renderModal = (
|
||||
overrides: Partial<React.ComponentProps<typeof ManageScanConfigModal>> = {},
|
||||
) => {
|
||||
const onOpenChange = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
const props: React.ComponentProps<typeof ManageScanConfigModal> = {
|
||||
open: true,
|
||||
onOpenChange,
|
||||
providerId: "provider-1",
|
||||
providerLabel: "AWS App Account",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", []),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
currentConfigId: null,
|
||||
onSaved,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
render(<ManageScanConfigModal {...props} />);
|
||||
return { onOpenChange, onSaved, props };
|
||||
};
|
||||
|
||||
const openSelectAndChoose = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
optionName: RegExp,
|
||||
) => {
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /scan configuration/i }),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: optionName }));
|
||||
};
|
||||
|
||||
const clickSave = (user: ReturnType<typeof userEvent.setup>) =>
|
||||
user.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
describe("ManageScanConfigModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setScanConfigurationProvidersMock.mockResolvedValue({
|
||||
success: "Scan Configuration updated successfully!",
|
||||
});
|
||||
});
|
||||
|
||||
it("has an accessible label on the configuration select", () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /scan configuration/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("attaches the provider to the chosen configuration", async () => {
|
||||
// Given an unattached provider.
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
|
||||
|
||||
// When the user picks "Config A" and saves.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the provider is added to that config's provider list.
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-a",
|
||||
["provider-1"],
|
||||
),
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("detaches the provider when Default is selected", async () => {
|
||||
// Given a provider currently attached to Config A (alongside another).
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange, onSaved } = renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", ["provider-1", "provider-2"]),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
});
|
||||
|
||||
// When the user switches back to Default and saves.
|
||||
await openSelectAndChoose(user, /^default$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then only this provider is dropped — the rest stay attached.
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-a",
|
||||
["provider-2"],
|
||||
),
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("moves the provider to another configuration", async () => {
|
||||
// Given a provider attached to Config A.
|
||||
const user = userEvent.setup();
|
||||
renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", ["provider-1"]),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
});
|
||||
|
||||
// When the user picks Config B and saves.
|
||||
await openSelectAndChoose(user, /^config b$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the provider is attached to Config B (the backend detaches it from A).
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-b",
|
||||
["provider-1"],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call the action when the selection is unchanged", async () => {
|
||||
// Given a provider already attached to Config A.
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange } = renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [makeConfig("config-a", "Config A", ["provider-1"])],
|
||||
});
|
||||
|
||||
// When the user saves without changing the selection.
|
||||
await clickSave(user);
|
||||
|
||||
// Then no request is sent, and the modal just closes.
|
||||
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("surfaces a destructive toast and keeps the modal open on failure", async () => {
|
||||
// Given the action returns a field error.
|
||||
const user = userEvent.setup();
|
||||
setScanConfigurationProvidersMock.mockResolvedValue({
|
||||
errors: { general: "Boom" },
|
||||
});
|
||||
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
|
||||
|
||||
// When the user attaches and saves.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the error is toasted and the modal stays open for a retry.
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
description: "Boom",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets a cancelled selection when the modal is reopened", async () => {
|
||||
// Given a provider with no attached config.
|
||||
const user = userEvent.setup();
|
||||
const baseProps: React.ComponentProps<typeof ManageScanConfigModal> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
providerId: "provider-1",
|
||||
providerLabel: "AWS App Account",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", []),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
currentConfigId: null,
|
||||
onSaved: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<ManageScanConfigModal {...baseProps} />);
|
||||
|
||||
// When the user picks Config A but closes the modal without saving.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
rerender(<ManageScanConfigModal {...baseProps} open={false} />);
|
||||
|
||||
// And reopens it for the same (still unattached) provider.
|
||||
rerender(<ManageScanConfigModal {...baseProps} open />);
|
||||
|
||||
// Then the selection falls back to Default — the stale choice is gone, so
|
||||
// saving without touching the dropdown sends nothing.
|
||||
await clickSave(user);
|
||||
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { setScanConfigurationProviders } from "@/actions/scan-configurations";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
// Sentinel for the "Default" option: detaches the provider so its scans fall
|
||||
// back to Prowler's built-in SDK defaults. Select values must be non-empty
|
||||
// strings, so we can't use "".
|
||||
const DEFAULT_VALUE = "__default__";
|
||||
|
||||
interface ManageScanConfigModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
scanConfigs: ScanConfigurationData[];
|
||||
/** The config this provider is currently attached to, if any. */
|
||||
currentConfigId: string | null;
|
||||
/** Called after a successful associate/disassociate so the parent can refresh. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ManageScanConfigFormProps = Omit<ManageScanConfigModalProps, "open">;
|
||||
|
||||
export function ManageScanConfigModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
...formProps
|
||||
}: ManageScanConfigModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Scan Configuration"
|
||||
size="md"
|
||||
>
|
||||
{/* Only mount the form while the modal is open so a fresh instance is
|
||||
created on every reopen — its selection always initializes from the
|
||||
provider's current config, never from a stale, cancelled selection.
|
||||
The key resets it again if the attached config changes mid-open. */}
|
||||
{open && (
|
||||
<ManageScanConfigForm
|
||||
key={`${formProps.providerId}:${formProps.currentConfigId ?? DEFAULT_VALUE}`}
|
||||
onOpenChange={onOpenChange}
|
||||
{...formProps}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageScanConfigForm({
|
||||
onOpenChange,
|
||||
providerId,
|
||||
providerLabel,
|
||||
scanConfigs,
|
||||
currentConfigId,
|
||||
onSaved,
|
||||
}: ManageScanConfigFormProps) {
|
||||
const { toast } = useToast();
|
||||
const [selected, setSelected] = useState<string>(
|
||||
currentConfigId ?? DEFAULT_VALUE,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
// No change — nothing to do.
|
||||
if (selected === (currentConfigId ?? DEFAULT_VALUE)) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reportError = (description: string) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description,
|
||||
});
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let result;
|
||||
if (selected === DEFAULT_VALUE) {
|
||||
// Detach: drop this provider from its current config.
|
||||
if (!currentConfigId) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const current = scanConfigs.find((c) => c.id === currentConfigId);
|
||||
// Bail if we don't have the current config loaded: sending a full
|
||||
// provider_ids replacement off a synthetic empty list would clear every
|
||||
// other provider attached to this configuration.
|
||||
if (!current) {
|
||||
reportError(
|
||||
"This scan configuration is no longer available. Refresh and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const next = current.attributes.providers.filter(
|
||||
(id) => id !== providerId,
|
||||
);
|
||||
result = await setScanConfigurationProviders(currentConfigId, next);
|
||||
} else {
|
||||
// Attach: add this provider to the chosen config. The backend moves it
|
||||
// off any other config automatically (one config per provider).
|
||||
const target = scanConfigs.find((c) => c.id === selected);
|
||||
// Same guard as the detach path: never replace provider_ids based on a
|
||||
// config we don't actually have.
|
||||
if (!target) {
|
||||
reportError(
|
||||
"This scan configuration is no longer available. Refresh and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const next = Array.from(
|
||||
new Set([...target.attributes.providers, providerId]),
|
||||
);
|
||||
result = await setScanConfigurationProviders(selected, next);
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: "Scan Configuration updated",
|
||||
description: result.success,
|
||||
});
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
reportError(
|
||||
result?.errors?.general ||
|
||||
result?.errors?.provider_ids ||
|
||||
"Failed to update the Scan Configuration. Please try again.",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// An invocation-level failure (transport/framework) rejects instead of
|
||||
// returning an error object — surface it instead of failing silently.
|
||||
reportError("Failed to update the Scan Configuration. Please try again.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-default-500 text-tiny">
|
||||
Choose the scan configuration to apply to{" "}
|
||||
<strong>{providerLabel}</strong> on its next scan, or leave default. To
|
||||
create or edit configurations, go to{" "}
|
||||
<CustomLink size="xs" href="/scans/config" target="_self">
|
||||
Scan Config
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Always show the dropdown with Default — even with no custom configs,
|
||||
the provider can fall back to Prowler's SDK defaults. */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Select value={selected} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label="Scan configuration">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VALUE}>Default</SelectItem>
|
||||
{scanConfigs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.attributes.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-default-500 text-tiny">
|
||||
<strong>Default</strong>
|
||||
{
|
||||
" uses Prowler's scan configuration baseline. Read more about it in the "
|
||||
}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
|
||||
>
|
||||
documentation
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
ProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
@@ -113,6 +118,9 @@ export function getColumnProviders(
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
|
||||
scanScheduleCapability?: ScanScheduleCapability,
|
||||
scanConfigs: ScanConfigurationData[] = [],
|
||||
scanConfigStatus: ScanConfigurationListStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
scanConfigIdByProviderId: ReadonlyMap<string, string> = new Map(),
|
||||
): ColumnDef<ProvidersTableRow>[] {
|
||||
return [
|
||||
{
|
||||
@@ -315,6 +323,9 @@ export function getColumnProviders(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hasSelection = Object.values(rowSelection).some(Boolean);
|
||||
const currentScanConfigId = isProvidersOrganizationRow(row.original)
|
||||
? null
|
||||
: (scanConfigIdByProviderId.get(row.original.id) ?? null);
|
||||
|
||||
return (
|
||||
<DataTableRowActions
|
||||
@@ -327,6 +338,9 @@ export function getColumnProviders(
|
||||
onClearSelection={onClearSelection}
|
||||
onOpenProviderWizard={onOpenProviderWizard}
|
||||
onOpenOrganizationWizard={onOpenOrganizationWizard}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
currentScanConfigId={currentScanConfigId}
|
||||
capability={scanScheduleCapability}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import type { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
|
||||
@@ -47,6 +48,22 @@ vi.mock("../forms/edit-name-form", () => ({
|
||||
EditNameForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../scan-config/manage-scan-config-modal", () => ({
|
||||
ManageScanConfigModal: ({
|
||||
open,
|
||||
currentConfigId,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentConfigId: string | null;
|
||||
}) =>
|
||||
open ? (
|
||||
<div
|
||||
data-testid="manage-scan-config-modal"
|
||||
data-current-config-id={currentConfigId ?? ""}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
|
||||
EDIT_SCAN_SCHEDULE_STATE: {
|
||||
LOADING: "loading",
|
||||
@@ -182,6 +199,18 @@ const createOuRow = () =>
|
||||
},
|
||||
}) as unknown as Row<ProvidersTableRow>;
|
||||
|
||||
const scanConfig: ScanConfigurationData = {
|
||||
type: "scan-configurations",
|
||||
id: "config-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
name: "Strict AWS",
|
||||
configuration: {},
|
||||
providers: ["provider-1"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("DataTableRowActions", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -361,6 +390,64 @@ describe("DataTableRowActions", () => {
|
||||
expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens scan config management with the precomputed current config id", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
scanConfigs={[scanConfig]}
|
||||
currentScanConfigId="config-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
await user.click(screen.getByText("Edit Scan Configuration"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("manage-scan-config-modal")).toHaveAttribute(
|
||||
"data-current-config-id",
|
||||
"config-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows scan config management as unavailable when scan configs failed to load", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
scanConfigStatus="unavailable"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then
|
||||
const item = screen
|
||||
.getByText("Scan Configuration unavailable")
|
||||
.closest("[role='menuitem']");
|
||||
expect(item).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("renders Update Credentials for provider rows with credentials", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Rocket,
|
||||
SlidersHorizontal,
|
||||
Timer,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -45,6 +46,11 @@ import {
|
||||
ProvidersOrganizationRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
@@ -55,6 +61,7 @@ import {
|
||||
import { DeleteForm } from "../forms/delete-form";
|
||||
import { DeleteOrganizationForm } from "../forms/delete-organization-form";
|
||||
import { EditNameForm } from "../forms/edit-name-form";
|
||||
import { ManageScanConfigModal } from "../scan-config/manage-scan-config-modal";
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<ProvidersTableRow>;
|
||||
@@ -72,6 +79,13 @@ interface DataTableRowActionsProps {
|
||||
onClearSelection: () => void;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
/**
|
||||
* All scan configurations in the tenant, used to associate/disassociate this
|
||||
* provider's config from the row menu (Cloud-only feature). Empty in OSS.
|
||||
*/
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
currentScanConfigId?: string | null;
|
||||
/**
|
||||
* Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud
|
||||
* decision). The prowler-cloud overlay injects a billing-aware capability so
|
||||
@@ -271,6 +285,9 @@ export function DataTableRowActions({
|
||||
onClearSelection,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
scanConfigs = [],
|
||||
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
currentScanConfigId = null,
|
||||
capability,
|
||||
}: DataTableRowActionsProps) {
|
||||
const canEditSchedule =
|
||||
@@ -282,6 +299,7 @@ export function DataTableRowActions({
|
||||
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
|
||||
});
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isScanConfigOpen, setIsScanConfigOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@@ -295,6 +313,10 @@ export function DataTableRowActions({
|
||||
const providerAlias = provider?.attributes.alias ?? null;
|
||||
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
|
||||
const hasSecret = Boolean(provider?.relationships.secret.data);
|
||||
const isCloudProvider = isCloud() && Boolean(provider);
|
||||
const canManageScanConfig =
|
||||
isCloudProvider &&
|
||||
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE;
|
||||
const scheduleProvider: ScanScheduleProvider | undefined = provider
|
||||
? {
|
||||
providerId,
|
||||
@@ -548,6 +570,17 @@ export function DataTableRowActions({
|
||||
provider={scheduleProvider}
|
||||
state={scheduleState}
|
||||
/>
|
||||
{canManageScanConfig && provider && (
|
||||
<ManageScanConfigModal
|
||||
open={isScanConfigOpen}
|
||||
onOpenChange={setIsScanConfigOpen}
|
||||
providerId={providerId}
|
||||
providerLabel={providerAlias || providerUid}
|
||||
scanConfigs={scanConfigs}
|
||||
currentConfigId={currentScanConfigId}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
@@ -575,6 +608,22 @@ export function DataTableRowActions({
|
||||
onSelect={() => void openScheduleEditor()}
|
||||
/>
|
||||
)}
|
||||
{canManageScanConfig && (
|
||||
<ActionDropdownItem
|
||||
icon={<SlidersHorizontal />}
|
||||
label="Edit Scan Configuration"
|
||||
onSelect={() => setIsScanConfigOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{isCloudProvider &&
|
||||
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE && (
|
||||
<ActionDropdownItem
|
||||
icon={<SlidersHorizontal />}
|
||||
label="Scan Configuration unavailable"
|
||||
description="Try again later."
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
|
||||
@@ -28,6 +28,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
sm: "px-3 py-2",
|
||||
md: "px-4 py-3",
|
||||
lg: "px-5 py-4",
|
||||
xl: "p-8",
|
||||
none: "p-0",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,11 +25,22 @@ function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function FieldError({
|
||||
className,
|
||||
multiline = false,
|
||||
...props
|
||||
}: React.ComponentProps<"p"> & {
|
||||
/** Preserve newlines for multi-line messages (e.g. server validation lists). */
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-error"
|
||||
className={cn("text-text-error-primary max-w-full text-xs", className)}
|
||||
className={cn(
|
||||
"text-text-error-primary max-w-full text-xs",
|
||||
multiline && "whitespace-pre-wrap",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,13 @@ interface ModalProps {
|
||||
size?: ModalSize;
|
||||
className?: string;
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
/**
|
||||
* Cap the dialog at 90dvh and scroll overflowing content, instead of
|
||||
* letting it grow past the viewport. Opt-in per modal (e.g. for content
|
||||
* whose height depends on user input) rather than a DS-wide default, so
|
||||
* existing modals keep their current sizing.
|
||||
*/
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
@@ -44,6 +51,7 @@ export const Modal = ({
|
||||
size = "xl",
|
||||
className,
|
||||
onOpenAutoFocus = preventInitialAutoFocus,
|
||||
scrollable = false,
|
||||
}: ModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -51,6 +59,7 @@ export const Modal = ({
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
className={cn(
|
||||
"border-text-neutral-tertiary bg-bg-neutral-secondary rounded-[24px] border shadow-[0_0_200px_0_rgba(15,44,46,0.50)]",
|
||||
scrollable && "max-h-[90dvh] overflow-y-auto",
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("does not import Next font loaders from the shared primitive", () => {
|
||||
// Given
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(path.join(currentDir, "textarea.tsx"), "utf8");
|
||||
|
||||
// Then
|
||||
expect(source).not.toContain("@/config/fonts");
|
||||
expect(source).not.toContain("next/font");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ComponentProps, forwardRef } from "react";
|
||||
import type { ComponentProps, Ref } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -20,31 +20,46 @@ const textareaVariants = cva(
|
||||
sm: "min-h-12 px-3 py-2 text-xs",
|
||||
lg: "min-h-24 px-5 py-4",
|
||||
},
|
||||
font: {
|
||||
sans: "",
|
||||
mono: "font-mono",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
textareaSize: "default",
|
||||
font: "sans",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface TextareaProps
|
||||
extends Omit<ComponentProps<"textarea">, "size">,
|
||||
VariantProps<typeof textareaVariants> {}
|
||||
VariantProps<typeof textareaVariants> {
|
||||
ref?: Ref<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, variant, textareaSize, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
className={cn(textareaVariants({ variant, textareaSize, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Textarea = ({
|
||||
className,
|
||||
variant,
|
||||
textareaSize,
|
||||
font,
|
||||
ref,
|
||||
...props
|
||||
}: TextareaProps) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
textareaVariants({ variant, textareaSize, font }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea, textareaVariants };
|
||||
export { textareaVariants };
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BreadcrumbNavigation } from "./breadcrumb-navigation";
|
||||
|
||||
const navigationMock = vi.hoisted(() => ({
|
||||
pathname: "/findings",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/findings",
|
||||
usePathname: () => navigationMock.pathname,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
@@ -22,6 +26,10 @@ vi.mock("@heroui/breadcrumbs", () => ({
|
||||
}));
|
||||
|
||||
describe("BreadcrumbNavigation", () => {
|
||||
afterEach(() => {
|
||||
navigationMock.pathname = "/findings";
|
||||
});
|
||||
|
||||
it("renders the title action next to the current breadcrumb title", () => {
|
||||
// Given / When
|
||||
render(
|
||||
@@ -40,4 +48,25 @@ describe("BreadcrumbNavigation", () => {
|
||||
screen.getByRole("button", { name: "Start product tour" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render icons for secondary breadcrumb items", () => {
|
||||
// Given
|
||||
navigationMock.pathname = "/scans/config";
|
||||
|
||||
// When
|
||||
render(
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title="Configuration"
|
||||
icon="lucide:sliders"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Configuration" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export function BreadcrumbNavigation({
|
||||
"/users": "lucide:users",
|
||||
"/compliance": "lucide:shield-check",
|
||||
"/findings": "lucide:search",
|
||||
"/scans": "lucide:activity",
|
||||
"/scans": "lucide:timer",
|
||||
"/roles": "lucide:key",
|
||||
"/resources": "lucide:database",
|
||||
"/lighthouse": <LighthouseIcon />,
|
||||
@@ -108,16 +108,20 @@ export function BreadcrumbNavigation({
|
||||
return path;
|
||||
};
|
||||
|
||||
const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => (
|
||||
const renderTitleWithIcon = (
|
||||
titleText: string,
|
||||
isLink: boolean = false,
|
||||
showIcon: boolean = true,
|
||||
) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{typeof icon === "string" ? (
|
||||
{showIcon && typeof icon === "string" ? (
|
||||
<Icon
|
||||
className="text-text-neutral-primary"
|
||||
height={24}
|
||||
icon={icon}
|
||||
width={24}
|
||||
/>
|
||||
) : icon ? (
|
||||
) : showIcon && icon ? (
|
||||
<div className="flex h-8 w-8 items-center justify-center *:h-full *:w-full">
|
||||
{icon}
|
||||
</div>
|
||||
@@ -151,13 +155,15 @@ export function BreadcrumbNavigation({
|
||||
{breadcrumbItems.map((breadcrumb, index) => (
|
||||
<BreadcrumbItem key={breadcrumb.path || index}>
|
||||
{breadcrumb.isLast && showTitle && title ? (
|
||||
renderTitleWithIcon(title)
|
||||
renderTitleWithIcon(title, false, index === 0)
|
||||
) : breadcrumb.isClickable && breadcrumb.path ? (
|
||||
<Link
|
||||
href={buildNavigationUrl(breadcrumb.path)}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-primary"
|
||||
@@ -165,7 +171,7 @@ export function BreadcrumbNavigation({
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : breadcrumb.icon ? (
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
@@ -179,7 +185,9 @@ export function BreadcrumbNavigation({
|
||||
onClick={breadcrumb.onClick}
|
||||
className="text-text-neutral-primary hover:text-text-neutral-primary-hover flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-primary"
|
||||
@@ -187,7 +195,7 @@ export function BreadcrumbNavigation({
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : breadcrumb.icon ? (
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
@@ -198,7 +206,9 @@ export function BreadcrumbNavigation({
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-default-500"
|
||||
@@ -206,7 +216,7 @@ export function BreadcrumbNavigation({
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : breadcrumb.icon ? (
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
|
||||
@@ -43,4 +43,34 @@ describe("SubmenuItem", () => {
|
||||
await screen.findAllByText("Available in Prowler Cloud"),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should render disabled Scan config menu items like disabled Alerts", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SubmenuItem
|
||||
href="/scans/config"
|
||||
label="Scan"
|
||||
icon={TestIcon}
|
||||
disabled
|
||||
highlight
|
||||
cloudOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: /scan/i });
|
||||
await user.hover(button.parentElement as HTMLElement);
|
||||
|
||||
// Then
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveClass(
|
||||
"cursor-not-allowed",
|
||||
"text-text-neutral-tertiary",
|
||||
);
|
||||
expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]");
|
||||
expect(
|
||||
await screen.findAllByText("Available in Prowler Cloud"),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,14 +92,14 @@ describe("getMenuList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should show Scan Configuration as disabled Cloud-only in OSS when Cloud is disabled", () => {
|
||||
it("should show Scan as disabled Cloud-only in OSS when Cloud is disabled", () => {
|
||||
// Given / When
|
||||
const scanConfig = findSubmenu("Scan Configuration");
|
||||
const scanConfig = findSubmenu("Scan");
|
||||
|
||||
// Then
|
||||
expect(scanConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/scan-configurations",
|
||||
href: "/scans/config",
|
||||
disabled: true,
|
||||
cloudOnly: true,
|
||||
highlight: true,
|
||||
@@ -108,24 +108,30 @@ describe("getMenuList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should show Scan Configuration as new under Configuration when Cloud is enabled", () => {
|
||||
it("should show Scan as new under Configuration when Cloud is enabled", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const scanConfig = getMenuList({ pathname: "/scan-configurations" })
|
||||
.flatMap((group) => group.menus)
|
||||
const menus = getMenuList({ pathname: "/scans/config" }).flatMap(
|
||||
(group) => group.menus,
|
||||
);
|
||||
const scanConfig = menus
|
||||
.flatMap((menu) => menu.submenus ?? [])
|
||||
.find((submenu) => submenu.label === "Scan Configuration");
|
||||
.find((submenu) => submenu.label === "Scan");
|
||||
const scans = menus.find((menu) => menu.label === "Scans");
|
||||
|
||||
// Then
|
||||
expect(scanConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/scan-configurations",
|
||||
href: "/scans/config",
|
||||
active: true,
|
||||
highlight: true,
|
||||
}),
|
||||
);
|
||||
// The top-level Scans item uses an exact-match active rule, so it must stay
|
||||
// inactive on the `/scans/config` sub-route.
|
||||
expect(scans).toEqual(expect.objectContaining({ active: false }));
|
||||
});
|
||||
|
||||
it("should remove the new highlight from Attack Paths", () => {
|
||||
|
||||
+17
-5
@@ -98,6 +98,19 @@ export const getMenuList = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/scans",
|
||||
label: "Scans",
|
||||
icon: Timer,
|
||||
// Exact match so it isn't also marked active on the `/scans/config`
|
||||
// sub-route (mirrors the top-level Lighthouse entry).
|
||||
active: pathname === "/scans",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
@@ -133,17 +146,15 @@ export const getMenuList = ({
|
||||
active: pathname === "/mutelist",
|
||||
},
|
||||
{
|
||||
href: "/scan-configurations",
|
||||
label: "Scan Configuration",
|
||||
href: "/scans/config",
|
||||
label: "Scan",
|
||||
icon: SlidersHorizontal,
|
||||
active: isCloudEnv && pathname.startsWith("/scan-configurations"),
|
||||
active: isCloudEnv && pathname.startsWith("/scans/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 },
|
||||
{ href: "/lighthouse/config", label: "Lighthouse AI", icon: Cog },
|
||||
],
|
||||
defaultOpen: true,
|
||||
@@ -160,6 +171,7 @@ export const getMenuList = ({
|
||||
submenus: [
|
||||
{ href: "/users", label: "Users", icon: User },
|
||||
{ href: "/invitations", label: "Invitations", icon: Mail },
|
||||
{ href: "/roles", label: "Roles", icon: UserCog },
|
||||
],
|
||||
defaultOpen: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateYaml } from "./yaml";
|
||||
|
||||
// The Scan Configuration editor (like the Mutelist editor) validates only YAML
|
||||
// *syntax* on the client; the API validates the configuration values
|
||||
// (ranges/enums) on create/update. These cover the syntax check the editor and
|
||||
// `scanConfigurationFormSchema` rely on.
|
||||
describe("validateYaml", () => {
|
||||
it("accepts a mapping with provider sections", () => {
|
||||
// When
|
||||
const result = validateYaml("aws:\n max_unused_access_keys_days: 45");
|
||||
|
||||
// Then
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a key with no value yet (the `aws:` typing state)", () => {
|
||||
// When — `aws:` parses to { aws: null }, still a mapping
|
||||
const result = validateYaml("aws:");
|
||||
|
||||
// Then
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed YAML with a syntax error", () => {
|
||||
// When — unmatched bracket is invalid flow syntax
|
||||
const result = validateYaml("aws: [1, 2");
|
||||
|
||||
// Then
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects a top-level list (config must be a mapping)", () => {
|
||||
// When
|
||||
const result = validateYaml("- aws\n- azure");
|
||||
|
||||
// Then
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty content", () => {
|
||||
// When
|
||||
const result = validateYaml("");
|
||||
|
||||
// Then
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a scalar (not a mapping)", () => {
|
||||
// When — a bare word parses to the string "aws", not a mapping
|
||||
const result = validateYaml("aws");
|
||||
|
||||
// Then
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
+31
-109
@@ -1,4 +1,3 @@
|
||||
import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
||||
@@ -275,116 +274,39 @@ Mutelist:
|
||||
Tags:
|
||||
- "Name=aws-controltower-VPC"`;
|
||||
|
||||
export interface ScanConfigurationValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ScanConfigurationValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ScanConfigurationValidationError[];
|
||||
}
|
||||
|
||||
// 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 Configuration 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 validateScanConfigurationPayload = (
|
||||
val: string,
|
||||
schema: Record<string, unknown> | null,
|
||||
): ScanConfigurationValidationResult => {
|
||||
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 defaultScanConfigurationYaml = `# Scan Configuration 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-configurations/schema; invalid values are flagged below.
|
||||
export const defaultScanConfigurationYaml = `# Override Prowler's per-tenant defaults below.
|
||||
# Keep only the keys you want to change; the rest
|
||||
# use the built-in defaults from config.yaml.
|
||||
# Values are validated on save.
|
||||
|
||||
aws:
|
||||
mute_non_default_regions: false
|
||||
max_unused_access_keys_days: 45
|
||||
max_console_access_days: 45
|
||||
max_unused_sagemaker_access_days: 90
|
||||
max_security_group_rules: 50
|
||||
|
||||
# azure:
|
||||
# php_latest_version: "8.2"
|
||||
azure:
|
||||
defender_attack_path_minimal_risk_level: "High"
|
||||
php_latest_version: "8.2"
|
||||
python_latest_version: "3.12"
|
||||
java_latest_version: "17"
|
||||
vm_backup_min_daily_retention_days: 7
|
||||
|
||||
gcp:
|
||||
mig_min_zones: 2
|
||||
max_snapshot_age_days: 90
|
||||
max_unused_account_days: 180
|
||||
storage_min_retention_days: 90
|
||||
secretmanager_max_rotation_days: 90
|
||||
|
||||
kubernetes:
|
||||
audit_log_maxbackup: 10
|
||||
audit_log_maxsize: 100
|
||||
audit_log_maxage: 30
|
||||
|
||||
m365:
|
||||
sign_in_frequency: 4
|
||||
recommended_mailtips_large_audience_threshold: 25
|
||||
audit_log_age: 90
|
||||
`;
|
||||
|
||||
+14
-6
@@ -723,11 +723,10 @@ export const mutedFindingsConfigFormSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
// The editor owns content validation: live YAML syntax + schema (ranges/enums)
|
||||
// checks run through `validateScanConfigurationPayload(yamlString, schema)` in
|
||||
// `lib/yaml.ts` and surface in a single inline panel. Here we only enforce the
|
||||
// form-level shape (a name and a non-empty configuration) so we don't render the
|
||||
// same YAML error twice.
|
||||
// Mirrors the Mutelist contract: the client only validates YAML *syntax* (that
|
||||
// it parses to a mapping). The actual configuration validation (ranges, enums)
|
||||
// is performed by the API on create/update and surfaced inline — see
|
||||
// `validate_configuration` in the backend serializer.
|
||||
export const scanConfigurationFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -737,7 +736,16 @@ export const scanConfigurationFormSchema = z.object({
|
||||
configuration: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "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([]),
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface ScanConfigurationData {
|
||||
attributes: ScanConfigurationAttributes;
|
||||
}
|
||||
|
||||
export const SCAN_CONFIGURATION_LIST_STATUS = {
|
||||
AVAILABLE: "available",
|
||||
UNAVAILABLE: "unavailable",
|
||||
} as const;
|
||||
|
||||
export type ScanConfigurationListStatus =
|
||||
(typeof SCAN_CONFIGURATION_LIST_STATUS)[keyof typeof SCAN_CONFIGURATION_LIST_STATUS];
|
||||
|
||||
export interface ScanConfigurationListState {
|
||||
status: ScanConfigurationListStatus;
|
||||
data: ScanConfigurationData[];
|
||||
}
|
||||
|
||||
export interface ScanConfigurationListResponse {
|
||||
data: ScanConfigurationData[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user