Merge branch 'master' into PRWLR-7160-findings-page-scan-id-filter-improvement

This commit is contained in:
Pablo Lara
2025-06-03 15:59:52 +02:00
34 changed files with 2847 additions and 319 deletions
+8 -1
View File
@@ -17,6 +17,13 @@ All notable changes to the **Prowler API** are documented in this file.
---
## [v1.8.3] (Prowler v5.7.3)
### Fixed
- Fixed transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916).
---
## [v1.8.2] (Prowler v5.7.2)
### Fixed
@@ -30,7 +37,7 @@ All notable changes to the **Prowler API** are documented in this file.
## [v1.8.1] (Prowler v5.7.1)
### Fixed
- Added database index to improve performance on finding lookup. [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800)
- Added database index to improve performance on finding lookup [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800).
---
+45 -19
View File
@@ -1,5 +1,4 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
@@ -47,10 +46,6 @@ class BaseViewSet(ModelViewSet):
class BaseRLSViewSet(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
with transaction.atomic():
return super().dispatch(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
# Ideally, this logic would be in the `.setup()` method but DRF view sets don't call it
# https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.setup
@@ -61,9 +56,21 @@ class BaseRLSViewSet(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
with rls_transaction(tenant_id):
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
self.request.tenant_id = tenant_id
self._rls_cm = rls_transaction(tenant_id)
self._rls_cm.__enter__()
super().initial(request, *args, **kwargs)
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if hasattr(self, "_rls_cm"):
self._rls_cm.__exit__(None, None, None)
del self._rls_cm
return response
def get_serializer_context(self):
context = super().get_serializer_context()
@@ -73,8 +80,7 @@ class BaseRLSViewSet(BaseViewSet):
class BaseTenantViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
with transaction.atomic():
tenant = super().dispatch(request, *args, **kwargs)
tenant = super().dispatch(request, *args, **kwargs)
try:
# If the request is a POST, create the admin role
@@ -117,15 +123,23 @@ class BaseTenantViewset(BaseViewSet):
raise NotAuthenticated("Tenant ID is not present in token")
user_id = str(request.user.id)
with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR):
return super().initial(request, *args, **kwargs)
self._rls_cm = rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR)
self._rls_cm.__enter__()
super().initial(request, *args, **kwargs)
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if hasattr(self, "_rls_cm"):
self._rls_cm.__exit__(None, None, None)
del self._rls_cm
return response
class BaseUserViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
with transaction.atomic():
return super().dispatch(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
# TODO refactor after improving RLS on users
if request.stream is not None and request.stream.method == "POST":
@@ -137,6 +151,18 @@ class BaseUserViewset(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
with rls_transaction(tenant_id):
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
self.request.tenant_id = tenant_id
self._rls_cm = rls_transaction(tenant_id)
self._rls_cm.__enter__()
super().initial(request, *args, **kwargs)
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if hasattr(self, "_rls_cm"):
self._rls_cm.__exit__(None, None, None)
del self._rls_cm
return response
+3
View File
@@ -10,6 +10,9 @@ All notable changes to the **Prowler UI** are documented in this file.
- Improved `SnippetChip` component and show resource name in new findings table. [(#7813)](https://github.com/prowler-cloud/prowler/pull/7813)
- Possibility to edit the organization name. [(#7829)](https://github.com/prowler-cloud/prowler/pull/7829)
- Add GCP credential method (Account Service Key). [(#7872)](https://github.com/prowler-cloud/prowler/pull/7872)
- Add compliance detail view: ENS [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853)
- Add compliance detail view: ISO [(#7897)](https://github.com/prowler-cloud/prowler/pull/7897)
- Add compliance detail view: CIS [(#7913)](https://github.com/prowler-cloud/prowler/pull/7913)
### 🔄 Changed
@@ -9,38 +9,49 @@ import {
} from "@/actions/compliances";
import { getProvider } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { ClientAccordionWrapper } from "@/components/compliance/compliance-accordion/client-accordion-wrapper";
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
import { SkeletonAccordion } from "@/components/compliance/compliance-skeleton-accordion";
import { FailedSectionsChart } from "@/components/compliance/failed-sections-chart";
import { FailedSectionsChartSkeleton } from "@/components/compliance/failed-sections-chart-skeleton";
import { RequirementsChart } from "@/components/compliance/requirements-chart";
import { RequirementsChartSkeleton } from "@/components/compliance/requirements-chart-skeleton";
import { ContentLayout } from "@/components/ui";
import { mapComplianceData, toAccordionItems } from "@/lib/compliance/ens";
import { ScanProps } from "@/types";
import {
FailedSection,
MappedComplianceData,
RequirementsTotals,
} from "@/types/compliance";
BarChart,
BarChartSkeleton,
ClientAccordionWrapper,
ComplianceHeader,
HeatmapChart,
HeatmapChartSkeleton,
PieChart,
PieChartSkeleton,
SkeletonAccordion,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ContentLayout } from "@/components/ui";
import {
calculateCategoryHeatmapData,
calculateRegionHeatmapData,
getComplianceMapper,
} from "@/lib/compliance/commons";
import { ScanProps } from "@/types";
import { Framework, RequirementsTotals } from "@/types/compliance";
interface ComplianceDetailSearchParams {
complianceId: string;
version?: string;
scanId?: string;
"filter[region__in]"?: string;
"filter[cis_profile_level]"?: string;
}
const Logo = ({ logoPath }: { logoPath: string }) => {
const ComplianceIconSmall = ({
logoPath,
title,
}: {
logoPath: string;
title: string;
}) => {
return (
<div className="relative ml-auto hidden h-[200px] w-[200px] flex-shrink-0 md:block">
<div className="relative h-6 w-6 flex-shrink-0">
<Image
src={logoPath}
alt="Compliance Logo"
alt={`${title} logo`}
fill
priority
className="object-contain"
className="h-10 w-10 min-w-10 rounded-md border-1 border-gray-300 bg-white object-contain p-[2px]"
/>
</div>
);
@@ -48,17 +59,13 @@ const Logo = ({ logoPath }: { logoPath: string }) => {
const ChartsWrapper = ({
children,
logoPath,
}: {
children: React.ReactNode;
logoPath: string;
logoPath?: string;
}) => {
return (
<div className="mb-8 flex w-full">
<div className="flex flex-col items-center gap-16 lg:flex-row">
{children}
</div>
{logoPath && <Logo logoPath={logoPath} />}
<div className="mb-8 flex w-full flex-col items-center justify-between lg:flex-row">
{children}
</div>
);
};
@@ -73,8 +80,8 @@ export default async function ComplianceDetail({
const { compliancetitle } = params;
const { complianceId, version, scanId } = searchParams;
const regionFilter = searchParams["filter[region__in]"];
const logoPath = `/${compliancetitle.toLowerCase()}.png`;
const cisProfileFilter = searchParams["filter[cis_profile_level]"];
const logoPath = getComplianceIcon(compliancetitle);
// Create a key that includes region filter for Suspense
const searchParamsKey = JSON.stringify(searchParams || {});
@@ -92,31 +99,33 @@ export default async function ComplianceDetail({
});
// Expand scans with provider information
const expandedScansData = await Promise.all(
scansData.data.map(async (scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
const expandedScansData = scansData?.data?.length
? await Promise.all(
scansData.data.map(async (scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
if (!providerId) {
return { ...scan, providerInfo: null };
}
if (!providerId) {
return { ...scan, providerInfo: null };
}
const formData = new FormData();
formData.append("id", providerId);
const formData = new FormData();
formData.append("id", providerId);
const providerData = await getProvider(formData);
const providerData = await getProvider(formData);
return {
...scan,
providerInfo: providerData?.data
? {
provider: providerData.data.attributes.provider,
uid: providerData.data.attributes.uid,
alias: providerData.data.attributes.alias,
}
: null,
};
}),
);
return {
...scan,
providerInfo: providerData?.data
? {
provider: providerData.data.attributes.provider,
uid: providerData.data.attributes.uid,
alias: providerData.data.attributes.alias,
}
: null,
};
}),
)
: [];
const selectedScanId = scanId || expandedScansData[0]?.id || null;
@@ -130,11 +139,21 @@ export default async function ComplianceDetail({
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
return (
<ContentLayout title={pageTitle} icon="fluent-mdl2:compliance-audit">
<ContentLayout
title={pageTitle}
icon={
logoPath ? (
<ComplianceIconSmall logoPath={logoPath} title={compliancetitle} />
) : (
"fluent-mdl2:compliance-audit"
)
}
>
<ComplianceHeader
scans={expandedScansData}
uniqueRegions={uniqueRegions}
showSearch={false}
framework={compliancetitle}
/>
<Suspense
@@ -142,8 +161,9 @@ export default async function ComplianceDetail({
fallback={
<div className="space-y-8">
<ChartsWrapper logoPath={logoPath}>
<RequirementsChartSkeleton />
<FailedSectionsChartSkeleton />
<PieChartSkeleton />
<BarChartSkeleton />
<HeatmapChartSkeleton />
</ChartsWrapper>
<SkeletonAccordion />
</div>
@@ -153,18 +173,57 @@ export default async function ComplianceDetail({
complianceId={complianceId}
scanId={selectedScanId}
region={regionFilter}
filter={cisProfileFilter}
logoPath={logoPath}
uniqueRegions={uniqueRegions}
isRegionFiltered={
!!regionFilter &&
regionFilter.split(",").length < uniqueRegions.length
}
/>
</Suspense>
</ContentLayout>
);
}
const getComplianceData = async (
complianceId: string,
scanId: string,
region?: string,
): Promise<MappedComplianceData> => {
const SSRComplianceContent = async ({
complianceId,
scanId,
region,
filter,
logoPath,
uniqueRegions,
isRegionFiltered,
}: {
complianceId: string;
scanId: string;
region?: string;
filter?: string;
logoPath?: string;
uniqueRegions: string[];
isRegionFiltered: boolean;
}) => {
if (!scanId) {
return (
<div className="space-y-8">
<ChartsWrapper logoPath={logoPath}>
<PieChart pass={0} fail={0} manual={0} />
<BarChart sections={[]} />
<HeatmapChart
regions={[]}
categories={[]}
isRegionFiltered={
!!region && region.split(",").length < uniqueRegions.length
}
filteredRegionName={region}
/>
</ChartsWrapper>
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
</div>
);
}
// Get compliance data and attributes once
const [attributesData, requirementsData] = await Promise.all([
getComplianceAttributes(complianceId),
getComplianceRequirements({
@@ -174,89 +233,56 @@ const getComplianceData = async (
}),
]);
const mappedData = mapComplianceData(attributesData, requirementsData);
return mappedData;
};
// Determine framework from the first attribute item
const framework = attributesData?.data?.[0]?.attributes?.framework;
const mapper = getComplianceMapper(framework);
const data = mapper.mapComplianceData(
attributesData,
requirementsData,
filter,
);
const getTopFailedSections = (
mappedData: MappedComplianceData,
): FailedSection[] => {
const failedSectionMap = new Map();
// Calculate region heatmap data using already obtained data
const regionHeatmapData = await calculateRegionHeatmapData(
complianceId,
scanId,
uniqueRegions,
attributesData,
mapper,
);
const categoryHeatmapData = calculateCategoryHeatmapData(data);
mappedData.forEach((framework) => {
framework.categories.forEach((category) => {
category.controls.forEach((control) => {
control.requirements.forEach((requirement) => {
if (requirement.status === "FAIL") {
const sectionName = category.name;
if (!failedSectionMap.has(sectionName)) {
failedSectionMap.set(sectionName, { total: 0, types: {} });
}
const sectionData = failedSectionMap.get(sectionName);
sectionData.total += 1;
const type = requirement.type;
sectionData.types[type] = (sectionData.types[type] || 0) + 1;
}
});
});
});
});
// Convert in descending order and slice top 5
return Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5); // Top 5
};
const SSRComplianceContent = async ({
complianceId,
scanId,
region,
logoPath,
}: {
complianceId: string;
scanId: string;
region?: string;
logoPath: string;
}) => {
if (!scanId) {
return (
<div className="space-y-8">
<ChartsWrapper logoPath={logoPath}>
<RequirementsChart pass={0} fail={0} manual={0} />
<FailedSectionsChart sections={[]} />
</ChartsWrapper>
<ClientAccordionWrapper items={[]} defaultExpandedKeys={[]} />
</div>
);
}
const data = await getComplianceData(complianceId, scanId, region);
const totalRequirements: RequirementsTotals = data.reduce(
(acc, framework) => ({
(acc: RequirementsTotals, framework: Framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const topFailedSections = getTopFailedSections(data);
const accordionItems = toAccordionItems(data, scanId);
const defaultKeys = accordionItems.slice(0, 2).map((item) => item.key);
const accordionItems = mapper.toAccordionItems(data, scanId);
const topFailedSections = mapper.getTopFailedSections(data);
// Todo: rethink as every compliance has a different number of items
// const defaultKeys = accordionItems.slice(0, 2).map((item) => item.key);
const defaultKeys = [""];
return (
<div className="space-y-8">
<ChartsWrapper logoPath={logoPath}>
<RequirementsChart
<PieChart
pass={totalRequirements.pass}
fail={totalRequirements.fail}
manual={totalRequirements.manual}
/>
<FailedSectionsChart sections={topFailedSections} />
<BarChart sections={topFailedSections} />
<HeatmapChart
regions={regionHeatmapData}
categories={categoryHeatmapData}
isRegionFiltered={isRegionFiltered}
filteredRegionName={region}
/>
</ChartsWrapper>
<Spacer className="h-1 w-full rounded-full bg-gray-200 dark:bg-gray-800" />
@@ -11,19 +11,22 @@ import {
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib";
import { getComplianceMapper } from "@/lib/compliance/commons";
import { ComplianceId, Requirement } from "@/types/compliance";
import { FindingProps, FindingsResponse } from "@/types/components";
import { ComplianceCustomDetails } from "../compliance-custom-details/ens-details";
interface ClientAccordionContentProps {
requirement: Requirement;
scanId: string;
framework: string;
disableFindings?: boolean;
}
export const ClientAccordionContent = ({
requirement,
framework,
scanId,
disableFindings = false,
}: ClientAccordionContentProps) => {
const [findings, setFindings] = useState<FindingsResponse | null>(null);
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
@@ -40,6 +43,7 @@ export const ClientAccordionContent = ({
useEffect(() => {
async function loadFindings() {
if (
!disableFindings &&
requirement.check_ids?.length > 0 &&
requirement.status !== "No findings" &&
(loadedPageRef.current !== pageNumber ||
@@ -95,7 +99,29 @@ export const ClientAccordionContent = ({
}
loadFindings();
}, [requirement, scanId, pageNumber, sort, region]);
}, [requirement, scanId, pageNumber, sort, region, disableFindings]);
const renderDetails = () => {
if (!complianceId) {
return null;
}
const mapper = getComplianceMapper(framework);
const detailsComponent = mapper.getDetailsComponent(requirement);
return <div className="w-full">{detailsComponent}</div>;
};
if (disableFindings) {
return (
<div className="w-full">
{renderDetails()}
<p className="text-sm text-gray-500">
This requirement has no checks; therefore, there are no findings.
</p>
</div>
);
}
const checks = requirement.check_ids || [];
const checksList = (
@@ -141,23 +167,6 @@ export const ClientAccordionContent = ({
return <div>There are no findings for this regions</div>;
};
const renderDetails = () => {
if (!complianceId) {
return null;
}
switch (complianceId) {
case "ens_rd2022_aws":
return (
<div className="w-full">
<ComplianceCustomDetails requirement={requirement} />
</div>
);
default:
return null;
}
};
return (
<div className="w-full">
{renderDetails()}
@@ -1,5 +1,4 @@
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
import { translateType } from "@/lib/compliance/ens";
interface ComplianceAccordionRequirementTitleProps {
type: string;
@@ -8,17 +7,13 @@ interface ComplianceAccordionRequirementTitleProps {
}
export const ComplianceAccordionRequirementTitle = ({
type,
name,
status,
}: ComplianceAccordionRequirementTitleProps) => {
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-3/4 items-center gap-1">
<span className="whitespace-nowrap text-sm font-bold capitalize">
{translateType(type)}:
</span>
<span className="whitespace-nowrap text-sm uppercase">{name}</span>
<span className="whitespace-nowrap text-sm">{name}</span>
</div>
<StatusFindingBadge status={status} />
</div>
@@ -5,6 +5,7 @@ interface ComplianceAccordionTitleProps {
pass: number;
fail: number;
manual?: number;
isParentLevel?: boolean;
}
export const ComplianceAccordionTitle = ({
@@ -12,6 +13,7 @@ export const ComplianceAccordionTitle = ({
pass,
fail,
manual = 0,
isParentLevel = false,
}: ComplianceAccordionTitleProps) => {
const total = pass + fail + manual;
const passPercentage = (pass / total) * 100;
@@ -30,7 +32,7 @@ export const ComplianceAccordionTitle = ({
</div>
<div className="mr-4 flex items-center gap-2">
<div className="hidden lg:block">
{total > 0 && (
{total > 0 && isParentLevel && (
<span className="whitespace-nowrap text-xs font-medium text-gray-600">
Requirements:
</span>
+1 -1
View File
@@ -73,7 +73,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
const navigateToDetail = () => {
// We will unlock this while developing the rest of complainces.
if (!id.includes("ens") && !id.includes("cis")) {
if (!id.includes("ens") && !id.includes("iso") && !id.includes("cis_")) {
return;
}
@@ -3,7 +3,7 @@
import { useTheme } from "next-themes";
import {
Bar,
BarChart,
BarChart as RechartsBarChart,
Legend,
ResponsiveContainer,
Tooltip,
@@ -12,17 +12,10 @@ import {
} from "recharts";
import { translateType } from "@/lib/compliance/ens";
type FailedSectionItem = {
name: string;
total: number;
types: {
[key: string]: number;
};
};
import { FailedSection } from "@/types/compliance";
interface FailedSectionsListProps {
sections: FailedSectionItem[];
sections: FailedSection[];
}
const title = (
@@ -31,7 +24,7 @@ const title = (
</h3>
);
export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
export const BarChart = ({ sections }: FailedSectionsListProps) => {
const { theme } = useTheme();
const getTypeColor = (type: string) => {
@@ -43,7 +36,7 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
case "refuerzo":
return "#7FB5FF"; // Increased contrast from #B5D7FF
default:
return "#868994";
return "#ff5356";
}
};
@@ -56,9 +49,28 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
}));
const allTypes = Array.from(
new Set(sections.flatMap((section) => Object.keys(section.types))),
new Set(sections.flatMap((section) => Object.keys(section.types || {}))),
);
// Add empty bars to complete up to 5 bars for better distribution
while (chartData.length < 5) {
const emptyBar: any = { name: "" };
allTypes.forEach((type) => {
emptyBar[type] = 0;
});
chartData.push(emptyBar);
}
// Calculate the maximum value to ensure proper scaling
const maxValue = Math.max(
...chartData.map((item) =>
allTypes.reduce((sum, type) => sum + ((item as any)[type] || 0), 0),
),
);
// Set minimum domain to ensure bars are always visible
const domainMax = Math.max(maxValue, 1);
// Check if there are no failed sections
if (!sections || sections.length === 0) {
return (
@@ -72,22 +84,25 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
}
return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
{title}
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
<div>{title}</div>
<div className="h-[320px] w-full">
<div className="h-full w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
<RechartsBarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 30, left: 40, bottom: 5 }}
maxBarSize={40}
margin={{ top: 12, bottom: 0 }}
maxBarSize={32}
>
<XAxis
type="number"
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
hide={true}
domain={[0, domainMax]}
tick={{
fontSize: 12,
fill: theme === "dark" ? "#94a3b8" : "#374151",
@@ -96,43 +111,57 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
<YAxis
type="category"
dataKey="name"
width={100}
width={1}
tick={{
fontSize: 12,
fill: theme === "dark" ? "#94a3b8" : "#374151",
textAnchor: "start",
style: {
transform: "translateX(10px) translateY(-26px)",
},
width: 400,
}}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: theme === "dark" ? "#1e293b" : "white",
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
borderRadius: "6px",
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
fontSize: "12px",
padding: "8px 12px",
color: theme === "dark" ? "white" : "black",
content={(props) => {
if (!props.active || !props.payload || !props.payload.length) {
return null;
}
const data = props.payload[0].payload;
if (!data.name || data.name === "") {
return null;
}
const hasValues = allTypes.some((type) => data[type] > 0);
if (!hasValues) {
return null;
}
return (
<div
style={{
backgroundColor: theme === "dark" ? "#1e293b" : "white",
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
borderRadius: "6px",
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
fontSize: "12px",
padding: "8px 12px",
color: theme === "dark" ? "white" : "black",
}}
>
{props.payload.map((entry: any, index: number) => (
<div key={index} style={{ color: entry.color }}>
{translateType(entry.dataKey)}: {entry.value}
</div>
))}
</div>
);
}}
formatter={(value: number, name: string) => [
value,
translateType(name),
]}
cursor={false}
/>
<Legend
formatter={(value) => translateType(value)}
wrapperStyle={{
fontSize: "10px",
display: "flex",
justifyContent: "center",
width: "100%",
}}
iconType="circle"
layout="horizontal"
verticalAlign="bottom"
height={36}
/>
{allTypes.map((type, i) => (
<Bar
key={type}
@@ -142,7 +171,21 @@ export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
radius={i === allTypes.length - 1 ? [0, 4, 4, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
<Legend
formatter={(value) => translateType(value)}
wrapperStyle={{
fontSize: "10px",
display: "flex",
justifyContent: "center",
width: "100%",
paddingTop: "16px",
marginBottom: "16px",
}}
iconType="circle"
layout="horizontal"
verticalAlign="bottom"
/>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</div>
@@ -0,0 +1,165 @@
"use client";
import { useTheme } from "next-themes";
import { useState } from "react";
import { CategoryData, RegionData } from "@/types/compliance";
interface HeatmapChartProps {
regions: RegionData[];
categories?: CategoryData[];
isRegionFiltered?: boolean; // Indicates if a region filter is active
filteredRegionName?: string; // Name of the filtered region
}
const getHeatmapColor = (percentage: number): string => {
if (percentage === 0) return "#10b981"; // Green for 0% failures
if (percentage <= 25) return "#eab308"; // Yellow
if (percentage <= 50) return "#f97316"; // Orange
if (percentage <= 100) return "#ef4444"; // Red
return "#ef4444";
};
const capitalizeFirstLetter = (text: string): string => {
const lowerText = text.toLowerCase();
const firstLetterIndex = lowerText.search(/[a-zA-Z]/);
if (firstLetterIndex === -1) return text; // No letters found
return (
lowerText.slice(0, firstLetterIndex) +
lowerText.charAt(firstLetterIndex).toUpperCase() +
lowerText.slice(firstLetterIndex + 1)
);
};
export const HeatmapChart = ({
regions,
categories = [],
isRegionFiltered = false,
}: HeatmapChartProps) => {
const { theme } = useTheme();
const [hoveredItem, setHoveredItem] = useState<
RegionData | CategoryData | null
>(null);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
// Determine what data to show and prepare it
const dataToShow = isRegionFiltered ? categories : regions;
const heatmapData = dataToShow
.filter((item) => item.totalRequirements > 0)
.sort((a, b) => b.failurePercentage - a.failurePercentage)
.slice(0, 9); // Exactly 9 items for 3x3 grid
// Check if there are no items with data
if (!dataToShow || dataToShow.length === 0 || heatmapData.length === 0) {
const noDataMessage = isRegionFiltered
? "No category data available"
: "No regional data available";
return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[400px]">
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
{isRegionFiltered
? "Categories Failure Rate"
: "Failure Rate by Region"}
</h3>
<div className="flex h-[320px] w-full items-center justify-center">
<p className="text-sm text-gray-500">{noDataMessage}</p>
</div>
</div>
);
}
const handleMouseEnter = (
item: RegionData | CategoryData,
event: React.MouseEvent,
) => {
setHoveredItem(item);
setMousePosition({ x: event.clientX, y: event.clientY });
};
const handleMouseMove = (event: React.MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
const handleMouseLeave = () => {
setHoveredItem(null);
};
return (
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
<div>
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
{isRegionFiltered
? "Categories Failure Rate"
: "Failure Rate by Region"}
</h3>
</div>
<div className="h-full w-full p-4">
{/* 3x3 Grid */}
<div className="grid h-full w-full grid-cols-3 gap-1">
{heatmapData.map((item) => (
<div
key={item.name}
className="flex items-center justify-center rounded border"
style={{
backgroundColor: getHeatmapColor(item.failurePercentage),
borderColor: theme === "dark" ? "#374151" : "#e5e7eb",
}}
onMouseEnter={(e) => handleMouseEnter(item, e)}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<div className="text-center">
<div
className="text-xs font-semibold"
style={{
color: theme === "dark" ? "#ffffff" : "#000000",
}}
>
{isRegionFiltered
? capitalizeFirstLetter(item.name)
: item.name}
</div>
<div
className="text-xs"
style={{
color: theme === "dark" ? "#ffffff" : "#000000",
}}
>
{item.failurePercentage}%
</div>
</div>
</div>
))}
</div>
{/* Custom Tooltip */}
{hoveredItem && (
<div
className="pointer-events-none fixed z-50 rounded border px-3 py-2 text-xs shadow-lg"
style={{
left: mousePosition.x + 10,
top: mousePosition.y - 10,
backgroundColor: theme === "dark" ? "#1e293b" : "white",
borderColor: theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)",
color: theme === "dark" ? "white" : "black",
}}
>
<div className="mb-1 font-semibold">
{isRegionFiltered
? capitalizeFirstLetter(hoveredItem.name)
: hoveredItem.name}
</div>
<div>Failure Rate: {hoveredItem.failurePercentage}%</div>
<div>
Failed: {hoveredItem.failedRequirements}/
{hoveredItem.totalRequirements}
</div>
</div>
)}
</div>
</div>
);
};
@@ -1,11 +1,17 @@
"use client";
import { useTheme } from "next-themes";
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
import {
Cell,
Label,
Pie,
PieChart as RechartsPieChart,
Tooltip,
} from "recharts";
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
interface RequirementsChartProps {
interface PieChartProps {
pass: number;
fail: number;
manual: number;
@@ -29,11 +35,7 @@ const chartConfig = {
},
} satisfies ChartConfig;
export const RequirementsChart = ({
pass,
fail,
manual,
}: RequirementsChartProps) => {
export const PieChart = ({ pass, fail, manual }: PieChartProps) => {
const { theme } = useTheme();
const chartData = [
@@ -119,7 +121,7 @@ export const RequirementsChart = ({
config={chartConfig}
className="aspect-square w-[200px] min-w-[200px]"
>
<PieChart>
<RechartsPieChart>
<Tooltip
cursor={false}
content={<CustomTooltip active={false} payload={[]} />}
@@ -168,7 +170,7 @@ export const RequirementsChart = ({
}}
/>
</Pie>
</PieChart>
</RechartsPieChart>
</ChartContainer>
<div className="mt-2 grid grid-cols-3 gap-4">
@@ -0,0 +1,150 @@
import ReactMarkdown from "react-markdown";
import { Requirement } from "@/types/compliance";
interface CISDetailsProps {
requirement: Requirement;
}
export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
const processReferences = (
references: string | number | string[] | undefined,
): string[] => {
if (typeof references !== "string") return [];
// Use regex to extract all URLs that start with https://
const urlRegex = /https:\/\/[^:]+/g;
const urls = references.match(urlRegex);
return urls || [];
};
return (
<div className="space-y-4">
{requirement.profile && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Profile Level
</h4>
<p className="text-sm">{requirement.profile}</p>
</div>
)}
{requirement.subsection && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
SubSection
</h4>
<p className="text-sm">{requirement.subsection}</p>
</div>
)}
{requirement.assessment_status && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Assessment Status
</h4>
<p className="text-sm">{requirement.assessment_status}</p>
</div>
)}
{requirement.description && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Description
</h4>
<p className="text-sm">{requirement.description}</p>
</div>
)}
{requirement.rationale_statement && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Rationale Statement
</h4>
<p className="text-sm">{requirement.rationale_statement}</p>
</div>
)}
{requirement.impact_statement && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Impact Statement
</h4>
<p className="text-sm">{requirement.impact_statement}</p>
</div>
)}
{requirement.remediation_procedure &&
typeof requirement.remediation_procedure === "string" && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Remediation Procedure
</h4>
{/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */}
{/* eslint-disable-next-line */}
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{requirement.remediation_procedure}</ReactMarkdown>
</div>
</div>
)}
{requirement.audit_procedure &&
typeof requirement.audit_procedure === "string" && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Audit Procedure
</h4>
{/* eslint-disable-next-line */}
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{requirement.audit_procedure}</ReactMarkdown>
</div>
</div>
)}
{requirement.additional_information && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Additional Information
</h4>
<p className="whitespace-pre-wrap text-sm">
{requirement.additional_information}
</p>
</div>
)}
{requirement.default_value && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
Default Value
</h4>
<p className="text-sm">{requirement.default_value}</p>
</div>
)}
{requirement.references && (
<div>
<h4 className="text-muted-foreground mb-1 text-sm font-medium">
References
</h4>
<div className="text-sm">
{processReferences(requirement.references).map(
(url: string, index: number) => (
<div key={index}>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 underline hover:text-blue-800"
>
{url}
</a>
</div>
),
)}
</div>
</div>
)}
</div>
);
};
@@ -1,6 +1,7 @@
import { translateType } from "@/lib/compliance/ens";
import { Requirement } from "@/types/compliance";
export const ComplianceCustomDetails = ({
export const ENSCustomDetails = ({
requirement,
}: {
requirement: Requirement;
@@ -11,27 +12,35 @@ export const ComplianceCustomDetails = ({
{requirement.description}
</div>
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="font-medium">Type:</span>
<span className="capitalize">
{translateType(requirement.type as string)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Level:</span>
<span className="capitalize">{requirement.nivel}</span>
</div>
{requirement.dimensiones && requirement.dimensiones.length > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium">Dimensions:</span>
<div className="flex flex-wrap gap-1">
{requirement.dimensiones.map(
(dimension: string, index: number) => (
<span
key={index}
className="rounded-full bg-gray-100 px-2 py-0.5 text-xs capitalize dark:bg-prowler-blue-400"
>
{dimension}
</span>
),
)}
{requirement.dimensiones &&
Array.isArray(requirement.dimensiones) &&
requirement.dimensiones.length > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium">Dimensions:</span>
<div className="flex flex-wrap gap-1">
{requirement.dimensiones.map(
(dimension: string, index: number) => (
<span
key={index}
className="rounded-full bg-gray-100 px-2 py-0.5 text-xs capitalize dark:bg-prowler-blue-400"
>
{dimension}
</span>
),
)}
</div>
</div>
</div>
)}
)}
</div>
</div>
);
@@ -0,0 +1,23 @@
import { Requirement } from "@/types/compliance";
export const ISOCustomDetails = ({
requirement,
}: {
requirement: Requirement;
}) => {
return (
<div className="mb-4">
<div className="mb-2 text-sm text-gray-600">
{requirement.description}
</div>
<div className="flex flex-col gap-2 text-sm">
{requirement.objetive_name && (
<div className="flex items-center gap-2">
<span className="font-medium">Objective:</span>
<span>{requirement.objetive_name}</span>
</div>
)}
</div>
</div>
);
};
@@ -13,6 +13,7 @@ interface ComplianceHeaderProps {
uniqueRegions: string[];
showSearch?: boolean;
showRegionFilter?: boolean;
framework?: string; // Framework name to show specific filters
}
export const ComplianceHeader = ({
@@ -20,25 +21,46 @@ export const ComplianceHeader = ({
uniqueRegions,
showSearch = true,
showRegionFilter = true,
framework,
}: ComplianceHeaderProps) => {
const frameworkFilters = [];
// Add CIS Profile Level filter if framework is CIS
if (framework === "CIS") {
frameworkFilters.push({
key: "cis_profile_level",
labelCheckboxGroup: "Level",
values: ["Level 1", "Level 2"],
index: 0, // Show first
showSelectAll: false, // No "Select All" option since Level 2 includes Level 1
defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1)
});
}
// Prepare region filters
const regionFilters = showRegionFilter
? [
{
key: "region__in",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 1, // Show after framework filters
defaultToSelectAll: true, // Default to all regions selected
},
]
: [];
const allFilters = [...frameworkFilters, ...regionFilters];
return (
<>
{showSearch && <FilterControls search />}
<Spacer y={8} />
<DataCompliance scans={scans} />
{showRegionFilter && (
{allFilters.length > 0 && (
<>
<Spacer y={8} />
<DataTableFilterCustom
filters={[
{
key: "region__in",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
},
]}
defaultOpen={true}
/>
<DataTableFilterCustom filters={allFilters} defaultOpen={true} />
</>
)}
<Spacer y={12} />
+18 -1
View File
@@ -1,4 +1,21 @@
export * from "./compliance-accordion/client-accordion-content";
export * from "./compliance-accordion/client-accordion-wrapper";
export * from "./compliance-accordion/compliance-accordion-requeriment-title";
export * from "./compliance-accordion/compliance-accordion-title";
export * from "./compliance-card";
export * from "./compliance-charts/bar-chart";
export * from "./compliance-charts/heatmap-chart";
export * from "./compliance-charts/pie-chart";
export * from "./compliance-custom-details/cis-details";
export * from "./compliance-custom-details/ens-details";
export * from "./compliance-custom-details/iso-details";
export * from "./compliance-header/compliance-header";
export * from "./compliance-header/compliance-scan-info";
export * from "./compliance-skeleton-grid";
export * from "./compliance-header/data-compliance";
export * from "./compliance-header/select-scan-compliance-data";
export * from "./no-scans-available";
export * from "./skeletons/bar-chart-skeleton";
export * from "./skeletons/compliance-accordion-skeleton";
export * from "./skeletons/compliance-grid-skeleton";
export * from "./skeletons/heatmap-chart-skeleton";
export * from "./skeletons/pie-chart-skeleton";
@@ -2,7 +2,7 @@
import { Skeleton } from "@nextui-org/react";
export const FailedSectionsChartSkeleton = () => {
export const BarChartSkeleton = () => {
return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
{/* Title skeleton */}
@@ -0,0 +1,28 @@
"use client";
import { Skeleton } from "@nextui-org/react";
export const HeatmapChartSkeleton = () => {
return (
<div className="flex h-[320px] w-[400px] flex-col items-center justify-between lg:w-[400px]">
{/* Title skeleton */}
<Skeleton className="h-4 w-36 rounded-lg">
<div className="h-4 bg-default-200" />
</Skeleton>
{/* Heatmap area skeleton - 3x3 grid like the real component */}
<div className="h-full w-full p-4">
<div className="grid h-full w-full grid-cols-3 gap-1">
{Array.from({ length: 9 }).map((_, index) => (
<Skeleton
key={index}
className="flex items-center justify-center rounded border"
>
<div className="h-full w-full bg-default-200" />
</Skeleton>
))}
</div>
</div>
</div>
);
};
@@ -2,7 +2,7 @@
import { Skeleton } from "@nextui-org/react";
export const RequirementsChartSkeleton = () => {
export const PieChartSkeleton = () => {
return (
<div className="flex h-[320px] flex-col items-center justify-between">
{/* Title skeleton */}
+4 -4
View File
@@ -118,7 +118,7 @@ export const Accordion = ({
return (
<NextUIAccordion
className={cn("w-full", className)}
className={cn("w-full !px-0", className)}
variant={variant}
selectionMode={selectionMode}
selectedKeys={expandedKeys}
@@ -137,12 +137,12 @@ export const Accordion = ({
isDisabled={item.isDisabled}
indicator={<ChevronDown className="text-gray-500" />}
classNames={{
base: index === 0 || index === 1 ? "my-2" : "my-1",
base: index === 0 || index === 1 ? "my-1" : "my-1",
title: "text-sm font-medium max-w-full overflow-hidden truncate",
subtitle: "text-xs text-gray-500",
trigger:
"p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center",
content: "p-2",
"py-2 px-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center",
content: "px-0 py-1",
}}
>
<AccordionContent
@@ -1,12 +1,13 @@
import { Suspense, use } from "react";
import { ReactNode, Suspense, use } from "react";
import { getUserInfo } from "@/actions/users/users";
import { Navbar } from "../nav-bar/navbar";
import { SkeletonContentLayout } from "./skeleton-content-layout";
interface ContentLayoutProps {
title: string;
icon: string;
icon: string | ReactNode;
children: React.ReactNode;
}
@@ -12,7 +12,13 @@ import {
} from "@nextui-org/react";
import { ChevronDown, X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ComplianceScanInfo } from "@/components/compliance";
import { CustomDropdownFilterProps } from "@/types";
@@ -24,6 +30,7 @@ export const CustomDropdownFilter = ({
const searchParams = useSearchParams();
const [groupSelected, setGroupSelected] = useState(new Set<string>());
const [isOpen, setIsOpen] = useState(false);
const hasUserInteracted = useRef(false);
const filterValues = useMemo(() => filter?.values || [], [filter?.values]);
const selectedValues = Array.from(groupSelected).filter(
@@ -41,24 +48,66 @@ export const CustomDropdownFilter = ({
useEffect(() => {
if (activeFilterValue.length > 0) {
const newSelection = new Set(activeFilterValue);
if (newSelection.size === filterValues.length) {
if (
newSelection.size === filterValues.length &&
filter?.showSelectAll !== false
) {
newSelection.add("all");
}
setGroupSelected(newSelection);
} else {
setGroupSelected(new Set());
} else if (!hasUserInteracted.current) {
// Handle default behavior when no URL params exist
// Only apply defaults if user hasn't interacted yet
// Only set visual state, don't trigger URL changes automatically
if (filter?.defaultToSelectAll && filterValues.length > 0) {
const newSelection = new Set(filterValues);
if (filter?.showSelectAll !== false) {
newSelection.add("all");
}
setGroupSelected(newSelection);
// DON'T notify parent automatically - wait for user interaction
} else if (filter?.defaultValues && filter.defaultValues.length > 0) {
// Handle specific default values
const validDefaultValues = filter.defaultValues.filter((value) =>
filterValues.includes(value),
);
const newSelection = new Set(validDefaultValues);
// Add "all" if all items are selected and showSelectAll is not false
if (
validDefaultValues.length === filterValues.length &&
filter?.showSelectAll !== false
) {
newSelection.add("all");
}
setGroupSelected(newSelection);
// DON'T notify parent automatically - wait for user interaction
} else {
setGroupSelected(new Set());
}
}
}, [activeFilterValue, filterValues.length]);
}, [
activeFilterValue,
filterValues,
filter?.defaultToSelectAll,
filter?.defaultValues,
filter?.showSelectAll,
]);
const updateSelection = useCallback(
(newValues: string[]) => {
// Mark that user has interacted with the filter
hasUserInteracted.current = true;
const actualValues = newValues.filter((key) => key !== "all");
const newSelection = new Set(actualValues);
// Auto-add "all" if all items are selected
// Auto-add "all" if all items are selected and showSelectAll is not false
if (
actualValues.length === filterValues.length &&
filterValues.length > 0
filterValues.length > 0 &&
filter?.showSelectAll !== false
) {
newSelection.add("all");
}
@@ -68,7 +117,7 @@ export const CustomDropdownFilter = ({
// Notify parent with actual values (excluding "all")
onFilterChange?.(filter.key, actualValues);
},
[filterValues.length, onFilterChange, filter.key],
[filterValues.length, onFilterChange, filter.key, filter?.showSelectAll],
);
const onSelectionChange = useCallback(
@@ -193,16 +242,20 @@ export const CustomDropdownFilter = ({
onValueChange={onSelectionChange}
className="font-bold"
>
<Checkbox
classNames={{
label: "text-small font-normal",
wrapper: "checkbox-update",
}}
value="all"
>
Select All
</Checkbox>
<Divider orientation="horizontal" className="mt-2" />
{filter?.showSelectAll !== false && (
<>
<Checkbox
classNames={{
label: "text-small font-normal",
wrapper: "checkbox-update",
}}
value="all"
>
Select All
</Checkbox>
<Divider orientation="horizontal" className="mt-2" />
</>
)}
<ScrollShadow
hideScrollBar
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
+15 -7
View File
@@ -1,13 +1,15 @@
import { Icon } from "@iconify/react";
import { ReactNode } from "react";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { UserProfileProps } from "@/types";
import { SheetMenu } from "../sidebar/sheet-menu";
import { UserNav } from "../user-nav/user-nav";
interface NavbarProps {
title: string;
icon: string;
icon: string | ReactNode;
user: UserProfileProps;
}
@@ -17,12 +19,18 @@ export function Navbar({ title, icon, user }: NavbarProps) {
<div className="mx-4 flex h-14 items-center sm:mx-8">
<div className="flex items-center space-x-2">
<SheetMenu />
<Icon
className="text-default-500"
height={24}
icon={icon}
width={24}
/>
{typeof icon === "string" ? (
<Icon
className="text-default-500"
height={24}
icon={icon}
width={24}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center [&>*]:h-full [&>*]:w-full">
{icon}
</div>
)}
<h1 className="text-sm font-bold text-default-700">{title}</h1>
</div>
<div className="flex flex-1 items-center justify-end gap-3">
+204
View File
@@ -0,0 +1,204 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
CISAttributesMetadata,
Framework,
Requirement,
RequirementItemData,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
filter?: string, // "Level 1" or "Level 2" or undefined (show all)
): Framework[] => {
const attributes = attributesData?.data || [];
const requirements = requirementsData?.data || [];
// Create a map for quick lookup of requirements by id
const requirementsMap = new Map<string, RequirementItemData>();
requirements.forEach((req: RequirementItemData) => {
requirementsMap.set(req.id, req);
});
const frameworks: Framework[] = [];
// Process attributes and merge with requirements data
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes
?.metadata as unknown as CISAttributesMetadata[];
const attrs = metadataArray?.[0];
if (!attrs) continue;
// Apply profile filter
if (filter === "Level 1" && attrs.Profile !== "Level 1") {
continue; // Skip Level 2 requirements when Level 1 is selected
}
// Get corresponding requirement data
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
const sectionName = attrs.Section;
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const requirementName = id;
// Find or create framework
let framework = frameworks.find((f) => f.name === frameworkName);
if (!framework) {
framework = {
name: frameworkName,
pass: 0,
fail: 0,
manual: 0,
categories: [],
};
frameworks.push(framework);
}
const normalizedSectionName = sectionName.replace(/^(\d+)\s/, "$1. ");
let category = framework.categories.find(
(c) => c.name === normalizedSectionName,
);
if (!category) {
category = {
name: normalizedSectionName,
pass: 0,
fail: 0,
manual: 0,
controls: [],
};
framework.categories.push(category);
}
// Create a control for this requirement (each requirement is its own control)
const controlLabel = `${id} - ${description}`;
const control = {
label: controlLabel,
pass: 0,
fail: 0,
manual: 0,
requirements: [] as Requirement[],
};
// Create requirement
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
name: requirementName,
description: attrs.Description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
profile: attrs.Profile,
subsection: attrs.SubSection || "",
assessment_status: attrs.AssessmentStatus,
rationale_statement: attrs.RationaleStatement,
impact_statement: attrs.ImpactStatement,
remediation_procedure: attrs.RemediationProcedure,
audit_procedure: attrs.AuditProcedure,
additional_information: attrs.AdditionalInformation,
default_value: attrs.DefaultValue || "",
references: attrs.References,
};
control.requirements.push(requirement);
// Update control counters
if (requirement.status === "MANUAL") {
control.manual++;
} else if (requirement.status === "PASS") {
control.pass++;
} else if (requirement.status === "FAIL") {
control.fail++;
}
category.controls.push(control);
}
// Calculate counters for categories and frameworks
frameworks.forEach((framework) => {
framework.pass = 0;
framework.fail = 0;
framework.manual = 0;
framework.categories.forEach((category) => {
category.pass = 0;
category.fail = 0;
category.manual = 0;
category.controls.forEach((control) => {
category.pass += control.pass;
category.fail += control.fail;
category.manual += control.manual;
});
framework.pass += category.pass;
framework.fail += category.fail;
framework.manual += category.manual;
});
});
return frameworks;
};
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
return data.flatMap((framework) =>
framework.categories.map((category) => {
return {
key: `${framework.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.map((control, i: number) => {
const requirement = control.requirements[0]; // Each control has one requirement
const itemKey = `${framework.name}-${category.name}-control-${i}`;
return {
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={control.label}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
requirement={requirement}
scanId={scanId || ""}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
};
}),
};
}),
);
};
+257
View File
@@ -0,0 +1,257 @@
import React from "react";
import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details";
import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details";
import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import {
AttributesData,
CategoryData,
FailedSection,
Framework,
RegionData,
Requirement,
RequirementsData,
} from "@/types/compliance";
import {
mapComplianceData as mapCISComplianceData,
toAccordionItems as toCISAccordionItems,
} from "./cis";
import {
mapComplianceData as mapENSComplianceData,
toAccordionItems as toENSAccordionItems,
} from "./ens";
import {
mapComplianceData as mapISOComplianceData,
toAccordionItems as toISOAccordionItems,
} from "./iso";
export interface ComplianceMapper {
mapComplianceData: (
attributesData: AttributesData,
requirementsData: RequirementsData,
filter?: string,
) => Framework[];
toAccordionItems: (
data: Framework[],
scanId: string | undefined,
) => AccordionItemProps[];
getTopFailedSections: (mappedData: Framework[]) => FailedSection[];
getDetailsComponent: (requirement: Requirement) => React.ReactNode;
}
// Common function for getting top failed sections
export const getTopFailedSections = (
mappedData: Framework[],
): FailedSection[] => {
const failedSectionMap = new Map();
mappedData.forEach((framework) => {
framework.categories.forEach((category) => {
category.controls.forEach((control) => {
control.requirements.forEach((requirement) => {
if (requirement.status === "FAIL") {
const sectionName = category.name;
if (!failedSectionMap.has(sectionName)) {
failedSectionMap.set(sectionName, { total: 0, types: {} });
}
const sectionData = failedSectionMap.get(sectionName);
sectionData.total += 1;
const type = requirement.type || "Fails";
sectionData.types[type as string] =
(sectionData.types[type as string] || 0) + 1;
}
});
});
});
});
// Convert in descending order and slice top 5
return Array.from(failedSectionMap.entries())
.map(([name, data]) => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5); // Top 5
};
// Registry of compliance mappers
const complianceMappers: Record<string, ComplianceMapper> = {
ENS: {
mapComplianceData: mapENSComplianceData,
toAccordionItems: toENSAccordionItems,
getTopFailedSections,
getDetailsComponent: (requirement: Requirement) =>
React.createElement(ENSCustomDetails, { requirement }),
},
ISO27001: {
mapComplianceData: mapISOComplianceData,
toAccordionItems: toISOAccordionItems,
getTopFailedSections,
getDetailsComponent: (requirement: Requirement) =>
React.createElement(ISOCustomDetails, { requirement }),
},
CIS: {
mapComplianceData: mapCISComplianceData,
toAccordionItems: toCISAccordionItems,
getTopFailedSections,
getDetailsComponent: (requirement: Requirement) =>
React.createElement(CISCustomDetails, { requirement }),
},
};
// Default mapper (fallback to ENS for backward compatibility)
const defaultMapper: ComplianceMapper = complianceMappers.ENS;
/**
* Get the appropriate compliance mapper based on the framework name
* @param framework - The framework name (e.g., "ENS", "ISO27001", "CIS")
* @returns ComplianceMapper object with specific functions for the framework
*/
export const getComplianceMapper = (framework?: string): ComplianceMapper => {
if (!framework) {
return defaultMapper;
}
return complianceMappers[framework] || defaultMapper;
};
export const calculateRegionHeatmapData = async (
complianceId: string,
scanId: string,
uniqueRegions: string[],
attributesData: AttributesData,
mapper: ComplianceMapper,
): Promise<RegionData[]> => {
if (!complianceId || !scanId || !uniqueRegions?.length) {
return [];
}
try {
const { getComplianceRequirements } = await import("@/actions/compliances");
// Get data for each region in parallel
const regionPromises = uniqueRegions.map(async (region) => {
try {
// Only need to fetch requirements data per region
const regionRequirementsData = await getComplianceRequirements({
complianceId,
scanId,
region, // Filter by specific region
});
// Map the data using the provided mapper
const mappedData = mapper.mapComplianceData(
attributesData,
regionRequirementsData,
);
// Calculate totals for this region
const regionTotals = mappedData.reduce(
(acc, framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const totalRequirements =
regionTotals.pass + regionTotals.fail + regionTotals.manual;
const failurePercentage =
totalRequirements > 0
? Math.round((regionTotals.fail / totalRequirements) * 100)
: 0;
return {
name: region,
failurePercentage,
totalRequirements,
failedRequirements: regionTotals.fail,
};
} catch (error) {
console.error(`Error fetching data for region ${region}:`, error);
return {
name: region,
failurePercentage: 0,
totalRequirements: 0,
failedRequirements: 0,
};
}
});
const regionData = await Promise.all(regionPromises);
// Filter, sort and limit to top 9 regions for 3x3 grid
const filteredData = regionData
.filter((region) => region.totalRequirements > 0)
.sort((a, b) => b.failurePercentage - a.failurePercentage)
.slice(0, 9);
return filteredData;
} catch (error) {
console.error("Error calculating region heatmap data:", error);
return [];
}
};
export const calculateCategoryHeatmapData = (
complianceData: Framework[],
): CategoryData[] => {
if (!complianceData?.length) {
return [];
}
try {
const categoryMap = new Map<
string,
{ pass: number; fail: number; manual: number }
>();
// Aggregate data by category
complianceData.forEach((framework) => {
framework.categories.forEach((category) => {
const existing = categoryMap.get(category.name) || {
pass: 0,
fail: 0,
manual: 0,
};
categoryMap.set(category.name, {
pass: existing.pass + category.pass,
fail: existing.fail + category.fail,
manual: existing.manual + category.manual,
});
});
});
const categoryData: CategoryData[] = Array.from(categoryMap.entries()).map(
([name, stats]) => {
const totalRequirements = stats.pass + stats.fail + stats.manual;
const failurePercentage =
totalRequirements > 0
? Math.round((stats.fail / totalRequirements) * 100)
: 0;
return {
name,
failurePercentage,
totalRequirements,
failedRequirements: stats.fail,
};
},
);
const filteredData = categoryData
.filter((category) => category.totalRequirements > 0)
.sort((a, b) => b.failurePercentage - a.failurePercentage)
.slice(0, 9); // Show top 9 categories
return filteredData;
} catch (error) {
console.error("Error calculating category heatmap data:", error);
return [];
}
};
+17 -10
View File
@@ -5,8 +5,8 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
ENSAttributesMetadata,
Framework,
MappedComplianceData,
Requirement,
RequirementItemData,
RequirementsData,
@@ -14,6 +14,10 @@ import {
} from "@/types/compliance";
export const translateType = (type: string) => {
if (!type) {
return "";
}
switch (type.toLowerCase()) {
case "requisito":
return "Requirement";
@@ -31,7 +35,7 @@ export const translateType = (type: string) => {
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
): MappedComplianceData => {
): Framework[] => {
const attributes = attributesData?.data || [];
const requirements = requirementsData?.data || [];
@@ -46,7 +50,9 @@ export const mapComplianceData = (
// Process attributes and merge with requirements data
for (const attributeItem of attributes) {
const id = attributeItem.id;
const attrs = attributeItem.attributes?.attributes?.metadata?.[0];
const attrs = attributeItem.attributes?.attributes
?.metadata?.[0] as ENSAttributesMetadata;
if (!attrs) continue;
// Get corresponding requirement data
@@ -96,7 +102,6 @@ export const mapComplianceData = (
if (!control) {
control = {
label: groupControlLabel,
type,
pass: 0,
fail: 0,
manual: 0,
@@ -166,7 +171,7 @@ export const mapComplianceData = (
};
export const toAccordionItems = (
data: MappedComplianceData,
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
return data.map((framework) => {
@@ -178,6 +183,7 @@ export const toAccordionItems = (
pass={framework.pass}
fail={framework.fail}
manual={framework.manual}
isParentLevel={true}
/>
),
content: "",
@@ -212,7 +218,7 @@ export const toAccordionItems = (
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type={requirement.type}
type={requirement.type as string}
name={requirement.name}
status={requirement.status as FindingStatus}
/>
@@ -221,12 +227,13 @@ export const toAccordionItems = (
<ClientAccordionContent
requirement={requirement}
scanId={scanId || ""}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 &&
requirement.manual === 0
}
/>
),
items: [],
isDisabled:
requirement.check_ids.length === 0 &&
requirement.manual === 0,
};
}),
isDisabled:
+212
View File
@@ -0,0 +1,212 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
Framework,
ISO27001AttributesMetadata,
Requirement,
RequirementItemData,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
): Framework[] => {
const attributes = attributesData?.data || [];
const requirements = requirementsData?.data || [];
// Create a map for quick lookup of requirements by id
const requirementsMap = new Map<string, RequirementItemData>();
requirements.forEach((req: RequirementItemData) => {
requirementsMap.set(req.id, req);
});
const frameworks: Framework[] = [];
// Process attributes and merge with requirements data
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes
?.metadata as unknown as ISO27001AttributesMetadata[];
const attrs = metadataArray?.[0];
if (!attrs) continue;
// Get corresponding requirement data
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
const categoryName = attrs.Category;
const controlLabel = `${attrs.Objetive_ID} - ${attrs.Objetive_Name}`;
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const requirementName = id;
const objetiveName = attrs.Objetive_Name;
const checkSummary = attrs.Check_Summary;
// Find or create framework
let framework = frameworks.find((f) => f.name === frameworkName);
if (!framework) {
framework = {
name: frameworkName,
pass: 0,
fail: 0,
manual: 0,
categories: [],
};
frameworks.push(framework);
}
// Find or create category
let category = framework.categories.find((c) => c.name === categoryName);
if (!category) {
category = {
name: categoryName,
pass: 0,
fail: 0,
manual: 0,
controls: [],
};
framework.categories.push(category);
}
// Find or create control
let control = category.controls.find((c) => c.label === controlLabel);
if (!control) {
control = {
label: controlLabel,
pass: 0,
fail: 0,
manual: 0,
requirements: [],
};
category.controls.push(control);
}
// Create requirement
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
name: requirementName,
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
objetive_name: objetiveName,
check_summary: checkSummary,
};
control.requirements.push(requirement);
}
// Calculate counters
frameworks.forEach((framework) => {
framework.pass = 0;
framework.fail = 0;
framework.manual = 0;
framework.categories.forEach((category) => {
category.pass = 0;
category.fail = 0;
category.manual = 0;
category.controls.forEach((control) => {
control.pass = 0;
control.fail = 0;
control.manual = 0;
control.requirements.forEach((requirement) => {
if (requirement.status === "MANUAL") {
control.manual++;
} else if (requirement.status === "PASS") {
control.pass++;
} else if (requirement.status === "FAIL") {
control.fail++;
}
});
category.pass += control.pass;
category.fail += control.fail;
category.manual += control.manual;
});
framework.pass += category.pass;
framework.fail += category.fail;
framework.manual += category.manual;
});
});
return frameworks;
};
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
return data.flatMap((framework) =>
framework.categories.map((category) => {
return {
key: `${framework.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.map((control, i: number) => {
return {
key: `${framework.name}-${category.name}-control-${i}`,
title: (
<ComplianceAccordionTitle
label={control.label}
pass={control.pass}
fail={control.fail}
manual={control.manual}
/>
),
content: "",
items: control.requirements.map((requirement, j: number) => {
const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`;
return {
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
requirement={requirement}
scanId={scanId || ""}
framework={framework.name}
disableFindings={
requirement.check_ids.length === 0 &&
requirement.manual === 0
}
/>
),
items: [],
};
}),
isDisabled:
control.pass === 0 && control.fail === 0 && control.manual === 0,
};
}),
};
}),
);
};
+1219 -7
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@
"@radix-ui/react-toast": "^1.2.4",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-table": "^8.19.3",
"add": "^2.0.6",
"alert": "^6.0.2",
@@ -35,6 +36,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.2",
"react-markdown": "^10.1.0",
"recharts": "^2.15.2",
"server-only": "^0.0.1",
"shadcn-ui": "^0.2.3",
+4 -3
View File
@@ -171,9 +171,9 @@ module.exports = {
"100%": { left: "100%", width: "100%" },
},
dropArrow: {
'0%': { transform: 'translateY(-8px)', opacity: '0' },
'50%': { opacity: '1' },
'100%': { transform: 'translateY(0)', opacity: '1' },
"0%": { transform: "translateY(-8px)", opacity: "0" },
"50%": { opacity: "1" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
animation: {
@@ -188,6 +188,7 @@ module.exports = {
},
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
nextui({
themes: {
dark: {
+52 -11
View File
@@ -1,6 +1,15 @@
export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings";
export type ComplianceId = "ens_rd2022_aws";
export type ComplianceId =
| "ens_rd2022_aws"
| "iso27001_2013_aws"
| "iso27001_2022_aws"
| "cis_1.4_aws"
| "cis_1.5_aws"
| "cis_2.0_aws"
| "cis_3.0_aws"
| "cis_4.0_aws"
| "cis_5.0_aws";
export interface CompliancesOverview {
data: ComplianceOverviewData[];
@@ -23,19 +32,17 @@ export interface Requirement {
name: string;
description: string;
status: RequirementStatus;
type: string;
pass: number;
fail: number;
manual: number;
check_ids: string[];
// ENS
nivel?: string;
dimensiones?: string[];
// This is to allow any key to be added to the requirement object
// because each compliance has different keys
[key: string]: string | string[] | number | undefined;
}
export interface Control {
label: string;
type: string;
pass: number;
fail: number;
manual: number;
@@ -58,12 +65,10 @@ export interface Framework {
categories: Category[];
}
export type MappedComplianceData = Framework[];
export interface FailedSection {
name: string;
total: number;
types: { [key: string]: number };
types?: { [key: string]: number };
}
export interface RequirementsTotals {
@@ -73,7 +78,7 @@ export interface RequirementsTotals {
}
// API Responses types:
export interface AttributesMetadata {
export interface ENSAttributesMetadata {
IdGrupoControl: string;
Marco: string;
Categoria: string;
@@ -85,6 +90,28 @@ export interface AttributesMetadata {
Dependencias: any[];
}
export interface ISO27001AttributesMetadata {
Category: string;
Objetive_ID: string;
Objetive_Name: string;
Check_Summary: string;
}
export interface CISAttributesMetadata {
Section: string;
SubSection: string | null;
Profile: string; // "Level 1" or "Level 2"
AssessmentStatus: string; // "Manual" or "Automated"
Description: string;
RationaleStatement: string;
ImpactStatement: string;
RemediationProcedure: string;
AuditProcedure: string;
AdditionalInformation: string;
DefaultValue: string | null;
References: string;
}
export interface AttributesItemData {
type: "compliance-requirements-attributes";
id: string;
@@ -93,7 +120,7 @@ export interface AttributesItemData {
version: string;
description: string;
attributes: {
metadata: AttributesMetadata[];
metadata: ENSAttributesMetadata[] | ISO27001AttributesMetadata[];
check_ids: string[];
};
};
@@ -117,3 +144,17 @@ export interface AttributesData {
export interface RequirementsData {
data: RequirementItemData[];
}
export interface RegionData {
name: string;
failurePercentage: number;
totalRequirements: number;
failedRequirements: number;
}
export interface CategoryData {
name: string;
failurePercentage: number;
totalRequirements: number;
failedRequirements: number;
}
+3
View File
@@ -18,6 +18,9 @@ export interface FilterOption {
};
}>;
index?: number;
showSelectAll?: boolean;
defaultToSelectAll?: boolean;
defaultValues?: string[];
}
export interface CustomDropdownFilterProps {