Compare commits

..
43 changed files with 1287 additions and 1653 deletions

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.47.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
@@ -1 +0,0 @@
Python base image from 3.12.15 to 3.12.16 in the API container image, patching CVE-2026-115557
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.47",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.49.0"
version = "1.48.1"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.49.0
version: 1.48.1
description: |-
Prowler API specification.
Generated
+31 -4
View File
@@ -1229,6 +1229,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/b3/8ec1268082f4d20cc8bf723a1a8e6b9e330bcc338a4dbcee9c7737e9dc1c/azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a", size = 1072620, upload-time = "2023-07-25T04:47:49.26Z" },
]
[[package]]
name = "azure-mgmt-cognitiveservices"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-mgmt-core" },
{ name = "msrest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/8e/9fcfdd507413a2536c5458b40942705ea8d74fe4e17f05fd1c32bb0225a9/azure_mgmt_cognitiveservices-14.1.0.tar.gz", hash = "sha256:915191374d0adb443863c20aaea2c9f3b9d558a849ac2b78f152249262fdcaf8", size = 184444, upload-time = "2025-10-24T07:28:14.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/ed6bbc01a03991255dd22aa41091360d225f1be20c88710af0f4a6c27459/azure_mgmt_cognitiveservices-14.1.0-py3-none-any.whl", hash = "sha256:3c0eecc00d183842a11d754f4f5c3328ff86f2d6c0a2b81b28dd7e073680258e", size = 290096, upload-time = "2025-10-24T07:28:15.766Z" },
]
[[package]]
name = "azure-mgmt-compute"
version = "34.0.0"
@@ -3503,6 +3517,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
[[package]]
name = "huaweicloudsdksmn"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5d/c0de47d011f1932c9c0ddf669c238d9fad01653d438d38203703526799d7/huaweicloudsdksmn-3.1.204-py3-none-any.whl", hash = "sha256:b0818ea9293e27458c8fa1d2da021c98006cbf9ce01cf17a0f1df598c4da3a6c", size = 323674, upload-time = "2026-07-09T09:04:24.804Z" },
]
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
@@ -4842,8 +4867,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.41.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
version = "5.47.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.47#1a62d7d99aea56db8e351f25f3ff385db7549fc6" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4864,6 +4889,7 @@ dependencies = [
{ name = "azure-mgmt-apimanagement" },
{ name = "azure-mgmt-applicationinsights" },
{ name = "azure-mgmt-authorization" },
{ name = "azure-mgmt-cognitiveservices" },
{ name = "azure-mgmt-compute" },
{ name = "azure-mgmt-containerregistry" },
{ name = "azure-mgmt-containerservice" },
@@ -4907,6 +4933,7 @@ dependencies = [
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
{ name = "huaweicloudsdksmn" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
@@ -4945,7 +4972,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.49.0"
version = "1.48.1"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5045,7 +5072,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.47" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
-56
View File
@@ -4,62 +4,6 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.47.0" description="October 29, 2026">
### 🧭 Add Provider — One Step to Connect Any Provider
The add-provider wizard follows the same model for every provider: one connect step, then the launch step. Adding an Azure subscription used to take six screens; now the account, its alias and its credentials are one form, and a single submit registers the account, stores the secret and tests the connection.
- A failed connection test shows inline, keeps the values and turns the action into **Retry connection** without registering the account twice.
- Access methods show as cards on the same step for AWS, GCP, Microsoft 365, GitHub, Alibaba Cloud and Cloudflare, and AWS, Azure and GCP switch to their organization flow through tabs.
- Users with the Manage Account permission can invite a teammate to finish the connection for every provider, not only AWS.
- **Ask Lighthouse AI** opens beside the wizard without closing it. It guides the connection from the Prowler docs by default and, when asked, connects the provider itself after confirming the provider, UID and credential field names, never echoing a secret back.
- Product tours and the guided onboarding sequence are removed; new tenants still land on the add-provider wizard.
### 🎯 Issues — Verdicts Explained in the List
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
- The issue list leads with the verdict, shows the attack path under each title and adds a **Why** column with the five evidence legs, so the reason for a verdict reads without opening the issue. The summary tiles become one line with the folded findings and the findings already covered by a control.
- A row opens in the side panel, next to Lighthouse AI, with the verdict, legs, remediation, triage and an **Attack path** tab with the issue graph, instead of expanding the issue inline.
- The menu entry is renamed to **Exploitability issues**.
### ☁️ Azure and AWS Checks
- `network_application_gateway_waf_enabled` and `network_application_gateway_http2_enabled` cover Azure Application Gateways and their WAF policies, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
- `iam_subscription_owners_between_2_and_3` verifies that each Azure subscription has between two and three Owners, counting owners inherited from management groups, mapped to CIS 5.0 and 6.0. Contributed by @BabafemiB. Thank you!
- The AWS security group internet exposure checks treat CIDRs in one ingress rule that together cover `0.0.0.0/0` or `::/0`, such as `0.0.0.0/1` plus `128.0.0.0/1`, as open to the internet; such a rule used to pass `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22` and its siblings. Contributed by @harshrajdebug. Thank you!
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🛠️ Improvements and Fixes
- Every MCP Server tool declares `readOnlyHint` and `destructiveHint`, so clients can tell a listing from a write when deciding whether to ask the user. Mutelist and mute rule writes are marked destructive because they hide findings for good.
- The cross-provider compliance tab keeps the frameworks that loaded, with a warning, when one catalog page or framework fails, instead of going blank.
- The NATO D32 compliance framework has its own icon on the compliance cards.
- Lighthouse AI replies no longer blink when the response finishes streaming, and the user avatar no longer requests the current page as an image, which rendered every page twice.
- The Invitations page uses **Status** instead of **State**, and the Invitations and Roles filters show capitalized options.
- **Prowler Cloud:** actions refused because the tenant is over its plan's usage limit, such as launching a scan, saving a schedule, adding a provider or triaging a finding, now link to the plans page.
- **Prowler Cloud:** creating API keys is a Prowler Cloud feature; on Local Server the button opens the upgrade dialog, and existing keys can still be edited and revoked.
- **Prowler Cloud:** issue evaluation after a scan reads the scan's findings through their indexes instead of a server-side cursor that took hours on large scans, so the evaluations no longer queue up behind each other.
- **Prowler Private Cloud:** Lighthouse AI can use Amazon Bedrock with only a region, relying on the installation's own AWS role, when the API allows it.
### 🔐 Security Updates
- `pymongo` upgraded to 4.18.3, `dnspython` to 2.9.0 and `oauthlib` to 4.0.0 in the SDK and the API container image, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure. The API also moves `django-allauth` to 65.19.7, since earlier releases cap `oauthlib` below 4.
- `Authlib` upgraded to 1.9.1 and `dnspython` to 2.9.0 in the MCP Server container image, patching CVE-2026-96760 (critical), CVE-2026-114615, CVE-2026-114616 and CVE-2026-114605.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @BabafemiB: Azure `network_application_gateway_waf_enabled`, `network_application_gateway_http2_enabled` and `iam_subscription_owners_between_2_and_3` checks ([#13160](https://github.com/prowler-cloud/prowler/pull/13160), [#13145](https://github.com/prowler-cloud/prowler/pull/13145))
- @harshrajdebug: AWS security group internet exposure fix for split CIDRs ([#13191](https://github.com/prowler-cloud/prowler/pull/13191))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.47.0) for the complete list of changes.
</Update>
<Update label="v5.46.0" description="October 26, 2026">
### 🎯 Issues — Attack Paths with an Exploitability Verdict
@@ -132,8 +132,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.47.0"
PROWLER_API_VERSION="5.47.0"
PROWLER_UI_VERSION="5.46.0"
PROWLER_API_VERSION="5.46.0"
```
<Note>
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
FROM python:3.15.1-alpine3.23@sha256:cb295b554dbbea09b2c0e6e6d9ce55f051347c902fbb611e618c071addfb30a3
FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b
LABEL maintainer="https://github.com/prowler-cloud"
@@ -1 +0,0 @@
MongoDB Atlas connection errors now include the HTTP status, error code and detail returned by Atlas instead of a generic HTTP error, and a custom exception message no longer persists into later errors of the same type
@@ -1 +0,0 @@
Python base image from 3.12.15 to 3.12.16 in the SDK container image, patching CVE-2026-115557
+1 -1
View File
@@ -52,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.48.0"
prowler_version = "5.47.1"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -38,9 +38,7 @@ class MongoDBAtlasBaseException(ProwlerException):
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "MongoDB Atlas"
error_info = dict(
self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
)
error_info = self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
if message:
error_info["message"] = message
super().__init__(
@@ -236,12 +236,7 @@ class MongodbatlasProvider(Provider):
message="MongoDB Atlas authentication failed. Please check your API credentials.",
)
if not response.ok:
raise MongoDBAtlasIdentityError(
file=os.path.basename(__file__),
message=MongodbatlasProvider._describe_api_error(response),
)
response.raise_for_status()
organizations_data = response.json()
# Extract organization information from the response
@@ -271,7 +266,7 @@ class MongodbatlasProvider(Provider):
return identity
except (MongoDBAtlasAuthenticationError, MongoDBAtlasIdentityError):
except MongoDBAtlasAuthenticationError:
raise
except Exception as error:
logger.critical(
@@ -281,22 +276,6 @@ class MongodbatlasProvider(Provider):
original_exception=error,
)
@staticmethod
def _describe_api_error(response) -> str:
"""Build an identity error message that keeps the reason Atlas returns."""
message = f"MongoDB Atlas identity setup failed - HTTP {response.status_code}"
try:
body = response.json()
except ValueError:
return message
if not isinstance(body, dict):
return message
if body.get("errorCode"):
message += f" {body['errorCode']}"
if body.get("detail"):
message += f": {body['detail']}"
return message
def print_credentials(self):
"""Print the MongoDB Atlas credentials"""
report_lines = [
+1 -1
View File
@@ -145,7 +145,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.48.0"
version = "5.47.1"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -136,60 +136,6 @@ class TestMongodbatlasProvider:
with pytest.raises(MongoDBAtlasIdentityError):
MongodbatlasProvider.setup_identity(session)
@patch("requests.get")
def test_setup_identity_forbidden_keeps_atlas_error_detail(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 403
mock_response.ok = False
mock_response.json.return_value = {
"detail": "IP address 203.0.113.10 is not allowed to access this resource.",
"error": 403,
"errorCode": "IP_ADDRESS_NOT_ON_ACCESS_LIST",
"reason": "Forbidden",
}
mock_get.return_value = mock_response
session = MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
)
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
MongodbatlasProvider.setup_identity(session)
assert str(exc_info.value) == (
"MongoDBAtlasIdentityError[8003]: MongoDB Atlas identity setup failed"
" - HTTP 403 IP_ADDRESS_NOT_ON_ACCESS_LIST: IP address 203.0.113.10"
" is not allowed to access this resource."
)
@patch("requests.get")
def test_setup_identity_error_without_json_body_reports_status(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 502
mock_response.ok = False
mock_response.json.side_effect = ValueError("No JSON")
mock_get.return_value = mock_response
session = MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
)
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
MongodbatlasProvider.setup_identity(session)
assert exc_info.value.message == (
"MongoDB Atlas identity setup failed - HTTP 502"
)
def test_custom_message_does_not_leak_into_later_exceptions(self):
MongoDBAtlasIdentityError(message="custom detail")
assert (
MongoDBAtlasIdentityError().message == "MongoDB Atlas identity setup failed"
)
def test_test_connection_success(self):
"""Test successful connection test"""
with (
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getIssue, notFound } = vi.hoisted(() => ({
getIssue: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({ notFound }));
vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-route", () => ({
FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
<div data-testid="issue-detail">{scanId}</div>
),
}));
import IssuePage from "./page";
const params = Promise.resolve({ id: "issue-1" });
describe("IssuePage", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockReset();
});
it("returns not found in Local Server without asking the API", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
expect(getIssue).not.toHaveBeenCalled();
});
it("returns not found when the API has no such issue", async () => {
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
});
it("shows the issue with the detail the list opens in place", async () => {
getIssue.mockResolvedValue({
data: {
type: "issues",
id: "issue-1",
attributes: {
pattern: "aws_public_bucket",
cloud: "aws",
account: "prod",
title: "Public bucket",
verdict: "likely",
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
render(await IssuePage({ params }));
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(
screen.getByRole("heading", { name: "Public bucket" }),
).toBeInTheDocument();
expect(screen.getByText("Likely")).toBeInTheDocument();
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
"href",
"/issues",
);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { ShieldAlert } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/actions/issues";
import { IssueDetail } from "@/components/issues/issue-detail";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
/**
* One issue on its own URL, so a path can be shared or linked from a ticket.
* The body is the same detail the list opens in place.
*/
export default async function IssuePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!isCloud()) notFound();
const { id } = await params;
const issue = (await getIssue(id))?.data;
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
if (!issue?.attributes) notFound();
const { attributes, relationships } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
</Button>
<Card variant="base" className="min-w-0 gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-tertiary text-xs">
{attributes.pattern}
</span>
</div>
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
{attributes.title}
</h2>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<IssueDetail
id={issue.id}
issue={attributes}
scanId={relationships?.scan?.data?.id}
providerId={relationships?.provider?.data?.id}
/>
</Card>
</div>
</FeatureFlagRoute>
</ContentLayout>
);
}
@@ -26,13 +26,10 @@ const ISSUE_FILTER_KEYS = [
export const IssuesList = async ({
searchParams,
filters: filterControls,
providerUids,
}: {
searchParams: SearchParamsProps;
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
filters?: ReactNode;
/** Provider uid by provider id, for the issue panel. */
providerUids?: Record<string, string>;
}) => {
const filters: Record<string, string> = {};
ISSUE_FILTER_KEYS.forEach((key) => {
@@ -90,11 +87,7 @@ export const IssuesList = async ({
{rows === null ? (
<IssuesLoadError />
) : rows.length ? (
<IssuesTable
issues={rows}
metadata={issues?.meta}
providerUids={providerUids}
/>
<IssuesTable issues={rows} metadata={issues?.meta} />
) : (
<p className="text-text-neutral-tertiary text-sm">
No issues match this filter.
+3 -8
View File
@@ -27,12 +27,6 @@ export default async function Issues({
getAllProviderGroups(),
]);
const providers = providersData?.data ?? [];
// The issue only references its provider; the uid shows in the panel.
const providerUids = Object.fromEntries(
providers.map((provider) => [provider.id, provider.attributes.uid]),
);
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
{/* Deployment gate above (notFound); per-account rollout here, also a
@@ -47,12 +41,13 @@ export default async function Issues({
>
<IssuesList
searchParams={resolvedSearchParams}
providerUids={providerUids}
filters={
// The three scope selectors share one row on desktop, like the
// other list pages.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ProviderAccountSelectors providers={providers} />
<ProviderAccountSelectors
providers={providersData?.data ?? []}
/>
<ProviderGroupSelector
groups={providerGroupsData?.data ?? []}
/>
+22 -36
View File
@@ -1,10 +1,14 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { IssueAttributes } from "@/types/issues";
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: () => <p>graph</p>,
}));
@@ -14,53 +18,35 @@ import { IssueAttackPath } from "./issue-attack-path";
const issue = { legs: {} } as IssueAttributes;
describe("IssueAttackPath", () => {
it("says there is nothing to draw, not a load error, without resources", () => {
// Given: no resources, and findings that could not load without a scan
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: [],
findings: undefined,
findingsTruncated: false,
}}
onRetry={vi.fn()}
onOpenFinding={vi.fn()}
/>,
);
it("says there is nothing to draw, not a load error, without resources", async () => {
// Given: no resources, and findings that cannot load without a scan
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(
screen.getByText("This issue has no affected resources to draw."),
await screen.findByText("This issue has no affected resources to draw."),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("offers a retry when the findings for the drawn resources fail", async () => {
// Given
const onRetry = vi.fn();
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: ["bucket"],
findings: undefined,
findingsTruncated: false,
}}
onRetry={onRetry}
onOpenFinding={vi.fn()}
/>,
);
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
});
// When
expect(
screen.getByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
render(<IssueAttackPath id="issue-1" issue={issue} />);
// Then
expect(onRetry).toHaveBeenCalled();
expect(
await screen.findByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
});
+19 -18
View File
@@ -2,25 +2,26 @@
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { IssueAttributes } from "@/types/issues";
import type { IssueEvidenceState } from "./use-issue-evidence";
interface IssueAttackPathProps {
id: string;
issue: IssueAttributes;
evidence: IssueEvidenceState;
onRetry: () => void;
onOpenFinding: (findingId: string) => void;
scanId?: string;
providerId?: string;
}
/** The issue's graph, or why there is none to draw yet. */
/** The issue's graph for the side panel; findings open on the full page. */
export const IssueAttackPath = ({
id,
issue,
evidence,
onRetry,
onOpenFinding,
scanId,
providerId,
}: IssueAttackPathProps) => {
if (evidence.status === "loading") {
const { state, retry } = useIssueEvidence(id, scanId, providerId);
if (state.status === "loading") {
return (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading the attack path…
@@ -29,29 +30,29 @@ export const IssueAttackPath = ({
}
// Nothing to draw beats a findings error: without resources there is no
// graph for the findings to land on.
if (evidence.status === "loaded" && evidence.resources.length === 0) {
if (state.status === "loaded" && state.resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources to draw.
</p>
);
}
if (evidence.status === "error" || evidence.findings === undefined) {
if (state.status === "error" || state.findings === undefined) {
return (
<IssuesLoadError
message="Could not load the attack path for this issue."
onRetry={onRetry}
onRetry={retry}
/>
);
}
return (
<IssueGraph
issue={issue}
resourceUids={evidence.resources}
findings={evidence.findings}
onOpenFinding={onOpenFinding}
{...(evidence.providerId && evidence.scanId
? { providerId: evidence.providerId, scanId: evidence.scanId }
resourceUids={state.resources}
findings={state.findings}
readOnly
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
);
+7 -16
View File
@@ -9,7 +9,6 @@ import {
complianceFrameworkLabel,
} from "@/components/compliance/compliance-framework-chip";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
import { IssueFinding } from "@/types/issues";
@@ -116,21 +115,13 @@ export const IssueCompliance = ({
);
};
// Laid out like the finding panel's "Compliance Frameworks" header row.
return (
<section aria-label="Compliance affected" className="flex flex-col gap-1.5">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
<section aria-label="Compliance affected">
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
{state.status === "loading" ? (
<div className="flex flex-wrap items-center gap-2">
<span role="status" className="sr-only">
Loading frameworks…
</span>
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading frameworks…
</p>
) : state.status === "unavailable" ? (
<IssuesLoadError
message="Could not load the frameworks for this issue."
@@ -139,7 +130,7 @@ export const IssueCompliance = ({
) : (
<>
{state.frameworks.length === 0 ? (
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary text-sm">
{incomplete
? "None of the findings checked map to a framework requirement."
: "None of the findings behind this issue map to a framework requirement."}
@@ -157,7 +148,7 @@ export const IssueCompliance = ({
</div>
)}
{incomplete && (
<p className="text-text-neutral-tertiary text-xs">
<p className="text-text-neutral-tertiary mt-2 text-xs">
Not every finding could be checked, so this list may be
incomplete.
</p>
+326
View File
@@ -0,0 +1,326 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDetail } from "@/components/issues/issue-detail";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues";
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
getIssueMock: vi.fn(),
getIssueFindingsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({
getIssue: getIssueMock,
getIssueFindings: getIssueFindingsMock,
getIssueNotes: async () => [],
}));
vi.mock("@/components/issues/issue-compliance", () => ({
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
truncated ? <p>Compliance from a capped preview</p> : null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<div data-testid="issue-graph">{resourceUids.length} resources</div>
),
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const issue = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
describe("IssueDetail", () => {
beforeEach(() => {
getIssueMock.mockReset();
getIssueFindingsMock.mockResolvedValue([]);
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("links to the issue's scan findings and reveals every folded resource", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: resourceUids } },
});
const user = userEvent.setup();
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
expect(
screen.getByRole("link", { name: /failed findings/i }),
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// The list leaves the uids out; the detail fetches them when it opens.
expect(getIssueMock).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
});
it("says what a path buys the attacker and where it starts", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
pattern: "aws_public_bucket",
outcome: "data_exposure",
attacker_start: "internet",
}}
/>,
);
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given - one more finding than the preview loads
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: [],
finding_uids: Array.from(
{ length: ISSUE_FINDINGS_LIMIT + 1 },
(_, i) => `uid-${i}`,
),
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
getIssueFindingsMock.mockResolvedValue([
{
id: "f1",
checkId: "check-a",
checkTitle: "Check A",
severity: "high",
resourceUid: "r1",
},
]);
// When
render(<IssueDetail id="issue-1" issue={issue} />);
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(<IssueDetail id="issue-1" issue={issue} />);
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("links each affected resource to it on the Resources page", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
});
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
expect(
await screen.findByRole("link", {
name: "Open in Resources: arn:aws:s3:::bucket",
}),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
);
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: resourceUids,
finding_uids: ["finding-uid"],
},
},
});
getIssueFindingsMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
// When
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(await screen.findByTestId("issue-graph")).toBeInTheDocument();
expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("names the conditions that make the path, explained by each leg", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
outcome: "data_exposure",
attacker_start: "internet",
legs: {
...issue.legs,
controls: {
state: "no",
text: "The public access block is off.",
evidence: [],
},
},
}}
/>,
);
const factors = screen.getByRole("list", { name: "Risk factors" });
expect(factors).toHaveTextContent("Internet-reachable");
expect(screen.getByText("No control blocking")).toHaveAttribute(
"title",
"The public access block is off.",
);
});
it("lists each folded finding once, with its own triage and a link to it", async () => {
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
const section = await screen.findByRole("region", { name: "Findings" });
const link = within(section).getByRole("link", {
name: "Bucket is public",
});
const destination = new URL(link.getAttribute("href")!, "https://app.test");
expect(Object.fromEntries(destination.searchParams)).toEqual({
"filter[scan__in]": "scan-1",
"filter[check_id__in]": "s3_bucket_public_access",
"filter[resource_uid]": "other-bucket",
"filter[muted]": "include",
expandedCheckId: "s3_bucket_public_access",
});
expect(within(section).getAllByRole("listitem")).toHaveLength(2);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument();
});
it("opens the global Jira flow with unique finding IDs", async () => {
// Given
const user = userEvent.setup();
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_public",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "open",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = render(
<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />,
);
// When
await user.click(
await screen.findByRole("button", { name: "Send to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the detail closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: issue.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { Send } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssueTriage } from "@/components/issues/issue-triage";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { riskFactors } from "@/lib/issues/risk-factors";
import {
LEG_LABELS,
LEG_QUESTIONS,
LEG_STATE_LABELS,
legBadgeVariant,
} from "@/lib/issues/verdicts";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
LegName,
} from "@/types/issues";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueDetailProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
}
export const IssueDetail = ({
id,
issue,
scanId,
providerId,
}: IssueDetailProps) => {
const openJiraDispatch = useJiraDispatchStore(
(state) => state.openJiraDispatch,
);
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
const { state, retry } = useIssueEvidence(id, scanId, providerId);
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
state.status === "loaded"
? Array.from(
new Map(
state.findings?.map((finding) => [finding.id, finding]),
).values(),
)
: [];
const jiraPayload = createJiraDispatchPayload({
findingTitle: issue.title,
targetIds: uniqueFindings.map((finding) => finding.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`,
});
return (
<div className="flex min-w-0 flex-col gap-4 py-4">
{/* The conditions that hold at once: the verdict says how bad, these
say why, and which part to argue with. */}
{factors.length > 0 && (
<ul aria-label="Risk factors" className="flex flex-wrap gap-1.5">
{factors.map((factor) => (
<li key={factor.label}>
<Badge variant={factor.variant} title={factor.title}>
{factor.label}
</Badge>
</li>
))}
</ul>
)}
{state.status === "loaded" && state.findings === undefined && (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
)}
{state.status === "loaded" &&
state.findings !== undefined &&
state.resources.length > 0 && (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
)}
{issue.outcome && issue.attacker_start && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoField label="Outcome">
{ISSUE_OUTCOME_LABEL[issue.outcome]}
</InfoField>
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[issue.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also need a
credential is the Access leg.
</p>
</InfoField>
</div>
)}
<section aria-label="Exploitability assessment">
<h4 className="mb-3 text-sm font-semibold">
Exploitability assessment
</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
const leg = issue.legs[name];
if (!leg) return null;
return (
<Card key={name} variant="inner" className="min-w-0 gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-sm font-medium">{LEG_LABELS[name]}</h5>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</div>
<p className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</p>
<p className="text-text-neutral-secondary text-sm">
{leg.text}
</p>
{leg.evidence.length > 0 && (
<ul className="flex flex-col gap-1">
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</Card>
);
})}
</div>
</section>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : state.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : state.resources.length > 0 ? (
<section aria-label="Affected resources">
<h4 className="mb-3 text-sm font-semibold">
Affected resources ({state.resources.length})
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li
key={uid}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, state.providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</li>
))}
</ul>
{visibleResources < state.resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() =>
setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
}
>
Show more resources
</Button>
)}
{visibleResources > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisibleResources(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</section>
) : null}
<section aria-label="Remediation">
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
<div className="flex flex-wrap items-center gap-3">
{activeScanId && (
<Button asChild variant="link" size="link-sm">
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
{jiraPayload && (
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through
// finding_uids if tickets for big issues need all of them.
<Button
variant="outline"
size="sm"
onClick={() => openJiraDispatch(jiraPayload)}
>
<Send aria-hidden="true" />
Send to Jira
</Button>
)}
</div>
</section>
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueFindings
findings={uniqueFindings}
scanId={activeScanId}
total={issue.findings_count}
/>
)}
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueCompliance
findings={state.findings}
scanId={activeScanId}
truncated={state.findingsTruncated}
/>
)}
<IssueTriage id={id} status={issue.triage_status} />
</div>
);
};
+68 -76
View File
@@ -1,91 +1,83 @@
import Link from "next/link";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
import { MUTED_FILTER } from "@/lib/findings-filters";
import type { IssueFindingsProps } from "./types";
const HEADERS = ["Finding", "Severity", "Triage"] as const;
/**
* The findings the issue folds, laid out like the finding panel's "Other
* findings" table. Each keeps its own triage: the issue's status answers for
* the combination, the finding's for whether that one check is right.
* The findings the issue folds, each with its own triage. That triage stays on
* the finding: the issue's status answers for the combination, the finding's
* for whether that one check is right.
*/
export const IssueFindings = ({
findings,
scanId,
total,
onOpenFinding,
}: IssueFindingsProps) => {
return (
<section aria-label="Findings" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{findings.length < total
? `Showing ${findings.length.toLocaleString()} of ${total.toLocaleString()}`
: `${total.toLocaleString()} Total Entries`}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
{HEADERS.map((header) => (
<TableHead key={header}>
<span className="text-text-neutral-secondary text-sm font-medium">
{header}
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{findings.map((finding) => (
<TableRow
key={finding.id}
className="group cursor-pointer"
onClick={() => onOpenFinding(finding.id)}
>
{/* max-w-0 lets the title take the spare width and truncate. */}
<TableCell className="w-full max-w-0">
{/* The row opens the finding; the button is its keyboard stop. */}
<button
type="button"
title={finding.checkTitle}
className="text-text-neutral-primary block max-w-full truncate text-left text-sm"
onClick={(event) => {
event.stopPropagation();
onOpenFinding(finding.id);
}}
>
{finding.checkTitle}
</button>
{/* One check fails on several resources: this tells them apart. */}
<p
className="text-text-neutral-tertiary truncate font-mono text-xs"
title={finding.resourceUid}
>
{finding.resourceUid}
</p>
</TableCell>
<TableCell>
<SeverityBadge severity={finding.severity} />
</TableCell>
<TableCell>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<section aria-label="Findings">
<h4 className="mb-3 text-sm font-semibold">
Findings ({total.toLocaleString()})
</h4>
<ul className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{findings.map((finding) => (
<li key={finding.id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full">
<div className="flex flex-col gap-1">
<div className="flex items-start justify-between gap-3">
{scanId ? (
<div className="min-w-0">
<Button
asChild
variant="link"
size="link-sm"
className="max-w-full"
>
<Link
href={`/findings?${new URLSearchParams({
"filter[scan__in]": scanId,
"filter[check_id__in]": finding.checkId,
"filter[resource_uid]": finding.resourceUid,
"filter[muted]": MUTED_FILTER.INCLUDE,
expandedCheckId: finding.checkId,
})}`}
title={finding.checkTitle}
>
<span className="truncate">{finding.checkTitle}</span>
</Link>
</Button>
</div>
) : (
<span className="text-text-neutral-primary min-w-0 text-sm break-words">
{finding.checkTitle}
</span>
)}
<SeverityBadge severity={finding.severity} />
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-text-neutral-tertiary min-w-0 truncate font-mono text-xs">
{finding.checkId}
</span>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</div>
</div>
</Card>
</li>
))}
</ul>
{findings.length < total && (
<p className="text-text-neutral-tertiary mt-2 text-xs">
Showing {findings.length.toLocaleString()} of {total.toLocaleString()}
.
</p>
)}
</section>
);
};
+44 -20
View File
@@ -6,8 +6,22 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { GraphNode } from "@/types/attack-paths";
import { IssueAttributes } from "@/types/issues";
const { getFindings } = vi.hoisted(() => ({ getFindings: vi.fn() }));
const { getFindings, navigateToFinding } = vi.hoisted(() => ({
getFindings: vi.fn(),
navigateToFinding: vi.fn(),
}));
vi.mock("@/actions/findings", () => ({ getFindings }));
vi.mock("@/hooks/use-finding-details", () => ({
useFindingDetails: () => ({
findingDetails: null,
findingDetailLoading: false,
resetFindingDetails: vi.fn(),
navigateToFinding,
}),
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
AttackPathGraph: ({
data,
@@ -38,7 +52,7 @@ const issue = { legs: {} } as IssueAttributes;
it("requires provider and scan identifiers together in the graph props", () => {
type Props = ComponentProps<typeof IssueGraph>;
type BaseProps = Pick<Props, "issue" | "resourceUids" | "onOpenFinding">;
type BaseProps = Pick<Props, "issue" | "resourceUids">;
expectTypeOf<BaseProps>().toExtend<Props>();
expectTypeOf<
@@ -49,10 +63,31 @@ it("requires provider and scan identifiers together in the graph props", () => {
});
describe("IssueGraph finding lookup", () => {
const openFinding = vi.fn();
beforeEach(() => {
openFinding.mockReset();
navigateToFinding.mockResolvedValue(undefined);
});
it("leaves findings closed in a read-only graph", async () => {
// Given: a host that cannot hold a second detail view, such as the panel
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
readOnly
/>,
);
// When
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).not.toHaveBeenCalled();
expect(getFindings).not.toHaveBeenCalled();
expect(
screen.getByText(/Open the full path to inspect a finding/),
).toBeInTheDocument();
});
it("opens a folded finding by id without another lookup", async () => {
@@ -74,7 +109,6 @@ describe("IssueGraph finding lookup", () => {
]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
@@ -82,7 +116,7 @@ describe("IssueGraph finding lookup", () => {
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(openFinding).toHaveBeenCalledWith("folded-finding");
expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
expect(getFindings).not.toHaveBeenCalled();
});
@@ -102,7 +136,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await user.click(screen.getByRole("button", { name: "Open finding" }));
@@ -116,7 +149,7 @@ describe("IssueGraph finding lookup", () => {
"filter[status]": "FAIL",
},
});
expect(openFinding).toHaveBeenCalledWith("finding-1");
expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
});
it("rejects a finding from a newer scan", async () => {
@@ -134,11 +167,10 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(openFinding).not.toHaveBeenCalled();
expect(navigateToFinding).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"no longer available for the issue's scan",
);
@@ -154,7 +186,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -178,7 +209,6 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -194,13 +224,7 @@ describe("IssueGraph finding lookup", () => {
});
it("does not look up findings without a provider and scan", async () => {
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
onOpenFinding={openFinding}
/>,
);
render(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(getFindings).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
+38 -8
View File
@@ -4,12 +4,14 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings";
import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
buildIssueGraph,
GRAPH_RESOURCE_LIMIT,
} from "@/components/issues/issue-graph.adapter";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import { GraphNode } from "@/types/attack-paths";
@@ -19,8 +21,11 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
/** Called with the clicked finding's id; the host shows its detail. */
onOpenFinding: (findingId: string) => void;
/**
* No finding drawer on click: for hosts inside the side panel, where a
* second detail view would replace the one holding the graph.
*/
readOnly?: boolean;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -46,8 +51,9 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
onOpenFinding,
readOnly = false,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
const view = buildIssueGraph(issue, resourceUids, findings);
const shownFindings = new Set(
@@ -58,6 +64,7 @@ export const IssueGraph = ({
const pending = useRef(false);
const mounted = useRef(true);
const [loading, setLoading] = useState(false);
const [lookupComplete, setLookupComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const expandedResources = new Set(
view.nodes
@@ -75,6 +82,8 @@ export const IssueGraph = ({
const handleNodeClick = async (node: GraphNode) => {
if (!isProwlerFindingNode(node.labels) || pending.current) return;
setError(null);
setLookupComplete(false);
finding.resetFindingDetails();
let findingId = node.properties.finding_id;
if (!findingId && (!providerId || !scanId)) {
setError(
@@ -111,7 +120,8 @@ export const IssueGraph = ({
}
findingId = match.id;
}
onOpenFinding(String(findingId));
await finding.navigateToFinding(String(findingId));
if (mounted.current) setLookupComplete(true);
} catch {
if (mounted.current)
setError("Could not load this finding. Select the finding to retry.");
@@ -120,6 +130,9 @@ export const IssueGraph = ({
if (mounted.current) setLoading(false);
}
};
const detailFailed =
lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
return (
<section
aria-label="Finding evidence"
@@ -131,16 +144,20 @@ export const IssueGraph = ({
{Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "}
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths. Select a finding to open it.
attack paths.
{readOnly && " Open the full path to inspect a finding."}
</p>
{loading && (
<p role="status" className="text-sm">
Loading finding details…
</p>
)}
{error && (
{(error || detailFailed) && (
<Alert variant="error">
<AlertDescription>{error}</AlertDescription>
<AlertDescription>
{error ??
"Could not load this finding. Select the finding to retry."}
</AlertDescription>
</Alert>
)}
<Card
@@ -152,10 +169,23 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
onNodeClick={readOnly ? undefined : handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
defaultOpen
onOpenChange={(open) => {
if (!open) {
setLookupComplete(false);
finding.resetFindingDetails();
}
}}
/>
)}
</section>
);
};
-533
View File
@@ -1,533 +0,0 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import {
ISSUE_FINDINGS_LIMIT,
IssueAttributes,
IssueFinding,
IssueResource,
} from "@/types/issues";
import { IssuePanel } from "./issue-panel";
const { getIssue, getIssueFindings, getFindingById } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
getFindingById: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/actions/issues", () => ({
getIssue,
getIssueFindings,
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
vi.mock("@/components/issues/issue-compliance", () => ({
IssueCompliance: ({ truncated }: { truncated: boolean }) =>
truncated ? <p>Compliance from a capped preview</p> : null,
}));
vi.mock("@/components/findings/send-to-jira-modal", () => ({
SendToJiraModal: () => null,
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: ({ finding }: { finding: { id: string } }) => (
<p>Detail of {finding.id}</p>
),
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
onOpenFinding,
}: {
resourceUids: string[];
onOpenFinding: (id: string) => void;
}) => (
<button type="button" onClick={() => onOpenFinding("graph-finding")}>
Graph of {resourceUids.length} resources
</button>
),
}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => <section aria-label={title}>{children}</section>,
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const attributes = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
const issueWith = (
overrides: Partial<IssueAttributes> = {},
scanId: string | undefined = "scan-1",
): IssueResource => ({
type: "issues",
id: "issue-1",
attributes: { ...attributes, ...overrides },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
...(scanId && { scan: { data: { type: "scans", id: scanId } } }),
},
});
const finding: IssueFinding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
const detailWith = (resource_uids: string[], finding_uids: string[] = []) => ({
data: { attributes: { resource_uids, finding_uids } },
});
const renderPanel = (
issue: IssueResource = issueWith(),
providerUid?: string,
) =>
render(
<IssuePanel
issue={issue}
providerUid={providerUid}
open
onOpenChange={vi.fn()}
/>,
);
const openTab = (name: string) =>
userEvent.click(screen.getByRole("tab", { name }));
describe("IssuePanel", () => {
beforeEach(() => {
getIssue.mockReset();
getIssueFindings.mockReset().mockResolvedValue([]);
getFindingById.mockReset();
useJiraDispatchStore.getState().closeJiraDispatch();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("says where the path starts and shows each leg's evidence", async () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(
issueWith({
outcome: "data_exposure",
attacker_start: "internet",
legs: {
reachability: {
state: "yes",
text: "Public access is enabled.",
evidence: ["s3_public · arn:aws:s3:::bucket"],
},
} as IssueAttributes["legs"],
}),
);
// Then
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
expect(
within(
screen.getByRole("list", { name: "Reachability evidence" }),
).getByText("s3_public · arn:aws:s3:::bucket"),
).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
});
it("names the provider with its account uid, like the finding panel", () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(issueWith(), "123456789012");
// Then
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("prod")).toBeInTheDocument();
expect(screen.getByText("123456789012")).toBeInTheDocument();
expect(screen.getByText("unmodelled")).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", () => {
getIssue.mockResolvedValue(detailWith([]));
renderPanel();
expect(screen.queryByText("Leads to")).not.toBeInTheDocument();
});
it("pages through every affected resource and links each to Resources", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids));
renderPanel();
// When
await openTab("Resources");
// Then: the list leaves the uids out, so the panel fetches them
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open in Resources: resource-1" }),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=resource-1&filter%5Bprovider_id__in%5D=provider-1",
);
await userEvent.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
});
it("says so when the resources cannot be loaded", async () => {
getIssue.mockResolvedValue(undefined);
renderPanel();
await openTab("Resources");
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids, ["finding-uid"]));
getIssueFindings
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([finding]);
renderPanel();
// When
await openTab("Findings");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(
await screen.findByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(getIssueFindings).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("lists each folded finding once, with its own triage", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
renderPanel();
// When
await openTab("Findings");
// Then
const section = await screen.findByRole("region", { name: "Findings" });
// One header row plus one row per finding.
expect(within(section).getAllByRole("row")).toHaveLength(3);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
// Cards of one check tell their resources apart.
expect(within(section).getByText("other-bucket")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300")).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given: one more finding than the preview loads
getIssue.mockResolvedValue(
detailWith(
[],
Array.from({ length: ISSUE_FINDINGS_LIMIT + 1 }, (_, i) => `uid-${i}`),
),
);
getIssueFindings.mockResolvedValue([finding]);
renderPanel();
// When
await openTab("Findings");
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("opens a finding in place and goes back to the tab it came from", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue({
data: {
id: "finding-1",
attributes: { check_metadata: { checktitle: "Bucket is public" } },
relationships: {},
},
});
renderPanel();
await openTab("Findings");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
// Then: the finding replaces the tabs, with a way back
expect(await screen.findByText("Detail of finding-1")).toBeInTheDocument();
expect(getFindingById).toHaveBeenCalledWith(
"finding-1",
"resources,scan.provider",
);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
// When
await userEvent.click(screen.getByText("Issue details"));
// Then
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute(
"aria-selected",
"true",
);
});
it("refreshes the findings behind the tabs when a finding closes", async () => {
// Given: the finding is triaged while open in the panel
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([{ ...finding, triageStatus: "open" }])
.mockResolvedValueOnce([{ ...finding, triageStatus: "risk_accepted" }]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
// Then: the row shows the new triage without dropping to a loading state
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(await screen.findByText("Risk Accepted")).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it.each([
{
failure: "the findings request",
fail: () => getIssueFindings.mockResolvedValueOnce(undefined),
failing: getIssueFindings,
},
{
failure: "the issue request",
fail: () => getIssue.mockRejectedValueOnce(new Error("Offline")),
failing: getIssue,
},
])(
"keeps the loaded rows when $failure fails on a background refresh",
async ({ fail, failing }) => {
// Given: the findings loaded, and a finding is open
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
fail();
// When: closing it refreshes, and the refresh fails
await userEvent.click(screen.getByText("Issue details"));
// Then: once the failing refresh has settled, the rows stay
await waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
await act(async () => {
await failing.mock.results[1]?.value?.catch(() => undefined);
});
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull();
},
);
it("keeps the rows on screen while the background refresh is pending", async () => {
// Given: the refresh after closing the finding never answers
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockReturnValueOnce(new Promise(() => {}));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssue).toHaveBeenCalledTimes(2));
// Then: the loaded rows stay, with no loading state in their place
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it("applies refreshed resources even when the refreshed findings fail", async () => {
// Given: the refresh brings a new resource but loses the findings
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockResolvedValueOnce(detailWith(["bucket", "new-bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([finding])
.mockResolvedValueOnce(undefined);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssueFindings).toHaveBeenCalledTimes(2));
// Then: the findings stay, the resources follow the refresh
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
await openTab("Resources");
expect(await screen.findByText("new-bucket")).toBeInTheDocument();
});
it("opens a finding clicked on the attack path in place", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue(undefined);
renderPanel();
await openTab("Attack path");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Graph of 1 resources" }),
);
// Then: a failed detail says so and can be retried
expect(getFindingById).toHaveBeenCalledWith(
"graph-finding",
"resources,scan.provider",
);
expect(
await screen.findByText("Could not load this finding."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(getFindingById).toHaveBeenCalledTimes(2);
});
it("sends the unique findings to Jira from the actions menu", async () => {
// Given: Prowler Cloud, where several findings can go in one dispatch
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = renderPanel();
// When
await userEvent.click(
await screen.findByRole("button", { name: "Issue actions" }),
);
await userEvent.click(
await screen.findByRole("menuitem", { name: "Send 2 Findings to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the panel closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: attributes.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+123 -398
View File
@@ -1,38 +1,16 @@
"use client";
import { ArrowLeft, Box, Tag } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { FindingDetailDrawer } from "@/components/findings/table";
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueResources } from "@/components/issues/issue-resources";
import { IssueTriage } from "@/components/issues/issue-triage";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import {
BreadcrumbNavigation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Button } from "@/components/shadcn/button/button";
import { EntityInfo } from "@/components/shadcn/entities";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
import { useFindingDetails } from "@/hooks/use-finding-details";
import {
LEG_LABELS,
LEG_QUESTIONS,
@@ -40,9 +18,6 @@ import {
legBadgeVariant,
VERDICTS,
} from "@/lib/issues/verdicts";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
@@ -50,399 +25,149 @@ import {
LegName,
} from "@/types/issues";
import { useIssueEvidence } from "./use-issue-evidence";
const ISSUE_PANEL_TAB = {
EXPLOITABILITY: "exploitability",
ATTACK_PATH: "attack-path",
FINDINGS: "findings",
RESOURCES: "resources",
REMEDIATION: "remediation",
TRIAGE: "triage",
} as const;
type IssuePanelTab = (typeof ISSUE_PANEL_TAB)[keyof typeof ISSUE_PANEL_TAB];
const ISSUE_PANEL_TABS: { id: IssuePanelTab; label: string }[] = [
{ id: ISSUE_PANEL_TAB.EXPLOITABILITY, label: "Exploitability" },
{ id: ISSUE_PANEL_TAB.ATTACK_PATH, label: "Attack path" },
{ id: ISSUE_PANEL_TAB.FINDINGS, label: "Findings" },
{ id: ISSUE_PANEL_TAB.RESOURCES, label: "Resources" },
{ id: ISSUE_PANEL_TAB.REMEDIATION, label: "Remediation" },
{ id: ISSUE_PANEL_TAB.TRIAGE, label: "Triage" },
];
interface IssuePanelProps {
issue: IssueResource;
/** The provider's account uid, which the issue itself does not carry. */
providerUid?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Everything about one issue, including drilling into its findings. */
export const IssuePanel = ({
issue,
providerUid,
open,
onOpenChange,
}: IssuePanelProps) => (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={issue.attributes.title}
description="The issue's verdict, attack path, evidence, fix and triage"
>
{/* Mounted only while open, so the evidence loads when someone looks. */}
<IssuePanelContent issue={issue} providerUid={providerUid} />
</DetailSidePanel>
);
const IssuePanelContent = ({
issue,
providerUid,
}: Pick<IssuePanelProps, "issue" | "providerUid">) => {
const router = useRouter();
/**
* The verdict and the reasoning behind it, from the list row alone (no
* fetch). Graph, resources, findings and compliance stay on the issue page.
*/
export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
const { attributes } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
const scanId = issue.relationships?.scan?.data?.id;
const providerId = issue.relationships?.provider?.data?.id;
const {
state: evidence,
retry,
refresh,
} = useIssueEvidence(issue.id, scanId, providerId);
// Controlled so closing a finding returns to the tab it was opened from.
const [tab, setTab] = useState<IssuePanelTab>(ISSUE_PANEL_TAB.EXPLOITABILITY);
const [openFindingId, setOpenFindingId] = useState<string | null>(null);
const finding = useFindingDetails();
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
const scanFindingsHref =
activeScanId &&
`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`;
const loadedFindings =
evidence.status === "loaded" ? evidence.findings : undefined;
// The findings request returns one row per finding and resource.
const uniqueFindings = Array.from(
new Map(loadedFindings?.map((item) => [item.id, item])).values(),
);
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through finding_uids
// if tickets for big issues need all of them.
const jiraPayload = createJiraDispatchPayload({
findingTitle: attributes.title,
targetIds: uniqueFindings.map((item) => item.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${attributes.findings_count} findings on this attack path.`,
});
const openFinding = (findingId: string) => {
setOpenFindingId(findingId);
void finding.navigateToFinding(findingId);
};
const closeFinding = () => {
setOpenFindingId(null);
finding.resetFindingDetails();
// The finding may have been triaged or muted while open.
refresh();
};
// A finding opens in place, like a resource's findings do: a second detail
// view would take the side panel over and close this issue.
if (openFindingId) {
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<BreadcrumbNavigation
mode="custom"
customItems={[
{
name: "Issue details",
icon: <ArrowLeft />,
isClickable: true,
onClick: closeFinding,
},
{
name: finding.findingDetailLoading
? "Loading..."
: (finding.findingDetails?.attributes?.check_metadata
?.checktitle ?? "Finding Detail"),
isLast: true,
},
]}
/>
{finding.findingDetailLoading ? (
<LoadingState label="Loading finding details..." />
) : finding.findingDetails ? (
// The detail scrolls inside itself, so it needs a bounded height.
<div className="min-h-0 flex-1">
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
inline
/>
</div>
) : (
<IssuesLoadError
message="Could not load this finding."
onRetry={() => openFinding(openFindingId)}
/>
)}
</div>
);
}
const evidencePending =
evidence.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : null;
return (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={attributes.title}
description="Why this issue got its verdict and how to fix it"
>
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<SeverityBadge severity={attributes.severity} />
</div>
{/* Same actions menu as the resource and finding panels. Jira joins
once the findings it sends have loaded. */}
<div className="shrink-0">
{scanFindingsHref || jiraPayload ? (
<ActionDropdown variant="bordered" ariaLabel="Issue actions">
{scanFindingsHref && (
<ActionDropdownItem
icon={<Tag className="size-5" />}
label="Open this scan's failed findings"
onSelect={() => router.push(scanFindingsHref)}
/>
)}
<JiraDispatchActionItem
label={buildJiraActionLabel({
findingCount: uniqueFindings.length,
})}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
evidence.status === "loading" && (
<Skeleton className="size-8 rounded-md" />
)
)}
</div>
</div>
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
{/* Under the title, like the finding panel's frameworks. */}
{evidence.status === "loading" ? (
<div aria-hidden="true" className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-28 rounded" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
</div>
) : (
loadedFindings &&
loadedFindings.length > 0 && (
<IssueCompliance
findings={loadedFindings}
scanId={activeScanId}
truncated={
evidence.status === "loaded" && evidence.findingsTruncated
}
/>
)
)}
{/* The finding panel's Provider / Service / Region row. */}
<div
data-responsive-container
className="@container mt-2 flex min-w-0 flex-col"
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<p className="text-text-neutral-tertiary text-xs break-all">
{attributes.findings_count.toLocaleString()}{" "}
{attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
{attributes.pattern}
</p>
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href={`/issues/${issue.id}`}>Open the full path</Link>
</Button>
</header>
<Tabs
defaultValue="exploitability"
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1fr)] @md:gap-x-8">
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Provider
</span>
<EntityInfo
cloudProvider={attributes.cloud}
nameIcon={<Box className="size-4" />}
entityAlias={attributes.account}
entityId={providerUid}
/>
</div>
<InfoField label="Findings" variant="compact" className="min-w-0">
<span className="block truncate whitespace-nowrap">
{attributes.findings_count.toLocaleString()}
</span>
</InfoField>
<InfoField label="Pattern" variant="compact" className="min-w-0">
<span
className="block truncate whitespace-nowrap"
title={attributes.pattern}
>
{attributes.pattern}
</span>
</InfoField>
<div className="mb-4 shrink-0">
<TabsList>
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="triage">Triage</TabsTrigger>
</TabsList>
</div>
</div>
</header>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as IssuePanelTab)}
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="mb-4 shrink-0">
<TabsList>
{ISSUE_PANEL_TABS.map(({ id, label }) => (
// Six tabs truncate on a phone; the tooltip names them.
<TabsTrigger key={id} value={id} tooltip={label}>
{label}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent value="exploitability" className="flex flex-col gap-4">
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent
value={ISSUE_PANEL_TAB.EXPLOITABILITY}
className="flex flex-col gap-4"
>
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also
need a credential is the Access leg.
</p>
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
{leg.evidence.length > 0 && (
<ul
aria-label={`${LEG_LABELS[name]} evidence`}
className="mt-1 flex flex-col gap-1"
>
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
{/* Mounted only while selected, so the graph's requests run when
someone asks for it. */}
<TabsContent value="attack-path">
<IssueAttackPath
id={issue.id}
issue={attributes}
scanId={scanId}
providerId={providerId}
/>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.ATTACK_PATH}>
<IssueAttackPath
issue={attributes}
evidence={evidence}
onRetry={retry}
onOpenFinding={openFinding}
/>
</TabsContent>
<TabsContent value="remediation" className="flex flex-col gap-2">
<p className="text-text-neutral-secondary text-sm">
{attributes.fix}
</p>
{scanId && (
<Button
asChild
variant="link"
size="link-sm"
className="self-start"
>
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": scanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.FINDINGS}>
{evidencePending ??
(evidence.status === "error" || loadedFindings === undefined ? (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
) : uniqueFindings.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
No findings to show for this issue.
</p>
) : (
<IssueFindings
findings={uniqueFindings}
total={attributes.findings_count}
onOpenFinding={openFinding}
/>
))}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.RESOURCES}>
{evidencePending ??
(evidence.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : (
evidence.status === "loaded" && (
<IssueResources
resources={evidence.resources}
providerId={evidence.providerId}
/>
)
))}
</TabsContent>
{/* Laid out like the finding panel's Remediation tab. */}
<TabsContent value={ISSUE_PANEL_TAB.REMEDIATION}>
<div className="flex flex-col gap-1 px-1">
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
<div className="text-text-neutral-primary text-sm">
<MarkdownContainer>{attributes.fix}</MarkdownContainer>
</div>
</div>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.TRIAGE}>
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
<TabsContent value="triage">
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
</DetailSidePanel>
);
};
-108
View File
@@ -1,108 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueResourcesProps {
resources: string[];
providerId?: string;
}
/** The resources the issue folds, a page at a time, as a table. */
export const IssueResources = ({
resources,
providerId,
}: IssueResourcesProps) => {
const [visible, setVisible] = useState(RESOURCE_PAGE_SIZE);
if (resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources.
</p>
);
}
return (
<section aria-label="Affected resources" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{resources.length.toLocaleString()} Total Entries
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Resource
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{resources.slice(0, visible).map((uid) => (
<TableRow key={uid}>
<TableCell className="max-w-0">
<CodeSnippet value={uid} ariaLabel={`Copy ${uid}`} />
</TableCell>
<TableCell className="text-right whitespace-nowrap">
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center gap-3">
{visible < resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible((value) => value + RESOURCE_PAGE_SIZE)}
>
Show more resources
</Button>
)}
{visible > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</div>
</section>
);
};
+26 -28
View File
@@ -23,13 +23,18 @@ vi.mock("@/actions/issues", () => ({
getIssueFindings: async () => [],
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById: vi.fn() }));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<p>Graph of {resourceUids.join(", ")}</p>
IssueGraph: ({
resourceUids,
readOnly,
}: {
resourceUids: string[];
readOnly?: boolean;
}) => (
<p>
Graph of {resourceUids.join(", ")}
{readOnly ? " (read-only)" : ""}
</p>
),
}));
// The real panel portals into the app shell's side panel, absent here.
@@ -130,54 +135,47 @@ describe("IssuesTable", () => {
it("opens the issue in the side panel and closes it", async () => {
// Given
const user = userEvent.setup();
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
render(<IssuesTable issues={[issue]} />);
// When
await user.click(screen.getByRole("button", { name: "Public bucket" }));
// Then: the assessment tab leads, and nothing sends the user elsewhere
// Then: the assessment tab leads, the full page stays one click away
const panel = screen.getByRole("region", { name: "Public bucket" });
expect(
within(panel).getByRole("tab", { name: "Exploitability" }),
).toHaveAttribute("aria-selected", "true");
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
expect(
within(panel).queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
within(panel).getByRole("link", { name: "Open the full path" }),
).toHaveAttribute("href", "/issues/issue-1");
// When
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
// Then
expect(within(panel).getByText("Restrict access")).toBeVisible();
// When: the scan's failed findings sit in the actions menu
await user.click(
within(panel).getByRole("button", { name: "Issue actions" }),
);
await user.click(
await screen.findByRole("menuitem", {
expect(
within(panel).getByRole("link", {
name: "Open this scan's failed findings",
}),
);
// Then
expect(push).toHaveBeenLastCalledWith(
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
// Then
// Then: the graph loads on demand and leaves finding drill-down to the page
expect(
await within(panel).findByText("Graph of bucket"),
await within(panel).findByText("Graph of bucket (read-only)"),
).toBeInTheDocument();
expect(getIssue).toHaveBeenCalledWith("issue-1");
-6
View File
@@ -169,12 +169,9 @@ const columns: ColumnDef<IssueRow>[] = [
export const IssuesTable = ({
issues,
metadata,
providerUids,
}: {
issues: IssueRow[];
metadata?: MetaDataProps;
/** Provider uid by provider id, shown in the panel's Provider field. */
providerUids?: Record<string, string>;
}) => {
const [openId, setOpenId] = useState<string | null>(null);
const openIssue = issues.find((issue) => issue.id === openId);
@@ -193,9 +190,6 @@ export const IssuesTable = ({
<IssuePanel
key={openIssue.id}
issue={openIssue}
providerUid={
providerUids?.[openIssue.relationships?.provider?.data?.id ?? ""]
}
open
onOpenChange={(open) => {
if (!open) setOpenId(null);
+115 -95
View File
@@ -4,10 +4,18 @@ import { page } from "vitest/browser";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import {
getFindingGroups,
getFindingGroupResources,
} from "@/actions/finding-groups";
import { getScan } from "@/actions/scans";
import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
import { IssueDetail } from "@/components/issues/issue-detail";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
@@ -145,57 +153,84 @@ describe("Issues responsive layout", () => {
await page.viewport(1280, 800);
});
it("opens a folded finding inside the panel and comes back to the issue", async () => {
// Given: the list next to the real side panel host the app shell mounts.
it("opens the selected finding's group and muted resource through the destination actions", async () => {
// Given: the destination returns the finding only for its supported filters.
setup();
worker.use(
http.get("*/findings/f1", () =>
http.get("*/scans/scan-1", () =>
HttpResponse.json({
data: {
type: "findings",
id: "f1",
id: "scan-1",
type: "scans",
attributes: {
uid: "finding-uid-1",
check_id: "s3_public",
status: "FAIL",
severity: "critical",
check_metadata: { checktitle: "S3 bucket is public" },
inserted_at: "2026-10-20T10:00:00Z",
completed_at: "2026-10-20T11:00:00Z",
},
relationships: { resources: { data: [] } },
},
}),
),
http.get("*/finding-groups", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[check_id__in]") === "s3_public" &&
params.get("filter[resource_uid]") === resource &&
params.get("filter[scan__in]") === "scan-1" &&
params.get("filter[inserted_at]") === "2026-10-20" &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches ? [{ id: "s3_public", type: "finding-groups" }] : [],
});
}),
http.get("*/finding-groups/s3_public/resources", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[resource_uid]") === resource &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches
? [{ id: "r1", attributes: { finding_id: "f1", muted: true } }]
: [],
});
}),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
<>
{content}
<GlobalSidePanel />
</>,
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>,
);
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByText("Production")
.click();
await screen.getByRole("tab", { name: "Findings" }).click();
const link = screen.getByRole("link", { name: "S3 bucket is public" });
await expect.element(link).toBeVisible();
// When
await screen.getByRole("button", { name: "S3 bucket is public" }).click();
// Then: the finding takes the panel without closing the issue
const back = screen.getByRole("button", { name: "Issue details" });
await expect.element(back).toBeVisible();
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.not.toBeInTheDocument();
// When
await back.click();
// When: resolve the same filters used by the Findings page and drill-down.
const destination = new URL(
link.element().getAttribute("href")!,
window.location.origin,
);
const { filters } = extractFiltersAndQuery(
Object.fromEntries(destination.searchParams),
);
const resolvedFilters = applyDefaultMutedFilter(
await resolveFindingScanDateFilters({
filters,
scans: [],
loadScan: async (id) => (await getScan(id))?.data,
}),
);
const groups = await getFindingGroups({ filters: resolvedFilters });
// Then
await expect
.element(screen.getByRole("tab", { name: "Findings" }))
.toHaveAttribute("aria-selected", "true");
expect(groups.data).toEqual([{ id: "s3_public", type: "finding-groups" }]);
expect(destination.searchParams.get("expandedCheckId")).toBe("s3_public");
const resources = await getFindingGroupResources({
checkId: "s3_public",
filters: resolvedFilters,
});
expect(resources.data).toEqual([
{ id: "r1", attributes: { finding_id: "f1", muted: true } },
]);
});
it("opens a clicked row in the app's Details side panel", async () => {
@@ -216,6 +251,9 @@ describe("Issues responsive layout", () => {
.click();
// Then
await expect
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-1");
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
@@ -251,15 +289,6 @@ describe("Issues responsive layout", () => {
http.get("*/issues", () =>
HttpResponse.json({ data: [issue, secondIssue] }),
),
// The panel loads the evidence of whichever issue it opens.
http.get("*/issues/issue-2", () =>
HttpResponse.json({
data: {
...secondIssue,
attributes: { ...secondIssue.attributes, resource_uids: [] },
},
}),
),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
@@ -285,13 +314,8 @@ describe("Issues responsive layout", () => {
// Then: the panel remounted on the new issue, back on its first tab
await expect
.element(
screen.getByRole("heading", {
level: 3,
name: "Admin role assumable from any account",
}),
)
.toBeVisible();
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-2");
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.toHaveAttribute("aria-selected", "true");
@@ -304,7 +328,7 @@ describe("Issues responsive layout", () => {
{ width: 375, theme: "dark" },
{ width: 1440, theme: "dark" },
])(
"keeps the list and the issue panel usable at $width px in $theme mode",
"keeps the list and the issue detail usable at $width px in $theme mode",
async ({ width, theme }) => {
await page.viewport(width, 1000);
localStorage.setItem("theme", theme);
@@ -313,13 +337,16 @@ describe("Issues responsive layout", () => {
searchParams: { "filter[pattern]": "aws_public_bucket" },
});
const screen = await render(
<>
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
</main>
<GlobalSidePanel />
</>,
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>
</main>,
);
await expect
.element(screen.getByRole("button", { name: "Remove Pattern filter" }))
@@ -329,46 +356,25 @@ describe("Issues responsive layout", () => {
screen.getByText("The internet → Data exposure · aws_public_bucket"),
)
.toBeVisible();
// When: the row opens in the panel
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByRole("button", { name: "Public bucket exposes customer data" })
.click();
// Then: every tab fits the viewport, long identifiers wrap
await expect
.element(screen.getByRole("region", { name: "Affected resources" }))
.toBeVisible();
await expect
.element(screen.getByText("Partly", { exact: true }))
.toBeVisible();
const fits = (element: Element) =>
element.getBoundingClientRect().right <= width;
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await screen.getByRole("tab", { name: "Findings" }).click();
await expect
.element(screen.getByRole("button", { name: "S3 bucket is public" }))
.toBeVisible();
await screen.getByRole("tab", { name: "Resources" }).click();
const resources = screen.getByRole("region", {
name: "Affected resources",
});
await expect.element(resources).toBeVisible();
expect(fits(resources.element())).toBe(true);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
await page.screenshot();
await screen.getByRole("tab", { name: "Triage" }).click();
await expect.element(screen.getByText("Public by design.")).toBeVisible();
await expect
.element(
screen.getByRole("img", { name: "Critical finding risk icon" }),
)
.toBeVisible();
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
const detailBounds = screen
.getByRole("region", { name: "Affected resources" })
.element()
.getBoundingClientRect();
expect(detailBounds.right).toBeLessThanOrEqual(width);
if (width < 640) {
await screen.getByRole("tab", { name: "Attack path" }).click();
await expect
.poll(() => {
const node = screen
@@ -380,6 +386,20 @@ describe("Issues responsive layout", () => {
})
.toBe(true);
}
// Long resource identifiers and evidence must wrap within the detail.
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await page.viewport(
width,
Math.max(1000, document.documentElement.scrollHeight),
);
await screen.getByRole("main").screenshot();
},
);
+1 -1
View File
@@ -4,9 +4,9 @@ import type { IssueFinding } from "@/types/issues";
export interface IssueFindingsProps {
/** One entry per finding, with a resource to open in the group drill-down. */
findings: IssueFinding[];
scanId?: string;
/** All the findings the issue folds; the list shows a bounded preview. */
total: number;
onOpenFinding: (findingId: string) => void;
}
export interface IssueTriageProps {
@@ -158,8 +158,9 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
expect(sections[1]?.items[0]).toMatchObject({
label: "Exploitability issues",
expect(
items.find((item) => item.label === "Exploitability issues"),
).toMatchObject({
href: "/issues",
});
expect(configuration.children).not.toEqual(
@@ -149,19 +149,6 @@ export function getNavigationConfig({
{
label: "SECURITY",
items: [
// First: the exploitable issues are what to fix before anything else.
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/compliance",
@@ -176,6 +163,18 @@ export function getNavigationConfig({
icon: Tag,
active: isRouteActive(pathname, "/findings"),
},
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/attack-paths",
@@ -156,19 +156,13 @@ export function BreadcrumbNavigation({
}
return (
<div className={cn(className, "w-fit max-w-full md:w-full")}>
<div className={cn(className, "w-fit md:w-full")}>
<nav aria-label="Breadcrumb">
{/* From sm up, one line: earlier crumbs keep their width and the
last one truncates. Below sm crumbs wrap, so a long trail never
pushes the header controls off a phone screen. */}
<ol className="flex min-w-0 flex-wrap items-center sm:flex-nowrap">
<ol className="flex flex-wrap items-center">
{breadcrumbItems.map((breadcrumb, index) => (
<li
key={breadcrumb.path || index}
className={cn(
"flex items-center",
index === breadcrumbItems.length - 1 ? "min-w-0" : "shrink-0",
)}
className="flex items-center"
aria-current={
index === breadcrumbItems.length - 1 ? "page" : undefined
}
@@ -204,16 +198,13 @@ export function BreadcrumbNavigation({
</span>
</button>
) : (
<div className="flex min-w-0 items-center gap-2">
<div className="flex items-center gap-2">
{index === 0 && breadcrumb.icon ? (
<BreadcrumbIcon className="text-text-neutral-tertiary">
{breadcrumb.icon}
</BreadcrumbIcon>
) : null}
<span
title={breadcrumb.name}
className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100"
>
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
{breadcrumb.name}
</span>
</div>
-74
View File
@@ -1,74 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getFindingById } = vi.hoisted(() => ({ getFindingById: vi.fn() }));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
import { useFindingDetails } from "./use-finding-details";
const deferred = () => {
let resolve!: (value: unknown) => void;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
describe("useFindingDetails", () => {
beforeEach(() => {
getFindingById.mockReset();
});
it("keeps a slow earlier lookup from overwriting a newer one", async () => {
// Given: lookup A is still pending when lookup B starts
const first = deferred();
const second = deferred();
getFindingById
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const { result } = renderHook(() => useFindingDetails());
act(() => {
void result.current.navigateToFinding("a");
});
act(() => {
void result.current.navigateToFinding("b");
});
// When: A answers after B started
await act(async () => first.resolve({ data: { id: "a" } }));
// Then: B is still loading and A is dropped
expect(result.current.findingDetails).toBeNull();
expect(result.current.findingDetailLoading).toBe(true);
// When
await act(async () => second.resolve({ data: { id: "b" } }));
// Then
expect(result.current.findingDetails).toEqual({ id: "b" });
expect(result.current.findingDetailLoading).toBe(false);
});
it("drops a lookup that answers after a reset", async () => {
// Given
const pending = deferred();
getFindingById.mockReturnValueOnce(pending.promise);
const { result } = renderHook(() => useFindingDetails());
act(() => {
void result.current.navigateToFinding("a");
});
// When: the finding closes, then its lookup answers
act(() => result.current.resetFindingDetails());
await act(async () => pending.resolve({ data: { id: "a" } }));
// Then
expect(result.current.findingDetails).toBeNull();
expect(result.current.findingDetailLoading).toBe(false);
});
});
+3 -7
View File
@@ -30,10 +30,7 @@ export function useFindingDetails(): UseFindingDetailsReturn {
if (findingFetchRef.current) {
findingFetchRef.current.abort();
}
// Each lookup checks its own controller: the ref already points at a
// newer lookup by the time a slow one answers.
const controller = new AbortController();
findingFetchRef.current = controller;
findingFetchRef.current = new AbortController();
setFindingDetailLoading(true);
try {
@@ -42,7 +39,7 @@ export function useFindingDetails(): UseFindingDetailsReturn {
"resources,scan.provider",
);
if (controller.signal.aborted) {
if (findingFetchRef.current?.signal.aborted) {
return;
}
@@ -55,14 +52,13 @@ export function useFindingDetails(): UseFindingDetailsReturn {
}
console.error("Error fetching finding:", error);
} finally {
if (!controller.signal.aborted) {
if (!findingFetchRef.current?.signal.aborted) {
setFindingDetailLoading(false);
}
}
};
const resetFindingDetails = () => {
findingFetchRef.current?.abort();
setFindingDetails(null);
setFindingDetailLoading(false);
};
@@ -34,18 +34,12 @@ export const useIssueEvidence = (
useEffect(() => {
let cancelled = false;
// A failed background refresh keeps what is already shown; only a first
// load or a retry (which resets to loading) surfaces the error.
const fail = () =>
setState((previous) =>
previous.status === "loaded" ? previous : { status: "error" },
);
getIssue(id)
.then(async (detail) => {
if (cancelled) return;
const attributes = detail?.data?.attributes;
if (!attributes?.resource_uids) {
fail();
setState({ status: "error" });
return;
}
const detailScanId =
@@ -58,30 +52,18 @@ export const useIssueEvidence = (
: findingUids.length === 0
? []
: undefined;
if (cancelled) return;
setState((previous) => {
// A refresh that loses the findings keeps the ones already shown,
// as long as they belong to the same scan; resources and provider
// always take the fresh answer.
const keepFindings =
findings === undefined &&
previous.status === "loaded" &&
previous.findings !== undefined &&
previous.scanId === detailScanId;
return {
if (!cancelled)
setState({
status: "loaded",
resources: attributes.resource_uids,
findings: keepFindings ? previous.findings : findings,
findingsTruncated: keepFindings
? previous.findingsTruncated
: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
findings,
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
scanId: detailScanId,
providerId: detailProviderId,
};
});
});
})
.catch(() => {
if (!cancelled) fail();
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
@@ -93,8 +75,5 @@ export const useIssueEvidence = (
setAttempt((value) => value + 1);
};
/** Reloads in the background: what is shown stays until the answer lands. */
const refresh = () => setAttempt((value) => value + 1);
return { state, retry, refresh };
return { state, retry };
};
Generated
+1 -1
View File
@@ -3801,7 +3801,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.48.0"
version = "5.47.1"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },