mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 09:24:27 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ad662105 | ||
|
|
fc31ff260d | ||
|
|
b5d086235f | ||
|
|
ba2d73a6d9 | ||
|
|
b50a7c5567 | ||
|
|
8ca66a62ff | ||
|
|
af0476124a | ||
|
|
f626aa7a87 | ||
|
|
7a78ca970a |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Python base image from 3.12.15 to 3.12.16 in the API container image, patching CVE-2026-115557
|
||||
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# =============================================================================
|
||||
# Final stage - Minimal runtime environment
|
||||
# =============================================================================
|
||||
FROM python:3.15.1-alpine3.23@sha256:cb295b554dbbea09b2c0e6e6d9ce55f051347c902fbb611e618c071addfb30a3
|
||||
FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
MongoDB Atlas connection errors now include the HTTP status, error code and detail returned by Atlas instead of a generic HTTP error, and a custom exception message no longer persists into later errors of the same type
|
||||
@@ -1 +0,0 @@
|
||||
Python base image from 3.12.15 to 3.12.16 in the SDK container image, patching CVE-2026-115557
|
||||
@@ -38,9 +38,7 @@ class MongoDBAtlasBaseException(ProwlerException):
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
provider = "MongoDB Atlas"
|
||||
error_info = dict(
|
||||
self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
)
|
||||
error_info = self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
if message:
|
||||
error_info["message"] = message
|
||||
super().__init__(
|
||||
|
||||
@@ -236,12 +236,7 @@ class MongodbatlasProvider(Provider):
|
||||
message="MongoDB Atlas authentication failed. Please check your API credentials.",
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise MongoDBAtlasIdentityError(
|
||||
file=os.path.basename(__file__),
|
||||
message=MongodbatlasProvider._describe_api_error(response),
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
organizations_data = response.json()
|
||||
|
||||
# Extract organization information from the response
|
||||
@@ -271,7 +266,7 @@ class MongodbatlasProvider(Provider):
|
||||
|
||||
return identity
|
||||
|
||||
except (MongoDBAtlasAuthenticationError, MongoDBAtlasIdentityError):
|
||||
except MongoDBAtlasAuthenticationError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
@@ -281,22 +276,6 @@ class MongodbatlasProvider(Provider):
|
||||
original_exception=error,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _describe_api_error(response) -> str:
|
||||
"""Build an identity error message that keeps the reason Atlas returns."""
|
||||
message = f"MongoDB Atlas identity setup failed - HTTP {response.status_code}"
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return message
|
||||
if not isinstance(body, dict):
|
||||
return message
|
||||
if body.get("errorCode"):
|
||||
message += f" {body['errorCode']}"
|
||||
if body.get("detail"):
|
||||
message += f": {body['detail']}"
|
||||
return message
|
||||
|
||||
def print_credentials(self):
|
||||
"""Print the MongoDB Atlas credentials"""
|
||||
report_lines = [
|
||||
|
||||
@@ -136,60 +136,6 @@ class TestMongodbatlasProvider:
|
||||
with pytest.raises(MongoDBAtlasIdentityError):
|
||||
MongodbatlasProvider.setup_identity(session)
|
||||
|
||||
@patch("requests.get")
|
||||
def test_setup_identity_forbidden_keeps_atlas_error_detail(self, mock_get):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 403
|
||||
mock_response.ok = False
|
||||
mock_response.json.return_value = {
|
||||
"detail": "IP address 203.0.113.10 is not allowed to access this resource.",
|
||||
"error": 403,
|
||||
"errorCode": "IP_ADDRESS_NOT_ON_ACCESS_LIST",
|
||||
"reason": "Forbidden",
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
session = MongoDBAtlasSession(
|
||||
public_key=ATLAS_PUBLIC_KEY,
|
||||
private_key=ATLAS_PRIVATE_KEY,
|
||||
)
|
||||
|
||||
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
|
||||
MongodbatlasProvider.setup_identity(session)
|
||||
|
||||
assert str(exc_info.value) == (
|
||||
"MongoDBAtlasIdentityError[8003]: MongoDB Atlas identity setup failed"
|
||||
" - HTTP 403 IP_ADDRESS_NOT_ON_ACCESS_LIST: IP address 203.0.113.10"
|
||||
" is not allowed to access this resource."
|
||||
)
|
||||
|
||||
@patch("requests.get")
|
||||
def test_setup_identity_error_without_json_body_reports_status(self, mock_get):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 502
|
||||
mock_response.ok = False
|
||||
mock_response.json.side_effect = ValueError("No JSON")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
session = MongoDBAtlasSession(
|
||||
public_key=ATLAS_PUBLIC_KEY,
|
||||
private_key=ATLAS_PRIVATE_KEY,
|
||||
)
|
||||
|
||||
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
|
||||
MongodbatlasProvider.setup_identity(session)
|
||||
|
||||
assert exc_info.value.message == (
|
||||
"MongoDB Atlas identity setup failed - HTTP 502"
|
||||
)
|
||||
|
||||
def test_custom_message_does_not_leak_into_later_exceptions(self):
|
||||
MongoDBAtlasIdentityError(message="custom detail")
|
||||
|
||||
assert (
|
||||
MongoDBAtlasIdentityError().message == "MongoDB Atlas identity setup failed"
|
||||
)
|
||||
|
||||
def test_test_connection_success(self):
|
||||
"""Test successful connection test"""
|
||||
with (
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface OverviewGridProps {
|
||||
threatScore: ReactNode;
|
||||
// Cloud-only widgets: the page leaves them out where they do not apply.
|
||||
exploitability?: ReactNode;
|
||||
topIssues?: ReactNode;
|
||||
checkFindings: ReactNode;
|
||||
riskSeverity: ReactNode;
|
||||
resourcesInventory: ReactNode;
|
||||
complianceWatchlist: ReactNode;
|
||||
serviceWatchlist: ReactNode;
|
||||
attackSurface: ReactNode;
|
||||
severityOverTime: ReactNode;
|
||||
graphsTabs: ReactNode;
|
||||
}
|
||||
|
||||
/** The Overview widget layout, shared by the real page and the sample preview. */
|
||||
export function OverviewGrid({
|
||||
threatScore,
|
||||
exploitability,
|
||||
topIssues,
|
||||
checkFindings,
|
||||
riskSeverity,
|
||||
resourcesInventory,
|
||||
complianceWatchlist,
|
||||
serviceWatchlist,
|
||||
attackSurface,
|
||||
severityOverTime,
|
||||
graphsTabs,
|
||||
}: OverviewGridProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
{threatScore}
|
||||
{exploitability}
|
||||
{checkFindings}
|
||||
{riskSeverity}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{resourcesInventory}</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
{complianceWatchlist}
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
{serviceWatchlist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{attackSurface}
|
||||
{topIssues}
|
||||
{severityOverTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{graphsTabs}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_SERVICES_RESPONSE } from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewSamplePreview } from "./overview-sample-preview";
|
||||
|
||||
// Card links import server actions; their auth chain has no place in jsdom.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
// The graph tabs have their own test; here they would only add chart noise.
|
||||
vi.mock("./sample-graphs-tabs", () => ({
|
||||
SampleGraphsTabs: () => <div data-testid="sample-graphs-tabs" />,
|
||||
}));
|
||||
|
||||
describe("OverviewSamplePreview", () => {
|
||||
it("fills the Overview with the demo account's numbers", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
expect(screen.getByText("Findings Severity Over Time")).toBeVisible();
|
||||
expect(screen.getByText("Internet Exposed Resources")).toBeVisible();
|
||||
expect(screen.getByTestId("sample-graphs-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps every sample card inert, so nothing drills into missing data", () => {
|
||||
const { container } = render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
const links = Array.from(container.querySelectorAll("a"));
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
for (const link of links) {
|
||||
expect(link.closest("[inert]")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("lists every sample service in the watchlist", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
// The riskiest service of the demo account leads the watchlist.
|
||||
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
|
||||
(left, right) => right.attributes.fail - left.attributes.fail,
|
||||
)[0];
|
||||
expect(
|
||||
screen.getAllByText(new RegExp(riskiest.id, "i")).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
|
||||
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
|
||||
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
SAMPLE_ISSUES_OVERVIEW,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
SAMPLE_ATTACK_SURFACE_RESPONSE,
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
SAMPLE_FAILED_BY_SEVERITY,
|
||||
SAMPLE_RESOURCE_GROUPS_RESPONSE,
|
||||
SAMPLE_SERVICES_RESPONSE,
|
||||
SAMPLE_SEVERITY_TRENDS,
|
||||
SAMPLE_STATUS,
|
||||
SAMPLE_THREAT_SCORE,
|
||||
} from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewGrid } from "../_components/overview-grid";
|
||||
import { AttackSurface } from "../attack-surface/_components/attack-surface";
|
||||
import { ExploitabilityChart } from "../exploitability";
|
||||
import { TopIssues } from "../exploitability/_components/top-issues";
|
||||
import { ResourcesInventory } from "../resources-inventory/_components/resources-inventory";
|
||||
import { RiskSeverityChart } from "../risk-severity";
|
||||
import { FindingSeverityOverTime } from "../severity-over-time";
|
||||
import { StatusChart } from "../status-chart";
|
||||
import { ThreatScore } from "../threat-score";
|
||||
import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
// `contents` keeps each card a direct item of the grid's flex rows, while
|
||||
// `inert` stops links and drill-downs into data the tenant does not have.
|
||||
const Inert = ({ children }: { children: ReactNode }) => (
|
||||
<div inert className="contents">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NO_SCOPE: Record<string, string> = {};
|
||||
|
||||
interface OverviewSamplePreviewProps {
|
||||
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
|
||||
showIssues: boolean;
|
||||
}
|
||||
|
||||
/** The Overview filled with the demo account, for tenants without a scan. */
|
||||
export function OverviewSamplePreview({
|
||||
showIssues,
|
||||
}: OverviewSamplePreviewProps) {
|
||||
const complianceItems = adaptComplianceWatchlistResponse(
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
).map((item) => ({
|
||||
id: item.id,
|
||||
framework: item.complianceId,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
score: item.score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Inert>
|
||||
<ThreatScore
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
exploitability={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Inert>
|
||||
<ExploitabilityChart
|
||||
counts={verdictCounts(SAMPLE_ISSUES_OVERVIEW)}
|
||||
issues={SAMPLE_ISSUES_OVERVIEW.issues}
|
||||
findings={SAMPLE_ISSUES_OVERVIEW.findings}
|
||||
actionable={SAMPLE_ISSUES_OVERVIEW.actionable}
|
||||
coveredFindings={
|
||||
SAMPLE_ISSUES_OVERVIEW.not_exploitable_findings
|
||||
}
|
||||
scope={NO_SCOPE}
|
||||
/>
|
||||
</Inert>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<Inert>
|
||||
<StatusChart
|
||||
failFindingsData={{
|
||||
total: SAMPLE_STATUS.fail,
|
||||
new: SAMPLE_STATUS.failNew,
|
||||
}}
|
||||
passFindingsData={{
|
||||
total: SAMPLE_STATUS.pass,
|
||||
new: SAMPLE_STATUS.passNew,
|
||||
}}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
riskSeverity={
|
||||
<Inert>
|
||||
<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />
|
||||
</Inert>
|
||||
}
|
||||
resourcesInventory={
|
||||
<Inert>
|
||||
<ResourcesInventory
|
||||
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Inert>
|
||||
<ComplianceWatchlist items={complianceItems} hasWatchlist />
|
||||
</Inert>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<Inert>
|
||||
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
|
||||
</Inert>
|
||||
}
|
||||
attackSurface={
|
||||
<Inert>
|
||||
<AttackSurface
|
||||
items={adaptAttackSurfaceOverview(SAMPLE_ATTACK_SURFACE_RESPONSE)}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
topIssues={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Inert>
|
||||
<TopIssues issues={SAMPLE_ISSUES} scope={NO_SCOPE} />
|
||||
</Inert>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<Inert>
|
||||
<Card variant="base" className="flex h-full flex-1 flex-col">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Findings Severity Over Time</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col px-6">
|
||||
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Inert>
|
||||
}
|
||||
graphsTabs={<SampleGraphsTabs />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
describe("SampleGraphsTabs", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("shows the sample new failed findings in the default tab without fetching", () => {
|
||||
// Given
|
||||
const firstNewCheck = SAMPLE_CHECKS.find((check) => check.newFailed > 0)!;
|
||||
|
||||
// When
|
||||
render(<SampleGraphsTabs />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "New Findings", selected: true }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText(firstNewCheck.title).length).toBeGreaterThan(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { ThreatMap } from "@/components/graphs/threat-map";
|
||||
import { LinkToFindings } from "@/components/overview";
|
||||
import { LatestFindingsTable } from "@/components/overview/new-findings-table/table";
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import {
|
||||
SAMPLE_NEW_FINDINGS,
|
||||
SAMPLE_RADAR_DATA,
|
||||
SAMPLE_RISK_PLOT_POINTS,
|
||||
SAMPLE_SANKEY_DATA,
|
||||
SAMPLE_THREAT_MAP_DATA,
|
||||
} from "@/lib/sample-preview/sample-graphs";
|
||||
|
||||
import { GraphsTabsClient } from "../graphs-tabs/_components/graphs-tabs-client";
|
||||
import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
|
||||
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
|
||||
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
|
||||
|
||||
// Inert so sample rows and charts never open drill-downs or detail panels.
|
||||
const Inert = ({ children }: { children: ReactNode }) => (
|
||||
<div inert className="flex w-full flex-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
|
||||
findings: (
|
||||
<Inert>
|
||||
<div className="flex w-full flex-col">
|
||||
<LatestFindingsTable
|
||||
data={SAMPLE_NEW_FINDINGS}
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle>Latest New Failed Findings</CardTitle>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Showing the latest 10 sorted by severity
|
||||
</p>
|
||||
</div>
|
||||
<LinkToFindings />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"threat-map": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-hidden">
|
||||
<ThreatMap data={SAMPLE_THREAT_MAP_DATA} height={460} />
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"risk-radar": (
|
||||
<Inert>
|
||||
<RiskRadarViewClient data={SAMPLE_RADAR_DATA} />
|
||||
</Inert>
|
||||
),
|
||||
"risk-pipeline": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<SankeyChart
|
||||
data={SAMPLE_SANKEY_DATA}
|
||||
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
|
||||
height={460}
|
||||
/>
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"risk-plot": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<RiskPlotClient data={SAMPLE_RISK_PLOT_POINTS} />
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
};
|
||||
|
||||
export const SampleGraphsTabs = () => (
|
||||
<GraphsTabsClient tabsContent={SAMPLE_TABS_CONTENT} />
|
||||
);
|
||||
+7
-2
@@ -27,6 +27,8 @@ import { ScanStatusBadge } from "./scan-status-badge";
|
||||
|
||||
interface ScanListTableProps {
|
||||
scans: AttackPathScan[];
|
||||
/** Overrides the `scanId` search param, e.g. for the sample scan. */
|
||||
selectedScanId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
@@ -227,12 +229,15 @@ const getColumns = ({
|
||||
* Table displaying AWS account Attack Paths scans
|
||||
* Shows scan metadata and allows selection of completed scans
|
||||
*/
|
||||
export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
export const ScanListTable = ({
|
||||
scans,
|
||||
selectedScanId: selectedScanIdOverride,
|
||||
}: ScanListTableProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedScanId = searchParams.get("scanId");
|
||||
const selectedScanId = selectedScanIdOverride ?? searchParams.get("scanId");
|
||||
const pageSize = parsePageParam(
|
||||
searchParams.get("scanPageSize"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
|
||||
+8
-3
@@ -20,15 +20,19 @@ export interface UseAttackPathScansResult {
|
||||
* project forbids `useEffect` in components, but a reusable data hook is the
|
||||
* sanctioned place for a mount-time fetch when no fetching library is wired up.
|
||||
*/
|
||||
export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
export function useAttackPathScans(
|
||||
/** Sample scans: shown as loaded, and the API is never called. */
|
||||
sampleScans?: AttackPathScan[],
|
||||
): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>(sampleScans ?? []);
|
||||
const [scansLoading, setScansLoading] = useState(!sampleScans);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Silent background refresh for auto-refresh: never flips loading/error, so it
|
||||
// can't disrupt the visible view if it fails.
|
||||
const refreshScans = async () => {
|
||||
if (sampleScans) return;
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
@@ -43,6 +47,7 @@ export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
// the error view's Retry action. A successful empty result (`{ data: [] }`) is
|
||||
// not an error; only a missing payload or a thrown request is.
|
||||
const loadScans = async () => {
|
||||
if (sampleScans) return;
|
||||
setScansLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
|
||||
@@ -56,8 +56,13 @@ const getDefaultValues = (query?: AttackPathQuery) => {
|
||||
* Custom hook for managing query builder form state
|
||||
* Handles query selection, parameter validation, and form submission
|
||||
*/
|
||||
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
|
||||
export const useQueryBuilder = (
|
||||
availableQueries: AttackPathQuery[],
|
||||
initialQueryId: string | null = null,
|
||||
) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(
|
||||
initialQueryId,
|
||||
);
|
||||
|
||||
const getQueryById = (queryId: string | null) =>
|
||||
availableQueries.find((query) => query.id === queryId);
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
|
||||
|
||||
const actions = vi.hoisted(() => ({
|
||||
getAttackPathScans: vi.fn(),
|
||||
getAvailableQueries: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeCustomQuery: vi.fn(),
|
||||
getFindingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/attack-paths", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/attack-paths")>()),
|
||||
getAttackPathScans: actions.getAttackPathScans,
|
||||
getAvailableQueries: actions.getAvailableQueries,
|
||||
executeQuery: actions.executeQuery,
|
||||
executeCustomQuery: actions.executeCustomQuery,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/findings")>()),
|
||||
getFindingById: actions.getFindingById,
|
||||
}));
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
import AttackPathsPage from "./attack-paths-page";
|
||||
import { AttackPathPageHarness } from "./attack-paths-page.harness";
|
||||
|
||||
const SAMPLE_FIXTURE = {
|
||||
scans: [SAMPLE_ATTACK_PATHS.scan],
|
||||
scanId: SAMPLE_ATTACK_PATHS.scan.id,
|
||||
queries: [SAMPLE_ATTACK_PATHS.query],
|
||||
queryId: SAMPLE_ATTACK_PATHS.query.id,
|
||||
queryResult: SAMPLE_ATTACK_PATHS.result,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useGraphStore.getState().reset();
|
||||
resetLighthouseContextStore();
|
||||
window.history.replaceState({}, "", "/attack-paths");
|
||||
});
|
||||
|
||||
describe("Attack Paths sample preview", () => {
|
||||
it("shows the sample graph and keeps it off the API and Lighthouse", async () => {
|
||||
// Given
|
||||
await render(<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />);
|
||||
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
|
||||
|
||||
// When
|
||||
await vi.waitFor(
|
||||
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
await graph.clickNode("sample-s3-exports");
|
||||
await vi.waitFor(() =>
|
||||
expect(graph.getNodeById("sample-finding-public-bucket")).not.toBeNull(),
|
||||
);
|
||||
await graph.clickNode("sample-finding-public-bucket");
|
||||
await graph.executeQuery({ selectFirst: false });
|
||||
|
||||
// Then
|
||||
expect(graph.showsNodeLabel("bastion-01")).toBe(true);
|
||||
expect(
|
||||
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
|
||||
).toBe(true);
|
||||
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
|
||||
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
|
||||
expect(actions.executeQuery).not.toHaveBeenCalled();
|
||||
expect(actions.getFindingById).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions["attack-path-current"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "@/lib/attack-paths/group-graph";
|
||||
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { SampleAttackPaths } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -74,18 +75,24 @@ import {
|
||||
const SCROLL_CONTAINER_CLASS =
|
||||
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
|
||||
|
||||
export default function AttackPathsPage() {
|
||||
interface AttackPathsPageProps {
|
||||
/** Sample mode: renders this data, never calls the attack-paths API. */
|
||||
sample?: SampleAttackPaths;
|
||||
}
|
||||
|
||||
export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const scanId = searchParams.get("scanId");
|
||||
// The sample scan id stays out of the URL, so the real page never sees it.
|
||||
const scanId = sample ? sample.scan.id : searchParams.get("scanId");
|
||||
const graphState = useGraphState();
|
||||
const finding = useFindingDetails();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
|
||||
useAttackPathScans();
|
||||
useAttackPathScans(sample ? [sample.scan] : undefined);
|
||||
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesLoading, setQueriesLoading] = useState(!sample);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
@@ -94,13 +101,26 @@ export default function AttackPathsPage() {
|
||||
const hasResetRef = useRef(false);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>(
|
||||
sample ? [sample.query] : [],
|
||||
);
|
||||
// Snapshot of the executed query's outcome, so the graph's terminal node
|
||||
// reflects the query that produced the current graph (not a later selection).
|
||||
const [executedOutcome, setExecutedOutcome] =
|
||||
useState<AttackPathOutcome | null>(null);
|
||||
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
const queryBuilder = useQueryBuilder(queries, sample?.query.id ?? null);
|
||||
|
||||
// The sample has one precomputed result: "running" its query shows it.
|
||||
const showSampleGraph = (sampleData: SampleAttackPaths) => {
|
||||
graphState.updateGraphData(adaptQueryResultToGraphData(sampleData.result), {
|
||||
queryId: sampleData.query.id,
|
||||
queryLabel: sampleData.query.attributes.name,
|
||||
queryKind: ATTACK_PATH_QUERY_KIND.PREDEFINED,
|
||||
parameters: {},
|
||||
});
|
||||
setExecutedOutcome(sampleData.query.attributes.outcome ?? null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResetRef.current) {
|
||||
@@ -110,7 +130,8 @@ export default function AttackPathsPage() {
|
||||
}, [graphState]);
|
||||
|
||||
useEffect(() => {
|
||||
graphState.resetGraph();
|
||||
if (sample) showSampleGraph(sample);
|
||||
else graphState.resetGraph();
|
||||
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
|
||||
|
||||
// Poll while a scan is in flight so the page auto-advances when the graph is ready.
|
||||
@@ -126,6 +147,8 @@ export default function AttackPathsPage() {
|
||||
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
|
||||
|
||||
useEffect(() => {
|
||||
if (sample) return;
|
||||
|
||||
const loadQueries = async () => {
|
||||
if (!scanId) {
|
||||
setQueriesError("No scan selected");
|
||||
@@ -168,7 +191,7 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
}, [sample, scanId, toast]);
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
@@ -179,6 +202,11 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (sample) {
|
||||
showSampleGraph(sample);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanId || !queryBuilder.selectedQuery) {
|
||||
showErrorToast("Error", "Please select both a scan and a query");
|
||||
return;
|
||||
@@ -320,10 +348,13 @@ export default function AttackPathsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
// Open finding drawer directly, bypassing the node-details modal.
|
||||
graphState.enterFilteredView(node.id);
|
||||
graphState.selectNode(null); // clear so node-details modal doesn't open first
|
||||
// Sample findings have no API record: focus the path, open no drawer.
|
||||
if (sample) return;
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
void handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
@@ -406,24 +437,26 @@ export default function AttackPathsPage() {
|
||||
graphState.selectedNode ?? graphState.filteredNode;
|
||||
const lighthouseGraphData = graphState.fullData ?? graphState.data;
|
||||
const lighthouseExecution = graphState.loading ? null : graphState.execution;
|
||||
const lighthouseContext = scanId
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
// Sample data never reaches Lighthouse.
|
||||
const lighthouseContext =
|
||||
scanId && !sample
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -465,9 +498,12 @@ export default function AttackPathsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
</Suspense>
|
||||
{/* The sample scan is the only one: nothing to pick or page. */}
|
||||
<div inert={Boolean(sample)}>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} selectedScanId={sample?.scan.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{isViewingPreviousCycleData && (
|
||||
<StatusAlert
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
export { default } from "./(workflow)/query-builder/attack-paths-page";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
|
||||
import AttackPathsPage from "./(workflow)/query-builder/attack-paths-page";
|
||||
|
||||
export default async function AttackPathsRoute() {
|
||||
// Decided on the server, so the sample never flashes in after the real page.
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (!samplePreview.active) return <AttackPathsPage />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ATTACK_PATHS}
|
||||
/>
|
||||
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
export const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceSamplePreview } from "./compliance-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/compliance",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("ComplianceSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("renders the Overview watchlist frameworks as inert cards without fetching", () => {
|
||||
render(<ComplianceSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const cards = screen.getAllByRole("button", {
|
||||
name: /compliance details$/,
|
||||
});
|
||||
expect(cards).toHaveLength(
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
|
||||
);
|
||||
|
||||
const cis = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.find(
|
||||
(item) => item.attributes.compliance_id === "cis_5.0_aws",
|
||||
)!.attributes;
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
|
||||
).toHaveTextContent(
|
||||
`${cis.requirements_passed} / ${cis.total_requirements}`,
|
||||
);
|
||||
|
||||
expect(cards[0].closest("[inert]")).not.toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ThreatScoreBadge } from "@/components/compliance";
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import {
|
||||
SAMPLE_COMPLIANCE_OVERVIEWS,
|
||||
SAMPLE_SCAN,
|
||||
} from "@/lib/sample-preview/sample-compliance";
|
||||
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
|
||||
|
||||
interface ComplianceSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
}
|
||||
|
||||
/** Sample per-scan compliance: the Overview watchlist frameworks, inert. */
|
||||
export function ComplianceSamplePreview({
|
||||
state,
|
||||
}: ComplianceSamplePreviewProps) {
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={state}
|
||||
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
|
||||
/>
|
||||
<div inert>
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
provider={SAMPLE_SCAN.providerInfo.provider}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceFrameworkGrid>
|
||||
{SAMPLE_COMPLIANCE_OVERVIEWS.map(({ id, attributes }) => (
|
||||
<div key={id} className="h-full [&>*]:h-full">
|
||||
<ComplianceCard
|
||||
title={attributes.framework}
|
||||
version={attributes.version}
|
||||
passingRequirements={attributes.requirements_passed}
|
||||
totalRequirements={attributes.total_requirements}
|
||||
prevPassingRequirements={attributes.requirements_passed}
|
||||
prevTotalRequirements={attributes.total_requirements}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ComplianceFrameworkGrid>
|
||||
</ComplianceOverviewPanel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_PREVIEW_STAGE,
|
||||
type SamplePreviewState,
|
||||
} from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import Compliance from "./page";
|
||||
|
||||
const {
|
||||
@@ -19,6 +24,7 @@ const {
|
||||
complianceOverviewGridSpy,
|
||||
getComplianceOverviewMetadataInfoMock,
|
||||
getCompliancesOverviewMock,
|
||||
getSamplePreviewStateMock,
|
||||
getScanMock,
|
||||
getScansMock,
|
||||
getThreatScoreMock,
|
||||
@@ -29,6 +35,9 @@ const {
|
||||
complianceOverviewGridSpy: vi.fn(),
|
||||
getComplianceOverviewMetadataInfoMock: vi.fn(),
|
||||
getCompliancesOverviewMock: vi.fn(),
|
||||
getSamplePreviewStateMock: vi.fn(
|
||||
async (): Promise<SamplePreviewState> => ({ active: false }),
|
||||
),
|
||||
getScanMock: vi.fn(),
|
||||
getScansMock: vi.fn(),
|
||||
getThreatScoreMock: vi.fn(),
|
||||
@@ -52,6 +61,14 @@ vi.mock("@/actions/scans", () => ({
|
||||
getScansByState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState: getSamplePreviewStateMock,
|
||||
}));
|
||||
|
||||
vi.mock("./_components/compliance-sample-preview", () => ({
|
||||
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
@@ -155,6 +172,30 @@ describe("Compliance overview page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance sample preview", () => {
|
||||
it("renders the sample and skips the real fetches until the first scan completes", async () => {
|
||||
// Given - a Cloud tenant whose first scan has not completed yet
|
||||
getSamplePreviewStateMock.mockResolvedValue({
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
(await Compliance({
|
||||
searchParams: Promise.resolve({}),
|
||||
})) as ReactElement,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Sample compliance preview")).toBeInTheDocument();
|
||||
expect(getScansMock).not.toHaveBeenCalled();
|
||||
expect(getCompliancesOverviewMock).not.toHaveBeenCalled();
|
||||
expect(loadComplianceWatchlistContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance overview task response", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -17,10 +17,10 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import { isReportDownloadLocked } from "@/lib/report-download-access";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
} from "@/types";
|
||||
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./_components/compliance-overview-panel";
|
||||
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
|
||||
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
|
||||
import { ComplianceSamplePreview } from "./_components/compliance-sample-preview";
|
||||
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
|
||||
import { CrossProviderOverview } from "./_components/cross-provider-overview";
|
||||
import {
|
||||
@@ -71,6 +73,16 @@ export default async function Compliance({
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces both tabs until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
|
||||
<ComplianceSamplePreview state={preview} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
@@ -409,19 +421,3 @@ const ComplianceWatchlistControls = async ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_FAILED_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { FindingsSamplePreview } from "./findings-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock, resolveFindingIdsMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
resolveFindingIdsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/findings",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
vi.mock("@/actions/findings/findings-by-resource", () => ({
|
||||
resolveFindingIdsByVisibleGroupResources: resolveFindingIdsMock,
|
||||
}));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("FindingsSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("renders every failed sample check in an inert table without fetching", () => {
|
||||
const { container } = render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const publicBucket = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.checkId === "s3_bucket_public_access",
|
||||
)!;
|
||||
const row = screen
|
||||
.getByText(publicBucket.title)
|
||||
.closest("tr") as HTMLElement;
|
||||
// Impacted resources: failed of failed + passed.
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed)),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed + publicBucket.passed)),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const table = container.querySelector("table") as HTMLElement;
|
||||
expect(within(table).getAllByRole("row", { hidden: true })).toHaveLength(
|
||||
SAMPLE_FAILED_CHECKS.length + 1,
|
||||
);
|
||||
expect(table.closest("[inert]")).not.toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
expect(resolveFindingIdsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FindingsGroupTable } from "@/components/findings/table";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { SAMPLE_FINDING_GROUPS } from "@/lib/sample-preview/sample-findings";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
const NO_FILTERS: Record<string, string> = {};
|
||||
|
||||
interface FindingsSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
}
|
||||
|
||||
/** Sample finding groups: no filters, no metadata (so no Lighthouse context), inert rows. */
|
||||
export function FindingsSamplePreview({ state }: FindingsSamplePreviewProps) {
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
|
||||
<div inert>
|
||||
<FindingsGroupTable
|
||||
data={SAMPLE_FINDING_GROUPS}
|
||||
resolvedFilters={NO_FILTERS}
|
||||
hasHistoricalData={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,17 +24,29 @@ import {
|
||||
isFindingsPreviewLocked,
|
||||
} from "@/lib/findings-access";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
import { FindingsFiltersSection } from "./_components/findings-filters-section";
|
||||
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
|
||||
import { FindingsSamplePreview } from "./_components/findings-sample-preview";
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces the page (and its paywall) until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Findings" icon={<Tag />}>
|
||||
<FindingsSamplePreview state={preview} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getIssues, getIssuesOverview } from "@/actions/issues";
|
||||
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
|
||||
import { VERDICT } from "@/types/issues";
|
||||
|
||||
import { IssuesList } from "./issues-list";
|
||||
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssues: vi.fn(),
|
||||
getIssuesOverview: vi.fn(),
|
||||
}));
|
||||
// Modules that reach next-auth under jsdom; sample rows never open the panel.
|
||||
vi.mock("@/components/issues/issue-panel", () => ({ IssuePanel: () => null }));
|
||||
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
|
||||
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/issues",
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const [exploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.EXPLOITABLE,
|
||||
);
|
||||
const [notExploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
|
||||
describe("IssuesList sample preview", () => {
|
||||
it("renders the sample issues inert, filtered by verdict, without the API", async () => {
|
||||
// When
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[verdict]": VERDICT.EXPLOITABLE },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
const title = screen.getByRole("button", {
|
||||
name: exploitable.attributes.title,
|
||||
});
|
||||
expect(title.closest("[inert]")).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(notExploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
expect(getIssues).not.toHaveBeenCalled();
|
||||
expect(getIssuesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches nothing for a scope the demo account is not in", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[cloud]": "gcp" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText(exploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
// The summary is scoped too, not just the rows.
|
||||
expect(screen.getByLabelText("All, 0 issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches every verdict of a verdict__in list", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict__in]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: exploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: notExploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
const outsideList = SAMPLE_ISSUES.filter(
|
||||
(issue) =>
|
||||
issue.attributes.verdict !== VERDICT.EXPLOITABLE &&
|
||||
issue.attributes.verdict !== VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
expect(outsideList.length).toBeGreaterThan(0);
|
||||
for (const issue of outsideList) {
|
||||
expect(
|
||||
screen.queryByText(issue.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a scalar verdict filter as one literal value", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("scopes the rows by severity like the API", async () => {
|
||||
const critical = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.severity === "critical",
|
||||
);
|
||||
const other = SAMPLE_ISSUES.find(
|
||||
(issue) => issue.attributes.severity !== "critical",
|
||||
);
|
||||
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[severity]": "critical" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const issue of critical) {
|
||||
expect(
|
||||
screen.getByRole("button", { name: issue.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(other!.attributes.title)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,13 @@ import { IssuesTable } from "@/components/issues/issues-table";
|
||||
import { VerdictFilter } from "@/components/issues/verdict-filter";
|
||||
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
summarizeSampleIssues,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import type { IssueResponse, IssuesOverviewResponse } from "@/types/issues";
|
||||
|
||||
/** Only the filters the issues endpoint understands are forwarded. */
|
||||
const ISSUE_FILTER_KEYS = [
|
||||
@@ -21,14 +27,17 @@ const ISSUE_FILTER_KEYS = [
|
||||
"filter[triage_status]",
|
||||
"filter[triage_status__in]",
|
||||
...ISSUE_SCOPE_KEYS,
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const IssuesList = async ({
|
||||
searchParams,
|
||||
filters: filterControls,
|
||||
providerUids,
|
||||
sample = false,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
/** Renders the sample issues instead of calling the issues API. */
|
||||
sample?: boolean;
|
||||
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
|
||||
filters?: ReactNode;
|
||||
/** Provider uid by provider id, for the issue panel. */
|
||||
@@ -48,16 +57,20 @@ export const IssuesList = async ({
|
||||
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
|
||||
),
|
||||
);
|
||||
const [summary, issues] = await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string" ? searchParams.sort : undefined,
|
||||
}),
|
||||
]);
|
||||
const [summary, issues] = sample
|
||||
? sampleIssues(filters, scope)
|
||||
: await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string"
|
||||
? searchParams.sort
|
||||
: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const attributes = summary?.data?.attributes;
|
||||
// handleApiResponse resolves {error, status} on a 4xx and undefined on a
|
||||
@@ -90,11 +103,14 @@ export const IssuesList = async ({
|
||||
{rows === null ? (
|
||||
<IssuesLoadError />
|
||||
) : rows.length ? (
|
||||
<IssuesTable
|
||||
issues={rows}
|
||||
metadata={issues?.meta}
|
||||
providerUids={providerUids}
|
||||
/>
|
||||
// Sample rows have no API record: inert, so no side panel or triage.
|
||||
<div inert={sample}>
|
||||
<IssuesTable
|
||||
issues={rows}
|
||||
metadata={issues?.meta}
|
||||
providerUids={providerUids}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No issues match this filter.
|
||||
@@ -103,3 +119,61 @@ export const IssuesList = async ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SampleIssue = (typeof SAMPLE_ISSUES)[number];
|
||||
|
||||
/** The value each forwarded filter matches on a sample issue. */
|
||||
const SAMPLE_FILTER_FIELDS: Record<
|
||||
(typeof ISSUE_FILTER_KEYS)[number],
|
||||
(issue: SampleIssue) => string | undefined
|
||||
> = {
|
||||
"filter[verdict]": (issue) => issue.attributes.verdict,
|
||||
"filter[verdict__in]": (issue) => issue.attributes.verdict,
|
||||
"filter[cloud]": (issue) => issue.attributes.cloud,
|
||||
"filter[pattern]": (issue) => issue.attributes.pattern,
|
||||
"filter[severity]": (issue) => issue.attributes.severity,
|
||||
"filter[provider_type]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_type__in]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_alias]": () => SAMPLE_ACCOUNT.alias,
|
||||
"filter[provider_id__in]": (issue) => issue.relationships?.provider?.data?.id,
|
||||
// The sample account belongs to no provider group.
|
||||
"filter[provider_groups__in]": () => undefined,
|
||||
"filter[triage_status]": (issue) => issue.attributes.triage_status,
|
||||
"filter[triage_status__in]": (issue) => issue.attributes.triage_status,
|
||||
};
|
||||
|
||||
const matchesSampleFilters = (
|
||||
issue: SampleIssue,
|
||||
filters: Record<string, string>,
|
||||
) =>
|
||||
Object.entries(filters).every(([key, value]) => {
|
||||
const field =
|
||||
SAMPLE_FILTER_FIELDS[key as keyof typeof SAMPLE_FILTER_FIELDS];
|
||||
const actual = field?.(issue);
|
||||
// Only `__in` filters take a list; the rest match one literal value.
|
||||
const accepted = key.endsWith("__in]") ? value.split(",") : [value];
|
||||
return actual !== undefined && accepted.includes(actual);
|
||||
});
|
||||
|
||||
/** The sample summary and rows, filtered like the API would. */
|
||||
function sampleIssues(
|
||||
filters: Record<string, string>,
|
||||
scope: Record<string, string>,
|
||||
): [IssuesOverviewResponse, IssueResponse] {
|
||||
const scoped = SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, scope),
|
||||
);
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
type: "issues-overview",
|
||||
attributes: summarizeSampleIssues(scoped),
|
||||
},
|
||||
},
|
||||
{
|
||||
data: SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, filters),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
|
||||
const {
|
||||
getAllProviders,
|
||||
getAllProviderGroups,
|
||||
getSamplePreviewState,
|
||||
notFound,
|
||||
} = vi.hoisted(() => ({
|
||||
getAllProviders: vi.fn(),
|
||||
getAllProviderGroups: vi.fn(),
|
||||
getSamplePreviewState: vi.fn(),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NOT_FOUND");
|
||||
}),
|
||||
@@ -22,6 +28,12 @@ vi.mock("@/components/shadcn/content-layout", () => ({
|
||||
ContentLayout: () => null,
|
||||
}));
|
||||
vi.mock("./_components/issues-list", () => ({ IssuesList: () => null }));
|
||||
vi.mock("@/components/shared/sample-preview/sample-preview-banner", () => ({
|
||||
SamplePreviewBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState,
|
||||
}));
|
||||
|
||||
import Issues from "./page";
|
||||
|
||||
@@ -29,6 +41,7 @@ describe("Issues deployment availability", () => {
|
||||
beforeEach(() => {
|
||||
getAllProviders.mockResolvedValue({ data: [] });
|
||||
getAllProviderGroups.mockResolvedValue({ data: [] });
|
||||
getSamplePreviewState.mockResolvedValue({ active: false });
|
||||
});
|
||||
it("returns not found before loading data in Local Server", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
@@ -45,4 +58,15 @@ describe("Issues deployment availability", () => {
|
||||
expect(getAllProviders).toHaveBeenCalledOnce();
|
||||
expect(getAllProviderGroups).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("skips the scope controls' data while the sample is shown", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getSamplePreviewState.mockResolvedValue({
|
||||
active: true,
|
||||
stage: "no_provider",
|
||||
runningScan: null,
|
||||
});
|
||||
await Issues({ searchParams: Promise.resolve({}) });
|
||||
expect(getAllProviders).not.toHaveBeenCalled();
|
||||
expect(getAllProviderGroups).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@ import { ProviderAccountSelectors } from "@/components/filters/provider-account-
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -22,6 +25,22 @@ export default async function Issues({
|
||||
if (!isCloud()) notFound();
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Issues" icon={<ShieldAlert />}>
|
||||
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ISSUES}
|
||||
/>
|
||||
{/* No scope selectors: the sample account is not one of theirs. */}
|
||||
<IssuesList searchParams={resolvedSearchParams} sample />
|
||||
</FeatureFlagRoute>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [providersData, providerGroupsData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
|
||||
@@ -38,8 +38,12 @@ describe("Overview page", () => {
|
||||
|
||||
it("uses the responsive container breakpoint for the primary metric row", () => {
|
||||
// Given
|
||||
const metricRowClassName = source.match(
|
||||
/<div className="([^"]+)">\s*<Suspense fallback={<ThreatScoreSkeleton \/>}>/,
|
||||
const gridSource = readFileSync(
|
||||
path.join(currentDir, "_overview/_components/overview-grid.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const metricRowClassName = gridSource.match(
|
||||
/<div className="([^"]+)">\s*\{threatScore\}/,
|
||||
)?.[1];
|
||||
|
||||
// When
|
||||
|
||||
+85
-71
@@ -12,12 +12,16 @@ import {
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { OverviewBanner } from "./_overview/_components/overview-banner";
|
||||
import { OverviewGrid } from "./_overview/_components/overview-grid";
|
||||
import { OverviewProviderContext } from "./_overview/_components/overview-provider-context";
|
||||
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
RiskSeverityChartSkeleton,
|
||||
RiskSeverityChartSSR,
|
||||
} from "./_overview/risk-severity";
|
||||
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
|
||||
import {
|
||||
FindingSeverityOverTimeSkeleton,
|
||||
FindingSeverityOverTimeSSR,
|
||||
@@ -60,12 +65,32 @@ export default async function Home({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const [providersData, providerGroupsData, lighthouseBannerHref] =
|
||||
await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
// Resolved alongside the page data, so tenants with scans wait no longer.
|
||||
const [
|
||||
samplePreview,
|
||||
providersData,
|
||||
providerGroupsData,
|
||||
lighthouseBannerHref,
|
||||
] = await Promise.all([
|
||||
getSamplePreviewState(),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
|
||||
// No completed scan yet: the demo account stands in for the tenant's data.
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
/>
|
||||
<OverviewSamplePreview showIssues={isCloud()} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
@@ -99,82 +124,71 @@ export default async function Home({
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
{isCloud() && (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
exploitability={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
riskSeverity={
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
resourcesInventory={
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
attackSurface={
|
||||
<Suspense fallback={<AttackSurfaceSkeleton />}>
|
||||
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
{isCloud() && (
|
||||
}
|
||||
topIssues={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<TopIssuesSkeleton />}>
|
||||
<TopIssuesSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
graphsTabs={
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_PREVIEW_EVENT,
|
||||
SAMPLE_PREVIEW_PAGE,
|
||||
SAMPLE_PREVIEW_STEP,
|
||||
} from "@/lib/sample-preview/sample-preview-events";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import {
|
||||
SAMPLE_PREVIEW_POLL_INTERVAL_MS,
|
||||
SamplePreviewBanner,
|
||||
} from "./sample-preview-banner";
|
||||
|
||||
const { getScanMock, refreshMock } = vi.hoisted(() => ({
|
||||
getScanMock: vi.fn(),
|
||||
refreshMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock }),
|
||||
}));
|
||||
|
||||
const RUNNING = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.SCAN_RUNNING,
|
||||
runningScan: { id: "scan-1", progress: 10 },
|
||||
};
|
||||
|
||||
const scanResponse = (state: string, progress: number) => ({
|
||||
data: { id: "scan-1", attributes: { state, progress } },
|
||||
});
|
||||
|
||||
const advancePoll = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(SAMPLE_PREVIEW_POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
describe("SamplePreviewBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("points a tenant without providers at the add-provider wizard", () => {
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={{
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
|
||||
runningScan: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("You are exploring sample data")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Add Provider" })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("addProviderSource=sample_preview"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the running scan's progress as it advances", async () => {
|
||||
getScanMock.mockResolvedValue(scanResponse("executing", 57));
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={RUNNING}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("10%")).toBeVisible();
|
||||
|
||||
await advancePoll();
|
||||
|
||||
expect(screen.getByText("57%")).toBeVisible();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps in the real data when the first scan completes", async () => {
|
||||
getScanMock.mockResolvedValue(scanResponse("completed", 100));
|
||||
const events: string[] = [];
|
||||
const listener = (event: Event) =>
|
||||
events.push((event as CustomEvent<{ step: string }>).detail.step);
|
||||
window.addEventListener(SAMPLE_PREVIEW_EVENT, listener);
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={RUNNING}
|
||||
/>,
|
||||
);
|
||||
|
||||
await advancePoll();
|
||||
await advancePoll();
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual([
|
||||
SAMPLE_PREVIEW_STEP.VIEWED,
|
||||
SAMPLE_PREVIEW_STEP.EXITED,
|
||||
]);
|
||||
window.removeEventListener(SAMPLE_PREVIEW_EVENT, listener);
|
||||
});
|
||||
|
||||
it("re-resolves the page when the tracked scan is gone", async () => {
|
||||
getScanMock.mockResolvedValue({ error: "Not found", status: 404 });
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={RUNNING}
|
||||
/>,
|
||||
);
|
||||
|
||||
await advancePoll();
|
||||
await advancePoll();
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(getScanMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps polling through a transient empty response", async () => {
|
||||
getScanMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValue(scanResponse("completed", 100));
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={RUNNING}
|
||||
/>,
|
||||
);
|
||||
|
||||
await advancePoll();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
|
||||
await advancePoll();
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips a poll while the previous request is still pending", async () => {
|
||||
getScanMock.mockReturnValue(new Promise(() => {}));
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={RUNNING}
|
||||
/>,
|
||||
);
|
||||
|
||||
await advancePoll();
|
||||
await advancePoll();
|
||||
|
||||
expect(getScanMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the in-progress scans tab from a running scan", () => {
|
||||
getScanMock.mockReturnValue(new Promise(() => {}));
|
||||
render(
|
||||
<SamplePreviewBanner
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
state={RUNNING}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "View Scan" })).toHaveAttribute(
|
||||
"href",
|
||||
"/scans?tab=active",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getScan } from "@/actions/scans";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Progress,
|
||||
} from "@/components/shadcn";
|
||||
import { toast } from "@/components/shadcn/toast";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { WIZARD_OPEN_SOURCE } from "@/lib/provider-funnel/provider-funnel-events";
|
||||
import { buildAddProviderHref } from "@/lib/providers-navigation";
|
||||
import {
|
||||
dispatchSamplePreview,
|
||||
SAMPLE_PREVIEW_CTA,
|
||||
SAMPLE_PREVIEW_STEP,
|
||||
type SamplePreviewCta,
|
||||
type SamplePreviewPage,
|
||||
} from "@/lib/sample-preview/sample-preview-events";
|
||||
import {
|
||||
SAMPLE_PREVIEW_STAGE,
|
||||
type SamplePreviewActive,
|
||||
type SamplePreviewStage,
|
||||
} from "@/lib/sample-preview/sample-preview-state";
|
||||
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
import { SCAN_JOBS_TAB } from "@/types/scans";
|
||||
|
||||
export const SAMPLE_PREVIEW_POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
interface StageContent {
|
||||
title: string;
|
||||
description: string;
|
||||
cta: SamplePreviewCta;
|
||||
ctaLabel: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const STAGE_CONTENT = {
|
||||
[SAMPLE_PREVIEW_STAGE.NO_PROVIDER]: {
|
||||
title: "You are exploring sample data",
|
||||
description:
|
||||
"It comes from a demo AWS account. Connect your cloud account to see your own findings here.",
|
||||
cta: SAMPLE_PREVIEW_CTA.ADD_PROVIDER,
|
||||
ctaLabel: "Add Provider",
|
||||
href: buildAddProviderHref(WIZARD_OPEN_SOURCE.SAMPLE_PREVIEW),
|
||||
},
|
||||
[SAMPLE_PREVIEW_STAGE.NO_SCAN]: {
|
||||
title: "You are exploring sample data",
|
||||
description:
|
||||
"It comes from a demo AWS account. Launch a scan to see your own findings here.",
|
||||
cta: SAMPLE_PREVIEW_CTA.LAUNCH_SCAN,
|
||||
ctaLabel: "Launch Scan",
|
||||
href: LAUNCH_SCAN_HREF,
|
||||
},
|
||||
[SAMPLE_PREVIEW_STAGE.SCAN_RUNNING]: {
|
||||
title: "Your first scan is running",
|
||||
description:
|
||||
"Meanwhile, explore sample data from a demo AWS account. Your results replace it as soon as the scan finishes.",
|
||||
cta: SAMPLE_PREVIEW_CTA.VIEW_SCAN,
|
||||
ctaLabel: "View Scan",
|
||||
href: `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
|
||||
},
|
||||
} as const satisfies Record<SamplePreviewStage, StageContent>;
|
||||
|
||||
interface SamplePreviewBannerProps {
|
||||
state: SamplePreviewActive;
|
||||
page: SamplePreviewPage;
|
||||
}
|
||||
|
||||
export function SamplePreviewBanner({ state, page }: SamplePreviewBannerProps) {
|
||||
// A new stage or scan after a refresh restarts the tracking and polling.
|
||||
return (
|
||||
<SamplePreviewBannerContent
|
||||
key={`${state.stage}-${state.runningScan?.id ?? "none"}`}
|
||||
state={state}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SamplePreviewBannerContent({ state, page }: SamplePreviewBannerProps) {
|
||||
const router = useRouter();
|
||||
const { stage, runningScan } = state;
|
||||
// Local state needed: polling updates the progress between server renders.
|
||||
const [progress, setProgress] = useState(runningScan?.progress ?? 0);
|
||||
const content = STAGE_CONTENT[stage];
|
||||
|
||||
useMountEffect(() => {
|
||||
dispatchSamplePreview({ step: SAMPLE_PREVIEW_STEP.VIEWED, page, stage });
|
||||
|
||||
if (!runningScan) return;
|
||||
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
const poll = async () => {
|
||||
// A slow response must not overwrite a newer one.
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
const response = await getScan(runningScan.id).finally(() => {
|
||||
inFlight = false;
|
||||
});
|
||||
if (stopped) return;
|
||||
|
||||
// The scan is gone (e.g. its provider was deleted): re-resolve the page.
|
||||
if (response?.status === 404) {
|
||||
stop();
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = response?.data?.attributes;
|
||||
if (!attributes) return;
|
||||
|
||||
if (attributes.state === SCAN_STATES.COMPLETED) {
|
||||
stop();
|
||||
dispatchSamplePreview({
|
||||
step: SAMPLE_PREVIEW_STEP.EXITED,
|
||||
page,
|
||||
stage,
|
||||
});
|
||||
toast({
|
||||
title: "Your scan results are ready",
|
||||
description: "The sample data has been replaced with your own.",
|
||||
});
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (attributes.state === SCAN_STATES.FAILED) {
|
||||
stop();
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(attributes.progress ?? 0);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(poll, SAMPLE_PREVIEW_POLL_INTERVAL_MS);
|
||||
function stop() {
|
||||
stopped = true;
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
return stop;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card variant="base" padding="none" className="mb-6">
|
||||
<CardContent className="flex flex-col gap-4 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-md border">
|
||||
<FlaskConical aria-hidden="true" className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-text-neutral-primary text-sm font-medium">
|
||||
{content.title}
|
||||
</p>
|
||||
<Badge variant="info" size="sm">
|
||||
Sample data
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{content.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
{runningScan ? (
|
||||
<div className="flex w-40 items-center gap-2">
|
||||
<Progress value={progress} aria-label="First scan progress" />
|
||||
<span className="text-text-neutral-secondary text-sm tabular-nums">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={runningScan ? "outline" : "default"}
|
||||
>
|
||||
<Link
|
||||
href={content.href}
|
||||
onClick={() =>
|
||||
dispatchSamplePreview({
|
||||
step: SAMPLE_PREVIEW_STEP.CTA_CLICKED,
|
||||
page,
|
||||
stage,
|
||||
cta: content.cta,
|
||||
})
|
||||
}
|
||||
>
|
||||
{content.ctaLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ describe("FEATURE_FLAG", () => {
|
||||
ISSUES: "issues-feature",
|
||||
BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
|
||||
FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
|
||||
SAMPLE_DATA_PREVIEW: "sample-data-preview",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export const FEATURE_FLAG = {
|
||||
// Boolean flag evaluated server-side per tenant in cloud. When on, trial
|
||||
// tenants only preview the most critical finding groups on /findings.
|
||||
FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
|
||||
// Boolean flag evaluated server-side per tenant in cloud. When on, tenants
|
||||
// without a completed scan see the demo account's data on their pages.
|
||||
SAMPLE_DATA_PREVIEW: "sample-data-preview",
|
||||
} as const;
|
||||
|
||||
export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG];
|
||||
|
||||
@@ -5,7 +5,7 @@ export const ISSUE_SCOPE_KEYS = [
|
||||
"filter[provider_id__in]",
|
||||
"filter[provider_type__in]",
|
||||
"filter[provider_groups__in]",
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const pickIssueScope = (
|
||||
params: SearchParamsProps | undefined | null,
|
||||
|
||||
@@ -35,6 +35,7 @@ export const WIZARD_OPEN_SOURCE = {
|
||||
PAGE_BUTTON: "page_button",
|
||||
EMPTY_STATE: "empty_state",
|
||||
ROW_ACTION: "row_action",
|
||||
SAMPLE_PREVIEW: "sample_preview",
|
||||
} as const;
|
||||
|
||||
export type WizardOpenSource =
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Whether tenants without a completed scan see sample data on their pages.
|
||||
* Self-hosted deployments never show it; the Prowler Cloud overlay replaces
|
||||
* this body with its feature-flag lookup.
|
||||
*/
|
||||
export const isSamplePreviewEnabled = (): Promise<boolean> =>
|
||||
Promise.resolve(false);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getScans, getScansByState } from "@/actions/scans";
|
||||
import { isSamplePreviewEnabled } from "@/lib/sample-preview-access";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
|
||||
import {
|
||||
resolveSamplePreviewState,
|
||||
type RunningScan,
|
||||
type SamplePreviewState,
|
||||
} from "./sample-preview-state";
|
||||
|
||||
interface ScanRow {
|
||||
id: string;
|
||||
attributes: { progress?: number | null };
|
||||
}
|
||||
|
||||
const hasRows = (response: unknown): boolean | undefined => {
|
||||
const data = (response as { data?: unknown } | undefined)?.data;
|
||||
return Array.isArray(data) ? data.length > 0 : undefined;
|
||||
};
|
||||
|
||||
// Undefined when the lookup failed, so the page doesn't offer a scan that may be running.
|
||||
const toRunningScan = (response: unknown): RunningScan | null | undefined => {
|
||||
const data = (response as { data?: ScanRow[] } | undefined)?.data;
|
||||
if (!Array.isArray(data)) return undefined;
|
||||
const scan = data[0];
|
||||
if (!scan) return null;
|
||||
return { id: scan.id, progress: scan.attributes.progress ?? 0 };
|
||||
};
|
||||
|
||||
/** Resolved once per request, so every page section shares one answer. */
|
||||
export const getSamplePreviewState = cache(
|
||||
async (): Promise<SamplePreviewState> => {
|
||||
const enabled = await isSamplePreviewEnabled();
|
||||
if (!enabled) return { active: false };
|
||||
|
||||
const [completedScans, providers, runningScans] = await Promise.all([
|
||||
getScansByState(),
|
||||
getProviders({ page: 1, pageSize: 1 }),
|
||||
getScans({
|
||||
pageSize: 1,
|
||||
sort: "-inserted_at",
|
||||
filters: {
|
||||
"filter[state__in]": `${SCAN_STATES.AVAILABLE},${SCAN_STATES.EXECUTING}`,
|
||||
},
|
||||
fields: { scans: "state,progress" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return resolveSamplePreviewState({
|
||||
enabled,
|
||||
hasCompletedScan: hasRows(completedScans),
|
||||
hasProviders: hasRows(providers),
|
||||
runningScan: toRunningScan(runningScans),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
AttackPathScan,
|
||||
GraphNode,
|
||||
GraphRelationship,
|
||||
QueryResultAttributes,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
import { SAMPLE_ACCOUNT, SAMPLE_CHECKS } from "./sample-dataset";
|
||||
|
||||
export interface SampleAttackPaths {
|
||||
scan: AttackPathScan;
|
||||
query: AttackPathQuery;
|
||||
result: QueryResultAttributes;
|
||||
}
|
||||
|
||||
const SAMPLE_REGION = "us-east-1";
|
||||
const SAMPLE_SCAN_COMPLETED_AT = SAMPLE_ACCOUNT.scannedAt;
|
||||
const SAMPLE_SCAN_STARTED_AT = "2026-10-28T09:04:00Z";
|
||||
|
||||
const arn = (service: string, resource: string, region = SAMPLE_REGION) =>
|
||||
`arn:aws:${service}:${region}:${SAMPLE_ACCOUNT.uid}:${resource}`;
|
||||
|
||||
const resource = (
|
||||
id: string,
|
||||
label: string,
|
||||
properties: GraphNode["properties"],
|
||||
): GraphNode => ({ id, labels: [label], properties: { id, ...properties } });
|
||||
|
||||
// Finding titles and severities come from SAMPLE_CHECKS, like the other pages.
|
||||
const finding = (id: string, checkId: string): GraphNode => {
|
||||
const check = SAMPLE_CHECKS.find((sample) => sample.checkId === checkId);
|
||||
return {
|
||||
id,
|
||||
labels: ["ProwlerFinding"],
|
||||
properties: {
|
||||
id,
|
||||
check_id: checkId,
|
||||
check_title: check?.title ?? checkId,
|
||||
severity: check?.severity ?? "high",
|
||||
status: "FAIL",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const rel = (
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
label: string,
|
||||
): GraphRelationship => ({ id, source, target, label });
|
||||
|
||||
// Internet -> SSH-open security group -> EC2 -> admin role -> policy -> bucket.
|
||||
const SAMPLE_NODES: GraphNode[] = [
|
||||
{
|
||||
id: "internet",
|
||||
labels: ["Internet"],
|
||||
properties: { id: "internet", name: "Internet" },
|
||||
},
|
||||
resource("sample-sg-bastion", "EC2SecurityGroup", {
|
||||
name: "bastion-ssh",
|
||||
groupid: "sg-0a1b2c3d4e5f60718",
|
||||
arn: arn("ec2", "security-group/sg-0a1b2c3d4e5f60718"),
|
||||
region: SAMPLE_REGION,
|
||||
}),
|
||||
resource("sample-ec2-bastion", "EC2Instance", {
|
||||
name: "bastion-01",
|
||||
instanceid: "i-0f1e2d3c4b5a69788",
|
||||
arn: arn("ec2", "instance/i-0f1e2d3c4b5a69788"),
|
||||
exposed_internet: true,
|
||||
publicipaddress: "203.0.113.24",
|
||||
region: SAMPLE_REGION,
|
||||
}),
|
||||
resource("sample-role-admin", "AWSRole", {
|
||||
name: "app-admin-role",
|
||||
arn: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:role/app-admin-role`,
|
||||
}),
|
||||
resource("sample-policy-admin", "AWSPolicy", {
|
||||
name: "app-admin-inline",
|
||||
arn: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:policy/app-admin-inline`,
|
||||
}),
|
||||
resource("sample-s3-exports", "S3Bucket", {
|
||||
name: "acme-customer-exports",
|
||||
arn: "arn:aws:s3:::acme-customer-exports",
|
||||
region: SAMPLE_REGION,
|
||||
anonymous_access: true,
|
||||
}),
|
||||
finding(
|
||||
"sample-finding-ssh",
|
||||
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
|
||||
),
|
||||
finding("sample-finding-imdsv2", "ec2_instance_imdsv2_enabled"),
|
||||
finding("sample-finding-privesc", "iam_policy_allows_privilege_escalation"),
|
||||
finding("sample-finding-public-bucket", "s3_bucket_public_access"),
|
||||
];
|
||||
|
||||
const SAMPLE_RELATIONSHIPS: GraphRelationship[] = [
|
||||
rel("sample-r1", "internet", "sample-sg-bastion", "CAN_ACCESS"),
|
||||
// Directed security group -> instance, so the focused path keeps the entry.
|
||||
rel("sample-r2", "sample-sg-bastion", "sample-ec2-bastion", "CAN_ACCESS"),
|
||||
rel(
|
||||
"sample-r3",
|
||||
"sample-ec2-bastion",
|
||||
"sample-role-admin",
|
||||
"STS_ASSUMEROLE_ALLOW",
|
||||
),
|
||||
rel("sample-r4", "sample-role-admin", "sample-policy-admin", "POLICY"),
|
||||
rel("sample-r5", "sample-policy-admin", "sample-s3-exports", "CAN_ACCESS"),
|
||||
rel("sample-r6", "sample-sg-bastion", "sample-finding-ssh", "HAS_FINDING"),
|
||||
rel(
|
||||
"sample-r7",
|
||||
"sample-ec2-bastion",
|
||||
"sample-finding-imdsv2",
|
||||
"HAS_FINDING",
|
||||
),
|
||||
rel(
|
||||
"sample-r8",
|
||||
"sample-role-admin",
|
||||
"sample-finding-privesc",
|
||||
"HAS_FINDING",
|
||||
),
|
||||
rel(
|
||||
"sample-r9",
|
||||
"sample-s3-exports",
|
||||
"sample-finding-public-bucket",
|
||||
"HAS_FINDING",
|
||||
),
|
||||
];
|
||||
|
||||
export const SAMPLE_ATTACK_PATHS: SampleAttackPaths = {
|
||||
scan: {
|
||||
type: "attack-paths-scans",
|
||||
id: "sample-attack-paths-scan",
|
||||
attributes: {
|
||||
state: "completed",
|
||||
progress: 100,
|
||||
graph_data_ready: true,
|
||||
provider_alias: SAMPLE_ACCOUNT.alias,
|
||||
provider_type: "aws",
|
||||
provider_uid: SAMPLE_ACCOUNT.uid,
|
||||
inserted_at: SAMPLE_SCAN_STARTED_AT,
|
||||
started_at: SAMPLE_SCAN_STARTED_AT,
|
||||
completed_at: SAMPLE_SCAN_COMPLETED_AT,
|
||||
duration: 480,
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: SAMPLE_ACCOUNT.providerId } },
|
||||
scan: { data: { type: "scans", id: SAMPLE_ACCOUNT.scanId } },
|
||||
task: { data: { type: "tasks", id: "sample-task" } },
|
||||
},
|
||||
},
|
||||
// Mirrors the API's query of the same id, without its tag parameters.
|
||||
query: {
|
||||
type: "attack-paths-scans",
|
||||
id: "aws-internet-exposed-ec2-sensitive-s3-access",
|
||||
attributes: {
|
||||
name: "Internet-Exposed EC2 with Sensitive S3 Access",
|
||||
short_description:
|
||||
"Find SSH-exposed EC2 instances that can assume roles to read sensitive S3 buckets.",
|
||||
description:
|
||||
"Detect EC2 instances with SSH exposed to the internet that can assume higher-privileged roles to read sensitive S3 buckets.",
|
||||
provider: "aws",
|
||||
parameters: [],
|
||||
attribution: null,
|
||||
documentation_link: null,
|
||||
outcome: { kind: "public_exposure", label: "Public exposure" },
|
||||
},
|
||||
},
|
||||
result: { nodes: SAMPLE_NODES, relationships: SAMPLE_RELATIONSHIPS },
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
COMPLIANCE_OVERVIEW_TYPE,
|
||||
ComplianceOverviewData,
|
||||
} from "@/types/compliance";
|
||||
import type { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { SAMPLE_ACCOUNT } from "./sample-dataset";
|
||||
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "./sample-overview";
|
||||
|
||||
// The Overview watchlist frameworks as per-scan compliance overviews, so both
|
||||
// pages show the same requirement counts.
|
||||
|
||||
// Framework and version as the compliance-overviews API names them.
|
||||
const SAMPLE_FRAMEWORK_NAMES: Record<
|
||||
string,
|
||||
{ framework: string; version: string }
|
||||
> = {
|
||||
"cis_5.0_aws": { framework: "CIS", version: "5.0" },
|
||||
aws_foundational_security_best_practices_aws: {
|
||||
framework: "AWS-Foundational-Security-Best-Practices",
|
||||
version: "",
|
||||
},
|
||||
"pci_4.0_aws": { framework: "PCI-DSS", version: "4.0" },
|
||||
iso27001_2022_aws: { framework: "ISO27001", version: "2022" },
|
||||
soc2_aws: { framework: "SOC2", version: "" },
|
||||
};
|
||||
|
||||
export const SAMPLE_COMPLIANCE_OVERVIEWS: ComplianceOverviewData[] =
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.map(({ attributes }) => ({
|
||||
type: COMPLIANCE_OVERVIEW_TYPE.OVERVIEW,
|
||||
id: attributes.compliance_id,
|
||||
attributes: {
|
||||
framework:
|
||||
SAMPLE_FRAMEWORK_NAMES[attributes.compliance_id]?.framework ??
|
||||
attributes.compliance_id,
|
||||
version: SAMPLE_FRAMEWORK_NAMES[attributes.compliance_id]?.version ?? "",
|
||||
requirements_passed: attributes.requirements_passed,
|
||||
requirements_failed: attributes.requirements_failed,
|
||||
requirements_manual: attributes.requirements_manual,
|
||||
total_requirements: attributes.total_requirements,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SAMPLE_SCAN: ScanEntity = {
|
||||
id: SAMPLE_ACCOUNT.scanId,
|
||||
providerInfo: {
|
||||
provider: "aws",
|
||||
alias: SAMPLE_ACCOUNT.alias,
|
||||
uid: SAMPLE_ACCOUNT.uid,
|
||||
},
|
||||
attributes: { name: "Sample scan", completed_at: SAMPLE_ACCOUNT.scannedAt },
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { AttackSurfaceId } from "@/actions/overview/attack-surface/attack-surface.adapter";
|
||||
import { RESOURCE_GROUP_IDS } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
|
||||
import { SEVERITY, type Severity } from "@/types/components";
|
||||
|
||||
// One demo AWS account, described once. Every sample page derives its numbers
|
||||
// from SAMPLE_CHECKS, so the Overview totals, the findings list and the issues
|
||||
// always agree with each other.
|
||||
|
||||
export const SAMPLE_ACCOUNT = {
|
||||
uid: "123456789012",
|
||||
alias: "Sample AWS account",
|
||||
providerId: "sample-provider",
|
||||
scanId: "sample-scan",
|
||||
scannedAt: "2026-10-28T09:12:00Z",
|
||||
} as const;
|
||||
|
||||
export const SAMPLE_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
"eu-west-1",
|
||||
"eu-central-1",
|
||||
] as const;
|
||||
|
||||
type ResourceGroupId =
|
||||
(typeof RESOURCE_GROUP_IDS)[keyof typeof RESOURCE_GROUP_IDS];
|
||||
|
||||
export interface SampleCheck {
|
||||
checkId: string;
|
||||
title: string;
|
||||
service: string;
|
||||
// What the check evaluates; checks of one type share their resources.
|
||||
resourceType: string;
|
||||
severity: Severity;
|
||||
resourceGroup: ResourceGroupId;
|
||||
failed: number;
|
||||
passed: number;
|
||||
// Failed findings that the latest scan saw for the first time.
|
||||
newFailed: number;
|
||||
attackSurface?: AttackSurfaceId;
|
||||
}
|
||||
|
||||
export const SAMPLE_CHECKS: SampleCheck[] = [
|
||||
{
|
||||
checkId: "s3_bucket_public_access",
|
||||
title: "Ensure S3 buckets are not publicly accessible",
|
||||
service: "s3",
|
||||
resourceType: "s3_bucket",
|
||||
severity: SEVERITY.CRITICAL,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.STORAGE,
|
||||
failed: 3,
|
||||
passed: 21,
|
||||
newFailed: 1,
|
||||
attackSurface: "internet-exposed",
|
||||
},
|
||||
{
|
||||
checkId: "iam_root_mfa_enabled",
|
||||
title: "Ensure MFA is enabled for the root account",
|
||||
service: "iam",
|
||||
resourceType: "iam_root_account",
|
||||
severity: SEVERITY.CRITICAL,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.IAM,
|
||||
failed: 1,
|
||||
passed: 0,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "rds_instance_no_public_access",
|
||||
title: "Ensure RDS instances are not publicly accessible",
|
||||
service: "rds",
|
||||
resourceType: "rds_instance",
|
||||
severity: SEVERITY.CRITICAL,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.DATABASE,
|
||||
failed: 1,
|
||||
passed: 5,
|
||||
newFailed: 1,
|
||||
attackSurface: "internet-exposed",
|
||||
},
|
||||
{
|
||||
checkId: "awslambda_function_no_secrets_in_variables",
|
||||
title: "Find secrets in Lambda function environment variables",
|
||||
service: "awslambda",
|
||||
resourceType: "lambda_function",
|
||||
severity: SEVERITY.CRITICAL,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.SERVERLESS,
|
||||
failed: 2,
|
||||
passed: 10,
|
||||
newFailed: 0,
|
||||
attackSurface: "secrets",
|
||||
},
|
||||
{
|
||||
checkId: "ec2_instance_secrets_user_data",
|
||||
title: "Find secrets in EC2 user data",
|
||||
service: "ec2",
|
||||
resourceType: "ec2_instance",
|
||||
severity: SEVERITY.CRITICAL,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.COMPUTE,
|
||||
failed: 1,
|
||||
passed: 11,
|
||||
newFailed: 0,
|
||||
attackSurface: "secrets",
|
||||
},
|
||||
{
|
||||
checkId: "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
|
||||
title:
|
||||
"Ensure no security groups allow ingress from 0.0.0.0/0 or ::/0 to SSH port 22",
|
||||
service: "ec2",
|
||||
resourceType: "ec2_security_group",
|
||||
severity: SEVERITY.HIGH,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.NETWORK,
|
||||
failed: 4,
|
||||
passed: 38,
|
||||
newFailed: 2,
|
||||
attackSurface: "internet-exposed",
|
||||
},
|
||||
{
|
||||
checkId: "iam_policy_allows_privilege_escalation",
|
||||
title: "Ensure no custom IAM policies allow privilege escalation",
|
||||
service: "iam",
|
||||
resourceType: "iam_policy",
|
||||
severity: SEVERITY.HIGH,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.IAM,
|
||||
failed: 2,
|
||||
passed: 14,
|
||||
newFailed: 0,
|
||||
attackSurface: "privilege-escalation",
|
||||
},
|
||||
{
|
||||
checkId: "cloudtrail_multi_region_enabled",
|
||||
title: "Ensure CloudTrail is enabled in all regions",
|
||||
service: "cloudtrail",
|
||||
resourceType: "cloudtrail_trail",
|
||||
severity: SEVERITY.HIGH,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.MONITORING,
|
||||
failed: 1,
|
||||
passed: 0,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "iam_user_mfa_enabled_console_access",
|
||||
title: "Ensure MFA is enabled for all IAM users with console access",
|
||||
service: "iam",
|
||||
resourceType: "iam_user",
|
||||
severity: SEVERITY.HIGH,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.IAM,
|
||||
failed: 3,
|
||||
passed: 6,
|
||||
newFailed: 1,
|
||||
},
|
||||
{
|
||||
checkId: "eks_cluster_not_publicly_accessible",
|
||||
title: "Ensure EKS clusters are not publicly accessible",
|
||||
service: "eks",
|
||||
resourceType: "eks_cluster",
|
||||
severity: SEVERITY.HIGH,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.CONTAINER,
|
||||
failed: 1,
|
||||
passed: 1,
|
||||
newFailed: 0,
|
||||
attackSurface: "internet-exposed",
|
||||
},
|
||||
{
|
||||
checkId: "ec2_instance_imdsv2_enabled",
|
||||
title: "Ensure EC2 instances require IMDSv2",
|
||||
service: "ec2",
|
||||
resourceType: "ec2_instance",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.COMPUTE,
|
||||
failed: 7,
|
||||
passed: 5,
|
||||
newFailed: 2,
|
||||
attackSurface: "ec2-imdsv1",
|
||||
},
|
||||
{
|
||||
checkId: "ec2_ebs_volume_encryption",
|
||||
title: "Ensure EBS volumes are encrypted",
|
||||
service: "ec2",
|
||||
resourceType: "ebs_volume",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.STORAGE,
|
||||
failed: 9,
|
||||
passed: 14,
|
||||
newFailed: 1,
|
||||
},
|
||||
{
|
||||
checkId: "s3_bucket_secure_transport_policy",
|
||||
title: "Ensure S3 buckets enforce encryption in transit",
|
||||
service: "s3",
|
||||
resourceType: "s3_bucket",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.STORAGE,
|
||||
failed: 11,
|
||||
passed: 13,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "iam_rotate_access_key_90_days",
|
||||
title: "Ensure access keys are rotated every 90 days or less",
|
||||
service: "iam",
|
||||
resourceType: "iam_access_key",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.IAM,
|
||||
failed: 5,
|
||||
passed: 7,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "guardduty_is_enabled",
|
||||
title: "Ensure Amazon GuardDuty is enabled",
|
||||
service: "guardduty",
|
||||
resourceType: "guardduty_detector",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.SECURITY,
|
||||
failed: 3,
|
||||
passed: 1,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "config_recorder_all_regions_enabled",
|
||||
title: "Ensure AWS Config is enabled in all regions",
|
||||
service: "config",
|
||||
resourceType: "config_recorder",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.GOVERNANCE,
|
||||
failed: 3,
|
||||
passed: 1,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "vpc_flow_logs_enabled",
|
||||
title: "Ensure VPC Flow Logs are enabled in all VPCs",
|
||||
service: "vpc",
|
||||
resourceType: "vpc",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.NETWORK,
|
||||
failed: 4,
|
||||
passed: 0,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "kms_cmk_rotation_enabled",
|
||||
title: "Ensure rotation is enabled for customer managed KMS keys",
|
||||
service: "kms",
|
||||
resourceType: "kms_key",
|
||||
severity: SEVERITY.MEDIUM,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.SECURITY,
|
||||
failed: 3,
|
||||
passed: 4,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "apigateway_restapi_logging_enabled",
|
||||
title: "Ensure API Gateway REST API stages have logging enabled",
|
||||
service: "apigateway",
|
||||
resourceType: "apigateway_rest_api",
|
||||
severity: SEVERITY.LOW,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.API_GATEWAY,
|
||||
failed: 2,
|
||||
passed: 1,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "sqs_queues_server_side_encryption_enabled",
|
||||
title: "Ensure SQS queues have server-side encryption enabled",
|
||||
service: "sqs",
|
||||
resourceType: "sqs_queue",
|
||||
severity: SEVERITY.LOW,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.MESSAGING,
|
||||
failed: 3,
|
||||
passed: 2,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "cloudwatch_log_metric_filter_root_usage",
|
||||
title: "Ensure a log metric filter and alarm exist for root account usage",
|
||||
service: "cloudwatch",
|
||||
resourceType: "cloudwatch_log_group",
|
||||
severity: SEVERITY.LOW,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.MONITORING,
|
||||
failed: 1,
|
||||
passed: 0,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "ec2_instance_managed_by_ssm",
|
||||
title: "Ensure EC2 instances are managed by Systems Manager",
|
||||
service: "ssm",
|
||||
resourceType: "ec2_instance",
|
||||
severity: SEVERITY.LOW,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.COMPUTE,
|
||||
failed: 4,
|
||||
passed: 8,
|
||||
newFailed: 0,
|
||||
},
|
||||
{
|
||||
checkId: "account_maintain_current_contact_details",
|
||||
title: "Maintain current contact details for the AWS account",
|
||||
service: "account",
|
||||
resourceType: "aws_account",
|
||||
severity: SEVERITY.INFORMATIONAL,
|
||||
resourceGroup: RESOURCE_GROUP_IDS.GOVERNANCE,
|
||||
failed: 1,
|
||||
passed: 0,
|
||||
newFailed: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_FAILED_CHECKS = SAMPLE_CHECKS.filter(
|
||||
(check) => check.failed > 0,
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import {
|
||||
SAMPLE_ACCOUNT,
|
||||
SAMPLE_FAILED_CHECKS,
|
||||
type SampleCheck,
|
||||
} from "./sample-dataset";
|
||||
|
||||
// Finding groups derived from SAMPLE_CHECKS, in the API response shape so they
|
||||
// run through the same adapter as real data.
|
||||
|
||||
interface SampleFindingGroupAttributes {
|
||||
check_id: string;
|
||||
check_title: string;
|
||||
check_description: null;
|
||||
severity: string;
|
||||
status: "FAIL";
|
||||
muted: false;
|
||||
impacted_providers: string[];
|
||||
resources_total: number;
|
||||
resources_fail: number;
|
||||
pass_count: number;
|
||||
fail_count: number;
|
||||
muted_count: number;
|
||||
new_count: number;
|
||||
changed_count: number;
|
||||
new_fail_count: number;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
failing_since: string;
|
||||
}
|
||||
|
||||
interface SampleFindingGroupItem {
|
||||
type: "finding-groups";
|
||||
id: string;
|
||||
attributes: SampleFindingGroupAttributes;
|
||||
}
|
||||
|
||||
const toFindingGroupItem = (check: SampleCheck): SampleFindingGroupItem => ({
|
||||
type: "finding-groups",
|
||||
id: check.checkId,
|
||||
attributes: {
|
||||
check_id: check.checkId,
|
||||
check_title: check.title,
|
||||
check_description: null,
|
||||
severity: check.severity,
|
||||
status: "FAIL",
|
||||
muted: false,
|
||||
impacted_providers: ["aws"],
|
||||
resources_total: check.failed + check.passed,
|
||||
resources_fail: check.failed,
|
||||
pass_count: check.passed,
|
||||
fail_count: check.failed,
|
||||
muted_count: 0,
|
||||
new_count: check.newFailed,
|
||||
changed_count: 0,
|
||||
new_fail_count: check.newFailed,
|
||||
first_seen_at: SAMPLE_ACCOUNT.scannedAt,
|
||||
last_seen_at: SAMPLE_ACCOUNT.scannedAt,
|
||||
failing_since: SAMPLE_ACCOUNT.scannedAt,
|
||||
},
|
||||
});
|
||||
|
||||
export const SAMPLE_FINDING_GROUPS_RESPONSE: {
|
||||
data: SampleFindingGroupItem[];
|
||||
} = { data: SAMPLE_FAILED_CHECKS.map(toFindingGroupItem) };
|
||||
|
||||
export const SAMPLE_FINDING_GROUPS: FindingGroupRow[] =
|
||||
adaptFindingGroupsResponse(SAMPLE_FINDING_GROUPS_RESPONSE);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_NEW_FINDINGS,
|
||||
SAMPLE_RISK_PLOT_POINTS,
|
||||
SAMPLE_SANKEY_DATA,
|
||||
SAMPLE_THREAT_MAP_DATA,
|
||||
} from "./sample-graphs";
|
||||
import { SAMPLE_STATUS } from "./sample-overview";
|
||||
|
||||
describe("sample graphs", () => {
|
||||
it("agrees with the Overview failed and new totals", () => {
|
||||
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
|
||||
|
||||
expect(
|
||||
sum(SAMPLE_THREAT_MAP_DATA.locations.map((l) => l.failFindings)),
|
||||
).toBe(SAMPLE_STATUS.fail);
|
||||
expect(
|
||||
sum(SAMPLE_THREAT_MAP_DATA.locations.map((l) => l.totalFindings)),
|
||||
).toBe(SAMPLE_STATUS.fail + SAMPLE_STATUS.pass);
|
||||
expect(sum(SAMPLE_SANKEY_DATA.links.map((link) => link.value))).toBe(
|
||||
SAMPLE_STATUS.fail,
|
||||
);
|
||||
expect(SAMPLE_RISK_PLOT_POINTS[0].y).toBe(SAMPLE_STATUS.fail);
|
||||
expect(SAMPLE_NEW_FINDINGS).toHaveLength(
|
||||
Math.min(SAMPLE_STATUS.failNew, 10),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
adaptToSankeyData,
|
||||
type SankeyData,
|
||||
} from "@/actions/overview/providers/sankey.adapter";
|
||||
import {
|
||||
adaptRegionsOverviewToThreatMap,
|
||||
type ThreatMapData,
|
||||
} from "@/actions/overview/regions/threat-map.adapter";
|
||||
import type { RegionsOverviewResponse } from "@/actions/overview/regions/types";
|
||||
import { adaptToRiskPlotData } from "@/actions/overview/risk-plot/risk-plot.adapter";
|
||||
import type { RiskPlotPoint } from "@/actions/overview/risk-plot/types/risk-plot.types";
|
||||
import { adaptCategoryOverviewToRadarData } from "@/actions/overview/risk-radar/risk-radar.adapter";
|
||||
import type { CategoryOverviewResponse } from "@/actions/overview/risk-radar/types";
|
||||
import type { RadarDataPoint } from "@/components/graphs/types";
|
||||
import {
|
||||
FINDING_DELTA,
|
||||
FINDING_STATUS,
|
||||
type FindingProps,
|
||||
SEVERITY,
|
||||
} from "@/types/components";
|
||||
|
||||
import {
|
||||
SAMPLE_ACCOUNT,
|
||||
SAMPLE_CHECKS,
|
||||
SAMPLE_REGIONS,
|
||||
type SampleCheck,
|
||||
} from "./sample-dataset";
|
||||
import {
|
||||
SAMPLE_FAILED_BY_SEVERITY,
|
||||
SAMPLE_THREAT_SCORE,
|
||||
} from "./sample-overview";
|
||||
|
||||
// Graphs-tab payloads derived from SAMPLE_CHECKS, run through the real adapters.
|
||||
|
||||
const META = { version: "v1" };
|
||||
const PROVIDER_TYPE = "aws";
|
||||
const NEW_FINDINGS_LIMIT = 10;
|
||||
|
||||
// Splits a count across SAMPLE_REGIONS; the remainder starts at `offset`.
|
||||
const splitAcrossRegions = (count: number, offset: number): number[] =>
|
||||
SAMPLE_REGIONS.map((_, regionIndex) => {
|
||||
const base = Math.floor(count / SAMPLE_REGIONS.length);
|
||||
const slot =
|
||||
(((regionIndex - offset) % SAMPLE_REGIONS.length) +
|
||||
SAMPLE_REGIONS.length) %
|
||||
SAMPLE_REGIONS.length;
|
||||
return base + (slot < count % SAMPLE_REGIONS.length ? 1 : 0);
|
||||
});
|
||||
|
||||
const regionFor = (checkIndex: number, ordinal: number) =>
|
||||
SAMPLE_REGIONS[(checkIndex + ordinal) % SAMPLE_REGIONS.length];
|
||||
|
||||
export const SAMPLE_SANKEY_DATA: SankeyData = adaptToSankeyData({
|
||||
[PROVIDER_TYPE]: SAMPLE_FAILED_BY_SEVERITY,
|
||||
});
|
||||
|
||||
const SAMPLE_REGIONS_RESPONSE: RegionsOverviewResponse = {
|
||||
data: SAMPLE_REGIONS.map((region, regionIndex) => {
|
||||
let fail = 0;
|
||||
let pass = 0;
|
||||
SAMPLE_CHECKS.forEach((check, checkIndex) => {
|
||||
fail += splitAcrossRegions(check.failed, checkIndex)[regionIndex];
|
||||
pass += splitAcrossRegions(check.passed, checkIndex)[regionIndex];
|
||||
});
|
||||
return {
|
||||
type: "regions-overview" as const,
|
||||
id: `${PROVIDER_TYPE}-${region}`,
|
||||
attributes: {
|
||||
provider_type: PROVIDER_TYPE,
|
||||
region,
|
||||
total: fail + pass,
|
||||
fail,
|
||||
pass,
|
||||
muted: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
meta: META,
|
||||
};
|
||||
|
||||
export const SAMPLE_THREAT_MAP_DATA: ThreatMapData =
|
||||
adaptRegionsOverviewToThreatMap(SAMPLE_REGIONS_RESPONSE);
|
||||
|
||||
export const SAMPLE_RISK_PLOT_POINTS: RiskPlotPoint[] = adaptToRiskPlotData([
|
||||
{
|
||||
providerId: SAMPLE_ACCOUNT.providerId,
|
||||
providerType: PROVIDER_TYPE,
|
||||
providerName: SAMPLE_ACCOUNT.alias,
|
||||
overallScore: SAMPLE_THREAT_SCORE.score,
|
||||
failedFindings: Object.values(SAMPLE_FAILED_BY_SEVERITY).reduce(
|
||||
(total, count) => total + count,
|
||||
0,
|
||||
),
|
||||
severity: SAMPLE_FAILED_BY_SEVERITY,
|
||||
},
|
||||
]).points;
|
||||
|
||||
// Prowler check-metadata categories for the sample checks.
|
||||
const CATEGORIES_BY_CHECK: Record<string, string[]> = {
|
||||
s3_bucket_public_access: ["internet-exposed"],
|
||||
iam_root_mfa_enabled: ["identity-access"],
|
||||
rds_instance_no_public_access: ["internet-exposed"],
|
||||
awslambda_function_no_secrets_in_variables: ["secrets"],
|
||||
ec2_instance_secrets_user_data: ["secrets"],
|
||||
ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22: [
|
||||
"internet-exposed",
|
||||
],
|
||||
iam_policy_allows_privilege_escalation: ["identity-access"],
|
||||
cloudtrail_multi_region_enabled: ["logging", "forensics-ready"],
|
||||
iam_user_mfa_enabled_console_access: ["identity-access"],
|
||||
eks_cluster_not_publicly_accessible: ["internet-exposed"],
|
||||
ec2_instance_imdsv2_enabled: ["ec2-imdsv1"],
|
||||
ec2_ebs_volume_encryption: ["encryption"],
|
||||
s3_bucket_secure_transport_policy: ["encryption"],
|
||||
iam_rotate_access_key_90_days: ["identity-access"],
|
||||
guardduty_is_enabled: ["threat-detection"],
|
||||
config_recorder_all_regions_enabled: ["logging", "forensics-ready"],
|
||||
vpc_flow_logs_enabled: ["logging", "forensics-ready"],
|
||||
kms_cmk_rotation_enabled: ["encryption"],
|
||||
apigateway_restapi_logging_enabled: ["logging"],
|
||||
sqs_queues_server_side_encryption_enabled: ["encryption"],
|
||||
cloudwatch_log_metric_filter_root_usage: ["logging"],
|
||||
ec2_instance_managed_by_ssm: ["node-security"],
|
||||
};
|
||||
|
||||
const categoriesOf = (check: SampleCheck) =>
|
||||
CATEGORIES_BY_CHECK[check.checkId] ?? [];
|
||||
|
||||
const SAMPLE_CATEGORIES_RESPONSE: CategoryOverviewResponse = {
|
||||
data: Array.from(new Set(SAMPLE_CHECKS.flatMap(categoriesOf))).map(
|
||||
(category) => {
|
||||
const checks = SAMPLE_CHECKS.filter((check) =>
|
||||
categoriesOf(check).includes(category),
|
||||
);
|
||||
const severity = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
informational: 0,
|
||||
};
|
||||
for (const check of checks) severity[check.severity] += check.failed;
|
||||
return {
|
||||
type: "category-overviews" as const,
|
||||
id: category,
|
||||
attributes: {
|
||||
total_findings: checks.reduce(
|
||||
(total, check) => total + check.failed + check.passed,
|
||||
0,
|
||||
),
|
||||
failed_findings: checks.reduce(
|
||||
(total, check) => total + check.failed,
|
||||
0,
|
||||
),
|
||||
new_failed_findings: checks.reduce(
|
||||
(total, check) => total + check.newFailed,
|
||||
0,
|
||||
),
|
||||
severity,
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
meta: META,
|
||||
};
|
||||
|
||||
export const SAMPLE_RADAR_DATA: RadarDataPoint[] =
|
||||
adaptCategoryOverviewToRadarData(SAMPLE_CATEGORIES_RESPONSE);
|
||||
|
||||
interface SampleResourceKind {
|
||||
type: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
const RESOURCE_BY_SERVICE: Record<string, SampleResourceKind> = {
|
||||
s3: { type: "AwsS3Bucket", prefix: "sample-exports-bucket" },
|
||||
rds: { type: "AwsRdsDbInstance", prefix: "sample-orders-db" },
|
||||
ec2: { type: "AwsEc2Instance", prefix: "sample-web-instance" },
|
||||
iam: { type: "AwsIamUser", prefix: "sample-iam-user" },
|
||||
};
|
||||
|
||||
const buildNewFinding = (
|
||||
check: SampleCheck,
|
||||
checkIndex: number,
|
||||
ordinal: number,
|
||||
): FindingProps => {
|
||||
const region = regionFor(checkIndex, ordinal);
|
||||
const kind = RESOURCE_BY_SERVICE[check.service] ?? {
|
||||
type: "Other",
|
||||
prefix: `sample-${check.service}-resource`,
|
||||
};
|
||||
const id = `sample-finding-${check.checkId}-${ordinal + 1}`;
|
||||
const resourceId = `sample-resource-${check.checkId}-${ordinal + 1}`;
|
||||
const resourceName = `${kind.prefix}-${ordinal + 1}`;
|
||||
const timestamp = SAMPLE_ACCOUNT.scannedAt;
|
||||
|
||||
return {
|
||||
type: "findings",
|
||||
id,
|
||||
attributes: {
|
||||
uid: id,
|
||||
delta: FINDING_DELTA.NEW,
|
||||
status: FINDING_STATUS.FAIL,
|
||||
status_extended: `${check.title}: ${resourceName} fails this check.`,
|
||||
severity: check.severity,
|
||||
check_id: check.checkId,
|
||||
muted: false,
|
||||
check_metadata: {
|
||||
risk: "",
|
||||
notes: "",
|
||||
checkid: check.checkId,
|
||||
provider: PROVIDER_TYPE,
|
||||
severity: check.severity,
|
||||
checktype: [],
|
||||
dependson: [],
|
||||
relatedto: [],
|
||||
categories: categoriesOf(check),
|
||||
checktitle: check.title,
|
||||
compliance: null,
|
||||
relatedurl: "",
|
||||
description: check.title,
|
||||
remediation: {
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
recommendation: { url: "", text: "" },
|
||||
},
|
||||
servicename: check.service,
|
||||
checkaliases: [],
|
||||
resourcetype: kind.type,
|
||||
subservicename: "",
|
||||
resourceidtemplate: "",
|
||||
},
|
||||
raw_result: null,
|
||||
inserted_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
first_seen_at: timestamp,
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [{ type: "resources", id: resourceId }] },
|
||||
scan: {
|
||||
data: { type: "scans", id: SAMPLE_ACCOUNT.scanId },
|
||||
attributes: {
|
||||
name: "Sample scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
unique_resource_count: 0,
|
||||
progress: 100,
|
||||
scanner_args: { checks_to_execute: [] },
|
||||
duration: 0,
|
||||
started_at: timestamp,
|
||||
inserted_at: timestamp,
|
||||
completed_at: timestamp,
|
||||
scheduled_at: null,
|
||||
next_scan_at: timestamp,
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
data: [{ type: "resources", id: resourceId }],
|
||||
id: resourceId,
|
||||
attributes: {
|
||||
uid: `arn:aws:${check.service}:${region}:${SAMPLE_ACCOUNT.uid}:${resourceName}`,
|
||||
name: resourceName,
|
||||
region,
|
||||
service: check.service,
|
||||
tags: {},
|
||||
type: kind.type,
|
||||
inserted_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
details: null,
|
||||
partition: "aws",
|
||||
},
|
||||
relationships: {
|
||||
provider: {
|
||||
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
|
||||
},
|
||||
findings: {
|
||||
meta: { count: 1 },
|
||||
data: [{ type: "findings", id }],
|
||||
},
|
||||
},
|
||||
links: { self: "" },
|
||||
},
|
||||
provider: {
|
||||
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
|
||||
attributes: {
|
||||
provider: PROVIDER_TYPE,
|
||||
uid: SAMPLE_ACCOUNT.uid,
|
||||
alias: SAMPLE_ACCOUNT.alias,
|
||||
connection: { connected: true, last_checked_at: timestamp },
|
||||
inserted_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: { type: "provider-secrets", id: "sample-secret" } },
|
||||
},
|
||||
links: { self: "" },
|
||||
},
|
||||
},
|
||||
links: { self: "" },
|
||||
};
|
||||
};
|
||||
|
||||
const SEVERITY_RANK = [
|
||||
SEVERITY.CRITICAL,
|
||||
SEVERITY.HIGH,
|
||||
SEVERITY.MEDIUM,
|
||||
SEVERITY.LOW,
|
||||
SEVERITY.INFORMATIONAL,
|
||||
];
|
||||
|
||||
// Latest new failed findings, most severe first, like the real tab.
|
||||
export const SAMPLE_NEW_FINDINGS: FindingProps[] = SAMPLE_CHECKS.flatMap(
|
||||
(check, checkIndex) =>
|
||||
Array.from({ length: check.newFailed }, (_, ordinal) =>
|
||||
buildNewFinding(check, checkIndex, ordinal),
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
SEVERITY_RANK.indexOf(a.attributes.severity) -
|
||||
SEVERITY_RANK.indexOf(b.attributes.severity),
|
||||
)
|
||||
.slice(0, NEW_FINDINGS_LIMIT);
|
||||
@@ -0,0 +1,247 @@
|
||||
import { SEVERITY } from "@/types/components";
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
import {
|
||||
type IssueAttributes,
|
||||
type IssueResource,
|
||||
type IssuesOverviewAttributes,
|
||||
LEG_STATE,
|
||||
type LegAssessment,
|
||||
type LegName,
|
||||
VERDICT,
|
||||
} from "@/types/issues";
|
||||
|
||||
import { SAMPLE_ACCOUNT } from "./sample-dataset";
|
||||
|
||||
const leg = (
|
||||
state: LegAssessment["state"],
|
||||
text: string,
|
||||
evidence: string[],
|
||||
): LegAssessment => ({ state, text, evidence });
|
||||
|
||||
type SampleIssueSeed = Omit<
|
||||
IssueAttributes,
|
||||
| "cloud"
|
||||
| "account"
|
||||
| "triage_status"
|
||||
| "notes_count"
|
||||
| "inserted_at"
|
||||
| "updated_at"
|
||||
> & { id: string; legs: Record<LegName, LegAssessment> };
|
||||
|
||||
// Each issue folds failed findings from SAMPLE_CHECKS along one attack path.
|
||||
const SAMPLE_ISSUE_SEEDS: SampleIssueSeed[] = [
|
||||
{
|
||||
id: "sample-issue-public-bucket",
|
||||
pattern: "aws_public_bucket",
|
||||
title: "Public S3 bucket exposes customer exports to the internet",
|
||||
verdict: VERDICT.EXPLOITABLE,
|
||||
severity: SEVERITY.CRITICAL,
|
||||
outcome: "data_exposure",
|
||||
attacker_start: "internet",
|
||||
fix: "Turn on S3 Block Public Access for the bucket and remove the public-read grant from its policy.",
|
||||
findings_count: 3,
|
||||
legs: {
|
||||
reachability: leg(
|
||||
LEG_STATE.YES,
|
||||
"The bucket policy grants s3:GetObject to everyone.",
|
||||
["s3_bucket_public_access"],
|
||||
),
|
||||
access: leg(
|
||||
LEG_STATE.YES,
|
||||
"No credential is needed to list or read objects.",
|
||||
["s3_bucket_public_access"],
|
||||
),
|
||||
target: leg(LEG_STATE.YES, "The bucket holds daily customer exports.", [
|
||||
"s3_bucket_public_access",
|
||||
]),
|
||||
controls: leg(
|
||||
LEG_STATE.NO,
|
||||
"Encryption in transit is not enforced and no access logging is on.",
|
||||
["s3_bucket_secure_transport_policy"],
|
||||
),
|
||||
verification: leg(
|
||||
LEG_STATE.YES,
|
||||
"An anonymous request returns the object list.",
|
||||
[],
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sample-issue-ssh-instance-role",
|
||||
pattern: "aws_exposed_instance_role",
|
||||
title: "Internet-facing instance with SSH open carries an admin role",
|
||||
verdict: VERDICT.LIKELY,
|
||||
severity: SEVERITY.HIGH,
|
||||
outcome: "privilege_escalation",
|
||||
attacker_start: "internet",
|
||||
fix: "Close port 22 to 0.0.0.0/0, require IMDSv2 on the instance and scope its role down to what the workload needs.",
|
||||
findings_count: 4,
|
||||
legs: {
|
||||
reachability: leg(
|
||||
LEG_STATE.YES,
|
||||
"The security group allows SSH from anywhere.",
|
||||
["ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22"],
|
||||
),
|
||||
access: leg(
|
||||
LEG_STATE.PARTIAL,
|
||||
"SSH needs a key, but the instance still answers IMDSv1.",
|
||||
["ec2_instance_imdsv2_enabled"],
|
||||
),
|
||||
target: leg(
|
||||
LEG_STATE.YES,
|
||||
"The instance role can pass roles and attach policies.",
|
||||
["iam_policy_allows_privilege_escalation"],
|
||||
),
|
||||
controls: leg(LEG_STATE.NO, "GuardDuty is off in this region.", [
|
||||
"guardduty_is_enabled",
|
||||
]),
|
||||
verification: leg(
|
||||
LEG_STATE.UNKNOWN,
|
||||
"Not verified against the live instance.",
|
||||
[],
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sample-issue-root-takeover",
|
||||
pattern: "aws_root_takeover",
|
||||
title: "Root account has no MFA and no alarm on its use",
|
||||
verdict: VERDICT.CONDITIONAL,
|
||||
severity: SEVERITY.CRITICAL,
|
||||
outcome: "privilege_escalation",
|
||||
attacker_start: "credential_leak",
|
||||
fix: "Enable a hardware MFA device on the root user and add a metric filter and alarm for root sign-ins.",
|
||||
findings_count: 2,
|
||||
legs: {
|
||||
reachability: leg(
|
||||
LEG_STATE.YES,
|
||||
"The root user can sign in to the console.",
|
||||
["iam_root_mfa_enabled"],
|
||||
),
|
||||
access: leg(
|
||||
LEG_STATE.PARTIAL,
|
||||
"An attacker needs the root password first.",
|
||||
["iam_root_mfa_enabled"],
|
||||
),
|
||||
target: leg(
|
||||
LEG_STATE.YES,
|
||||
"Root controls every resource and billing.",
|
||||
[],
|
||||
),
|
||||
controls: leg(LEG_STATE.NO, "No alarm fires on root usage.", [
|
||||
"cloudwatch_log_metric_filter_root_usage",
|
||||
]),
|
||||
verification: leg(LEG_STATE.UNKNOWN, "Not verified.", []),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sample-issue-lambda-secrets",
|
||||
pattern: "aws_secret_store",
|
||||
title:
|
||||
"Lambda functions keep database credentials in plain environment variables",
|
||||
verdict: VERDICT.LIKELY,
|
||||
severity: SEVERITY.HIGH,
|
||||
outcome: "data_exposure",
|
||||
attacker_start: "authenticated_principal",
|
||||
fix: "Move the credentials to Secrets Manager and read them at runtime; rotate the exposed ones.",
|
||||
findings_count: 2,
|
||||
legs: {
|
||||
reachability: leg(
|
||||
LEG_STATE.YES,
|
||||
"Any principal with lambda:GetFunction reads the variables.",
|
||||
["awslambda_function_no_secrets_in_variables"],
|
||||
),
|
||||
access: leg(LEG_STATE.YES, "Several roles hold read access to Lambda.", [
|
||||
"iam_policy_allows_privilege_escalation",
|
||||
]),
|
||||
target: leg(
|
||||
LEG_STATE.YES,
|
||||
"The credentials open the public RDS instance.",
|
||||
["rds_instance_no_public_access"],
|
||||
),
|
||||
controls: leg(
|
||||
LEG_STATE.PARTIAL,
|
||||
"Access keys rotate, but not every 90 days.",
|
||||
["iam_rotate_access_key_90_days"],
|
||||
),
|
||||
verification: leg(LEG_STATE.UNKNOWN, "Not verified.", []),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sample-issue-unencrypted-volumes",
|
||||
pattern: "aws_unencrypted_at_rest",
|
||||
title: "EBS volumes and SQS queues store data unencrypted",
|
||||
verdict: VERDICT.NOT_EXPLOITABLE,
|
||||
severity: SEVERITY.MEDIUM,
|
||||
outcome: "",
|
||||
attacker_start: "",
|
||||
fix: "Turn on EBS encryption by default in each region and enable SSE on the queues.",
|
||||
findings_count: 12,
|
||||
legs: {
|
||||
reachability: leg(
|
||||
LEG_STATE.NO,
|
||||
"None of the volumes is attached to an exposed instance.",
|
||||
["ec2_ebs_volume_encryption"],
|
||||
),
|
||||
access: leg(LEG_STATE.NO, "Reading them needs an existing foothold.", []),
|
||||
target: leg(LEG_STATE.PARTIAL, "Some volumes hold application data.", [
|
||||
"ec2_ebs_volume_encryption",
|
||||
]),
|
||||
controls: leg(
|
||||
LEG_STATE.PARTIAL,
|
||||
"KMS keys exist but are not used everywhere.",
|
||||
["kms_cmk_rotation_enabled"],
|
||||
),
|
||||
verification: leg(LEG_STATE.UNKNOWN, "Not verified.", []),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_ISSUES: IssueResource[] = SAMPLE_ISSUE_SEEDS.map(
|
||||
({ id, ...attributes }) => ({
|
||||
type: "issues",
|
||||
id,
|
||||
attributes: {
|
||||
...attributes,
|
||||
cloud: "aws",
|
||||
account: SAMPLE_ACCOUNT.uid,
|
||||
triage_status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
notes_count: 0,
|
||||
inserted_at: SAMPLE_ACCOUNT.scannedAt,
|
||||
updated_at: SAMPLE_ACCOUNT.scannedAt,
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: SAMPLE_ACCOUNT.providerId } },
|
||||
scan: { data: { type: "scans", id: SAMPLE_ACCOUNT.scanId } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const sumFindings = (issues: IssueResource[]) =>
|
||||
issues.reduce((total, issue) => total + issue.attributes.findings_count, 0);
|
||||
|
||||
/** The issues overview the API would return for these issues. */
|
||||
export const summarizeSampleIssues = (
|
||||
issues: IssueResource[],
|
||||
): IssuesOverviewAttributes => {
|
||||
const countVerdict = (verdict: IssueAttributes["verdict"]) =>
|
||||
issues.filter((issue) => issue.attributes.verdict === verdict).length;
|
||||
const notExploitable = issues.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
return {
|
||||
exploitable: countVerdict(VERDICT.EXPLOITABLE),
|
||||
likely: countVerdict(VERDICT.LIKELY),
|
||||
conditional: countVerdict(VERDICT.CONDITIONAL),
|
||||
not_exploitable: countVerdict(VERDICT.NOT_EXPLOITABLE),
|
||||
unknown: countVerdict(VERDICT.UNKNOWN),
|
||||
issues: issues.length,
|
||||
findings: sumFindings(issues),
|
||||
// Matches the API's ACTIONABLE_VERDICTS.
|
||||
actionable:
|
||||
countVerdict(VERDICT.EXPLOITABLE) + countVerdict(VERDICT.LIKELY),
|
||||
not_exploitable_findings: sumFindings(notExploitable),
|
||||
};
|
||||
};
|
||||
|
||||
export const SAMPLE_ISSUES_OVERVIEW = summarizeSampleIssues(SAMPLE_ISSUES);
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { AttackSurfaceOverviewResponse } from "@/actions/overview/attack-surface/types";
|
||||
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
|
||||
import type { ResourceGroupOverviewResponse } from "@/actions/overview/resources-inventory/types";
|
||||
import type { ServicesOverviewResponse } from "@/actions/overview/services/types/services.types";
|
||||
import type {
|
||||
CriticalRequirement,
|
||||
SectionScores,
|
||||
} from "@/actions/overview/threat-score/types/threat-score.types";
|
||||
import type { LineDataPoint } from "@/components/graphs/types";
|
||||
import { SEVERITY, type Severity } from "@/types/components";
|
||||
|
||||
import {
|
||||
SAMPLE_ACCOUNT,
|
||||
SAMPLE_CHECKS,
|
||||
type SampleCheck,
|
||||
} from "./sample-dataset";
|
||||
|
||||
// Overview payloads derived from SAMPLE_CHECKS. They keep the API response
|
||||
// shapes, so the page runs them through the same adapters as real data.
|
||||
|
||||
const META = { version: "v1" };
|
||||
|
||||
const sum = (checks: SampleCheck[], pick: (check: SampleCheck) => number) =>
|
||||
checks.reduce((total, check) => total + pick(check), 0);
|
||||
|
||||
const groupBy = <K extends string>(
|
||||
key: (check: SampleCheck) => K,
|
||||
): [K, SampleCheck[]][] => {
|
||||
const groups: Partial<Record<K, SampleCheck[]>> = {};
|
||||
for (const check of SAMPLE_CHECKS) {
|
||||
groups[key(check)] = [...(groups[key(check)] ?? []), check];
|
||||
}
|
||||
return Object.entries(groups) as [K, SampleCheck[]][];
|
||||
};
|
||||
|
||||
const failedBySeverity = (checks: SampleCheck[]): Record<Severity, number> => {
|
||||
const counts: Record<Severity, number> = {
|
||||
[SEVERITY.CRITICAL]: 0,
|
||||
[SEVERITY.HIGH]: 0,
|
||||
[SEVERITY.MEDIUM]: 0,
|
||||
[SEVERITY.LOW]: 0,
|
||||
[SEVERITY.INFORMATIONAL]: 0,
|
||||
};
|
||||
for (const check of checks) counts[check.severity] += check.failed;
|
||||
return counts;
|
||||
};
|
||||
|
||||
// Checks of one resource type evaluate the same resources; types don't overlap.
|
||||
const resourcesCount = (checks: SampleCheck[]) => {
|
||||
const widestByType: Record<string, number> = {};
|
||||
for (const check of checks) {
|
||||
widestByType[check.resourceType] = Math.max(
|
||||
widestByType[check.resourceType] ?? 0,
|
||||
check.failed + check.passed,
|
||||
);
|
||||
}
|
||||
return Object.values(widestByType).reduce((total, n) => total + n, 0);
|
||||
};
|
||||
|
||||
export const SAMPLE_STATUS = {
|
||||
fail: sum(SAMPLE_CHECKS, (check) => check.failed),
|
||||
pass: sum(SAMPLE_CHECKS, (check) => check.passed),
|
||||
failNew: sum(SAMPLE_CHECKS, (check) => check.newFailed),
|
||||
passNew: 0,
|
||||
};
|
||||
|
||||
export const SAMPLE_FAILED_BY_SEVERITY = failedBySeverity(SAMPLE_CHECKS);
|
||||
|
||||
export const SAMPLE_SERVICES_RESPONSE: ServicesOverviewResponse = {
|
||||
data: groupBy((check) => check.service).map(([service, checks]) => ({
|
||||
type: "services-overview",
|
||||
id: service,
|
||||
attributes: {
|
||||
total: sum(checks, (check) => check.failed + check.passed),
|
||||
fail: sum(checks, (check) => check.failed),
|
||||
pass: sum(checks, (check) => check.passed),
|
||||
muted: 0,
|
||||
},
|
||||
})),
|
||||
meta: META,
|
||||
};
|
||||
|
||||
export const SAMPLE_RESOURCE_GROUPS_RESPONSE: ResourceGroupOverviewResponse = {
|
||||
data: groupBy((check) => check.resourceGroup).map(([group, checks]) => ({
|
||||
type: "resource-group-overview",
|
||||
id: group,
|
||||
attributes: {
|
||||
id: group,
|
||||
total_findings: sum(checks, (check) => check.failed + check.passed),
|
||||
failed_findings: sum(checks, (check) => check.failed),
|
||||
new_failed_findings: sum(checks, (check) => check.newFailed),
|
||||
resources_count: resourcesCount(checks),
|
||||
severity: failedBySeverity(checks),
|
||||
},
|
||||
})),
|
||||
meta: META,
|
||||
};
|
||||
|
||||
export const SAMPLE_ATTACK_SURFACE_RESPONSE: AttackSurfaceOverviewResponse = {
|
||||
data: groupBy((check) => check.attackSurface ?? "")
|
||||
.filter(([surface]) => surface !== "")
|
||||
.map(([surface, checks]) => ({
|
||||
type: "attack-surface-overviews",
|
||||
id: surface,
|
||||
attributes: {
|
||||
total_findings: sum(checks, (check) => check.failed + check.passed),
|
||||
failed_findings: sum(checks, (check) => check.failed),
|
||||
muted_failed_findings: 0,
|
||||
check_ids: checks.map((check) => check.checkId),
|
||||
},
|
||||
})),
|
||||
meta: META,
|
||||
};
|
||||
|
||||
const watchlistItem = (
|
||||
complianceId: string,
|
||||
passed: number,
|
||||
failed: number,
|
||||
manual: number,
|
||||
) => ({
|
||||
type: "compliance-watchlist-overviews" as const,
|
||||
id: complianceId,
|
||||
attributes: {
|
||||
compliance_id: complianceId,
|
||||
requirements_passed: passed,
|
||||
requirements_failed: failed,
|
||||
requirements_manual: manual,
|
||||
total_requirements: passed + failed + manual,
|
||||
},
|
||||
});
|
||||
|
||||
export const SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE: ComplianceWatchlistResponse =
|
||||
{
|
||||
data: [
|
||||
watchlistItem("cis_5.0_aws", 38, 21, 4),
|
||||
watchlistItem("aws_foundational_security_best_practices_aws", 142, 61, 0),
|
||||
watchlistItem("pci_4.0_aws", 97, 52, 12),
|
||||
watchlistItem("iso27001_2022_aws", 61, 22, 10),
|
||||
watchlistItem("soc2_aws", 44, 13, 6),
|
||||
],
|
||||
};
|
||||
|
||||
export const SAMPLE_THREAT_SCORE: {
|
||||
score: number;
|
||||
scoreDelta: number;
|
||||
sectionScores: SectionScores;
|
||||
criticalRequirements: CriticalRequirement[];
|
||||
} = {
|
||||
score: 61.8,
|
||||
scoreDelta: 2.4,
|
||||
sectionScores: {
|
||||
"1. IAM": 54.2,
|
||||
"2. Attack Surface": 58.9,
|
||||
"3. Logging and Monitoring": 66.5,
|
||||
"4. Encryption": 71.3,
|
||||
},
|
||||
criticalRequirements: [
|
||||
{
|
||||
requirement_id: "1.1.1",
|
||||
risk_level: 5,
|
||||
weight: 1000,
|
||||
title: "Ensure MFA is enabled for the root account",
|
||||
},
|
||||
{
|
||||
requirement_id: "2.1.3",
|
||||
risk_level: 5,
|
||||
weight: 1000,
|
||||
title: "Ensure S3 buckets are not publicly accessible",
|
||||
},
|
||||
{
|
||||
requirement_id: "2.2.1",
|
||||
risk_level: 5,
|
||||
weight: 1000,
|
||||
title: "Ensure RDS instances are not publicly accessible",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TREND_DAYS = 8;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// A week of daily scans that ends on the sample scan, trending down slowly.
|
||||
export const SAMPLE_SEVERITY_TRENDS: LineDataPoint[] = Array.from(
|
||||
{ length: TREND_DAYS },
|
||||
(_, index) => {
|
||||
const daysAgo = TREND_DAYS - 1 - index;
|
||||
const date = new Date(
|
||||
Date.parse(SAMPLE_ACCOUNT.scannedAt) - daysAgo * DAY_MS,
|
||||
);
|
||||
const drift = (severity: Severity) =>
|
||||
SAMPLE_FAILED_BY_SEVERITY[severity] + Math.round(daysAgo * 0.6);
|
||||
return {
|
||||
date: date.toISOString().slice(0, 10),
|
||||
critical: drift(SEVERITY.CRITICAL),
|
||||
high: drift(SEVERITY.HIGH),
|
||||
medium: drift(SEVERITY.MEDIUM),
|
||||
low: drift(SEVERITY.LOW),
|
||||
informational: SAMPLE_FAILED_BY_SEVERITY[SEVERITY.INFORMATIONAL],
|
||||
muted: 0,
|
||||
scan_ids: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SamplePreviewStage } from "./sample-preview-state";
|
||||
|
||||
// Window events the sample preview dispatches. Like the provider funnel, they
|
||||
// carry no listener of their own: a deployment that measures the experiment
|
||||
// subscribes from outside. Details are low-cardinality only.
|
||||
export const SAMPLE_PREVIEW_EVENT = "prowler:sample-preview";
|
||||
|
||||
export const SAMPLE_PREVIEW_STEP = {
|
||||
VIEWED: "viewed",
|
||||
CTA_CLICKED: "cta_clicked",
|
||||
// The first scan completed while the sample was on screen.
|
||||
EXITED: "exited",
|
||||
} as const;
|
||||
|
||||
export const SAMPLE_PREVIEW_PAGE = {
|
||||
OVERVIEW: "overview",
|
||||
FINDINGS: "findings",
|
||||
COMPLIANCE: "compliance",
|
||||
ISSUES: "issues",
|
||||
ATTACK_PATHS: "attack_paths",
|
||||
} as const;
|
||||
|
||||
export type SamplePreviewPage =
|
||||
(typeof SAMPLE_PREVIEW_PAGE)[keyof typeof SAMPLE_PREVIEW_PAGE];
|
||||
|
||||
export const SAMPLE_PREVIEW_CTA = {
|
||||
ADD_PROVIDER: "add_provider",
|
||||
LAUNCH_SCAN: "launch_scan",
|
||||
VIEW_SCAN: "view_scan",
|
||||
} as const;
|
||||
|
||||
export type SamplePreviewCta =
|
||||
(typeof SAMPLE_PREVIEW_CTA)[keyof typeof SAMPLE_PREVIEW_CTA];
|
||||
|
||||
interface SamplePreviewBaseDetail {
|
||||
page: SamplePreviewPage;
|
||||
stage: SamplePreviewStage;
|
||||
}
|
||||
|
||||
export interface SamplePreviewViewedDetail extends SamplePreviewBaseDetail {
|
||||
step: typeof SAMPLE_PREVIEW_STEP.VIEWED;
|
||||
}
|
||||
|
||||
export interface SamplePreviewCtaClickedDetail extends SamplePreviewBaseDetail {
|
||||
step: typeof SAMPLE_PREVIEW_STEP.CTA_CLICKED;
|
||||
cta: SamplePreviewCta;
|
||||
}
|
||||
|
||||
export interface SamplePreviewExitedDetail extends SamplePreviewBaseDetail {
|
||||
step: typeof SAMPLE_PREVIEW_STEP.EXITED;
|
||||
}
|
||||
|
||||
export type SamplePreviewDetail =
|
||||
| SamplePreviewViewedDetail
|
||||
| SamplePreviewCtaClickedDetail
|
||||
| SamplePreviewExitedDetail;
|
||||
|
||||
export function dispatchSamplePreview(detail: SamplePreviewDetail): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<SamplePreviewDetail>(SAMPLE_PREVIEW_EVENT, { detail }),
|
||||
);
|
||||
} catch {
|
||||
// A listener that throws must never break the page it observes.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveSamplePreviewState,
|
||||
SAMPLE_PREVIEW_STAGE,
|
||||
} from "./sample-preview-state";
|
||||
|
||||
const NEW_TENANT = {
|
||||
enabled: true,
|
||||
hasCompletedScan: false,
|
||||
hasProviders: false,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("resolveSamplePreviewState", () => {
|
||||
it("shows the sample to a tenant that has not connected a provider", () => {
|
||||
expect(resolveSamplePreviewState(NEW_TENANT)).toEqual({
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
|
||||
runningScan: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the sample while the first scan runs, with its real progress", () => {
|
||||
const runningScan = { id: "scan-1", progress: 43 };
|
||||
|
||||
expect(
|
||||
resolveSamplePreviewState({
|
||||
...NEW_TENANT,
|
||||
hasProviders: true,
|
||||
runningScan,
|
||||
}),
|
||||
).toEqual({
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.SCAN_RUNNING,
|
||||
runningScan,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the sample for a connected provider that has not been scanned", () => {
|
||||
expect(
|
||||
resolveSamplePreviewState({ ...NEW_TENANT, hasProviders: true }),
|
||||
).toMatchObject({ active: true, stage: SAMPLE_PREVIEW_STAGE.NO_SCAN });
|
||||
});
|
||||
|
||||
it("drops the sample once the tenant has a completed scan of its own", () => {
|
||||
expect(
|
||||
resolveSamplePreviewState({
|
||||
...NEW_TENANT,
|
||||
hasProviders: true,
|
||||
hasCompletedScan: true,
|
||||
}),
|
||||
).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it("shows the real page when the scan lookup failed", () => {
|
||||
expect(
|
||||
resolveSamplePreviewState({ ...NEW_TENANT, hasCompletedScan: undefined }),
|
||||
).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it("shows the real page when the provider lookup failed", () => {
|
||||
expect(
|
||||
resolveSamplePreviewState({ ...NEW_TENANT, hasProviders: undefined }),
|
||||
).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it("shows the real page when the running scan lookup failed", () => {
|
||||
expect(
|
||||
resolveSamplePreviewState({
|
||||
...NEW_TENANT,
|
||||
hasProviders: true,
|
||||
runningScan: undefined,
|
||||
}),
|
||||
).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it("never shows the sample where the deployment has not enabled it", () => {
|
||||
expect(
|
||||
resolveSamplePreviewState({ ...NEW_TENANT, enabled: false }),
|
||||
).toEqual({ active: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Why a tenant still sees sample data: what it has not done yet. */
|
||||
export const SAMPLE_PREVIEW_STAGE = {
|
||||
NO_PROVIDER: "no_provider",
|
||||
NO_SCAN: "no_scan",
|
||||
SCAN_RUNNING: "scan_running",
|
||||
} as const;
|
||||
|
||||
export type SamplePreviewStage =
|
||||
(typeof SAMPLE_PREVIEW_STAGE)[keyof typeof SAMPLE_PREVIEW_STAGE];
|
||||
|
||||
export interface RunningScan {
|
||||
id: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface SamplePreviewActive {
|
||||
active: true;
|
||||
stage: SamplePreviewStage;
|
||||
runningScan: RunningScan | null;
|
||||
}
|
||||
|
||||
export interface SamplePreviewInactive {
|
||||
active: false;
|
||||
}
|
||||
|
||||
export type SamplePreviewState = SamplePreviewActive | SamplePreviewInactive;
|
||||
|
||||
export interface SamplePreviewInputs {
|
||||
enabled: boolean;
|
||||
// `undefined` = the lookup failed: the real page wins, never the sample.
|
||||
hasCompletedScan: boolean | undefined;
|
||||
hasProviders: boolean | undefined;
|
||||
runningScan: RunningScan | null | undefined;
|
||||
}
|
||||
|
||||
const INACTIVE: SamplePreviewInactive = { active: false };
|
||||
|
||||
/** Sample data stands in until the tenant's first scan completes. */
|
||||
export function resolveSamplePreviewState({
|
||||
enabled,
|
||||
hasCompletedScan,
|
||||
hasProviders,
|
||||
runningScan,
|
||||
}: SamplePreviewInputs): SamplePreviewState {
|
||||
if (
|
||||
!enabled ||
|
||||
hasCompletedScan !== false ||
|
||||
hasProviders === undefined ||
|
||||
runningScan === undefined
|
||||
) {
|
||||
return INACTIVE;
|
||||
}
|
||||
|
||||
if (runningScan) {
|
||||
return {
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.SCAN_RUNNING,
|
||||
runningScan,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
stage:
|
||||
hasProviders === false
|
||||
? SAMPLE_PREVIEW_STAGE.NO_PROVIDER
|
||||
: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user