Skip to content

Output

Output formatters for console, JSON, HTML, SARIF, CycloneDX, SPDX, and other formats.

Generate reviewable artifacts from a scan:

agent-bom scan --demo --offline --format json --output findings.json

Review the finding's advisory, affected product and reachability before assigning an impact. Missing impact metadata remains unspecified in SARIF; legacy scan records use unknown. Broad input-validation CWEs alone do not establish data loss or code execution. The original advisory description remains evidence for reviewing the effect and its preconditions.

Generated VEX groups only equivalent assessments and retains every affected product PURL. Different versions or suppression decisions are not discarded just because they share a CVE. Review those product-specific statements before using VEX to suppress findings downstream.

agent_bom.output

Output formatters for AI-BOM reports.

SEVERITY_BADGES module-attribute

SEVERITY_BADGES: dict[Severity, str] = {CRITICAL: 'white on red', HIGH: 'white on #e67e22', MEDIUM: 'black on yellow', LOW: 'white on #555555', UNKNOWN: 'black on white'}

SEVERITY_TEXT module-attribute

SEVERITY_TEXT: dict[Severity, str] = {CRITICAL: 'red bold', HIGH: '#e67e22 bold', MEDIUM: 'yellow', LOW: 'dim'}

print_summary

print_summary(report: AIBOMReport) -> None

Print a summary of the agent-bom scan report to console.

Source code in src/agent_bom/output/console_render.py
def print_summary(report: AIBOMReport) -> None:
    """Print a summary of the agent-bom scan report to console."""
    from agent_bom.output.brand_tokens import PRODUCT_NAME, REPORT_TITLE

    _console().print("\n")
    _console().print(
        Panel.fit(
            f"[bold]{REPORT_TITLE}[/bold]\n"
            f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
            f"{PRODUCT_NAME} v{report.tool_version}",
            border_style="blue",
        )
    )

    # Summary stats
    table = Table(show_header=False, box=None, padding=(0, 2))
    table.add_column("Metric", style="bold")
    table.add_column("Value")
    table.add_row("Agents discovered", str(report.total_agents))
    table.add_row("MCP servers", str(report.total_servers))
    table.add_row("Total packages", str(report.total_packages))
    table.add_row("Vulnerabilities", str(report.total_vulnerabilities))
    # Scope-labelled: ``critical_vulns`` counts blast radii, so a critical
    # non-CVE finding (malicious package, CIS FAIL, toxic combination) is not in
    # it. The posture headline a few lines below counts every finding category,
    # so an unscoped "Critical findings" here contradicts it on the same screen.
    table.add_row("Critical package CVEs", str(len(report.critical_vulns)))

    # AI inventory stats (if scan was run)
    ai_inv = getattr(report, "ai_inventory_data", None)
    if ai_inv and ai_inv.get("total_components", 0) > 0:
        table.add_row("AI components", str(ai_inv["total_components"]))
        shadow = ai_inv.get("shadow_ai_count", 0)
        if shadow:
            table.add_row("Shadow AI", f"[yellow]{shadow}[/yellow]")
        depr = ai_inv.get("deprecated_models_count", 0)
        if depr:
            table.add_row("Deprecated models", str(depr))
        keys = ai_inv.get("api_keys_count", 0)
        if keys:
            table.add_row("Hardcoded API keys", f"[red]{keys}[/red]")

    project_inv = getattr(report, "project_inventory_data", None)
    if project_inv:
        table.add_row("Project manifests", str(project_inv.get("manifest_files", 0)))
        table.add_row("Lockfiles", str(project_inv.get("lockfiles", 0)))
        table.add_row(
            "Project inventory",
            (
                f"{project_inv.get('package_count', 0)} packages "
                f"({project_inv.get('direct_packages', 0)} direct / {project_inv.get('transitive_packages', 0)} transitive)"
            ),
        )
        lockfile_backed_packages = project_inv.get("lockfile_backed_packages", project_inv.get("package_count", 0))
        declaration_only_packages = project_inv.get("declaration_only_packages", 0)
        advisory_depth_pct = project_inv.get("advisory_depth_pct")
        advisory_depth = f"{lockfile_backed_packages} lockfile-backed / {declaration_only_packages} declaration-only"
        if advisory_depth_pct is not None:
            advisory_depth += f" ({advisory_depth_pct}% lockfile-backed)"
        table.add_row("Advisory depth", advisory_depth)

    repo_trust = getattr(report, "repo_trust_data", None)
    if isinstance(repo_trust, dict) and repo_trust:
        name = repo_trust.get("full_name") or repo_trust.get("repo_url") or "repository"
        if repo_trust.get("status") == "ok":
            bits = [str(name)]
            if repo_trust.get("stars") is not None:
                bits.append(f"★ {repo_trust['stars']}")
            if repo_trust.get("contributors") is not None:
                bits.append(f"{repo_trust['contributors']} contributors")
            if repo_trust.get("license"):
                bits.append(str(repo_trust["license"]))
            if repo_trust.get("language"):
                bits.append(str(repo_trust["language"]))
            if repo_trust.get("pushed_at"):
                bits.append(f"pushed {repo_trust['pushed_at']}")
            table.add_row("Repo trust", " · ".join(bits))
        else:
            table.add_row("Repo trust", f"{name} ({repo_trust.get('status', 'unavailable')})")

    model_sc = getattr(report, "model_supply_chain_data", None)
    if model_sc:
        table.add_row(
            "Model artifacts",
            (
                f"{model_sc.get('model_files', 0)} file(s), "
                f"{model_sc.get('manifest_files', 0)} manifest(s), "
                f"{model_sc.get('provenance_checks', 0)} provenance check(s)"
            ),
        )
        table.add_row(
            "Model integrity",
            (
                f"{model_sc.get('signed_files', 0)} signed, "
                f"{model_sc.get('hash_verification', {}).get('verified', 0)} hash-verified, "
                f"{model_sc.get('hash_verification', {}).get('tampered', 0)} tampered"
            ),
        )
        model_lineage = model_sc.get("manifests_with_repo_id", 0) + model_sc.get("adapter_lineage_refs", 0)
        if model_lineage:
            table.add_row(
                "Model lineage",
                (f"{model_sc.get('sharded_bundles', 0)} sharded bundle(s), {model_lineage} lineage ref(s)"),
            )
        model_flags = model_sc.get("files_with_security_flags", 0) + model_sc.get("provenance_with_security_flags", 0)
        model_flags += model_sc.get("manifests_with_security_flags", 0)
        if model_flags:
            table.add_row("Model risk flags", f"[yellow]{model_flags}[/yellow]")

    perf = report.scan_performance_data or {}
    osv = perf.get("osv") or {}
    registry = perf.get("registry") or {}
    advisory = perf.get("advisory_coverage") or {}
    if osv and _osv_lookup_count(osv) > 0:
        hit_rate = osv.get("cache_hit_rate_pct")
        osv_label = f"{osv.get('cache_hits', 0)} hit / {osv.get('cache_misses', 0)} miss"
        if hit_rate is not None:
            osv_label += f" ({hit_rate}% hit rate)"
        table.add_row("OSV cache", osv_label)
    if registry and _registry_lookup_count(registry) > 0:
        reg_label = f"{registry.get('cache_hits', 0)} hit / {registry.get('cache_misses', 0)} miss"
        reg_rate = registry.get("cache_hit_rate_pct")
        if reg_rate is not None:
            reg_label += f" ({reg_rate}% hit rate)"
        table.add_row("Registry cache", reg_label)
    if advisory:
        primary = advisory.get("primary_sources", {})
        enriched = advisory.get("records_with_enrichment", 0)
        primary_bits = [f"{source} {count}" for source, count in primary.items() if count]
        advisory_label = ", ".join(primary_bits) if primary_bits else "no advisory sources attributed"
        if enriched:
            advisory_label += f" · {enriched} enriched"
        table.add_row("Threat intel", advisory_label)

    _console().print(table)

    coverage_warnings = getattr(report, "coverage_warnings", None) or []
    if coverage_warnings:

        def _coverage_line(w: dict) -> str:
            if w.get("reason") == "manifest_parse_error":
                return (
                    f"[bold yellow]⚠ {w.get('release', '?')}[/bold yellow] — "
                    f"{w.get('detail') or 'manifest failed to parse; ecosystem not scanned'}"
                )
            return (
                f"[bold yellow]⚠ {w.get('release', '?')}[/bold yellow] — "
                f"{w.get('package_count', 0)} package(s) present, "
                f"{w.get('advisory_rows', 0)} advisory row(s) in the data source"
            )

        lines = [_coverage_line(w) for w in coverage_warnings]
        body = "\n".join(lines) + (
            "\n\n[dim]Vulnerability coverage for the release(s) above is incomplete — likely "
            "end-of-life and no longer carried by the data source. Results may UNDER-report; a "
            "low or zero count is NOT a clean bill of health.[/dim]"
        )
        _console().print(
            Panel.fit(
                body,
                title="[bold red]Incomplete vulnerability coverage[/bold red]",
                border_style="red",
            )
        )

print_compact_summary

print_compact_summary(report: AIBOMReport, *, verbose: bool = False) -> None

Compact summary — verdict-led posture in 2-4 lines.

Default (verbose=False) leads with a severity-coloured one-liner verdict, follows with an inventory count line, and only mentions the posture grade / drivers / credentials when richer context exists, pointing the operator at --verbose for that detail.

verbose=True re-renders the previous detailed panel form (posture grade, weak driver dimensions, credential list, privilege counts, AI inventory).

Source code in src/agent_bom/output/compact.py
def print_compact_summary(report: AIBOMReport, *, verbose: bool = False) -> None:
    """Compact summary — verdict-led posture in 2-4 lines.

    Default (``verbose=False``) leads with a severity-coloured one-liner
    verdict, follows with an inventory count line, and only mentions the
    posture grade / drivers / credentials when richer context exists,
    pointing the operator at ``--verbose`` for that detail.

    ``verbose=True`` re-renders the previous detailed panel form
    (posture grade, weak driver dimensions, credential list, privilege
    counts, AI inventory).
    """
    from collections import Counter

    from agent_bom.finding import FindingType
    from agent_bom.output import _sev_badge, console
    from agent_bom.output.finding_views import active_cve_findings, finding_severity
    from agent_bom.posture import compute_posture_scorecard

    sev_counts: Counter[str] = Counter()
    active_findings = active_cve_findings(report)
    for finding in active_findings:
        sev_counts[finding_severity(finding).value.upper()] += 1
    policy_findings = [finding for finding in report.to_findings() if finding.finding_type != FindingType.CVE]
    for finding in policy_findings:
        sev_counts[str(finding.severity).upper()] += 1

    scorecard = compute_posture_scorecard(report)
    from agent_bom.evidence.scan_run import ScanOutcome, effective_scan_run

    coverage = report.scan_performance_data or {}
    scan_run = effective_scan_run(report)
    coverage_incomplete = coverage.get("coverage_state") == "incomplete" or scan_run.outcome is not ScanOutcome.COMPLETE
    high_risk_policy_count = sev_counts.get("CRITICAL", 0) + sev_counts.get("HIGH", 0)
    scorecard_summary = scorecard.summary
    if scan_run.outcome is ScanOutcome.FAILED:
        posture = "[bold white on red] SCAN FAILED [/bold white on red]"
        border_style = "red"
    elif coverage_incomplete:
        scorecard_summary = "scan coverage incomplete"
    if high_risk_policy_count and not active_findings:
        scorecard_summary = f"{high_risk_policy_count} high-risk policy/security finding(s) present"
    preferred_driver_order = [
        "credential_hygiene",
        "vulnerability_posture",
        "active_exploitation",
        "configuration_quality",
        "supply_chain_quality",
        "compliance_coverage",
    ]
    weak_dimensions = [
        scorecard.dimensions[name]
        for name in preferred_driver_order
        if name in scorecard.dimensions and scorecard.dimensions[name].score < 90
    ][:2]
    if len(weak_dimensions) < 2:
        seen_names = {dim.name for dim in weak_dimensions}
        for dim in sorted(scorecard.dimensions.values(), key=lambda d: d.score):
            if dim.score >= 80 or dim.name in seen_names:
                continue
            weak_dimensions.append(dim)
            seen_names.add(dim.name)
            if len(weak_dimensions) >= 2:
                break

    badge_parts_present = False
    if coverage_incomplete:
        posture = "[bold black on yellow] PARTIAL COVERAGE [/bold black on yellow]"
        border_style = "yellow"
    elif report.total_vulnerabilities == 0 and not active_findings and not policy_findings:
        posture = "[bold white on green] CLEAN [/bold white on green]"
        border_style = "green"
    else:
        badge_parts = []
        sev_map = [
            ("CRITICAL", Severity.CRITICAL),
            ("HIGH", Severity.HIGH),
            ("MEDIUM", Severity.MEDIUM),
            ("LOW", Severity.LOW),
        ]
        for sev_name, sev_enum in sev_map:
            if sev_counts.get(sev_name):
                badge_parts.append(f"{_sev_badge(sev_enum)} {sev_counts[sev_name]}")
        # UNKNOWN findings are still real advisories; they just lack finalized
        # severity scoring data and should not read like parser breakage.
        unknown_count = sev_counts.get("UNKNOWN", 0) + sev_counts.get("NONE", 0)
        if unknown_count:
            badge_parts.append(f"[dim]{unknown_count} advisory[/dim]")
        posture = "  ".join(badge_parts) if badge_parts else "[dim]advisory findings pending severity[/dim]"
        border_style = "red" if sev_counts.get("CRITICAL", 0) > 0 else "yellow"
        badge_parts_present = bool(badge_parts)

    # Credential count
    cred_names: list[str] = []
    for a in report.agents:
        for s in a.mcp_servers:
            cred_names.extend(s.credential_names)
    cred_names = sorted(set(cred_names))

    # Privilege count
    elevated = sum(1 for a in report.agents for s in a.mcp_servers if s.permission_profile and s.permission_profile.is_elevated)

    # Direct vs transitive package counts
    all_pkgs = [p for a in report.agents for s in a.mcp_servers for p in s.packages]
    n_direct = sum(1 for p in all_pkgs if p.is_direct)
    n_transitive = len(all_pkgs) - n_direct
    pkg_detail = f" ({n_direct}D/{n_transitive}T)" if n_transitive else ""

    has_ai_inventory = bool(getattr(report, "ai_inventory_data", None) and (report.ai_inventory_data or {}).get("total_components", 0) > 0)
    has_more_context = bool(weak_dimensions or cred_names or elevated or has_ai_inventory or coverage_incomplete or scorecard.score < 90)

    inventory_line = (
        f"  [bold]{report.total_agents}[/bold] agents [dim]·[/dim] "
        f"[bold]{report.total_servers}[/bold] servers [dim]·[/dim] "
        f"[bold]{report.total_packages}[/bold][dim]{pkg_detail}[/dim] packages"
    )

    if not verbose:
        # Default verdict-led form: verdict + inventory + a compact posture
        # card + optional --verbose hint. The posture grade is now surfaced
        # inline so a normal scan reads a posture verdict in one screen.
        # Scope-label the headline: these totals come from the unified findings
        # stream (package CVEs + graph/policy findings), not package CVEs alone.
        posture_scope = " [dim]· all finding categories[/dim]" if badge_parts_present else ""
        lines = [
            f"  [bold]Security posture:[/bold]  {posture}{posture_scope}",
            inventory_line,
        ]
        if scorecard.score < 100 or high_risk_policy_count:
            grade_line = f"  [bold]Posture grade:[/bold]  {_posture_grade_badge(scorecard.grade)} {scorecard.score:.0f}/100"
            if scorecard_summary:
                # limit=45 keeps the grade line inside an 80-col panel (no
                # wrapped "—…" fragment on the default terminal width).
                grade_line += f"  [dim]{_compact_detail(scorecard_summary, limit=45)}[/dim]"
            lines.append(grade_line)
            if weak_dimensions:
                lines.append(f"  [dim]Top driver:[/dim] [yellow]{weak_dimensions[0].name}[/yellow]")
        if has_more_context:
            # The grade is shown above, so the hint covers the remaining detail.
            hint_bits: list[str] = []
            if weak_dimensions:
                hint_bits.append(f"{len(weak_dimensions)} weak driver(s)")
            if cred_names:
                hint_bits.append(f"{len(cred_names)} credential(s)")
            if elevated:
                hint_bits.append(f"{elevated} elevated server(s)")
            hint_text = " · ".join(hint_bits) if hint_bits else "drivers and credentials"
            lines.append("")
            lines.append(f"  [dim]→ Run with[/dim] [bold]--verbose[/bold] [dim]for[/dim] {hint_text}")

        console.print(
            Panel(
                "\n".join(lines),
                title=lane_title("scan", f"agent-bom v{report.tool_version}"),
                border_style=border_style,
                padding=(0, 1),
            )
        )
        return

    # Verbose form: full posture detail (the previous default rendering).
    lines = [
        f"  [bold]CONFIG POSTURE GRADE:[/bold]  {_posture_grade_badge(scorecard.grade)} "
        f"[bold]{scorecard.score:.1f}/100[/bold]  [dim]{scorecard_summary}[/dim]",
        f"  [bold]SECURITY POSTURE:[/bold]  {posture}",
        "",
        f"  Agents  [bold]{report.total_agents}[/bold]    "
        f"Servers  [bold]{report.total_servers}[/bold]    "
        f"Packages  [bold]{report.total_packages}[/bold][dim]{pkg_detail}[/dim]    "
        f"Vulns  [bold]{report.total_vulnerabilities}[/bold]    "
        f"Findings  [bold]{len(policy_findings)}[/bold]",
    ]
    if weak_dimensions:
        driver_parts = [f"[yellow]{dim.name}[/yellow]: {_compact_detail(dim.details, limit=54)}" for dim in weak_dimensions]
        lines.append(f"  [bold]Top Drivers:[/bold]  {' [dim]·[/dim] '.join(driver_parts)}")
    if cred_names:
        names = ", ".join(cred_names[:3])
        more = f" +{len(cred_names) - 3}" if len(cred_names) > 3 else ""
        lines.append(f"  [yellow]Credentials:[/yellow]  {names}{more}")
    if elevated:
        lines.append(f"  [red]Privileges:[/red]  {elevated} server(s) elevated")

    # AI inventory stats (if scan was run)
    ai_inv = getattr(report, "ai_inventory_data", None)
    if ai_inv and ai_inv.get("total_components", 0) > 0:
        ai_parts = [f"[bold]{ai_inv['total_components']}[/bold] components"]
        shadow = ai_inv.get("shadow_ai_count", 0)
        depr = ai_inv.get("deprecated_models_count", 0)
        keys = ai_inv.get("api_keys_count", 0)
        if keys:
            ai_parts.append(f"[red]{keys} hardcoded key(s)[/red]")
        if shadow:
            ai_parts.append(f"[yellow]{shadow} shadow AI[/yellow]")
        if depr:
            ai_parts.append(f"{depr} deprecated")
        sdks = ai_inv.get("unique_sdks", [])
        if sdks:
            sdk_str = ", ".join(sdks[:4]) + (f" +{len(sdks) - 4}" if len(sdks) > 4 else "")
            ai_parts.append(f"SDKs: [cyan]{sdk_str}[/cyan]")
        ai_str = " \u00b7 ".join(ai_parts)
        lines.append(f"  [bold]AI Inventory:[/bold]  {ai_str}")

    console.print(
        Panel(
            "\n".join(lines),
            title=lane_title("scan", f"agent-bom v{report.tool_version}"),
            border_style=border_style,
            padding=(0, 1),
        )
    )

print_compact_agents

print_compact_agents(report: AIBOMReport) -> None

One-line-per-agent table.

Source code in src/agent_bom/output/compact.py
def print_compact_agents(report: AIBOMReport) -> None:
    """One-line-per-agent table."""
    from agent_bom.output import console

    configured = [a for a in report.agents if a.status == AgentStatus.CONFIGURED]
    if not configured:
        return

    console.print()
    console.print(Rule(lane_title("discover", "Agents"), style="dim"))
    table = Table(box=None, padding=(0, 2), show_header=True, header_style="bold dim")
    table.add_column("Agent")
    table.add_column("Type", style="dim")
    table.add_column("Servers", justify="right")
    table.add_column("Pkgs", justify="right")
    table.add_column("Creds", justify="right")
    table.add_column("Vulns", justify="right")

    for a in configured:
        n_servers = len(a.mcp_servers)
        n_pkgs = sum(len(s.packages) for s in a.mcp_servers)
        n_creds = sum(len(s.credential_names) for s in a.mcp_servers)
        n_vulns = sum(s.total_vulnerabilities for s in a.mcp_servers)
        vuln_style = "red" if n_vulns > 0 else "dim"
        cred_style = "yellow" if n_creds > 0 else "dim"
        table.add_row(
            f"[bold]{_agent_display_name(a)}[/bold]",
            a.agent_type.value if hasattr(a.agent_type, "value") else str(a.agent_type),
            str(n_servers),
            str(n_pkgs),
            f"[{cred_style}]{n_creds}[/{cred_style}]",
            f"[{vuln_style}]{n_vulns}[/{vuln_style}]",
        )

    console.print(table)

print_compact_blast_radius

print_compact_blast_radius(report: AIBOMReport, limit: int = 10, fixable_only: bool = False, page: int = 1) -> None

Show top N findings in a compact table.

Context-aware: shows blast radius chain (agent → server → credential) only when MCP agent context is available. Falls back to a clean vuln table for scan types without agent context (image, check, iac, CI/CD).

Source code in src/agent_bom/output/compact.py
def print_compact_blast_radius(report: AIBOMReport, limit: int = 10, fixable_only: bool = False, page: int = 1) -> None:
    """Show top N findings in a compact table.

    Context-aware: shows blast radius chain (agent → server → credential) only
    when MCP agent context is available. Falls back to a clean vuln table for
    scan types without agent context (image, check, iac, CI/CD).
    """
    from rich.markup import escape

    from agent_bom.output import _sev_badge, console
    from agent_bom.output.finding_views import (
        active_cve_findings,
        cve_findings,
        exploit_likelihood_value,
        finding_severity,
        is_actionable_finding,
        is_package_direct,
        package_name,
        package_version,
        sanitize_output_text,
    )

    def safe(value: object) -> str:
        return escape(sanitize_output_text(value))

    active_findings = active_cve_findings(report)
    if not active_findings:
        return

    priority = [finding for finding in active_findings if is_actionable_finding(finding)]
    rest_count = len(active_findings) - len(priority)
    if fixable_only:
        priority = [finding for finding in priority if _forward_fix_version(finding)]
    if not priority:
        display_list = [finding for finding in active_findings if _forward_fix_version(finding)] if fixable_only else active_findings
    else:
        display_list = priority
    total = len(display_list)
    page, start, end = _page_window(total, limit, page)
    shown = display_list[start:end]

    # Detect if we have blast radius context (agents/servers/credentials)
    has_blast_context = any(finding.affected_agents and (finding.affected_servers or finding.exposed_credentials) for finding in shown)

    console.print()
    shown_n = len(shown)
    total_active = len(active_findings)
    total_pages = max(1, (total + max(1, limit) - 1) // max(1, limit))
    if total > limit:
        # Priority list truncated by --limit; tell the operator how many
        # additional priority rows aren't shown.
        suffix = ""
        if total_active > total:
            suffix = f" · {total_active - total} more below priority"
        title = f"Top Findings (page {page}/{total_pages} · {start + 1}-{end} of {total}{suffix})"
    elif total_active > shown_n:
        # Display list fits in --limit but priority filtering hid some
        # lower-severity rows further down. Tell the operator both numbers
        # so '+ N hidden' below the table doesn't look contradictory.
        title = f"Findings ({shown_n} of {total_active} shown · {total_active - shown_n} hidden)"
    else:
        title = f"Findings ({shown_n})"
    console.print(Rule(lane_title("analyze", title), style="dim"))

    # Context-aware table layout. The vulnerability ID column has no ratio so
    # it sizes to content and never truncates — the ID is the one cell that
    # must stay copy-pasteable; flexible columns absorb narrow widths instead.
    table = Table(expand=True, padding=(0, 1))
    table.add_column("Sev", no_wrap=True)
    table.add_column("Vulnerability", no_wrap=True)
    table.add_column("Package", ratio=2, overflow="fold")
    if has_blast_context:
        table.add_column("Blast Radius", ratio=3, overflow="fold")
    show_epss = any(finding.epss_score is not None for finding in shown)
    if show_epss:
        table.add_column("EPSS", justify="center", no_wrap=True)
    table.add_column("Fix", ratio=1, overflow="fold")

    for finding in shown:
        forward_fix = _forward_fix_version(finding)
        fix = f"[green]{safe(forward_fix)}[/green]" if forward_fix else "[dim]no fix[/dim]"
        _exploit_level = exploit_likelihood_value(finding)
        if finding.is_kev:
            kev = " [red bold]KEV[/red bold]"
        elif _exploit_level == "likely_exploited":
            kev = " [#e67e22 bold]EXPL[/#e67e22 bold]"
        elif _exploit_level == "public_exploit":
            kev = " [yellow]PoC[/yellow]"
        else:
            kev = ""

        epss_display = "[dim]—[/dim]"
        if finding.epss_score is not None:
            epss_pct = int(finding.epss_score * 100)
            epss_style = "red bold" if epss_pct >= 70 else "yellow" if epss_pct >= 30 else "dim"
            epss_display = f"[{epss_style}]{epss_pct}%[/{epss_style}]"

        pkg_display = f"{safe(package_name(finding))}@{safe(package_version(finding))}" + (
            "" if is_package_direct(finding) else " [dim]T[/dim]"
        )
        vuln_id = safe(finding.cve_id or finding.id)

        if has_blast_context:
            agent_names = [safe(value) for value in finding.affected_agents]
            cred_names = [safe(value) for value in finding.exposed_credentials]
            server_names = [safe(value) for value in finding.affected_servers]
            chain_parts: list[str] = []
            if agent_names:
                name = agent_names[0][:16]
                chain_parts.append(f"[bold]{name}[/bold]")
                if len(agent_names) > 1:
                    chain_parts[-1] += f"+{len(agent_names) - 1}"
            if server_names:
                name = server_names[0][:16]
                chain_parts.append(f"{name}")
            if cred_names:
                name = cred_names[0][:20]
                chain_parts.append(f"[yellow]{name}[/yellow]")
                if len(cred_names) > 1:
                    chain_parts[-1] += f"+{len(cred_names) - 1}"
            blast_display = " → ".join(chain_parts) if chain_parts else "[dim]—[/dim]"
            row = [
                _sev_badge(finding_severity(finding)),
                f"{vuln_id}{kev}",
                pkg_display,
                blast_display,
            ]
        else:
            row = [
                _sev_badge(finding_severity(finding)),
                f"{vuln_id}{kev}",
                pkg_display,
            ]
        if show_epss:
            row.append(epss_display)
        row.append(fix)
        table.add_row(*row)

    console.print(table)

    overflow = total - end
    hidden_before = start
    if overflow > 0 or hidden_before > 0 or rest_count > 0:
        parts = []
        if hidden_before > 0:
            parts.append(f"{hidden_before} earlier hidden")
        if overflow > 0:
            parts.append(f"{overflow} more critical/high")
        if rest_count > 0:
            parts.append(f"{rest_count} medium/low hidden")
        nav_hints = []
        if page < total_pages:
            nav_hints.append(f"--page {page + 1} next")
        if page > 1:
            nav_hints.append(f"--page {page - 1} previous")
        nav_hints.append("--verbose full list")
        console.print(f"  [dim]+ {' · '.join(parts)}{' · '.join(nav_hints)}[/dim]")

    # Critical details section — show description and blast chain for CRIT/HIGH only
    critical_findings = [finding for finding in shown if finding_severity(finding) in (Severity.CRITICAL, Severity.HIGH)]
    if critical_findings:
        console.print()
        console.print(Rule(lane_title("analyze", "Critical Details"), style="dim"))
        sev_style_map = {Severity.CRITICAL: "red bold", Severity.HIGH: "#e67e22 bold"}
        for finding in critical_findings[:5]:
            sev = finding_severity(finding)
            style = sev_style_map.get(sev, "white")
            summary = safe(finding.description or "")
            if len(summary) > 80:
                summary = summary[:77] + "..."
            sev_label = sev.value.upper()
            pkg_ref = f"{safe(package_name(finding))}@{safe(package_version(finding))}"
            vuln_id = safe(finding.cve_id or finding.id)
            console.print(f"\n  [{style}]{vuln_id}[/{style}] · {pkg_ref} · [{style}]{sev_label}[/{style}]")
            if summary:
                console.print(f"  {summary}")
            forward_fix = _forward_fix_version(finding)
            if forward_fix:
                console.print(f"  Fix: [green]upgrade to ≥ {safe(forward_fix)}[/green]")
            if has_blast_context and (finding.affected_agents or finding.exposed_credentials):
                agent_str = ", ".join(safe(value) for value in finding.affected_agents[:3])
                cred_str = ", ".join(safe(value) for value in finding.exposed_credentials[:3])
                blast_parts = []
                if agent_str:
                    blast_parts.append(agent_str)
                if finding.affected_servers:
                    blast_parts.append(", ".join(safe(value) for value in finding.affected_servers[:2]))
                if cred_str:
                    blast_parts.append(f"[yellow]{cred_str}[/yellow]")
                if blast_parts:
                    console.print(f"  Blast: {' → '.join(blast_parts)}")

    console.print()
    all_cve = cve_findings(report)
    fixable = sum(1 for finding in all_cve if _forward_fix_version(finding))
    kev_count = sum(1 for finding in all_cve if finding.is_kev)
    unknown_sev = sum(1 for finding in all_cve if finding_severity(finding) == Severity.NONE)
    hints = ["[dim]--verbose[/dim] full details", "[dim]-f html[/dim] interactive report"]
    if page < total_pages:
        hints.insert(0, f"[dim]--page {page + 1}[/dim] next findings")
    if fixable:
        hints.insert(0, f"[green]{fixable} fixable[/green]")
    if kev_count:
        hints.insert(0, f"[red]{kev_count} KEV[/red]")
    if unknown_sev > 0 and unknown_sev == len(all_cve):
        hints.insert(0, "[yellow]--enrich[/yellow] for severity scores")
    console.print(Rule(style="dim"))
    console.print(f"  {' · '.join(hints)}")

print_blast_radius

print_blast_radius(report: AIBOMReport, fixable_only: bool = False) -> None

Print blast radius analysis for vulnerabilities.

Source code in src/agent_bom/output/console_render.py
def print_blast_radius(report: AIBOMReport, fixable_only: bool = False) -> None:
    """Print blast radius analysis for vulnerabilities."""
    all_cve = cve_findings(report)
    if not all_cve:
        return

    _console().print()
    _console().print(Rule("Blast Radius Analysis", style="red"))
    _console().print()

    table = Table(title="Vulnerability Impact Chain", expand=True, padding=(0, 1))
    table.add_column("Risk", justify="center", no_wrap=True)
    table.add_column("Vulnerability", no_wrap=True, ratio=3)
    table.add_column("Severity", no_wrap=True)
    table.add_column("EPSS", justify="center", no_wrap=True)
    table.add_column("KEV", justify="center", no_wrap=True)
    table.add_column("Blast", justify="center", no_wrap=True)
    table.add_column("Threats", ratio=3)
    table.add_column("Fix", ratio=2)

    # Filter to actionable findings only — UNKNOWN severity transitive
    # deps with no creds/tools are noise. Users see all with --verbose.
    actionable = [finding for finding in all_cve if is_actionable_finding(finding)]
    # --fixable-only: keep only entries that have a fix available
    if fixable_only:
        actionable = [finding for finding in actionable if finding.fixed_version]
    if not actionable:
        _console().print("  [green]✓ No actionable findings (all transitive/low-severity noise).[/green]")
        if len(all_cve) > 0:
            _console().print(f"  [dim]{len(all_cve)} low-priority findings hidden. Use --verbose to see all.[/dim]")
        return

    for finding in actionable[:25]:  # Top 25 actionable
        sev = finding_severity(finding)
        sev_style = SEVERITY_TEXT.get(sev, "white")
        if finding.fixed_version:
            fix = f"[green]✓ {escape(sanitize_output_text(finding.fixed_version))}[/green]"
        else:
            fix = "[red dim]No fix[/red dim]"

        # EPSS score display
        epss_display = "—"
        if finding.epss_score is not None:
            epss_pct = int(finding.epss_score * 100)
            epss_style = "red bold" if epss_pct >= 70 else "yellow" if epss_pct >= 30 else "dim"
            epss_display = f"[{epss_style}]{epss_pct}%[/{epss_style}]"

        # KEV indicator
        kev_display = "[red bold]🔥[/red bold]" if finding.is_kev else "—"

        # Malicious package indicator
        if is_package_malicious(finding):
            kev_display += " [red bold]☠[/red bold]"

        # Blast column: agents/creds compact
        blast_parts = []
        n_agents = len(finding.affected_agents)
        n_creds = len(finding.exposed_credentials)
        if n_agents:
            blast_parts.append(f"{n_agents}A")
        if n_creds:
            blast_parts.append(f"[yellow]{n_creds}C[/yellow]")
        blast_display = "/".join(blast_parts) if blast_parts else "—"

        # Vulnerability: ID + package on two lines
        vuln_id = escape(sanitize_output_text(finding.cve_id or finding.id))
        package_label = escape(f"{sanitize_output_text(package_name(finding))}@{sanitize_output_text(package_version(finding))}")
        vuln_display = f"{vuln_id}\n[dim]{package_label}[/dim]"

        # Threats column: actual framework tag IDs per finding
        threat_lines = []
        if finding.owasp_tags:
            tags = sorted(finding.owasp_tags)[:3]
            extra = f" +{len(finding.owasp_tags) - 3}" if len(finding.owasp_tags) > 3 else ""
            threat_lines.append(f"[purple]{' '.join(tags)}{extra}[/purple]")
        if finding.atlas_tags:
            tags = sorted(finding.atlas_tags)[:3]
            extra = f" +{len(finding.atlas_tags) - 3}" if len(finding.atlas_tags) > 3 else ""
            threat_lines.append(f"[cyan]{' '.join(tags)}{extra}[/cyan]")
        if finding.attack_tags:
            tags = sorted(finding.attack_tags)[:3]
            extra = f" +{len(finding.attack_tags) - 3}" if len(finding.attack_tags) > 3 else ""
            threat_lines.append(f"[red]{' '.join(tags)}{extra}[/red]")
        if finding.nist_ai_rmf_tags:
            tags = sorted(finding.nist_ai_rmf_tags)[:3]
            extra = f" +{len(finding.nist_ai_rmf_tags) - 3}" if len(finding.nist_ai_rmf_tags) > 3 else ""
            threat_lines.append(f"[green]{' '.join(tags)}{extra}[/green]")
        if finding.owasp_mcp_tags:
            tags = sorted(finding.owasp_mcp_tags)[:3]
            extra = f" +{len(finding.owasp_mcp_tags) - 3}" if len(finding.owasp_mcp_tags) > 3 else ""
            threat_lines.append(f"[yellow]{' '.join(tags)}{extra}[/yellow]")
        if finding.owasp_agentic_tags:
            tags = sorted(finding.owasp_agentic_tags)[:3]
            extra = f" +{len(finding.owasp_agentic_tags) - 3}" if len(finding.owasp_agentic_tags) > 3 else ""
            threat_lines.append(f"[magenta]{' '.join(tags)}{extra}[/magenta]")
        if finding.eu_ai_act_tags:
            tags = sorted(finding.eu_ai_act_tags)[:3]
            extra = f" +{len(finding.eu_ai_act_tags) - 3}" if len(finding.eu_ai_act_tags) > 3 else ""
            threat_lines.append(f"[blue]{' '.join(tags)}{extra}[/blue]")
        if finding.nist_csf_tags:
            tags = sorted(finding.nist_csf_tags)[:3]
            extra = f" +{len(finding.nist_csf_tags) - 3}" if len(finding.nist_csf_tags) > 3 else ""
            threat_lines.append(f"[bright_green]{' '.join(tags)}{extra}[/bright_green]")
        if finding.iso_27001_tags:
            tags = sorted(finding.iso_27001_tags)[:3]
            extra = f" +{len(finding.iso_27001_tags) - 3}" if len(finding.iso_27001_tags) > 3 else ""
            threat_lines.append(f"[bright_cyan]{' '.join(tags)}{extra}[/bright_cyan]")
        if finding.soc2_tags:
            tags = sorted(finding.soc2_tags)[:3]
            extra = f" +{len(finding.soc2_tags) - 3}" if len(finding.soc2_tags) > 3 else ""
            threat_lines.append(f"[bright_yellow]{' '.join(tags)}{extra}[/bright_yellow]")
        if finding.cis_tags:
            tags = sorted(finding.cis_tags)[:3]
            extra = f" +{len(finding.cis_tags) - 3}" if len(finding.cis_tags) > 3 else ""
            threat_lines.append(f"[bright_magenta]{' '.join(tags)}{extra}[/bright_magenta]")
        threats_display = "\n".join(threat_lines) if threat_lines else "—"

        table.add_row(
            f"[{sev_style}]{finding.risk_score:.1f}[/{sev_style}]",
            vuln_display,
            _sev_badge(sev),
            epss_display,
            kev_display,
            blast_display,
            threats_display,
            fix,
        )

    _console().print(table)

    if len(all_cve) > 25:
        _console().print(f"\n  [dim]...and {len(all_cve) - 25} more findings. Use --output to export full report.[/dim]")

    # Verification sources — one link per unique CVE
    seen_ids: set[str] = set()
    sources: list[tuple[str, str]] = []
    for finding in all_cve:
        vid = finding.cve_id or finding.id
        if vid in seen_ids:
            continue
        seen_ids.add(vid)
        refs = finding_references(finding)
        if refs:
            sources.append((vid, refs[0]))
        elif vid.startswith("CVE-"):
            sources.append((vid, f"https://osv.dev/vulnerability/{vid}"))
        elif vid.startswith("GHSA-"):
            sources.append((vid, f"https://github.com/advisories/{vid}"))
    if sources:
        _console().print("\n[bold]Verification Sources[/bold]")
        for vid, url in sources[:15]:
            _console().print(f"  [dim]{vid}[/dim]  →  [link={url}]{url}[/link]")
        if len(sources) > 15:
            _console().print(f"  [dim]...and {len(sources) - 15} more (see JSON output for full list)[/dim]")

export_json

export_json(report: AIBOMReport, output_path: str, *, report_json: dict[str, Any] | None = None) -> None

Export report as JSON file.

Source code in src/agent_bom/output/json_fmt.py
def export_json(
    report: AIBOMReport,
    output_path: str,
    *,
    report_json: dict[str, Any] | None = None,
) -> None:
    """Export report as JSON file."""
    data = to_redacted_json(report, report_json=report_json)
    with Path(output_path).open("w", encoding="utf-8") as fh:
        json.dump(data, fh, indent=2)
        fh.write("\n")

export_html

export_html(report: AIBOMReport, output_path: str, blast_radii: list | None = None, *, offline_assets: bool = False) -> None

Export report as a self-contained HTML file.

Source code in src/agent_bom/output/__init__.py
def export_html(
    report: AIBOMReport,
    output_path: str,
    blast_radii: list | None = None,
    *,
    offline_assets: bool = False,
) -> None:
    """Export report as a self-contained HTML file."""
    from agent_bom.output.html import export_html as _export_html

    _export_html(report, output_path, blast_radii or [], offline_assets=offline_assets)

export_sarif

export_sarif(report: AIBOMReport, output_path: str, *, exclude_unfixable: bool = False) -> None

Export report as SARIF 2.1.0 JSON file.

Source code in src/agent_bom/output/sarif.py
def export_sarif(
    report: AIBOMReport,
    output_path: str,
    *,
    exclude_unfixable: bool = False,
) -> None:
    """Export report as SARIF 2.1.0 JSON file."""
    data = to_sarif(report, exclude_unfixable=exclude_unfixable)
    Path(output_path).write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")

export_cyclonedx

export_cyclonedx(report: AIBOMReport, output_path: str) -> None

Export report as CycloneDX 1.7 JSON file.

Source code in src/agent_bom/output/cyclonedx_fmt.py
def export_cyclonedx(report: AIBOMReport, output_path: str) -> None:
    """Export report as CycloneDX 1.7 JSON file."""
    cdx = to_cyclonedx(report)
    Path(output_path).write_text(json.dumps(cdx, indent=2))

export_spdx

export_spdx(report: AIBOMReport, output_path: str) -> None

Export report as a canonical SPDX 3.0.1 JSON-LD file.

Source code in src/agent_bom/output/spdx_fmt.py
def export_spdx(report: AIBOMReport, output_path: str) -> None:
    """Export report as a canonical SPDX 3.0.1 JSON-LD file."""
    data = to_spdx(report)
    Path(output_path).write_text(json.dumps(data, indent=2))