Compare commits

..
Author SHA1 Message Date
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
7 changed files with 222 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.15.1-slim-trixie@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS build
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
FROM python:3.15.1-alpine3.23@sha256:cb295b554dbbea09b2c0e6e6d9ce55f051347c902fbb611e618c071addfb30a3
FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b
LABEL maintainer="https://github.com/prowler-cloud"
+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 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
+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">
+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