Skip to content

Commit c397151

Browse files
committed
Include package changes in JSON reports
Expose classified package snapshots and raw scores to JSON consumers. Preserve existing output fields and document version-pairing limits. Assisted-by: Codex:GPT-6
1 parent 8193af4 commit c397151

4 files changed

Lines changed: 665 additions & 3 deletions

File tree

‎docs/cli-reference.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,21 @@ from `report_url`, which is the display link and may include output options such
341341
as license-detail settings. These additions apply to standard Socket JSON, not
342342
FOSSA compatibility output.
343343
344+
Standard Socket JSON also includes four package-change arrays:
345+
`new_packages`, `updated_packages`, `removed_packages`, and `replaced_packages`.
346+
Rows contain `id`, `name`, `version`, `ecosystem`, `direct`, `author`, `size`,
347+
`transitives`, `introduced_by`, `capabilities`, `is_new`, `author_url`, `url`,
348+
`purl`, and `scores`. The containing array defines the change category; `is_new`
349+
is preserved as package metadata. The current producer includes direct
350+
dependencies by default. Scores retain the API's raw names and values; missing
351+
scores are `{}`. `purl` uses the CLI's established ecosystem-separated form,
352+
such as `@scope/package@1.2.3`.
353+
354+
Full-scan inventory and no-run results have four empty arrays. Updated and
355+
replaced entries are separate snapshots; JSON does not pair versions or supply
356+
`previousVersion`. These additive fields can affect consumers that reject
357+
unknown keys and do not represent complete output-format parity.
358+
344359
#### Security Configuration
345360
| Parameter | Required | Default | Description |
346361
|:-------------------------|:---------|:--------|:------------------------------|

‎socketsecurity/core/report_output.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from copy import deepcopy
12
from dataclasses import dataclass
23
from typing import Optional
34

@@ -6,7 +7,7 @@
67
clone_diff_with_selected_alerts,
78
select_diff_alerts,
89
)
9-
from socketsecurity.core.classes import Diff
10+
from socketsecurity.core.classes import Diff, Purl
1011
from socketsecurity.core.messages import Messages
1112

1213

@@ -50,6 +51,12 @@ def build_report_references(diff_report: Diff) -> ReportReferences:
5051
)
5152

5253

54+
def _serialize_package_change(package: Purl) -> dict[str, object]:
55+
result = deepcopy(package.to_dict())
56+
result["scores"] = deepcopy(package.scores)
57+
return result
58+
59+
5360
def build_console_summary(diff_report: Diff, config: CliConfig) -> str:
5461
references = build_report_references(diff_report)
5562
is_full_scan = references.is_full_scan
@@ -152,4 +159,16 @@ def build_socket_json_report(
152159
commit_sha if isinstance(commit_sha, str) or commit_sha is None else None
153160
)
154161
report["legal_mode"] = legal_flag if isinstance(legal_flag, bool) else False
162+
report["new_packages"] = [
163+
_serialize_package_change(package) for package in diff_report.new_packages
164+
]
165+
report["updated_packages"] = [
166+
_serialize_package_change(package) for package in diff_report.updated_packages
167+
]
168+
report["removed_packages"] = [
169+
_serialize_package_change(package) for package in diff_report.removed_packages
170+
]
171+
report["replaced_packages"] = [
172+
_serialize_package_change(package) for package in diff_report.replaced_packages
173+
]
155174
return report
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
"""Package-change JSON transport coverage across real Core and SDK paths."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterator
6+
7+
import pytest
8+
from socketdev import socketdev
9+
from socketdev.fullscans import FullScanParams
10+
11+
from socketsecurity.config import CliConfig
12+
from socketsecurity.core import Core
13+
from socketsecurity.core.report_output import build_socket_json_report
14+
from socketsecurity.core.socket_config import SocketConfig
15+
from tests.support.socket_api_fixture import (
16+
SocketApiFixture,
17+
SocketResponse,
18+
socket_api_fixture,
19+
)
20+
21+
22+
@pytest.fixture
23+
def socket_api() -> Iterator[SocketApiFixture]:
24+
fixture = socket_api_fixture()
25+
try:
26+
yield fixture
27+
finally:
28+
fixture.close()
29+
30+
31+
def _core(api: SocketApiFixture) -> Core:
32+
sdk = socketdev(token="fictional-local-only", timeout=3)
33+
sdk.api.api_url = api.base_url
34+
return Core(
35+
config=SocketConfig(api_key="fictional-local-only"),
36+
sdk=sdk,
37+
cli_config=CliConfig(api_token="fictional-local-only", repo="fixture-repo"),
38+
)
39+
40+
41+
def _score() -> dict[str, int | float | None]:
42+
return {
43+
"supplyChain": 0,
44+
"quality": 0.25,
45+
"maintenance": 1,
46+
"vulnerability": None,
47+
"license": 0,
48+
"overall": 0.5,
49+
}
50+
51+
52+
def _artifact(
53+
artifact_id: str,
54+
change_type: str,
55+
name: str,
56+
version: str,
57+
*,
58+
direct: bool = True,
59+
namespace: str | None = None,
60+
score: dict[str, int | float | None] | None = None,
61+
parent_id: str | None = None,
62+
) -> dict[str, object]:
63+
reference: dict[str, object] = {
64+
"diffType": change_type,
65+
"direct": direct,
66+
"topLevelAncestors": [parent_id] if parent_id else [],
67+
"dependencies": [],
68+
"manifestFiles": [{"file": "requirements.txt"}] if direct else [],
69+
"artifact": {"id": artifact_id},
70+
}
71+
artifact: dict[str, object] = {
72+
"diffType": change_type,
73+
"id": artifact_id,
74+
"type": "npm" if namespace else "pypi",
75+
"name": name,
76+
"version": version,
77+
"alerts": [],
78+
"licenseDetails": [],
79+
"author": ["Fixture Author"],
80+
"head" if change_type in ("added", "updated", "unchanged") else "base": [
81+
reference
82+
],
83+
}
84+
if namespace:
85+
artifact["namespace"] = namespace
86+
if score is not None:
87+
artifact["score"] = score
88+
return artifact
89+
90+
91+
def _artifacts() -> dict[str, list[dict[str, object]]]:
92+
return {
93+
"added": [
94+
_artifact("added-id", "added", "added-lib", "1.0.0", score=_score()),
95+
_artifact(
96+
"transitive-id",
97+
"added",
98+
"transitive-lib",
99+
"1.1.0",
100+
direct=False,
101+
parent_id="added-id",
102+
),
103+
],
104+
"updated": [
105+
_artifact(
106+
"updated-id",
107+
"updated",
108+
"updated-lib",
109+
"2.0.0",
110+
namespace="@fictional",
111+
score=_score(),
112+
)
113+
],
114+
"removed": [_artifact("removed-id", "removed", "removed-lib", "3.0.0")],
115+
"replaced": [_artifact("replaced-id", "replaced", "replaced-lib", "0.9.0")],
116+
"unchanged": [_artifact("unchanged-id", "unchanged", "steady-lib", "4.0.0")],
117+
}
118+
119+
120+
def _stream_response(
121+
artifacts: dict[str, list[dict[str, object]]],
122+
) -> dict[str, object]:
123+
commit = {
124+
"repository_id": "repo-fixture",
125+
"branch": "main",
126+
"id": "scan-fixture",
127+
"organization_id": "org-fixture",
128+
"committers": [],
129+
}
130+
return {
131+
"before": commit,
132+
"after": commit,
133+
"diff_report_url": "https://socket.dev/fixture/diff?source=stream#done",
134+
"artifacts": artifacts,
135+
}
136+
137+
138+
@pytest.mark.parametrize("streaming_fallback", [False, True])
139+
def test_json_contains_real_classified_packages_without_extra_transport(
140+
socket_api: SocketApiFixture,
141+
streaming_fallback: bool,
142+
) -> None:
143+
core = _core(socket_api)
144+
artifacts = _artifacts()
145+
if streaming_fallback:
146+
socket_api.route(
147+
"POST",
148+
"orgs/fixture-org/diff-scans/from-ids",
149+
SocketResponse(403, {"error": {"message": "forbidden"}}),
150+
)
151+
socket_api.route(
152+
"GET",
153+
"orgs/fixture-org/full-scans/diff",
154+
SocketResponse(200, _stream_response(artifacts)),
155+
)
156+
else:
157+
socket_api.route(
158+
"POST",
159+
"orgs/fixture-org/diff-scans/from-ids",
160+
SocketResponse(201, {"diff_scan": {"id": "diff-fixture"}}),
161+
)
162+
socket_api.route(
163+
"GET",
164+
"orgs/fixture-org/diff-scans/diff-fixture",
165+
SocketResponse(
166+
200,
167+
{
168+
"diff_scan": {
169+
"artifacts": artifacts,
170+
"before_full_scan": {"id": "baseline-id"},
171+
"after_full_scan": {"id": "new-scan-id"},
172+
}
173+
},
174+
),
175+
)
176+
177+
comparison = core._get_package_comparison(
178+
"baseline-id", "new-scan-id", include_license_details=False
179+
)
180+
requests_after_comparison = len(socket_api.requests)
181+
report = build_socket_json_report(
182+
core.create_diff_report(
183+
comparison.added_packages,
184+
comparison.removed_packages,
185+
{"unchanged-id": comparison.packages["pypi/steady-lib@4.0.0"]},
186+
),
187+
CliConfig(api_token="fictional-local-only", repo="fixture-repo"),
188+
)
189+
190+
assert len(socket_api.requests) == requests_after_comparison
191+
assert [row["id"] for row in report["new_packages"]] == ["added-id"]
192+
assert [row["id"] for row in report["updated_packages"]] == ["updated-id"]
193+
assert [row["id"] for row in report["removed_packages"]] == ["removed-id"]
194+
assert [row["id"] for row in report["replaced_packages"]] == ["replaced-id"]
195+
assert report["new_packages"][0]["scores"] == _score()
196+
assert report["new_packages"][0]["url"] == (
197+
"https://socket.dev/pypi/package/added-lib/overview/1.0.0"
198+
)
199+
assert report["updated_packages"][0]["purl"] == "@fictional/updated-lib@2.0.0"
200+
assert report["updated_packages"][0]["url"] == (
201+
"https://socket.dev/npm/package/@fictional/updated-lib/overview/2.0.0"
202+
)
203+
assert report["removed_packages"][0]["scores"] == {}
204+
assert report["new_alerts"] == []
205+
comparison_requests = socket_api.requests[1:]
206+
if streaming_fallback:
207+
assert [request.path for request in comparison_requests] == [
208+
"orgs/fixture-org/diff-scans/from-ids",
209+
"orgs/fixture-org/full-scans/diff",
210+
]
211+
assert comparison_requests[1].query == {
212+
"before": ["baseline-id"],
213+
"after": ["new-scan-id"],
214+
"include_license_details": ["false"],
215+
}
216+
else:
217+
assert [request.path for request in comparison_requests] == [
218+
"orgs/fixture-org/diff-scans/from-ids",
219+
"orgs/fixture-org/diff-scans/diff-fixture",
220+
]
221+
assert comparison_requests[1].query == {
222+
"cached": ["true"],
223+
"omit_unchanged": ["true"],
224+
}
225+
226+
all_packages = core.create_diff_report(
227+
comparison.added_packages,
228+
comparison.removed_packages,
229+
{"unchanged-id": comparison.packages["pypi/steady-lib@4.0.0"]},
230+
direct_only=False,
231+
)
232+
assert [row.id for row in all_packages.new_packages] == [
233+
"added-id",
234+
"transitive-id",
235+
]
236+
transitive_report = build_socket_json_report(
237+
all_packages,
238+
CliConfig(api_token="fictional-local-only", repo="fixture-repo"),
239+
)
240+
assert [row["id"] for row in transitive_report["new_packages"]] == [
241+
"added-id",
242+
"transitive-id",
243+
]
244+
245+
246+
def test_no_run_keeps_change_lists_empty_without_scan_requests(
247+
socket_api: SocketApiFixture,
248+
) -> None:
249+
core = _core(socket_api)
250+
request_count_after_setup = len(socket_api.requests)
251+
252+
result = core.create_new_diff(
253+
[], FullScanParams(repo="fixture-repo"), no_change=True
254+
)
255+
report = build_socket_json_report(
256+
result,
257+
CliConfig(api_token="fictional-local-only", repo="fixture-repo"),
258+
)
259+
260+
assert len(socket_api.requests) == request_count_after_setup
261+
assert result.id == "NO_DIFF_RAN"
262+
assert report["full_scan_id"] == "NO_DIFF_RAN"
263+
assert report["new_packages"] == []
264+
assert report["updated_packages"] == []
265+
assert report["removed_packages"] == []
266+
assert report["replaced_packages"] == []
267+
268+
269+
def test_full_scan_keeps_change_lists_empty(
270+
socket_api: SocketApiFixture,
271+
tmp_path,
272+
) -> None:
273+
core = _core(socket_api)
274+
socket_api.route(
275+
"POST",
276+
"orgs/fixture-org/full-scans",
277+
SocketResponse(
278+
201,
279+
{
280+
"id": "inventory-scan-id",
281+
"created_at": "2026-01-01T00:00:00Z",
282+
"updated_at": "2026-01-01T00:00:00Z",
283+
"organization_id": "org-fixture",
284+
"repository_id": "repo-fixture",
285+
"branch": "main",
286+
"html_report_url": "https://socket.dev/scan/inventory-scan-id",
287+
},
288+
),
289+
)
290+
manifest = tmp_path / "requirements.txt"
291+
manifest.write_text("fictional-package==1.0.0\n")
292+
293+
result = core.create_full_scan_with_report_url(
294+
[str(tmp_path)],
295+
FullScanParams(repo="fixture-repo", org_slug="fixture-org", branch="main"),
296+
explicit_files=[str(manifest)],
297+
)
298+
report = build_socket_json_report(
299+
result,
300+
CliConfig(api_token="fictional-local-only", repo="fixture-repo"),
301+
)
302+
303+
assert result.id == "inventory-scan-id"
304+
assert report["new_scan_id"] == "inventory-scan-id"
305+
assert report["new_packages"] == []
306+
assert report["updated_packages"] == []
307+
assert report["removed_packages"] == []
308+
assert report["replaced_packages"] == []

0 commit comments

Comments
 (0)