diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index cecc66f..da08c04 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -2755,7 +2755,12 @@ def get_source_data(package: Package, packages: dict) -> list: top_package = packages.get(top_id) if top_package: manifests = "" - top_purl = f"{top_package.type}/{top_package.name}@{top_package.version}" + ancestor_name = ( + f"{top_package.namespace}/{top_package.name}" + if top_package.namespace + else top_package.name + ) + top_purl = f"{top_package.type}/{ancestor_name}@{top_package.version}" if hasattr(top_package, "manifestFiles") and top_package.manifestFiles: for manifest_data in top_package.manifestFiles: manifest_file = manifest_data.get("file") diff --git a/tests/core/test_json_package_changes_transport.py b/tests/core/test_json_package_changes_transport.py index e4c27cc..b827483 100644 --- a/tests/core/test_json_package_changes_transport.py +++ b/tests/core/test_json_package_changes_transport.py @@ -12,6 +12,7 @@ from socketsecurity.config import CliConfig from socketsecurity.core import Core from socketsecurity.core.classes import Package +from socketsecurity.core.messages import Messages from socketsecurity.core.report_output import build_socket_json_report from socketsecurity.core.socket_config import SocketConfig from tests.support.socket_api_fixture import ( @@ -62,11 +63,13 @@ def _artifact( ecosystem: str | None = None, score: dict[str, int | float | None] | None = None, parent_id: str | None = None, + ancestor_ids: list[str] | None = None, + alerts: list[dict[str, object]] | None = None, ) -> dict[str, object]: reference: dict[str, object] = { "diffType": change_type, "direct": direct, - "topLevelAncestors": [parent_id] if parent_id else [], + "topLevelAncestors": ancestor_ids or ([parent_id] if parent_id else []), "dependencies": [], "manifestFiles": [{"file": "requirements.txt"}] if direct else [], "artifact": {"id": artifact_id}, @@ -77,7 +80,7 @@ def _artifact( "type": ecosystem or ("npm" if namespace else "pypi"), "name": name, "version": version, - "alerts": [], + "alerts": alerts or [], "licenseDetails": [], "author": ["Fixture Author"], "head" if change_type in ("added", "updated", "unchanged") else "base": [ @@ -401,6 +404,115 @@ def test_package_change_identity_keeps_namespace_and_bucket_boundaries( ] +@pytest.mark.parametrize("streaming_fallback", [False, True]) +def test_transitive_alert_and_package_json_keep_ancestor_namespaces( + socket_api: SocketApiFixture, + streaming_fallback: bool, +) -> None: + core = _core(socket_api) + alert = { + "key": "fixture-env-alert", + "type": "envVars", + "severity": "low", + "category": "supplyChainRisk", + "props": {}, + "action": "warn", + } + artifacts = { + "added": [ + _artifact("root-one", "added", "widget", "1.0.0", namespace="@one"), + _artifact("root-two", "added", "widget", "1.0.0", namespace="@two"), + _artifact( + "child", + "added", + "leaf", + "2.0.0", + direct=False, + ancestor_ids=["root-one", "root-two"], + alerts=[alert], + ), + ], + "updated": [], + "removed": [], + "replaced": [], + "unchanged": [], + } + 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-provenance-fixture"}}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/diff-scans/diff-provenance-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 + ) + request_count = len(socket_api.requests) + expected_sources = [ + ["npm/@one/widget@1.0.0", "requirements.txt"], + ["npm/@two/widget@1.0.0", "requirements.txt"], + ] + alert_diff = core.create_diff_report( + comparison.added_packages, comparison.removed_packages, {} + ) + assert [package.id for package in alert_diff.new_packages] == [ + "root-one", + "root-two", + ] + assert alert_diff.new_alerts[0].introduced_by == [ + tuple(row) for row in expected_sources + ] + _, source_text = Messages.create_sources(alert_diff.new_alerts[0], "raw") + assert source_text == "npm/@one/widget@1.0.0;npm/@two/widget@1.0.0" + alert_report = build_socket_json_report( + alert_diff, CliConfig(api_token="fictional-local-only", repo="fixture-repo") + ) + assert alert_report["new_alerts"][0]["introduced_by"] == expected_sources + assert alert_report["scan_failed"] is False + + full_diff = core.create_diff_report( + comparison.added_packages, + comparison.removed_packages, + {}, + direct_only=False, + ) + child_purl = next( + package for package in full_diff.new_packages if package.id == "child" + ) + assert child_purl.introduced_by == [tuple(row) for row in expected_sources] + full_report = build_socket_json_report( + full_diff, CliConfig(api_token="fictional-local-only", repo="fixture-repo") + ) + child_row = next(row for row in full_report["new_packages"] if row["id"] == "child") + assert child_row["introduced_by"] == [tuple(row) for row in expected_sources] + assert len(socket_api.requests) == request_count + + def test_package_change_identity_treats_missing_and_empty_namespace_equally( socket_api: SocketApiFixture, ) -> None: diff --git a/tests/unit/test_package_provenance.py b/tests/unit/test_package_provenance.py new file mode 100644 index 0000000..833a5b1 --- /dev/null +++ b/tests/unit/test_package_provenance.py @@ -0,0 +1,179 @@ +import copy +import json + +from socketsecurity.config import CliConfig +from socketsecurity.core import Core +from socketsecurity.core.classes import Diff, Issue, Package +from socketsecurity.core.messages import Messages +from socketsecurity.core.report_output import build_socket_json_report + + +def _package( + package_id: str, + *, + name: str = "widget", + version: str = "1.0.0", + ecosystem: str = "npm", + namespace: str | None = None, + direct: bool = False, + ancestors: list[str] | None = None, + manifests: list[dict[str, str]] | None = None, + purl: str = "", +) -> Package: + package = Package( + type=ecosystem, + namespace=namespace, + name=name, + version=version, + id=package_id, + score={}, + alerts=[], + direct=direct, + topLevelAncestors=ancestors, + manifestFiles=manifests, + purl=purl, + ) + return package + + +def _issue(introduced_by: list[tuple[str, str]]) -> Issue: + return Issue( + pkg_type="npm", + pkg_name="leaf", + pkg_version="1.0.0", + category="supplyChainRisk", + type="envVars", + severity="low", + pkg_id="npm/leaf@1.0.0", + props={}, + key="fixture-alert", + error=False, + warn=True, + ignore=False, + monitor=False, + description="Fixture alert", + title="Fixture alert", + emoji="!", + next_step_title="Review package", + suggestion="", + introduced_by=introduced_by, + purl="pkg:npm/leaf@1.0.0", + url="https://socket.dev/npm/package/leaf/alerts/1.0.0", + ) + + +def test_source_data_preserves_scopes_and_maven_groups_and_consumers(): + first = _package("first", namespace="@one", manifests=[{"file": "package.json"}]) + second = _package("second", namespace="@two", manifests=[{"file": "package.json"}]) + maven = _package( + "maven", + ecosystem="maven", + namespace="com.one", + manifests=[{"file": "pom.xml"}], + ) + child = _package("child", ancestors=["first", "second", "maven"]) + packages = {package.id: package for package in (first, second, maven, child)} + before = copy.deepcopy(packages) + + sources = Core.get_source_data(child, packages) + + assert sources == [ + ("npm/@one/widget@1.0.0", "package.json"), + ("npm/@two/widget@1.0.0", "package.json"), + ("maven/com.one/widget@1.0.0", "pom.xml"), + ] + assert packages == before + issue = _issue(sources[:2]) + _, raw = Messages.create_sources(issue, "raw") + _, console = Messages.create_sources(issue, "console") + _, markdown = Messages.create_sources(issue, "md") + assert raw == "npm/@one/widget@1.0.0;npm/@two/widget@1.0.0" + assert console == raw + assert "npm/@one/widget@1.0.0" in markdown + assert "npm/@two/widget@1.0.0" in markdown + + report = build_socket_json_report( + Diff(new_alerts=[issue]), CliConfig(api_token="", repo="fixture") + ) + serialized = json.loads(json.dumps(report)) + assert serialized["new_alerts"][0]["introduced_by"] == [ + ["npm/@one/widget@1.0.0", "package.json"], + ["npm/@two/widget@1.0.0", "package.json"], + ] + assert len(serialized) == 18 + + +def test_source_data_preserves_unscoped_direct_missing_and_order_behavior(): + unscoped = _package( + "root", + ecosystem="pypi", + namespace=None, + manifests=[{"file": "requirements.in"}, {"file": "requirements.txt"}], + ) + empty = _package( + "empty", + ecosystem="pypi", + namespace="", + manifests=[{"file": "pyproject.toml"}], + ) + direct = _package( + "direct", + direct=True, + manifests=[{"file": "one.json"}, {"file": "two.json"}], + ) + no_manifest = _package("no-manifest", manifests=[]) + child = _package( + "child", ancestors=["root", "empty", "missing", "root", "no-manifest"] + ) + packages = {item.id: item for item in (unscoped, empty, direct, no_manifest, child)} + + assert Core.get_source_data(child, packages) == [ + ("pypi/widget@1.0.0", "requirements.in;requirements.txt"), + ("pypi/widget@1.0.0", "pyproject.toml"), + ("pypi/widget@1.0.0", "requirements.in;requirements.txt"), + ] + assert Core.get_source_data(direct, packages) == [("direct", "one.json;two.json")] + assert Core.get_source_data(_package("orphan", ancestors=[]), packages) == [] + assert ( + Core.get_source_data(_package("missing", ancestors=["absent"]), packages) == [] + ) + + +def test_source_data_uses_structured_identity_without_mutating_purls_or_traversing(): + artifact = { + "id": "socket", + "type": "npm", + "namespace": "@one", + "name": "widget", + "version": "1.0.0", + "score": {}, + "alerts": [], + "topLevelAncestors": [], + "manifestFiles": [{"file": "package.json"}], + } + socket_package = Package.from_socket_artifact(artifact) + comparison_package = _package( + "comparison", + namespace="@one", + purl="@one/widget@1.0.0", + manifests=[{"file": "package.json"}], + ) + Core.update_package_values(comparison_package) + child = _package("child", ancestors=["socket", "comparison", "socket", "child"]) + socket_package.topLevelAncestors = ["comparison"] + comparison_package.topLevelAncestors = ["socket"] + packages = {item.id: item for item in (socket_package, comparison_package, child)} + original_purls = (socket_package.purl, comparison_package.purl) + original = copy.deepcopy(packages) + + assert Core.get_source_data(child, packages) == [ + ("npm/@one/widget@1.0.0", "package.json"), + ("npm/@one/widget@1.0.0", "package.json"), + ("npm/@one/widget@1.0.0", "package.json"), + ] + assert Core.get_source_data(socket_package, packages) == [ + ("npm/@one/widget@1.0.0", "package.json") + ] + assert socket_package.purl == original_purls[0] + assert comparison_package.purl == original_purls[1] + assert packages == original