Compare commits

...

10 Commits

Author SHA1 Message Date
Pedro Martín 278c9fbe0a Merge branch 'master' into PROWLER-1477-bug-threat-score-compliance-view-missing-categories-broken-navigation-and-chart-issues-ui 2026-05-06 08:53:53 +02:00
pedrooot 18e4811554 Merge branch 'PROWLER-1477-bug-threat-score-compliance-view-missing-categories-broken-navigation-and-chart-issues-ui' of https://github.com/prowler-cloud/prowler into PROWLER-1477-bug-threat-score-compliance-view-missing-categories-broken-navigation-and-chart-issues-ui 2026-05-05 16:52:37 +02:00
pedrooot c1b2b4cae4 feat(ui): add 100% for pillars w/o findings 2026-05-05 16:52:25 +02:00
Alejandro Bailo a9932601f0 Merge branch 'master' into PROWLER-1477-bug-threat-score-compliance-view-missing-categories-broken-navigation-and-chart-issues-ui 2026-05-05 12:36:20 +02:00
alejandrobailo 1a51954edf chore(ui): trim CHANGELOG to PR-only entry under 1.25.3 2026-05-05 12:34:58 +02:00
alejandrobailo aad0cb1580 refactor(ui): tighten types in ThreatScore views 2026-05-05 12:34:55 +02:00
alejandrobailo 29d3febe7d refactor(ui): replace useEffect with callback ref in accordion auto-scroll 2026-05-05 12:34:51 +02:00
Pedro Martín a5f17d94f9 Merge branch 'master' into PROWLER-1477-bug-threat-score-compliance-view-missing-categories-broken-navigation-and-chart-issues-ui 2026-05-04 12:57:00 +02:00
pedrooot de99318f93 chore(changelog): update with latest changes 2026-05-04 12:50:58 +02:00
pedrooot 6706e67a85 fix(ui): repair ThreatScore compliance views 2026-05-04 12:46:26 +02:00
16 changed files with 536 additions and 62 deletions
+1
View File
@@ -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>
);
+58 -26
View File
@@ -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>
+41 -5
View File
@@ -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 },
+1
View File
@@ -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}`
}
+2 -1
View File
@@ -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) =>
+47
View File
@@ -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 };
};
+100
View File
@@ -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);
});
});
+13 -4
View File
@@ -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",
]);
});
});
+75
View File
@@ -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);
};
+5
View File
@@ -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 {