mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-22 06:19:47 +08:00
Narrow OPP to two yes/no security questions: 1. Can data leave this sandbox? (exfiltration) 2. Can the agent write despite read-only intent? (write bypass) Removes 4 advisory/lint queries (overpermissive methods, L4 gaps, binary inheritance, inference relay), 3 output modes (Mermaid, topology, HTML), and fixes 3 bugs (include_workdir not used in exfil analysis, __all__ broken when protos missing, CLI PYTHONPATH). ~40% line reduction (2900 -> 1769 lines). Signed-off-by: Alexander Watson <zredlined@gmail.com>
308 lines
9.9 KiB
Python
308 lines
9.9 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Terminal report and compact CI output for verification findings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
from rich.tree import Tree
|
|
|
|
from .queries import (
|
|
ExfilPath,
|
|
Finding,
|
|
RiskLevel,
|
|
WriteBypassPath,
|
|
)
|
|
|
|
RISK_COLORS = {
|
|
RiskLevel.CRITICAL: "bold red",
|
|
RiskLevel.HIGH: "red",
|
|
}
|
|
|
|
RISK_ICONS = {
|
|
RiskLevel.CRITICAL: "CRITICAL",
|
|
RiskLevel.HIGH: "HIGH",
|
|
}
|
|
|
|
|
|
def _hl(text: str) -> str:
|
|
"""Highlight key terms in finding summaries with rich markup."""
|
|
highlights = {
|
|
"L4-only": "[bold red]L4-only[/bold red]",
|
|
"L7": "[bold green]L7[/bold green]",
|
|
"wire protocol": "[bold red]wire protocol[/bold red]",
|
|
"no HTTP inspection": "[bold red]no HTTP inspection[/bold red]",
|
|
}
|
|
for term, markup in highlights.items():
|
|
text = text.replace(term, markup)
|
|
return text
|
|
|
|
|
|
def _compact_detail(finding: Finding) -> str:
|
|
"""Generate a short detail string for compact output."""
|
|
if finding.query == "data_exfiltration":
|
|
by_status: dict[str, set[str]] = {}
|
|
for p in finding.paths:
|
|
if hasattr(p, "l7_status") and hasattr(p, "endpoint_host"):
|
|
by_status.setdefault(p.l7_status, set()).add(
|
|
f"{p.endpoint_host}:{p.endpoint_port}"
|
|
)
|
|
parts = []
|
|
if "l4_only" in by_status:
|
|
parts.append(f"L4-only: {', '.join(sorted(by_status['l4_only']))}")
|
|
if "l7_bypassed" in by_status:
|
|
parts.append(
|
|
f"wire protocol bypass: {', '.join(sorted(by_status['l7_bypassed']))}"
|
|
)
|
|
if "l7_allows_write" in by_status:
|
|
parts.append(f"L7 write: {', '.join(sorted(by_status['l7_allows_write']))}")
|
|
return "; ".join(parts)
|
|
elif finding.query == "write_bypass":
|
|
reasons = set()
|
|
endpoints = set()
|
|
for p in finding.paths:
|
|
if hasattr(p, "bypass_reason"):
|
|
reasons.add(p.bypass_reason)
|
|
if hasattr(p, "endpoint_host"):
|
|
endpoints.add(f"{p.endpoint_host}:{p.endpoint_port}")
|
|
ep_list = ", ".join(sorted(endpoints))
|
|
if "l4_only" in reasons and "l7_bypass_protocol" in reasons:
|
|
return f"L4-only + wire protocol: {ep_list}"
|
|
if "l4_only" in reasons:
|
|
return f"L4-only (no inspection): {ep_list}"
|
|
if "l7_bypass_protocol" in reasons:
|
|
return f"wire protocol bypasses L7: {ep_list}"
|
|
return ""
|
|
|
|
|
|
def render_compact(
|
|
findings: list[Finding],
|
|
_policy_path: str,
|
|
_credentials_path: str,
|
|
console: Console | None = None,
|
|
) -> int:
|
|
"""Compact output for CI. Two lines per finding: summary + detail."""
|
|
if console is None or console.width > 72:
|
|
console = Console(width=72)
|
|
|
|
active = [f for f in findings if not f.accepted]
|
|
accepted_findings = [f for f in findings if f.accepted]
|
|
|
|
compact_titles = {
|
|
"data_exfiltration": "Data exfiltration possible",
|
|
"write_bypass": "Write bypass — read-only intent violated",
|
|
}
|
|
|
|
for finding in active:
|
|
style = RISK_COLORS[finding.risk]
|
|
label = RISK_ICONS[finding.risk]
|
|
title = compact_titles.get(finding.query, finding.title)
|
|
detail = _compact_detail(finding)
|
|
|
|
console.print(f" [{style}]{label:>8}[/{style}] {_hl(title)}")
|
|
if detail:
|
|
console.print(f" {_hl(detail)}")
|
|
console.print()
|
|
|
|
for finding in accepted_findings:
|
|
title = compact_titles.get(finding.query, finding.title)
|
|
console.print(f" [dim]ACCEPTED {title}[/dim]")
|
|
|
|
if accepted_findings:
|
|
console.print()
|
|
|
|
counts = {}
|
|
for f in active:
|
|
counts[f.risk] = counts.get(f.risk, 0) + 1
|
|
has_critical = RiskLevel.CRITICAL in counts
|
|
has_high = RiskLevel.HIGH in counts
|
|
accepted_note = f", {len(accepted_findings)} accepted" if accepted_findings else ""
|
|
|
|
if has_critical or has_high:
|
|
n = counts.get(RiskLevel.CRITICAL, 0) + counts.get(RiskLevel.HIGH, 0)
|
|
console.print(
|
|
f" [bold white on red] FAIL [/bold white on red] {n} critical/high gaps{accepted_note}"
|
|
)
|
|
return 1
|
|
elif accepted_findings:
|
|
console.print(
|
|
f" [bold white on green] PASS [/bold white on green] all findings accepted{accepted_note}"
|
|
)
|
|
return 0
|
|
else:
|
|
console.print(" [bold white on green] PASS [/bold white on green] no findings")
|
|
return 0
|
|
|
|
|
|
def render_report(
|
|
findings: list[Finding],
|
|
policy_path: str,
|
|
credentials_path: str,
|
|
console: Console | None = None,
|
|
) -> int:
|
|
"""Render findings to the terminal. Returns exit code (0 = pass, 1 = fail)."""
|
|
if console is None or console.width > 80:
|
|
console = Console(width=80)
|
|
|
|
policy_name = Path(policy_path).name
|
|
creds_name = Path(credentials_path).name
|
|
|
|
console.print()
|
|
console.print(
|
|
Panel(
|
|
"[bold]OpenShell Policy Prover[/bold]",
|
|
border_style="blue",
|
|
)
|
|
)
|
|
console.print(f" Policy: {policy_name}")
|
|
console.print(f" Credentials: {creds_name}")
|
|
console.print()
|
|
|
|
active = [f for f in findings if not f.accepted]
|
|
accepted_findings = [f for f in findings if f.accepted]
|
|
|
|
counts = {}
|
|
for f in active:
|
|
counts[f.risk] = counts.get(f.risk, 0) + 1
|
|
|
|
summary = Table(title="Finding Summary", show_header=True, border_style="dim")
|
|
summary.add_column("Risk Level", style="bold")
|
|
summary.add_column("Count", justify="right")
|
|
for level in [RiskLevel.CRITICAL, RiskLevel.HIGH]:
|
|
if level in counts:
|
|
style = RISK_COLORS[level]
|
|
summary.add_row(
|
|
Text(RISK_ICONS[level], style=style),
|
|
Text(str(counts[level]), style=style),
|
|
)
|
|
if accepted_findings:
|
|
summary.add_row(
|
|
Text("ACCEPTED", style="dim"),
|
|
Text(str(len(accepted_findings)), style="dim"),
|
|
)
|
|
console.print(summary)
|
|
console.print()
|
|
|
|
if not active and not accepted_findings:
|
|
console.print("[bold green]No findings. Policy posture is clean.[/bold green]")
|
|
return 0
|
|
|
|
for i, finding in enumerate(active, 1):
|
|
risk_style = RISK_COLORS[finding.risk]
|
|
risk_label = RISK_ICONS[finding.risk]
|
|
|
|
console.print(
|
|
Panel(
|
|
f"[{risk_style}]{risk_label}[/{risk_style}] {finding.title}",
|
|
border_style=risk_style,
|
|
title=f"Finding #{i}",
|
|
title_align="left",
|
|
)
|
|
)
|
|
console.print(f" {finding.description}")
|
|
console.print()
|
|
|
|
if finding.paths:
|
|
_render_paths(console, finding.paths)
|
|
|
|
if finding.remediation:
|
|
console.print(" [bold]Remediation:[/bold]")
|
|
for r in finding.remediation:
|
|
console.print(f" - {r}")
|
|
console.print()
|
|
|
|
if accepted_findings:
|
|
console.print(Panel("[dim]Accepted Risks[/dim]", border_style="dim"))
|
|
for finding in accepted_findings:
|
|
console.print(f" [dim]{RISK_ICONS[finding.risk]} {finding.title}[/dim]")
|
|
console.print(f" [dim]Reason: {finding.accepted_reason}[/dim]")
|
|
console.print()
|
|
|
|
has_critical = RiskLevel.CRITICAL in counts
|
|
has_high = RiskLevel.HIGH in counts
|
|
accepted_note = f" ({len(accepted_findings)} accepted)" if accepted_findings else ""
|
|
|
|
if has_critical or has_high:
|
|
console.print(
|
|
Panel(
|
|
f"[bold red]FAIL[/bold red] — Critical/high gaps found.{accepted_note}",
|
|
border_style="red",
|
|
)
|
|
)
|
|
return 1
|
|
elif accepted_findings:
|
|
console.print(
|
|
Panel(
|
|
f"[bold green]PASS[/bold green] — All findings accepted.{accepted_note}",
|
|
border_style="green",
|
|
)
|
|
)
|
|
return 0
|
|
else:
|
|
console.print(
|
|
Panel(
|
|
"[bold green]PASS[/bold green] — No findings.",
|
|
border_style="green",
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
def _render_paths(console: Console, paths: list) -> None:
|
|
"""Render finding paths as a table or tree depending on type."""
|
|
if not paths:
|
|
return
|
|
|
|
first = paths[0]
|
|
if isinstance(first, ExfilPath):
|
|
_render_exfil_paths(console, paths)
|
|
elif isinstance(first, WriteBypassPath):
|
|
_render_write_bypass_paths(console, paths)
|
|
|
|
|
|
def _render_exfil_paths(console: Console, paths: list[ExfilPath]) -> None:
|
|
table = Table(show_header=True, border_style="dim", padding=(0, 1))
|
|
table.add_column("Binary", style="bold")
|
|
table.add_column("Endpoint")
|
|
table.add_column("L7 Status")
|
|
table.add_column("Mechanism", max_width=60)
|
|
|
|
for p in paths:
|
|
l7_style = {
|
|
"l4_only": "bold red",
|
|
"l7_bypassed": "red",
|
|
"l7_allows_write": "yellow",
|
|
}.get(p.l7_status, "white")
|
|
|
|
table.add_row(
|
|
p.binary,
|
|
f"{p.endpoint_host}:{p.endpoint_port}",
|
|
Text(p.l7_status, style=l7_style),
|
|
p.mechanism,
|
|
)
|
|
|
|
console.print(table)
|
|
console.print()
|
|
|
|
|
|
def _render_write_bypass_paths(console: Console, paths: list[WriteBypassPath]) -> None:
|
|
for p in paths:
|
|
tree = Tree(f"[bold]{p.binary}[/bold] -> {p.endpoint_host}:{p.endpoint_port}")
|
|
tree.add(f"Policy: {p.policy_name} (intent: {p.policy_intent})")
|
|
tree.add(f"[red]Bypass: {p.bypass_reason}[/red]")
|
|
if p.credential_actions:
|
|
cred_branch = tree.add("Credential enables:")
|
|
for action in p.credential_actions[:5]:
|
|
cred_branch.add(action)
|
|
if len(p.credential_actions) > 5:
|
|
cred_branch.add(f"... and {len(p.credential_actions) - 5} more")
|
|
console.print(tree)
|
|
|
|
console.print()
|