#!/usr/bin/env python3 from __future__ import annotations import argparse import html import re from pathlib import Path ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") _REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( (re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"), (re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"), ( re.compile( r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)" ), r"\1[REDACTED]", ), ( re.compile( r'(?i)(["\'](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|client[_-]?secret|api[_-]?key|secret)["\']\s*:\s*["\'])(.*?)(["\'])' ), r"\1[REDACTED]\3", ), ) def strip_ansi(value: str) -> str: return ANSI_RE.sub("", value) def redact_line(line: str) -> str: redacted = strip_ansi(line) for pattern, replacement in _REDACTION_PATTERNS: redacted = pattern.sub(replacement, redacted) return redacted def _escape(value: str) -> str: return html.escape(value, quote=True) def _display_lines(output: str, max_lines: int) -> list[str]: if max_lines < 1: raise ValueError("max_lines must be >= 1") source_lines = output.splitlines() if not source_lines and output == "": source_lines = [""] visible = source_lines[:max_lines] rendered = [redact_line(line) for line in visible] omitted = len(source_lines) - len(visible) if omitted > 0: rendered.append(f"[{omitted} more lines omitted]") return rendered def render_svg( output: str, *, command: str, cwd: str, exit_code: int, executed_at: str, max_lines: int = 120, width: int = 1200, ) -> str: lines = _display_lines(output, max_lines=max_lines) line_height = 22 top_bar = 44 metadata_height = 86 output_top = top_bar + metadata_height + 18 bottom_padding = 28 height = output_top + max(1, len(lines)) * line_height + bottom_padding safe_command = _escape(redact_line(command)) safe_cwd = _escape(redact_line(cwd)) safe_time = _escape(executed_at) safe_exit = _escape(str(exit_code)) parts = [ f'', "terminal evidence", "Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.", f'', f'', f'', '', '', '', 'terminal evidence', f'$ {safe_command}', f'cwd: {safe_cwd}', f'time: {safe_time} ยท exit {safe_exit}', f'', ] for index, line in enumerate(lines): y = output_top + (index + 1) * line_height escaped = _escape(line) # Keep all text derived from output. The SVG viewport clips extreme-width lines # rather than inventing wrapped content or changing the raw evidence. fill = "#d2a8ff" if line.startswith("[") and line.endswith("more lines omitted]") else "#e6edf3" parts.append( f'' f"{escaped}" ) parts.append("") return "\n".join(parts) + "\n" def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Render retained command output as a terminal-style SVG evidence asset." ) parser.add_argument("input", type=Path, help="UTF-8 raw command output file") parser.add_argument("output", type=Path, help="Destination SVG") parser.add_argument("--command", required=True, help="Command that produced the raw output") parser.add_argument("--cwd", required=True, help="Working directory of the command") parser.add_argument("--exit-code", type=int, required=True, help="Actual process exit code") parser.add_argument("--executed-at", required=True, help="Actual ISO-8601 execution timestamp") parser.add_argument("--max-lines", type=int, default=120, help="Maximum raw lines to render") return parser.parse_args() def main() -> int: args = _parse_args() raw = args.input.read_text(encoding="utf-8", errors="replace") svg = render_svg( raw, command=args.command, cwd=args.cwd, exit_code=args.exit_code, executed_at=args.executed_at, max_lines=args.max_lines, ) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(svg, encoding="utf-8") return 0 if __name__ == "__main__": raise SystemExit(main())