Files
document-haness/scripts/terminal-evidence/render_terminal.py
T
DongHyeonkaandClaude Opus 5 7fc7c69157 fix(terminal-evidence): 마스킹이 자격증명을 덮으면서 명령을 고치고 있었다
두 가지가 겹쳐 있었다.

줄 맨 앞 앵커 때문에 `curl -H "Authorization: Bearer ..."` 처럼 명령 인자 안에 든
자격증명을 놓쳤다. 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 그 명령줄이다.

그리고 키워드 패턴의 값이 `[^\s,;]+` 라 공백까지 먹어 닫는 따옴표를 넘어갔다.
`curl -H "X-Api-Key: TESTONLY-x" https://...` 가
`curl -H "X-Api-Key: [REDACTED] https://...` 가 된다. 다중 -H 에서는 다음 인자의
경계까지 무너진다. 증거에 실린 명령이 실제로 돌린 명령과 달라진다.

값의 끝을 따옴표 앞에서 막되, 감싼 따옴표는 되돌려 놓는다. 문자 집합만 좁히면
`TOKEN="eyJ..."` 가 여는 따옴표에서 막혀 아예 안 가려진다.

함께 메운 것: Authorization/Proxy-Authorization 의 Basic, `curl -u`/`--user`
(사용자 이름은 남긴다 — 어느 계정으로 붙었는지가 증거의 일부다), 그리고 JWT.

회귀는 「가려졌는가」만 묻지 않는다. 원문과 따옴표 수가 같은지 함께 본다.
앞선 회귀가 그것을 안 물어서 이 결함을 통과시켰다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
2026-09-10 11:06:32 +09:00

169 lines
7.3 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], ...] = (
# 줄 맨 앞에 앵커를 두면 `curl -H "Authorization: Bearer ..."` 를 놓친다.
# 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 그 명령줄이다.
# 값은 따옴표와 줄바꿈 전까지 먹는다 — 헤더 한 줄이면 줄 끝까지, 인용부호 안이면 닫는
# 따옴표 앞까지다. 따옴표를 넘겨 먹으면 명령의 나머지가 통째로 가려진다
(re.compile(r"(?i)(\b(?:proxy-)?authorization\s*:\s*(?:bearer|basic)\s+)[^\"'\r\n]*"),
r"\1[REDACTED]"),
(re.compile(r"(?i)(\b(?:set-cookie|cookie)\s*:\s*)[^\"'\r\n]*"), r"\1[REDACTED]"),
# `curl -u user:pw` · `--user user:pw`. 사용자 이름은 남긴다
(re.compile(r"(?i)((?:^|\s)(?:-u|--user)[=\s]+)([^\s:\"']+):([^\s\"']+)"),
r"\1\2:[REDACTED]"),
# JWT 자체. `eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다
(re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+"),
"[REDACTED]"),
# 접속 문자열의 자격증명 — postgresql://app:<암호>@db:5432/app.
# 사용자 이름은 남긴다. 어느 계정으로 붙었는지가 증거의 일부다
(
re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)([^:/?#\s@]+):([^@\s/]+)@"),
r"\1\2:[REDACTED]@",
),
# 값의 끝을 **따옴표 앞에서** 막는다. `[^\s,;]+` 로 두면 닫는 따옴표까지 먹어
# `-H "X-Api-Key: [REDACTED] https://...` 가 되고, 증거에 실린 명령이 실제로 돌린
# 명령과 달라진다. 감싼 따옴표가 있으면 그대로 되돌려 놓는다
(
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\n]+)([\"']?)"
),
r"\1\2[REDACTED]\4",
),
(
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())