fix(ui): show Top Failed Requirements for compliances without section hierarchy (#9471)

Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
Pedro Martín
2025-12-09 16:28:47 +01:00
committed by GitHub
parent 20ed8b3d2d
commit 50286846e0
8 changed files with 204 additions and 94 deletions

View File

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

View File

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

View File

@@ -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 (
<div className="flex flex-col gap-8">
@@ -205,7 +205,10 @@ const SSRComplianceContent = async ({
fail={totalRequirements.fail}
manual={totalRequirements.manual}
/>
<TopFailedSectionsCard sections={topFailedSections} />
<TopFailedSectionsCard
sections={topFailedResult.items}
dataType={topFailedResult.type}
/>
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
</div>

View File

@@ -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 (
<Card
variant="base"
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
>
<CardHeader>
<CardTitle>Top Failed Sections</CardTitle>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 items-center justify-start">
<HorizontalBarChart data={barData} />

View File

@@ -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<string, number>;
}
/**
* Builds the TopFailedResult from the accumulated map data
*/
const buildTopFailedResult = (
map: Map<string, FailedSectionData>,
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<string, FailedSectionData>,
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<string, FailedSectionData>();
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;

View File

@@ -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<string, ComplianceMapper> => ({
@@ -97,7 +97,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
getTopFailedSections: getMITRETopFailedSections,
calculateCategoryHeatmapData: calculateMITRECategoryHeatmapData,
getDetailsComponent: (requirement: Requirement) =>
React.createElement(MITRECustomDetails, { requirement }),
createElement(MITRECustomDetails, { requirement }),
},
ProwlerThreatScore: {
mapComplianceData: mapThetaComplianceData,
@@ -168,7 +168,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
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<string, ComplianceMapper> => ({
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
React.createElement(CCCCustomDetails, { requirement }),
createElement(CCCCustomDetails, { requirement }),
},
});

View File

@@ -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<string, number>;
}
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,15 +116,14 @@ 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) => {
return displayableRequirements.map((requirement, i) => {
const itemKey = `${framework.name}-req-${i}`;
return {
@@ -141,30 +148,31 @@ export const toAccordionItems = (
),
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<string, FailedSectionData>();
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 }))
return {
items: Array.from(failedSectionMap.entries())
.map(([name, data]): FailedSection => ({ name, ...data }))
.sort((a, b) => b.total - a.total)
.slice(0, 5); // Top 5
.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) || {

View File

@@ -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<string, number>;
}
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 {