Files
document-haness/scripts/terminal-evidence/tests/test_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

171 lines
7.5 KiB
Python

import re
import unittest
import xml.etree.ElementTree as ET
from render_terminal import redact_line, render_svg
class RenderTerminalTest(unittest.TestCase):
def test_svg_is_valid_xml_and_escapes_output(self):
svg = render_svg(
"<tag>& value\nsecond",
command="printf '<tag>& value'",
cwd="/shared/codebase/demo",
exit_code=0,
executed_at="2026-08-28T06:00:00Z",
)
ET.fromstring(svg)
self.assertIn("&lt;tag&gt;&amp; value", svg)
self.assertNotIn("<tag>& value", svg)
def test_metadata_is_rendered(self):
svg = render_svg(
"BUILD SUCCESSFUL",
command="./gradlew test",
cwd="/shared/codebase/demo",
exit_code=0,
executed_at="2026-08-28T06:00:00Z",
)
self.assertIn("./gradlew test", svg)
self.assertIn("/shared/codebase/demo", svg)
self.assertIn("exit 0", svg)
self.assertIn("2026-08-28T06:00:00Z", svg)
def test_obvious_secrets_are_redacted(self):
cases = {
"Authorization: Bearer abc.def.ghi": "Authorization: Bearer [REDACTED]",
"TOKEN=super-secret": "TOKEN=[REDACTED]",
"PASSWORD=hunter2": "PASSWORD=[REDACTED]",
"client_secret: abc123": "client_secret: [REDACTED]",
"Cookie: SESSION=abcdef": "Cookie: [REDACTED]",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_credentials_inside_a_command_line_are_redacted(self):
"""줄 맨 앞이 아니라 명령 인자 안에 있는 자격증명.
터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 `curl -H` 의 인자다.
값은 닫는 따옴표 앞까지만 먹는다 — 넘겨 먹으면 명령의 나머지가 통째로 가려진다.
아래 값은 전부 합성이고 실제 비밀값이 아니다.
"""
cases = {
'curl -H "Authorization: Bearer TESTONLY-aaa.bbb.ccc" https://example.test/api':
'curl -H "Authorization: Bearer [REDACTED]" https://example.test/api',
"curl -H 'Authorization: Bearer TESTONLY-xyz' -sS https://example.test":
"curl -H 'Authorization: Bearer [REDACTED]' -sS https://example.test",
'curl -H "Cookie: SESSION=TESTONLY-sess" https://example.test/api':
'curl -H "Cookie: [REDACTED]" https://example.test/api',
"Set-Cookie: SESSION=TESTONLY-x; HttpOnly":
"Set-Cookie: [REDACTED]",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_connection_string_password_is_redacted_and_user_is_kept(self):
"""scheme://user:pw@host 의 암호만 가린다.
어느 계정으로 붙었는지는 증거의 일부라 사용자 이름을 남긴다.
"""
cases = {
"psql postgresql://app:TESTONLY-pw@db:5432/app":
"psql postgresql://app:[REDACTED]@db:5432/app",
"DATABASE_URL=mysql://root:TESTONLY-pw@127.0.0.1:3306/app":
"DATABASE_URL=mysql://root:[REDACTED]@127.0.0.1:3306/app",
"redis://default:TESTONLY-pw@cache:6379/0":
"redis://default:[REDACTED]@cache:6379/0",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_masking_does_not_eat_the_closing_quote(self):
"""가려졌다는 것과 명령이 그대로라는 것은 다르다.
값의 끝을 공백까지로 두면 닫는 따옴표까지 먹어 증거에 실린 명령이 실제로 돌린
명령과 달라진다. 가려진 것만 보고 지나치지 않도록 따옴표 수를 함께 센다.
아래 값은 전부 합성이고 실제 비밀값이 아니다.
"""
lines = (
'curl -H "X-Api-Key: TESTONLY-i-apikey-header" https://example.invalid/d',
'curl -H "X-Token: TESTONLY-x" -H "Accept: application/json" https://example.invalid/d',
'export TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig"',
"curl -H 'X-Api-Key: TESTONLY-single' https://example.invalid/d",
)
for raw in lines:
with self.subTest(raw=raw):
out = redact_line(raw)
self.assertIn("[REDACTED]", out)
self.assertNotIn("TESTONLY", out)
self.assertEqual(raw.count('"'), out.count('"'), out)
self.assertEqual(raw.count("'"), out.count("'"), out)
def test_basic_auth_shapes_are_redacted(self):
"""Bearer 말고도 자격증명이 실리는 자리가 있다."""
cases = {
'curl -H "Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
'curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d',
'curl -H "Proxy-Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
'curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d',
"curl -u admin:TESTONLY-basic-pw https://example.invalid/d":
"curl -u admin:[REDACTED] https://example.invalid/d",
"curl --user admin:TESTONLY-basic-pw https://example.invalid/d":
"curl --user admin:[REDACTED] https://example.invalid/d",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(expected, redact_line(raw))
def test_a_bare_jwt_is_redacted(self):
"""`eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다."""
raw = "Set token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig now"
out = redact_line(raw)
self.assertEqual("Set token [REDACTED] now", out)
def test_ordinary_urls_are_not_touched(self):
"""자격증명이 없는 주소는 그대로 둔다. 과하게 가리면 증거를 못 읽는다."""
for line in (
"https://example.test/api?x=1",
"git clone https://github.com/org/repo.git",
"GET https://example.test/studio/documents/abc-123/edit -> 200",
):
with self.subTest(line=line):
self.assertEqual(line, redact_line(line))
def test_normal_output_is_not_changed_by_redaction(self):
line = "GET /api/me -> 200 in 14ms"
self.assertEqual(line, redact_line(line))
def test_truncation_marker_is_rendered_without_fabricating_hidden_lines(self):
output = "\n".join(f"line-{i}" for i in range(8))
svg = render_svg(
output,
command="demo",
cwd="/tmp",
exit_code=1,
executed_at="2026-08-28T06:00:00Z",
max_lines=3,
)
self.assertIn("line-0", svg)
self.assertIn("line-2", svg)
self.assertNotIn("line-3", svg)
self.assertIn("[5 more lines omitted]", svg)
def test_terminal_chrome_and_monospace_are_present(self):
svg = render_svg(
"ok",
command="echo ok",
cwd="/tmp",
exit_code=0,
executed_at="2026-08-28T06:00:00Z",
)
self.assertGreaterEqual(len(re.findall(r"<circle\b", svg)), 3)
self.assertIn("monospace", svg)
self.assertIn("terminal evidence", svg)
if __name__ == "__main__":
unittest.main()