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:
Alejandro Bailo
2025-07-09 08:15:23 +02:00
committed by GitHub
parent 49ca3ca325
commit a3aef18cfe
23 changed files with 863 additions and 34 deletions

View File

@@ -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)
---

View File

@@ -0,0 +1 @@
export * from "./processors";

View 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.",
},
};
}
};

View File

@@ -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>
</>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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 ${

View File

@@ -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>
);

View File

@@ -1,2 +1,3 @@
export * from "./delete-form";
export * from "./edit-form";
export { MutedFindingsConfigForm } from "./muted-findings-config-form";

View 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>
);
};

View File

@@ -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";

View 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>
</>
);
};

View File

@@ -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>
</>

View File

@@ -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
View 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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(),
});

View File

@@ -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
View 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;