mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
10 Commits
master
...
278c9fbe0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 278c9fbe0a | |||
| 18e4811554 | |||
| c1b2b4cae4 | |||
| a9932601f0 | |||
| 1a51954edf | |||
| aad0cb1580 | |||
| 29d3febe7d | |||
| a5f17d94f9 | |||
| de99318f93 | |||
| 6706e67a85 |
@@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🔄 Changed
|
||||
|
||||
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
|
||||
- ThreatScore compliance views: canonical pillar order across all charts and the accordion, clickable pillars on `/compliance` that anchor the detail page, Top Failed Sections always shows the full pillar set, and donut tooltip now triggers on every segment [(#10975)](https://github.com/prowler-cloud/prowler/pull/10975)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ interface ComplianceDetailSearchParams {
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
section?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
page?: string;
|
||||
@@ -57,7 +58,7 @@ export default async function ComplianceDetail({
|
||||
}) {
|
||||
const { compliancetitle } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { complianceId, version, scanId } = resolvedSearchParams;
|
||||
const { complianceId, version, scanId, section } = resolvedSearchParams;
|
||||
const regionFilter = resolvedSearchParams["filter[region__in]"];
|
||||
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
@@ -225,6 +226,7 @@ export default async function ComplianceDetail({
|
||||
filter={cisProfileFilter}
|
||||
attributesData={attributesData}
|
||||
threatScoreData={threatScoreData}
|
||||
targetSection={section}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
@@ -238,6 +240,7 @@ const SSRComplianceContent = async ({
|
||||
filter,
|
||||
attributesData,
|
||||
threatScoreData,
|
||||
targetSection,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
@@ -248,6 +251,7 @@ const SSRComplianceContent = async ({
|
||||
overallScore: number;
|
||||
sectionScores: Record<string, number>;
|
||||
} | null;
|
||||
targetSection?: string;
|
||||
}) => {
|
||||
const requirementsData = await getComplianceRequirements({
|
||||
complianceId,
|
||||
@@ -288,6 +292,21 @@ const SSRComplianceContent = async ({
|
||||
const accordionItems = mapper.toAccordionItems(data, scanId);
|
||||
const topFailedResult = mapper.getTopFailedSections(data);
|
||||
|
||||
// Resolve which accordion key matches the requested ?section= so we can
|
||||
// auto-expand it on first render. Each mapper builds keys as
|
||||
// `${framework.name}-${category.name}`; rebuild the exact candidates here
|
||||
// to avoid suffix collisions across frameworks or category names.
|
||||
const initialExpandedKeys: string[] = [];
|
||||
if (targetSection) {
|
||||
const candidates = new Set(
|
||||
data.map((f: Framework) => `${f.name}-${targetSection}`),
|
||||
);
|
||||
const match = accordionItems.find((item) => candidates.has(item.key));
|
||||
if (match) {
|
||||
initialExpandedKeys.push(match.key);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Charts section */}
|
||||
@@ -315,6 +334,7 @@ const SSRComplianceContent = async ({
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailedResult.items}
|
||||
dataType={topFailedResult.type}
|
||||
prepopulated={topFailedResult.prepopulated}
|
||||
/>
|
||||
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
||||
</div>
|
||||
@@ -323,7 +343,8 @@ const SSRComplianceContent = async ({
|
||||
<ClientAccordionWrapper
|
||||
hideExpandButton={complianceId.includes("mitre_attack")}
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={[]}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,12 @@ export const ClientAccordionWrapper = ({
|
||||
items,
|
||||
defaultExpandedKeys,
|
||||
hideExpandButton = false,
|
||||
scrollToKey,
|
||||
}: {
|
||||
items: AccordionItemProps[];
|
||||
defaultExpandedKeys: string[];
|
||||
hideExpandButton?: boolean;
|
||||
scrollToKey?: string;
|
||||
}) => {
|
||||
const [selectedKeys, setSelectedKeys] =
|
||||
useState<string[]>(defaultExpandedKeys);
|
||||
@@ -56,8 +58,22 @@ export const ClientAccordionWrapper = ({
|
||||
setSelectedKeys(keys);
|
||||
};
|
||||
|
||||
// Callback ref runs after the container's children have committed to the
|
||||
// DOM, so we can locate the target accordion item without an effect. The
|
||||
// rAF defers one frame so HeroUI's expand animation has applied the final
|
||||
// layout offset before scrollIntoView lands.
|
||||
const containerRef = (node: HTMLDivElement | null) => {
|
||||
if (!node || !scrollToKey) return;
|
||||
requestAnimationFrame(() => {
|
||||
const target = node.querySelector(
|
||||
`[data-accordion-key="${CSS.escape(scrollToKey)}"]`,
|
||||
);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={containerRef}>
|
||||
{!hideExpandButton && (
|
||||
<div className="text-text-neutral-tertiary hover:text-text-neutral-primary mt-[-16px] flex justify-end text-xs font-medium transition-colors">
|
||||
<Button
|
||||
@@ -75,7 +91,6 @@ export const ClientAccordionWrapper = ({
|
||||
items={items}
|
||||
variant="light"
|
||||
selectionMode="multiple"
|
||||
defaultExpandedKeys={defaultExpandedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getScoreTextClass,
|
||||
SCORE_COLORS,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { getOrderedPillars } from "@/lib/compliance/threatscore-pillars";
|
||||
|
||||
export interface ThreatScoreBreakdownCardProps {
|
||||
overallScore: number;
|
||||
@@ -25,17 +26,17 @@ export function ThreatScoreBreakdownCard({
|
||||
const scoreLevel = getScoreLevel(overallScore);
|
||||
const scoreColor = SCORE_COLORS[scoreLevel];
|
||||
|
||||
// Convert section scores to tooltip data for the radial chart
|
||||
const tooltipData = Object.entries(sectionScores).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
color: SCORE_COLORS[getScoreLevel(value)],
|
||||
}));
|
||||
const pillars = getOrderedPillars(sectionScores);
|
||||
|
||||
// Sort sections by score (lowest first to highlight areas needing attention)
|
||||
const sortedSections = Object.entries(sectionScores).sort(
|
||||
([, a], [, b]) => a - b,
|
||||
);
|
||||
// Tooltip preserves canonical order so the radial chart hover panel
|
||||
// mirrors the breakdown list below it.
|
||||
const tooltipData = pillars
|
||||
.filter((p) => p.hasData)
|
||||
.map(({ name, score }) => ({
|
||||
name,
|
||||
value: score,
|
||||
color: SCORE_COLORS[getScoreLevel(score)],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full w-full flex-col">
|
||||
@@ -76,19 +77,23 @@ export function ThreatScoreBreakdownCard({
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sortedSections.map(([section, score]) => (
|
||||
<div key={section} className="space-y-0.5">
|
||||
{pillars.map(({ name, score, hasData }) => (
|
||||
<div key={name} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-default-700 truncate pr-2">
|
||||
{section}
|
||||
</span>
|
||||
<span className={`font-semibold ${getScoreTextClass(score)}`}>
|
||||
{score.toFixed(1)}%
|
||||
<span className="text-default-700 truncate pr-2">{name}</span>
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
hasData
|
||||
? getScoreTextClass(score)
|
||||
: "text-text-neutral-tertiary"
|
||||
}`}
|
||||
>
|
||||
{hasData ? `${score.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={score}
|
||||
aria-label={`${name} score`}
|
||||
value={hasData ? score : 0}
|
||||
color={getScoreColor(score)}
|
||||
size="md"
|
||||
className="w-full"
|
||||
|
||||
@@ -12,13 +12,17 @@ import {
|
||||
interface TopFailedSectionsCardProps {
|
||||
sections: FailedSection[];
|
||||
dataType?: TopFailedDataType;
|
||||
// True when `sections` already covers every relevant category (e.g.
|
||||
// ThreatScore's canonical pillars zero-filled). Renders the supplied list
|
||||
// as-is instead of falling back to severity placeholders on zero totals.
|
||||
prepopulated?: boolean;
|
||||
}
|
||||
|
||||
export function TopFailedSectionsCard({
|
||||
sections,
|
||||
dataType = TOP_FAILED_DATA_TYPE.SECTIONS,
|
||||
prepopulated = false,
|
||||
}: TopFailedSectionsCardProps) {
|
||||
// Transform FailedSection[] to BarDataPoint[]
|
||||
const total = sections.reduce((sum, section) => sum + section.total, 0);
|
||||
|
||||
const barData: BarDataPoint[] = sections.map((section) => ({
|
||||
@@ -39,7 +43,10 @@ export function TopFailedSectionsCard({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center justify-start">
|
||||
<HorizontalBarChart data={barData} />
|
||||
<HorizontalBarChart
|
||||
data={barData}
|
||||
useSeverityEmptyState={!prepopulated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
getScoreIndicatorClass,
|
||||
getScoreTextClass,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import {
|
||||
getOrderedPillars,
|
||||
THREATSCORE_SECTION_PARAM,
|
||||
} from "@/lib/compliance/threatscore-pillars";
|
||||
import {
|
||||
downloadComplianceCsv,
|
||||
downloadComplianceReportPdf,
|
||||
@@ -46,7 +50,7 @@ export const ThreatScoreBadge = ({
|
||||
|
||||
const complianceId = `prowler_threatscore_${provider.toLowerCase()}`;
|
||||
|
||||
const handleCardClick = () => {
|
||||
const buildDetailHref = (section?: string) => {
|
||||
const title = "ProwlerThreatScore";
|
||||
const version = "1.0";
|
||||
const formattedTitleForUrl = encodeURIComponent(title);
|
||||
@@ -62,9 +66,23 @@ export const ThreatScoreBadge = ({
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
router.push(`${path}?${params.toString()}`);
|
||||
if (section) {
|
||||
params.set(THREATSCORE_SECTION_PARAM, section);
|
||||
}
|
||||
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(buildDetailHref());
|
||||
};
|
||||
|
||||
const handlePillarClick = (section: string) => {
|
||||
router.push(buildDetailHref(section));
|
||||
};
|
||||
|
||||
const pillars = getOrderedPillars(sectionScores);
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
if (isDownloadingCsv) return;
|
||||
setIsDownloadingCsv(true);
|
||||
@@ -113,31 +131,45 @@ export const ThreatScoreBadge = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pillar breakdown — always visible */}
|
||||
{sectionScores && Object.keys(sectionScores).length > 0 && (
|
||||
{/* Pillar breakdown — always visible, in canonical order */}
|
||||
{pillars.length > 0 && (
|
||||
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
{Object.entries(sectionScores)
|
||||
.sort(([, a], [, b]) => a - b)
|
||||
.map(([section, sectionScore]) => (
|
||||
<div key={section} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
|
||||
{section}
|
||||
</span>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={sectionScore}
|
||||
className="border-border-neutral-secondary h-2 min-w-16 flex-1 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getScoreColor(sectionScore),
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={`w-12 shrink-0 text-right font-medium ${getScoreTextClass(sectionScore)}`}
|
||||
>
|
||||
{sectionScore.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{pillars.map(({ name, score: sectionScore, hasData }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => hasData && handlePillarClick(name)}
|
||||
disabled={!hasData}
|
||||
aria-disabled={!hasData}
|
||||
aria-label={
|
||||
hasData
|
||||
? `Open ${name} details`
|
||||
: `${name} (no data for this scan)`
|
||||
}
|
||||
className="hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
|
||||
{name}
|
||||
</span>
|
||||
<Progress
|
||||
aria-label={`${name} score`}
|
||||
value={hasData ? sectionScore : 0}
|
||||
className="border-border-neutral-secondary h-2 min-w-16 flex-1 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getScoreColor(sectionScore),
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={`w-12 shrink-0 text-right font-medium ${
|
||||
hasData
|
||||
? getScoreTextClass(sectionScore)
|
||||
: "text-text-neutral-tertiary"
|
||||
}`}
|
||||
>
|
||||
{hasData ? `${sectionScore.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
|
||||
import {
|
||||
Cell,
|
||||
Label,
|
||||
Pie,
|
||||
PieChart,
|
||||
Sector,
|
||||
type SectorProps,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
|
||||
|
||||
@@ -156,6 +164,22 @@ export function DonutChart({
|
||||
},
|
||||
}));
|
||||
|
||||
// Reserve a small ring at the outer edge so the active sector can grow into
|
||||
// it without being clipped by the SVG viewport (consumers like
|
||||
// RequirementsStatusCard wrap the chart in a fixed-size box where
|
||||
// outerRadius == container/2 leaves no room to expand).
|
||||
const ACTIVE_GROW = 4;
|
||||
const restingOuterRadius = Math.max(
|
||||
innerRadius + 1,
|
||||
outerRadius - ACTIVE_GROW,
|
||||
);
|
||||
|
||||
// Grows the hovered slice up to the original outerRadius so tiny segments
|
||||
// (e.g. 1% fail) are easy to see and target with the cursor (PROWLER-1477).
|
||||
const renderActiveShape = (props: SectorProps) => (
|
||||
<Sector {...props} outerRadius={(props.outerRadius ?? 0) + ACTIVE_GROW} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartContainer
|
||||
@@ -163,15 +187,29 @@ export function DonutChart({
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
>
|
||||
<PieChart>
|
||||
{!isEmpty && <Tooltip content={<CustomTooltip />} />}
|
||||
{!isEmpty && (
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
cursor={false}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
/>
|
||||
)}
|
||||
<Pie
|
||||
data={isEmpty ? emptyData : chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
outerRadius={restingOuterRadius}
|
||||
strokeWidth={0}
|
||||
paddingAngle={0}
|
||||
// `?? undefined` — Recharts treats `null` as truthy in some paths
|
||||
// and `||` would clobber index 0 (e.g. the "Pass" pillar).
|
||||
activeIndex={hoveredIndex ?? undefined}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => {
|
||||
if (!isEmpty) setHoveredIndex(index);
|
||||
}}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
{(isEmpty ? emptyData : chartData).map((entry, index) => {
|
||||
const opacity =
|
||||
@@ -186,8 +224,6 @@ export function DonutChart({
|
||||
style={{
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
onSegmentClick(data[index], index);
|
||||
|
||||
@@ -14,17 +14,24 @@ interface HorizontalBarChartProps {
|
||||
height?: number;
|
||||
title?: string;
|
||||
onBarClick?: (dataPoint: BarDataPoint, index: number) => void;
|
||||
/**
|
||||
* When false, totals of 0 still render the supplied `data` as zero-width
|
||||
* bars instead of falling back to severity placeholders. Useful for callers
|
||||
* that pre-populate a canonical category list (e.g. ThreatScore pillars).
|
||||
*/
|
||||
useSeverityEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function HorizontalBarChart({
|
||||
data,
|
||||
title,
|
||||
onBarClick,
|
||||
useSeverityEmptyState = true,
|
||||
}: HorizontalBarChartProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const total = data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
const isEmpty = total <= 0;
|
||||
const isEmpty = total <= 0 && (useSeverityEmptyState || data.length === 0);
|
||||
|
||||
const emptyData: BarDataPoint[] = [
|
||||
{ name: "Critical", value: 1, percentage: 100 },
|
||||
|
||||
@@ -134,6 +134,7 @@ export const Accordion = ({
|
||||
{items.map((item, index) => (
|
||||
<AccordionItem
|
||||
key={item.key}
|
||||
data-accordion-key={item.key}
|
||||
aria-label={
|
||||
typeof item.title === "string" ? item.title : `Item ${item.key}`
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
toAccordionItems as toMITREAccordionItems,
|
||||
} from "./mitre";
|
||||
import {
|
||||
getTopFailedSections as getThreatScoreTopFailedSections,
|
||||
mapComplianceData as mapThetaComplianceData,
|
||||
toAccordionItems as toThetaAccordionItems,
|
||||
} from "./threat";
|
||||
@@ -169,7 +170,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||
ProwlerThreatScore: {
|
||||
mapComplianceData: mapThetaComplianceData,
|
||||
toAccordionItems: toThetaAccordionItems,
|
||||
getTopFailedSections,
|
||||
getTopFailedSections: getThreatScoreTopFailedSections,
|
||||
calculateCategoryHeatmapData: (complianceData: Framework[]) =>
|
||||
calculateCategoryHeatmapData(complianceData),
|
||||
getDetailsComponent: (requirement: Requirement) =>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
FailedSection,
|
||||
Framework,
|
||||
REQUIREMENT_STATUS,
|
||||
TOP_FAILED_DATA_TYPE,
|
||||
TopFailedResult,
|
||||
} from "@/types/compliance";
|
||||
|
||||
import {
|
||||
compareSectionsByCanonicalOrder,
|
||||
THREATSCORE_PILLARS,
|
||||
} from "./threatscore-pillars";
|
||||
|
||||
// Builds the Top Failed Sections data for ThreatScore: every canonical pillar
|
||||
// is always present (zero-fill) so the chart remains meaningful even when
|
||||
// only one or two pillars have failures. Sections returned by the data that
|
||||
// are not in the canonical list are appended afterwards in canonical order.
|
||||
export const getTopFailedSections = (
|
||||
mappedData: Framework[],
|
||||
): TopFailedResult => {
|
||||
const totals = new Map<string, number>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
THREATSCORE_PILLARS.forEach((name) => {
|
||||
totals.set(name, 0);
|
||||
seen.add(name);
|
||||
});
|
||||
|
||||
mappedData.forEach((framework) => {
|
||||
framework.categories.forEach((category) => {
|
||||
seen.add(category.name);
|
||||
category.controls.forEach((control) => {
|
||||
control.requirements.forEach((requirement) => {
|
||||
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
||||
totals.set(category.name, (totals.get(category.name) ?? 0) + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const items: FailedSection[] = Array.from(seen)
|
||||
.sort(compareSectionsByCanonicalOrder)
|
||||
.map((name) => ({ name, total: totals.get(name) ?? 0 }));
|
||||
|
||||
return { items, type: TOP_FAILED_DATA_TYPE.SECTIONS, prepopulated: true };
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Framework, REQUIREMENT_STATUS } from "@/types/compliance";
|
||||
|
||||
import { getTopFailedSections } from "./threat-helpers";
|
||||
import { THREATSCORE_PILLARS } from "./threatscore-pillars";
|
||||
|
||||
const buildFramework = (
|
||||
categoriesSpec: Array<{
|
||||
name: string;
|
||||
statuses: Array<"PASS" | "FAIL" | "MANUAL">;
|
||||
}>,
|
||||
): Framework => ({
|
||||
name: "ProwlerThreatScore",
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
categories: categoriesSpec.map((spec) => ({
|
||||
name: spec.name,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
controls: [
|
||||
{
|
||||
label: "control-0",
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
requirements: spec.statuses.map((status, i) => ({
|
||||
name: `${spec.name}-req-${i}`,
|
||||
description: "",
|
||||
status: REQUIREMENT_STATUS[status],
|
||||
check_ids: [],
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
manual: 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
|
||||
describe("threat.getTopFailedSections", () => {
|
||||
it("returns every canonical pillar with zero-fill when no failures", () => {
|
||||
const data = [buildFramework([{ name: "1. IAM", statuses: ["PASS"] }])];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items.map((i) => i.name)).toEqual([...THREATSCORE_PILLARS]);
|
||||
expect(result.items.every((i) => i.total === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("counts FAIL requirements per category and keeps canonical order", () => {
|
||||
const data = [
|
||||
buildFramework([
|
||||
{ name: "1. IAM", statuses: ["FAIL", "FAIL"] },
|
||||
{ name: "4. Encryption", statuses: ["FAIL"] },
|
||||
]),
|
||||
];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{ name: "1. IAM", total: 2 },
|
||||
{ name: "2. Attack Surface", total: 0 },
|
||||
{ name: "3. Logging and Monitoring", total: 0 },
|
||||
{ name: "4. Encryption", total: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends non-canonical sections after the canonical ones", () => {
|
||||
const data = [
|
||||
buildFramework([
|
||||
{ name: "1. IAM", statuses: ["FAIL"] },
|
||||
{ name: "5. Data Protection", statuses: ["FAIL", "FAIL"] },
|
||||
]),
|
||||
];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items.map((i) => i.name)).toEqual([
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
"5. Data Protection",
|
||||
]);
|
||||
expect(
|
||||
result.items.find((i) => i.name === "5. Data Protection")?.total,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores PASS and MANUAL when counting failures", () => {
|
||||
const data = [
|
||||
buildFramework([
|
||||
{ name: "1. IAM", statuses: ["PASS", "MANUAL", "FAIL", "PASS"] },
|
||||
]),
|
||||
];
|
||||
const result = getTopFailedSections(data);
|
||||
|
||||
expect(result.items.find((i) => i.name === "1. IAM")?.total).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
findOrCreateFramework,
|
||||
updateCounters,
|
||||
} from "./commons";
|
||||
import { compareSectionsByCanonicalOrder } from "./threatscore-pillars";
|
||||
|
||||
export { getTopFailedSections } from "./threat-helpers";
|
||||
|
||||
export const mapComplianceData = (
|
||||
attributesData: AttributesData,
|
||||
@@ -91,6 +94,14 @@ export const mapComplianceData = (
|
||||
control.requirements.push(requirement);
|
||||
}
|
||||
|
||||
// Sort categories within each framework by canonical pillar order so
|
||||
// the accordion, charts and breakdown all agree on the same ordering.
|
||||
frameworks.forEach((framework) => {
|
||||
framework.categories.sort((a, b) =>
|
||||
compareSectionsByCanonicalOrder(a.name, b.name),
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate counters and percentualScore (Threat-specific logic)
|
||||
frameworks.forEach((framework) => {
|
||||
framework.pass = 0;
|
||||
@@ -149,9 +160,7 @@ export const mapComplianceData = (
|
||||
? (numerator / denominator) * 100
|
||||
: 0;
|
||||
|
||||
// Add percentualScore to category (we can extend the type or use a custom property)
|
||||
(category as any).percentualScore =
|
||||
Math.round(percentualScore * 100) / 100; // Round to 2 decimal places
|
||||
category.percentualScore = Math.round(percentualScore * 100) / 100;
|
||||
|
||||
framework.pass += category.pass;
|
||||
framework.fail += category.fail;
|
||||
@@ -168,7 +177,7 @@ export const toAccordionItems = (
|
||||
): AccordionItemProps[] => {
|
||||
return data.flatMap((framework) =>
|
||||
framework.categories.map((category) => {
|
||||
const percentualScore = (category as any).percentualScore || 0;
|
||||
const percentualScore = category.percentualScore ?? 0;
|
||||
|
||||
return {
|
||||
key: `${framework.name}-${category.name}`,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareSectionsByCanonicalOrder,
|
||||
getOrderedPillars,
|
||||
THREATSCORE_PILLARS,
|
||||
} from "./threatscore-pillars";
|
||||
|
||||
describe("getOrderedPillars", () => {
|
||||
it("returns every canonical pillar in canonical order, treating missing canonical pillars as 100% (no findings = secure)", () => {
|
||||
const result = getOrderedPillars({ "1. IAM": 90, "4. Encryption": 60 });
|
||||
|
||||
expect(result.map((p) => p.name)).toEqual([...THREATSCORE_PILLARS]);
|
||||
expect(result[0]).toEqual({ name: "1. IAM", score: 90, hasData: true });
|
||||
expect(result[1]).toEqual({
|
||||
name: "2. Attack Surface",
|
||||
score: 100,
|
||||
hasData: true,
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
name: "3. Logging and Monitoring",
|
||||
score: 100,
|
||||
hasData: true,
|
||||
});
|
||||
expect(result[3]).toEqual({
|
||||
name: "4. Encryption",
|
||||
score: 60,
|
||||
hasData: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends non-canonical sections after the canonical ones, sorted naturally", () => {
|
||||
const result = getOrderedPillars({
|
||||
"1. IAM": 50,
|
||||
"10. Future Pillar": 70,
|
||||
"5. Data Protection": 80,
|
||||
});
|
||||
|
||||
expect(result.map((p) => p.name)).toEqual([
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
"5. Data Protection",
|
||||
"10. Future Pillar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles undefined sectionScores gracefully", () => {
|
||||
const result = getOrderedPillars(undefined);
|
||||
|
||||
expect(result).toHaveLength(THREATSCORE_PILLARS.length);
|
||||
expect(result.every((p) => !p.hasData)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats non-numeric or non-finite scores as missing data", () => {
|
||||
// Defensive: API contract is Record<string, number>, but null/string/NaN
|
||||
// should never crash a `score.toFixed(...)` consumer.
|
||||
const result = getOrderedPillars({
|
||||
"1. IAM": Number.NaN as unknown as number,
|
||||
"2. Attack Surface": null as unknown as number,
|
||||
"3. Logging and Monitoring": "80" as unknown as number,
|
||||
"4. Encryption": 60,
|
||||
});
|
||||
|
||||
expect(result[0]).toEqual({ name: "1. IAM", score: 0, hasData: false });
|
||||
expect(result[1]).toEqual({
|
||||
name: "2. Attack Surface",
|
||||
score: 0,
|
||||
hasData: false,
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
name: "3. Logging and Monitoring",
|
||||
score: 0,
|
||||
hasData: false,
|
||||
});
|
||||
expect(result[3]).toEqual({
|
||||
name: "4. Encryption",
|
||||
score: 60,
|
||||
hasData: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareSectionsByCanonicalOrder", () => {
|
||||
it("orders canonical pillars by their declared position", () => {
|
||||
const sections = [
|
||||
"4. Encryption",
|
||||
"2. Attack Surface",
|
||||
"1. IAM",
|
||||
"3. Logging and Monitoring",
|
||||
];
|
||||
sections.sort(compareSectionsByCanonicalOrder);
|
||||
expect(sections).toEqual([...THREATSCORE_PILLARS]);
|
||||
});
|
||||
|
||||
it("places unknown sections after canonical ones, in natural order", () => {
|
||||
const sections = [
|
||||
"Custom Section",
|
||||
"10. Tenth",
|
||||
"1. IAM",
|
||||
"5. Data Protection",
|
||||
];
|
||||
sections.sort(compareSectionsByCanonicalOrder);
|
||||
expect(sections).toEqual([
|
||||
"1. IAM",
|
||||
"5. Data Protection",
|
||||
"10. Tenth",
|
||||
"Custom Section",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SectionScores } from "@/actions/overview/threat-score";
|
||||
|
||||
export const THREATSCORE_PILLARS = [
|
||||
"1. IAM",
|
||||
"2. Attack Surface",
|
||||
"3. Logging and Monitoring",
|
||||
"4. Encryption",
|
||||
] as const;
|
||||
|
||||
export interface OrderedPillar {
|
||||
name: string;
|
||||
score: number;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
const compareNatural = (a: string, b: string) =>
|
||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
// API contract is `Record<string, number>`, but defensively coerce so a
|
||||
// future null/string value cannot blow up `score.toFixed(...)` callers.
|
||||
// `treatMissingAsFull` makes a missing canonical pillar mean "no findings →
|
||||
// 100%" rather than "no data". Only safe when `sectionScores` is provided
|
||||
// (i.e. the scan ran); when undefined we still surface "no data".
|
||||
const readScore = (
|
||||
scores: SectionScores,
|
||||
name: string,
|
||||
treatMissingAsFull: boolean,
|
||||
): { score: number; hasData: boolean } => {
|
||||
const raw = scores[name];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return { score: raw, hasData: true };
|
||||
}
|
||||
if (treatMissingAsFull && raw === undefined) {
|
||||
return { score: 100, hasData: true };
|
||||
}
|
||||
return { score: 0, hasData: false };
|
||||
};
|
||||
|
||||
export function getOrderedPillars(
|
||||
sectionScores?: SectionScores,
|
||||
): OrderedPillar[] {
|
||||
const scores = sectionScores ?? {};
|
||||
const treatMissingAsFull = sectionScores !== undefined;
|
||||
const remaining = new Set(Object.keys(scores));
|
||||
|
||||
const canonical: OrderedPillar[] = THREATSCORE_PILLARS.map((name) => {
|
||||
remaining.delete(name);
|
||||
const { score, hasData } = readScore(scores, name, treatMissingAsFull);
|
||||
return { name, score, hasData };
|
||||
});
|
||||
|
||||
const extras: OrderedPillar[] = Array.from(remaining)
|
||||
.sort(compareNatural)
|
||||
.map((name) => {
|
||||
const { score, hasData } = readScore(scores, name, false);
|
||||
return { name, score, hasData };
|
||||
});
|
||||
|
||||
return [...canonical, ...extras];
|
||||
}
|
||||
|
||||
export const THREATSCORE_SECTION_PARAM = "section";
|
||||
|
||||
export const compareSectionsByCanonicalOrder = (a: string, b: string) => {
|
||||
const indexA = THREATSCORE_PILLARS.indexOf(
|
||||
a as (typeof THREATSCORE_PILLARS)[number],
|
||||
);
|
||||
const indexB = THREATSCORE_PILLARS.indexOf(
|
||||
b as (typeof THREATSCORE_PILLARS)[number],
|
||||
);
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return compareNatural(a, b);
|
||||
};
|
||||
@@ -60,6 +60,7 @@ export interface Category {
|
||||
fail: number;
|
||||
manual: number;
|
||||
controls: Control[];
|
||||
percentualScore?: number;
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
@@ -89,6 +90,10 @@ export type TopFailedDataType =
|
||||
export interface TopFailedResult {
|
||||
items: FailedSection[];
|
||||
type: TopFailedDataType;
|
||||
// True when items already cover every relevant category (zero-fill). The
|
||||
// chart should render the supplied list as-is instead of falling back to
|
||||
// severity placeholders when totals are zero.
|
||||
prepopulated?: boolean;
|
||||
}
|
||||
|
||||
export interface RequirementsTotals {
|
||||
|
||||
Reference in New Issue
Block a user