두 가지가 겹쳐 있었다. 줄 맨 앞 앵커 때문에 `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
171 lines
7.5 KiB
Python
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("<tag>& 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()
|