mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat(ui): add Risk Radar component with category filtering (#9561)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -1,60 +1,8 @@
|
||||
import type { RadarDataPoint } from "@/components/graphs/types";
|
||||
import { getCategoryLabel } from "@/lib/categories";
|
||||
|
||||
import { CategoryOverview, CategoryOverviewResponse } from "./types";
|
||||
|
||||
// Category IDs from the API
|
||||
const CATEGORY_IDS = {
|
||||
E3: "e3",
|
||||
E5: "e5",
|
||||
ENCRYPTION: "encryption",
|
||||
FORENSICS_READY: "forensics-ready",
|
||||
IAM: "iam",
|
||||
INTERNET_EXPOSED: "internet-exposed",
|
||||
LOGGING: "logging",
|
||||
NETWORK: "network",
|
||||
PUBLICLY_ACCESSIBLE: "publicly-accessible",
|
||||
SECRETS: "secrets",
|
||||
STORAGE: "storage",
|
||||
THREAT_DETECTION: "threat-detection",
|
||||
TRUSTBOUNDARIES: "trustboundaries",
|
||||
UNUSED: "unused",
|
||||
} as const;
|
||||
|
||||
export type CategoryId = (typeof CATEGORY_IDS)[keyof typeof CATEGORY_IDS];
|
||||
|
||||
// Human-readable labels for category IDs
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
[CATEGORY_IDS.E3]: "E3",
|
||||
[CATEGORY_IDS.E5]: "E5",
|
||||
[CATEGORY_IDS.ENCRYPTION]: "Encryption",
|
||||
[CATEGORY_IDS.FORENSICS_READY]: "Forensics Ready",
|
||||
[CATEGORY_IDS.IAM]: "IAM",
|
||||
[CATEGORY_IDS.INTERNET_EXPOSED]: "Internet Exposed",
|
||||
[CATEGORY_IDS.LOGGING]: "Logging",
|
||||
[CATEGORY_IDS.NETWORK]: "Network",
|
||||
[CATEGORY_IDS.PUBLICLY_ACCESSIBLE]: "Publicly Accessible",
|
||||
[CATEGORY_IDS.SECRETS]: "Secrets",
|
||||
[CATEGORY_IDS.STORAGE]: "Storage",
|
||||
[CATEGORY_IDS.THREAT_DETECTION]: "Threat Detection",
|
||||
[CATEGORY_IDS.TRUSTBOUNDARIES]: "Trust Boundaries",
|
||||
[CATEGORY_IDS.UNUSED]: "Unused",
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a category ID to a human-readable label.
|
||||
* Falls back to capitalizing the ID if not found in the mapping.
|
||||
*/
|
||||
function getCategoryLabel(id: string): string {
|
||||
if (CATEGORY_LABELS[id]) {
|
||||
return CATEGORY_LABELS[id];
|
||||
}
|
||||
// Fallback: capitalize and replace hyphens with spaces
|
||||
return id
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage of new failed findings relative to total failed findings.
|
||||
*/
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { BarDataPoint } from "@/components/graphs/types";
|
||||
import { mapProviderFiltersForFindings } from "@/lib/provider-helpers";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
|
||||
// Threat Score colors (0-100 scale, higher = better)
|
||||
// ThreatScore colors (0-100 scale, higher = better)
|
||||
const THREAT_COLORS = {
|
||||
DANGER: "var(--bg-fail-primary)", // 0-30
|
||||
WARNING: "var(--bg-warning-primary)", // 31-60
|
||||
@@ -100,7 +100,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||
</p>
|
||||
<p className="text-text-neutral-secondary text-sm font-medium">
|
||||
<span style={{ color: scoreColor, fontWeight: "bold" }}>{x}%</span>{" "}
|
||||
Threat Score
|
||||
Prowler ThreatScore
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<AlertPill value={y} />
|
||||
@@ -268,8 +268,8 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
Risk Plot
|
||||
</h3>
|
||||
<p className="text-text-neutral-tertiary mt-1 text-xs">
|
||||
Threat Score is severity-weighted, not quantity-based. Higher
|
||||
severity findings have greater impact on the score.
|
||||
Prowler ThreatScore is severity-weighted, not quantity-based.
|
||||
Higher severity findings have greater impact on the score.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -287,9 +287,9 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
name="Threat Score"
|
||||
name="Prowler ThreatScore"
|
||||
label={{
|
||||
value: "Threat Score",
|
||||
value: "Prowler ThreatScore",
|
||||
position: "bottom",
|
||||
offset: 10,
|
||||
fill: "var(--color-text-neutral-secondary)",
|
||||
@@ -367,7 +367,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Threat Score: {selectedPoint.x}% | Fail Findings:{" "}
|
||||
Prowler ThreatScore: {selectedPoint.x}% | Fail Findings:{" "}
|
||||
{selectedPoint.y}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { RadarDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
|
||||
interface CategorySelectorProps {
|
||||
categories: RadarDataPoint[];
|
||||
selectedCategory: string | null;
|
||||
onCategoryChange: (categoryId: string | null) => void;
|
||||
}
|
||||
|
||||
export function CategorySelector({
|
||||
categories,
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
}: CategorySelectorProps) {
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === "" || value === "all") {
|
||||
onCategoryChange(null);
|
||||
} else {
|
||||
onCategoryChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={selectedCategory ?? "all"} onValueChange={handleValueChange}>
|
||||
<SelectTrigger size="sm" className="w-[200px]">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All categories</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.categoryId} value={category.categoryId}>
|
||||
{category.category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type { BarDataPoint, RadarDataPoint } from "@/components/graphs/types";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
|
||||
import { CategorySelector } from "./category-selector";
|
||||
|
||||
interface RiskRadarViewClientProps {
|
||||
data: RadarDataPoint[];
|
||||
}
|
||||
@@ -24,6 +26,15 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
||||
setSelectedPoint(point);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (categoryId: string | null) => {
|
||||
if (categoryId === null) {
|
||||
setSelectedPoint(null);
|
||||
} else {
|
||||
const point = data.find((d) => d.categoryId === categoryId);
|
||||
setSelectedPoint(point ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBarClick = (dataPoint: BarDataPoint) => {
|
||||
if (!selectedPoint) return;
|
||||
|
||||
@@ -59,6 +70,11 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
||||
<h3 className="text-neutral-primary text-lg font-semibold">
|
||||
Risk Radar
|
||||
</h3>
|
||||
<CategorySelector
|
||||
categories={data}
|
||||
selectedCategory={selectedPoint?.categoryId ?? null}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[400px] w-full flex-1">
|
||||
|
||||
@@ -116,7 +116,7 @@ export function ThreatScore({
|
||||
className="flex min-h-[372px] w-full flex-col justify-between lg:max-w-[312px]"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Prowler Threat Score</CardTitle>
|
||||
<CardTitle>Prowler ThreatScore</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col justify-between space-y-4">
|
||||
@@ -165,7 +165,7 @@ export function ThreatScore({
|
||||
className="mt-0.5 min-h-4 min-w-4 shrink-0"
|
||||
/>
|
||||
<p>
|
||||
Threat score has{" "}
|
||||
Prowler ThreatScore has{" "}
|
||||
{scoreDelta > 0 ? "improved" : "decreased"} by{" "}
|
||||
{Math.abs(scoreDelta)}%
|
||||
</p>
|
||||
@@ -194,7 +194,7 @@ export function ThreatScore({
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Threat Score Data Unavailable
|
||||
Prowler ThreatScore Data Unavailable
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -53,11 +53,12 @@ export default async function Findings({
|
||||
getScans({ pageSize: 50 }),
|
||||
]);
|
||||
|
||||
// Extract unique regions and services from the new endpoint
|
||||
// Extract unique regions, services, categories from the new endpoint
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
|
||||
const uniqueResourceTypes =
|
||||
metadataInfoData?.data?.attributes?.resource_types || [];
|
||||
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
|
||||
|
||||
// Extract provider IDs and details using helper functions
|
||||
const providerIds = providersData ? extractProviderIds(providersData) : [];
|
||||
@@ -93,6 +94,7 @@ export default async function Findings({
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
/>
|
||||
<Spacer y={8} />
|
||||
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
|
||||
|
||||
@@ -43,11 +43,6 @@ export const DEFAULT_FILTER_BADGES: FilterBadgeConfig[] = [
|
||||
label: "Check ID",
|
||||
formatMultiple: (count) => `${count} Check IDs filtered`,
|
||||
},
|
||||
{
|
||||
filterKey: "category__in",
|
||||
label: "Category",
|
||||
formatMultiple: (count) => `${count} Categories filtered`,
|
||||
},
|
||||
{
|
||||
filterKey: "scan__in",
|
||||
label: "Scan",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { filterFindings } from "@/components/filters/data-filters";
|
||||
import { FilterControls } from "@/components/filters/filter-controls";
|
||||
import { useRelatedFilters } from "@/hooks";
|
||||
import { getCategoryLabel } from "@/lib/categories";
|
||||
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
|
||||
|
||||
interface FindingsFiltersProps {
|
||||
@@ -14,6 +15,7 @@ interface FindingsFiltersProps {
|
||||
uniqueRegions: string[];
|
||||
uniqueServices: string[];
|
||||
uniqueResourceTypes: string[];
|
||||
uniqueCategories: string[];
|
||||
}
|
||||
|
||||
export const FindingsFilters = ({
|
||||
@@ -24,6 +26,7 @@ export const FindingsFilters = ({
|
||||
uniqueRegions,
|
||||
uniqueServices,
|
||||
uniqueResourceTypes,
|
||||
uniqueCategories,
|
||||
}: FindingsFiltersProps) => {
|
||||
const { availableProviderIds, availableScans } = useRelatedFilters({
|
||||
providerIds,
|
||||
@@ -66,6 +69,13 @@ export const FindingsFilters = ({
|
||||
values: uniqueResourceTypes,
|
||||
index: 8,
|
||||
},
|
||||
{
|
||||
key: FilterType.CATEGORY,
|
||||
labelCheckboxGroup: "Category",
|
||||
values: uniqueCategories,
|
||||
labelFormatter: getCategoryLabel,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: FilterType.SCAN,
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
|
||||
@@ -61,6 +61,17 @@ export function HorizontalBarChart({
|
||||
"var(--bg-neutral-tertiary)";
|
||||
|
||||
const isClickable = !isEmpty && onBarClick;
|
||||
const maxValue =
|
||||
data.length > 0 ? Math.max(...data.map((d) => d.value)) : 0;
|
||||
const calculatedWidth = isEmpty
|
||||
? item.percentage
|
||||
: (item.percentage ??
|
||||
(maxValue > 0 ? (item.value / maxValue) * 100 : 0));
|
||||
// Calculate display percentage (value / total * 100)
|
||||
const displayPercentage = isEmpty
|
||||
? 0
|
||||
: (item.percentage ??
|
||||
(total > 0 ? Math.round((item.value / total) * 100) : 0));
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
@@ -105,15 +116,13 @@ export function HorizontalBarChart({
|
||||
</div>
|
||||
|
||||
{/* Bar - flexible */}
|
||||
<div className="relative flex-1">
|
||||
<div className="relative h-[22px] flex-1">
|
||||
<div className="bg-bg-neutral-tertiary absolute inset-0 h-[22px] w-full rounded-sm" />
|
||||
{(item.value > 0 || isEmpty) && (
|
||||
<div
|
||||
className="relative h-[22px] rounded-sm border border-black/10 transition-all duration-300"
|
||||
style={{
|
||||
width: isEmpty
|
||||
? `${item.percentage}%`
|
||||
: `${item.percentage || (item.value / Math.max(...data.map((d) => d.value))) * 100}%`,
|
||||
width: `${calculatedWidth}%`,
|
||||
backgroundColor: barColor,
|
||||
opacity: isFaded ? 0.5 : 1,
|
||||
}}
|
||||
@@ -174,7 +183,7 @@ export function HorizontalBarChart({
|
||||
}}
|
||||
>
|
||||
<span className="min-w-[26px] text-right font-medium">
|
||||
{isEmpty ? "0" : item.percentage}%
|
||||
{displayPercentage}%
|
||||
</span>
|
||||
<span className="shrink-0 font-medium">•</span>
|
||||
<span className="font-bold whitespace-nowrap">
|
||||
|
||||
@@ -98,6 +98,7 @@ const CustomDot = ({
|
||||
}: CustomDotProps) => {
|
||||
const currentCategory = payload.name || payload.category;
|
||||
const isSelected = selectedPoint?.category === currentCategory;
|
||||
const isFaded = selectedPoint !== null && !isSelected;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -127,13 +128,14 @@ const CustomDot = ({
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={isSelected ? 9 : 6}
|
||||
fillOpacity={1}
|
||||
style={{
|
||||
fill: isSelected
|
||||
? "var(--bg-button-primary)"
|
||||
: "var(--bg-radar-button)",
|
||||
fillOpacity: isFaded ? 0.3 : 1,
|
||||
cursor: onSelectPoint ? "pointer" : "default",
|
||||
pointerEvents: "all",
|
||||
transition: "fill-opacity 200ms ease-in-out",
|
||||
}}
|
||||
onClick={onSelectPoint ? handleClick : undefined}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SEVERITY_ORDER = {
|
||||
Medium: 2,
|
||||
Low: 3,
|
||||
Informational: 4,
|
||||
Info: 4,
|
||||
} as const;
|
||||
|
||||
export const LAYOUT_OPTIONS = {
|
||||
|
||||
@@ -151,13 +151,16 @@ export const DataTableFilterCustom = ({
|
||||
<MultiSelectSeparator />
|
||||
{filter.values.map((value) => {
|
||||
const entity = getEntityForValue(filter, value);
|
||||
const displayLabel = filter.labelFormatter
|
||||
? filter.labelFormatter(value)
|
||||
: value;
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
badgeLabel={getBadgeLabel(entity, value)}
|
||||
badgeLabel={getBadgeLabel(entity, displayLabel)}
|
||||
>
|
||||
{entity ? renderEntityContent(entity) : value}
|
||||
{entity ? renderEntityContent(entity) : displayLabel}
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
|
||||
54
ui/lib/categories.ts
Normal file
54
ui/lib/categories.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Special cases that don't follow standard capitalization rules.
|
||||
* Add entries here for edge cases that heuristics can't handle.
|
||||
*/
|
||||
const SPECIAL_CASES: Record<string, string> = {
|
||||
// Add special cases here if needed, e.g.:
|
||||
// "someweirdcase": "SomeWeirdCase",
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a category ID to a human-readable label.
|
||||
*
|
||||
* Capitalization rules (in order of priority):
|
||||
* 1. Special cases dictionary - for edge cases that don't follow patterns
|
||||
* 2. Acronym + version pattern (e.g., imdsv1 -> IMDSv1, apiv2 -> APIv2)
|
||||
* 3. Short words (≤3 chars) - fully capitalized (e.g., iam -> IAM, ec2 -> EC2)
|
||||
* 4. Default - capitalize first letter (e.g., internet -> Internet)
|
||||
*
|
||||
* Examples:
|
||||
* - "internet-exposed" -> "Internet Exposed"
|
||||
* - "iam" -> "IAM"
|
||||
* - "ec2-imdsv1" -> "EC2 IMDSv1"
|
||||
* - "forensics-ready" -> "Forensics Ready"
|
||||
*/
|
||||
export function getCategoryLabel(id: string): string {
|
||||
return id
|
||||
.split("-")
|
||||
.map((word) => formatWord(word))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatWord(word: string): string {
|
||||
const lowerWord = word.toLowerCase();
|
||||
|
||||
// 1. Check special cases dictionary
|
||||
if (lowerWord in SPECIAL_CASES) {
|
||||
return SPECIAL_CASES[lowerWord];
|
||||
}
|
||||
|
||||
// 2. Acronym + version pattern (e.g., imdsv1 -> IMDSv1)
|
||||
const versionMatch = lowerWord.match(/^([a-z]+)(v\d+)$/);
|
||||
if (versionMatch) {
|
||||
const [, acronym, version] = versionMatch;
|
||||
return acronym.toUpperCase() + version.toLowerCase();
|
||||
}
|
||||
|
||||
// 3. Short words are likely acronyms (IAM, EC2, S3, API, VPC, etc.)
|
||||
if (word.length <= 3) {
|
||||
return word.toUpperCase();
|
||||
}
|
||||
|
||||
// 4. Default: capitalize first letter
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface FilterOption {
|
||||
labelCheckboxGroup: string;
|
||||
values: string[];
|
||||
valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>;
|
||||
labelFormatter?: (value: string) => string;
|
||||
index?: number;
|
||||
showSelectAll?: boolean;
|
||||
defaultToSelectAll?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user