diff --git a/scripts/terminal-evidence/render_terminal.py b/scripts/terminal-evidence/render_terminal.py index a00699a..2da2d53 100755 --- a/scripts/terminal-evidence/render_terminal.py +++ b/scripts/terminal-evidence/render_terminal.py @@ -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( diff --git a/scripts/terminal-evidence/tests/test_render_terminal.py b/scripts/terminal-evidence/tests/test_render_terminal.py index ed3e1ac..9cc54cf 100644 --- a/scripts/terminal-evidence/tests/test_render_terminal.py +++ b/scripts/terminal-evidence/tests/test_render_terminal.py @@ -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))