import re import sys from io import StringIO import pytest 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.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 from tests.providers.azure.azure_fixtures import set_mocked_azure_provider from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider from tests.providers.github.github_fixtures import APP_ID, set_mocked_github_provider from tests.providers.googleworkspace.googleworkspace_fixtures import ( set_mocked_googleworkspace_provider, ) from tests.providers.kubernetes.kubernetes_fixtures import ( set_mocked_kubernetes_provider, ) from tests.providers.m365.m365_fixtures import set_mocked_m365_provider from tests.providers.mongodbatlas.mongodbatlas_fixtures import ( set_mocked_mongodbatlas_provider, ) html_stats = { "total_pass": 25, "total_muted_pass": 20, "total_fail": 5, "total_muted_fail": 5, "resources_count": 1, "findings_count": 30, } pass_html_finding = """ PASS high service eu-west-1 service_test_check_id service_test_check_id

test-risk

•test-compliance: test-compliance

""" fail_html_finding = """ FAIL high service eu-west-1 service_test_check_id service_test_check_id test-resource-uid •key1=value1 •key2=value2 test-status-extended

test-risk

test-remediation-recommendation-text

•test-compliance: test-compliance

""" muted_html_finding = """ MUTED (PASS) high service eu-west-1 service_test_check_id service_test_check_id

test-risk

•test-compliance: test-compliance

""" manual_html_finding = """ MANUAL high service eu-west-1 service_test_check_id service_test_check_id

test-risk

•test-compliance: test-compliance

""" aws_html_assessment_summary = """
AWS Assessment Summary
AWS Credentials
""" azure_html_assessment_summary = """
Azure Assessment Summary
Azure Credentials
""" gcp_html_assessment_summary = """
GCP Assessment Summary
GCP Credentials
""" kubernetes_html_assessment_summary = """
Kubernetes Assessment Summary
Kubernetes Credentials
""" github_personal_access_token_html_assessment_summary = """
GitHub Assessment Summary
GitHub Credentials
""" github_app_html_assessment_summary = """
GitHub Assessment Summary
GitHub Credentials
""" m365_html_assessment_summary = """
M365 Assessment Summary
M365 Credentials
""" mongodbatlas_html_assessment_summary = """
MongoDB Atlas Assessment Summary
MongoDB Atlas Credentials
""" image_registry_html_assessment_summary = """
Image Assessment Summary
Image Credentials
""" image_list_html_assessment_summary = """
Image Assessment Summary
Image Credentials
""" def get_aws_html_header(args: list) -> str: """ Generate the HTML header for AWS Args: args (list): List of arguments passed to the script Returns: str: HTML header for AWS """ aws_html_header = f""" Prowler - The Handy Cloud Security Tool
prowler-logo
Report Information
  • Version: {prowler_version}
  • Parameters used: {redact_argv(args)}
  • Date: {timestamp.isoformat()}
{aws_html_assessment_summary}
Assessment Overview
  • Total Findings: 30
  • Passed: 25
  • Passed (Muted): 20
  • Failed: 5
  • Failed (Muted): 5
  • Total Resources: 1
""" return aws_html_header html_footer = """
Status Severity Service Name Region Check ID Check Title Resource ID Resource Tags Status Extended Risk Recommendation Compliance
""" def _setup_aws_xss(provider, payload): provider.identity.account = payload provider.identity.profile = payload provider.identity.audited_regions = [payload] provider.identity.user_id = payload provider.identity.identity_arn = payload def _setup_azure_xss(provider, payload): provider.identity.tenant_ids = [payload] provider.identity.tenant_domain = payload provider.identity.subscriptions = {payload: payload} provider.identity.identity_type = payload provider.identity.identity_id = payload def _setup_gcp_xss(provider, payload): provider.project_ids = [payload] provider.session._service_account_email = payload def _setup_kubernetes_xss(provider, payload): provider.identity.cluster = payload provider.identity.context = payload def _setup_github_xss(provider, payload): provider.identity = MagicMock(spec=["account_name", "account_email"]) provider.identity.account_name = payload provider.identity.account_email = payload provider.auth_method = payload def _setup_m365_xss(provider, payload): provider.identity.tenant_domain = payload provider.identity.identity_type = payload provider.identity.identity_id = payload provider.identity.user = payload def _setup_nhn_xss(provider, payload): provider.identity.tenant_domain = payload provider.identity.identity_type = payload provider.identity.identity_id = payload def _setup_mongodbatlas_xss(provider, payload): provider.identity.organization_name = payload def _setup_iac_xss(provider, payload): provider.scan_repository_url = payload provider.scan_path = None provider.auth_method = payload def _setup_image_xss(provider, payload): provider.registry = payload provider.images = [payload] provider.auth_method = payload def _setup_llm_xss(provider, payload): provider.model = payload provider.plugins = [payload] provider.max_concurrency = payload provider.config_path = payload def _setup_oraclecloud_xss(provider, payload): provider.session.profile = payload provider.identity.tenancy_name = payload provider.identity.tenancy_id = payload def _setup_stackit_xss(provider, payload): provider.identity.project_id = payload provider.identity.project_name = payload provider.identity.audited_regions = {payload} def _setup_cloudflare_xss(provider, payload): account = MagicMock() account.id = payload provider.accounts = [account] provider.session.api_token = "token" provider.session.api_key = None provider.session.api_email = None provider.identity.email = payload def _setup_alibabacloud_xss(provider, payload): provider.identity.account_id = payload provider.identity.account_name = payload provider.identity.audited_regions = payload provider.identity.identity_arn = payload provider.identity.user_name = payload def _setup_openstack_xss(provider, payload): provider.identity.project_id = payload provider.identity.project_name = payload provider.identity.region_name = payload provider.identity.username = payload provider.identity.user_id = payload def _setup_googleworkspace_xss(provider, payload): provider.identity.domain = payload provider.identity.customer_id = payload provider.identity.delegated_user = payload def _setup_e2enetworks_xss(provider, payload): provider.identity.project_id = payload provider.identity.locations = [payload] def _setup_vercel_xss(provider, payload): team = MagicMock() team.name = payload team.id = payload provider.identity.team = team provider.identity.email = payload provider.identity.username = payload def _setup_okta_xss(provider, payload): provider.identity.org_domain = payload provider.auth_method = payload provider.identity.client_id = payload def _setup_scaleway_xss(provider, payload): provider.identity.organization_id = payload provider.session.access_key = payload provider.identity.bearer_type = payload provider.identity.bearer_email = payload provider.identity.bearer_id = payload provider.session.default_region = payload def _setup_linode_xss(provider, payload): provider.identity.username = payload provider.identity.email = payload provider.identity.account_id = payload def _setup_huaweicloud_xss(provider, payload): provider.identity.account_id = payload provider.identity.account_name = payload provider.identity.profile = payload provider.identity.regions = {payload} provider.identity.domain_id = payload provider.identity.user_id = payload provider.identity.user_name = payload provider.identity.identity_type = payload PROVIDER_XSS_SETUPS = [ ("aws", _setup_aws_xss), ("azure", _setup_azure_xss), ("gcp", _setup_gcp_xss), ("kubernetes", _setup_kubernetes_xss), ("github", _setup_github_xss), ("m365", _setup_m365_xss), ("nhn", _setup_nhn_xss), ("mongodbatlas", _setup_mongodbatlas_xss), ("iac", _setup_iac_xss), ("image", _setup_image_xss), ("llm", _setup_llm_xss), ("oraclecloud", _setup_oraclecloud_xss), ("stackit", _setup_stackit_xss), ("cloudflare", _setup_cloudflare_xss), ("alibabacloud", _setup_alibabacloud_xss), ("openstack", _setup_openstack_xss), ("googleworkspace", _setup_googleworkspace_xss), ("e2enetworks", _setup_e2enetworks_xss), ("vercel", _setup_vercel_xss), ("okta", _setup_okta_xss), ("scaleway", _setup_scaleway_xss), ("linode", _setup_linode_xss), ("huaweicloud", _setup_huaweicloud_xss), ] class TestHTML: def test_transform_fail_finding(self): findings = [ generate_finding_output( status="FAIL", resource_tags={"key1": "value1", "key2": "value2"}, severity="high", service_name="service", region=AWS_REGION_EU_WEST_1, check_id="service_test_check_id", check_title="service_test_check_id", resource_uid="test-resource-uid", status_extended="test-status-extended", risk="test-risk", remediation_recommendation_text="test-remediation-recommendation-text", remediation_recommendation_url="https://hub.prowler.com/check/check-id", compliance={"test-compliance": "test-compliance"}, ) ] html = HTML(findings) output_data = html.data[0] assert isinstance(output_data, str) assert output_data == fail_html_finding def test_transform_escapes_provider_originated_fields(self): xss_payload = '' findings = [ generate_finding_output( region="REGION&<>'\"", resource_uid="resource&<>'\"_uid", resource_tags={f"key&<>'\"{xss_payload}": f"value&<>'\"{xss_payload}"}, status_extended=f"status&<>'\"_{xss_payload}", remediation_recommendation_url="https://hub.prowler.com/check/check-id", ) ] output_data = HTML(findings).data[0] assert xss_payload not in output_data assert "region&<>'"" in output_data assert "resource&<>'"_uid" in output_data assert "status&<>'"_<img" in output_data assert "•key&<>'"<img" in output_data assert "=value&<>'"<img" in output_data def test_transform_escapes_metadata_fields(self): finding = generate_finding_output() finding.metadata.Severity = MagicMock(value='') finding.metadata.ServiceName = '' finding.metadata.CheckID = '_suffix' finding.metadata.CheckTitle = '' finding.metadata.Risk = '**Risk** ' finding.metadata.Remediation.Recommendation.Text = ( '**Recommendation** ' ) finding.metadata.Remediation.Recommendation.Url = ( 'https://example.com">' ) output_data = HTML([finding]).data[0] raw_payloads = ( '', '', '_suffix', '', '', '', 'href="https://example.com">"', ) for payload in raw_payloads: assert payload not in output_data assert "<img data-field="severity" src=x>" in output_data assert "<img data-field="service" src=x>" in output_data assert ( "<img data-field="check_id" src=x>_suffix" in output_data ) assert "<img data-field="check_title" src=x>" in output_data assert ( "Risk <img data-field="risk" src=x>" in output_data ) assert ( "Recommendation <img " "data-field="recommendation" src=x>" in output_data ) assert ( 'href="https://example.com"><img ' 'data-field="url" src=x>"' in output_data ) def test_transform_pass_finding(self): findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] html = HTML(findings) output_data = html.data[0] assert isinstance(output_data, str) assert output_data == pass_html_finding def test_transform_muted_finding(self): findings = [ generate_finding_output( muted=True, remediation_recommendation_url="https://hub.prowler.com/check/check-id", ) ] html = HTML(findings) output_data = html.data[0] assert isinstance(output_data, str) assert output_data == muted_html_finding def test_transform_manual_finding(self): findings = [ generate_finding_output( status="MANUAL", remediation_recommendation_url="https://hub.prowler.com/check/check-id", ) ] html = HTML(findings) output_data = html.data[0] assert isinstance(output_data, str) assert output_data == manual_html_finding def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) 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) mock_file.seek(0) content = mock_file.read() args = sys.argv[1:] assert content == get_aws_html_header(args) + pass_html_finding + html_footer def test_batch_write_data_to_file_without_findings(self): assert not HTML([])._file_descriptor def test_write_header(self): mock_file = StringIO() findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) output._file_descriptor = mock_file provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1]) output.write_header(mock_file, provider, html_stats) mock_file.seek(0) content = mock_file.read() args = sys.argv[1:] assert content == get_aws_html_header(args) def test_write_footer(self): mock_file = StringIO() findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) output._file_descriptor = mock_file output.write_footer(mock_file) mock_file.seek(0) content = mock_file.read() assert content == html_footer def test_aws_get_assessment_summary(self): findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1]) summary = output.get_assessment_summary(provider) assert summary == aws_html_assessment_summary def test_azure_get_assessment_summary(self): findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_azure_provider() summary = output.get_assessment_summary(provider) assert summary == summary def test_gcp_get_assessment_summary(self): findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) summary = output.get_assessment_summary(provider) assert summary == gcp_html_assessment_summary def test_kubernetes_get_assessment_summary(self): findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_kubernetes_provider() summary = output.get_assessment_summary(provider) assert summary == kubernetes_html_assessment_summary def test_m365_get_assessment_summary(self): findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_m365_provider() summary = output.get_assessment_summary(provider) expected_summary = m365_html_assessment_summary assert summary == expected_summary def test_github_personal_access_token_get_assessment_summary(self): """Test GitHub HTML assessment summary generation with Personal Access Token authentication.""" findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_github_provider(auth_method="Personal Access Token") summary = output.get_assessment_summary(provider) # Check for expected content in the summary assert "GitHub Assessment Summary" in summary assert "GitHub Credentials" in summary assert "GitHub account: account-name" in summary assert "GitHub authentication method: Personal Access Token" in summary # Note: account_email is None in the default fixture, so it shouldn't appear def test_github_app_get_assessment_summary(self): """Test GitHub HTML assessment summary generation with GitHub App authentication.""" findings = [ generate_finding_output( remediation_recommendation_url="https://hub.prowler.com/check/check-id" ) ] output = HTML(findings) provider = set_mocked_github_provider( auth_method="GitHub App Token", identity=GithubAppIdentityInfo( app_id=APP_ID, app_name="test-app", installations=["test-org"] ), ) summary = output.get_assessment_summary(provider) logger.error(summary) # Check for expected content in the summary assert "GitHub Assessment Summary" in summary assert "GitHub Credentials" in summary assert "GitHub App Name: test-app" in summary assert "Installations: test-org" in summary assert "GitHub authentication method: GitHub App Token" in summary assert f"GitHub App ID: {APP_ID}" in summary def test_mongodbatlas_get_assessment_summary(self): """Test MongoDB Atlas HTML assessment summary generation.""" findings = [generate_finding_output()] output = HTML(findings) provider = set_mocked_mongodbatlas_provider() summary = output.get_assessment_summary(provider) assert summary == mongodbatlas_html_assessment_summary def test_googleworkspace_get_assessment_summary(self): """Test Google Workspace HTML assessment summary generation.""" findings = [generate_finding_output()] output = HTML(findings) provider = set_mocked_googleworkspace_provider() summary = output.get_assessment_summary(provider) assert "Google Workspace Assessment Summary" in summary assert "Google Workspace Credentials" in summary assert "Domain: test-company.com" in summary assert "Customer ID: C1234567" in summary assert "Delegated User: prowler-reader@test-company.com" in summary assert ( "Authentication Method: Service Account with Domain-Wide Delegation" in summary ) def test_image_get_assessment_summary_with_registry(self): """Test Image HTML assessment summary with registry URL.""" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "image" provider.registry = "myregistry.io" provider.images = ["nginx:latest", "alpine:3.18"] provider.auth_method = "Docker login" summary = output.get_assessment_summary(provider) assert summary == image_registry_html_assessment_summary def test_image_get_assessment_summary_with_images(self): """Test Image HTML assessment summary with image list.""" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "image" provider.registry = None provider.images = ["nginx:latest", "alpine:3.18"] provider.auth_method = "No auth" summary = output.get_assessment_summary(provider) assert summary == image_list_html_assessment_summary def test_stackit_get_assessment_summary(self): """Test StackIT HTML assessment summary shows the project ID.""" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "stackit" provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d" provider.identity.project_name = "ProwlerDev" provider.identity.audited_regions = {"eu01", "eu02"} summary = output.get_assessment_summary(provider) assert "StackIT Assessment Summary" in summary assert "StackIT Credentials" in summary assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary assert "Project Name: ProwlerDev" in summary assert "Regions: eu01, eu02" in summary assert "Authentication Type: Service Account Key" in summary def test_stackit_get_assessment_summary_without_project_name(self): """Project ID is always shown; the Project Name line is omitted when the service account cannot read it from Resource Manager.""" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "stackit" provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d" provider.identity.project_name = "" provider.identity.audited_regions = {"eu01"} summary = output.get_assessment_summary(provider) assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary assert "Project Name:" not in summary def test_e2enetworks_get_assessment_summary(self): """Test E2E Networks HTML assessment summary shows project and locations.""" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "e2enetworks" provider.identity.project_id = 12345 provider.identity.locations = ["Delhi", "Chennai"] summary = output.get_assessment_summary(provider) assert "E2E Networks Assessment Summary" in summary assert "Project ID: 12345" in summary assert "Locations: Delhi, Chennai" in summary assert "API Key + Bearer Token" in summary def test_e2enetworks_get_assessment_summary_escapes_locations(self): """Test E2E Networks HTML assessment summary escapes user-controlled locations.""" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "e2enetworks" provider.identity.project_id = 12345 provider.identity.locations = ['Delhi">'] summary = output.get_assessment_summary(provider) assert "" not in summary assert "Delhi"><script>alert(1)</script>" in summary @pytest.mark.parametrize( "provider_type,setup_fn", PROVIDER_XSS_SETUPS, ids=[t for t, _ in PROVIDER_XSS_SETUPS], ) def test_get_assessment_summary_escapes_provider_identity( self, provider_type, setup_fn ): """Every provider header must HTML-escape tenant-controlled identity fields.""" payload = "" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = provider_type setup_fn(provider, payload) summary = output.get_assessment_summary(provider) assert payload not in summary assert "<script>alert(1)</script>" in summary def test_provider_xss_setups_covers_every_assessment_summary_method(self): """Adding a new get__assessment_summary without a PROVIDER_XSS_SETUPS entry must fail this test, so the escape guarantee cannot silently regress.""" pattern = re.compile(r"^get_(.+)_assessment_summary$") discovered = set() for name in dir(HTML): match = pattern.match(name) if match: discovered.add(match.group(1)) covered = {ptype for ptype, _ in PROVIDER_XSS_SETUPS} missing = discovered - covered extra = covered - discovered assert ( not missing ), f"providers without XSS coverage in PROVIDER_XSS_SETUPS: {sorted(missing)}" assert ( not extra ), f"PROVIDER_XSS_SETUPS entries with no matching HTML method: {sorted(extra)}" def test_github_app_get_assessment_summary_escapes_app_identity(self): """The GitHub App branch (elif hasattr app_id) must escape app_name/app_id/installations, which the PAT setup does not exercise.""" payload = "" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "github" provider.identity = MagicMock(spec=["app_id", "app_name", "installations"]) provider.identity.app_id = payload provider.identity.app_name = payload provider.identity.installations = [payload] provider.auth_method = payload summary = output.get_assessment_summary(provider) assert payload not in summary assert "<script>alert(1)</script>" in summary def test_iac_get_assessment_summary_escapes_scan_path(self): """The IAC scan_path branch (else of `if scan_repository_url`) must escape it.""" payload = "" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "iac" provider.scan_repository_url = None provider.scan_path = payload provider.auth_method = payload summary = output.get_assessment_summary(provider) assert payload not in summary assert "<script>alert(1)</script>" in summary def test_image_get_assessment_summary_escapes_images_list(self): """The Image `else` branch (no registry, images list) must escape each image.""" payload = "" findings = [generate_finding_output()] output = HTML(findings) provider = MagicMock() provider.type = "image" provider.registry = None provider.images = [payload] provider.auth_method = payload summary = output.get_assessment_summary(provider) assert payload not in summary assert "<script>alert(1)</script>" in summary def test_process_markdown_bold_text(self): """Test that **text** is converted to text""" test_text = "This is **bold text** and this is **also bold**" result = HTML.process_markdown(test_text) expected = ( "This is bold text and this is also bold" ) assert result == expected def test_process_markdown_italic_text(self): """Test that *text* is converted to text""" test_text = "This is *italic text* and this is *also italic*" result = HTML.process_markdown(test_text) expected = "This is italic text and this is also italic" assert result == expected def test_process_markdown_code_text(self): """Test that `text` is converted to text""" test_text = "Use the `ls` command to list files and `cd` to change directories" result = HTML.process_markdown(test_text) expected = "Use the ls command to list files and cd to change directories" assert result == expected def test_process_markdown_line_breaks(self): """Test that line breaks are converted to
tags""" test_text = "Line 1\nLine 2\nLine 3" result = HTML.process_markdown(test_text) expected = "Line 1
\nLine 2
\nLine 3" assert result == expected def test_process_markdown_mixed_formatting(self): """Test mixed markdown formatting""" test_text = "**Bold text** with *italic* and `code` elements.\n\nNew paragraph with **more bold**." result = HTML.process_markdown(test_text) expected = "Bold text with italic and code elements.
\n
\nNew paragraph with more bold." assert result == expected def test_process_markdown_empty_string(self): """Test that empty string returns empty string""" result = HTML.process_markdown("") assert result == "" def test_process_markdown_none_input(self): """Test that None input returns None""" result = HTML.process_markdown(None) assert result is None def test_process_markdown_no_markdown(self): """Test that plain text without markdown is returned unchanged""" test_text = "This is plain text without any markdown formatting" result = HTML.process_markdown(test_text) assert result == test_text def test_transform_with_markdown_risk(self): """Test that Risk field with markdown is properly converted""" findings = [ generate_finding_output( risk="Outdated contacts delay **security notifications** and slow **incident response**", remediation_recommendation_url="https://hub.prowler.com/check/check-id", ) ] html = HTML(findings) output_data = html.data[0] # Check that markdown is converted to HTML assert "security notifications" in output_data assert "incident response" in output_data def test_transform_with_markdown_recommendation(self): """Test that Recommendation field with markdown is properly converted""" findings = [ generate_finding_output( risk="test-risk", remediation_recommendation_text="Adopt:\n- **Primary** and **alternate contacts**\n- Use `monitored aliases`", remediation_recommendation_url="https://hub.prowler.com/check/check-id", ) ] html = HTML(findings) output_data = html.data[0] # Check that markdown is converted to HTML assert "Primary" in output_data assert "alternate contacts" in output_data assert "monitored aliases" in output_data assert "
" in output_data # Line breaks converted def test_process_markdown_strips_javascript_links(self): """Markdown links with javascript: scheme must not produce clickable hrefs.""" result = HTML.process_markdown( "Click [here](javascript:alert("xss")) to continue" ) assert 'href="javascript:' not in result assert "here" in result def test_process_markdown_keeps_https_links(self): """Markdown links with https: scheme must be preserved.""" result = HTML.process_markdown("[docs](https://docs.prowler.com)") assert 'href="https://docs.prowler.com"' in result assert "