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