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 AI-BOM report to console.

Source code in src/agent_bom/output/console_render.py
def print_summary(report: AIBOMReport) -> None:
    """Print a summary of the AI-BOM report to console."""
    _console().print("\n")
    _console().print(
        Panel.fit(
            f"[bold]AI-BOM Report[/bold]\n"
            f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
            f"agent-bom 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)

    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)

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.posture import compute_posture_scorecard
    from agent_bom.vex import active_blast_radii

    sev_counts: Counter[str] = Counter()
    active_findings = active_blast_radii(report.blast_radii)
    for br in active_findings:
        sev_counts[br.vulnerability.severity.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)
    coverage = report.scan_performance_data or {}
    coverage_incomplete = coverage.get("coverage_state") == "incomplete"
    high_risk_policy_count = sev_counts.get("CRITICAL", 0) + sev_counts.get("HIGH", 0)
    scorecard_summary = scorecard.summary
    if 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

    if coverage_incomplete:
        posture = "[bold black on yellow] PARTIAL COVERAGE [/bold black on yellow]"
        border_style = "yellow"
    elif report.total_vulnerabilities == 0 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"

    # 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: 2 lines + optional --verbose hint.
        lines = [
            f"  [bold]Security posture:[/bold]  {posture}",
            inventory_line,
        ]
        if has_more_context:
            hint_bits: list[str] = []
            if scorecard.score < 100:
                hint_bits.append(f"posture grade {_posture_grade_badge(scorecard.grade)} {scorecard.score:.0f}/100")
            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=f"[bold]agent-bom[/bold]  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=f"[bold]agent-bom[/bold]  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("[bold]Agents[/bold]", 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) -> 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) -> 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.vex import active_blast_radii

    if not report.blast_radii:
        return

    # Filter: show actionable findings by default, count the rest
    active_findings = active_blast_radii(report.blast_radii)
    if not active_findings:
        return
    priority = [br for br in active_findings if br.is_actionable]
    rest_count = len(active_findings) - len(priority)
    if fixable_only:
        priority = [br for br in priority if br.vulnerability.fixed_version]
    if not priority:
        display_list = [br for br in active_findings if br.vulnerability.fixed_version] if fixable_only else active_findings
    else:
        display_list = priority
    shown = display_list[:limit]

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

    console.print()
    total = len(display_list)
    shown_n = len(shown)
    total_active = len(active_findings)
    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 ({min(limit, total)} 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(f"[bold]{title}[/bold]", style="dim"))

    # Context-aware table layout
    table = Table(expand=True, padding=(0, 1))
    table.add_column("Sev", no_wrap=True)
    table.add_column("Vulnerability", no_wrap=True, ratio=2)
    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 br in shown:
        fix = f"[green]{br.vulnerability.fixed_version}[/green]" if br.vulnerability.fixed_version else "[dim]no fix[/dim]"
        # Exploit-likelihood (issue #486) — KEV wins; elevated EPSS-only
        # levels still surface a muted hint so the operator knows why
        # the row is flagged even when it's not in CISA KEV.
        _exploit_level = br.vulnerability.exploit_likelihood
        if br.vulnerability.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 br.vulnerability.epss_score is not None:
            epss_pct = int(br.vulnerability.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"{br.package.name}@{br.package.version}" + ("" if br.package.is_direct else " [dim]T[/dim]")

        if has_blast_context:
            # Build single-line blast chain: agent → server → credential
            agent_names = [a.name for a in br.affected_agents]
            cred_names = list(br.exposed_credentials)
            server_names = [s.name for s in br.affected_servers] if br.affected_servers else []
            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(br.vulnerability.severity),
                f"{br.vulnerability.id}{kev}",
                pkg_display,
                blast_display,
                epss_display,
                fix,
            )
        else:
            table.add_row(
                _sev_badge(br.vulnerability.severity),
                f"{br.vulnerability.id}{kev}",
                pkg_display,
                epss_display,
                fix,
            )

    console.print(table)

    overflow = total - len(shown)
    if overflow > 0 or rest_count > 0:
        parts = []
        if overflow > 0:
            parts.append(f"{overflow} more critical/high")
        if rest_count > 0:
            parts.append(f"{rest_count} medium/low hidden")
        console.print(f"  [dim]+ {' · '.join(parts)} — use --verbose for full list[/dim]")

    # Critical details section — show description and blast chain for CRIT/HIGH only
    critical_findings = [br for br in shown if br.vulnerability.severity in (Severity.CRITICAL, Severity.HIGH)]
    if critical_findings:
        console.print()
        console.print(Rule("[bold]Critical Details[/bold]", style="dim"))
        sev_style_map = {Severity.CRITICAL: "red bold", Severity.HIGH: "#e67e22 bold"}
        for br in critical_findings[:5]:
            style = sev_style_map.get(br.vulnerability.severity, "white")
            summary = br.vulnerability.summary or ""
            if len(summary) > 80:
                summary = summary[:77] + "..."
            sev_label = br.vulnerability.severity.value.upper()
            pkg_ref = f"{br.package.name}@{br.package.version}"
            console.print(f"\n  [{style}]{br.vulnerability.id}[/{style}] · {pkg_ref} · [{style}]{sev_label}[/{style}]")
            if summary:
                console.print(f"  {summary}")
            if br.vulnerability.fixed_version:
                console.print(f"  Fix: [green]upgrade to ≥ {br.vulnerability.fixed_version}[/green]")
            if has_blast_context and (br.affected_agents or br.exposed_credentials):
                agent_str = ", ".join(a.name for a in br.affected_agents[:3])
                cred_str = ", ".join(br.exposed_credentials[:3])
                blast_parts = []
                if agent_str:
                    blast_parts.append(agent_str)
                if br.affected_servers:
                    blast_parts.append(", ".join(s.name for s in br.affected_servers[:2]))
                if cred_str:
                    blast_parts.append(f"[yellow]{cred_str}[/yellow]")
                if blast_parts:
                    console.print(f"  Blast: {' → '.join(blast_parts)}")

    # Status bar
    console.print()
    fixable = sum(1 for br in report.blast_radii if br.vulnerability.fixed_version)
    kev_count = sum(1 for br in report.blast_radii if br.vulnerability.is_kev)
    unknown_sev = sum(1 for br in report.blast_radii if br.vulnerability.severity == Severity.NONE)
    hints = ["[dim]--verbose[/dim] full details", "[dim]-f html[/dim] interactive report"]
    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(report.blast_radii):
        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."""
    if not report.blast_radii:
        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 = [br for br in report.blast_radii if br.is_actionable]
    # --fixable-only: keep only entries that have a fix available
    if fixable_only:
        actionable = [br for br in actionable if br.vulnerability.fixed_version]
    if not actionable:
        _console().print("  [green]✓ No actionable findings (all transitive/low-severity noise).[/green]")
        if len(report.blast_radii) > 0:
            _console().print(f"  [dim]{len(report.blast_radii)} low-priority findings hidden. Use --verbose to see all.[/dim]")
        return

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

        # EPSS score display
        epss_display = "—"
        if br.vulnerability.epss_score is not None:
            epss_pct = int(br.vulnerability.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 br.vulnerability.is_kev else "—"

        # Malicious package indicator
        if br.package.is_malicious:
            kev_display += " [red bold]☠[/red bold]"

        # Blast column: agents/creds compact
        blast_parts = []
        n_agents = len(br.affected_agents)
        n_creds = len(br.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_display = f"{br.vulnerability.id}\n[dim]{br.package.name}@{br.package.version}[/dim]"

        # Threats column: actual framework tag IDs per finding
        threat_lines = []
        if br.owasp_tags:
            tags = sorted(br.owasp_tags)[:3]
            extra = f" +{len(br.owasp_tags) - 3}" if len(br.owasp_tags) > 3 else ""
            threat_lines.append(f"[purple]{' '.join(tags)}{extra}[/purple]")
        if br.atlas_tags:
            tags = sorted(br.atlas_tags)[:3]
            extra = f" +{len(br.atlas_tags) - 3}" if len(br.atlas_tags) > 3 else ""
            threat_lines.append(f"[cyan]{' '.join(tags)}{extra}[/cyan]")
        if getattr(br, "attack_tags", None):
            tags = sorted(br.attack_tags)[:3]
            extra = f" +{len(br.attack_tags) - 3}" if len(br.attack_tags) > 3 else ""
            threat_lines.append(f"[red]{' '.join(tags)}{extra}[/red]")
        if br.nist_ai_rmf_tags:
            tags = sorted(br.nist_ai_rmf_tags)[:3]
            extra = f" +{len(br.nist_ai_rmf_tags) - 3}" if len(br.nist_ai_rmf_tags) > 3 else ""
            threat_lines.append(f"[green]{' '.join(tags)}{extra}[/green]")
        if br.owasp_mcp_tags:
            tags = sorted(br.owasp_mcp_tags)[:3]
            extra = f" +{len(br.owasp_mcp_tags) - 3}" if len(br.owasp_mcp_tags) > 3 else ""
            threat_lines.append(f"[yellow]{' '.join(tags)}{extra}[/yellow]")
        if br.owasp_agentic_tags:
            tags = sorted(br.owasp_agentic_tags)[:3]
            extra = f" +{len(br.owasp_agentic_tags) - 3}" if len(br.owasp_agentic_tags) > 3 else ""
            threat_lines.append(f"[magenta]{' '.join(tags)}{extra}[/magenta]")
        if br.eu_ai_act_tags:
            tags = sorted(br.eu_ai_act_tags)[:3]
            extra = f" +{len(br.eu_ai_act_tags) - 3}" if len(br.eu_ai_act_tags) > 3 else ""
            threat_lines.append(f"[blue]{' '.join(tags)}{extra}[/blue]")
        if br.nist_csf_tags:
            tags = sorted(br.nist_csf_tags)[:3]
            extra = f" +{len(br.nist_csf_tags) - 3}" if len(br.nist_csf_tags) > 3 else ""
            threat_lines.append(f"[bright_green]{' '.join(tags)}{extra}[/bright_green]")
        if br.iso_27001_tags:
            tags = sorted(br.iso_27001_tags)[:3]
            extra = f" +{len(br.iso_27001_tags) - 3}" if len(br.iso_27001_tags) > 3 else ""
            threat_lines.append(f"[bright_cyan]{' '.join(tags)}{extra}[/bright_cyan]")
        if br.soc2_tags:
            tags = sorted(br.soc2_tags)[:3]
            extra = f" +{len(br.soc2_tags) - 3}" if len(br.soc2_tags) > 3 else ""
            threat_lines.append(f"[bright_yellow]{' '.join(tags)}{extra}[/bright_yellow]")
        if br.cis_tags:
            tags = sorted(br.cis_tags)[:3]
            extra = f" +{len(br.cis_tags) - 3}" if len(br.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}]{br.risk_score:.1f}[/{sev_style}]",
            vuln_display,
            _sev_badge(br.vulnerability.severity),
            epss_display,
            kev_display,
            blast_display,
            threats_display,
            fix,
        )

    _console().print(table)

    if len(report.blast_radii) > 25:
        _console().print(f"\n  [dim]...and {len(report.blast_radii) - 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 br in report.blast_radii:
        vid = br.vulnerability.id
        if vid in seen_ids:
            continue
        seen_ids.add(vid)
        if br.vulnerability.references:
            sources.append((vid, br.vulnerability.references[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_json(report)
    Path(output_path).write_text(json.dumps(data, indent=2))

export_html

export_html(report: AIBOMReport, output_path: str, blast_radii: list | None = None) -> 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) -> 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 [])

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.6 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.6 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 SPDX 3.0 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 SPDX 3.0 JSON-LD file."""
    data = to_spdx(report)
    Path(output_path).write_text(json.dumps(data, indent=2))