Compare commits

..
94 changed files with 1462 additions and 6394 deletions

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.47.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.47",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.49.0"
version = "1.48.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.49.0
version: 1.48.0
description: |-
Prowler API specification.
Generated
+31 -4
View File
@@ -1229,6 +1229,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/b3/8ec1268082f4d20cc8bf723a1a8e6b9e330bcc338a4dbcee9c7737e9dc1c/azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a", size = 1072620, upload-time = "2023-07-25T04:47:49.26Z" },
]
[[package]]
name = "azure-mgmt-cognitiveservices"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-mgmt-core" },
{ name = "msrest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/8e/9fcfdd507413a2536c5458b40942705ea8d74fe4e17f05fd1c32bb0225a9/azure_mgmt_cognitiveservices-14.1.0.tar.gz", hash = "sha256:915191374d0adb443863c20aaea2c9f3b9d558a849ac2b78f152249262fdcaf8", size = 184444, upload-time = "2025-10-24T07:28:14.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/ed6bbc01a03991255dd22aa41091360d225f1be20c88710af0f4a6c27459/azure_mgmt_cognitiveservices-14.1.0-py3-none-any.whl", hash = "sha256:3c0eecc00d183842a11d754f4f5c3328ff86f2d6c0a2b81b28dd7e073680258e", size = 290096, upload-time = "2025-10-24T07:28:15.766Z" },
]
[[package]]
name = "azure-mgmt-compute"
version = "34.0.0"
@@ -3503,6 +3517,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
[[package]]
name = "huaweicloudsdksmn"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5d/c0de47d011f1932c9c0ddf669c238d9fad01653d438d38203703526799d7/huaweicloudsdksmn-3.1.204-py3-none-any.whl", hash = "sha256:b0818ea9293e27458c8fa1d2da021c98006cbf9ce01cf17a0f1df598c4da3a6c", size = 323674, upload-time = "2026-07-09T09:04:24.804Z" },
]
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
@@ -4842,8 +4867,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.41.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
version = "5.47.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.47#1a62d7d99aea56db8e351f25f3ff385db7549fc6" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4864,6 +4889,7 @@ dependencies = [
{ name = "azure-mgmt-apimanagement" },
{ name = "azure-mgmt-applicationinsights" },
{ name = "azure-mgmt-authorization" },
{ name = "azure-mgmt-cognitiveservices" },
{ name = "azure-mgmt-compute" },
{ name = "azure-mgmt-containerregistry" },
{ name = "azure-mgmt-containerservice" },
@@ -4907,6 +4933,7 @@ dependencies = [
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
{ name = "huaweicloudsdksmn" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
@@ -4945,7 +4972,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.49.0"
version = "1.48.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5045,7 +5072,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.47" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
-56
View File
@@ -4,62 +4,6 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.47.0" description="October 29, 2026">
### 🧭 Add Provider — One Step to Connect Any Provider
The add-provider wizard follows the same model for every provider: one connect step, then the launch step. Adding an Azure subscription used to take six screens; now the account, its alias and its credentials are one form, and a single submit registers the account, stores the secret and tests the connection.
- A failed connection test shows inline, keeps the values and turns the action into **Retry connection** without registering the account twice.
- Access methods show as cards on the same step for AWS, GCP, Microsoft 365, GitHub, Alibaba Cloud and Cloudflare, and AWS, Azure and GCP switch to their organization flow through tabs.
- Users with the Manage Account permission can invite a teammate to finish the connection for every provider, not only AWS.
- **Ask Lighthouse AI** opens beside the wizard without closing it. It guides the connection from the Prowler docs by default and, when asked, connects the provider itself after confirming the provider, UID and credential field names, never echoing a secret back.
- Product tours and the guided onboarding sequence are removed; new tenants still land on the add-provider wizard.
### 🎯 Issues — Verdicts Explained in the List
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
- The issue list leads with the verdict, shows the attack path under each title and adds a **Why** column with the five evidence legs, so the reason for a verdict reads without opening the issue. The summary tiles become one line with the folded findings and the findings already covered by a control.
- A row opens in the side panel, next to Lighthouse AI, with the verdict, legs, remediation, triage and an **Attack path** tab with the issue graph, instead of expanding the issue inline.
- The menu entry is renamed to **Exploitability issues**.
### ☁️ Azure and AWS Checks
- `network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` cover Azure Application Gateways and their WAF policies, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
- `iam_subscription_owners_between_2_and_3` verifies that each Azure subscription has between two and three Owners, counting owners inherited from management groups, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
- The AWS security group internet exposure checks treat CIDRs in one ingress rule that together cover `0.0.0.0/0` or `::/0`, such as `0.0.0.0/1` plus `128.0.0.0/1`, as open to the internet; such a rule used to pass `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22` and its siblings. Contributed by @harshrajdebug. Thank you!
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🛠️ Improvements and Fixes
- Every MCP Server tool declares `readOnlyHint` and `destructiveHint`, so clients can tell a listing from a write when deciding whether to ask the user. Mutelist and mute rule writes are marked destructive because they hide findings for good.
- The cross-provider compliance tab keeps the frameworks that loaded, with a warning, when one catalog page or framework fails, instead of going blank.
- The NATO D32 compliance framework has its own icon on the compliance cards.
- Lighthouse AI replies no longer blink when the response finishes streaming, and the user avatar no longer requests the current page as an image, which rendered every page twice.
- The Invitations page uses **Status** instead of **State**, and the Invitations and Roles filters show capitalized options.
- **Prowler Cloud:** actions refused because the tenant is over its plan's usage limit, such as launching a scan, saving a schedule, adding a provider or triaging a finding, now link to the plans page.
- **Prowler Cloud:** creating API keys is a Prowler Cloud feature; on Local Server the button opens the upgrade dialog, and existing keys can still be edited and revoked.
- **Prowler Cloud:** issue evaluation after a scan reads the scan's findings through their indexes instead of a server-side cursor that took hours on large scans, so the evaluations no longer queue up behind each other.
- **Prowler Private Cloud:** Lighthouse AI can use Amazon Bedrock with only a region, relying on the installation's own AWS role, when the API allows it.
### 🔐 Security Updates
- `pymongo` upgraded to 4.18.3, `dnspython` to 2.9.0 and `oauthlib` to 4.0.0 in the SDK and the API container image, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure. The API also moves `django-allauth` to 65.19.7, since earlier releases cap `oauthlib` below 4.
- `Authlib` upgraded to 1.9.1 and `dnspython` to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @BabafemiB: Azure `network_application_gateway_waf_enabled`, `network_application_gateway_http2_enabled` and `iam_subscription_owners_between_2_and_3` checks ([#13160](https://github.com/prowler-cloud/prowler/pull/13160), [#13145](https://github.com/prowler-cloud/prowler/pull/13145))
- @harshrajdebug: AWS security group internet exposure fix for split CIDRs ([#13191](https://github.com/prowler-cloud/prowler/pull/13191))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.47.0) for the complete list of changes.
</Update>
<Update label="v5.46.0" description="October 26, 2026">
### 🎯 Issues — Attack Paths with an Exploitability Verdict
@@ -132,8 +132,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.47.0"
PROWLER_API_VERSION="5.47.0"
PROWLER_UI_VERSION="5.46.0"
PROWLER_API_VERSION="5.46.0"
```
<Note>
+1 -1
View File
@@ -52,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.48.0"
prowler_version = "5.47.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+1 -1
View File
@@ -145,7 +145,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.48.0"
version = "5.47.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -16,10 +16,6 @@ import {
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import {
isSampleScanFilter,
sampleFindingGroupResources,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
/**
@@ -191,16 +187,6 @@ async function fetchFindingGroupResourcesEndpoint(
filters = {},
}: FetchFindingGroupResourcesParams,
) {
if (isSampleScanFilter(filters)) {
const status = filters["filter[status]"] ?? filters["filter[status__in]"];
return sampleFindingGroupResources({
checkId,
page,
pageSize,
status: Array.isArray(status) ? status.join(",") : status,
});
}
const headers = await getAuthHeaders({ contentType: false });
const normalizedFilters = normalizeFindingGroupResourceFilters(filters);
@@ -4,10 +4,6 @@ import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { IN_WATCHLIST_FILTER_KEY } from "@/lib/compliance/watchlist";
import {
isSampleId,
sampleComplianceFrameworks,
} from "@/lib/sample-preview/sample-records";
import { isCloud } from "@/lib/shared/env";
import type { FindingComplianceFrameworksResult } from "@/types/compliance-watchlist";
@@ -36,9 +32,6 @@ export const getFindingComplianceFrameworks = async (
}
const { inWatchlist = false } = parsedOptions.data;
if (isSampleId(parsedFindingId.data)) {
return sampleComplianceFrameworks(parsedFindingId.data);
}
if (!isCloud()) return { frameworks: [], unavailable: true };
try {
@@ -11,11 +11,6 @@ import {
} from "@/lib";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isSampleResourceUid,
sampleFindingsDocument,
sampleFindingsQuery,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500;
@@ -266,15 +261,6 @@ export const getLatestFindingsByResourceUid = async ({
pageSize?: number;
includeMuted?: boolean;
}) => {
if (isSampleResourceUid(resourceUid)) {
return sampleFindingsDocument(
sampleFindingsQuery({
"filter[resource_uid]": resourceUid,
"filter[status]": "FAIL",
}),
);
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
@@ -1,8 +1,6 @@
import { isSampleId } from "@/lib/sample-preview/sample-records";
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
import {
FINDING_TRIAGE_BILLING_HREF,
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_STATUS,
FINDING_TRIAGE_STATUS_LABELS,
@@ -145,12 +143,6 @@ const createSummary = (
summary.disabledReason = options.disabledReason;
}
// Sample findings have no triage record to write to.
if (isSampleId(summary.findingId)) {
summary.canEdit = false;
summary.disabledReason = FINDING_TRIAGE_DISABLED_REASON.SAMPLE;
}
return summary;
};
-21
View File
@@ -6,14 +6,6 @@ import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findi
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
getSampleFinding,
isSampleId,
isSampleScanFilter,
sampleFindingDocument,
sampleFindingsDocument,
sampleFindingsQuery,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
const withFindingTriageSummaries = <T extends { data?: unknown }>(
@@ -32,14 +24,6 @@ export const getFindings = async ({
sort = "",
filters = {},
}) => {
if (isSampleScanFilter(filters)) {
return withFindingTriageSummaries(
sampleFindingsDocument(
sampleFindingsQuery(filters).slice(0, Number(pageSize) || 10),
),
);
}
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1)
@@ -180,11 +164,6 @@ export const getFindingById = async (
include = "",
_options?: GetFindingByIdOptions,
) => {
if (isSampleId(findingId)) {
const finding = getSampleFinding(findingId);
return finding ? sampleFindingDocument(finding) : undefined;
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
+16 -41
View File
@@ -1,13 +1,6 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { getSampleIssueDetail } from "@/lib/sample-preview/sample-issues";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
sampleFindingsQuery,
sampleIssueFindingsDocument,
} from "@/lib/sample-preview/sample-records";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
ISSUE_FINDINGS_LIMIT,
@@ -93,11 +86,6 @@ export const getIssues = async ({
export const getIssue = async (
id: string,
): Promise<IssueDetailResponse | undefined> => {
if (isSampleId(id)) {
const issue = getSampleIssueDetail(id);
return issue ? { data: issue } : undefined;
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`);
@@ -118,13 +106,6 @@ export const getIssueFindings = async (
if (!scanId) return undefined;
const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT);
if (uids.length === 0) return [];
if (isSampleId(scanId)) {
return toIssueFindings(
sampleIssueFindingsDocument(
sampleFindingsQuery({ "filter[uid__in]": uids.join(",") }),
),
);
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings`);
@@ -175,38 +156,32 @@ export const updateIssueTriage = async (
id: string,
{ status, note }: UpdateIssueTriageInput,
): Promise<IssueWriteResult> =>
isSampleId(id)
? { error: SAMPLE_WRITE_MESSAGE }
: writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage`,
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
{
type: "issue-triages",
attributes: { status, ...(note ? { note } : {}) },
},
);
writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage`,
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
{
type: "issue-triages",
attributes: { status, ...(note ? { note } : {}) },
},
);
/** Add a note to an issue's triage without changing its status. */
export const addIssueNote = async (
id: string,
body: string,
): Promise<IssueWriteResult> =>
isSampleId(id)
? { error: SAMPLE_WRITE_MESSAGE }
: writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage/notes`,
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
{
type: "issue-triage-notes",
attributes: { body },
},
);
writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage/notes`,
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
{
type: "issue-triage-notes",
attributes: { body },
},
);
export const getIssueNotes = async (
id: string,
): Promise<IssueTriageNote[] | undefined> => {
if (isSampleId(id)) return [];
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
-8
View File
@@ -3,10 +3,6 @@
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-records";
import {
DeleteMuteRuleActionState,
@@ -135,10 +131,6 @@ export const createMuteRule = async (
};
}
if (findingIds.some(isSampleId)) {
return { errors: { general: SAMPLE_WRITE_MESSAGE } };
}
try {
const url = new URL(`${apiBaseUrl}/mute-rules`);
-7
View File
@@ -12,10 +12,6 @@ import {
sanitizeErrorMessage,
} from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isSampleId,
sampleResourceEvents,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { isCloud } from "@/lib/shared/env";
import { OrganizationResource } from "@/types/organizations";
@@ -188,9 +184,6 @@ export const getResourceEvents = async (
pageSize?: number;
} = {},
) => {
if (isSampleId(resourceId)) {
return { data: sampleResourceEvents(resourceId) };
}
if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) {
return { error: "Invalid resource ID format." };
}
@@ -1,75 +0,0 @@
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>
</>
);
}
@@ -1,53 +0,0 @@
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("only leaves links live where the target page shows the sample", () => {
const { container } = render(<OverviewSamplePreview showIssues={false} />);
const links = Array.from(container.querySelectorAll("a"));
const live = links.filter((link) => !link.closest("[inert]"));
expect(live.length).toBeGreaterThan(0);
expect(live.length).toBeLessThan(links.length);
for (const link of live) {
expect(link.getAttribute("href")).toMatch(/^\/(findings|issues)(\?|$)/);
}
});
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);
});
});
@@ -1,152 +0,0 @@
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 into pages with no sample data (compliance details,
// resources). Cards that link to Findings or Issues stay live: those pages
// show the sample and apply the link's filters.
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}>
<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}
/>
</FeatureFlagGate>
) : undefined
}
checkFindings={
<StatusChart
failFindingsData={{
total: SAMPLE_STATUS.fail,
new: SAMPLE_STATUS.failNew,
}}
passFindingsData={{
total: SAMPLE_STATUS.pass,
new: SAMPLE_STATUS.passNew,
}}
/>
}
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
resourcesInventory={
<Inert>
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
</Inert>
}
complianceWatchlist={
<Inert>
<ComplianceWatchlist items={complianceItems} hasWatchlist />
</Inert>
}
serviceWatchlist={
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
}
attackSurface={
<AttackSurface
items={adaptAttackSurfaceOverview(SAMPLE_ATTACK_SURFACE_RESPONSE)}
/>
}
topIssues={
showIssues ? (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<TopIssues issues={SAMPLE_ISSUES} scope={NO_SCOPE} />
</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 />}
/>
);
}
@@ -1,36 +0,0 @@
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();
});
});
@@ -1,83 +0,0 @@
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,8 +27,6 @@ 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;
@@ -229,15 +227,12 @@ const getColumns = ({
* Table displaying AWS account Attack Paths scans
* Shows scan metadata and allows selection of completed scans
*/
export const ScanListTable = ({
scans,
selectedScanId: selectedScanIdOverride,
}: ScanListTableProps) => {
export const ScanListTable = ({ scans }: ScanListTableProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const selectedScanId = selectedScanIdOverride ?? searchParams.get("scanId");
const selectedScanId = searchParams.get("scanId");
const pageSize = parsePageParam(
searchParams.get("scanPageSize"),
DEFAULT_PAGE_SIZE,
@@ -20,19 +20,15 @@ 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(
/** 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);
export function useAttackPathScans(): UseAttackPathScansResult {
const [scans, setScans] = useState<AttackPathScan[]>([]);
const [scansLoading, setScansLoading] = useState(true);
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) {
@@ -47,7 +43,6 @@ export function useAttackPathScans(
// 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,13 +56,8 @@ 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[],
initialQueryId: string | null = null,
) => {
const [selectedQuery, setSelectedQuery] = useState<string | null>(
initialQueryId,
);
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
const getQueryById = (queryId: string | null) =>
availableQueries.find((query) => query.id === queryId);
@@ -1,80 +0,0 @@
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,7 +35,6 @@ 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,
@@ -75,24 +74,18 @@ import {
const SCROLL_CONTAINER_CLASS =
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
interface AttackPathsPageProps {
/** Sample mode: renders this data, never calls the attack-paths API. */
sample?: SampleAttackPaths;
}
export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
export default function AttackPathsPage() {
const searchParams = useSearchParams();
const pathname = usePathname();
// 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 scanId = searchParams.get("scanId");
const graphState = useGraphState();
const finding = useFindingDetails();
const { toast } = useToast();
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
useAttackPathScans(sample ? [sample.scan] : undefined);
useAttackPathScans();
const [queriesLoading, setQueriesLoading] = useState(!sample);
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<GraphHandle>(null);
@@ -101,26 +94,13 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
const hasResetRef = useRef(false);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>(
sample ? [sample.query] : [],
);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
// 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, 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);
};
const queryBuilder = useQueryBuilder(queries);
useEffect(() => {
if (!hasResetRef.current) {
@@ -130,8 +110,7 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
}, [graphState]);
useEffect(() => {
if (sample) showSampleGraph(sample);
else graphState.resetGraph();
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.
@@ -147,8 +126,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
useEffect(() => {
if (sample) return;
const loadQueries = async () => {
if (!scanId) {
setQueriesError("No scan selected");
@@ -191,7 +168,7 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
};
loadQueries();
}, [sample, scanId, toast]);
}, [scanId, toast]);
const showErrorToast = (title: string, description: string) => {
toast({
@@ -202,11 +179,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
};
const handleExecuteQuery = async () => {
if (sample) {
showSampleGraph(sample);
return;
}
if (!scanId || !queryBuilder.selectedQuery) {
showErrorToast("Error", "Please select both a scan and a query");
return;
@@ -348,13 +320,10 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
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;
}
@@ -437,26 +406,24 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
graphState.selectedNode ?? graphState.filteredNode;
const lighthouseGraphData = graphState.fullData ?? graphState.data;
const lighthouseExecution = graphState.loading ? null : graphState.execution;
// 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;
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;
return (
<div className="flex flex-col gap-6">
@@ -498,12 +465,9 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
/>
) : (
<>
{/* 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>
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
{isViewingPreviousCycleData && (
<StatusAlert
+1 -22
View File
@@ -1,22 +1 @@
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} />
</>
);
}
export { default } from "./(workflow)/query-builder/attack-paths-page";
@@ -1,19 +0,0 @@
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>
);
};
@@ -1,61 +0,0 @@
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();
});
});
@@ -1,62 +0,0 @@
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,11 +12,6 @@ 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 {
@@ -24,7 +19,6 @@ const {
complianceOverviewGridSpy,
getComplianceOverviewMetadataInfoMock,
getCompliancesOverviewMock,
getSamplePreviewStateMock,
getScanMock,
getScansMock,
getThreatScoreMock,
@@ -35,9 +29,6 @@ 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(),
@@ -61,14 +52,6 @@ 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,
}));
@@ -172,30 +155,6 @@ describe("Compliance overview page", () => {
});
});
describe("Compliance sample preview", () => {
it("renders the sample and skips the real fetches until the first scan completes", async () => {
// Given - a Cloud tenant whose first scan has not completed yet
getSamplePreviewStateMock.mockResolvedValue({
active: true,
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
runningScan: null,
});
// When
render(
(await Compliance({
searchParams: Promise.resolve({}),
})) as ReactElement,
);
// Then
expect(screen.getByText("Sample compliance preview")).toBeInTheDocument();
expect(getScansMock).not.toHaveBeenCalled();
expect(getCompliancesOverviewMock).not.toHaveBeenCalled();
expect(loadComplianceWatchlistContextMock).not.toHaveBeenCalled();
});
});
describe("Compliance overview task response", () => {
beforeEach(() => {
vi.clearAllMocks();
+17 -13
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,10 +30,8 @@ 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 {
@@ -73,16 +71,6 @@ 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 || {});
@@ -421,3 +409,19 @@ 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>
);
};
@@ -1,121 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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("next-auth/react", () => ({
useSession: () => ({ data: null, status: "unauthenticated" }),
}));
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);
// jsdom has no IntersectionObserver; the drill-down's infinite scroll needs one.
vi.stubGlobal(
"IntersectionObserver",
class {
observe() {}
disconnect() {}
},
);
});
it("renders every failed sample check 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]")).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(getScanMock).not.toHaveBeenCalled();
expect(resolveFindingIdsMock).not.toHaveBeenCalled();
});
it("expands a sample group into its resources from the sample records", async () => {
const user = userEvent.setup();
render(<FindingsSamplePreview state={STATE} />);
await user.click(
screen.getByText("Ensure S3 buckets are not publicly accessible"),
);
expect(
await screen.findByText("acme-customer-exports"),
).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
it("applies the severity an Overview link carried, with a way back to all", () => {
render(
<FindingsSamplePreview
state={STATE}
searchParams={{
"filter[severity__in]": "critical",
"filter[status__in]": "FAIL",
}}
/>,
);
const critical = SAMPLE_FAILED_CHECKS.filter(
(check) => check.severity === "critical",
);
const high = SAMPLE_FAILED_CHECKS.find(
(check) => check.severity === "high",
)!;
for (const check of critical) {
expect(screen.getByText(check.title)).toBeInTheDocument();
}
expect(screen.queryByText(high.title)).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Show all" })).toHaveAttribute(
"href",
"/findings",
);
});
});
@@ -1,50 +0,0 @@
import Link from "next/link";
import { FindingsGroupTable } from "@/components/findings/table";
import { Button } from "@/components/shadcn";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import { filterSampleFindingGroups } 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";
import type { SearchParamsProps } from "@/types/components";
// Scoping to the sample scan is what makes every drill-down action answer
// from the sample records instead of the API.
const SAMPLE_SCAN_FILTERS: Record<string, string> = {
"filter[scan]": SAMPLE_ACCOUNT.scanId,
};
interface FindingsSamplePreviewProps {
state: SamplePreviewActive;
/** The filters an Overview link carried; there are no filter controls. */
searchParams?: SearchParamsProps;
}
/** Sample finding groups: no filter controls, no metadata (so no Lighthouse context). */
export function FindingsSamplePreview({
state,
searchParams = {},
}: FindingsSamplePreviewProps) {
const { groups, filtered } = filterSampleFindingGroups(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
{filtered && (
<p className="text-text-neutral-tertiary mb-4 text-sm">
Showing {groups.length} of the sample checks, filtered from the
Overview.{" "}
<Button variant="link" size="link-sm" asChild>
<Link href="/findings">Show all</Link>
</Button>
</p>
)}
<FindingsGroupTable
data={groups}
resolvedFilters={SAMPLE_SCAN_FILTERS}
hasHistoricalData
/>
</>
);
}
-15
View File
@@ -24,32 +24,17 @@ 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}
searchParams={await searchParams}
/>
</ContentLayout>
);
}
const resolvedSearchParams = await searchParams;
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getIssue, notFound } = vi.hoisted(() => ({
getIssue: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({ notFound }));
vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-route", () => ({
FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
<div data-testid="issue-detail">{scanId}</div>
),
}));
import IssuePage from "./page";
const params = Promise.resolve({ id: "issue-1" });
describe("IssuePage", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockReset();
});
it("returns not found in Local Server without asking the API", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
expect(getIssue).not.toHaveBeenCalled();
});
it("returns not found when the API has no such issue", async () => {
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
});
it("shows the issue with the detail the list opens in place", async () => {
getIssue.mockResolvedValue({
data: {
type: "issues",
id: "issue-1",
attributes: {
pattern: "aws_public_bucket",
cloud: "aws",
account: "prod",
title: "Public bucket",
verdict: "likely",
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
render(await IssuePage({ params }));
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(
screen.getByRole("heading", { name: "Public bucket" }),
).toBeInTheDocument();
expect(screen.getByText("Likely")).toBeInTheDocument();
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
"href",
"/issues",
);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { ShieldAlert } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/actions/issues";
import { IssueDetail } from "@/components/issues/issue-detail";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
/**
* One issue on its own URL, so a path can be shared or linked from a ticket.
* The body is the same detail the list opens in place.
*/
export default async function IssuePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!isCloud()) notFound();
const { id } = await params;
const issue = (await getIssue(id))?.data;
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
if (!issue?.attributes) notFound();
const { attributes, relationships } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
</Button>
<Card variant="base" className="min-w-0 gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-tertiary text-xs">
{attributes.pattern}
</span>
</div>
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
{attributes.title}
</h2>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<IssueDetail
id={issue.id}
issue={attributes}
scanId={relationships?.scan?.data?.id}
providerId={relationships?.provider?.data?.id}
/>
</Card>
</div>
</FeatureFlagRoute>
</ContentLayout>
);
}
@@ -1,133 +0,0 @@
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; the panel has its own tests.
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 clickable, 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]")).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,13 +7,7 @@ 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 = [
@@ -27,21 +21,15 @@ 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. */
providerUids?: Record<string, string>;
}) => {
const filters: Record<string, string> = {};
ISSUE_FILTER_KEYS.forEach((key) => {
@@ -57,20 +45,16 @@ export const IssuesList = async ({
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
),
);
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 [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 attributes = summary?.data?.attributes;
// handleApiResponse resolves {error, status} on a 4xx and undefined on a
@@ -103,11 +87,7 @@ export const IssuesList = async ({
{rows === null ? (
<IssuesLoadError />
) : rows.length ? (
<IssuesTable
issues={rows}
metadata={issues?.meta}
providerUids={providerUids}
/>
<IssuesTable issues={rows} metadata={issues?.meta} />
) : (
<p className="text-text-neutral-tertiary text-sm">
No issues match this filter.
@@ -116,61 +96,3 @@ export const IssuesList = async ({
</>
);
};
type SampleIssue = (typeof SAMPLE_ISSUES)[number];
/** The value each forwarded filter matches on a sample issue. */
const SAMPLE_FILTER_FIELDS: Record<
(typeof ISSUE_FILTER_KEYS)[number],
(issue: SampleIssue) => string | undefined
> = {
"filter[verdict]": (issue) => issue.attributes.verdict,
"filter[verdict__in]": (issue) => issue.attributes.verdict,
"filter[cloud]": (issue) => issue.attributes.cloud,
"filter[pattern]": (issue) => issue.attributes.pattern,
"filter[severity]": (issue) => issue.attributes.severity,
"filter[provider_type]": (issue) => issue.attributes.cloud,
"filter[provider_type__in]": (issue) => issue.attributes.cloud,
"filter[provider_alias]": () => SAMPLE_ACCOUNT.alias,
"filter[provider_id__in]": (issue) => issue.relationships?.provider?.data?.id,
// The sample account belongs to no provider group.
"filter[provider_groups__in]": () => undefined,
"filter[triage_status]": (issue) => issue.attributes.triage_status,
"filter[triage_status__in]": (issue) => issue.attributes.triage_status,
};
const matchesSampleFilters = (
issue: SampleIssue,
filters: Record<string, string>,
) =>
Object.entries(filters).every(([key, value]) => {
const field =
SAMPLE_FILTER_FIELDS[key as keyof typeof SAMPLE_FILTER_FIELDS];
const actual = field?.(issue);
// Only `__in` filters take a list; the rest match one literal value.
const accepted = key.endsWith("__in]") ? value.split(",") : [value];
return actual !== undefined && accepted.includes(actual);
});
/** The sample summary and rows, filtered like the API would. */
function sampleIssues(
filters: Record<string, string>,
scope: Record<string, string>,
): [IssuesOverviewResponse, IssueResponse] {
const scoped = SAMPLE_ISSUES.filter((issue) =>
matchesSampleFilters(issue, scope),
);
return [
{
data: {
type: "issues-overview",
attributes: summarizeSampleIssues(scoped),
},
},
{
data: SAMPLE_ISSUES.filter((issue) =>
matchesSampleFilters(issue, filters),
),
},
];
}
+1 -25
View File
@@ -1,14 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
getAllProviders,
getAllProviderGroups,
getSamplePreviewState,
notFound,
} = vi.hoisted(() => ({
const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
getAllProviders: vi.fn(),
getAllProviderGroups: vi.fn(),
getSamplePreviewState: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
@@ -28,12 +22,6 @@ 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";
@@ -41,7 +29,6 @@ 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");
@@ -58,15 +45,4 @@ 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();
});
});
+3 -36
View File
@@ -8,20 +8,12 @@ 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_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import { IssuesList } from "./_components/issues-list";
const SAMPLE_PROVIDER_UIDS = {
[SAMPLE_ACCOUNT.providerId]: SAMPLE_ACCOUNT.uid,
};
export default async function Issues({
searchParams,
}: {
@@ -30,37 +22,11 @@ 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
providerUids={SAMPLE_PROVIDER_UIDS}
/>
</FeatureFlagRoute>
</ContentLayout>
);
}
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
const providers = providersData?.data ?? [];
// The issue only references its provider; the uid shows in the panel.
const providerUids = Object.fromEntries(
providers.map((provider) => [provider.id, provider.attributes.uid]),
);
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
{/* Deployment gate above (notFound); per-account rollout here, also a
@@ -75,12 +41,13 @@ export default async function Issues({
>
<IssuesList
searchParams={resolvedSearchParams}
providerUids={providerUids}
filters={
// The three scope selectors share one row on desktop, like the
// other list pages.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ProviderAccountSelectors providers={providers} />
<ProviderAccountSelectors
providers={providersData?.data ?? []}
/>
<ProviderGroupSelector
groups={providerGroupsData?.data ?? []}
/>
+2 -6
View File
@@ -38,12 +38,8 @@ describe("Overview page", () => {
it("uses the responsive container breakpoint for the primary metric row", () => {
// Given
const gridSource = readFileSync(
path.join(currentDir, "_overview/_components/overview-grid.tsx"),
"utf8",
);
const metricRowClassName = gridSource.match(
/<div className="([^"]+)">\s*\{threatScore\}/,
const metricRowClassName = source.match(
/<div className="([^"]+)">\s*<Suspense fallback={<ThreatScoreSkeleton \/>}>/,
)?.[1];
// When
+71 -85
View File
@@ -12,16 +12,12 @@ 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";
@@ -46,7 +42,6 @@ import {
RiskSeverityChartSkeleton,
RiskSeverityChartSSR,
} from "./_overview/risk-severity";
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
@@ -65,32 +60,12 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
// 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>
);
}
const [providersData, providerGroupsData, lighthouseBannerHref] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
]);
return (
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
@@ -124,71 +99,82 @@ export default async function Home({
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
<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={
<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">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
}
topIssues={
isCloud() ? (
{isCloud() && (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<Suspense fallback={<TopIssuesSkeleton />}>
<TopIssuesSSR searchParams={resolvedSearchParams} />
</Suspense>
</FeatureFlagGate>
) : undefined
}
severityOverTime={
)}
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
}
graphsTabs={
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>
}
/>
</div>
</div>
<div className="mt-6">
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
}
@@ -42,8 +42,6 @@ import { FindingTriageStatusControl } from "./finding-triage-status-control";
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
export const SAMPLE_TRIAGE_COPY =
"Sample data. Connect your cloud account to triage your own findings.";
const getDisabledCopy = ({
triage,
@@ -58,10 +56,6 @@ const getDisabledCopy = ({
return CLOUD_ONLY_TOOLTIP_COPY;
}
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.SAMPLE) {
return SAMPLE_TRIAGE_COPY;
}
// Status-picker only: notes stay available on resolved findings.
if (lockResolved && isTriageStatusLocked(triage.status)) {
return FINDING_TRIAGE_RESOLVED_LOCKED_COPY;
@@ -707,43 +707,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
});
});
describe("ResourceDetailDrawerContent — sample data", () => {
it("should not link sample resources to pages or consoles that lack them", () => {
// Given
const { container } = render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={{
...mockFinding,
id: "sample-finding-s3_bucket_public_access-1",
resourceId: "sample-resource-s3_bucket-1",
resourceUid: "arn:aws:s3:::acme-customer-exports",
providerType: "aws",
}}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// Then
expect(
screen.queryByRole("link", { name: "View Resource" }),
).not.toBeInTheDocument();
const hrefs = Array.from(container.querySelectorAll("a")).map((link) =>
link.getAttribute("href"),
);
expect(hrefs.filter((href) => href?.includes("aws.amazon.com"))).toEqual(
[],
);
});
});
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
it("should render Triage and Add Triage Note for other findings rows", () => {
// Given
@@ -84,7 +84,6 @@ import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { getRegionFlag } from "@/lib/region-flags";
import { isSampleId } from "@/lib/sample-preview/sample-records";
import { isCloud } from "@/lib/shared/env";
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -337,22 +336,17 @@ export function ResourceDetailDrawerContent({
const regionFilter = searchParams.get("filter[region__in]");
const nativeIacConfig = resolveNativeIacConfig(providerType);
const showOverviewCheckMetaContent = showCheckMetaContent;
// Resources and Scans have no sample data, so sample records link nowhere.
const resourceDetailHref =
f?.resourceId && !isSampleId(f.resourceId)
? buildResourceDetailHref(f.resourceId)
: null;
// The sample account is not theirs: its console links would open nothing.
const externalResourceTarget = isSampleId(f?.resourceId)
? null
: resolveExternalTarget({
providerType,
resourceUid,
providerUid,
resourceName,
findingUid: f?.uid,
region: resourceRegion,
});
const resourceDetailHref = f?.resourceId
? buildResourceDetailHref(f.resourceId)
: null;
const externalResourceTarget = resolveExternalTarget({
providerType,
resourceUid,
providerUid,
resourceName,
findingUid: f?.uid,
region: resourceRegion,
});
const findingRecommendationUrl = f?.remediation.recommendation.url;
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
@@ -498,9 +492,8 @@ export function ResourceDetailDrawerContent({
</div>
)}
{/* Lighthouse skill launcher: chip rail under the title. Sample
findings exist only in the UI, so Lighthouse has nothing to read. */}
{isCloud() && !isNavigating && !isSampleId(currentFinding?.id) && (
{/* Lighthouse skill launcher: chip rail under the title. */}
{isCloud() && !isNavigating && (
<LighthouseSkillsRail
onLaunchSkill={handleLaunchSkill}
onSubmitPrompt={handleSubmitPrompt}
@@ -1137,18 +1130,16 @@ export function ResourceDetailDrawerContent({
<p className="text-text-neutral-secondary text-xs">
Showing the latest scan that evaluated this finding
</p>
{!isSampleId(f?.scan?.id) && (
<Button variant="link" size="link-sm" asChild>
<Link
href={`/scans?filter[id__in]=${f?.scan?.id}`}
target="_blank"
rel="noopener noreferrer"
>
View scan
<ExternalLink className="size-3" />
</Link>
</Button>
)}
<Button variant="link" size="link-sm" asChild>
<Link
href={`/scans?filter[id__in]=${f?.scan?.id}`}
target="_blank"
rel="noopener noreferrer"
>
View scan
<ExternalLink className="size-3" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<InfoField label="Scan Name" variant="compact">
+22 -36
View File
@@ -1,10 +1,14 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { IssueAttributes } from "@/types/issues";
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: () => <p>graph</p>,
}));
@@ -14,53 +18,35 @@ import { IssueAttackPath } from "./issue-attack-path";
const issue = { legs: {} } as IssueAttributes;
describe("IssueAttackPath", () => {
it("says there is nothing to draw, not a load error, without resources", () => {
// Given: no resources, and findings that could not load without a scan
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: [],
findings: undefined,
findingsTruncated: false,
}}
onRetry={vi.fn()}
onOpenFinding={vi.fn()}
/>,
);
it("says there is nothing to draw, not a load error, without resources", async () => {
// Given: no resources, and findings that cannot load without a scan
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
screen.getByText("This issue has no affected resources to draw."),
await screen.findByText("This issue has no affected resources to draw."),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("offers a retry when the findings for the drawn resources fail", async () => {
// Given
const onRetry = vi.fn();
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: ["bucket"],
findings: undefined,
findingsTruncated: false,
}}
onRetry={onRetry}
onOpenFinding={vi.fn()}
/>,
);
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
});
// When
expect(
screen.getByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(onRetry).toHaveBeenCalled();
expect(
await screen.findByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
});
+19 -18
View File
@@ -2,25 +2,26 @@
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { IssueAttributes } from "@/types/issues";
import type { IssueEvidenceState } from "./use-issue-evidence";
interface IssueAttackPathProps {
id: string;
issue: IssueAttributes;
evidence: IssueEvidenceState;
onRetry: () => void;
onOpenFinding: (findingId: string) => void;
scanId?: string;
providerId?: string;
}
/** The issue's graph, or why there is none to draw yet. */
/** The issue's graph for the side panel; findings open on the full page. */
export const IssueAttackPath = ({
id,
issue,
evidence,
onRetry,
onOpenFinding,
scanId,
providerId,
}: IssueAttackPathProps) => {
if (evidence.status === "loading") {
const { state, retry } = useIssueEvidence(id, scanId, providerId);
if (state.status === "loading") {
return (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading the attack path…
@@ -29,29 +30,29 @@ export const IssueAttackPath = ({
}
// Nothing to draw beats a findings error: without resources there is no
// graph for the findings to land on.
if (evidence.status === "loaded" && evidence.resources.length === 0) {
if (state.status === "loaded" && state.resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources to draw.
</p>
);
}
if (evidence.status === "error" || evidence.findings === undefined) {
if (state.status === "error" || state.findings === undefined) {
return (
<IssuesLoadError
message="Could not load the attack path for this issue."
onRetry={onRetry}
onRetry={retry}
/>
);
}
return (
<IssueGraph
issue={issue}
resourceUids={evidence.resources}
findings={evidence.findings}
onOpenFinding={onOpenFinding}
{...(evidence.providerId && evidence.scanId
? { providerId: evidence.providerId, scanId: evidence.scanId }
resourceUids={state.resources}
findings={state.findings}
readOnly
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
);
+7 -16
View File
@@ -9,7 +9,6 @@ import {
complianceFrameworkLabel,
} from "@/components/compliance/compliance-framework-chip";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
import { IssueFinding } from "@/types/issues";
@@ -116,21 +115,13 @@ export const IssueCompliance = ({
);
};
// Laid out like the finding panel's "Compliance Frameworks" header row.
return (
<section aria-label="Compliance affected" className="flex flex-col gap-1.5">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
<section aria-label="Compliance affected">
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
{state.status === "loading" ? (
<div className="flex flex-wrap items-center gap-2">
<span role="status" className="sr-only">
Loading frameworks…
</span>
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading frameworks…
</p>
) : state.status === "unavailable" ? (
<IssuesLoadError
message="Could not load the frameworks for this issue."
@@ -139,7 +130,7 @@ export const IssueCompliance = ({
) : (
<>
{state.frameworks.length === 0 ? (
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary text-sm">
{incomplete
? "None of the findings checked map to a framework requirement."
: "None of the findings behind this issue map to a framework requirement."}
@@ -157,7 +148,7 @@ export const IssueCompliance = ({
</div>
)}
{incomplete && (
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary mt-2 text-xs">
Not every finding could be checked, so this list may be
incomplete.
</p>
+326
View File
@@ -0,0 +1,326 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDetail } from "@/components/issues/issue-detail";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues";
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
getIssueMock: vi.fn(),
getIssueFindingsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({
getIssue: getIssueMock,
getIssueFindings: getIssueFindingsMock,
getIssueNotes: async () => [],
}));
vi.mock("@/components/issues/issue-compliance", () => ({
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
truncated ? <p>Compliance from a capped preview</p> : null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<div data-testid="issue-graph">{resourceUids.length} resources</div>
),
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const issue = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
describe("IssueDetail", () => {
beforeEach(() => {
getIssueMock.mockReset();
getIssueFindingsMock.mockResolvedValue([]);
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("links to the issue's scan findings and reveals every folded resource", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: resourceUids } },
});
const user = userEvent.setup();
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
expect(
screen.getByRole("link", { name: /failed findings/i }),
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// The list leaves the uids out; the detail fetches them when it opens.
expect(getIssueMock).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
});
it("says what a path buys the attacker and where it starts", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
pattern: "aws_public_bucket",
outcome: "data_exposure",
attacker_start: "internet",
}}
/>,
);
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given - one more finding than the preview loads
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: [],
finding_uids: Array.from(
{ length: ISSUE_FINDINGS_LIMIT + 1 },
(_, i) => `uid-${i}`,
),
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
getIssueFindingsMock.mockResolvedValue([
{
id: "f1",
checkId: "check-a",
checkTitle: "Check A",
severity: "high",
resourceUid: "r1",
},
]);
// When
render(<IssueDetail id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(<IssueDetail id="issue-1" issue={issue} />);
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("links each affected resource to it on the Resources page", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
});
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
expect(
await screen.findByRole("link", {
name: "Open in Resources: arn:aws:s3:::bucket",
}),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
);
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: resourceUids,
finding_uids: ["finding-uid"],
},
},
});
getIssueFindingsMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
// When
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(await screen.findByTestId("issue-graph")).toBeInTheDocument();
expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("names the conditions that make the path, explained by each leg", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
outcome: "data_exposure",
attacker_start: "internet",
legs: {
...issue.legs,
controls: {
state: "no",
text: "The public access block is off.",
evidence: [],
},
},
}}
/>,
);
const factors = screen.getByRole("list", { name: "Risk factors" });
expect(factors).toHaveTextContent("Internet-reachable");
expect(screen.getByText("No control blocking")).toHaveAttribute(
"title",
"The public access block is off.",
);
});
it("lists each folded finding once, with its own triage and a link to it", async () => {
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
const section = await screen.findByRole("region", { name: "Findings" });
const link = within(section).getByRole("link", {
name: "Bucket is public",
});
const destination = new URL(link.getAttribute("href")!, "https://app.test");
expect(Object.fromEntries(destination.searchParams)).toEqual({
"filter[scan__in]": "scan-1",
"filter[check_id__in]": "s3_bucket_public_access",
"filter[resource_uid]": "other-bucket",
"filter[muted]": "include",
expandedCheckId: "s3_bucket_public_access",
});
expect(within(section).getAllByRole("listitem")).toHaveLength(2);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument();
});
it("opens the global Jira flow with unique finding IDs", async () => {
// Given
const user = userEvent.setup();
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_public",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "open",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = render(
<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />,
);
// When
await user.click(
await screen.findByRole("button", { name: "Send to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the detail closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: issue.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { Send } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssueTriage } from "@/components/issues/issue-triage";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { riskFactors } from "@/lib/issues/risk-factors";
import {
LEG_LABELS,
LEG_QUESTIONS,
LEG_STATE_LABELS,
legBadgeVariant,
} from "@/lib/issues/verdicts";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
LegName,
} from "@/types/issues";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueDetailProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
}
export const IssueDetail = ({
id,
issue,
scanId,
providerId,
}: IssueDetailProps) => {
const openJiraDispatch = useJiraDispatchStore(
(state) => state.openJiraDispatch,
);
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
const { state, retry } = useIssueEvidence(id, scanId, providerId);
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
state.status === "loaded"
? Array.from(
new Map(
state.findings?.map((finding) => [finding.id, finding]),
).values(),
)
: [];
const jiraPayload = createJiraDispatchPayload({
findingTitle: issue.title,
targetIds: uniqueFindings.map((finding) => finding.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`,
});
return (
<div className="flex min-w-0 flex-col gap-4 py-4">
{/* The conditions that hold at once: the verdict says how bad, these
say why, and which part to argue with. */}
{factors.length > 0 && (
<ul aria-label="Risk factors" className="flex flex-wrap gap-1.5">
{factors.map((factor) => (
<li key={factor.label}>
<Badge variant={factor.variant} title={factor.title}>
{factor.label}
</Badge>
</li>
))}
</ul>
)}
{state.status === "loaded" && state.findings === undefined && (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
)}
{state.status === "loaded" &&
state.findings !== undefined &&
state.resources.length > 0 && (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
)}
{issue.outcome && issue.attacker_start && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoField label="Outcome">
{ISSUE_OUTCOME_LABEL[issue.outcome]}
</InfoField>
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[issue.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also need a
credential is the Access leg.
</p>
</InfoField>
</div>
)}
<section aria-label="Exploitability assessment">
<h4 className="mb-3 text-sm font-semibold">
Exploitability assessment
</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
const leg = issue.legs[name];
if (!leg) return null;
return (
<Card key={name} variant="inner" className="min-w-0 gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-sm font-medium">{LEG_LABELS[name]}</h5>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</div>
<p className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</p>
<p className="text-text-neutral-secondary text-sm">
{leg.text}
</p>
{leg.evidence.length > 0 && (
<ul className="flex flex-col gap-1">
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</Card>
);
})}
</div>
</section>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : state.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : state.resources.length > 0 ? (
<section aria-label="Affected resources">
<h4 className="mb-3 text-sm font-semibold">
Affected resources ({state.resources.length})
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li
key={uid}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, state.providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</li>
))}
</ul>
{visibleResources < state.resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() =>
setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
}
>
Show more resources
</Button>
)}
{visibleResources > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisibleResources(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</section>
) : null}
<section aria-label="Remediation">
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
<div className="flex flex-wrap items-center gap-3">
{activeScanId && (
<Button asChild variant="link" size="link-sm">
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
{jiraPayload && (
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through
// finding_uids if tickets for big issues need all of them.
<Button
variant="outline"
size="sm"
onClick={() => openJiraDispatch(jiraPayload)}
>
<Send aria-hidden="true" />
Send to Jira
</Button>
)}
</div>
</section>
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueFindings
findings={uniqueFindings}
scanId={activeScanId}
total={issue.findings_count}
/>
)}
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueCompliance
findings={state.findings}
scanId={activeScanId}
truncated={state.findingsTruncated}
/>
)}
<IssueTriage id={id} status={issue.triage_status} />
</div>
);
};
+68 -76
View File
@@ -1,91 +1,83 @@
import Link from "next/link";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
import { MUTED_FILTER } from "@/lib/findings-filters";
import type { IssueFindingsProps } from "./types";
const HEADERS = ["Finding", "Severity", "Triage"] as const;
/**
* The findings the issue folds, laid out like the finding panel's "Other
* findings" table. Each keeps its own triage: the issue's status answers for
* the combination, the finding's for whether that one check is right.
* The findings the issue folds, each with its own triage. That triage stays on
* the finding: the issue's status answers for the combination, the finding's
* for whether that one check is right.
*/
export const IssueFindings = ({
findings,
scanId,
total,
onOpenFinding,
}: IssueFindingsProps) => {
return (
<section aria-label="Findings" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{findings.length < total
? `Showing ${findings.length.toLocaleString()} of ${total.toLocaleString()}`
: `${total.toLocaleString()} Total Entries`}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
{HEADERS.map((header) => (
<TableHead key={header}>
<span className="text-text-neutral-secondary text-sm font-medium">
{header}
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{findings.map((finding) => (
<TableRow
key={finding.id}
className="group cursor-pointer"
onClick={() => onOpenFinding(finding.id)}
>
{/* max-w-0 lets the title take the spare width and truncate. */}
<TableCell className="w-full max-w-0">
{/* The row opens the finding; the button is its keyboard stop. */}
<button
type="button"
title={finding.checkTitle}
className="text-text-neutral-primary block max-w-full truncate text-left text-sm"
onClick={(event) => {
event.stopPropagation();
onOpenFinding(finding.id);
}}
>
{finding.checkTitle}
</button>
{/* One check fails on several resources: this tells them apart. */}
<p
className="text-text-neutral-tertiary truncate font-mono text-xs"
title={finding.resourceUid}
>
{finding.resourceUid}
</p>
</TableCell>
<TableCell>
<SeverityBadge severity={finding.severity} />
</TableCell>
<TableCell>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<section aria-label="Findings">
<h4 className="mb-3 text-sm font-semibold">
Findings ({total.toLocaleString()})
</h4>
<ul className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{findings.map((finding) => (
<li key={finding.id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full">
<div className="flex flex-col gap-1">
<div className="flex items-start justify-between gap-3">
{scanId ? (
<div className="min-w-0">
<Button
asChild
variant="link"
size="link-sm"
className="max-w-full"
>
<Link
href={`/findings?${new URLSearchParams({
"filter[scan__in]": scanId,
"filter[check_id__in]": finding.checkId,
"filter[resource_uid]": finding.resourceUid,
"filter[muted]": MUTED_FILTER.INCLUDE,
expandedCheckId: finding.checkId,
})}`}
title={finding.checkTitle}
>
<span className="truncate">{finding.checkTitle}</span>
</Link>
</Button>
</div>
) : (
<span className="text-text-neutral-primary min-w-0 text-sm break-words">
{finding.checkTitle}
</span>
)}
<SeverityBadge severity={finding.severity} />
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-text-neutral-tertiary min-w-0 truncate font-mono text-xs">
{finding.checkId}
</span>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</div>
</div>
</Card>
</li>
))}
</ul>
{findings.length < total && (
<p className="text-text-neutral-tertiary mt-2 text-xs">
Showing {findings.length.toLocaleString()} of {total.toLocaleString()}
.
</p>
)}
</section>
);
};
+44 -20
View File
@@ -6,8 +6,22 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { GraphNode } from "@/types/attack-paths";
import { IssueAttributes } from "@/types/issues";
const { getFindings } = vi.hoisted(() => ({ getFindings: vi.fn() }));
const { getFindings, navigateToFinding } = vi.hoisted(() => ({
getFindings: vi.fn(),
navigateToFinding: vi.fn(),
}));
vi.mock("@/actions/findings", () => ({ getFindings }));
vi.mock("@/hooks/use-finding-details", () => ({
useFindingDetails: () => ({
findingDetails: null,
findingDetailLoading: false,
resetFindingDetails: vi.fn(),
navigateToFinding,
}),
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
AttackPathGraph: ({
data,
@@ -38,7 +52,7 @@ const issue = { legs: {} } as IssueAttributes;
it("requires provider and scan identifiers together in the graph props", () => {
type Props = ComponentProps<typeof IssueGraph>;
type BaseProps = Pick<Props, "issue" | "resourceUids" | "onOpenFinding">;
type BaseProps = Pick<Props, "issue" | "resourceUids">;
expectTypeOf<BaseProps>().toExtend<Props>();
expectTypeOf<
@@ -49,10 +63,31 @@ it("requires provider and scan identifiers together in the graph props", () => {
});
describe("IssueGraph finding lookup", () => {
const openFinding = vi.fn();
beforeEach(() => {
openFinding.mockReset();
navigateToFinding.mockResolvedValue(undefined);
});
it("leaves findings closed in a read-only graph", async () => {
// Given: a host that cannot hold a second detail view, such as the panel
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
readOnly
/>,
);
// When
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).not.toHaveBeenCalled();
expect(getFindings).not.toHaveBeenCalled();
expect(
screen.getByText(/Open the full path to inspect a finding/),
).toBeInTheDocument();
});
it("opens a folded finding by id without another lookup", async () => {
@@ -74,7 +109,6 @@ describe("IssueGraph finding lookup", () => {
]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
@@ -82,7 +116,7 @@ describe("IssueGraph finding lookup", () => {
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(openFinding).toHaveBeenCalledWith("folded-finding");
expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
expect(getFindings).not.toHaveBeenCalled();
});
@@ -102,7 +136,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await user.click(screen.getByRole("button", { name: "Open finding" }));
@@ -116,7 +149,7 @@ describe("IssueGraph finding lookup", () => {
"filter[status]": "FAIL",
},
});
expect(openFinding).toHaveBeenCalledWith("finding-1");
expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
});
it("rejects a finding from a newer scan", async () => {
@@ -134,11 +167,10 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(openFinding).not.toHaveBeenCalled();
expect(navigateToFinding).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"no longer available for the issue's scan",
);
@@ -154,7 +186,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -178,7 +209,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -194,13 +224,7 @@ describe("IssueGraph finding lookup", () => {
});
it("does not look up findings without a provider and scan", async () => {
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
onOpenFinding={openFinding}
/>,
);
render(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(getFindings).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
+38 -8
View File
@@ -4,12 +4,14 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings";
import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
buildIssueGraph,
GRAPH_RESOURCE_LIMIT,
} from "@/components/issues/issue-graph.adapter";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import { GraphNode } from "@/types/attack-paths";
@@ -19,8 +21,11 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
/** Called with the clicked finding's id; the host shows its detail. */
onOpenFinding: (findingId: string) => void;
/**
* No finding drawer on click: for hosts inside the side panel, where a
* second detail view would replace the one holding the graph.
*/
readOnly?: boolean;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -46,8 +51,9 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
onOpenFinding,
readOnly = false,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
const view = buildIssueGraph(issue, resourceUids, findings);
const shownFindings = new Set(
@@ -58,6 +64,7 @@ export const IssueGraph = ({
const pending = useRef(false);
const mounted = useRef(true);
const [loading, setLoading] = useState(false);
const [lookupComplete, setLookupComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const expandedResources = new Set(
view.nodes
@@ -75,6 +82,8 @@ export const IssueGraph = ({
const handleNodeClick = async (node: GraphNode) => {
if (!isProwlerFindingNode(node.labels) || pending.current) return;
setError(null);
setLookupComplete(false);
finding.resetFindingDetails();
let findingId = node.properties.finding_id;
if (!findingId && (!providerId || !scanId)) {
setError(
@@ -111,7 +120,8 @@ export const IssueGraph = ({
}
findingId = match.id;
}
onOpenFinding(String(findingId));
await finding.navigateToFinding(String(findingId));
if (mounted.current) setLookupComplete(true);
} catch {
if (mounted.current)
setError("Could not load this finding. Select the finding to retry.");
@@ -120,6 +130,9 @@ export const IssueGraph = ({
if (mounted.current) setLoading(false);
}
};
const detailFailed =
lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
return (
<section
aria-label="Finding evidence"
@@ -131,16 +144,20 @@ export const IssueGraph = ({
{Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "}
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths. Select a finding to open it.
attack paths.
{readOnly && " Open the full path to inspect a finding."}
</p>
{loading && (
<p role="status" className="text-sm">
Loading finding details…
</p>
)}
{error && (
{(error || detailFailed) && (
<Alert variant="error">
<AlertDescription>{error}</AlertDescription>
<AlertDescription>
{error ??
"Could not load this finding. Select the finding to retry."}
</AlertDescription>
</Alert>
)}
<Card
@@ -152,10 +169,23 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
onNodeClick={readOnly ? undefined : handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
defaultOpen
onOpenChange={(open) => {
if (!open) {
setLookupComplete(false);
finding.resetFindingDetails();
}
}}
/>
)}
</section>
);
};
-533
View File
@@ -1,533 +0,0 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import {
ISSUE_FINDINGS_LIMIT,
IssueAttributes,
IssueFinding,
IssueResource,
} from "@/types/issues";
import { IssuePanel } from "./issue-panel";
const { getIssue, getIssueFindings, getFindingById } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
getFindingById: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/actions/issues", () => ({
getIssue,
getIssueFindings,
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
vi.mock("@/components/issues/issue-compliance", () => ({
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
truncated ? <p>Compliance from a capped preview</p> : null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: ({ finding }: { finding: { id: string } }) => (
<p>Detail of {finding.id}</p>
),
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
onOpenFinding,
}: {
resourceUids: string[];
onOpenFinding: (id: string) => void;
}) => (
<button type="button" onClick={() => onOpenFinding("graph-finding")}>
Graph of {resourceUids.length} resources
</button>
),
}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => <section aria-label={title}>{children}</section>,
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const attributes = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
const issueWith = (
overrides: Partial<IssueAttributes> = {},
scanId: string | undefined = "scan-1",
): IssueResource => ({
type: "issues",
id: "issue-1",
attributes: { ...attributes, ...overrides },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
...(scanId && { scan: { data: { type: "scans", id: scanId } } }),
},
});
const finding: IssueFinding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
const detailWith = (resource_uids: string[], finding_uids: string[] = []) => ({
data: { attributes: { resource_uids, finding_uids } },
});
const renderPanel = (
issue: IssueResource = issueWith(),
providerUid?: string,
) =>
render(
<IssuePanel
issue={issue}
providerUid={providerUid}
open
onOpenChange={vi.fn()}
/>,
);
const openTab = (name: string) =>
userEvent.click(screen.getByRole("tab", { name }));
describe("IssuePanel", () => {
beforeEach(() => {
getIssue.mockReset();
getIssueFindings.mockReset().mockResolvedValue([]);
getFindingById.mockReset();
useJiraDispatchStore.getState().closeJiraDispatch();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("says where the path starts and shows each leg's evidence", async () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(
issueWith({
outcome: "data_exposure",
attacker_start: "internet",
legs: {
reachability: {
state: "yes",
text: "Public access is enabled.",
evidence: ["s3_public · arn:aws:s3:::bucket"],
},
} as IssueAttributes["legs"],
}),
);
// Then
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
expect(
within(
screen.getByRole("list", { name: "Reachability evidence" }),
).getByText("s3_public · arn:aws:s3:::bucket"),
).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
});
it("names the provider with its account uid, like the finding panel", () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(issueWith(), "123456789012");
// Then
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("prod")).toBeInTheDocument();
expect(screen.getByText("123456789012")).toBeInTheDocument();
expect(screen.getByText("unmodelled")).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", () => {
getIssue.mockResolvedValue(detailWith([]));
renderPanel();
expect(screen.queryByText("Leads to")).not.toBeInTheDocument();
});
it("pages through every affected resource and links each to Resources", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids));
renderPanel();
// When
await openTab("Resources");
// Then: the list leaves the uids out, so the panel fetches them
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open in Resources: resource-1" }),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=resource-1&filter%5Bprovider_id__in%5D=provider-1",
);
await userEvent.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
});
it("says so when the resources cannot be loaded", async () => {
getIssue.mockResolvedValue(undefined);
renderPanel();
await openTab("Resources");
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids, ["finding-uid"]));
getIssueFindings
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([finding]);
renderPanel();
// When
await openTab("Findings");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(
await screen.findByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(getIssueFindings).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("lists each folded finding once, with its own triage", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
renderPanel();
// When
await openTab("Findings");
// Then
const section = await screen.findByRole("region", { name: "Findings" });
// One header row plus one row per finding.
expect(within(section).getAllByRole("row")).toHaveLength(3);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
// Cards of one check tell their resources apart.
expect(within(section).getByText("other-bucket")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300")).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given: one more finding than the preview loads
getIssue.mockResolvedValue(
detailWith(
[],
Array.from({ length: ISSUE_FINDINGS_LIMIT + 1 }, (_, i) => `uid-${i}`),
),
);
getIssueFindings.mockResolvedValue([finding]);
renderPanel();
// When
await openTab("Findings");
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("opens a finding in place and goes back to the tab it came from", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue({
data: {
id: "finding-1",
attributes: { check_metadata: { checktitle: "Bucket is public" } },
relationships: {},
},
});
renderPanel();
await openTab("Findings");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
// Then: the finding replaces the tabs, with a way back
expect(await screen.findByText("Detail of finding-1")).toBeInTheDocument();
expect(getFindingById).toHaveBeenCalledWith(
"finding-1",
"resources,scan.provider",
);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
// When
await userEvent.click(screen.getByText("Issue details"));
// Then
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute(
"aria-selected",
"true",
);
});
it("refreshes the findings behind the tabs when a finding closes", async () => {
// Given: the finding is triaged while open in the panel
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([{ ...finding, triageStatus: "open" }])
.mockResolvedValueOnce([{ ...finding, triageStatus: "risk_accepted" }]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
// Then: the row shows the new triage without dropping to a loading state
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(await screen.findByText("Risk Accepted")).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it.each([
{
failure: "the findings request",
fail: () => getIssueFindings.mockResolvedValueOnce(undefined),
failing: getIssueFindings,
},
{
failure: "the issue request",
fail: () => getIssue.mockRejectedValueOnce(new Error("Offline")),
failing: getIssue,
},
])(
"keeps the loaded rows when $failure fails on a background refresh",
async ({ fail, failing }) => {
// Given: the findings loaded, and a finding is open
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
fail();
// When: closing it refreshes, and the refresh fails
await userEvent.click(screen.getByText("Issue details"));
// Then: once the failing refresh has settled, the rows stay
await waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
await act(async () => {
await failing.mock.results[1]?.value?.catch(() => undefined);
});
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull();
},
);
it("keeps the rows on screen while the background refresh is pending", async () => {
// Given: the refresh after closing the finding never answers
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockReturnValueOnce(new Promise(() => {}));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssue).toHaveBeenCalledTimes(2));
// Then: the loaded rows stay, with no loading state in their place
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it("applies refreshed resources even when the refreshed findings fail", async () => {
// Given: the refresh brings a new resource but loses the findings
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockResolvedValueOnce(detailWith(["bucket", "new-bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([finding])
.mockResolvedValueOnce(undefined);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssueFindings).toHaveBeenCalledTimes(2));
// Then: the findings stay, the resources follow the refresh
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
await openTab("Resources");
expect(await screen.findByText("new-bucket")).toBeInTheDocument();
});
it("opens a finding clicked on the attack path in place", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue(undefined);
renderPanel();
await openTab("Attack path");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Graph of 1 resources" }),
);
// Then: a failed detail says so and can be retried
expect(getFindingById).toHaveBeenCalledWith(
"graph-finding",
"resources,scan.provider",
);
expect(
await screen.findByText("Could not load this finding."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(getFindingById).toHaveBeenCalledTimes(2);
});
it("sends the unique findings to Jira from the actions menu", async () => {
// Given: Prowler Cloud, where several findings can go in one dispatch
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = renderPanel();
// When
await userEvent.click(
await screen.findByRole("button", { name: "Issue actions" }),
);
await userEvent.click(
await screen.findByRole("menuitem", { name: "Send 2 Findings to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the panel closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: attributes.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+123 -398
View File
@@ -1,38 +1,16 @@
"use client";
import { ArrowLeft, Box, Tag } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { FindingDetailDrawer } from "@/components/findings/table";
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueResources } from "@/components/issues/issue-resources";
import { IssueTriage } from "@/components/issues/issue-triage";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import {
BreadcrumbNavigation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Button } from "@/components/shadcn/button/button";
import { EntityInfo } from "@/components/shadcn/entities";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
import { useFindingDetails } from "@/hooks/use-finding-details";
import {
LEG_LABELS,
LEG_QUESTIONS,
@@ -40,9 +18,6 @@ import {
legBadgeVariant,
VERDICTS,
} from "@/lib/issues/verdicts";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
@@ -50,399 +25,149 @@ import {
LegName,
} from "@/types/issues";
import { useIssueEvidence } from "./use-issue-evidence";
const ISSUE_PANEL_TAB = {
EXPLOITABILITY: "exploitability",
ATTACK_PATH: "attack-path",
FINDINGS: "findings",
RESOURCES: "resources",
REMEDIATION: "remediation",
TRIAGE: "triage",
} as const;
type IssuePanelTab = (typeof ISSUE_PANEL_TAB)[keyof typeof ISSUE_PANEL_TAB];
const ISSUE_PANEL_TABS: { id: IssuePanelTab; label: string }[] = [
{ id: ISSUE_PANEL_TAB.EXPLOITABILITY, label: "Exploitability" },
{ id: ISSUE_PANEL_TAB.ATTACK_PATH, label: "Attack path" },
{ id: ISSUE_PANEL_TAB.FINDINGS, label: "Findings" },
{ id: ISSUE_PANEL_TAB.RESOURCES, label: "Resources" },
{ id: ISSUE_PANEL_TAB.REMEDIATION, label: "Remediation" },
{ id: ISSUE_PANEL_TAB.TRIAGE, label: "Triage" },
];
interface IssuePanelProps {
issue: IssueResource;
/** The provider's account uid, which the issue itself does not carry. */
providerUid?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Everything about one issue, including drilling into its findings. */
export const IssuePanel = ({
issue,
providerUid,
open,
onOpenChange,
}: IssuePanelProps) => (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={issue.attributes.title}
description="The issue's verdict, attack path, evidence, fix and triage"
>
{/* Mounted only while open, so the evidence loads when someone looks. */}
<IssuePanelContent issue={issue} providerUid={providerUid} />
</DetailSidePanel>
);
const IssuePanelContent = ({
issue,
providerUid,
}: Pick<IssuePanelProps, "issue" | "providerUid">) => {
const router = useRouter();
/**
* The verdict and the reasoning behind it, from the list row alone (no
* fetch). Graph, resources, findings and compliance stay on the issue page.
*/
export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
const { attributes } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
const scanId = issue.relationships?.scan?.data?.id;
const providerId = issue.relationships?.provider?.data?.id;
const {
state: evidence,
retry,
refresh,
} = useIssueEvidence(issue.id, scanId, providerId);
// Controlled so closing a finding returns to the tab it was opened from.
const [tab, setTab] = useState<IssuePanelTab>(ISSUE_PANEL_TAB.EXPLOITABILITY);
const [openFindingId, setOpenFindingId] = useState<string | null>(null);
const finding = useFindingDetails();
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
const scanFindingsHref =
activeScanId &&
`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`;
const loadedFindings =
evidence.status === "loaded" ? evidence.findings : undefined;
// The findings request returns one row per finding and resource.
const uniqueFindings = Array.from(
new Map(loadedFindings?.map((item) => [item.id, item])).values(),
);
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through finding_uids
// if tickets for big issues need all of them.
const jiraPayload = createJiraDispatchPayload({
findingTitle: attributes.title,
targetIds: uniqueFindings.map((item) => item.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${attributes.findings_count} findings on this attack path.`,
});
const openFinding = (findingId: string) => {
setOpenFindingId(findingId);
void finding.navigateToFinding(findingId);
};
const closeFinding = () => {
setOpenFindingId(null);
finding.resetFindingDetails();
// The finding may have been triaged or muted while open.
refresh();
};
// A finding opens in place, like a resource's findings do: a second detail
// view would take the side panel over and close this issue.
if (openFindingId) {
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<BreadcrumbNavigation
mode="custom"
customItems={[
{
name: "Issue details",
icon: <ArrowLeft />,
isClickable: true,
onClick: closeFinding,
},
{
name: finding.findingDetailLoading
? "Loading..."
: (finding.findingDetails?.attributes?.check_metadata
?.checktitle ?? "Finding Detail"),
isLast: true,
},
]}
/>
{finding.findingDetailLoading ? (
<LoadingState label="Loading finding details..." />
) : finding.findingDetails ? (
// The detail scrolls inside itself, so it needs a bounded height.
<div className="min-h-0 flex-1">
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
inline
/>
</div>
) : (
<IssuesLoadError
message="Could not load this finding."
onRetry={() => openFinding(openFindingId)}
/>
)}
</div>
);
}
const evidencePending =
evidence.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : null;
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={attributes.title}
description="Why this issue got its verdict and how to fix it"
>
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<SeverityBadge severity={attributes.severity} />
</div>
{/* Same actions menu as the resource and finding panels. Jira joins
once the findings it sends have loaded. */}
<div className="shrink-0">
{scanFindingsHref || jiraPayload ? (
<ActionDropdown variant="bordered" ariaLabel="Issue actions">
{scanFindingsHref && (
<ActionDropdownItem
icon={<Tag className="size-5" />}
label="Open this scan's failed findings"
onSelect={() => router.push(scanFindingsHref)}
/>
)}
<JiraDispatchActionItem
label={buildJiraActionLabel({
findingCount: uniqueFindings.length,
})}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
evidence.status === "loading" && (
<Skeleton className="size-8 rounded-md" />
)
)}
</div>
</div>
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
{/* Under the title, like the finding panel's frameworks. */}
{evidence.status === "loading" ? (
<div aria-hidden="true" className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-28 rounded" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
</div>
) : (
loadedFindings &&
loadedFindings.length > 0 && (
<IssueCompliance
findings={loadedFindings}
scanId={activeScanId}
truncated={
evidence.status === "loaded" && evidence.findingsTruncated
}
/>
)
)}
{/* The finding panel's Provider / Service / Region row. */}
<div
data-responsive-container
className="@container mt-2 flex min-w-0 flex-col"
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<p className="text-text-neutral-tertiary text-xs break-all">
{attributes.findings_count.toLocaleString()}{" "}
{attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
{attributes.pattern}
</p>
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href={`/issues/${issue.id}`}>Open the full path</Link>
</Button>
</header>
<Tabs
defaultValue="exploitability"
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1fr)] @md:gap-x-8">
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Provider
</span>
<EntityInfo
cloudProvider={attributes.cloud}
nameIcon={<Box className="size-4" />}
entityAlias={attributes.account}
entityId={providerUid}
/>
</div>
<InfoField label="Findings" variant="compact" className="min-w-0">
<span className="block truncate whitespace-nowrap">
{attributes.findings_count.toLocaleString()}
</span>
</InfoField>
<InfoField label="Pattern" variant="compact" className="min-w-0">
<span
className="block truncate whitespace-nowrap"
title={attributes.pattern}
>
{attributes.pattern}
</span>
</InfoField>
<div className="mb-4 shrink-0">
<TabsList>
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="triage">Triage</TabsTrigger>
</TabsList>
</div>
</div>
</header>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as IssuePanelTab)}
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="mb-4 shrink-0">
<TabsList>
{ISSUE_PANEL_TABS.map(({ id, label }) => (
// Six tabs truncate on a phone; the tooltip names them.
<TabsTrigger key={id} value={id} tooltip={label}>
{label}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent value="exploitability" className="flex flex-col gap-4">
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent
value={ISSUE_PANEL_TAB.EXPLOITABILITY}
className="flex flex-col gap-4"
>
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also
need a credential is the Access leg.
</p>
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
{leg.evidence.length > 0 && (
<ul
aria-label={`${LEG_LABELS[name]} evidence`}
className="mt-1 flex flex-col gap-1"
>
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
{/* Mounted only while selected, so the graph's requests run when
someone asks for it. */}
<TabsContent value="attack-path">
<IssueAttackPath
id={issue.id}
issue={attributes}
scanId={scanId}
providerId={providerId}
/>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.ATTACK_PATH}>
<IssueAttackPath
issue={attributes}
evidence={evidence}
onRetry={retry}
onOpenFinding={openFinding}
/>
</TabsContent>
<TabsContent value="remediation" className="flex flex-col gap-2">
<p className="text-text-neutral-secondary text-sm">
{attributes.fix}
</p>
{scanId && (
<Button
asChild
variant="link"
size="link-sm"
className="self-start"
>
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": scanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.FINDINGS}>
{evidencePending ??
(evidence.status === "error" || loadedFindings === undefined ? (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
) : uniqueFindings.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
No findings to show for this issue.
</p>
) : (
<IssueFindings
findings={uniqueFindings}
total={attributes.findings_count}
onOpenFinding={openFinding}
/>
))}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.RESOURCES}>
{evidencePending ??
(evidence.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : (
evidence.status === "loaded" && (
<IssueResources
resources={evidence.resources}
providerId={evidence.providerId}
/>
)
))}
</TabsContent>
{/* Laid out like the finding panel's Remediation tab. */}
<TabsContent value={ISSUE_PANEL_TAB.REMEDIATION}>
<div className="flex flex-col gap-1 px-1">
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
<div className="text-text-neutral-primary text-sm">
<MarkdownContainer>{attributes.fix}</MarkdownContainer>
</div>
</div>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.TRIAGE}>
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
<TabsContent value="triage">
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
</DetailSidePanel>
);
};
@@ -1,26 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { IssueResources } from "./issue-resources";
const UID = "arn:aws:s3:::acme-customer-exports";
describe("IssueResources", () => {
it("links each resource to the Resources page of its provider", () => {
render(<IssueResources resources={[UID]} providerId="provider-1" />);
expect(
screen.getByRole("link", { name: `Open in Resources: ${UID}` }),
).toHaveAttribute(
"href",
`/resources?filter%5Buid%5D=${encodeURIComponent(UID)}&filter%5Bprovider_id__in%5D=provider-1`,
);
});
it("lists sample resources without a link, since Resources has no sample data", () => {
render(<IssueResources resources={[UID]} providerId="sample-provider" />);
expect(screen.getByText(UID)).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
});
-112
View File
@@ -1,112 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { isSampleId } from "@/lib/sample-preview/sample-records";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueResourcesProps {
resources: string[];
providerId?: string;
}
/** The resources the issue folds, a page at a time, as a table. */
export const IssueResources = ({
resources,
providerId,
}: IssueResourcesProps) => {
const [visible, setVisible] = useState(RESOURCE_PAGE_SIZE);
if (resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources.
</p>
);
}
return (
<section aria-label="Affected resources" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{resources.length.toLocaleString()} Total Entries
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Resource
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{resources.slice(0, visible).map((uid) => (
<TableRow key={uid}>
<TableCell className="max-w-0">
<CodeSnippet value={uid} ariaLabel={`Copy ${uid}`} />
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{/* The Resources page has no sample data to open. */}
{!isSampleId(providerId) && (
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center gap-3">
{visible < resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible((value) => value + RESOURCE_PAGE_SIZE)}
>
Show more resources
</Button>
)}
{visible > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</div>
</section>
);
};
+26 -28
View File
@@ -23,13 +23,18 @@ vi.mock("@/actions/issues", () => ({
getIssueFindings: async () => [],
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById: vi.fn() }));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<p>Graph of {resourceUids.join(", ")}</p>
IssueGraph: ({
resourceUids,
readOnly,
}: {
resourceUids: string[];
readOnly?: boolean;
}) => (
<p>
Graph of {resourceUids.join(", ")}
{readOnly ? " (read-only)" : ""}
</p>
),
}));
// The real panel portals into the app shell's side panel, absent here.
@@ -130,54 +135,47 @@ describe("IssuesTable", () => {
it("opens the issue in the side panel and closes it", async () => {
// Given
const user = userEvent.setup();
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
render(<IssuesTable issues={[issue]} />);
// When
await user.click(screen.getByRole("button", { name: "Public bucket" }));
// Then: the assessment tab leads, and nothing sends the user elsewhere
// Then: the assessment tab leads, the full page stays one click away
const panel = screen.getByRole("region", { name: "Public bucket" });
expect(
within(panel).getByRole("tab", { name: "Exploitability" }),
).toHaveAttribute("aria-selected", "true");
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
expect(
within(panel).queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
within(panel).getByRole("link", { name: "Open the full path" }),
).toHaveAttribute("href", "/issues/issue-1");
// When
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
// Then
expect(within(panel).getByText("Restrict access")).toBeVisible();
// When: the scan's failed findings sit in the actions menu
await user.click(
within(panel).getByRole("button", { name: "Issue actions" }),
);
await user.click(
await screen.findByRole("menuitem", {
expect(
within(panel).getByRole("link", {
name: "Open this scan's failed findings",
}),
);
// Then
expect(push).toHaveBeenLastCalledWith(
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
// Then
// Then: the graph loads on demand and leaves finding drill-down to the page
expect(
await within(panel).findByText("Graph of bucket"),
await within(panel).findByText("Graph of bucket (read-only)"),
).toBeInTheDocument();
expect(getIssue).toHaveBeenCalledWith("issue-1");
-6
View File
@@ -169,12 +169,9 @@ const columns: ColumnDef<IssueRow>[] = [
export const IssuesTable = ({
issues,
metadata,
providerUids,
}: {
issues: IssueRow[];
metadata?: MetaDataProps;
/** Provider uid by provider id, shown in the panel's Provider field. */
providerUids?: Record<string, string>;
}) => {
const [openId, setOpenId] = useState<string | null>(null);
const openIssue = issues.find((issue) => issue.id === openId);
@@ -193,9 +190,6 @@ export const IssuesTable = ({
<IssuePanel
key={openIssue.id}
issue={openIssue}
providerUid={
providerUids?.[openIssue.relationships?.provider?.data?.id ?? ""]
}
open
onOpenChange={(open) => {
if (!open) setOpenId(null);
+115 -95
View File
@@ -4,10 +4,18 @@ import { page } from "vitest/browser";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import {
getFindingGroups,
getFindingGroupResources,
} from "@/actions/finding-groups";
import { getScan } from "@/actions/scans";
import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
import { IssueDetail } from "@/components/issues/issue-detail";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
@@ -145,57 +153,84 @@ describe("Issues responsive layout", () => {
await page.viewport(1280, 800);
});
it("opens a folded finding inside the panel and comes back to the issue", async () => {
// Given: the list next to the real side panel host the app shell mounts.
it("opens the selected finding's group and muted resource through the destination actions", async () => {
// Given: the destination returns the finding only for its supported filters.
setup();
worker.use(
http.get("*/findings/f1", () =>
http.get("*/scans/scan-1", () =>
HttpResponse.json({
data: {
type: "findings",
id: "f1",
id: "scan-1",
type: "scans",
attributes: {
uid: "finding-uid-1",
check_id: "s3_public",
status: "FAIL",
severity: "critical",
check_metadata: { checktitle: "S3 bucket is public" },
inserted_at: "2026-10-20T10:00:00Z",
completed_at: "2026-10-20T11:00:00Z",
},
relationships: { resources: { data: [] } },
},
}),
),
http.get("*/finding-groups", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[check_id__in]") === "s3_public" &&
params.get("filter[resource_uid]") === resource &&
params.get("filter[scan__in]") === "scan-1" &&
params.get("filter[inserted_at]") === "2026-10-20" &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches ? [{ id: "s3_public", type: "finding-groups" }] : [],
});
}),
http.get("*/finding-groups/s3_public/resources", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[resource_uid]") === resource &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches
? [{ id: "r1", attributes: { finding_id: "f1", muted: true } }]
: [],
});
}),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
<>
{content}
<GlobalSidePanel />
</>,
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>,
);
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByText("Production")
.click();
await screen.getByRole("tab", { name: "Findings" }).click();
const link = screen.getByRole("link", { name: "S3 bucket is public" });
await expect.element(link).toBeVisible();
// When
await screen.getByRole("button", { name: "S3 bucket is public" }).click();
// Then: the finding takes the panel without closing the issue
const back = screen.getByRole("button", { name: "Issue details" });
await expect.element(back).toBeVisible();
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.not.toBeInTheDocument();
// When
await back.click();
// When: resolve the same filters used by the Findings page and drill-down.
const destination = new URL(
link.element().getAttribute("href")!,
window.location.origin,
);
const { filters } = extractFiltersAndQuery(
Object.fromEntries(destination.searchParams),
);
const resolvedFilters = applyDefaultMutedFilter(
await resolveFindingScanDateFilters({
filters,
scans: [],
loadScan: async (id) => (await getScan(id))?.data,
}),
);
const groups = await getFindingGroups({ filters: resolvedFilters });
// Then
await expect
.element(screen.getByRole("tab", { name: "Findings" }))
.toHaveAttribute("aria-selected", "true");
expect(groups.data).toEqual([{ id: "s3_public", type: "finding-groups" }]);
expect(destination.searchParams.get("expandedCheckId")).toBe("s3_public");
const resources = await getFindingGroupResources({
checkId: "s3_public",
filters: resolvedFilters,
});
expect(resources.data).toEqual([
{ id: "r1", attributes: { finding_id: "f1", muted: true } },
]);
});
it("opens a clicked row in the app's Details side panel", async () => {
@@ -216,6 +251,9 @@ describe("Issues responsive layout", () => {
.click();
// Then
await expect
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-1");
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
@@ -251,15 +289,6 @@ describe("Issues responsive layout", () => {
http.get("*/issues", () =>
HttpResponse.json({ data: [issue, secondIssue] }),
),
// The panel loads the evidence of whichever issue it opens.
http.get("*/issues/issue-2", () =>
HttpResponse.json({
data: {
...secondIssue,
attributes: { ...secondIssue.attributes, resource_uids: [] },
},
}),
),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
@@ -285,13 +314,8 @@ describe("Issues responsive layout", () => {
// Then: the panel remounted on the new issue, back on its first tab
await expect
.element(
screen.getByRole("heading", {
level: 3,
name: "Admin role assumable from any account",
}),
)
.toBeVisible();
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-2");
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.toHaveAttribute("aria-selected", "true");
@@ -304,7 +328,7 @@ describe("Issues responsive layout", () => {
{ width: 375, theme: "dark" },
{ width: 1440, theme: "dark" },
])(
"keeps the list and the issue panel usable at $width px in $theme mode",
"keeps the list and the issue detail usable at $width px in $theme mode",
async ({ width, theme }) => {
await page.viewport(width, 1000);
localStorage.setItem("theme", theme);
@@ -313,13 +337,16 @@ describe("Issues responsive layout", () => {
searchParams: { "filter[pattern]": "aws_public_bucket" },
});
const screen = await render(
<>
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
</main>
<GlobalSidePanel />
</>,
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>
</main>,
);
await expect
.element(screen.getByRole("button", { name: "Remove Pattern filter" }))
@@ -329,46 +356,25 @@ describe("Issues responsive layout", () => {
screen.getByText("The internet → Data exposure · aws_public_bucket"),
)
.toBeVisible();
// When: the row opens in the panel
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByRole("button", { name: "Public bucket exposes customer data" })
.click();
// Then: every tab fits the viewport, long identifiers wrap
await expect
.element(screen.getByRole("region", { name: "Affected resources" }))
.toBeVisible();
await expect
.element(screen.getByText("Partly", { exact: true }))
.toBeVisible();
const fits = (element: Element) =>
element.getBoundingClientRect().right <= width;
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await screen.getByRole("tab", { name: "Findings" }).click();
await expect
.element(screen.getByRole("button", { name: "S3 bucket is public" }))
.toBeVisible();
await screen.getByRole("tab", { name: "Resources" }).click();
const resources = screen.getByRole("region", {
name: "Affected resources",
});
await expect.element(resources).toBeVisible();
expect(fits(resources.element())).toBe(true);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
await page.screenshot();
await screen.getByRole("tab", { name: "Triage" }).click();
await expect.element(screen.getByText("Public by design.")).toBeVisible();
await expect
.element(
screen.getByRole("img", { name: "Critical finding risk icon" }),
)
.toBeVisible();
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
const detailBounds = screen
.getByRole("region", { name: "Affected resources" })
.element()
.getBoundingClientRect();
expect(detailBounds.right).toBeLessThanOrEqual(width);
if (width < 640) {
await screen.getByRole("tab", { name: "Attack path" }).click();
await expect
.poll(() => {
const node = screen
@@ -380,6 +386,20 @@ describe("Issues responsive layout", () => {
})
.toBe(true);
}
// Long resource identifiers and evidence must wrap within the detail.
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await page.viewport(
width,
Math.max(1000, document.documentElement.scrollHeight),
);
await screen.getByRole("main").screenshot();
},
);
+1 -1
View File
@@ -4,9 +4,9 @@ import type { IssueFinding } from "@/types/issues";
export interface IssueFindingsProps {
/** One entry per finding, with a resource to open in the group drill-down. */
findings: IssueFinding[];
scanId?: string;
/** All the findings the issue folds; the list shows a bounded preview. */
total: number;
onOpenFinding: (findingId: string) => void;
}
export interface IssueTriageProps {
@@ -158,8 +158,9 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
expect(sections[1]?.items[0]).toMatchObject({
label: "Exploitability issues",
expect(
items.find((item) => item.label === "Exploitability issues"),
).toMatchObject({
href: "/issues",
});
expect(configuration.children).not.toEqual(
@@ -149,19 +149,6 @@ export function getNavigationConfig({
{
label: "SECURITY",
items: [
// First: the exploitable issues are what to fix before anything else.
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/compliance",
@@ -176,6 +163,18 @@ export function getNavigationConfig({
icon: Tag,
active: isRouteActive(pathname, "/findings"),
},
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/attack-paths",
@@ -77,22 +77,4 @@ describe("LighthouseContextContributor", () => {
useLighthouseContextStore.getState().contributions["findings-total"],
).toMatchObject({ total: 17 });
});
it("should never register sample records", () => {
render(
<LighthouseContextContributor
contributorId="finding-group-sample"
item={{
kind: "finding",
id: "sample-group-s3_bucket_public_access",
source: "selection",
scopeKey: "findings:/findings",
label: "Ensure S3 buckets are not publicly accessible",
findingId: "sample-group-s3_bucket_public_access",
}}
/>,
);
expect(useLighthouseContextStore.getState().contributions).toEqual({});
});
});
@@ -1,7 +1,6 @@
"use client";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isSampleId } from "@/lib/sample-preview/sample-records";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import type { LighthouseContextItem } from "@/types/lighthouse-context";
@@ -14,9 +13,6 @@ export function LighthouseContextContributor({
contributorId,
item,
}: LighthouseContextContributorProps) {
// Sample records exist only in the UI; Lighthouse would look them up in vain.
if (isSampleId(item.id)) return null;
return (
<MountedLighthouseContextContributor
key={`${contributorId}:${JSON.stringify(item)}`}
@@ -156,19 +156,13 @@ export function BreadcrumbNavigation({
}
return (
<div className={cn(className, "w-fit max-w-full md:w-full")}>
<div className={cn(className, "w-fit md:w-full")}>
<nav aria-label="Breadcrumb">
{/* From sm up, one line: earlier crumbs keep their width and the
last one truncates. Below sm crumbs wrap, so a long trail never
pushes the header controls off a phone screen. */}
<ol className="flex min-w-0 flex-wrap items-center sm:flex-nowrap">
<ol className="flex flex-wrap items-center">
{breadcrumbItems.map((breadcrumb, index) => (
<li
key={breadcrumb.path || index}
className={cn(
"flex items-center",
index === breadcrumbItems.length - 1 ? "min-w-0" : "shrink-0",
)}
className="flex items-center"
aria-current={
index === breadcrumbItems.length - 1 ? "page" : undefined
}
@@ -204,16 +198,13 @@ export function BreadcrumbNavigation({
</span>
</button>
) : (
<div className="flex min-w-0 items-center gap-2">
<div className="flex items-center gap-2">
{index === 0 && breadcrumb.icon ? (
<BreadcrumbIcon className="text-text-neutral-tertiary">
{breadcrumb.icon}
</BreadcrumbIcon>
) : null}
<span
title={breadcrumb.name}
className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100"
>
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
{breadcrumb.name}
</span>
</div>
@@ -1,173 +0,0 @@
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",
);
});
});
@@ -1,207 +0,0 @@
"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>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getFindingById } = vi.hoisted(() => ({ getFindingById: vi.fn() }));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
import { useFindingDetails } from "./use-finding-details";
const deferred = () => {
let resolve!: (value: unknown) => void;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
describe("useFindingDetails", () => {
beforeEach(() => {
getFindingById.mockReset();
});
it("keeps a slow earlier lookup from overwriting a newer one", async () => {
// Given: lookup A is still pending when lookup B starts
const first = deferred();
const second = deferred();
getFindingById
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const { result } = renderHook(() => useFindingDetails());
act(() => {
void result.current.navigateToFinding("a");
});
act(() => {
void result.current.navigateToFinding("b");
});
// When: A answers after B started
await act(async () => first.resolve({ data: { id: "a" } }));
// Then: B is still loading and A is dropped
expect(result.current.findingDetails).toBeNull();
expect(result.current.findingDetailLoading).toBe(true);
// When
await act(async () => second.resolve({ data: { id: "b" } }));
// Then
expect(result.current.findingDetails).toEqual({ id: "b" });
expect(result.current.findingDetailLoading).toBe(false);
});
it("drops a lookup that answers after a reset", async () => {
// Given
const pending = deferred();
getFindingById.mockReturnValueOnce(pending.promise);
const { result } = renderHook(() => useFindingDetails());
act(() => {
void result.current.navigateToFinding("a");
});
// When: the finding closes, then its lookup answers
act(() => result.current.resetFindingDetails());
await act(async () => pending.resolve({ data: { id: "a" } }));
// Then
expect(result.current.findingDetails).toBeNull();
expect(result.current.findingDetailLoading).toBe(false);
});
});
+3 -7
View File
@@ -30,10 +30,7 @@ export function useFindingDetails(): UseFindingDetailsReturn {
if (findingFetchRef.current) {
findingFetchRef.current.abort();
}
// Each lookup checks its own controller: the ref already points at a
// newer lookup by the time a slow one answers.
const controller = new AbortController();
findingFetchRef.current = controller;
findingFetchRef.current = new AbortController();
setFindingDetailLoading(true);
try {
@@ -42,7 +39,7 @@ export function useFindingDetails(): UseFindingDetailsReturn {
"resources,scan.provider",
);
if (controller.signal.aborted) {
if (findingFetchRef.current?.signal.aborted) {
return;
}
@@ -55,14 +52,13 @@ export function useFindingDetails(): UseFindingDetailsReturn {
}
console.error("Error fetching finding:", error);
} finally {
if (!controller.signal.aborted) {
if (!findingFetchRef.current?.signal.aborted) {
setFindingDetailLoading(false);
}
}
};
const resetFindingDetails = () => {
findingFetchRef.current?.abort();
setFindingDetails(null);
setFindingDetailLoading(false);
};
@@ -34,18 +34,12 @@ export const useIssueEvidence = (
useEffect(() => {
let cancelled = false;
// A failed background refresh keeps what is already shown; only a first
// load or a retry (which resets to loading) surfaces the error.
const fail = () =>
setState((previous) =>
previous.status === "loaded" ? previous : { status: "error" },
);
getIssue(id)
.then(async (detail) => {
if (cancelled) return;
const attributes = detail?.data?.attributes;
if (!attributes?.resource_uids) {
fail();
setState({ status: "error" });
return;
}
const detailScanId =
@@ -58,30 +52,18 @@ export const useIssueEvidence = (
: findingUids.length === 0
? []
: undefined;
if (cancelled) return;
setState((previous) => {
// A refresh that loses the findings keeps the ones already shown,
// as long as they belong to the same scan; resources and provider
// always take the fresh answer.
const keepFindings =
findings === undefined &&
previous.status === "loaded" &&
previous.findings !== undefined &&
previous.scanId === detailScanId;
return {
if (!cancelled)
setState({
status: "loaded",
resources: attributes.resource_uids,
findings: keepFindings ? previous.findings : findings,
findingsTruncated: keepFindings
? previous.findingsTruncated
: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
findings,
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
scanId: detailScanId,
providerId: detailProviderId,
};
});
});
})
.catch(() => {
if (!cancelled) fail();
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
@@ -93,8 +75,5 @@ export const useIssueEvidence = (
setAttempt((value) => value + 1);
};
/** Reloads in the background: what is shown stays until the answer lands. */
const refresh = () => setAttempt((value) => value + 1);
return { state, retry, refresh };
return { state, retry };
};
-1
View File
@@ -8,7 +8,6 @@ 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,9 +12,6 @@ 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,7 +35,6 @@ 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
@@ -1,7 +0,0 @@
/**
* 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);
@@ -1,59 +0,0 @@
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),
});
},
);
@@ -1,188 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// Sample answers return before any auth header is built.
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
import {
adaptFindingGroupResourcesResponse,
getFindingGroupResources,
} from "@/actions/finding-groups";
import {
adaptFindingsByResourceResponse,
getFindingById,
getLatestFindingsByResourceUid,
} from "@/actions/findings";
import { getFindingComplianceFrameworks } from "@/actions/findings/finding-compliance-frameworks";
import {
addIssueNote,
getIssue,
getIssueFindings,
getIssueNotes,
updateIssueTriage,
} from "@/actions/issues";
import { createMuteRule } from "@/actions/mute-rules";
import { getResourceEvents } from "@/actions/resources";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import { SAMPLE_ACCOUNT, SAMPLE_CHECKS } from "./sample-dataset";
import { SAMPLE_ISSUES } from "./sample-issues";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "./sample-overview";
import { SAMPLE_WRITE_MESSAGE } from "./sample-records";
// The sample drill-downs run the real actions and adapters; none may reach the API.
const fetchMock = vi.fn();
const SAMPLE_SCAN_FILTERS = { "filter[scan]": SAMPLE_ACCOUNT.scanId };
const PUBLIC_BUCKETS = SAMPLE_CHECKS.find(
(check) => check.checkId === "s3_bucket_public_access",
)!;
const groupRows = async (page = 1) =>
adaptFindingGroupResourcesResponse(
await getFindingGroupResources({
checkId: PUBLIC_BUCKETS.checkId,
page,
pageSize: 10,
filters: SAMPLE_SCAN_FILTERS,
}),
PUBLIC_BUCKETS.checkId,
);
describe("sample drill-downs", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
it("expands a finding group into its resources, failed first, without the API", async () => {
const rows = await groupRows();
const lastPage = await groupRows(3);
expect(rows).toHaveLength(10);
expect(
rows.slice(0, PUBLIC_BUCKETS.failed).map((row) => row.status),
).toEqual(Array(PUBLIC_BUCKETS.failed).fill("FAIL"));
expect(rows[PUBLIC_BUCKETS.failed].status).toBe("PASS");
expect(rows[0]).toMatchObject({
resourceName: "acme-customer-exports",
providerAlias: SAMPLE_ACCOUNT.alias,
statusExtended:
"S3 Bucket acme-customer-exports has a bucket policy that allows public access.",
});
// 24 buckets: pages of 10, 10 and 4.
expect(lastPage).toHaveLength(
PUBLIC_BUCKETS.failed + PUBLIC_BUCKETS.passed - 20,
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("opens a group resource in the drawer with its check details and frameworks", async () => {
const [row] = await groupRows();
const [finding] = adaptFindingsByResourceResponse(
await getFindingById(row.findingId, "resources,scan.provider"),
);
const otherFindings = adaptFindingsByResourceResponse(
await getLatestFindingsByResourceUid({ resourceUid: row.resourceUid }),
);
expect(finding).toMatchObject({
id: row.findingId,
checkTitle: PUBLIC_BUCKETS.title,
resourceName: "acme-customer-exports",
providerAlias: SAMPLE_ACCOUNT.alias,
providerUid: SAMPLE_ACCOUNT.uid,
scan: expect.objectContaining({ state: "completed" }),
});
expect(finding.risk).not.toBe("");
expect(finding.remediation.code.cli).toContain("acme-customer-exports");
expect(finding.complianceFrameworks).toContain("CIS");
// The same bucket also fails the secure transport check.
expect(otherFindings.map((other) => other.checkId)).toEqual(
expect.arrayContaining([
"s3_bucket_public_access",
"s3_bucket_secure_transport_policy",
]),
);
const { frameworks } = await getFindingComplianceFrameworks(row.findingId, {
inWatchlist: true,
});
// The same frameworks the Overview watchlist and Compliance page show.
expect(frameworks.map((framework) => framework.complianceId)).toEqual(
expect.arrayContaining(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.map(
(item) => item.attributes.compliance_id,
),
),
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("shows the CloudTrail history of a sample resource in the Events tab", async () => {
const [row] = await groupRows();
const [finding] = adaptFindingsByResourceResponse(
await getFindingById(row.findingId, "resources,scan.provider"),
);
const events = await getResourceEvents(finding.resourceId);
expect(
events.data?.map(
(event: { attributes: { event_name: string } }) =>
event.attributes.event_name,
),
).toEqual(["PutBucketPolicy", "CreateBucket"]);
expect(fetchMock).not.toHaveBeenCalled();
});
it("marks sample findings read-only, with the reason the triage control shows", async () => {
const [row] = await groupRows();
expect(row.triage).toMatchObject({
status: FINDING_TRIAGE_STATUS.OPEN,
canEdit: false,
disabledReason: "sample",
});
});
it("opens every sample issue with exactly the findings it counts", async () => {
for (const issue of SAMPLE_ISSUES) {
const detail = await getIssue(issue.id);
const scanId = detail?.data.relationships?.scan?.data?.id ?? "";
const findings = await getIssueFindings(
scanId,
detail?.data.attributes.finding_uids ?? [],
);
expect(findings).toHaveLength(issue.attributes.findings_count);
expect(new Set(detail?.data.attributes.resource_uids)).toEqual(
new Set(findings?.map((finding) => finding.resourceUid)),
);
await expect(getIssueNotes(issue.id)).resolves.toEqual([]);
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("refuses writes on sample data with a message to connect an account", async () => {
const [issue] = SAMPLE_ISSUES;
const [row] = await groupRows();
const muteForm = new FormData();
muteForm.set("name", "Sample mute");
muteForm.set("reason", "Trying it out");
muteForm.set("finding_ids", JSON.stringify([row.findingId]));
await expect(
updateIssueTriage(issue.id, {
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
}),
).resolves.toEqual({ error: SAMPLE_WRITE_MESSAGE });
await expect(addIssueNote(issue.id, "A note")).resolves.toEqual({
error: SAMPLE_WRITE_MESSAGE,
});
await expect(createMuteRule(null, muteForm)).resolves.toEqual({
errors: { general: SAMPLE_WRITE_MESSAGE },
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -1,170 +0,0 @@
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 },
};
@@ -1,270 +0,0 @@
// What the finding drawer shows for each sample check: the check metadata a
// real scan stores on every finding, and the status line per resource.
/** A framework of the sample watchlist (see SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE). */
export interface SampleFramework {
complianceId: string;
framework: string;
name: string;
version: string;
}
export interface SampleCheckDetail {
description: string;
risk: string;
recommendation: string;
cli?: string;
/** Status line of a failed finding; `{name}` is the resource name. */
fail: string;
pass: string;
compliance: SampleFramework[];
}
const CIS: SampleFramework = {
complianceId: "cis_5.0_aws",
framework: "CIS",
name: "CIS Amazon Web Services Foundations Benchmark",
version: "5.0",
};
const FSBP: SampleFramework = {
complianceId: "aws_foundational_security_best_practices_aws",
framework: "AWS-Foundational-Security-Best-Practices",
name: "AWS Foundational Security Best Practices",
version: "",
};
const PCI: SampleFramework = {
complianceId: "pci_4.0_aws",
framework: "PCI-DSS",
name: "PCI DSS",
version: "4.0",
};
const ISO: SampleFramework = {
complianceId: "iso27001_2022_aws",
framework: "ISO27001",
name: "ISO/IEC 27001",
version: "2022",
};
const SOC2: SampleFramework = {
complianceId: "soc2_aws",
framework: "SOC2",
name: "SOC 2",
version: "",
};
export const SAMPLE_CHECK_DETAILS: Record<string, SampleCheckDetail> = {
s3_bucket_public_access: {
description:
"Checks whether the bucket policy or ACL grants access to everyone.",
risk: "Anyone on the internet can list and download the objects, which leaks whatever the bucket stores.",
recommendation:
"Turn on S3 Block Public Access for the bucket and remove public grants from its policy and ACL.",
cli: "aws s3api put-public-access-block --bucket {name} --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true",
fail: "S3 Bucket {name} has a bucket policy that allows public access.",
pass: "S3 Bucket {name} is not publicly accessible.",
compliance: [CIS, FSBP, PCI, ISO, SOC2],
},
iam_root_mfa_enabled: {
description: "Checks whether the root user has an MFA device.",
risk: "A leaked root password gives full control of the account, billing included, with nothing else in the way.",
recommendation:
"Enable a hardware or virtual MFA device for the root user and stop using it for daily work.",
fail: "MFA is not enabled for the root account.",
pass: "MFA is enabled for the root account.",
compliance: [CIS, FSBP, PCI, ISO, SOC2],
},
rds_instance_no_public_access: {
description: "Checks whether the RDS instance is publicly accessible.",
risk: "A public database endpoint is exposed to brute force and to any credential that leaks.",
recommendation:
"Set PubliclyAccessible to false and reach the database through private subnets.",
cli: "aws rds modify-db-instance --db-instance-identifier {name} --no-publicly-accessible --apply-immediately",
fail: "RDS Instance {name} is set as publicly accessible.",
pass: "RDS Instance {name} is not publicly accessible.",
compliance: [FSBP, PCI, ISO],
},
awslambda_function_no_secrets_in_variables: {
description: "Looks for secrets in the function's environment variables.",
risk: "Anyone who can read the function configuration gets the credentials in plain text.",
recommendation:
"Store the secret in Secrets Manager, read it at runtime and rotate the exposed value.",
fail: "Potential secret found in Lambda function {name} variables -> DB_PASSWORD.",
pass: "No secrets found in Lambda function {name} variables.",
compliance: [FSBP, PCI, SOC2],
},
ec2_instance_secrets_user_data: {
description: "Looks for secrets in the instance user data.",
risk: "User data is readable from the instance and through the EC2 API, so the secret is effectively public inside the account.",
recommendation:
"Remove the secret from user data and fetch it from Secrets Manager or Parameter Store at boot.",
fail: "Potential secret found in EC2 instance {name} User Data -> AWS_SECRET_ACCESS_KEY.",
pass: "No secrets found in EC2 instance {name} User Data.",
compliance: [FSBP, PCI],
},
ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22: {
description:
"Checks whether the security group allows SSH from 0.0.0.0/0 or ::/0.",
risk: "SSH open to the internet invites brute force and exploitation of any SSH vulnerability.",
recommendation:
"Restrict port 22 to known addresses, or drop SSH and use Session Manager.",
cli: "aws ec2 revoke-security-group-ingress --group-id {name} --protocol tcp --port 22 --cidr 0.0.0.0/0",
fail: "Security group {name} has SSH port 22 open to the Internet.",
pass: "Security group {name} does not have SSH port 22 open to the Internet.",
compliance: [CIS, FSBP, PCI, ISO],
},
iam_policy_allows_privilege_escalation: {
description:
"Checks whether a customer managed policy grants actions that let a principal raise its own privileges.",
risk: "A principal with this policy can attach admin policies or pass privileged roles to itself.",
recommendation:
"Remove iam:PassRole, iam:AttachUserPolicy and similar actions, or scope them to specific resources.",
fail: "Custom Policy {name} allows privilege escalation using the following actions: iam:PassRole, iam:AttachRolePolicy.",
pass: "Custom Policy {name} does not allow privilege escalation.",
compliance: [FSBP, ISO, SOC2],
},
cloudtrail_multi_region_enabled: {
description: "Checks whether a CloudTrail trail logs every region.",
risk: "Activity in regions without a trail leaves no audit record.",
recommendation: "Create a multi-region trail that logs management events.",
cli: "aws cloudtrail update-trail --name {name} --is-multi-region-trail",
fail: "No CloudTrail trails enabled with logging were found in some regions.",
pass: "Trail {name} is multiregion and it is logging.",
compliance: [CIS, FSBP, PCI, ISO, SOC2],
},
iam_user_mfa_enabled_console_access: {
description: "Checks whether IAM users with a console password have MFA.",
risk: "A phished or reused password is enough to sign in to the console.",
recommendation:
"Require MFA for every user with console access, or move them to IAM Identity Center.",
fail: "User {name} has Console Password enabled but MFA disabled.",
pass: "User {name} has Console Password enabled and MFA enabled.",
compliance: [CIS, FSBP, PCI, ISO, SOC2],
},
eks_cluster_not_publicly_accessible: {
description:
"Checks whether the EKS API endpoint is reachable from the internet.",
risk: "A public control plane is exposed to credential stuffing and API vulnerabilities.",
recommendation:
"Disable public endpoint access or restrict it to known CIDR blocks.",
fail: "EKS cluster {name} is publicly accessible.",
pass: "EKS cluster {name} is not publicly accessible.",
compliance: [FSBP, ISO],
},
ec2_instance_imdsv2_enabled: {
description: "Checks whether the instance requires IMDSv2.",
risk: "IMDSv1 lets an SSRF bug steal the instance role credentials.",
recommendation: "Set HttpTokens to required on the instance.",
cli: "aws ec2 modify-instance-metadata-options --instance-id {name} --http-tokens required",
fail: "EC2 Instance {name} has IMDSv2 disabled or not required.",
pass: "EC2 Instance {name} has IMDSv2 enabled and required.",
compliance: [FSBP, ISO],
},
ec2_ebs_volume_encryption: {
description: "Checks whether the EBS volume is encrypted.",
risk: "Snapshots and volumes copied out of the account expose the data in clear.",
recommendation:
"Enable EBS encryption by default in each region and migrate existing volumes.",
fail: "EBS Volume {name} is unencrypted.",
pass: "EBS Volume {name} is encrypted.",
compliance: [CIS, FSBP, PCI, ISO],
},
s3_bucket_secure_transport_policy: {
description:
"Checks whether the bucket policy denies requests that are not sent over HTTPS.",
risk: "Objects can travel unencrypted and be read or changed in transit.",
recommendation:
'Add a bucket policy statement that denies s3:* when "aws:SecureTransport" is false.',
fail: "S3 Bucket {name} allows requests over insecure transport in the bucket policy.",
pass: "S3 Bucket {name} has a bucket policy to deny requests over insecure transport.",
compliance: [CIS, FSBP, PCI],
},
iam_rotate_access_key_90_days: {
description: "Checks whether access keys are older than 90 days.",
risk: "Long-lived keys are more likely to have leaked somewhere.",
recommendation: "Rotate the key and delete the old one.",
fail: "User {name} has not rotated access key 1 in over 90 days (214 days).",
pass: "User {name} does not have access keys older than 90 days.",
compliance: [CIS, FSBP, PCI, ISO],
},
guardduty_is_enabled: {
description: "Checks whether GuardDuty is enabled in the region.",
risk: "Malicious activity in the region goes undetected.",
recommendation:
"Enable GuardDuty in every region, ideally from a delegated administrator.",
cli: "aws guardduty create-detector --enable",
fail: "GuardDuty detector {name} not enabled.",
pass: "GuardDuty detector {name} enabled.",
compliance: [FSBP, PCI, ISO, SOC2],
},
config_recorder_all_regions_enabled: {
description: "Checks whether AWS Config records in the region.",
risk: "Configuration changes in the region leave no history to investigate.",
recommendation:
"Enable an AWS Config recorder for all resource types in every region.",
fail: "AWS Config recorder {name} is disabled.",
pass: "AWS Config recorder {name} is enabled.",
compliance: [CIS, FSBP, PCI, SOC2],
},
vpc_flow_logs_enabled: {
description: "Checks whether the VPC has flow logs.",
risk: "Without flow logs there is no network record for an investigation.",
recommendation:
"Enable flow logs on the VPC, at least for rejected traffic.",
cli: "aws ec2 create-flow-logs --resource-type VPC --resource-ids {name} --traffic-type REJECT --log-destination-type s3 --log-destination arn:aws:s3:::flow-logs",
fail: "VPC {name} Flow logs are disabled.",
pass: "VPC {name} Flow logs are enabled.",
compliance: [CIS, FSBP, PCI, ISO],
},
kms_cmk_rotation_enabled: {
description: "Checks whether automatic rotation is on for the key.",
risk: "A key that never rotates widens the impact of any key exposure.",
recommendation: "Enable automatic key rotation.",
cli: "aws kms enable-key-rotation --key-id {name}",
fail: "KMS CMK {name} has automatic rotation disabled.",
pass: "KMS CMK {name} has automatic rotation enabled.",
compliance: [CIS, FSBP, PCI],
},
apigateway_restapi_logging_enabled: {
description: "Checks whether the API stages have logging enabled.",
risk: "Requests to the API leave no trace for debugging or investigation.",
recommendation:
"Enable CloudWatch execution or access logging on every stage.",
fail: "API Gateway {name} has stage logging disabled.",
pass: "API Gateway {name} has stage logging enabled.",
compliance: [FSBP, PCI],
},
sqs_queues_server_side_encryption_enabled: {
description: "Checks whether the queue uses server-side encryption.",
risk: "Messages are stored without encryption at rest.",
recommendation: "Enable SSE-SQS or SSE-KMS on the queue.",
fail: "SQS queue {name} is not using Server Side Encryption.",
pass: "SQS queue {name} is using Server Side Encryption.",
compliance: [FSBP, PCI],
},
cloudwatch_log_metric_filter_root_usage: {
description: "Checks for a metric filter and alarm on root user activity.",
risk: "Root sign-ins go unnoticed.",
recommendation:
"Create a metric filter on the CloudTrail log group for root usage and an alarm on it.",
fail: "No CloudWatch log groups found with metric filters or alarms associated.",
pass: "CloudWatch log group {name} found with metric filter and alarms set.",
compliance: [CIS, PCI],
},
ec2_instance_managed_by_ssm: {
description: "Checks whether Systems Manager manages the instance.",
risk: "Unmanaged instances miss patching and inventory.",
recommendation:
"Install the SSM agent and attach an instance profile with AmazonSSMManagedInstanceCore.",
fail: "EC2 Instance {name} is not managed by Systems Manager.",
pass: "EC2 Instance {name} is managed by Systems Manager.",
compliance: [FSBP, ISO],
},
account_maintain_current_contact_details: {
description: "Reminds you to keep the account contact details current.",
risk: "AWS cannot reach the right people about security events on the account.",
recommendation:
"Review the primary and alternate contacts in the account settings.",
fail: "Manual check: Login to the AWS Console. Choose your account name on the top right of the window -> My Account -> Contact Information.",
pass: "Account contact details are current.",
compliance: [CIS],
},
};
@@ -1,52 +0,0 @@
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
@@ -1,309 +0,0 @@
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,
);
-101
View File
@@ -1,101 +0,0 @@
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
import type { SearchParamsProps } from "@/types/components";
import {
SAMPLE_ACCOUNT,
SAMPLE_FAILED_CHECKS,
type SampleCheck,
} from "./sample-dataset";
import { SAMPLE_ID_PREFIX } from "./sample-records";
// 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",
// The sample prefix keeps the group out of Lighthouse context.
id: `${SAMPLE_ID_PREFIX}group-${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,
},
});
/** The filters the Overview drill-downs set, matched the way the API would. */
const SAMPLE_GROUP_FILTERS: Record<
string,
(check: SampleCheck) => string | undefined
> = {
"filter[severity__in]": (check) => check.severity,
"filter[service__in]": (check) => check.service,
"filter[category__in]": (check) => check.attackSurface,
// Every sample group fails.
"filter[status__in]": () => "FAIL",
};
/** The sample groups an Overview link asked for, and whether any filter applied. */
export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
const active = Object.entries(SAMPLE_GROUP_FILTERS).flatMap(
([key, field]) => {
const value = searchParams[key];
return typeof value === "string" && value
? [{ field, accepted: value.split(",") }]
: [];
},
);
const checks = SAMPLE_FAILED_CHECKS.filter((check) =>
active.every(({ field, accepted }) => {
const actual = field(check);
return actual !== undefined && accepted.includes(actual);
}),
);
return {
groups: adaptFindingGroupsResponse({
data: checks.map(toFindingGroupItem),
}),
filtered: active.length > 0,
};
};
@@ -1,29 +0,0 @@
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
@@ -1,322 +0,0 @@
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);
-305
View File
@@ -1,305 +0,0 @@
import { SEVERITY } from "@/types/components";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import {
type IssueAttributes,
type IssueDetailResource,
type IssueResource,
type IssuesOverviewAttributes,
LEG_STATE,
type LegAssessment,
type LegName,
VERDICT,
} from "@/types/issues";
import { SAMPLE_ACCOUNT } from "./sample-dataset";
import { failedSampleFindings } from "./sample-records";
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"
| "findings_count"
> & {
id: string;
legs: Record<LegName, LegAssessment>;
/** The failed findings the issue folds: the first `count` of each check. */
evidence: [checkId: string, count: number][];
};
// 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.",
evidence: [
["s3_bucket_public_access", 1],
["s3_bucket_secure_transport_policy", 1],
],
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.",
evidence: [
["ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", 1],
["ec2_instance_imdsv2_enabled", 1],
["iam_policy_allows_privilege_escalation", 1],
["guardduty_is_enabled", 1],
],
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.",
evidence: [
["iam_root_mfa_enabled", 1],
["cloudwatch_log_metric_filter_root_usage", 1],
],
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.",
evidence: [
["awslambda_function_no_secrets_in_variables", 2],
["rds_instance_no_public_access", 1],
],
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.",
evidence: [
["ec2_ebs_volume_encryption", 9],
["sqs_queues_server_side_encryption_enabled", 3],
],
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.", []),
},
},
];
const issueFindings = (evidence: SampleIssueSeed["evidence"]) =>
evidence.flatMap(([checkId, count]) =>
failedSampleFindings(checkId).slice(0, count),
);
const SAMPLE_ISSUE_DETAILS: IssueDetailResource[] = SAMPLE_ISSUE_SEEDS.map(
({ id, evidence, ...attributes }) => {
const findings = issueFindings(evidence);
return {
type: "issues",
id,
attributes: {
...attributes,
findings_count: findings.length,
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,
finding_uids: findings.map((finding) => finding.uid),
resource_uids: Array.from(
new Set(findings.map((finding) => finding.resource.uid)),
),
},
relationships: {
provider: {
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
},
scan: { data: { type: "scans", id: SAMPLE_ACCOUNT.scanId } },
},
};
},
);
/** The list rows: the detail without the uid lists only `/issues/{id}` carries. */
export const SAMPLE_ISSUES: IssueResource[] = SAMPLE_ISSUE_DETAILS.map(
({
attributes: {
finding_uids: _findingUids,
resource_uids: _resourceUids,
...attributes
},
...issue
}) => ({
...issue,
attributes,
}),
);
export const getSampleIssueDetail = (id: string) =>
SAMPLE_ISSUE_DETAILS.find((issue) => issue.id === id);
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
@@ -1,203 +0,0 @@
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: [],
};
},
);
@@ -1,67 +0,0 @@
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.
}
}
@@ -1,83 +0,0 @@
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 });
});
});
@@ -1,70 +0,0 @@
/** 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,
};
}
-627
View File
@@ -1,627 +0,0 @@
import {
type FindingComplianceFrameworksResult,
WATCHLIST_SCOPE,
} from "@/types/compliance-watchlist";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import { SAMPLE_CHECK_DETAILS } from "./sample-check-details";
import {
SAMPLE_ACCOUNT,
SAMPLE_CHECKS,
SAMPLE_REGIONS,
type SampleCheck,
} from "./sample-dataset";
// One record per sample resource and per sample finding, so every drill-down
// (group rows, finding drawer, resource drawer, issue panel) opens something
// concrete. Every id starts with SAMPLE_ID_PREFIX: real ids are UUIDs, so the
// actions tell sample lookups apart without any other state.
export const SAMPLE_ID_PREFIX = "sample-";
export const isSampleId = (id: string | undefined | null): id is string =>
typeof id === "string" && id.startsWith(SAMPLE_ID_PREFIX);
export interface SampleResource {
id: string;
uid: string;
name: string;
region: string;
service: string;
type: string;
}
export interface SampleFinding {
id: string;
uid: string;
check: SampleCheck;
resource: SampleResource;
status: "FAIL" | "PASS";
delta: "new" | null;
}
// Deterministic AWS-looking ids (i-0…, sg-0…): 17 hex digits after the dash.
const hexId = (prefix: string, seed: number, index: number) => {
const high = Math.imul(seed + index, 2654435761) >>> 0;
const low = Math.imul(high ^ seed, 2246822519) >>> 0;
return `${prefix}-0${high.toString(16).padStart(8, "0")}${low.toString(16).padStart(8, "0")}`;
};
const arn = (service: string, region: string, path: string) =>
`arn:aws:${service}:${region}:${SAMPLE_ACCOUNT.uid}:${path}`;
interface ResourceKind {
service: string;
type: string;
/** Global resources live in one region, as Prowler reports them. */
global?: boolean;
name: (index: number, region: string) => string;
uid: (name: string, region: string) => string;
}
const named =
(names: string[], fallback: string) =>
(index: number): string =>
names[index] ?? `${fallback}-${index + 1}`;
const RESOURCE_KINDS: Record<string, ResourceKind> = {
s3_bucket: {
service: "s3",
type: "AwsS3Bucket",
name: named(
[
"acme-customer-exports",
"acme-web-assets",
"acme-partner-uploads",
"acme-app-logs",
"acme-terraform-state",
"acme-data-lake-raw",
"acme-backups",
"acme-ml-datasets",
"acme-cloudtrail-logs",
"acme-invoices",
"acme-build-artifacts",
],
"acme-data",
),
uid: (name) => `arn:aws:s3:::${name}`,
},
iam_root_account: {
service: "iam",
type: "AwsIamUser",
global: true,
name: () => "<root_account>",
uid: () => `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:root`,
},
rds_instance: {
service: "rds",
type: "AwsRdsDbInstance",
name: named(["orders-db", "billing-db", "analytics-db"], "app-db"),
uid: (name, region) => arn("rds", region, `db:${name}`),
},
lambda_function: {
service: "awslambda",
type: "AwsLambdaFunction",
name: named(
["orders-sync", "invoice-generator", "thumbnailer", "webhook-relay"],
"worker",
),
uid: (name, region) => arn("lambda", region, `function:${name}`),
},
ec2_instance: {
service: "ec2",
type: "AwsEc2Instance",
name: (index) => hexId("i", 0x3f2a9c1, index),
uid: (name, region) => arn("ec2", region, `instance/${name}`),
},
ec2_security_group: {
service: "ec2",
type: "AwsEc2SecurityGroup",
name: (index) => hexId("sg", 0x1b7d42e, index),
uid: (name, region) => arn("ec2", region, `security-group/${name}`),
},
iam_policy: {
service: "iam",
type: "AwsIamPolicy",
global: true,
name: named(
["ci-deployer", "data-pipeline", "support-access"],
"app-policy",
),
uid: (name) => `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:policy/${name}`,
},
cloudtrail_trail: {
service: "cloudtrail",
type: "AwsCloudTrailTrail",
name: () => "management-events",
uid: (name, region) => arn("cloudtrail", region, `trail/${name}`),
},
iam_user: {
service: "iam",
type: "AwsIamUser",
global: true,
name: named(
["maria.lopez", "deploy-bot", "jon.smith", "ana.garcia"],
"user",
),
uid: (name) => `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:user/${name}`,
},
eks_cluster: {
service: "eks",
type: "AwsEksCluster",
name: named(["prod-platform", "staging-platform"], "cluster"),
uid: (name, region) => arn("eks", region, `cluster/${name}`),
},
ebs_volume: {
service: "ec2",
type: "AwsEc2Volume",
name: (index) => hexId("vol", 0x2c4e8a3, index),
uid: (name, region) => arn("ec2", region, `volume/${name}`),
},
iam_access_key: {
service: "iam",
type: "AwsIamUser",
global: true,
name: named(
["deploy-bot", "legacy-etl", "maria.lopez", "backup-agent"],
"svc",
),
uid: (name) => `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:user/${name}`,
},
guardduty_detector: {
service: "guardduty",
type: "AwsGuardDutyDetector",
name: (_index, region) => `detector-${region}`,
uid: (name, region) => arn("guardduty", region, `detector/${name}`),
},
config_recorder: {
service: "config",
type: "AwsConfigConfigurationRecorder",
name: (_index, region) => `default-${region}`,
uid: (name, region) => arn("config", region, `recorder/${name}`),
},
vpc: {
service: "vpc",
type: "AwsEc2Vpc",
name: (index) => hexId("vpc", 0x5e19b07, index),
uid: (name, region) => arn("ec2", region, `vpc/${name}`),
},
kms_key: {
service: "kms",
type: "AwsKmsKey",
name: (index) =>
`${(0x8f3a2b1c + index * 104729).toString(16)}-4c1d-9e2f-a7b6-3d5e8f0a1b2c`,
uid: (name, region) => arn("kms", region, `key/${name}`),
},
apigateway_rest_api: {
service: "apigateway",
type: "AwsApiGatewayRestApi",
name: named(["public-api", "partner-api", "internal-api"], "api"),
uid: (name, region) => `arn:aws:apigateway:${region}::/restapis/${name}`,
},
sqs_queue: {
service: "sqs",
type: "AwsSqsQueue",
name: named(
["orders", "emails", "webhooks", "invoices", "dead-letter"],
"queue",
),
uid: (name, region) => arn("sqs", region, name),
},
cloudwatch_log_group: {
service: "cloudwatch",
type: "AwsLogsLogGroup",
name: () => "cloudtrail/management-events",
uid: (name, region) => arn("logs", region, `log-group:${name}`),
},
aws_account: {
service: "account",
type: "Other",
global: true,
name: () => SAMPLE_ACCOUNT.uid,
uid: () => `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:root`,
},
};
const resourceKind = (type: string): ResourceKind => {
const kind = RESOURCE_KINDS[type];
if (!kind) throw new Error(`No sample resource kind for ${type}`);
return kind;
};
// Checks of one resource type evaluate the same resources, so the pool is as
// large as the widest check of that type (as sample-overview counts them).
const poolSizes = SAMPLE_CHECKS.reduce<Record<string, number>>(
(sizes, check) => ({
...sizes,
[check.resourceType]: Math.max(
sizes[check.resourceType] ?? 0,
check.failed + check.passed,
),
}),
{},
);
const RESOURCE_POOLS: Record<string, SampleResource[]> = Object.fromEntries(
Object.entries(poolSizes).map(([type, size]) => {
const kind = resourceKind(type);
return [
type,
Array.from({ length: size }, (_, index) => {
const region = kind.global
? SAMPLE_REGIONS[0]
: SAMPLE_REGIONS[index % SAMPLE_REGIONS.length];
const name = kind.name(index, region);
return {
id: `${SAMPLE_ID_PREFIX}resource-${type}-${index + 1}`,
uid: kind.uid(name, region),
name,
region,
service: kind.service,
type: kind.type,
};
}),
];
}),
);
export const SAMPLE_RESOURCES: SampleResource[] =
Object.values(RESOURCE_POOLS).flat();
// The first `failed` resources of the pool fail, so checks of one type fail
// on the same resources (the exposed instance also misses IMDSv2).
export const SAMPLE_FINDINGS: SampleFinding[] = SAMPLE_CHECKS.flatMap((check) =>
RESOURCE_POOLS[check.resourceType]
.slice(0, check.failed + check.passed)
.map((resource, index) => ({
id: `${SAMPLE_ID_PREFIX}finding-${check.checkId}-${index + 1}`,
uid: `prowler-aws-${check.checkId}-${SAMPLE_ACCOUNT.uid}-${resource.region}-${resource.name}`,
check,
resource,
status: index < check.failed ? "FAIL" : "PASS",
delta: index < check.newFailed ? "new" : null,
})),
);
const FINDINGS_BY_ID = new Map(
SAMPLE_FINDINGS.map((finding) => [finding.id, finding]),
);
export const getSampleFinding = (id: string) => FINDINGS_BY_ID.get(id);
export const failedSampleFindings = (checkId: string) =>
SAMPLE_FINDINGS.filter(
(finding) => finding.check.checkId === checkId && finding.status === "FAIL",
);
// JSON:API documents, shaped like /findings?include=resources,scan.provider.
const fillName = (text: string, name: string) =>
text.replaceAll("{name}", name);
const findingItem = (finding: SampleFinding) => {
const { check, resource } = finding;
const detail = SAMPLE_CHECK_DETAILS[check.checkId];
return {
type: "findings",
id: finding.id,
attributes: {
uid: finding.uid,
check_id: check.checkId,
status: finding.status,
status_extended: fillName(
finding.status === "FAIL" ? detail.fail : detail.pass,
resource.name,
),
severity: check.severity,
delta: finding.delta,
muted: false,
muted_reason: null,
first_seen_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
inserted_at: SAMPLE_ACCOUNT.scannedAt,
triage_status: FINDING_TRIAGE_STATUS.OPEN,
raw_result: null,
// Versioned keys, as the API sends them: the drawer strips the version.
compliance: Object.fromEntries(
detail.compliance.map(({ framework, version }) => [
version ? `${framework}-${version}` : framework,
[],
]),
),
check_metadata: {
checkid: check.checkId,
checktitle: check.title,
checktype: [],
checkaliases: [],
provider: "aws",
servicename: check.service,
subservicename: "",
severity: check.severity,
resourcetype: resource.type,
resourceidtemplate: "",
resourcegroup: check.resourceGroup,
description: detail.description,
risk: detail.risk,
notes: "",
relatedurl: "",
dependson: [],
relatedto: [],
categories: [],
compliance: null,
remediation: {
recommendation: { text: detail.recommendation, url: "" },
code: {
cli: detail.cli ? fillName(detail.cli, resource.name) : "",
other: "",
nativeiac: "",
terraform: "",
},
},
additionalurls: [],
},
},
relationships: {
resources: { data: [{ type: "resources" as const, id: resource.id }] },
scan: { data: { type: "scans", id: SAMPLE_ACCOUNT.scanId } },
},
};
};
const resourceItem = (resource: SampleResource) => ({
type: "resources",
id: resource.id,
attributes: {
uid: resource.uid,
name: resource.name,
region: resource.region,
service: resource.service,
type: resource.type,
tags: {},
details: null,
metadata: null,
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
},
relationships: {
provider: { data: { type: "providers", id: SAMPLE_ACCOUNT.providerId } },
},
});
const SCAN_ITEM = {
type: "scans",
id: SAMPLE_ACCOUNT.scanId,
attributes: {
name: "Sample scan",
trigger: "manual",
state: "completed",
unique_resource_count: SAMPLE_RESOURCES.length,
progress: 100,
duration: 742,
started_at: SAMPLE_ACCOUNT.scannedAt,
completed_at: SAMPLE_ACCOUNT.scannedAt,
inserted_at: SAMPLE_ACCOUNT.scannedAt,
scheduled_at: null,
},
relationships: {
provider: { data: { type: "providers", id: SAMPLE_ACCOUNT.providerId } },
},
};
const PROVIDER_ITEM = {
type: "providers",
id: SAMPLE_ACCOUNT.providerId,
attributes: {
provider: "aws",
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
connection: { connected: true, last_checked_at: SAMPLE_ACCOUNT.scannedAt },
},
};
export const sampleFindingsDocument = (findings: SampleFinding[]) => {
const resources = new Map(
findings.map(({ resource }) => [resource.id, resource]),
);
return {
data: findings.map(findingItem),
included: [
...Array.from(resources.values()).map(resourceItem),
SCAN_ITEM,
PROVIDER_ITEM,
],
meta: { version: "v1" },
};
};
/** /findings?include=resources: the issue panel asks for nothing else. */
export const sampleIssueFindingsDocument = (findings: SampleFinding[]) => ({
data: findings.map(findingItem),
included: findings.map(({ resource }) => resourceItem(resource)),
});
export const sampleFindingDocument = (finding: SampleFinding) => {
const { data, ...rest } = sampleFindingsDocument([finding]);
return { ...rest, data: data[0] };
};
const paginate = <T>(items: T[], page: number, pageSize: number) => ({
items: items.slice((page - 1) * pageSize, page * pageSize),
pagination: {
page,
pages: Math.max(1, Math.ceil(items.length / pageSize)),
count: items.length,
},
});
/** /finding-groups/{check_id}/resources: failed first, like the API's sort. */
export const sampleFindingGroupResources = ({
checkId,
page,
pageSize,
status,
}: {
checkId: string;
page: number;
pageSize: number;
status?: string;
}) => {
const findings = SAMPLE_FINDINGS.filter(
(finding) =>
finding.check.checkId === checkId &&
(!status || status.split(",").includes(finding.status)),
);
const { items, pagination } = paginate(findings, page, pageSize);
return {
data: items.map((finding) => ({
type: "finding-group-resources",
id: finding.resource.id,
attributes: {
finding_id: finding.id,
finding_uid: finding.uid,
resource: {
uid: finding.resource.uid,
name: finding.resource.name,
service: finding.resource.service,
region: finding.resource.region,
type: finding.resource.type,
resource_group: finding.check.resourceGroup,
},
provider: {
type: "aws",
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
},
status: finding.status,
status_extended: findingItem(finding).attributes.status_extended,
severity: finding.check.severity,
delta: finding.delta,
muted: false,
first_seen_at: SAMPLE_ACCOUNT.scannedAt,
last_seen_at: SAMPLE_ACCOUNT.scannedAt,
triage_status: FINDING_TRIAGE_STATUS.OPEN,
},
})),
meta: { pagination },
};
};
/** /findings filtered the way the drawers and the issue graph ask. */
export const sampleFindingsQuery = (
filters: Record<string, string | string[] | undefined>,
) => {
const value = (key: string) => {
const raw = filters[key];
return Array.isArray(raw) ? raw.join(",") : raw;
};
const resourceUid = value("filter[resource_uid]");
const checkId = value("filter[check_id]");
const status = value("filter[status]");
const uids = value("filter[uid__in]")?.split(",");
return SAMPLE_FINDINGS.filter(
(finding) =>
(!resourceUid || finding.resource.uid === resourceUid) &&
(!checkId || finding.check.checkId === checkId) &&
(!status || finding.status === status) &&
(!uids || uids.includes(finding.uid)),
);
};
/** Whether a request is scoped to the sample scan. */
export const isSampleScanFilter = (
filters: Record<string, string | string[] | undefined> | undefined,
) => filters?.["filter[scan]"] === SAMPLE_ACCOUNT.scanId;
const SAMPLE_RESOURCE_UIDS = new Set(
SAMPLE_RESOURCES.map((resource) => resource.uid),
);
export const isSampleResourceUid = (uid: string) =>
SAMPLE_RESOURCE_UIDS.has(uid);
/** Shown when a write targets sample data: nothing is saved. */
export const SAMPLE_WRITE_MESSAGE =
"This is sample data, so nothing was saved. Connect your cloud account to work on your own findings.";
/** /findings/{id}/compliance-frameworks: the check's frameworks, all watched. */
export const sampleComplianceFrameworks = (
findingId: string,
): FindingComplianceFrameworksResult => {
const finding = getSampleFinding(findingId);
const frameworks = finding
? SAMPLE_CHECK_DETAILS[finding.check.checkId].compliance
: [];
return {
frameworks: frameworks.map((framework) => ({
...framework,
id: `${SAMPLE_ID_PREFIX}${framework.complianceId}`,
providerType: "aws",
scope: WATCHLIST_SCOPE.PROVIDER,
inWatchlist: true,
})),
unavailable: false,
};
};
// CloudTrail names per resource type: how it was created, then last changed.
const SAMPLE_EVENT_NAMES: Record<string, [create: string, change: string]> = {
AwsS3Bucket: ["CreateBucket", "PutBucketPolicy"],
AwsEc2Instance: ["RunInstances", "ModifyInstanceMetadataOptions"],
AwsEc2SecurityGroup: ["CreateSecurityGroup", "AuthorizeSecurityGroupIngress"],
AwsEc2Volume: ["CreateVolume", "AttachVolume"],
AwsIamUser: ["CreateUser", "CreateAccessKey"],
AwsIamPolicy: ["CreatePolicy", "CreatePolicyVersion"],
AwsRdsDbInstance: ["CreateDBInstance", "ModifyDBInstance"],
AwsLambdaFunction: [
"CreateFunction20150331",
"UpdateFunctionConfiguration20150331v2",
],
AwsEksCluster: ["CreateCluster", "UpdateClusterConfig"],
AwsKmsKey: ["CreateKey", "DisableKeyRotation"],
AwsSqsQueue: ["CreateQueue", "SetQueueAttributes"],
};
const DAY_MS = 24 * 60 * 60 * 1000;
const daysBeforeScan = (days: number) =>
new Date(Date.parse(SAMPLE_ACCOUNT.scannedAt) - days * DAY_MS).toISOString();
/** /resources/{id}/events: two CloudTrail write events for the resource. */
export const sampleResourceEvents = (resourceId: string) => {
const resource = SAMPLE_RESOURCES.find(({ id }) => id === resourceId);
const names = resource && SAMPLE_EVENT_NAMES[resource.type];
if (!resource || !names) return [];
const event = (
index: number,
eventName: string,
actor: string,
userAgent: string,
days: number,
) => ({
type: "resource-events" as const,
id: `${resource.id}-event-${index}`,
attributes: {
event_time: daysBeforeScan(days),
event_name: eventName,
event_source: `${resource.service === "awslambda" ? "lambda" : resource.service}.amazonaws.com`,
actor,
actor_uid: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:user/${actor}`,
actor_type: "IAMUser",
source_ip_address: index === 1 ? "203.0.113.24" : "198.51.100.7",
user_agent: userAgent,
request_data: null,
response_data: null,
error_code: null,
error_message: null,
},
});
return [
event(2, names[1], "maria.lopez", "AWS Internal", 5),
event(
1,
names[0],
"deploy-bot",
"APN/1.0 HashiCorp/1.0 Terraform/1.9.5",
143,
),
];
};
-1
View File
@@ -85,7 +85,6 @@ export const FINDING_TRIAGE_DISABLED_REASON = {
CLOUD_ONLY: "cloud_only",
FORBIDDEN: "forbidden",
LOADING: "loading",
SAMPLE: "sample",
} as const;
export type FindingTriageDisabledReason =
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10, <3.14"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -3801,7 +3801,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.48.0"
version = "5.47.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },