Compare commits

..
Author SHA1 Message Date
renovate[bot] 5a1c1d2826 chore(mcp): update python docker tag to v3.15.1 2026-11-01 10:31:48 +00:00
139 changed files with 511 additions and 7636 deletions

No files matched your search

+8
View File
@@ -151,6 +151,14 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
| Component | Location | Tech Stack |
|-----------|----------|------------|
| SDK | `prowler/` | Python 3.10+, uv |
| API | `api/` | Django 5.1, DRF, Celery |
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
| Dashboard | `dashboard/` | Dash, Plotly |
---
## Python Development
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
+23
View File
@@ -97,6 +97,29 @@ Needs retry → Celery task
---
## TECH STACK
Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | PostgreSQL 16 | pytest 8.x
---
## PROJECT STRUCTURE
```text
api/src/backend/
├── api/ # Main Django app
│ ├── v1/ # API version 1 (views, serializers, urls)
│ ├── models.py # Django models
│ ├── filters.py # FilterSet classes
│ ├── base_views.py # Base ViewSet classes
│ ├── rls.py # Row-Level Security
│ └── tests/ # Unit tests
├── config/ # Django configuration
└── tasks/ # Celery tasks
```
---
## COMMANDS
```bash
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
-1
View File
@@ -1 +0,0 @@
pyasn1 to 0.6.5, patching the ASN.1 decoding denial of service advisories
+1 -1
View File
@@ -367,7 +367,7 @@ constraint-dependencies = [
"py-deviceid==0.1.1",
"py-iam-expand==0.3.0",
"py-ocsf-models==0.10.0",
"pyasn1==0.6.5",
"pyasn1==0.6.4",
"pyasn1-modules==0.4.2",
"pycodestyle==2.14.0",
"pycparser==3.0",
Generated
+6 -6
View File
@@ -1,5 +1,5 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
@@ -283,7 +283,7 @@ constraints = [
{ name = "py-deviceid", specifier = "==0.1.1" },
{ name = "py-iam-expand", specifier = "==0.3.0" },
{ name = "py-ocsf-models", specifier = "==0.10.0" },
{ name = "pyasn1", specifier = "==0.6.5" },
{ name = "pyasn1", specifier = "==0.6.4" },
{ name = "pyasn1-modules", specifier = "==0.4.2" },
{ name = "pycodestyle", specifier = "==2.14.0" },
{ name = "pycparser", specifier = "==3.0" },
@@ -4204,7 +4204,7 @@ wheels = [
[package.optional-dependencies]
broker = [
{ name = "pymsalruntime" },
{ name = "pymsalruntime", marker = "sys_platform == 'win32'" },
]
[[package]]
@@ -5174,11 +5174,11 @@ wheels = [
[[package]]
name = "pyasn1"
version = "0.6.5"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
FROM python:3.13.18-alpine3.23@sha256:e4aeaca9081549f19bd5bc8605ed5ab257ba34f97e0471f8e09161f45b4b08b8
FROM python:3.15.1-alpine3.23@sha256:cb295b554dbbea09b2c0e6e6d9ce55f051347c902fbb611e618c071addfb30a3
LABEL maintainer="https://github.com/prowler-cloud"
@@ -1 +0,0 @@
joserfc to 1.7.5, patching the JWE decryption denial of service advisory
@@ -1 +0,0 @@
Python base image from 3.13.16 to 3.13.18 in the MCP Server container image, patching CVE-2026-115557
+1 -1
View File
@@ -79,7 +79,7 @@ constraint-dependencies = [
"authlib==1.9.1",
"cryptography==50.0.0",
"dnspython==2.9.0",
"joserfc==1.7.5",
"joserfc==1.6.8",
"mcp==1.28.1",
"pyjwt==2.14.0",
"python-multipart==0.0.30"
+4 -4
View File
@@ -13,7 +13,7 @@ constraints = [
{ name = "authlib", specifier = "==1.9.1" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dnspython", specifier = "==2.9.0" },
{ name = "joserfc", specifier = "==1.7.5" },
{ name = "joserfc", specifier = "==1.6.8" },
{ name = "mcp", specifier = "==1.28.1" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-multipart", specifier = "==0.0.30" },
@@ -594,14 +594,14 @@ wheels = [
[[package]]
name = "joserfc"
version = "1.7.5"
version = "1.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
]
[[package]]
@@ -1 +0,0 @@
pyasn1 to 0.6.5 and joserfc to 1.7.5 in the SDK, patching the ASN.1 decoding and JWE decryption denial of service advisories
+2 -2
View File
@@ -288,7 +288,7 @@ constraint-dependencies = [
"itsdangerous==2.2.0",
"jinja2==3.1.6",
"jmespath==1.1.0",
"joserfc==1.7.5",
"joserfc==1.6.8",
"jsonpatch==1.33",
"jsonpath-ng==1.8.0",
"jsonpointer==3.1.1",
@@ -342,7 +342,7 @@ constraint-dependencies = [
"protobuf==7.34.1",
"psutil==7.2.2",
"py-partiql-parser==0.6.1",
"pyasn1==0.6.5",
"pyasn1==0.6.4",
"pyasn1-modules==0.4.2",
"pycodestyle==2.12.1",
"pycparser==3.0",
+25 -1
View File
@@ -183,7 +183,31 @@ test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => {
---
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there; use `components/shadcn/`.
## TECH STACK
Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | Recharts 2.15.4
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there.
---
## PROJECT STRUCTURE
```text
ui/
├── app/(auth)/ # Auth pages
├── app/(prowler)/ # Main app: compliance, findings, providers, scans
├── components/shadcn/ # shadcn/ui components (USE THIS)
├── components/ui/ # Cloud-overlay re-export shims (do not add here)
├── actions/ # Server actions
├── types/ # Shared types
├── hooks/ # Shared hooks
├── lib/ # Utilities
├── store/ # Zustand state
├── tests/ # Playwright E2E
└── styles/ # Global CSS
```
---
@@ -16,11 +16,6 @@ import {
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import {
isSampleScanFilter,
pickSampleFindingFilters,
sampleFindingGroupResources,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
/**
@@ -192,19 +187,6 @@ async function fetchFindingGroupResourcesEndpoint(
filters = {},
}: FetchFindingGroupResourcesParams,
) {
if (isSampleScanFilter(filters)) {
const status = filters["filter[status]"] ?? filters["filter[status__in]"];
const search = filters["filter[name__icontains]"];
return sampleFindingGroupResources({
checkId,
page,
pageSize,
status: Array.isArray(status) ? status.join(",") : status,
search: Array.isArray(search) ? search[0] : search,
filters: pickSampleFindingFilters(filters),
});
}
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 {
@@ -278,14 +278,6 @@ describe("getLatestFindingsByResourceUid", () => {
handleApiResponseMock.mockResolvedValue({ data: [] });
});
it("does not substitute sample records for a real resource with the same ARN", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
await getLatestFindingsByResourceUid({
resourceUid: "arn:aws:s3:::acme-customer-exports",
});
expect(fetchMock).toHaveBeenCalledOnce();
});
it("should restrict to FAIL, exclude muted findings, and apply severity/time sorting by default", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
@@ -11,10 +11,6 @@ import {
} from "@/lib";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
sampleFindingsDocument,
sampleFindingsQuery,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500;
@@ -259,24 +255,12 @@ export const getLatestFindingsByResourceUid = async ({
page = 1,
pageSize = 50,
includeMuted = false,
sample = false,
}: {
resourceUid: string;
page?: number;
pageSize?: number;
includeMuted?: boolean;
/** Explicitly scoped by the sample finding; real resource names can match the demo. */
sample?: boolean;
}) => {
if (sample) {
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-guards";
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;
};
-5
View File
@@ -10,10 +10,6 @@ import {
import { createMuteRule } from "@/actions/mute-rules";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { ACTION_ERROR_STATUS, UsageLimitError } from "@/lib/action-errors";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
FINDING_TRIAGE_STATUS_LABELS,
@@ -285,7 +281,6 @@ export async function loadFindingTriageDetail(
}
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
if (isSampleId(input.findingId)) throw new Error(SAMPLE_WRITE_MESSAGE);
try {
return await applyFindingTriageUpdate(input);
} catch (error) {
-33
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,26 +24,6 @@ export const getFindings = async ({
sort = "",
filters = {},
}) => {
if (isSampleScanFilter(filters)) {
const findings = sampleFindingsQuery(filters);
const size = Number(pageSize) || 10;
const current = Math.max(1, Number(page) || 1);
const document = sampleFindingsDocument(
findings.slice((current - 1) * size, current * size),
);
return withFindingTriageSummaries({
...document,
meta: {
...document.meta,
pagination: {
page: current,
pages: Math.max(1, Math.ceil(findings.length / size)),
count: findings.length,
},
},
});
}
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1)
@@ -192,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}`);
-6
View File
@@ -3,10 +3,6 @@
import { pollTaskUntilSettled } from "@/actions/task/poll";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { handleApiError } from "@/lib/server-actions-helper";
import type {
IntegrationProps,
@@ -126,8 +122,6 @@ export const sendJiraDispatch = async ({
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
if (targetIds.some(isSampleId))
return { success: false, error: SAMPLE_WRITE_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
+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`);
-12
View File
@@ -12,11 +12,6 @@ import {
sanitizeErrorMessage,
} from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isSampleId,
sampleResourceDrawerData,
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";
@@ -189,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." };
}
@@ -296,10 +288,6 @@ export const getResourceDrawerData = async ({
pageSize?: number;
query?: string;
}) => {
if (isSampleId(resourceId)) {
return sampleResourceDrawerData(resourceId, query);
}
const isCloudEnv = isCloud();
const [resourceData, findingsResponse, organizationsResponse] =
-5
View File
@@ -19,10 +19,6 @@ import {
isReportDownloadLocked,
REPORT_DOWNLOAD_LOCKED_ERROR,
} from "@/lib/report-download-access";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { SCAN_STATES } from "@/types/attack-paths";
@@ -199,7 +195,6 @@ export const createPartialScan = async ({
providerId: string;
resourceUids: string[];
}) => {
if (isSampleId(providerId)) return { error: SAMPLE_WRITE_MESSAGE };
if (!providerId) {
return { error: "Provider ID is required" };
}
@@ -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>
</>
);
}
@@ -25,14 +25,10 @@ function getScoreColor(score: number): string {
}
interface RiskPlotClientProps {
disableNavigation?: boolean;
data: RiskPlotPoint[];
}
export function RiskPlotClient({
data,
disableNavigation = false,
}: RiskPlotClientProps) {
export function RiskPlotClient({ data }: RiskPlotClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
@@ -135,7 +131,7 @@ export function RiskPlotClient({
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={disableNavigation ? undefined : handleBarClick}
onBarClick={handleBarClick}
/>
</div>
) : (
@@ -13,14 +13,10 @@ import { SEVERITY_FILTER_MAP } from "@/types/severities";
import { CategorySelector } from "./category-selector";
interface RiskRadarViewClientProps {
disableNavigation?: boolean;
data: RadarDataPoint[];
}
export function RiskRadarViewClient({
data,
disableNavigation = false,
}: RiskRadarViewClientProps) {
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
@@ -103,7 +99,7 @@ export function RiskRadarViewClient({
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={disableNavigation ? undefined : handleBarClick}
onBarClick={handleBarClick}
/>
</div>
) : (
@@ -1,74 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
SAMPLE_SERVICES_RESPONSE,
SAMPLE_STATUS,
} from "@/lib/sample-preview/sample-overview";
import { OverviewSamplePreview } from "./overview-sample-preview";
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
// Card links import server actions; their auth chain has no place in jsdom.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, 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: () => null,
}));
describe("OverviewSamplePreview", () => {
it("renders the demo account's passing and failing totals", () => {
render(<OverviewSamplePreview showIssues={false} />);
const card = screen
.getByText("Check Findings")
.closest('[data-slot="card"]')!;
expect(
within(card as HTMLElement).getByText(
SAMPLE_STATUS.fail.toLocaleString(),
),
).toBeVisible();
expect(
within(card as HTMLElement).getByText(
SAMPLE_STATUS.pass.toLocaleString(),
),
).toBeVisible();
});
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"));
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link.getAttribute("href")).toMatch(
/^\/(findings|issues|compliance|resources)(\?|$)/,
);
}
});
it("opens sample findings filtered by the selected service", async () => {
const user = userEvent.setup();
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];
await user.click(
screen.getByRole("button", { name: new RegExp(riskiest.id, "i") }),
);
const url = new URL(pushMock.mock.calls[0][0], "https://prowler.test");
expect(url.pathname).toBe("/findings");
expect(url.searchParams.get("filter[service__in]")).toBe(riskiest.id);
expect(url.searchParams.get("filter[status__in]")).toBe("FAIL");
});
});
@@ -1,131 +0,0 @@
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
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";
const NO_SCOPE: Record<string, string> = {};
interface OverviewSamplePreviewProps {
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
showIssues: boolean;
/** Compliance totals derived from the sample requirements, when available. */
complianceWatchlist?: ComplianceWatchlistResponse["data"];
}
/** The Overview filled with the demo account, for tenants without a scan. */
export function OverviewSamplePreview({
showIssues,
complianceWatchlist = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data,
}: OverviewSamplePreviewProps) {
const complianceItems = adaptComplianceWatchlistResponse({
...SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
data: complianceWatchlist,
}).map((item) => ({
id: item.id,
framework: item.complianceId,
label: item.label,
icon: item.icon,
score: item.score,
}));
return (
<OverviewGrid
threatScore={
<ThreatScore
score={SAMPLE_THREAT_SCORE.score}
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
/>
}
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={
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
}
complianceWatchlist={
// The sample Compliance page is per scan: no multi-scan view to link.
<ComplianceWatchlist items={complianceItems} hasWatchlist={false} />
}
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={
<FindingSeverityOverTimeCard>
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} readOnly />
</FindingSeverityOverTimeCard>
}
graphsTabs={<SampleGraphsTabs />}
/>
);
}
@@ -1,51 +0,0 @@
import { expect, it } from "vitest";
import { render } from "@/__tests__/render-browser";
import {
SAMPLE_NEW_FINDINGS,
SAMPLE_RADAR_DATA,
} from "@/lib/sample-preview/sample-graphs";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
it("offers each latest new failed finding as a drawer trigger", async () => {
const screen = await render(<SampleGraphsTabs />);
const [finding] = SAMPLE_NEW_FINDINGS;
// The trigger opens the finding drawer, which resolves sample ids locally.
await expect
.element(
screen
.getByRole("button", {
name: finding.attributes.check_metadata.checktitle,
})
.first(),
)
.toBeEnabled();
});
it("lets users explore sample categories without opening live findings", async () => {
const screen = await render(<SampleGraphsTabs />);
const category = SAMPLE_RADAR_DATA[0];
// A click that lands before the tabs hydrate is lost: retry until selected.
const radarTab = screen.getByRole("tab", { name: "Risk Radar" });
await expect
.poll(async () => {
await radarTab.click();
return radarTab.element().getAttribute("aria-selected");
})
.toBe("true");
await expect.element(screen.getByRole("combobox")).toBeVisible();
await screen.getByRole("combobox").click();
await screen
.getByRole("option", { name: category.category, exact: true })
.click();
await expect
.element(screen.getByText(`${category.value} Total Findings`))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: /Critical/ }))
.not.toBeInTheDocument();
});
@@ -1,81 +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";
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
findings: (
<div className="flex w-full flex-1">
<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>
</div>
),
"threat-map": (
<div className="flex w-full flex-1">
<div className="w-full flex-1 overflow-hidden">
<ThreatMap
disableNavigation
data={SAMPLE_THREAT_MAP_DATA}
height={460}
/>
</div>
</div>
),
"risk-radar": (
<div className="flex w-full flex-1">
<RiskRadarViewClient disableNavigation data={SAMPLE_RADAR_DATA} />
</div>
),
"risk-pipeline": (
<div className="flex w-full flex-1">
<div className="w-full flex-1 overflow-visible">
<SankeyChart
disableNavigation
data={SAMPLE_SANKEY_DATA}
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
height={460}
/>
</div>
</div>
),
"risk-plot": (
<div className="flex w-full flex-1">
<div className="w-full flex-1 overflow-visible">
<RiskPlotClient disableNavigation data={SAMPLE_RISK_PLOT_POINTS} />
</div>
</div>
),
};
export const SampleGraphsTabs = () => (
<GraphsTabsClient tabsContent={SAMPLE_TABS_CONTENT} />
);
@@ -20,12 +20,10 @@ import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
data: LineDataPoint[];
readOnly?: boolean;
}
export const FindingSeverityOverTime = ({
data: initialData,
readOnly = false,
}: FindingSeverityOverTimeProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -133,15 +131,13 @@ export const FindingSeverityOverTime = ({
return (
<>
{!readOnly && (
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
)}
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
{error ? (
<div
role="alert"
@@ -156,7 +152,7 @@ export const FindingSeverityOverTime = ({
lines={lines}
height={400}
xAxisInterval={getXAxisInterval()}
onPointClick={readOnly ? undefined : handlePointClick}
onPointClick={handlePointClick}
/>
</div>
)}
@@ -1,5 +1,4 @@
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
@@ -43,8 +42,16 @@ export const FindingSeverityOverTimeSSR = async ({
}
return (
<FindingSeverityOverTimeCard>
<FindingSeverityOverTime data={result.data.data} />
</FindingSeverityOverTimeCard>
<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={result.data.data} />
</CardContent>
</Card>
);
};
@@ -27,9 +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;
readOnly?: boolean;
}
const DEFAULT_PAGE_SIZE = 10;
@@ -103,10 +100,8 @@ const buildMetadata = (
const getColumns = ({
selectedScanId,
readOnly,
}: {
selectedScanId: string | null;
readOnly: boolean;
}): ColumnDef<AttackPathScan>[] => [
{
id: "select",
@@ -120,11 +115,10 @@ const getColumns = ({
<RadioGroupItem
value={row.original.id}
checked={isSelected}
disabled={readOnly || !canSelect}
disabled={!canSelect}
className={cn(
"size-5",
!readOnly &&
canSelect &&
canSelect &&
!isSelected &&
"border-text-neutral-secondary cursor-pointer",
!canSelect && "disabled:opacity-70",
@@ -233,16 +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,
readOnly = false,
}: 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,
@@ -288,32 +278,23 @@ export const ScanListTable = ({
return (
<RadioGroup
disabled={readOnly}
value={selectedScanId ?? ""}
onValueChange={handleSelectScan}
className="gap-0"
>
<DataTable
columns={getColumns({ selectedScanId, readOnly })}
columns={getColumns({ selectedScanId })}
data={paginatedScans}
metadata={
readOnly
? undefined
: buildMetadata(scans.length, currentPage, totalPages)
}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={
readOnly
? undefined
: (row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}
}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
@@ -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,97 +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 { failedSampleFindings } from "@/lib/sample-preview/sample-records";
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,
};
const [BUCKET_FINDING] = failedSampleFindings("s3_bucket_public_access");
const [INSTANCE_FINDING] = failedSampleFindings("ec2_instance_imdsv2_enabled");
beforeEach(() => {
actions.getFindingById.mockReset();
useGraphStore.getState().reset();
resetLighthouseContextStore();
window.history.replaceState({}, "", "/attack-paths");
});
describe("Attack Paths sample preview", () => {
it("shows the sample graph, opens its findings and keeps it off the API and Lighthouse", async () => {
// Given
const screen = await render(
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />,
);
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
await expect
.element(screen.getByRole("radio", { name: "Selected scan" }))
.toBeDisabled();
// 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(BUCKET_FINDING.id)).not.toBeNull(),
);
await graph.clickNode(BUCKET_FINDING.id);
// The finding drawer resolves the sample record through the read action.
await vi.waitFor(() =>
expect(actions.getFindingById).toHaveBeenCalledWith(
BUCKET_FINDING.id,
expect.anything(),
),
);
await graph.executeQuery({ selectFirst: false });
// Then
expect(graph.showsNodeLabel(INSTANCE_FINDING.resource.name)).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(
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,10 +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
findingNavigationInFlightRef.current = true;
void handleViewFinding(String(node.properties?.id || node.id));
return;
}
@@ -434,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">
@@ -495,13 +465,8 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
/>
) : (
<>
{/* The sample scan is the only one: nothing to pick or page. */}
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable
scans={scans}
selectedScanId={sample?.scan.id}
readOnly={Boolean(sample)}
/>
<ScanListTable scans={scans} />
</Suspense>
{isViewingPreviousCycleData && (
+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";
@@ -14,11 +14,9 @@ const {
getComplianceAttributesMock,
getComplianceOverviewMetadataInfoMock,
getComplianceRequirementsMock,
getSamplePreviewStateMock,
getScanMock,
mapComplianceDataMock,
} = vi.hoisted(() => ({
getSamplePreviewStateMock: vi.fn(),
getComplianceAttributesMock: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(),
getComplianceRequirementsMock: vi.fn(),
@@ -114,14 +112,6 @@ vi.mock("@/lib/shared/env", () => ({
isCloud: () => false,
}));
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("../_components/compliance-detail-sample-preview", () => ({
ComplianceDetailSamplePreview: () => <div>Sample compliance detail</div>,
}));
vi.mock("../_components/cross-account-detail", () => ({
CrossAccountDetail: () => null,
}));
@@ -241,46 +231,3 @@ describe("Compliance detail task response", () => {
).toBeInTheDocument();
});
});
describe("Compliance detail for the sample scan", () => {
const renderSampleDetail = async () =>
ComplianceDetail({
params: Promise.resolve({ compliancetitle: "CIS" }),
searchParams: Promise.resolve({
complianceId: "cis_5.0_aws",
version: "5.0",
scanId: "sample-scan",
}),
});
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the sample detail without asking the API about the scan", async () => {
// Given
getSamplePreviewStateMock.mockResolvedValue({
active: true,
stage: "no_scan",
runningScan: null,
});
// When
render((await renderSampleDetail()) as ReactElement);
// Then
expect(screen.getByText("Sample compliance detail")).toBeInTheDocument();
expect(getScanMock).not.toHaveBeenCalled();
expect(getComplianceRequirementsMock).not.toHaveBeenCalled();
expect(getComplianceOverviewMetadataInfoMock).not.toHaveBeenCalled();
});
it("sends the sample scan back to Compliance once the preview is over", async () => {
// Given
getSamplePreviewStateMock.mockResolvedValue({ active: false });
// When / Then
await expect(renderSampleDetail()).rejects.toThrow("redirect");
expect(getScanMock).not.toHaveBeenCalled();
});
});
@@ -39,8 +39,6 @@ import {
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import type { SearchParamsProps } from "@/types";
@@ -52,7 +50,6 @@ import {
import { isKnownProviderType } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { ComplianceDetailSamplePreview } from "../_components/compliance-detail-sample-preview";
import { CrossAccountDetail } from "../_components/cross-account-detail";
import { CrossProviderDetail } from "../_components/cross-provider-detail";
import { buildSearchParamsKey } from "../_lib/search-params-key";
@@ -81,25 +78,6 @@ export default async function ComplianceDetail({
notFound();
}
// The sample scan never reaches the API: its detail is built from the catalog.
if (scanId && isSampleId(scanId)) {
const preview = await getSamplePreviewState();
if (!preview.active) redirect("/compliance");
return (
<ComplianceDetailSamplePreview
state={preview}
compliancetitle={compliancetitle}
complianceId={complianceId}
version={version}
filter={getSingleSearchParam(
resolvedSearchParams["filter[cis_profile_level]"],
)}
targetSection={section}
/>
);
}
const subscriptionOnlyPromise = isReportDownloadLocked();
// Cross-provider mode replaces the per-scan pipeline with the universal
@@ -1,125 +0,0 @@
import {
ClientAccordionWrapper,
ComplianceHeader,
ComplianceWarming,
RequirementsStatusCard,
TopFailedSectionsCard,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { SAMPLE_SCAN } from "@/lib/sample-preview/sample-compliance";
import { getSampleComplianceDetail } from "@/lib/sample-preview/sample-compliance-requirements";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { Framework, RequirementsTotals } from "@/types/compliance";
interface ComplianceDetailSamplePreviewProps {
state: SamplePreviewActive;
compliancetitle: string;
complianceId: string;
version?: string;
/** CIS profile level, applied by the mapper like on the real page. */
filter?: string;
/** `?section=`: the accordion section to open, like on the real page. */
targetSection?: string;
}
/** A sample framework's detail: real catalog, sample statuses, no reports or Lighthouse. */
export async function ComplianceDetailSamplePreview({
state,
compliancetitle,
complianceId,
version,
filter,
targetSection,
}: ComplianceDetailSamplePreviewProps) {
const detail = await getSampleComplianceDetail(complianceId);
const formattedTitle = compliancetitle.split("-").join(" ");
const title = version ? `${formattedTitle} - ${version}` : formattedTitle;
// The catalog is still warming (or unavailable): same state as the real page.
if (!detail) {
return (
<ContentLayout title={title}>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<ComplianceWarming />
</ContentLayout>
);
}
const mapper = getComplianceMapper(
detail.attributesData.data[0]?.attributes.framework,
);
const data: Framework[] = mapper.mapComplianceData(
detail.attributesData,
{ data: detail.requirements },
filter,
);
const totals = data.reduce<RequirementsTotals>(
(acc, framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const topFailed = mapper.getTopFailedSections(data);
const accordionItems = mapper.toAccordionItems(data, SAMPLE_SCAN.id);
// Mappers key sections as `${framework.name}-${section}`.
const sectionKeys = new Set(
data.map((framework) => `${framework.name}-${targetSection}`),
);
const initialExpandedKeys = targetSection
? accordionItems
.filter((item) => sectionKeys.has(item.key))
.map((item) => item.key)
.slice(0, 1)
: [];
return (
<ContentLayout title={title}>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<Card variant="base" className="mb-6 w-full gap-4 p-4 md:p-5">
<ComplianceHeader
scans={[]}
uniqueRegions={[]}
showSearch={false}
framework={compliancetitle}
showProviders={false}
logoPath={getComplianceIcon(compliancetitle)}
complianceTitle={compliancetitle}
selectedScan={SAMPLE_SCAN}
/>
</Card>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCard
pass={totals.pass}
fail={totals.fail}
manual={totals.manual}
/>
<TopFailedSectionsCard
sections={topFailed.items}
dataType={topFailed.type}
prepopulated={topFailed.prepopulated}
/>
</div>
{/* Requirement findings come from the sample records via the sample scan id. */}
<ClientAccordionWrapper
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys[0]}
/>
</div>
</ContentLayout>
);
}
@@ -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,36 +0,0 @@
import { expect, it } from "vitest";
import { render } from "@/__tests__/render-browser";
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceSamplePreview } from "./compliance-sample-preview";
it("opens sample framework details without offering report exports", async () => {
// Given / When
const screen = await render(
<ComplianceSamplePreview
state={{
active: true,
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
runningScan: null,
}}
/>,
);
// Then: frameworks open their sample detail; reports stay unavailable.
await expect
.element(screen.getByRole("heading", { name: "CIS - 5.0", exact: true }))
.toBeVisible();
await expect
.element(
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
)
.toBeEnabled();
await expect
.element(screen.getByRole("progressbar", { name: "ThreatScore progress" }))
.toHaveAttribute("aria-valuenow", String(SAMPLE_THREAT_SCORE.score));
await expect
.element(screen.getByRole("button", { name: /export/i }))
.not.toBeInTheDocument();
});
@@ -1,66 +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 type { ComplianceOverviewData } from "@/types/compliance";
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
interface ComplianceSamplePreviewProps {
state: SamplePreviewActive;
/** Totals derived from the same requirements the detail page shows. */
overviews?: ComplianceOverviewData[];
}
/** Sample per-scan compliance: cards open the sample detail; no reports. */
export function ComplianceSamplePreview({
state,
overviews = SAMPLE_COMPLIANCE_OVERVIEWS,
}: ComplianceSamplePreviewProps) {
return (
<>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<div className="mb-6">
<ThreatScoreBadge
readOnly
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>
{overviews.map(({ id, attributes }) => (
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
hideActions
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>
</>
);
}
-45
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,18 +52,6 @@ vi.mock("@/actions/scans", () => ({
getScansByState: vi.fn(),
}));
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("@/lib/sample-preview/sample-compliance-requirements", () => ({
getSampleComplianceTotals: async () => [],
}));
vi.mock("./_components/compliance-sample-preview", () => ({
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
}));
vi.mock("@/lib/shared/env", () => ({
isCloud: isCloudMock,
}));
@@ -176,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 -20
View File
@@ -17,12 +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 { toSampleComplianceOverviews } from "@/lib/sample-preview/sample-compliance";
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
@@ -32,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 {
@@ -75,21 +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}
overviews={toSampleComplianceOverviews(
await getSampleComplianceTotals(),
)}
/>
</ContentLayout>
);
}
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
@@ -428,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,156 +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 { SAMPLE_FINDINGS } from "@/lib/sample-preview/sample-records";
import { FindingsSamplePreview } from "./findings-sample-preview";
const { fetchMock, getScanMock, resolveFindingIdsMock, searchParams } =
vi.hoisted(() => ({
// One instance, like Next's hook: a new one per render loops the filter bar.
searchParams: new URLSearchParams(),
fetchMock: vi.fn(),
getScanMock: vi.fn(),
resolveFindingIdsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
useSearchParams: () => searchParams,
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 scrollIntoView; the filter bar's command lists call it.
Element.prototype.scrollIntoView = vi.fn();
// 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("expands a PASS group into passing resources only", async () => {
const user = userEvent.setup();
render(
<FindingsSamplePreview
state={STATE}
searchParams={{ "filter[status__in]": "PASS" }}
/>,
);
await user.click(
screen.getByText("Ensure S3 buckets are not publicly accessible"),
);
const passing = SAMPLE_FINDINGS.find(
(finding) =>
finding.check.checkId === "s3_bucket_public_access" &&
finding.status === "PASS",
)!;
expect(await screen.findByText(passing.resource.name)).toBeInTheDocument();
expect(screen.queryByText("acme-customer-exports")).not.toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
it("applies the severity an Overview link carried", () => {
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();
});
it("offers the real filter bar without fetching its options", () => {
render(<FindingsSamplePreview state={STATE} />);
expect(
screen.getByRole("combobox", { name: /^Filter by Provider\./ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "More Filters" }),
).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -1,62 +0,0 @@
import { FindingsFilters } from "@/components/findings/findings-filters";
import { FindingsGroupTable } from "@/components/findings/table";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FilterTransitionWrapper } from "@/contexts";
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import {
filterSampleFindingGroups,
SAMPLE_FILTER_PROVIDER,
SAMPLE_FINDING_FILTER_OPTIONS,
} 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 filter bar, Overview drill-downs and the table's search and sorting. */
searchParams?: SearchParamsProps;
}
/** Sample finding groups with the real filter bar; options come from the records. */
export function FindingsSamplePreview({
state,
searchParams = {},
}: FindingsSamplePreviewProps) {
const { groups, resourceFilters } = filterSampleFindingGroups(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
<FilterTransitionWrapper>
<div className="mb-6">
{/* No scans or groups: only the sample account and its own values. */}
<FindingsFilters
providers={[SAMPLE_FILTER_PROVIDER]}
providerGroups={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={SAMPLE_FINDING_FILTER_OPTIONS.regions}
uniqueServices={SAMPLE_FINDING_FILTER_OPTIONS.services}
uniqueResourceTypes={SAMPLE_FINDING_FILTER_OPTIONS.resourceTypes}
uniqueCategories={SAMPLE_FINDING_FILTER_OPTIONS.categories}
uniqueGroups={SAMPLE_FINDING_FILTER_OPTIONS.groups}
/>
</div>
<FindingsGroupTable
// Key resets client state (selection, drill-down) when data or filters change.
key={`${groups.map((group) => group.id).join(",")}|${JSON.stringify(resourceFilters)}`}
data={groups}
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
hasHistoricalData
/>
</FilterTransitionWrapper>
</>
);
}
-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);
@@ -1,34 +0,0 @@
import { expect, it, vi } from "vitest";
import { render } from "@/__tests__/render-browser";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
import { IssuesList } from "./issues-list";
it("opens a sample issue and its findings without fetching tenant data", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const issue = SAMPLE_ISSUES[0];
const content = await IssuesList({ searchParams: {}, sample: true });
const screen = await render(
<>
{content}
<GlobalSidePanel />
</>,
);
await screen
.getByRole("button", { name: issue.attributes.title, exact: true })
.click();
await screen.getByRole("tab", { name: "Findings", exact: true }).click();
await expect
.element(
screen.getByRole("button", {
name: "Ensure S3 buckets are not publicly accessible",
exact: true,
}),
)
.toBeVisible();
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -1,157 +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("sorts sample issues when the findings column is selected", async () => {
render(
await IssuesList({
searchParams: { sort: "-findings_count" },
sample: true,
}),
);
const titles = new Set(
SAMPLE_ISSUES.map((issue) => issue.attributes.title),
);
const displayed = screen
.getAllByRole("button")
.map((button) => button.textContent)
.filter((title) => title && titles.has(title));
expect(displayed).toEqual(
[...SAMPLE_ISSUES]
.sort(
(a, b) => b.attributes.findings_count - a.attributes.findings_count,
)
.map((issue) => issue.attributes.title),
);
});
it("filters sample issues by verdict without calling the API", async () => {
// When
render(
await IssuesList({
searchParams: { "filter[verdict]": VERDICT.EXPLOITABLE },
sample: true,
}),
);
// Then
expect(
screen.getByRole("button", {
name: exploitable.attributes.title,
}),
).toBeVisible();
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();
});
});
@@ -6,14 +6,8 @@ import { IssuesSummary } from "@/components/issues/issues-summary";
import { IssuesTable } from "@/components/issues/issues-table";
import { VerdictFilter } from "@/components/issues/verdict-filter";
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
import { verdictCounts, VERDICTS } from "@/lib/issues/verdicts";
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import {
SAMPLE_ISSUES,
summarizeSampleIssues,
} from "@/lib/sample-preview/sample-issues";
import { verdictCounts } from "@/lib/issues/verdicts";
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,17 +21,14 @@ 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. */
@@ -57,20 +48,16 @@ export const IssuesList = async ({
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
),
);
const [summary, issues] = sample
? sampleIssues(filters, scope, searchParams.sort)
: 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
@@ -116,74 +103,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>,
sort: SearchParamsProps["sort"],
): [IssuesOverviewResponse, IssueResponse] {
const scoped = SAMPLE_ISSUES.filter((issue) =>
matchesSampleFilters(issue, scope),
);
const rows = SAMPLE_ISSUES.filter((issue) =>
matchesSampleFilters(issue, filters),
);
if (typeof sort === "string") {
const descending = sort.startsWith("-");
const field = descending ? sort.slice(1) : sort;
if (field === "verdict_rank" || field === "findings_count") {
const value = (issue: SampleIssue) =>
field === "findings_count"
? issue.attributes.findings_count
: VERDICTS.findIndex(({ id }) => id === issue.attributes.verdict);
rows.sort((a, b) => (value(a) - value(b)) * (descending ? -1 : 1));
}
}
return [
{
data: {
type: "issues-overview",
attributes: summarizeSampleIssues(scoped),
},
},
{
data: rows,
},
];
}
+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();
});
});
-28
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,26 +22,6 @@ 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(),
@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useForm } from "react-hook-form";
import {
createLighthouseV2Configuration,
@@ -12,7 +12,6 @@ import {
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
BEDROCK_AUTH_METHOD,
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
buildLighthouseV2ConfigurationInput,
@@ -75,14 +74,6 @@ export function LighthouseV2ConfigurationForm({
mode: "onSubmit",
});
const status = getConnectionStatus(configuration);
const bedrockAuthMethod = useWatch({
control: form.control,
name: "bedrockAuthMethod",
});
// An API key has no instance-role fallback, so the hint only fits access keys.
const usesInstanceRoleHint =
provider.credentialsOptional &&
bedrockAuthMethod !== BEDROCK_AUTH_METHOD.API_KEY;
const runConnectionTest = async (configurationId: string) => {
setTesting(true);
@@ -152,12 +143,7 @@ export function LighthouseV2ConfigurationForm({
return;
}
// Keep the chosen Bedrock auth method only when its credentials were
// saved; a blank save keeps the stored ones, whatever method they use.
form.reset({
...getFormDefaults(result.data),
...(credentials ? { bedrockAuthMethod: values.bedrockAuthMethod } : {}),
});
form.reset(getFormDefaults(result.data));
onConfigurationSaved(result.data);
notifyLighthouseV2ConfigurationsChanged();
if (shouldTestAfterSave) {
@@ -274,17 +260,14 @@ export function LighthouseV2ConfigurationForm({
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
usesInstanceRoleHint
? configuration
? "Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role."
: "Leave the keys blank to use this installation's own AWS role; only the region is needed."
provider.credentialsOptional
? "Leave the keys blank to use this installation's own AWS role; only the region is needed."
: configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
control={form.control}
credentialsOptional={provider.credentialsOptional}
errors={form.formState.errors}
hasStoredCredentials={hasConfiguration}
@@ -1,33 +1,24 @@
import { Controller, type useForm, useWatch } from "react-hook-form";
import { type useForm } from "react-hook-form";
import {
BEDROCK_AUTH_METHOD,
type LighthouseV2ConfigFormValues,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { type LighthouseV2ConfigFormValues } from "@/app/(prowler)/lighthouse/_lib/config";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
// Stored secrets are never sent back by the API, so the fields would look
// empty even when a key exists; the masked placeholder stands in for it.
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
export function CredentialFields({
control,
credentialsOptional = false,
errors,
hasStoredCredentials = false,
provider,
register,
}: {
control: ReturnType<typeof useForm<LighthouseV2ConfigFormValues>>["control"];
// Keys may be left blank (Bedrock with the installation's own role).
credentialsOptional?: boolean;
errors: ReturnType<
@@ -39,50 +30,17 @@ export function CredentialFields({
typeof useForm<LighthouseV2ConfigFormValues>
>["register"];
}) {
const isBedrock = provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK;
// The masked placeholder would suggest a key that may not be there: with
// optional keys a saved configuration may have none, and for Bedrock the
// API does not say whether access keys or an API key are stored.
// With optional keys a saved configuration may have none, so the masked
// placeholder would suggest a key that is not there.
const secretPlaceholder =
hasStoredCredentials && !credentialsOptional && !isBedrock
hasStoredCredentials && !credentialsOptional
? STORED_SECRET_PLACEHOLDER
: undefined;
const keyLabelSuffix = credentialsOptional ? " (optional)" : "";
const bedrockAuthMethod = useWatch({ control, name: "bedrockAuthMethod" });
const usesBedrockApiKey =
isBedrock && bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY;
return (
<div className="grid gap-4">
{isBedrock && (
<Field>
<FieldLabel>Authentication method</FieldLabel>
<Controller
control={control}
name="bedrockAuthMethod"
render={({ field }) => (
<RadioGroup
value={field.value}
onValueChange={field.onChange}
orientation="horizontal"
aria-label="Authentication method"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={BEDROCK_AUTH_METHOD.ACCESS_KEYS} />
AWS access keys
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={BEDROCK_AUTH_METHOD.API_KEY} />
Bedrock API key
</label>
</RadioGroup>
)}
/>
</Field>
)}
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE ||
usesBedrockApiKey) && (
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
<Input
@@ -114,45 +72,41 @@ export function CredentialFields({
</Field>
)}
{isBedrock && (
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
<div className="grid gap-4 md:grid-cols-2">
{!usesBedrockApiKey && (
<>
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
</>
)}
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
<Field className="md:col-span-2">
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
@@ -284,14 +284,14 @@ describe("LighthouseV2ConfigPage", () => {
maskedKey,
);
// But not for Bedrock: the API does not say whether access keys or an
// API key are stored, so masking either set could claim the wrong one
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
expect(screen.getByLabelText("AWS access key ID")).not.toHaveAttribute(
expect(screen.getByLabelText("AWS access key ID")).toHaveAttribute(
"placeholder",
maskedKey,
);
expect(screen.getByLabelText("AWS secret access key")).not.toHaveAttribute(
expect(screen.getByLabelText("AWS secret access key")).toHaveAttribute(
"placeholder",
maskedKey,
);
});
@@ -330,44 +330,6 @@ describe("LighthouseV2ConfigPage", () => {
expect(createConfigurationMock).not.toHaveBeenCalled();
});
it("does not hint at a stored Bedrock API key it cannot know about", async () => {
// Given: Bedrock has a stored configuration of unknown credential type
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// When
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
// Then
expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder");
});
it("falls back to access keys after saving Bedrock without new credentials", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
renderPage();
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
// When: saved with blank fields, so the stored credentials stay
await user.click(
within(
screen.getByRole("region", { name: "Lighthouse AI settings" }),
).getByRole("button", { name: /^Save$/i }),
);
// Then: the form no longer claims an API key that was never saved
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
"credentials",
);
expect(
await screen.findByRole("radio", { name: "AWS access keys" }),
).toBeChecked();
});
it("shows Bedrock access key, secret key, and region fields", async () => {
// Given
const user = userEvent.setup();
@@ -382,42 +344,6 @@ describe("LighthouseV2ConfigPage", () => {
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
});
it("saves Bedrock with an API key instead of access keys", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
testConnectionMock.mockResolvedValue({ data: configurations[1] });
renderPage();
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// When: the API key method is chosen
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
// Then: the access key fields give way to the API key field
expect(
screen.queryByLabelText("AWS access key ID"),
).not.toBeInTheDocument();
expect(
screen.queryByLabelText("AWS secret access key"),
).not.toBeInTheDocument();
// When
await user.type(screen.getByLabelText("API key"), "ABSKlongterm");
await user.type(screen.getByLabelText("AWS region"), "us-east-1");
await user.click(
within(
screen.getByRole("region", { name: "Lighthouse AI settings" }),
).getByRole("button", { name: /^Save$/i }),
);
// Then
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
expect(updateConfigurationMock.mock.calls[0]?.[1].credentials).toEqual({
api_key: "ABSKlongterm",
aws_region_name: "us-east-1",
});
});
it("changes a Bedrock region without keys when the installation role is allowed", async () => {
// Given: the API reports Bedrock needs no credentials (Private Cloud)
const user = userEvent.setup();
@@ -432,13 +358,6 @@ describe("LighthouseV2ConfigPage", () => {
});
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// Then: blank fields keep the stored credentials, so the hint says so
expect(
screen.getByText(
"Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role.",
),
).toBeInTheDocument();
// When: only the region is entered
expect(
screen.getByLabelText("AWS access key ID (optional)"),
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { LIGHTHOUSE_V2_PROVIDER_TYPE } from "@/app/(prowler)/lighthouse/_types";
import {
BEDROCK_AUTH_METHOD,
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
EMPTY_FORM_VALUES,
@@ -137,101 +136,4 @@ describe("Bedrock configuration form", () => {
).toBeUndefined();
});
});
describe("when authenticating with a Bedrock API key", () => {
const apiKeyValues = (overrides: Partial<LighthouseV2ConfigFormValues>) =>
values({ bedrockAuthMethod: BEDROCK_AUTH_METHOD.API_KEY, ...overrides });
it("should require the API key and the region for a new configuration", () => {
// Given / When / Then
expect(errorPaths(false, apiKeyValues({}))).toEqual([
"apiKey",
"awsRegionName",
]);
});
it("should still require the API key where the installation role is allowed", () => {
// Given / When / Then
expect(
errorPaths(
false,
apiKeyValues({ awsRegionName: "us-east-1" }),
OPTIONAL,
),
).toEqual(["apiKey"]);
});
it("should require the region to rotate the key on an existing configuration", () => {
// Given / When / Then
expect(
errorPaths(true, apiKeyValues({ apiKey: "ABSKlongterm" })),
).toEqual(["awsRegionName"]);
});
it.each(["ABSKlongterm", "bedrock-api-key-shortterm"])(
"should accept the %s key format",
(apiKey) => {
// Given / When / Then
expect(
errorPaths(
false,
apiKeyValues({ apiKey, awsRegionName: "us-east-1" }),
),
).toEqual([]);
},
);
it("should reject a key that is not a Bedrock API key", () => {
// Given / When / Then
expect(
errorPaths(
false,
apiKeyValues({ apiKey: "sk-proj-123", awsRegionName: "us-east-1" }),
),
).toEqual(["apiKey"]);
});
it("should send the key and region, without leftover access keys", () => {
// Given: access keys typed before switching to the API key method
const formValues = apiKeyValues({
apiKey: " ABSKlongterm ",
awsAccessKeyId: "AKIA",
awsSecretAccessKey: "secret",
awsRegionName: "us-east-1",
});
// When / Then
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
api_key: "ABSKlongterm",
aws_region_name: "us-east-1",
});
});
it("should keep the stored configuration when nothing is entered", () => {
// Given / When / Then
expect(errorPaths(true, apiKeyValues({}))).toEqual([]);
expect(
buildCredentialPayload(BEDROCK, apiKeyValues({}), true),
).toBeUndefined();
});
});
describe("when authenticating with access keys", () => {
it("should ignore an API key left over from the other method", () => {
// Given
const formValues = values({
apiKey: "ABSKlongterm",
awsAccessKeyId: "AKIA",
awsSecretAccessKey: "secret",
awsRegionName: "us-east-1",
});
// When / Then
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
aws_access_key_id: "AKIA",
aws_secret_access_key: "secret",
aws_region_name: "us-east-1",
});
});
});
});
@@ -37,20 +37,8 @@ export interface FeedbackState {
variant: FeedbackVariant;
}
export const BEDROCK_AUTH_METHOD = {
ACCESS_KEYS: "access_keys",
API_KEY: "api_key",
} as const;
export type BedrockAuthMethod =
(typeof BEDROCK_AUTH_METHOD)[keyof typeof BEDROCK_AUTH_METHOD];
// Long-term keys start with "ABSK", short-term ones with "bedrock-api-key-".
const BEDROCK_API_KEY_PATTERN = /^(ABSK|bedrock-api-key-)\S+$/;
const lighthouseV2ConfigFormSchemaBase = z.object({
apiKey: z.string(),
bedrockAuthMethod: z.enum(BEDROCK_AUTH_METHOD),
awsAccessKeyId: z.string(),
awsSecretAccessKey: z.string(),
awsRegionName: z.string(),
@@ -63,7 +51,6 @@ export type LighthouseV2ConfigFormValues = z.infer<
export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
apiKey: "",
bedrockAuthMethod: BEDROCK_AUTH_METHOD.ACCESS_KEYS,
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegionName: "",
@@ -130,34 +117,6 @@ export function buildLighthouseV2ConfigFormSchema(
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
if (data.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
const regionName = data.awsRegionName.trim();
if (hasConfiguration && !apiKey && !regionName) return;
if (!apiKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bedrock API key is required.",
path: ["apiKey"],
});
} else if (!BEDROCK_API_KEY_PATTERN.test(apiKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Bedrock API keys start with 'ABSK' (long-term) or 'bedrock-api-key-' (short-term).",
path: ["apiKey"],
});
}
if (!regionName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS region is required.",
path: ["awsRegionName"],
});
}
return;
}
if (credentialsOptional) {
// The installation's own role: a region alone is enough, and keys,
// when given, still have to come as a pair.
@@ -231,14 +190,6 @@ export function buildCredentialPayload(
{ credentialsOptional = false }: LighthouseV2ProviderCapabilities = {},
): LighthouseV2Credentials | undefined {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
if (values.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
const apiKey = values.apiKey.trim();
const regionName = values.awsRegionName.trim();
if (hasConfiguration && !apiKey && !regionName) return undefined;
return { api_key: apiKey, aws_region_name: regionName };
}
const hasBedrockCredentials =
Boolean(values.awsAccessKeyId.trim()) ||
Boolean(values.awsSecretAccessKey.trim()) ||
+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 -89
View File
@@ -12,17 +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 { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
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";
@@ -47,7 +42,6 @@ import {
RiskSeverityChartSkeleton,
RiskSeverityChartSSR,
} from "./_overview/risk-severity";
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
@@ -66,35 +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()}
complianceWatchlist={await getSampleComplianceTotals()}
/>
</ContentLayout>
);
}
const [providersData, providerGroupsData, lighthouseBannerHref] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
]);
return (
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
@@ -128,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>
);
}
@@ -1,6 +1,5 @@
import { listScanConfigurations } from "@/actions/scan-configurations";
import { ProvidersAccountsView } from "@/components/providers/providers-accounts-view";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import {
@@ -34,19 +33,17 @@ export const ProvidersTabContent = async ({
searchParams: SearchParamsProps;
}) => {
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState, samplePreview] = await Promise.all([
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
searchParams,
isCloud: isCloudEnvironment,
}),
loadScanConfigs(isCloudEnvironment),
getSamplePreviewState(),
]);
return (
<ProvidersAccountsView
isCloud={isCloudEnvironment}
samplePreviewActive={isCloudEnvironment && samplePreview.active}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
@@ -1,53 +0,0 @@
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { ResourcesTableWithSelection } from "@/components/resources/table";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FilterTransitionWrapper } from "@/contexts";
import { SAMPLE_FILTER_PROVIDER } 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 {
filterSampleResources,
getSampleResourceRow,
SAMPLE_RESOURCE_FILTER_OPTIONS,
} from "@/lib/sample-preview/sample-resources";
import type { SearchParamsProps } from "@/types";
interface ResourcesSamplePreviewProps {
state: SamplePreviewActive;
searchParams: SearchParamsProps;
}
/** Sample resources with the real filter bar, table and resource drawer. */
export function ResourcesSamplePreview({
state,
searchParams,
}: ResourcesSamplePreviewProps) {
const { data, metadata } = filterSampleResources(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.RESOURCES} />
<FilterTransitionWrapper>
<div className="mb-6">
<ResourcesFilters
providers={[SAMPLE_FILTER_PROVIDER]}
providerGroups={[]}
uniqueRegions={SAMPLE_RESOURCE_FILTER_OPTIONS.regions}
uniqueServices={SAMPLE_RESOURCE_FILTER_OPTIONS.services}
uniqueResourceTypes={SAMPLE_RESOURCE_FILTER_OPTIONS.types}
uniqueGroups={SAMPLE_RESOURCE_FILTER_OPTIONS.groups}
/>
</div>
<ResourcesTableWithSelection
data={data}
metadata={metadata}
// A copied resource link reopens its drawer, as on the real page.
initialResource={getSampleResourceRow(
searchParams.resourceId?.toString(),
)}
contributeLighthouseContext={false}
/>
</FilterTransitionWrapper>
</>
);
}
-17
View File
@@ -22,31 +22,14 @@ import {
hasDateOrScanFilter,
replaceFieldKey,
} from "@/lib";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { ResourceProps, SearchParamsProps } from "@/types";
import { ResourcesSamplePreview } from "./_components/resources-sample-preview";
export default async function Resources({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
// No completed scan yet: the demo account stands in for the tenant's resources.
const preview = await getSamplePreviewState();
if (preview.active) {
return (
<ContentLayout title="Resources" icon={<Warehouse />}>
<ResourcesSamplePreview
state={preview}
searchParams={resolvedSearchParams}
/>
</ContentLayout>
);
}
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
+22 -27
View File
@@ -52,8 +52,6 @@ interface ComplianceCardProps {
watchlistAction?: ReactNode;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
/** Hides report downloads and watchlist changes, e.g. for sample scans. */
hideActions?: boolean;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
@@ -67,7 +65,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
isLatestCisForProvider = false,
watchlistAction,
subscriptionOnly = false,
hideActions = false,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -162,30 +159,28 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
</div>
</CardContent>
</button>
{!hideActions && (
<CardAction
className="absolute top-2 right-2 z-10 flex items-center gap-1"
role="group"
aria-label="Compliance actions"
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
)}
<CardAction
className="absolute top-2 right-2 z-10 flex items-center gap-1"
role="group"
aria-label="Compliance actions"
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
</Card>
);
};
@@ -9,10 +9,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
@@ -37,7 +33,6 @@ export function ComplianceFrameworkChip({
}: ComplianceFrameworkChipProps) {
const icon = getComplianceIcon(framework.complianceId);
const label = complianceFrameworkLabel(framework);
const sample = isSampleId(framework.id);
const content = icon ? (
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
<Image
@@ -55,7 +50,7 @@ export function ComplianceFrameworkChip({
return (
<Tooltip>
<TooltipTrigger asChild>
{isNavigable && !sample ? (
{isNavigable ? (
<Button
type="button"
variant={icon ? "bare" : "outline"}
@@ -73,9 +68,7 @@ export function ComplianceFrameworkChip({
</Badge>
)}
</TooltipTrigger>
<TooltipContent>
{sample ? `${label}. ${SAMPLE_DATA_READ_ONLY_MESSAGE}` : label}
</TooltipContent>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
+41 -52
View File
@@ -38,8 +38,6 @@ interface ThreatScoreBadgeProps {
sectionScores?: SectionScores;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
/** Display scores without detail navigation or report downloads. */
readOnly?: boolean;
}
export const ThreatScoreBadge = ({
@@ -48,9 +46,7 @@ export const ThreatScoreBadge = ({
provider,
sectionScores,
subscriptionOnly = false,
readOnly = false,
}: ThreatScoreBadgeProps) => {
const DetailContainer = readOnly ? "div" : "button";
const router = useRouter();
const runReportDownload = useReportDownload(subscriptionOnly);
const searchParams = useSearchParams();
@@ -121,11 +117,11 @@ export const ThreatScoreBadge = ({
return (
<Card variant="base" padding="md" className="relative gap-4">
<CardContent className="flex flex-col gap-4 p-0 pr-14 lg:flex-row lg:items-start lg:gap-6">
{/* ThreatScore summary, with detail navigation when available. */}
<DetailContainer
className="border-border-neutral-secondary bg-bg-neutral-tertiary enabled:hover:border-border-neutral-primary enabled:hover:bg-bg-neutral-secondary flex shrink-0 flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors enabled:cursor-pointer lg:pr-3"
onClick={readOnly ? undefined : handleCardClick}
type={readOnly ? undefined : "button"}
{/* Clickable ThreatScore button */}
<button
className="border-border-neutral-secondary bg-bg-neutral-tertiary hover:border-border-neutral-primary hover:bg-bg-neutral-secondary flex shrink-0 cursor-pointer flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors lg:pr-3"
onClick={handleCardClick}
type="button"
>
<ThreatScoreLogo />
@@ -140,29 +136,24 @@ export const ThreatScoreBadge = ({
indicatorClassName={getScoreIndicatorClass(getScoreColor(score))}
/>
</div>
</DetailContainer>
</button>
{/* Pillar breakdown — always visible, in canonical order */}
{pillars.length > 0 && (
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
{pillars.map(({ name, score: sectionScore, hasData }) => (
<DetailContainer
<button
key={name}
type={readOnly ? undefined : "button"}
onClick={
readOnly
? undefined
: () => hasData && handlePillarClick(name)
}
disabled={readOnly ? undefined : !hasData}
type="button"
onClick={() => hasData && handlePillarClick(name)}
disabled={!hasData}
aria-disabled={!hasData}
aria-label={
readOnly
? undefined
: hasData
? `Open ${name} details`
: `${name} (no data for this scan)`
hasData
? `Open ${name} details`
: `${name} (no data for this scan)`
}
className="enabled:hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
className="hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
>
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
{name}
@@ -184,40 +175,38 @@ export const ThreatScoreBadge = ({
>
{hasData ? `${sectionScore.toFixed(1)}%` : "—"}
</span>
</DetailContainer>
</button>
))}
</div>
)}
</CardContent>
{/* ActionDropdown for downloads — top-right */}
{!readOnly && (
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
)}
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
</Card>
);
};
@@ -2,8 +2,6 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
// ---------------------------------------------------------------------------
// Hoist mocks to avoid deep dependency chains
// ---------------------------------------------------------------------------
@@ -46,32 +44,6 @@ describe("FloatingSelectionActions — onBeforeOpen error handling", () => {
vi.clearAllMocks();
});
it.each([
{ selectedFindingIds: ["sample-finding-1"] },
{
selectedFindingIds: ["s3_bucket_public_access"],
disabledReason: SAMPLE_DATA_READ_ONLY_MESSAGE,
},
])(
"blocks sample selection actions before resolving IDs: %j",
async (props) => {
const onBeforeOpen = vi.fn();
const user = userEvent.setup();
render(
<FloatingSelectionActions
selectedCount={1}
{...props}
onBeforeOpen={onBeforeOpen}
/>,
);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("title", SAMPLE_DATA_READ_ONLY_MESSAGE);
await user.click(button);
expect(onBeforeOpen).not.toHaveBeenCalled();
},
);
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
// Given — onBeforeOpen always throws
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Network error"));
@@ -10,10 +10,6 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown/action-dropdown";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
@@ -31,7 +27,6 @@ interface FloatingSelectionActionsBaseProps {
label?: string;
/** Custom mute action label. Defaults to "Mute". */
muteLabel?: string;
disabledReason?: string;
}
type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps &
@@ -56,13 +51,7 @@ export function FloatingSelectionActions({
muteLabel = "Mute",
jiraPayload,
jiraLabel,
disabledReason,
}: FloatingSelectionActionsProps) {
const reason =
disabledReason ??
(selectedFindingIds.some(isSampleId)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined);
const [isModalOpen, setIsModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
@@ -86,7 +75,6 @@ export function FloatingSelectionActions({
};
const handleMuteClick = async () => {
if (reason) return;
if (onBeforeOpen) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -158,21 +146,17 @@ export function FloatingSelectionActions({
icon={<VolumeX />}
label={muteLabel}
aria-label={muteLabel}
disabled={Boolean(reason)}
disabledTooltip={reason}
onSelect={() => void handleMuteClick()}
/>
<JiraDispatchActionItem
label={jiraLabel}
payload={jiraPayload}
disabledReason={reason}
/>
</ActionDropdown>
) : (
<Button
onClick={() => void handleMuteClick()}
disabled={isResolving || Boolean(reason)}
title={reason}
disabled={isResolving}
size="lg"
>
{isResolving ? (
@@ -7,11 +7,6 @@ import {
PROWLER_CLOUD_ONLY_TOOLTIP,
} from "@/lib/deployment";
import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
@@ -19,13 +14,11 @@ import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
interface JiraDispatchActionItemProps {
label: string;
payload: JiraDispatchModalPayload | null | undefined;
disabledReason?: string;
}
export const JiraDispatchActionItem = ({
label,
payload,
disabledReason,
}: JiraDispatchActionItemProps) => {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
@@ -36,21 +29,12 @@ export const JiraDispatchActionItem = ({
if (!payload) return null;
const reason =
disabledReason ??
(getJiraSelectionBatches(payload.selection).some((batch) =>
batch.targetIds.some(isSampleId),
)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined);
const { requiresUpgrade } = getJiraDispatchActionState(
payload,
isGroupedJiraDispatchEnabled(),
);
const handleSelect = () => {
if (reason) return;
if (requiresUpgrade) {
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
return;
@@ -64,11 +48,7 @@ export const JiraDispatchActionItem = ({
icon={<JiraIcon size={20} />}
label={label}
aria-label={label}
disabled={Boolean(reason)}
disabledTooltip={reason}
tooltip={
!reason && requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
}
tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined}
onSelect={handleSelect}
/>
);
@@ -11,13 +11,11 @@ export const RECHECK_RESOURCE_LABEL = "Re-check resource";
interface RecheckResourceActionItemProps {
target: Partial<PartialScanTarget> | null | undefined;
disabledReason?: string;
}
/** Prowler Cloud only: opens the partial-scan confirmation for one resource. */
export const RecheckResourceActionItem = ({
target,
disabledReason,
}: RecheckResourceActionItemProps) => {
const resolvedTarget = usePartialScanTarget(target);
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
@@ -29,11 +27,7 @@ export const RecheckResourceActionItem = ({
icon={<RefreshCw className="size-5" />}
label={RECHECK_RESOURCE_LABEL}
aria-label={RECHECK_RESOURCE_LABEL}
disabled={Boolean(disabledReason)}
disabledTooltip={disabledReason}
onSelect={() => {
if (!disabledReason) openPartialScan(resolvedTarget);
}}
onSelect={() => openPartialScan(resolvedTarget)}
/>
);
};
@@ -15,7 +15,6 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ hasPermission: hasPermissionMock }),
}));
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
import { usePartialScanStore } from "@/store/partial-scan/store";
import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item";
@@ -55,22 +54,12 @@ describe("RecheckResourceIconButton", () => {
expect(onRowClick).not.toHaveBeenCalled();
});
it("disables sample re-checks without opening the confirmation or triggering the row", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(
<div onClick={onRowClick}>
<RecheckResourceIconButton
target={target}
disabledReason={SAMPLE_DATA_READ_ONLY_MESSAGE}
/>
</div>,
);
it("is always green and pulsing", () => {
render(<RecheckResourceIconButton target={target} />);
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
expect(button).toBeDisabled();
await user.click(button);
expect(usePartialScanStore.getState().activeTarget).toBeNull();
expect(onRowClick).not.toHaveBeenCalled();
expect(button.className).toContain("text-button-primary");
expect(button.className).toContain("animate-pulse");
});
it("is hidden outside Prowler Cloud", () => {
@@ -10,6 +10,7 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { usePartialScanTarget } from "@/hooks/use-partial-scan-target";
import { cn } from "@/lib/utils";
import { usePartialScanStore } from "@/store";
import type { PartialScanTarget } from "@/types/partial-scans";
@@ -18,14 +19,13 @@ import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item";
interface RecheckResourceIconButtonProps {
target: Partial<PartialScanTarget> | null | undefined;
className?: string;
disabledReason?: string;
}
// Beside "last seen", using the shared icon-button and disabled states.
// Beside "last seen": always green and pulsing, so the re-check is noticed.
// It is an action, not a notification, so it never settles into a seen state.
export function RecheckResourceIconButton({
target,
className,
disabledReason,
}: RecheckResourceIconButtonProps) {
const resolvedTarget = usePartialScanTarget(target);
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
@@ -35,33 +35,28 @@ export function RecheckResourceIconButton({
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
// The row itself opens the detail drawer on click.
event.stopPropagation();
if (disabledReason) return;
openPartialScan(resolvedTarget);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={className}
tabIndex={disabledReason ? 0 : undefined}
onClick={(event) => event.stopPropagation()}
<Button
type="button"
variant="bare"
size="icon"
aria-label={RECHECK_RESOURCE_LABEL}
onClick={handleClick}
className={cn(
"text-button-primary hover:text-button-primary active:text-button-primary size-5 shrink-0 rounded-md motion-safe:animate-pulse",
"hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused]",
className,
)}
>
<Button
type="button"
variant="bare"
size="icon-xs"
aria-label={RECHECK_RESOURCE_LABEL}
disabled={Boolean(disabledReason)}
onClick={handleClick}
>
<RefreshCw aria-hidden />
</Button>
</span>
<RefreshCw className="size-3.5" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>
{disabledReason ?? RECHECK_RESOURCE_LABEL}
</TooltipContent>
<TooltipContent>{RECHECK_RESOURCE_LABEL}</TooltipContent>
</Tooltip>
);
}
@@ -283,74 +283,6 @@ function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
);
}
describe("sample resource actions", () => {
beforeEach(() => {
vi.clearAllMocks();
isCloudMock.mockReturnValue(true);
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
useJiraDispatchStore.getState().closeJiraDispatch();
usePartialScanStore.getState().closePartialScan();
});
const sampleResource = () =>
makeResource({
id: "sample-resource-s3_bucket-1",
findingId: "sample-finding-s3_bucket_public_access-1",
checkId: "s3_bucket_public_access",
providerUid: "123456789012",
providerAlias: "Sample AWS account",
resourceName: "acme-customer-exports",
resourceUid: "arn:aws:s3:::acme-customer-exports",
triage: makeTriageSummary({
findingId: "sample-finding-s3_bucket_public_access-1",
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.SAMPLE,
}),
});
it("does not send sample IDs to Lighthouse from a resource row", async () => {
// Given
const user = userEvent.setup();
renderResourceActionsCell({ resource: sampleResource() });
const action = screen.getByRole("button", { name: "Lighthouse Skills" });
// When
expect(action).toBeDisabled();
await user.click(action);
// Then
expect(launchSkillMock).not.toHaveBeenCalled();
});
it("does not dispatch sample findings to the real Jira workflow", async () => {
// Given
const user = userEvent.setup();
renderResourceActionsCell({ resource: sampleResource() });
const action = screen.getByRole("button", { name: /Send.*Jira/ });
// When
expect(action).toBeDisabled();
await user.click(action);
// Then
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
});
it("does not start the real re-check workflow for a sample resource", async () => {
// Given
const user = userEvent.setup();
renderResourceActionsCell({ resource: sampleResource() });
const action = screen.getByRole("button", { name: "Re-check resource" });
// When
expect(action).toBeDisabled();
await user.click(action);
// Then
expect(usePartialScanStore.getState().activeTarget).toBeNull();
});
});
function renderResourceActionsCell({
resource = makeResource(),
onSkillLaunchOpenDrawer,
@@ -23,10 +23,6 @@ import { getFailingForLabel } from "@/lib/date-utils";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { FindingResourceRow } from "@/types";
import type {
@@ -103,9 +99,6 @@ const ResourceRowActions = ({
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
}) => {
const resource = row.original;
const sampleDisabledReason = isSampleId(resource.findingId)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined;
const canMute = canMuteFindingResource(resource);
const launchSkill = useLighthouseSkillLaunch();
const launchPrompt = useLighthousePromptLaunch();
@@ -145,7 +138,6 @@ const ResourceRowActions = ({
});
const handleMuteClick = async () => {
if (sampleDisabledReason) return;
const displayIds = getDisplayIds();
// Single resource: findingId is already a real finding UUID
@@ -210,8 +202,7 @@ const ResourceRowActions = ({
)
}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={Boolean(sampleDisabledReason) || !canMute || isResolving}
disabledTooltip={sampleDisabledReason}
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
<JiraDispatchActionItem
@@ -222,11 +213,9 @@ const ResourceRowActions = ({
/>
<RecheckResourceActionItem
target={buildResourceRecheckTarget(resource)}
disabledReason={sampleDisabledReason}
/>
{isCloud() && (
<LighthouseSkillsSubmenu
disabledReason={sampleDisabledReason}
onLaunch={(skill) => {
onSkillLaunchOpenDrawer?.(row.index);
launchSkill(skill, buildResourceFindingItem(resource));
@@ -414,11 +403,6 @@ export function getColumnFindingResources({
<DateWithTime dateTime={row.original.lastSeenAt} />
<RecheckResourceIconButton
target={buildResourceRecheckTarget(row.original)}
disabledReason={
isSampleId(row.original.findingId)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined
}
/>
</span>
</InfoField>
@@ -179,25 +179,6 @@ describe("DataTableRowActions", () => {
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("blocks sample group actions even though their target check IDs are real", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={makeFindingRow({
id: "sample-group-s3_bucket_public_access",
rowType: "group",
checkId: "s3_bucket_public_access",
})}
/>,
);
const jira = screen.getByRole("button", { name: /Jira/ });
const mute = screen.getByRole("button", { name: /Mute Finding Group/ });
expect(jira).toBeDisabled();
expect(mute).toBeDisabled();
await user.click(jira);
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
});
it("offers a Cloud re-check of the row's resource with its provider id", async () => {
// Given — a flat finding row expanded with its resource and provider
const user = userEvent.setup();
@@ -17,10 +17,6 @@ import { isFindingGroupMuted } from "@/lib/findings-groups";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { getOptionalText } from "@/lib/utils";
import type {
@@ -132,9 +128,6 @@ export function DataTableRowActions<T extends FindingRowData>({
}: DataTableRowActionsProps<T>) {
const router = useRouter();
const finding = row.original;
const sampleDisabledReason = isSampleId(finding.id)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined;
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
const [mutePreparationError, setMutePreparationError] = useState<
@@ -218,7 +211,6 @@ export function DataTableRowActions<T extends FindingRowData>({
};
const handleMuteClick = async () => {
if (sampleDisabledReason) return;
if (resolveMuteIds) {
setResolvedIds([]);
setMutePreparationError(null);
@@ -324,22 +316,13 @@ export function DataTableRowActions<T extends FindingRowData>({
)
}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={Boolean(sampleDisabledReason) || !canMute || isResolving}
disabledTooltip={sampleDisabledReason}
disabled={!canMute || isResolving}
onSelect={handleMuteClick}
/>
<JiraDispatchActionItem
label={jiraLabel}
payload={jiraPayload}
disabledReason={sampleDisabledReason}
/>
<RecheckResourceActionItem
target={recheckTarget}
disabledReason={sampleDisabledReason}
/>
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
<RecheckResourceActionItem target={recheckTarget} />
{isCloud() && !isGroup && (
<LighthouseSkillsSubmenu
disabledReason={sampleDisabledReason}
onLaunch={handleLaunchSkill}
onSubmitPrompt={handleSubmitPrompt}
/>
@@ -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;
@@ -18,10 +18,6 @@ import {
buildFindingGroupContext,
buildFindingSummaryContext,
} from "@/lib/lighthouse/context/contributions";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { useCloudUpgradeStore } from "@/store";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
@@ -448,12 +444,6 @@ const FindingsGroupTableContent = ({
{(selectedCheckIds.length > 0 || hasResourceSelection) && jiraPayload && (
<FloatingSelectionActions
disabledReason={
selectedFindings.some((finding) => isSampleId(finding.id)) ||
activeResourceSelection.some(isSampleId)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined
}
selectedCount={
selectedCheckIds.length + activeResourceSelection.length
}
@@ -58,24 +58,6 @@ vi.mock("@/components/shadcn/dropdown", () => ({
}));
describe("Lighthouse skills launch controls", () => {
it("does not offer skills or prompts for a sample resource", () => {
render(
<LighthouseSkillsRowButton
findingItem={{
kind: "finding",
id: "sample-finding-1",
source: "focused",
scopeKey: "findings:/findings",
label: "Sample finding",
findingId: "sample-finding-1",
}}
/>,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(requestPanelSkillLaunchMock).not.toHaveBeenCalled();
expect(requestPanelChatMessageMock).not.toHaveBeenCalled();
});
beforeEach(() => {
vi.clearAllMocks();
useSidePanelStore.setState({
@@ -15,10 +15,6 @@ import {
DropdownMenuSubTrigger,
} from "@/components/shadcn/dropdown";
import { getSkillsForSurface } from "@/lib/lighthouse/skills/registry";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { cn } from "@/lib/utils";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import {
@@ -49,10 +45,7 @@ export function useLighthouseSkillLaunch() {
return (
skill: LighthouseSkillDefinition,
findingItem: LighthouseFindingContextItem,
) => {
if (isSampleId(findingItem.id)) return;
launchPanelSkill(skill, buildRowEnvelope(findingItem));
};
) => launchPanelSkill(skill, buildRowEnvelope(findingItem));
}
// Free-text sibling of useLighthouseSkillLaunch: starts a fresh Lighthouse
@@ -61,7 +54,6 @@ export function useLighthousePromptLaunch() {
const openSidePanel = useSidePanelStore((state) => state.openPanel);
return (text: string, findingItem: LighthouseFindingContextItem) => {
if (isSampleId(findingItem.id)) return;
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
void import("@/app/(prowler)/lighthouse/_lib/panel-chat-store").then(
({ requestPanelChatMessage }) =>
@@ -177,22 +169,10 @@ function AskLighthouseAnythingRow({
export function LighthouseSkillsSubmenu({
onLaunch,
onSubmitPrompt,
disabledReason,
}: {
onLaunch: (skill: LighthouseSkillDefinition) => void;
onSubmitPrompt?: (text: string) => void;
disabledReason?: string;
}) {
if (disabledReason) {
return (
<ActionDropdownItem
icon={<LighthouseIcon size={16} />}
label="Lighthouse Skills"
disabled
disabledTooltip={disabledReason}
/>
);
}
return (
<>
<DropdownMenuSeparator />
@@ -232,17 +212,6 @@ export function LighthouseSkillsRowButton({
const launchSkill = useLighthouseSkillLaunch();
const launchPrompt = useLighthousePromptLaunch();
if (isSampleId(findingItem.id)) {
return (
<span title={SAMPLE_DATA_READ_ONLY_MESSAGE}>
<CornerDownRight
className="text-text-neutral-tertiary size-4 shrink-0"
aria-hidden
/>
</span>
);
}
return (
<span className="relative flex size-4 shrink-0 items-center justify-center">
<ActionDropdown
@@ -707,89 +707,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
});
});
describe("ResourceDetailDrawerContent — sample data", () => {
it("does not navigate to compliance details for a nonexistent sample scan", async () => {
// Given
const user = userEvent.setup();
vi.stubGlobal("open", mockWindowOpen);
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",
scan: {
id: "sample-scan",
name: "Sample scan",
trigger: "manual",
state: "completed",
uniqueResourceCount: 24,
progress: 100,
duration: 742,
startedAt: "2026-10-28T09:12:00Z",
completedAt: "2026-10-28T09:12:00Z",
insertedAt: "2026-10-28T09:12:00Z",
scheduledAt: null,
},
}}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// When
const link = screen.queryByRole("button", {
name: "Open PCI-DSS 4.0 compliance details",
});
if (link) await user.click(link);
// Then
expect(mockWindowOpen).not.toHaveBeenCalled();
});
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,10 +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,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -293,9 +289,6 @@ export function ResourceDetailDrawerContent({
// During carousel navigation we only trust row-backed data until the next
// finding payload is fully ready, otherwise stale details flash briefly.
const f = isNavigating ? null : currentFinding;
const sampleDisabledReason = isSampleId(f?.id)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined;
const jiraPayload = createJiraDispatchPayload({
targetIds: f ? [f.id] : [],
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
@@ -335,32 +328,25 @@ export function ResourceDetailDrawerContent({
searchParams.get("filter[scan__in]"),
);
const complianceScanId =
isSampleId(f?.id) || isSampleId(f?.scan?.id)
? null
: selectedScanIds.length === 1
? selectedScanIds[0]
: selectedScanIds.length === 0
? (f?.scan?.id ?? null)
: null;
selectedScanIds.length === 1
? selectedScanIds[0]
: selectedScanIds.length === 0
? (f?.scan?.id ?? null)
: null;
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)
@@ -506,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}
@@ -649,10 +634,7 @@ export function ResourceDetailDrawerContent({
<span className="group flex items-center gap-1.5">
<DateWithTime inline dateTime={lastSeenAt || "-"} />
{f && (
<RecheckResourceIconButton
target={recheckTarget}
disabledReason={sampleDisabledReason}
/>
<RecheckResourceIconButton target={recheckTarget} />
)}
</span>
</InfoField>
@@ -705,18 +687,14 @@ export function ResourceDetailDrawerContent({
)
}
label={f.isMuted ? "Muted" : "Mute"}
disabled={f.isMuted || Boolean(sampleDisabledReason)}
disabledTooltip={sampleDisabledReason}
disabled={f.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<JiraDispatchActionItem
label={buildJiraActionLabel({ findingCount: 1 })}
payload={jiraPayload}
/>
<RecheckResourceActionItem
target={recheckTarget}
disabledReason={sampleDisabledReason}
/>
<RecheckResourceActionItem target={recheckTarget} />
{externalResourceTarget && (
<ActionDropdownItem
icon={<ExternalLink className="size-5" />}
@@ -1152,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">
@@ -1453,9 +1429,6 @@ function OtherFindingRow({
}) {
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const isMuted = finding.isMuted || isOptimisticallyMuted;
const sampleDisabledReason = isSampleId(finding.id)
? SAMPLE_DATA_READ_ONLY_MESSAGE
: undefined;
const jiraPayload = createJiraDispatchPayload({
targetIds: [finding.id],
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
@@ -1550,8 +1523,7 @@ function OtherFindingRow({
)
}
label={isMuted ? "Muted" : "Mute"}
disabled={isMuted || Boolean(sampleDisabledReason)}
disabledTooltip={sampleDisabledReason}
disabled={isMuted}
onSelect={() => setIsMuteModalOpen(true)}
/>
<JiraDispatchActionItem
@@ -15,7 +15,6 @@ import {
isManualPassTriageUpdate,
shouldMarkFindingMutedForTriageUpdate,
} from "@/lib/finding-triage";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { FindingResourceRow } from "@/types";
import {
@@ -257,7 +256,6 @@ export function useResourceDetailDrawer({
resourceUid,
pageSize: 50,
includeMuted: includeMutedInOtherFindings,
...(isSampleId(findingId) ? { sample: true } : {}),
});
const adapted = adaptFindingsByResourceResponse(response);
+3 -5
View File
@@ -289,11 +289,9 @@ export function LineChart({
</ChartContainer>
<div className="mt-4 flex flex-col items-start gap-2">
{onPointClick && (
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity
</p>
)}
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity
</p>
<ChartLegend
items={legendItems}
selectedItem={selectedLine}
+4 -10
View File
@@ -43,7 +43,6 @@ interface ZeroDataProvider {
}
interface SankeyChartProps {
disableNavigation?: boolean;
data: {
nodes: SankeyNode[];
links: SankeyLink[];
@@ -169,8 +168,7 @@ export const CustomNode = ({
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
const isClickable =
Boolean(onNodeClick) && SEVERITY_FILTER_MAP[nodeName] !== undefined;
const isClickable = SEVERITY_FILTER_MAP[nodeName] !== undefined;
const handleMouseEnter = (e: React.MouseEvent) => {
if (!hasTooltip) return;
@@ -389,10 +387,7 @@ const CustomLink = ({
fill={color}
fillOpacity={getOpacity()}
stroke="none"
style={{
cursor: onLinkClick ? "pointer" : "default",
transition: "fill-opacity 0.2s",
}}
style={{ cursor: "pointer", transition: "fill-opacity 0.2s" }}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
@@ -406,7 +401,6 @@ export function SankeyChart({
data,
zeroDataProviders = [],
height = 400,
disableNavigation = false,
}: SankeyChartProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -516,7 +510,7 @@ export function SankeyChart({
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
onNodeClick={disableNavigation ? undefined : handleNodeClick}
onNodeClick={handleNodeClick}
/>
);
@@ -538,7 +532,7 @@ export function SankeyChart({
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
onLinkClick={disableNavigation ? undefined : handleLinkClick}
onLinkClick={handleLinkClick}
/>
);
+17 -26
View File
@@ -79,7 +79,7 @@ function LocationDetails({
onBarClick,
}: {
location: Pick<LocationPoint, "name" | "totalFindings" | "severityData">;
onBarClick?: (dataPoint: BarDataPoint) => void;
onBarClick: (dataPoint: BarDataPoint) => void;
}) {
return (
<div className="flex w-full flex-col">
@@ -109,7 +109,6 @@ function LocationDetails({
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
disableNavigation = false,
}: ThreatMapProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -490,34 +489,26 @@ export function ThreatMap({
{selectedLocation ? (
<LocationDetails
location={selectedLocation}
onBarClick={
disableNavigation
? undefined
: (dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}
}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}}
/>
) : isGlobalSelected && globalAggregatedData ? (
<LocationDetails
location={globalAggregatedData}
onBarClick={
disableNavigation
? undefined
: (dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}
}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}}
/>
) : (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
-1
View File
@@ -36,7 +36,6 @@ export interface ThreatMapData {
}
export interface ThreatMapProps {
disableNavigation?: boolean;
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
@@ -71,18 +71,6 @@ describe("IssueCompliance", () => {
getFrameworksMock.mockResolvedValue({ frameworks: [], unavailable: false });
});
it("shows sample frameworks without linking to real compliance reports", async () => {
getFrameworksMock.mockResolvedValue({
frameworks: [framework("cis_aws", "CIS")],
unavailable: false,
});
renderCompliance([finding("sample-finding-1", "check-a")], "sample-scan");
expect(await screen.findByAltText("CIS")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /compliance details/i }),
).not.toBeInTheDocument();
});
it("looks up one finding per check, up to the limit", async () => {
const findings = [
finding("f1", "check-a"),
+2 -3
View File
@@ -10,7 +10,6 @@ import {
} from "@/components/compliance/compliance-framework-chip";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
import { IssueFinding } from "@/types/issues";
@@ -104,7 +103,7 @@ export const IssueCompliance = ({
(state.partial || truncated || checks.size > COMPLIANCE_LOOKUP_LIMIT);
const open = (framework: FindingComplianceFramework) => {
if (!scanId || isSampleId(scanId)) return;
if (!scanId) return;
window.open(
buildComplianceDetailHref({
complianceId: framework.complianceId,
@@ -151,7 +150,7 @@ export const IssueCompliance = ({
<ComplianceFrameworkChip
key={framework.complianceId}
framework={framework}
isNavigable={Boolean(scanId) && !isSampleId(scanId)}
isNavigable={Boolean(scanId)}
onOpen={open}
/>
))}
+3 -6
View File
@@ -42,7 +42,6 @@ import {
} from "@/lib/issues/verdicts";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
@@ -119,11 +118,9 @@ const IssuePanelContent = ({
const finding = useFindingDetails();
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
const scanFindingsParams = new URLSearchParams();
if (activeScanId && !isSampleId(activeScanId))
scanFindingsParams.set("filter[scan__in]", activeScanId);
scanFindingsParams.set("filter[status__in]", "FAIL");
const scanFindingsHref = activeScanId && `/findings?${scanFindingsParams}`;
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.
@@ -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();
});
});
+8 -12
View File
@@ -13,7 +13,6 @@ import {
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
const RESOURCE_PAGE_SIZE = 12;
@@ -71,17 +70,14 @@ export const IssueResources = ({
<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>
)}
<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>
))}
@@ -4,7 +4,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { PRICING_URL } from "@/lib/external-urls";
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
import { IssueTriage } from "./issue-triage";
@@ -73,19 +72,6 @@ describe("IssueTriage", () => {
expect(screen.getByText("Oct 20, 2026")).toBeInTheDocument();
});
it("explains why sample triage and notes are disabled before editing", async () => {
render(<IssueTriage id="sample-issue-1" status="open" />);
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toBeDisabled();
expect(screen.getByRole("textbox", { name: "Triage note" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Save triage" })).toBeDisabled();
expect(screen.getByText(SAMPLE_DATA_READ_ONLY_MESSAGE)).toBeInTheDocument();
expect(getIssueNotesMock).not.toHaveBeenCalled();
expect(updateIssueTriageMock).not.toHaveBeenCalled();
expect(addIssueNoteMock).not.toHaveBeenCalled();
});
it("saves a new status together with its note", async () => {
// Given
const user = userEvent.setup();
+4 -16
View File
@@ -16,10 +16,6 @@ import { useToast } from "@/components/shadcn/toast/use-toast";
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
import { isUsageLimitError } from "@/lib/action-errors";
import {
isSampleId,
SAMPLE_DATA_READ_ONLY_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
@@ -37,7 +33,6 @@ import type { IssueTriageProps } from "./types";
* accepting a risk does not make a path unexploitable.
*/
export const IssueTriage = ({ id, status }: IssueTriageProps) => {
const sample = isSampleId(id);
const router = useRouter();
const { toast } = useToast();
const [pending, startTransition] = useTransition();
@@ -55,7 +50,6 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
}
useEffect(() => {
if (sample) return;
let cancelled = false;
getIssueNotes(id).then((loaded) => {
if (!cancelled) setNotes(loaded);
@@ -63,14 +57,13 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
return () => {
cancelled = true;
};
}, [id, reload, sample]);
}, [id, reload]);
const statusChanged = nextStatus !== status;
const trimmedNote = note.trim();
const save = () =>
startTransition(async () => {
if (sample) return;
const result =
statusChanged && isManualStatus(nextStatus)
? await updateIssueTriage(id, {
@@ -96,15 +89,10 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
return (
<section aria-label="Triage" className="flex flex-col gap-3">
<h4 className="text-sm font-semibold">Triage</h4>
{sample && (
<p className="text-text-neutral-secondary text-sm">
{SAMPLE_DATA_READ_ONLY_MESSAGE}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<div className="w-40">
<TriageStatusPicker
disabled={pending || sample}
disabled={pending}
value={nextStatus}
statusValues={FINDING_TRIAGE_MANUAL_STATUS_VALUES}
onValueChange={setNextStatus}
@@ -113,7 +101,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
<Button
size="sm"
onClick={save}
disabled={sample || pending || (!statusChanged && !trimmedNote)}
disabled={pending || (!statusChanged && !trimmedNote)}
>
{pending ? "Saving…" : "Save triage"}
</Button>
@@ -125,7 +113,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
maxLength={FINDING_TRIAGE_NOTE_MAX_LENGTH}
rows={3}
// A successful save clears the note: text typed meanwhile would be lost.
disabled={pending || sample}
disabled={pending}
onChange={(event) => setNote(event.target.value)}
/>
{notes === undefined ? (
@@ -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({});
});
});
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more