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
25 changed files with 1477 additions and 64 deletions

No files matched your search

+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
+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
+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">
+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