mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { Snippet } from "@heroui/snippet";
|
|
import { Spinner } from "@heroui/spinner";
|
|
import { Tooltip } from "@heroui/tooltip";
|
|
import { ExternalLink, InfoIcon } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { getFindingById } from "@/actions/findings";
|
|
import { getResourceById } from "@/actions/resources";
|
|
import { FindingDetail } from "@/components/findings/table/finding-detail";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
|
import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui";
|
|
import {
|
|
DateWithTime,
|
|
getProviderLogo,
|
|
InfoField,
|
|
} from "@/components/ui/entities";
|
|
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
|
import { createDict } from "@/lib";
|
|
import { buildGitFileUrl } from "@/lib/iac-utils";
|
|
import { FindingProps, ProviderType, ResourceProps } from "@/types";
|
|
|
|
const SEVERITY_ORDER = {
|
|
critical: 0,
|
|
high: 1,
|
|
medium: 2,
|
|
low: 3,
|
|
informational: 4,
|
|
} as const;
|
|
|
|
type SeverityLevel = keyof typeof SEVERITY_ORDER;
|
|
|
|
interface ResourceFinding {
|
|
type: "findings";
|
|
id: string;
|
|
attributes: {
|
|
status: "PASS" | "FAIL" | "MANUAL";
|
|
severity: SeverityLevel;
|
|
check_metadata?: {
|
|
checktitle?: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface FindingReference {
|
|
id: string;
|
|
}
|
|
|
|
const renderValue = (value: string | null | undefined) => {
|
|
return value && value.trim() !== "" ? value : "-";
|
|
};
|
|
|
|
const parseMetadata = (
|
|
metadata: Record<string, unknown> | string | null | undefined,
|
|
): Record<string, unknown> | null => {
|
|
if (!metadata) return null;
|
|
|
|
if (typeof metadata === "string") {
|
|
try {
|
|
const parsed = JSON.parse(metadata);
|
|
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (typeof metadata === "object" && metadata !== null) {
|
|
return metadata as Record<string, unknown>;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const buildCustomBreadcrumbs = (
|
|
_resourceName: string,
|
|
findingTitle?: string,
|
|
onBackToResource?: () => void,
|
|
): CustomBreadcrumbItem[] => {
|
|
const breadcrumbs: CustomBreadcrumbItem[] = [
|
|
{
|
|
name: "Resource Details",
|
|
isClickable: !!findingTitle,
|
|
onClick: findingTitle ? onBackToResource : undefined,
|
|
isLast: !findingTitle,
|
|
},
|
|
];
|
|
|
|
if (findingTitle) {
|
|
breadcrumbs.push({
|
|
name: findingTitle,
|
|
isLast: true,
|
|
isClickable: false,
|
|
});
|
|
}
|
|
|
|
return breadcrumbs;
|
|
};
|
|
|
|
export const ResourceDetail = ({
|
|
resourceId,
|
|
initialResourceData,
|
|
}: {
|
|
resourceId: string;
|
|
initialResourceData: ResourceProps;
|
|
}) => {
|
|
const [findingsData, setFindingsData] = useState<ResourceFinding[]>([]);
|
|
const [resourceTags, setResourceTags] = useState<Record<string, string>>({});
|
|
const [findingsLoading, setFindingsLoading] = useState(true);
|
|
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [findingDetails, setFindingDetails] = useState<FindingProps | null>(
|
|
null,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const loadFindings = async () => {
|
|
setFindingsLoading(true);
|
|
|
|
try {
|
|
const resourceData = await getResourceById(resourceId, {
|
|
include: ["findings"],
|
|
fields: ["tags", "findings"],
|
|
});
|
|
|
|
if (resourceData?.data) {
|
|
// Get tags from the detailed resource data
|
|
setResourceTags(resourceData.data.attributes.tags || {});
|
|
|
|
// Create dictionary for findings and expand them
|
|
if (resourceData.data.relationships?.findings) {
|
|
const findingsDict = createDict("findings", resourceData);
|
|
const findings =
|
|
resourceData.data.relationships.findings.data?.map(
|
|
(finding: FindingReference) => findingsDict[finding.id],
|
|
) || [];
|
|
setFindingsData(findings as ResourceFinding[]);
|
|
} else {
|
|
setFindingsData([]);
|
|
}
|
|
} else {
|
|
setFindingsData([]);
|
|
setResourceTags({});
|
|
}
|
|
} catch (err) {
|
|
console.error("Error loading findings:", err);
|
|
setFindingsData([]);
|
|
setResourceTags({});
|
|
} finally {
|
|
setFindingsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (resourceId) {
|
|
loadFindings();
|
|
}
|
|
}, [resourceId]);
|
|
|
|
const navigateToFinding = async (findingId: string) => {
|
|
setSelectedFindingId(findingId);
|
|
|
|
try {
|
|
const findingData = await getFindingById(
|
|
findingId,
|
|
"resources,scan.provider",
|
|
);
|
|
if (findingData?.data) {
|
|
// Create dictionaries for resources, scans, and providers
|
|
const resourceDict = createDict("resources", findingData);
|
|
const scanDict = createDict("scans", findingData);
|
|
const providerDict = createDict("providers", findingData);
|
|
|
|
// Expand the finding with its corresponding resource, scan, and provider
|
|
const finding = findingData.data;
|
|
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
|
const resource =
|
|
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
|
const provider = providerDict[scan?.relationships?.provider?.data?.id];
|
|
|
|
const expandedFinding = {
|
|
...finding,
|
|
relationships: { scan, resource, provider },
|
|
};
|
|
|
|
setFindingDetails(expandedFinding);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching finding:", error);
|
|
}
|
|
};
|
|
|
|
const handleBackToResource = () => {
|
|
setSelectedFindingId(null);
|
|
setFindingDetails(null);
|
|
};
|
|
|
|
if (!initialResourceData) {
|
|
return (
|
|
<div className="flex min-h-96 flex-col items-center justify-center gap-4 rounded-lg p-8">
|
|
<Spinner size="lg" />
|
|
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
|
Loading resource details...
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const resource = initialResourceData;
|
|
const attributes = resource.attributes;
|
|
const providerData = resource.relationships.provider.data.attributes;
|
|
|
|
// Filter only failed findings and sort by severity
|
|
const failedFindings = findingsData
|
|
.filter(
|
|
(finding: ResourceFinding) => finding?.attributes?.status === "FAIL",
|
|
)
|
|
.sort((a: ResourceFinding, b: ResourceFinding) => {
|
|
const severityA = (a?.attributes?.severity?.toLowerCase() ||
|
|
"informational") as SeverityLevel;
|
|
const severityB = (b?.attributes?.severity?.toLowerCase() ||
|
|
"informational") as SeverityLevel;
|
|
return (
|
|
(SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999)
|
|
);
|
|
});
|
|
|
|
// Build Git URL for IaC resources
|
|
const gitUrl =
|
|
providerData.provider === "iac"
|
|
? buildGitFileUrl(
|
|
providerData.uid,
|
|
attributes.name,
|
|
"",
|
|
attributes.region,
|
|
)
|
|
: null;
|
|
|
|
if (selectedFindingId) {
|
|
const findingTitle =
|
|
findingDetails?.attributes?.check_metadata?.checktitle ||
|
|
"Finding Detail";
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<BreadcrumbNavigation
|
|
mode="custom"
|
|
customItems={buildCustomBreadcrumbs(
|
|
attributes.name,
|
|
findingTitle,
|
|
handleBackToResource,
|
|
)}
|
|
/>
|
|
|
|
{findingDetails && <FindingDetail findingDetails={findingDetails} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 rounded-lg">
|
|
{/* Resource Details section */}
|
|
<Card variant="base" padding="lg">
|
|
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
|
<div className="flex flex-row items-center justify-start gap-2">
|
|
<CardTitle>Resource Details</CardTitle>
|
|
{providerData.provider === "iac" && gitUrl && (
|
|
<Tooltip content="Go to Resource in the Repository" size="sm">
|
|
<a
|
|
href={gitUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-bg-data-info mt-1 inline-flex cursor-pointer"
|
|
aria-label="Open resource in repository"
|
|
>
|
|
<ExternalLink size={16} className="inline" />
|
|
</a>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
{getProviderLogo(providerData.provider as ProviderType)}
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<InfoField label="Resource UID" variant="simple">
|
|
<Snippet
|
|
className="border-border-neutral-tertiary bg-bg-neutral-tertiary rounded-lg border py-1"
|
|
hideSymbol
|
|
>
|
|
<span className="text-xs whitespace-pre-line">
|
|
{renderValue(attributes.uid)}
|
|
</span>
|
|
</Snippet>
|
|
</InfoField>
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<InfoField label="Resource Name">
|
|
{renderValue(attributes.name)}
|
|
</InfoField>
|
|
<InfoField label="Resource Type">
|
|
{renderValue(attributes.type)}
|
|
</InfoField>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<InfoField label="Service">
|
|
{renderValue(attributes.service)}
|
|
</InfoField>
|
|
<InfoField label="Region">
|
|
{renderValue(attributes.region)}
|
|
</InfoField>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<InfoField label="Partition">
|
|
{renderValue(attributes.partition)}
|
|
</InfoField>
|
|
<InfoField label="Details">
|
|
{renderValue(attributes.details)}
|
|
</InfoField>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<InfoField label="Created At">
|
|
<DateWithTime inline dateTime={attributes.inserted_at} />
|
|
</InfoField>
|
|
<InfoField label="Last Updated">
|
|
<DateWithTime inline dateTime={attributes.updated_at} />
|
|
</InfoField>
|
|
</div>
|
|
|
|
{(() => {
|
|
const parsedMetadata = parseMetadata(attributes.metadata);
|
|
return parsedMetadata &&
|
|
Object.entries(parsedMetadata).length > 0 ? (
|
|
<InfoField label="Metadata" variant="simple">
|
|
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary relative w-full rounded-lg border">
|
|
<Snippet
|
|
className="absolute top-2 right-2 z-10 bg-transparent"
|
|
classNames={{
|
|
base: "bg-transparent p-0 min-w-0",
|
|
pre: "hidden",
|
|
}}
|
|
>
|
|
{JSON.stringify(parsedMetadata, null, 2)}
|
|
</Snippet>
|
|
<pre className="minimal-scrollbar mr-10 max-h-[100px] overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
|
{JSON.stringify(parsedMetadata, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</InfoField>
|
|
) : null;
|
|
})()}
|
|
|
|
{resourceTags && Object.entries(resourceTags).length > 0 ? (
|
|
<div className="flex flex-col gap-4">
|
|
<h4 className="text-sm font-bold text-gray-500 dark:text-gray-400">
|
|
Tags
|
|
</h4>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
{Object.entries(resourceTags).map(([key, value]) => (
|
|
<InfoField key={key} label={key}>
|
|
{renderValue(value)}
|
|
</InfoField>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Failed findings associated with this resource section */}
|
|
<Card variant="base" padding="lg">
|
|
<CardHeader>
|
|
<CardTitle>Failed findings associated with this resource</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{findingsLoading ? (
|
|
<div className="flex items-center justify-center gap-2 py-8">
|
|
<Spinner size="sm" />
|
|
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
|
Loading findings...
|
|
</p>
|
|
</div>
|
|
) : failedFindings.length > 0 ? (
|
|
<div className="flex flex-col gap-4">
|
|
<p className="dark:text-prowler-theme-pale/80 text-sm text-gray-600">
|
|
Total failed findings: {failedFindings.length}
|
|
</p>
|
|
{failedFindings.map((finding: ResourceFinding, index: number) => {
|
|
const { attributes: findingAttrs, id } = finding;
|
|
|
|
// Handle cases where finding might not have all attributes
|
|
if (!findingAttrs) {
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="shadow-small dark:bg-prowler-blue-400 flex flex-col gap-2 rounded-lg px-4 py-2"
|
|
>
|
|
<p className="text-sm text-red-600">
|
|
Finding {id} - No attributes available
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { severity, check_metadata, status } = findingAttrs;
|
|
const checktitle =
|
|
check_metadata?.checktitle || "Unknown check";
|
|
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => navigateToFinding(id)}
|
|
className="shadow-small border-border-neutral-tertiary bg-bg-neutral-tertiary flex w-full cursor-pointer flex-col gap-2 rounded-lg px-4 py-2"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="dark:text-prowler-theme-pale/90 text-left text-sm font-medium text-gray-800">
|
|
{checktitle}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<SeverityBadge severity={severity || "-"} />
|
|
<StatusFindingBadge status={status || "-"} />
|
|
<InfoIcon
|
|
className="text-button-primary cursor-pointer"
|
|
size={16}
|
|
onClick={() => navigateToFinding(id)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="dark:text-prowler-theme-pale/80 text-gray-600">
|
|
No failed findings found for this resource.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|