Compare commits

..
Author SHA1 Message Date
alejandrobailo 17ad662105 test(ui): assert verdict__in excludes every other sample verdict 2026-10-30 13:53:05 +01:00
alejandrobailo fc31ff260d test(ui): assert verdict__in excludes verdicts outside the list 2026-10-30 13:43:10 +01:00
alejandrobailo b5d086235f test(ui): cover a two-value verdict__in filter on sample issues 2026-10-30 13:33:13 +01:00
alejandrobailo ba2d73a6d9 fix(ui): tighten the sample preview filters, resource counts and guard
- Split comma lists only for __in issue filters
- Count sample resources per resource type, not per service
- Show the real page when the provider lookup fails
2026-10-30 13:20:00 +01:00
alejandrobailo b50a7c5567 fix(ui): keep the sample preview consistent with the API it stands in for
- Filter and summarize sample issues with every forwarded filter
- Count only exploitable and likely issues as actionable
- Show the real page when the running-scan lookup fails
- Stop polling on a missing scan and skip overlapping polls
- Open the in-progress scans tab from View Scan
- Sum sample resources per service and map the SSM check category
- Keep the focused sample attack path connected to the internet
2026-10-30 13:06:49 +01:00
alejandrobailo 8ca66a62ff feat(ui): show sample data on issues and attack paths 2026-10-30 11:12:22 +01:00
alejandrobailo af0476124a feat(ui): show sample data on findings and compliance 2026-10-30 11:12:22 +01:00
alejandrobailo f626aa7a87 feat(ui): show sample data on the overview before the first scan 2026-10-30 11:12:21 +01:00
alejandrobailo 7a78ca970a feat(ui): add sample data preview state, dataset and banner 2026-10-30 11:12:21 +01:00
56 changed files with 3345 additions and 450 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 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
View File
@@ -1,4 +1,4 @@
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 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
-2
View File
@@ -698,8 +698,6 @@ def prowler():
html_output = HTML(
findings=finding_outputs,
file_path=f"{filename}{html_file_suffix}",
# Only the OSS CLI shows the Prowler Cloud hand-off block
show_cloud_cta=True,
)
generated_outputs["regular"].append(html_output)
html_output.batch_write_data_to_file(
@@ -1 +0,0 @@
Prowler Cloud hand-off block after the assessment summary in OSS CLI HTML reports, controlled by an opt-in `show_cloud_cta` option that Prowler Cloud and Private Cloud exports leave disabled
@@ -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
+3 -80
View File
@@ -20,18 +20,6 @@ from prowler.providers.common.provider import Provider
_SAFE_URL_SCHEMES = {"http", "https"}
# Prowler Cloud hand-off block. Only the OSS CLI opts in (see `show_cloud_cta`),
# so Prowler Cloud and Private Cloud reports never contain it. These URLs are
# static: no assessment data is ever appended to them.
PROWLER_CLOUD_CTA_URL = (
"https://cloud.prowler.com/sign-up"
"?utm_source=prowler-html-report&utm_medium=oss&utm_campaign=report-handoff"
)
PROWLER_CLOUD_PRICING_URL = "https://prowler.com/pricing"
PROWLER_CLOUD_IMPORT_DOCS_URL = (
"https://docs.prowler.com/user-guide/tutorials/prowler-import-findings"
)
def _safe_url(url: str) -> str:
"""Return url if its scheme is http/https, otherwise return empty string."""
@@ -59,57 +47,6 @@ def _strip_unsafe_links(html_content: str) -> str:
class HTML(Output):
def __init__(
self,
findings: list[Finding],
file_path: str = None,
file_extension: str = "",
from_cli: bool = True,
show_cloud_cta: bool = False,
) -> None:
"""
Initializes the HTML output.
Args:
findings (list[Finding]): findings to transform
file_path (str): path of the output file
file_extension (str): extension of the output file
from_cli (bool): whether the output is written in one go (CLI) or in batches (API)
show_cloud_cta (bool): whether to render the Prowler Cloud hand-off block after the
assessment summary. Disabled by default; only the OSS CLI enables it.
"""
self._show_cloud_cta = show_cloud_cta
super().__init__(
findings,
file_path=file_path,
file_extension=file_extension,
from_cli=from_cli,
)
@staticmethod
def get_cloud_cta() -> str:
"""
Returns the Prowler Cloud hand-off block shown after the assessment summary in OSS CLI
reports. It is static markup: no scripts, no requests, and no assessment data in URLs.
Returns:
str: HTML markup of the block
"""
return f"""
<div class="row mt-3 prowler-cloud-cta" role="complementary" aria-labelledby="prowler-cloud-cta-title">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title" id="prowler-cloud-cta-title">Keep scanning locally. Bring your findings to Prowler Cloud.</h5>
<p class="card-text">Import your accompanying OCSF report to explore managed security workflows. <a href="{PROWLER_CLOUD_IMPORT_DOCS_URL}" target="_blank" rel="noopener noreferrer">How to import findings</a></p>
<a class="btn btn-primary" href="{PROWLER_CLOUD_CTA_URL}" target="_blank" rel="noopener noreferrer">Explore Prowler Cloud</a>
<a class="btn btn-link" href="{PROWLER_CLOUD_PRICING_URL}" target="_blank" rel="noopener noreferrer">Compare editions</a>
<p class="card-text text-muted mt-2 mb-0"><small>Nothing is uploaded automatically.</small></p>
</div>
</div>
</div>
</div>"""
@staticmethod
def process_markdown(text: str) -> str:
"""
@@ -205,11 +142,7 @@ class HTML(Output):
):
if self._file_descriptor.tell() == 0:
HTML.write_header(
self._file_descriptor,
provider,
stats,
self._from_cli,
show_cloud_cta=getattr(self, "_show_cloud_cta", False),
self._file_descriptor, provider, stats, self._from_cli
)
for finding in self._data:
self._file_descriptor.write(finding)
@@ -227,7 +160,6 @@ class HTML(Output):
provider: Provider,
stats: dict,
from_cli: bool = True,
show_cloud_cta: bool = False,
) -> None:
"""
Writes the header of the HTML file.
@@ -237,17 +169,8 @@ class HTML(Output):
provider (Provider): the provider object
stats (dict): the statistics of the findings
from_cli (bool): whether the request is from the CLI or not
show_cloud_cta (bool): whether to include the Prowler Cloud hand-off block. When
False the block, its styles and its URLs are omitted entirely.
"""
try:
cloud_cta_style = (
"""
@media print { .prowler-cloud-cta { display: none !important; } }"""
if show_cloud_cta
else ""
)
cloud_cta = HTML.get_cloud_cta() if show_cloud_cta else ""
file_descriptor.write(f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -276,7 +199,7 @@ class HTML(Output):
.container-fluid {{font-size: 14px;}}
.float-left {{ float: left !important; max-width: 100%; }}{cloud_cta_style}
.float-left {{ float: left !important; max-width: 100%; }}
</style>
<title>Prowler - The Handy Cloud Security Tool</title>
</head>
@@ -336,7 +259,7 @@ class HTML(Output):
</ul>
</div>
</div>
</div>{cloud_cta}
</div>
</div>
<div class="row-mt-3">
<div class="col-md-12">
@@ -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 = [
+1 -136
View File
@@ -8,12 +8,7 @@ from mock import MagicMock, patch
from prowler.config.config import prowler_version, timestamp
from prowler.lib.cli.redact import redact_argv
from prowler.lib.logger import logger
from prowler.lib.outputs.html.html import (
HTML,
PROWLER_CLOUD_CTA_URL,
PROWLER_CLOUD_IMPORT_DOCS_URL,
PROWLER_CLOUD_PRICING_URL,
)
from prowler.lib.outputs.html.html import HTML
from prowler.providers.github.models import GithubAppIdentityInfo
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider
@@ -1526,133 +1521,3 @@ class TestHTML:
]
output_data = HTML(findings).data[0]
assert 'href="https://hub.prowler.com/check/check-id"' in output_data
def test_write_header_omits_cloud_cta_by_default(self):
mock_file = StringIO()
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
HTML.write_header(mock_file, provider, html_stats)
content = mock_file.getvalue()
assert content == get_aws_html_header(sys.argv[1:])
assert "prowler-cloud-cta" not in content
assert PROWLER_CLOUD_CTA_URL not in content
assert "utm_campaign=report-handoff" not in content
assert PROWLER_CLOUD_PRICING_URL not in content
assert PROWLER_CLOUD_IMPORT_DOCS_URL not in content
def test_write_header_omits_cloud_cta_when_from_cli_but_not_enabled(self):
# `from_cli` alone must never turn the block on: it is a batching flag, not an
# OSS marker, and it defaults to True for every library consumer.
mock_file = StringIO()
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
HTML.write_header(mock_file, provider, html_stats, from_cli=True)
content = mock_file.getvalue()
assert "prowler-cloud-cta" not in content
assert "cloud.prowler.com" not in content
def test_write_header_includes_cloud_cta_when_enabled(self):
mock_file = StringIO()
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
HTML.write_header(mock_file, provider, html_stats, show_cloud_cta=True)
content = mock_file.getvalue()
assert content.count('class="row mt-3 prowler-cloud-cta"') == 1
assert "Keep scanning locally. Bring your findings to Prowler Cloud." in content
assert (
"Import your accompanying OCSF report to explore managed security workflows."
in content
)
assert f'href="{PROWLER_CLOUD_CTA_URL}"' in content
assert ">Explore Prowler Cloud</a>" in content
assert f'href="{PROWLER_CLOUD_PRICING_URL}"' in content
assert ">Compare editions</a>" in content
assert f'href="{PROWLER_CLOUD_IMPORT_DOCS_URL}"' in content
assert ">How to import findings</a>" in content
assert "Nothing is uploaded automatically." in content
assert 'rel="noopener noreferrer"' in content
assert (
"@media print { .prowler-cloud-cta { display: none !important; } }"
in content
)
# Placed after the assessment summary and before the findings table
assert (
content.index("AWS Assessment Summary")
< content.index('class="row mt-3 prowler-cloud-cta"')
< content.index('id="findingsTable"')
)
# No script, form or automatic request is introduced by the block
cta_block = HTML.get_cloud_cta()
assert "<script" not in cta_block
assert "<form" not in cta_block
assert "<img" not in cta_block
assert "<iframe" not in cta_block
def test_cloud_cta_does_not_change_assessment_content(self):
mock_file_oss = StringIO()
mock_file_cloud = StringIO()
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
HTML.write_header(mock_file_oss, provider, html_stats, show_cloud_cta=True)
HTML.write_header(mock_file_cloud, provider, html_stats, show_cloud_cta=False)
oss = mock_file_oss.getvalue()
cloud = mock_file_cloud.getvalue()
stripped = oss.replace(HTML.get_cloud_cta(), "").replace(
"\n @media print { .prowler-cloud-cta { display: none !important; } }",
"",
)
assert stripped == cloud
def test_batch_write_data_to_file_oss_cli_includes_cloud_cta(self):
mock_file = StringIO()
findings = [
generate_finding_output(
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
)
]
output = HTML(findings, show_cloud_cta=True)
output._file_descriptor = mock_file
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
with patch.object(mock_file, "close", return_value=None):
output.batch_write_data_to_file(provider, html_stats)
content = mock_file.getvalue()
assert PROWLER_CLOUD_CTA_URL in content
assert content.count('class="row mt-3 prowler-cloud-cta"') == 1
# Findings and footer are untouched
assert pass_html_finding in content
assert content.endswith(html_footer)
def test_batch_write_data_to_file_api_omits_cloud_cta(self):
# Mirrors how Prowler Cloud / Private Cloud construct the output in the API
mock_file = StringIO()
findings = [
generate_finding_output(
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
)
]
output = HTML(
findings,
file_path=None,
file_extension=".html",
from_cli=False,
)
output._file_descriptor = mock_file
output.close_file = True
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
with patch.object(mock_file, "close", return_value=None):
output.batch_write_data_to_file(provider, html_stats)
content = mock_file.getvalue()
assert "prowler-cloud-cta" not in content
assert "cloud.prowler.com" not in content
assert "utm_campaign=report-handoff" not in content
assert PROWLER_CLOUD_PRICING_URL not in content
assert PROWLER_CLOUD_IMPORT_DOCS_URL not in content
assert pass_html_finding in content
@@ -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} />
);
@@ -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,
@@ -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);
@@ -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
+22 -1
View File
@@ -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>
</>
);
}
+41
View File
@@ -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();
+13 -17
View File
@@ -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>
</>
);
}
+12
View File
@@ -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),
),
},
];
}
+25 -1
View File
@@ -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();
});
});
+19
View File
@@ -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(),
+6 -2
View File
@@ -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
View File
@@ -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>
);
}
+1
View File
@@ -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",
});
});
+3
View File
@@ -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];
+1 -1
View File
@@ -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 =
+7
View File
@@ -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 },
};
+309
View File
@@ -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,
);
+70
View File
@@ -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),
);
});
});
+322
View File
@@ -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);
+247
View File
@@ -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);
+203
View File
@@ -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,
};
}