mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 09:24:27 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a1c1d2826 | ||
|
|
67aa941012 | ||
|
|
f64e5e10ad | ||
|
|
008864797b | ||
|
|
6999bbac9b | ||
|
|
2cc5c4b2f9 |
No files matched your search
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.47.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
|
||||
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
|
||||
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Python base image from 3.12.15 to 3.12.16 in the API container image, patching CVE-2026-115557
|
||||
+2
-2
@@ -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@v5.47",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"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.48.0"
|
||||
version = "1.49.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.48.0
|
||||
version: 1.49.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
Generated
+4
-31
@@ -1229,20 +1229,6 @@ 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"
|
||||
@@ -3517,17 +3503,6 @@ 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"
|
||||
@@ -4867,8 +4842,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.47.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.47#1a62d7d99aea56db8e351f25f3ff385db7549fc6" }
|
||||
version = "5.41.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4889,7 +4864,6 @@ 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" },
|
||||
@@ -4933,7 +4907,6 @@ dependencies = [
|
||||
{ name = "huaweicloudsdkkms" },
|
||||
{ name = "huaweicloudsdkobs" },
|
||||
{ name = "huaweicloudsdkrds" },
|
||||
{ name = "huaweicloudsdksmn" },
|
||||
{ name = "huaweicloudsdkvpc" },
|
||||
{ name = "huaweicloudsdkwaf" },
|
||||
{ name = "jsonschema" },
|
||||
@@ -4972,7 +4945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.48.0"
|
||||
version = "1.49.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5072,7 +5045,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=v5.47" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
|
||||
@@ -4,6 +4,62 @@ 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.46.0"
|
||||
PROWLER_API_VERSION="5.46.0"
|
||||
PROWLER_UI_VERSION="5.47.0"
|
||||
PROWLER_API_VERSION="5.47.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# =============================================================================
|
||||
# Final stage - Minimal runtime environment
|
||||
# =============================================================================
|
||||
FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b
|
||||
FROM python:3.15.1-alpine3.23@sha256:cb295b554dbbea09b2c0e6e6d9ce55f051347c902fbb611e618c071addfb30a3
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
Python base image from 3.12.15 to 3.12.16 in the SDK container image, patching CVE-2026-115557
|
||||
@@ -52,7 +52,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.47.0"
|
||||
prowler_version = "5.48.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -38,7 +38,9 @@ class MongoDBAtlasBaseException(ProwlerException):
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
provider = "MongoDB Atlas"
|
||||
error_info = self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
error_info = dict(
|
||||
self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
)
|
||||
if message:
|
||||
error_info["message"] = message
|
||||
super().__init__(
|
||||
|
||||
@@ -236,7 +236,12 @@ class MongodbatlasProvider(Provider):
|
||||
message="MongoDB Atlas authentication failed. Please check your API credentials.",
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
if not response.ok:
|
||||
raise MongoDBAtlasIdentityError(
|
||||
file=os.path.basename(__file__),
|
||||
message=MongodbatlasProvider._describe_api_error(response),
|
||||
)
|
||||
|
||||
organizations_data = response.json()
|
||||
|
||||
# Extract organization information from the response
|
||||
@@ -266,7 +271,7 @@ class MongodbatlasProvider(Provider):
|
||||
|
||||
return identity
|
||||
|
||||
except MongoDBAtlasAuthenticationError:
|
||||
except (MongoDBAtlasAuthenticationError, MongoDBAtlasIdentityError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
@@ -276,6 +281,22 @@ 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
@@ -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.47.0"
|
||||
version = "5.48.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -136,6 +136,60 @@ 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 (
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
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,10 +26,13 @@ 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) => {
|
||||
@@ -87,7 +90,11 @@ export const IssuesList = async ({
|
||||
{rows === null ? (
|
||||
<IssuesLoadError />
|
||||
) : rows.length ? (
|
||||
<IssuesTable issues={rows} metadata={issues?.meta} />
|
||||
<IssuesTable
|
||||
issues={rows}
|
||||
metadata={issues?.meta}
|
||||
providerUids={providerUids}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
No issues match this filter.
|
||||
|
||||
@@ -27,6 +27,12 @@ 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
|
||||
@@ -41,13 +47,12 @@ 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={providersData?.data ?? []}
|
||||
/>
|
||||
<ProviderAccountSelectors providers={providers} />
|
||||
<ProviderGroupSelector
|
||||
groups={providerGroupsData?.data ?? []}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
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>,
|
||||
}));
|
||||
@@ -18,35 +14,53 @@ 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", 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} />);
|
||||
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()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("This issue has no affected resources to draw."),
|
||||
screen.getByText("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
|
||||
getIssue.mockResolvedValue({
|
||||
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
|
||||
});
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<IssueAttackPath
|
||||
issue={issue}
|
||||
evidence={{
|
||||
status: "loaded",
|
||||
resources: ["bucket"],
|
||||
findings: undefined,
|
||||
findingsTruncated: false,
|
||||
}}
|
||||
onRetry={onRetry}
|
||||
onOpenFinding={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
render(<IssueAttackPath id="issue-1" issue={issue} />);
|
||||
expect(
|
||||
screen.getByText("Could not load the attack path for this issue."),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Could not load the attack path for this issue."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,26 +2,25 @@
|
||||
|
||||
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;
|
||||
scanId?: string;
|
||||
providerId?: string;
|
||||
evidence: IssueEvidenceState;
|
||||
onRetry: () => void;
|
||||
onOpenFinding: (findingId: string) => void;
|
||||
}
|
||||
|
||||
/** The issue's graph for the side panel; findings open on the full page. */
|
||||
/** The issue's graph, or why there is none to draw yet. */
|
||||
export const IssueAttackPath = ({
|
||||
id,
|
||||
issue,
|
||||
scanId,
|
||||
providerId,
|
||||
evidence,
|
||||
onRetry,
|
||||
onOpenFinding,
|
||||
}: IssueAttackPathProps) => {
|
||||
const { state, retry } = useIssueEvidence(id, scanId, providerId);
|
||||
|
||||
if (state.status === "loading") {
|
||||
if (evidence.status === "loading") {
|
||||
return (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading the attack path…
|
||||
@@ -30,29 +29,29 @@ export const IssueAttackPath = ({
|
||||
}
|
||||
// Nothing to draw beats a findings error: without resources there is no
|
||||
// graph for the findings to land on.
|
||||
if (state.status === "loaded" && state.resources.length === 0) {
|
||||
if (evidence.status === "loaded" && evidence.resources.length === 0) {
|
||||
return (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
This issue has no affected resources to draw.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "error" || state.findings === undefined) {
|
||||
if (evidence.status === "error" || evidence.findings === undefined) {
|
||||
return (
|
||||
<IssuesLoadError
|
||||
message="Could not load the attack path for this issue."
|
||||
onRetry={retry}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IssueGraph
|
||||
issue={issue}
|
||||
resourceUids={state.resources}
|
||||
findings={state.findings}
|
||||
readOnly
|
||||
{...(state.providerId && state.scanId
|
||||
? { providerId: state.providerId, scanId: state.scanId }
|
||||
resourceUids={evidence.resources}
|
||||
findings={evidence.findings}
|
||||
onOpenFinding={onOpenFinding}
|
||||
{...(evidence.providerId && evidence.scanId
|
||||
? { providerId: evidence.providerId, scanId: evidence.scanId }
|
||||
: { providerId: undefined, scanId: undefined })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
@@ -115,13 +116,21 @@ export const IssueCompliance = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Laid out like the finding panel's "Compliance Frameworks" header row.
|
||||
return (
|
||||
<section aria-label="Compliance affected">
|
||||
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
|
||||
<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>
|
||||
{state.status === "loading" ? (
|
||||
<p role="status" className="text-text-neutral-tertiary text-sm">
|
||||
Loading frameworks…
|
||||
</p>
|
||||
<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>
|
||||
) : state.status === "unavailable" ? (
|
||||
<IssuesLoadError
|
||||
message="Could not load the frameworks for this issue."
|
||||
@@ -130,7 +139,7 @@ export const IssueCompliance = ({
|
||||
) : (
|
||||
<>
|
||||
{state.frameworks.length === 0 ? (
|
||||
<p className="text-text-neutral-tertiary text-sm">
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{incomplete
|
||||
? "None of the findings checked map to a framework requirement."
|
||||
: "None of the findings behind this issue map to a framework requirement."}
|
||||
@@ -148,7 +157,7 @@ export const IssueCompliance = ({
|
||||
</div>
|
||||
)}
|
||||
{incomplete && (
|
||||
<p className="text-text-neutral-tertiary mt-2 text-xs">
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Not every finding could be checked, so this list may be
|
||||
incomplete.
|
||||
</p>
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { 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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
"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'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>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +1,91 @@
|
||||
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, 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.
|
||||
* 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.
|
||||
*/
|
||||
export const IssueFindings = ({
|
||||
findings,
|
||||
scanId,
|
||||
total,
|
||||
onOpenFinding,
|
||||
}: IssueFindingsProps) => {
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
};
|
||||
@@ -6,22 +6,8 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import { GraphNode } from "@/types/attack-paths";
|
||||
import { IssueAttributes } from "@/types/issues";
|
||||
|
||||
const { getFindings, navigateToFinding } = vi.hoisted(() => ({
|
||||
getFindings: vi.fn(),
|
||||
navigateToFinding: vi.fn(),
|
||||
}));
|
||||
const { getFindings } = vi.hoisted(() => ({ getFindings: 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,
|
||||
@@ -52,7 +38,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">;
|
||||
type BaseProps = Pick<Props, "issue" | "resourceUids" | "onOpenFinding">;
|
||||
|
||||
expectTypeOf<BaseProps>().toExtend<Props>();
|
||||
expectTypeOf<
|
||||
@@ -63,31 +49,10 @@ it("requires provider and scan identifiers together in the graph props", () => {
|
||||
});
|
||||
|
||||
describe("IssueGraph finding lookup", () => {
|
||||
const openFinding = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
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();
|
||||
openFinding.mockReset();
|
||||
});
|
||||
|
||||
it("opens a folded finding by id without another lookup", async () => {
|
||||
@@ -109,6 +74,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -116,7 +82,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
|
||||
// Then
|
||||
expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
|
||||
expect(openFinding).toHaveBeenCalledWith("folded-finding");
|
||||
expect(getFindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -136,6 +102,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
@@ -149,7 +116,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
"filter[status]": "FAIL",
|
||||
},
|
||||
});
|
||||
expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
|
||||
expect(openFinding).toHaveBeenCalledWith("finding-1");
|
||||
});
|
||||
|
||||
it("rejects a finding from a newer scan", async () => {
|
||||
@@ -167,10 +134,11 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
expect(navigateToFinding).not.toHaveBeenCalled();
|
||||
expect(openFinding).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"no longer available for the issue's scan",
|
||||
);
|
||||
@@ -186,6 +154,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
@@ -209,6 +178,7 @@ describe("IssueGraph finding lookup", () => {
|
||||
resourceUids={["bucket"]}
|
||||
providerId="provider-1"
|
||||
scanId="scan-1"
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
@@ -224,7 +194,13 @@ describe("IssueGraph finding lookup", () => {
|
||||
});
|
||||
|
||||
it("does not look up findings without a provider and scan", async () => {
|
||||
render(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
|
||||
render(
|
||||
<IssueGraph
|
||||
issue={issue}
|
||||
resourceUids={["bucket"]}
|
||||
onOpenFinding={openFinding}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
|
||||
expect(getFindings).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
|
||||
@@ -4,14 +4,12 @@ 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";
|
||||
@@ -21,11 +19,8 @@ interface IssueGraphBaseProps {
|
||||
issue: IssueAttributes;
|
||||
resourceUids: string[];
|
||||
findings?: IssueFinding[];
|
||||
/**
|
||||
* 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;
|
||||
/** Called with the clicked finding's id; the host shows its detail. */
|
||||
onOpenFinding: (findingId: string) => void;
|
||||
}
|
||||
|
||||
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
|
||||
@@ -51,9 +46,8 @@ export const IssueGraph = ({
|
||||
findings = [],
|
||||
providerId,
|
||||
scanId,
|
||||
readOnly = false,
|
||||
onOpenFinding,
|
||||
}: IssueGraphProps) => {
|
||||
const finding = useFindingDetails();
|
||||
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
|
||||
const view = buildIssueGraph(issue, resourceUids, findings);
|
||||
const shownFindings = new Set(
|
||||
@@ -64,7 +58,6 @@ 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
|
||||
@@ -82,8 +75,6 @@ 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(
|
||||
@@ -120,8 +111,7 @@ export const IssueGraph = ({
|
||||
}
|
||||
findingId = match.id;
|
||||
}
|
||||
await finding.navigateToFinding(String(findingId));
|
||||
if (mounted.current) setLookupComplete(true);
|
||||
onOpenFinding(String(findingId));
|
||||
} catch {
|
||||
if (mounted.current)
|
||||
setError("Could not load this finding. Select the finding to retry.");
|
||||
@@ -130,9 +120,6 @@ export const IssueGraph = ({
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
const detailFailed =
|
||||
lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Finding evidence"
|
||||
@@ -144,20 +131,16 @@ 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.
|
||||
{readOnly && " Open the full path to inspect a finding."}
|
||||
attack paths. Select a finding to open it.
|
||||
</p>
|
||||
{loading && (
|
||||
<p role="status" className="text-sm">
|
||||
Loading finding details…
|
||||
</p>
|
||||
)}
|
||||
{(error || detailFailed) && (
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription>
|
||||
{error ??
|
||||
"Could not load this finding. Select the finding to retry."}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card
|
||||
@@ -169,23 +152,10 @@ export const IssueGraph = ({
|
||||
data={{ nodes: view.nodes, edges: view.edges }}
|
||||
view={view}
|
||||
expandedResources={expandedResources}
|
||||
onNodeClick={readOnly ? undefined : handleNodeClick}
|
||||
onNodeClick={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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,533 @@
|
||||
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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Box, Tag } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import {
|
||||
BreadcrumbNavigation,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { CodeSnippet } from "@/components/shadcn/code-snippet";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
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,
|
||||
@@ -18,6 +40,9 @@ 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,
|
||||
@@ -25,149 +50,399 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
/** 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();
|
||||
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 (
|
||||
<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 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">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant={verdict?.variant ?? "tag"}>
|
||||
{verdict?.label ?? attributes.verdict}
|
||||
</Badge>
|
||||
<SeverityBadge severity={attributes.severity} />
|
||||
</div>
|
||||
<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"
|
||||
{/* 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"
|
||||
>
|
||||
<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 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"
|
||||
<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}
|
||||
>
|
||||
{(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>
|
||||
|
||||
{/* 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="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's failed findings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="triage">
|
||||
<IssueTriage
|
||||
key={`${issue.id}-${attributes.updated_at}`}
|
||||
id={issue.id}
|
||||
status={attributes.triage_status}
|
||||
/>
|
||||
</TabsContent>
|
||||
{attributes.pattern}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DetailSidePanel>
|
||||
</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={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>
|
||||
|
||||
<TabsContent value={ISSUE_PANEL_TAB.ATTACK_PATH}>
|
||||
<IssueAttackPath
|
||||
issue={attributes}
|
||||
evidence={evidence}
|
||||
onRetry={retry}
|
||||
onOpenFinding={openFinding}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -23,18 +23,13 @@ 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,
|
||||
readOnly,
|
||||
}: {
|
||||
resourceUids: string[];
|
||||
readOnly?: boolean;
|
||||
}) => (
|
||||
<p>
|
||||
Graph of {resourceUids.join(", ")}
|
||||
{readOnly ? " (read-only)" : ""}
|
||||
</p>
|
||||
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
|
||||
<p>Graph of {resourceUids.join(", ")}</p>
|
||||
),
|
||||
}));
|
||||
// The real panel portals into the app shell's side panel, absent here.
|
||||
@@ -135,47 +130,54 @@ describe("IssuesTable", () => {
|
||||
it("opens the issue in the side panel and closes it", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<IssuesTable issues={[issue]} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Public bucket" }));
|
||||
|
||||
// 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).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();
|
||||
expect(
|
||||
within(panel).getByRole("link", {
|
||||
name: "Open this scan's failed findings",
|
||||
}),
|
||||
).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"] },
|
||||
},
|
||||
});
|
||||
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
|
||||
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();
|
||||
|
||||
// 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", {
|
||||
name: "Open this scan's failed findings",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(push).toHaveBeenLastCalledWith(
|
||||
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
|
||||
|
||||
// Then: the graph loads on demand and leaves finding drill-down to the page
|
||||
// Then
|
||||
expect(
|
||||
await within(panel).findByText("Graph of bucket (read-only)"),
|
||||
await within(panel).findByText("Graph of bucket"),
|
||||
).toBeInTheDocument();
|
||||
expect(getIssue).toHaveBeenCalledWith("issue-1");
|
||||
|
||||
|
||||
@@ -169,9 +169,12 @@ 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);
|
||||
@@ -190,6 +193,9 @@ export const IssuesTable = ({
|
||||
<IssuePanel
|
||||
key={openIssue.id}
|
||||
issue={openIssue}
|
||||
providerUid={
|
||||
providerUids?.[openIssue.relationships?.provider?.data?.id ?? ""]
|
||||
}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenId(null);
|
||||
|
||||
@@ -4,18 +4,10 @@ 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)}`;
|
||||
@@ -153,84 +145,57 @@ describe("Issues responsive layout", () => {
|
||||
await page.viewport(1280, 800);
|
||||
});
|
||||
|
||||
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.
|
||||
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.
|
||||
setup();
|
||||
worker.use(
|
||||
http.get("*/scans/scan-1", () =>
|
||||
http.get("*/findings/f1", () =>
|
||||
HttpResponse.json({
|
||||
data: {
|
||||
id: "scan-1",
|
||||
type: "scans",
|
||||
type: "findings",
|
||||
id: "f1",
|
||||
attributes: {
|
||||
inserted_at: "2026-10-20T10:00:00Z",
|
||||
completed_at: "2026-10-20T11:00:00Z",
|
||||
uid: "finding-uid-1",
|
||||
check_id: "s3_public",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: { checktitle: "S3 bucket is public" },
|
||||
},
|
||||
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(
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={issue.attributes}
|
||||
scanId="scan-1"
|
||||
providerId="provider-1"
|
||||
/>,
|
||||
<>
|
||||
{content}
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "S3 bucket is public" });
|
||||
await expect.element(link).toBeVisible();
|
||||
await screen
|
||||
.getByRole("row", { name: /Public bucket exposes customer data/ })
|
||||
.getByText("Production")
|
||||
.click();
|
||||
await screen.getByRole("tab", { name: "Findings" }).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 });
|
||||
// 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();
|
||||
|
||||
// Then
|
||||
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 } },
|
||||
]);
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Findings" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("opens a clicked row in the app's Details side panel", async () => {
|
||||
@@ -251,9 +216,6 @@ 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();
|
||||
@@ -289,6 +251,15 @@ 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(
|
||||
@@ -314,8 +285,13 @@ describe("Issues responsive layout", () => {
|
||||
|
||||
// Then: the panel remounted on the new issue, back on its first tab
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "Open the full path" }))
|
||||
.toHaveAttribute("href", "/issues/issue-2");
|
||||
.element(
|
||||
screen.getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Admin role assumable from any account",
|
||||
}),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("tab", { name: "Exploitability" }))
|
||||
.toHaveAttribute("aria-selected", "true");
|
||||
@@ -328,7 +304,7 @@ describe("Issues responsive layout", () => {
|
||||
{ width: 375, theme: "dark" },
|
||||
{ width: 1440, theme: "dark" },
|
||||
])(
|
||||
"keeps the list and the issue detail usable at $width px in $theme mode",
|
||||
"keeps the list and the issue panel usable at $width px in $theme mode",
|
||||
async ({ width, theme }) => {
|
||||
await page.viewport(width, 1000);
|
||||
localStorage.setItem("theme", theme);
|
||||
@@ -337,16 +313,13 @@ 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}
|
||||
<IssueDetail
|
||||
id={issue.id}
|
||||
issue={issue.attributes}
|
||||
scanId="scan-1"
|
||||
providerId="provider-1"
|
||||
/>
|
||||
</main>,
|
||||
<>
|
||||
<main className="flex min-w-0 flex-col gap-6 p-4">
|
||||
<h1 className="text-2xl font-semibold">Issues</h1>
|
||||
{content}
|
||||
</main>
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Remove Pattern filter" }))
|
||||
@@ -356,25 +329,46 @@ describe("Issues responsive layout", () => {
|
||||
screen.getByText("The internet → Data exposure · aws_public_bucket"),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("region", { name: "Affected resources" }))
|
||||
.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.getByText("Partly", { exact: true }))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText("Public by design.")).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("img", { name: "Critical finding risk icon" }),
|
||||
)
|
||||
.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);
|
||||
const detailBounds = screen
|
||||
.getByRole("region", { name: "Affected resources" })
|
||||
.element()
|
||||
.getBoundingClientRect();
|
||||
expect(detailBounds.right).toBeLessThanOrEqual(width);
|
||||
await page.screenshot();
|
||||
|
||||
await screen.getByRole("tab", { name: "Triage" }).click();
|
||||
await expect.element(screen.getByText("Public by design.")).toBeVisible();
|
||||
|
||||
if (width < 640) {
|
||||
await screen.getByRole("tab", { name: "Attack path" }).click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
const node = screen
|
||||
@@ -386,20 +380,6 @@ 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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,12 +34,18 @@ 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) {
|
||||
setState({ status: "error" });
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
const detailScanId =
|
||||
@@ -52,18 +58,30 @@ export const useIssueEvidence = (
|
||||
: findingUids.length === 0
|
||||
? []
|
||||
: undefined;
|
||||
if (!cancelled)
|
||||
setState({
|
||||
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 {
|
||||
status: "loaded",
|
||||
resources: attributes.resource_uids,
|
||||
findings,
|
||||
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
|
||||
findings: keepFindings ? previous.findings : findings,
|
||||
findingsTruncated: keepFindings
|
||||
? previous.findingsTruncated
|
||||
: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
|
||||
scanId: detailScanId,
|
||||
providerId: detailProviderId,
|
||||
});
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: "error" });
|
||||
if (!cancelled) fail();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -75,5 +93,8 @@ export const useIssueEvidence = (
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
|
||||
return { state, retry };
|
||||
/** Reloads in the background: what is shown stays until the answer lands. */
|
||||
const refresh = () => setAttempt((value) => value + 1);
|
||||
|
||||
return { state, retry, refresh };
|
||||
};
|
||||
@@ -158,9 +158,8 @@ describe("getNavigationConfig", () => {
|
||||
|
||||
// Then
|
||||
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
|
||||
expect(
|
||||
items.find((item) => item.label === "Exploitability issues"),
|
||||
).toMatchObject({
|
||||
expect(sections[1]?.items[0]).toMatchObject({
|
||||
label: "Exploitability issues",
|
||||
href: "/issues",
|
||||
});
|
||||
expect(configuration.children).not.toEqual(
|
||||
|
||||
@@ -149,6 +149,19 @@ 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",
|
||||
@@ -163,18 +176,6 @@ 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,13 +156,19 @@ export function BreadcrumbNavigation({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className, "w-fit md:w-full")}>
|
||||
<div className={cn(className, "w-fit max-w-full md:w-full")}>
|
||||
<nav aria-label="Breadcrumb">
|
||||
<ol className="flex flex-wrap items-center">
|
||||
{/* 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">
|
||||
{breadcrumbItems.map((breadcrumb, index) => (
|
||||
<li
|
||||
key={breadcrumb.path || index}
|
||||
className="flex items-center"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
index === breadcrumbItems.length - 1 ? "min-w-0" : "shrink-0",
|
||||
)}
|
||||
aria-current={
|
||||
index === breadcrumbItems.length - 1 ? "page" : undefined
|
||||
}
|
||||
@@ -198,13 +204,16 @@ export function BreadcrumbNavigation({
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{index === 0 && breadcrumb.icon ? (
|
||||
<BreadcrumbIcon className="text-text-neutral-tertiary">
|
||||
{breadcrumb.icon}
|
||||
</BreadcrumbIcon>
|
||||
) : null}
|
||||
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
|
||||
<span
|
||||
title={breadcrumb.name}
|
||||
className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100"
|
||||
>
|
||||
{breadcrumb.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,10 @@ export function useFindingDetails(): UseFindingDetailsReturn {
|
||||
if (findingFetchRef.current) {
|
||||
findingFetchRef.current.abort();
|
||||
}
|
||||
findingFetchRef.current = new AbortController();
|
||||
// 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;
|
||||
setFindingDetailLoading(true);
|
||||
|
||||
try {
|
||||
@@ -39,7 +42,7 @@ export function useFindingDetails(): UseFindingDetailsReturn {
|
||||
"resources,scan.provider",
|
||||
);
|
||||
|
||||
if (findingFetchRef.current?.signal.aborted) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,13 +55,14 @@ export function useFindingDetails(): UseFindingDetailsReturn {
|
||||
}
|
||||
console.error("Error fetching finding:", error);
|
||||
} finally {
|
||||
if (!findingFetchRef.current?.signal.aborted) {
|
||||
if (!controller.signal.aborted) {
|
||||
setFindingDetailLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetFindingDetails = () => {
|
||||
findingFetchRef.current?.abort();
|
||||
setFindingDetails(null);
|
||||
setFindingDetailLoading(false);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.10, <3.14"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.13'",
|
||||
@@ -3801,7 +3801,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.47.0"
|
||||
version = "5.48.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
|
||||
Reference in new issue
Block a user