mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-12 12:48:47 +00:00
Compare commits
9 Commits
add-status
...
feat/PROWL
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f03d83872e | ||
|
|
bb620022f5 | ||
|
|
27a81defec | ||
|
|
a81293d2ea | ||
|
|
80427dd127 | ||
|
|
14e9506b87 | ||
|
|
3e72d575d4 | ||
|
|
79825d35fc | ||
|
|
6215c1ba46 |
@@ -62,7 +62,7 @@ You are a code reviewer for the Prowler UI project. Analyze the full file conten
|
|||||||
**RULES TO CHECK:**
|
**RULES TO CHECK:**
|
||||||
1. React Imports: NO `import * as React` or `import React, {` → Use `import { useState }`
|
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`
|
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")`
|
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
|
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.
|
6. Zod v4: Use `.min(1)` not `.nonempty()`, `z.email()` not `z.string().email()`. All inputs must be validated with Zod.
|
||||||
|
|||||||
@@ -23,6 +23,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)
|
## [1.14.2] (Prowler v5.14.2)
|
||||||
|
|
||||||
### 🐞 Fixed
|
### 🐞 Fixed
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from "./attack-surface";
|
|||||||
export * from "./findings";
|
export * from "./findings";
|
||||||
export * from "./providers";
|
export * from "./providers";
|
||||||
export * from "./regions";
|
export * from "./regions";
|
||||||
|
export * from "./risk-plot";
|
||||||
export * from "./services";
|
export * from "./services";
|
||||||
export * from "./severity-trends";
|
export * from "./severity-trends";
|
||||||
export * from "./threat-score";
|
export * from "./threat-score";
|
||||||
|
|||||||
4
ui/actions/overview/risk-plot/index.ts
Normal file
4
ui/actions/overview/risk-plot/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Risk Plot Actions
|
||||||
|
export * from "./risk-plot";
|
||||||
|
export * from "./risk-plot.adapter";
|
||||||
|
export * from "./types/risk-plot.types";
|
||||||
94
ui/actions/overview/risk-plot/risk-plot.adapter.ts
Normal file
94
ui/actions/overview/risk-plot/risk-plot.adapter.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { getProviderDisplayName } from "@/types/providers";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderRiskData,
|
||||||
|
RiskPlotDataResponse,
|
||||||
|
RiskPlotPoint,
|
||||||
|
} from "./types/risk-plot.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates percentage with proper rounding.
|
||||||
|
*/
|
||||||
|
function calculatePercentage(value: number, total: number): number {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round((value / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts raw provider risk data to the format expected by RiskPlotClient.
|
||||||
|
*
|
||||||
|
* @param providersRiskData - Array of risk data per provider from API
|
||||||
|
* @returns Formatted data for the Risk Plot scatter chart
|
||||||
|
*/
|
||||||
|
export function adaptToRiskPlotData(
|
||||||
|
providersRiskData: ProviderRiskData[],
|
||||||
|
): RiskPlotDataResponse {
|
||||||
|
const points: RiskPlotPoint[] = [];
|
||||||
|
const providersWithoutData: RiskPlotDataResponse["providersWithoutData"] = [];
|
||||||
|
|
||||||
|
for (const providerData of providersRiskData) {
|
||||||
|
// Skip providers without ThreatScore data (no completed scans)
|
||||||
|
if (providerData.overallScore === null) {
|
||||||
|
providersWithoutData.push({
|
||||||
|
id: providerData.providerId,
|
||||||
|
name: providerData.providerName,
|
||||||
|
type: providerData.providerType,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert provider type to display name (aws -> AWS, gcp -> Google, etc.)
|
||||||
|
const providerDisplayName = getProviderDisplayName(
|
||||||
|
providerData.providerType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build severity data for the horizontal bar chart with percentages
|
||||||
|
let severityData;
|
||||||
|
let totalFailedFindings = 0;
|
||||||
|
|
||||||
|
if (providerData.severity) {
|
||||||
|
const { critical, high, medium, low, informational } =
|
||||||
|
providerData.severity;
|
||||||
|
totalFailedFindings = critical + high + medium + low + informational;
|
||||||
|
|
||||||
|
severityData = [
|
||||||
|
{
|
||||||
|
name: "Critical",
|
||||||
|
value: critical,
|
||||||
|
percentage: calculatePercentage(critical, totalFailedFindings),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "High",
|
||||||
|
value: high,
|
||||||
|
percentage: calculatePercentage(high, totalFailedFindings),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Medium",
|
||||||
|
value: medium,
|
||||||
|
percentage: calculatePercentage(medium, totalFailedFindings),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Low",
|
||||||
|
value: low,
|
||||||
|
percentage: calculatePercentage(low, totalFailedFindings),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Info",
|
||||||
|
value: informational,
|
||||||
|
percentage: calculatePercentage(informational, totalFailedFindings),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
x: providerData.overallScore ?? 0,
|
||||||
|
y: totalFailedFindings,
|
||||||
|
provider: providerDisplayName,
|
||||||
|
name: providerData.providerName,
|
||||||
|
providerId: providerData.providerId,
|
||||||
|
severityData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { points, providersWithoutData };
|
||||||
|
}
|
||||||
69
ui/actions/overview/risk-plot/risk-plot.ts
Normal file
69
ui/actions/overview/risk-plot/risk-plot.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getFindingsBySeverity } from "@/actions/overview/findings";
|
||||||
|
import { getThreatScore } from "@/actions/overview/threat-score";
|
||||||
|
import { ProviderProps } from "@/types/providers";
|
||||||
|
|
||||||
|
import { ProviderRiskData } from "./types/risk-plot.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches risk data for a single provider.
|
||||||
|
* Combines ThreatScore and Severity data in parallel.
|
||||||
|
*/
|
||||||
|
export async function getProviderRiskData(
|
||||||
|
provider: ProviderProps,
|
||||||
|
): Promise<ProviderRiskData> {
|
||||||
|
const providerId = provider.id;
|
||||||
|
const providerType = provider.attributes.provider;
|
||||||
|
const providerName = provider.attributes.alias || provider.attributes.uid;
|
||||||
|
|
||||||
|
// Fetch ThreatScore and Severity in parallel
|
||||||
|
const [threatScoreResponse, severityResponse] = await Promise.all([
|
||||||
|
getThreatScore({
|
||||||
|
filters: {
|
||||||
|
provider_id: providerId,
|
||||||
|
include: "provider",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getFindingsBySeverity({
|
||||||
|
filters: {
|
||||||
|
"filter[provider_id]": providerId,
|
||||||
|
"filter[status]": "FAIL",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Extract ThreatScore data
|
||||||
|
// When filtering by single provider, API returns array with one item (not aggregated)
|
||||||
|
const threatScoreData = threatScoreResponse?.data?.[0]?.attributes;
|
||||||
|
const overallScore = threatScoreData?.overall_score
|
||||||
|
? parseFloat(threatScoreData.overall_score)
|
||||||
|
: null;
|
||||||
|
const failedFindings = threatScoreData?.failed_findings ?? 0;
|
||||||
|
|
||||||
|
// Extract Severity data
|
||||||
|
const severityData = severityResponse?.data?.attributes ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
providerType,
|
||||||
|
providerName,
|
||||||
|
overallScore,
|
||||||
|
failedFindings,
|
||||||
|
severity: severityData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches risk data for multiple providers in parallel.
|
||||||
|
* Used by the Risk Plot SSR component.
|
||||||
|
*/
|
||||||
|
export async function getProvidersRiskData(
|
||||||
|
providers: ProviderProps[],
|
||||||
|
): Promise<ProviderRiskData[]> {
|
||||||
|
const riskDataPromises = providers.map((provider) =>
|
||||||
|
getProviderRiskData(provider),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all(riskDataPromises);
|
||||||
|
}
|
||||||
58
ui/actions/overview/risk-plot/types/risk-plot.types.ts
Normal file
58
ui/actions/overview/risk-plot/types/risk-plot.types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Risk Plot Types
|
||||||
|
// Data structures for the Risk Plot scatter chart
|
||||||
|
|
||||||
|
import type { BarDataPoint } from "@/components/graphs/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single point in the Risk Plot scatter chart.
|
||||||
|
* Each point represents a provider/account with its risk metrics.
|
||||||
|
*/
|
||||||
|
export interface RiskPlotPoint {
|
||||||
|
/** ThreatScore (0-100 scale, higher = better) */
|
||||||
|
x: number;
|
||||||
|
/** Total failed findings count */
|
||||||
|
y: number;
|
||||||
|
/** Provider type display name (AWS, Azure, Google, etc.) */
|
||||||
|
provider: string;
|
||||||
|
/** Provider alias or UID (account identifier) */
|
||||||
|
name: string;
|
||||||
|
/** Provider ID for filtering/navigation */
|
||||||
|
providerId: string;
|
||||||
|
/** Severity breakdown for the horizontal bar chart */
|
||||||
|
severityData?: BarDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw data from the API combined for a single provider.
|
||||||
|
* Used internally before transformation to RiskPlotPoint.
|
||||||
|
*/
|
||||||
|
export interface ProviderRiskData {
|
||||||
|
providerId: string;
|
||||||
|
providerType: string;
|
||||||
|
providerName: string;
|
||||||
|
/** ThreatScore overall_score (0-100 scale) */
|
||||||
|
overallScore: number | null;
|
||||||
|
/** Failed findings from ThreatScore snapshot */
|
||||||
|
failedFindings: number;
|
||||||
|
/** Severity breakdown */
|
||||||
|
severity: {
|
||||||
|
critical: number;
|
||||||
|
high: number;
|
||||||
|
medium: number;
|
||||||
|
low: number;
|
||||||
|
informational: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response structure for risk plot data fetching.
|
||||||
|
*/
|
||||||
|
export interface RiskPlotDataResponse {
|
||||||
|
points: RiskPlotPoint[];
|
||||||
|
/** Providers that have no data or no completed scans */
|
||||||
|
providersWithoutData: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -11,15 +11,15 @@ export const GRAPH_TABS = [
|
|||||||
id: "threat-map",
|
id: "threat-map",
|
||||||
label: "Threat Map",
|
label: "Threat Map",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "risk-plot",
|
||||||
|
label: "Risk Plot",
|
||||||
|
},
|
||||||
// TODO: Uncomment when ready to enable other tabs
|
// TODO: Uncomment when ready to enable other tabs
|
||||||
// {
|
// {
|
||||||
// id: "risk-radar",
|
// id: "risk-radar",
|
||||||
// label: "Risk Radar",
|
// label: "Risk Radar",
|
||||||
// },
|
// },
|
||||||
// {
|
|
||||||
// id: "risk-plot",
|
|
||||||
// label: "Risk Plot",
|
|
||||||
// },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TabId = (typeof GRAPH_TABS)[number]["id"];
|
export type TabId = (typeof GRAPH_TABS)[number]["id"];
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { GraphsTabsClient } from "./_components/graphs-tabs-client";
|
|||||||
import { GRAPH_TABS, type TabId } from "./_config/graphs-tabs-config";
|
import { GRAPH_TABS, type TabId } from "./_config/graphs-tabs-config";
|
||||||
import { FindingsViewSSR } from "./findings-view";
|
import { FindingsViewSSR } from "./findings-view";
|
||||||
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
|
import { RiskPipelineViewSSR } from "./risk-pipeline-view/risk-pipeline-view.ssr";
|
||||||
|
import { RiskPlotSSR } from "./risk-plot/risk-plot.ssr";
|
||||||
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
|
import { ThreatMapViewSSR } from "./threat-map-view/threat-map-view.ssr";
|
||||||
// TODO: Uncomment when ready to enable other tabs
|
// TODO: Uncomment when ready to enable other tabs
|
||||||
// import { RiskPlotView } from "./risk-plot/risk-plot-view";
|
|
||||||
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
|
// import { RiskRadarViewSSR } from "./risk-radar-view/risk-radar-view.ssr";
|
||||||
|
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
@@ -25,9 +25,9 @@ const GRAPH_COMPONENTS: Record<TabId, GraphComponent> = {
|
|||||||
findings: FindingsViewSSR as GraphComponent,
|
findings: FindingsViewSSR as GraphComponent,
|
||||||
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
|
"risk-pipeline": RiskPipelineViewSSR as GraphComponent,
|
||||||
"threat-map": ThreatMapViewSSR as GraphComponent,
|
"threat-map": ThreatMapViewSSR as GraphComponent,
|
||||||
|
"risk-plot": RiskPlotSSR as GraphComponent,
|
||||||
// TODO: Uncomment when ready to enable other tabs
|
// TODO: Uncomment when ready to enable other tabs
|
||||||
// "risk-radar": RiskRadarViewSSR as GraphComponent,
|
// "risk-radar": RiskRadarViewSSR as GraphComponent,
|
||||||
// "risk-plot": RiskPlotView as GraphComponent,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GraphsTabsWrapperProps {
|
interface GraphsTabsWrapperProps {
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk Plot Client Component
|
||||||
|
*
|
||||||
|
* NOTE: This component uses CSS variables (var()) for Recharts styling.
|
||||||
|
* Recharts SVG-based components (Scatter, XAxis, YAxis, CartesianGrid, etc.)
|
||||||
|
* do not support Tailwind classes and require raw color values or CSS variables.
|
||||||
|
* This is a documented limitation of the Recharts library.
|
||||||
|
* @see https://recharts.org/en-US/api
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Scatter,
|
Scatter,
|
||||||
ScatterChart,
|
ScatterChart,
|
||||||
@@ -12,6 +22,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
|
import type { RiskPlotPoint } from "@/actions/overview/risk-plot";
|
||||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||||
import { AlertPill } from "@/components/graphs/shared/alert-pill";
|
import { AlertPill } from "@/components/graphs/shared/alert-pill";
|
||||||
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
|
import { ChartLegend } from "@/components/graphs/shared/chart-legend";
|
||||||
@@ -19,69 +30,83 @@ import {
|
|||||||
AXIS_FONT_SIZE,
|
AXIS_FONT_SIZE,
|
||||||
CustomXAxisTick,
|
CustomXAxisTick,
|
||||||
} from "@/components/graphs/shared/custom-axis-tick";
|
} from "@/components/graphs/shared/custom-axis-tick";
|
||||||
import { getSeverityColorByRiskScore } from "@/components/graphs/shared/utils";
|
|
||||||
import type { BarDataPoint } from "@/components/graphs/types";
|
import type { BarDataPoint } from "@/components/graphs/types";
|
||||||
|
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
||||||
|
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||||
|
|
||||||
const PROVIDER_COLORS = {
|
// Threat Score colors (0-100 scale, higher = better)
|
||||||
AWS: "var(--color-bg-data-aws)",
|
const THREAT_COLORS = {
|
||||||
Azure: "var(--color-bg-data-azure)",
|
DANGER: "var(--bg-fail-primary)", // 0-30
|
||||||
Google: "var(--color-bg-data-gcp)",
|
WARNING: "var(--bg-warning-primary)", // 31-60
|
||||||
};
|
SUCCESS: "var(--bg-pass-primary)", // 61-100
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface ScatterPoint {
|
/**
|
||||||
x: number;
|
* Get color based on ThreatScore (0-100 scale, higher = better)
|
||||||
y: number;
|
*/
|
||||||
provider: string;
|
function getThreatScoreColor(score: number): string {
|
||||||
name: string;
|
if (score > 60) return THREAT_COLORS.SUCCESS;
|
||||||
severityData?: BarDataPoint[];
|
if (score > 30) return THREAT_COLORS.WARNING;
|
||||||
|
return THREAT_COLORS.DANGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider colors from globals.css
|
||||||
|
const PROVIDER_COLORS: Record<string, string> = {
|
||||||
|
AWS: "var(--bg-data-aws)",
|
||||||
|
Azure: "var(--bg-data-azure)",
|
||||||
|
"Google Cloud": "var(--bg-data-gcp)",
|
||||||
|
Kubernetes: "var(--bg-data-kubernetes)",
|
||||||
|
"Microsoft 365": "var(--bg-data-m365)",
|
||||||
|
GitHub: "var(--bg-data-github)",
|
||||||
|
"MongoDB Atlas": "var(--bg-data-azure)",
|
||||||
|
"Infrastructure as Code": "var(--bg-data-kubernetes)",
|
||||||
|
"Oracle Cloud Infrastructure": "var(--bg-data-gcp)",
|
||||||
|
};
|
||||||
|
|
||||||
interface RiskPlotClientProps {
|
interface RiskPlotClientProps {
|
||||||
data: ScatterPoint[];
|
data: RiskPlotPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{ payload: ScatterPoint }>;
|
payload?: Array<{ payload: RiskPlotPoint }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScatterDotProps {
|
// Props that Recharts passes to the shape component
|
||||||
|
interface RechartsScatterDotProps {
|
||||||
cx: number;
|
cx: number;
|
||||||
cy: number;
|
cy: number;
|
||||||
payload: ScatterPoint;
|
payload: RiskPlotPoint;
|
||||||
selectedPoint: ScatterPoint | null;
|
|
||||||
onSelectPoint: (point: ScatterPoint) => void;
|
|
||||||
allData: ScatterPoint[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegendProps {
|
// Extended props for our custom scatter dot component
|
||||||
payload?: Array<{ value: string; color: string }>;
|
interface ScatterDotProps extends RechartsScatterDotProps {
|
||||||
|
selectedPoint: RiskPlotPoint | null;
|
||||||
|
onSelectPoint: (point: RiskPlotPoint) => void;
|
||||||
|
allData: RiskPlotPoint[];
|
||||||
|
selectedProvider: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||||
if (active && payload && payload.length) {
|
if (!active || !payload?.length) return null;
|
||||||
const data = payload[0].payload;
|
|
||||||
const severityColor = getSeverityColorByRiskScore(data.x);
|
|
||||||
|
|
||||||
return (
|
const { name, x, y } = payload[0].payload;
|
||||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
|
const scoreColor = getThreatScoreColor(x);
|
||||||
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
|
|
||||||
{data.name}
|
return (
|
||||||
</p>
|
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary pointer-events-none min-w-[200px] rounded-xl border p-3 shadow-lg">
|
||||||
<p className="text-text-neutral-secondary text-sm font-medium">
|
<p className="text-text-neutral-primary mb-2 text-sm font-semibold">
|
||||||
{/* Dynamic color from getSeverityColorByRiskScore - required inline style */}
|
{name}
|
||||||
<span style={{ color: severityColor, fontWeight: "bold" }}>
|
</p>
|
||||||
{data.x}
|
<p className="text-text-neutral-secondary text-sm font-medium">
|
||||||
</span>{" "}
|
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
|
||||||
Risk Score
|
Threat Score
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<AlertPill value={data.y} />
|
<AlertPill value={y} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomScatterDot = ({
|
const CustomScatterDot = ({
|
||||||
@@ -91,24 +116,31 @@ const CustomScatterDot = ({
|
|||||||
selectedPoint,
|
selectedPoint,
|
||||||
onSelectPoint,
|
onSelectPoint,
|
||||||
allData,
|
allData,
|
||||||
|
selectedProvider,
|
||||||
}: ScatterDotProps) => {
|
}: ScatterDotProps) => {
|
||||||
const isSelected = selectedPoint?.name === payload.name;
|
const isSelected = selectedPoint?.name === payload.name;
|
||||||
const size = isSelected ? 18 : 8;
|
const size = isSelected ? 18 : 8;
|
||||||
const selectedColor = "var(--bg-button-primary)"; // emerald-400
|
const selectedColor = "var(--bg-button-primary)";
|
||||||
const fill = isSelected
|
const fill = isSelected
|
||||||
? selectedColor
|
? selectedColor
|
||||||
: PROVIDER_COLORS[payload.provider as keyof typeof PROVIDER_COLORS] ||
|
: PROVIDER_COLORS[payload.provider] || "var(--color-text-neutral-tertiary)";
|
||||||
"var(--color-text-neutral-tertiary)";
|
const isFaded =
|
||||||
|
selectedProvider !== null && payload.provider !== selectedProvider;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
const fullDataItem = allData?.find(
|
const fullDataItem = allData?.find((d) => d.name === payload.name);
|
||||||
(d: ScatterPoint) => d.name === payload.name,
|
|
||||||
);
|
|
||||||
onSelectPoint?.(fullDataItem || payload);
|
onSelectPoint?.(fullDataItem || payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g style={{ cursor: "pointer" }} onClick={handleClick}>
|
<g
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: isFaded ? 0.2 : 1,
|
||||||
|
transition: "opacity 0.2s",
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<>
|
<>
|
||||||
<circle
|
<circle
|
||||||
@@ -143,60 +175,86 @@ const CustomScatterDot = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CustomLegend = ({ payload }: LegendProps) => {
|
/**
|
||||||
const items =
|
* Factory function that creates a scatter dot shape component with closure over selection state.
|
||||||
payload?.map((entry: { value: string; color: string }) => ({
|
* Recharts shape prop types the callback parameter as `unknown` due to its flexible API.
|
||||||
label: entry.value,
|
* We safely cast to RechartsScatterDotProps since we know the actual shape of props passed by Scatter.
|
||||||
color: entry.color,
|
* @see https://recharts.org/en-US/api/Scatter#shape
|
||||||
})) || [];
|
*/
|
||||||
|
|
||||||
return <ChartLegend items={items} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createScatterDotShape(
|
function createScatterDotShape(
|
||||||
selectedPoint: ScatterPoint | null,
|
selectedPoint: RiskPlotPoint | null,
|
||||||
onSelectPoint: (point: ScatterPoint) => void,
|
onSelectPoint: (point: RiskPlotPoint) => void,
|
||||||
allData: ScatterPoint[],
|
allData: RiskPlotPoint[],
|
||||||
) {
|
selectedProvider: string | null,
|
||||||
const ScatterDotShape = (props: unknown) => {
|
): (props: unknown) => React.JSX.Element {
|
||||||
const dotProps = props as Omit<
|
const ScatterDotShape = (props: unknown) => (
|
||||||
ScatterDotProps,
|
<CustomScatterDot
|
||||||
"selectedPoint" | "onSelectPoint" | "allData"
|
{...(props as RechartsScatterDotProps)}
|
||||||
>;
|
selectedPoint={selectedPoint}
|
||||||
return (
|
onSelectPoint={onSelectPoint}
|
||||||
<CustomScatterDot
|
allData={allData}
|
||||||
{...dotProps}
|
selectedProvider={selectedProvider}
|
||||||
selectedPoint={selectedPoint}
|
/>
|
||||||
onSelectPoint={onSelectPoint}
|
);
|
||||||
allData={allData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
ScatterDotShape.displayName = "ScatterDotShape";
|
ScatterDotShape.displayName = "ScatterDotShape";
|
||||||
return ScatterDotShape;
|
return ScatterDotShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||||
const [selectedPoint, setSelectedPoint] = useState<ScatterPoint | null>(null);
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
const dataByProvider = data.reduce(
|
// Group data by provider for separate Scatter series
|
||||||
|
const dataByProvider = data.reduce<Record<string, RiskPlotPoint[]>>(
|
||||||
(acc, point) => {
|
(acc, point) => {
|
||||||
const provider = point.provider;
|
(acc[point.provider] ??= []).push(point);
|
||||||
if (!acc[provider]) {
|
|
||||||
acc[provider] = [];
|
|
||||||
}
|
|
||||||
acc[provider].push(point);
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof data>,
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectPoint = (point: ScatterPoint) => {
|
const providers = Object.keys(dataByProvider);
|
||||||
if (selectedPoint?.name === point.name) {
|
|
||||||
setSelectedPoint(null);
|
const handleSelectPoint = (point: RiskPlotPoint) => {
|
||||||
} else {
|
setSelectedPoint((current) =>
|
||||||
setSelectedPoint(point);
|
current?.name === point.name ? null : point,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProviderClick = (provider: string) => {
|
||||||
|
setSelectedProvider((current) => (current === provider ? null : provider));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBarClick = (dataPoint: BarDataPoint) => {
|
||||||
|
if (!selectedPoint) return;
|
||||||
|
|
||||||
|
// Build the URL with current filters
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
// Transform provider filters (provider_id__in -> provider__in)
|
||||||
|
mapProviderFiltersForFindings(params);
|
||||||
|
|
||||||
|
// Add severity filter
|
||||||
|
const severity = SEVERITY_FILTER_MAP[dataPoint.name];
|
||||||
|
if (severity) {
|
||||||
|
params.set("filter[severity__in]", severity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add provider filter for the selected point
|
||||||
|
params.set("filter[provider__in]", selectedPoint.providerId);
|
||||||
|
|
||||||
|
// Add exclude muted findings filter
|
||||||
|
params.set("filter[muted]", "false");
|
||||||
|
|
||||||
|
// Filter by FAIL findings
|
||||||
|
params.set("filter[status__in]", "FAIL");
|
||||||
|
|
||||||
|
// Navigate to findings page
|
||||||
|
router.push(`/findings?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,26 +262,14 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
<div className="flex flex-1 gap-12">
|
<div className="flex flex-1 gap-12">
|
||||||
{/* Plot Section - in Card */}
|
{/* Plot Section - in Card */}
|
||||||
<div className="flex basis-[70%] flex-col">
|
<div className="flex basis-[70%] flex-col">
|
||||||
<div
|
<div className="border-border-neutral-primary bg-bg-neutral-secondary flex flex-1 flex-col rounded-lg border p-4">
|
||||||
className="flex flex-1 flex-col rounded-lg border p-4"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border-neutral-primary)",
|
|
||||||
backgroundColor: "var(--bg-neutral-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3
|
<h3 className="text-text-neutral-primary text-lg font-semibold">
|
||||||
className="text-lg font-semibold"
|
|
||||||
style={{ color: "var(--text-neutral-primary)" }}
|
|
||||||
>
|
|
||||||
Risk Plot
|
Risk Plot
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="relative min-h-[400px] w-full flex-1">
|
||||||
className="relative w-full flex-1"
|
|
||||||
style={{ minHeight: "400px" }}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ScatterChart
|
<ScatterChart
|
||||||
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
|
margin={{ top: 20, right: 30, bottom: 60, left: 60 }}
|
||||||
@@ -237,16 +283,16 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
dataKey="x"
|
dataKey="x"
|
||||||
name="Risk Score"
|
name="Threat Score"
|
||||||
label={{
|
label={{
|
||||||
value: "Risk Score",
|
value: "Threat Score",
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
offset: 10,
|
offset: 10,
|
||||||
fill: "var(--color-text-neutral-secondary)",
|
fill: "var(--color-text-neutral-secondary)",
|
||||||
}}
|
}}
|
||||||
tick={CustomXAxisTick}
|
tick={CustomXAxisTick}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
domain={[0, 10]}
|
domain={[0, 100]}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
@@ -268,30 +314,43 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip />} />
|
||||||
<Legend
|
|
||||||
content={<CustomLegend />}
|
|
||||||
wrapperStyle={{ paddingTop: "40px" }}
|
|
||||||
/>
|
|
||||||
{Object.entries(dataByProvider).map(([provider, points]) => (
|
{Object.entries(dataByProvider).map(([provider, points]) => (
|
||||||
<Scatter
|
<Scatter
|
||||||
key={provider}
|
key={provider}
|
||||||
name={provider}
|
name={provider}
|
||||||
data={points}
|
data={points}
|
||||||
fill={
|
fill={
|
||||||
PROVIDER_COLORS[
|
PROVIDER_COLORS[provider] ||
|
||||||
provider as keyof typeof PROVIDER_COLORS
|
"var(--color-text-neutral-tertiary)"
|
||||||
] || "var(--color-text-neutral-tertiary)"
|
|
||||||
}
|
}
|
||||||
shape={createScatterDotShape(
|
shape={createScatterDotShape(
|
||||||
selectedPoint,
|
selectedPoint,
|
||||||
handleSelectPoint,
|
handleSelectPoint,
|
||||||
data,
|
data,
|
||||||
|
selectedProvider,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScatterChart>
|
</ScatterChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Legend - below chart */}
|
||||||
|
<div className="mt-4 flex flex-col items-start gap-2">
|
||||||
|
<p className="text-text-neutral-tertiary pl-2 text-xs">
|
||||||
|
Click to filter by provider.
|
||||||
|
</p>
|
||||||
|
<ChartLegend
|
||||||
|
items={providers.map((p) => ({
|
||||||
|
label: p,
|
||||||
|
color:
|
||||||
|
PROVIDER_COLORS[p] || "var(--color-text-neutral-tertiary)",
|
||||||
|
dataKey: p,
|
||||||
|
}))}
|
||||||
|
selectedItem={selectedProvider}
|
||||||
|
onItemClick={handleProviderClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -300,28 +359,22 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
|||||||
{selectedPoint && selectedPoint.severityData ? (
|
{selectedPoint && selectedPoint.severityData ? (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h4
|
<h4 className="text-text-neutral-primary text-base font-semibold">
|
||||||
className="text-base font-semibold"
|
|
||||||
style={{ color: "var(--text-neutral-primary)" }}
|
|
||||||
>
|
|
||||||
{selectedPoint.name}
|
{selectedPoint.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p
|
<p className="text-text-neutral-tertiary text-xs">
|
||||||
className="text-xs"
|
Threat Score: {selectedPoint.x}% | Failed Findings:{" "}
|
||||||
style={{ color: "var(--text-neutral-tertiary)" }}
|
|
||||||
>
|
|
||||||
Risk Score: {selectedPoint.x} | Failed Findings:{" "}
|
|
||||||
{selectedPoint.y}
|
{selectedPoint.y}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<HorizontalBarChart data={selectedPoint.severityData} />
|
<HorizontalBarChart
|
||||||
|
data={selectedPoint.severityData}
|
||||||
|
onBarClick={handleBarClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full items-center justify-center text-center">
|
<div className="flex w-full items-center justify-center text-center">
|
||||||
<p
|
<p className="text-text-neutral-tertiary text-sm">
|
||||||
className="text-sm"
|
|
||||||
style={{ color: "var(--text-neutral-tertiary)" }}
|
|
||||||
>
|
|
||||||
Select a point on the plot to view details
|
Select a point on the plot to view details
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import { RiskPlotClient, type ScatterPoint } from "./risk-plot-client";
|
|
||||||
|
|
||||||
// Mock data - Risk Score (0-10) vs Failed Findings count
|
|
||||||
const mockScatterData: ScatterPoint[] = [
|
|
||||||
{
|
|
||||||
x: 9.2,
|
|
||||||
y: 1456,
|
|
||||||
provider: "AWS",
|
|
||||||
name: "Amazon RDS",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 456 },
|
|
||||||
{ name: "High", value: 600 },
|
|
||||||
{ name: "Medium", value: 250 },
|
|
||||||
{ name: "Low", value: 120 },
|
|
||||||
{ name: "Info", value: 30 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 8.5,
|
|
||||||
y: 892,
|
|
||||||
provider: "AWS",
|
|
||||||
name: "Amazon EC2",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 280 },
|
|
||||||
{ name: "High", value: 350 },
|
|
||||||
{ name: "Medium", value: 180 },
|
|
||||||
{ name: "Low", value: 70 },
|
|
||||||
{ name: "Info", value: 12 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 7.1,
|
|
||||||
y: 445,
|
|
||||||
provider: "AWS",
|
|
||||||
name: "Amazon S3",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 140 },
|
|
||||||
{ name: "High", value: 180 },
|
|
||||||
{ name: "Medium", value: 90 },
|
|
||||||
{ name: "Low", value: 30 },
|
|
||||||
{ name: "Info", value: 5 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 6.3,
|
|
||||||
y: 678,
|
|
||||||
provider: "AWS",
|
|
||||||
name: "AWS Lambda",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 214 },
|
|
||||||
{ name: "High", value: 270 },
|
|
||||||
{ name: "Medium", value: 135 },
|
|
||||||
{ name: "Low", value: 54 },
|
|
||||||
{ name: "Info", value: 5 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 4.2,
|
|
||||||
y: 156,
|
|
||||||
provider: "AWS",
|
|
||||||
name: "AWS Backup",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 49 },
|
|
||||||
{ name: "High", value: 62 },
|
|
||||||
{ name: "Medium", value: 31 },
|
|
||||||
{ name: "Low", value: 12 },
|
|
||||||
{ name: "Info", value: 2 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 8.8,
|
|
||||||
y: 1023,
|
|
||||||
provider: "Azure",
|
|
||||||
name: "Azure SQL Database",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 323 },
|
|
||||||
{ name: "High", value: 410 },
|
|
||||||
{ name: "Medium", value: 205 },
|
|
||||||
{ name: "Low", value: 82 },
|
|
||||||
{ name: "Info", value: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 7.9,
|
|
||||||
y: 834,
|
|
||||||
provider: "Azure",
|
|
||||||
name: "Azure Virtual Machines",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 263 },
|
|
||||||
{ name: "High", value: 334 },
|
|
||||||
{ name: "Medium", value: 167 },
|
|
||||||
{ name: "Low", value: 67 },
|
|
||||||
{ name: "Info", value: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 6.4,
|
|
||||||
y: 567,
|
|
||||||
provider: "Azure",
|
|
||||||
name: "Azure Storage",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 179 },
|
|
||||||
{ name: "High", value: 227 },
|
|
||||||
{ name: "Medium", value: 113 },
|
|
||||||
{ name: "Low", value: 45 },
|
|
||||||
{ name: "Info", value: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 5.1,
|
|
||||||
y: 289,
|
|
||||||
provider: "Azure",
|
|
||||||
name: "Azure Key Vault",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 91 },
|
|
||||||
{ name: "High", value: 115 },
|
|
||||||
{ name: "Medium", value: 58 },
|
|
||||||
{ name: "Low", value: 23 },
|
|
||||||
{ name: "Info", value: 2 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 7.6,
|
|
||||||
y: 712,
|
|
||||||
provider: "Google",
|
|
||||||
name: "Cloud SQL",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 225 },
|
|
||||||
{ name: "High", value: 285 },
|
|
||||||
{ name: "Medium", value: 142 },
|
|
||||||
{ name: "Low", value: 57 },
|
|
||||||
{ name: "Info", value: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 6.9,
|
|
||||||
y: 623,
|
|
||||||
provider: "Google",
|
|
||||||
name: "Compute Engine",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 197 },
|
|
||||||
{ name: "High", value: 249 },
|
|
||||||
{ name: "Medium", value: 124 },
|
|
||||||
{ name: "Low", value: 50 },
|
|
||||||
{ name: "Info", value: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 5.8,
|
|
||||||
y: 412,
|
|
||||||
provider: "Google",
|
|
||||||
name: "Cloud Storage",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 130 },
|
|
||||||
{ name: "High", value: 165 },
|
|
||||||
{ name: "Medium", value: 82 },
|
|
||||||
{ name: "Low", value: 33 },
|
|
||||||
{ name: "Info", value: 2 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 4.5,
|
|
||||||
y: 198,
|
|
||||||
provider: "Google",
|
|
||||||
name: "Cloud Run",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 63 },
|
|
||||||
{ name: "High", value: 79 },
|
|
||||||
{ name: "Medium", value: 39 },
|
|
||||||
{ name: "Low", value: 16 },
|
|
||||||
{ name: "Info", value: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: 8.9,
|
|
||||||
y: 945,
|
|
||||||
provider: "AWS",
|
|
||||||
name: "Amazon RDS Aurora",
|
|
||||||
severityData: [
|
|
||||||
{ name: "Critical", value: 299 },
|
|
||||||
{ name: "High", value: 378 },
|
|
||||||
{ name: "Medium", value: 189 },
|
|
||||||
{ name: "Low", value: 76 },
|
|
||||||
{ name: "Info", value: 3 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function RiskPlotView() {
|
|
||||||
return <RiskPlotClient data={mockScatterData} />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
adaptToRiskPlotData,
|
||||||
|
getProvidersRiskData,
|
||||||
|
} from "@/actions/overview/risk-plot";
|
||||||
|
import { getProviders } from "@/actions/providers";
|
||||||
|
import { SearchParamsProps } from "@/types";
|
||||||
|
|
||||||
|
import { pickFilterParams } from "../../_lib/filter-params";
|
||||||
|
import { RiskPlotClient } from "./risk-plot-client";
|
||||||
|
|
||||||
|
export async function RiskPlotSSR({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: SearchParamsProps;
|
||||||
|
}) {
|
||||||
|
const filters = pickFilterParams(searchParams);
|
||||||
|
|
||||||
|
const providerTypeFilter = filters["filter[provider_type__in]"];
|
||||||
|
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||||
|
|
||||||
|
// Fetch all providers
|
||||||
|
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||||
|
const allProviders = providersListResponse?.data || [];
|
||||||
|
|
||||||
|
// Filter providers based on search params
|
||||||
|
let filteredProviders = allProviders;
|
||||||
|
|
||||||
|
if (providerIdFilter) {
|
||||||
|
// Filter by specific provider IDs
|
||||||
|
const selectedIds = String(providerIdFilter)
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim());
|
||||||
|
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
|
||||||
|
} else if (providerTypeFilter) {
|
||||||
|
// Filter by provider types
|
||||||
|
const selectedTypes = String(providerTypeFilter)
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim().toLowerCase());
|
||||||
|
filteredProviders = allProviders.filter((p) =>
|
||||||
|
selectedTypes.includes(p.attributes.provider.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No providers to show
|
||||||
|
if (filteredProviders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[460px] w-full items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<Info size={48} className="text-text-neutral-tertiary" />
|
||||||
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
|
No providers available for the selected filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch risk data for all filtered providers in parallel
|
||||||
|
const providersRiskData = await getProvidersRiskData(filteredProviders);
|
||||||
|
|
||||||
|
// Transform to chart format
|
||||||
|
const { points, providersWithoutData } =
|
||||||
|
adaptToRiskPlotData(providersRiskData);
|
||||||
|
|
||||||
|
// No data available
|
||||||
|
if (points.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[460px] w-full items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<Info size={48} className="text-text-neutral-tertiary" />
|
||||||
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
|
No risk data available for the selected providers
|
||||||
|
</p>
|
||||||
|
{providersWithoutData.length > 0 && (
|
||||||
|
<p className="text-text-neutral-tertiary text-xs">
|
||||||
|
{providersWithoutData.length} provider(s) have no completed scans
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex-1 overflow-visible">
|
||||||
|
<RiskPlotClient data={points} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -195,7 +195,7 @@ const SSRComplianceContent = async ({
|
|||||||
{ pass: 0, fail: 0, manual: 0 },
|
{ pass: 0, fail: 0, manual: 0 },
|
||||||
);
|
);
|
||||||
const accordionItems = mapper.toAccordionItems(data, scanId);
|
const accordionItems = mapper.toAccordionItems(data, scanId);
|
||||||
const topFailedSections = mapper.getTopFailedSections(data);
|
const topFailedResult = mapper.getTopFailedSections(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
@@ -205,7 +205,10 @@ const SSRComplianceContent = async ({
|
|||||||
fail={totalRequirements.fail}
|
fail={totalRequirements.fail}
|
||||||
manual={totalRequirements.manual}
|
manual={totalRequirements.manual}
|
||||||
/>
|
/>
|
||||||
<TopFailedSectionsCard sections={topFailedSections} />
|
<TopFailedSectionsCard
|
||||||
|
sections={topFailedResult.items}
|
||||||
|
dataType={topFailedResult.type}
|
||||||
|
/>
|
||||||
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,20 @@
|
|||||||
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
import { HorizontalBarChart } from "@/components/graphs/horizontal-bar-chart";
|
||||||
import { BarDataPoint } from "@/components/graphs/types";
|
import { BarDataPoint } from "@/components/graphs/types";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
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 {
|
interface TopFailedSectionsCardProps {
|
||||||
sections: FailedSection[];
|
sections: FailedSection[];
|
||||||
|
dataType?: TopFailedDataType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopFailedSectionsCard({
|
export function TopFailedSectionsCard({
|
||||||
sections,
|
sections,
|
||||||
|
dataType = TOP_FAILED_DATA_TYPE.SECTIONS,
|
||||||
}: TopFailedSectionsCardProps) {
|
}: TopFailedSectionsCardProps) {
|
||||||
// Transform FailedSection[] to BarDataPoint[]
|
// Transform FailedSection[] to BarDataPoint[]
|
||||||
const total = sections.reduce((sum, section) => sum + section.total, 0);
|
const total = sections.reduce((sum, section) => sum + section.total, 0);
|
||||||
@@ -22,13 +28,18 @@ export function TopFailedSectionsCard({
|
|||||||
color: "var(--bg-fail-primary)",
|
color: "var(--bg-fail-primary)",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const title =
|
||||||
|
dataType === TOP_FAILED_DATA_TYPE.REQUIREMENTS
|
||||||
|
? "Top Failed Requirements"
|
||||||
|
: "Top Failed Sections";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
variant="base"
|
variant="base"
|
||||||
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
|
className="flex min-h-[372px] w-full flex-col sm:min-w-[500px]"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top Failed Sections</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 items-center justify-start">
|
<CardContent className="flex flex-1 items-center justify-start">
|
||||||
<HorizontalBarChart data={barData} />
|
<HorizontalBarChart data={barData} />
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export function AlertPill({
|
|||||||
>
|
>
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
size={iconSize}
|
size={iconSize}
|
||||||
style={{ color: "var(--color-text-text-error)" }}
|
style={{ color: "var(--color-text-error-primary)" }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(textSizeClass, "font-semibold")}
|
className={cn(textSizeClass, "font-semibold")}
|
||||||
style={{ color: "var(--color-text-text-error)" }}
|
style={{ color: "var(--color-text-error-primary)" }}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,14 +1,65 @@
|
|||||||
import {
|
import {
|
||||||
|
Category,
|
||||||
CategoryData,
|
CategoryData,
|
||||||
|
Control,
|
||||||
FailedSection,
|
FailedSection,
|
||||||
Framework,
|
Framework,
|
||||||
Requirement,
|
|
||||||
REQUIREMENT_STATUS,
|
REQUIREMENT_STATUS,
|
||||||
RequirementItemData,
|
RequirementItemData,
|
||||||
RequirementsData,
|
RequirementsData,
|
||||||
RequirementStatus,
|
RequirementStatus,
|
||||||
|
TOP_FAILED_DATA_TYPE,
|
||||||
|
TopFailedDataType,
|
||||||
|
TopFailedResult,
|
||||||
} from "@/types/compliance";
|
} 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 = (
|
export const updateCounters = (
|
||||||
target: { pass: number; fail: number; manual: number },
|
target: { pass: number; fail: number; manual: number },
|
||||||
status: RequirementStatus,
|
status: RequirementStatus,
|
||||||
@@ -24,38 +75,45 @@ export const updateCounters = (
|
|||||||
|
|
||||||
export const getTopFailedSections = (
|
export const getTopFailedSections = (
|
||||||
mappedData: Framework[],
|
mappedData: Framework[],
|
||||||
): FailedSection[] => {
|
): TopFailedResult => {
|
||||||
const failedSectionMap = new Map();
|
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) => {
|
mappedData.forEach((framework) => {
|
||||||
framework.categories.forEach((category) => {
|
framework.categories.forEach((category) => {
|
||||||
category.controls.forEach((control) => {
|
category.controls.forEach((control) => {
|
||||||
control.requirements.forEach((requirement) => {
|
control.requirements.forEach((requirement) => {
|
||||||
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
|
||||||
const sectionName = category.name;
|
const type =
|
||||||
|
typeof requirement.type === "string" ? requirement.type : "Fails";
|
||||||
if (!failedSectionMap.has(sectionName)) {
|
incrementFailedCount(failedSectionMap, category.name, type);
|
||||||
failedSectionMap.set(sectionName, { total: 0, types: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionData = failedSectionMap.get(sectionName);
|
|
||||||
sectionData.total += 1;
|
|
||||||
|
|
||||||
const type = requirement.type || "Fails";
|
|
||||||
|
|
||||||
sectionData.types[type as string] =
|
|
||||||
(sectionData.types[type as string] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert in descending order and slice top 5
|
return buildTopFailedResult(failedSectionMap, TOP_FAILED_DATA_TYPE.SECTIONS);
|
||||||
return Array.from(failedSectionMap.entries())
|
|
||||||
.map(([name, data]) => ({ name, ...data }))
|
|
||||||
.sort((a, b) => b.total - a.total)
|
|
||||||
.slice(0, 5); // Top 5
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calculateCategoryHeatmapData = (
|
export const calculateCategoryHeatmapData = (
|
||||||
@@ -146,9 +204,9 @@ export const findOrCreateFramework = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const findOrCreateCategory = (
|
export const findOrCreateCategory = (
|
||||||
categories: any[],
|
categories: Category[],
|
||||||
categoryName: string,
|
categoryName: string,
|
||||||
) => {
|
): Category => {
|
||||||
let category = categories.find((c) => c.name === categoryName);
|
let category = categories.find((c) => c.name === categoryName);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
category = {
|
category = {
|
||||||
@@ -163,7 +221,10 @@ export const findOrCreateCategory = (
|
|||||||
return category;
|
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);
|
let control = controls.find((c) => c.label === controlLabel);
|
||||||
if (!control) {
|
if (!control) {
|
||||||
control = {
|
control = {
|
||||||
@@ -178,7 +239,7 @@ export const findOrCreateControl = (controls: any[], controlLabel: string) => {
|
|||||||
return control;
|
return control;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calculateFrameworkCounters = (frameworks: Framework[]) => {
|
export const calculateFrameworkCounters = (frameworks: Framework[]): void => {
|
||||||
frameworks.forEach((framework) => {
|
frameworks.forEach((framework) => {
|
||||||
// Reset framework counters
|
// Reset framework counters
|
||||||
framework.pass = 0;
|
framework.pass = 0;
|
||||||
@@ -186,9 +247,9 @@ export const calculateFrameworkCounters = (frameworks: Framework[]) => {
|
|||||||
framework.manual = 0;
|
framework.manual = 0;
|
||||||
|
|
||||||
// Handle flat structure (requirements directly in framework)
|
// Handle flat structure (requirements directly in framework)
|
||||||
const directRequirements = (framework as any).requirements || [];
|
const directRequirements = framework.requirements ?? [];
|
||||||
if (directRequirements.length > 0) {
|
if (directRequirements.length > 0) {
|
||||||
directRequirements.forEach((requirement: Requirement) => {
|
directRequirements.forEach((requirement) => {
|
||||||
updateCounters(framework, requirement.status);
|
updateCounters(framework, requirement.status);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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 { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details";
|
||||||
import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details";
|
import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details";
|
||||||
@@ -14,10 +14,10 @@ import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
|
|||||||
import {
|
import {
|
||||||
AttributesData,
|
AttributesData,
|
||||||
CategoryData,
|
CategoryData,
|
||||||
FailedSection,
|
|
||||||
Framework,
|
Framework,
|
||||||
Requirement,
|
Requirement,
|
||||||
RequirementsData,
|
RequirementsData,
|
||||||
|
TopFailedResult,
|
||||||
} from "@/types/compliance";
|
} from "@/types/compliance";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -74,9 +74,9 @@ export interface ComplianceMapper {
|
|||||||
data: Framework[],
|
data: Framework[],
|
||||||
scanId: string | undefined,
|
scanId: string | undefined,
|
||||||
) => AccordionItemProps[];
|
) => AccordionItemProps[];
|
||||||
getTopFailedSections: (mappedData: Framework[]) => FailedSection[];
|
getTopFailedSections: (mappedData: Framework[]) => TopFailedResult;
|
||||||
calculateCategoryHeatmapData: (complianceData: Framework[]) => CategoryData[];
|
calculateCategoryHeatmapData: (complianceData: Framework[]) => CategoryData[];
|
||||||
getDetailsComponent: (requirement: Requirement) => React.ReactNode;
|
getDetailsComponent: (requirement: Requirement) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultMapper = (): ComplianceMapper => ({
|
const getDefaultMapper = (): ComplianceMapper => ({
|
||||||
@@ -86,7 +86,7 @@ const getDefaultMapper = (): ComplianceMapper => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(GenericCustomDetails, { requirement }),
|
createElement(GenericCustomDetails, { requirement }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
||||||
@@ -97,7 +97,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(C5CustomDetails, { requirement }),
|
createElement(C5CustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
ENS: {
|
ENS: {
|
||||||
mapComplianceData: mapENSComplianceData,
|
mapComplianceData: mapENSComplianceData,
|
||||||
@@ -106,7 +106,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(ENSCustomDetails, { requirement }),
|
createElement(ENSCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
ISO27001: {
|
ISO27001: {
|
||||||
mapComplianceData: mapISOComplianceData,
|
mapComplianceData: mapISOComplianceData,
|
||||||
@@ -115,7 +115,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(ISOCustomDetails, { requirement }),
|
createElement(ISOCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
CIS: {
|
CIS: {
|
||||||
mapComplianceData: mapCISComplianceData,
|
mapComplianceData: mapCISComplianceData,
|
||||||
@@ -124,7 +124,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(CISCustomDetails, { requirement }),
|
createElement(CISCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
"AWS-Well-Architected-Framework-Security-Pillar": {
|
"AWS-Well-Architected-Framework-Security-Pillar": {
|
||||||
mapComplianceData: mapAWSWellArchitectedComplianceData,
|
mapComplianceData: mapAWSWellArchitectedComplianceData,
|
||||||
@@ -133,7 +133,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
"AWS-Well-Architected-Framework-Reliability-Pillar": {
|
"AWS-Well-Architected-Framework-Reliability-Pillar": {
|
||||||
mapComplianceData: mapAWSWellArchitectedComplianceData,
|
mapComplianceData: mapAWSWellArchitectedComplianceData,
|
||||||
@@ -142,7 +142,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
createElement(AWSWellArchitectedCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
"KISA-ISMS-P": {
|
"KISA-ISMS-P": {
|
||||||
mapComplianceData: mapKISAComplianceData,
|
mapComplianceData: mapKISAComplianceData,
|
||||||
@@ -151,7 +151,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(KISACustomDetails, { requirement }),
|
createElement(KISACustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
"MITRE-ATTACK": {
|
"MITRE-ATTACK": {
|
||||||
mapComplianceData: mapMITREComplianceData,
|
mapComplianceData: mapMITREComplianceData,
|
||||||
@@ -159,7 +159,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
getTopFailedSections: getMITRETopFailedSections,
|
getTopFailedSections: getMITRETopFailedSections,
|
||||||
calculateCategoryHeatmapData: calculateMITRECategoryHeatmapData,
|
calculateCategoryHeatmapData: calculateMITRECategoryHeatmapData,
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(MITRECustomDetails, { requirement }),
|
createElement(MITRECustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
ProwlerThreatScore: {
|
ProwlerThreatScore: {
|
||||||
mapComplianceData: mapThetaComplianceData,
|
mapComplianceData: mapThetaComplianceData,
|
||||||
@@ -168,7 +168,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (complianceData: Framework[]) =>
|
calculateCategoryHeatmapData: (complianceData: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(complianceData),
|
calculateCategoryHeatmapData(complianceData),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(ThreatCustomDetails, { requirement }),
|
createElement(ThreatCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
CCC: {
|
CCC: {
|
||||||
mapComplianceData: mapCCCComplianceData,
|
mapComplianceData: mapCCCComplianceData,
|
||||||
@@ -177,7 +177,7 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
|
|||||||
calculateCategoryHeatmapData: (data: Framework[]) =>
|
calculateCategoryHeatmapData: (data: Framework[]) =>
|
||||||
calculateCategoryHeatmapData(data),
|
calculateCategoryHeatmapData(data),
|
||||||
getDetailsComponent: (requirement: Requirement) =>
|
getDetailsComponent: (requirement: Requirement) =>
|
||||||
React.createElement(CCCCustomDetails, { requirement }),
|
createElement(CCCCustomDetails, { requirement }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
REQUIREMENT_STATUS,
|
REQUIREMENT_STATUS,
|
||||||
RequirementsData,
|
RequirementsData,
|
||||||
RequirementStatus,
|
RequirementStatus,
|
||||||
|
TOP_FAILED_DATA_TYPE,
|
||||||
|
TopFailedResult,
|
||||||
} from "@/types/compliance";
|
} from "@/types/compliance";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,12 @@ import {
|
|||||||
findOrCreateFramework,
|
findOrCreateFramework,
|
||||||
} from "./commons";
|
} from "./commons";
|
||||||
|
|
||||||
|
// Type for the internal map used in getTopFailedSections
|
||||||
|
interface FailedSectionData {
|
||||||
|
total: number;
|
||||||
|
types: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
export const mapComplianceData = (
|
export const mapComplianceData = (
|
||||||
attributesData: AttributesData,
|
attributesData: AttributesData,
|
||||||
requirementsData: RequirementsData,
|
requirementsData: RequirementsData,
|
||||||
@@ -92,9 +100,9 @@ export const mapComplianceData = (
|
|||||||
}) || [],
|
}) || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add requirement directly to framework (store in a special property)
|
// Add requirement directly to framework (flat structure - no categories)
|
||||||
(framework as any).requirements = (framework as any).requirements || [];
|
framework.requirements = framework.requirements ?? [];
|
||||||
(framework as any).requirements.push(requirement);
|
framework.requirements.push(requirement);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate counters using common helper (works with flat structure)
|
// Calculate counters using common helper (works with flat structure)
|
||||||
@@ -108,63 +116,63 @@ export const toAccordionItems = (
|
|||||||
scanId: string | undefined,
|
scanId: string | undefined,
|
||||||
): AccordionItemProps[] => {
|
): AccordionItemProps[] => {
|
||||||
return data.flatMap((framework) => {
|
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)
|
// Filter out requirements without metadata (can't be displayed in accordion)
|
||||||
const displayableRequirements = requirements.filter(
|
const displayableRequirements = requirements.filter(
|
||||||
(requirement: Requirement) => requirement.hasMetadata !== false,
|
(requirement) => requirement.hasMetadata !== false,
|
||||||
);
|
);
|
||||||
|
|
||||||
return displayableRequirements.map(
|
return displayableRequirements.map((requirement, i) => {
|
||||||
(requirement: Requirement, i: number) => {
|
const itemKey = `${framework.name}-req-${i}`;
|
||||||
const itemKey = `${framework.name}-req-${i}`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: itemKey,
|
key: itemKey,
|
||||||
title: (
|
title: (
|
||||||
<ComplianceAccordionRequirementTitle
|
<ComplianceAccordionRequirementTitle
|
||||||
type=""
|
type=""
|
||||||
name={requirement.name}
|
name={requirement.name}
|
||||||
status={requirement.status as FindingStatus}
|
status={requirement.status as FindingStatus}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
content: (
|
content: (
|
||||||
<ClientAccordionContent
|
<ClientAccordionContent
|
||||||
key={`content-${itemKey}`}
|
key={`content-${itemKey}`}
|
||||||
requirement={requirement}
|
requirement={requirement}
|
||||||
scanId={scanId || ""}
|
scanId={scanId || ""}
|
||||||
framework={framework.name}
|
framework={framework.name}
|
||||||
disableFindings={
|
disableFindings={
|
||||||
requirement.check_ids.length === 0 && requirement.manual === 0
|
requirement.check_ids.length === 0 && requirement.manual === 0
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
items: [],
|
items: [],
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom function for MITRE to get top failed sections grouped by tactics
|
// Custom function for MITRE to get top failed sections grouped by tactics
|
||||||
export const getTopFailedSections = (
|
export const getTopFailedSections = (
|
||||||
mappedData: Framework[],
|
mappedData: Framework[],
|
||||||
): FailedSection[] => {
|
): TopFailedResult => {
|
||||||
const failedSectionMap = new Map();
|
const failedSectionMap = new Map<string, FailedSectionData>();
|
||||||
|
|
||||||
mappedData.forEach((framework) => {
|
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) {
|
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) => {
|
tactics.forEach((tactic) => {
|
||||||
if (!failedSectionMap.has(tactic)) {
|
if (!failedSectionMap.has(tactic)) {
|
||||||
failedSectionMap.set(tactic, { total: 0, types: {} });
|
failedSectionMap.set(tactic, { total: 0, types: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionData = failedSectionMap.get(tactic);
|
const sectionData = failedSectionMap.get(tactic)!;
|
||||||
sectionData.total += 1;
|
sectionData.total += 1;
|
||||||
|
|
||||||
const type = "Fails";
|
const type = "Fails";
|
||||||
@@ -175,10 +183,13 @@ export const getTopFailedSections = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Convert in descending order and slice top 5
|
// Convert in descending order and slice top 5
|
||||||
return Array.from(failedSectionMap.entries())
|
return {
|
||||||
.map(([name, data]) => ({ name, ...data }))
|
items: Array.from(failedSectionMap.entries())
|
||||||
.sort((a, b) => b.total - a.total)
|
.map(([name, data]): FailedSection => ({ name, ...data }))
|
||||||
.slice(0, 5); // Top 5
|
.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
|
// Custom function for MITRE to calculate category heatmap data grouped by tactics
|
||||||
@@ -197,10 +208,12 @@ export const calculateCategoryHeatmapData = (
|
|||||||
|
|
||||||
// Aggregate data by tactics
|
// Aggregate data by tactics
|
||||||
complianceData.forEach((framework) => {
|
complianceData.forEach((framework) => {
|
||||||
const requirements = (framework as any).requirements || [];
|
const requirements = framework.requirements ?? [];
|
||||||
|
|
||||||
requirements.forEach((requirement: Requirement) => {
|
requirements.forEach((requirement) => {
|
||||||
const tactics = (requirement.tactics as string[]) || [];
|
const tactics = Array.isArray(requirement.tactics)
|
||||||
|
? (requirement.tactics as string[])
|
||||||
|
: [];
|
||||||
|
|
||||||
tactics.forEach((tactic) => {
|
tactics.forEach((tactic) => {
|
||||||
const existing = tacticMap.get(tactic) || {
|
const existing = tacticMap.get(tactic) || {
|
||||||
|
|||||||
@@ -68,12 +68,27 @@ export interface Framework {
|
|||||||
fail: number;
|
fail: number;
|
||||||
manual: number;
|
manual: number;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
// Optional: flat structure for frameworks like MITRE that don't have categories
|
||||||
|
requirements?: Requirement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FailedSection {
|
export interface FailedSection {
|
||||||
name: string;
|
name: string;
|
||||||
total: number;
|
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 {
|
export interface RequirementsTotals {
|
||||||
@@ -92,7 +107,7 @@ export interface ENSAttributesMetadata {
|
|||||||
Nivel: string;
|
Nivel: string;
|
||||||
Dimensiones: string[];
|
Dimensiones: string[];
|
||||||
ModoEjecucion: string;
|
ModoEjecucion: string;
|
||||||
Dependencias: any[];
|
Dependencias: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISO27001AttributesMetadata {
|
export interface ISO27001AttributesMetadata {
|
||||||
|
|||||||
Reference in New Issue
Block a user