Skip to content

Commit 7a986c8

Browse files
leliaclaude
andcommitted
fix(comments): parse legacy alert rows defensively
Each row of the legacy comment table was unpacked through four consecutive splits with no bounds checks: five cells, then the markdown link, then the ecosystem, then the version. The row comes back from the provider's API, so a cell carrying an extra "|", a package cell that is not a link, or a name with no version raised out of the comment rewrite and ended the run before it reported status. A scoped package name in Socket's own table reached the same place with nobody doing anything unusual. parse_alert_table_row returns None instead of raising for any row it cannot read, and an unreadable row keeps its alert reported -- the safe direction, since a row that cannot be parsed cannot be evaluated against the ignore commands either. Also pins change-type preservation against the real artifact conversion rather than a stubbed field. The existing test assigned diffType by hand, so it would have passed whether or not the conversion populated it; the new one runs real DiffArtifact objects through both response shapes, and fails if the field is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 239c85f commit 7a986c8

3 files changed

Lines changed: 83 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,13 @@
7575
- Shared security comment copy no longer describes GitLab merge request output
7676
as Socket for GitHub.
7777
- Updating a security comment in the legacy table format no longer raises on a
78-
scoped package name. That path split the package cell on every `@`, so a name
79-
carrying its own `@` unpacked into three values; it now splits from the right,
80-
matching the current comment format. Ignore commands for a scoped package are
81-
accepted there in both the ecosystem-qualified and bare forms.
78+
malformed row. Each row was unpacked through four consecutive splits with no
79+
bounds checks, so a cell carrying an extra `|`, a package cell that is not a
80+
markdown link, or a name with no version ended the run before it reported
81+
status — and a scoped package name in Socket's own table was enough to trigger
82+
it. Rows are now parsed defensively, and a row that cannot be read keeps its
83+
alert reported. Ignore commands for a scoped package are accepted there in both
84+
the ecosystem-qualified and bare forms.
8285
- Server URLs read from `GITHUB_SERVER_URL` and `CI_SERVER_URL` are validated as
8386
http(s) URLs before being composed into a diff scan's external link, matching
8487
the check already applied to the other repository URLs read from CI.

‎socketsecurity/core/scm_comments.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,35 @@ def process_security_comment(comment: Comment, comments) -> str:
145145

146146
return new_body
147147

148+
@staticmethod
149+
def parse_alert_table_row(line: str) -> Optional[tuple[str, str, str]]:
150+
"""Pull ``(ecosystem, package, version)`` out of a legacy alert table row.
151+
152+
Returns None for any row that does not have the expected shape rather than
153+
raising. The row comes back from the provider's API, so its contents are
154+
outside this process's control: a cell carrying an extra ``|``, a package
155+
cell that is not a markdown link, or a name with no version all used to
156+
raise out of the comment rewrite and take the run down before it reported
157+
status. A row that cannot be read is a row whose alert stays reported.
158+
"""
159+
cells = line.strip().lstrip("|").rstrip("|").split("|")
160+
if len(cells) != 5:
161+
return None
162+
package = cells[1]
163+
if "](" not in package:
164+
return None
165+
details = package.split("](", 1)[0].lstrip("[")
166+
if "/" not in details:
167+
return None
168+
ecosystem, remainder = details.split("/", 1)
169+
if "@" not in remainder:
170+
return None
171+
# Split from the right: a scoped name carries its own "@".
172+
pkg_name, pkg_version = remainder.rsplit("@", 1)
173+
if not pkg_name or not pkg_version:
174+
return None
175+
return ecosystem, pkg_name, pkg_version
176+
148177
@staticmethod
149178
def process_original_security_comment(
150179
comment: Comment,
@@ -160,19 +189,21 @@ def process_original_security_comment(
160189
start = True
161190
lines.append(line)
162191
elif start and "end-socket-alerts-table" not in line and not Comments.is_heading_line(line) and line != '':
163-
title, package, introduced_by, manifest, ci = line.lstrip("|").rstrip("|").split("|")
164-
details, _ = package.split("](")
165-
ecosystem, details = details.split("/", 1)
166-
ecosystem = ecosystem.lstrip("[")
167-
# Split from the right: a scoped name carries its own "@".
168-
pkg_name, pkg_version = details.rsplit("@", 1)
192+
parsed = Comments.parse_alert_table_row(line)
169193
# ignore_all has to be checked outside the loop: an ignore-all
170194
# comment produces no ignore_commands, so a loop-internal check
171195
# never runs and every row was kept.
172-
ignore = ignore_all or any(
173-
Comments.is_ignore(pkg_name, pkg_version, name, version, ecosystem)
174-
for name, version in ignore_commands
175-
)
196+
if parsed is None:
197+
# An unparseable row cannot be evaluated against the ignore
198+
# commands, so keep it: leaving an alert reported is the safe
199+
# direction, and the comment body is not ours to discard.
200+
ignore = ignore_all
201+
else:
202+
ecosystem, pkg_name, pkg_version = parsed
203+
ignore = ignore_all or any(
204+
Comments.is_ignore(pkg_name, pkg_version, name, version, ecosystem)
205+
for name, version in ignore_commands
206+
)
176207
if not ignore:
177208
kept_alert = True
178209
lines.append(line)

‎tests/core/test_diff_generation.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import json
2-
from dataclasses import fields
2+
from dataclasses import asdict, fields
33
from pathlib import Path
44

55
import pytest
6+
from socketdev.fullscans import DiffArtifact
67

78
from socketsecurity.core import Core
89
from socketsecurity.core.classes import Package
@@ -108,6 +109,39 @@ def test_create_diff_report_preserves_package_change_types(core, diff_input):
108109
assert diff.removed_packages == []
109110
assert {package.id for package in diff.replaced_packages} == {"dp2"}
110111

112+
113+
def _diff_artifact(change_type: str, flattened: bool) -> dict:
114+
"""A DiffArtifact of the given change type, in one of the two response shapes.
115+
116+
The API sends flattened artifacts; the older shape carries the dependency
117+
context in a head/base ref instead, and Package.from_diff_artifact reads
118+
diffType differently in each.
119+
"""
120+
raw = json.loads(
121+
(Path(__file__).parent.parent / "data/fullscans/diff/stream_diff.json").read_text()
122+
)["data"]["artifacts"]["added"][0]
123+
artifact = dict(raw, diffType=change_type, head=None, base=None)
124+
if not flattened:
125+
link = {"topLevelAncestors": ["x"], "direct": True, "artifact": None,
126+
"dependencies": [], "manifestFiles": []}
127+
key = "head" if change_type in ("added", "updated") else "base"
128+
artifact[key] = [link]
129+
return asdict(DiffArtifact.from_dict(artifact))
130+
131+
132+
@pytest.mark.parametrize("flattened", [True, False], ids=["flattened", "ref-shaped"])
133+
@pytest.mark.parametrize("change_type", ["added", "updated", "removed", "replaced"])
134+
def test_change_type_survives_artifact_conversion(change_type, flattened):
135+
"""The classification reads Package.diffType, so the conversion must set it.
136+
137+
create_diff_report buckets on this field alone. A conversion that dropped it
138+
would silently report every update as an addition and every replacement as a
139+
removal, which is the inaccuracy the change-type split exists to prevent.
140+
"""
141+
package = Package.from_diff_artifact(_diff_artifact(change_type, flattened))
142+
143+
assert package.diffType == change_type
144+
111145
def create_input(core):
112146
# Get two different scans to compare
113147
head_scan = core.get_full_scan("head")

0 commit comments

Comments
 (0)