149 lines
5.8 KiB
Python
Executable File
149 lines
5.8 KiB
Python
Executable File
#!/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'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img">',
|
|
"<title>terminal evidence</title>",
|
|
"<desc>Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.</desc>",
|
|
f'<rect x="1" y="1" width="{width - 2}" height="{height - 2}" rx="14" fill="#0d1117" stroke="#30363d"/>',
|
|
f'<rect x="1" y="1" width="{width - 2}" height="{top_bar}" rx="14" fill="#161b22"/>',
|
|
f'<rect x="1" y="{top_bar - 14}" width="{width - 2}" height="14" fill="#161b22"/>',
|
|
'<circle cx="24" cy="22" r="6" fill="#ff5f57"/>',
|
|
'<circle cx="44" cy="22" r="6" fill="#febc2e"/>',
|
|
'<circle cx="64" cy="22" r="6" fill="#28c840"/>',
|
|
'<text x="92" y="27" fill="#8b949e" font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">terminal evidence</text>',
|
|
f'<text x="24" y="68" fill="#c9d1d9" font-size="15" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">$ {safe_command}</text>',
|
|
f'<text x="24" y="92" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">cwd: {safe_cwd}</text>',
|
|
f'<text x="24" y="116" fill="#8b949e" font-size="13" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">time: {safe_time} · exit {safe_exit}</text>',
|
|
f'<line x1="24" y1="{top_bar + metadata_height}" x2="{width - 24}" y2="{top_bar + metadata_height}" stroke="#30363d"/>',
|
|
]
|
|
|
|
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'<text x="24" y="{y}" fill="{fill}" font-size="14" '
|
|
'font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" xml:space="preserve">'
|
|
f"{escaped}</text>"
|
|
)
|
|
|
|
parts.append("</svg>")
|
|
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())
|