Compare commits

..
Author SHA1 Message Date
Toni de la Fuente faf4568a26 Merge branch 'feat/html-report-cloud-cta' into feat/prowler-cloud-handoff 2026-10-30 13:04:46 -07:00
Toni de la Fuente 93f48ab953 Merge branch 'feat/dashboard-terminal-output' into review/dashboard-all 2026-10-30 12:55:09 -07:00
Toni de la FuenteandClaude Fable 5.1 4a190ce55a refactor(sdk): tighten the Prowler Cloud terminal hand-offs
- Print the dashboard CSV loading line after the hand-off block
- Drop Dash's own "Dash is running on" line and reword the ready line
- Rewrite the CLI scan banner into six 80-column lines with per-provider onboarding
- Honour PROWLER_NO_PROMOTIONS in the CLI scan banner

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 12:55:01 -07:00
Toni de la Fuente 14ff4bdc08 Merge branch 'feat/dashboard-findings-cloud-handoff' into review/dashboard-all 2026-10-30 11:18:54 -07:00
Toni de la FuenteandClaude Fable 5.1 b55d7dc3a6 feat(dashboard): add Exploitable Issues page and Cloud screenshots to promo pages
- Move Findings to the top of the Upgrade to Prowler Cloud group
- Add an Exploitable Issues page describing attack path verdicts and legs
- Show a Prowler Cloud screenshot from the docs on every gated page
- Cover the new page, sidebar order and screenshot assets with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:18:11 -07:00
Toni de la Fuente 26018cc208 Merge branch 'feat/dashboard-terminal-output' into review/dashboard-all 2026-10-30 11:08:46 -07:00
Toni de la FuenteandClaude Fable 5.1 74c0b39253 refactor(dashboard): show only the domain link in the dashboard hand-off block
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:07:33 -07:00
Toni de la FuenteandClaude Fable 5.1 ced975a81e chore(sdk): add changelog fragment for the dashboard terminal output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:06:05 -07:00
Toni de la FuenteandClaude Fable 5.1 c49a183917 fix(dashboard): title the local dashboard as Prowler Local Dashboard
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:03:30 -07:00
Toni de la FuenteandClaude Fable 5.1 f7891d2337 test(dashboard): assert the Local Dashboard logo label in the banner test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:03:00 -07:00
Toni de la FuenteandClaude Fable 5.1 4d92319060 feat(dashboard): style the dashboard terminal output like the CLI banner
- Reuse the Prowler ASCII logo with a Local Dashboard label
- Render the hand-off and ready blocks with the banner bar, check and link style

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:02:20 -07:00
Toni de la FuenteandClaude Fable 5.1 f6c235b443 feat(dashboard): add Prowler MCP page to the Prowler Cloud section
- Register a /cloud/mcp promo page with documented MCP capabilities
- Link the AI agent connection guide and flag the preview status
- Add the sidebar entry and icon
- Cover the page copy, links and icon with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 11:00:22 -07:00
Toni de la FuenteandClaude Fable 5.1 fdc8d2704d feat(dashboard): compact terminal output for prowler dashboard
- Replace the long commercial banner with identity, version and CSV directory
- Announce the effective local URL only once the server is listening
- Print a single Prowler Cloud hand-off block, skippable with PROWLER_NO_PROMOTIONS
- Honour NO_COLOR and move the S3 download note to the documentation
- Cover banner selection, promotion omission and the ready hook with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:58:21 -07:00
Toni de la FuenteandClaude Fable 5.1 ab707fdfc9 feat(dashboard): add Prowler Cloud compliance reporting hand-off card
- Append a compact hand-off card to the Compliance page
- Highlight Watchlist, cross-account and cross-provider roll-ups and PDF reports
- Reuse the Findings hand-off copy, links and note
- Cover the card and the layout placement with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:47:01 -07:00
Toni de la FuenteandClaude Fable 5.1 eb97f20e45 feat(dashboard): turn the Findings promo page into a Prowler Cloud hand-off
- Replace the Unlock Findings upsell with the shared hand-off message
- Add a how-to block for importing the OCSF report into Prowler Cloud
- Add the Compare editions link and the no-upload note
- Keep the other promo pages on the unchanged template
- Cover copy, links and template isolation with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:25:34 -07:00
Toni de la FuenteandClaude Fable 5.1 51bf412ff7 feat(sdk): link the import findings tutorial from the HTML report CTA
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:13:52 -07:00
Toni de la FuenteandClaude Fable 5.1 21e11b46e3 chore(sdk): add changelog fragment for HTML report Cloud hand-off block
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:09:28 -07:00
Toni de la FuenteandClaude Fable 5.1 ed6de7c8a2 feat(sdk): add Prowler Cloud hand-off block to OSS CLI HTML report
- Add opt-in `show_cloud_cta` to the HTML output, disabled by default
- Enable it only from the OSS CLI entry point
- Omit block, styles and campaign URLs entirely when disabled
- Hide the block when printing and keep assessment content unchanged
- Cover OSS vs API conditions with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:07:11 -07:00
139 changed files with 1844 additions and 6388 deletions

No files matched your search

+8
View File
@@ -151,6 +151,14 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
| Component | Location | Tech Stack |
|-----------|----------|------------|
| SDK | `prowler/` | Python 3.10+, uv |
| API | `api/` | Django 5.1, DRF, Celery |
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
| Dashboard | `dashboard/` | Dash, Plotly |
---
## Python Development
+23
View File
@@ -97,6 +97,29 @@ Needs retry → Celery task
---
## TECH STACK
Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | PostgreSQL 16 | pytest 8.x
---
## PROJECT STRUCTURE
```text
api/src/backend/
├── api/ # Main Django app
│ ├── v1/ # API version 1 (views, serializers, urls)
│ ├── models.py # Django models
│ ├── filters.py # FilterSet classes
│ ├── base_views.py # Base ViewSet classes
│ ├── rls.py # Row-Level Security
│ └── tests/ # Unit tests
├── config/ # Django configuration
└── tasks/ # Celery tasks
```
---
## COMMANDS
```bash
-1
View File
@@ -1 +0,0 @@
pyasn1 to 0.6.5, patching the ASN.1 decoding denial of service advisories
+1 -1
View File
@@ -367,7 +367,7 @@ constraint-dependencies = [
"py-deviceid==0.1.1",
"py-iam-expand==0.3.0",
"py-ocsf-models==0.10.0",
"pyasn1==0.6.5",
"pyasn1==0.6.4",
"pyasn1-modules==0.4.2",
"pycodestyle==2.14.0",
"pycparser==3.0",
Generated
+6 -6
View File
@@ -1,5 +1,5 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
@@ -283,7 +283,7 @@ constraints = [
{ name = "py-deviceid", specifier = "==0.1.1" },
{ name = "py-iam-expand", specifier = "==0.3.0" },
{ name = "py-ocsf-models", specifier = "==0.10.0" },
{ name = "pyasn1", specifier = "==0.6.5" },
{ name = "pyasn1", specifier = "==0.6.4" },
{ name = "pyasn1-modules", specifier = "==0.4.2" },
{ name = "pycodestyle", specifier = "==2.14.0" },
{ name = "pycparser", specifier = "==3.0" },
@@ -4204,7 +4204,7 @@ wheels = [
[package.optional-dependencies]
broker = [
{ name = "pymsalruntime" },
{ name = "pymsalruntime", marker = "sys_platform == 'win32'" },
]
[[package]]
@@ -5174,11 +5174,11 @@ wheels = [
[[package]]
name = "pyasn1"
version = "0.6.5"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
+31 -13
View File
@@ -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"]),
]
+117
View File
@@ -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) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

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

+3
View File
@@ -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",
)
+112
View File
@@ -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
+265 -17
View File
@@ -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),
)
+7 -1
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
FROM python:3.13.18-alpine3.23@sha256:e4aeaca9081549f19bd5bc8605ed5ab257ba34f97e0471f8e09161f45b4b08b8
FROM python:3.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
+1 -1
View File
@@ -79,7 +79,7 @@ constraint-dependencies = [
"authlib==1.9.1",
"cryptography==50.0.0",
"dnspython==2.9.0",
"joserfc==1.7.5",
"joserfc==1.6.8",
"mcp==1.28.1",
"pyjwt==2.14.0",
"python-multipart==0.0.30"
+4 -4
View File
@@ -13,7 +13,7 @@ constraints = [
{ name = "authlib", specifier = "==1.9.1" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dnspython", specifier = "==2.9.0" },
{ name = "joserfc", specifier = "==1.7.5" },
{ name = "joserfc", specifier = "==1.6.8" },
{ name = "mcp", specifier = "==1.28.1" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-multipart", specifier = "==0.0.30" },
@@ -594,14 +594,14 @@ wheels = [
[[package]]
name = "joserfc"
version = "1.7.5"
version = "1.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
]
[[package]]
+2
View File
@@ -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
+118 -29
View File
@@ -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.
""")
+80 -3
View File
@@ -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">
+2 -2
View File
@@ -288,7 +288,7 @@ constraint-dependencies = [
"itsdangerous==2.2.0",
"jinja2==3.1.6",
"jmespath==1.1.0",
"joserfc==1.7.5",
"joserfc==1.6.8",
"jsonpatch==1.33",
"jsonpath-ng==1.8.0",
"jsonpointer==3.1.1",
@@ -342,7 +342,7 @@ constraint-dependencies = [
"protobuf==7.34.1",
"psutil==7.2.2",
"py-partiql-parser==0.6.1",
"pyasn1==0.6.5",
"pyasn1==0.6.4",
"pyasn1-modules==0.4.2",
"pycodestyle==2.12.1",
"pycparser==3.0",
+363
View File
@@ -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",
}
+126
View File
@@ -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
+113
View File
@@ -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 == ""
+136 -1
View File
@@ -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
+25 -1
View File
@@ -183,7 +183,31 @@ test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => {
---
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there; use `components/shadcn/`.
## TECH STACK
Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | Recharts 2.15.4
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there.
---
## PROJECT STRUCTURE
```text
ui/
├── app/(auth)/ # Auth pages
├── app/(prowler)/ # Main app: compliance, findings, providers, scans
├── components/shadcn/ # shadcn/ui components (USE THIS)
├── components/ui/ # Cloud-overlay re-export shims (do not add here)
├── actions/ # Server actions
├── types/ # Shared types
├── hooks/ # Shared hooks
├── lib/ # Utilities
├── store/ # Zustand state
├── tests/ # Playwright E2E
└── styles/ # Global CSS
```
---
@@ -16,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;
};
-5
View File
@@ -10,10 +10,6 @@ import {
import { createMuteRule } from "@/actions/mute-rules";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { ACTION_ERROR_STATUS, UsageLimitError } from "@/lib/action-errors";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
FINDING_TRIAGE_STATUS_LABELS,
@@ -285,7 +281,6 @@ export async function loadFindingTriageDetail(
}
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
if (isSampleId(input.findingId)) throw new Error(SAMPLE_WRITE_MESSAGE);
try {
return await applyFindingTriageUpdate(input);
} catch (error) {
-21
View File
@@ -6,14 +6,6 @@ import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findi
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
getSampleFinding,
isSampleId,
isSampleScanFilter,
sampleFindingDocument,
sampleFindingsDocument,
sampleFindingsQuery,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
const withFindingTriageSummaries = <T extends { data?: unknown }>(
@@ -32,14 +24,6 @@ export const getFindings = async ({
sort = "",
filters = {},
}) => {
if (isSampleScanFilter(filters)) {
return withFindingTriageSummaries(
sampleFindingsDocument(
sampleFindingsQuery(filters).slice(0, Number(pageSize) || 10),
),
);
}
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1)
@@ -180,11 +164,6 @@ export const getFindingById = async (
include = "",
_options?: GetFindingByIdOptions,
) => {
if (isSampleId(findingId)) {
const finding = getSampleFinding(findingId);
return finding ? sampleFindingDocument(finding) : undefined;
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
-6
View File
@@ -3,10 +3,6 @@
import { pollTaskUntilSettled } from "@/actions/task/poll";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { handleApiError } from "@/lib/server-actions-helper";
import type {
IntegrationProps,
@@ -126,8 +122,6 @@ export const sendJiraDispatch = async ({
| { success: true; taskId: string; message: string }
| { success: false; error: string }
> => {
if (targetIds.some(isSampleId))
return { success: false, error: SAMPLE_WRITE_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
+16 -41
View File
@@ -1,13 +1,6 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { getSampleIssueDetail } from "@/lib/sample-preview/sample-issues";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
sampleFindingsQuery,
sampleIssueFindingsDocument,
} from "@/lib/sample-preview/sample-records";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
ISSUE_FINDINGS_LIMIT,
@@ -93,11 +86,6 @@ export const getIssues = async ({
export const getIssue = async (
id: string,
): Promise<IssueDetailResponse | undefined> => {
if (isSampleId(id)) {
const issue = getSampleIssueDetail(id);
return issue ? { data: issue } : undefined;
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`);
@@ -118,13 +106,6 @@ export const getIssueFindings = async (
if (!scanId) return undefined;
const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT);
if (uids.length === 0) return [];
if (isSampleId(scanId)) {
return toIssueFindings(
sampleIssueFindingsDocument(
sampleFindingsQuery({ "filter[uid__in]": uids.join(",") }),
),
);
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings`);
@@ -175,38 +156,32 @@ export const updateIssueTriage = async (
id: string,
{ status, note }: UpdateIssueTriageInput,
): Promise<IssueWriteResult> =>
isSampleId(id)
? { error: SAMPLE_WRITE_MESSAGE }
: writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage`,
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
{
type: "issue-triages",
attributes: { status, ...(note ? { note } : {}) },
},
);
writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage`,
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
{
type: "issue-triages",
attributes: { status, ...(note ? { note } : {}) },
},
);
/** Add a note to an issue's triage without changing its status. */
export const addIssueNote = async (
id: string,
body: string,
): Promise<IssueWriteResult> =>
isSampleId(id)
? { error: SAMPLE_WRITE_MESSAGE }
: writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage/notes`,
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
{
type: "issue-triage-notes",
attributes: { body },
},
);
writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage/notes`,
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
{
type: "issue-triage-notes",
attributes: { body },
},
);
export const getIssueNotes = async (
id: string,
): Promise<IssueTriageNote[] | undefined> => {
if (isSampleId(id)) return [];
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
-8
View File
@@ -3,10 +3,6 @@
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-records";
import {
DeleteMuteRuleActionState,
@@ -135,10 +131,6 @@ export const createMuteRule = async (
};
}
if (findingIds.some(isSampleId)) {
return { errors: { general: SAMPLE_WRITE_MESSAGE } };
}
try {
const url = new URL(`${apiBaseUrl}/mute-rules`);
-7
View File
@@ -12,10 +12,6 @@ import {
sanitizeErrorMessage,
} from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isSampleId,
sampleResourceEvents,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { isCloud } from "@/lib/shared/env";
import { OrganizationResource } from "@/types/organizations";
@@ -188,9 +184,6 @@ export const getResourceEvents = async (
pageSize?: number;
} = {},
) => {
if (isSampleId(resourceId)) {
return { data: sampleResourceEvents(resourceId) };
}
if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) {
return { error: "Invalid resource ID format." };
}
-5
View File
@@ -19,10 +19,6 @@ import {
isReportDownloadLocked,
REPORT_DOWNLOAD_LOCKED_ERROR,
} from "@/lib/report-download-access";
import {
isSampleId,
SAMPLE_WRITE_MESSAGE,
} from "@/lib/sample-preview/sample-guards";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { SCAN_STATES } from "@/types/attack-paths";
@@ -199,7 +195,6 @@ export const createPartialScan = async ({
providerId: string;
resourceUids: string[];
}) => {
if (isSampleId(providerId)) return { error: SAMPLE_WRITE_MESSAGE };
if (!providerId) {
return { error: "Provider ID is required" };
}
@@ -1,75 +0,0 @@
import { type ReactNode } from "react";
interface OverviewGridProps {
threatScore: ReactNode;
// Cloud-only widgets: the page leaves them out where they do not apply.
exploitability?: ReactNode;
topIssues?: ReactNode;
checkFindings: ReactNode;
riskSeverity: ReactNode;
resourcesInventory: ReactNode;
complianceWatchlist: ReactNode;
serviceWatchlist: ReactNode;
attackSurface: ReactNode;
severityOverTime: ReactNode;
graphsTabs: ReactNode;
}
/** The Overview widget layout, shared by the real page and the sample preview. */
export function OverviewGrid({
threatScore,
exploitability,
topIssues,
checkFindings,
riskSeverity,
resourcesInventory,
complianceWatchlist,
serviceWatchlist,
attackSurface,
severityOverTime,
graphsTabs,
}: OverviewGridProps) {
return (
<>
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
{threatScore}
{exploitability}
{checkFindings}
{riskSeverity}
</div>
<div className="mt-6">{resourcesInventory}</div>
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
the line's max-content rather than to its own 312px, and a card wider
than the column loses its right border and padding to
`overflow-hidden`. It never wrapped anyway. */}
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
{/* Sized to its own list, unlike the service card below: this one is
as long as the organization made its watchlist, so growing it to
fill the column puts a hole under two pinned frameworks. */}
{/* `h-full` only where the cards sit side by side and share a row
height. In the column it would hand the card the stretched
wrapper's height and undo the fit-to-content sizing. */}
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
{complianceWatchlist}
</div>
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
{serviceWatchlist}
</div>
</div>
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
<div className="flex flex-1 flex-col gap-6">
{attackSurface}
{topIssues}
{severityOverTime}
</div>
</div>
<div className="mt-6">{graphsTabs}</div>
</>
);
}
@@ -1,53 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SAMPLE_SERVICES_RESPONSE } from "@/lib/sample-preview/sample-overview";
import { OverviewSamplePreview } from "./overview-sample-preview";
// Card links import server actions; their auth chain has no place in jsdom.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
// The graph tabs have their own test; here they would only add chart noise.
vi.mock("./sample-graphs-tabs", () => ({
SampleGraphsTabs: () => <div data-testid="sample-graphs-tabs" />,
}));
describe("OverviewSamplePreview", () => {
it("fills the Overview with the demo account's numbers", () => {
render(<OverviewSamplePreview showIssues={false} />);
expect(screen.getByText("Findings Severity Over Time")).toBeVisible();
expect(screen.getByText("Internet Exposed Resources")).toBeVisible();
expect(screen.getByTestId("sample-graphs-tabs")).toBeInTheDocument();
});
it("only leaves links live where the target page shows the sample", () => {
const { container } = render(<OverviewSamplePreview showIssues={false} />);
const links = Array.from(container.querySelectorAll("a"));
const live = links.filter((link) => !link.closest("[inert]"));
expect(live.length).toBeGreaterThan(0);
expect(live.length).toBeLessThan(links.length);
for (const link of live) {
expect(link.getAttribute("href")).toMatch(/^\/(findings|issues)(\?|$)/);
}
});
it("lists every sample service in the watchlist", () => {
render(<OverviewSamplePreview showIssues={false} />);
// The riskiest service of the demo account leads the watchlist.
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
(left, right) => right.attributes.fail - left.attributes.fail,
)[0];
expect(
screen.getAllByText(new RegExp(riskiest.id, "i")).length,
).toBeGreaterThan(0);
});
});
@@ -1,152 +0,0 @@
import { type ReactNode } from "react";
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { verdictCounts } from "@/lib/issues/verdicts";
import {
SAMPLE_ISSUES,
SAMPLE_ISSUES_OVERVIEW,
} from "@/lib/sample-preview/sample-issues";
import {
SAMPLE_ATTACK_SURFACE_RESPONSE,
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
SAMPLE_FAILED_BY_SEVERITY,
SAMPLE_RESOURCE_GROUPS_RESPONSE,
SAMPLE_SERVICES_RESPONSE,
SAMPLE_SEVERITY_TRENDS,
SAMPLE_STATUS,
SAMPLE_THREAT_SCORE,
} from "@/lib/sample-preview/sample-overview";
import { OverviewGrid } from "../_components/overview-grid";
import { AttackSurface } from "../attack-surface/_components/attack-surface";
import { ExploitabilityChart } from "../exploitability";
import { TopIssues } from "../exploitability/_components/top-issues";
import { ResourcesInventory } from "../resources-inventory/_components/resources-inventory";
import { RiskSeverityChart } from "../risk-severity";
import { FindingSeverityOverTime } from "../severity-over-time";
import { StatusChart } from "../status-chart";
import { ThreatScore } from "../threat-score";
import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
// `contents` keeps each card a direct item of the grid's flex rows, while
// `inert` stops links into pages with no sample data (compliance details,
// resources). Cards that link to Findings or Issues stay live: those pages
// show the sample and apply the link's filters.
const Inert = ({ children }: { children: ReactNode }) => (
<div inert className="contents">
{children}
</div>
);
const NO_SCOPE: Record<string, string> = {};
interface OverviewSamplePreviewProps {
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
showIssues: boolean;
}
/** The Overview filled with the demo account, for tenants without a scan. */
export function OverviewSamplePreview({
showIssues,
}: OverviewSamplePreviewProps) {
const complianceItems = adaptComplianceWatchlistResponse(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
).map((item) => ({
id: item.id,
framework: item.complianceId,
label: item.label,
icon: item.icon,
score: item.score,
}));
return (
<OverviewGrid
threatScore={
<Inert>
<ThreatScore
score={SAMPLE_THREAT_SCORE.score}
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
/>
</Inert>
}
exploitability={
showIssues ? (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<ExploitabilityChart
counts={verdictCounts(SAMPLE_ISSUES_OVERVIEW)}
issues={SAMPLE_ISSUES_OVERVIEW.issues}
findings={SAMPLE_ISSUES_OVERVIEW.findings}
actionable={SAMPLE_ISSUES_OVERVIEW.actionable}
coveredFindings={SAMPLE_ISSUES_OVERVIEW.not_exploitable_findings}
scope={NO_SCOPE}
/>
</FeatureFlagGate>
) : undefined
}
checkFindings={
<StatusChart
failFindingsData={{
total: SAMPLE_STATUS.fail,
new: SAMPLE_STATUS.failNew,
}}
passFindingsData={{
total: SAMPLE_STATUS.pass,
new: SAMPLE_STATUS.passNew,
}}
/>
}
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
resourcesInventory={
<Inert>
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
</Inert>
}
complianceWatchlist={
<Inert>
<ComplianceWatchlist items={complianceItems} hasWatchlist />
</Inert>
}
serviceWatchlist={
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
}
attackSurface={
<AttackSurface
items={adaptAttackSurfaceOverview(SAMPLE_ATTACK_SURFACE_RESPONSE)}
/>
}
topIssues={
showIssues ? (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<TopIssues issues={SAMPLE_ISSUES} scope={NO_SCOPE} />
</FeatureFlagGate>
) : undefined
}
severityOverTime={
<Inert>
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} />
</CardContent>
</Card>
</Inert>
}
graphsTabs={<SampleGraphsTabs />}
/>
);
}
@@ -1,36 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_CHECKS } from "@/lib/sample-preview/sample-dataset";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
describe("SampleGraphsTabs", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
it("shows the sample new failed findings in the default tab without fetching", () => {
// Given
const firstNewCheck = SAMPLE_CHECKS.find((check) => check.newFailed > 0)!;
// When
render(<SampleGraphsTabs />);
// Then
expect(
screen.getByRole("tab", { name: "New Findings", selected: true }),
).toBeInTheDocument();
expect(screen.getAllByText(firstNewCheck.title).length).toBeGreaterThan(0);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -1,83 +0,0 @@
import { type ReactNode } from "react";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { ThreatMap } from "@/components/graphs/threat-map";
import { LinkToFindings } from "@/components/overview";
import { LatestFindingsTable } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
import {
SAMPLE_NEW_FINDINGS,
SAMPLE_RADAR_DATA,
SAMPLE_RISK_PLOT_POINTS,
SAMPLE_SANKEY_DATA,
SAMPLE_THREAT_MAP_DATA,
} from "@/lib/sample-preview/sample-graphs";
import { GraphsTabsClient } from "../graphs-tabs/_components/graphs-tabs-client";
import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
// Inert so sample rows and charts never open drill-downs or detail panels.
const Inert = ({ children }: { children: ReactNode }) => (
<div inert className="flex w-full flex-1">
{children}
</div>
);
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
findings: (
<Inert>
<div className="flex w-full flex-col">
<LatestFindingsTable
data={SAMPLE_NEW_FINDINGS}
header={
<div className="flex w-full items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<CardTitle>Latest New Failed Findings</CardTitle>
<p className="text-text-neutral-tertiary text-xs">
Showing the latest 10 sorted by severity
</p>
</div>
<LinkToFindings />
</div>
}
/>
</div>
</Inert>
),
"threat-map": (
<Inert>
<div className="w-full flex-1 overflow-hidden">
<ThreatMap data={SAMPLE_THREAT_MAP_DATA} height={460} />
</div>
</Inert>
),
"risk-radar": (
<Inert>
<RiskRadarViewClient data={SAMPLE_RADAR_DATA} />
</Inert>
),
"risk-pipeline": (
<Inert>
<div className="w-full flex-1 overflow-visible">
<SankeyChart
data={SAMPLE_SANKEY_DATA}
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
height={460}
/>
</div>
</Inert>
),
"risk-plot": (
<Inert>
<div className="w-full flex-1 overflow-visible">
<RiskPlotClient data={SAMPLE_RISK_PLOT_POINTS} />
</div>
</Inert>
),
};
export const SampleGraphsTabs = () => (
<GraphsTabsClient tabsContent={SAMPLE_TABS_CONTENT} />
);
@@ -27,8 +27,6 @@ import { ScanStatusBadge } from "./scan-status-badge";
interface ScanListTableProps {
scans: AttackPathScan[];
/** Overrides the `scanId` search param, e.g. for the sample scan. */
selectedScanId?: string;
}
const DEFAULT_PAGE_SIZE = 10;
@@ -229,15 +227,12 @@ const getColumns = ({
* Table displaying AWS account Attack Paths scans
* Shows scan metadata and allows selection of completed scans
*/
export const ScanListTable = ({
scans,
selectedScanId: selectedScanIdOverride,
}: ScanListTableProps) => {
export const ScanListTable = ({ scans }: ScanListTableProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const selectedScanId = selectedScanIdOverride ?? searchParams.get("scanId");
const selectedScanId = searchParams.get("scanId");
const pageSize = parsePageParam(
searchParams.get("scanPageSize"),
DEFAULT_PAGE_SIZE,
@@ -20,19 +20,15 @@ export interface UseAttackPathScansResult {
* project forbids `useEffect` in components, but a reusable data hook is the
* sanctioned place for a mount-time fetch when no fetching library is wired up.
*/
export function useAttackPathScans(
/** Sample scans: shown as loaded, and the API is never called. */
sampleScans?: AttackPathScan[],
): UseAttackPathScansResult {
const [scans, setScans] = useState<AttackPathScan[]>(sampleScans ?? []);
const [scansLoading, setScansLoading] = useState(!sampleScans);
export function useAttackPathScans(): UseAttackPathScansResult {
const [scans, setScans] = useState<AttackPathScan[]>([]);
const [scansLoading, setScansLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const mountedRef = useRef(true);
// Silent background refresh for auto-refresh: never flips loading/error, so it
// can't disrupt the visible view if it fails.
const refreshScans = async () => {
if (sampleScans) return;
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
@@ -47,7 +43,6 @@ export function useAttackPathScans(
// the error view's Retry action. A successful empty result (`{ data: [] }`) is
// not an error; only a missing payload or a thrown request is.
const loadScans = async () => {
if (sampleScans) return;
setScansLoading(true);
setLoadError(false);
try {
@@ -56,13 +56,8 @@ const getDefaultValues = (query?: AttackPathQuery) => {
* Custom hook for managing query builder form state
* Handles query selection, parameter validation, and form submission
*/
export const useQueryBuilder = (
availableQueries: AttackPathQuery[],
initialQueryId: string | null = null,
) => {
const [selectedQuery, setSelectedQuery] = useState<string | null>(
initialQueryId,
);
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
const getQueryById = (queryId: string | null) =>
availableQueries.find((query) => query.id === queryId);
@@ -1,80 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@/__tests__/render-browser";
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
const actions = vi.hoisted(() => ({
getAttackPathScans: vi.fn(),
getAvailableQueries: vi.fn(),
executeQuery: vi.fn(),
executeCustomQuery: vi.fn(),
getFindingById: vi.fn(),
}));
vi.mock("@/actions/attack-paths", async (importOriginal) => ({
...(await importOriginal<typeof import("@/actions/attack-paths")>()),
getAttackPathScans: actions.getAttackPathScans,
getAvailableQueries: actions.getAvailableQueries,
executeQuery: actions.executeQuery,
executeCustomQuery: actions.executeCustomQuery,
}));
vi.mock("@/actions/findings", async (importOriginal) => ({
...(await importOriginal<typeof import("@/actions/findings")>()),
getFindingById: actions.getFindingById,
}));
import { useGraphStore } from "./_hooks/use-graph-state";
import AttackPathsPage from "./attack-paths-page";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
const SAMPLE_FIXTURE = {
scans: [SAMPLE_ATTACK_PATHS.scan],
scanId: SAMPLE_ATTACK_PATHS.scan.id,
queries: [SAMPLE_ATTACK_PATHS.query],
queryId: SAMPLE_ATTACK_PATHS.query.id,
queryResult: SAMPLE_ATTACK_PATHS.result,
};
beforeEach(() => {
useGraphStore.getState().reset();
resetLighthouseContextStore();
window.history.replaceState({}, "", "/attack-paths");
});
describe("Attack Paths sample preview", () => {
it("shows the sample graph and keeps it off the API and Lighthouse", async () => {
// Given
await render(<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />);
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
// When
await vi.waitFor(
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
{
timeout: 10000,
},
);
await graph.clickNode("sample-s3-exports");
await vi.waitFor(() =>
expect(graph.getNodeById("sample-finding-public-bucket")).not.toBeNull(),
);
await graph.clickNode("sample-finding-public-bucket");
await graph.executeQuery({ selectFirst: false });
// Then
expect(graph.showsNodeLabel("bastion-01")).toBe(true);
expect(
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
).toBe(true);
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
expect(actions.executeQuery).not.toHaveBeenCalled();
expect(actions.getFindingById).not.toHaveBeenCalled();
expect(
useLighthouseContextStore.getState().contributions["attack-path-current"],
).toBeUndefined();
});
});
@@ -35,7 +35,6 @@ import {
OUTCOME_NODE_LABEL,
} from "@/lib/attack-paths/group-graph";
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
import type { SampleAttackPaths } from "@/lib/sample-preview/sample-attack-paths";
import { isCloud } from "@/lib/shared/env";
import type {
AttackPathQuery,
@@ -75,24 +74,18 @@ import {
const SCROLL_CONTAINER_CLASS =
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
interface AttackPathsPageProps {
/** Sample mode: renders this data, never calls the attack-paths API. */
sample?: SampleAttackPaths;
}
export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
export default function AttackPathsPage() {
const searchParams = useSearchParams();
const pathname = usePathname();
// The sample scan id stays out of the URL, so the real page never sees it.
const scanId = sample ? sample.scan.id : searchParams.get("scanId");
const scanId = searchParams.get("scanId");
const graphState = useGraphState();
const finding = useFindingDetails();
const { toast } = useToast();
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
useAttackPathScans(sample ? [sample.scan] : undefined);
useAttackPathScans();
const [queriesLoading, setQueriesLoading] = useState(!sample);
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<GraphHandle>(null);
@@ -101,26 +94,13 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
const hasResetRef = useRef(false);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>(
sample ? [sample.query] : [],
);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
// Snapshot of the executed query's outcome, so the graph's terminal node
// reflects the query that produced the current graph (not a later selection).
const [executedOutcome, setExecutedOutcome] =
useState<AttackPathOutcome | null>(null);
const queryBuilder = useQueryBuilder(queries, sample?.query.id ?? null);
// The sample has one precomputed result: "running" its query shows it.
const showSampleGraph = (sampleData: SampleAttackPaths) => {
graphState.updateGraphData(adaptQueryResultToGraphData(sampleData.result), {
queryId: sampleData.query.id,
queryLabel: sampleData.query.attributes.name,
queryKind: ATTACK_PATH_QUERY_KIND.PREDEFINED,
parameters: {},
});
setExecutedOutcome(sampleData.query.attributes.outcome ?? null);
};
const queryBuilder = useQueryBuilder(queries);
useEffect(() => {
if (!hasResetRef.current) {
@@ -130,8 +110,7 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
}, [graphState]);
useEffect(() => {
if (sample) showSampleGraph(sample);
else graphState.resetGraph();
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Poll while a scan is in flight so the page auto-advances when the graph is ready.
@@ -147,8 +126,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
useEffect(() => {
if (sample) return;
const loadQueries = async () => {
if (!scanId) {
setQueriesError("No scan selected");
@@ -191,7 +168,7 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
};
loadQueries();
}, [sample, scanId, toast]);
}, [scanId, toast]);
const showErrorToast = (title: string, description: string) => {
toast({
@@ -202,11 +179,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
};
const handleExecuteQuery = async () => {
if (sample) {
showSampleGraph(sample);
return;
}
if (!scanId || !queryBuilder.selectedQuery) {
showErrorToast("Error", "Please select both a scan and a query");
return;
@@ -348,13 +320,10 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
return;
}
findingNavigationInFlightRef.current = true;
// Open finding drawer directly, bypassing the node-details modal.
graphState.enterFilteredView(node.id);
graphState.selectNode(null); // clear so node-details modal doesn't open first
// Sample findings have no API record: focus the path, open no drawer.
if (sample) return;
findingNavigationInFlightRef.current = true;
void handleViewFinding(String(node.properties?.id || node.id));
return;
}
@@ -437,26 +406,24 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
graphState.selectedNode ?? graphState.filteredNode;
const lighthouseGraphData = graphState.fullData ?? graphState.data;
const lighthouseExecution = graphState.loading ? null : graphState.execution;
// Sample data never reaches Lighthouse.
const lighthouseContext =
scanId && !sample
? buildAttackPathContext({
pathname,
scanId,
queryId: lighthouseExecution?.queryId,
queryLabel: lighthouseExecution?.queryLabel,
queryKind: lighthouseExecution?.queryKind,
parameters: lighthouseExecution?.parameters,
graphData: lighthouseExecution ? lighthouseGraphData : null,
selectedNode:
lighthouseExecution && lighthouseSelectedNode
? {
id: lighthouseSelectedNode.id,
type: lighthouseSelectedNode.labels[0],
}
: null,
})
: null;
const lighthouseContext = scanId
? buildAttackPathContext({
pathname,
scanId,
queryId: lighthouseExecution?.queryId,
queryLabel: lighthouseExecution?.queryLabel,
queryKind: lighthouseExecution?.queryKind,
parameters: lighthouseExecution?.parameters,
graphData: lighthouseExecution ? lighthouseGraphData : null,
selectedNode:
lighthouseExecution && lighthouseSelectedNode
? {
id: lighthouseSelectedNode.id,
type: lighthouseSelectedNode.labels[0],
}
: null,
})
: null;
return (
<div className="flex flex-col gap-6">
@@ -498,12 +465,9 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
/>
) : (
<>
{/* The sample scan is the only one: nothing to pick or page. */}
<div inert={Boolean(sample)}>
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} selectedScanId={sample?.scan.id} />
</Suspense>
</div>
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
{isViewingPreviousCycleData && (
<StatusAlert
+1 -22
View File
@@ -1,22 +1 @@
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import AttackPathsPage from "./(workflow)/query-builder/attack-paths-page";
export default async function AttackPathsRoute() {
// Decided on the server, so the sample never flashes in after the real page.
const samplePreview = await getSamplePreviewState();
if (!samplePreview.active) return <AttackPathsPage />;
return (
<>
<SamplePreviewBanner
state={samplePreview}
page={SAMPLE_PREVIEW_PAGE.ATTACK_PATHS}
/>
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />
</>
);
}
export { default } from "./(workflow)/query-builder/attack-paths-page";
@@ -1,19 +0,0 @@
import type { ReactNode } from "react";
import { Card, CardContent } from "@/components/shadcn/card/card";
export const ComplianceOverviewPanel = ({
children,
}: {
children: ReactNode;
}) => {
return (
<Card
variant="base"
padding="none"
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
>
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
</Card>
);
};
@@ -1,61 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceSamplePreview } from "./compliance-sample-preview";
const { fetchMock, getScanMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
getScanMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/compliance",
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
const STATE = {
active: true as const,
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
runningScan: null,
};
describe("ComplianceSamplePreview", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
it("renders the Overview watchlist frameworks as inert cards without fetching", () => {
render(<ComplianceSamplePreview state={STATE} />);
expect(
screen.getByText("You are exploring sample data"),
).toBeInTheDocument();
const cards = screen.getAllByRole("button", {
name: /compliance details$/,
});
expect(cards).toHaveLength(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
);
const cis = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.find(
(item) => item.attributes.compliance_id === "cis_5.0_aws",
)!.attributes;
expect(
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
).toHaveTextContent(
`${cis.requirements_passed} / ${cis.total_requirements}`,
);
expect(cards[0].closest("[inert]")).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(getScanMock).not.toHaveBeenCalled();
});
});
@@ -1,62 +0,0 @@
import { ThreatScoreBadge } from "@/components/compliance";
import { ComplianceCard } from "@/components/compliance/compliance-card";
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import {
SAMPLE_COMPLIANCE_OVERVIEWS,
SAMPLE_SCAN,
} from "@/lib/sample-preview/sample-compliance";
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
interface ComplianceSamplePreviewProps {
state: SamplePreviewActive;
}
/** Sample per-scan compliance: the Overview watchlist frameworks, inert. */
export function ComplianceSamplePreview({
state,
}: ComplianceSamplePreviewProps) {
return (
<>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<div inert>
<div className="mb-6">
<ThreatScoreBadge
score={SAMPLE_THREAT_SCORE.score}
scanId={SAMPLE_SCAN.id}
provider={SAMPLE_SCAN.providerInfo.provider}
selectedScan={SAMPLE_SCAN}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
/>
</div>
<ComplianceOverviewPanel>
<ComplianceFrameworkGrid>
{SAMPLE_COMPLIANCE_OVERVIEWS.map(({ id, attributes }) => (
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
title={attributes.framework}
version={attributes.version}
passingRequirements={attributes.requirements_passed}
totalRequirements={attributes.total_requirements}
prevPassingRequirements={attributes.requirements_passed}
prevTotalRequirements={attributes.total_requirements}
scanId={SAMPLE_SCAN.id}
complianceId={id}
id={id}
selectedScan={SAMPLE_SCAN}
/>
</div>
))}
</ComplianceFrameworkGrid>
</ComplianceOverviewPanel>
</div>
</>
);
}
-41
View File
@@ -12,11 +12,6 @@ import {
} from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
SAMPLE_PREVIEW_STAGE,
type SamplePreviewState,
} from "@/lib/sample-preview/sample-preview-state";
import Compliance from "./page";
const {
@@ -24,7 +19,6 @@ const {
complianceOverviewGridSpy,
getComplianceOverviewMetadataInfoMock,
getCompliancesOverviewMock,
getSamplePreviewStateMock,
getScanMock,
getScansMock,
getThreatScoreMock,
@@ -35,9 +29,6 @@ const {
complianceOverviewGridSpy: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(),
getCompliancesOverviewMock: vi.fn(),
getSamplePreviewStateMock: vi.fn(
async (): Promise<SamplePreviewState> => ({ active: false }),
),
getScanMock: vi.fn(),
getScansMock: vi.fn(),
getThreatScoreMock: vi.fn(),
@@ -61,14 +52,6 @@ vi.mock("@/actions/scans", () => ({
getScansByState: vi.fn(),
}));
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("./_components/compliance-sample-preview", () => ({
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
}));
vi.mock("@/lib/shared/env", () => ({
isCloud: isCloudMock,
}));
@@ -172,30 +155,6 @@ describe("Compliance overview page", () => {
});
});
describe("Compliance sample preview", () => {
it("renders the sample and skips the real fetches until the first scan completes", async () => {
// Given - a Cloud tenant whose first scan has not completed yet
getSamplePreviewStateMock.mockResolvedValue({
active: true,
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
runningScan: null,
});
// When
render(
(await Compliance({
searchParams: Promise.resolve({}),
})) as ReactElement,
);
// Then
expect(screen.getByText("Sample compliance preview")).toBeInTheDocument();
expect(getScansMock).not.toHaveBeenCalled();
expect(getCompliancesOverviewMock).not.toHaveBeenCalled();
expect(loadComplianceWatchlistContextMock).not.toHaveBeenCalled();
});
});
describe("Compliance overview task response", () => {
beforeEach(() => {
vi.clearAllMocks();
+17 -13
View File
@@ -17,10 +17,10 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
@@ -30,10 +30,8 @@ import {
} from "@/types";
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
import { ComplianceOverviewPanel } from "./_components/compliance-overview-panel";
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
import { ComplianceSamplePreview } from "./_components/compliance-sample-preview";
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
import { CrossProviderOverview } from "./_components/cross-provider-overview";
import {
@@ -73,16 +71,6 @@ export default async function Compliance({
}: {
searchParams: Promise<SearchParamsProps>;
}) {
// Sample data replaces both tabs until the first scan completes.
const preview = await getSamplePreviewState();
if (preview.active) {
return (
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
<ComplianceSamplePreview state={preview} />
</ContentLayout>
);
}
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
@@ -421,3 +409,19 @@ const ComplianceWatchlistControls = async ({
/>
);
};
const ComplianceOverviewPanel = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<Card
variant="base"
padding="none"
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
>
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
</Card>
);
};
@@ -1,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
/>
</>
);
}
-15
View File
@@ -24,32 +24,17 @@ import {
isFindingsPreviewLocked,
} from "@/lib/findings-access";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
import { FindingsFiltersSection } from "./_components/findings-filters-section";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
import { FindingsSamplePreview } from "./_components/findings-sample-preview";
export default async function Findings({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
// Sample data replaces the page (and its paywall) until the first scan completes.
const preview = await getSamplePreviewState();
if (preview.active) {
return (
<ContentLayout title="Findings" icon={<Tag />}>
<FindingsSamplePreview
state={preview}
searchParams={await searchParams}
/>
</ContentLayout>
);
}
const resolvedSearchParams = await searchParams;
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
@@ -1,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 -25
View File
@@ -1,14 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
getAllProviders,
getAllProviderGroups,
getSamplePreviewState,
notFound,
} = vi.hoisted(() => ({
const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
getAllProviders: vi.fn(),
getAllProviderGroups: vi.fn(),
getSamplePreviewState: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
@@ -28,12 +22,6 @@ vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: () => null,
}));
vi.mock("./_components/issues-list", () => ({ IssuesList: () => null }));
vi.mock("@/components/shared/sample-preview/sample-preview-banner", () => ({
SamplePreviewBanner: () => null,
}));
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState,
}));
import Issues from "./page";
@@ -41,7 +29,6 @@ describe("Issues deployment availability", () => {
beforeEach(() => {
getAllProviders.mockResolvedValue({ data: [] });
getAllProviderGroups.mockResolvedValue({ data: [] });
getSamplePreviewState.mockResolvedValue({ active: false });
});
it("returns not found before loading data in Local Server", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
@@ -58,15 +45,4 @@ describe("Issues deployment availability", () => {
expect(getAllProviders).toHaveBeenCalledOnce();
expect(getAllProviderGroups).toHaveBeenCalledOnce();
});
it("skips the scope controls' data while the sample is shown", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getSamplePreviewState.mockResolvedValue({
active: true,
stage: "no_provider",
runningScan: null,
});
await Issues({ searchParams: Promise.resolve({}) });
expect(getAllProviders).not.toHaveBeenCalled();
expect(getAllProviderGroups).not.toHaveBeenCalled();
});
});
-28
View File
@@ -8,20 +8,12 @@ import { ProviderAccountSelectors } from "@/components/filters/provider-account-
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import { IssuesList } from "./_components/issues-list";
const SAMPLE_PROVIDER_UIDS = {
[SAMPLE_ACCOUNT.providerId]: SAMPLE_ACCOUNT.uid,
};
export default async function Issues({
searchParams,
}: {
@@ -30,26 +22,6 @@ export default async function Issues({
if (!isCloud()) notFound();
const resolvedSearchParams = await searchParams;
const samplePreview = await getSamplePreviewState();
if (samplePreview.active) {
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<SamplePreviewBanner
state={samplePreview}
page={SAMPLE_PREVIEW_PAGE.ISSUES}
/>
{/* No scope selectors: the sample account is not one of theirs. */}
<IssuesList
searchParams={resolvedSearchParams}
sample
providerUids={SAMPLE_PROVIDER_UIDS}
/>
</FeatureFlagRoute>
</ContentLayout>
);
}
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useForm } from "react-hook-form";
import {
createLighthouseV2Configuration,
@@ -12,7 +12,6 @@ import {
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
BEDROCK_AUTH_METHOD,
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
buildLighthouseV2ConfigurationInput,
@@ -75,14 +74,6 @@ export function LighthouseV2ConfigurationForm({
mode: "onSubmit",
});
const status = getConnectionStatus(configuration);
const bedrockAuthMethod = useWatch({
control: form.control,
name: "bedrockAuthMethod",
});
// An API key has no instance-role fallback, so the hint only fits access keys.
const usesInstanceRoleHint =
provider.credentialsOptional &&
bedrockAuthMethod !== BEDROCK_AUTH_METHOD.API_KEY;
const runConnectionTest = async (configurationId: string) => {
setTesting(true);
@@ -152,12 +143,7 @@ export function LighthouseV2ConfigurationForm({
return;
}
// Keep the chosen Bedrock auth method only when its credentials were
// saved; a blank save keeps the stored ones, whatever method they use.
form.reset({
...getFormDefaults(result.data),
...(credentials ? { bedrockAuthMethod: values.bedrockAuthMethod } : {}),
});
form.reset(getFormDefaults(result.data));
onConfigurationSaved(result.data);
notifyLighthouseV2ConfigurationsChanged();
if (shouldTestAfterSave) {
@@ -274,17 +260,14 @@ export function LighthouseV2ConfigurationForm({
icon={<KeyRound className="size-4" />}
title="Credentials"
description={
usesInstanceRoleHint
? configuration
? "Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role."
: "Leave the keys blank to use this installation's own AWS role; only the region is needed."
provider.credentialsOptional
? "Leave the keys blank to use this installation's own AWS role; only the region is needed."
: configuration
? "Leave blank to keep existing credentials."
: "Credentials are required for new configurations."
}
>
<CredentialFields
control={form.control}
credentialsOptional={provider.credentialsOptional}
errors={form.formState.errors}
hasStoredCredentials={hasConfiguration}
@@ -1,33 +1,24 @@
import { Controller, type useForm, useWatch } from "react-hook-form";
import { type useForm } from "react-hook-form";
import {
BEDROCK_AUTH_METHOD,
type LighthouseV2ConfigFormValues,
} from "@/app/(prowler)/lighthouse/_lib/config";
import { type LighthouseV2ConfigFormValues } from "@/app/(prowler)/lighthouse/_lib/config";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
type LighthouseV2ProviderType,
} from "@/app/(prowler)/lighthouse/_types";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Input } from "@/components/shadcn/input/input";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
// Stored secrets are never sent back by the API, so the fields would look
// empty even when a key exists; the masked placeholder stands in for it.
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
export function CredentialFields({
control,
credentialsOptional = false,
errors,
hasStoredCredentials = false,
provider,
register,
}: {
control: ReturnType<typeof useForm<LighthouseV2ConfigFormValues>>["control"];
// Keys may be left blank (Bedrock with the installation's own role).
credentialsOptional?: boolean;
errors: ReturnType<
@@ -39,50 +30,17 @@ export function CredentialFields({
typeof useForm<LighthouseV2ConfigFormValues>
>["register"];
}) {
const isBedrock = provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK;
// The masked placeholder would suggest a key that may not be there: with
// optional keys a saved configuration may have none, and for Bedrock the
// API does not say whether access keys or an API key are stored.
// With optional keys a saved configuration may have none, so the masked
// placeholder would suggest a key that is not there.
const secretPlaceholder =
hasStoredCredentials && !credentialsOptional && !isBedrock
hasStoredCredentials && !credentialsOptional
? STORED_SECRET_PLACEHOLDER
: undefined;
const keyLabelSuffix = credentialsOptional ? " (optional)" : "";
const bedrockAuthMethod = useWatch({ control, name: "bedrockAuthMethod" });
const usesBedrockApiKey =
isBedrock && bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY;
return (
<div className="grid gap-4">
{isBedrock && (
<Field>
<FieldLabel>Authentication method</FieldLabel>
<Controller
control={control}
name="bedrockAuthMethod"
render={({ field }) => (
<RadioGroup
value={field.value}
onValueChange={field.onChange}
orientation="horizontal"
aria-label="Authentication method"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={BEDROCK_AUTH_METHOD.ACCESS_KEYS} />
AWS access keys
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value={BEDROCK_AUTH_METHOD.API_KEY} />
Bedrock API key
</label>
</RadioGroup>
)}
/>
</Field>
)}
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE ||
usesBedrockApiKey) && (
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
<Field>
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
<Input
@@ -114,45 +72,41 @@ export function CredentialFields({
</Field>
)}
{isBedrock && (
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
<div className="grid gap-4 md:grid-cols-2">
{!usesBedrockApiKey && (
<>
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-access-key">
AWS access key ID{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-access-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsAccessKeyId)}
{...register("awsAccessKeyId")}
/>
{errors.awsAccessKeyId?.message && (
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
</>
)}
<Field>
<FieldLabel htmlFor="lighthouse-v2-secret-key">
AWS secret access key{keyLabelSuffix}
</FieldLabel>
<Input
id="lighthouse-v2-secret-key"
type="password"
autoComplete="off"
placeholder={secretPlaceholder}
aria-invalid={Boolean(errors.awsSecretAccessKey)}
{...register("awsSecretAccessKey")}
/>
{errors.awsSecretAccessKey?.message && (
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
)}
</Field>
<Field className="md:col-span-2">
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
@@ -284,14 +284,14 @@ describe("LighthouseV2ConfigPage", () => {
maskedKey,
);
// But not for Bedrock: the API does not say whether access keys or an
// API key are stored, so masking either set could claim the wrong one
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
expect(screen.getByLabelText("AWS access key ID")).not.toHaveAttribute(
expect(screen.getByLabelText("AWS access key ID")).toHaveAttribute(
"placeholder",
maskedKey,
);
expect(screen.getByLabelText("AWS secret access key")).not.toHaveAttribute(
expect(screen.getByLabelText("AWS secret access key")).toHaveAttribute(
"placeholder",
maskedKey,
);
});
@@ -330,44 +330,6 @@ describe("LighthouseV2ConfigPage", () => {
expect(createConfigurationMock).not.toHaveBeenCalled();
});
it("does not hint at a stored Bedrock API key it cannot know about", async () => {
// Given: Bedrock has a stored configuration of unknown credential type
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// When
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
// Then
expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder");
});
it("falls back to access keys after saving Bedrock without new credentials", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
renderPage();
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
// When: saved with blank fields, so the stored credentials stay
await user.click(
within(
screen.getByRole("region", { name: "Lighthouse AI settings" }),
).getByRole("button", { name: /^Save$/i }),
);
// Then: the form no longer claims an API key that was never saved
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
"credentials",
);
expect(
await screen.findByRole("radio", { name: "AWS access keys" }),
).toBeChecked();
});
it("shows Bedrock access key, secret key, and region fields", async () => {
// Given
const user = userEvent.setup();
@@ -382,42 +344,6 @@ describe("LighthouseV2ConfigPage", () => {
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
});
it("saves Bedrock with an API key instead of access keys", async () => {
// Given
const user = userEvent.setup();
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
testConnectionMock.mockResolvedValue({ data: configurations[1] });
renderPage();
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// When: the API key method is chosen
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
// Then: the access key fields give way to the API key field
expect(
screen.queryByLabelText("AWS access key ID"),
).not.toBeInTheDocument();
expect(
screen.queryByLabelText("AWS secret access key"),
).not.toBeInTheDocument();
// When
await user.type(screen.getByLabelText("API key"), "ABSKlongterm");
await user.type(screen.getByLabelText("AWS region"), "us-east-1");
await user.click(
within(
screen.getByRole("region", { name: "Lighthouse AI settings" }),
).getByRole("button", { name: /^Save$/i }),
);
// Then
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
expect(updateConfigurationMock.mock.calls[0]?.[1].credentials).toEqual({
api_key: "ABSKlongterm",
aws_region_name: "us-east-1",
});
});
it("changes a Bedrock region without keys when the installation role is allowed", async () => {
// Given: the API reports Bedrock needs no credentials (Private Cloud)
const user = userEvent.setup();
@@ -432,13 +358,6 @@ describe("LighthouseV2ConfigPage", () => {
});
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
// Then: blank fields keep the stored credentials, so the hint says so
expect(
screen.getByText(
"Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role.",
),
).toBeInTheDocument();
// When: only the region is entered
expect(
screen.getByLabelText("AWS access key ID (optional)"),
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { LIGHTHOUSE_V2_PROVIDER_TYPE } from "@/app/(prowler)/lighthouse/_types";
import {
BEDROCK_AUTH_METHOD,
buildCredentialPayload,
buildLighthouseV2ConfigFormSchema,
EMPTY_FORM_VALUES,
@@ -137,101 +136,4 @@ describe("Bedrock configuration form", () => {
).toBeUndefined();
});
});
describe("when authenticating with a Bedrock API key", () => {
const apiKeyValues = (overrides: Partial<LighthouseV2ConfigFormValues>) =>
values({ bedrockAuthMethod: BEDROCK_AUTH_METHOD.API_KEY, ...overrides });
it("should require the API key and the region for a new configuration", () => {
// Given / When / Then
expect(errorPaths(false, apiKeyValues({}))).toEqual([
"apiKey",
"awsRegionName",
]);
});
it("should still require the API key where the installation role is allowed", () => {
// Given / When / Then
expect(
errorPaths(
false,
apiKeyValues({ awsRegionName: "us-east-1" }),
OPTIONAL,
),
).toEqual(["apiKey"]);
});
it("should require the region to rotate the key on an existing configuration", () => {
// Given / When / Then
expect(
errorPaths(true, apiKeyValues({ apiKey: "ABSKlongterm" })),
).toEqual(["awsRegionName"]);
});
it.each(["ABSKlongterm", "bedrock-api-key-shortterm"])(
"should accept the %s key format",
(apiKey) => {
// Given / When / Then
expect(
errorPaths(
false,
apiKeyValues({ apiKey, awsRegionName: "us-east-1" }),
),
).toEqual([]);
},
);
it("should reject a key that is not a Bedrock API key", () => {
// Given / When / Then
expect(
errorPaths(
false,
apiKeyValues({ apiKey: "sk-proj-123", awsRegionName: "us-east-1" }),
),
).toEqual(["apiKey"]);
});
it("should send the key and region, without leftover access keys", () => {
// Given: access keys typed before switching to the API key method
const formValues = apiKeyValues({
apiKey: " ABSKlongterm ",
awsAccessKeyId: "AKIA",
awsSecretAccessKey: "secret",
awsRegionName: "us-east-1",
});
// When / Then
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
api_key: "ABSKlongterm",
aws_region_name: "us-east-1",
});
});
it("should keep the stored configuration when nothing is entered", () => {
// Given / When / Then
expect(errorPaths(true, apiKeyValues({}))).toEqual([]);
expect(
buildCredentialPayload(BEDROCK, apiKeyValues({}), true),
).toBeUndefined();
});
});
describe("when authenticating with access keys", () => {
it("should ignore an API key left over from the other method", () => {
// Given
const formValues = values({
apiKey: "ABSKlongterm",
awsAccessKeyId: "AKIA",
awsSecretAccessKey: "secret",
awsRegionName: "us-east-1",
});
// When / Then
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
aws_access_key_id: "AKIA",
aws_secret_access_key: "secret",
aws_region_name: "us-east-1",
});
});
});
});
@@ -37,20 +37,8 @@ export interface FeedbackState {
variant: FeedbackVariant;
}
export const BEDROCK_AUTH_METHOD = {
ACCESS_KEYS: "access_keys",
API_KEY: "api_key",
} as const;
export type BedrockAuthMethod =
(typeof BEDROCK_AUTH_METHOD)[keyof typeof BEDROCK_AUTH_METHOD];
// Long-term keys start with "ABSK", short-term ones with "bedrock-api-key-".
const BEDROCK_API_KEY_PATTERN = /^(ABSK|bedrock-api-key-)\S+$/;
const lighthouseV2ConfigFormSchemaBase = z.object({
apiKey: z.string(),
bedrockAuthMethod: z.enum(BEDROCK_AUTH_METHOD),
awsAccessKeyId: z.string(),
awsSecretAccessKey: z.string(),
awsRegionName: z.string(),
@@ -63,7 +51,6 @@ export type LighthouseV2ConfigFormValues = z.infer<
export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
apiKey: "",
bedrockAuthMethod: BEDROCK_AUTH_METHOD.ACCESS_KEYS,
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegionName: "",
@@ -130,34 +117,6 @@ export function buildLighthouseV2ConfigFormSchema(
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
if (data.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
const regionName = data.awsRegionName.trim();
if (hasConfiguration && !apiKey && !regionName) return;
if (!apiKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Bedrock API key is required.",
path: ["apiKey"],
});
} else if (!BEDROCK_API_KEY_PATTERN.test(apiKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Bedrock API keys start with 'ABSK' (long-term) or 'bedrock-api-key-' (short-term).",
path: ["apiKey"],
});
}
if (!regionName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "AWS region is required.",
path: ["awsRegionName"],
});
}
return;
}
if (credentialsOptional) {
// The installation's own role: a region alone is enough, and keys,
// when given, still have to come as a pair.
@@ -231,14 +190,6 @@ export function buildCredentialPayload(
{ credentialsOptional = false }: LighthouseV2ProviderCapabilities = {},
): LighthouseV2Credentials | undefined {
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
if (values.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
const apiKey = values.apiKey.trim();
const regionName = values.awsRegionName.trim();
if (hasConfiguration && !apiKey && !regionName) return undefined;
return { api_key: apiKey, aws_region_name: regionName };
}
const hasBedrockCredentials =
Boolean(values.awsAccessKeyId.trim()) ||
Boolean(values.awsSecretAccessKey.trim()) ||
+2 -6
View File
@@ -38,12 +38,8 @@ describe("Overview page", () => {
it("uses the responsive container breakpoint for the primary metric row", () => {
// Given
const gridSource = readFileSync(
path.join(currentDir, "_overview/_components/overview-grid.tsx"),
"utf8",
);
const metricRowClassName = gridSource.match(
/<div className="([^"]+)">\s*\{threatScore\}/,
const metricRowClassName = source.match(
/<div className="([^"]+)">\s*<Suspense fallback={<ThreatScoreSkeleton \/>}>/,
)?.[1];
// When
+71 -85
View File
@@ -12,16 +12,12 @@ import {
} from "@/components/layout/app-sidebar";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { DOCS_URLS } from "@/lib/external-urls";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import { OverviewBanner } from "./_overview/_components/overview-banner";
import { OverviewGrid } from "./_overview/_components/overview-grid";
import { OverviewProviderContext } from "./_overview/_components/overview-provider-context";
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
@@ -46,7 +42,6 @@ import {
RiskSeverityChartSkeleton,
RiskSeverityChartSSR,
} from "./_overview/risk-severity";
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
import {
FindingSeverityOverTimeSkeleton,
FindingSeverityOverTimeSSR,
@@ -65,32 +60,12 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
// Resolved alongside the page data, so tenants with scans wait no longer.
const [
samplePreview,
providersData,
providerGroupsData,
lighthouseBannerHref,
] = await Promise.all([
getSamplePreviewState(),
getAllProviders(),
getAllProviderGroups(),
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
]);
// No completed scan yet: the demo account stands in for the tenant's data.
if (samplePreview.active) {
return (
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
<SamplePreviewBanner
state={samplePreview}
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
/>
<OverviewSamplePreview showIssues={isCloud()} />
</ContentLayout>
);
}
const [providersData, providerGroupsData, lighthouseBannerHref] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
]);
return (
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
@@ -124,71 +99,82 @@ export default async function Home({
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
<OverviewGrid
threatScore={
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
}
exploitability={
isCloud() ? (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<Suspense fallback={<ExploitabilityChartSkeleton />}>
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</FeatureFlagGate>
) : undefined
}
checkFindings={
<Suspense fallback={<StatusChartSkeleton />}>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
}
riskSeverity={
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
}
resourcesInventory={
<Suspense fallback={<ResourcesInventorySkeleton />}>
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
</Suspense>
}
complianceWatchlist={
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
}
serviceWatchlist={
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
}
attackSurface={
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
<Suspense fallback={<ThreatScoreSkeleton />}>
<ThreatScoreSSR searchParams={resolvedSearchParams} />
</Suspense>
{isCloud() && (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<Suspense fallback={<ExploitabilityChartSkeleton />}>
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</FeatureFlagGate>
)}
<Suspense fallback={<StatusChartSkeleton />}>
<CheckFindingsSSR searchParams={resolvedSearchParams} />
</Suspense>
<Suspense fallback={<RiskSeverityChartSkeleton />}>
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6">
<Suspense fallback={<ResourcesInventorySkeleton />}>
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
the line's max-content rather than to its own 312px, and a card wider
than the column loses its right border and padding to
`overflow-hidden`. It never wrapped anyway. */}
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
{/* Sized to its own list, unlike the service card below: this one is
as long as the organization made its watchlist, so growing it to
fill the column puts a hole under two pinned frameworks. */}
{/* `h-full` only where the cards sit side by side and share a row
height. In the column it would hand the card the stretched
wrapper's height and undo the fit-to-content sizing. */}
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
<Suspense fallback={<WatchlistCardSkeleton />}>
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
</Suspense>
</div>
</div>
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
<div className="flex flex-1 flex-col gap-6">
<Suspense fallback={<AttackSurfaceSkeleton />}>
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
</Suspense>
}
topIssues={
isCloud() ? (
{isCloud() && (
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
<Suspense fallback={<TopIssuesSkeleton />}>
<TopIssuesSSR searchParams={resolvedSearchParams} />
</Suspense>
</FeatureFlagGate>
) : undefined
}
severityOverTime={
)}
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
</Suspense>
}
graphsTabs={
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>
}
/>
</div>
</div>
<div className="mt-6">
<Suspense fallback={<RiskPipelineViewSkeleton />}>
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
</Suspense>
</div>
</ContentLayout>
);
}
@@ -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"),
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more