From 50286846e0b43a05b852847c4ab4b0a9a0063625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 9 Dec 2025 16:28:47 +0100 Subject: [PATCH] fix(ui): show Top Failed Requirements for compliances without section hierarchy (#9471) Co-authored-by: Alan Buscaglia --- ui/.husky/pre-commit | 2 +- ui/CHANGELOG.md | 7 ++ .../compliance/[compliancetitle]/page.tsx | 7 +- .../top-failed-sections-card.tsx | 15 ++- ui/lib/compliance/commons.tsx | 115 ++++++++++++++---- ui/lib/compliance/compliance-mapper.ts | 30 ++--- ui/lib/compliance/mitre.tsx | 103 +++++++++------- ui/types/compliance.ts | 19 ++- 8 files changed, 204 insertions(+), 94 deletions(-) diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit index c82401e6e5..9e12cf3a85 100755 --- a/ui/.husky/pre-commit +++ b/ui/.husky/pre-commit @@ -62,7 +62,7 @@ You are a code reviewer for the Prowler UI project. Analyze the full file conten **RULES TO CHECK:** 1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }` 2. TypeScript: NO union types like `type X = "a" | "b"` → Use const-based: `const X = {...} as const` -3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes. +3. Tailwind: NO `var()` or hex colors in className → Use Tailwind utilities and semantic color classes. Exception: `var()` is allowed when passing colors to chart/graph components that require CSS color strings (not Tailwind classes) for their APIs. 4. cn(): Use for merging multiple classes or for conditionals (handles Tailwind conflicts with twMerge) → `cn(BUTTON_STYLES.base, BUTTON_STYLES.active, isLoading && "opacity-50")` 5. React 19: NO `useMemo`/`useCallback` without reason 6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod. diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7e40c7e078..ff45e1de04 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -24,6 +24,13 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [1.14.3] (Prowler Unreleased) + +### 🐞 Fixed +- Show top failed requirements in compliance specific view for compliance without sections [(#9471)](https://github.com/prowler-cloud/prowler/pull/9471) + +--- + ## [1.14.2] (Prowler v5.14.2) ### 🐞 Fixed diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 4061478f94..974b8edede 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -195,7 +195,7 @@ const SSRComplianceContent = async ({ { pass: 0, fail: 0, manual: 0 }, ); const accordionItems = mapper.toAccordionItems(data, scanId); - const topFailedSections = mapper.getTopFailedSections(data); + const topFailedResult = mapper.getTopFailedSections(data); return (
@@ -205,7 +205,10 @@ const SSRComplianceContent = async ({ fail={totalRequirements.fail} manual={totalRequirements.manual} /> - + {/* */}
diff --git a/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx b/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx index 70de7d6bc4..f2429cb597 100644 --- a/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx +++ b/ui/components/compliance/compliance-charts/top-failed-sections-card.tsx @@ -3,14 +3,20 @@ import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart"; import { BarDataPoint } from "@/components/graphs/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; -import { FailedSection } from "@/types/compliance"; +import { + FailedSection, + TOP_FAILED_DATA_TYPE, + TopFailedDataType, +} from "@/types/compliance"; interface TopFailedSectionsCardProps { sections: FailedSection[]; + dataType?: TopFailedDataType; } export function TopFailedSectionsCard({ sections, + dataType = TOP_FAILED_DATA_TYPE.SECTIONS, }: TopFailedSectionsCardProps) { // Transform FailedSection[] to BarDataPoint[] const total = sections.reduce((sum, section) => sum + section.total, 0); @@ -22,13 +28,18 @@ export function TopFailedSectionsCard({ color: "var(--bg-fail-primary)", })); + const title = + dataType === TOP_FAILED_DATA_TYPE.REQUIREMENTS + ? "Top Failed Requirements" + : "Top Failed Sections"; + return ( - Top Failed Sections + {title} diff --git a/ui/lib/compliance/commons.tsx b/ui/lib/compliance/commons.tsx index 3a1462cce5..dc96678e2c 100644 --- a/ui/lib/compliance/commons.tsx +++ b/ui/lib/compliance/commons.tsx @@ -1,14 +1,65 @@ import { + Category, CategoryData, + Control, FailedSection, Framework, - Requirement, REQUIREMENT_STATUS, RequirementItemData, RequirementsData, RequirementStatus, + TOP_FAILED_DATA_TYPE, + TopFailedDataType, + TopFailedResult, } from "@/types/compliance"; +// Type for the internal map used in getTopFailedSections +interface FailedSectionData { + total: number; + types: Record; +} + +/** + * Builds the TopFailedResult from the accumulated map data + */ +const buildTopFailedResult = ( + map: Map, + type: TopFailedDataType, +): TopFailedResult => ({ + items: Array.from(map.entries()) + .map(([name, data]): FailedSection => ({ name, ...data })) + .sort((a, b) => b.total - a.total) + .slice(0, 5), + type, +}); + +/** + * Checks if the framework uses a flat structure (requirements directly on framework) + * vs hierarchical structure (categories -> controls -> requirements) + */ +const hasFlatStructure = (frameworks: Framework[]): boolean => + frameworks.some( + (framework) => + (framework.requirements?.length ?? 0) > 0 && + framework.categories.length === 0, + ); + +/** + * Increments the failed count for a given name in the map + */ +const incrementFailedCount = ( + map: Map, + name: string, + type: string, +): void => { + if (!map.has(name)) { + map.set(name, { total: 0, types: {} }); + } + const data = map.get(name)!; + data.total += 1; + data.types[type] = (data.types[type] || 0) + 1; +}; + export const updateCounters = ( target: { pass: number; fail: number; manual: number }, status: RequirementStatus, @@ -24,38 +75,45 @@ export const updateCounters = ( export const getTopFailedSections = ( mappedData: Framework[], -): FailedSection[] => { - const failedSectionMap = new Map(); +): TopFailedResult => { + const failedSectionMap = new Map(); + if (hasFlatStructure(mappedData)) { + // Handle flat structure: count failed requirements directly + mappedData.forEach((framework) => { + const directRequirements = framework.requirements ?? []; + + directRequirements.forEach((requirement) => { + if (requirement.status === REQUIREMENT_STATUS.FAIL) { + const type = + typeof requirement.type === "string" ? requirement.type : "Fails"; + incrementFailedCount(failedSectionMap, requirement.name, type); + } + }); + }); + + return buildTopFailedResult( + failedSectionMap, + TOP_FAILED_DATA_TYPE.REQUIREMENTS, + ); + } + + // Handle hierarchical structure: count by category (section) mappedData.forEach((framework) => { framework.categories.forEach((category) => { category.controls.forEach((control) => { control.requirements.forEach((requirement) => { if (requirement.status === 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; + const type = + typeof requirement.type === "string" ? requirement.type : "Fails"; + incrementFailedCount(failedSectionMap, category.name, type); } }); }); }); }); - // 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 + return buildTopFailedResult(failedSectionMap, TOP_FAILED_DATA_TYPE.SECTIONS); }; export const calculateCategoryHeatmapData = ( @@ -146,9 +204,9 @@ export const findOrCreateFramework = ( }; export const findOrCreateCategory = ( - categories: any[], + categories: Category[], categoryName: string, -) => { +): Category => { let category = categories.find((c) => c.name === categoryName); if (!category) { category = { @@ -163,7 +221,10 @@ export const findOrCreateCategory = ( return category; }; -export const findOrCreateControl = (controls: any[], controlLabel: string) => { +export const findOrCreateControl = ( + controls: Control[], + controlLabel: string, +): Control => { let control = controls.find((c) => c.label === controlLabel); if (!control) { control = { @@ -178,7 +239,7 @@ export const findOrCreateControl = (controls: any[], controlLabel: string) => { return control; }; -export const calculateFrameworkCounters = (frameworks: Framework[]) => { +export const calculateFrameworkCounters = (frameworks: Framework[]): void => { frameworks.forEach((framework) => { // Reset framework counters framework.pass = 0; @@ -186,9 +247,9 @@ export const calculateFrameworkCounters = (frameworks: Framework[]) => { framework.manual = 0; // Handle flat structure (requirements directly in framework) - const directRequirements = (framework as any).requirements || []; + const directRequirements = framework.requirements ?? []; if (directRequirements.length > 0) { - directRequirements.forEach((requirement: Requirement) => { + directRequirements.forEach((requirement) => { updateCounters(framework, requirement.status); }); return; diff --git a/ui/lib/compliance/compliance-mapper.ts b/ui/lib/compliance/compliance-mapper.ts index da47130491..2446156e2b 100644 --- a/ui/lib/compliance/compliance-mapper.ts +++ b/ui/lib/compliance/compliance-mapper.ts @@ -1,4 +1,4 @@ -import React from "react"; +import { createElement, ReactNode } from "react"; import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details"; import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details"; @@ -14,10 +14,10 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; import { AttributesData, CategoryData, - FailedSection, Framework, Requirement, RequirementsData, + TopFailedResult, } from "@/types/compliance"; import { @@ -74,9 +74,9 @@ export interface ComplianceMapper { data: Framework[], scanId: string | undefined, ) => AccordionItemProps[]; - getTopFailedSections: (mappedData: Framework[]) => FailedSection[]; + getTopFailedSections: (mappedData: Framework[]) => TopFailedResult; calculateCategoryHeatmapData: (complianceData: Framework[]) => CategoryData[]; - getDetailsComponent: (requirement: Requirement) => React.ReactNode; + getDetailsComponent: (requirement: Requirement) => ReactNode; } const getDefaultMapper = (): ComplianceMapper => ({ @@ -86,7 +86,7 @@ const getDefaultMapper = (): ComplianceMapper => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(GenericCustomDetails, { requirement }), + createElement(GenericCustomDetails, { requirement }), }); const getComplianceMappers = (): Record => ({ @@ -97,7 +97,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(C5CustomDetails, { requirement }), + createElement(C5CustomDetails, { requirement }), }, ENS: { mapComplianceData: mapENSComplianceData, @@ -106,7 +106,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(ENSCustomDetails, { requirement }), + createElement(ENSCustomDetails, { requirement }), }, ISO27001: { mapComplianceData: mapISOComplianceData, @@ -115,7 +115,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(ISOCustomDetails, { requirement }), + createElement(ISOCustomDetails, { requirement }), }, CIS: { mapComplianceData: mapCISComplianceData, @@ -124,7 +124,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(CISCustomDetails, { requirement }), + createElement(CISCustomDetails, { requirement }), }, "AWS-Well-Architected-Framework-Security-Pillar": { mapComplianceData: mapAWSWellArchitectedComplianceData, @@ -133,7 +133,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(AWSWellArchitectedCustomDetails, { requirement }), + createElement(AWSWellArchitectedCustomDetails, { requirement }), }, "AWS-Well-Architected-Framework-Reliability-Pillar": { mapComplianceData: mapAWSWellArchitectedComplianceData, @@ -142,7 +142,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(AWSWellArchitectedCustomDetails, { requirement }), + createElement(AWSWellArchitectedCustomDetails, { requirement }), }, "KISA-ISMS-P": { mapComplianceData: mapKISAComplianceData, @@ -151,7 +151,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(KISACustomDetails, { requirement }), + createElement(KISACustomDetails, { requirement }), }, "MITRE-ATTACK": { mapComplianceData: mapMITREComplianceData, @@ -159,7 +159,7 @@ const getComplianceMappers = (): Record => ({ getTopFailedSections: getMITRETopFailedSections, calculateCategoryHeatmapData: calculateMITRECategoryHeatmapData, getDetailsComponent: (requirement: Requirement) => - React.createElement(MITRECustomDetails, { requirement }), + createElement(MITRECustomDetails, { requirement }), }, ProwlerThreatScore: { mapComplianceData: mapThetaComplianceData, @@ -168,7 +168,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (complianceData: Framework[]) => calculateCategoryHeatmapData(complianceData), getDetailsComponent: (requirement: Requirement) => - React.createElement(ThreatCustomDetails, { requirement }), + createElement(ThreatCustomDetails, { requirement }), }, CCC: { mapComplianceData: mapCCCComplianceData, @@ -177,7 +177,7 @@ const getComplianceMappers = (): Record => ({ calculateCategoryHeatmapData: (data: Framework[]) => calculateCategoryHeatmapData(data), getDetailsComponent: (requirement: Requirement) => - React.createElement(CCCCustomDetails, { requirement }), + createElement(CCCCustomDetails, { requirement }), }, }); diff --git a/ui/lib/compliance/mitre.tsx b/ui/lib/compliance/mitre.tsx index 706cfcb36a..1358a2c240 100644 --- a/ui/lib/compliance/mitre.tsx +++ b/ui/lib/compliance/mitre.tsx @@ -12,6 +12,8 @@ import { REQUIREMENT_STATUS, RequirementsData, RequirementStatus, + TOP_FAILED_DATA_TYPE, + TopFailedResult, } from "@/types/compliance"; import { @@ -20,6 +22,12 @@ import { findOrCreateFramework, } from "./commons"; +// Type for the internal map used in getTopFailedSections +interface FailedSectionData { + total: number; + types: Record; +} + export const mapComplianceData = ( attributesData: AttributesData, requirementsData: RequirementsData, @@ -92,9 +100,9 @@ export const mapComplianceData = ( }) || [], }; - // Add requirement directly to framework (store in a special property) - (framework as any).requirements = (framework as any).requirements || []; - (framework as any).requirements.push(requirement); + // Add requirement directly to framework (flat structure - no categories) + framework.requirements = framework.requirements ?? []; + framework.requirements.push(requirement); } // Calculate counters using common helper (works with flat structure) @@ -108,63 +116,63 @@ export const toAccordionItems = ( scanId: string | undefined, ): AccordionItemProps[] => { return data.flatMap((framework) => { - const requirements = (framework as any).requirements || []; + const requirements = framework.requirements ?? []; // Filter out requirements without metadata (can't be displayed in accordion) const displayableRequirements = requirements.filter( - (requirement: Requirement) => requirement.hasMetadata !== false, + (requirement) => requirement.hasMetadata !== false, ); - return displayableRequirements.map( - (requirement: Requirement, i: number) => { - const itemKey = `${framework.name}-req-${i}`; + return displayableRequirements.map((requirement, i) => { + const itemKey = `${framework.name}-req-${i}`; - return { - key: itemKey, - title: ( - - ), - content: ( - - ), - items: [], - }; - }, - ); + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }); }); }; // Custom function for MITRE to get top failed sections grouped by tactics export const getTopFailedSections = ( mappedData: Framework[], -): FailedSection[] => { - const failedSectionMap = new Map(); +): TopFailedResult => { + const failedSectionMap = new Map(); mappedData.forEach((framework) => { - const requirements = (framework as any).requirements || []; + const requirements = framework.requirements ?? []; - requirements.forEach((requirement: Requirement) => { + requirements.forEach((requirement) => { if (requirement.status === REQUIREMENT_STATUS.FAIL) { - const tactics = (requirement.tactics as string[]) || []; + const tactics = Array.isArray(requirement.tactics) + ? (requirement.tactics as string[]) + : []; tactics.forEach((tactic) => { if (!failedSectionMap.has(tactic)) { failedSectionMap.set(tactic, { total: 0, types: {} }); } - const sectionData = failedSectionMap.get(tactic); + const sectionData = failedSectionMap.get(tactic)!; sectionData.total += 1; const type = "Fails"; @@ -175,10 +183,13 @@ export const getTopFailedSections = ( }); // 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 + return { + items: Array.from(failedSectionMap.entries()) + .map(([name, data]): FailedSection => ({ name, ...data })) + .sort((a, b) => b.total - a.total) + .slice(0, 5), + type: TOP_FAILED_DATA_TYPE.SECTIONS, + }; }; // Custom function for MITRE to calculate category heatmap data grouped by tactics @@ -197,10 +208,12 @@ export const calculateCategoryHeatmapData = ( // Aggregate data by tactics complianceData.forEach((framework) => { - const requirements = (framework as any).requirements || []; + const requirements = framework.requirements ?? []; - requirements.forEach((requirement: Requirement) => { - const tactics = (requirement.tactics as string[]) || []; + requirements.forEach((requirement) => { + const tactics = Array.isArray(requirement.tactics) + ? (requirement.tactics as string[]) + : []; tactics.forEach((tactic) => { const existing = tacticMap.get(tactic) || { diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts index 2920c8e98b..5c77ed1ee0 100644 --- a/ui/types/compliance.ts +++ b/ui/types/compliance.ts @@ -68,12 +68,27 @@ export interface Framework { fail: number; manual: number; categories: Category[]; + // Optional: flat structure for frameworks like MITRE that don't have categories + requirements?: Requirement[]; } export interface FailedSection { name: string; total: number; - types?: { [key: string]: number }; + types?: Record; +} + +export const TOP_FAILED_DATA_TYPE = { + SECTIONS: "sections", + REQUIREMENTS: "requirements", +} as const; + +export type TopFailedDataType = + (typeof TOP_FAILED_DATA_TYPE)[keyof typeof TOP_FAILED_DATA_TYPE]; + +export interface TopFailedResult { + items: FailedSection[]; + type: TopFailedDataType; } export interface RequirementsTotals { @@ -92,7 +107,7 @@ export interface ENSAttributesMetadata { Nivel: string; Dimensiones: string[]; ModoEjecucion: string; - Dependencias: any[]; + Dependencias: unknown[]; } export interface ISO27001AttributesMetadata {