mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-23 06:30:28 +08:00
Integrate Z3-based formal verification for sandbox policies as openshell.prover. Detects data exfiltration paths, write bypass attacks, L7 enforcement gaps, overpermissive methods, and privilege escalation via binary capability chains. Closes #699 Signed-off-by: Alexander Watson <zredlined@gmail.com>
1298 lines
46 KiB
Python
1298 lines
46 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Rich terminal report, Mermaid graph, and HTML report generation for verification findings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html as html_mod
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
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,
|
|
InheritancePath,
|
|
L4PolicyGapPath,
|
|
OverpermissiveMethodPath,
|
|
RiskLevel,
|
|
WriteBypassPath,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from .binary_registry import BinaryRegistry
|
|
from .policy_parser import PolicyModel
|
|
|
|
RISK_COLORS = {
|
|
RiskLevel.CRITICAL: "bold red",
|
|
RiskLevel.HIGH: "red",
|
|
RiskLevel.MEDIUM: "yellow",
|
|
RiskLevel.LOW: "green",
|
|
RiskLevel.ADVISORY: "cyan",
|
|
}
|
|
|
|
RISK_ICONS = {
|
|
RiskLevel.CRITICAL: "CRITICAL",
|
|
RiskLevel.HIGH: "HIGH",
|
|
RiskLevel.MEDIUM: "MEDIUM",
|
|
RiskLevel.LOW: "LOW",
|
|
RiskLevel.ADVISORY: "ADVISORY",
|
|
}
|
|
|
|
|
|
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]",
|
|
"inference.local": "[bold cyan]inference.local[/bold cyan]",
|
|
"github.com:443": "[bold]github.com:443[/bold]",
|
|
"api.github.com:443": "[bold]api.github.com:443[/bold]",
|
|
}
|
|
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 ""
|
|
elif finding.query == "overpermissive_methods":
|
|
endpoints = set()
|
|
for p in finding.paths:
|
|
if hasattr(p, "endpoint_host"):
|
|
endpoints.add(f"{p.endpoint_host}:{p.endpoint_port}")
|
|
return f'method: "*" or DELETE on: {", ".join(sorted(endpoints))}'
|
|
elif finding.query == "l4_policy_gaps":
|
|
endpoints = set()
|
|
inconsistent = False
|
|
for p in finding.paths:
|
|
if hasattr(p, "endpoint_host"):
|
|
endpoints.add(f"{p.endpoint_host}:{p.endpoint_port}")
|
|
if hasattr(p, "has_sibling_l7") and p.has_sibling_l7:
|
|
inconsistent = True
|
|
prefix = "inconsistent enforcement" if inconsistent else "no protocol: rest"
|
|
return f"{prefix}: {', '.join(sorted(endpoints))}"
|
|
elif finding.query == "binary_inheritance":
|
|
return f"{len(finding.paths)} via ancestor chain"
|
|
return ""
|
|
|
|
|
|
def render_compact(
|
|
findings: list[Finding],
|
|
_policy_path: str,
|
|
_credentials_path: str,
|
|
console: Console | None = None,
|
|
) -> int:
|
|
"""Compact output for demos and 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",
|
|
"overpermissive_methods": "Overpermissive HTTP methods — destructive ops allowed",
|
|
"l4_policy_gaps": "L4-only endpoints — no HTTP inspection",
|
|
"binary_inheritance": "Binaries inherit unintended access",
|
|
"inference_relay": "inference.local bypasses policy",
|
|
}
|
|
|
|
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 active:
|
|
console.print(
|
|
f" [bold black on yellow] PASS [/bold black on yellow] advisories only{accepted_note}"
|
|
)
|
|
return 0
|
|
else:
|
|
console.print(
|
|
f" [bold white on green] PASS [/bold white on green] all findings accepted{accepted_note}"
|
|
)
|
|
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 = clean, 1 = critical/high)."""
|
|
if console is None or console.width > 80:
|
|
console = Console(width=80)
|
|
|
|
# Header
|
|
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]
|
|
|
|
# Summary table
|
|
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,
|
|
RiskLevel.MEDIUM,
|
|
RiskLevel.LOW,
|
|
RiskLevel.ADVISORY,
|
|
]:
|
|
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:
|
|
console.print(
|
|
Panel(
|
|
f"[bold red]FAIL[/bold red] — Critical gaps found.{accepted_note}",
|
|
border_style="red",
|
|
)
|
|
)
|
|
return 1
|
|
elif has_high:
|
|
console.print(
|
|
Panel(
|
|
f"[bold red]FAIL[/bold red] — High-risk gaps found.{accepted_note}",
|
|
border_style="red",
|
|
)
|
|
)
|
|
return 1
|
|
elif active:
|
|
console.print(
|
|
Panel(
|
|
f"[bold yellow]PASS[/bold yellow] — Advisories only.{accepted_note}",
|
|
border_style="yellow",
|
|
)
|
|
)
|
|
return 0
|
|
else:
|
|
console.print(
|
|
Panel(
|
|
f"[bold green]PASS[/bold green] — All findings accepted.{accepted_note}",
|
|
border_style="green",
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
def _render_paths(console: Console, paths: list) -> None:
|
|
"""Render finding paths as a tree or table 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)
|
|
elif isinstance(first, OverpermissiveMethodPath):
|
|
_render_overpermissive_paths(console, paths)
|
|
elif isinstance(first, L4PolicyGapPath):
|
|
_render_l4_gap_paths(console, paths)
|
|
elif isinstance(first, InheritancePath):
|
|
_render_inheritance_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()
|
|
|
|
|
|
def _render_overpermissive_paths(
|
|
console: Console, paths: list[OverpermissiveMethodPath]
|
|
) -> None:
|
|
table = Table(show_header=True, border_style="dim", padding=(0, 1))
|
|
table.add_column("Endpoint", style="bold")
|
|
table.add_column("Policy")
|
|
table.add_column("Allowed")
|
|
table.add_column("Needed")
|
|
table.add_column("Excess", style="red")
|
|
table.add_column("Risk Detail", max_width=40)
|
|
|
|
seen = set()
|
|
for p in paths:
|
|
key = (p.endpoint_host, p.endpoint_port, p.policy_name)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
table.add_row(
|
|
f"{p.endpoint_host}:{p.endpoint_port}",
|
|
p.policy_name,
|
|
p.allowed_methods,
|
|
p.needed_methods,
|
|
p.excess_methods,
|
|
p.risk_detail,
|
|
)
|
|
|
|
console.print(table)
|
|
console.print()
|
|
|
|
|
|
def _render_l4_gap_paths(console: Console, paths: list[L4PolicyGapPath]) -> None:
|
|
by_endpoint: dict[str, list[L4PolicyGapPath]] = {}
|
|
for p in paths:
|
|
key = f"{p.endpoint_host}:{p.endpoint_port}"
|
|
by_endpoint.setdefault(key, []).append(p)
|
|
|
|
for endpoint, eps in by_endpoint.items():
|
|
first = eps[0]
|
|
style = "red" if first.has_sibling_l7 else "yellow"
|
|
label = "INCONSISTENT" if first.has_sibling_l7 else "L4-ONLY"
|
|
tree = Tree(
|
|
f"[{style}]{label}[/{style}] [{style} bold]{endpoint}[/{style} bold]"
|
|
f" (policy: {first.policy_name})"
|
|
)
|
|
binaries = sorted({p.binary for p in eps})
|
|
tree.add(f"Binaries: {', '.join(binaries)}")
|
|
tree.add(f"[dim]{first.detail}[/dim]")
|
|
console.print(tree)
|
|
|
|
console.print()
|
|
|
|
|
|
def _render_inheritance_paths(console: Console, paths: list[InheritancePath]) -> None:
|
|
by_parent: dict[str, list[InheritancePath]] = {}
|
|
for p in paths:
|
|
by_parent.setdefault(p.parent, []).append(p)
|
|
|
|
for parent, children in by_parent.items():
|
|
tree = Tree(f"[bold]{parent}[/bold] (allowed by policy)")
|
|
for child in children:
|
|
caps = ", ".join(child.child_capabilities)
|
|
style = "red" if "bypasses_l7" in child.child_capabilities else "yellow"
|
|
tree.add(f"[{style}]{child.child}[/{style}] — [{style}]{caps}[/{style}]")
|
|
console.print(tree)
|
|
|
|
console.print()
|
|
|
|
|
|
def _m_id(prefix: str, s: str) -> str:
|
|
"""Generate a Mermaid-safe node ID from a string."""
|
|
return (
|
|
prefix
|
|
+ "_"
|
|
+ s.replace("/", "_")
|
|
.replace(".", "_")
|
|
.replace(":", "_")
|
|
.replace("-", "_")
|
|
.replace("*", "star")
|
|
)
|
|
|
|
|
|
def _m_escape(s: str) -> str:
|
|
"""Escape a string for use in Mermaid labels."""
|
|
return s.replace('"', "'").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def _m_short_binary(path: str) -> str:
|
|
"""Shorten a binary path for display."""
|
|
return path.rsplit("/", 1)[-1]
|
|
|
|
|
|
def generate_mermaid(findings: list[Finding]) -> str:
|
|
"""Generate a Mermaid findings graph showing all risk paths across query types."""
|
|
lines = [
|
|
"flowchart LR",
|
|
"",
|
|
" %% Style definitions",
|
|
" classDef l4only fill:#e74c3c,stroke:#c0392b,color:#fff",
|
|
" classDef l7bypass fill:#e67e22,stroke:#d35400,color:#fff",
|
|
" classDef l7write fill:#f39c12,stroke:#e67e22,color:#fff",
|
|
" classDef l7safe fill:#27ae60,stroke:#1e8449,color:#fff",
|
|
" classDef binary fill:#3498db,stroke:#2980b9,color:#fff",
|
|
" classDef filesystem fill:#95a5a6,stroke:#7f8c8d,color:#fff",
|
|
" classDef overperm fill:#e74c3c,stroke:#c0392b,color:#fff",
|
|
" classDef inherited fill:#9b59b6,stroke:#8e44ad,color:#fff",
|
|
"",
|
|
]
|
|
|
|
declared_nodes: set[str] = set()
|
|
edges: list[str] = []
|
|
|
|
exfil_paths: list[ExfilPath] = []
|
|
write_bypasses: list[WriteBypassPath] = []
|
|
overperm_paths: list[OverpermissiveMethodPath] = []
|
|
l4_gaps: list[L4PolicyGapPath] = []
|
|
inherit_paths: list[InheritancePath] = []
|
|
|
|
for finding in findings:
|
|
if finding.accepted:
|
|
continue
|
|
for p in finding.paths:
|
|
if isinstance(p, ExfilPath):
|
|
exfil_paths.append(p)
|
|
elif isinstance(p, WriteBypassPath):
|
|
write_bypasses.append(p)
|
|
elif isinstance(p, OverpermissiveMethodPath):
|
|
overperm_paths.append(p)
|
|
elif isinstance(p, L4PolicyGapPath):
|
|
l4_gaps.append(p)
|
|
elif isinstance(p, InheritancePath):
|
|
inherit_paths.append(p)
|
|
|
|
# Filesystem source node
|
|
if exfil_paths:
|
|
fs_id = "FS"
|
|
if fs_id not in declared_nodes:
|
|
lines.append(f' {fs_id}[("Filesystem — readable paths")]:::filesystem')
|
|
declared_nodes.add(fs_id)
|
|
|
|
# Exfiltration paths
|
|
if exfil_paths:
|
|
lines.append("")
|
|
lines.append(" subgraph exfil [Data Exfiltration]")
|
|
lines.append(" direction LR")
|
|
|
|
seen_exfil: set[tuple[str, str, int]] = set()
|
|
for p in exfil_paths:
|
|
key = (p.binary, p.endpoint_host, p.endpoint_port)
|
|
if key in seen_exfil:
|
|
continue
|
|
seen_exfil.add(key)
|
|
|
|
b_id = _m_id("B", p.binary)
|
|
e_id = _m_id("E", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
|
|
if b_id not in declared_nodes:
|
|
lines.append(f' {b_id}["{_m_short_binary(p.binary)}"]:::binary')
|
|
declared_nodes.add(b_id)
|
|
|
|
style_class = {
|
|
"l4_only": "l4only",
|
|
"l7_bypassed": "l7bypass",
|
|
"l7_allows_write": "l7write",
|
|
}.get(p.l7_status, "l7write")
|
|
label_text = {
|
|
"l4_only": "L4 only",
|
|
"l7_bypassed": "wire protocol",
|
|
"l7_allows_write": "L7 write",
|
|
}.get(p.l7_status, "")
|
|
|
|
if e_id not in declared_nodes:
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(p.endpoint_host)}:{p.endpoint_port}"]:::{style_class}'
|
|
)
|
|
declared_nodes.add(e_id)
|
|
|
|
edges.append(f" FS -.->|read| {b_id}")
|
|
edges.append(f" {b_id} -->|{label_text}| {e_id}")
|
|
|
|
lines.append(" end")
|
|
|
|
# Write bypass paths
|
|
if write_bypasses:
|
|
lines.append("")
|
|
lines.append(" subgraph wbypass [Write Bypass]")
|
|
lines.append(" direction LR")
|
|
|
|
seen_wb: set[tuple[str, str, int]] = set()
|
|
for p in write_bypasses:
|
|
key = (p.binary, p.endpoint_host, p.endpoint_port)
|
|
if key in seen_wb:
|
|
continue
|
|
seen_wb.add(key)
|
|
|
|
b_id = _m_id("B", p.binary)
|
|
e_id = _m_id("WE", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
|
|
if b_id not in declared_nodes:
|
|
lines.append(f' {b_id}["{_m_short_binary(p.binary)}"]:::binary')
|
|
declared_nodes.add(b_id)
|
|
|
|
reason_label = {
|
|
"l4_only": "L4 bypass",
|
|
"l7_bypass_protocol": "wire protocol",
|
|
}.get(p.bypass_reason, p.bypass_reason)
|
|
if e_id not in declared_nodes:
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(p.endpoint_host)}:{p.endpoint_port}'
|
|
f'<br/>intent: {p.policy_intent}"]:::l4only'
|
|
)
|
|
declared_nodes.add(e_id)
|
|
|
|
edges.append(f" {b_id} ==>|{reason_label}| {e_id}")
|
|
|
|
lines.append(" end")
|
|
|
|
# Overpermissive methods
|
|
if overperm_paths:
|
|
lines.append("")
|
|
lines.append(" subgraph operm [Overpermissive Methods]")
|
|
lines.append(" direction LR")
|
|
|
|
seen_op: set[tuple[str, int, str]] = set()
|
|
for p in overperm_paths:
|
|
key = (p.endpoint_host, p.endpoint_port, p.policy_name)
|
|
if key in seen_op:
|
|
continue
|
|
seen_op.add(key)
|
|
|
|
e_id = _m_id("OP", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
if e_id not in declared_nodes:
|
|
excess = (
|
|
p.excess_methods if len(p.excess_methods) < 30 else "DELETE, ..."
|
|
)
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(p.endpoint_host)}:{p.endpoint_port}'
|
|
f'<br/>excess: {_m_escape(excess)}"]:::overperm'
|
|
)
|
|
declared_nodes.add(e_id)
|
|
|
|
lines.append(" end")
|
|
|
|
# L4 policy gaps
|
|
if l4_gaps:
|
|
lines.append("")
|
|
lines.append(" subgraph l4gap [L4 Policy Gaps]")
|
|
lines.append(" direction LR")
|
|
|
|
seen_l4: set[tuple[str, int]] = set()
|
|
for p in l4_gaps:
|
|
key = (p.endpoint_host, p.endpoint_port)
|
|
if key in seen_l4:
|
|
continue
|
|
seen_l4.add(key)
|
|
|
|
e_id = _m_id("L4", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
label_suffix = "INCONSISTENT" if p.has_sibling_l7 else "no L7"
|
|
if e_id not in declared_nodes:
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(p.endpoint_host)}:{p.endpoint_port}'
|
|
f'<br/>{label_suffix}"]:::l4only'
|
|
)
|
|
declared_nodes.add(e_id)
|
|
|
|
b_id = _m_id("B", p.binary)
|
|
if b_id not in declared_nodes:
|
|
lines.append(f' {b_id}["{_m_short_binary(p.binary)}"]:::binary')
|
|
declared_nodes.add(b_id)
|
|
|
|
edges.append(f" {b_id} -.->|no inspection| {e_id}")
|
|
|
|
lines.append(" end")
|
|
|
|
# Binary inheritance
|
|
if inherit_paths:
|
|
lines.append("")
|
|
lines.append(" subgraph inherit [Binary Inheritance]")
|
|
lines.append(" direction TB")
|
|
|
|
seen_inh: set[tuple[str, str]] = set()
|
|
for p in inherit_paths:
|
|
key = (p.parent, p.child)
|
|
if key in seen_inh:
|
|
continue
|
|
seen_inh.add(key)
|
|
|
|
parent_id = _m_id("IB", p.parent)
|
|
child_id = _m_id("IC", p.child)
|
|
|
|
if parent_id not in declared_nodes:
|
|
lines.append(f' {parent_id}["{_m_short_binary(p.parent)}"]:::binary')
|
|
declared_nodes.add(parent_id)
|
|
if child_id not in declared_nodes:
|
|
caps = ", ".join(p.child_capabilities)
|
|
style = (
|
|
"l7bypass" if "bypasses_l7" in p.child_capabilities else "inherited"
|
|
)
|
|
lines.append(
|
|
f' {child_id}["{_m_short_binary(p.child)}'
|
|
f'<br/>{_m_escape(caps)}"]:::{style}'
|
|
)
|
|
declared_nodes.add(child_id)
|
|
|
|
edges.append(f" {parent_id} -->|spawns| {child_id}")
|
|
|
|
lines.append(" end")
|
|
|
|
# Add all edges after subgraphs
|
|
if edges:
|
|
lines.append("")
|
|
lines.append(" %% Edges")
|
|
for edge in dict.fromkeys(edges):
|
|
lines.append(edge)
|
|
|
|
if not any([exfil_paths, write_bypasses, overperm_paths, l4_gaps, inherit_paths]):
|
|
lines.append(" PASS([No active findings]):::l7safe")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def generate_mermaid_topology(
|
|
policy: PolicyModel,
|
|
binary_registry: BinaryRegistry,
|
|
findings: list[Finding],
|
|
) -> str:
|
|
"""Generate a full policy topology map showing all endpoints, binaries, and enforcement levels."""
|
|
lines = [
|
|
"flowchart LR",
|
|
"",
|
|
" %% Style definitions",
|
|
" classDef l4only fill:#e74c3c,stroke:#c0392b,color:#fff",
|
|
" classDef l7enforce fill:#27ae60,stroke:#1e8449,color:#fff",
|
|
" classDef l7warn fill:#f39c12,stroke:#e67e22,color:#fff",
|
|
" classDef binary fill:#3498db,stroke:#2980b9,color:#fff",
|
|
" classDef child fill:#9b59b6,stroke:#8e44ad,color:#fff",
|
|
" classDef filesystem fill:#95a5a6,stroke:#7f8c8d,color:#fff",
|
|
" classDef finding fill:#e74c3c,stroke:#c0392b,color:#fff,stroke-width:3px",
|
|
"",
|
|
]
|
|
|
|
declared: set[str] = set()
|
|
edges: list[str] = []
|
|
|
|
problem_endpoints: set[tuple[str, int]] = set()
|
|
problem_binaries: set[str] = set()
|
|
for f in findings:
|
|
if f.accepted:
|
|
continue
|
|
if f.query in ("l4_policy_gaps", "overpermissive_methods", "write_bypass"):
|
|
for p in f.paths:
|
|
if hasattr(p, "endpoint_host"):
|
|
problem_endpoints.add(
|
|
(p.endpoint_host, getattr(p, "endpoint_port", 443))
|
|
)
|
|
if hasattr(p, "binary"):
|
|
problem_binaries.add(p.binary)
|
|
|
|
# Filesystem
|
|
lines.append(' FS[("Filesystem")]:::filesystem')
|
|
declared.add("FS")
|
|
|
|
# Endpoints by policy group
|
|
for policy_name, rule in policy.network_policies.items():
|
|
safe_name = policy_name.replace("-", "_").replace(" ", "_")
|
|
lines.append("")
|
|
lines.append(f" subgraph {safe_name} [{policy_name}]")
|
|
lines.append(" direction TB")
|
|
|
|
for ep in rule.endpoints:
|
|
for port in ep.effective_ports:
|
|
e_id = _m_id("E", f"{ep.host}_{port}")
|
|
if e_id in declared:
|
|
continue
|
|
declared.add(e_id)
|
|
|
|
is_problem = (ep.host, port) in problem_endpoints
|
|
|
|
if ep.is_l7_enforced:
|
|
methods = ep.allowed_methods
|
|
has_write = (
|
|
bool(methods & {"POST", "PUT", "PATCH", "DELETE"})
|
|
if methods
|
|
else True
|
|
)
|
|
has_delete = "DELETE" in methods if methods else True
|
|
has_wildcard = any(r.method == "*" for r in ep.rules)
|
|
|
|
if has_wildcard or has_delete:
|
|
style = "l7warn"
|
|
enforcement_label = (
|
|
"L7 method:*" if has_wildcard else "L7 +DELETE"
|
|
)
|
|
elif has_write:
|
|
style = "l7enforce"
|
|
enforcement_label = "L7 " + ",".join(
|
|
sorted(methods - {"HEAD", "OPTIONS"})
|
|
)
|
|
else:
|
|
style = "l7enforce"
|
|
enforcement_label = "L7 read-only"
|
|
else:
|
|
style = "l4only"
|
|
enforcement_label = "L4 only"
|
|
|
|
if is_problem:
|
|
style = "finding"
|
|
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(ep.host)}:{port}<br/>{enforcement_label}"]:::{style}'
|
|
)
|
|
|
|
lines.append(" end")
|
|
|
|
# Binaries and their connections
|
|
lines.append("")
|
|
lines.append(" subgraph binaries [Binaries]")
|
|
lines.append(" direction TB")
|
|
|
|
all_binaries: dict[str, list[tuple[str, str]]] = {}
|
|
for policy_name, rule in policy.network_policies.items():
|
|
for b in rule.binaries:
|
|
for ep in rule.endpoints:
|
|
for port in ep.effective_ports:
|
|
e_id = _m_id("E", f"{ep.host}_{port}")
|
|
all_binaries.setdefault(b.path, []).append((policy_name, e_id))
|
|
|
|
for bpath in all_binaries:
|
|
b_id = _m_id("B", bpath)
|
|
if b_id not in declared:
|
|
style = "finding" if bpath in problem_binaries else "binary"
|
|
lines.append(f' {b_id}["{_m_short_binary(bpath)}"]:::{style}')
|
|
declared.add(b_id)
|
|
|
|
# Spawn chains
|
|
for bpath in all_binaries:
|
|
children = binary_registry.transitive_spawns(bpath)
|
|
for child in children:
|
|
child_id = _m_id("C", child)
|
|
if child_id not in declared:
|
|
cap = binary_registry.get_or_unknown(child)
|
|
caps = []
|
|
if cap.bypasses_l7:
|
|
caps.append("L7-bypass")
|
|
if cap.can_exfiltrate:
|
|
caps.append("exfil")
|
|
if cap.can_construct_http:
|
|
caps.append("http")
|
|
cap_str = f"<br/>{', '.join(caps)}" if caps else ""
|
|
lines.append(
|
|
f' {child_id}["{_m_short_binary(child)}{cap_str}"]:::child'
|
|
)
|
|
declared.add(child_id)
|
|
|
|
b_id = _m_id("B", bpath)
|
|
edges.append(f" {b_id} -.->|spawns| {child_id}")
|
|
|
|
lines.append(" end")
|
|
|
|
# Binary -> Endpoint edges
|
|
lines.append("")
|
|
lines.append(" %% Binary -> Endpoint access")
|
|
for bpath, endpoint_ids in all_binaries.items():
|
|
b_id = _m_id("B", bpath)
|
|
for _, e_id in endpoint_ids:
|
|
edges.append(f" {b_id} --> {e_id}")
|
|
|
|
# Filesystem -> Binary edges (for exfil-capable binaries)
|
|
for bpath in all_binaries:
|
|
cap = binary_registry.get_or_unknown(bpath)
|
|
if cap.can_exfiltrate:
|
|
b_id = _m_id("B", bpath)
|
|
edges.append(f" FS -.->|read| {b_id}")
|
|
|
|
# Deduplicated edges
|
|
if edges:
|
|
lines.append("")
|
|
lines.append(" %% Edges")
|
|
for edge in dict.fromkeys(edges):
|
|
lines.append(edge)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# Per-finding focused Mermaid diagrams
|
|
|
|
_MERMAID_STYLES = """\
|
|
classDef l4only fill:#e74c3c,stroke:#c0392b,color:#fff
|
|
classDef l7bypass fill:#e67e22,stroke:#d35400,color:#fff
|
|
classDef l7write fill:#f39c12,stroke:#e67e22,color:#fff
|
|
classDef l7safe fill:#27ae60,stroke:#1e8449,color:#fff
|
|
classDef binary fill:#3498db,stroke:#2980b9,color:#fff
|
|
classDef filesystem fill:#95a5a6,stroke:#7f8c8d,color:#fff
|
|
classDef overperm fill:#e74c3c,stroke:#c0392b,color:#fff
|
|
classDef inherited fill:#9b59b6,stroke:#8e44ad,color:#fff
|
|
classDef finding fill:#e74c3c,stroke:#c0392b,color:#fff,stroke-width:3px
|
|
classDef l7enforce fill:#27ae60,stroke:#1e8449,color:#fff
|
|
classDef l7warn fill:#f39c12,stroke:#e67e22,color:#fff
|
|
classDef child fill:#9b59b6,stroke:#8e44ad,color:#fff"""
|
|
|
|
|
|
def _mermaid_for_l4_gaps(finding: Finding) -> str:
|
|
"""Focused diagram: which endpoints are missing L7 and which binaries reach them."""
|
|
lines = ["flowchart LR", _MERMAID_STYLES, ""]
|
|
declared: set[str] = set()
|
|
|
|
by_ep: dict[tuple[str, int], list[L4PolicyGapPath]] = {}
|
|
for p in finding.paths:
|
|
if isinstance(p, L4PolicyGapPath):
|
|
by_ep.setdefault((p.endpoint_host, p.endpoint_port), []).append(p)
|
|
|
|
for (host, port), ep_paths in by_ep.items():
|
|
e_id = _m_id("E", f"{host}_{port}")
|
|
first = ep_paths[0]
|
|
label = "INCONSISTENT" if first.has_sibling_l7 else "no L7"
|
|
lines.append(f' {e_id}["{_m_escape(host)}:{port}<br/>{label}"]:::l4only')
|
|
declared.add(e_id)
|
|
|
|
for p in ep_paths:
|
|
b_id = _m_id("B", p.binary)
|
|
if b_id not in declared:
|
|
lines.append(f' {b_id}["{_m_short_binary(p.binary)}"]:::binary')
|
|
declared.add(b_id)
|
|
lines.append(f" {b_id} -->|no inspection| {e_id}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _mermaid_for_overperm(finding: Finding) -> str:
|
|
"""Focused diagram: endpoints with wildcard/excess methods."""
|
|
lines = ["flowchart LR", _MERMAID_STYLES, ""]
|
|
seen: set[tuple[str, int]] = set()
|
|
|
|
for p in finding.paths:
|
|
if not isinstance(p, OverpermissiveMethodPath):
|
|
continue
|
|
key = (p.endpoint_host, p.endpoint_port)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
e_id = _m_id("E", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
needed_id = _m_id("N", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
excess_id = _m_id("X", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(p.endpoint_host)}:{p.endpoint_port}'
|
|
f'<br/>policy: {p.policy_name}"]:::binary'
|
|
)
|
|
lines.append(f' {needed_id}(["{_m_escape(p.needed_methods)}"]):::l7safe')
|
|
lines.append(f' {excess_id}(["{_m_escape(p.excess_methods)}"]):::overperm')
|
|
lines.append(f" {e_id} -->|needed| {needed_id}")
|
|
lines.append(f" {e_id} -->|excess| {excess_id}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _mermaid_for_write_bypass(finding: Finding) -> str:
|
|
"""Focused diagram: how writes bypass read-only intent."""
|
|
lines = ["flowchart LR", _MERMAID_STYLES, ""]
|
|
declared: set[str] = set()
|
|
|
|
seen: set[tuple[str, str, int]] = set()
|
|
for p in finding.paths:
|
|
if not isinstance(p, WriteBypassPath):
|
|
continue
|
|
key = (p.binary, p.endpoint_host, p.endpoint_port)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
|
|
b_id = _m_id("B", p.binary)
|
|
e_id = _m_id("E", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
|
|
if b_id not in declared:
|
|
lines.append(f' {b_id}["{_m_short_binary(p.binary)}"]:::binary')
|
|
declared.add(b_id)
|
|
if e_id not in declared:
|
|
lines.append(
|
|
f' {e_id}["{_m_escape(p.endpoint_host)}:{p.endpoint_port}'
|
|
f'<br/>intent: {p.policy_intent}"]:::l4only'
|
|
)
|
|
declared.add(e_id)
|
|
|
|
reason = {
|
|
"l4_only": "L4 bypass",
|
|
"l7_bypass_protocol": "wire protocol",
|
|
}.get(p.bypass_reason, p.bypass_reason)
|
|
lines.append(f" {b_id} ==>|{reason}| {e_id}")
|
|
|
|
if p.credential_actions:
|
|
c_id = _m_id("CR", f"{p.endpoint_host}_{p.endpoint_port}")
|
|
if c_id not in declared:
|
|
acts = p.credential_actions[:3]
|
|
if len(p.credential_actions) > 3:
|
|
acts.append(f"+{len(p.credential_actions) - 3} more")
|
|
label = "<br/>".join(_m_escape(a) for a in acts)
|
|
lines.append(f' {c_id}["{label}"]:::overperm')
|
|
declared.add(c_id)
|
|
lines.append(f" {e_id} -.->|credential enables| {c_id}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _mermaid_for_exfil(finding: Finding) -> str:
|
|
"""Focused diagram: exfiltration paths grouped by mechanism type."""
|
|
lines = ["flowchart LR", _MERMAID_STYLES, ""]
|
|
declared: set[str] = set()
|
|
|
|
lines.append(' FS[("Filesystem — readable paths")]:::filesystem')
|
|
declared.add("FS")
|
|
|
|
by_status: dict[str, list[ExfilPath]] = {}
|
|
seen: set[tuple[str, str, int]] = set()
|
|
for p in finding.paths:
|
|
if not isinstance(p, ExfilPath):
|
|
continue
|
|
key = (p.binary, p.endpoint_host, p.endpoint_port)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
by_status.setdefault(p.l7_status, []).append(p)
|
|
|
|
status_labels = {
|
|
"l4_only": "L4-only — no inspection",
|
|
"l7_bypassed": "Wire Protocol Bypass",
|
|
"l7_allows_write": "L7 Write Channels",
|
|
}
|
|
|
|
for status, status_paths in by_status.items():
|
|
label = status_labels.get(status, status)
|
|
safe = "sg_" + status.replace("_", "")
|
|
lines.append(f" subgraph {safe} [{label}]")
|
|
lines.append(" direction LR")
|
|
|
|
style_class = {
|
|
"l4_only": "l4only",
|
|
"l7_bypassed": "l7bypass",
|
|
"l7_allows_write": "l7write",
|
|
}.get(status, "l7write")
|
|
|
|
by_binary: dict[str, list[str]] = {}
|
|
for p in status_paths:
|
|
by_binary.setdefault(p.binary, []).append(
|
|
f"{p.endpoint_host}:{p.endpoint_port}"
|
|
)
|
|
|
|
for bpath, endpoints in by_binary.items():
|
|
b_id = _m_id("B", f"{bpath}_{status}")
|
|
if b_id not in declared:
|
|
lines.append(f' {b_id}["{_m_short_binary(bpath)}"]:::binary')
|
|
declared.add(b_id)
|
|
|
|
shown = endpoints[:4]
|
|
for ep_str in shown:
|
|
e_id = _m_id("E", f"{ep_str}_{status}")
|
|
if e_id not in declared:
|
|
lines.append(f' {e_id}["{_m_escape(ep_str)}"]:::{style_class}')
|
|
declared.add(e_id)
|
|
lines.append(f" {b_id} --> {e_id}")
|
|
|
|
if len(endpoints) > 4:
|
|
more_id = _m_id("M", f"{bpath}_{status}")
|
|
lines.append(
|
|
f' {more_id}["+{len(endpoints) - 4} more"]:::{style_class}'
|
|
)
|
|
lines.append(f" {b_id} --> {more_id}")
|
|
|
|
lines.append(" end")
|
|
for bpath in by_binary:
|
|
b_id = _m_id("B", f"{bpath}_{status}")
|
|
lines.append(f" FS -.-> {b_id}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _mermaid_for_inheritance(finding: Finding) -> str:
|
|
"""Focused diagram: spawn tree from allowed binaries to inherited children."""
|
|
lines = ["flowchart TB", _MERMAID_STYLES, ""]
|
|
declared: set[str] = set()
|
|
|
|
seen_pairs: set[tuple[str, str]] = set()
|
|
for p in finding.paths:
|
|
if not isinstance(p, InheritancePath):
|
|
continue
|
|
key = (p.parent, p.child)
|
|
if key in seen_pairs:
|
|
continue
|
|
seen_pairs.add(key)
|
|
|
|
parent_id = _m_id("P", p.parent)
|
|
child_id = _m_id("C", p.child)
|
|
|
|
if parent_id not in declared:
|
|
lines.append(
|
|
f' {parent_id}["{_m_short_binary(p.parent)}<br/>allowed by policy"]:::binary'
|
|
)
|
|
declared.add(parent_id)
|
|
|
|
if child_id not in declared:
|
|
caps = ", ".join(p.child_capabilities)
|
|
style = "l7bypass" if "bypasses_l7" in p.child_capabilities else "inherited"
|
|
lines.append(
|
|
f' {child_id}["{_m_short_binary(p.child)}<br/>{_m_escape(caps)}"]:::{style}'
|
|
)
|
|
declared.add(child_id)
|
|
|
|
lines.append(f" {parent_id} -->|spawns| {child_id}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# HTML Report
|
|
|
|
_FINDING_DIAGRAM_GENERATORS = {
|
|
"l4_policy_gaps": _mermaid_for_l4_gaps,
|
|
"overpermissive_methods": _mermaid_for_overperm,
|
|
"write_bypass": _mermaid_for_write_bypass,
|
|
"data_exfiltration": _mermaid_for_exfil,
|
|
"binary_inheritance": _mermaid_for_inheritance,
|
|
}
|
|
|
|
_RISK_COLORS_HTML = {
|
|
RiskLevel.CRITICAL: "#e74c3c",
|
|
RiskLevel.HIGH: "#e67e22",
|
|
RiskLevel.MEDIUM: "#f39c12",
|
|
RiskLevel.LOW: "#27ae60",
|
|
RiskLevel.ADVISORY: "#3498db",
|
|
}
|
|
|
|
_RISK_BG_COLORS_HTML = {
|
|
RiskLevel.CRITICAL: "#fdedec",
|
|
RiskLevel.HIGH: "#fdebd0",
|
|
RiskLevel.MEDIUM: "#fef9e7",
|
|
RiskLevel.LOW: "#eafaf1",
|
|
RiskLevel.ADVISORY: "#ebf5fb",
|
|
}
|
|
|
|
|
|
def render_html(
|
|
findings: list[Finding],
|
|
policy_path: str,
|
|
_credentials_path: str,
|
|
policy: PolicyModel,
|
|
binary_registry: BinaryRegistry,
|
|
output_path: Path,
|
|
) -> int:
|
|
"""Render findings as a self-contained HTML file with per-finding Mermaid diagrams."""
|
|
policy_name = Path(policy_path).name
|
|
active = [f for f in findings if not f.accepted]
|
|
accepted_findings = [f for f in findings if f.accepted]
|
|
|
|
counts: dict[RiskLevel, int] = {}
|
|
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
|
|
verdict_pass = not (has_critical or has_high)
|
|
|
|
finding_sections = []
|
|
for _i, finding in enumerate(active, 1):
|
|
color = _RISK_COLORS_HTML[finding.risk]
|
|
bg = _RISK_BG_COLORS_HTML[finding.risk]
|
|
risk_label = finding.risk.value.upper()
|
|
|
|
diagram_html = ""
|
|
gen = _FINDING_DIAGRAM_GENERATORS.get(finding.query)
|
|
if gen and finding.paths:
|
|
mermaid_code = gen(finding)
|
|
diagram_html = f'<div class="mermaid">\n{mermaid_code}\n</div>'
|
|
|
|
remediation_html = ""
|
|
if finding.remediation:
|
|
items = "".join(
|
|
f"<li>{html_mod.escape(r)}</li>" for r in finding.remediation
|
|
)
|
|
remediation_html = f"<h4>Remediation</h4><ul>{items}</ul>"
|
|
|
|
finding_sections.append(f"""
|
|
<div class="finding" style="border-left: 4px solid {color}; background: {bg};">
|
|
<div class="finding-header">
|
|
<span class="risk-badge" style="background: {color};">{risk_label}</span>
|
|
<span class="finding-title">{html_mod.escape(finding.title)}</span>
|
|
</div>
|
|
<p class="finding-desc">{html_mod.escape(finding.description)}</p>
|
|
{diagram_html}
|
|
{remediation_html}
|
|
</div>""")
|
|
|
|
accepted_html = ""
|
|
if accepted_findings:
|
|
items = []
|
|
for f in accepted_findings:
|
|
items.append(
|
|
f"<li><strong>{f.risk.value.upper()}</strong> {html_mod.escape(f.title)}"
|
|
f"<br/><em>{html_mod.escape(f.accepted_reason)}</em></li>"
|
|
)
|
|
accepted_html = f"""
|
|
<div class="accepted-section">
|
|
<h2>Accepted Risks</h2>
|
|
<ul>{"".join(items)}</ul>
|
|
</div>"""
|
|
|
|
summary_badges = []
|
|
for level in [
|
|
RiskLevel.CRITICAL,
|
|
RiskLevel.HIGH,
|
|
RiskLevel.MEDIUM,
|
|
RiskLevel.LOW,
|
|
RiskLevel.ADVISORY,
|
|
]:
|
|
if level in counts:
|
|
color = _RISK_COLORS_HTML[level]
|
|
summary_badges.append(
|
|
f'<span class="risk-badge" style="background: {color};">'
|
|
f"{counts[level]} {level.value.upper()}</span>"
|
|
)
|
|
if accepted_findings:
|
|
summary_badges.append(
|
|
f'<span class="risk-badge" style="background: #95a5a6;">'
|
|
f"{len(accepted_findings)} ACCEPTED</span>"
|
|
)
|
|
|
|
verdict_color = "#27ae60" if verdict_pass else "#e74c3c"
|
|
verdict_text = "PASS" if verdict_pass else "FAIL"
|
|
if verdict_pass and accepted_findings:
|
|
verdict_detail = "All findings accepted"
|
|
elif verdict_pass and active:
|
|
verdict_detail = "Advisories only"
|
|
elif not verdict_pass:
|
|
n = counts.get(RiskLevel.CRITICAL, 0) + counts.get(RiskLevel.HIGH, 0)
|
|
verdict_detail = f"{n} critical/high gap{'s' if n != 1 else ''}"
|
|
else:
|
|
verdict_detail = "No findings"
|
|
|
|
topology_mermaid = generate_mermaid_topology(policy, binary_registry, findings)
|
|
|
|
page = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
<title>OPP Report — {html_mod.escape(policy_name)}</title>
|
|
<style>
|
|
:root {{ --bg: #fafafa; --text: #2c3e50; --border: #e0e0e0; --card-bg: #fff; }}
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, sans-serif;
|
|
background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem; max-width: 1100px; margin: 0 auto; }}
|
|
h1 {{ font-size: 1.5rem; margin-bottom: 0.25rem; }}
|
|
h2 {{ font-size: 1.2rem; margin: 2rem 0 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }}
|
|
h4 {{ font-size: 0.9rem; margin: 1rem 0 0.5rem; color: #555; }}
|
|
.subtitle {{ color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }}
|
|
.verdict {{ display: inline-block; padding: 0.4rem 1rem; border-radius: 4px; color: #fff;
|
|
font-weight: 700; font-size: 1.1rem; margin-bottom: 0.25rem; }}
|
|
.verdict-detail {{ color: #666; font-size: 0.9rem; margin-left: 0.5rem; }}
|
|
.summary {{ display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }}
|
|
.risk-badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 3px;
|
|
color: #fff; font-size: 0.8rem; font-weight: 600; }}
|
|
.finding {{ background: var(--card-bg); border-radius: 6px; padding: 1.25rem; margin-bottom: 1.25rem; }}
|
|
.finding-header {{ display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }}
|
|
.finding-title {{ font-weight: 600; font-size: 1rem; }}
|
|
.finding-desc {{ color: #555; font-size: 0.9rem; margin-bottom: 1rem; }}
|
|
.finding ul {{ padding-left: 1.5rem; font-size: 0.85rem; color: #555; }}
|
|
.finding li {{ margin-bottom: 0.3rem; }}
|
|
.mermaid {{ background: #fff; border: 1px solid var(--border); border-radius: 4px;
|
|
padding: 1rem; margin: 1rem 0; overflow-x: auto; }}
|
|
.accepted-section {{ background: #f5f5f5; border-radius: 6px; padding: 1.25rem; margin-top: 2rem; }}
|
|
.accepted-section ul {{ padding-left: 1.5rem; font-size: 0.85rem; color: #666; }}
|
|
.accepted-section li {{ margin-bottom: 0.5rem; }}
|
|
.topology-section {{ margin-top: 2rem; }}
|
|
.legend {{ display: flex; gap: 1rem; flex-wrap: wrap; margin: 0.75rem 0; font-size: 0.8rem; }}
|
|
.legend-item {{ display: flex; align-items: center; gap: 0.3rem; }}
|
|
.legend-swatch {{ width: 14px; height: 14px; border-radius: 3px; display: inline-block; }}
|
|
footer {{ margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border);
|
|
font-size: 0.8rem; color: #999; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>OpenShell Policy Prover</h1>
|
|
<div class="subtitle">Policy: {html_mod.escape(policy_name)}</div>
|
|
|
|
<div>
|
|
<span class="verdict" style="background: {verdict_color};">{verdict_text}</span>
|
|
<span class="verdict-detail">{html_mod.escape(verdict_detail)}</span>
|
|
</div>
|
|
<div class="summary">{"".join(summary_badges)}</div>
|
|
|
|
<h2>Findings</h2>
|
|
{"".join(finding_sections) if finding_sections else '<p style="color: #27ae60; font-weight: 600;">No active findings.</p>'}
|
|
|
|
{accepted_html}
|
|
|
|
<div class="topology-section">
|
|
<h2>Policy Topology</h2>
|
|
<p style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">
|
|
Full map of all endpoints, binaries, and enforcement levels.
|
|
Red nodes have structural findings. Green nodes are properly configured.
|
|
</p>
|
|
<div class="legend">
|
|
<span class="legend-item"><span class="legend-swatch" style="background: #27ae60;"></span> L7 enforced</span>
|
|
<span class="legend-item"><span class="legend-swatch" style="background: #f39c12;"></span> L7 warn (wildcard)</span>
|
|
<span class="legend-item"><span class="legend-swatch" style="background: #e74c3c;"></span> L4 only / finding</span>
|
|
<span class="legend-item"><span class="legend-swatch" style="background: #3498db;"></span> Binary</span>
|
|
<span class="legend-item"><span class="legend-swatch" style="background: #9b59b6;"></span> Inherited child</span>
|
|
</div>
|
|
<div class="mermaid">
|
|
{topology_mermaid}
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
Generated by OpenShell Policy Prover (OPP)
|
|
</footer>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
<script>mermaid.initialize({{ startOnLoad: true, theme: "neutral", securityLevel: "loose" }});</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
output_path.write_text(page)
|
|
return 1 if not verdict_pass else 0
|