Files
hermes-agent/hermes_cli/security_audit.py

445 lines
17 KiB
Python

"""On-demand supply-chain audit for Hermes Agent installs.
Vulnerabilities are looked up against OSV.dev (``api.osv.dev/v1/querybatch`` + ``/v1/vulns/{id}``).
Single-shot, on-demand, never daily — see the design notes in ``references/security-disclosure-
triage.md``.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Optional
from hermes_constants import get_hermes_home
OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch"
OSV_VULN_URL = "https://api.osv.dev/v1/vulns/{vid}"
OSV_BATCH_MAX = 1000 # OSV documented hard cap per request
HTTP_TIMEOUT = 20
DETAIL_PARALLELISM = 8
# Severity ordering for --fail-on gating. UNKNOWN sits below LOW so it
# never blocks unless --fail-on is passed something even lower (we don't
# expose that).
SEVERITY_ORDER = {
"UNKNOWN": 0,
"LOW": 1,
"MODERATE": 2,
"MEDIUM": 2,
"HIGH": 3,
"CRITICAL": 4,
}
# ─── Data shapes ──────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Component:
"""A single (name, version, ecosystem) tuple discovered on disk."""
name: str
version: str
ecosystem: str # "PyPI" | "npm" — exactly as OSV expects
source: str # human-readable origin, e.g. "venv", "plugin:foo", "mcp:bar"
@dataclass
class Vulnerability:
osv_id: str
severity: str = "UNKNOWN"
summary: str = ""
fixed_versions: list[str] = field(default_factory=list)
@dataclass
class Finding:
component: Component
vuln: Vulnerability
# ─── Component discovery ──────────────────────────────────────────────────────
def _discover_venv() -> list[Component]:
"""Every dist installed in the running Python's import path."""
from importlib.metadata import distributions
out: list[Component] = []
seen: set[tuple[str, str]] = set()
for dist in distributions():
try:
name = (dist.metadata["Name"] or "").strip()
except Exception:
continue
version = (dist.version or "").strip()
key = (name.lower(), version)
if name and version and key not in seen:
seen.add(key)
out.append(Component(name=name, version=version, ecosystem="PyPI", source="venv"))
return out
# requirements.txt line: drop comments, environment markers, options, extras
_REQ_LINE = re.compile(
r"""^\s*
(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)
(?:\[[^\]]+\])? # extras
\s*==\s*
(?P<version>[A-Za-z0-9._+!-]+)
\s*(?:;.*)?$
""",
re.VERBOSE,
)
def _match_pins(specs: Iterable[str]) -> list[tuple[str, str]]:
"""``name==version`` pairs for every spec that is an exact pin; all others are skipped."""
return [(m.group("name"), m.group("version")) for spec in specs if (m := _REQ_LINE.match(spec))]
def _parse_requirements(text: str) -> list[tuple[str, str]]:
"""Extract ``name==version`` pins. Everything else (>=, ~=, no pin) is skipped.
A loose pin can't be mapped to a single OSV query, and getting it wrong is worse than missing a
finding for an audit tool — false positives train users to ignore output.
"""
lines = (raw.strip() for raw in text.splitlines())
return _match_pins(line for line in lines if line and not line.startswith(("#", "-")))
def _parse_pyproject_pins(text: str) -> list[tuple[str, str]]:
"""Pull ``name==version`` pins from a ``pyproject.toml`` ``dependencies`` list."""
try:
import tomllib
except ImportError: # pragma: no cover - 3.10 only
return []
try:
data = tomllib.loads(text)
except Exception:
return []
project = data.get("project") or {}
optional = project.get("optional-dependencies") or {}
groups = [project.get("dependencies")] + (list(optional.values()) if isinstance(optional, dict) else [])
return _match_pins(str(x) for group in groups if isinstance(group, list) for x in group)
_PLUGIN_PIN_FILES = (
("requirements.txt", _parse_requirements),
("requirements-dev.txt", _parse_requirements),
("pyproject.toml", _parse_pyproject_pins),
)
def _discover_plugins(hermes_home: Path) -> list[Component]:
"""Python deps declared by plugins under ``~/.hermes/plugins``.
Plugins typically don't install into the venv (they're directory-based with relative imports),
so their stated requirements are useful audit surface even when the venv scan misses them.
"""
plugins_dir = hermes_home / "plugins"
if not plugins_dir.is_dir():
return []
out: list[Component] = []
for plugin_dir in sorted(plugins_dir.iterdir()):
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
continue
for filename, parse in _PLUGIN_PIN_FILES:
path = plugin_dir / filename
try:
pins = parse(path.read_text(encoding="utf-8", errors="replace")) if path.is_file() else []
except OSError:
continue
out.extend(Component(name=n, version=v, ecosystem="PyPI", source=f"plugin:{plugin_dir.name}") for n, v in pins)
return out
# npx forms we recognise:
# npx -y @scope/pkg@1.2.3
# npx --yes pkg@1.2.3
# npx pkg@1.2.3 [...args]
# We deliberately don't try to resolve unversioned names — that maps to
# "latest" at runtime and isn't a stable audit subject.
_NPX_PKG = re.compile(r"^(@[A-Za-z0-9._-]+/[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)@([A-Za-z0-9._+-]+)$")
# uvx forms:
# uvx pkg==1.2.3
# uvx --with pkg==1.2.3 entrypoint
_UVX_PKG = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9._+!-]+)$")
# launcher basename -> (package-ref regex, OSV ecosystem)
_MCP_LAUNCHERS = {"npx": (_NPX_PKG, "npm"), "uvx": (_UVX_PKG, "PyPI")}
def _extract_mcp_component(server_name: str, command: str, args: list[str]) -> Optional[Component]:
"""Best-effort: parse `command/args` into a (name, version, ecosystem).
Returns None when the entry doesn't pin an auditable version (local paths, Docker images,
unversioned npx, ...); the audit stays silent rather than guess.
"""
cmd = (command or "").strip().lower()
launcher = next((k for k in _MCP_LAUNCHERS if cmd.endswith(k)), None) # any prefix path
# Skip flag tokens; the first non-flag token must be a pinned ref or we stay silent.
ref = next((token for token in args if not token.startswith("-")), None)
if launcher is None or ref is None:
return None
pattern, ecosystem = _MCP_LAUNCHERS[launcher]
m = pattern.match(ref)
return m and Component(name=m.group(1), version=m.group(2), ecosystem=ecosystem, source=f"mcp:{server_name}")
def _discover_mcp() -> list[Component]:
"""Pinned MCP server packages from ``config.yaml``."""
try:
from hermes_cli.mcp_config import _get_mcp_servers
except Exception:
return []
servers = _get_mcp_servers()
if not isinstance(servers, dict):
return []
out: list[Component] = []
for name, cfg in servers.items():
if not isinstance(cfg, dict) or not isinstance(cfg.get("args") or [], list):
continue
comp = _extract_mcp_component(name, cfg.get("command", "") or "", [str(a) for a in cfg.get("args") or []])
if comp:
out.append(comp)
return out
# ─── OSV client ───────────────────────────────────────────────────────────────
_HTTP_ERRORS = (urllib.error.URLError, TimeoutError, ConnectionError)
def _http_json(url: str, payload: Optional[dict] = None) -> dict:
"""GET ``url`` (or POST ``payload`` as JSON when given) and decode the JSON body."""
if payload is None:
req = urllib.request.Request(url, method="GET")
else:
req = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST"
)
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
def _osv_query_batch(components: list[Component]) -> dict[Component, list[str]]:
"""Return {component -> [osv_id, ...]} for components with any vulns."""
findings: dict[Component, list[str]] = {}
for chunk_start in range(0, len(components), OSV_BATCH_MAX):
chunk = components[chunk_start:chunk_start + OSV_BATCH_MAX]
payload = {
"queries": [{"package": {"name": c.name, "ecosystem": c.ecosystem}, "version": c.version} for c in chunk]
}
try:
resp = _http_json(OSV_BATCH_URL, payload)
except _HTTP_ERRORS as exc:
raise RuntimeError(f"OSV batch query failed: {exc}") from exc
for comp, result in zip(chunk, resp.get("results") or []):
ids = [v.get("id") for v in (result or {}).get("vulns") or [] if v.get("id")]
if ids:
findings[comp] = ids
return findings
def _osv_severity_from_record(record: dict) -> str:
"""Extract CVSS-derived severity tier from an OSV vuln record."""
# OSV puts CVSS in `severity` (top-level or per-affected) and a
# human-readable bucket in `database_specific.severity` for GHSAs.
# Top-level `severity` holds CVSS vector strings we can't tier without a lib; fall back to
# the per-affected ecosystem_specific bucket after the GHSA database_specific one.
candidates = [(record.get("database_specific") or {}).get("severity")] + [
(entry.get("ecosystem_specific") or {}).get("severity") for entry in record.get("affected") or []
]
for sev in candidates:
if isinstance(sev, str) and sev.strip().upper() in SEVERITY_ORDER:
return sev.strip().upper()
return "UNKNOWN"
def _osv_fixed_versions(record: dict) -> list[str]:
fixes = [
str(event["fixed"])
for entry in record.get("affected") or []
for rng in entry.get("ranges") or []
for event in rng.get("events") or []
if "fixed" in event
]
return list(dict.fromkeys(fixes)) # dedupe, preserve order
def _osv_fetch_details(vuln_ids: Iterable[str]) -> dict[str, Vulnerability]:
"""Fetch summary/severity for each unique vuln id, in parallel."""
unique = sorted({vid for vid in vuln_ids if vid})
if not unique:
return {}
def _fetch_one(vid: str) -> Vulnerability:
try:
rec = _http_json(OSV_VULN_URL.format(vid=vid))
except _HTTP_ERRORS:
return Vulnerability(osv_id=vid)
return Vulnerability(
osv_id=vid,
severity=_osv_severity_from_record(rec),
summary=(rec.get("summary") or "").strip(),
fixed_versions=_osv_fixed_versions(rec),
)
with concurrent.futures.ThreadPoolExecutor(max_workers=DETAIL_PARALLELISM) as pool:
return {vuln.osv_id: vuln for vuln in pool.map(_fetch_one, unique)}
# ─── Orchestration ────────────────────────────────────────────────────────────
def _discover_components(
*,
skip_venv: bool = False,
skip_plugins: bool = False,
skip_mcp: bool = False,
hermes_home: Optional[Path] = None,
) -> list[Component]:
"""Discover all scannable components across the enabled sources."""
home = hermes_home or Path(get_hermes_home())
components: list[Component] = []
if not skip_venv:
components.extend(_discover_venv())
if not skip_plugins:
components.extend(_discover_plugins(home))
if not skip_mcp:
components.extend(_discover_mcp())
return components
def run_audit(
*,
skip_venv: bool = False,
skip_plugins: bool = False,
skip_mcp: bool = False,
hermes_home: Optional[Path] = None,
components: Optional[list[Component]] = None,
) -> list[Finding]:
"""Query OSV for the given (or freshly discovered) components.
``components`` lets callers that already ran discovery (e.g. for a count) reuse it instead
of scanning the venv/plugins/MCP config a second time.
"""
if components is None:
components = _discover_components(
skip_venv=skip_venv, skip_plugins=skip_plugins, skip_mcp=skip_mcp, hermes_home=hermes_home
)
raw = _osv_query_batch(components) if components else {}
if not raw:
return []
details = _osv_fetch_details(vid for ids in raw.values() for vid in ids)
findings = [
Finding(component=comp, vuln=details.get(vid) or Vulnerability(osv_id=vid))
for comp, ids in raw.items()
for vid in ids
]
findings.sort(
key=lambda f: (
-SEVERITY_ORDER.get(f.vuln.severity, 0),
f.component.source,
f.component.name.lower(),
f.vuln.osv_id,
)
)
return findings
# ─── Rendering ────────────────────────────────────────────────────────────────
def _render_human(findings: list[Finding], total_components: int) -> str:
if not findings:
return f"No known vulnerabilities found across {total_components} component(s)."
lines = [f"Found {len(findings)} known vulnerability finding(s) across {total_components} component(s):", ""]
last_source = None
for f in findings:
if f.component.source != last_source:
lines.append(f"[{f.component.source}]")
last_source = f.component.source
lines.append(f" {f.vuln.severity.ljust(8)} {f.component.name}=={f.component.version} {f.vuln.osv_id}")
if summary := f.vuln.summary:
lines.append(f" {summary if len(summary) <= 100 else summary[:97] + '...'}")
if f.vuln.fixed_versions:
lines.append(f" fixed in: {', '.join(f.vuln.fixed_versions[:3])}")
return "\n".join(lines)
def _render_json(findings: list[Finding], total_components: int) -> str:
payload = {
"total_components_scanned": total_components,
"finding_count": len(findings),
"findings": [
{
"package": f.component.name,
"version": f.component.version,
"ecosystem": f.component.ecosystem,
"source": f.component.source,
"vuln_id": f.vuln.osv_id,
"severity": f.vuln.severity,
"summary": f.vuln.summary,
"fixed_versions": f.vuln.fixed_versions,
}
for f in findings
],
}
return json.dumps(payload, indent=2)
# ─── CLI entrypoint ───────────────────────────────────────────────────────────
def cmd_security_audit(args: argparse.Namespace) -> int:
"""Implementation of `hermes security audit`."""
home = Path(get_hermes_home())
skip_venv = bool(getattr(args, "skip_venv", False))
skip_plugins = bool(getattr(args, "skip_plugins", False))
skip_mcp = bool(getattr(args, "skip_mcp", False))
output_json = bool(getattr(args, "json", False))
fail_on = (getattr(args, "fail_on", None) or "critical").upper()
if fail_on not in SEVERITY_ORDER:
print(
f"unknown --fail-on value: {fail_on.lower()} "
f"(choose from: low, moderate, high, critical)",
file=sys.stderr,
)
return 2
components = _discover_components(
skip_venv=skip_venv, skip_plugins=skip_plugins, skip_mcp=skip_mcp, hermes_home=home
)
total = len(components)
if total == 0:
print(
json.dumps({"total_components_scanned": 0, "finding_count": 0, "findings": []})
if output_json
else "No components discovered (everything skipped, or empty environment)."
)
return 0
try:
findings = run_audit(hermes_home=home, components=components)
except RuntimeError as exc:
print(f"audit failed: {exc}", file=sys.stderr)
return 2
print((_render_json if output_json else _render_human)(findings, total))
# Exit code: 1 iff any finding meets or exceeds the --fail-on threshold.
threshold = SEVERITY_ORDER[fail_on]
return int(any(SEVERITY_ORDER.get(f.vuln.severity, 0) >= threshold for f in findings))