Compare commits
No files matched your search
@@ -1,28 +1,41 @@
|
||||
# Importing Packages
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import click
|
||||
import dash
|
||||
import dash_bootstrap_components as dbc
|
||||
from colorama import Fore, Style
|
||||
from colorama import init as colorama_init
|
||||
from dash import dcc, html
|
||||
from dash.dependencies import Input, Output
|
||||
|
||||
from dashboard.config import folder_path_overview
|
||||
from prowler.config.config import orange_color
|
||||
from prowler.lib.banner import print_banner
|
||||
from dashboard.lib.startup import (
|
||||
install_server_ready_hook,
|
||||
silence_dash_running_line,
|
||||
)
|
||||
from prowler.lib.banner import (
|
||||
print_dashboard_banner,
|
||||
print_dashboard_loading,
|
||||
print_dashboard_promotion,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
# Honour the NO_COLOR convention: the dashboard parser has no --no-color flag.
|
||||
if os.environ.get("NO_COLOR"):
|
||||
colorama_init(strip=True)
|
||||
|
||||
# Compact startup output: identity, one hand-off block and the CSV directory.
|
||||
# The local URL is printed by ``install_server_ready_hook`` once the server is
|
||||
# actually listening, so nothing claims to be ready before it is. S3 download
|
||||
# instructions live in the dashboard documentation instead of every startup.
|
||||
cli = sys.modules["flask.cli"]
|
||||
print_banner()
|
||||
print(
|
||||
f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}"
|
||||
)
|
||||
cli.show_server_banner = lambda *_: click.echo(
|
||||
f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3://<your-bucket>/output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files."
|
||||
)
|
||||
print_dashboard_banner()
|
||||
print_dashboard_promotion()
|
||||
print_dashboard_loading(folder_path_overview)
|
||||
cli.show_server_banner = lambda *_: None
|
||||
install_server_ready_hook()
|
||||
|
||||
# Initialize the app - incorporate css
|
||||
dashboard = dash.Dash(
|
||||
@@ -30,8 +43,10 @@ dashboard = dash.Dash(
|
||||
external_stylesheets=[dbc.themes.FLATLY],
|
||||
use_pages=True,
|
||||
suppress_callback_exceptions=True,
|
||||
title="Prowler Dashboard",
|
||||
title="Prowler Local Dashboard",
|
||||
)
|
||||
# The ready block already announces the URL; drop Dash's pre-bind duplicate.
|
||||
silence_dash_running_line(dashboard)
|
||||
|
||||
# ``use_pages`` above already imported dashboard/pages/cloud.py and registered
|
||||
# every /cloud/* route. Import its metadata now (after app instantiation) so
|
||||
@@ -70,7 +85,10 @@ DASHBOARD_ITEMS = [
|
||||
# Gated navigation groups reference the shared feature metadata by slug so the
|
||||
# sidebar and the informational pages never drift apart.
|
||||
GATED_GROUPS = [
|
||||
("Upgrade to Prowler Cloud", ["lighthouse-ai", "attack-paths", "findings"]),
|
||||
(
|
||||
"Upgrade to Prowler Cloud",
|
||||
["findings", "exploitable-issues", "lighthouse-ai", "mcp", "attack-paths"],
|
||||
),
|
||||
("Configuration", ["alerts", "mutelist", "integrations"]),
|
||||
("Workspace", ["organization"]),
|
||||
]
|
||||
|
||||
@@ -300,6 +300,18 @@
|
||||
margin: 0 0 26px;
|
||||
}
|
||||
|
||||
/* Prowler Cloud screenshot shown on each gated page. */
|
||||
.pc-shot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
height: auto;
|
||||
border: 1px solid var(--pc-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.08);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.pc-benefits {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -350,6 +362,111 @@
|
||||
background: var(--pc-btn-press);
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Findings hand-off additions */
|
||||
|
||||
/* Secondary "how to" block rendered between the message and the CTA. */
|
||||
.pc-steps {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
text-align: left;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--pc-border);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin: 0 0 26px;
|
||||
}
|
||||
|
||||
.pc-steps-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--pc-text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.pc-steps-list {
|
||||
/* Tailwind's preflight strips list markers; restore the numbering. */
|
||||
list-style: decimal outside;
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
color: var(--pc-text-2);
|
||||
}
|
||||
|
||||
.pc-steps-note {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #52525b;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.pc-steps-note:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pc-cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px 20px;
|
||||
}
|
||||
|
||||
.pc-link-secondary {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--pc-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.pc-link-secondary:hover {
|
||||
color: var(--pc-btn-press);
|
||||
}
|
||||
|
||||
.pc-note {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #52525b;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* Compact hand-off card used at the end of the Compliance page. */
|
||||
.pc-handoff {
|
||||
min-width: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--pc-border);
|
||||
border-radius: 14px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.pc-handoff .pc-avail {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pc-handoff-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--pc-text);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pc-handoff-desc {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pc-handoff-benefits {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pc-handoff .pc-cta-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.pc-handoff .pc-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- Responsive collapse */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a6 6 0 0 1-12 0V8z"/></svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -1,5 +1,7 @@
|
||||
from dash import dcc, html
|
||||
|
||||
from dashboard.pages.cloud import build_compliance_handoff_card
|
||||
|
||||
|
||||
def create_layout_overview(
|
||||
account_dropdown: html.Div,
|
||||
@@ -194,6 +196,7 @@ def create_layout_compliance(
|
||||
className="text-prowler-stone-900 text-lg font-bold",
|
||||
),
|
||||
html.Div(className="flex flex-wrap", id="output"),
|
||||
build_compliance_handoff_card(),
|
||||
],
|
||||
className="grid gap-x-8 gap-y-8 2xl:container mx-auto",
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Startup output helpers for `prowler dashboard`.
|
||||
|
||||
The local URL is announced only once the HTTP server is listening. Werkzeug
|
||||
calls ``BaseWSGIServer.log_startup`` right after it binds the socket, so a
|
||||
thin wrapper around that method reads the effective host and port from the
|
||||
bound server and prints the ready message. Dash silences the Werkzeug logger
|
||||
below ERROR while in debug mode, so hooking the method (not the log) is what
|
||||
makes the announcement reliable. Werkzeug's own behaviour is untouched: the
|
||||
original method still runs and no logger is reconfigured.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from colorama import Fore, Style
|
||||
from werkzeug.serving import BaseWSGIServer, get_interface_ip
|
||||
|
||||
from prowler.config.config import banner_color
|
||||
|
||||
_WILDCARD_HOSTS = {"0.0.0.0": "127.0.0.1", "::": "[::1]"}
|
||||
|
||||
|
||||
def server_urls(host, port, scheme="http"):
|
||||
"""Return the browser URL and the other reachable URLs for a bound server.
|
||||
|
||||
A wildcard host (``0.0.0.0`` or ``::``) is never presented as a browser
|
||||
destination: the loopback address is used instead and the interface
|
||||
address is listed as also reachable.
|
||||
"""
|
||||
if host in _WILDCARD_HOSTS:
|
||||
primary = f"{scheme}://{_WILDCARD_HOSTS[host]}:{port}/"
|
||||
family = socket.AF_INET if host == "0.0.0.0" else socket.AF_INET6
|
||||
try:
|
||||
interface_ip = get_interface_ip(family)
|
||||
except OSError:
|
||||
interface_ip = None
|
||||
others = []
|
||||
if interface_ip:
|
||||
if ":" in interface_ip:
|
||||
interface_ip = f"[{interface_ip}]"
|
||||
others.append(f"{scheme}://{interface_ip}:{port}/")
|
||||
return primary, others
|
||||
display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
return f"{scheme}://{display_host}:{port}/", []
|
||||
|
||||
|
||||
def format_ready_message(host, port, scheme="http"):
|
||||
"""Render the ready block, styled like the CLI banner, for a listening server."""
|
||||
primary, others = server_urls(host, port, scheme)
|
||||
bar = f"{banner_color}│{Style.RESET_ALL}"
|
||||
check = f"{Fore.GREEN}✓{Style.RESET_ALL}"
|
||||
lines = [
|
||||
f"{bar} {check} {Style.BRIGHT}Prowler Local Dashboard now is running on{Style.RESET_ALL} "
|
||||
f"{banner_color}{primary}{Style.RESET_ALL}"
|
||||
]
|
||||
if host in _WILDCARD_HOSTS:
|
||||
lines.append(f"{bar} Listening on all interfaces ({host}).")
|
||||
if others:
|
||||
lines.append(f"{bar} Also reachable at: " + ", ".join(others))
|
||||
lines.append(f"{bar} Press Ctrl+C to stop.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class DashRunningLineFilter(logging.Filter):
|
||||
"""Drop Dash's own ``Dash is running on ...`` line from the app logger.
|
||||
|
||||
The ready block printed by ``install_server_ready_hook`` already states the
|
||||
effective URL once the server is listening, so Dash's pre-bind line would
|
||||
only repeat it. Every other record on the logger still passes.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
return not record.getMessage().startswith("Dash is running on")
|
||||
|
||||
|
||||
def silence_dash_running_line(app):
|
||||
"""Attach ``DashRunningLineFilter`` to a Dash app's logger once."""
|
||||
if not any(isinstance(f, DashRunningLineFilter) for f in app.logger.filters):
|
||||
app.logger.addFilter(DashRunningLineFilter())
|
||||
|
||||
|
||||
def _print_now(message):
|
||||
"""Print and flush so the ready message shows even when stdout is piped."""
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def install_server_ready_hook(announce=_print_now):
|
||||
"""Announce the effective address right after Werkzeug binds the socket.
|
||||
|
||||
Wraps ``BaseWSGIServer.log_startup`` once per process. The wrapper calls
|
||||
the original method first, so Werkzeug's own startup output is unchanged,
|
||||
then prints the ready block a single time. A failed bind never reaches
|
||||
``log_startup``, so a startup error is never announced as ready.
|
||||
"""
|
||||
if getattr(BaseWSGIServer.log_startup, "_prowler_ready_hook", False):
|
||||
return BaseWSGIServer.log_startup
|
||||
|
||||
original = BaseWSGIServer.log_startup
|
||||
state = {"announced": False}
|
||||
|
||||
def log_startup(self):
|
||||
original(self)
|
||||
if state["announced"]:
|
||||
return
|
||||
state["announced"] = True
|
||||
scheme = "http" if self.ssl_context is None else "https"
|
||||
announce("\n" + format_ready_message(self.host, self.port, scheme) + "\n")
|
||||
|
||||
log_startup._prowler_ready_hook = True
|
||||
log_startup._prowler_original = original
|
||||
BaseWSGIServer.log_startup = log_startup
|
||||
return log_startup
|
||||
@@ -21,12 +21,58 @@ CLOUD_CTA_BASE = (
|
||||
# Path to the recolorable checkmark mask used for benefit bullets.
|
||||
CHECK_ICON = "/assets/images/icons/cloud/check.svg"
|
||||
|
||||
# Prowler Cloud screenshots shown on each gated page. They come from the
|
||||
# product documentation (docs/images) and are downscaled for the dashboard.
|
||||
SCREENSHOT_DIR = "/assets/images/cloud/screenshots"
|
||||
|
||||
# Prowler Cloud pricing page used by the "Compare editions" secondary link.
|
||||
CLOUD_PRICING_URL = "https://prowler.com/pricing"
|
||||
|
||||
# Findings hand-off CTA. Shares the ``report-handoff`` campaign and ``oss``
|
||||
# medium with the OSS HTML report CTA; ``utm_source`` identifies this entry
|
||||
# point and ``intent`` expresses the user's goal. The URL is static: no
|
||||
# assessment data (accounts, resources, findings, paths) is ever appended.
|
||||
FINDINGS_HANDOFF_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-local-dashboard&utm_medium=oss"
|
||||
"&utm_campaign=report-handoff&utm_content=findings&intent=import-findings"
|
||||
)
|
||||
|
||||
# Same hand-off for the Compliance page; only ``utm_content`` differs.
|
||||
COMPLIANCE_HANDOFF_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-local-dashboard&utm_medium=oss"
|
||||
"&utm_campaign=report-handoff&utm_content=compliance&intent=import-findings"
|
||||
)
|
||||
|
||||
# Shared hand-off copy (identical to the OSS HTML report CTA).
|
||||
HANDOFF_TITLE = "Keep scanning locally. Bring your findings to Prowler Cloud."
|
||||
HANDOFF_DESCRIPTION = (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
)
|
||||
HANDOFF_CTA_LABEL = "Explore Prowler Cloud"
|
||||
HANDOFF_SECONDARY_LABEL = "Compare editions"
|
||||
HANDOFF_NOTE = "Nothing is uploaded automatically."
|
||||
|
||||
# What Prowler Cloud adds on top of the local compliance view, as documented in
|
||||
# the Compliance, Cross-Provider Compliance and Cross-Provider Type Compliance
|
||||
# tutorials. Keep these verifiable; do not add unreleased capabilities.
|
||||
COMPLIANCE_HANDOFF_BENEFITS = [
|
||||
"Compliance Watchlist to pin the frameworks you track",
|
||||
"Cross-account and cross-provider roll-ups of a framework, with a per-provider breakdown",
|
||||
"Downloadable CSV and PDF compliance reports",
|
||||
]
|
||||
|
||||
|
||||
# Normative feature definitions. ``icon`` points to a local mask asset so no
|
||||
# external requests are needed and the glyph recolors per context.
|
||||
CLOUD_FEATURES = [
|
||||
{
|
||||
"slug": "lighthouse-ai",
|
||||
"screenshot": {
|
||||
"file": "lighthouse-ai.jpg",
|
||||
"alt": "Lighthouse AI chat in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/lighthouse-ai",
|
||||
"nav_label": "Lighthouse AI",
|
||||
"page_title": "Lighthouse AI",
|
||||
@@ -43,8 +89,64 @@ CLOUD_FEATURES = [
|
||||
"utm_content": "lighthouse-ai",
|
||||
"icon": "/assets/images/icons/cloud/lighthouse-ai.svg",
|
||||
},
|
||||
{
|
||||
# Wording mirrors the Prowler Cloud issues model: an issue folds the
|
||||
# findings of one attack path and carries a verdict, legs and a fix.
|
||||
"slug": "exploitable-issues",
|
||||
"route": "/cloud/exploitable-issues",
|
||||
"nav_label": "Exploitable Issues",
|
||||
"page_title": "Exploitable Issues",
|
||||
"card_title": "Unlock Exploitable Issues",
|
||||
"description": (
|
||||
"See which findings combine into an exploitable attack path, from "
|
||||
"where a threat actor starts to what they would gain, so the toxic "
|
||||
"combinations get fixed first."
|
||||
),
|
||||
"benefits": [
|
||||
"Each issue folds the related findings of one attack path into a single verdict: exploitable, likely, conditional or not exploitable",
|
||||
"Reachability, access, target, controls and verification are assessed separately, with the evidence behind each",
|
||||
"Outcome and starting point are spelled out, from the internet to a leaked credential, together with the fix and a triage status",
|
||||
],
|
||||
"utm_content": "exploitable-issues",
|
||||
"icon": "/assets/images/icons/cloud/exploitable-issues.svg",
|
||||
"note": "Exploitable Issues is rolling out to Prowler Cloud tenants.",
|
||||
},
|
||||
{
|
||||
# Capabilities below mirror the Prowler MCP documentation (Overview and
|
||||
# Cloud MCP Server configuration). The hosted server is in preview.
|
||||
"slug": "mcp",
|
||||
"screenshot": {
|
||||
"file": "mcp.jpg",
|
||||
"alt": "Claude Code querying Prowler through the Prowler MCP Server",
|
||||
},
|
||||
"route": "/cloud/mcp",
|
||||
"nav_label": "MCP Server",
|
||||
"page_title": "Prowler MCP",
|
||||
"card_title": "Unlock Prowler MCP",
|
||||
"description": (
|
||||
"Connect your AI assistant to Prowler through the hosted MCP Server "
|
||||
"at mcp.prowler.com, with no installation, using a Prowler Cloud "
|
||||
"API key."
|
||||
),
|
||||
"benefits": [
|
||||
"Ask about findings, resources and compliance in natural language from Claude Code, Cursor, VS Code or Codex",
|
||||
"Trigger scans and manage providers, mute rules and integrations from your AI agent",
|
||||
"Cloud-only tools for findings triage, scan scheduling, scan configurations and alerts",
|
||||
],
|
||||
"utm_content": "mcp",
|
||||
"icon": "/assets/images/icons/cloud/mcp.svg",
|
||||
"secondary_link": {
|
||||
"label": "Connect your AI agent",
|
||||
"href": "https://docs.prowler.com/user-guide/ai-agents/",
|
||||
},
|
||||
"note": "Prowler MCP Server is currently a preview feature.",
|
||||
},
|
||||
{
|
||||
"slug": "attack-paths",
|
||||
"screenshot": {
|
||||
"file": "attack-paths.jpg",
|
||||
"alt": "Attack path graph in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/attack-paths",
|
||||
"nav_label": "Attack Paths",
|
||||
"page_title": "Attack Paths",
|
||||
@@ -62,25 +164,53 @@ CLOUD_FEATURES = [
|
||||
"icon": "/assets/images/icons/cloud/attack-paths.svg",
|
||||
},
|
||||
{
|
||||
# Findings is a voluntary hand-off, not an upsell: the dashboard keeps
|
||||
# showing findings from CSV, and this page explains how to bring the
|
||||
# OCSF report to Prowler Cloud. Copy is shared with the OSS HTML report.
|
||||
"slug": "findings",
|
||||
"screenshot": {
|
||||
"file": "findings.jpg",
|
||||
"alt": "Findings table with triage in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/findings",
|
||||
"nav_label": "Findings",
|
||||
"page_title": "Findings",
|
||||
"card_title": "Unlock Findings",
|
||||
"card_title": "Keep scanning locally. Bring your findings to Prowler Cloud.",
|
||||
"description": (
|
||||
"Filter, investigate, and prioritize security findings across "
|
||||
"providers and scans from one workspace."
|
||||
"Import your accompanying OCSF report to explore managed security "
|
||||
"workflows."
|
||||
),
|
||||
"benefits": [
|
||||
"Search and filter findings across all connected accounts",
|
||||
"Track status, severity, ownership, and remediation context",
|
||||
"Triage findings and share a consistent source of truth with your security team",
|
||||
],
|
||||
"benefits": [],
|
||||
"utm_content": "findings",
|
||||
"icon": "/assets/images/icons/cloud/findings.svg",
|
||||
"cta_label": "Explore Prowler Cloud",
|
||||
"cta_url": FINDINGS_HANDOFF_CTA_URL,
|
||||
"secondary_link": {"label": "Compare editions", "href": CLOUD_PRICING_URL},
|
||||
"note": "Nothing is uploaded automatically.",
|
||||
"steps": {
|
||||
"title": "How to bring your results",
|
||||
"items": [
|
||||
"Create or sign in to your Prowler Cloud account.",
|
||||
"In Prowler Cloud, open Scans \u2192 Import Findings.",
|
||||
"Select the accompanying .ocsf.json report and start the import.",
|
||||
],
|
||||
"notes": [
|
||||
"This dashboard reads CSV files. Cloud import requires the OCSF "
|
||||
"JSON report, not the CSV or HTML report.",
|
||||
"If you did not generate an OCSF report, include json-ocsf in "
|
||||
"your output formats on your next scan.",
|
||||
"Import Findings is available to roles with the Manage Ingestions "
|
||||
"permission on an active Prowler Cloud subscription; each cloud "
|
||||
"account in the report counts as a provider.",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"slug": "alerts",
|
||||
"screenshot": {
|
||||
"file": "alerts.jpg",
|
||||
"alt": "Alert rules list in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/alerts",
|
||||
"nav_label": "Alerts",
|
||||
"page_title": "Alerts",
|
||||
@@ -99,6 +229,10 @@ CLOUD_FEATURES = [
|
||||
},
|
||||
{
|
||||
"slug": "mutelist",
|
||||
"screenshot": {
|
||||
"file": "mutelist.jpg",
|
||||
"alt": "Mutelist configuration in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/mutelist",
|
||||
"nav_label": "Mutelist",
|
||||
"page_title": "Mutelist",
|
||||
@@ -117,6 +251,10 @@ CLOUD_FEATURES = [
|
||||
},
|
||||
{
|
||||
"slug": "integrations",
|
||||
"screenshot": {
|
||||
"file": "integrations.jpg",
|
||||
"alt": "Integrations page in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/integrations",
|
||||
"nav_label": "Integrations",
|
||||
"page_title": "Integrations",
|
||||
@@ -135,6 +273,10 @@ CLOUD_FEATURES = [
|
||||
},
|
||||
{
|
||||
"slug": "organization",
|
||||
"screenshot": {
|
||||
"file": "organization.jpg",
|
||||
"alt": "Organization users page in Prowler Cloud",
|
||||
},
|
||||
"route": "/cloud/organization",
|
||||
"nav_label": "Organization",
|
||||
"page_title": "Organization",
|
||||
@@ -178,9 +320,114 @@ def _benefit_item(text):
|
||||
)
|
||||
|
||||
|
||||
def _handoff_links(cta_url):
|
||||
"""Return the primary CTA and the "Compare editions" link for a hand-off."""
|
||||
return html.Div(
|
||||
[
|
||||
html.A(
|
||||
HANDOFF_CTA_LABEL,
|
||||
href=cta_url,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-cta",
|
||||
),
|
||||
html.A(
|
||||
HANDOFF_SECONDARY_LABEL,
|
||||
href=CLOUD_PRICING_URL,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-link-secondary",
|
||||
),
|
||||
],
|
||||
className="pc-cta-row",
|
||||
)
|
||||
|
||||
|
||||
def build_compliance_handoff_card():
|
||||
"""Build the compact hand-off card shown at the end of the Compliance page.
|
||||
|
||||
The local compliance view keeps working from CSV; this card only explains
|
||||
what Prowler Cloud adds for compliance reporting. Static links only: no
|
||||
upload control, no request, no assessment data in URLs.
|
||||
"""
|
||||
return html.Div(
|
||||
[
|
||||
html.Div("Available in Prowler Cloud", className="pc-avail"),
|
||||
html.H2(HANDOFF_TITLE, className="pc-handoff-title"),
|
||||
html.P(HANDOFF_DESCRIPTION, className="pc-card-desc pc-handoff-desc"),
|
||||
html.Ul(
|
||||
[_benefit_item(benefit) for benefit in COMPLIANCE_HANDOFF_BENEFITS],
|
||||
className="pc-benefits pc-handoff-benefits",
|
||||
),
|
||||
_handoff_links(COMPLIANCE_HANDOFF_CTA_URL),
|
||||
html.P(HANDOFF_NOTE, className="pc-note"),
|
||||
],
|
||||
className="pc-handoff pc-font",
|
||||
role="complementary",
|
||||
)
|
||||
|
||||
|
||||
def _screenshot(screenshot):
|
||||
"""Render the Prowler Cloud screenshot that shows the gated feature."""
|
||||
return html.Img(
|
||||
src=f"{SCREENSHOT_DIR}/{screenshot['file']}",
|
||||
alt=screenshot["alt"],
|
||||
className="pc-shot",
|
||||
)
|
||||
|
||||
|
||||
def _steps_block(steps):
|
||||
"""Build the optional secondary "how to" block rendered below the message.
|
||||
|
||||
It is plain text and links only: no file selector, upload control or
|
||||
request is introduced, since the browser cannot read local report files.
|
||||
"""
|
||||
return html.Div(
|
||||
[
|
||||
html.H3(steps["title"], className="pc-steps-title"),
|
||||
html.Ol(
|
||||
[html.Li(item) for item in steps["items"]],
|
||||
className="pc-steps-list",
|
||||
),
|
||||
*[html.P(note, className="pc-steps-note") for note in steps["notes"]],
|
||||
],
|
||||
className="pc-steps",
|
||||
)
|
||||
|
||||
|
||||
def build_cloud_layout(feature):
|
||||
"""Build the reusable gated informational page for a single feature."""
|
||||
cta_url = f"{CLOUD_CTA_BASE}{feature['utm_content']}"
|
||||
"""Build the reusable gated informational page for a single feature.
|
||||
|
||||
Features may override the CTA (``cta_label``/``cta_url``) and add an
|
||||
optional ``screenshot``, ``steps`` block, ``secondary_link`` and ``note``.
|
||||
Features that do not set them render exactly as before.
|
||||
"""
|
||||
cta_url = feature.get("cta_url") or f"{CLOUD_CTA_BASE}{feature['utm_content']}"
|
||||
cta_label = feature.get("cta_label", "Upgrade to Prowler Cloud")
|
||||
steps = feature.get("steps")
|
||||
screenshot = feature.get("screenshot")
|
||||
secondary_link = feature.get("secondary_link")
|
||||
note = feature.get("note")
|
||||
|
||||
cta_links = [
|
||||
html.A(
|
||||
cta_label,
|
||||
href=cta_url,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-cta",
|
||||
)
|
||||
]
|
||||
if secondary_link:
|
||||
cta_links.append(
|
||||
html.A(
|
||||
secondary_link["label"],
|
||||
href=secondary_link["href"],
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-link-secondary",
|
||||
)
|
||||
)
|
||||
|
||||
return html.Div(
|
||||
html.Div(
|
||||
@@ -227,6 +474,7 @@ def build_cloud_layout(feature):
|
||||
feature["description"],
|
||||
className="pc-card-desc",
|
||||
),
|
||||
*([_screenshot(screenshot)] if screenshot else []),
|
||||
html.Ul(
|
||||
[
|
||||
_benefit_item(benefit)
|
||||
@@ -234,13 +482,13 @@ def build_cloud_layout(feature):
|
||||
],
|
||||
className="pc-benefits",
|
||||
),
|
||||
html.A(
|
||||
"Upgrade to Prowler Cloud",
|
||||
href=cta_url,
|
||||
target="_blank",
|
||||
rel="noopener noreferrer",
|
||||
className="pc-cta",
|
||||
*([_steps_block(steps)] if steps else []),
|
||||
*(
|
||||
[html.Div(cta_links, className="pc-cta-row")]
|
||||
if secondary_link
|
||||
else cta_links
|
||||
),
|
||||
*([html.P(note, className="pc-note")] if note else []),
|
||||
],
|
||||
className="pc-card-body",
|
||||
),
|
||||
@@ -260,6 +508,6 @@ for _feature in CLOUD_FEATURES:
|
||||
f"cloud_{_feature['slug'].replace('-', '_')}",
|
||||
path=_feature["route"],
|
||||
name=_feature["nav_label"],
|
||||
title=f"Prowler Dashboard - {_feature['page_title']}",
|
||||
title=f"Prowler Local Dashboard - {_feature['page_title']}",
|
||||
layout=build_cloud_layout(_feature),
|
||||
)
|
||||
@@ -24,7 +24,13 @@ Make sure you update the `/your/local/dir/prowler-output` to match the path that
|
||||
**Remember that the `dashboard` server is not authenticated. If you expose it to the Internet, do it at your own risk.**
|
||||
|
||||
</Note>
|
||||
The banner and additional info about the dashboard will be shown on your console: <img src="/images/cli/dashboard/dashboard-banner.png" />
|
||||
The console shows the dashboard version, the directory the CSV files are loaded from, the local URL once the server is listening, and a short note about bringing your findings to Prowler Cloud. Set `PROWLER_NO_PROMOTIONS=1` to omit that note:
|
||||
|
||||
```sh
|
||||
PROWLER_NO_PROMOTIONS=1 prowler dashboard
|
||||
```
|
||||
|
||||
Set `NO_COLOR=1` to print the startup output without colors.
|
||||
|
||||
## Overview Page
|
||||
|
||||
|
||||
@@ -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,3 +1,5 @@
|
||||
import os
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from prowler.config.config import banner_color, orange_color, prowler_version, timestamp
|
||||
@@ -8,6 +10,17 @@ from prowler.config.config import banner_color, orange_color, prowler_version, t
|
||||
CLOUD_DISPLAY_TEXT = "cloud.prowler.com"
|
||||
CLOUD_BANNER_URL = "https://cloud.prowler.com/sign-up?utm_source=prowler-cli"
|
||||
|
||||
# Compact hand-off shown by `prowler dashboard`. Same copy, campaign and medium
|
||||
# as the OSS HTML report CTA and the dashboard Findings page; ``utm_source``
|
||||
# identifies the terminal. The URL is static: no local paths or findings data.
|
||||
DASHBOARD_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-dashboard-terminal&utm_medium=oss&utm_campaign=report-handoff"
|
||||
)
|
||||
|
||||
# Set to a non-empty value other than 0/false/no to skip promotional blocks.
|
||||
NO_PROMOTIONS_ENV = "PROWLER_NO_PROMOTIONS"
|
||||
|
||||
|
||||
def _hyperlink(url: str, text: str) -> str:
|
||||
"""Wrap ``text`` in an OSC 8 terminal hyperlink pointing to ``url``.
|
||||
@@ -18,6 +31,19 @@ def _hyperlink(url: str, text: str) -> str:
|
||||
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
|
||||
|
||||
|
||||
def _logo_banner(label: str) -> str:
|
||||
"""Return the Prowler ASCII logo with ``label - v<version>`` and the date line."""
|
||||
return rf"""{banner_color} _
|
||||
_ __ _ __ _____ _| | ___ _ __
|
||||
| '_ \| '__/ _ \ \ /\ / / |/ _ \ '__|
|
||||
| |_) | | | (_) \ V V /| | __/ |
|
||||
| .__/|_| \___/ \_/\_/ |_|\___|_| {label} - v{prowler_version}
|
||||
|_|
|
||||
|
||||
{Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL}
|
||||
"""
|
||||
|
||||
|
||||
def print_banner(legend: bool = False, provider: str = None):
|
||||
"""
|
||||
Prints the banner with optional legend for color codes.
|
||||
@@ -29,16 +55,7 @@ def print_banner(legend: bool = False, provider: str = None):
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
banner = rf"""{banner_color} _
|
||||
_ __ _ __ _____ _| | ___ _ __
|
||||
| '_ \| '__/ _ \ \ /\ / / |/ _ \ '__|
|
||||
| |_) | | | (_) \ V V /| | __/ |
|
||||
| .__/|_| \___/ \_/\_/ |_|\___|_| CLI - v{prowler_version}
|
||||
|_|
|
||||
|
||||
{Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL}
|
||||
"""
|
||||
print(banner)
|
||||
print(_logo_banner("CLI"))
|
||||
|
||||
print_prowler_cloud_banner(provider)
|
||||
|
||||
@@ -52,37 +69,109 @@ def print_banner(legend: bool = False, provider: str = None):
|
||||
""")
|
||||
|
||||
|
||||
# Per-provider wording for the bulk onboarding line of the CLI banner.
|
||||
_BULK_ONBOARDING = {
|
||||
"aws": "connect your whole AWS Organization in minutes",
|
||||
"azure": "connect every Azure subscription in minutes",
|
||||
"gcp": "connect every GCP project in minutes",
|
||||
"kubernetes": "connect all your clusters in minutes",
|
||||
"m365": "connect every M365 tenant in minutes",
|
||||
}
|
||||
|
||||
|
||||
def print_prowler_cloud_banner(provider: str = None):
|
||||
"""
|
||||
Prints a promotional banner highlighting what Prowler Cloud adds on top of
|
||||
the open-source CLI.
|
||||
|
||||
Shown at the start and end of a scan to let users know about the managed
|
||||
platform capabilities they are missing (CLI findings upload, attack paths,
|
||||
AI, triage, organizations, continuous scanning with custom scheduling and
|
||||
scan configuration, integrations and live compliance dashboards).
|
||||
Prints the Prowler Cloud hand-off shown at the start and end of a CLI scan:
|
||||
what the managed platform adds on top of this local snapshot, in six
|
||||
short lines that fit an 80-column terminal, plus the sign-up link.
|
||||
Skipped when ``PROWLER_NO_PROMOTIONS`` is set.
|
||||
|
||||
Parameters:
|
||||
- provider (str): The provider that was scanned, used to tailor the message.
|
||||
- provider (str): The provider being scanned, used to tailor the onboarding line.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
if promotions_disabled():
|
||||
return
|
||||
check = f"{Fore.GREEN}✓{Style.RESET_ALL}"
|
||||
bar = f"{banner_color}│{Style.RESET_ALL}"
|
||||
onboarding = _BULK_ONBOARDING.get(
|
||||
provider, "connect all your accounts and keep them scanned"
|
||||
)
|
||||
|
||||
def line(title, text):
|
||||
return f"{bar} {check} {Style.BRIGHT}{title}{Style.RESET_ALL} - {text}"
|
||||
|
||||
print(f"""
|
||||
{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL}
|
||||
{bar} {Style.BRIGHT}This scan is a snapshot 📸. Prowler Cloud gives you the full picture.{Style.RESET_ALL}
|
||||
{bar} Import this scan's OCSF report or scan continuously, with managed workflows.
|
||||
{bar}
|
||||
{bar} {check} {Style.BRIGHT}Send your findings{Style.RESET_ALL} - directly from the Prowler CLI to Prowler Cloud.
|
||||
{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - custom scheduling and scan configuration with history, trends and alerts.
|
||||
{bar} {check} {Style.BRIGHT}Triage{Style.RESET_ALL} - review findings, flag false positives and track accepted risk with your team.
|
||||
{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation.
|
||||
{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening.
|
||||
{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date.
|
||||
{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI.
|
||||
{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels.
|
||||
{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds.
|
||||
{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC.
|
||||
{line("Exploitable Issues", "findings chained into attack paths, with the fix.")}
|
||||
{line("Findings Triage", "history, trends, false positives and accepted risk.")}
|
||||
{line("Lighthouse AI + MCP", "ask, prioritize and remediate from your AI tools.")}
|
||||
{line("Live Compliance", "50+ frameworks across providers and accounts.")}
|
||||
{line("Alerts & Integrations", "Jira, Slack, Security Hub, S3, SSO and RBAC.")}
|
||||
{line("Scheduled Scans", onboarding + ".")}
|
||||
{bar}
|
||||
{bar} {banner_color}Start free at 👉 {_hyperlink(CLOUD_BANNER_URL, CLOUD_DISPLAY_TEXT)}{Style.RESET_ALL}
|
||||
{bar} Nothing is uploaded automatically.
|
||||
""")
|
||||
|
||||
|
||||
def promotions_disabled() -> bool:
|
||||
"""Return True when ``PROWLER_NO_PROMOTIONS`` asks to skip promotional output."""
|
||||
value = os.environ.get(NO_PROMOTIONS_ENV, "").strip().lower()
|
||||
return value not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
def print_dashboard_banner() -> None:
|
||||
"""
|
||||
Prints the `prowler dashboard` header: the Prowler logo labelled
|
||||
``Local Dashboard - v<version>`` and the date. The CSV directory and the
|
||||
server URL are announced separately.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
print(_logo_banner("Local Dashboard"))
|
||||
|
||||
|
||||
def print_dashboard_loading(csv_directory: str) -> None:
|
||||
"""
|
||||
Prints the directory the dashboard loads its CSV files from. Shown after the
|
||||
hand-off block, right before the files are read.
|
||||
|
||||
Parameters:
|
||||
- csv_directory (str): the directory the dashboard reads CSV files from.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
print(f"{Fore.GREEN}Loading CSV files from {csv_directory} ...{Style.RESET_ALL}")
|
||||
|
||||
|
||||
def print_dashboard_promotion() -> None:
|
||||
"""
|
||||
Prints the single compact Prowler Cloud hand-off block for `prowler dashboard`,
|
||||
styled like the CLI's Prowler Cloud banner (bar, bold title, check and 👉 link).
|
||||
Skipped when ``PROWLER_NO_PROMOTIONS`` is set. The link shows ``cloud.prowler.com``
|
||||
like the CLI banner; terminals with OSC 8 support make it clickable with the
|
||||
UTM-tagged URL, others display the plain domain.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
if promotions_disabled():
|
||||
return
|
||||
bar = f"{banner_color}│{Style.RESET_ALL}"
|
||||
check = f"{Fore.GREEN}✓{Style.RESET_ALL}"
|
||||
cta = _hyperlink(DASHBOARD_CTA_URL, CLOUD_DISPLAY_TEXT)
|
||||
print(f"""
|
||||
{bar} {Style.BRIGHT}Keep scanning locally. Bring your findings to Prowler Cloud.{Style.RESET_ALL}
|
||||
{bar} Import your accompanying OCSF report to explore managed security workflows.
|
||||
{bar}
|
||||
{bar} {check} {Style.BRIGHT}Explore Prowler Cloud{Style.RESET_ALL} 👉 {banner_color}{cta}{Style.RESET_ALL}
|
||||
{bar}
|
||||
{bar} Nothing is uploaded automatically.
|
||||
""")
|
||||
@@ -20,6 +20,18 @@ from prowler.providers.common.provider import Provider
|
||||
|
||||
_SAFE_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
# Prowler Cloud hand-off block. Only the OSS CLI opts in (see `show_cloud_cta`),
|
||||
# so Prowler Cloud and Private Cloud reports never contain it. These URLs are
|
||||
# static: no assessment data is ever appended to them.
|
||||
PROWLER_CLOUD_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up"
|
||||
"?utm_source=prowler-html-report&utm_medium=oss&utm_campaign=report-handoff"
|
||||
)
|
||||
PROWLER_CLOUD_PRICING_URL = "https://prowler.com/pricing"
|
||||
PROWLER_CLOUD_IMPORT_DOCS_URL = (
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-import-findings"
|
||||
)
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str:
|
||||
"""Return url if its scheme is http/https, otherwise return empty string."""
|
||||
@@ -47,6 +59,57 @@ def _strip_unsafe_links(html_content: str) -> str:
|
||||
|
||||
|
||||
class HTML(Output):
|
||||
def __init__(
|
||||
self,
|
||||
findings: list[Finding],
|
||||
file_path: str = None,
|
||||
file_extension: str = "",
|
||||
from_cli: bool = True,
|
||||
show_cloud_cta: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the HTML output.
|
||||
|
||||
Args:
|
||||
findings (list[Finding]): findings to transform
|
||||
file_path (str): path of the output file
|
||||
file_extension (str): extension of the output file
|
||||
from_cli (bool): whether the output is written in one go (CLI) or in batches (API)
|
||||
show_cloud_cta (bool): whether to render the Prowler Cloud hand-off block after the
|
||||
assessment summary. Disabled by default; only the OSS CLI enables it.
|
||||
"""
|
||||
self._show_cloud_cta = show_cloud_cta
|
||||
super().__init__(
|
||||
findings,
|
||||
file_path=file_path,
|
||||
file_extension=file_extension,
|
||||
from_cli=from_cli,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_cloud_cta() -> str:
|
||||
"""
|
||||
Returns the Prowler Cloud hand-off block shown after the assessment summary in OSS CLI
|
||||
reports. It is static markup: no scripts, no requests, and no assessment data in URLs.
|
||||
|
||||
Returns:
|
||||
str: HTML markup of the block
|
||||
"""
|
||||
return f"""
|
||||
<div class="row mt-3 prowler-cloud-cta" role="complementary" aria-labelledby="prowler-cloud-cta-title">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title" id="prowler-cloud-cta-title">Keep scanning locally. Bring your findings to Prowler Cloud.</h5>
|
||||
<p class="card-text">Import your accompanying OCSF report to explore managed security workflows. <a href="{PROWLER_CLOUD_IMPORT_DOCS_URL}" target="_blank" rel="noopener noreferrer">How to import findings</a></p>
|
||||
<a class="btn btn-primary" href="{PROWLER_CLOUD_CTA_URL}" target="_blank" rel="noopener noreferrer">Explore Prowler Cloud</a>
|
||||
<a class="btn btn-link" href="{PROWLER_CLOUD_PRICING_URL}" target="_blank" rel="noopener noreferrer">Compare editions</a>
|
||||
<p class="card-text text-muted mt-2 mb-0"><small>Nothing is uploaded automatically.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
@staticmethod
|
||||
def process_markdown(text: str) -> str:
|
||||
"""
|
||||
@@ -142,7 +205,11 @@ class HTML(Output):
|
||||
):
|
||||
if self._file_descriptor.tell() == 0:
|
||||
HTML.write_header(
|
||||
self._file_descriptor, provider, stats, self._from_cli
|
||||
self._file_descriptor,
|
||||
provider,
|
||||
stats,
|
||||
self._from_cli,
|
||||
show_cloud_cta=getattr(self, "_show_cloud_cta", False),
|
||||
)
|
||||
for finding in self._data:
|
||||
self._file_descriptor.write(finding)
|
||||
@@ -160,6 +227,7 @@ class HTML(Output):
|
||||
provider: Provider,
|
||||
stats: dict,
|
||||
from_cli: bool = True,
|
||||
show_cloud_cta: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Writes the header of the HTML file.
|
||||
@@ -169,8 +237,17 @@ class HTML(Output):
|
||||
provider (Provider): the provider object
|
||||
stats (dict): the statistics of the findings
|
||||
from_cli (bool): whether the request is from the CLI or not
|
||||
show_cloud_cta (bool): whether to include the Prowler Cloud hand-off block. When
|
||||
False the block, its styles and its URLs are omitted entirely.
|
||||
"""
|
||||
try:
|
||||
cloud_cta_style = (
|
||||
"""
|
||||
@media print { .prowler-cloud-cta { display: none !important; } }"""
|
||||
if show_cloud_cta
|
||||
else ""
|
||||
)
|
||||
cloud_cta = HTML.get_cloud_cta() if show_cloud_cta else ""
|
||||
file_descriptor.write(f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -199,7 +276,7 @@ class HTML(Output):
|
||||
|
||||
.container-fluid {{font-size: 14px;}}
|
||||
|
||||
.float-left {{ float: left !important; max-width: 100%; }}
|
||||
.float-left {{ float: left !important; max-width: 100%; }}{cloud_cta_style}
|
||||
</style>
|
||||
<title>Prowler - The Handy Cloud Security Tool</title>
|
||||
</head>
|
||||
@@ -259,7 +336,7 @@ class HTML(Output):
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{cloud_cta}
|
||||
</div>
|
||||
<div class="row-mt-3">
|
||||
<div class="col-md-12">
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from dash import html
|
||||
from plotly.utils import PlotlyJSONEncoder
|
||||
|
||||
from dashboard.pages.cloud import (
|
||||
CLOUD_CTA_BASE,
|
||||
CLOUD_FEATURES,
|
||||
CLOUD_FEATURES_BY_SLUG,
|
||||
CLOUD_PRICING_URL,
|
||||
FINDINGS_HANDOFF_CTA_URL,
|
||||
build_cloud_layout,
|
||||
)
|
||||
|
||||
EXPECTED_FINDINGS_CTA_URL = (
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard"
|
||||
"&utm_medium=oss&utm_campaign=report-handoff&utm_content=findings"
|
||||
"&intent=import-findings"
|
||||
)
|
||||
|
||||
|
||||
def _serialize(component) -> str:
|
||||
"""Flatten a Dash component tree into a JSON string for text assertions."""
|
||||
return json.dumps(component, cls=PlotlyJSONEncoder, ensure_ascii=False)
|
||||
|
||||
|
||||
def _links(component):
|
||||
"""Yield every ``html.A`` component in the tree."""
|
||||
stack = [component]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, html.A):
|
||||
yield node
|
||||
children = getattr(node, "children", None)
|
||||
if isinstance(children, (list, tuple)):
|
||||
# Reverse so the stack yields children in document order
|
||||
stack.extend(reversed(children))
|
||||
elif children is not None and hasattr(children, "to_plotly_json"):
|
||||
stack.append(children)
|
||||
|
||||
|
||||
class TestFindingsHandoffPage:
|
||||
def setup_method(self):
|
||||
self.layout = build_cloud_layout(CLOUD_FEATURES_BY_SLUG["findings"])
|
||||
self.text = _serialize(self.layout)
|
||||
|
||||
def test_shared_message_matches_html_report_cta(self):
|
||||
assert (
|
||||
"Keep scanning locally. Bring your findings to Prowler Cloud." in self.text
|
||||
)
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in self.text
|
||||
)
|
||||
assert "Explore Prowler Cloud" in self.text
|
||||
assert "Compare editions" in self.text
|
||||
assert "Nothing is uploaded automatically." in self.text
|
||||
|
||||
def test_old_upsell_copy_is_gone(self):
|
||||
assert "Unlock Findings" not in self.text
|
||||
assert "Upgrade to Prowler Cloud" not in self.text
|
||||
assert (
|
||||
"Search and filter findings across all connected accounts" not in self.text
|
||||
)
|
||||
|
||||
def test_cta_keeps_every_tracking_parameter(self):
|
||||
cta = next(a for a in _links(self.layout) if a.className == "pc-cta")
|
||||
assert cta.href == EXPECTED_FINDINGS_CTA_URL == FINDINGS_HANDOFF_CTA_URL
|
||||
assert cta.children == "Explore Prowler Cloud"
|
||||
assert cta.target == "_blank"
|
||||
assert cta.rel == "noopener noreferrer"
|
||||
for param in (
|
||||
"utm_source=prowler-local-dashboard",
|
||||
"utm_medium=oss",
|
||||
"utm_campaign=report-handoff",
|
||||
"utm_content=findings",
|
||||
"intent=import-findings",
|
||||
):
|
||||
assert param in cta.href
|
||||
|
||||
def test_secondary_link_points_to_pricing(self):
|
||||
link = next(
|
||||
a for a in _links(self.layout) if a.className == "pc-link-secondary"
|
||||
)
|
||||
assert link.href == CLOUD_PRICING_URL == "https://prowler.com/pricing"
|
||||
assert link.children == "Compare editions"
|
||||
assert link.target == "_blank"
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_steps_block_content(self):
|
||||
assert "How to bring your results" in self.text
|
||||
assert "Create or sign in to your Prowler Cloud account." in self.text
|
||||
assert "In Prowler Cloud, open Scans → Import Findings." in self.text
|
||||
assert (
|
||||
"Select the accompanying .ocsf.json report and start the import."
|
||||
in self.text
|
||||
)
|
||||
assert (
|
||||
"This dashboard reads CSV files. Cloud import requires the OCSF JSON report, not the CSV or HTML report."
|
||||
in self.text
|
||||
)
|
||||
assert (
|
||||
"If you did not generate an OCSF report, include json-ocsf in your output formats on your next scan."
|
||||
in self.text
|
||||
)
|
||||
assert "Manage Ingestions" in self.text
|
||||
|
||||
def test_hierarchy_message_steps_cta_note(self):
|
||||
assert (
|
||||
self.text.index("Keep scanning locally.")
|
||||
< self.text.index("How to bring your results")
|
||||
< self.text.index("Explore Prowler Cloud")
|
||||
< self.text.index("Nothing is uploaded automatically.")
|
||||
)
|
||||
|
||||
def test_no_upload_or_file_selector(self):
|
||||
assert "Upload" not in self.text
|
||||
assert '"type": "Input"' not in self.text
|
||||
assert "<input" not in self.text.lower()
|
||||
|
||||
def test_all_external_links_open_safely(self):
|
||||
links = list(_links(self.layout))
|
||||
assert len(links) == 2
|
||||
for link in links:
|
||||
assert link.href.startswith("https://")
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_no_assessment_data_in_urls(self):
|
||||
for link in _links(self.layout):
|
||||
assert "/Users/" not in link.href
|
||||
assert "account" not in link.href.lower()
|
||||
assert ".csv" not in link.href
|
||||
|
||||
|
||||
class TestOtherCloudPagesUnchanged:
|
||||
@pytest.mark.parametrize(
|
||||
"slug",
|
||||
[
|
||||
f["slug"]
|
||||
for f in CLOUD_FEATURES
|
||||
if f["slug"] not in ("findings", "mcp", "exploitable-issues")
|
||||
],
|
||||
)
|
||||
def test_other_pages_keep_upsell_template(self, slug):
|
||||
feature = CLOUD_FEATURES_BY_SLUG[slug]
|
||||
layout = build_cloud_layout(feature)
|
||||
text = _serialize(layout)
|
||||
links = list(_links(layout))
|
||||
|
||||
assert feature["card_title"].startswith("Unlock ")
|
||||
assert feature["card_title"] in text
|
||||
assert len(links) == 1
|
||||
assert links[0].children == "Upgrade to Prowler Cloud"
|
||||
assert links[0].href == f"{CLOUD_CTA_BASE}{feature['utm_content']}"
|
||||
assert len(feature["benefits"]) == 3
|
||||
assert "How to bring your results" not in text
|
||||
assert "pc-steps" not in text
|
||||
assert "pc-note" not in text
|
||||
assert "pc-link-secondary" not in text
|
||||
assert "report-handoff" not in text
|
||||
|
||||
def test_navigation_label_unchanged(self):
|
||||
assert CLOUD_FEATURES_BY_SLUG["findings"]["nav_label"] == "Findings"
|
||||
assert CLOUD_FEATURES_BY_SLUG["findings"]["route"] == "/cloud/findings"
|
||||
|
||||
|
||||
class TestComplianceHandoffCard:
|
||||
def setup_method(self):
|
||||
from dashboard.pages.cloud import (
|
||||
COMPLIANCE_HANDOFF_CTA_URL,
|
||||
build_compliance_handoff_card,
|
||||
)
|
||||
|
||||
self.expected_url = COMPLIANCE_HANDOFF_CTA_URL
|
||||
self.card = build_compliance_handoff_card()
|
||||
self.text = _serialize(self.card)
|
||||
|
||||
def test_shared_message_and_note(self):
|
||||
assert (
|
||||
"Keep scanning locally. Bring your findings to Prowler Cloud." in self.text
|
||||
)
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in self.text
|
||||
)
|
||||
assert "Nothing is uploaded automatically." in self.text
|
||||
assert "Unlock" not in self.text
|
||||
|
||||
def test_compliance_benefits_are_the_documented_ones(self):
|
||||
assert "Compliance Watchlist" in self.text
|
||||
assert "Cross-account and cross-provider roll-ups" in self.text
|
||||
assert "CSV and PDF compliance reports" in self.text
|
||||
assert "attack path" not in self.text.lower()
|
||||
assert "trial" not in self.text.lower()
|
||||
|
||||
def test_links(self):
|
||||
links = list(_links(self.card))
|
||||
assert [a.children for a in links] == [
|
||||
"Explore Prowler Cloud",
|
||||
"Compare editions",
|
||||
]
|
||||
cta, pricing = links
|
||||
assert (
|
||||
cta.href
|
||||
== self.expected_url
|
||||
== (
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard"
|
||||
"&utm_medium=oss&utm_campaign=report-handoff&utm_content=compliance"
|
||||
"&intent=import-findings"
|
||||
)
|
||||
)
|
||||
assert pricing.href == "https://prowler.com/pricing"
|
||||
for link in links:
|
||||
assert link.target == "_blank"
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_no_upload_controls(self):
|
||||
assert "Upload" not in self.text
|
||||
assert '"type": "Input"' not in self.text
|
||||
|
||||
|
||||
class TestComplianceLayoutIncludesHandoff:
|
||||
def test_card_is_last_element_and_header_link_kept(self):
|
||||
from dash import html
|
||||
|
||||
from dashboard.lib.layouts import create_layout_compliance
|
||||
|
||||
layout = create_layout_compliance(
|
||||
html.Div(id="a"), html.Div(id="d"), html.Div(id="r"), html.Div(id="c")
|
||||
)
|
||||
last = layout.children[-1]
|
||||
assert "pc-handoff" in last.className
|
||||
text = _serialize(layout)
|
||||
assert text.count("Keep scanning locally.") == 1
|
||||
# Existing header link and compliance containers are untouched
|
||||
assert "Subscribe to Prowler Cloud" in text
|
||||
assert '"id": "output"' in text
|
||||
assert '"id": "overall_status_result_graph"' in text
|
||||
|
||||
|
||||
class TestMcpPage:
|
||||
def setup_method(self):
|
||||
self.feature = CLOUD_FEATURES_BY_SLUG["mcp"]
|
||||
self.layout = build_cloud_layout(self.feature)
|
||||
self.text = _serialize(self.layout)
|
||||
|
||||
def test_copy_and_template(self):
|
||||
assert self.feature["nav_label"] == "MCP Server"
|
||||
assert self.feature["route"] == "/cloud/mcp"
|
||||
assert "Unlock Prowler MCP" in self.text
|
||||
assert "mcp.prowler.com" in self.text
|
||||
assert "Prowler MCP Server is currently a preview feature." in self.text
|
||||
assert len(self.feature["benefits"]) == 3
|
||||
assert "How to bring your results" not in self.text
|
||||
|
||||
def test_links(self):
|
||||
links = list(_links(self.layout))
|
||||
assert [a.children for a in links] == [
|
||||
"Upgrade to Prowler Cloud",
|
||||
"Connect your AI agent",
|
||||
]
|
||||
assert links[0].href == f"{CLOUD_CTA_BASE}mcp"
|
||||
assert links[1].href == "https://docs.prowler.com/user-guide/ai-agents/"
|
||||
for link in links:
|
||||
assert link.target == "_blank"
|
||||
assert link.rel == "noopener noreferrer"
|
||||
|
||||
def test_icon_asset_exists(self):
|
||||
from pathlib import Path
|
||||
|
||||
icon = Path("dashboard") / self.feature["icon"].lstrip("/")
|
||||
assert icon.is_file()
|
||||
|
||||
|
||||
class TestPageTitles:
|
||||
def test_cloud_pages_are_titled_prowler_local_dashboard(self):
|
||||
import dash
|
||||
|
||||
for feature in CLOUD_FEATURES:
|
||||
page = dash.page_registry[f"cloud_{feature['slug'].replace('-', '_')}"]
|
||||
assert page["title"] == f"Prowler Local Dashboard - {feature['page_title']}"
|
||||
|
||||
|
||||
class TestExploitableIssuesPage:
|
||||
def setup_method(self):
|
||||
self.feature = CLOUD_FEATURES_BY_SLUG["exploitable-issues"]
|
||||
self.layout = build_cloud_layout(self.feature)
|
||||
self.text = _serialize(self.layout)
|
||||
|
||||
def test_copy_and_template(self):
|
||||
assert self.feature["nav_label"] == "Exploitable Issues"
|
||||
assert self.feature["route"] == "/cloud/exploitable-issues"
|
||||
assert "Unlock Exploitable Issues" in self.text
|
||||
assert "toxic combinations" in self.text
|
||||
assert "exploitable, likely, conditional or not exploitable" in self.text
|
||||
assert "rolling out" in self.text
|
||||
assert len(self.feature["benefits"]) == 3
|
||||
|
||||
def test_single_upgrade_cta(self):
|
||||
links = list(_links(self.layout))
|
||||
assert len(links) == 1
|
||||
assert links[0].children == "Upgrade to Prowler Cloud"
|
||||
assert links[0].href == f"{CLOUD_CTA_BASE}exploitable-issues"
|
||||
assert links[0].rel == "noopener noreferrer"
|
||||
|
||||
|
||||
class TestScreenshots:
|
||||
def _images(self, component):
|
||||
stack = [component]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, html.Img):
|
||||
yield node
|
||||
children = getattr(node, "children", None)
|
||||
if isinstance(children, (list, tuple)):
|
||||
stack.extend(reversed(children))
|
||||
elif children is not None and hasattr(children, "to_plotly_json"):
|
||||
stack.append(children)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slug", [f["slug"] for f in CLOUD_FEATURES if f.get("screenshot")]
|
||||
)
|
||||
def test_screenshot_rendered_with_existing_asset(self, slug):
|
||||
from pathlib import Path
|
||||
|
||||
feature = CLOUD_FEATURES_BY_SLUG[slug]
|
||||
images = list(self._images(build_cloud_layout(feature)))
|
||||
assert len(images) == 1
|
||||
image = images[0]
|
||||
assert (
|
||||
image.src
|
||||
== f"/assets/images/cloud/screenshots/{feature['screenshot']['file']}"
|
||||
)
|
||||
assert image.alt and "Prowler" in image.alt
|
||||
assert image.className == "pc-shot"
|
||||
assert (Path("dashboard") / image.src.lstrip("/")).is_file()
|
||||
|
||||
def test_every_gated_page_but_exploitable_issues_has_a_screenshot(self):
|
||||
missing = [f["slug"] for f in CLOUD_FEATURES if not f.get("screenshot")]
|
||||
assert missing == ["exploitable-issues"]
|
||||
|
||||
def test_pages_without_screenshot_render_no_image(self):
|
||||
layout = build_cloud_layout(CLOUD_FEATURES_BY_SLUG["exploitable-issues"])
|
||||
assert list(self._images(layout)) == []
|
||||
|
||||
|
||||
class TestSidebarOrder:
|
||||
def test_findings_first_then_exploitable_issues(self):
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
source = Path("dashboard/__main__.py").read_text()
|
||||
match = re.search(r'"Upgrade to Prowler Cloud",\s*\[([^\]]+)\]', source)
|
||||
slugs = re.findall(r'"([a-z-]+)"', match.group(1))
|
||||
assert slugs[:2] == ["findings", "exploitable-issues"]
|
||||
assert set(slugs) == {
|
||||
"findings",
|
||||
"exploitable-issues",
|
||||
"lighthouse-ai",
|
||||
"mcp",
|
||||
"attack-paths",
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from werkzeug.serving import BaseWSGIServer
|
||||
|
||||
from dashboard.lib import startup
|
||||
from dashboard.lib.startup import (
|
||||
DashRunningLineFilter,
|
||||
format_ready_message,
|
||||
install_server_ready_hook,
|
||||
server_urls,
|
||||
silence_dash_running_line,
|
||||
)
|
||||
|
||||
|
||||
class TestServerUrls:
|
||||
def test_loopback_host(self):
|
||||
assert server_urls("127.0.0.1", 11666) == ("http://127.0.0.1:11666/", [])
|
||||
|
||||
def test_custom_host_and_port(self):
|
||||
assert server_urls("10.0.0.5", 8080) == ("http://10.0.0.5:8080/", [])
|
||||
|
||||
def test_ipv6_host_is_bracketed(self):
|
||||
assert server_urls("::1", 11666) == ("http://[::1]:11666/", [])
|
||||
|
||||
def test_wildcard_host_uses_loopback_and_lists_interface(self):
|
||||
with mock.patch.object(
|
||||
startup, "get_interface_ip", return_value="192.168.1.20"
|
||||
):
|
||||
primary, others = server_urls("0.0.0.0", 11666)
|
||||
assert primary == "http://127.0.0.1:11666/"
|
||||
assert others == ["http://192.168.1.20:11666/"]
|
||||
|
||||
def test_wildcard_host_without_interface(self):
|
||||
with mock.patch.object(startup, "get_interface_ip", side_effect=OSError):
|
||||
assert server_urls("0.0.0.0", 11666) == ("http://127.0.0.1:11666/", [])
|
||||
|
||||
def test_https_scheme(self):
|
||||
assert server_urls("127.0.0.1", 443, "https")[0] == "https://127.0.0.1:443/"
|
||||
|
||||
|
||||
class TestFormatReadyMessage:
|
||||
def test_single_address(self):
|
||||
message = format_ready_message("127.0.0.1", 11666)
|
||||
assert "Prowler Local Dashboard now is running on" in message
|
||||
assert "http://127.0.0.1:11666/" in message
|
||||
assert message.endswith("Press Ctrl+C to stop.")
|
||||
assert "Also reachable" not in message
|
||||
assert "all interfaces" not in message
|
||||
|
||||
def test_all_interfaces(self):
|
||||
with mock.patch.object(
|
||||
startup, "get_interface_ip", return_value="192.168.1.20"
|
||||
):
|
||||
message = format_ready_message("0.0.0.0", 11666)
|
||||
assert "http://127.0.0.1:11666/" in message
|
||||
assert "Listening on all interfaces (0.0.0.0)." in message
|
||||
assert "Also reachable at: http://192.168.1.20:11666/" in message
|
||||
assert "http://0.0.0.0" not in message
|
||||
|
||||
|
||||
class TestInstallServerReadyHook:
|
||||
def setup_method(self):
|
||||
self._original = BaseWSGIServer.log_startup
|
||||
|
||||
def teardown_method(self):
|
||||
BaseWSGIServer.log_startup = getattr(
|
||||
BaseWSGIServer.log_startup, "_prowler_original", self._original
|
||||
)
|
||||
|
||||
def test_announces_once_after_bind_and_keeps_original(self):
|
||||
printed = []
|
||||
calls = []
|
||||
BaseWSGIServer.log_startup = lambda self: calls.append(self.port)
|
||||
install_server_ready_hook(printed.append)
|
||||
|
||||
server = SimpleNamespace(host="127.0.0.1", port=11667, ssl_context=None)
|
||||
BaseWSGIServer.log_startup(server)
|
||||
BaseWSGIServer.log_startup(server)
|
||||
|
||||
assert calls == [11667, 11667] # werkzeug's own startup logic still runs
|
||||
assert len(printed) == 1 # a reload or second run does not repeat it
|
||||
assert "http://127.0.0.1:11667/" in printed[0]
|
||||
assert "Press Ctrl+C to stop." in printed[0]
|
||||
|
||||
def test_install_is_idempotent(self):
|
||||
first = install_server_ready_hook(lambda _: None)
|
||||
second = install_server_ready_hook(lambda _: None)
|
||||
assert first is second
|
||||
|
||||
def test_failed_bind_is_never_announced(self):
|
||||
# make_server raises before log_startup is reached; nothing to announce
|
||||
printed = []
|
||||
install_server_ready_hook(printed.append)
|
||||
with mock.patch("werkzeug.serving.make_server", side_effect=OSError("in use")):
|
||||
from werkzeug.serving import run_simple
|
||||
|
||||
try:
|
||||
run_simple("127.0.0.1", 1, lambda *_: [], use_reloader=False)
|
||||
except OSError:
|
||||
pass
|
||||
assert printed == []
|
||||
|
||||
|
||||
class TestSilenceDashRunningLine:
|
||||
def test_drops_only_the_running_line(self):
|
||||
logger = logging.getLogger("tests.dashboard.startup.dash")
|
||||
logger.handlers = []
|
||||
logger.setLevel(logging.INFO)
|
||||
records = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = records.append
|
||||
logger.addHandler(handler)
|
||||
app = SimpleNamespace(logger=logger)
|
||||
|
||||
silence_dash_running_line(app)
|
||||
silence_dash_running_line(app)
|
||||
|
||||
logger.info(
|
||||
"Dash is running on %s://%s%s%s\n", "http", "127.0.0.1", ":11666", "/"
|
||||
)
|
||||
logger.info("Something else worth seeing")
|
||||
|
||||
assert [r.getMessage() for r in records] == ["Something else worth seeing"]
|
||||
assert sum(isinstance(f, DashRunningLineFilter) for f in logger.filters) == 1
|
||||
@@ -0,0 +1,113 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.config.config import prowler_version
|
||||
from prowler.lib.banner import (
|
||||
DASHBOARD_CTA_URL,
|
||||
NO_PROMOTIONS_ENV,
|
||||
print_dashboard_banner,
|
||||
print_dashboard_loading,
|
||||
print_dashboard_promotion,
|
||||
print_prowler_cloud_banner,
|
||||
promotions_disabled,
|
||||
)
|
||||
|
||||
|
||||
class TestPromotionsDisabled:
|
||||
def test_unset_is_enabled(self, monkeypatch):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
assert promotions_disabled() is False
|
||||
|
||||
def test_truthy_values_disable(self, monkeypatch):
|
||||
for value in ("1", "true", "YES", "anything"):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, value)
|
||||
assert promotions_disabled() is True, value
|
||||
|
||||
def test_falsy_values_keep_enabled(self, monkeypatch):
|
||||
for value in ("0", "false", "no", "", " "):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, value)
|
||||
assert promotions_disabled() is False, value
|
||||
|
||||
|
||||
class TestDashboardBanner:
|
||||
def test_header_has_identity_and_version(self, capsys):
|
||||
print_dashboard_banner()
|
||||
out = capsys.readouterr().out
|
||||
assert f"Local Dashboard - v{prowler_version}" in out
|
||||
assert "Date:" in out
|
||||
assert "Loading CSV files" not in out
|
||||
# The long commercial banner is not part of the dashboard header
|
||||
assert "snapshot" not in out
|
||||
assert "Start free" not in out
|
||||
assert "aws s3 cp" not in out
|
||||
|
||||
def test_loading_line_names_the_directory(self, capsys):
|
||||
print_dashboard_loading("/tmp/synthetic/output")
|
||||
out = capsys.readouterr().out
|
||||
assert "Loading CSV files from /tmp/synthetic/output ..." in out
|
||||
|
||||
def test_promotion_block_content(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
print_dashboard_promotion()
|
||||
out = capsys.readouterr().out
|
||||
assert "Keep scanning locally. Bring your findings to Prowler Cloud." in out
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in out
|
||||
)
|
||||
assert "Explore Prowler Cloud" in out
|
||||
assert "\u2713" in out and "\u2502" in out # check and bar like the CLI banner
|
||||
assert "Nothing is uploaded automatically." in out
|
||||
# The UTM-tagged URL is only the OSC 8 target; the visible text is the domain
|
||||
assert f"\033]8;;{DASHBOARD_CTA_URL}\033\\cloud.prowler.com" in out
|
||||
assert f"\n{DASHBOARD_CTA_URL}" not in out
|
||||
assert out.count(DASHBOARD_CTA_URL) == 1
|
||||
assert DASHBOARD_CTA_URL == (
|
||||
"https://cloud.prowler.com/sign-up?utm_source=prowler-dashboard-terminal"
|
||||
"&utm_medium=oss&utm_campaign=report-handoff"
|
||||
)
|
||||
assert out.count("Keep scanning locally.") == 1
|
||||
# Readable in 80 columns: every non-URL line fits
|
||||
for line in out.splitlines():
|
||||
if "http" not in line and "\033" not in line:
|
||||
assert len(line) <= 80, line
|
||||
|
||||
def test_promotion_skipped_when_disabled(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, "1")
|
||||
print_dashboard_promotion()
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_scan_banner_untouched(self, capsys):
|
||||
with mock.patch("prowler.lib.banner.print_prowler_cloud_banner") as cloud:
|
||||
from prowler.lib.banner import print_banner
|
||||
|
||||
print_banner()
|
||||
out = capsys.readouterr().out
|
||||
assert f"CLI - v{prowler_version}" in out
|
||||
cloud.assert_called_once_with(None)
|
||||
|
||||
|
||||
class TestCliCloudBanner:
|
||||
def test_content_is_brief_and_tailored(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
monkeypatch.setenv("NO_COLOR", "1")
|
||||
print_prowler_cloud_banner("aws")
|
||||
out = capsys.readouterr().out
|
||||
assert "Prowler Cloud gives you the full picture." in out
|
||||
assert "Exploitable Issues" in out
|
||||
assert "AWS Organization" in out
|
||||
assert "cloud.prowler.com" in out
|
||||
assert "Nothing is uploaded automatically." in out
|
||||
assert "Bulk Provisioning" not in out
|
||||
assert len([ln for ln in out.splitlines() if ln.strip()]) <= 12
|
||||
|
||||
def test_unknown_provider_uses_generic_line(self, monkeypatch, capsys):
|
||||
monkeypatch.delenv(NO_PROMOTIONS_ENV, raising=False)
|
||||
print_prowler_cloud_banner("github")
|
||||
out = capsys.readouterr().out
|
||||
assert "connect all your accounts" in out
|
||||
assert "AWS Organization" not in out
|
||||
|
||||
def test_respects_no_promotions(self, monkeypatch, capsys):
|
||||
monkeypatch.setenv(NO_PROMOTIONS_ENV, "1")
|
||||
print_prowler_cloud_banner("aws")
|
||||
assert capsys.readouterr().out == ""
|
||||
@@ -8,7 +8,12 @@ from mock import MagicMock, patch
|
||||
from prowler.config.config import prowler_version, timestamp
|
||||
from prowler.lib.cli.redact import redact_argv
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.lib.outputs.html.html import (
|
||||
HTML,
|
||||
PROWLER_CLOUD_CTA_URL,
|
||||
PROWLER_CLOUD_IMPORT_DOCS_URL,
|
||||
PROWLER_CLOUD_PRICING_URL,
|
||||
)
|
||||
from prowler.providers.github.models import GithubAppIdentityInfo
|
||||
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
|
||||
from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider
|
||||
@@ -1521,3 +1526,133 @@ class TestHTML:
|
||||
]
|
||||
output_data = HTML(findings).data[0]
|
||||
assert 'href="https://hub.prowler.com/check/check-id"' in output_data
|
||||
|
||||
def test_write_header_omits_cloud_cta_by_default(self):
|
||||
mock_file = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file, provider, html_stats)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert content == get_aws_html_header(sys.argv[1:])
|
||||
assert "prowler-cloud-cta" not in content
|
||||
assert PROWLER_CLOUD_CTA_URL not in content
|
||||
assert "utm_campaign=report-handoff" not in content
|
||||
assert PROWLER_CLOUD_PRICING_URL not in content
|
||||
assert PROWLER_CLOUD_IMPORT_DOCS_URL not in content
|
||||
|
||||
def test_write_header_omits_cloud_cta_when_from_cli_but_not_enabled(self):
|
||||
# `from_cli` alone must never turn the block on: it is a batching flag, not an
|
||||
# OSS marker, and it defaults to True for every library consumer.
|
||||
mock_file = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file, provider, html_stats, from_cli=True)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert "prowler-cloud-cta" not in content
|
||||
assert "cloud.prowler.com" not in content
|
||||
|
||||
def test_write_header_includes_cloud_cta_when_enabled(self):
|
||||
mock_file = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file, provider, html_stats, show_cloud_cta=True)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert content.count('class="row mt-3 prowler-cloud-cta"') == 1
|
||||
assert "Keep scanning locally. Bring your findings to Prowler Cloud." in content
|
||||
assert (
|
||||
"Import your accompanying OCSF report to explore managed security workflows."
|
||||
in content
|
||||
)
|
||||
assert f'href="{PROWLER_CLOUD_CTA_URL}"' in content
|
||||
assert ">Explore Prowler Cloud</a>" in content
|
||||
assert f'href="{PROWLER_CLOUD_PRICING_URL}"' in content
|
||||
assert ">Compare editions</a>" in content
|
||||
assert f'href="{PROWLER_CLOUD_IMPORT_DOCS_URL}"' in content
|
||||
assert ">How to import findings</a>" in content
|
||||
assert "Nothing is uploaded automatically." in content
|
||||
assert 'rel="noopener noreferrer"' in content
|
||||
assert (
|
||||
"@media print { .prowler-cloud-cta { display: none !important; } }"
|
||||
in content
|
||||
)
|
||||
# Placed after the assessment summary and before the findings table
|
||||
assert (
|
||||
content.index("AWS Assessment Summary")
|
||||
< content.index('class="row mt-3 prowler-cloud-cta"')
|
||||
< content.index('id="findingsTable"')
|
||||
)
|
||||
# No script, form or automatic request is introduced by the block
|
||||
cta_block = HTML.get_cloud_cta()
|
||||
assert "<script" not in cta_block
|
||||
assert "<form" not in cta_block
|
||||
assert "<img" not in cta_block
|
||||
assert "<iframe" not in cta_block
|
||||
|
||||
def test_cloud_cta_does_not_change_assessment_content(self):
|
||||
mock_file_oss = StringIO()
|
||||
mock_file_cloud = StringIO()
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
HTML.write_header(mock_file_oss, provider, html_stats, show_cloud_cta=True)
|
||||
HTML.write_header(mock_file_cloud, provider, html_stats, show_cloud_cta=False)
|
||||
|
||||
oss = mock_file_oss.getvalue()
|
||||
cloud = mock_file_cloud.getvalue()
|
||||
stripped = oss.replace(HTML.get_cloud_cta(), "").replace(
|
||||
"\n @media print { .prowler-cloud-cta { display: none !important; } }",
|
||||
"",
|
||||
)
|
||||
assert stripped == cloud
|
||||
|
||||
def test_batch_write_data_to_file_oss_cli_includes_cloud_cta(self):
|
||||
mock_file = StringIO()
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
|
||||
)
|
||||
]
|
||||
output = HTML(findings, show_cloud_cta=True)
|
||||
output._file_descriptor = mock_file
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
with patch.object(mock_file, "close", return_value=None):
|
||||
output.batch_write_data_to_file(provider, html_stats)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert PROWLER_CLOUD_CTA_URL in content
|
||||
assert content.count('class="row mt-3 prowler-cloud-cta"') == 1
|
||||
# Findings and footer are untouched
|
||||
assert pass_html_finding in content
|
||||
assert content.endswith(html_footer)
|
||||
|
||||
def test_batch_write_data_to_file_api_omits_cloud_cta(self):
|
||||
# Mirrors how Prowler Cloud / Private Cloud construct the output in the API
|
||||
mock_file = StringIO()
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
|
||||
)
|
||||
]
|
||||
output = HTML(
|
||||
findings,
|
||||
file_path=None,
|
||||
file_extension=".html",
|
||||
from_cli=False,
|
||||
)
|
||||
output._file_descriptor = mock_file
|
||||
output.close_file = True
|
||||
provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
|
||||
|
||||
with patch.object(mock_file, "close", return_value=None):
|
||||
output.batch_write_data_to_file(provider, html_stats)
|
||||
|
||||
content = mock_file.getvalue()
|
||||
assert "prowler-cloud-cta" not in content
|
||||
assert "cloud.prowler.com" not in content
|
||||
assert "utm_campaign=report-handoff" not in content
|
||||
assert PROWLER_CLOUD_PRICING_URL not in content
|
||||
assert PROWLER_CLOUD_IMPORT_DOCS_URL not in content
|
||||
assert pass_html_finding in content
|
||||