Skip to content

Commit b8502bb

Browse files
leliaclaude
andcommitted
fix(comments): escape repository-derived values when rendering comments
Manifest paths and sources are file paths inside the scanned repository, so anyone who can open a pull request controls them: a directory named with link or tag syntax, holding a manifest, put that markup into a comment posted by a trusted integration. Alert text comes from the API. Neither is markup the CLI authored, so both are escaped where they are interpolated -- text nodes with html.escape, href and src with quotes escaped too, since an unescaped quote closes the attribute and everything after it reads as more attributes. The alert markers are the exception: they are read back verbatim when a comment is rewritten, so they cannot be escaped without breaking the ignore round trip. They instead lose only the ability to terminate the comment early. plain and raw styles are untouched. Slack, Jira and the console do not render HTML, and escaping there would show entities to a human. Round-trip tests render a comment with each hostile path and feed it back through the parser, because the renderer and the parser are two halves of one loop: an escaping choice the parser cannot read would silently stop ignores working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent adb56c6 commit b8502bb

3 files changed

Lines changed: 218 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@
8989
- Server URLs read from `GITHUB_SERVER_URL` and `CI_SERVER_URL` are validated as
9090
http(s) URLs before being composed into a diff scan's external link, matching
9191
the check already applied to the other repository URLs read from CI.
92+
- Repository-derived values are escaped before they are rendered into a pull
93+
request or merge request comment. Manifest paths and sources are file paths from
94+
the scanned repository, and alert text comes from the API; neither is markup the
95+
CLI authored, so both are now escaped at the point they are interpolated. The
96+
alert markers can no longer be terminated early by a package name. Slack, Jira
97+
and console output are unchanged, since none of them render HTML.
9298

9399
## 2.8.1
94100

‎socketsecurity/core/messages.py‎

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,37 @@ def inline_html_text(value) -> str:
837837
return ""
838838
return " ".join(str(value).split())
839839

840+
@staticmethod
841+
def html_text(value) -> str:
842+
"""Flatten a value onto one line and escape it for an HTML text node.
843+
844+
Manifest paths and sources come from the customer's repository, so any PR
845+
author controls them: a directory named ``![x](https://host/p.png)`` or
846+
carrying a raw tag would otherwise render as that markup inside a comment
847+
posted by a trusted integration. Alert text comes from the API and is
848+
escaped for the same reason, since neither is markup the CLI authored.
849+
"""
850+
return escape(Messages.inline_html_text(value))
851+
852+
@staticmethod
853+
def html_attr(value) -> str:
854+
"""Escape a value for an HTML attribute, quotes included.
855+
856+
Used for href and src, where an unescaped quote closes the attribute and
857+
everything after it is read as more attributes.
858+
"""
859+
return escape(Messages.inline_html_text(value), quote=True)
860+
861+
@staticmethod
862+
def comment_marker_text(value) -> str:
863+
"""Neutralize an HTML comment terminator inside a marker value.
864+
865+
The alert markers carry the package name so the comment can be rewritten
866+
later, and the parser reads them back verbatim -- so this cannot escape the
867+
value, only stop it ending the comment early.
868+
"""
869+
return str(value or "").replace("-->", "--&gt;").replace("<!--", "&lt;!--")
870+
840871
@staticmethod
841872
def normalize_comment_html(comment: str) -> str:
842873
"""
@@ -960,43 +991,47 @@ def security_comment_template(diff: Diff, config=None) -> str:
960991
patched_version = Messages.get_patched_version(alert)
961992
patched_version_html = (
962993
"<p><strong>Patched version:</strong> "
963-
f"<code>{escape(Messages.inline_html_text(patched_version))}</code></p>"
994+
f"<code>{Messages.html_text(patched_version)}</code></p>"
964995
if patched_version else ""
965996
)
966997
# Generate proper manifest URL
967998
manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config)
999+
pkg_label = Messages.html_text(f"{alert.pkg_name}@{alert.pkg_version}")
1000+
# The marker is read back verbatim when the comment is rewritten, so it
1001+
# keeps the raw name and only loses the ability to close the comment.
1002+
pkg_marker = Messages.comment_marker_text(f"{alert.pkg_name}@{alert.pkg_version}")
9681003
# Generate a table row for each alert
9691004
ignore_html = (
9701005
f"<p><em>Mark as acceptable risk:</em> To ignore this alert only in this pull request, reply with:<br/>"
971-
f"<code>@SocketSecurity ignore {alert.pkg_type}/{alert.pkg_name}@{alert.pkg_version}</code><br/>"
1006+
f"<code>@SocketSecurity ignore {Messages.html_text(alert.pkg_type)}/{pkg_label}</code><br/>"
9721007
f"Or ignore all future alerts with:<br/>"
9731008
f"<code>@SocketSecurity ignore-all</code></p>"
9741009
) if show_ignore else ""
9751010
comment += f"""
976-
<!-- start-socket-alert-{alert.pkg_name}@{alert.pkg_version} -->
1011+
<!-- start-socket-alert-{pkg_marker} -->
9771012
<tr>
9781013
<td><strong>{action}</strong></td>
9791014
<td align="center">
980-
<img src="{severity_icon}" alt="{alert.severity}" width="20" height="20">
1015+
<img src="{severity_icon}" alt="{Messages.html_attr(alert.severity)}" width="20" height="20">
9811016
</td>
9821017
<td>
9831018
<details {details_open}>
984-
<summary>{alert.pkg_name}@{alert.pkg_version} - {Messages.inline_html_text(alert.title)}</summary>
985-
<p><strong>Note:</strong> {Messages.inline_html_text(alert.description)}</p>
1019+
<summary>{pkg_label} - {Messages.html_text(alert.title)}</summary>
1020+
<p><strong>Note:</strong> {Messages.html_text(alert.description)}</p>
9861021
{patched_version_html}
987-
<p><strong>Source:</strong> <a href="{manifest_url}">Manifest File</a></p>
1022+
<p><strong>Source:</strong> <a href="{Messages.html_attr(manifest_url)}">Manifest File</a></p>
9881023
<p>ℹ️ Read more on:
989-
<a href="{alert.purl}">This package</a> |
990-
<a href="{alert.url}">This alert</a> |
1024+
<a href="{Messages.html_attr(alert.purl)}">This package</a> |
1025+
<a href="{Messages.html_attr(alert.url)}">This alert</a> |
9911026
<a href="https://socket.dev/alerts/malware">What is known malware?</a></p>
9921027
<blockquote>
993-
<p><em>Suggestion:</em> {Messages.inline_html_text(alert.suggestion)}</p>
1028+
<p><em>Suggestion:</em> {Messages.html_text(alert.suggestion)}</p>
9941029
{ignore_html}
9951030
</blockquote>
9961031
</details>
9971032
</td>
9981033
</tr>
999-
<!-- end-socket-alert-{alert.pkg_name}@{alert.pkg_version} -->
1034+
<!-- end-socket-alert-{pkg_marker} -->
10001035
"""
10011036

10021037
# Add license policy violation entries grouped by PURL
@@ -1007,38 +1042,47 @@ def security_comment_template(diff: Diff, config=None) -> str:
10071042
# Use orange diamond for license policy violations
10081043
license_icon = "🔶"
10091044

1045+
license_label = Messages.html_text(
1046+
f"{first_alert.pkg_name}@{first_alert.pkg_version}"
1047+
)
1048+
# The marker is read back verbatim when the comment is rewritten, so it
1049+
# keeps the raw name and only loses the ability to close the comment.
1050+
license_marker = Messages.comment_marker_text(
1051+
f"{first_alert.pkg_name}@{first_alert.pkg_version}"
1052+
)
1053+
10101054
# Build license findings list
10111055
license_findings = []
10121056
for alert in alerts:
10131057
license_findings.append(alert.title)
10141058

10151059
comment += f"""
1016-
<!-- start-socket-alert-{first_alert.pkg_name}@{first_alert.pkg_version} -->
1060+
<!-- start-socket-alert-{license_marker} -->
10171061
<tr>
10181062
<td><strong>{action}</strong></td>
10191063
<td align="center">{license_icon}</td>
10201064
<td>
10211065
<details>
1022-
<summary>{first_alert.pkg_name}@{first_alert.pkg_version} has a License Policy Violation.</summary>
1066+
<summary>{license_label} has a License Policy Violation.</summary>
10231067
<p><strong>License findings:</strong></p>
10241068
<ul>
10251069
"""
10261070
for finding in license_findings:
1027-
comment += f" <li>{Messages.inline_html_text(finding)}</li>\n"
1071+
comment += f" <li>{Messages.html_text(finding)}</li>\n"
10281072

10291073

10301074
# Generate proper manifest URL for license violations
10311075
license_manifest_url = Messages.get_manifest_file_url(diff, first_alert.manifests, config)
10321076

10331077
license_ignore_html = (
10341078
f"<p><em>Mark the package as acceptable risk:</em> To ignore this alert only in this pull request, reply with the comment "
1035-
f"<code>@SocketSecurity ignore {first_alert.pkg_type}/{first_alert.pkg_name}@{first_alert.pkg_version}</code>. "
1079+
f"<code>@SocketSecurity ignore {Messages.html_text(first_alert.pkg_type)}/{license_label}</code>. "
10361080
f"You can also ignore all packages with <code>@SocketSecurity ignore-all</code>. "
10371081
f"To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.</p>"
10381082
) if show_ignore else ""
10391083
comment += f""" </ul>
1040-
<p><strong>From:</strong> <a href="{license_manifest_url}">Manifest File</a></p>
1041-
<p>ℹ️ Read more on: <a href="{first_alert.purl}">This package</a> | <a href="https://socket.dev/alerts/license">What is a license policy violation?</a></p>
1084+
<p><strong>From:</strong> <a href="{Messages.html_attr(license_manifest_url)}">Manifest File</a></p>
1085+
<p>ℹ️ Read more on: <a href="{Messages.html_attr(first_alert.purl)}">This package</a> | <a href="https://socket.dev/alerts/license">What is a license policy violation?</a></p>
10421086
<blockquote>
10431087
<p><em>Next steps:</em> Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at <strong>support@socket.dev</strong>.</p>
10441088
<p><em>Suggestion:</em> Find a package that does not violate your license policy or adjust your policy to allow this package's license.</p>
@@ -1047,7 +1091,7 @@ def security_comment_template(diff: Diff, config=None) -> str:
10471091
</details>
10481092
</td>
10491093
</tr>
1050-
<!-- end-socket-alert-{first_alert.pkg_name}@{first_alert.pkg_version} -->
1094+
<!-- end-socket-alert-{license_marker} -->
10511095
"""
10521096

10531097
# Close table
@@ -1408,8 +1452,11 @@ def create_sources(alert: Issue, style="md") -> tuple[str, str]:
14081452

14091453
for source, manifest in alert.introduced_by:
14101454
if style == "md":
1411-
add_str = f"<li>{manifest}</li>"
1412-
source_str = f"<li>{source}</li>"
1455+
# These land in rendered Markdown, where an unescaped path is read
1456+
# as markup. plain and raw are consumed by Slack, Jira and the
1457+
# console, which do not render HTML, so they stay verbatim.
1458+
add_str = f"<li>{Messages.html_text(manifest)}</li>"
1459+
source_str = f"<li>{Messages.html_text(source)}</li>"
14131460
elif style == "plain":
14141461
add_str = f"• {manifest}"
14151462
source_str = f"• {source}"

‎tests/unit/test_pr_comment_rendering.py‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from dataclasses import dataclass
1111

12+
import pytest
13+
1214
from socketsecurity.core.classes import Comment, Diff, Issue
1315
from socketsecurity.core.messages import Messages
1416
from socketsecurity.core.scm_comments import Comments
@@ -358,3 +360,146 @@ def test_strips_the_action_filter(self):
358360

359361
def test_returns_empty_when_absent(self):
360362
assert Comments.extract_report_url("no link here") == ""
363+
364+
365+
# --- Escaping repo-derived values ---------------------------------------------
366+
#
367+
# Manifest paths and sources are file paths inside the customer's repository, so
368+
# anyone who can open a pull request controls them: a directory named
369+
# `![x](https://host/p.png)` holding a manifest puts that markup into a comment
370+
# posted by a trusted integration. GitHub and GitLab sanitize comment HTML, so the
371+
# exposure is external resource loading, phishing links and content spoofing
372+
# rather than script execution.
373+
374+
375+
@dataclass
376+
class _RepoConfig(_FakeConfig):
377+
"""A config that reaches the branch which embeds the path verbatim.
378+
379+
Without repo/branch, get_manifest_file_url returns "" or a percent-encoded
380+
Socket link, and the path never lands in the comment -- so a test using the
381+
bare config asserts nothing.
382+
"""
383+
repo: str = "acme/widgets"
384+
branch: str = "main"
385+
386+
387+
HOSTILE_PATHS = {
388+
"image": "![x](https://evil.example/p.png)/package.json",
389+
"link": "[click me](https://evil.example)/package.json",
390+
"raw_tag": "<img src=x onerror=alert(1)>/package.json",
391+
"backtick": "`code`/package.json",
392+
"pipe": "a|b/package.json",
393+
"quote": 'a" onmouseover="x/package.json',
394+
"comment_close": "x-->y/package.json",
395+
}
396+
397+
398+
def _rendered_with_path(path: str) -> str:
399+
return Messages.security_comment_template(
400+
_make_diff([_make_alert(manifests=path)]), _RepoConfig()
401+
)
402+
403+
404+
def test_the_hostile_path_actually_reaches_the_comment():
405+
"""Guards the fixture itself: if the path stops being rendered, the escaping
406+
tests below would pass while asserting nothing."""
407+
body = _rendered_with_path("sentinel-path/package.json")
408+
409+
assert "sentinel-path" in body
410+
411+
412+
@pytest.mark.parametrize("name,path", sorted(HOSTILE_PATHS.items()))
413+
def test_hostile_manifest_path_cannot_introduce_markup(name, path):
414+
"""In the rendered comment the path only ever lands inside an href, where
415+
Markdown is inert. The property that matters there is that the value cannot
416+
open a tag or close the attribute -- see create_sources for the context where
417+
Markdown itself is live."""
418+
body = _rendered_with_path(path)
419+
420+
rendered = [ln for ln in body.split("\n") if "Manifest File" in ln][0]
421+
value = rendered.split('href="', 1)[1].split('"', 1)[0]
422+
423+
for char in ("<", ">", '"'):
424+
assert char not in value, f"{char!r} survived into the href: {value!r}"
425+
assert_html_block_intact(body)
426+
427+
428+
def test_quote_in_a_path_cannot_escape_the_href():
429+
body = _rendered_with_path('a" onmouseover="x/package.json')
430+
431+
assert "&quot;" in body
432+
assert 'href="https://github.com/acme/widgets/blob/main/a" ' not in body
433+
434+
435+
def test_hostile_package_name_cannot_close_the_alert_marker():
436+
body = Messages.security_comment_template(
437+
_make_diff([_make_alert(pkg_name="evil-->x")]), _FakeConfig()
438+
)
439+
440+
# Exactly the terminator the CLI wrote, and no stray one inside the value.
441+
for line in body.split("\n"):
442+
if "socket-alert-" in line:
443+
assert line.count("-->") == 1, line
444+
445+
446+
def test_alert_text_from_the_api_is_escaped():
447+
body = Messages.security_comment_template(
448+
_make_diff([_make_alert(description="<script>alert(1)</script>")]), _FakeConfig()
449+
)
450+
451+
assert "<script>" not in body
452+
assert "&lt;script&gt;" in body
453+
454+
455+
@pytest.mark.parametrize("name,path", sorted(HOSTILE_PATHS.items()))
456+
def test_hostile_path_still_round_trips_through_the_ignore_parser(name, path):
457+
"""Escaping must not break reading the comment back.
458+
459+
The renderer and the comment parser are two halves of one loop: a comment the
460+
CLI writes is re-read on the next run to apply ignore commands. An escaping
461+
choice the parser cannot read would silently stop ignores working.
462+
"""
463+
security = _make_comment(_rendered_with_path(path))
464+
comments = {
465+
"security": security,
466+
"ignore": [_make_comment("SocketSecurity ignore npm/lodash@4.17.21", comment_id=2)],
467+
}
468+
469+
new_body = Comments.process_security_comment(security, comments)
470+
471+
assert "No dependency alerts to report" in new_body
472+
473+
474+
class TestCreateSourcesEscaping:
475+
"""create_sources' md style emits <li> into rendered Markdown, so unlike the
476+
href context a path there IS interpreted as Markdown."""
477+
478+
def _md(self, path: str) -> str:
479+
alert = _make_alert()
480+
alert.introduced_by = [("direct", path)]
481+
manifest_str, _ = Messages.create_sources(alert, "md")
482+
return manifest_str
483+
484+
@pytest.mark.parametrize("name,path", sorted(HOSTILE_PATHS.items()))
485+
def test_md_style_escapes_the_path(self, name, path):
486+
"""Escaping stops the value opening a tag. Markdown inside the value is
487+
inert because GFM does not parse Markdown within a raw HTML block, which
488+
is why escaping rather than code spans is the right tool for <li>."""
489+
out = self._md(path)
490+
491+
assert "<img src=x onerror=alert(1)>" not in out
492+
# Structure intact: exactly the one element create_sources wrote.
493+
assert out.count("<li>") == 1 and out.count("</li>") == 1
494+
inner = out[out.index("<li>") + 4 : out.index("</li>")]
495+
for char in ("<", ">"):
496+
assert char not in inner, f"{char!r} survived into the list item: {inner!r}"
497+
498+
def test_plain_style_is_left_verbatim(self):
499+
"""Slack, Jira and the console do not render HTML."""
500+
alert = _make_alert()
501+
alert.introduced_by = [("direct", "<img src=x>/package.json")]
502+
503+
manifest_str, _ = Messages.create_sources(alert, "plain")
504+
505+
assert "<img src=x>/package.json" in manifest_str

0 commit comments

Comments
 (0)