# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
313 lines
14 KiB
Python
313 lines
14 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 ``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.
|
|
SEVERITY_ORDER = {"UNKNOWN": 0, "LOW": 1, "MODERATE": 2, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
|
|
|
|
|
@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
|
|
|
|
|
|
def _discover_venv() -> list[Component]:
|
|
"""Every dist installed in the running Python's import path."""
|
|
from importlib.metadata import distributions
|
|
|
|
out: dict[tuple[str, str], Component] = {}
|
|
for dist in distributions():
|
|
try:
|
|
name = (dist.metadata["Name"] or "").strip()
|
|
except Exception:
|
|
continue
|
|
version = (dist.version or "").strip()
|
|
if name and version:
|
|
out.setdefault((name.lower(), version), Component(name=name, version=version, ecosystem="PyPI", source="venv"))
|
|
return list(out.values())
|
|
|
|
|
|
# ``name[extras]==version ; marker`` — an exact pin, optionally with extras and an environment marker.
|
|
_REQ_LINE = re.compile(r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?\s*==\s*(?P<version>[A-Za-z0-9._+!-]+)\s*(?:;.*)?$")
|
|
|
|
|
|
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. Loose specs (>=, ~=, no pin) are skipped: they can't map to
|
|
a single OSV query, and false positives train users to ignore an audit tool's 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
|
|
project = tomllib.loads(text).get("project") or {}
|
|
except Exception:
|
|
return []
|
|
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, so their stated requirements are audit surface the venv scan misses.
|
|
"""
|
|
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-sig", 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
|
|
|
|
|
|
# Recognised pinned refs: ``npx [-y|--yes] [@scope/]pkg@1.2.3`` and ``uvx [--with] pkg==1.2.3``.
|
|
# Unversioned names map to "latest" at runtime and aren'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_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]:
|
|
"""Parse `command/args` into a Component, or None when the entry doesn't pin an auditable
|
|
version (local paths, Docker images, unversioned npx, ...) — stay 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 []
|
|
comps = (
|
|
_extract_mcp_component(name, cfg.get("command", "") or "", [str(a) for a in cfg.get("args") or []])
|
|
for name, cfg in servers.items()
|
|
if isinstance(cfg, dict) and isinstance(cfg.get("args") or [], list)
|
|
)
|
|
return [c for c in comps if c]
|
|
|
|
|
|
_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."""
|
|
req = urllib.request.Request(url, method="GET") if payload is None else urllib.request.Request(
|
|
url, data=json.dumps(payload).encode("utf-8"), method="POST", headers={"Content-Type": "application/json"})
|
|
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 []):
|
|
if ids := [v.get("id") for v in (result or {}).get("vulns") or [] if v.get("id")]:
|
|
findings[comp] = ids
|
|
return findings
|
|
|
|
|
|
def _osv_severity_from_record(record: dict) -> str:
|
|
"""CVSS-derived severity tier from an OSV vuln record.
|
|
|
|
Top-level ``severity`` holds CVSS vector strings we can't tier without a lib, so use the GHSA
|
|
``database_specific`` bucket first, then the per-affected ``ecosystem_specific`` one.
|
|
"""
|
|
candidates = [(record.get("database_specific") or {}).get("severity")] + [
|
|
(entry.get("ecosystem_specific") or {}).get("severity") for entry in record.get("affected") or []]
|
|
tiers = (sev.strip().upper() for sev in candidates if isinstance(sev, str))
|
|
return next((tier for tier in tiers if tier in SEVERITY_ORDER), "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."""
|
|
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(vid, _osv_severity_from_record(rec), (rec.get("summary") or "").strip(), _osv_fixed_versions(rec))
|
|
|
|
unique = sorted({vid for vid in vuln_ids if vid})
|
|
if not unique:
|
|
return {}
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=DETAIL_PARALLELISM) as pool:
|
|
return {vuln.osv_id: vuln for vuln in pool.map(_fetch_one, unique)}
|
|
|
|
|
|
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())
|
|
sources = ((skip_venv, _discover_venv), (skip_plugins, lambda: _discover_plugins(home)), (skip_mcp, _discover_mcp))
|
|
return [c for skip, discover in sources if not skip for c in discover()]
|
|
|
|
|
|
def run_audit(*, components: Optional[list[Component]] = None, **discover_kwargs) -> list[Finding]:
|
|
"""Query OSV for ``components`` (or discover them with ``discover_kwargs`` when None; passing
|
|
an already-discovered list avoids scanning the venv/plugins/MCP config a second time).
|
|
"""
|
|
if components is None:
|
|
components = _discover_components(**discover_kwargs)
|
|
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(comp, 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
|
|
|
|
|
|
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:
|
|
c, v = f.component, f.vuln
|
|
if c.source != last_source:
|
|
lines.append(f"[{c.source}]")
|
|
last_source = c.source
|
|
lines.append(f" {v.severity.ljust(8)} {c.name}=={c.version} {v.osv_id}")
|
|
if summary := v.summary:
|
|
lines.append(f" {summary if len(summary) <= 100 else summary[:97] + '...'}")
|
|
if v.fixed_versions:
|
|
lines.append(f" fixed in: {', '.join(v.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)
|
|
|
|
|
|
def cmd_security_audit(args: argparse.Namespace) -> int:
|
|
"""Implementation of `hermes security audit`."""
|
|
home = Path(get_hermes_home())
|
|
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()} (choose from: low, moderate, high, critical)", file=sys.stderr)
|
|
return 2
|
|
|
|
skips = {k: bool(getattr(args, k, False)) for k in ("skip_venv", "skip_plugins", "skip_mcp")}
|
|
components = _discover_components(hermes_home=home, **skips)
|
|
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))
|