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:
co-authored by
Claude Opus 5
parent
edd45dfec6
commit
7fc7c69157
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user