Compare commits
No files matched your search
@@ -151,6 +151,14 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
|
||||
|
||||
| Component | Location | Tech Stack |
|
||||
|-----------|----------|------------|
|
||||
| SDK | `prowler/` | Python 3.10+, uv |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
| Dashboard | `dashboard/` | Dash, Plotly |
|
||||
|
||||
---
|
||||
|
||||
## Python Development
|
||||
|
||||
@@ -97,6 +97,29 @@ Needs retry → Celery task
|
||||
|
||||
---
|
||||
|
||||
## TECH STACK
|
||||
|
||||
Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | PostgreSQL 16 | pytest 8.x
|
||||
|
||||
---
|
||||
|
||||
## PROJECT STRUCTURE
|
||||
|
||||
```text
|
||||
api/src/backend/
|
||||
├── api/ # Main Django app
|
||||
│ ├── v1/ # API version 1 (views, serializers, urls)
|
||||
│ ├── models.py # Django models
|
||||
│ ├── filters.py # FilterSet classes
|
||||
│ ├── base_views.py # Base ViewSet classes
|
||||
│ ├── rls.py # Row-Level Security
|
||||
│ └── tests/ # Unit tests
|
||||
├── config/ # Django configuration
|
||||
└── tasks/ # Celery tasks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pyasn1 to 0.6.5, patching the ASN.1 decoding denial of service advisories
|
||||
@@ -367,7 +367,7 @@ constraint-dependencies = [
|
||||
"py-deviceid==0.1.1",
|
||||
"py-iam-expand==0.3.0",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pyasn1==0.6.5",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.14.0",
|
||||
"pycparser==3.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
revision = 3
|
||||
requires-python = ">=3.11, <3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12' and sys_platform == 'win32'",
|
||||
@@ -283,7 +283,7 @@ constraints = [
|
||||
{ name = "py-deviceid", specifier = "==0.1.1" },
|
||||
{ name = "py-iam-expand", specifier = "==0.3.0" },
|
||||
{ name = "py-ocsf-models", specifier = "==0.10.0" },
|
||||
{ name = "pyasn1", specifier = "==0.6.5" },
|
||||
{ name = "pyasn1", specifier = "==0.6.4" },
|
||||
{ name = "pyasn1-modules", specifier = "==0.4.2" },
|
||||
{ name = "pycodestyle", specifier = "==2.14.0" },
|
||||
{ name = "pycparser", specifier = "==3.0" },
|
||||
@@ -4204,7 +4204,7 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
broker = [
|
||||
{ name = "pymsalruntime" },
|
||||
{ name = "pymsalruntime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5174,11 +5174,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.5"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
# Importing Packages
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import click
|
||||
import dash
|
||||
import dash_bootstrap_components as dbc
|
||||
from colorama import Fore, Style
|
||||
from colorama import init as colorama_init
|
||||
from dash import dcc, html
|
||||
from dash.dependencies import Input, Output
|
||||
|
||||
from dashboard.config import folder_path_overview
|
||||
from prowler.config.config import orange_color
|
||||
from prowler.lib.banner import print_banner
|
||||
from dashboard.lib.startup import (
|
||||
install_server_ready_hook,
|
||||
silence_dash_running_line,
|
||||
)
|
||||
from prowler.lib.banner import (
|
||||
print_dashboard_banner,
|
||||
print_dashboard_loading,
|
||||
print_dashboard_promotion,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
# Honour the NO_COLOR convention: the dashboard parser has no --no-color flag.
|
||||
if os.environ.get("NO_COLOR"):
|
||||
colorama_init(strip=True)
|
||||
|
||||
# Compact startup output: identity, one hand-off block and the CSV directory.
|
||||
# The local URL is printed by ``install_server_ready_hook`` once the server is
|
||||
# actually listening, so nothing claims to be ready before it is. S3 download
|
||||
# instructions live in the dashboard documentation instead of every startup.
|
||||
cli = sys.modules["flask.cli"]
|
||||
print_banner()
|
||||
print(
|
||||
f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}"
|
||||
)
|
||||
cli.show_server_banner = lambda *_: click.echo(
|
||||
f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3://<your-bucket>/output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files."
|
||||
)
|
||||
print_dashboard_banner()
|
||||
print_dashboard_promotion()
|
||||
print_dashboard_loading(folder_path_overview)
|
||||
cli.show_server_banner = lambda *_: None
|
||||
install_server_ready_hook()
|
||||
|
||||
# Initialize the app - incorporate css
|
||||
dashboard = dash.Dash(
|
||||
@@ -30,8 +43,10 @@ dashboard = dash.Dash(
|
||||
external_stylesheets=[dbc.themes.FLATLY],
|
||||
use_pages=True,
|
||||
suppress_callback_exceptions=True,
|
||||
title="Prowler Dashboard",
|
||||
title="Prowler Local Dashboard",
|
||||
)
|
||||
# The ready block already announces the URL; drop Dash's pre-bind duplicate.
|
||||
silence_dash_running_line(dashboard)
|
||||
|
||||
# ``use_pages`` above already imported dashboard/pages/cloud.py and registered
|
||||
# every /cloud/* route. Import its metadata now (after app instantiation) so
|
||||
@@ -70,7 +85,10 @@ DASHBOARD_ITEMS = [
|
||||
# Gated navigation groups reference the shared feature metadata by slug so the
|
||||
# sidebar and the informational pages never drift apart.
|
||||
GATED_GROUPS = [
|
||||
("Upgrade to Prowler Cloud", ["lighthouse-ai", "attack-paths", "findings"]),
|
||||
(
|
||||
"Upgrade to Prowler Cloud",
|
||||
["findings", "exploitable-issues", "lighthouse-ai", "mcp", "attack-paths"],
|
||||
),
|
||||
("Configuration", ["alerts", "mutelist", "integrations"]),
|
||||
("Workspace", ["organization"]),
|
||||
]
|
||||
|
||||
@@ -300,6 +300,18 @@
|
||||
margin: 0 0 26px;
|
||||
}
|
||||
|
||||
/* Prowler Cloud screenshot shown on each gated page. */
|
||||
.pc-shot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
height: auto;
|
||||
border: 1px solid var(--pc-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.08);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.pc-benefits {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -350,6 +362,111 @@
|
||||
background: var(--pc-btn-press);
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Findings hand-off additions */
|
||||
|
||||
/* Secondary "how to" block rendered between the message and the CTA. */
|
||||
.pc-steps {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
text-align: left;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--pc-border);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin: 0 0 26px;
|
||||
}
|
||||
|
||||
.pc-steps-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--pc-text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.pc-steps-list {
|
||||
/* Tailwind's preflight strips list markers; restore the numbering. */
|
||||
list-style: decimal outside;
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
color: var(--pc-text-2);
|
||||
}
|
||||
|
||||
.pc-steps-note {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #52525b;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.pc-steps-note:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pc-cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px 20px;
|
||||
}
|
||||
|
||||
.pc-link-secondary {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--pc-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.pc-link-secondary:hover {
|
||||
color: var(--pc-btn-press);
|
||||
}
|
||||
|
||||
.pc-note {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #52525b;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* Compact hand-off card used at the end of the Compliance page. */
|
||||
.pc-handoff {
|
||||
min-width: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--pc-border);
|
||||
border-radius: 14px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.pc-handoff .pc-avail {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pc-handoff-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--pc-text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pc-handoff-desc {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pc-handoff-benefits {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pc-handoff .pc-cta-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.pc-handoff .pc-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- Responsive collapse */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a6 6 0 0 1-12 0V8z"/></svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -1,5 +1,7 @@
|
||||
from dash import dcc, html
|
||||
|
||||
from dashboard.pages.cloud import build_compliance_handoff_card
|
||||
|
||||
|
||||
def create_layout_overview(
|
||||
account_dropdown: html.Div,
|
||||
@@ -194,6 +196,7 @@ def create_layout_compliance(
|
||||
className="text-prowler-stone-900 text-lg font-bold",
|
||||
),
|
||||
html.Div(className="flex flex-wrap", id="output"),
|
||||
build_compliance_handoff_card(),
|
||||
],
|
||||
className="grid gap-x-8 gap-y-8 2xl:container mx-auto",
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Startup output helpers for `prowler dashboard`.
|
||||
|
||||
The local URL is announced only once the HTTP server is listening. Werkzeug
|
||||
calls ``BaseWSGIServer.log_startup`` right after it binds the socket, so a
|
||||
thin wrapper around that method reads the effective host and port from the
|
||||
bound server and prints the ready message. Dash silences the Werkzeug logger
|
||||
below ERROR while in debug mode, so hooking the method (not the log) is what
|
||||
makes the announcement reliable. Werkzeug's own behaviour is untouched: the
|
||||
original method still runs and no logger is reconfigured.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from colorama import Fore, Style
|
||||
from werkzeug.serving import BaseWSGIServer, get_interface_ip
|
||||
|
||||
from prowler.config.config import banner_color
|
||||
|
||||
_WILDCARD_HOSTS = {"0.0.0.0": "127.0.0.1", "::": "[::1]"}
|
||||
|
||||
|
||||
def server_urls(host, port, scheme="http"):
|
||||
"""Return the browser URL and the other reachable URLs for a bound server.
|
||||
|
||||
A wildcard host (``0.0.0.0`` or ``::``) is never presented as a browser
|
||||
destination: the loopback address is used instead and the interface
|
||||
address is listed as also reachable.
|
||||
"""
|
||||
if host in _WILDCARD_HOSTS:
|
||||
primary = f"{scheme}://{_WILDCARD_HOSTS[host]}:{port}/"
|
||||
family = socket.AF_INET if host == "0.0.0.0" else socket.AF_INET6
|
||||
try:
|
||||
interface_ip = get_interface_ip(family)
|
||||
except OSError:
|
||||
interface_ip = None
|
||||
others = []
|
||||
if interface_ip:
|
||||
if ":" in interface_ip:
|
||||
interface_ip = f"[{interface_ip}]"
|
||||
others.append(f"{scheme}://{interface_ip}:{port}/")
|
||||
return primary, others
|
||||
display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
return f"{scheme}://{display_host}:{port}/", []
|
||||
|
||||
|
||||
def format_ready_message(host, port, scheme="http"):
|
||||
"""Render the ready block, styled like the CLI banner, for a listening server."""
|
||||
primary, others = server_urls(host, port, scheme)
|
||||
bar = f"{banner_color}│{Style.RESET_ALL}"
|
||||
check = f"{Fore.GREEN}✓{Style.RESET_ALL}"
|
||||
lines = [
|
||||
f"{bar} {check} {Style.BRIGHT}Prowler Local Dashboard now is running on{Style.RESET_ALL} "
|
||||
f"{banner_color}{primary}{Style.RESET_ALL}"
|
||||
]
|
||||
if host in _WILDCARD_HOSTS:
|
||||
lines.append(f"{bar} Listening on all interfaces ({host}).")
|
||||
if others:
|
||||
lines.append(f"{bar} Also reachable at: " + ", ".join(others))
|
||||
lines.append(f"{bar} Press Ctrl+C to stop.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class DashRunningLineFilter(logging.Filter):
|
||||
"""Drop Dash's own ``Dash is running on ...`` line from the app logger.
|
||||
|
||||
The ready block printed by ``install_server_ready_hook`` already states the
|
||||
effective URL once the server is listening, so Dash's pre-bind line would
|
||||
only repeat it. Every other record on the logger still passes.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
return not record.getMessage().startswith("Dash is running on")
|
||||
|
||||
|
||||
def silence_dash_running_line(app):
|
||||
"""Attach ``DashRunningLineFilter`` to a Dash app's logger once."""
|
||||
if not any(isinstance(f, DashRunningLineFilter) for f in app.logger.filters):
|
||||
app.logger.addFilter(DashRunningLineFilter())
|
||||
|
||||
|
||||
def _print_now(message):
|
||||
"""Print and flush so the ready message shows even when stdout is piped."""
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def install_server_ready_hook(announce=_print_now):
|
||||
"""Announce the effective address right after Werkzeug binds the socket.
|
||||
|
||||
Wraps ``BaseWSGIServer.log_startup`` once per process. The wrapper calls
|
||||
the original method first, so Werkzeug's own startup output is unchanged,
|
||||
then prints the ready block a single time. A failed bind never reaches
|
||||
``log_startup``, so a startup error is never announced as ready.
|
||||
"""
|
||||
if getattr(BaseWSGIServer.log_startup, "_prowler_ready_hook", False):
|
||||
return BaseWSGIServer.log_startup
|
||||
|
||||
original = BaseWSGIServer.log_startup
|
||||
state = {"announced": False}
|
||||
|
||||
def log_startup(self):
|
||||
original(self)
|
||||
if state["announced"]:
|
||||
return
|
||||
state["announced"] = True
|
||||
scheme = "http" if self.ssl_context is None else "https"
|
||||
announce("\n" + format_ready_message(self.host, self.port, scheme) + "\n")
|
||||
|
||||
log_startup._prowler_ready_hook = True
|
||||
log_startup._prowler_original = original
|
||||
BaseWSGIServer.log_startup = log_startup
|
||||
return log_startup
|
||||
@@ -21,12 +21,58 @@ CLOUD_CTA_BASE = (
|
||||
# Path to the recolorable checkmark mask used for benefit bullets.
|
||||
CHECK_ICON = "/assets/images/icons/cloud/check.svg"
|
||||
|
||||
# Prowler Cloud screenshots shown on each gated page. They come from the
|
||||
# product documentation (docs/images) and are downscaled for the dashboard.
|
||||
SCREENSHOT_DIR = "/assets/images/cloud/screenshots"
|
||||
|
||||
# Prowler Cloud pricing page used by the "Compare editions" secondary link.
|
||||
CLOUD_PRICING_URL = "https://prowler.com/pricing"
|
||||
|
||||
# Findings hand-off CTA. Shares the ``report-handoff`` campaign and ``oss``
|
||||
# medium with the OSS HTML report CTA; ``utm_source`` identifies this entry
|
||||
# point and ``intent`` expresses the user's goal. The URL is static: no
|
||||
# assessment data (accounts, resources, findings, paths) is ever appended.
|
||||
FINDINGS_HANDOFF_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-local-dashboard&utm_medium=oss"
|
||||
"&utm_campaign=report-handoff&utm_content=findings&intent=import-findings"
|
||||
)
|
||||
|
||||
# Same hand-off for the Compliance page; only ``utm_content`` differs.
|
||||
COMPLIANCE_HANDOFF_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-local-dashboard&utm_medium=oss"
|
||||
"&utm_campaign=report-handoff&utm_content=compliance&intent=import-findings"
|
||||
)
|
||||
|
||||
# Shared hand-off copy (identical to the OSS HTML report CTA).
|
||||
HANDOFF_TITLE = "Keep scanning locally. Bring your findings to Prowler Cloud."
|
||||
HANDOFF_DESCRIPTION = (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
)
|
||||
HANDOFF_CTA_LABEL = "Explore Prowler Cloud"
|
||||
HANDOFF_SECONDARY_LABEL = "Compare editions"
|
||||
HANDOFF_NOTE = "Nothing is uploaded automatically."
|
||||
|
||||
# What Prowler Cloud adds on top of the local compliance view, as documented in
|
||||
# the Compliance, Cross-Provider Compliance and Cross-Provider Type Compliance
|
||||
# tutorials. Keep these verifiable; do not add unreleased capabilities.
|
||||
COMPLIANCE_HANDOFF_BENEFITS = [
|
||||
"Compliance Watchlist to pin the frameworks you track",
|
||||
"Cross-account and cross-provider roll-ups of a framework, with a per-provider breakdown",
|
||||
"Downloadable CSV and PDF compliance reports",
|
||||
]
|
||||
|
||||
|
||||
# Normative feature definitions. ``icon`` points to a local mask asset so no
|
||||
# external requests are needed and the glyph recolors per context.
|
||||
CLOUD_FEATURES = [
|
||||
{
|
||||
"slug": "lighthouse-ai",
|
||||
"screenshot": {
|
||||
"file": "lighthouse-ai.jpg",
|
||||
"alt": "Lighthouse AI chat in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/lighthouse-ai",
|
||||
"nav_label": "Lighthouse AI",
|
||||
"page_title": "Lighthouse AI",
|
||||
@@ -43,8 +89,64 @@ CLOUD_FEATURES = [
|
||||
"utm_content": "lighthouse-ai",
|
||||
"icon": "/assets/images/icons/cloud/lighthouse-ai.svg",
|
||||
},
|
||||
{
|
||||
# Wording mirrors the Prowler Cloud issues model: an issue folds the
|
||||
# findings of one attack path and carries a verdict, legs and a fix.
|
||||
"slug": "exploitable-issues",
|
||||
"route": "/cloud/exploitable-issues",
|
||||
"nav_label": "Exploitable Issues",
|
||||
"page_title": "Exploitable Issues",
|
||||
"card_title": "Unlock Exploitable Issues",
|
||||
"description": (
|
||||
"See which findings combine into an exploitable attack path, from "
|
||||
"where a threat actor starts to what they would gain, so the toxic "
|
||||
"combinations get fixed first."
|
||||
),
|
||||
"benefits": [
|
||||
"Each issue folds the related findings of one attack path into a single verdict: exploitable, likely, conditional or not exploitable",
|
||||
"Reachability, access, target, controls and verification are assessed separately, with the evidence behind each",
|
||||
"Outcome and starting point are spelled out, from the internet to a leaked credential, together with the fix and a triage status",
|
||||
],
|
||||
"utm_content": "exploitable-issues",
|
||||
"icon": "/assets/images/icons/cloud/exploitable-issues.svg",
|
||||
"note": "Exploitable Issues is rolling out to Prowler Cloud tenants.",
|
||||
},
|
||||
{
|
||||
# Capabilities below mirror the Prowler MCP documentation (Overview and
|
||||
# Cloud MCP Server configuration). The hosted server is in preview.
|
||||
"slug": "mcp",
|
||||
"screenshot": {
|
||||
"file": "mcp.jpg",
|
||||
"alt": "Claude Code querying Prowler through the Prowler MCP Server",
|
||||
},
|
||||
"route": "/cloud/mcp",
|
||||
"nav_label": "MCP Server",
|
||||
"page_title": "Prowler MCP",
|
||||
"card_title": "Unlock Prowler MCP",
|
||||
"description": (
|
||||
"Connect your AI assistant to Prowler through the hosted MCP Server "
|
||||
"at mcp.prowler.com, with no installation, using a Prowler Cloud "
|
||||
"API key."
|
||||
),
|
||||
"benefits": [
|
||||
"Ask about findings, resources and compliance in natural language from Claude Code, Cursor, VS Code or Codex",
|
||||
"Trigger scans and manage providers, mute rules and integrations from your AI agent",
|
||||
"Cloud-only tools for findings triage, scan scheduling, scan configurations and alerts",
|
||||
],
|
||||
"utm_content": "mcp",
|
||||
"icon": "/assets/images/icons/cloud/mcp.svg",
|
||||
"secondary_link": {
|
||||
"label": "Connect your AI agent",
|
||||
"href": "https://docs.prowler.com/user-guide/ai-agents/",
|
||||
},
|
||||
"note": "Prowler MCP Server is currently a preview feature.",
|
||||
},
|
||||
{
|
||||
"slug": "attack-paths",
|
||||
"screenshot": {
|
||||
"file": "attack-paths.jpg",
|
||||
"alt": "Attack path graph in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/attack-paths",
|
||||
"nav_label": "Attack Paths",
|
||||
"page_title": "Attack Paths",
|
||||
@@ -62,25 +164,53 @@ CLOUD_FEATURES = [
|
||||
"icon": "/assets/images/icons/cloud/attack-paths.svg",
|
||||
},
|
||||
{
|
||||
# Findings is a voluntary hand-off, not an upsell: the dashboard keeps
|
||||
# showing findings from CSV, and this page explains how to bring the
|
||||
# OCSF report to Prowler Cloud. Copy is shared with the OSS HTML report.
|
||||
"slug": "findings",
|
||||
"screenshot": {
|
||||
"file": "findings.jpg",
|
||||
"alt": "Findings table with triage in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/findings",
|
||||
"nav_label": "Findings",
|
||||
"page_title": "Findings",
|
||||
"card_title": "Unlock Findings",
|
||||
"card_title": "Keep scanning locally. Bring your findings to Prowler Cloud.",
|
||||
"description": (
|
||||
"Filter, investigate, and prioritize security findings across "
|
||||
"providers and scans from one workspace."
|
||||
"Import your accompanying OCSF report to explore managed security "
|
||||
"workflows."
|
||||
),
|
||||
"benefits": [
|
||||
"Search and filter findings across all connected accounts",
|
||||
"Track status, severity, ownership, and remediation context",
|
||||
"Triage findings and share a consistent source of truth with your security team",
|
||||
],
|
||||
"benefits": [],
|
||||
"utm_content": "findings",
|
||||
"icon": "/assets/images/icons/cloud/findings.svg",
|
||||
"cta_label": "Explore Prowler Cloud",
|
||||
"cta_url": FINDINGS_HANDOFF_CTA_URL,
|
||||
"secondary_link": {"label": "Compare editions", "href": CLOUD_PRICING_URL},
|
||||
"note": "Nothing is uploaded automatically.",
|
||||
"steps": {
|
||||
"title": "How to bring your results",
|
||||
"items": [
|
||||
"Create or sign in to your Prowler Cloud account.",
|
||||
"In Prowler Cloud, open Scans \u2192 Import Findings.",
|
||||
"Select the accompanying .ocsf.json report and start the import.",
|
||||
],
|
||||
"notes": [
|
||||
"This dashboard reads CSV files. Cloud import requires the OCSF "
|
||||
"JSON report, not the CSV or HTML report.",
|
||||
"If you did not generate an OCSF report, include json-ocsf in "
|
||||
"your output formats on your next scan.",
|
||||
"Import Findings is available to roles with the Manage Ingestions "
|
||||
"permission on an active Prowler Cloud subscription; each cloud "
|
||||
"account in the report counts as a provider.",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"slug": "alerts",
|
||||
"screenshot": {
|
||||
"file": "alerts.jpg",
|
||||
"alt": "Alert rules list in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/alerts",
|
||||
"nav_label": "Alerts",
|
||||
"page_title": "Alerts",
|
||||
@@ -99,6 +229,10 @@ CLOUD_FEATURES = [
|
||||
},
|
||||
{
|
||||
"slug": "mutelist",
|
||||
"screenshot": {
|
||||
"file": "mutelist.jpg",
|
||||
"alt": "Mutelist configuration in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/mutelist",
|
||||
"nav_label": "Mutelist",
|
||||
"page_title": "Mutelist",
|
||||
@@ -117,6 +251,10 @@ CLOUD_FEATURES = [
|
||||
},
|
||||
{
|
||||
"slug": "integrations",
|
||||
"screenshot": {
|
||||
"file": "integrations.jpg",
|
||||
"alt": "Integrations page in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/integrations",
|
||||
"nav_label": "Integrations",
|
||||
"page_title": "Integrations",
|
||||
@@ -135,6 +273,10 @@ CLOUD_FEATURES = [
|
||||
},
|
||||
{
|
||||
"slug": "organization",
|
||||
"screenshot": {
|
||||
"file": "organization.jpg",
|
||||
"alt": "Organization users page in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/organization",
|
||||
"nav_label": "Organization",
|
||||
"page_title": "Organization",
|
||||
@@ -178,9 +320,114 @@ def _benefit_item(text):
|
||||
)
|
||||
|
||||
|
||||
def _handoff_links(cta_url):
|
||||
"""Return the primary CTA and the "Compare editions" link for a hand-off."""
|
||||
return html.Div(
|
||||
[
|
||||
html.A(
|
||||
HANDOFF_CTA_LABEL,
|
||||
href=cta_url,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-cta",
|
||||
),
|
||||
html.A(
|
||||
HANDOFF_SECONDARY_LABEL,
|
||||
href=CLOUD_PRICING_URL,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-link-secondary",
|
||||
),
|
||||
],
|
||||
className="pc-cta-row",
|
||||
)
|
||||
|
||||
|
||||
def build_compliance_handoff_card():
|
||||
"""Build the compact hand-off card shown at the end of the Compliance page.
|
||||
|
||||
The local compliance view keeps working from CSV; this card only explains
|
||||
what Prowler Cloud adds for compliance reporting. Static links only: no
|
||||
upload control, no request, no assessment data in URLs.
|
||||
"""
|
||||
return html.Div(
|
||||
[
|
||||
html.Div("Available in Prowler Cloud", className="pc-avail"),
|
||||
html.H2(HANDOFF_TITLE, className="pc-handoff-title"),
|
||||
html.P(HANDOFF_DESCRIPTION, className="pc-card-desc pc-handoff-desc"),
|
||||
html.Ul(
|
||||
[_benefit_item(benefit) for benefit in COMPLIANCE_HANDOFF_BENEFITS],
|
||||
className="pc-benefits pc-handoff-benefits",
|
||||
),
|
||||
_handoff_links(COMPLIANCE_HANDOFF_CTA_URL),
|
||||
html.P(HANDOFF_NOTE, className="pc-note"),
|
||||
],
|
||||
className="pc-handoff pc-font",
|
||||
role="complementary",
|
||||
)
|
||||
|
||||
|
||||
def _screenshot(screenshot):
|
||||
"""Render the Prowler Cloud screenshot that shows the gated feature."""
|
||||
return html.Img(
|
||||
src=f"{SCREENSHOT_DIR}/{screenshot['file']}",
|
||||
alt=screenshot["alt"],
|
||||
className="pc-shot",
|
||||
)
|
||||
|
||||
|
||||
def _steps_block(steps):
|
||||
"""Build the optional secondary "how to" block rendered below the message.
|
||||
|
||||
It is plain text and links only: no file selector, upload control or
|
||||
request is introduced, since the browser cannot read local report files.
|
||||
"""
|
||||
return html.Div(
|
||||
[
|
||||
html.H3(steps["title"], className="pc-steps-title"),
|
||||
html.Ol(
|
||||
[html.Li(item) for item in steps["items"]],
|
||||
className="pc-steps-list",
|
||||
),
|
||||
*[html.P(note, className="pc-steps-note") for note in steps["notes"]],
|
||||
],
|
||||
className="pc-steps",
|
||||
)
|
||||
|
||||
|
||||
def build_cloud_layout(feature):
|
||||
"""Build the reusable gated informational page for a single feature."""
|
||||
cta_url = f"{CLOUD_CTA_BASE}{feature['utm_content']}"
|
||||
"""Build the reusable gated informational page for a single feature.
|
||||
|
||||
Features may override the CTA (``cta_label``/``cta_url``) and add an
|
||||
optional ``screenshot``, ``steps`` block, ``secondary_link`` and ``note``.
|
||||
Features that do not set them render exactly as before.
|
||||
"""
|
||||
cta_url = feature.get("cta_url") or f"{CLOUD_CTA_BASE}{feature['utm_content']}"
|
||||
cta_label = feature.get("cta_label", "Upgrade to Prowler Cloud")
|
||||
steps = feature.get("steps")
|
||||
screenshot = feature.get("screenshot")
|
||||
secondary_link = feature.get("secondary_link")
|
||||
note = feature.get("note")
|
||||
|
||||
cta_links = [
|
||||
html.A(
|
||||
cta_label,
|
||||
href=cta_url,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-cta",
|
||||
)
|
||||
]
|
||||
if secondary_link:
|
||||
cta_links.append(
|
||||
html.A(
|
||||
secondary_link["label"],
|
||||
href=secondary_link["href"],
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-link-secondary",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(
|
||||
html.Div(
|
||||
@@ -227,6 +474,7 @@ def build_cloud_layout(feature):
|
||||
feature["description"],
|
||||
className="pc-card-desc",
|
||||
),
|
||||
*([_screenshot(screenshot)] if screenshot else []),
|
||||
html.Ul(
|
||||
[
|
||||
_benefit_item(benefit)
|
||||
@@ -234,13 +482,13 @@ def build_cloud_layout(feature):
|
||||
],
|
||||
className="pc-benefits",
|
||||
),
|
||||
html.A(
|
||||
"Upgrade to Prowler Cloud",
|
||||
href=cta_url,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-cta",
|
||||
*([_steps_block(steps)] if steps else []),
|
||||
*(
|
||||
[html.Div(cta_links, className="pc-cta-row")]
|
||||
if secondary_link
|
||||
else cta_links
|
||||
),
|
||||
*([html.P(note, className="pc-note")] if note else []),
|
||||
],
|
||||
className="pc-card-body",
|
||||
),
|
||||
@@ -260,6 +508,6 @@ for _feature in CLOUD_FEATURES:
|
||||
f"cloud_{_feature['slug'].replace('-', '_')}",
|
||||
path=_feature["route"],
|
||||
name=_feature["nav_label"],
|
||||
title=f"Prowler Dashboard - {_feature['page_title']}",
|
||||
title=f"Prowler Local Dashboard - {_feature['page_title']}",
|
||||
layout=build_cloud_layout(_feature),
|
||||
)
|
||||
@@ -24,7 +24,13 @@ Make sure you update the `/your/local/dir/prowler-output` to match the path that
|
||||
**Remember that the `dashboard` server is not authenticated. If you expose it to the Internet, do it at your own risk.**
|
||||
|
||||
</Note>
|
||||
The banner and additional info about the dashboard will be shown on your console: <img src="/images/cli/dashboard/dashboard-banner.png" />
|
||||
The console shows the dashboard version, the directory the CSV files are loaded from, the local URL once the server is listening, and a short note about bringing your findings to Prowler Cloud. Set `PROWLER_NO_PROMOTIONS=1` to omit that note:
|
||||
|
||||
```sh
|
||||
PROWLER_NO_PROMOTIONS=1 prowler dashboard
|
||||
```
|
||||
|
||||
Set `NO_COLOR=1` to print the startup output without colors.
|
||||
|
||||
## Overview Page
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# =============================================================================
|
||||
# Final stage - Minimal runtime environment
|
||||
# =============================================================================
|
||||
FROM python:3.13.18-alpine3.23@sha256:e4aeaca9081549f19bd5bc8605ed5ab257ba34f97e0471f8e09161f45b4b08b8
|
||||
FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
joserfc to 1.7.5, patching the JWE decryption denial of service advisory
|
||||
@@ -1 +0,0 @@
|
||||
Python base image from 3.13.16 to 3.13.18 in the MCP Server container image, patching CVE-2026-115557
|
||||
@@ -79,7 +79,7 @@ constraint-dependencies = [
|
||||
"authlib==1.9.1",
|
||||
"cryptography==50.0.0",
|
||||
"dnspython==2.9.0",
|
||||
"joserfc==1.7.5",
|
||||
"joserfc==1.6.8",
|
||||
"mcp==1.28.1",
|
||||
"pyjwt==2.14.0",
|
||||
"python-multipart==0.0.30"
|
||||
|
||||
@@ -13,7 +13,7 @@ constraints = [
|
||||
{ name = "authlib", specifier = "==1.9.1" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "dnspython", specifier = "==2.9.0" },
|
||||
{ name = "joserfc", specifier = "==1.7.5" },
|
||||
{ name = "joserfc", specifier = "==1.6.8" },
|
||||
{ name = "mcp", specifier = "==1.28.1" },
|
||||
{ name = "pyjwt", specifier = "==2.14.0" },
|
||||
{ name = "python-multipart", specifier = "==0.0.30" },
|
||||
@@ -594,14 +594,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.7.5"
|
||||
version = "1.6.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -698,6 +698,8 @@ def prowler():
|
||||
html_output = HTML(
|
||||
findings=finding_outputs,
|
||||
file_path=f"{filename}{html_file_suffix}",
|
||||
# Only the OSS CLI shows the Prowler Cloud hand-off block
|
||||
show_cloud_cta=True,
|
||||
)
|
||||
generated_outputs["regular"].append(html_output)
|
||||
html_output.batch_write_data_to_file(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`prowler dashboard` startup output now shows the Local Dashboard logo, the CSV directory, a brief Prowler Cloud hand-off block that `PROWLER_NO_PROMOTIONS=1` disables, and the local URL once the server is listening
|
||||
@@ -0,0 +1 @@
|
||||
Prowler Cloud hand-off block after the assessment summary in OSS CLI HTML reports, controlled by an opt-in `show_cloud_cta` option that Prowler Cloud and Private Cloud exports leave disabled
|
||||
@@ -1 +0,0 @@
|
||||
pyasn1 to 0.6.5 and joserfc to 1.7.5 in the SDK, patching the ASN.1 decoding and JWE decryption denial of service advisories
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from prowler.config.config import banner_color, orange_color, prowler_version, timestamp
|
||||
@@ -8,6 +10,17 @@ from prowler.config.config import banner_color, orange_color, prowler_version, t
|
||||
CLOUD_DISPLAY_TEXT = "cloud.prowler.com"
|
||||
CLOUD_BANNER_URL = "https://cloud.prowler.com/sign-up?utm_source=prowler-cli"
|
||||
|
||||
# Compact hand-off shown by `prowler dashboard`. Same copy, campaign and medium
|
||||
# as the OSS HTML report CTA and the dashboard Findings page; ``utm_source``
|
||||
# identifies the terminal. The URL is static: no local paths or findings data.
|
||||
DASHBOARD_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-dashboard-terminal&utm_medium=oss&utm_campaign=report-handoff"
|
||||
)
|
||||
|
||||
# Set to a non-empty value other than 0/false/no to skip promotional blocks.
|
||||
NO_PROMOTIONS_ENV = "PROWLER_NO_PROMOTIONS"
|
||||
|
||||
|
||||
def _hyperlink(url: str, text: str) -> str:
|
||||
"""Wrap ``text`` in an OSC 8 terminal hyperlink pointing to ``url``.
|
||||
@@ -18,6 +31,19 @@ def _hyperlink(url: str, text: str) -> str:
|
||||
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
|
||||
|
||||
|
||||
def _logo_banner(label: str) -> str:
|
||||
"""Return the Prowler ASCII logo with ``label - v<version>`` and the date line."""
|
||||
return rf"""{banner_color} _
|
||||
_ __ _ __ _____ _| | ___ _ __
|
||||
| '_ \| '__/ _ \ \ /\ / / |/ _ \ '__|
|
||||
| |_) | | | (_) \ V V /| | __/ |
|
||||
| .__/|_| \___/ \_/\_/ |_|\___|_| {label} - v{prowler_version}
|
||||
|_|
|
||||
|
||||
{Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL}
|
||||
"""
|
||||
|
||||
|
||||
def print_banner(legend: bool = False, provider: str = None):
|
||||
"""
|
||||
Prints the banner with optional legend for color codes.
|
||||
@@ -29,16 +55,7 @@ def print_banner(legend: bool = False, provider: str = None):
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
banner = rf"""{banner_color} _
|
||||
_ __ _ __ _____ _| | ___ _ __
|
||||
| '_ \| '__/ _ \ \ /\ / / |/ _ \ '__|
|
||||
| |_) | | | (_) \ V V /| | __/ |
|
||||
| .__/|_| \___/ \_/\_/ |_|\___|_| CLI - v{prowler_version}
|
||||
|_|
|
||||
|
||||
{Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL}
|
||||
"""
|
||||
print(banner)
|
||||
print(_logo_banner("CLI"))
|
||||
|
||||
print_prowler_cloud_banner(provider)
|
||||
|
||||
@@ -52,37 +69,109 @@ def print_banner(legend: bool = False, provider: str = None):
|
||||
""")
|
||||
|
||||
|
||||
# Per-provider wording for the bulk onboarding line of the CLI banner.
|
||||
_BULK_ONBOARDING = {
|
||||
"aws": "connect your whole AWS Organization in minutes",
|
||||
"azure": "connect every Azure subscription in minutes",
|
||||
"gcp": "connect every GCP project in minutes",
|
||||
"kubernetes": "connect all your clusters in minutes",
|
||||
"m365": "connect every M365 tenant in minutes",
|
||||
}
|
||||
|
||||
|
||||
def print_prowler_cloud_banner(provider: str = None):
|
||||
"""
|
||||
Prints a promotional banner highlighting what Prowler Cloud adds on top of
|
||||
the open-source CLI.
|
||||
|
||||
Shown at the start and end of a scan to let users know about the managed
|
||||
platform capabilities they are missing (CLI findings upload, attack paths,
|
||||
AI, triage, organizations, continuous scanning with custom scheduling and
|
||||
scan configuration, integrations and live compliance dashboards).
|
||||
Prints the Prowler Cloud hand-off shown at the start and end of a CLI scan:
|
||||
what the managed platform adds on top of this local snapshot, in six
|
||||
short lines that fit an 80-column terminal, plus the sign-up link.
|
||||
Skipped when ``PROWLER_NO_PROMOTIONS`` is set.
|
||||
|
||||
Parameters:
|
||||
- provider (str): The provider that was scanned, used to tailor the message.
|
||||
- provider (str): The provider being scanned, used to tailor the onboarding line.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
if promotions_disabled():
|
||||
return
|
||||
check = f"{Fore.GREEN}✓{Style.RESET_ALL}"
|
||||
bar = f"{banner_color}│{Style.RESET_ALL}"
|
||||
onboarding = _BULK_ONBOARDING.get(
|
||||
provider, "connect all your accounts and keep them scanned"
|
||||
)
|
||||
|
||||
def line(title, text):
|
||||
return f"{bar} {check} {Style.BRIGHT}{title}{Style.RESET_ALL} - {text}"
|
||||
|
||||
print(f"""
|
||||
{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL}
|
||||
{bar} {Style.BRIGHT}This scan is a snapshot 📸. Prowler Cloud gives you the full picture.{Style.RESET_ALL}
|
||||
{bar} Import this scan's OCSF report or scan continuously, with managed workflows.
|
||||
{bar}
|
||||
{bar} {check} {Style.BRIGHT}Send your findings{Style.RESET_ALL} - directly from the Prowler CLI to Prowler Cloud.
|
||||
{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - custom scheduling and scan configuration with history, trends and alerts.
|
||||
{bar} {check} {Style.BRIGHT}Triage{Style.RESET_ALL} - review findings, flag false positives and track accepted risk with your team.
|
||||
{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation.
|
||||
{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening.
|
||||
{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date.
|
||||
{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI.
|
||||
{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels.
|
||||
{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds.
|
||||
{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC.
|
||||
{line("Exploitable Issues", "findings chained into attack paths, with the fix.")}
|
||||
{line("Findings Triage", "history, trends, false positives and accepted risk.")}
|
||||
{line("Lighthouse AI + MCP", "ask, prioritize and remediate from your AI tools.")}
|
||||
{line("Live Compliance", "50+ frameworks across providers and accounts.")}
|
||||
{line("Alerts & Integrations", "Jira, Slack, Security Hub, S3, SSO and RBAC.")}
|
||||
{line("Scheduled Scans", onboarding + ".")}
|
||||
{bar}
|
||||
{bar} {banner_color}Start free at 👉 {_hyperlink(CLOUD_BANNER_URL, CLOUD_DISPLAY_TEXT)}{Style.RESET_ALL}
|
||||
{bar} Nothing is uploaded automatically.
|
||||
""")
|
||||
|
||||
|
||||
def promotions_disabled() -> bool:
|
||||
"""Return True when ``PROWLER_NO_PROMOTIONS`` asks to skip promotional output."""
|
||||
value = os.environ.get(NO_PROMOTIONS_ENV, "").strip().lower()
|
||||
return value not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
def print_dashboard_banner() -> None:
|
||||
"""
|
||||
Prints the `prowler dashboard` header: the Prowler logo labelled
|
||||
``Local Dashboard - v<version>`` and the date. The CSV directory and the
|
||||
server URL are announced separately.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
print(_logo_banner("Local Dashboard"))
|
||||
|
||||
|
||||
def print_dashboard_loading(csv_directory: str) -> None:
|
||||
"""
|
||||
Prints the directory the dashboard loads its CSV files from. Shown after the
|
||||
hand-off block, right before the files are read.
|
||||
|
||||
Parameters:
|
||||
- csv_directory (str): the directory the dashboard reads CSV files from.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
print(f"{Fore.GREEN}Loading CSV files from {csv_directory} ...{Style.RESET_ALL}")
|
||||
|
||||
|
||||
def print_dashboard_promotion() -> None:
|
||||
"""
|
||||
Prints the single compact Prowler Cloud hand-off block for `prowler dashboard`,
|
||||
styled like the CLI's Prowler Cloud banner (bar, bold title, check and 👉 link).
|
||||
Skipped when ``PROWLER_NO_PROMOTIONS`` is set. The link shows ``cloud.prowler.com``
|
||||
like the CLI banner; terminals with OSC 8 support make it clickable with the
|
||||
UTM-tagged URL, others display the plain domain.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
if promotions_disabled():
|
||||
return
|
||||
bar = f"{banner_color}│{Style.RESET_ALL}"
|
||||
check = f"{Fore.GREEN}✓{Style.RESET_ALL}"
|
||||
cta = _hyperlink(DASHBOARD_CTA_URL, CLOUD_DISPLAY_TEXT)
|
||||
print(f"""
|
||||
{bar} {Style.BRIGHT}Keep scanning locally. Bring your findings to Prowler Cloud.{Style.RESET_ALL}
|
||||
{bar} Import your accompanying OCSF report to explore managed security workflows.
|
||||
{bar}
|
||||
{bar} {check} {Style.BRIGHT}Explore Prowler Cloud{Style.RESET_ALL} 👉 {banner_color}{cta}{Style.RESET_ALL}
|
||||
{bar}
|
||||
{bar} Nothing is uploaded automatically.
|
||||
""")
|
||||
@@ -20,6 +20,18 @@ from prowler.providers.common.provider import Provider
|
||||
|
||||
_SAFE_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
# Prowler Cloud hand-off block. Only the OSS CLI opts in (see `show_cloud_cta`),
|
||||
# so Prowler Cloud and Private Cloud reports never contain it. These URLs are
|
||||
# static: no assessment data is ever appended to them.
|
||||
PROWLER_CLOUD_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-html-report&utm_medium=oss&utm_campaign=report-handoff"
|
||||
)
|
||||
PROWLER_CLOUD_PRICING_URL = "https://prowler.com/pricing"
|
||||
PROWLER_CLOUD_IMPORT_DOCS_URL = (
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-import-findings"
|
||||
)
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str:
|
||||
"""Return url if its scheme is http/https, otherwise return empty string."""
|
||||
@@ -47,6 +59,57 @@ def _strip_unsafe_links(html_content: str) -> str:
|
||||
|
||||
|
||||
class HTML(Output):
|
||||
def __init__(
|
||||
self,
|
||||
findings: list[Finding],
|
||||
file_path: str = None,
|
||||
file_extension: str = "",
|
||||
from_cli: bool = True,
|
||||
show_cloud_cta: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the HTML output.
|
||||
|
||||
Args:
|
||||
findings (list[Finding]): findings to transform
|
||||
file_path (str): path of the output file
|
||||
file_extension (str): extension of the output file
|
||||
from_cli (bool): whether the output is written in one go (CLI) or in batches (API)
|
||||
show_cloud_cta (bool): whether to render the Prowler Cloud hand-off block after the
|
||||
assessment summary. Disabled by default; only the OSS CLI enables it.
|
||||
"""
|
||||
self._show_cloud_cta = show_cloud_cta
|
||||
super().__init__(
|
||||
findings,
|
||||
file_path=file_path,
|
||||
file_extension=file_extension,
|
||||
from_cli=from_cli,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_cloud_cta() -> str:
|
||||
"""
|
||||
Returns the Prowler Cloud hand-off block shown after the assessment summary in OSS CLI
|
||||
reports. It is static markup: no scripts, no requests, and no assessment data in URLs.
|
||||
|
||||
Returns:
|
||||
str: HTML markup of the block
|
||||
"""
|
||||
return f"""
|
||||
<div class="row mt-3 prowler-cloud-cta" role="complementary" aria-labelledby="prowler-cloud-cta-title">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title" id="prowler-cloud-cta-title">Keep scanning locally. Bring your findings to Prowler Cloud.</h5>
|
||||
<p class="card-text">Import your accompanying OCSF report to explore managed security workflows. <a href="{PROWLER_CLOUD_IMPORT_DOCS_URL}" target="_blank" rel="noopener noreferrer">How to import findings</a></p>
|
||||
<a class="btn btn-primary" href="{PROWLER_CLOUD_CTA_URL}" target="_blank" rel="noopener noreferrer">Explore Prowler Cloud</a>
|
||||
<a class="btn btn-link" href="{PROWLER_CLOUD_PRICING_URL}" target="_blank" rel="noopener noreferrer">Compare editions</a>
|
||||
<p class="card-text text-muted mt-2 mb-0"><small>Nothing is uploaded automatically.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
@staticmethod
|
||||
def process_markdown(text: str) -> str:
|
||||
"""
|
||||
@@ -142,7 +205,11 @@ class HTML(Output):
|
||||
):
|
||||
if self._file_descriptor.tell() == 0:
|
||||
HTML.write_header(
|
||||
self._file_descriptor, provider, stats, self._from_cli
|
||||
self._file_descriptor,
|
||||
provider,
|
||||
stats,
|
||||
self._from_cli,
|
||||
show_cloud_cta=getattr(self, "_show_cloud_cta", False),
|
||||
)
|
||||
for finding in self._data:
|
||||
self._file_descriptor.write(finding)
|
||||
@@ -160,6 +227,7 @@ class HTML(Output):
|
||||
provider: Provider,
|
||||
stats: dict,
|
||||
from_cli: bool = True,
|
||||
show_cloud_cta: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Writes the header of the HTML file.
|
||||
@@ -169,8 +237,17 @@ class HTML(Output):
|
||||
provider (Provider): the provider object
|
||||
stats (dict): the statistics of the findings
|
||||
from_cli (bool): whether the request is from the CLI or not
|
||||
show_cloud_cta (bool): whether to include the Prowler Cloud hand-off block. When
|
||||
False the block, its styles and its URLs are omitted entirely.
|
||||
"""
|
||||
try:
|
||||
cloud_cta_style = (
|
||||
"""
|
||||
@media print { .prowler-cloud-cta { display: none !important; } }"""
|
||||
if show_cloud_cta
|
||||
else ""
|
||||
)
|
||||
cloud_cta = HTML.get_cloud_cta() if show_cloud_cta else ""
|
||||
file_descriptor.write(f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -199,7 +276,7 @@ class HTML(Output):
|
||||
|
||||
.container-fluid {{font-size: 14px;}}
|
||||
|
||||
.float-left {{ float: left !important; max-width: 100%; }}
|
||||
.float-left {{ float: left !important; max-width: 100%; }}{cloud_cta_style}
|
||||
</style>
|
||||
<title>Prowler - The Handy Cloud Security Tool</title>
|
||||
</head>
|
||||
@@ -259,7 +336,7 @@ class HTML(Output):
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{cloud_cta}
|
||||
</div>
|
||||
<div class="row-mt-3">
|
||||
<div class="col-md-12">
|
||||
|
||||
@@ -288,7 +288,7 @@ constraint-dependencies = [
|
||||
"itsdangerous==2.2.0",
|
||||
"jinja2==3.1.6",
|
||||
"jmespath==1.1.0",
|
||||
"joserfc==1.7.5",
|
||||
"joserfc==1.6.8",
|
||||
"jsonpatch==1.33",
|
||||
"jsonpath-ng==1.8.0",
|
||||
"jsonpointer==3.1.1",
|
||||
@@ -342,7 +342,7 @@ constraint-dependencies = [
|
||||
"protobuf==7.34.1",
|
||||
"psutil==7.2.2",
|
||||
"py-partiql-parser==0.6.1",
|
||||
"pyasn1==0.6.5",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.12.1",
|
||||
"pycparser==3.0",
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from dash import html
|
||||
from plotly.utils import PlotlyJSONEncoder
|
||||
|
||||
from dashboard.pages.cloud import (
|
||||
CLOUD_CTA_BASE,
|
||||
CLOUD_FEATURES,
|
||||
CLOUD_FEATURES_BY_SLUG,
|
||||
CLOUD_PRICING_URL,
|
||||
FINDINGS_HANDOFF_CTA_URL,
|
||||
build_cloud_layout,
|
||||
)
|
||||
|
||||
EXPECTED_FINDINGS_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard"
|
||||
"&utm_medium=oss&utm_campaign=report-handoff&utm_content=findings"
|
||||
"&intent=import-findings"
|
||||
)
|
||||
|
||||
|
||||
def _serialize(component) -> str:
|
||||
"""Flatten a Dash component tree into a JSON string for text assertions."""
|
||||
return json.dumps(component, cls=PlotlyJSONEncoder, ensure_ascii=False)
|
||||
|
||||
|
||||
def _links(component):
|
||||
"""Yield every ``html.A`` component in the tree."""
|
||||
stack = [component]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, html.A):
|
||||
yield node
|
||||
children = getattr(node, "children", None)
|
||||
if isinstance(children, (list, tuple)):
|
||||
# Reverse so the stack yields children in document order
|
||||
stack.extend(reversed(children))
|
||||
elif children is not None and hasattr(children, "to_plotly_json"):
|
||||
stack.append(children)
|
||||
|
||||
|
||||
class TestFindingsHandoffPage:
|
||||
def setup_method(self):
|
||||
self.layout = build_cloud_layout(CLOUD_FEATURES_BY_SLUG["findings"])
|
||||
self.text = _serialize(self.layout)
|
||||
|
||||
def test_shared_message_matches_html_report_cta(self):
|
||||
assert (
|
||||
"Keep scanning locally. Bring your findings to Prowler Cloud." in self.text
|
||||
)
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in self.text
|
||||
)
|
||||
assert "Explore Prowler Cloud" in self.text
|
||||
assert "Compare editions" in self.text
|
||||
assert "Nothing is uploaded automatically." in self.text
|
||||
|
||||
def test_old_upsell_copy_is_gone(self):
|
||||
assert "Unlock Findings" not in self.text
|
||||
assert "Upgrade to Prowler Cloud" not in self.text
|
||||
assert (
|
||||
"Search and filter findings across all connected accounts" not in self.text
|
||||
)
|
||||
|
||||
def test_cta_keeps_every_tracking_parameter(self):
|
||||
cta = next(a for a in _links(self.layout) if a.className == "pc-cta")
|
||||
assert cta.href == EXPECTED_FINDINGS_CTA_URL == FINDINGS_HANDOFF_CTA_URL
|
||||
assert cta.children == "Explore Prowler Cloud"
|
||||
assert cta.target == "_blank"
|
||||
assert cta.rel == "noopener noreferrer"
|
||||
for param in (
|
||||
"utm_source=prowler-local-dashboard",
|
||||
"utm_medium=oss",
|
||||
"utm_campaign=report-handoff",
|
||||
"utm_content=findings",
|
||||
"intent=import-findings",
|
||||
):
|
||||
assert param in cta.href
|
||||
|
||||
def test_secondary_link_points_to_pricing(self):
|
||||
link = next(
|
||||
a for a in _links(self.layout) if a.className == "pc-link-secondary"
|
||||
)
|
||||
assert link.href == CLOUD_PRICING_URL == "https://prowler.com/pricing"
|
||||
assert link.children == "Compare editions"
|
||||
assert link.target == "_blank"
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_steps_block_content(self):
|
||||
assert "How to bring your results" in self.text
|
||||
assert "Create or sign in to your Prowler Cloud account." in self.text
|
||||
assert "In Prowler Cloud, open Scans → Import Findings." in self.text
|
||||
assert (
|
||||
"Select the accompanying .ocsf.json report and start the import."
|
||||
in self.text
|
||||
)
|
||||
assert (
|
||||
"This dashboard reads CSV files. Cloud import requires the OCSF JSON report, not the CSV or HTML report."
|
||||
in self.text
|
||||
)
|
||||
assert (
|
||||
"If you did not generate an OCSF report, include json-ocsf in your output formats on your next scan."
|
||||
in self.text
|
||||
)
|
||||
assert "Manage Ingestions" in self.text
|
||||
|
||||
def test_hierarchy_message_steps_cta_note(self):
|
||||
assert (
|
||||
self.text.index("Keep scanning locally.")
|
||||
< self.text.index("How to bring your results")
|
||||
< self.text.index("Explore Prowler Cloud")
|
||||
< self.text.index("Nothing is uploaded automatically.")
|
||||
)
|
||||
|
||||
def test_no_upload_or_file_selector(self):
|
||||
assert "Upload" not in self.text
|
||||
assert '"type": "Input"' not in self.text
|
||||
assert "<input" not in self.text.lower()
|
||||
|
||||
def test_all_external_links_open_safely(self):
|
||||
links = list(_links(self.layout))
|
||||
assert len(links) == 2
|
||||
for link in links:
|
||||
assert link.href.startswith("https://")
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_no_assessment_data_in_urls(self):
|
||||
for link in _links(self.layout):
|
||||
assert "/Users/" not in link.href
|
||||
assert "account" not in link.href.lower()
|
||||
assert ".csv" not in link.href
|
||||
|
||||
|
||||
class TestOtherCloudPagesUnchanged:
|
||||
@pytest.mark.parametrize(
|
||||
"slug",
|
||||
[
|
||||
f["slug"]
|
||||
for f in CLOUD_FEATURES
|
||||
if f["slug"] not in ("findings", "mcp", "exploitable-issues")
|
||||
],
|
||||
)
|
||||
def test_other_pages_keep_upsell_template(self, slug):
|
||||
feature = CLOUD_FEATURES_BY_SLUG[slug]
|
||||
layout = build_cloud_layout(feature)
|
||||
text = _serialize(layout)
|
||||
links = list(_links(layout))
|
||||
|
||||
assert feature["card_title"].startswith("Unlock ")
|
||||
assert feature["card_title"] in text
|
||||
assert len(links) == 1
|
||||
assert links[0].children == "Upgrade to Prowler Cloud"
|
||||
assert links[0].href == f"{CLOUD_CTA_BASE}{feature['utm_content']}"
|
||||
assert len(feature["benefits"]) == 3
|
||||
assert "How to bring your results" not in text
|
||||
assert "pc-steps" not in text
|
||||
assert "pc-note" not in text
|
||||
assert "pc-link-secondary" not in text
|
||||
assert "report-handoff" not in text
|
||||
|
||||
def test_navigation_label_unchanged(self):
|
||||
assert CLOUD_FEATURES_BY_SLUG["findings"]["nav_label"] == "Findings"
|
||||
assert CLOUD_FEATURES_BY_SLUG["findings"]["route"] == "/cloud/findings"
|
||||
|
||||
|
||||
class TestComplianceHandoffCard:
|
||||
def setup_method(self):
|
||||
from dashboard.pages.cloud import (
|
||||
COMPLIANCE_HANDOFF_CTA_URL,
|
||||
build_compliance_handoff_card,
|
||||
)
|
||||
|
||||
self.expected_url = COMPLIANCE_HANDOFF_CTA_URL
|
||||
self.card = build_compliance_handoff_card()
|
||||
self.text = _serialize(self.card)
|
||||
|
||||
def test_shared_message_and_note(self):
|
||||
assert (
|
||||
"Keep scanning locally. Bring your findings to Prowler Cloud." in self.text
|
||||
)
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in self.text
|
||||
)
|
||||
assert "Nothing is uploaded automatically." in self.text
|
||||
assert "Unlock" not in self.text
|
||||
|
||||
def test_compliance_benefits_are_the_documented_ones(self):
|
||||
assert "Compliance Watchlist" in self.text
|
||||
assert "Cross-account and cross-provider roll-ups" in self.text
|
||||
assert "CSV and PDF compliance reports" in self.text
|
||||
assert "attack path" not in self.text.lower()
|
||||
assert "trial" not in self.text.lower()
|
||||
|
||||
def test_links(self):
|
||||
links = list(_links(self.card))
|
||||
assert [a.children for a in links] == [
|
||||
"Explore Prowler Cloud",
|
||||
"Compare editions",
|
||||
]
|
||||
cta, pricing = links
|
||||
assert (
|
||||
cta.href
|
||||
== self.expected_url
|
||||
== (
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard"
|
||||
"&utm_medium=oss&utm_campaign=report-handoff&utm_content=compliance"
|
||||
"&intent=import-findings"
|
||||
)
|
||||
)
|
||||
assert pricing.href == "https://prowler.com/pricing"
|
||||
for link in links:
|
||||
assert link.target == "_blank"
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_no_upload_controls(self):
|
||||
assert "Upload" not in self.text
|
||||
assert '"type": "Input"' not in self.text
|
||||
|
||||
|
||||
class TestComplianceLayoutIncludesHandoff:
|
||||
def test_card_is_last_element_and_header_link_kept(self):
|
||||
from dash import html
|
||||
|
||||
from dashboard.lib.layouts import create_layout_compliance
|
||||
|
||||
layout = create_layout_compliance(
|
||||
html.Div(id="a"), html.Div(id="d"), html.Div(id="r"), html.Div(id="c")
|
||||
)
|
||||
last = layout.children[-1]
|
||||
assert "pc-handoff" in last.className
|
||||
text = _serialize(layout)
|
||||
assert text.count("Keep scanning locally.") == 1
|
||||
# Existing header link and compliance containers are untouched
|
||||
assert "Subscribe to Prowler Cloud" in text
|
||||
assert '"id": "output"' in text
|
||||
assert '"id": "overall_status_result_graph"' in text
|
||||
|
||||
|
||||
class TestMcpPage:
|
||||
def setup_method(self):
|
||||
self.feature = CLOUD_FEATURES_BY_SLUG["mcp"]
|
||||
self.layout = build_cloud_layout(self.feature)
|
||||
self.text = _serialize(self.layout)
|
||||
|
||||
def test_copy_and_template(self):
|
||||
assert self.feature["nav_label"] == "MCP Server"
|
||||
assert self.feature["route"] == "/cloud/mcp"
|
||||
assert "Unlock Prowler MCP" in self.text
|
||||
assert "mcp.prowler.com" in self.text
|
||||
assert "Prowler MCP Server is currently a preview feature." in self.text
|
||||
assert len(self.feature["benefits"]) == 3
|
||||
assert "How to bring your results" not in self.text
|
||||
|
||||
def test_links(self):
|
||||
links = list(_links(self.layout))
|
||||
assert [a.children for a in links] == [
|
||||
"Upgrade to Prowler Cloud",
|
||||
"Connect your AI agent",
|
||||
]
|
||||
assert links[0].href == f"{CLOUD_CTA_BASE}mcp"
|
||||
assert links[1].href == "https://docs.prowler.com/user-guide/ai-agents/"
|
||||
for link in links:
|
||||
assert link.target == "_blank"
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_icon_asset_exists(self):
|
||||
from pathlib import Path
|
||||
|
||||
icon = Path("dashboard") / self.feature["icon"].lstrip("/")
|
||||
assert icon.is_file()
|
||||
|
||||
|
||||
class TestPageTitles:
|
||||
def test_cloud_pages_are_titled_prowler_local_dashboard(self):
|
||||
import dash
|
||||
|
||||
for feature in CLOUD_FEATURES:
|
||||
page = dash.page_registry[f"cloud_{feature['slug'].replace('-', '_')}"]
|
||||
assert page["title"] == f"Prowler Local Dashboard - {feature['page_title']}"
|
||||
|
||||
|
||||
class TestExploitableIssuesPage:
|
||||
def setup_method(self):
|
||||
self.feature = CLOUD_FEATURES_BY_SLUG["exploitable-issues"]
|
||||
self.layout = build_cloud_layout(self.feature)
|
||||
self.text = _serialize(self.layout)
|
||||
|
||||
def test_copy_and_template(self):
|
||||
assert self.feature["nav_label"] == "Exploitable Issues"
|
||||
assert self.feature["route"] == "/cloud/exploitable-issues"
|
||||
assert "Unlock Exploitable Issues" in self.text
|
||||
assert "toxic combinations" in self.text
|
||||
assert "exploitable, likely, conditional or not exploitable" in self.text
|
||||
assert "rolling out" in self.text
|
||||
assert len(self.feature["benefits"]) == 3
|
||||
|
||||
def test_single_upgrade_cta(self):
|
||||
links = list(_links(self.layout))
|
||||
assert len(links) == 1
|
||||
assert links[0].children == "Upgrade to Prowler Cloud"
|
||||
assert links[0].href == f"{CLOUD_CTA_BASE}exploitable-issues"
|
||||
assert links[0].rel == "noopener noreferrer"
|
||||
|
||||
|
||||
class TestScreenshots:
|
||||
def _images(self, component):
|
||||
stack = [component]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, html.Img):
|
||||
yield node
|
||||
children = getattr(node, "children", None)
|
||||
if isinstance(children, (list, tuple)):
|
||||
stack.extend(reversed(children))
|
||||
elif children is not None and hasattr(children, "to_plotly_json"):
|
||||
stack.append(children)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slug", [f["slug"] for f in CLOUD_FEATURES if f.get("screenshot")]
|
||||
)
|
||||
def test_screenshot_rendered_with_existing_asset(self, slug):
|
||||
from pathlib import Path
|
||||
|
||||
feature = CLOUD_FEATURES_BY_SLUG[slug]
|
||||
images = list(self._images(build_cloud_layout(feature)))
|
||||
assert len(images) == 1
|
||||
image = images[0]
|
||||
assert (
|
||||
image.src
|
||||
== f"/assets/images/cloud/screenshots/{feature['screenshot']['file']}"
|
||||
)
|
||||
assert image.alt and "Prowler" in image.alt
|
||||
assert image.className == "pc-shot"
|
||||
assert (Path("dashboard") / image.src.lstrip("/")).is_file()
|
||||
|
||||
def test_every_gated_page_but_exploitable_issues_has_a_screenshot(self):
|
||||
missing = [f["slug"] for f in CLOUD_FEATURES if not f.get("screenshot")]
|
||||
assert missing == ["exploitable-issues"]
|
||||
|
||||
def test_pages_without_screenshot_render_no_image(self):
|
||||
layout = build_cloud_layout(CLOUD_FEATURES_BY_SLUG["exploitable-issues"])
|
||||
assert list(self._images(layout)) == []
|
||||
|
||||
|
||||
class TestSidebarOrder:
|
||||
def test_findings_first_then_exploitable_issues(self):
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("dashboard/__main__.py").read_text()
|
||||
match = re.search(r'"Upgrade to Prowler Cloud",\s*\[([^\]]+)\]', source)
|
||||
slugs = re.findall(r'"([a-z-]+)"', match.group(1))
|
||||
assert slugs[:2] == ["findings", "exploitable-issues"]
|
||||
assert set(slugs) == {
|
||||
"findings",
|
||||
"exploitable-issues",
|
||||
"lighthouse-ai",
|
||||
"mcp",
|
||||
"attack-paths",
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from werkzeug.serving import BaseWSGIServer
|
||||
|
||||
from dashboard.lib import startup
|
||||
from dashboard.lib.startup import (
|
||||
DashRunningLineFilter,
|
||||
format_ready_message,
|
||||
install_server_ready_hook,
|
||||
server_urls,
|
||||
silence_dash_running_line,
|
||||
)
|
||||
|
||||
|
||||
class TestServerUrls:
|
||||
def test_loopback_host(self):
|
||||
assert server_urls("127.0.0.1", 11666) == ("http://127.0.0.1:11666/", [])
|
||||
|
||||
def test_custom_host_and_port(self):
|
||||
assert server_urls("10.0.0.5", 8080) == ("http://10.0.0.5:8080/", [])
|
||||
|
||||
def test_ipv6_host_is_bracketed(self):
|
||||
assert server_urls("::1", 11666) == ("http://[::1]:11666/", [])
|
||||
|
||||
def test_wildcard_host_uses_loopback_and_lists_interface(self):
|
||||
with mock.patch.object(
|
||||
startup, "get_interface_ip", return_value="192.168.1.20"
|
||||
):
|
||||
primary, others = server_urls("0.0.0.0", 11666)
|
||||
assert primary == "http://127.0.0.1:11666/"
|
||||
assert others == ["http://192.168.1.20:11666/"]
|
||||
|
||||
def test_wildcard_host_without_interface(self):
|
||||
with mock.patch.object(startup, "get_interface_ip", side_effect=OSError):
|
||||
assert server_urls("0.0.0.0", 11666) == ("http://127.0.0.1:11666/", [])
|
||||
|
||||
def test_https_scheme(self):
|
||||
assert server_urls("127.0.0.1", 443, "https")[0] == "https://127.0.0.1:443/"
|
||||
|
||||
|
||||
class TestFormatReadyMessage:
|
||||
def test_single_address(self):
|
||||
message = format_ready_message("127.0.0.1", 11666)
|
||||
assert "Prowler Local Dashboard now is running on" in message
|
||||
assert "http://127.0.0.1:11666/" in message
|
||||
assert message.endswith("Press Ctrl+C to stop.")
|
||||
assert "Also reachable" not in message
|
||||
assert "all interfaces" not in message
|
||||
|
||||
def test_all_interfaces(self):
|
||||
with mock.patch.object(
|
||||
startup, "get_interface_ip", return_value="192.168.1.20"
|
||||
):
|
||||
message = format_ready_message("0.0.0.0", 11666)
|
||||
assert "http://127.0.0.1:11666/" in message
|
||||
assert "Listening on all interfaces (0.0.0.0)." in message
|
||||
assert "Also reachable at: http://192.168.1.20:11666/" in message
|
||||
assert "http://0.0.0.0" not in message
|
||||
|
||||
|
||||
class TestInstallServerReadyHook:
|
||||
def setup_method(self):
|
||||
self._original = BaseWSGIServer.log_startup
|
||||
|
||||
def teardown_method(self):
|
||||
BaseWSGIServer.log_startup = getattr(
|
||||
BaseWSGIServer.log_startup, "_prowler_original", self._original
|
||||
)
|
||||
|
||||
def test_announces_once_after_bind_and_keeps_original(self):
|
||||
printed = []
|
||||
calls = []
|
||||
BaseWSGIServer.log_startup = lambda self: calls.append(self.port)
|
||||
install_server_ready_hook(printed.append)
|
||||
|
||||
server = SimpleNamespace(host="127.0.0.1", port=11667, ssl_context=None)
|
||||
BaseWSGIServer.log_startup(server)
|
||||
BaseWSGIServer.log_startup(server)
|
||||
|
||||
assert calls == [11667, 11667] # werkzeug's own startup logic still runs
|
||||
assert len(printed) == 1 # a reload or second run does not repeat it
|
||||
assert "http://127.0.0.1:11667/" in printed[0]
|
||||
assert "Press Ctrl+C to stop." in printed[0]
|
||||
|
||||
def test_install_is_idempotent(self):
|
||||
first = install_server_ready_hook(lambda _: None)
|
||||
second = install_server_ready_hook(lambda _: None)
|
||||
assert first is second
|
||||
|
||||
def test_failed_bind_is_never_announced(self):
|
||||
# make_server raises before log_startup is reached; nothing to announce
|
||||
printed = []
|
||||
install_server_ready_hook(printed.append)
|
||||
with mock.patch("werkzeug.serving.make_server", side_effect=OSError("in use")):
|
||||
from werkzeug.serving import run_simple
|
||||
|
||||
try:
|
||||
run_simple("127.0.0.1", 1, lambda *_: [], use_reloader=False)
|
||||
except OSError:
|
||||
pass
|
||||
assert printed == []
|
||||
|
||||
|
||||
class TestSilenceDashRunningLine:
|
||||
def test_drops_only_the_running_line(self):
|
||||
logger = logging.getLogger("tests.dashboard.startup.dash")
|
||||
logger.handlers = []
|
||||
logger.setLevel(logging.INFO)
|
||||
records = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = records.append
|
||||
logger.addHandler(handler)
|
||||
app = SimpleNamespace(logger=logger)
|
||||
|
||||
silence_dash_running_line(app)
|
||||
silence_dash_running_line(app)
|
||||
|
||||
logger.info(
|
||||
"Dash is running on %s://%s%s%s\n", "http", "127.0.0.1", ":11666", "/"
|
||||
)
|
||||
logger.info("Something else worth seeing")
|
||||
|
||||
assert [r.getMessage() for r in records] == ["Something else worth seeing"]
|
||||
assert sum(isinstance(f, DashRunningLineFilter) for f in logger.filters) == 1
|
||||
@@ -0,0 +1,113 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.config.config import prowler_version
|
||||
from prowler.lib.banner import (
|
||||
DASHBOARD_CTA_URL,
|
||||
NO_PROMOTIONS_ENV,
|
||||
print_dashboard_banner,
|
||||
print_dashboard_loading,
|
||||
print_dashboard_promotion,
|
||||
print_prowler_cloud_banner,
|
||||
promotions_disabled,
|
||||
)
|
||||
|
||||
|
||||
class TestPromotionsDisabled:
|
||||
def test_unset_is_enabled(self, monkeypatch):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
assert promotions_disabled() is False
|
||||
|
||||
def test_truthy_values_disable(self, monkeypatch):
|
||||
for value in ("1", "true", "YES", "anything"):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, value)
|
||||
assert promotions_disabled() is True, value
|
||||
|
||||
def test_falsy_values_keep_enabled(self, monkeypatch):
|
||||
for value in ("0", "false", "no", "", " "):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, value)
|
||||
assert promotions_disabled() is False, value
|
||||
|
||||
|
||||
class TestDashboardBanner:
|
||||
def test_header_has_identity_and_version(self, capsys):
|
||||
print_dashboard_banner()
|
||||
out = capsys.readouterr().out
|
||||
assert f"Local Dashboard - v{prowler_version}" in out
|
||||
assert "Date:" in out
|
||||
assert "Loading CSV files" not in out
|
||||
# The long commercial banner is not part of the dashboard header
|
||||
assert "snapshot" not in out
|
||||
assert "Start free" not in out
|
||||
assert "aws s3 cp" not in out
|
||||
|
||||
def test_loading_line_names_the_directory(self, capsys):
|
||||
print_dashboard_loading("/tmp/synthetic/output")
|
||||
out = capsys.readouterr().out
|
||||
assert "Loading CSV files from /tmp/synthetic/output ..." in out
|
||||
|
||||
def test_promotion_block_content(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
print_dashboard_promotion()
|
||||
out = capsys.readouterr().out
|
||||
assert "Keep scanning locally. Bring your findings to Prowler Cloud." in out
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in out
|
||||
)
|
||||
assert "Explore Prowler Cloud" in out
|
||||
assert "\u2713" in out and "\u2502" in out # check and bar like the CLI banner
|
||||
assert "Nothing is uploaded automatically." in out
|
||||
# The UTM-tagged URL is only the OSC 8 target; the visible text is the domain
|
||||
assert f"\033]8;;{DASHBOARD_CTA_URL}\033\\cloud.prowler.com" in out
|
||||
assert f"\n{DASHBOARD_CTA_URL}" not in out
|
||||
assert out.count(DASHBOARD_CTA_URL) == 1
|
||||
assert DASHBOARD_CTA_URL == (
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-dashboard-terminal"
|
||||
"&utm_medium=oss&utm_campaign=report-handoff"
|
||||
)
|
||||
assert out.count("Keep scanning locally.") == 1
|
||||
# Readable in 80 columns: every non-URL line fits
|
||||
for line in out.splitlines():
|
||||
if "http" not in line and "\033" not in line:
|
||||
assert len(line) <= 80, line
|
||||
|
||||
def test_promotion_skipped_when_disabled(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, "1")
|
||||
print_dashboard_promotion()
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_scan_banner_untouched(self, capsys):
|
||||
with mock.patch("prowler.lib.banner.print_prowler_cloud_banner") as cloud:
|
||||
from prowler.lib.banner import print_banner
|
||||
|
||||
print_banner()
|
||||
out = capsys.readouterr().out
|
||||
assert f"CLI - v{prowler_version}" in out
|
||||
cloud.assert_called_once_with(None)
|
||||
|
||||
|
||||
class TestCliCloudBanner:
|
||||
def test_content_is_brief_and_tailored(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
monkeypatch.setenv("NO_COLOR", "1")
|
||||
print_prowler_cloud_banner("aws")
|
||||
out = capsys.readouterr().out
|
||||
assert "Prowler Cloud gives you the full picture." in out
|
||||
assert "Exploitable Issues" in out
|
||||
assert "AWS Organization" in out
|
||||
assert "cloud.prowler.com" in out
|
||||
assert "Nothing is uploaded automatically." in out
|
||||
assert "Bulk Provisioning" not in out
|
||||
assert len([ln for ln in out.splitlines() if ln.strip()]) <= 12
|
||||
|
||||
def test_unknown_provider_uses_generic_line(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
print_prowler_cloud_banner("github")
|
||||
out = capsys.readouterr().out
|
||||
assert "connect all your accounts" in out
|
||||
assert "AWS Organization" not in out
|
||||
|
||||
def test_respects_no_promotions(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, "1")
|
||||
print_prowler_cloud_banner("aws")
|
||||
assert capsys.readouterr().out == ""
|
||||
@@ -8,7 +8,12 @@ from mock import MagicMock, patch
|
||||
from prowler.config.config import prowler_version, timestamp
|
||||
from prowler.lib.cli.redact import redact_argv
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.lib.outputs.html.html import (
|
||||
HTML,
|
||||
PROWLER_CLOUD_CTA_URL,
|
||||
PROWLER_CLOUD_IMPORT_DOCS_URL,
|
||||
PROWLER_CLOUD_PRICING_URL,
|
||||
)
|
||||
from prowler.providers.github.models import GithubAppIdentityInfo
|
||||
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
|
||||
from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider
|
||||
@@ -1521,3 +1526,133 @@ class TestHTML:
|
||||
]
|
||||
output_data = HTML(findings).data[0]
|
||||
assert 'href="https://hub.prowler.com/check/check-id"' in output_data
|
||||
|
||||
def test_write_header_omits_cloud_cta_by_default(self):
|
||||
mock_file = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file, provider, html_stats)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert content == get_aws_html_header(sys.argv[1:])
|
||||
assert "prowler-cloud-cta" not in content
|
||||
assert PROWLER_CLOUD_CTA_URL not in content
|
||||
assert "utm_campaign=report-handoff" not in content
|
||||
assert PROWLER_CLOUD_PRICING_URL not in content
|
||||
assert PROWLER_CLOUD_IMPORT_DOCS_URL not in content
|
||||
|
||||
def test_write_header_omits_cloud_cta_when_from_cli_but_not_enabled(self):
|
||||
# `from_cli` alone must never turn the block on: it is a batching flag, not an
|
||||
# OSS marker, and it defaults to True for every library consumer.
|
||||
mock_file = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file, provider, html_stats, from_cli=True)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert "prowler-cloud-cta" not in content
|
||||
assert "cloud.prowler.com" not in content
|
||||
|
||||
def test_write_header_includes_cloud_cta_when_enabled(self):
|
||||
mock_file = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file, provider, html_stats, show_cloud_cta=True)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert content.count('class="row mt-3 prowler-cloud-cta"') == 1
|
||||
assert "Keep scanning locally. Bring your findings to Prowler Cloud." in content
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in content
|
||||
)
|
||||
assert f'href="{PROWLER_CLOUD_CTA_URL}"' in content
|
||||
assert ">Explore Prowler Cloud</a>" in content
|
||||
assert f'href="{PROWLER_CLOUD_PRICING_URL}"' in content
|
||||
assert ">Compare editions</a>" in content
|
||||
assert f'href="{PROWLER_CLOUD_IMPORT_DOCS_URL}"' in content
|
||||
assert ">How to import findings</a>" in content
|
||||
assert "Nothing is uploaded automatically." in content
|
||||
assert 'rel="noopener noreferrer"' in content
|
||||
assert (
|
||||
"@media print { .prowler-cloud-cta { display: none !important; } }"
|
||||
in content
|
||||
)
|
||||
# Placed after the assessment summary and before the findings table
|
||||
assert (
|
||||
content.index("AWS Assessment Summary")
|
||||
< content.index('class="row mt-3 prowler-cloud-cta"')
|
||||
< content.index('id="findingsTable"')
|
||||
)
|
||||
# No script, form or automatic request is introduced by the block
|
||||
cta_block = HTML.get_cloud_cta()
|
||||
assert "<script" not in cta_block
|
||||
assert "<form" not in cta_block
|
||||
assert "<img" not in cta_block
|
||||
assert "<iframe" not in cta_block
|
||||
|
||||
def test_cloud_cta_does_not_change_assessment_content(self):
|
||||
mock_file_oss = StringIO()
|
||||
mock_file_cloud = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file_oss, provider, html_stats, show_cloud_cta=True)
|
||||
HTML.write_header(mock_file_cloud, provider, html_stats, show_cloud_cta=False)
|
||||
|
||||
oss = mock_file_oss.getvalue()
|
||||
cloud = mock_file_cloud.getvalue()
|
||||
stripped = oss.replace(HTML.get_cloud_cta(), "").replace(
|
||||
"\n @media print { .prowler-cloud-cta { display: none !important; } }",
|
||||
"",
|
||||
)
|
||||
assert stripped == cloud
|
||||
|
||||
def test_batch_write_data_to_file_oss_cli_includes_cloud_cta(self):
|
||||
mock_file = StringIO()
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
|
||||
)
|
||||
]
|
||||
output = HTML(findings, show_cloud_cta=True)
|
||||
output._file_descriptor = mock_file
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
with patch.object(mock_file, "close", return_value=None):
|
||||
output.batch_write_data_to_file(provider, html_stats)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert PROWLER_CLOUD_CTA_URL in content
|
||||
assert content.count('class="row mt-3 prowler-cloud-cta"') == 1
|
||||
# Findings and footer are untouched
|
||||
assert pass_html_finding in content
|
||||
assert content.endswith(html_footer)
|
||||
|
||||
def test_batch_write_data_to_file_api_omits_cloud_cta(self):
|
||||
# Mirrors how Prowler Cloud / Private Cloud construct the output in the API
|
||||
mock_file = StringIO()
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
|
||||
)
|
||||
]
|
||||
output = HTML(
|
||||
findings,
|
||||
file_path=None,
|
||||
file_extension=".html",
|
||||
from_cli=False,
|
||||
)
|
||||
output._file_descriptor = mock_file
|
||||
output.close_file = True
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
with patch.object(mock_file, "close", return_value=None):
|
||||
output.batch_write_data_to_file(provider, html_stats)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert "prowler-cloud-cta" not in content
|
||||
assert "cloud.prowler.com" not in content
|
||||
assert "utm_campaign=report-handoff" not in content
|
||||
assert PROWLER_CLOUD_PRICING_URL not in content
|
||||
assert PROWLER_CLOUD_IMPORT_DOCS_URL not in content
|
||||
assert pass_html_finding in content
|
||||
@@ -183,7 +183,31 @@ test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => {
|
||||
|
||||
---
|
||||
|
||||
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there; use `components/shadcn/`.
|
||||
## TECH STACK
|
||||
|
||||
Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
|
||||
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | Recharts 2.15.4
|
||||
|
||||
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there.
|
||||
|
||||
---
|
||||
|
||||
## PROJECT STRUCTURE
|
||||
|
||||
```text
|
||||
ui/
|
||||
├── app/(auth)/ # Auth pages
|
||||
├── app/(prowler)/ # Main app: compliance, findings, providers, scans
|
||||
├── components/shadcn/ # shadcn/ui components (USE THIS)
|
||||
├── components/ui/ # Cloud-overlay re-export shims (do not add here)
|
||||
├── actions/ # Server actions
|
||||
├── types/ # Shared types
|
||||
├── hooks/ # Shared hooks
|
||||
├── lib/ # Utilities
|
||||
├── store/ # Zustand state
|
||||
├── tests/ # Playwright E2E
|
||||
└── styles/ # Global CSS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ import {
|
||||
} from "@/lib";
|
||||
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleScanFilter,
|
||||
sampleFindingGroupResources,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
/**
|
||||
@@ -191,18 +187,6 @@ async function fetchFindingGroupResourcesEndpoint(
|
||||
filters = {},
|
||||
}: FetchFindingGroupResourcesParams,
|
||||
) {
|
||||
if (isSampleScanFilter(filters)) {
|
||||
const status = filters["filter[status]"] ?? filters["filter[status__in]"];
|
||||
const search = filters["filter[name__icontains]"];
|
||||
return sampleFindingGroupResources({
|
||||
checkId,
|
||||
page,
|
||||
pageSize,
|
||||
status: Array.isArray(status) ? status.join(",") : status,
|
||||
search: Array.isArray(search) ? search[0] : search,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const normalizedFilters = normalizeFindingGroupResourceFilters(filters);
|
||||
|
||||
|
||||
@@ -4,10 +4,6 @@ import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { IN_WATCHLIST_FILTER_KEY } from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
isSampleId,
|
||||
sampleComplianceFrameworks,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { FindingComplianceFrameworksResult } from "@/types/compliance-watchlist";
|
||||
|
||||
@@ -36,9 +32,6 @@ export const getFindingComplianceFrameworks = async (
|
||||
}
|
||||
|
||||
const { inWatchlist = false } = parsedOptions.data;
|
||||
if (isSampleId(parsedFindingId.data)) {
|
||||
return sampleComplianceFrameworks(parsedFindingId.data);
|
||||
}
|
||||
if (!isCloud()) return { frameworks: [], unavailable: true };
|
||||
|
||||
try {
|
||||
|
||||
@@ -278,14 +278,6 @@ describe("getLatestFindingsByResourceUid", () => {
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("does not substitute sample records for a real resource with the same ARN", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
await getLatestFindingsByResourceUid({
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should restrict to FAIL, exclude muted findings, and apply severity/time sorting by default", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
} from "@/lib";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
sampleFindingsDocument,
|
||||
sampleFindingsQuery,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500;
|
||||
@@ -259,24 +255,12 @@ export const getLatestFindingsByResourceUid = async ({
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
includeMuted = false,
|
||||
sample = false,
|
||||
}: {
|
||||
resourceUid: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
includeMuted?: boolean;
|
||||
/** Explicitly scoped by the sample finding; real resource names can match the demo. */
|
||||
sample?: boolean;
|
||||
}) => {
|
||||
if (sample) {
|
||||
return sampleFindingsDocument(
|
||||
sampleFindingsQuery({
|
||||
"filter[resource_uid]": resourceUid,
|
||||
"filter[status]": "FAIL",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-records";
|
||||
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_BILLING_HREF,
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
@@ -145,12 +143,6 @@ const createSummary = (
|
||||
summary.disabledReason = options.disabledReason;
|
||||
}
|
||||
|
||||
// Sample findings have no triage record to write to.
|
||||
if (isSampleId(summary.findingId)) {
|
||||
summary.canEdit = false;
|
||||
summary.disabledReason = FINDING_TRIAGE_DISABLED_REASON.SAMPLE;
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { ACTION_ERROR_STATUS, UsageLimitError } from "@/lib/action-errors";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
@@ -285,7 +281,6 @@ export async function loadFindingTriageDetail(
|
||||
}
|
||||
|
||||
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
|
||||
if (isSampleId(input.findingId)) throw new Error(SAMPLE_WRITE_MESSAGE);
|
||||
try {
|
||||
return await applyFindingTriageUpdate(input);
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,14 +6,6 @@ import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findi
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
getSampleFinding,
|
||||
isSampleId,
|
||||
isSampleScanFilter,
|
||||
sampleFindingDocument,
|
||||
sampleFindingsDocument,
|
||||
sampleFindingsQuery,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const withFindingTriageSummaries = <T extends { data?: unknown }>(
|
||||
@@ -32,14 +24,6 @@ export const getFindings = async ({
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
if (isSampleScanFilter(filters)) {
|
||||
return withFindingTriageSummaries(
|
||||
sampleFindingsDocument(
|
||||
sampleFindingsQuery(filters).slice(0, Number(pageSize) || 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1)
|
||||
@@ -180,11 +164,6 @@ export const getFindingById = async (
|
||||
include = "",
|
||||
_options?: GetFindingByIdOptions,
|
||||
) => {
|
||||
if (isSampleId(findingId)) {
|
||||
const finding = getSampleFinding(findingId);
|
||||
return finding ? sampleFindingDocument(finding) : undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { handleApiError } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
@@ -126,8 +122,6 @@ export const sendJiraDispatch = async ({
|
||||
| { success: true; taskId: string; message: string }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
if (targetIds.some(isSampleId))
|
||||
return { success: false, error: SAMPLE_WRITE_MESSAGE };
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { getSampleIssueDetail } from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
sampleFindingsQuery,
|
||||
sampleIssueFindingsDocument,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
ISSUE_FINDINGS_LIMIT,
|
||||
@@ -93,11 +86,6 @@ export const getIssues = async ({
|
||||
export const getIssue = async (
|
||||
id: string,
|
||||
): Promise<IssueDetailResponse | undefined> => {
|
||||
if (isSampleId(id)) {
|
||||
const issue = getSampleIssueDetail(id);
|
||||
return issue ? { data: issue } : undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -118,13 +106,6 @@ export const getIssueFindings = async (
|
||||
if (!scanId) return undefined;
|
||||
const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT);
|
||||
if (uids.length === 0) return [];
|
||||
if (isSampleId(scanId)) {
|
||||
return toIssueFindings(
|
||||
sampleIssueFindingsDocument(
|
||||
sampleFindingsQuery({ "filter[uid__in]": uids.join(",") }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/findings`);
|
||||
@@ -175,38 +156,32 @@ export const updateIssueTriage = async (
|
||||
id: string,
|
||||
{ status, note }: UpdateIssueTriageInput,
|
||||
): Promise<IssueWriteResult> =>
|
||||
isSampleId(id)
|
||||
? { error: SAMPLE_WRITE_MESSAGE }
|
||||
: writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
|
||||
/** Add a note to an issue's triage without changing its status. */
|
||||
export const addIssueNote = async (
|
||||
id: string,
|
||||
body: string,
|
||||
): Promise<IssueWriteResult> =>
|
||||
isSampleId(id)
|
||||
? { error: SAMPLE_WRITE_MESSAGE }
|
||||
: writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
|
||||
export const getIssueNotes = async (
|
||||
id: string,
|
||||
): Promise<IssueTriageNote[] | undefined> => {
|
||||
if (isSampleId(id)) return [];
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
|
||||
import {
|
||||
DeleteMuteRuleActionState,
|
||||
@@ -135,10 +131,6 @@ export const createMuteRule = async (
|
||||
};
|
||||
}
|
||||
|
||||
if (findingIds.some(isSampleId)) {
|
||||
return { errors: { general: SAMPLE_WRITE_MESSAGE } };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/mute-rules`);
|
||||
|
||||
|
||||
@@ -12,10 +12,6 @@ import {
|
||||
sanitizeErrorMessage,
|
||||
} from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleId,
|
||||
sampleResourceEvents,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
@@ -188,9 +184,6 @@ export const getResourceEvents = async (
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => {
|
||||
if (isSampleId(resourceId)) {
|
||||
return { data: sampleResourceEvents(resourceId) };
|
||||
}
|
||||
if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) {
|
||||
return { error: "Invalid resource ID format." };
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ import {
|
||||
isReportDownloadLocked,
|
||||
REPORT_DOWNLOAD_LOCKED_ERROR,
|
||||
} from "@/lib/report-download-access";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
@@ -199,7 +195,6 @@ export const createPartialScan = async ({
|
||||
providerId: string;
|
||||
resourceUids: string[];
|
||||
}) => {
|
||||
if (isSampleId(providerId)) return { error: SAMPLE_WRITE_MESSAGE };
|
||||
if (!providerId) {
|
||||
return { error: "Provider ID is required" };
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface OverviewGridProps {
|
||||
threatScore: ReactNode;
|
||||
// Cloud-only widgets: the page leaves them out where they do not apply.
|
||||
exploitability?: ReactNode;
|
||||
topIssues?: ReactNode;
|
||||
checkFindings: ReactNode;
|
||||
riskSeverity: ReactNode;
|
||||
resourcesInventory: ReactNode;
|
||||
complianceWatchlist: ReactNode;
|
||||
serviceWatchlist: ReactNode;
|
||||
attackSurface: ReactNode;
|
||||
severityOverTime: ReactNode;
|
||||
graphsTabs: ReactNode;
|
||||
}
|
||||
|
||||
/** The Overview widget layout, shared by the real page and the sample preview. */
|
||||
export function OverviewGrid({
|
||||
threatScore,
|
||||
exploitability,
|
||||
topIssues,
|
||||
checkFindings,
|
||||
riskSeverity,
|
||||
resourcesInventory,
|
||||
complianceWatchlist,
|
||||
serviceWatchlist,
|
||||
attackSurface,
|
||||
severityOverTime,
|
||||
graphsTabs,
|
||||
}: OverviewGridProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
{threatScore}
|
||||
{exploitability}
|
||||
{checkFindings}
|
||||
{riskSeverity}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{resourcesInventory}</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
{complianceWatchlist}
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
{serviceWatchlist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{attackSurface}
|
||||
{topIssues}
|
||||
{severityOverTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{graphsTabs}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_SERVICES_RESPONSE } from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewSamplePreview } from "./overview-sample-preview";
|
||||
|
||||
// Card links import server actions; their auth chain has no place in jsdom.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
// The graph tabs have their own test; here they would only add chart noise.
|
||||
vi.mock("./sample-graphs-tabs", () => ({
|
||||
SampleGraphsTabs: () => <div data-testid="sample-graphs-tabs" />,
|
||||
}));
|
||||
|
||||
describe("OverviewSamplePreview", () => {
|
||||
it("fills the Overview with the demo account's numbers", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
expect(screen.getByText("Findings Severity Over Time")).toBeVisible();
|
||||
expect(screen.getByText("Internet Exposed Resources")).toBeVisible();
|
||||
expect(screen.getByTestId("sample-graphs-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only leaves links live where the target page shows the sample", () => {
|
||||
const { container } = render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
const links = Array.from(container.querySelectorAll("a"));
|
||||
const live = links.filter((link) => !link.closest("[inert]"));
|
||||
expect(live.length).toBeGreaterThan(0);
|
||||
expect(live.length).toBeLessThan(links.length);
|
||||
for (const link of live) {
|
||||
expect(link.getAttribute("href")).toMatch(/^\/(findings|issues)(\?|$)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists every sample service in the watchlist", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
// The riskiest service of the demo account leads the watchlist.
|
||||
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
|
||||
(left, right) => right.attributes.fail - left.attributes.fail,
|
||||
)[0];
|
||||
expect(
|
||||
screen.getAllByText(new RegExp(riskiest.id, "i")).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
|
||||
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
|
||||
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
SAMPLE_ISSUES_OVERVIEW,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
SAMPLE_ATTACK_SURFACE_RESPONSE,
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
SAMPLE_FAILED_BY_SEVERITY,
|
||||
SAMPLE_RESOURCE_GROUPS_RESPONSE,
|
||||
SAMPLE_SERVICES_RESPONSE,
|
||||
SAMPLE_SEVERITY_TRENDS,
|
||||
SAMPLE_STATUS,
|
||||
SAMPLE_THREAT_SCORE,
|
||||
} from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewGrid } from "../_components/overview-grid";
|
||||
import { AttackSurface } from "../attack-surface/_components/attack-surface";
|
||||
import { ExploitabilityChart } from "../exploitability";
|
||||
import { TopIssues } from "../exploitability/_components/top-issues";
|
||||
import { ResourcesInventory } from "../resources-inventory/_components/resources-inventory";
|
||||
import { RiskSeverityChart } from "../risk-severity";
|
||||
import { FindingSeverityOverTime } from "../severity-over-time";
|
||||
import { StatusChart } from "../status-chart";
|
||||
import { ThreatScore } from "../threat-score";
|
||||
import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
// `contents` keeps each card a direct item of the grid's flex rows, while
|
||||
// `inert` stops links into pages with no sample data (compliance details,
|
||||
// resources). Cards that link to Findings or Issues stay live: those pages
|
||||
// show the sample and apply the link's filters.
|
||||
const Inert = ({ children }: { children: ReactNode }) => (
|
||||
<div inert className="contents">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const NO_SCOPE: Record<string, string> = {};
|
||||
|
||||
interface OverviewSamplePreviewProps {
|
||||
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
|
||||
showIssues: boolean;
|
||||
}
|
||||
|
||||
/** The Overview filled with the demo account, for tenants without a scan. */
|
||||
export function OverviewSamplePreview({
|
||||
showIssues,
|
||||
}: OverviewSamplePreviewProps) {
|
||||
const complianceItems = adaptComplianceWatchlistResponse(
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
).map((item) => ({
|
||||
id: item.id,
|
||||
framework: item.complianceId,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
score: item.score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Inert>
|
||||
<ThreatScore
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
exploitability={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<ExploitabilityChart
|
||||
counts={verdictCounts(SAMPLE_ISSUES_OVERVIEW)}
|
||||
issues={SAMPLE_ISSUES_OVERVIEW.issues}
|
||||
findings={SAMPLE_ISSUES_OVERVIEW.findings}
|
||||
actionable={SAMPLE_ISSUES_OVERVIEW.actionable}
|
||||
coveredFindings={SAMPLE_ISSUES_OVERVIEW.not_exploitable_findings}
|
||||
scope={NO_SCOPE}
|
||||
/>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<StatusChart
|
||||
failFindingsData={{
|
||||
total: SAMPLE_STATUS.fail,
|
||||
new: SAMPLE_STATUS.failNew,
|
||||
}}
|
||||
passFindingsData={{
|
||||
total: SAMPLE_STATUS.pass,
|
||||
new: SAMPLE_STATUS.passNew,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
|
||||
resourcesInventory={
|
||||
<Inert>
|
||||
<ResourcesInventory
|
||||
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
|
||||
/>
|
||||
</Inert>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Inert>
|
||||
<ComplianceWatchlist items={complianceItems} hasWatchlist />
|
||||
</Inert>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
|
||||
}
|
||||
attackSurface={
|
||||
<AttackSurface
|
||||
items={adaptAttackSurfaceOverview(SAMPLE_ATTACK_SURFACE_RESPONSE)}
|
||||
/>
|
||||
}
|
||||
topIssues={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<TopIssues issues={SAMPLE_ISSUES} scope={NO_SCOPE} />
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<Inert>
|
||||
<Card variant="base" className="flex h-full flex-1 flex-col">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Findings Severity Over Time</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col px-6">
|
||||
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Inert>
|
||||
}
|
||||
graphsTabs={<SampleGraphsTabs />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
describe("SampleGraphsTabs", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("shows the sample new failed findings in the default tab without fetching", () => {
|
||||
// Given
|
||||
const firstNewCheck = SAMPLE_CHECKS.find((check) => check.newFailed > 0)!;
|
||||
|
||||
// When
|
||||
render(<SampleGraphsTabs />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "New Findings", selected: true }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText(firstNewCheck.title).length).toBeGreaterThan(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { ThreatMap } from "@/components/graphs/threat-map";
|
||||
import { LinkToFindings } from "@/components/overview";
|
||||
import { LatestFindingsTable } from "@/components/overview/new-findings-table/table";
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import {
|
||||
SAMPLE_NEW_FINDINGS,
|
||||
SAMPLE_RADAR_DATA,
|
||||
SAMPLE_RISK_PLOT_POINTS,
|
||||
SAMPLE_SANKEY_DATA,
|
||||
SAMPLE_THREAT_MAP_DATA,
|
||||
} from "@/lib/sample-preview/sample-graphs";
|
||||
|
||||
import { GraphsTabsClient } from "../graphs-tabs/_components/graphs-tabs-client";
|
||||
import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
|
||||
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
|
||||
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
|
||||
|
||||
// Inert so sample rows and charts never open drill-downs or detail panels.
|
||||
const Inert = ({ children }: { children: ReactNode }) => (
|
||||
<div inert className="flex w-full flex-1">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
|
||||
findings: (
|
||||
<Inert>
|
||||
<div className="flex w-full flex-col">
|
||||
<LatestFindingsTable
|
||||
data={SAMPLE_NEW_FINDINGS}
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle>Latest New Failed Findings</CardTitle>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Showing the latest 10 sorted by severity
|
||||
</p>
|
||||
</div>
|
||||
<LinkToFindings />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"threat-map": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-hidden">
|
||||
<ThreatMap data={SAMPLE_THREAT_MAP_DATA} height={460} />
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"risk-radar": (
|
||||
<Inert>
|
||||
<RiskRadarViewClient data={SAMPLE_RADAR_DATA} />
|
||||
</Inert>
|
||||
),
|
||||
"risk-pipeline": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<SankeyChart
|
||||
data={SAMPLE_SANKEY_DATA}
|
||||
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
|
||||
height={460}
|
||||
/>
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
"risk-plot": (
|
||||
<Inert>
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<RiskPlotClient data={SAMPLE_RISK_PLOT_POINTS} />
|
||||
</div>
|
||||
</Inert>
|
||||
),
|
||||
};
|
||||
|
||||
export const SampleGraphsTabs = () => (
|
||||
<GraphsTabsClient tabsContent={SAMPLE_TABS_CONTENT} />
|
||||
);
|
||||
@@ -27,8 +27,6 @@ import { ScanStatusBadge } from "./scan-status-badge";
|
||||
|
||||
interface ScanListTableProps {
|
||||
scans: AttackPathScan[];
|
||||
/** Overrides the `scanId` search param, e.g. for the sample scan. */
|
||||
selectedScanId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
@@ -229,15 +227,12 @@ const getColumns = ({
|
||||
* Table displaying AWS account Attack Paths scans
|
||||
* Shows scan metadata and allows selection of completed scans
|
||||
*/
|
||||
export const ScanListTable = ({
|
||||
scans,
|
||||
selectedScanId: selectedScanIdOverride,
|
||||
}: ScanListTableProps) => {
|
||||
export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedScanId = selectedScanIdOverride ?? searchParams.get("scanId");
|
||||
const selectedScanId = searchParams.get("scanId");
|
||||
const pageSize = parsePageParam(
|
||||
searchParams.get("scanPageSize"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
|
||||
@@ -20,19 +20,15 @@ export interface UseAttackPathScansResult {
|
||||
* project forbids `useEffect` in components, but a reusable data hook is the
|
||||
* sanctioned place for a mount-time fetch when no fetching library is wired up.
|
||||
*/
|
||||
export function useAttackPathScans(
|
||||
/** Sample scans: shown as loaded, and the API is never called. */
|
||||
sampleScans?: AttackPathScan[],
|
||||
): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>(sampleScans ?? []);
|
||||
const [scansLoading, setScansLoading] = useState(!sampleScans);
|
||||
export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Silent background refresh for auto-refresh: never flips loading/error, so it
|
||||
// can't disrupt the visible view if it fails.
|
||||
const refreshScans = async () => {
|
||||
if (sampleScans) return;
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
@@ -47,7 +43,6 @@ export function useAttackPathScans(
|
||||
// the error view's Retry action. A successful empty result (`{ data: [] }`) is
|
||||
// not an error; only a missing payload or a thrown request is.
|
||||
const loadScans = async () => {
|
||||
if (sampleScans) return;
|
||||
setScansLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
|
||||
@@ -56,13 +56,8 @@ const getDefaultValues = (query?: AttackPathQuery) => {
|
||||
* Custom hook for managing query builder form state
|
||||
* Handles query selection, parameter validation, and form submission
|
||||
*/
|
||||
export const useQueryBuilder = (
|
||||
availableQueries: AttackPathQuery[],
|
||||
initialQueryId: string | null = null,
|
||||
) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(
|
||||
initialQueryId,
|
||||
);
|
||||
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
|
||||
|
||||
const getQueryById = (queryId: string | null) =>
|
||||
availableQueries.find((query) => query.id === queryId);
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
|
||||
|
||||
const actions = vi.hoisted(() => ({
|
||||
getAttackPathScans: vi.fn(),
|
||||
getAvailableQueries: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeCustomQuery: vi.fn(),
|
||||
getFindingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/attack-paths", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/attack-paths")>()),
|
||||
getAttackPathScans: actions.getAttackPathScans,
|
||||
getAvailableQueries: actions.getAvailableQueries,
|
||||
executeQuery: actions.executeQuery,
|
||||
executeCustomQuery: actions.executeCustomQuery,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/findings")>()),
|
||||
getFindingById: actions.getFindingById,
|
||||
}));
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
import AttackPathsPage from "./attack-paths-page";
|
||||
import { AttackPathPageHarness } from "./attack-paths-page.harness";
|
||||
|
||||
const SAMPLE_FIXTURE = {
|
||||
scans: [SAMPLE_ATTACK_PATHS.scan],
|
||||
scanId: SAMPLE_ATTACK_PATHS.scan.id,
|
||||
queries: [SAMPLE_ATTACK_PATHS.query],
|
||||
queryId: SAMPLE_ATTACK_PATHS.query.id,
|
||||
queryResult: SAMPLE_ATTACK_PATHS.result,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useGraphStore.getState().reset();
|
||||
resetLighthouseContextStore();
|
||||
window.history.replaceState({}, "", "/attack-paths");
|
||||
});
|
||||
|
||||
describe("Attack Paths sample preview", () => {
|
||||
it("shows the sample graph and keeps it off the API and Lighthouse", async () => {
|
||||
// Given
|
||||
await render(<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />);
|
||||
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
|
||||
|
||||
// When
|
||||
await vi.waitFor(
|
||||
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
await graph.clickNode("sample-s3-exports");
|
||||
await vi.waitFor(() =>
|
||||
expect(graph.getNodeById("sample-finding-public-bucket")).not.toBeNull(),
|
||||
);
|
||||
await graph.clickNode("sample-finding-public-bucket");
|
||||
await graph.executeQuery({ selectFirst: false });
|
||||
|
||||
// Then
|
||||
expect(graph.showsNodeLabel("bastion-01")).toBe(true);
|
||||
expect(
|
||||
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
|
||||
).toBe(true);
|
||||
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
|
||||
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
|
||||
expect(actions.executeQuery).not.toHaveBeenCalled();
|
||||
expect(actions.getFindingById).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions["attack-path-current"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "@/lib/attack-paths/group-graph";
|
||||
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { SampleAttackPaths } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -75,24 +74,18 @@ import {
|
||||
const SCROLL_CONTAINER_CLASS =
|
||||
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
|
||||
|
||||
interface AttackPathsPageProps {
|
||||
/** Sample mode: renders this data, never calls the attack-paths API. */
|
||||
sample?: SampleAttackPaths;
|
||||
}
|
||||
|
||||
export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
export default function AttackPathsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// The sample scan id stays out of the URL, so the real page never sees it.
|
||||
const scanId = sample ? sample.scan.id : searchParams.get("scanId");
|
||||
const scanId = searchParams.get("scanId");
|
||||
const graphState = useGraphState();
|
||||
const finding = useFindingDetails();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
|
||||
useAttackPathScans(sample ? [sample.scan] : undefined);
|
||||
useAttackPathScans();
|
||||
|
||||
const [queriesLoading, setQueriesLoading] = useState(!sample);
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
@@ -101,26 +94,13 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
const hasResetRef = useRef(false);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>(
|
||||
sample ? [sample.query] : [],
|
||||
);
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
// Snapshot of the executed query's outcome, so the graph's terminal node
|
||||
// reflects the query that produced the current graph (not a later selection).
|
||||
const [executedOutcome, setExecutedOutcome] =
|
||||
useState<AttackPathOutcome | null>(null);
|
||||
|
||||
const queryBuilder = useQueryBuilder(queries, sample?.query.id ?? null);
|
||||
|
||||
// The sample has one precomputed result: "running" its query shows it.
|
||||
const showSampleGraph = (sampleData: SampleAttackPaths) => {
|
||||
graphState.updateGraphData(adaptQueryResultToGraphData(sampleData.result), {
|
||||
queryId: sampleData.query.id,
|
||||
queryLabel: sampleData.query.attributes.name,
|
||||
queryKind: ATTACK_PATH_QUERY_KIND.PREDEFINED,
|
||||
parameters: {},
|
||||
});
|
||||
setExecutedOutcome(sampleData.query.attributes.outcome ?? null);
|
||||
};
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResetRef.current) {
|
||||
@@ -130,8 +110,7 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
}, [graphState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sample) showSampleGraph(sample);
|
||||
else graphState.resetGraph();
|
||||
graphState.resetGraph();
|
||||
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
|
||||
|
||||
// Poll while a scan is in flight so the page auto-advances when the graph is ready.
|
||||
@@ -147,8 +126,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
|
||||
|
||||
useEffect(() => {
|
||||
if (sample) return;
|
||||
|
||||
const loadQueries = async () => {
|
||||
if (!scanId) {
|
||||
setQueriesError("No scan selected");
|
||||
@@ -191,7 +168,7 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
};
|
||||
|
||||
loadQueries();
|
||||
}, [sample, scanId, toast]);
|
||||
}, [scanId, toast]);
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
@@ -202,11 +179,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (sample) {
|
||||
showSampleGraph(sample);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanId || !queryBuilder.selectedQuery) {
|
||||
showErrorToast("Error", "Please select both a scan and a query");
|
||||
return;
|
||||
@@ -348,13 +320,10 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
// Open finding drawer directly, bypassing the node-details modal.
|
||||
graphState.enterFilteredView(node.id);
|
||||
graphState.selectNode(null); // clear so node-details modal doesn't open first
|
||||
// Sample findings have no API record: focus the path, open no drawer.
|
||||
if (sample) return;
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
void handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
@@ -437,26 +406,24 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
graphState.selectedNode ?? graphState.filteredNode;
|
||||
const lighthouseGraphData = graphState.fullData ?? graphState.data;
|
||||
const lighthouseExecution = graphState.loading ? null : graphState.execution;
|
||||
// Sample data never reaches Lighthouse.
|
||||
const lighthouseContext =
|
||||
scanId && !sample
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
const lighthouseContext = scanId
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -498,12 +465,9 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* The sample scan is the only one: nothing to pick or page. */}
|
||||
<div inert={Boolean(sample)}>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} selectedScanId={sample?.scan.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
</Suspense>
|
||||
|
||||
{isViewingPreviousCycleData && (
|
||||
<StatusAlert
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
|
||||
import AttackPathsPage from "./(workflow)/query-builder/attack-paths-page";
|
||||
|
||||
export default async function AttackPathsRoute() {
|
||||
// Decided on the server, so the sample never flashes in after the real page.
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (!samplePreview.active) return <AttackPathsPage />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ATTACK_PATHS}
|
||||
/>
|
||||
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { default } from "./(workflow)/query-builder/attack-paths-page";
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
export const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceSamplePreview } from "./compliance-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/compliance",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("ComplianceSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("renders the Overview watchlist frameworks as inert cards without fetching", () => {
|
||||
render(<ComplianceSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const cards = screen.getAllByRole("button", {
|
||||
name: /compliance details$/,
|
||||
});
|
||||
expect(cards).toHaveLength(
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
|
||||
);
|
||||
|
||||
const cis = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.find(
|
||||
(item) => item.attributes.compliance_id === "cis_5.0_aws",
|
||||
)!.attributes;
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
|
||||
).toHaveTextContent(
|
||||
`${cis.requirements_passed} / ${cis.total_requirements}`,
|
||||
);
|
||||
|
||||
expect(cards[0].closest("[inert]")).not.toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ThreatScoreBadge } from "@/components/compliance";
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import {
|
||||
SAMPLE_COMPLIANCE_OVERVIEWS,
|
||||
SAMPLE_SCAN,
|
||||
} from "@/lib/sample-preview/sample-compliance";
|
||||
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
|
||||
|
||||
interface ComplianceSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
}
|
||||
|
||||
/** Sample per-scan compliance: the Overview watchlist frameworks, inert. */
|
||||
export function ComplianceSamplePreview({
|
||||
state,
|
||||
}: ComplianceSamplePreviewProps) {
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={state}
|
||||
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
|
||||
/>
|
||||
<div inert>
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
provider={SAMPLE_SCAN.providerInfo.provider}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceFrameworkGrid>
|
||||
{SAMPLE_COMPLIANCE_OVERVIEWS.map(({ id, attributes }) => (
|
||||
<div key={id} className="h-full [&>*]:h-full">
|
||||
<ComplianceCard
|
||||
title={attributes.framework}
|
||||
version={attributes.version}
|
||||
passingRequirements={attributes.requirements_passed}
|
||||
totalRequirements={attributes.total_requirements}
|
||||
prevPassingRequirements={attributes.requirements_passed}
|
||||
prevTotalRequirements={attributes.total_requirements}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ComplianceFrameworkGrid>
|
||||
</ComplianceOverviewPanel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,6 @@ import {
|
||||
} from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_PREVIEW_STAGE,
|
||||
type SamplePreviewState,
|
||||
} from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import Compliance from "./page";
|
||||
|
||||
const {
|
||||
@@ -24,7 +19,6 @@ const {
|
||||
complianceOverviewGridSpy,
|
||||
getComplianceOverviewMetadataInfoMock,
|
||||
getCompliancesOverviewMock,
|
||||
getSamplePreviewStateMock,
|
||||
getScanMock,
|
||||
getScansMock,
|
||||
getThreatScoreMock,
|
||||
@@ -35,9 +29,6 @@ const {
|
||||
complianceOverviewGridSpy: vi.fn(),
|
||||
getComplianceOverviewMetadataInfoMock: vi.fn(),
|
||||
getCompliancesOverviewMock: vi.fn(),
|
||||
getSamplePreviewStateMock: vi.fn(
|
||||
async (): Promise<SamplePreviewState> => ({ active: false }),
|
||||
),
|
||||
getScanMock: vi.fn(),
|
||||
getScansMock: vi.fn(),
|
||||
getThreatScoreMock: vi.fn(),
|
||||
@@ -61,14 +52,6 @@ vi.mock("@/actions/scans", () => ({
|
||||
getScansByState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState: getSamplePreviewStateMock,
|
||||
}));
|
||||
|
||||
vi.mock("./_components/compliance-sample-preview", () => ({
|
||||
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
@@ -172,30 +155,6 @@ describe("Compliance overview page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance sample preview", () => {
|
||||
it("renders the sample and skips the real fetches until the first scan completes", async () => {
|
||||
// Given - a Cloud tenant whose first scan has not completed yet
|
||||
getSamplePreviewStateMock.mockResolvedValue({
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
(await Compliance({
|
||||
searchParams: Promise.resolve({}),
|
||||
})) as ReactElement,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Sample compliance preview")).toBeInTheDocument();
|
||||
expect(getScansMock).not.toHaveBeenCalled();
|
||||
expect(getCompliancesOverviewMock).not.toHaveBeenCalled();
|
||||
expect(loadComplianceWatchlistContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance overview task response", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -17,10 +17,10 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import { isReportDownloadLocked } from "@/lib/report-download-access";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
@@ -30,10 +30,8 @@ import {
|
||||
} from "@/types";
|
||||
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./_components/compliance-overview-panel";
|
||||
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
|
||||
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
|
||||
import { ComplianceSamplePreview } from "./_components/compliance-sample-preview";
|
||||
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
|
||||
import { CrossProviderOverview } from "./_components/cross-provider-overview";
|
||||
import {
|
||||
@@ -73,16 +71,6 @@ export default async function Compliance({
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces both tabs until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
|
||||
<ComplianceSamplePreview state={preview} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
@@ -421,3 +409,19 @@ const ComplianceWatchlistControls = async ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_FAILED_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
import { SAMPLE_FINDINGS } from "@/lib/sample-preview/sample-records";
|
||||
|
||||
import { FindingsSamplePreview } from "./findings-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock, resolveFindingIdsMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
resolveFindingIdsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/findings",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({ data: null, status: "unauthenticated" }),
|
||||
}));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
vi.mock("@/actions/findings/findings-by-resource", () => ({
|
||||
resolveFindingIdsByVisibleGroupResources: resolveFindingIdsMock,
|
||||
}));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("FindingsSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
// jsdom has no IntersectionObserver; the drill-down's infinite scroll needs one.
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("renders every failed sample check without fetching", () => {
|
||||
const { container } = render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const publicBucket = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.checkId === "s3_bucket_public_access",
|
||||
)!;
|
||||
const row = screen
|
||||
.getByText(publicBucket.title)
|
||||
.closest("tr") as HTMLElement;
|
||||
// Impacted resources: failed of failed + passed.
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed)),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed + publicBucket.passed)),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const table = container.querySelector("table") as HTMLElement;
|
||||
expect(within(table).getAllByRole("row", { hidden: true })).toHaveLength(
|
||||
SAMPLE_FAILED_CHECKS.length + 1,
|
||||
);
|
||||
expect(table.closest("[inert]")).toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
expect(resolveFindingIdsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a sample group into its resources from the sample records", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByText("Ensure S3 buckets are not publicly accessible"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("acme-customer-exports"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a PASS group into passing resources only", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSamplePreview
|
||||
state={STATE}
|
||||
searchParams={{ "filter[status__in]": "PASS" }}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByText("Ensure S3 buckets are not publicly accessible"),
|
||||
);
|
||||
const passing = SAMPLE_FINDINGS.find(
|
||||
(finding) =>
|
||||
finding.check.checkId === "s3_bucket_public_access" &&
|
||||
finding.status === "PASS",
|
||||
)!;
|
||||
expect(await screen.findByText(passing.resource.name)).toBeInTheDocument();
|
||||
expect(screen.queryByText("acme-customer-exports")).not.toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the severity an Overview link carried, with a way back to all", () => {
|
||||
render(
|
||||
<FindingsSamplePreview
|
||||
state={STATE}
|
||||
searchParams={{
|
||||
"filter[severity__in]": "critical",
|
||||
"filter[status__in]": "FAIL",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const critical = SAMPLE_FAILED_CHECKS.filter(
|
||||
(check) => check.severity === "critical",
|
||||
);
|
||||
const high = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.severity === "high",
|
||||
)!;
|
||||
for (const check of critical) {
|
||||
expect(screen.getByText(check.title)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(high.title)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Show all" })).toHaveAttribute(
|
||||
"href",
|
||||
"/findings",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { FindingsGroupTable } from "@/components/findings/table";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import { filterSampleFindingGroups } from "@/lib/sample-preview/sample-findings";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
import type { SearchParamsProps } from "@/types/components";
|
||||
|
||||
// Scoping to the sample scan is what makes every drill-down action answer
|
||||
// from the sample records instead of the API.
|
||||
const SAMPLE_SCAN_FILTERS: Record<string, string> = {
|
||||
"filter[scan]": SAMPLE_ACCOUNT.scanId,
|
||||
};
|
||||
|
||||
interface FindingsSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
/** Overview drill-downs and the table's search and sorting controls. */
|
||||
searchParams?: SearchParamsProps;
|
||||
}
|
||||
|
||||
/** Sample finding groups: no filter controls, no metadata (so no Lighthouse context). */
|
||||
export function FindingsSamplePreview({
|
||||
state,
|
||||
searchParams = {},
|
||||
}: FindingsSamplePreviewProps) {
|
||||
const { groups, filtered, resourceFilters } =
|
||||
filterSampleFindingGroups(searchParams);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
|
||||
{filtered && (
|
||||
<p className="text-text-neutral-tertiary mb-4 text-sm">
|
||||
Showing {groups.length} sample checks matching your filters.{" "}
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link href="/findings">Show all</Link>
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
<FindingsGroupTable
|
||||
// Key resets client state (selection, drill-down) when data changes.
|
||||
key={groups.map((group) => group.id).join(",")}
|
||||
data={groups}
|
||||
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
|
||||
hasHistoricalData
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,32 +24,17 @@ import {
|
||||
isFindingsPreviewLocked,
|
||||
} from "@/lib/findings-access";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
import { FindingsFiltersSection } from "./_components/findings-filters-section";
|
||||
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
|
||||
import { FindingsSamplePreview } from "./_components/findings-sample-preview";
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces the page (and its paywall) until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Findings" icon={<Tag />}>
|
||||
<FindingsSamplePreview
|
||||
state={preview}
|
||||
searchParams={await searchParams}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getIssues, getIssuesOverview } from "@/actions/issues";
|
||||
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
|
||||
import { VERDICT } from "@/types/issues";
|
||||
|
||||
import { IssuesList } from "./issues-list";
|
||||
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssues: vi.fn(),
|
||||
getIssuesOverview: vi.fn(),
|
||||
}));
|
||||
// Modules that reach next-auth under jsdom; the panel has its own tests.
|
||||
vi.mock("@/components/issues/issue-panel", () => ({ IssuePanel: () => null }));
|
||||
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
|
||||
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/issues",
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const [exploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.EXPLOITABLE,
|
||||
);
|
||||
const [notExploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
|
||||
describe("IssuesList sample preview", () => {
|
||||
it("sorts sample issues when the findings column is selected", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { sort: "-findings_count" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
const titles = new Set(
|
||||
SAMPLE_ISSUES.map((issue) => issue.attributes.title),
|
||||
);
|
||||
const displayed = screen
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent)
|
||||
.filter((title) => title && titles.has(title));
|
||||
expect(displayed).toEqual(
|
||||
[...SAMPLE_ISSUES]
|
||||
.sort(
|
||||
(a, b) => b.attributes.findings_count - a.attributes.findings_count,
|
||||
)
|
||||
.map((issue) => issue.attributes.title),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the sample issues clickable, filtered by verdict, without the API", async () => {
|
||||
// When
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[verdict]": VERDICT.EXPLOITABLE },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
const title = screen.getByRole("button", {
|
||||
name: exploitable.attributes.title,
|
||||
});
|
||||
expect(title.closest("[inert]")).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(notExploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
expect(getIssues).not.toHaveBeenCalled();
|
||||
expect(getIssuesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches nothing for a scope the demo account is not in", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[cloud]": "gcp" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText(exploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
// The summary is scoped too, not just the rows.
|
||||
expect(screen.getByLabelText("All, 0 issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches every verdict of a verdict__in list", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict__in]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: exploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: notExploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
const outsideList = SAMPLE_ISSUES.filter(
|
||||
(issue) =>
|
||||
issue.attributes.verdict !== VERDICT.EXPLOITABLE &&
|
||||
issue.attributes.verdict !== VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
expect(outsideList.length).toBeGreaterThan(0);
|
||||
for (const issue of outsideList) {
|
||||
expect(
|
||||
screen.queryByText(issue.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a scalar verdict filter as one literal value", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("scopes the rows by severity like the API", async () => {
|
||||
const critical = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.severity === "critical",
|
||||
);
|
||||
const other = SAMPLE_ISSUES.find(
|
||||
(issue) => issue.attributes.severity !== "critical",
|
||||
);
|
||||
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[severity]": "critical" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const issue of critical) {
|
||||
expect(
|
||||
screen.getByRole("button", { name: issue.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(other!.attributes.title)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,8 @@ import { IssuesSummary } from "@/components/issues/issues-summary";
|
||||
import { IssuesTable } from "@/components/issues/issues-table";
|
||||
import { VerdictFilter } from "@/components/issues/verdict-filter";
|
||||
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
|
||||
import { verdictCounts, VERDICTS } from "@/lib/issues/verdicts";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
summarizeSampleIssues,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import type { IssueResponse, IssuesOverviewResponse } from "@/types/issues";
|
||||
|
||||
/** Only the filters the issues endpoint understands are forwarded. */
|
||||
const ISSUE_FILTER_KEYS = [
|
||||
@@ -27,17 +21,14 @@ const ISSUE_FILTER_KEYS = [
|
||||
"filter[triage_status]",
|
||||
"filter[triage_status__in]",
|
||||
...ISSUE_SCOPE_KEYS,
|
||||
] as const;
|
||||
];
|
||||
|
||||
export const IssuesList = async ({
|
||||
searchParams,
|
||||
filters: filterControls,
|
||||
providerUids,
|
||||
sample = false,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
/** Renders the sample issues instead of calling the issues API. */
|
||||
sample?: boolean;
|
||||
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
|
||||
filters?: ReactNode;
|
||||
/** Provider uid by provider id, for the issue panel. */
|
||||
@@ -57,20 +48,16 @@ export const IssuesList = async ({
|
||||
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
|
||||
),
|
||||
);
|
||||
const [summary, issues] = sample
|
||||
? sampleIssues(filters, scope, searchParams.sort)
|
||||
: await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string"
|
||||
? searchParams.sort
|
||||
: undefined,
|
||||
}),
|
||||
]);
|
||||
const [summary, issues] = await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string" ? searchParams.sort : undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const attributes = summary?.data?.attributes;
|
||||
// handleApiResponse resolves {error, status} on a 4xx and undefined on a
|
||||
@@ -116,74 +103,3 @@ export const IssuesList = async ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SampleIssue = (typeof SAMPLE_ISSUES)[number];
|
||||
|
||||
/** The value each forwarded filter matches on a sample issue. */
|
||||
const SAMPLE_FILTER_FIELDS: Record<
|
||||
(typeof ISSUE_FILTER_KEYS)[number],
|
||||
(issue: SampleIssue) => string | undefined
|
||||
> = {
|
||||
"filter[verdict]": (issue) => issue.attributes.verdict,
|
||||
"filter[verdict__in]": (issue) => issue.attributes.verdict,
|
||||
"filter[cloud]": (issue) => issue.attributes.cloud,
|
||||
"filter[pattern]": (issue) => issue.attributes.pattern,
|
||||
"filter[severity]": (issue) => issue.attributes.severity,
|
||||
"filter[provider_type]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_type__in]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_alias]": () => SAMPLE_ACCOUNT.alias,
|
||||
"filter[provider_id__in]": (issue) => issue.relationships?.provider?.data?.id,
|
||||
// The sample account belongs to no provider group.
|
||||
"filter[provider_groups__in]": () => undefined,
|
||||
"filter[triage_status]": (issue) => issue.attributes.triage_status,
|
||||
"filter[triage_status__in]": (issue) => issue.attributes.triage_status,
|
||||
};
|
||||
|
||||
const matchesSampleFilters = (
|
||||
issue: SampleIssue,
|
||||
filters: Record<string, string>,
|
||||
) =>
|
||||
Object.entries(filters).every(([key, value]) => {
|
||||
const field =
|
||||
SAMPLE_FILTER_FIELDS[key as keyof typeof SAMPLE_FILTER_FIELDS];
|
||||
const actual = field?.(issue);
|
||||
// Only `__in` filters take a list; the rest match one literal value.
|
||||
const accepted = key.endsWith("__in]") ? value.split(",") : [value];
|
||||
return actual !== undefined && accepted.includes(actual);
|
||||
});
|
||||
|
||||
/** The sample summary and rows, filtered like the API would. */
|
||||
function sampleIssues(
|
||||
filters: Record<string, string>,
|
||||
scope: Record<string, string>,
|
||||
sort: SearchParamsProps["sort"],
|
||||
): [IssuesOverviewResponse, IssueResponse] {
|
||||
const scoped = SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, scope),
|
||||
);
|
||||
const rows = SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, filters),
|
||||
);
|
||||
if (typeof sort === "string") {
|
||||
const descending = sort.startsWith("-");
|
||||
const field = descending ? sort.slice(1) : sort;
|
||||
if (field === "verdict_rank" || field === "findings_count") {
|
||||
const value = (issue: SampleIssue) =>
|
||||
field === "findings_count"
|
||||
? issue.attributes.findings_count
|
||||
: VERDICTS.findIndex(({ id }) => id === issue.attributes.verdict);
|
||||
rows.sort((a, b) => (value(a) - value(b)) * (descending ? -1 : 1));
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
type: "issues-overview",
|
||||
attributes: summarizeSampleIssues(scoped),
|
||||
},
|
||||
},
|
||||
{
|
||||
data: rows,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getAllProviders,
|
||||
getAllProviderGroups,
|
||||
getSamplePreviewState,
|
||||
notFound,
|
||||
} = vi.hoisted(() => ({
|
||||
const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
|
||||
getAllProviders: vi.fn(),
|
||||
getAllProviderGroups: vi.fn(),
|
||||
getSamplePreviewState: vi.fn(),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NOT_FOUND");
|
||||
}),
|
||||
@@ -28,12 +22,6 @@ vi.mock("@/components/shadcn/content-layout", () => ({
|
||||
ContentLayout: () => null,
|
||||
}));
|
||||
vi.mock("./_components/issues-list", () => ({ IssuesList: () => null }));
|
||||
vi.mock("@/components/shared/sample-preview/sample-preview-banner", () => ({
|
||||
SamplePreviewBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState,
|
||||
}));
|
||||
|
||||
import Issues from "./page";
|
||||
|
||||
@@ -41,7 +29,6 @@ describe("Issues deployment availability", () => {
|
||||
beforeEach(() => {
|
||||
getAllProviders.mockResolvedValue({ data: [] });
|
||||
getAllProviderGroups.mockResolvedValue({ data: [] });
|
||||
getSamplePreviewState.mockResolvedValue({ active: false });
|
||||
});
|
||||
it("returns not found before loading data in Local Server", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
@@ -58,15 +45,4 @@ describe("Issues deployment availability", () => {
|
||||
expect(getAllProviders).toHaveBeenCalledOnce();
|
||||
expect(getAllProviderGroups).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("skips the scope controls' data while the sample is shown", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getSamplePreviewState.mockResolvedValue({
|
||||
active: true,
|
||||
stage: "no_provider",
|
||||
runningScan: null,
|
||||
});
|
||||
await Issues({ searchParams: Promise.resolve({}) });
|
||||
expect(getAllProviders).not.toHaveBeenCalled();
|
||||
expect(getAllProviderGroups).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,20 +8,12 @@ import { ProviderAccountSelectors } from "@/components/filters/provider-account-
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { IssuesList } from "./_components/issues-list";
|
||||
|
||||
const SAMPLE_PROVIDER_UIDS = {
|
||||
[SAMPLE_ACCOUNT.providerId]: SAMPLE_ACCOUNT.uid,
|
||||
};
|
||||
|
||||
export default async function Issues({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -30,26 +22,6 @@ export default async function Issues({
|
||||
if (!isCloud()) notFound();
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Issues" icon={<ShieldAlert />}>
|
||||
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ISSUES}
|
||||
/>
|
||||
{/* No scope selectors: the sample account is not one of theirs. */}
|
||||
<IssuesList
|
||||
searchParams={resolvedSearchParams}
|
||||
sample
|
||||
providerUids={SAMPLE_PROVIDER_UIDS}
|
||||
/>
|
||||
</FeatureFlagRoute>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [providersData, providerGroupsData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
createLighthouseV2Configuration,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
updateLighthouseV2Configuration,
|
||||
} from "@/app/(prowler)/lighthouse/_actions";
|
||||
import {
|
||||
BEDROCK_AUTH_METHOD,
|
||||
buildCredentialPayload,
|
||||
buildLighthouseV2ConfigFormSchema,
|
||||
buildLighthouseV2ConfigurationInput,
|
||||
@@ -75,14 +74,6 @@ export function LighthouseV2ConfigurationForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const status = getConnectionStatus(configuration);
|
||||
const bedrockAuthMethod = useWatch({
|
||||
control: form.control,
|
||||
name: "bedrockAuthMethod",
|
||||
});
|
||||
// An API key has no instance-role fallback, so the hint only fits access keys.
|
||||
const usesInstanceRoleHint =
|
||||
provider.credentialsOptional &&
|
||||
bedrockAuthMethod !== BEDROCK_AUTH_METHOD.API_KEY;
|
||||
|
||||
const runConnectionTest = async (configurationId: string) => {
|
||||
setTesting(true);
|
||||
@@ -152,12 +143,7 @@ export function LighthouseV2ConfigurationForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the chosen Bedrock auth method only when its credentials were
|
||||
// saved; a blank save keeps the stored ones, whatever method they use.
|
||||
form.reset({
|
||||
...getFormDefaults(result.data),
|
||||
...(credentials ? { bedrockAuthMethod: values.bedrockAuthMethod } : {}),
|
||||
});
|
||||
form.reset(getFormDefaults(result.data));
|
||||
onConfigurationSaved(result.data);
|
||||
notifyLighthouseV2ConfigurationsChanged();
|
||||
if (shouldTestAfterSave) {
|
||||
@@ -274,17 +260,14 @@ export function LighthouseV2ConfigurationForm({
|
||||
icon={<KeyRound className="size-4" />}
|
||||
title="Credentials"
|
||||
description={
|
||||
usesInstanceRoleHint
|
||||
? configuration
|
||||
? "Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role."
|
||||
: "Leave the keys blank to use this installation's own AWS role; only the region is needed."
|
||||
provider.credentialsOptional
|
||||
? "Leave the keys blank to use this installation's own AWS role; only the region is needed."
|
||||
: configuration
|
||||
? "Leave blank to keep existing credentials."
|
||||
: "Credentials are required for new configurations."
|
||||
}
|
||||
>
|
||||
<CredentialFields
|
||||
control={form.control}
|
||||
credentialsOptional={provider.credentialsOptional}
|
||||
errors={form.formState.errors}
|
||||
hasStoredCredentials={hasConfiguration}
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import { Controller, type useForm, useWatch } from "react-hook-form";
|
||||
import { type useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
BEDROCK_AUTH_METHOD,
|
||||
type LighthouseV2ConfigFormValues,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/config";
|
||||
import { type LighthouseV2ConfigFormValues } from "@/app/(prowler)/lighthouse/_lib/config";
|
||||
import {
|
||||
LIGHTHOUSE_V2_PROVIDER_TYPE,
|
||||
type LighthouseV2ProviderType,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/components/shadcn/radio-group/radio-group";
|
||||
|
||||
// Stored secrets are never sent back by the API, so the fields would look
|
||||
// empty even when a key exists; the masked placeholder stands in for it.
|
||||
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
|
||||
|
||||
export function CredentialFields({
|
||||
control,
|
||||
credentialsOptional = false,
|
||||
errors,
|
||||
hasStoredCredentials = false,
|
||||
provider,
|
||||
register,
|
||||
}: {
|
||||
control: ReturnType<typeof useForm<LighthouseV2ConfigFormValues>>["control"];
|
||||
// Keys may be left blank (Bedrock with the installation's own role).
|
||||
credentialsOptional?: boolean;
|
||||
errors: ReturnType<
|
||||
@@ -39,50 +30,17 @@ export function CredentialFields({
|
||||
typeof useForm<LighthouseV2ConfigFormValues>
|
||||
>["register"];
|
||||
}) {
|
||||
const isBedrock = provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK;
|
||||
// The masked placeholder would suggest a key that may not be there: with
|
||||
// optional keys a saved configuration may have none, and for Bedrock the
|
||||
// API does not say whether access keys or an API key are stored.
|
||||
// With optional keys a saved configuration may have none, so the masked
|
||||
// placeholder would suggest a key that is not there.
|
||||
const secretPlaceholder =
|
||||
hasStoredCredentials && !credentialsOptional && !isBedrock
|
||||
hasStoredCredentials && !credentialsOptional
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: undefined;
|
||||
const keyLabelSuffix = credentialsOptional ? " (optional)" : "";
|
||||
const bedrockAuthMethod = useWatch({ control, name: "bedrockAuthMethod" });
|
||||
const usesBedrockApiKey =
|
||||
isBedrock && bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY;
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{isBedrock && (
|
||||
<Field>
|
||||
<FieldLabel>Authentication method</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="bedrockAuthMethod"
|
||||
render={({ field }) => (
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
orientation="horizontal"
|
||||
aria-label="Authentication method"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={BEDROCK_AUTH_METHOD.ACCESS_KEYS} />
|
||||
AWS access keys
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={BEDROCK_AUTH_METHOD.API_KEY} />
|
||||
Bedrock API key
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
|
||||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE ||
|
||||
usesBedrockApiKey) && (
|
||||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
|
||||
<Input
|
||||
@@ -114,45 +72,41 @@ export function CredentialFields({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isBedrock && (
|
||||
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{!usesBedrockApiKey && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-access-key">
|
||||
AWS access key ID{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-access-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsAccessKeyId)}
|
||||
{...register("awsAccessKeyId")}
|
||||
/>
|
||||
{errors.awsAccessKeyId?.message && (
|
||||
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-access-key">
|
||||
AWS access key ID{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-access-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsAccessKeyId)}
|
||||
{...register("awsAccessKeyId")}
|
||||
/>
|
||||
{errors.awsAccessKeyId?.message && (
|
||||
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-secret-key">
|
||||
AWS secret access key{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-secret-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsSecretAccessKey)}
|
||||
{...register("awsSecretAccessKey")}
|
||||
/>
|
||||
{errors.awsSecretAccessKey?.message && (
|
||||
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-secret-key">
|
||||
AWS secret access key{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-secret-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsSecretAccessKey)}
|
||||
{...register("awsSecretAccessKey")}
|
||||
/>
|
||||
{errors.awsSecretAccessKey?.message && (
|
||||
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
|
||||
|
||||
@@ -284,14 +284,14 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
maskedKey,
|
||||
);
|
||||
|
||||
// But not for Bedrock: the API does not say whether access keys or an
|
||||
// API key are stored, so masking either set could claim the wrong one
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
expect(screen.getByLabelText("AWS access key ID")).not.toHaveAttribute(
|
||||
expect(screen.getByLabelText("AWS access key ID")).toHaveAttribute(
|
||||
"placeholder",
|
||||
maskedKey,
|
||||
);
|
||||
expect(screen.getByLabelText("AWS secret access key")).not.toHaveAttribute(
|
||||
expect(screen.getByLabelText("AWS secret access key")).toHaveAttribute(
|
||||
"placeholder",
|
||||
maskedKey,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -330,44 +330,6 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
expect(createConfigurationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not hint at a stored Bedrock API key it cannot know about", async () => {
|
||||
// Given: Bedrock has a stored configuration of unknown credential type
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder");
|
||||
});
|
||||
|
||||
it("falls back to access keys after saving Bedrock without new credentials", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
|
||||
|
||||
// When: saved with blank fields, so the stored credentials stay
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
|
||||
// Then: the form no longer claims an API key that was never saved
|
||||
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
|
||||
"credentials",
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("radio", { name: "AWS access keys" }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows Bedrock access key, secret key, and region fields", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -382,42 +344,6 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves Bedrock with an API key instead of access keys", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
|
||||
testConnectionMock.mockResolvedValue({ data: configurations[1] });
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// When: the API key method is chosen
|
||||
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
|
||||
|
||||
// Then: the access key fields give way to the API key field
|
||||
expect(
|
||||
screen.queryByLabelText("AWS access key ID"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("AWS secret access key"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText("API key"), "ABSKlongterm");
|
||||
await user.type(screen.getByLabelText("AWS region"), "us-east-1");
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1].credentials).toEqual({
|
||||
api_key: "ABSKlongterm",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("changes a Bedrock region without keys when the installation role is allowed", async () => {
|
||||
// Given: the API reports Bedrock needs no credentials (Private Cloud)
|
||||
const user = userEvent.setup();
|
||||
@@ -432,13 +358,6 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// Then: blank fields keep the stored credentials, so the hint says so
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// When: only the region is entered
|
||||
expect(
|
||||
screen.getByLabelText("AWS access key ID (optional)"),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { LIGHTHOUSE_V2_PROVIDER_TYPE } from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
import {
|
||||
BEDROCK_AUTH_METHOD,
|
||||
buildCredentialPayload,
|
||||
buildLighthouseV2ConfigFormSchema,
|
||||
EMPTY_FORM_VALUES,
|
||||
@@ -137,101 +136,4 @@ describe("Bedrock configuration form", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when authenticating with a Bedrock API key", () => {
|
||||
const apiKeyValues = (overrides: Partial<LighthouseV2ConfigFormValues>) =>
|
||||
values({ bedrockAuthMethod: BEDROCK_AUTH_METHOD.API_KEY, ...overrides });
|
||||
|
||||
it("should require the API key and the region for a new configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(false, apiKeyValues({}))).toEqual([
|
||||
"apiKey",
|
||||
"awsRegionName",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should still require the API key where the installation role is allowed", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
apiKeyValues({ awsRegionName: "us-east-1" }),
|
||||
OPTIONAL,
|
||||
),
|
||||
).toEqual(["apiKey"]);
|
||||
});
|
||||
|
||||
it("should require the region to rotate the key on an existing configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(true, apiKeyValues({ apiKey: "ABSKlongterm" })),
|
||||
).toEqual(["awsRegionName"]);
|
||||
});
|
||||
|
||||
it.each(["ABSKlongterm", "bedrock-api-key-shortterm"])(
|
||||
"should accept the %s key format",
|
||||
(apiKey) => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
apiKeyValues({ apiKey, awsRegionName: "us-east-1" }),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("should reject a key that is not a Bedrock API key", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
apiKeyValues({ apiKey: "sk-proj-123", awsRegionName: "us-east-1" }),
|
||||
),
|
||||
).toEqual(["apiKey"]);
|
||||
});
|
||||
|
||||
it("should send the key and region, without leftover access keys", () => {
|
||||
// Given: access keys typed before switching to the API key method
|
||||
const formValues = apiKeyValues({
|
||||
apiKey: " ABSKlongterm ",
|
||||
awsAccessKeyId: "AKIA",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegionName: "us-east-1",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
|
||||
api_key: "ABSKlongterm",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep the stored configuration when nothing is entered", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(true, apiKeyValues({}))).toEqual([]);
|
||||
expect(
|
||||
buildCredentialPayload(BEDROCK, apiKeyValues({}), true),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when authenticating with access keys", () => {
|
||||
it("should ignore an API key left over from the other method", () => {
|
||||
// Given
|
||||
const formValues = values({
|
||||
apiKey: "ABSKlongterm",
|
||||
awsAccessKeyId: "AKIA",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegionName: "us-east-1",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
|
||||
aws_access_key_id: "AKIA",
|
||||
aws_secret_access_key: "secret",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,20 +37,8 @@ export interface FeedbackState {
|
||||
variant: FeedbackVariant;
|
||||
}
|
||||
|
||||
export const BEDROCK_AUTH_METHOD = {
|
||||
ACCESS_KEYS: "access_keys",
|
||||
API_KEY: "api_key",
|
||||
} as const;
|
||||
|
||||
export type BedrockAuthMethod =
|
||||
(typeof BEDROCK_AUTH_METHOD)[keyof typeof BEDROCK_AUTH_METHOD];
|
||||
|
||||
// Long-term keys start with "ABSK", short-term ones with "bedrock-api-key-".
|
||||
const BEDROCK_API_KEY_PATTERN = /^(ABSK|bedrock-api-key-)\S+$/;
|
||||
|
||||
const lighthouseV2ConfigFormSchemaBase = z.object({
|
||||
apiKey: z.string(),
|
||||
bedrockAuthMethod: z.enum(BEDROCK_AUTH_METHOD),
|
||||
awsAccessKeyId: z.string(),
|
||||
awsSecretAccessKey: z.string(),
|
||||
awsRegionName: z.string(),
|
||||
@@ -63,7 +51,6 @@ export type LighthouseV2ConfigFormValues = z.infer<
|
||||
|
||||
export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
|
||||
apiKey: "",
|
||||
bedrockAuthMethod: BEDROCK_AUTH_METHOD.ACCESS_KEYS,
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsRegionName: "",
|
||||
@@ -130,34 +117,6 @@ export function buildLighthouseV2ConfigFormSchema(
|
||||
|
||||
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
|
||||
|
||||
if (data.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
|
||||
const regionName = data.awsRegionName.trim();
|
||||
if (hasConfiguration && !apiKey && !regionName) return;
|
||||
|
||||
if (!apiKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Bedrock API key is required.",
|
||||
path: ["apiKey"],
|
||||
});
|
||||
} else if (!BEDROCK_API_KEY_PATTERN.test(apiKey)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Bedrock API keys start with 'ABSK' (long-term) or 'bedrock-api-key-' (short-term).",
|
||||
path: ["apiKey"],
|
||||
});
|
||||
}
|
||||
if (!regionName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "AWS region is required.",
|
||||
path: ["awsRegionName"],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialsOptional) {
|
||||
// The installation's own role: a region alone is enough, and keys,
|
||||
// when given, still have to come as a pair.
|
||||
@@ -231,14 +190,6 @@ export function buildCredentialPayload(
|
||||
{ credentialsOptional = false }: LighthouseV2ProviderCapabilities = {},
|
||||
): LighthouseV2Credentials | undefined {
|
||||
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
|
||||
if (values.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
|
||||
const apiKey = values.apiKey.trim();
|
||||
const regionName = values.awsRegionName.trim();
|
||||
if (hasConfiguration && !apiKey && !regionName) return undefined;
|
||||
|
||||
return { api_key: apiKey, aws_region_name: regionName };
|
||||
}
|
||||
|
||||
const hasBedrockCredentials =
|
||||
Boolean(values.awsAccessKeyId.trim()) ||
|
||||
Boolean(values.awsSecretAccessKey.trim()) ||
|
||||
|
||||
@@ -38,12 +38,8 @@ describe("Overview page", () => {
|
||||
|
||||
it("uses the responsive container breakpoint for the primary metric row", () => {
|
||||
// Given
|
||||
const gridSource = readFileSync(
|
||||
path.join(currentDir, "_overview/_components/overview-grid.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const metricRowClassName = gridSource.match(
|
||||
/<div className="([^"]+)">\s*\{threatScore\}/,
|
||||
const metricRowClassName = source.match(
|
||||
/<div className="([^"]+)">\s*<Suspense fallback={<ThreatScoreSkeleton \/>}>/,
|
||||
)?.[1];
|
||||
|
||||
// When
|
||||
|
||||
@@ -12,16 +12,12 @@ import {
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { OverviewBanner } from "./_overview/_components/overview-banner";
|
||||
import { OverviewGrid } from "./_overview/_components/overview-grid";
|
||||
import { OverviewProviderContext } from "./_overview/_components/overview-provider-context";
|
||||
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
|
||||
@@ -46,7 +42,6 @@ import {
|
||||
RiskSeverityChartSkeleton,
|
||||
RiskSeverityChartSSR,
|
||||
} from "./_overview/risk-severity";
|
||||
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
|
||||
import {
|
||||
FindingSeverityOverTimeSkeleton,
|
||||
FindingSeverityOverTimeSSR,
|
||||
@@ -65,32 +60,12 @@ export default async function Home({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
// Resolved alongside the page data, so tenants with scans wait no longer.
|
||||
const [
|
||||
samplePreview,
|
||||
providersData,
|
||||
providerGroupsData,
|
||||
lighthouseBannerHref,
|
||||
] = await Promise.all([
|
||||
getSamplePreviewState(),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
|
||||
// No completed scan yet: the demo account stands in for the tenant's data.
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
/>
|
||||
<OverviewSamplePreview showIssues={isCloud()} />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
const [providersData, providerGroupsData, lighthouseBannerHref] =
|
||||
await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
@@ -124,71 +99,82 @@ export default async function Home({
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
exploitability={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
riskSeverity={
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
resourcesInventory={
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
attackSurface={
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
{isCloud() && (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<Suspense fallback={<AttackSurfaceSkeleton />}>
|
||||
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
topIssues={
|
||||
isCloud() ? (
|
||||
{isCloud() && (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<TopIssuesSkeleton />}>
|
||||
<TopIssuesSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
)}
|
||||
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
graphsTabs={
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { listScanConfigurations } from "@/actions/scan-configurations";
|
||||
import { ProvidersAccountsView } from "@/components/providers/providers-accounts-view";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import {
|
||||
@@ -34,19 +33,17 @@ export const ProvidersTabContent = async ({
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const isCloudEnvironment = isCloud();
|
||||
const [providersView, scanConfigsState, samplePreview] = await Promise.all([
|
||||
const [providersView, scanConfigsState] = await Promise.all([
|
||||
loadProvidersAccountsViewData({
|
||||
searchParams,
|
||||
isCloud: isCloudEnvironment,
|
||||
}),
|
||||
loadScanConfigs(isCloudEnvironment),
|
||||
getSamplePreviewState(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProvidersAccountsView
|
||||
isCloud={isCloudEnvironment}
|
||||
samplePreviewActive={isCloudEnvironment && samplePreview.active}
|
||||
filters={providersView.filters}
|
||||
providers={providersView.providers}
|
||||
providerGroups={providersView.providerGroups}
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
|
||||
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
|
||||
@@ -37,7 +33,6 @@ export function ComplianceFrameworkChip({
|
||||
}: ComplianceFrameworkChipProps) {
|
||||
const icon = getComplianceIcon(framework.complianceId);
|
||||
const label = complianceFrameworkLabel(framework);
|
||||
const sample = isSampleId(framework.id);
|
||||
const content = icon ? (
|
||||
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
@@ -55,7 +50,7 @@ export function ComplianceFrameworkChip({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable && !sample ? (
|
||||
{isNavigable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={icon ? "bare" : "outline"}
|
||||
@@ -73,9 +68,7 @@ export function ComplianceFrameworkChip({
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sample ? `${label}. ${SAMPLE_DATA_READ_ONLY_MESSAGE}` : label}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks to avoid deep dependency chains
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,32 +44,6 @@ describe("FloatingSelectionActions — onBeforeOpen error handling", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ selectedFindingIds: ["sample-finding-1"] },
|
||||
{
|
||||
selectedFindingIds: ["s3_bucket_public_access"],
|
||||
disabledReason: SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
},
|
||||
])(
|
||||
"blocks sample selection actions before resolving IDs: %j",
|
||||
async (props) => {
|
||||
const onBeforeOpen = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FloatingSelectionActions
|
||||
selectedCount={1}
|
||||
{...props}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("title", SAMPLE_DATA_READ_ONLY_MESSAGE);
|
||||
await user.click(button);
|
||||
expect(onBeforeOpen).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
|
||||
// Given — onBeforeOpen always throws
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown/action-dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
|
||||
import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
|
||||
@@ -31,7 +27,6 @@ interface FloatingSelectionActionsBaseProps {
|
||||
label?: string;
|
||||
/** Custom mute action label. Defaults to "Mute". */
|
||||
muteLabel?: string;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps &
|
||||
@@ -56,13 +51,7 @@ export function FloatingSelectionActions({
|
||||
muteLabel = "Mute",
|
||||
jiraPayload,
|
||||
jiraLabel,
|
||||
disabledReason,
|
||||
}: FloatingSelectionActionsProps) {
|
||||
const reason =
|
||||
disabledReason ??
|
||||
(selectedFindingIds.some(isSampleId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
@@ -86,7 +75,6 @@ export function FloatingSelectionActions({
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
if (reason) return;
|
||||
if (onBeforeOpen) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
@@ -158,21 +146,17 @@ export function FloatingSelectionActions({
|
||||
icon={<VolumeX />}
|
||||
label={muteLabel}
|
||||
aria-label={muteLabel}
|
||||
disabled={Boolean(reason)}
|
||||
disabledTooltip={reason}
|
||||
onSelect={() => void handleMuteClick()}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
label={jiraLabel}
|
||||
payload={jiraPayload}
|
||||
disabledReason={reason}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => void handleMuteClick()}
|
||||
disabled={isResolving || Boolean(reason)}
|
||||
title={reason}
|
||||
disabled={isResolving}
|
||||
size="lg"
|
||||
>
|
||||
{isResolving ? (
|
||||
|
||||
@@ -7,11 +7,6 @@ import {
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP,
|
||||
} from "@/lib/deployment";
|
||||
import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
|
||||
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
@@ -19,13 +14,11 @@ import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
interface JiraDispatchActionItemProps {
|
||||
label: string;
|
||||
payload: JiraDispatchModalPayload | null | undefined;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export const JiraDispatchActionItem = ({
|
||||
label,
|
||||
payload,
|
||||
disabledReason,
|
||||
}: JiraDispatchActionItemProps) => {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
@@ -36,21 +29,12 @@ export const JiraDispatchActionItem = ({
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
const reason =
|
||||
disabledReason ??
|
||||
(getJiraSelectionBatches(payload.selection).some((batch) =>
|
||||
batch.targetIds.some(isSampleId),
|
||||
)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined);
|
||||
|
||||
const { requiresUpgrade } = getJiraDispatchActionState(
|
||||
payload,
|
||||
isGroupedJiraDispatchEnabled(),
|
||||
);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (reason) return;
|
||||
if (requiresUpgrade) {
|
||||
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
|
||||
return;
|
||||
@@ -64,11 +48,7 @@ export const JiraDispatchActionItem = ({
|
||||
icon={<JiraIcon size={20} />}
|
||||
label={label}
|
||||
aria-label={label}
|
||||
disabled={Boolean(reason)}
|
||||
disabledTooltip={reason}
|
||||
tooltip={
|
||||
!reason && requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
|
||||
}
|
||||
tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,13 +11,11 @@ export const RECHECK_RESOURCE_LABEL = "Re-check resource";
|
||||
|
||||
interface RecheckResourceActionItemProps {
|
||||
target: Partial<PartialScanTarget> | null | undefined;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/** Prowler Cloud only: opens the partial-scan confirmation for one resource. */
|
||||
export const RecheckResourceActionItem = ({
|
||||
target,
|
||||
disabledReason,
|
||||
}: RecheckResourceActionItemProps) => {
|
||||
const resolvedTarget = usePartialScanTarget(target);
|
||||
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
|
||||
@@ -29,11 +27,7 @@ export const RecheckResourceActionItem = ({
|
||||
icon={<RefreshCw className="size-5" />}
|
||||
label={RECHECK_RESOURCE_LABEL}
|
||||
aria-label={RECHECK_RESOURCE_LABEL}
|
||||
disabled={Boolean(disabledReason)}
|
||||
disabledTooltip={disabledReason}
|
||||
onSelect={() => {
|
||||
if (!disabledReason) openPartialScan(resolvedTarget);
|
||||
}}
|
||||
onSelect={() => openPartialScan(resolvedTarget)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ hasPermission: hasPermissionMock }),
|
||||
}));
|
||||
|
||||
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
|
||||
import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item";
|
||||
@@ -63,25 +62,6 @@ describe("RecheckResourceIconButton", () => {
|
||||
expect(button.className).toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("blocks sample re-checks and removes the pulse", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={onRowClick}>
|
||||
<RecheckResourceIconButton
|
||||
target={target}
|
||||
disabledReason={SAMPLE_DATA_READ_ONLY_MESSAGE}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button.className).not.toContain("animate-pulse");
|
||||
await user.click(button);
|
||||
expect(usePartialScanStore.getState().activeTarget).toBeNull();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is hidden outside Prowler Cloud", () => {
|
||||
isCloudMock.mockReturnValue(false);
|
||||
render(<RecheckResourceIconButton target={target} />);
|
||||
|
||||
@@ -19,15 +19,13 @@ import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item";
|
||||
interface RecheckResourceIconButtonProps {
|
||||
target: Partial<PartialScanTarget> | null | undefined;
|
||||
className?: string;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
// Beside "last seen": green and pulsing when a re-check is available.
|
||||
// Beside "last seen": always green and pulsing, so the re-check is noticed.
|
||||
// It is an action, not a notification, so it never settles into a seen state.
|
||||
export function RecheckResourceIconButton({
|
||||
target,
|
||||
className,
|
||||
disabledReason,
|
||||
}: RecheckResourceIconButtonProps) {
|
||||
const resolvedTarget = usePartialScanTarget(target);
|
||||
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
|
||||
@@ -37,7 +35,6 @@ export function RecheckResourceIconButton({
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
// The row itself opens the detail drawer on click.
|
||||
event.stopPropagation();
|
||||
if (disabledReason) return;
|
||||
openPartialScan(resolvedTarget);
|
||||
};
|
||||
|
||||
@@ -49,22 +46,17 @@ export function RecheckResourceIconButton({
|
||||
variant="bare"
|
||||
size="icon"
|
||||
aria-label={RECHECK_RESOURCE_LABEL}
|
||||
aria-disabled={Boolean(disabledReason)}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"size-5 shrink-0 rounded-md",
|
||||
disabledReason
|
||||
? "text-text-neutral-tertiary cursor-not-allowed"
|
||||
: "text-button-primary hover:text-button-primary active:text-button-primary hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused] motion-safe:animate-pulse",
|
||||
"text-button-primary hover:text-button-primary active:text-button-primary size-5 shrink-0 rounded-md motion-safe:animate-pulse",
|
||||
"hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{disabledReason ?? RECHECK_RESOURCE_LABEL}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{RECHECK_RESOURCE_LABEL}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -283,74 +283,6 @@ function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("sample resource actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
});
|
||||
|
||||
const sampleResource = () =>
|
||||
makeResource({
|
||||
id: "sample-resource-s3_bucket-1",
|
||||
findingId: "sample-finding-s3_bucket_public_access-1",
|
||||
checkId: "s3_bucket_public_access",
|
||||
providerUid: "123456789012",
|
||||
providerAlias: "Sample AWS account",
|
||||
resourceName: "acme-customer-exports",
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "sample-finding-s3_bucket_public_access-1",
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.SAMPLE,
|
||||
}),
|
||||
});
|
||||
|
||||
it("does not send sample IDs to Lighthouse from a resource row", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderResourceActionsCell({ resource: sampleResource() });
|
||||
const action = screen.getByRole("button", { name: "Lighthouse Skills" });
|
||||
|
||||
// When
|
||||
expect(action).toBeDisabled();
|
||||
await user.click(action);
|
||||
|
||||
// Then
|
||||
expect(launchSkillMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispatch sample findings to the real Jira workflow", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderResourceActionsCell({ resource: sampleResource() });
|
||||
const action = screen.getByRole("button", { name: /Send.*Jira/ });
|
||||
|
||||
// When
|
||||
expect(action).toBeDisabled();
|
||||
await user.click(action);
|
||||
|
||||
// Then
|
||||
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
|
||||
});
|
||||
|
||||
it("does not start the real re-check workflow for a sample resource", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderResourceActionsCell({ resource: sampleResource() });
|
||||
const action = screen.getByRole("button", { name: "Re-check resource" });
|
||||
|
||||
// When
|
||||
expect(action).toBeDisabled();
|
||||
await user.click(action);
|
||||
|
||||
// Then
|
||||
expect(usePartialScanStore.getState().activeTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function renderResourceActionsCell({
|
||||
resource = makeResource(),
|
||||
onSkillLaunchOpenDrawer,
|
||||
|
||||
@@ -23,10 +23,6 @@ import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
@@ -103,9 +99,6 @@ const ResourceRowActions = ({
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}) => {
|
||||
const resource = row.original;
|
||||
const sampleDisabledReason = isSampleId(resource.findingId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
@@ -145,7 +138,6 @@ const ResourceRowActions = ({
|
||||
});
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
if (sampleDisabledReason) return;
|
||||
const displayIds = getDisplayIds();
|
||||
|
||||
// Single resource: findingId is already a real finding UUID
|
||||
@@ -210,8 +202,7 @@ const ResourceRowActions = ({
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={Boolean(sampleDisabledReason) || !canMute || isResolving}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
disabled={!canMute || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
@@ -222,11 +213,9 @@ const ResourceRowActions = ({
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={buildResourceRecheckTarget(resource)}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
{isCloud() && (
|
||||
<LighthouseSkillsSubmenu
|
||||
disabledReason={sampleDisabledReason}
|
||||
onLaunch={(skill) => {
|
||||
onSkillLaunchOpenDrawer?.(row.index);
|
||||
launchSkill(skill, buildResourceFindingItem(resource));
|
||||
@@ -414,11 +403,6 @@ export function getColumnFindingResources({
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} />
|
||||
<RecheckResourceIconButton
|
||||
target={buildResourceRecheckTarget(row.original)}
|
||||
disabledReason={
|
||||
isSampleId(row.original.findingId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</InfoField>
|
||||
|
||||
@@ -179,25 +179,6 @@ describe("DataTableRowActions", () => {
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("blocks sample group actions even though their target check IDs are real", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
id: "sample-group-s3_bucket_public_access",
|
||||
rowType: "group",
|
||||
checkId: "s3_bucket_public_access",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const jira = screen.getByRole("button", { name: /Jira/ });
|
||||
const mute = screen.getByRole("button", { name: /Mute Finding Group/ });
|
||||
expect(jira).toBeDisabled();
|
||||
expect(mute).toBeDisabled();
|
||||
await user.click(jira);
|
||||
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a Cloud re-check of the row's resource with its provider id", async () => {
|
||||
// Given — a flat finding row expanded with its resource and provider
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -17,10 +17,6 @@ import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -132,9 +128,6 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
}: DataTableRowActionsProps<T>) {
|
||||
const router = useRouter();
|
||||
const finding = row.original;
|
||||
const sampleDisabledReason = isSampleId(finding.id)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
|
||||
const [mutePreparationError, setMutePreparationError] = useState<
|
||||
@@ -218,7 +211,6 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
if (sampleDisabledReason) return;
|
||||
if (resolveMuteIds) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
@@ -324,22 +316,13 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={Boolean(sampleDisabledReason) || !canMute || isResolving}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
disabled={!canMute || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
label={jiraLabel}
|
||||
payload={jiraPayload}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={recheckTarget}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
|
||||
<RecheckResourceActionItem target={recheckTarget} />
|
||||
{isCloud() && !isGroup && (
|
||||
<LighthouseSkillsSubmenu
|
||||
disabledReason={sampleDisabledReason}
|
||||
onLaunch={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
/>
|
||||
|
||||
@@ -42,8 +42,6 @@ import { FindingTriageStatusControl } from "./finding-triage-status-control";
|
||||
|
||||
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
|
||||
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
|
||||
export const SAMPLE_TRIAGE_COPY =
|
||||
"Sample data. Connect your cloud account to triage your own findings.";
|
||||
|
||||
const getDisabledCopy = ({
|
||||
triage,
|
||||
@@ -58,10 +56,6 @@ const getDisabledCopy = ({
|
||||
return CLOUD_ONLY_TOOLTIP_COPY;
|
||||
}
|
||||
|
||||
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.SAMPLE) {
|
||||
return SAMPLE_TRIAGE_COPY;
|
||||
}
|
||||
|
||||
// Status-picker only: notes stay available on resolved findings.
|
||||
if (lockResolved && isTriageStatusLocked(triage.status)) {
|
||||
return FINDING_TRIAGE_RESOLVED_LOCKED_COPY;
|
||||
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
buildFindingGroupContext,
|
||||
buildFindingSummaryContext,
|
||||
} from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
@@ -448,12 +444,6 @@ const FindingsGroupTableContent = ({
|
||||
|
||||
{(selectedCheckIds.length > 0 || hasResourceSelection) && jiraPayload && (
|
||||
<FloatingSelectionActions
|
||||
disabledReason={
|
||||
selectedFindings.some((finding) => isSampleId(finding.id)) ||
|
||||
activeResourceSelection.some(isSampleId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined
|
||||
}
|
||||
selectedCount={
|
||||
selectedCheckIds.length + activeResourceSelection.length
|
||||
}
|
||||
|
||||
@@ -58,24 +58,6 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
}));
|
||||
|
||||
describe("Lighthouse skills launch controls", () => {
|
||||
it("does not offer skills or prompts for a sample resource", () => {
|
||||
render(
|
||||
<LighthouseSkillsRowButton
|
||||
findingItem={{
|
||||
kind: "finding",
|
||||
id: "sample-finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Sample finding",
|
||||
findingId: "sample-finding-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(requestPanelSkillLaunchMock).not.toHaveBeenCalled();
|
||||
expect(requestPanelChatMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSidePanelStore.setState({
|
||||
|
||||
@@ -15,10 +15,6 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { getSkillsForSurface } from "@/lib/lighthouse/skills/registry";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import {
|
||||
@@ -49,10 +45,7 @@ export function useLighthouseSkillLaunch() {
|
||||
return (
|
||||
skill: LighthouseSkillDefinition,
|
||||
findingItem: LighthouseFindingContextItem,
|
||||
) => {
|
||||
if (isSampleId(findingItem.id)) return;
|
||||
launchPanelSkill(skill, buildRowEnvelope(findingItem));
|
||||
};
|
||||
) => launchPanelSkill(skill, buildRowEnvelope(findingItem));
|
||||
}
|
||||
|
||||
// Free-text sibling of useLighthouseSkillLaunch: starts a fresh Lighthouse
|
||||
@@ -61,7 +54,6 @@ export function useLighthousePromptLaunch() {
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
|
||||
return (text: string, findingItem: LighthouseFindingContextItem) => {
|
||||
if (isSampleId(findingItem.id)) return;
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
void import("@/app/(prowler)/lighthouse/_lib/panel-chat-store").then(
|
||||
({ requestPanelChatMessage }) =>
|
||||
@@ -177,22 +169,10 @@ function AskLighthouseAnythingRow({
|
||||
export function LighthouseSkillsSubmenu({
|
||||
onLaunch,
|
||||
onSubmitPrompt,
|
||||
disabledReason,
|
||||
}: {
|
||||
onLaunch: (skill: LighthouseSkillDefinition) => void;
|
||||
onSubmitPrompt?: (text: string) => void;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
if (disabledReason) {
|
||||
return (
|
||||
<ActionDropdownItem
|
||||
icon={<LighthouseIcon size={16} />}
|
||||
label="Lighthouse Skills"
|
||||
disabled
|
||||
disabledTooltip={disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -232,17 +212,6 @@ export function LighthouseSkillsRowButton({
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
|
||||
if (isSampleId(findingItem.id)) {
|
||||
return (
|
||||
<span title={SAMPLE_DATA_READ_ONLY_MESSAGE}>
|
||||
<CornerDownRight
|
||||
className="text-text-neutral-tertiary size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center">
|
||||
<ActionDropdown
|
||||
|
||||
@@ -707,89 +707,6 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — sample data", () => {
|
||||
it("does not navigate to compliance details for a nonexistent sample scan", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
id: "sample-finding-s3_bucket_public_access-1",
|
||||
resourceId: "sample-resource-s3_bucket-1",
|
||||
scan: {
|
||||
id: "sample-scan",
|
||||
name: "Sample scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
uniqueResourceCount: 24,
|
||||
progress: 100,
|
||||
duration: 742,
|
||||
startedAt: "2026-10-28T09:12:00Z",
|
||||
completedAt: "2026-10-28T09:12:00Z",
|
||||
insertedAt: "2026-10-28T09:12:00Z",
|
||||
scheduledAt: null,
|
||||
},
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const link = screen.queryByRole("button", {
|
||||
name: "Open PCI-DSS 4.0 compliance details",
|
||||
});
|
||||
if (link) await user.click(link);
|
||||
|
||||
// Then
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not link sample resources to pages or consoles that lack them", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
id: "sample-finding-s3_bucket_public_access-1",
|
||||
resourceId: "sample-resource-s3_bucket-1",
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
providerType: "aws",
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "View Resource" }),
|
||||
).not.toBeInTheDocument();
|
||||
const hrefs = Array.from(container.querySelectorAll("a")).map((link) =>
|
||||
link.getAttribute("href"),
|
||||
);
|
||||
expect(hrefs.filter((href) => href?.includes("aws.amazon.com"))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
it("should render Triage and Add Triage Note for other findings rows", () => {
|
||||
// Given
|
||||
|
||||
@@ -84,10 +84,6 @@ import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
@@ -293,9 +289,6 @@ export function ResourceDetailDrawerContent({
|
||||
// During carousel navigation we only trust row-backed data until the next
|
||||
// finding payload is fully ready, otherwise stale details flash briefly.
|
||||
const f = isNavigating ? null : currentFinding;
|
||||
const sampleDisabledReason = isSampleId(f?.id)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
targetIds: f ? [f.id] : [],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
@@ -335,32 +328,25 @@ export function ResourceDetailDrawerContent({
|
||||
searchParams.get("filter[scan__in]"),
|
||||
);
|
||||
const complianceScanId =
|
||||
isSampleId(f?.id) || isSampleId(f?.scan?.id)
|
||||
? null
|
||||
: selectedScanIds.length === 1
|
||||
? selectedScanIds[0]
|
||||
: selectedScanIds.length === 0
|
||||
? (f?.scan?.id ?? null)
|
||||
: null;
|
||||
selectedScanIds.length === 1
|
||||
? selectedScanIds[0]
|
||||
: selectedScanIds.length === 0
|
||||
? (f?.scan?.id ?? null)
|
||||
: null;
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
const nativeIacConfig = resolveNativeIacConfig(providerType);
|
||||
const showOverviewCheckMetaContent = showCheckMetaContent;
|
||||
// Resources and Scans have no sample data, so sample records link nowhere.
|
||||
const resourceDetailHref =
|
||||
f?.resourceId && !isSampleId(f.resourceId)
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
// The sample account is not theirs: its console links would open nothing.
|
||||
const externalResourceTarget = isSampleId(f?.resourceId)
|
||||
? null
|
||||
: resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
const resourceDetailHref = f?.resourceId
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
const externalResourceTarget = resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
const findingRecommendationUrl = f?.remediation.recommendation.url;
|
||||
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
|
||||
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
|
||||
@@ -506,9 +492,8 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lighthouse skill launcher: chip rail under the title. Sample
|
||||
findings exist only in the UI, so Lighthouse has nothing to read. */}
|
||||
{isCloud() && !isNavigating && !isSampleId(currentFinding?.id) && (
|
||||
{/* Lighthouse skill launcher: chip rail under the title. */}
|
||||
{isCloud() && !isNavigating && (
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
@@ -649,10 +634,7 @@ export function ResourceDetailDrawerContent({
|
||||
<span className="group flex items-center gap-1.5">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
{f && (
|
||||
<RecheckResourceIconButton
|
||||
target={recheckTarget}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
<RecheckResourceIconButton target={recheckTarget} />
|
||||
)}
|
||||
</span>
|
||||
</InfoField>
|
||||
@@ -705,18 +687,14 @@ export function ResourceDetailDrawerContent({
|
||||
)
|
||||
}
|
||||
label={f.isMuted ? "Muted" : "Mute"}
|
||||
disabled={f.isMuted || Boolean(sampleDisabledReason)}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
disabled={f.isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
label={buildJiraActionLabel({ findingCount: 1 })}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={recheckTarget}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
<RecheckResourceActionItem target={recheckTarget} />
|
||||
{externalResourceTarget && (
|
||||
<ActionDropdownItem
|
||||
icon={<ExternalLink className="size-5" />}
|
||||
@@ -1152,18 +1130,16 @@ export function ResourceDetailDrawerContent({
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
Showing the latest scan that evaluated this finding
|
||||
</p>
|
||||
{!isSampleId(f?.scan?.id) && (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Scan Name" variant="compact">
|
||||
@@ -1453,9 +1429,6 @@ function OtherFindingRow({
|
||||
}) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const isMuted = finding.isMuted || isOptimisticallyMuted;
|
||||
const sampleDisabledReason = isSampleId(finding.id)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
targetIds: [finding.id],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
@@ -1550,8 +1523,7 @@ function OtherFindingRow({
|
||||
)
|
||||
}
|
||||
label={isMuted ? "Muted" : "Mute"}
|
||||
disabled={isMuted || Boolean(sampleDisabledReason)}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
disabled={isMuted}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
isManualPassTriageUpdate,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
@@ -257,7 +256,6 @@ export function useResourceDetailDrawer({
|
||||
resourceUid,
|
||||
pageSize: 50,
|
||||
includeMuted: includeMutedInOtherFindings,
|
||||
...(isSampleId(findingId) ? { sample: true } : {}),
|
||||
});
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
|
||||
|
||||
@@ -71,18 +71,6 @@ describe("IssueCompliance", () => {
|
||||
getFrameworksMock.mockResolvedValue({ frameworks: [], unavailable: false });
|
||||
});
|
||||
|
||||
it("shows sample frameworks without linking to real compliance reports", async () => {
|
||||
getFrameworksMock.mockResolvedValue({
|
||||
frameworks: [framework("cis_aws", "CIS")],
|
||||
unavailable: false,
|
||||
});
|
||||
renderCompliance([finding("sample-finding-1", "check-a")], "sample-scan");
|
||||
expect(await screen.findByAltText("CIS")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /compliance details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("looks up one finding per check, up to the limit", async () => {
|
||||
const findings = [
|
||||
finding("f1", "check-a"),
|
||||
|
||||