Files
OpenShell/examples/policy-advisor/ctf.py
John T. Myers 454327d890 feat(policy): add policy recommendation plumbing (#204) (#222)
* feat(policy): add policy recommendation plumbing — denial aggregation, transport, approval pipeline, and mechanistic recommendations

Implement the infrastructure layer for automated policy recommendations (#204):

- Proto: 9 new RPCs and messages for draft policy lifecycle (submit, get, approve, reject, approve-all, edit, undo, clear, history)
- Persistence: SQLite/Postgres migrations and store methods for draft_policy_chunks and denial_summaries tables
- Server: Full gRPC handler implementations with mechanistic mapper that auto-generates NetworkPolicyRule proposals from denial summaries
- Sandbox: DenialAggregator with MPSC channel, deduplication, periodic flush to gateway via SubmitPolicyAnalysis
- CLI: 'openshell draft' subcommand with get/approve/reject/approve-all/undo/clear/history operations
- TUI: Draft recommendations panel accessible from sandbox policy view
- Docs: Architecture documentation in architecture/policy-advisor.md

* feat(policy): add L7-aware mechanistic mapper and policy advisor CTF example

Add L7 rule generation to mechanistic mapper (build_l7_rules,
generalise_path, looks_like_id) with 3 new unit tests. Add
examples/policy-advisor/ with a 7-gate CTF script, restrictive
sandbox policy, and walkthrough README.

* fix(policy): use sandbox name for denial flush and add TUI draft badges

Fix denial aggregator passing sandbox UUID instead of name to
SubmitPolicyAnalysis, which caused 'sandbox not found' errors on
flush. Add notification badges to the TUI sandbox list and detail
header showing pending draft recommendation counts.

* fix(policy): deduplicate draft chunks and tolerate overlapping OPA rules

Skip draft chunk creation when a pending/approved chunk already covers
the same host:port endpoint, preventing duplicate rules across denial
aggregator flush cycles.

Rewrite three OPA complete rules (network_policy_for_request,
matched_network_policy, matched_endpoint_config) to tolerate multiple
matching policies without triggering a "complete rule conflict" error.
network_policy_for_request becomes a boolean, matched_network_policy
uses a set comprehension with min(), and matched_endpoint_config uses
an array comprehension with index-0 selection.

* feat(tui): interactive draft actions, highlight bar, and detail popup

Rework the draft recommendations panel to match the logs UX:
- Highlight bar (green accent + background) instead of arrow marker
- Viewport-aware j/k scrolling with g/G for top/bottom
- Enter opens a full-screen detail popup showing endpoints, binaries,
  rationale, security notes, and action hints

Add approve/reject/approve-all draft actions:
- [a] approve selected chunk, [x] reject, [A] approve all pending
- Actions work from both the list view and the detail popup
- gRPC calls run async; result updates status bar and refreshes data
- Nav bar shows all available keybindings

Fix draft count refresh: sandbox_draft_counts now refreshes on every
tick (not just Dashboard), so the detail header badge updates in
real time.

Improve badge labels: show 'N pending' instead of a bare number in
both the dashboard sandbox list and sandbox detail header.

* refactor(policy): DB-level draft chunk dedup with hit counter and timestamps

Replace the in-memory HashSet dedup in SubmitPolicyAnalysis with a
database-level upsert. New denormalized columns on draft_policy_chunks:
- host, port: extracted from proposed_rule at insert time
- hit_count: incremented on conflict (same sandbox + host + port)
- first_seen_ms, last_seen_ms: track when the endpoint was first and
  most recently proposed

A partial unique index (WHERE status IN ('pending','approved')) ensures
only one active chunk per endpoint per sandbox; rejected/superseded
chunks don't block new proposals.

Surface hit_count and first/last_seen in:
- CLI: 'openshell draft get' shows 'Hits: N (first ..., last ...)'
- TUI: detail popup shows hits row; list view shows 'Nx' suffix

* fix(policy): optimistic retry on policy version conflicts + structured logging

merge_chunk_into_policy and remove_chunk_from_policy now retry up to 5
times on UNIQUE constraint violations (version conflicts from concurrent
approvals). Each attempt re-reads the latest policy, re-merges the rule,
and increments the version. This eliminates the race condition where
rapid successive approvals would fail with a DB error.

Add structured tracing to all draft action handlers:
- ApproveDraftChunk: logs rule_name, host, port, hit_count before merge
  and version + policy_hash after success
- RejectDraftChunk: logs rule_name, host, port, reason
- ApproveAllDraftChunks: logs pending_count at start, per-chunk merge
  progress, and final summary with chunks_approved/skipped
- UndoDraftChunk: logs before/after with rule_name and version
- Retry attempts log as warnings with attempt number and conflicting
  version

* wip: forward proxy fix, mapper allowed_ips, TUI polish, CTF rewrite

* fix(tui): use correct --gateway flag for ssh-proxy ProxyCommand

* chore: add Docker cleanup script for stale images, volumes, and build cache

* feat(tui): approve-all confirmation modal and CTF cleanup

Add [A] confirmation popup that snapshots pending chunks, shows a
scrollable list, and approves each chunk individually on confirm.
This prevents approving chunks that arrived after the modal opened.

Remove transient issue #205 reference from CTF victory banner.

* fix(tui): correct import ordering for rustfmt

* wip: stateful toggle model, rename to network rules

Draft chunks now follow a toggle state machine:
  pending -> approved | rejected (initial decision)
  approved <-> rejected (toggle)

One row per (sandbox_id, host, port) via expanded unique index.
Rejecting an approved rule removes it from the active policy.
Re-approving a rejected rule merges it back.

Rename CLI from 'draft' to 'rule', TUI from 'Draft Recommendations'
to 'Network Rules'. State-aware keybindings: approved shows [x] Revoke,
rejected shows [a] Approve. Fix sandbox detail hiding delete confirmation
behind pending message.

* refactor(policy): move mapper sandbox-side, slim schema, per-binary granularity

Move mechanistic mapper from gateway to sandbox so all analysis runs
sandbox-side (N sandboxes = N independent pipelines). Gateway is now a
thin validate + persist + approval layer.

Architectural changes:
- Move mechanistic_mapper.rs from navigator-server to navigator-sandbox
- Sandbox flush flow: aggregator drains -> mapper runs -> proposals sent
- Gateway SubmitPolicyAnalysis: validate + persist only, no mapper
- Drop denial_summaries table (write-only, zero readers)
- Consolidate migrations 003+004+005 into single 003

Schema slimming:
- Drop 5 unused columns from draft_policy_chunks (stage, denial_refs,
  supersedes_chunk_id, analysis_mode, decided_by)
- Add per-binary granularity: binary column, widen unique index to
  (sandbox_id, host, port, binary)
- Mapper groups by (host, port, binary), one proposal per triple
- Merge appends binary to existing rule; revoke removes just that binary

CTF & UX:
- 7-gate CTF: add Gate 3 (curl -> ifconfig.me:80) for per-binary demo
- TUI shows binary short name in list, full path in detail popup
- CLI output shows binary field
- Idempotent rule names, hit_count accumulates real denial counts
- Rationale text no longer bakes in stale denial count
2026-03-12 08:28:26 -07:00

543 lines
17 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
Policy Advisor CTF -- Mechanistic Mode
A capture-the-flag challenge that exercises OpenShell's policy recommendation
pipeline. Run this inside a sandbox with the restrictive policy, then use the
TUI to approve mechanistic recommendations and unlock each gate.
Gate 1 tests the basic HTTPS flow (CONNECT tunnel).
Gate 2 tests plain HTTP (forward proxy path).
Gate 3 uses curl to hit an endpoint only it accesses (per-binary tracking).
Gates 4-6 fire concurrently to test batch approval.
Usage:
python3 ctf.py # run all gates
python3 ctf.py --dry-run # print gate list without making requests
"""
from __future__ import annotations
import json
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime
# -- Terminal formatting -------------------------------------------------------
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
RETRY_INTERVAL = 10 # seconds between retries
MAX_RETRIES = 180 # ~30 minutes max per gate
def log(level: str, msg: str, **kv: object) -> None:
"""Structured log line with timestamp and colour."""
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
colours = {
"INFO": BLUE,
"GATE": CYAN,
"PASS": GREEN,
"FAIL": RED,
"WARN": YELLOW,
"FLAG": MAGENTA,
}
c = colours.get(level, "")
extra = " ".join(f"{DIM}{k}={v}{RESET}" for k, v in kv.items())
print(f" {DIM}{ts}{RESET} {c}{level:4}{RESET} {msg} {extra}", flush=True)
# -- Gate definitions ----------------------------------------------------------
GATES: list[dict] = [
# -- Gate 1: HTTPS on 443 (CONNECT tunnel) --------------------------------
{
"num": 1,
"name": "The Ping",
"host": "httpbin.org",
"port": 443,
"url": "https://httpbin.org/get",
"method": "GET",
"headers": {"Accept": "application/json"},
"body": None,
"hint": "A simple HTTPS echo to prove the CONNECT tunnel works.",
"extract": lambda d: f"origin = {json.loads(d).get('origin', '?')}",
},
# -- Gate 2: plain HTTP on 80 (forward proxy, python) ----------------------
{
"num": 2,
"name": "The Cartographer",
"host": "ip-api.com",
"port": 80,
"url": "http://ip-api.com/json/?fields=status,country,city,query",
"method": "GET",
"headers": {},
"body": None,
"hint": "Navigate the unencrypted waters of port 80 (python).",
"extract": lambda d: (
"{city}, {country} ({query})".format_map(json.loads(d))
if json.loads(d).get("status") == "success"
else json.loads(d).get("message", "?")
),
},
# -- Gate 3: curl-only endpoint (per-binary granularity) -----------------
{
"num": 3,
"name": "The Cartographer's Apprentice",
"host": "ifconfig.me",
"port": 80,
"url": "http://ifconfig.me",
"method": "GET",
"headers": {},
"body": None,
"hint": "curl charts its own course -- a different endpoint only it can reach.",
"use_curl": True,
"extract": lambda d: f"public IP = {d.strip()}",
},
# -- Gates 4-6: concurrent HTTPS requests (batch approval) ----------------
{
"num": 4,
"name": "The Oracle",
"host": "api.github.com",
"port": 443,
"url": "https://api.github.com/zen",
"method": "GET",
"headers": {
"User-Agent": "openshell-ctf",
"Accept": "application/vnd.github+json",
},
"body": None,
"hint": "Ancient wisdom from the code forge.",
"extract": lambda d: d.strip()[:80],
},
{
"num": 5,
"name": "The Jester",
"host": "icanhazdadjoke.com",
"port": 443,
"url": "https://icanhazdadjoke.com/",
"method": "GET",
"headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"},
"body": None,
"hint": "Laughter unlocks the fourth seal.",
"extract": lambda d: json.loads(d).get("joke", "?")[:120],
},
{
"num": 6,
"name": "The Sphinx",
"host": "catfact.ninja",
"port": 443,
"url": "https://catfact.ninja/fact",
"method": "GET",
"headers": {"Accept": "application/json", "User-Agent": "openshell-ctf"},
"body": None,
"hint": "Answer the Sphinx's riddle.",
"extract": lambda d: json.loads(d).get("fact", "?")[:120],
},
# -- Gate 7: HTTPS to internal IP (allowed_ips SSRF override) -------------
{
"num": 7,
"name": "The Vault",
"host": "gitlab-master.nvidia.com",
"port": 443,
"url": "https://gitlab-master.nvidia.com/",
"method": "GET",
"headers": {"User-Agent": "openshell-ctf"},
"body": None,
"hint": "Reach behind the firewall -- this host resolves to a private IP.",
"extract": lambda d: f"page length = {len(d)} bytes",
},
]
# -- Network request logic ----------------------------------------------------
def _is_proxy_block(exc: Exception) -> bool:
"""Heuristic: did the sandbox proxy reject this connection?"""
msg = str(exc).lower()
return any(
tok in msg
for tok in ("403", "forbidden", "connection refused", "connection reset")
)
def attempt_gate_curl(gate: dict) -> tuple[str, str]:
"""Try to pass through a gate using curl as the binary.
Returns the same tuple convention as ``attempt_gate``.
"""
try:
result = subprocess.run(
["curl", "-sS", "--max-time", "15", gate["url"]],
capture_output=True,
text=True,
timeout=20,
)
if result.returncode == 0 and result.stdout:
flag = gate["extract"](result.stdout)
return "pass", flag
stderr = result.stderr.strip().lower()
if any(tok in stderr for tok in ("403", "forbidden", "refused", "reset")):
return (
"blocked",
f"blocked by sandbox proxy (curl: {result.stderr.strip()[:80]})",
)
if result.returncode != 0:
return (
"blocked",
f"curl failed (rc={result.returncode}: {result.stderr.strip()[:80]})",
)
return "blocked", "curl returned empty response"
except subprocess.TimeoutExpired:
return "blocked", "curl timed out"
except FileNotFoundError:
return "error", "curl not found in sandbox"
except Exception as exc: # noqa: BLE001
return "error", f"unexpected curl error ({exc})"
def attempt_gate(gate: dict) -> tuple[str, str]:
"""Try to pass through a gate.
Returns ``("pass", flag)`` on success, ``("blocked", reason)`` when the
proxy denied the connection (retryable), or ``("error", detail)`` for a
real upstream failure (not retryable).
"""
if gate.get("use_curl"):
return attempt_gate_curl(gate)
try:
req = urllib.request.Request(
gate["url"],
headers=gate.get("headers") or {},
method=gate["method"],
)
if gate.get("body"):
req.data = gate["body"].encode("utf-8")
with urllib.request.urlopen(req, timeout=15) as resp:
data = resp.read().decode("utf-8")
flag = gate["extract"](data)
return "pass", flag
except urllib.error.HTTPError as exc:
if exc.code == 403:
return "blocked", "blocked by sandbox proxy (403)"
return "error", f"HTTP {exc.code} from {gate['host']}"
except urllib.error.URLError as exc:
if _is_proxy_block(exc):
return "blocked", "blocked by sandbox proxy"
reason = str(exc.reason)
if "timed out" in reason:
return "blocked", "connection timed out"
return "blocked", f"connection failed ({reason})"
except (ConnectionError, OSError, socket.timeout) as exc:
if _is_proxy_block(exc):
return "blocked", "connection refused by proxy"
return "blocked", f"network error ({exc})"
except Exception as exc: # noqa: BLE001
return "error", f"unexpected error ({exc})"
# -- Banner / victory ----------------------------------------------------------
BANNER = f"""
{CYAN}{BOLD}\
+============================================================+
| POLICY ADVISOR CTF -- MECHANISTIC MODE |
+============================================================+
| |
| Your sandbox blocks all traffic except api.anthropic.com |
| 7 gates stand between you and victory. |
| |
| Gate 1 HTTPS endpoint (CONNECT tunnel) |
| Gate 2 HTTP endpoint (forward proxy, python) |
| Gate 3 curl-only endpoint (per-binary granularity) |
| Gate 4-6 Concurrent requests (batch approval) |
| Gate 7 Internal IP endpoint (allowed_ips SSRF override) |
| |
+============================================================+\
{RESET}
"""
VICTORY = f"""
{GREEN}{BOLD}\
+============================================================+
| |
| * ALL 7 GATES UNLOCKED * |
| |
| You've mastered mechanistic policy recommendations. |
| |
| Each denied connection was detected by the sandbox |
| proxy, analyzed by the sandbox-side mechanistic mapper, |
| and submitted to the gateway as a NetworkPolicyRule |
| for your approval. |
| |
+============================================================+\
{RESET}
"""
# -- Dry-run -------------------------------------------------------------------
def dry_run() -> None:
"""Print gate list without making any network requests."""
print(BANNER)
log("INFO", "Dry-run mode -- listing gates only")
print()
for g in GATES:
proto = "HTTPS" if g["port"] == 443 else "HTTP"
concurrent = " (concurrent)" if 4 <= g["num"] <= 6 else ""
print(
f" {CYAN}Gate {g['num']}{RESET} "
f"{BOLD}{g['name']}{RESET} "
f"{DIM}{g['host']}:{g['port']} {g['method']} {proto}{concurrent}{RESET}"
)
print(f" {DIM}{g['hint']}{RESET}")
print()
log("INFO", "Run without --dry-run inside a sandbox to start the challenge")
# -- Sequential gate runner ----------------------------------------------------
def run_gate(gate: dict) -> bool:
"""Run a single gate with retry loop. Returns True on success."""
num = gate["num"]
total = len(GATES)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print(f" {CYAN}{BOLD} GATE {num}/{total}: {gate['name'].upper()}{RESET}")
proto = "https" if gate["port"] == 443 else "http"
print(
f" {DIM} {gate['hint']}{RESET}\n"
f" {DIM} target: {proto}://{gate['host']}:{gate['port']} "
f"({gate['method']}){RESET}"
)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print()
for attempt in range(1, MAX_RETRIES + 1):
log(
"GATE",
f"Gate {num} attempt #{attempt}",
host=gate["host"],
port=gate["port"],
)
status, result = attempt_gate(gate)
if status == "pass":
log("PASS", f"Gate {num} UNLOCKED")
log("FLAG", result)
print()
return True
if status == "error":
log("WARN", f"Gate {num} skipped: {result}")
log("INFO", "This is an upstream error, not a proxy block")
print()
return True
log("FAIL", f"Gate {num}: {result}")
if attempt == 1:
log("WARN", "Approve the recommendation in the TUI to proceed")
log(
"INFO",
"TUI: select sandbox -> [r] drafts -> [a] approve",
)
for remaining in range(RETRY_INTERVAL, 0, -1):
print(
f"\r {DIM} retrying in {remaining:>2}s ...{RESET}",
end="",
flush=True,
)
time.sleep(1)
print("\r" + " " * 50 + "\r", end="", flush=True)
log("FAIL", f"Gate {num} timed out after {MAX_RETRIES} attempts")
return False
# -- Concurrent gate runner ----------------------------------------------------
def run_gates_concurrent(gates: list[dict]) -> int:
"""Run multiple gates concurrently, retrying until all pass.
Returns the number of gates that passed.
"""
nums = ", ".join(str(g["num"]) for g in gates)
total = len(GATES)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print(f" {CYAN}{BOLD} GATES {nums} (CONCURRENT):{RESET}")
for g in gates:
proto = "https" if g["port"] == 443 else "http"
print(
f" {DIM} Gate {g['num']}: {g['name']} "
f"{proto}://{g['host']}:{g['port']}{RESET}"
)
print(f" {CYAN}{BOLD}{'=' * 60}{RESET}")
print()
log(
"INFO",
f"Firing {len(gates)} requests concurrently -- approve them all at once",
)
log("INFO", "Tip: press [A] in the TUI draft panel to approve all pending")
print()
remaining_gates = list(gates)
for attempt in range(1, MAX_RETRIES + 1):
log(
"GATE",
f"Concurrent attempt #{attempt}",
pending=len(remaining_gates),
)
# Fire all remaining gates concurrently.
results: dict[int, tuple[str, str]] = {}
threads: list[threading.Thread] = []
def _attempt(g: dict) -> None:
results[g["num"]] = attempt_gate(g)
for g in remaining_gates:
t = threading.Thread(target=_attempt, args=(g,))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=30)
# Process results.
still_blocked = []
for g in remaining_gates:
num = g["num"]
status, result = results.get(num, ("blocked", "no result"))
if status == "pass":
log("PASS", f"Gate {num}/{total} UNLOCKED ({g['name']})")
log("FLAG", result)
elif status == "error":
log("WARN", f"Gate {num}/{total} skipped: {result}")
else:
log("FAIL", f"Gate {num}/{total}: {result}")
still_blocked.append(g)
passed = len(remaining_gates) - len(still_blocked)
if passed > 0:
print()
if not still_blocked:
return len(gates)
remaining_gates = still_blocked
if attempt == 1:
log("WARN", "Approve the recommendations in the TUI to proceed")
log(
"INFO",
"TUI: select sandbox -> [r] drafts -> [A] approve all",
)
for secs in range(RETRY_INTERVAL, 0, -1):
print(
f"\r {DIM} retrying in {secs:>2}s ...{RESET}",
end="",
flush=True,
)
time.sleep(1)
print("\r" + " " * 50 + "\r", end="", flush=True)
failed = len(remaining_gates)
log("FAIL", f"{failed} gate(s) timed out after {MAX_RETRIES} attempts")
return len(gates) - failed
# -- Main CTF loop -------------------------------------------------------------
def run_ctf() -> int:
print(BANNER)
log("INFO", "Starting CTF challenge", gates=len(GATES))
log("INFO", f"Retry interval: {RETRY_INTERVAL}s between attempts")
log(
"INFO",
"Tip: open the TUI now if you haven't -> openshell term",
)
print()
completed = 0
# Gate 1: single HTTPS endpoint
if not run_gate(GATES[0]):
return 1
completed += 1
# Gate 2: HTTP endpoint (python)
if not run_gate(GATES[1]):
return 1
completed += 1
# Gate 3: same endpoint via curl (per-binary granularity)
if not run_gate(GATES[2]):
return 1
completed += 1
# Gates 4-6: concurrent
completed += run_gates_concurrent(GATES[3:6])
# Gate 7: internal IP endpoint (allowed_ips SSRF override)
if not run_gate(GATES[6]):
return 1
completed += 1
# Done
if completed >= len(GATES):
print(VICTORY)
log("INFO", "CTF complete", gates_passed=f"{completed}/{len(GATES)}")
return 0
log("WARN", f"CTF incomplete: {completed}/{len(GATES)} gates passed")
return 1
# -- Entry point ---------------------------------------------------------------
if __name__ == "__main__":
try:
if "--dry-run" in sys.argv:
dry_run()
sys.exit(0)
sys.exit(run_ctf())
except KeyboardInterrupt:
print(f"\n {YELLOW}CTF interrupted.{RESET}")
sys.exit(130)