@ictechgy/context-guard 0.4.15 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.ko.md +46 -1
- package/README.md +58 -2
- package/package.json +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +18 -0
- package/plugins/context-guard/README.md +18 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +5765 -224
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-pack +110 -11
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +6 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -10,9 +10,11 @@ from __future__ import annotations
|
|
|
10
10
|
import argparse
|
|
11
11
|
import codecs
|
|
12
12
|
import collections
|
|
13
|
+
from dataclasses import dataclass
|
|
13
14
|
import hashlib
|
|
15
|
+
import importlib.util
|
|
14
16
|
import os
|
|
15
|
-
from pathlib import PurePosixPath
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
16
18
|
import queue
|
|
17
19
|
import re
|
|
18
20
|
import signal
|
|
@@ -20,7 +22,36 @@ import subprocess
|
|
|
20
22
|
import sys
|
|
21
23
|
import threading
|
|
22
24
|
import time
|
|
23
|
-
from
|
|
25
|
+
from types import ModuleType
|
|
26
|
+
from typing import BinaryIO, Iterable, Iterator, NamedTuple, TextIO
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_credential_policy() -> ModuleType:
|
|
30
|
+
script_dir = Path(__file__).resolve().parent
|
|
31
|
+
candidate = (
|
|
32
|
+
script_dir.parent / "lib" / "credential_policy.py"
|
|
33
|
+
if script_dir.name == "bin"
|
|
34
|
+
else script_dir / "credential_policy.py"
|
|
35
|
+
)
|
|
36
|
+
spec = importlib.util.spec_from_file_location(
|
|
37
|
+
"_context_guard_sanitize_credential_policy",
|
|
38
|
+
candidate,
|
|
39
|
+
)
|
|
40
|
+
if spec is None or spec.loader is None:
|
|
41
|
+
raise RuntimeError(f"could not load credential policy: {candidate}")
|
|
42
|
+
module = importlib.util.module_from_spec(spec)
|
|
43
|
+
spec.loader.exec_module(module)
|
|
44
|
+
return module
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CREDENTIAL_POLICY = load_credential_policy()
|
|
48
|
+
SECRET_KEY = _CREDENTIAL_POLICY.SECRET_KEY
|
|
49
|
+
CAMEL_ACRONYM_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_ACRONYM_BOUNDARY_RE
|
|
50
|
+
CAMEL_WORD_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_WORD_BOUNDARY_RE
|
|
51
|
+
normalize_sensitive_key = _CREDENTIAL_POLICY.normalize_sensitive_key
|
|
52
|
+
is_sensitive_key = _CREDENTIAL_POLICY.is_sensitive_key
|
|
53
|
+
redact_url_like_secret_params = _CREDENTIAL_POLICY.redact_url_like_secret_params
|
|
54
|
+
redact_high_confidence_credentials = _CREDENTIAL_POLICY.redact_high_confidence_credentials
|
|
24
55
|
|
|
25
56
|
TERMINAL_CONTROL_RE = re.compile(
|
|
26
57
|
r"(?:"
|
|
@@ -40,6 +71,22 @@ ABSOLUTE_PATH_RE = re.compile(
|
|
|
40
71
|
WINDOWS_PATH_RE = re.compile(
|
|
41
72
|
rf"(?P<prefix>^|[\s('\"=])(?P<path>[A-Za-z]:\\(?:{PATH_SEGMENT}\\)+{PATH_SEGMENT})"
|
|
42
73
|
)
|
|
74
|
+
TRACEBACK_PATH_RE = re.compile(
|
|
75
|
+
rf"(?P<prefix>\bFile\s+[\"'])(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
76
|
+
r"(?P<suffix>[\"'],\s+line\s+\d+)"
|
|
77
|
+
)
|
|
78
|
+
LOCATION_PATH_RE = re.compile(
|
|
79
|
+
rf"(?P<prefix>^(?:\s*[+-]\s*)?)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
80
|
+
r"(?P<suffix>:\d+(?::\d+)?(?=[:\s]|$))"
|
|
81
|
+
)
|
|
82
|
+
DIFF_PATH_RE = re.compile(
|
|
83
|
+
rf"(?P<prefix>^(?:---|\+\+\+)\s+)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
84
|
+
r"(?P<suffix>(?:\t.*)?$)"
|
|
85
|
+
)
|
|
86
|
+
LISTING_PATH_RE = re.compile(
|
|
87
|
+
rf"(?P<prefix>^\s*)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
88
|
+
r"(?P<suffix>/?(?:\s+->\s+\S+)?\s*$)"
|
|
89
|
+
)
|
|
43
90
|
PRIVATE_KEY_BEGIN_RE = re.compile(
|
|
44
91
|
r"-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----"
|
|
45
92
|
)
|
|
@@ -52,25 +99,25 @@ AUTH_HEADER_RE = re.compile(
|
|
|
52
99
|
COOKIE_HEADER_RE = re.compile(
|
|
53
100
|
r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Set-)?Cookie\s*:\s*).+$"
|
|
54
101
|
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
102
|
+
# QUOTED_VALUE_BODY의 두 대안은 첫 문자가 서로 겹치지 않아야 한다.
|
|
103
|
+
# 예전 형태 `(?:\\.|(?!(?P=quote)).)*`에서는 역슬래시 하나가 `\\.`의 시작이면서
|
|
104
|
+
# 동시에 두 번째 대안의 `.`이기도 했다. 닫는 따옴표가 없는 줄에서는 역슬래시
|
|
105
|
+
# 연속 구간을 나누는 방법이 피보나치 수만큼 생겨 지수 시간 백트래킹(ReDoS)이
|
|
106
|
+
# 발생했다 — 47자짜리 줄이 7.6초를 소모했다.
|
|
107
|
+
# 두 번째 대안에서 역슬래시를 제외해 각 위치에서 적용 가능한 대안이 하나뿐이도록
|
|
108
|
+
# 만든다. 뒤에 붙은 `\\?`는 이 배제로 잃게 되는 유일한 문자열 부류, 즉 값이
|
|
109
|
+
# 짝지어지지 않은 역슬래시 하나로 끝나는 경우(`token = "abc\"` → value `abc\`)를
|
|
110
|
+
# 복원한다. 이 형태가 예전 형태와 언어·스팬·그룹 모두에서 동치임은
|
|
111
|
+
# tests/test_sanitize_output_redos.py의 차분 배터리로 고정한다.
|
|
112
|
+
QUOTED_VALUE_BODY = r"(?:\\.|(?!(?P=quote))[^\\])*\\?"
|
|
66
113
|
INLINE_QUOTED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
67
114
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
68
115
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
69
116
|
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*)"
|
|
70
|
-
rf"(?P<quote>[\"'])(?P<value>
|
|
117
|
+
rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
|
|
71
118
|
)
|
|
72
119
|
CODE_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*"
|
|
73
|
-
CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]
|
|
120
|
+
CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
|
|
74
121
|
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE = re.compile(
|
|
75
122
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
76
123
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
@@ -109,28 +156,131 @@ UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
|
|
|
109
156
|
rf"(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
110
157
|
rf"[\"']?(?:{SECRET_KEY})[\"']?\s*[:=]\s*(?P<value>(?![\"']).*)$"
|
|
111
158
|
)
|
|
159
|
+
# F-14 one-pass scanner.
|
|
160
|
+
#
|
|
161
|
+
# 아홉 개 consumer 는 모두 아래 fragment 를 각자 품고 있었다. unanchored 패턴에서는
|
|
162
|
+
# offset 마다 `[^:\n]+` 가 다음 콜론까지 삼키고 되돌아오므로, 콜론이 드문 긴 줄에서
|
|
163
|
+
# offset 당 O(n) 작업이 발생해 전체가 이차가 된다(측정: 82KB 한 줄 2.62초,
|
|
164
|
+
# 배가 비율 3.6~3.96). 대신 줄마다 앞머리 location prefix 를 단조 좌→우로 한 번만
|
|
165
|
+
# 확정하고, consumer 에는 fragment 를 제거한 쌍둥이 패턴을 먹인다.
|
|
166
|
+
LOCATION_PREFIX_FRAGMENT = r"(?:(?:[^:\n]+):\d+(?::\d+)?:)?"
|
|
167
|
+
# path 성분에서 '=' 를 제외한다. 허용하면 'api_key=abc:123:456def' 처럼 비밀 값 안에서
|
|
168
|
+
# 줄이 쪼개져 앞부분만 가려지고 뒷부분이 남는 절단 누출이 발생한다. grep/diff 경로에
|
|
169
|
+
# '=' 가 들어가는 경우는 드물고, 그런 줄은 그냥 fast path 를 쓰지 않을 뿐이다.
|
|
170
|
+
# 공백과 유니코드는 계약대로 계속 허용한다.
|
|
171
|
+
LOCATION_PREFIX_SCAN_RE = re.compile(
|
|
172
|
+
r"\A(?P<lead>[ \t]*(?:[+-][ \t]*)?)(?P<location>[^:\n=]+:\d+(?::\d+)?:)"
|
|
173
|
+
)
|
|
174
|
+
# 후보 스팬의 신호는 두 종류이고 처리도 달라야 한다.
|
|
175
|
+
#
|
|
176
|
+
# ASSIGNMENT: 비밀 키나 헤더 이름 뒤에 ':' 또는 '=' 가 오면 그 스팬은 경로가 아니라
|
|
177
|
+
# 값의 일부다. 예: 'token:123456789:AAH-...' 는 앞부분만 가리면 뒷부분이 남는다.
|
|
178
|
+
# 이 경우 분리 자체를 하지 않고 전체 줄을 consumer 에 넘긴다.
|
|
179
|
+
#
|
|
180
|
+
# PATH_ONLY: 따옴표나 private key 표지처럼 경로 자체에 들어갈 수 있는 문자는 후보를
|
|
181
|
+
# 경로로 인정하되, 그 스팬도 함께 검사한다. 예: "src/it's.py:5:api_key='x'".
|
|
182
|
+
LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE = re.compile(
|
|
183
|
+
rf"(?i)(?:-----BEGIN|(?:Proxy-)?Authorization\s*:|(?:Set-)?Cookie\s*:"
|
|
184
|
+
rf"|[\"']?(?:{SECRET_KEY})[\"']?\s*[:=])"
|
|
185
|
+
)
|
|
186
|
+
LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE = re.compile(r"[\"']")
|
|
187
|
+
NINE_LOCATION_CONSUMERS = (
|
|
188
|
+
"AUTH_HEADER_RE",
|
|
189
|
+
"COOKIE_HEADER_RE",
|
|
190
|
+
"INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
|
|
191
|
+
"INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
|
|
192
|
+
"INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
|
|
193
|
+
"INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
|
|
194
|
+
"INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
|
|
195
|
+
"UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
196
|
+
"MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def without_location_prefix(compiled: re.Pattern[str]) -> re.Pattern[str]:
|
|
201
|
+
"""Return the same consumer with its embedded location prefix removed.
|
|
202
|
+
|
|
203
|
+
Deriving the twin from the compiled source keeps the two spellings from
|
|
204
|
+
drifting: if the fragment ever moves or changes, this fails loudly instead of
|
|
205
|
+
silently leaving a per-offset location scan in place.
|
|
206
|
+
"""
|
|
207
|
+
source = compiled.pattern
|
|
208
|
+
if source.count(LOCATION_PREFIX_FRAGMENT) != 1:
|
|
209
|
+
raise RuntimeError(
|
|
210
|
+
"consumer no longer embeds exactly one location prefix fragment"
|
|
211
|
+
)
|
|
212
|
+
return re.compile(source.replace(LOCATION_PREFIX_FRAGMENT, "", 1), compiled.flags)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ScannedLine(NamedTuple):
|
|
216
|
+
"""One monotonic left-to-right split of a line into prefix and remainder.
|
|
217
|
+
|
|
218
|
+
An assignment signal inside the candidate means the span is part of a value
|
|
219
|
+
rather than a path, so no split is reported at all; splitting there would
|
|
220
|
+
redact only the leading part and leave the tail behind. A path-only signal
|
|
221
|
+
such as a quote in a filename keeps the split but sets ``declined``, and
|
|
222
|
+
unanchored consumers then also scan the bounded candidate span.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
prefix: str
|
|
226
|
+
remainder: str
|
|
227
|
+
scan_index: int
|
|
228
|
+
declined: bool = False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def scan_location_prefix(line: str) -> ScannedLine:
|
|
232
|
+
"""Identify a leading grep/diff location prefix in exactly one anchored scan.
|
|
233
|
+
|
|
234
|
+
The scan index only ever moves forward: it is the end of the anchored match,
|
|
235
|
+
or zero when there is no prefix.
|
|
236
|
+
|
|
237
|
+
The path component must keep accepting spaces and Unicode, because real grep
|
|
238
|
+
and diff output carries such filenames. That permissiveness means the
|
|
239
|
+
anchored run can reach past a secret that happens to sit before a later
|
|
240
|
+
``:<digits>:`` sequence, so the candidate span is checked once for consumer
|
|
241
|
+
signal and flagged when any is present. The check is a single bounded pass, so
|
|
242
|
+
the scan stays linear.
|
|
243
|
+
"""
|
|
244
|
+
match = LOCATION_PREFIX_SCAN_RE.match(line)
|
|
245
|
+
if match is None:
|
|
246
|
+
return ScannedLine("", line, 0)
|
|
247
|
+
end = match.end()
|
|
248
|
+
candidate = line[:end]
|
|
249
|
+
if LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE.search(candidate):
|
|
250
|
+
# 값의 일부를 경로로 오인한 경우다. 분리하면 앞부분만 가려져 뒤가 남는다.
|
|
251
|
+
return ScannedLine("", line, 0, False)
|
|
252
|
+
declined = bool(LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE.search(candidate))
|
|
253
|
+
return ScannedLine(candidate, line[end:], end, declined)
|
|
254
|
+
|
|
255
|
+
|
|
112
256
|
CONTINUATION_OPERATOR_RE = re.compile(
|
|
113
257
|
r"(?i)(?:\\|\|\||&&|\?\?|[+*/%&|^?,]|\?|:|\bor\b|\band\b|\belse\b)\s*(?://.*|#.*)?$"
|
|
114
258
|
)
|
|
115
|
-
URL_LIKE_RE = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s]+")
|
|
116
|
-
URL_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s?&#;]+")
|
|
117
259
|
SAFE_UNQUOTED_VALUES = {
|
|
118
260
|
"[redacted]",
|
|
261
|
+
"bool",
|
|
262
|
+
"boolean",
|
|
263
|
+
"bytes",
|
|
119
264
|
"false",
|
|
265
|
+
"float",
|
|
266
|
+
"int",
|
|
267
|
+
"integer",
|
|
120
268
|
"none",
|
|
121
269
|
"null",
|
|
270
|
+
"object",
|
|
122
271
|
"os.getenv",
|
|
123
272
|
"process.env",
|
|
273
|
+
"str",
|
|
274
|
+
"string",
|
|
124
275
|
"true",
|
|
125
276
|
"undefined",
|
|
277
|
+
"unknown",
|
|
126
278
|
}
|
|
127
279
|
IDENTIFIER_CHAIN_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$")
|
|
128
280
|
SAFE_ENV_LOOKUP_CALL_RE = re.compile(r"^(?:os\.getenv|os\.environ\.get)\(\s*[\"'][A-Za-z0-9_.-]{1,80}[\"']\s*\)$")
|
|
129
281
|
SAFE_RE_COMPILE_CALL_RE = re.compile(r"^re\.compile\([^;\n]*\)$")
|
|
130
282
|
SAFE_CODE_EXPRESSION_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\(\s*(?:{CODE_IDENTIFIER}(?:\s*,\s*{CODE_IDENTIFIER})*)?\s*\)$")
|
|
131
283
|
GETTER_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\.get\(\s*[\"'](?P<key>[A-Za-z0-9_.-]{{1,80}})[\"']\s*\)$")
|
|
132
|
-
CAMEL_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
|
|
133
|
-
CAMEL_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
134
284
|
SAFE_GETTER_KEY_NAMES = {
|
|
135
285
|
"access_key",
|
|
136
286
|
"access_token",
|
|
@@ -162,26 +312,18 @@ SAFE_GETTER_KEY_NAMES = {
|
|
|
162
312
|
"sid",
|
|
163
313
|
"token",
|
|
164
314
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
(re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"), "[REDACTED]"),
|
|
178
|
-
(re.compile(r"sk-(?:ant|proj)-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
|
|
179
|
-
(re.compile(r"sk-[A-Za-z0-9][A-Za-z0-9_-]{20,}"), "[REDACTED]"),
|
|
180
|
-
(re.compile(r"npm_[A-Za-z0-9]{20,}"), "[REDACTED]"),
|
|
181
|
-
(re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), "[REDACTED]"),
|
|
182
|
-
(re.compile(r"SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), "[REDACTED]"),
|
|
183
|
-
(re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "[REDACTED]"),
|
|
184
|
-
(re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@", re.IGNORECASE), r"\1[REDACTED]@"),
|
|
315
|
+
ASSIGNMENT_KEY_RE = re.compile(
|
|
316
|
+
r"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?\s*[:=]\s*$"
|
|
317
|
+
)
|
|
318
|
+
SOURCE_SAFE_VALUE_RE = re.compile(
|
|
319
|
+
r"^(?:"
|
|
320
|
+
r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*|"
|
|
321
|
+
r"[A-Za-z_$][A-Za-z0-9_$]*(?:\[[A-Za-z0-9_$., |]+\])+|"
|
|
322
|
+
r"\d+(?:\.\d+)?|"
|
|
323
|
+
r"\$\{[A-Za-z0-9_.-]+\}|"
|
|
324
|
+
r"<[A-Za-z0-9_.-]+>|"
|
|
325
|
+
r"(?:YOUR|REPLACE|EXAMPLE|PLACEHOLDER)_[A-Z0-9_]+"
|
|
326
|
+
r")$"
|
|
185
327
|
)
|
|
186
328
|
ANCHOR_RE = re.compile(
|
|
187
329
|
r"^(?:diff --git |index [0-9a-f]|--- |\+\+\+ |@@ |Binary files |(?:[^:\n]+):\d+(?::\d+)?:)",
|
|
@@ -200,6 +342,55 @@ COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
|
|
|
200
342
|
RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
|
|
201
343
|
|
|
202
344
|
|
|
345
|
+
SANITIZATION_MODES = (
|
|
346
|
+
"unknown_text",
|
|
347
|
+
"command_search_diff",
|
|
348
|
+
"filesystem_listing",
|
|
349
|
+
"source_code",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass(frozen=True)
|
|
354
|
+
class SanitizationContext:
|
|
355
|
+
mode: str = "unknown_text"
|
|
356
|
+
private_roots: tuple[str, ...] = ()
|
|
357
|
+
|
|
358
|
+
def __post_init__(self) -> None:
|
|
359
|
+
if self.mode not in SANITIZATION_MODES:
|
|
360
|
+
raise ValueError(f"unsupported sanitization context: {self.mode}")
|
|
361
|
+
object.__setattr__(
|
|
362
|
+
self,
|
|
363
|
+
"private_roots",
|
|
364
|
+
tuple(str(root) for root in self.private_roots),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def coerce_sanitization_context(
|
|
369
|
+
value: SanitizationContext | str,
|
|
370
|
+
*,
|
|
371
|
+
private_roots: Iterable[str] = (),
|
|
372
|
+
) -> SanitizationContext:
|
|
373
|
+
if isinstance(value, SanitizationContext):
|
|
374
|
+
supplied_roots = tuple(str(root) for root in private_roots)
|
|
375
|
+
normalized_context_roots = _normalized_private_roots(value.private_roots)
|
|
376
|
+
if supplied_roots and _normalized_private_roots(
|
|
377
|
+
supplied_roots
|
|
378
|
+
) != normalized_context_roots:
|
|
379
|
+
raise ValueError(
|
|
380
|
+
"private_roots must be declared inside SanitizationContext "
|
|
381
|
+
"when a context object is supplied"
|
|
382
|
+
)
|
|
383
|
+
roots = normalized_context_roots
|
|
384
|
+
mode = value.mode
|
|
385
|
+
else:
|
|
386
|
+
roots = tuple(str(root) for root in private_roots)
|
|
387
|
+
mode = str(value)
|
|
388
|
+
return SanitizationContext(
|
|
389
|
+
mode=mode,
|
|
390
|
+
private_roots=_normalized_private_roots(roots),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
203
394
|
def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
|
|
204
395
|
try:
|
|
205
396
|
number = int(value)
|
|
@@ -231,16 +422,98 @@ def stable_hash(value: str, length: int = 12) -> str:
|
|
|
231
422
|
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:length]
|
|
232
423
|
|
|
233
424
|
|
|
234
|
-
def
|
|
425
|
+
def anonymized_path(path: str) -> str:
|
|
426
|
+
normalized = path.replace("\\", "/")
|
|
427
|
+
name = PurePosixPath(normalized).name or "path"
|
|
428
|
+
return f"{name}#path:{stable_hash(path)}"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def anonymize_absolute_paths_with_count(text: str) -> tuple[str, int]:
|
|
432
|
+
count = 0
|
|
433
|
+
|
|
235
434
|
def repl(match: re.Match[str]) -> str:
|
|
435
|
+
nonlocal count
|
|
236
436
|
prefix = match.group("prefix")
|
|
237
437
|
path = match.group("path")
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return f"{prefix}{name}#path:{stable_hash(path)}"
|
|
438
|
+
count += 1
|
|
439
|
+
return f"{prefix}{anonymized_path(path)}"
|
|
241
440
|
|
|
242
441
|
text = ABSOLUTE_PATH_RE.sub(repl, text)
|
|
243
|
-
return WINDOWS_PATH_RE.sub(repl, text)
|
|
442
|
+
return WINDOWS_PATH_RE.sub(repl, text), count
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def anonymize_absolute_paths(text: str) -> str:
|
|
446
|
+
return anonymize_absolute_paths_with_count(text)[0]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _replace_named_paths(text: str, pattern: re.Pattern[str]) -> tuple[str, int]:
|
|
450
|
+
count = 0
|
|
451
|
+
|
|
452
|
+
def repl(match: re.Match[str]) -> str:
|
|
453
|
+
nonlocal count
|
|
454
|
+
count += 1
|
|
455
|
+
return (
|
|
456
|
+
match.group("prefix")
|
|
457
|
+
+ anonymized_path(match.group("path"))
|
|
458
|
+
+ match.group("suffix")
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return pattern.sub(repl, text), count
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _normalized_private_roots(private_roots: Iterable[str]) -> tuple[str, ...]:
|
|
465
|
+
normalized: list[str] = []
|
|
466
|
+
for root in private_roots:
|
|
467
|
+
value = os.path.normpath(os.path.abspath(os.path.expanduser(str(root))))
|
|
468
|
+
if value not in normalized:
|
|
469
|
+
normalized.append(value)
|
|
470
|
+
return tuple(normalized)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _is_under_private_root(path: str, private_roots: tuple[str, ...]) -> bool:
|
|
474
|
+
normalized = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
|
475
|
+
return any(
|
|
476
|
+
normalized == root or normalized.startswith(root.rstrip(os.sep) + os.sep)
|
|
477
|
+
for root in private_roots
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def anonymize_private_root_paths(
|
|
482
|
+
text: str,
|
|
483
|
+
private_roots: tuple[str, ...],
|
|
484
|
+
) -> tuple[str, int]:
|
|
485
|
+
if not private_roots:
|
|
486
|
+
return text, 0
|
|
487
|
+
count = 0
|
|
488
|
+
|
|
489
|
+
def repl(match: re.Match[str]) -> str:
|
|
490
|
+
nonlocal count
|
|
491
|
+
path = match.group("path")
|
|
492
|
+
if not _is_under_private_root(path, private_roots):
|
|
493
|
+
return match.group(0)
|
|
494
|
+
count += 1
|
|
495
|
+
return match.group("prefix") + anonymized_path(path)
|
|
496
|
+
|
|
497
|
+
return ABSOLUTE_PATH_RE.sub(repl, text), count
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def anonymize_paths_for_context(
|
|
501
|
+
text: str,
|
|
502
|
+
*,
|
|
503
|
+
context: SanitizationContext,
|
|
504
|
+
) -> tuple[str, int]:
|
|
505
|
+
if context.mode in {"unknown_text", "source_code"}:
|
|
506
|
+
return text, 0
|
|
507
|
+
|
|
508
|
+
total = 0
|
|
509
|
+
if context.mode == "filesystem_listing":
|
|
510
|
+
text, count = anonymize_private_root_paths(text, context.private_roots)
|
|
511
|
+
total += count
|
|
512
|
+
|
|
513
|
+
for pattern in (TRACEBACK_PATH_RE, LOCATION_PATH_RE, DIFF_PATH_RE):
|
|
514
|
+
text, count = _replace_named_paths(text, pattern)
|
|
515
|
+
total += count
|
|
516
|
+
return text, total
|
|
244
517
|
|
|
245
518
|
|
|
246
519
|
def cap_line(line: str, max_line_chars: int) -> tuple[str, bool]:
|
|
@@ -260,21 +533,38 @@ def normalize_getter_key(key: str) -> str:
|
|
|
260
533
|
return re.sub(r"_+", "_", key).strip("_").lower()
|
|
261
534
|
|
|
262
535
|
|
|
536
|
+
def assignment_key(prefix: str) -> str | None:
|
|
537
|
+
match = ASSIGNMENT_KEY_RE.search(prefix)
|
|
538
|
+
return match.group("key") if match is not None else None
|
|
539
|
+
|
|
540
|
+
|
|
263
541
|
def is_safe_getter_key(key: str) -> bool:
|
|
264
542
|
return normalize_getter_key(key) in SAFE_GETTER_KEY_NAMES
|
|
265
543
|
|
|
266
544
|
|
|
267
|
-
def should_redact_unquoted_secret_value(
|
|
545
|
+
def should_redact_unquoted_secret_value(
|
|
546
|
+
line: str,
|
|
547
|
+
match: re.Match[str],
|
|
548
|
+
*,
|
|
549
|
+
context: SanitizationContext,
|
|
550
|
+
) -> bool:
|
|
268
551
|
value = match.group("value").strip()
|
|
269
552
|
prefix = match.group("prefix")
|
|
270
553
|
if not value:
|
|
271
554
|
return False
|
|
272
555
|
if value.lower() in SAFE_UNQUOTED_VALUES:
|
|
273
556
|
return False
|
|
557
|
+
if re.search(r":\s*$", prefix):
|
|
558
|
+
return not (
|
|
559
|
+
context.mode == "source_code"
|
|
560
|
+
and SOURCE_SAFE_VALUE_RE.match(value) is not None
|
|
561
|
+
)
|
|
274
562
|
if IDENTIFIER_CHAIN_RE.match(value):
|
|
275
563
|
return False
|
|
276
564
|
if SAFE_ENV_LOOKUP_CALL_RE.match(value) or SAFE_RE_COMPILE_CALL_RE.match(value):
|
|
277
565
|
return False
|
|
566
|
+
if context.mode == "source_code" and SOURCE_SAFE_VALUE_RE.match(value):
|
|
567
|
+
return False
|
|
278
568
|
getter_match = GETTER_CALL_RE.match(value)
|
|
279
569
|
if re.search(r"\s[:=]\s*$", prefix) and (
|
|
280
570
|
SAFE_CODE_EXPRESSION_CALL_RE.match(value)
|
|
@@ -284,45 +574,99 @@ def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool
|
|
|
284
574
|
return True
|
|
285
575
|
|
|
286
576
|
|
|
287
|
-
def
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def redact_secret_assignments(line: str) -> tuple[str, bool]:
|
|
577
|
+
def redact_secret_assignments(
|
|
578
|
+
line: str,
|
|
579
|
+
*,
|
|
580
|
+
context: SanitizationContext,
|
|
581
|
+
) -> tuple[str, bool]:
|
|
582
|
+
"""Redact inline secret assignments using the no-location consumer twins.
|
|
583
|
+
|
|
584
|
+
The leading grep/diff location prefix is identified once by the scanner and
|
|
585
|
+
reattached untouched, so no consumer re-parses it at every offset.
|
|
586
|
+
"""
|
|
587
|
+
# URL 형태 비밀 파라미터는 분리 전 전체 줄에서 처리한다. prefix 안에 들어 있어도
|
|
588
|
+
# 가려지지 않고 다시 붙는 일이 없어야 한다.
|
|
301
589
|
line, redacted = redact_url_like_secret_params(line)
|
|
590
|
+
# 반드시 URL 치환 이후의 문자열에서 다시 스캔한다. 호출자가 미리 계산한 split 을
|
|
591
|
+
# 받아 쓰면 치환으로 길이가 바뀐 문자열에 낡은 오프셋을 적용해 내용이 유실된다.
|
|
592
|
+
scan = scan_location_prefix(line)
|
|
593
|
+
location_prefix = scan.prefix
|
|
594
|
+
line = scan.remainder
|
|
302
595
|
|
|
303
596
|
def quoted_repl(match: re.Match[str]) -> str:
|
|
304
597
|
nonlocal redacted
|
|
598
|
+
key = assignment_key(match.group("prefix"))
|
|
599
|
+
if key is None or not is_sensitive_key(key):
|
|
600
|
+
return match.group(0)
|
|
305
601
|
redacted = True
|
|
306
602
|
return f"{match.group('lead')}{match.group('prefix')}{match.group('quote')}[REDACTED]{match.group('quote')}"
|
|
307
603
|
|
|
308
604
|
def unquoted_repl(match: re.Match[str]) -> str:
|
|
309
605
|
nonlocal redacted
|
|
310
|
-
|
|
606
|
+
key = assignment_key(match.group("prefix"))
|
|
607
|
+
if key is None or not is_sensitive_key(key):
|
|
608
|
+
return match.group(0)
|
|
609
|
+
if not should_redact_unquoted_secret_value(line, match, context=context):
|
|
311
610
|
return match.group(0)
|
|
312
611
|
redacted = True
|
|
313
612
|
return f"{match.group('lead')}{match.group('prefix')}[REDACTED]"
|
|
314
613
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
614
|
+
def apply(span: str) -> str:
|
|
615
|
+
# 언제나 fragment 를 제거한 쌍둥이만 쓴다. 원본으로 되돌아가면 offset 마다
|
|
616
|
+
# location 을 다시 파싱해 이차 비용이 되살아난다.
|
|
617
|
+
span = INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(quoted_repl, span)
|
|
618
|
+
span = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
619
|
+
span = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
620
|
+
span = INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
621
|
+
span = INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
622
|
+
return span
|
|
623
|
+
|
|
624
|
+
# 후보 스팬이 consumer 신호를 품었을 때만 그 스팬도 검사한다. 두 스팬 모두 길이가
|
|
625
|
+
# 유계이므로 전체는 선형으로 남는다.
|
|
626
|
+
if scan.declined:
|
|
627
|
+
location_prefix = apply(location_prefix)
|
|
628
|
+
return location_prefix + apply(line), redacted
|
|
321
629
|
|
|
322
630
|
|
|
323
631
|
MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
|
|
324
632
|
rf"(?i)(?:^|[\s;{{\[,])(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
325
|
-
rf"[\"']?(
|
|
633
|
+
rf"[\"']?(?P<key>{SECRET_KEY})[\"']?\s*[:=]\s*(?P<quote>[\"'])"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# fragment 를 제거한 일곱 쌍둥이. 이차 비용은 unanchored consumer 에서만 발생하므로
|
|
638
|
+
# 그 일곱 개만 쌍둥이로 돌린다. ^ 로 고정된 헤더 두 개는 fragment 를 한 위치에서만
|
|
639
|
+
# 시도하니 원본을 그대로 쓰고, 스캐너 없이 스스로 prefix 를 건너뛴다.
|
|
640
|
+
UNANCHORED_LOCATION_CONSUMERS = (
|
|
641
|
+
"INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
|
|
642
|
+
"INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
|
|
643
|
+
"INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
|
|
644
|
+
"INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
|
|
645
|
+
"INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
|
|
646
|
+
"UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
647
|
+
"MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
648
|
+
)
|
|
649
|
+
ANCHORED_LOCATION_CONSUMERS = ("AUTH_HEADER_RE", "COOKIE_HEADER_RE")
|
|
650
|
+
INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
651
|
+
INLINE_QUOTED_SECRET_ASSIGNMENT_RE
|
|
652
|
+
)
|
|
653
|
+
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
654
|
+
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE
|
|
655
|
+
)
|
|
656
|
+
INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
657
|
+
INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE
|
|
658
|
+
)
|
|
659
|
+
INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
660
|
+
INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE
|
|
661
|
+
)
|
|
662
|
+
INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
663
|
+
INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE
|
|
664
|
+
)
|
|
665
|
+
UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
666
|
+
UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE
|
|
667
|
+
)
|
|
668
|
+
MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
669
|
+
MULTILINE_SECRET_ASSIGNMENT_RE
|
|
326
670
|
)
|
|
327
671
|
|
|
328
672
|
|
|
@@ -347,11 +691,23 @@ def has_unescaped_quote(text: str, quote: str, start: int = 0) -> bool:
|
|
|
347
691
|
|
|
348
692
|
|
|
349
693
|
def detect_multiline_secret_assignment(line: str) -> str | None:
|
|
350
|
-
"""Return the quote delimiter when any secret assignment starts a multiline value.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
694
|
+
"""Return the quote delimiter when any secret assignment starts a multiline value.
|
|
695
|
+
|
|
696
|
+
The leading location prefix is removed by the one-pass scanner first, so the
|
|
697
|
+
detector never re-parses it. Quote balancing still runs against the same
|
|
698
|
+
remainder the detector matched in, which keeps offsets consistent.
|
|
699
|
+
"""
|
|
700
|
+
scan = scan_location_prefix(line)
|
|
701
|
+
# 전체 줄을 먼저 본다: $ 와 줄 끝 의미가 기준선과 같아야 한다. 그 다음 remainder 를
|
|
702
|
+
# 보아 location prefix 바로 뒤에 붙은 키(콜론 직후)까지 잡는다. 둘 다 한 번씩이라 선형.
|
|
703
|
+
spans = (line, scan.remainder) if scan.prefix else (line,)
|
|
704
|
+
for span in spans:
|
|
705
|
+
for marker in MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE.finditer(span):
|
|
706
|
+
if not is_sensitive_key(marker.group("key")):
|
|
707
|
+
continue
|
|
708
|
+
quote = marker.group("quote")
|
|
709
|
+
if not has_unescaped_quote(span, quote, marker.end("quote")):
|
|
710
|
+
return quote
|
|
355
711
|
return None
|
|
356
712
|
|
|
357
713
|
|
|
@@ -382,9 +738,22 @@ def ends_with_continuation_operator(text: str) -> bool:
|
|
|
382
738
|
|
|
383
739
|
|
|
384
740
|
def detect_multiline_secret_expression(line: str) -> int | None:
|
|
385
|
-
|
|
741
|
+
# 스캐너가 앞머리 location prefix 를 먼저 떼어내므로 detector 는 그것을 재파싱하지 않는다.
|
|
742
|
+
# prefix 슬라이싱과 offset 이 어긋나지 않도록 이후 계산도 같은 remainder 위에서 한다.
|
|
743
|
+
scan = scan_location_prefix(line)
|
|
744
|
+
spans = (line, scan.remainder) if scan.prefix else (line,)
|
|
745
|
+
marker = None
|
|
746
|
+
for span in spans:
|
|
747
|
+
marker = UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE.search(span)
|
|
748
|
+
if marker is not None:
|
|
749
|
+
line = span
|
|
750
|
+
break
|
|
386
751
|
if marker is None:
|
|
387
752
|
return None
|
|
753
|
+
prefix = line[: marker.start("value")]
|
|
754
|
+
key = assignment_key(prefix)
|
|
755
|
+
if key is None or not is_sensitive_key(key):
|
|
756
|
+
return None
|
|
388
757
|
value = marker.group("value").strip()
|
|
389
758
|
if not value:
|
|
390
759
|
return 0
|
|
@@ -419,12 +788,23 @@ def secret_or_private_key_redaction_label(line: str) -> str:
|
|
|
419
788
|
|
|
420
789
|
|
|
421
790
|
class LineSanitizer:
|
|
422
|
-
def __init__(
|
|
791
|
+
def __init__(
|
|
792
|
+
self,
|
|
793
|
+
*,
|
|
794
|
+
show_paths: bool = False,
|
|
795
|
+
context: SanitizationContext | str = "unknown_text",
|
|
796
|
+
private_roots: Iterable[str] = (),
|
|
797
|
+
) -> None:
|
|
423
798
|
self.show_paths = show_paths
|
|
799
|
+
self.context = coerce_sanitization_context(
|
|
800
|
+
context,
|
|
801
|
+
private_roots=private_roots,
|
|
802
|
+
)
|
|
424
803
|
self.in_private_key_block = False
|
|
425
804
|
self.multiline_secret_quote: str | None = None
|
|
426
805
|
self.multiline_secret_expression_depth: int | None = None
|
|
427
806
|
self.redactions = 0
|
|
807
|
+
self.path_redactions = 0
|
|
428
808
|
|
|
429
809
|
def sanitize(self, raw_line: str) -> tuple[str, bool]:
|
|
430
810
|
line = strip_ansi(raw_line)
|
|
@@ -481,6 +861,11 @@ class LineSanitizer:
|
|
|
481
861
|
self.multiline_secret_expression_depth = expression_depth
|
|
482
862
|
return self._finish(diff_prefix + "[REDACTED MULTILINE SECRET]\n", True)
|
|
483
863
|
|
|
864
|
+
# 헤더 두 consumer 는 ^ 로 고정되어 있으므로 스캐너가 떼어낸 remainder 에 적용하고
|
|
865
|
+
# 앞머리 prefix 를 손대지 않은 채 다시 붙인다. 바이트 재구성은 정확해야 한다.
|
|
866
|
+
# 헤더 두 consumer 는 ^ 로 고정되어 있어 location fragment 를 단 한 위치에서만
|
|
867
|
+
# 시도한다. 즉 이차 비용의 원인이 아니므로 원본 패턴을 그대로 쓰고, 자신이
|
|
868
|
+
# 앞머리 prefix 를 건너뛰게 둔다. 스캐너는 unanchored consumer 만 돕는다.
|
|
484
869
|
new_line, count = AUTH_HEADER_RE.subn(r"\g<prefix>[REDACTED]", line)
|
|
485
870
|
if count:
|
|
486
871
|
redacted = True
|
|
@@ -491,22 +876,30 @@ class LineSanitizer:
|
|
|
491
876
|
redacted = True
|
|
492
877
|
line = new_line
|
|
493
878
|
|
|
494
|
-
line, assignment_redacted = redact_secret_assignments(
|
|
879
|
+
line, assignment_redacted = redact_secret_assignments(
|
|
880
|
+
line,
|
|
881
|
+
context=self.context,
|
|
882
|
+
)
|
|
495
883
|
if assignment_redacted:
|
|
496
884
|
redacted = True
|
|
497
885
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
redacted = True
|
|
886
|
+
line, credential_redactions = redact_high_confidence_credentials(line)
|
|
887
|
+
if credential_redactions:
|
|
888
|
+
redacted = True
|
|
502
889
|
|
|
503
890
|
return self._finish(line, redacted)
|
|
504
891
|
|
|
505
892
|
def _finish(self, line: str, redacted: bool) -> tuple[str, bool]:
|
|
893
|
+
if not self.show_paths:
|
|
894
|
+
line, path_redactions = anonymize_paths_for_context(
|
|
895
|
+
line,
|
|
896
|
+
context=self.context,
|
|
897
|
+
)
|
|
898
|
+
if path_redactions:
|
|
899
|
+
self.path_redactions += path_redactions
|
|
900
|
+
redacted = True
|
|
506
901
|
if redacted:
|
|
507
902
|
self.redactions += 1
|
|
508
|
-
if not self.show_paths:
|
|
509
|
-
line = anonymize_absolute_paths(line)
|
|
510
903
|
return line, redacted
|
|
511
904
|
|
|
512
905
|
|
|
@@ -600,7 +993,11 @@ class BoundedOutput:
|
|
|
600
993
|
|
|
601
994
|
|
|
602
995
|
def sanitize_stream(stream: Iterable[str], args: argparse.Namespace) -> tuple[str, int, int]:
|
|
603
|
-
sanitizer = LineSanitizer(
|
|
996
|
+
sanitizer = LineSanitizer(
|
|
997
|
+
show_paths=args.show_paths,
|
|
998
|
+
context=getattr(args, "context", "unknown_text"),
|
|
999
|
+
private_roots=getattr(args, "private_root", ()),
|
|
1000
|
+
)
|
|
604
1001
|
bounded = BoundedOutput(
|
|
605
1002
|
max_lines=args.max_lines,
|
|
606
1003
|
max_chars=args.max_chars,
|
|
@@ -872,6 +1269,33 @@ def stdin_has_data(stdin: TextIO) -> bool:
|
|
|
872
1269
|
return not stdin.isatty()
|
|
873
1270
|
|
|
874
1271
|
|
|
1272
|
+
def command_uses_search_diff_output(command: list[str]) -> bool:
|
|
1273
|
+
if not command:
|
|
1274
|
+
return False
|
|
1275
|
+
executable = os.path.basename(command[0])
|
|
1276
|
+
if executable in {"grep", "rg"}:
|
|
1277
|
+
return True
|
|
1278
|
+
if executable != "git":
|
|
1279
|
+
return False
|
|
1280
|
+
value_options = {"-C", "-c", "--git-dir", "--work-tree", "--namespace"}
|
|
1281
|
+
skip_next = False
|
|
1282
|
+
for arg in command[1:]:
|
|
1283
|
+
if skip_next:
|
|
1284
|
+
skip_next = False
|
|
1285
|
+
continue
|
|
1286
|
+
if arg == "--":
|
|
1287
|
+
break
|
|
1288
|
+
if arg in value_options:
|
|
1289
|
+
skip_next = True
|
|
1290
|
+
continue
|
|
1291
|
+
if any(arg.startswith(option + "=") for option in value_options if option.startswith("--")):
|
|
1292
|
+
continue
|
|
1293
|
+
if arg.startswith("-"):
|
|
1294
|
+
continue
|
|
1295
|
+
return arg in {"diff", "grep", "log", "show"}
|
|
1296
|
+
return False
|
|
1297
|
+
|
|
1298
|
+
|
|
875
1299
|
def build_parser() -> argparse.ArgumentParser:
|
|
876
1300
|
parser = argparse.ArgumentParser(
|
|
877
1301
|
description="Redact secrets and budget grep/diff/log output before sending it to Claude."
|
|
@@ -896,6 +1320,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
896
1320
|
action="store_true",
|
|
897
1321
|
help="show raw absolute paths instead of basename#path:<hash>; local debugging only because private paths may be exposed",
|
|
898
1322
|
)
|
|
1323
|
+
parser.add_argument(
|
|
1324
|
+
"--context",
|
|
1325
|
+
choices=SANITIZATION_MODES,
|
|
1326
|
+
default="unknown_text",
|
|
1327
|
+
help=(
|
|
1328
|
+
"sanitization origin policy (default: unknown_text); path-aware modes "
|
|
1329
|
+
"only anonymize structurally proven locations"
|
|
1330
|
+
),
|
|
1331
|
+
)
|
|
1332
|
+
parser.add_argument(
|
|
1333
|
+
"--private-root",
|
|
1334
|
+
action="append",
|
|
1335
|
+
default=[],
|
|
1336
|
+
help=(
|
|
1337
|
+
"private root eligible for path anonymization in filesystem_listing "
|
|
1338
|
+
"mode; may be repeated"
|
|
1339
|
+
),
|
|
1340
|
+
)
|
|
1341
|
+
parser.add_argument(
|
|
1342
|
+
"--context-guard-wrapper-v1",
|
|
1343
|
+
choices=("command_search_diff",),
|
|
1344
|
+
dest="wrapper_context",
|
|
1345
|
+
help=argparse.SUPPRESS,
|
|
1346
|
+
)
|
|
899
1347
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
900
1348
|
return parser
|
|
901
1349
|
|
|
@@ -907,6 +1355,30 @@ def main() -> int:
|
|
|
907
1355
|
command = args.command
|
|
908
1356
|
if command and command[0] == "--":
|
|
909
1357
|
command = command[1:]
|
|
1358
|
+
if args.wrapper_context is not None:
|
|
1359
|
+
if len(command) != 3 or command[0:2] != ["bash", "-lc"] or not command[2]:
|
|
1360
|
+
print(
|
|
1361
|
+
"context-guard-sanitize-output: invalid context-guard wrapper v1 shape",
|
|
1362
|
+
file=sys.stderr,
|
|
1363
|
+
)
|
|
1364
|
+
return 2
|
|
1365
|
+
context = coerce_sanitization_context(
|
|
1366
|
+
args.wrapper_context,
|
|
1367
|
+
private_roots=args.private_root,
|
|
1368
|
+
)
|
|
1369
|
+
else:
|
|
1370
|
+
context = coerce_sanitization_context(
|
|
1371
|
+
args.context,
|
|
1372
|
+
private_roots=args.private_root,
|
|
1373
|
+
)
|
|
1374
|
+
if (
|
|
1375
|
+
context.mode == "unknown_text"
|
|
1376
|
+
and command_uses_search_diff_output(command)
|
|
1377
|
+
):
|
|
1378
|
+
context = SanitizationContext(
|
|
1379
|
+
mode="command_search_diff",
|
|
1380
|
+
private_roots=context.private_roots,
|
|
1381
|
+
)
|
|
910
1382
|
|
|
911
1383
|
proc: subprocess.Popen[bytes] | None = None
|
|
912
1384
|
command_stream: TimedCommandStream | None = None
|
|
@@ -927,14 +1399,17 @@ def main() -> int:
|
|
|
927
1399
|
print("context-guard-sanitize-output: missing command or stdin", file=sys.stderr)
|
|
928
1400
|
return 2
|
|
929
1401
|
|
|
1402
|
+
args.context = context
|
|
930
1403
|
output, _redactions, _line_count = sanitize_stream(stream, args)
|
|
931
1404
|
rc: int | None = None
|
|
932
1405
|
if proc is not None:
|
|
933
1406
|
rc = command_stream.returncode() if command_stream is not None else proc.wait()
|
|
934
1407
|
if command_stream is not None and command_stream.timed_out and not command_stream.timeout_reported:
|
|
935
|
-
timeout_line, _redacted = LineSanitizer(
|
|
936
|
-
|
|
937
|
-
|
|
1408
|
+
timeout_line, _redacted = LineSanitizer(
|
|
1409
|
+
show_paths=args.show_paths,
|
|
1410
|
+
context=context,
|
|
1411
|
+
private_roots=args.private_root,
|
|
1412
|
+
).sanitize(command_stream.timeout_message())
|
|
938
1413
|
command_stream.timeout_reported = True
|
|
939
1414
|
output = output + timeout_line
|
|
940
1415
|
|