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
This commit is contained in:
DongHyeonka
2026-09-10 11:06:32 +09:00
co-authored by Claude Opus 5
parent edd45dfec6
commit 7fc7c69157
2 changed files with 115 additions and 4 deletions
+24 -4
View File
@@ -9,13 +9,33 @@ 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]"),
# 줄 맨 앞에 앵커를 두면 `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"(?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[REDACTED]",
r"\1\2[REDACTED]\4",
),
(
re.compile(
@@ -43,6 +43,97 @@ class RenderTerminalTest(unittest.TestCase):
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))