Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/manus_agent/tools/get_dependency_blast_radius.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
5. PyPI JSON API — package metadata (PyPI only; download counts from
pypistats.org with graceful degradation on 429)
6. Maven Central — artifact metadata + version count (Maven only)
7. Go module proxy — latest version + release timestamp (Go only)
8. deps.dev API — cross-ecosystem version history + first-release date
(Go, crates.io, and others)

The tool deliberately avoids paid/rate-limited APIs so it works without
configuration. When a source is unavailable the result degrades gracefully.
Expand All @@ -26,6 +29,8 @@

import logging
import re
import urllib.parse
from datetime import datetime, timezone
from typing import Any

import requests
Expand All @@ -51,6 +56,8 @@
_PYPI_JSON_URL = "https://pypi.org/pypi/{}/json"
_PYPISTATS_URL = "https://pypistats.org/api/packages/{}/recent"
_MAVEN_SEARCH_URL = "https://search.maven.org/solrsearch/select"
_GO_PROXY_LATEST_URL = "https://proxy.golang.org/{}/@latest"
_DEPS_DEV_PKG_URL = "https://api.deps.dev/v3alpha/systems/GO/packages/{}"

_TIMEOUT = 20

Expand Down Expand Up @@ -373,6 +380,66 @@ def _enrich_maven(name: str) -> dict[str, Any]:
return result


def _parse_go_timestamp(ts: str) -> datetime | None:
"""Parse a Go module proxy / deps.dev ISO-8601 timestamp robustly."""
if not ts:
return None
# Strip trailing Z or +00:00 offset
ts = ts.rstrip("Z")
if ts.endswith("+00:00"):
ts = ts[:-6]
# Truncate sub-second part to 6 digits (Python limit)
dot = ts.find(".")
if dot != -1:
ts = ts[: dot + 7] # keep at most 6 fractional digits
try:
return datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
except ValueError:
return None


def _enrich_go(name: str) -> dict[str, Any]:
"""Fetch Go module metadata from the Go module proxy and deps.dev."""
result: dict[str, Any] = {"ecosystem": "Go", "package_name": name}
# URL-encode the module path for both APIs (slashes → %2F)
encoded = urllib.parse.quote(name, safe="")

# --- Call 1: Go module proxy @latest (latest version + timestamp) ---
try:
proxy_data = _get(_GO_PROXY_LATEST_URL.format(encoded))
result["latest_version"] = proxy_data.get("Version", "")
ts = proxy_data.get("Time", "")
if ts:
dt = _parse_go_timestamp(ts)
if dt:
result["latest_release_date"] = dt.strftime("%Y-%m-%d")
except Exception as exc:
logger.debug("Go proxy latest failed for %s: %s", name, exc)

# --- Call 2: deps.dev package endpoint (version list + first release) ---
try:
deps_data = _get(_DEPS_DEV_PKG_URL.format(encoded))
versions = deps_data.get("versions", [])
result["total_versions"] = len(versions)

# Extract first-release date from the earliest publishedAt timestamp
timestamps: list[datetime] = []
for v in versions:
dt = _parse_go_timestamp(v.get("publishedAt", ""))
if dt:
timestamps.append(dt)
if timestamps:
first_dt = min(timestamps)
result["first_release_date"] = first_dt.strftime("%Y-%m-%d")
now = datetime.now(tz=timezone.utc)
age_years = (now - first_dt).days / 365.25
result["age_years"] = round(age_years, 1)
except Exception as exc:
logger.debug("deps.dev enrich failed for %s: %s", name, exc)

return result


def _enrich_package(name: str, ecosystem: str) -> dict[str, Any]:
"""Dispatch to the right enrichment function based on ecosystem."""
eco_lower = (ecosystem or "").lower()
Expand All @@ -382,6 +449,8 @@ def _enrich_package(name: str, ecosystem: str) -> dict[str, Any]:
return _enrich_pypi(name)
elif eco_lower in ("maven", "java", "gradle"):
return _enrich_maven(name)
elif eco_lower in ("go", "golang"):
return _enrich_go(name)
# Unknown ecosystem — return minimal record
return {"ecosystem": ecosystem, "package_name": name}

Expand Down Expand Up @@ -433,6 +502,7 @@ def get_dependency_blast_radius( # noqa: C901
- **npm**: dependent package count + weekly/monthly download stats
- **PyPI**: package metadata + download stats (when pypistats is available)
- **Maven**: artifact metadata from Maven Central
- **Go**: latest version + release date + version history (via Go module proxy and deps.dev)
3. Computes a qualitative blast-radius label: CRITICAL / HIGH / MEDIUM / LOW.

Use after ``get_nvd_data`` to understand *how many projects are exposed*
Expand Down Expand Up @@ -559,6 +629,18 @@ def get_dependency_blast_radius( # noqa: C901
if r.get("version_count"):
lines.append(f" Version count: {r['version_count']}")

# Go-specific stats
if eco.lower() in ("go", "golang"):
if r.get("latest_version"):
lines.append(f" Latest version: {r['latest_version']}")
if r.get("total_versions"):
lines.append(f" Total versions: {r['total_versions']}")
if r.get("first_release_date"):
age = f" ({r['age_years']} yrs old)" if r.get("age_years") else ""
lines.append(f" First released: {r['first_release_date']}{age}")
if r.get("latest_release_date"):
lines.append(f" Latest release: {r['latest_release_date']}")

if source:
lines.append(f" Data sources: {source}")

Expand Down
197 changes: 197 additions & 0 deletions tests/test_dependency_blast_radius.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@

from manus_agent.tools.get_dependency_blast_radius import (
_blast_score,
_enrich_go,
_enrich_maven,
_enrich_npm,
_enrich_package,
_enrich_pypi,
_fetch_ghsa_affected,
_fetch_nvd_affected,
_fetch_osv_affected,
_parse_go_timestamp,
_parse_input,
_summarise_osv_ranges,
get_dependency_blast_radius,
Expand Down Expand Up @@ -672,12 +674,180 @@ def test_maven_ecosystem(self):
_enrich_package("log4j-core", "Maven")
mock_maven.assert_called_once_with("log4j-core")

def test_go_ecosystem(self):
with patch("manus_agent.tools.get_dependency_blast_radius._enrich_go") as mock_go:
mock_go.return_value = {"ecosystem": "Go", "package_name": "golang.org/x/net"}
_enrich_package("golang.org/x/net", "Go")
mock_go.assert_called_once_with("golang.org/x/net")

def test_golang_alias_routes_to_go(self):
with patch("manus_agent.tools.get_dependency_blast_radius._enrich_go") as mock_go:
mock_go.return_value = {"ecosystem": "Go", "package_name": "github.com/gin-gonic/gin"}
_enrich_package("github.com/gin-gonic/gin", "golang")
mock_go.assert_called_once_with("github.com/gin-gonic/gin")

def test_unknown_ecosystem_returns_minimal_record(self):
result = _enrich_package("unknown-pkg", "SomeExoticEcosystem")
assert result["ecosystem"] == "SomeExoticEcosystem"
assert result["package_name"] == "unknown-pkg"


# ===========================================================================
# _parse_go_timestamp
# ===========================================================================


class TestParseGoTimestamp:
def test_parses_z_suffix(self):
dt = _parse_go_timestamp("2022-10-19T15:28:41Z")
assert dt is not None
assert dt.year == 2022
assert dt.month == 10
assert dt.day == 19

def test_parses_utc_offset(self):
dt = _parse_go_timestamp("2020-01-15T00:00:00+00:00")
assert dt is not None
assert dt.year == 2020

def test_parses_naive_datetime(self):
dt = _parse_go_timestamp("2018-03-04T12:30:00")
assert dt is not None
assert dt.year == 2018

def test_parses_with_microseconds(self):
dt = _parse_go_timestamp("2021-06-01T08:00:00.123456Z")
assert dt is not None
assert dt.year == 2021

def test_parses_with_fractional_seconds_truncated(self):
# .NET-style 7-digit ticks should be truncated to 6
dt = _parse_go_timestamp("2019-11-11T10:10:10.1234567Z")
assert dt is not None
assert dt.year == 2019

def test_empty_string_returns_none(self):
assert _parse_go_timestamp("") is None

def test_invalid_returns_none(self):
assert _parse_go_timestamp("not-a-date") is None


# ===========================================================================
# _enrich_go
# ===========================================================================


class TestEnrichGo:
def _proxy_latest(self, version: str, time: str) -> dict:
return {"Version": version, "Time": time}

def _deps_dev_response(self, versions: list[dict]) -> dict:
return {
"packageKey": {"system": "GO", "name": "golang.org/x/net"},
"versions": versions,
}

def _make_version_entry(self, version: str, published_at: str, is_default: bool = False) -> dict:
return {
"versionKey": {"system": "GO", "name": "golang.org/x/net", "version": version},
"publishedAt": published_at,
"isDefault": is_default,
}

def test_returns_latest_version_and_dates(self):
proxy_resp = MagicMock()
proxy_resp.json.return_value = self._proxy_latest("v0.20.0", "2024-03-05T14:00:00Z")
deps_versions = [
self._make_version_entry("v0.1.0", "2022-10-19T15:28:41Z"),
self._make_version_entry("v0.20.0", "2024-03-05T14:00:00Z", is_default=True),
]
deps_resp = MagicMock()
deps_resp.json.return_value = self._deps_dev_response(deps_versions)
with patch("requests.get", side_effect=[proxy_resp, deps_resp]):
result = _enrich_go("golang.org/x/net")
assert result["ecosystem"] == "Go"
assert result["package_name"] == "golang.org/x/net"
assert result["latest_version"] == "v0.20.0"
assert result["latest_release_date"] == "2024-03-05"
assert result["first_release_date"] == "2022-10-19"
assert result["total_versions"] == 2
assert result["age_years"] > 0

def test_url_encodes_module_path(self):
"""Slashes in module path must be percent-encoded in the API URL."""
proxy_resp = MagicMock()
proxy_resp.json.return_value = self._proxy_latest("v1.9.4", "2023-11-01T00:00:00Z")
deps_resp = MagicMock()
deps_resp.json.return_value = self._deps_dev_response([])
with patch("requests.get", side_effect=[proxy_resp, deps_resp]) as mock_get:
_enrich_go("github.com/gorilla/mux")
# First call is Go proxy; second is deps.dev — both must not contain raw slashes in the path segment
first_url = mock_get.call_args_list[0][0][0]
assert "github.com%2Fgorilla%2Fmux" in first_url or "github.com/gorilla/mux" not in first_url.split(
"proxy.golang.org/", 1
)[-1].split("/@latest")[0].replace("%2F", "/").replace("/", "")

def test_proxy_failure_still_returns_deps_data(self):
"""If Go proxy fails, we still get version info from deps.dev."""
proxy_resp = MagicMock()
proxy_resp.raise_for_status.side_effect = Exception("503 Service Unavailable")
deps_versions = [self._make_version_entry("v1.0.0", "2020-05-01T00:00:00Z")]
deps_resp = MagicMock()
deps_resp.json.return_value = self._deps_dev_response(deps_versions)
with patch("requests.get", side_effect=[proxy_resp, deps_resp]):
result = _enrich_go("github.com/gorilla/mux")
assert result["ecosystem"] == "Go"
assert result["first_release_date"] == "2020-05-01"
assert "latest_version" not in result

def test_deps_dev_failure_still_returns_proxy_data(self):
"""If deps.dev fails, we still get latest version from proxy."""
proxy_resp = MagicMock()
proxy_resp.json.return_value = self._proxy_latest("v1.5.0", "2023-01-15T00:00:00Z")
deps_resp = MagicMock()
deps_resp.raise_for_status.side_effect = Exception("429 Too Many Requests")
with patch("requests.get", side_effect=[proxy_resp, deps_resp]):
result = _enrich_go("golang.org/x/crypto")
assert result["latest_version"] == "v1.5.0"
assert result["latest_release_date"] == "2023-01-15"
assert "total_versions" not in result

def test_both_calls_fail_returns_minimal_record(self):
with patch("requests.get", side_effect=Exception("network error")):
result = _enrich_go("golang.org/x/sys")
assert result["ecosystem"] == "Go"
assert result["package_name"] == "golang.org/x/sys"
assert "latest_version" not in result
assert "first_release_date" not in result

def test_empty_versions_list_no_crash(self):
proxy_resp = MagicMock()
proxy_resp.json.return_value = self._proxy_latest("v0.1.0", "2023-06-01T00:00:00Z")
deps_resp = MagicMock()
deps_resp.json.return_value = self._deps_dev_response([])
with patch("requests.get", side_effect=[proxy_resp, deps_resp]):
result = _enrich_go("github.com/some/newmodule")
assert result["total_versions"] == 0
assert "first_release_date" not in result
assert "age_years" not in result

def test_versions_missing_publishedat_skipped(self):
"""Versions without publishedAt timestamps are skipped gracefully."""
proxy_resp = MagicMock()
proxy_resp.json.return_value = self._proxy_latest("v2.0.0", "2024-01-01T00:00:00Z")
deps_versions = [
{"versionKey": {"version": "v1.0.0"}, "publishedAt": ""},
self._make_version_entry("v2.0.0", "2024-01-01T00:00:00Z"),
]
deps_resp = MagicMock()
deps_resp.json.return_value = self._deps_dev_response(deps_versions)
with patch("requests.get", side_effect=[proxy_resp, deps_resp]):
result = _enrich_go("github.com/example/lib")
# Only v2.0.0 contributes a valid timestamp; first == latest
assert result["first_release_date"] == "2024-01-01"


# ===========================================================================
# get_dependency_blast_radius (integration tests with full stack mocked)
# ===========================================================================
Expand Down Expand Up @@ -887,6 +1057,33 @@ def test_ecosystem_label_in_output(self):
result = get_dependency_blast_radius("CVE-2023-32681")
assert "Python" in result or "PyPI" in result

def test_go_package_output_shows_version_and_dates(self):
"""Go enrichment fields appear correctly in rendered output."""
pkgs = [{"name": "golang.org/x/net", "ecosystem": "Go", "version_range": "< 0.17.0", "source": "osv"}]
go_stats = {
"ecosystem": "Go",
"package_name": "golang.org/x/net",
"latest_version": "v0.20.0",
"latest_release_date": "2024-03-05",
"first_release_date": "2022-10-19",
"age_years": 1.4,
"total_versions": 42,
}
with (
patch("manus_agent.tools.get_dependency_blast_radius._fetch_nvd_affected", return_value=[]),
patch("manus_agent.tools.get_dependency_blast_radius._fetch_osv_affected", return_value=pkgs),
patch("manus_agent.tools.get_dependency_blast_radius._fetch_ghsa_affected", return_value=[]),
patch(
"manus_agent.tools.get_dependency_blast_radius._enrich_package",
return_value=go_stats,
),
):
result = get_dependency_blast_radius("CVE-2023-44487")
assert "golang.org/x/net" in result
assert "v0.20.0" in result
assert "2022-10-19" in result
assert "42" in result


# ===========================================================================
# CLI integration: _build_blast_radius_parser
Expand Down
Loading