diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1249b0d..4b2d91b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -341,6 +341,21 @@ from `report_url`, which is the display link and may include output options such as license-detail settings. These additions apply to standard Socket JSON, not FOSSA compatibility output. +Standard Socket JSON also includes four package-change arrays: +`new_packages`, `updated_packages`, `removed_packages`, and `replaced_packages`. +Rows contain `id`, `name`, `version`, `ecosystem`, `direct`, `author`, `size`, +`transitives`, `introduced_by`, `capabilities`, `is_new`, `author_url`, `url`, +`purl`, and `scores`. The containing array defines the change category; `is_new` +is preserved as package metadata. The current producer includes direct +dependencies by default. Scores retain the API's raw names and values; missing +scores are `{}`. `purl` uses the CLI's established ecosystem-separated form, +such as `@scope/package@1.2.3`. + +Full-scan inventory and no-run results have four empty arrays. Updated and +replaced entries are separate snapshots; JSON does not pair versions or supply +`previousVersion`. These additive fields can affect consumers that reject +unknown keys and do not represent complete output-format parity. + #### Security Configuration | Parameter | Required | Default | Description | |:-------------------------|:---------|:--------|:------------------------------| diff --git a/socketsecurity/core/report_output.py b/socketsecurity/core/report_output.py index 90fdd1d..e749b2d 100644 --- a/socketsecurity/core/report_output.py +++ b/socketsecurity/core/report_output.py @@ -1,3 +1,4 @@ +from copy import deepcopy from dataclasses import dataclass from typing import Optional @@ -6,7 +7,7 @@ clone_diff_with_selected_alerts, select_diff_alerts, ) -from socketsecurity.core.classes import Diff +from socketsecurity.core.classes import Diff, Purl from socketsecurity.core.messages import Messages @@ -50,6 +51,12 @@ def build_report_references(diff_report: Diff) -> ReportReferences: ) +def _serialize_package_change(package: Purl) -> dict[str, object]: + result = deepcopy(package.to_dict()) + result["scores"] = deepcopy(package.scores) + return result + + def build_console_summary(diff_report: Diff, config: CliConfig) -> str: references = build_report_references(diff_report) is_full_scan = references.is_full_scan @@ -152,4 +159,16 @@ def build_socket_json_report( commit_sha if isinstance(commit_sha, str) or commit_sha is None else None ) report["legal_mode"] = legal_flag if isinstance(legal_flag, bool) else False + report["new_packages"] = [ + _serialize_package_change(package) for package in diff_report.new_packages + ] + report["updated_packages"] = [ + _serialize_package_change(package) for package in diff_report.updated_packages + ] + report["removed_packages"] = [ + _serialize_package_change(package) for package in diff_report.removed_packages + ] + report["replaced_packages"] = [ + _serialize_package_change(package) for package in diff_report.replaced_packages + ] return report diff --git a/tests/core/test_json_package_changes_transport.py b/tests/core/test_json_package_changes_transport.py new file mode 100644 index 0000000..867b9ba --- /dev/null +++ b/tests/core/test_json_package_changes_transport.py @@ -0,0 +1,308 @@ +"""Package-change JSON transport coverage across real Core and SDK paths.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from socketdev import socketdev +from socketdev.fullscans import FullScanParams + +from socketsecurity.config import CliConfig +from socketsecurity.core import Core +from socketsecurity.core.report_output import build_socket_json_report +from socketsecurity.core.socket_config import SocketConfig +from tests.support.socket_api_fixture import ( + SocketApiFixture, + SocketResponse, + socket_api_fixture, +) + + +@pytest.fixture +def socket_api() -> Iterator[SocketApiFixture]: + fixture = socket_api_fixture() + try: + yield fixture + finally: + fixture.close() + + +def _core(api: SocketApiFixture) -> Core: + sdk = socketdev(token="fictional-local-only", timeout=3) + sdk.api.api_url = api.base_url + return Core( + config=SocketConfig(api_key="fictional-local-only"), + sdk=sdk, + cli_config=CliConfig(api_token="fictional-local-only", repo="fixture-repo"), + ) + + +def _score() -> dict[str, int | float | None]: + return { + "supplyChain": 0, + "quality": 0.25, + "maintenance": 1, + "vulnerability": None, + "license": 0, + "overall": 0.5, + } + + +def _artifact( + artifact_id: str, + change_type: str, + name: str, + version: str, + *, + direct: bool = True, + namespace: str | None = None, + score: dict[str, int | float | None] | None = None, + parent_id: str | None = None, +) -> dict[str, object]: + reference: dict[str, object] = { + "diffType": change_type, + "direct": direct, + "topLevelAncestors": [parent_id] if parent_id else [], + "dependencies": [], + "manifestFiles": [{"file": "requirements.txt"}] if direct else [], + "artifact": {"id": artifact_id}, + } + artifact: dict[str, object] = { + "diffType": change_type, + "id": artifact_id, + "type": "npm" if namespace else "pypi", + "name": name, + "version": version, + "alerts": [], + "licenseDetails": [], + "author": ["Fixture Author"], + "head" if change_type in ("added", "updated", "unchanged") else "base": [ + reference + ], + } + if namespace: + artifact["namespace"] = namespace + if score is not None: + artifact["score"] = score + return artifact + + +def _artifacts() -> dict[str, list[dict[str, object]]]: + return { + "added": [ + _artifact("added-id", "added", "added-lib", "1.0.0", score=_score()), + _artifact( + "transitive-id", + "added", + "transitive-lib", + "1.1.0", + direct=False, + parent_id="added-id", + ), + ], + "updated": [ + _artifact( + "updated-id", + "updated", + "updated-lib", + "2.0.0", + namespace="@fictional", + score=_score(), + ) + ], + "removed": [_artifact("removed-id", "removed", "removed-lib", "3.0.0")], + "replaced": [_artifact("replaced-id", "replaced", "replaced-lib", "0.9.0")], + "unchanged": [_artifact("unchanged-id", "unchanged", "steady-lib", "4.0.0")], + } + + +def _stream_response( + artifacts: dict[str, list[dict[str, object]]], +) -> dict[str, object]: + commit = { + "repository_id": "repo-fixture", + "branch": "main", + "id": "scan-fixture", + "organization_id": "org-fixture", + "committers": [], + } + return { + "before": commit, + "after": commit, + "diff_report_url": "https://socket.dev/fixture/diff?source=stream#done", + "artifacts": artifacts, + } + + +@pytest.mark.parametrize("streaming_fallback", [False, True]) +def test_json_contains_real_classified_packages_without_extra_transport( + socket_api: SocketApiFixture, + streaming_fallback: bool, +) -> None: + core = _core(socket_api) + artifacts = _artifacts() + if streaming_fallback: + socket_api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(403, {"error": {"message": "forbidden"}}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/full-scans/diff", + SocketResponse(200, _stream_response(artifacts)), + ) + else: + socket_api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(201, {"diff_scan": {"id": "diff-fixture"}}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/diff-scans/diff-fixture", + SocketResponse( + 200, + { + "diff_scan": { + "artifacts": artifacts, + "before_full_scan": {"id": "baseline-id"}, + "after_full_scan": {"id": "new-scan-id"}, + } + }, + ), + ) + + comparison = core._get_package_comparison( + "baseline-id", "new-scan-id", include_license_details=False + ) + requests_after_comparison = len(socket_api.requests) + report = build_socket_json_report( + core.create_diff_report( + comparison.added_packages, + comparison.removed_packages, + {"unchanged-id": comparison.packages["pypi/steady-lib@4.0.0"]}, + ), + CliConfig(api_token="fictional-local-only", repo="fixture-repo"), + ) + + assert len(socket_api.requests) == requests_after_comparison + assert [row["id"] for row in report["new_packages"]] == ["added-id"] + assert [row["id"] for row in report["updated_packages"]] == ["updated-id"] + assert [row["id"] for row in report["removed_packages"]] == ["removed-id"] + assert [row["id"] for row in report["replaced_packages"]] == ["replaced-id"] + assert report["new_packages"][0]["scores"] == _score() + assert report["new_packages"][0]["url"] == ( + "https://socket.dev/pypi/package/added-lib/overview/1.0.0" + ) + assert report["updated_packages"][0]["purl"] == "@fictional/updated-lib@2.0.0" + assert report["updated_packages"][0]["url"] == ( + "https://socket.dev/npm/package/@fictional/updated-lib/overview/2.0.0" + ) + assert report["removed_packages"][0]["scores"] == {} + assert report["new_alerts"] == [] + comparison_requests = socket_api.requests[1:] + if streaming_fallback: + assert [request.path for request in comparison_requests] == [ + "orgs/fixture-org/diff-scans/from-ids", + "orgs/fixture-org/full-scans/diff", + ] + assert comparison_requests[1].query == { + "before": ["baseline-id"], + "after": ["new-scan-id"], + "include_license_details": ["false"], + } + else: + assert [request.path for request in comparison_requests] == [ + "orgs/fixture-org/diff-scans/from-ids", + "orgs/fixture-org/diff-scans/diff-fixture", + ] + assert comparison_requests[1].query == { + "cached": ["true"], + "omit_unchanged": ["true"], + } + + all_packages = core.create_diff_report( + comparison.added_packages, + comparison.removed_packages, + {"unchanged-id": comparison.packages["pypi/steady-lib@4.0.0"]}, + direct_only=False, + ) + assert [row.id for row in all_packages.new_packages] == [ + "added-id", + "transitive-id", + ] + transitive_report = build_socket_json_report( + all_packages, + CliConfig(api_token="fictional-local-only", repo="fixture-repo"), + ) + assert [row["id"] for row in transitive_report["new_packages"]] == [ + "added-id", + "transitive-id", + ] + + +def test_no_run_keeps_change_lists_empty_without_scan_requests( + socket_api: SocketApiFixture, +) -> None: + core = _core(socket_api) + request_count_after_setup = len(socket_api.requests) + + result = core.create_new_diff( + [], FullScanParams(repo="fixture-repo"), no_change=True + ) + report = build_socket_json_report( + result, + CliConfig(api_token="fictional-local-only", repo="fixture-repo"), + ) + + assert len(socket_api.requests) == request_count_after_setup + assert result.id == "NO_DIFF_RAN" + assert report["full_scan_id"] == "NO_DIFF_RAN" + assert report["new_packages"] == [] + assert report["updated_packages"] == [] + assert report["removed_packages"] == [] + assert report["replaced_packages"] == [] + + +def test_full_scan_keeps_change_lists_empty( + socket_api: SocketApiFixture, + tmp_path, +) -> None: + core = _core(socket_api) + socket_api.route( + "POST", + "orgs/fixture-org/full-scans", + SocketResponse( + 201, + { + "id": "inventory-scan-id", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "organization_id": "org-fixture", + "repository_id": "repo-fixture", + "branch": "main", + "html_report_url": "https://socket.dev/scan/inventory-scan-id", + }, + ), + ) + manifest = tmp_path / "requirements.txt" + manifest.write_text("fictional-package==1.0.0\n") + + result = core.create_full_scan_with_report_url( + [str(tmp_path)], + FullScanParams(repo="fixture-repo", org_slug="fixture-org", branch="main"), + explicit_files=[str(manifest)], + ) + report = build_socket_json_report( + result, + CliConfig(api_token="fictional-local-only", repo="fixture-repo"), + ) + + assert result.id == "inventory-scan-id" + assert report["new_scan_id"] == "inventory-scan-id" + assert report["new_packages"] == [] + assert report["updated_packages"] == [] + assert report["removed_packages"] == [] + assert report["replaced_packages"] == [] diff --git a/tests/unit/test_report_output.py b/tests/unit/test_report_output.py index d50277a..d07b349 100644 --- a/tests/unit/test_report_output.py +++ b/tests/unit/test_report_output.py @@ -2,7 +2,7 @@ import json from socketsecurity.config import CliConfig -from socketsecurity.core.classes import Diff, Issue +from socketsecurity.core.classes import Diff, Issue, Package, Purl from socketsecurity.core.report_output import ( build_console_summary, build_report_references, @@ -50,6 +50,38 @@ def _issue( ) +def _purl( + package_id: str, + name: str, + version: str, + ecosystem: str, + *, + namespace: str = "", + scores: dict[str, int | float | None] | None = None, + direct: bool = True, + introduced_by: list[tuple[str, str]] | None = None, + capabilities: list[str] | None = None, + is_new: bool = False, +) -> Purl: + package_path = f"{namespace}/{name}" if namespace else name + return Purl( + id=package_id, + name=name, + version=version, + ecosystem=ecosystem, + direct=direct, + author=["Fictional Maintainer"], + size=23, + transitives=4, + introduced_by=introduced_by or [("fictional-root", "requirements.txt")], + capabilities=capabilities or [], + is_new=is_new, + url=f"https://socket.dev/{ecosystem}/package/{package_path}/overview/{version}", + purl=f"{package_path}@{version}", + scores=scores, + ) + + def _diff( *, new_alerts: list[Issue] | None = None, @@ -259,6 +291,10 @@ def test_default_socket_json_key_set_and_metadata_remain_unchanged(): "new_scan_id", "old_scan_url", "new_scan_url", + "new_packages", + "updated_packages", + "removed_packages", + "replaced_packages", } assert report == { "scan_failed": False, @@ -275,6 +311,10 @@ def test_default_socket_json_key_set_and_metadata_remain_unchanged(): "new_scan_id": "full-123", "old_scan_url": None, "new_scan_url": None, + "new_packages": [], + "updated_packages": [], + "removed_packages": [], + "replaced_packages": [], } @@ -305,9 +345,24 @@ def test_scan_references_are_distinct_nullable_and_preserve_input(): "new_scan_id": "full-123", "old_scan_url": "https://reports.example.test/before?x=1#base", "new_scan_url": "https://reports.example.test/after?x=2#new", + "new_packages": [], + "updated_packages": [], + "removed_packages": [], + "replaced_packages": [], } assert json.loads(json.dumps(report))["old_scan_id"] == "baseline-456" - assert diff.__dict__ == original + for field, value in original.items(): + if field in { + "new_packages", + "updated_packages", + "removed_packages", + "replaced_packages", + }: + assert [package.__dict__ for package in getattr(diff, field)] == [ + package.__dict__ for package in value + ] + else: + assert getattr(diff, field) == value def test_missing_references_remain_null_without_url_inference(): @@ -351,3 +406,268 @@ def test_missing_reference_fields_keep_existing_json_defaults(): assert report["full_scan_id"] == "" assert report["diff_url"] == "" assert report["report_url"] is None + assert report["new_packages"] == [] + assert report["updated_packages"] == [] + assert report["removed_packages"] == [] + assert report["replaced_packages"] == [] + + +def test_package_change_lists_preserve_exact_public_fields_and_scores(): + added = _purl( + "added-id", + "added-package", + "1.0.0", + "npm", + scores={"supplyChain": 0, "quality": 0.25, "license": None}, + capabilities=["network"], + is_new=True, + ) + updated = _purl( + "updated-id", + "changed-package", + "2.0.0", + "pypi", + scores={"overall": 1, "maintenance": 0.5}, + ) + removed = _purl( + "removed-id", + "removed-package", + "3.0.0", + "cargo", + scores={"vulnerability": 0, "quality": 1, "overall": None}, + ) + replaced = _purl( + "replaced-id", + "changed-package", + "1.0.0", + "pypi", + scores={"license": 0.75}, + ) + diff = _diff() + diff.new_packages = [added] + diff.updated_packages = [updated] + diff.removed_packages = [removed] + diff.replaced_packages = [replaced] + original = copy.deepcopy(diff.__dict__) + + report = build_socket_json_report(diff, _config()) + + assert set(report) == { + "scan_failed", + "new_alerts", + "full_scan_id", + "diff_url", + "report_url", + "repo", + "branch", + "commit_sha", + "legal_mode", + "diff_scan_id", + "old_scan_id", + "new_scan_id", + "old_scan_url", + "new_scan_url", + "new_packages", + "updated_packages", + "removed_packages", + "replaced_packages", + } + expected_row_keys = { + "id", + "name", + "version", + "ecosystem", + "direct", + "author", + "size", + "transitives", + "introduced_by", + "capabilities", + "is_new", + "author_url", + "url", + "purl", + "scores", + } + expected = { + "new_packages": { + "id": "added-id", + "name": "added-package", + "version": "1.0.0", + "ecosystem": "npm", + "direct": True, + "author": ["Fictional Maintainer"], + "size": 23, + "transitives": 4, + "introduced_by": [("fictional-root", "requirements.txt")], + "capabilities": ["network"], + "is_new": True, + "author_url": "[Fictional Maintainer](https://socket.dev/npm/user/Fictional Maintainer)", + "url": "https://socket.dev/npm/package/added-package/overview/1.0.0", + "purl": "added-package@1.0.0", + "scores": {"supplyChain": 0, "quality": 0.25, "license": None}, + }, + "updated_packages": { + "id": "updated-id", + "name": "changed-package", + "version": "2.0.0", + "ecosystem": "pypi", + "direct": True, + "author": ["Fictional Maintainer"], + "size": 23, + "transitives": 4, + "introduced_by": [("fictional-root", "requirements.txt")], + "capabilities": [], + "is_new": False, + "author_url": "[Fictional Maintainer](https://socket.dev/pypi/user/Fictional Maintainer)", + "url": "https://socket.dev/pypi/package/changed-package/overview/2.0.0", + "purl": "changed-package@2.0.0", + "scores": {"overall": 1, "maintenance": 0.5}, + }, + "removed_packages": { + "id": "removed-id", + "name": "removed-package", + "version": "3.0.0", + "ecosystem": "cargo", + "direct": True, + "author": ["Fictional Maintainer"], + "size": 23, + "transitives": 4, + "introduced_by": [("fictional-root", "requirements.txt")], + "capabilities": [], + "is_new": False, + "author_url": "[Fictional Maintainer](https://socket.dev/cargo/user/Fictional Maintainer)", + "url": "https://socket.dev/cargo/package/removed-package/overview/3.0.0", + "purl": "removed-package@3.0.0", + "scores": {"vulnerability": 0, "quality": 1, "overall": None}, + }, + "replaced_packages": { + "id": "replaced-id", + "name": "changed-package", + "version": "1.0.0", + "ecosystem": "pypi", + "direct": True, + "author": ["Fictional Maintainer"], + "size": 23, + "transitives": 4, + "introduced_by": [("fictional-root", "requirements.txt")], + "capabilities": [], + "is_new": False, + "author_url": "[Fictional Maintainer](https://socket.dev/pypi/user/Fictional Maintainer)", + "url": "https://socket.dev/pypi/package/changed-package/overview/1.0.0", + "purl": "changed-package@1.0.0", + "scores": {"license": 0.75}, + }, + } + for field, expected_row in expected.items(): + rows = report[field] + assert isinstance(rows, list) + assert len(rows) == 1 + row = rows[0] + assert isinstance(row, dict) + assert set(row) == expected_row_keys + assert row == expected_row + + serialized = json.loads(json.dumps(report)) + assert serialized["new_packages"][0]["introduced_by"] == [ + ["fictional-root", "requirements.txt"] + ] + assert serialized["new_packages"][0]["scores"] == { + "supplyChain": 0, + "quality": 0.25, + "license": None, + } + report["new_packages"][0]["scores"]["quality"] = 99 + report["new_packages"][0]["author"].append("Changed") + report["new_packages"][0]["capabilities"].append("filesystem") + report["new_packages"][0]["introduced_by"].append(("other", "other.txt")) + assert added.scores == {"supplyChain": 0, "quality": 0.25, "license": None} + assert added.author == ["Fictional Maintainer"] + assert added.capabilities == ["network"] + assert added.introduced_by == [("fictional-root", "requirements.txt")] + for field, value in original.items(): + if field in { + "new_packages", + "updated_packages", + "removed_packages", + "replaced_packages", + }: + assert [package.__dict__ for package in getattr(diff, field)] == [ + package.__dict__ for package in value + ] + else: + assert getattr(diff, field) == value + + +def test_package_change_lists_keep_order_multiplicity_and_missing_scores(): + first = _purl("first", "shared", "1.0.0", "npm", namespace="@one") + second = _purl("second", "shared", "1.0.0", "npm", namespace="@two") + another = _purl("third", "shared", "2.0.0", "npm", namespace="@one") + no_scores = _purl("no-scores", "bare", "1.0.0", "pypi", scores=None) + diff = _diff() + diff.new_packages = [first, second, first, another] + diff.updated_packages = [no_scores] + + report = build_socket_json_report(diff, _config()) + + assert [row["id"] for row in report["new_packages"]] == [ + "first", + "second", + "first", + "third", + ] + assert [row["purl"] for row in report["new_packages"]] == [ + "@one/shared@1.0.0", + "@two/shared@1.0.0", + "@one/shared@1.0.0", + "@one/shared@2.0.0", + ] + assert report["updated_packages"][0]["scores"] == {} + + +def test_package_change_lists_remain_independent_of_alert_selection(): + added = _purl("added", "dependency", "1.0.0", "npm") + unchanged = _issue("Existing blocking alert", error=True) + diff = _diff(new_alerts=[], unchanged_alerts=[unchanged]) + diff.new_packages = [added] + diff.updated_packages = [_purl("updated", "dependency", "2.0.0", "npm")] + diff.removed_packages = [_purl("removed", "dependency", "0.9.0", "npm")] + diff.replaced_packages = [_purl("replaced", "dependency", "0.8.0", "npm")] + + ordinary = build_socket_json_report(diff, _config()) + strict = build_socket_json_report(diff, _config(strict_blocking=True)) + + for field in ( + "new_packages", + "updated_packages", + "removed_packages", + "replaced_packages", + ): + assert ordinary[field] == strict[field] + assert ordinary["new_alerts"] == [] + assert strict["new_alerts"][0]["title"] == "Existing blocking alert" + assert ordinary["scan_failed"] is False + assert strict["scan_failed"] is True + + +def test_full_scan_package_inventory_does_not_become_change_lists(): + diff = _diff(is_full_scan=True) + inventory_package = Package.from_socket_artifact( + { + "id": "inventory-id", + "type": "npm", + "name": "inventory-package", + "version": "1.0.0", + "alerts": [], + "score": {}, + "topLevelAncestors": [], + } + ) + diff.packages = {"inventory-only": inventory_package} + + report = build_socket_json_report(diff, _config()) + + assert report["new_packages"] == [] + assert report["updated_packages"] == [] + assert report["removed_packages"] == [] + assert report["replaced_packages"] == []