mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat: Mutelist implementation (#8190)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com> Co-authored-by: Drew Kerrigan <drew@prowler.com>
This commit is contained in:
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Mutelist configuration form [(#8190)](https://github.com/prowler-cloud/prowler/pull/8190)
|
||||
- SAML login integration [(#8203)](https://github.com/prowler-cloud/prowler/pull/8203)
|
||||
|
||||
### 🔄 Changed
|
||||
@@ -13,6 +14,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Upgrade to Next.js 14.2.30 and lock TypeScript to 5.5.4 for ESLint compatibility [(#8189)](https://github.com/prowler-cloud/prowler/pull/8189)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
### Removed
|
||||
|
||||
---
|
||||
@@ -20,9 +22,11 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
## [v1.8.1] (Prowler 5.8.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Latest new failed findings now use `GET /findings/latest` [(#8219)](https://github.com/prowler-cloud/prowler/pull/8219)
|
||||
|
||||
### Removed
|
||||
|
||||
- Validation of the provider's secret type during updates [(#8197)](https://github.com/prowler-cloud/prowler/pull/8197)
|
||||
|
||||
---
|
||||
|
||||
1
ui/actions/processors/index.ts
Normal file
1
ui/actions/processors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./processors";
|
||||
225
ui/actions/processors/processors.ts
Normal file
225
ui/actions/processors/processors.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
||||
import {
|
||||
DeleteMutedFindingsConfigActionState,
|
||||
MutedFindingsConfigActionState,
|
||||
ProcessorData,
|
||||
} from "@/types/processors";
|
||||
|
||||
export const createMutedFindingsConfig = async (
|
||||
_prevState: MutedFindingsConfigActionState,
|
||||
formData: FormData,
|
||||
): Promise<MutedFindingsConfigActionState> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const formDataObject = Object.fromEntries(formData);
|
||||
const validatedData = mutedFindingsConfigFormSchema.safeParse(formDataObject);
|
||||
|
||||
if (!validatedData.success) {
|
||||
const formFieldErrors = validatedData.error.flatten().fieldErrors;
|
||||
return {
|
||||
errors: {
|
||||
configuration: formFieldErrors?.configuration?.[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { configuration } = validatedData.data;
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/processors`);
|
||||
|
||||
const bodyData = {
|
||||
data: {
|
||||
type: "processors",
|
||||
attributes: {
|
||||
processor_type: "mutelist",
|
||||
configuration: configuration,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.message ||
|
||||
`Failed to create Mutelist configuration: ${response.statusText}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to create Mutelist configuration: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await response.json();
|
||||
return { success: "Mutelist configuration created successfully!" };
|
||||
} catch (error) {
|
||||
console.error("Error creating Mutelist config:", error);
|
||||
return {
|
||||
errors: {
|
||||
configuration:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error creating Mutelist configuration. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMutedFindingsConfig = async (
|
||||
_prevState: MutedFindingsConfigActionState,
|
||||
formData: FormData,
|
||||
): Promise<MutedFindingsConfigActionState> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const formDataObject = Object.fromEntries(formData);
|
||||
const validatedData = mutedFindingsConfigFormSchema.safeParse(formDataObject);
|
||||
|
||||
if (!validatedData.success) {
|
||||
const formFieldErrors = validatedData.error.flatten().fieldErrors;
|
||||
return {
|
||||
errors: {
|
||||
configuration: formFieldErrors?.configuration?.[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { configuration, id } = validatedData.data;
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
errors: {
|
||||
general: "Configuration ID is required for update",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/processors/${id}`);
|
||||
|
||||
const bodyData = {
|
||||
data: {
|
||||
type: "processors",
|
||||
id,
|
||||
attributes: {
|
||||
configuration: configuration,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.message ||
|
||||
`Failed to update Mutelist configuration: ${response.statusText}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to update Mutelist configuration: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await response.json();
|
||||
return { success: "Mutelist configuration updated successfully!" };
|
||||
} catch (error) {
|
||||
console.error("Error updating Mutelist config:", error);
|
||||
return {
|
||||
errors: {
|
||||
configuration:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error updating Mutelist configuration. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getMutedFindingsConfig = async (): Promise<
|
||||
ProcessorData | undefined
|
||||
> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/processors`);
|
||||
url.searchParams.append("filter[processor_type]", "mutelist");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Mutelist config: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data[0];
|
||||
} catch (error) {
|
||||
console.error("Error fetching Mutelist config:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMutedFindingsConfig = async (
|
||||
_prevState: DeleteMutedFindingsConfigActionState,
|
||||
formData: FormData,
|
||||
): Promise<DeleteMutedFindingsConfigActionState> => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const formDataObject = Object.fromEntries(formData);
|
||||
const processorId = formDataObject.id as string;
|
||||
|
||||
if (!processorId) {
|
||||
return {
|
||||
errors: {
|
||||
general: "Configuration ID is required for deletion",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/processors/${processorId}`);
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.errors?.[0]?.detail ||
|
||||
`Failed to delete Mutelist configuration: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { success: "Mutelist configuration deleted successfully!" };
|
||||
} catch (error) {
|
||||
console.error("Error deleting Mutelist config:", error);
|
||||
return {
|
||||
errors: {
|
||||
general:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error deleting Mutelist configuration. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,10 @@ import { Suspense } from "react";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { FilterControls, filterProviders } from "@/components/filters";
|
||||
import { ManageGroupsButton } from "@/components/manage-groups";
|
||||
import { AddProviderButton } from "@/components/providers";
|
||||
import {
|
||||
AddProviderButton,
|
||||
MutedFindingsConfigButton,
|
||||
} from "@/components/providers";
|
||||
import {
|
||||
ColumnProviders,
|
||||
SkeletonTableProviders,
|
||||
@@ -24,24 +27,31 @@ export default async function Providers({
|
||||
<ContentLayout title="Cloud Providers" icon="fluent:cloud-sync-24-regular">
|
||||
<FilterControls search customFilters={filterProviders || []} />
|
||||
<Spacer y={8} />
|
||||
<div className="flex items-center gap-4 md:justify-end">
|
||||
<ManageGroupsButton />
|
||||
<AddProviderButton />
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12">
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableProviders />}>
|
||||
<SSRDataTable searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<>
|
||||
<div className="flex items-center gap-4 md:justify-end">
|
||||
<ManageGroupsButton />
|
||||
<MutedFindingsConfigButton isDisabled={true} />
|
||||
<AddProviderButton />
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12">
|
||||
<SkeletonTableProviders />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ProvidersContent searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const SSRDataTable = async ({
|
||||
const ProvidersContent = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
@@ -66,6 +76,8 @@ const SSRDataTable = async ({
|
||||
pageSize,
|
||||
});
|
||||
|
||||
const hasProviders = providersData?.data && providersData.data.length > 0;
|
||||
|
||||
const providerGroupDict =
|
||||
providersData?.included
|
||||
?.filter((item: any) => item.type === "provider-groups")
|
||||
@@ -85,10 +97,23 @@ const SSRDataTable = async ({
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={ColumnProviders}
|
||||
data={enrichedProviders || []}
|
||||
metadata={providersData?.meta}
|
||||
/>
|
||||
<>
|
||||
<div className="flex items-center gap-4 md:justify-end">
|
||||
<ManageGroupsButton />
|
||||
<MutedFindingsConfigButton isDisabled={!hasProviders} />
|
||||
<AddProviderButton />
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12">
|
||||
<DataTable
|
||||
columns={ColumnProviders}
|
||||
data={enrichedProviders || []}
|
||||
metadata={providersData?.meta}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Suspense } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getScans, getScansByState } from "@/actions/scans";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers";
|
||||
import {
|
||||
AutoRefresh,
|
||||
NoProvidersAdded,
|
||||
@@ -92,6 +93,10 @@ export default async function Scans({
|
||||
providerDetails={providerDetails}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<MutedFindingsConfigButton isDisabled={thereIsNoProvidersConnected} />
|
||||
</div>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableScans searchParams={searchParams} />
|
||||
</Suspense>
|
||||
|
||||
@@ -10,9 +10,9 @@ export const CustomCheckboxMutedFindings = () => {
|
||||
}}
|
||||
size="md"
|
||||
color="danger"
|
||||
aria-label="Include Muted Findings"
|
||||
aria-label="Include Mutelist"
|
||||
>
|
||||
Include Muted Findings
|
||||
Include Mutelist
|
||||
</Checkbox>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,13 +4,17 @@ import { MutedIcon } from "../icons";
|
||||
|
||||
interface MutedProps {
|
||||
isMuted: boolean;
|
||||
mutedReason: string;
|
||||
}
|
||||
|
||||
export const Muted = ({ isMuted }: MutedProps) => {
|
||||
export const Muted = ({
|
||||
isMuted,
|
||||
mutedReason = "This finding is muted",
|
||||
}: MutedProps) => {
|
||||
if (isMuted === false) return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={"This finding is muted"} className="text-xs">
|
||||
<Tooltip content={mutedReason} className="text-xs">
|
||||
<div className="w-fit rounded-full border border-system-severity-critical/40 p-1">
|
||||
<MutedIcon className="h-4 w-4 text-system-severity-critical" />
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
|
||||
cell: ({ row }) => {
|
||||
const { checktitle } = getFindingsMetadata(row);
|
||||
const {
|
||||
attributes: { muted },
|
||||
attributes: { muted, muted_reason },
|
||||
} = getFindingsData(row);
|
||||
const { delta } = row.original.attributes;
|
||||
|
||||
@@ -120,7 +120,7 @@ export const ColumnFindings: ColumnDef<FindingProps>[] = [
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute -right-2 top-1/2 -translate-y-1/2">
|
||||
<Muted isMuted={muted} />
|
||||
<Muted isMuted={muted} mutedReason={muted_reason || ""} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,10 @@ export const FindingDetail = ({
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Muted isMuted={attributes.muted} />
|
||||
<Muted
|
||||
isMuted={attributes.muted}
|
||||
mutedReason={attributes.muted_reason || ""}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
|
||||
|
||||
@@ -79,7 +79,7 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
|
||||
cell: ({ row }) => {
|
||||
const { checktitle } = getFindingsMetadata(row);
|
||||
const {
|
||||
attributes: { muted },
|
||||
attributes: { muted, muted_reason },
|
||||
} = getFindingsData(row);
|
||||
const { delta } = row.original.attributes;
|
||||
return (
|
||||
@@ -95,7 +95,7 @@ export const ColumnNewFindingsToDate: ColumnDef<FindingProps>[] = [
|
||||
</p>
|
||||
</div>
|
||||
<span className="absolute -right-2 top-1/2 -translate-y-1/2">
|
||||
<Muted isMuted={muted} />
|
||||
<Muted isMuted={muted} mutedReason={muted_reason || ""} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./delete-form";
|
||||
export * from "./edit-form";
|
||||
export { MutedFindingsConfigForm } from "./muted-findings-config-form";
|
||||
|
||||
262
ui/components/providers/forms/muted-findings-config-form.tsx
Normal file
262
ui/components/providers/forms/muted-findings-config-form.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { Textarea } from "@nextui-org/react";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
|
||||
import {
|
||||
createMutedFindingsConfig,
|
||||
deleteMutedFindingsConfig,
|
||||
getMutedFindingsConfig,
|
||||
updateMutedFindingsConfig,
|
||||
} from "@/actions/processors";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomButton } from "@/components/ui/custom";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
import { convertToYaml, parseYamlValidation } from "@/lib/yaml";
|
||||
import {
|
||||
MutedFindingsConfigActionState,
|
||||
ProcessorData,
|
||||
} from "@/types/processors";
|
||||
|
||||
interface MutedFindingsConfigFormProps {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const MutedFindingsConfigForm = ({
|
||||
setIsOpen,
|
||||
}: MutedFindingsConfigFormProps) => {
|
||||
const [config, setConfig] = useState<ProcessorData | null>(null);
|
||||
const [configText, setConfigText] = useState("");
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [yamlValidation, setYamlValidation] = useState<{
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}>({ isValid: true });
|
||||
const [hasUserStartedTyping, setHasUserStartedTyping] = useState(false);
|
||||
|
||||
const [state, formAction, isPending] = useFormState<
|
||||
MutedFindingsConfigActionState,
|
||||
FormData
|
||||
>(config ? updateMutedFindingsConfig : createMutedFindingsConfig, null);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
getMutedFindingsConfig().then((result) => {
|
||||
setConfig(result || null);
|
||||
const yamlConfig = convertToYaml(result?.attributes.configuration || "");
|
||||
setConfigText(yamlConfig);
|
||||
setHasUserStartedTyping(false); // Reset when loading initial config
|
||||
if (yamlConfig) {
|
||||
setYamlValidation(parseYamlValidation(yamlConfig));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
toast({
|
||||
title: "Configuration saved successfully",
|
||||
description: state.success,
|
||||
});
|
||||
setIsOpen(false);
|
||||
} else if (state?.errors?.general) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: state.errors.general,
|
||||
});
|
||||
} else if (state?.errors?.configuration) {
|
||||
// Reset typing state when there are new server errors
|
||||
setHasUserStartedTyping(false);
|
||||
}
|
||||
}, [state, toast, setIsOpen]);
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
setConfigText(value);
|
||||
// Clear server errors when user starts typing
|
||||
setHasUserStartedTyping(true);
|
||||
// Validate YAML in real-time
|
||||
const validation = parseYamlValidation(value);
|
||||
setYamlValidation(validation);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
const formData = new FormData();
|
||||
formData.append("id", config.id);
|
||||
|
||||
try {
|
||||
const result = await deleteMutedFindingsConfig(null, formData);
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: "Configuration deleted successfully",
|
||||
description: result.success,
|
||||
});
|
||||
setIsOpen(false);
|
||||
} else if (result?.errors?.general) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.errors.general,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: "Error deleting configuration. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showDeleteConfirmation) {
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h3 className="text-lg font-semibold text-default-700">
|
||||
Delete Mutelist Configuration
|
||||
</h3>
|
||||
<p className="text-sm text-default-600">
|
||||
Are you sure you want to delete this configuration? This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
<div className="flex w-full justify-center space-x-6">
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Cancel"
|
||||
className="w-full bg-transparent"
|
||||
variant="faded"
|
||||
size="lg"
|
||||
onPress={() => setShowDeleteConfirmation(false)}
|
||||
isDisabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Delete"
|
||||
className="w-full"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
size="lg"
|
||||
isLoading={isDeleting}
|
||||
startContent={!isDeleting && <DeleteIcon size={24} />}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
{isDeleting ? "Deleting" : "Delete"}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col space-y-4">
|
||||
{config && <input type="hidden" name="id" value={config.id} />}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<ul className="mb-4 list-disc pl-5 text-sm text-default-600">
|
||||
<li>
|
||||
<strong>
|
||||
This Mutelist configuration will take effect on the next scan.
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Mutelist configuration can be modified at anytime on the Providers
|
||||
and Scans pages.
|
||||
</li>
|
||||
<li>
|
||||
Learn more about configuring the Mutelist{" "}
|
||||
<Link
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/mutelist/"
|
||||
target="_blank"
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
A default Mutelist is used, to exclude certain predefined
|
||||
resources, if no Mutelist is provided.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="configuration"
|
||||
className="text-sm font-medium text-default-700"
|
||||
>
|
||||
Mutelist Configuration
|
||||
</label>
|
||||
<div>
|
||||
<Textarea
|
||||
id="configuration"
|
||||
name="configuration"
|
||||
placeholder="Enter your YAML configuration..."
|
||||
variant="bordered"
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
minRows={20}
|
||||
maxRows={20}
|
||||
isInvalid={
|
||||
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
|
||||
!yamlValidation.isValid
|
||||
}
|
||||
errorMessage={
|
||||
(!hasUserStartedTyping && state?.errors?.configuration) ||
|
||||
(!yamlValidation.isValid ? yamlValidation.error : "")
|
||||
}
|
||||
classNames={{
|
||||
input: fontMono.className + " text-sm",
|
||||
base: "min-h-[400px]",
|
||||
errorMessage: "whitespace-pre-wrap",
|
||||
}}
|
||||
/>
|
||||
{yamlValidation.isValid && configText && hasUserStartedTyping && (
|
||||
<div className="my-1 flex items-center px-1 text-tiny text-success">
|
||||
<span>Valid YAML format</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
<FormButtons
|
||||
setIsOpen={setIsOpen}
|
||||
submitText={config ? "Update" : "Save"}
|
||||
isDisabled={!yamlValidation.isValid || !configText.trim()}
|
||||
/>
|
||||
|
||||
{config && (
|
||||
<CustomButton
|
||||
type="button"
|
||||
ariaLabel="Delete Configuration"
|
||||
className="w-full"
|
||||
variant="bordered"
|
||||
color="danger"
|
||||
size="md"
|
||||
startContent={<DeleteIcon size={20} />}
|
||||
onPress={() => setShowDeleteConfirmation(true)}
|
||||
isDisabled={isPending}
|
||||
>
|
||||
Delete Configuration
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -2,5 +2,6 @@ export * from "./add-provider-button";
|
||||
export * from "./credentials-update-info";
|
||||
export * from "./forms/delete-form";
|
||||
export * from "./link-to-scans";
|
||||
export * from "./muted-findings-config-button";
|
||||
export * from "./provider-info";
|
||||
export * from "./radio-group-provider";
|
||||
|
||||
49
ui/components/providers/muted-findings-config-button.tsx
Normal file
49
ui/components/providers/muted-findings-config-button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
|
||||
import { MutedFindingsConfigForm } from "./forms";
|
||||
|
||||
interface MutedFindingsConfigButtonProps {
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const MutedFindingsConfigButton = ({
|
||||
isDisabled = false,
|
||||
}: MutedFindingsConfigButtonProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (!isDisabled) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomAlertModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="Configure Mutelist"
|
||||
size="3xl"
|
||||
>
|
||||
<MutedFindingsConfigForm setIsOpen={setIsOpen} />
|
||||
</CustomAlertModal>
|
||||
|
||||
<CustomButton
|
||||
ariaLabel="Configure Mutelist"
|
||||
variant="dashed"
|
||||
color="warning"
|
||||
size="md"
|
||||
startContent={<SettingsIcon size={20} />}
|
||||
onPress={handleOpenModal}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Configure Mutelist
|
||||
</CustomButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ interface CustomAlertModalProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
|
||||
}
|
||||
|
||||
export const CustomAlertModal: React.FC<CustomAlertModalProps> = ({
|
||||
@@ -15,12 +16,13 @@ export const CustomAlertModal: React.FC<CustomAlertModalProps> = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
size = "xl",
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
size="xl"
|
||||
size={size}
|
||||
classNames={{
|
||||
base: "dark:bg-prowler-blue-800",
|
||||
closeButton: "rounded-md",
|
||||
@@ -33,9 +35,11 @@ export const CustomAlertModal: React.FC<CustomAlertModalProps> = ({
|
||||
<>
|
||||
<ModalHeader className="flex flex-col py-0">{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-small text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-small text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</ModalBody>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface FormCancelButtonProps {
|
||||
interface FormSubmitButtonProps {
|
||||
children?: React.ReactNode;
|
||||
loadingText?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormButtonsProps {
|
||||
@@ -22,6 +23,7 @@ interface FormButtonsProps {
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
loadingText?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const FormCancelButton = ({
|
||||
@@ -45,6 +47,7 @@ export const FormCancelButton = ({
|
||||
export const FormSubmitButton = ({
|
||||
children = "Save",
|
||||
loadingText = "Loading",
|
||||
isDisabled = false,
|
||||
}: FormSubmitButtonProps) => {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
@@ -57,6 +60,7 @@ export const FormSubmitButton = ({
|
||||
color="action"
|
||||
size="lg"
|
||||
isLoading={pending}
|
||||
isDisabled={isDisabled}
|
||||
startContent={!pending && <SaveIcon size={24} />}
|
||||
>
|
||||
{pending ? <>{loadingText}</> : <span>{children}</span>}
|
||||
@@ -69,12 +73,13 @@ export const FormButtons = ({
|
||||
submitText = "Save",
|
||||
cancelText = "Cancel",
|
||||
loadingText = "Loading",
|
||||
isDisabled = false,
|
||||
}: FormButtonsProps) => {
|
||||
return (
|
||||
<div className="flex w-full justify-center space-x-6">
|
||||
<FormCancelButton setIsOpen={setIsOpen}>{cancelText}</FormCancelButton>
|
||||
|
||||
<FormSubmitButton loadingText={loadingText}>
|
||||
<FormSubmitButton loadingText={loadingText} isDisabled={isDisabled}>
|
||||
{submitText}
|
||||
</FormSubmitButton>
|
||||
</div>
|
||||
|
||||
174
ui/lib/yaml.ts
Normal file
174
ui/lib/yaml.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import yaml from "js-yaml";
|
||||
|
||||
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
||||
|
||||
/**
|
||||
* Validates if a string is valid YAML and returns detailed validation result
|
||||
*/
|
||||
export const validateYaml = (
|
||||
val: string,
|
||||
): { isValid: boolean; error?: string } => {
|
||||
try {
|
||||
const parsed = yaml.load(val);
|
||||
|
||||
if (parsed === null || parsed === undefined) {
|
||||
return { isValid: false, error: "YAML content is empty or null" };
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "YAML must be an object, not an array or primitive value",
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown YAML parsing error";
|
||||
return { isValid: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a YAML string contains a valid mutelist structure and returns detailed validation result
|
||||
*/
|
||||
export const validateMutelistYaml = (
|
||||
val: string,
|
||||
): { isValid: boolean; error?: string } => {
|
||||
try {
|
||||
const parsed = yaml.load(val) as Record<string, any>;
|
||||
|
||||
// yaml.load() can return null, arrays, or primitives
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { isValid: false, error: "YAML content must be a valid object" };
|
||||
}
|
||||
|
||||
// Verify structure using optional chaining
|
||||
const accounts = parsed.Mutelist?.Accounts;
|
||||
if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Missing or invalid 'Mutelist.Accounts' structure",
|
||||
};
|
||||
}
|
||||
|
||||
const accountKeys = Object.keys(accounts);
|
||||
if (accountKeys.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "At least one account must be defined in 'Mutelist.Accounts'",
|
||||
};
|
||||
}
|
||||
|
||||
for (const accountKey of accountKeys) {
|
||||
const account = accounts[accountKey];
|
||||
if (!account || typeof account !== "object" || Array.isArray(account)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Account '${accountKey}' must be a valid object`,
|
||||
};
|
||||
}
|
||||
|
||||
const checks = account.Checks;
|
||||
if (!checks || typeof checks !== "object" || Array.isArray(checks)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Missing or invalid 'Checks' structure for account '${accountKey}'`,
|
||||
};
|
||||
}
|
||||
|
||||
const checkKeys = Object.keys(checks);
|
||||
if (checkKeys.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `At least one check must be defined for account '${accountKey}'`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const checkKey of checkKeys) {
|
||||
const check = checks[checkKey];
|
||||
if (!check || typeof check !== "object" || Array.isArray(check)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Check '${checkKey}' in account '${accountKey}' must be a valid object`,
|
||||
};
|
||||
}
|
||||
|
||||
const { Regions: regions, Resources: resources } = check;
|
||||
if (!Array.isArray(regions)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `'Regions' must be an array in check '${checkKey}' for account '${accountKey}'`,
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(resources)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `'Resources' must be an array in check '${checkKey}' for account '${accountKey}'`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error validating mutelist structure";
|
||||
return { isValid: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates YAML using the mutelist schema and returns detailed error information
|
||||
*/
|
||||
export const parseYamlValidation = (
|
||||
yamlString: string,
|
||||
): { isValid: boolean; error?: string } => {
|
||||
try {
|
||||
const result = mutedFindingsConfigFormSchema.safeParse({
|
||||
configuration: yamlString,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return { isValid: true };
|
||||
} else {
|
||||
const firstError = result.error.issues[0];
|
||||
return {
|
||||
isValid: false,
|
||||
error: firstError.message,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown validation error";
|
||||
return { isValid: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a configuration (string or object) to YAML format
|
||||
*/
|
||||
export const convertToYaml = (config: string | object): string => {
|
||||
if (!config) return "";
|
||||
|
||||
try {
|
||||
// If it's already an object, convert directly to YAML
|
||||
if (typeof config === "object") {
|
||||
return yaml.dump(config, { indent: 2 });
|
||||
}
|
||||
|
||||
// If it's a string, try to parse as JSON first
|
||||
try {
|
||||
const jsonConfig = JSON.parse(config);
|
||||
return yaml.dump(jsonConfig, { indent: 2 });
|
||||
} catch {
|
||||
// If it's not JSON, assume it's already YAML
|
||||
return config;
|
||||
}
|
||||
} catch (error) {
|
||||
return config.toString();
|
||||
}
|
||||
};
|
||||
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"@react-aria/visually-hidden": "3.8.12",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-table": "^8.19.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"add": "^2.0.6",
|
||||
"ai": "^4.3.16",
|
||||
"alert": "^6.0.2",
|
||||
@@ -39,6 +40,7 @@
|
||||
"immer": "^10.1.1",
|
||||
"intl-messageformat": "^10.5.0",
|
||||
"jose": "^5.9.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.471.0",
|
||||
"marked": "^15.0.12",
|
||||
@@ -6631,6 +6633,12 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@react-aria/visually-hidden": "3.8.12",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-table": "^8.19.3",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"add": "^2.0.6",
|
||||
"ai": "^4.3.16",
|
||||
"alert": "^6.0.2",
|
||||
@@ -31,6 +32,7 @@
|
||||
"immer": "^10.1.1",
|
||||
"intl-messageformat": "^10.5.0",
|
||||
"jose": "^5.9.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.471.0",
|
||||
"marked": "^15.0.12",
|
||||
|
||||
@@ -450,6 +450,7 @@ export interface FindingProps {
|
||||
severity: "informational" | "low" | "medium" | "high" | "critical";
|
||||
check_id: string;
|
||||
muted: boolean;
|
||||
muted_reason?: string;
|
||||
check_metadata: {
|
||||
risk: string;
|
||||
notes: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { validateMutelistYaml, validateYaml } from "@/lib/yaml";
|
||||
|
||||
import { ProviderType } from "./providers";
|
||||
|
||||
@@ -320,3 +321,29 @@ export const samlConfigFormSchema = z.object({
|
||||
.trim()
|
||||
.min(1, { message: "Metadata XML is required" }),
|
||||
});
|
||||
|
||||
export const mutedFindingsConfigFormSchema = z.object({
|
||||
configuration: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: "Configuration is required" })
|
||||
.superRefine((val, ctx) => {
|
||||
const yamlValidation = validateYaml(val);
|
||||
if (!yamlValidation.isValid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid YAML format: ${yamlValidation.error}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mutelistValidation = validateMutelistYaml(val);
|
||||
if (!mutelistValidation.isValid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid mutelist structure: ${mutelistValidation.error}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -2,5 +2,6 @@ export * from "./authFormSchema";
|
||||
export * from "./components";
|
||||
export * from "./filters";
|
||||
export * from "./formSchemas";
|
||||
export * from "./processors";
|
||||
export * from "./providers";
|
||||
export * from "./scans";
|
||||
|
||||
27
ui/types/processors.ts
Normal file
27
ui/types/processors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface ProcessorAttributes {
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
processor_type: "mutelist";
|
||||
configuration: string;
|
||||
}
|
||||
|
||||
export interface ProcessorData {
|
||||
type: "processors";
|
||||
id: string;
|
||||
attributes: ProcessorAttributes;
|
||||
}
|
||||
|
||||
export type MutedFindingsConfigActionState = {
|
||||
errors?: {
|
||||
configuration?: string;
|
||||
general?: string;
|
||||
};
|
||||
success?: string;
|
||||
} | null;
|
||||
|
||||
export type DeleteMutedFindingsConfigActionState = {
|
||||
errors?: {
|
||||
general?: string;
|
||||
};
|
||||
success?: string;
|
||||
} | null;
|
||||
Reference in New Issue
Block a user