Skip to content

Output

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

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))
    table.add_row("Critical findings", 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 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,
    )

    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)
    if has_blast_context:
        table.add_column("Blast Radius", ratio=3, no_wrap=True)
    table.add_column("EPSS", justify="center", no_wrap=True)
    table.add_column("Fix", ratio=1)

    for finding in shown:
        forward_fix = _forward_fix_version(finding)
        fix = f"[green]{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"{package_name(finding)}@{package_version(finding)}" + ("" if is_package_direct(finding) else " [dim]T[/dim]")
        vuln_id = finding.cve_id or finding.id

        if has_blast_context:
            agent_names = list(finding.affected_agents)
            cred_names = list(finding.exposed_credentials)
            server_names = list(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]"
            table.add_row(
                _sev_badge(finding_severity(finding)),
                f"{vuln_id}{kev}",
                pkg_display,
                blast_display,
                epss_display,
                fix,
            )
        else:
            table.add_row(
                _sev_badge(finding_severity(finding)),
                f"{vuln_id}{kev}",
                pkg_display,
                epss_display,
                fix,
            )

    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 = finding.description or ""
            if len(summary) > 80:
                summary = summary[:77] + "..."
            sev_label = sev.value.upper()
            pkg_ref = f"{package_name(finding)}@{package_version(finding)}"
            vuln_id = 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 ≥ {forward_fix}[/green]")
            if has_blast_context and (finding.affected_agents or finding.exposed_credentials):
                agent_str = ", ".join(finding.affected_agents[:3])
                cred_str = ", ".join(finding.exposed_credentials[:3])
                blast_parts = []
                if agent_str:
                    blast_parts.append(agent_str)
                if finding.affected_servers:
                    blast_parts.append(", ".join(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]✓ {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 = finding.cve_id or finding.id
        vuln_display = f"{vuln_id}\n[dim]{package_name(finding)}@{package_version(finding)}[/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) -> None

Export report as JSON file.

Source code in src/agent_bom/output/json_fmt.py
def export_json(report: AIBOMReport, output_path: str) -> None:
    """Export report as JSON file."""
    data = to_redacted_json(report)
    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, indent=2))

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))