mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-23 20:04:27 +00:00
fix(html): escape provider data in reports (#12247)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
This commit is contained in:
1 parent
2a5a95eb6d
commit
d6667ec2cb
3 files changed
+144
-11
No files matched your search
@@ -0,0 +1 @@
|
||||
HTML reports escape provider-originated finding fields to prevent stored cross-site scripting through malicious cloud resource tags
|
||||
@@ -1,5 +1,7 @@
|
||||
import re
|
||||
import sys
|
||||
from io import TextIOWrapper
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import markdown
|
||||
from markupsafe import escape
|
||||
@@ -16,6 +18,33 @@ from prowler.lib.outputs.output import Finding, Output
|
||||
from prowler.lib.outputs.utils import parse_html_string, unroll_dict
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
_SAFE_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str:
|
||||
"""Return url if its scheme is http/https, otherwise return empty string."""
|
||||
if not url:
|
||||
return ""
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
return url if scheme in _SAFE_URL_SCHEMES else ""
|
||||
|
||||
|
||||
def _strip_unsafe_links(html_content: str) -> str:
|
||||
"""Replace <a href> tags whose href is not http/https with their link text."""
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
href = match.group("href")
|
||||
body = match.group("body")
|
||||
safe = _safe_url(href)
|
||||
return f'<a href="{safe}">{body}</a>' if safe else body
|
||||
|
||||
return re.sub(
|
||||
r'<a\s[^>]*href="(?P<href>[^"]*)"[^>]*>(?P<body>.*?)</a>',
|
||||
_replace,
|
||||
html_content,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class HTML(Output):
|
||||
@staticmethod
|
||||
@@ -52,7 +81,7 @@ class HTML(Output):
|
||||
html_content = html_content.replace("<p>", "")
|
||||
html_content = html_content.replace("</p>", "")
|
||||
|
||||
return html_content
|
||||
return _strip_unsafe_links(html_content)
|
||||
|
||||
def transform(self, findings: list[Finding]) -> None:
|
||||
"""Transforms the findings into the HTML format.
|
||||
@@ -77,16 +106,16 @@ class HTML(Output):
|
||||
self._data.append(f"""
|
||||
<tr class="{row_class}">
|
||||
<td>{finding_status}</td>
|
||||
<td>{finding.metadata.Severity.value}</td>
|
||||
<td>{finding.metadata.ServiceName}</td>
|
||||
<td>{finding.region.lower()}</td>
|
||||
<td>{finding.metadata.CheckID.replace("_", "<wbr />_")}</td>
|
||||
<td>{finding.metadata.CheckTitle}</td>
|
||||
<td>{finding.resource_uid.replace("<", "<").replace(">", ">").replace("_", "<wbr />_")}</td>
|
||||
<td>{parse_html_string(unroll_dict(finding.resource_tags))}</td>
|
||||
<td>{finding.status_extended.replace("<", "<").replace(">", ">").replace("_", "<wbr />_")}</td>
|
||||
<td><p class="show-read-more">{HTML.process_markdown(finding.metadata.Risk)}</p></td>
|
||||
<td><p class="show-read-more">{HTML.process_markdown(finding.metadata.Remediation.Recommendation.Text)}</p> <a class="read-more" href="{finding.metadata.Remediation.Recommendation.Url}"><i class="fas fa-external-link-alt"></i></a></td>
|
||||
<td>{str(escape(finding.metadata.Severity.value))}</td>
|
||||
<td>{str(escape(finding.metadata.ServiceName))}</td>
|
||||
<td>{str(escape(finding.region.lower()))}</td>
|
||||
<td>{str(escape(finding.metadata.CheckID)).replace("_", "<wbr />_")}</td>
|
||||
<td>{str(escape(finding.metadata.CheckTitle))}</td>
|
||||
<td>{str(escape(finding.resource_uid)).replace("_", "<wbr />_")}</td>
|
||||
<td>{parse_html_string(str(escape(unroll_dict(finding.resource_tags))))}</td>
|
||||
<td>{str(escape(finding.status_extended)).replace("_", "<wbr />_")}</td>
|
||||
<td><p class="show-read-more">{HTML.process_markdown(str(escape(finding.metadata.Risk)))}</p></td>
|
||||
<td><p class="show-read-more">{HTML.process_markdown(str(escape(finding.metadata.Remediation.Recommendation.Text)))}</p> <a class="read-more" href="{str(escape(_safe_url(finding.metadata.Remediation.Recommendation.Url)))}"><i class="fas fa-external-link-alt"></i></a></td>
|
||||
<td><p class="show-read-more">{parse_html_string(unroll_dict(finding.compliance, separator=": "))}</p></td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
@@ -701,6 +701,75 @@ class TestHTML:
|
||||
assert isinstance(output_data, str)
|
||||
assert output_data == fail_html_finding
|
||||
|
||||
def test_transform_escapes_provider_originated_fields(self):
|
||||
xss_payload = '<img src=x onerror="window.PROWLER_TAG_XSS=1">'
|
||||
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&<>'"<wbr />_uid" in output_data
|
||||
assert "status&<>'"<wbr />_<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='<img data-field="severity" src=x>')
|
||||
finding.metadata.ServiceName = '<img data-field="service" src=x>'
|
||||
finding.metadata.CheckID = '<img data-field="check_id" src=x>_suffix'
|
||||
finding.metadata.CheckTitle = '<img data-field="check_title" src=x>'
|
||||
finding.metadata.Risk = '**Risk** <img data-field="risk" src=x>'
|
||||
finding.metadata.Remediation.Recommendation.Text = (
|
||||
'**Recommendation** <img data-field="recommendation" src=x>'
|
||||
)
|
||||
finding.metadata.Remediation.Recommendation.Url = (
|
||||
'https://example.com"><img data-field="url" src=x>'
|
||||
)
|
||||
|
||||
output_data = HTML([finding]).data[0]
|
||||
|
||||
raw_payloads = (
|
||||
'<img data-field="severity" src=x>',
|
||||
'<img data-field="service" src=x>',
|
||||
'<img data-field="check_id" src=x>_suffix',
|
||||
'<img data-field="check_title" src=x>',
|
||||
'<img data-field="risk" src=x>',
|
||||
'<img data-field="recommendation" src=x>',
|
||||
'href="https://example.com"><img data-field="url" src=x>"',
|
||||
)
|
||||
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<wbr />_id" src=x><wbr />_suffix"
|
||||
in output_data
|
||||
)
|
||||
assert "<img data-field="check_title" src=x>" in output_data
|
||||
assert (
|
||||
"<strong>Risk</strong> <img data-field="risk" src=x>"
|
||||
in output_data
|
||||
)
|
||||
assert (
|
||||
"<strong>Recommendation</strong> <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(
|
||||
@@ -1116,3 +1185,37 @@ class TestHTML:
|
||||
assert "<strong>alternate contacts</strong>" in output_data
|
||||
assert "<code>monitored aliases</code>" in output_data
|
||||
assert "<br />" 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 "<a" in result
|
||||
|
||||
def test_transform_recommendation_url_javascript_scheme_is_blocked(self):
|
||||
"""Recommendation.Url with javascript: scheme must render as empty href."""
|
||||
finding = generate_finding_output(
|
||||
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
|
||||
)
|
||||
finding.metadata.Remediation.Recommendation.Url = "javascript:alert(1)"
|
||||
output_data = HTML([finding]).data[0]
|
||||
assert 'href="javascript:' not in output_data
|
||||
assert 'href=""' in output_data
|
||||
|
||||
def test_transform_recommendation_url_https_is_kept(self):
|
||||
"""Recommendation.Url with https: scheme must appear unchanged in href."""
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
remediation_recommendation_url="https://hub.prowler.com/check/check-id"
|
||||
)
|
||||
]
|
||||
output_data = HTML(findings).data[0]
|
||||
assert 'href="https://hub.prowler.com/check/check-id"' in output_data
|
||||
Reference in new issue
Block a user