mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 20:33:41 +00:00
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
import { TriangleAlert } from "lucide-react";
|
|
import Link from "next/link";
|
|
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/shadcn";
|
|
import { issuesHref } from "@/lib/issues/scope";
|
|
import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts";
|
|
import { cn } from "@/lib/utils";
|
|
import { Verdict } from "@/types/issues";
|
|
|
|
interface ExploitabilityChartProps {
|
|
counts: Record<Verdict, number>;
|
|
issues: number;
|
|
findings: number;
|
|
actionable: number;
|
|
/** Findings folded into not-exploitable issues: a control already covers them. */
|
|
coveredFindings: number;
|
|
/** Overview scope, carried into every drill-down so the counts still match. */
|
|
scope: Record<string, string>;
|
|
}
|
|
|
|
/** The verdicts below the headline, in the order the card lists them. */
|
|
const REST: Verdict[] = ["conditional", "unknown", "not_exploitable"];
|
|
|
|
/** The dot each verdict row wears. */
|
|
const TONE_DOT: Record<VerdictTone, string> = {
|
|
[VERDICT_TONE.fail]: "bg-bg-fail",
|
|
[VERDICT_TONE.warning]: "bg-bg-data-medium",
|
|
[VERDICT_TONE.pass]: "bg-bg-pass",
|
|
[VERDICT_TONE.neutral]: "bg-bg-data-muted",
|
|
};
|
|
|
|
/** The share bar runs worst to best; likely is a lighter exploitable. */
|
|
const BAR_SEGMENT: Record<Verdict, string> = {
|
|
exploitable: "bg-bg-fail",
|
|
likely: "bg-bg-fail/60",
|
|
conditional: TONE_DOT[VERDICT_TONE.warning],
|
|
unknown: TONE_DOT[VERDICT_TONE.neutral],
|
|
not_exploitable: TONE_DOT[VERDICT_TONE.pass],
|
|
};
|
|
|
|
/**
|
|
* The fold, sized for the ThreatScore row.
|
|
*
|
|
* The headline is exploitable + likely — the issues someone should look at
|
|
* tonight — on a red surface. Everything else is work, not an incident, so it
|
|
* sits as a quiet list underneath. Every number links into the issue list, so
|
|
* nothing here is a dead end.
|
|
*/
|
|
export const ExploitabilityChart = ({
|
|
counts,
|
|
issues,
|
|
findings,
|
|
actionable,
|
|
coveredFindings,
|
|
scope,
|
|
}: ExploitabilityChartProps) => {
|
|
const barTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
|
|
return (
|
|
<Card
|
|
variant="base"
|
|
className="flex min-h-[372px] w-full min-w-0 flex-col lg:max-w-[312px]"
|
|
>
|
|
<CardHeader className="flex items-baseline justify-between gap-3">
|
|
<CardTitle>Exploitability</CardTitle>
|
|
<span className="text-text-neutral-tertiary text-xs">
|
|
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
|
|
</span>
|
|
</CardHeader>
|
|
|
|
<CardContent className="flex flex-1 flex-col gap-3">
|
|
<Link
|
|
href={issuesHref(scope, {
|
|
"filter[verdict__in]": "exploitable,likely",
|
|
})}
|
|
aria-label={`${actionable} exploitable or likely issues`}
|
|
className="border-bg-fail/35 bg-bg-fail/8 hover:bg-bg-fail/15 focus-visible:ring-ring/50 flex items-center gap-3 rounded-lg border px-4 py-3.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
>
|
|
<TriangleAlert className="text-bg-fail size-5 shrink-0" />
|
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<span className="text-text-neutral-primary text-sm font-medium">
|
|
Exploitable or likely
|
|
</span>
|
|
<span className="text-text-neutral-tertiary text-xs">
|
|
{counts.exploitable} exploitable · {counts.likely} likely
|
|
</span>
|
|
</span>
|
|
<span className="text-text-neutral-primary text-[28px] leading-8 font-light tracking-tight tabular-nums">
|
|
{actionable.toLocaleString()}
|
|
</span>
|
|
</Link>
|
|
|
|
{/* The ladder's shape: whether this estate is mostly open or mostly
|
|
blocked, sized by share and never by severity. The rows around it
|
|
carry the same links for keyboard and screen-reader users. */}
|
|
{barTotal > 0 && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="bg-bg-neutral-tertiary flex h-2 w-full overflow-hidden rounded-full"
|
|
>
|
|
{(Object.keys(BAR_SEGMENT) as Verdict[])
|
|
.filter((id) => counts[id] > 0)
|
|
.map((id) => (
|
|
<Link
|
|
key={id}
|
|
tabIndex={-1}
|
|
href={issuesHref(scope, { "filter[verdict]": id })}
|
|
title={`${counts[id]} ${(VERDICTS.find((item) => item.id === id)?.label ?? id).toLowerCase()}`}
|
|
style={{ width: `${(counts[id] / barTotal) * 100}%` }}
|
|
className={cn(
|
|
"h-full transition-opacity hover:opacity-80",
|
|
BAR_SEGMENT[id],
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Card variant="inner" padding="none" className="gap-0 px-4 py-0.5">
|
|
<ul className="divide-border-neutral-secondary flex flex-col divide-y">
|
|
{REST.map((id) => {
|
|
const verdict = VERDICTS.find((item) => item.id === id);
|
|
return (
|
|
<li key={id}>
|
|
<Link
|
|
href={issuesHref(scope, { "filter[verdict]": id })}
|
|
aria-label={`${counts[id]} ${(verdict?.label ?? id).toLowerCase()} ${counts[id] === 1 ? "issue" : "issues"}`}
|
|
className="text-text-neutral-secondary hover:text-text-neutral-primary focus-visible:ring-ring/50 flex h-10 items-center gap-2.5 rounded-md text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"size-2 shrink-0 rounded-full",
|
|
verdict && TONE_DOT[verdict.tone],
|
|
)}
|
|
/>
|
|
<span className="flex-1">{verdict?.label ?? id}</span>
|
|
<span className="text-text-neutral-primary font-semibold tabular-nums">
|
|
{counts[id].toLocaleString()}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</Card>
|
|
|
|
{/* The fold. Without the findings count the issue count says nothing
|
|
about how much noise was removed, so the two are always shown
|
|
together. */}
|
|
<p className="border-border-neutral-secondary text-text-neutral-tertiary mt-auto border-t pt-3 text-xs">
|
|
{findings.toLocaleString()} failed findings folded into{" "}
|
|
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
|
|
{coveredFindings > 0 && (
|
|
<>
|
|
{" · "}
|
|
<Button asChild variant="link" size="link-xs">
|
|
<Link
|
|
href={issuesHref(scope, {
|
|
"filter[verdict]": "not_exploitable",
|
|
})}
|
|
>
|
|
{coveredFindings.toLocaleString()}{" "}
|
|
{coveredFindings === 1 ? "finding" : "findings"}
|
|
</Link>
|
|
</Button>{" "}
|
|
already covered by a control
|
|
</>
|
|
)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|