@ictechgy/context-guard 0.4.15 → 0.5.1
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 +80 -0
- package/README.ko.md +128 -2
- package/README.md +144 -3
- package/docs/distribution.md +100 -0
- package/package.json +4 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +43 -1
- package/plugins/context-guard/README.md +44 -1
- package/plugins/context-guard/bin/bash_reference_policy.py +967 -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 +9865 -211
- 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 +777 -83
- package/plugins/context-guard/bin/context-guard-guard-read +496 -57
- package/plugins/context-guard/bin/context-guard-mcp +2 -1
- package/plugins/context-guard/bin/context-guard-pack +1570 -150
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2669 -236
- package/plugins/context-guard/bin/context-guard-sanitize-output +723 -92
- package/plugins/context-guard/bin/context-guard-setup +1944 -222
- package/plugins/context-guard/bin/context-guard-statusline +163 -55
- package/plugins/context-guard/bin/context-guard-statusline-merged +78 -23
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +795 -48
- 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 +10 -2
- package/plugins/context-guard/lib/credential_policy.py +185 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -10,17 +10,63 @@ 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
|
|
20
|
+
import shutil
|
|
18
21
|
import signal
|
|
19
22
|
import subprocess
|
|
20
23
|
import sys
|
|
21
24
|
import threading
|
|
22
25
|
import time
|
|
23
|
-
from
|
|
26
|
+
from types import ModuleType
|
|
27
|
+
from typing import BinaryIO, Iterable, Iterator, NamedTuple, TextIO
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
WRAPPER_ENV_DENY = frozenset({
|
|
31
|
+
"BASHOPTS",
|
|
32
|
+
"BASH_ENV",
|
|
33
|
+
"BASH_XTRACEFD",
|
|
34
|
+
"ENV",
|
|
35
|
+
"PS4",
|
|
36
|
+
"PYTHONHOME",
|
|
37
|
+
"PYTHONPATH",
|
|
38
|
+
"PYTHONSTARTUP",
|
|
39
|
+
"SHELLOPTS",
|
|
40
|
+
})
|
|
41
|
+
BASH_FUNCTION_ENV_RE = re.compile(r"^BASH_FUNC_.*%%$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_credential_policy() -> ModuleType:
|
|
45
|
+
script_dir = Path(__file__).resolve().parent
|
|
46
|
+
candidate = (
|
|
47
|
+
script_dir.parent / "lib" / "credential_policy.py"
|
|
48
|
+
if script_dir.name == "bin"
|
|
49
|
+
else script_dir / "credential_policy.py"
|
|
50
|
+
)
|
|
51
|
+
spec = importlib.util.spec_from_file_location(
|
|
52
|
+
"_context_guard_sanitize_credential_policy",
|
|
53
|
+
candidate,
|
|
54
|
+
)
|
|
55
|
+
if spec is None or spec.loader is None:
|
|
56
|
+
raise RuntimeError(f"could not load credential policy: {candidate}")
|
|
57
|
+
module = importlib.util.module_from_spec(spec)
|
|
58
|
+
spec.loader.exec_module(module)
|
|
59
|
+
return module
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_CREDENTIAL_POLICY = load_credential_policy()
|
|
63
|
+
SECRET_KEY = _CREDENTIAL_POLICY.SECRET_KEY
|
|
64
|
+
CAMEL_ACRONYM_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_ACRONYM_BOUNDARY_RE
|
|
65
|
+
CAMEL_WORD_BOUNDARY_RE = _CREDENTIAL_POLICY.CAMEL_WORD_BOUNDARY_RE
|
|
66
|
+
normalize_sensitive_key = _CREDENTIAL_POLICY.normalize_sensitive_key
|
|
67
|
+
is_sensitive_key = _CREDENTIAL_POLICY.is_sensitive_key
|
|
68
|
+
redact_url_like_secret_params = _CREDENTIAL_POLICY.redact_url_like_secret_params
|
|
69
|
+
redact_high_confidence_credentials = _CREDENTIAL_POLICY.redact_high_confidence_credentials
|
|
24
70
|
|
|
25
71
|
TERMINAL_CONTROL_RE = re.compile(
|
|
26
72
|
r"(?:"
|
|
@@ -40,6 +86,22 @@ ABSOLUTE_PATH_RE = re.compile(
|
|
|
40
86
|
WINDOWS_PATH_RE = re.compile(
|
|
41
87
|
rf"(?P<prefix>^|[\s('\"=])(?P<path>[A-Za-z]:\\(?:{PATH_SEGMENT}\\)+{PATH_SEGMENT})"
|
|
42
88
|
)
|
|
89
|
+
TRACEBACK_PATH_RE = re.compile(
|
|
90
|
+
rf"(?P<prefix>\bFile\s+[\"'])(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
91
|
+
r"(?P<suffix>[\"'],\s+line\s+\d+)"
|
|
92
|
+
)
|
|
93
|
+
LOCATION_PATH_RE = re.compile(
|
|
94
|
+
rf"(?P<prefix>^(?:\s*[+-]\s*)?)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
95
|
+
r"(?P<suffix>:\d+(?::\d+)?(?=[:\s]|$))"
|
|
96
|
+
)
|
|
97
|
+
DIFF_PATH_RE = re.compile(
|
|
98
|
+
rf"(?P<prefix>^(?:---|\+\+\+)\s+)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
99
|
+
r"(?P<suffix>(?:\t.*)?$)"
|
|
100
|
+
)
|
|
101
|
+
LISTING_PATH_RE = re.compile(
|
|
102
|
+
rf"(?P<prefix>^\s*)(?P<path>/(?:{PATH_SEGMENT}/)+{PATH_SEGMENT})"
|
|
103
|
+
r"(?P<suffix>/?(?:\s+->\s+\S+)?\s*$)"
|
|
104
|
+
)
|
|
43
105
|
PRIVATE_KEY_BEGIN_RE = re.compile(
|
|
44
106
|
r"-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----"
|
|
45
107
|
)
|
|
@@ -52,29 +114,30 @@ AUTH_HEADER_RE = re.compile(
|
|
|
52
114
|
COOKIE_HEADER_RE = re.compile(
|
|
53
115
|
r"(?i)^(?P<prefix>\s*(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:Set-)?Cookie\s*:\s*).+$"
|
|
54
116
|
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
)
|
|
117
|
+
# QUOTED_VALUE_BODY의 두 대안은 첫 문자가 서로 겹치지 않아야 한다.
|
|
118
|
+
# 예전 형태 `(?:\\.|(?!(?P=quote)).)*`에서는 역슬래시 하나가 `\\.`의 시작이면서
|
|
119
|
+
# 동시에 두 번째 대안의 `.`이기도 했다. 닫는 따옴표가 없는 줄에서는 역슬래시
|
|
120
|
+
# 연속 구간을 나누는 방법이 피보나치 수만큼 생겨 지수 시간 백트래킹(ReDoS)이
|
|
121
|
+
# 발생했다 — 47자짜리 줄이 7.6초를 소모했다.
|
|
122
|
+
# 두 번째 대안에서 역슬래시를 제외해 각 위치에서 적용 가능한 대안이 하나뿐이도록
|
|
123
|
+
# 만든다. 뒤에 붙은 `\\?`는 이 배제로 잃게 되는 유일한 문자열 부류, 즉 값이
|
|
124
|
+
# 짝지어지지 않은 역슬래시 하나로 끝나는 경우(`token = "abc\"` → value `abc\`)를
|
|
125
|
+
# 복원한다. 이 형태가 예전 형태와 언어·스팬·그룹 모두에서 동치임은
|
|
126
|
+
# tests/test_sanitize_output_redos.py의 차분 배터리로 고정한다.
|
|
127
|
+
QUOTED_VALUE_BODY = r"(?:\\.|(?!(?P=quote))[^\\])*\\?"
|
|
128
|
+
SECRET_ASSIGNMENT_SEPARATOR = r"[ \t]*[:=][ \t]*"
|
|
66
129
|
INLINE_QUOTED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
67
130
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
68
131
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
69
|
-
rf"[\"']?(?:{SECRET_KEY})[\"']
|
|
70
|
-
rf"(?P<quote>[\"'])(?P<value>
|
|
132
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
|
|
133
|
+
rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
|
|
71
134
|
)
|
|
72
135
|
CODE_IDENTIFIER = r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*"
|
|
73
|
-
CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]
|
|
136
|
+
CALL_ARGUMENT_CHUNK = r"(?:[^()\"'\n;]|\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|\([^()]*\))*"
|
|
74
137
|
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE = re.compile(
|
|
75
138
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
76
139
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
77
|
-
rf"[\"']?(?:{SECRET_KEY})[\"']
|
|
140
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
|
|
78
141
|
rf"(?P<value>(?![\"']){CODE_IDENTIFIER}\({CALL_ARGUMENT_CHUNK}\))"
|
|
79
142
|
)
|
|
80
143
|
SECRET_IDENTIFIER_PART = (
|
|
@@ -86,7 +149,7 @@ FALLBACK_SECRET_OPERAND = rf"(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*{SECRET_IDENTIFIER_P
|
|
|
86
149
|
INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
|
|
87
150
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
88
151
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
89
|
-
rf"[\"']?(?:{SECRET_KEY})[\"']
|
|
152
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
|
|
90
153
|
rf"(?P<value>(?![\"']|\[REDACTED\])"
|
|
91
154
|
rf"[^;\n]*?(?:\bor\b|\|\||\?\?|\belse\b|\?[^:\n;]*:)\s*"
|
|
92
155
|
rf"(?:[\"'](?:\\.|[^\"'\\])*[\"']|{FALLBACK_SECRET_OPERAND})[^;\n]*)"
|
|
@@ -94,43 +157,165 @@ INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE = re.compile(
|
|
|
94
157
|
INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
95
158
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
96
159
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
97
|
-
rf"[\"']?(?:{SECRET_KEY})[\"']
|
|
160
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
|
|
98
161
|
rf"(?P<value>(?![\"']|\[REDACTED\])"
|
|
99
162
|
rf"[^\s,;}}\]]*(?:\([^;\n]*?\)|\{{[^;\n]*?\}}|\[[^;\n]*?\])[^\s,;}}\]]*)"
|
|
100
163
|
)
|
|
101
164
|
INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE = re.compile(
|
|
102
165
|
rf"(?i)(?P<lead>^|[\s;{{\[,])"
|
|
103
166
|
rf"(?P<prefix>(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
104
|
-
rf"[\"']?(?:{SECRET_KEY})[\"']
|
|
167
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
|
|
105
168
|
rf"(?P<value>(?![\"']|\[REDACTED\])[^\s,;}}\]]+)"
|
|
106
169
|
)
|
|
170
|
+
WHITESPACE_SECRET_ASSIGNMENT_PREFIX = (
|
|
171
|
+
rf"(?P<lead>\A)(?P<prefix>[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
|
|
172
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+)"
|
|
173
|
+
)
|
|
174
|
+
WHITESPACE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
|
|
175
|
+
rf"(?i){WHITESPACE_SECRET_ASSIGNMENT_PREFIX}"
|
|
176
|
+
rf"(?P<quote>[\"'])(?P<value>{QUOTED_VALUE_BODY})(?P=quote)(?P<tail>[^\s,;}}\]]*)"
|
|
177
|
+
)
|
|
178
|
+
WHITESPACE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
|
|
179
|
+
rf"(?i){WHITESPACE_SECRET_ASSIGNMENT_PREFIX}"
|
|
180
|
+
rf"(?P<value>(?![\"']|\[REDACTED\])\S[^\n]*)"
|
|
181
|
+
)
|
|
107
182
|
UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
|
|
108
183
|
rf"(?i)(?:^|[\s;{{\[,])"
|
|
109
184
|
rf"(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
110
|
-
rf"[\"']?(?:{SECRET_KEY})[\"']
|
|
185
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR}(?P<value>(?![\"']).*)$"
|
|
186
|
+
)
|
|
187
|
+
# F-14 one-pass scanner.
|
|
188
|
+
#
|
|
189
|
+
# 아홉 개 consumer 는 모두 아래 fragment 를 각자 품고 있었다. unanchored 패턴에서는
|
|
190
|
+
# offset 마다 `[^:\n]+` 가 다음 콜론까지 삼키고 되돌아오므로, 콜론이 드문 긴 줄에서
|
|
191
|
+
# offset 당 O(n) 작업이 발생해 전체가 이차가 된다(측정: 82KB 한 줄 2.62초,
|
|
192
|
+
# 배가 비율 3.6~3.96). 대신 줄마다 앞머리 location prefix 를 단조 좌→우로 한 번만
|
|
193
|
+
# 확정하고, consumer 에는 fragment 를 제거한 쌍둥이 패턴을 먹인다.
|
|
194
|
+
LOCATION_PREFIX_FRAGMENT = r"(?:(?:[^:\n]+):\d+(?::\d+)?:)?"
|
|
195
|
+
# path 성분에서 '=' 를 제외한다. 허용하면 'api_key=abc:123:456def' 처럼 비밀 값 안에서
|
|
196
|
+
# 줄이 쪼개져 앞부분만 가려지고 뒷부분이 남는 절단 누출이 발생한다. grep/diff 경로에
|
|
197
|
+
# '=' 가 들어가는 경우는 드물고, 그런 줄은 그냥 fast path 를 쓰지 않을 뿐이다.
|
|
198
|
+
# 공백과 유니코드는 계약대로 계속 허용한다.
|
|
199
|
+
LOCATION_PREFIX_SCAN_RE = re.compile(
|
|
200
|
+
r"\A(?P<lead>[ \t]*(?:[+-][ \t]*)?)(?P<location>[^:\n=]+:\d+(?::\d+)?:)"
|
|
201
|
+
)
|
|
202
|
+
# 후보 스팬의 신호는 두 종류이고 처리도 달라야 한다.
|
|
203
|
+
#
|
|
204
|
+
# ASSIGNMENT: 비밀 키나 헤더 이름 뒤에 ':' 또는 '=' 가 오면 그 스팬은 경로가 아니라
|
|
205
|
+
# 값의 일부다. 예: 'token:123456789:AAH-...' 는 앞부분만 가리면 뒷부분이 남는다.
|
|
206
|
+
# 이 경우 분리 자체를 하지 않고 전체 줄을 consumer 에 넘긴다.
|
|
207
|
+
#
|
|
208
|
+
# PATH_ONLY: 따옴표나 private key 표지처럼 경로 자체에 들어갈 수 있는 문자는 후보를
|
|
209
|
+
# 경로로 인정하되, 그 스팬도 함께 검사한다. 예: "src/it's.py:5:api_key='x'".
|
|
210
|
+
LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE = re.compile(
|
|
211
|
+
rf"(?i)(?:-----BEGIN|(?:Proxy-)?Authorization\s*:|(?:Set-)?Cookie\s*:"
|
|
212
|
+
rf"|[\"']?(?:{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR})"
|
|
213
|
+
)
|
|
214
|
+
LOCATION_PREFIX_WHITESPACE_ASSIGNMENT_SIGNAL_RE = re.compile(
|
|
215
|
+
rf"(?i)\A[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
|
|
216
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+"
|
|
217
|
+
)
|
|
218
|
+
LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE = re.compile(r"[\"']")
|
|
219
|
+
NINE_LOCATION_CONSUMERS = (
|
|
220
|
+
"AUTH_HEADER_RE",
|
|
221
|
+
"COOKIE_HEADER_RE",
|
|
222
|
+
"INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
|
|
223
|
+
"INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
|
|
224
|
+
"INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
|
|
225
|
+
"INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
|
|
226
|
+
"INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
|
|
227
|
+
"UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
228
|
+
"MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
111
229
|
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def without_location_prefix(compiled: re.Pattern[str]) -> re.Pattern[str]:
|
|
233
|
+
"""Return the same consumer with its embedded location prefix removed.
|
|
234
|
+
|
|
235
|
+
Deriving the twin from the compiled source keeps the two spellings from
|
|
236
|
+
drifting: if the fragment ever moves or changes, this fails loudly instead of
|
|
237
|
+
silently leaving a per-offset location scan in place.
|
|
238
|
+
"""
|
|
239
|
+
source = compiled.pattern
|
|
240
|
+
if source.count(LOCATION_PREFIX_FRAGMENT) != 1:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
"consumer no longer embeds exactly one location prefix fragment"
|
|
243
|
+
)
|
|
244
|
+
return re.compile(source.replace(LOCATION_PREFIX_FRAGMENT, "", 1), compiled.flags)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ScannedLine(NamedTuple):
|
|
248
|
+
"""One monotonic left-to-right split of a line into prefix and remainder.
|
|
249
|
+
|
|
250
|
+
An assignment signal inside the candidate means the span is part of a value
|
|
251
|
+
rather than a path, so no split is reported at all; splitting there would
|
|
252
|
+
redact only the leading part and leave the tail behind. A path-only signal
|
|
253
|
+
such as a quote in a filename keeps the split but sets ``declined``, and
|
|
254
|
+
unanchored consumers then also scan the bounded candidate span.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
prefix: str
|
|
258
|
+
remainder: str
|
|
259
|
+
scan_index: int
|
|
260
|
+
declined: bool = False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def scan_location_prefix(line: str) -> ScannedLine:
|
|
264
|
+
"""Identify a leading grep/diff location prefix in exactly one anchored scan.
|
|
265
|
+
|
|
266
|
+
The scan index only ever moves forward: it is the end of the anchored match,
|
|
267
|
+
or zero when there is no prefix.
|
|
268
|
+
|
|
269
|
+
The path component must keep accepting spaces and Unicode, because real grep
|
|
270
|
+
and diff output carries such filenames. That permissiveness means the
|
|
271
|
+
anchored run can reach past a secret that happens to sit before a later
|
|
272
|
+
``:<digits>:`` sequence, so the candidate span is checked once for consumer
|
|
273
|
+
signal and flagged when any is present. The check is a single bounded pass, so
|
|
274
|
+
the scan stays linear.
|
|
275
|
+
"""
|
|
276
|
+
match = LOCATION_PREFIX_SCAN_RE.match(line)
|
|
277
|
+
if match is None:
|
|
278
|
+
return ScannedLine("", line, 0)
|
|
279
|
+
end = match.end()
|
|
280
|
+
candidate = line[:end]
|
|
281
|
+
if (
|
|
282
|
+
LOCATION_PREFIX_ASSIGNMENT_SIGNAL_RE.search(candidate)
|
|
283
|
+
or LOCATION_PREFIX_WHITESPACE_ASSIGNMENT_SIGNAL_RE.match(candidate)
|
|
284
|
+
):
|
|
285
|
+
# 값의 일부를 경로로 오인한 경우다. 분리하면 앞부분만 가려져 뒤가 남는다.
|
|
286
|
+
return ScannedLine("", line, 0, False)
|
|
287
|
+
declined = bool(LOCATION_PREFIX_PATH_ONLY_SIGNAL_RE.search(candidate))
|
|
288
|
+
return ScannedLine(candidate, line[end:], end, declined)
|
|
289
|
+
|
|
290
|
+
|
|
112
291
|
CONTINUATION_OPERATOR_RE = re.compile(
|
|
113
292
|
r"(?i)(?:\\|\|\||&&|\?\?|[+*/%&|^?,]|\?|:|\bor\b|\band\b|\belse\b)\s*(?://.*|#.*)?$"
|
|
114
293
|
)
|
|
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
294
|
SAFE_UNQUOTED_VALUES = {
|
|
118
295
|
"[redacted]",
|
|
296
|
+
"bool",
|
|
297
|
+
"boolean",
|
|
298
|
+
"bytes",
|
|
119
299
|
"false",
|
|
300
|
+
"float",
|
|
301
|
+
"int",
|
|
302
|
+
"integer",
|
|
120
303
|
"none",
|
|
121
304
|
"null",
|
|
305
|
+
"object",
|
|
122
306
|
"os.getenv",
|
|
123
307
|
"process.env",
|
|
308
|
+
"str",
|
|
309
|
+
"string",
|
|
124
310
|
"true",
|
|
125
311
|
"undefined",
|
|
312
|
+
"unknown",
|
|
126
313
|
}
|
|
127
314
|
IDENTIFIER_CHAIN_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+$")
|
|
128
315
|
SAFE_ENV_LOOKUP_CALL_RE = re.compile(r"^(?:os\.getenv|os\.environ\.get)\(\s*[\"'][A-Za-z0-9_.-]{1,80}[\"']\s*\)$")
|
|
129
316
|
SAFE_RE_COMPILE_CALL_RE = re.compile(r"^re\.compile\([^;\n]*\)$")
|
|
130
317
|
SAFE_CODE_EXPRESSION_CALL_RE = re.compile(rf"^{CODE_IDENTIFIER}\(\s*(?:{CODE_IDENTIFIER}(?:\s*,\s*{CODE_IDENTIFIER})*)?\s*\)$")
|
|
131
318
|
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
319
|
SAFE_GETTER_KEY_NAMES = {
|
|
135
320
|
"access_key",
|
|
136
321
|
"access_token",
|
|
@@ -162,26 +347,43 @@ SAFE_GETTER_KEY_NAMES = {
|
|
|
162
347
|
"sid",
|
|
163
348
|
"token",
|
|
164
349
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
350
|
+
AMBIGUOUS_BARE_WHITESPACE_KEYS = frozenset(
|
|
351
|
+
{
|
|
352
|
+
"auth",
|
|
353
|
+
"authorization",
|
|
354
|
+
"cookie",
|
|
355
|
+
"credential",
|
|
356
|
+
"credentials",
|
|
357
|
+
"csrf",
|
|
358
|
+
"jwt",
|
|
359
|
+
"pass",
|
|
360
|
+
"password",
|
|
361
|
+
"passwd",
|
|
362
|
+
"pwd",
|
|
363
|
+
"secret",
|
|
364
|
+
"session",
|
|
365
|
+
"sid",
|
|
366
|
+
"sig",
|
|
367
|
+
"signature",
|
|
368
|
+
"token",
|
|
369
|
+
"xsrf",
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
ASSIGNMENT_KEY_RE = re.compile(
|
|
373
|
+
rf"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?{SECRET_ASSIGNMENT_SEPARATOR}$"
|
|
374
|
+
)
|
|
375
|
+
WHITESPACE_ASSIGNMENT_KEY_RE = re.compile(
|
|
376
|
+
r"(?P<key>[A-Za-z_$][A-Za-z0-9_$.-]*)[\"']?[ \t]+$"
|
|
377
|
+
)
|
|
378
|
+
SOURCE_SAFE_VALUE_RE = re.compile(
|
|
379
|
+
r"^(?:"
|
|
380
|
+
r"[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*|"
|
|
381
|
+
r"[A-Za-z_$][A-Za-z0-9_$]*(?:\[[A-Za-z0-9_$., |]+\])+|"
|
|
382
|
+
r"\d+(?:\.\d+)?|"
|
|
383
|
+
r"\$\{[A-Za-z0-9_.-]+\}|"
|
|
384
|
+
r"<[A-Za-z0-9_.-]+>|"
|
|
385
|
+
r"(?:YOUR|REPLACE|EXAMPLE|PLACEHOLDER)_[A-Z0-9_]+"
|
|
386
|
+
r")$"
|
|
185
387
|
)
|
|
186
388
|
ANCHOR_RE = re.compile(
|
|
187
389
|
r"^(?:diff --git |index [0-9a-f]|--- |\+\+\+ |@@ |Binary files |(?:[^:\n]+):\d+(?::\d+)?:)",
|
|
@@ -200,6 +402,55 @@ COMMAND_MAX_UNTERMINATED_LINE_CHARS = 4_096
|
|
|
200
402
|
RAW_TRUNCATION_REDACTION_HOLDBACK_CHARS = 1_024
|
|
201
403
|
|
|
202
404
|
|
|
405
|
+
SANITIZATION_MODES = (
|
|
406
|
+
"unknown_text",
|
|
407
|
+
"command_search_diff",
|
|
408
|
+
"filesystem_listing",
|
|
409
|
+
"source_code",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@dataclass(frozen=True)
|
|
414
|
+
class SanitizationContext:
|
|
415
|
+
mode: str = "unknown_text"
|
|
416
|
+
private_roots: tuple[str, ...] = ()
|
|
417
|
+
|
|
418
|
+
def __post_init__(self) -> None:
|
|
419
|
+
if self.mode not in SANITIZATION_MODES:
|
|
420
|
+
raise ValueError(f"unsupported sanitization context: {self.mode}")
|
|
421
|
+
object.__setattr__(
|
|
422
|
+
self,
|
|
423
|
+
"private_roots",
|
|
424
|
+
tuple(str(root) for root in self.private_roots),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def coerce_sanitization_context(
|
|
429
|
+
value: SanitizationContext | str,
|
|
430
|
+
*,
|
|
431
|
+
private_roots: Iterable[str] = (),
|
|
432
|
+
) -> SanitizationContext:
|
|
433
|
+
if isinstance(value, SanitizationContext):
|
|
434
|
+
supplied_roots = tuple(str(root) for root in private_roots)
|
|
435
|
+
normalized_context_roots = _normalized_private_roots(value.private_roots)
|
|
436
|
+
if supplied_roots and _normalized_private_roots(
|
|
437
|
+
supplied_roots
|
|
438
|
+
) != normalized_context_roots:
|
|
439
|
+
raise ValueError(
|
|
440
|
+
"private_roots must be declared inside SanitizationContext "
|
|
441
|
+
"when a context object is supplied"
|
|
442
|
+
)
|
|
443
|
+
roots = normalized_context_roots
|
|
444
|
+
mode = value.mode
|
|
445
|
+
else:
|
|
446
|
+
roots = tuple(str(root) for root in private_roots)
|
|
447
|
+
mode = str(value)
|
|
448
|
+
return SanitizationContext(
|
|
449
|
+
mode=mode,
|
|
450
|
+
private_roots=_normalized_private_roots(roots),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
203
454
|
def bounded_int(value: object, default: int, minimum: int, maximum: int) -> int:
|
|
204
455
|
try:
|
|
205
456
|
number = int(value)
|
|
@@ -231,16 +482,98 @@ def stable_hash(value: str, length: int = 12) -> str:
|
|
|
231
482
|
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:length]
|
|
232
483
|
|
|
233
484
|
|
|
234
|
-
def
|
|
485
|
+
def anonymized_path(path: str) -> str:
|
|
486
|
+
normalized = path.replace("\\", "/")
|
|
487
|
+
name = PurePosixPath(normalized).name or "path"
|
|
488
|
+
return f"{name}#path:{stable_hash(path)}"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def anonymize_absolute_paths_with_count(text: str) -> tuple[str, int]:
|
|
492
|
+
count = 0
|
|
493
|
+
|
|
235
494
|
def repl(match: re.Match[str]) -> str:
|
|
495
|
+
nonlocal count
|
|
236
496
|
prefix = match.group("prefix")
|
|
237
497
|
path = match.group("path")
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return f"{prefix}{name}#path:{stable_hash(path)}"
|
|
498
|
+
count += 1
|
|
499
|
+
return f"{prefix}{anonymized_path(path)}"
|
|
241
500
|
|
|
242
501
|
text = ABSOLUTE_PATH_RE.sub(repl, text)
|
|
243
|
-
return WINDOWS_PATH_RE.sub(repl, text)
|
|
502
|
+
return WINDOWS_PATH_RE.sub(repl, text), count
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def anonymize_absolute_paths(text: str) -> str:
|
|
506
|
+
return anonymize_absolute_paths_with_count(text)[0]
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _replace_named_paths(text: str, pattern: re.Pattern[str]) -> tuple[str, int]:
|
|
510
|
+
count = 0
|
|
511
|
+
|
|
512
|
+
def repl(match: re.Match[str]) -> str:
|
|
513
|
+
nonlocal count
|
|
514
|
+
count += 1
|
|
515
|
+
return (
|
|
516
|
+
match.group("prefix")
|
|
517
|
+
+ anonymized_path(match.group("path"))
|
|
518
|
+
+ match.group("suffix")
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return pattern.sub(repl, text), count
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _normalized_private_roots(private_roots: Iterable[str]) -> tuple[str, ...]:
|
|
525
|
+
normalized: list[str] = []
|
|
526
|
+
for root in private_roots:
|
|
527
|
+
value = os.path.normpath(os.path.abspath(os.path.expanduser(str(root))))
|
|
528
|
+
if value not in normalized:
|
|
529
|
+
normalized.append(value)
|
|
530
|
+
return tuple(normalized)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _is_under_private_root(path: str, private_roots: tuple[str, ...]) -> bool:
|
|
534
|
+
normalized = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
|
535
|
+
return any(
|
|
536
|
+
normalized == root or normalized.startswith(root.rstrip(os.sep) + os.sep)
|
|
537
|
+
for root in private_roots
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def anonymize_private_root_paths(
|
|
542
|
+
text: str,
|
|
543
|
+
private_roots: tuple[str, ...],
|
|
544
|
+
) -> tuple[str, int]:
|
|
545
|
+
if not private_roots:
|
|
546
|
+
return text, 0
|
|
547
|
+
count = 0
|
|
548
|
+
|
|
549
|
+
def repl(match: re.Match[str]) -> str:
|
|
550
|
+
nonlocal count
|
|
551
|
+
path = match.group("path")
|
|
552
|
+
if not _is_under_private_root(path, private_roots):
|
|
553
|
+
return match.group(0)
|
|
554
|
+
count += 1
|
|
555
|
+
return match.group("prefix") + anonymized_path(path)
|
|
556
|
+
|
|
557
|
+
return ABSOLUTE_PATH_RE.sub(repl, text), count
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def anonymize_paths_for_context(
|
|
561
|
+
text: str,
|
|
562
|
+
*,
|
|
563
|
+
context: SanitizationContext,
|
|
564
|
+
) -> tuple[str, int]:
|
|
565
|
+
if context.mode in {"unknown_text", "source_code"}:
|
|
566
|
+
return text, 0
|
|
567
|
+
|
|
568
|
+
total = 0
|
|
569
|
+
if context.mode == "filesystem_listing":
|
|
570
|
+
text, count = anonymize_private_root_paths(text, context.private_roots)
|
|
571
|
+
total += count
|
|
572
|
+
|
|
573
|
+
for pattern in (TRACEBACK_PATH_RE, LOCATION_PATH_RE, DIFF_PATH_RE):
|
|
574
|
+
text, count = _replace_named_paths(text, pattern)
|
|
575
|
+
total += count
|
|
576
|
+
return text, total
|
|
244
577
|
|
|
245
578
|
|
|
246
579
|
def cap_line(line: str, max_line_chars: int) -> tuple[str, bool]:
|
|
@@ -260,21 +593,56 @@ def normalize_getter_key(key: str) -> str:
|
|
|
260
593
|
return re.sub(r"_+", "_", key).strip("_").lower()
|
|
261
594
|
|
|
262
595
|
|
|
596
|
+
def assignment_key(prefix: str) -> str | None:
|
|
597
|
+
for pattern in (ASSIGNMENT_KEY_RE, WHITESPACE_ASSIGNMENT_KEY_RE):
|
|
598
|
+
match = pattern.search(prefix)
|
|
599
|
+
if match is not None:
|
|
600
|
+
return match.group("key")
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
|
|
263
604
|
def is_safe_getter_key(key: str) -> bool:
|
|
264
605
|
return normalize_getter_key(key) in SAFE_GETTER_KEY_NAMES
|
|
265
606
|
|
|
266
607
|
|
|
267
|
-
def
|
|
608
|
+
def is_ambiguous_bare_whitespace_key(key: str) -> bool:
|
|
609
|
+
return normalize_sensitive_key(key.strip().strip("\"'")) in AMBIGUOUS_BARE_WHITESPACE_KEYS
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def should_redact_whitespace_secret_value(key: str, value: str) -> bool:
|
|
613
|
+
stripped_key = key.strip().strip("\"'")
|
|
614
|
+
normalized_key = normalize_sensitive_key(stripped_key)
|
|
615
|
+
if normalized_key == "pass" and stripped_key != "pass":
|
|
616
|
+
return False
|
|
617
|
+
return not (
|
|
618
|
+
normalized_key in AMBIGUOUS_BARE_WHITESPACE_KEYS
|
|
619
|
+
and re.search(r"[ \t]", value.strip()) is not None
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def should_redact_unquoted_secret_value(
|
|
624
|
+
line: str,
|
|
625
|
+
match: re.Match[str],
|
|
626
|
+
*,
|
|
627
|
+
context: SanitizationContext,
|
|
628
|
+
) -> bool:
|
|
268
629
|
value = match.group("value").strip()
|
|
269
630
|
prefix = match.group("prefix")
|
|
270
631
|
if not value:
|
|
271
632
|
return False
|
|
272
633
|
if value.lower() in SAFE_UNQUOTED_VALUES:
|
|
273
634
|
return False
|
|
635
|
+
if re.search(r":\s*$", prefix):
|
|
636
|
+
return not (
|
|
637
|
+
context.mode == "source_code"
|
|
638
|
+
and SOURCE_SAFE_VALUE_RE.match(value) is not None
|
|
639
|
+
)
|
|
274
640
|
if IDENTIFIER_CHAIN_RE.match(value):
|
|
275
641
|
return False
|
|
276
642
|
if SAFE_ENV_LOOKUP_CALL_RE.match(value) or SAFE_RE_COMPILE_CALL_RE.match(value):
|
|
277
643
|
return False
|
|
644
|
+
if context.mode == "source_code" and SOURCE_SAFE_VALUE_RE.match(value):
|
|
645
|
+
return False
|
|
278
646
|
getter_match = GETTER_CALL_RE.match(value)
|
|
279
647
|
if re.search(r"\s[:=]\s*$", prefix) and (
|
|
280
648
|
SAFE_CODE_EXPRESSION_CALL_RE.match(value)
|
|
@@ -284,45 +652,121 @@ def should_redact_unquoted_secret_value(line: str, match: re.Match[str]) -> bool
|
|
|
284
652
|
return True
|
|
285
653
|
|
|
286
654
|
|
|
287
|
-
def
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def redact_secret_assignments(line: str) -> tuple[str, bool]:
|
|
655
|
+
def redact_secret_assignments(
|
|
656
|
+
line: str,
|
|
657
|
+
*,
|
|
658
|
+
context: SanitizationContext,
|
|
659
|
+
) -> tuple[str, bool]:
|
|
660
|
+
"""Redact inline secret assignments using the no-location consumer twins.
|
|
661
|
+
|
|
662
|
+
The leading grep/diff location prefix is identified once by the scanner and
|
|
663
|
+
reattached untouched, so no consumer re-parses it at every offset.
|
|
664
|
+
"""
|
|
665
|
+
# URL 형태 비밀 파라미터는 분리 전 전체 줄에서 처리한다. prefix 안에 들어 있어도
|
|
666
|
+
# 가려지지 않고 다시 붙는 일이 없어야 한다.
|
|
301
667
|
line, redacted = redact_url_like_secret_params(line)
|
|
668
|
+
# 반드시 URL 치환 이후의 문자열에서 다시 스캔한다. 호출자가 미리 계산한 split 을
|
|
669
|
+
# 받아 쓰면 치환으로 길이가 바뀐 문자열에 낡은 오프셋을 적용해 내용이 유실된다.
|
|
670
|
+
scan = scan_location_prefix(line)
|
|
671
|
+
location_prefix = scan.prefix
|
|
672
|
+
line = scan.remainder
|
|
302
673
|
|
|
303
674
|
def quoted_repl(match: re.Match[str]) -> str:
|
|
304
675
|
nonlocal redacted
|
|
676
|
+
key = assignment_key(match.group("prefix"))
|
|
677
|
+
if key is None or not is_sensitive_key(key):
|
|
678
|
+
return match.group(0)
|
|
305
679
|
redacted = True
|
|
306
680
|
return f"{match.group('lead')}{match.group('prefix')}{match.group('quote')}[REDACTED]{match.group('quote')}"
|
|
307
681
|
|
|
308
|
-
def unquoted_repl(
|
|
682
|
+
def unquoted_repl(
|
|
683
|
+
match: re.Match[str],
|
|
684
|
+
*,
|
|
685
|
+
whitespace_separator: bool = False,
|
|
686
|
+
) -> str:
|
|
309
687
|
nonlocal redacted
|
|
310
|
-
|
|
688
|
+
key = assignment_key(match.group("prefix"))
|
|
689
|
+
if key is None or not is_sensitive_key(key):
|
|
690
|
+
return match.group(0)
|
|
691
|
+
if whitespace_separator and not should_redact_whitespace_secret_value(
|
|
692
|
+
key,
|
|
693
|
+
match.group("value"),
|
|
694
|
+
):
|
|
695
|
+
return match.group(0)
|
|
696
|
+
if not should_redact_unquoted_secret_value(line, match, context=context):
|
|
311
697
|
return match.group(0)
|
|
312
698
|
redacted = True
|
|
313
699
|
return f"{match.group('lead')}{match.group('prefix')}[REDACTED]"
|
|
314
700
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
701
|
+
def apply(span: str) -> str:
|
|
702
|
+
# 언제나 fragment 를 제거한 쌍둥이만 쓴다. 원본으로 되돌아가면 offset 마다
|
|
703
|
+
# location 을 다시 파싱해 이차 비용이 되살아난다.
|
|
704
|
+
span = INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(quoted_repl, span)
|
|
705
|
+
span = INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
706
|
+
span = INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
707
|
+
span = INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
708
|
+
span = WHITESPACE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(quoted_repl, span)
|
|
709
|
+
span = WHITESPACE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(
|
|
710
|
+
lambda match: unquoted_repl(match, whitespace_separator=True),
|
|
711
|
+
span,
|
|
712
|
+
)
|
|
713
|
+
span = INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE.sub(unquoted_repl, span)
|
|
714
|
+
return span
|
|
715
|
+
|
|
716
|
+
# 후보 스팬이 consumer 신호를 품었을 때만 그 스팬도 검사한다. 두 스팬 모두 길이가
|
|
717
|
+
# 유계이므로 전체는 선형으로 남는다.
|
|
718
|
+
if scan.declined:
|
|
719
|
+
location_prefix = apply(location_prefix)
|
|
720
|
+
return location_prefix + apply(line), redacted
|
|
321
721
|
|
|
322
722
|
|
|
323
723
|
MULTILINE_SECRET_ASSIGNMENT_RE = re.compile(
|
|
324
724
|
rf"(?i)(?:^|[\s;{{\[,])(?:(?:[^:\n]+):\d+(?::\d+)?:)?\s*(?:[+-]\s*)?(?:export\s+)?"
|
|
325
|
-
rf"[\"']?(
|
|
725
|
+
rf"[\"']?(?P<key>{SECRET_KEY})[\"']?{SECRET_ASSIGNMENT_SEPARATOR}(?P<quote>[\"'])"
|
|
726
|
+
)
|
|
727
|
+
WHITESPACE_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
|
|
728
|
+
rf"(?i)\A[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
|
|
729
|
+
rf"[\"']?(?P<key>{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+(?P<quote>[\"'])"
|
|
730
|
+
)
|
|
731
|
+
WHITESPACE_UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = re.compile(
|
|
732
|
+
rf"(?i)\A[ \t]*(?:[+-][ \t]*)?(?:export[ \t]+)?"
|
|
733
|
+
rf"[\"']?(?:{SECRET_KEY})[\"']?(?![ \t]*[:=])[ \t]+(?P<value>(?![\"']).*)$"
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# fragment 를 제거한 일곱 쌍둥이. 이차 비용은 unanchored consumer 에서만 발생하므로
|
|
738
|
+
# 그 일곱 개만 쌍둥이로 돌린다. ^ 로 고정된 헤더 두 개는 fragment 를 한 위치에서만
|
|
739
|
+
# 시도하니 원본을 그대로 쓰고, 스캐너 없이 스스로 prefix 를 건너뛴다.
|
|
740
|
+
UNANCHORED_LOCATION_CONSUMERS = (
|
|
741
|
+
"INLINE_QUOTED_SECRET_ASSIGNMENT_RE",
|
|
742
|
+
"INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE",
|
|
743
|
+
"INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE",
|
|
744
|
+
"INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE",
|
|
745
|
+
"INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE",
|
|
746
|
+
"UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
747
|
+
"MULTILINE_SECRET_ASSIGNMENT_RE",
|
|
748
|
+
)
|
|
749
|
+
ANCHORED_LOCATION_CONSUMERS = ("AUTH_HEADER_RE", "COOKIE_HEADER_RE")
|
|
750
|
+
INLINE_QUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
751
|
+
INLINE_QUOTED_SECRET_ASSIGNMENT_RE
|
|
752
|
+
)
|
|
753
|
+
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
754
|
+
INLINE_UNQUOTED_CALL_SECRET_ASSIGNMENT_RE
|
|
755
|
+
)
|
|
756
|
+
INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
757
|
+
INLINE_UNQUOTED_FALLBACK_SECRET_ASSIGNMENT_RE
|
|
758
|
+
)
|
|
759
|
+
INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
760
|
+
INLINE_UNQUOTED_BRACKETED_SECRET_ASSIGNMENT_RE
|
|
761
|
+
)
|
|
762
|
+
INLINE_UNQUOTED_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
763
|
+
INLINE_UNQUOTED_SECRET_ASSIGNMENT_RE
|
|
764
|
+
)
|
|
765
|
+
UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
766
|
+
UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_RE
|
|
767
|
+
)
|
|
768
|
+
MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE = without_location_prefix(
|
|
769
|
+
MULTILINE_SECRET_ASSIGNMENT_RE
|
|
326
770
|
)
|
|
327
771
|
|
|
328
772
|
|
|
@@ -347,11 +791,30 @@ def has_unescaped_quote(text: str, quote: str, start: int = 0) -> bool:
|
|
|
347
791
|
|
|
348
792
|
|
|
349
793
|
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
|
-
|
|
794
|
+
"""Return the quote delimiter when any secret assignment starts a multiline value.
|
|
795
|
+
|
|
796
|
+
The leading location prefix is removed by the one-pass scanner first, so the
|
|
797
|
+
detector never re-parses it. Quote balancing still runs against the same
|
|
798
|
+
remainder the detector matched in, which keeps offsets consistent.
|
|
799
|
+
"""
|
|
800
|
+
scan = scan_location_prefix(line)
|
|
801
|
+
# 전체 줄을 먼저 본다: $ 와 줄 끝 의미가 기준선과 같아야 한다. 그 다음 remainder 를
|
|
802
|
+
# 보아 location prefix 바로 뒤에 붙은 키(콜론 직후)까지 잡는다. 둘 다 한 번씩이라 선형.
|
|
803
|
+
spans = (line, scan.remainder) if scan.prefix else (line,)
|
|
804
|
+
for span in spans:
|
|
805
|
+
for pattern, whitespace_separator in (
|
|
806
|
+
(MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, False),
|
|
807
|
+
(WHITESPACE_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, True),
|
|
808
|
+
):
|
|
809
|
+
for marker in pattern.finditer(span):
|
|
810
|
+
key = marker.group("key")
|
|
811
|
+
if not is_sensitive_key(key):
|
|
812
|
+
continue
|
|
813
|
+
if whitespace_separator and is_ambiguous_bare_whitespace_key(key):
|
|
814
|
+
continue
|
|
815
|
+
quote = marker.group("quote")
|
|
816
|
+
if not has_unescaped_quote(span, quote, marker.end("quote")):
|
|
817
|
+
return quote
|
|
355
818
|
return None
|
|
356
819
|
|
|
357
820
|
|
|
@@ -382,9 +845,30 @@ def ends_with_continuation_operator(text: str) -> bool:
|
|
|
382
845
|
|
|
383
846
|
|
|
384
847
|
def detect_multiline_secret_expression(line: str) -> int | None:
|
|
385
|
-
|
|
848
|
+
# 스캐너가 앞머리 location prefix 를 먼저 떼어내므로 detector 는 그것을 재파싱하지 않는다.
|
|
849
|
+
# prefix 슬라이싱과 offset 이 어긋나지 않도록 이후 계산도 같은 remainder 위에서 한다.
|
|
850
|
+
scan = scan_location_prefix(line)
|
|
851
|
+
spans = (line, scan.remainder) if scan.prefix else (line,)
|
|
852
|
+
marker = None
|
|
853
|
+
for span in spans:
|
|
854
|
+
for pattern, whitespace_separator in (
|
|
855
|
+
(UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, False),
|
|
856
|
+
(WHITESPACE_UNQUOTED_MULTILINE_SECRET_ASSIGNMENT_NO_LOCATION_RE, True),
|
|
857
|
+
):
|
|
858
|
+
marker = pattern.search(span)
|
|
859
|
+
if marker is not None:
|
|
860
|
+
line = span
|
|
861
|
+
break
|
|
862
|
+
if marker is not None:
|
|
863
|
+
break
|
|
386
864
|
if marker is None:
|
|
387
865
|
return None
|
|
866
|
+
prefix = line[: marker.start("value")]
|
|
867
|
+
key = assignment_key(prefix)
|
|
868
|
+
if key is None or not is_sensitive_key(key):
|
|
869
|
+
return None
|
|
870
|
+
if whitespace_separator and is_ambiguous_bare_whitespace_key(key):
|
|
871
|
+
return None
|
|
388
872
|
value = marker.group("value").strip()
|
|
389
873
|
if not value:
|
|
390
874
|
return 0
|
|
@@ -419,12 +903,23 @@ def secret_or_private_key_redaction_label(line: str) -> str:
|
|
|
419
903
|
|
|
420
904
|
|
|
421
905
|
class LineSanitizer:
|
|
422
|
-
def __init__(
|
|
906
|
+
def __init__(
|
|
907
|
+
self,
|
|
908
|
+
*,
|
|
909
|
+
show_paths: bool = False,
|
|
910
|
+
context: SanitizationContext | str = "unknown_text",
|
|
911
|
+
private_roots: Iterable[str] = (),
|
|
912
|
+
) -> None:
|
|
423
913
|
self.show_paths = show_paths
|
|
914
|
+
self.context = coerce_sanitization_context(
|
|
915
|
+
context,
|
|
916
|
+
private_roots=private_roots,
|
|
917
|
+
)
|
|
424
918
|
self.in_private_key_block = False
|
|
425
919
|
self.multiline_secret_quote: str | None = None
|
|
426
920
|
self.multiline_secret_expression_depth: int | None = None
|
|
427
921
|
self.redactions = 0
|
|
922
|
+
self.path_redactions = 0
|
|
428
923
|
|
|
429
924
|
def sanitize(self, raw_line: str) -> tuple[str, bool]:
|
|
430
925
|
line = strip_ansi(raw_line)
|
|
@@ -481,6 +976,11 @@ class LineSanitizer:
|
|
|
481
976
|
self.multiline_secret_expression_depth = expression_depth
|
|
482
977
|
return self._finish(diff_prefix + "[REDACTED MULTILINE SECRET]\n", True)
|
|
483
978
|
|
|
979
|
+
# 헤더 두 consumer 는 ^ 로 고정되어 있으므로 스캐너가 떼어낸 remainder 에 적용하고
|
|
980
|
+
# 앞머리 prefix 를 손대지 않은 채 다시 붙인다. 바이트 재구성은 정확해야 한다.
|
|
981
|
+
# 헤더 두 consumer 는 ^ 로 고정되어 있어 location fragment 를 단 한 위치에서만
|
|
982
|
+
# 시도한다. 즉 이차 비용의 원인이 아니므로 원본 패턴을 그대로 쓰고, 자신이
|
|
983
|
+
# 앞머리 prefix 를 건너뛰게 둔다. 스캐너는 unanchored consumer 만 돕는다.
|
|
484
984
|
new_line, count = AUTH_HEADER_RE.subn(r"\g<prefix>[REDACTED]", line)
|
|
485
985
|
if count:
|
|
486
986
|
redacted = True
|
|
@@ -491,22 +991,30 @@ class LineSanitizer:
|
|
|
491
991
|
redacted = True
|
|
492
992
|
line = new_line
|
|
493
993
|
|
|
494
|
-
line, assignment_redacted = redact_secret_assignments(
|
|
994
|
+
line, assignment_redacted = redact_secret_assignments(
|
|
995
|
+
line,
|
|
996
|
+
context=self.context,
|
|
997
|
+
)
|
|
495
998
|
if assignment_redacted:
|
|
496
999
|
redacted = True
|
|
497
1000
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
redacted = True
|
|
1001
|
+
line, credential_redactions = redact_high_confidence_credentials(line)
|
|
1002
|
+
if credential_redactions:
|
|
1003
|
+
redacted = True
|
|
502
1004
|
|
|
503
1005
|
return self._finish(line, redacted)
|
|
504
1006
|
|
|
505
1007
|
def _finish(self, line: str, redacted: bool) -> tuple[str, bool]:
|
|
1008
|
+
if not self.show_paths:
|
|
1009
|
+
line, path_redactions = anonymize_paths_for_context(
|
|
1010
|
+
line,
|
|
1011
|
+
context=self.context,
|
|
1012
|
+
)
|
|
1013
|
+
if path_redactions:
|
|
1014
|
+
self.path_redactions += path_redactions
|
|
1015
|
+
redacted = True
|
|
506
1016
|
if redacted:
|
|
507
1017
|
self.redactions += 1
|
|
508
|
-
if not self.show_paths:
|
|
509
|
-
line = anonymize_absolute_paths(line)
|
|
510
1018
|
return line, redacted
|
|
511
1019
|
|
|
512
1020
|
|
|
@@ -600,7 +1108,11 @@ class BoundedOutput:
|
|
|
600
1108
|
|
|
601
1109
|
|
|
602
1110
|
def sanitize_stream(stream: Iterable[str], args: argparse.Namespace) -> tuple[str, int, int]:
|
|
603
|
-
sanitizer = LineSanitizer(
|
|
1111
|
+
sanitizer = LineSanitizer(
|
|
1112
|
+
show_paths=args.show_paths,
|
|
1113
|
+
context=getattr(args, "context", "unknown_text"),
|
|
1114
|
+
private_roots=getattr(args, "private_root", ()),
|
|
1115
|
+
)
|
|
604
1116
|
bounded = BoundedOutput(
|
|
605
1117
|
max_lines=args.max_lines,
|
|
606
1118
|
max_chars=args.max_chars,
|
|
@@ -836,6 +1348,7 @@ def run_command(
|
|
|
836
1348
|
timeout_seconds: int,
|
|
837
1349
|
*,
|
|
838
1350
|
max_line_chars: int = MAX_LINE_CHARS_LIMIT,
|
|
1351
|
+
environment: dict[str, str] | None = None,
|
|
839
1352
|
) -> tuple[Iterable[str], subprocess.Popen[bytes] | None, int | None]:
|
|
840
1353
|
popen_kwargs: dict[str, object] = {}
|
|
841
1354
|
if os.name != "nt":
|
|
@@ -843,6 +1356,7 @@ def run_command(
|
|
|
843
1356
|
try:
|
|
844
1357
|
proc = subprocess.Popen(
|
|
845
1358
|
command,
|
|
1359
|
+
env=environment,
|
|
846
1360
|
stdout=subprocess.PIPE,
|
|
847
1361
|
stderr=subprocess.STDOUT,
|
|
848
1362
|
text=False,
|
|
@@ -868,10 +1382,45 @@ def run_command(
|
|
|
868
1382
|
)
|
|
869
1383
|
|
|
870
1384
|
|
|
1385
|
+
def sanitized_wrapper_environment() -> dict[str, str]:
|
|
1386
|
+
environment = os.environ.copy()
|
|
1387
|
+
for name in tuple(environment):
|
|
1388
|
+
if name in WRAPPER_ENV_DENY or BASH_FUNCTION_ENV_RE.fullmatch(name):
|
|
1389
|
+
environment.pop(name, None)
|
|
1390
|
+
return environment
|
|
1391
|
+
|
|
1392
|
+
|
|
871
1393
|
def stdin_has_data(stdin: TextIO) -> bool:
|
|
872
1394
|
return not stdin.isatty()
|
|
873
1395
|
|
|
874
1396
|
|
|
1397
|
+
def command_uses_search_diff_output(command: list[str]) -> bool:
|
|
1398
|
+
if not command:
|
|
1399
|
+
return False
|
|
1400
|
+
executable = os.path.basename(command[0])
|
|
1401
|
+
if executable in {"grep", "rg"}:
|
|
1402
|
+
return True
|
|
1403
|
+
if executable != "git":
|
|
1404
|
+
return False
|
|
1405
|
+
value_options = {"-C", "-c", "--git-dir", "--work-tree", "--namespace"}
|
|
1406
|
+
skip_next = False
|
|
1407
|
+
for arg in command[1:]:
|
|
1408
|
+
if skip_next:
|
|
1409
|
+
skip_next = False
|
|
1410
|
+
continue
|
|
1411
|
+
if arg == "--":
|
|
1412
|
+
break
|
|
1413
|
+
if arg in value_options:
|
|
1414
|
+
skip_next = True
|
|
1415
|
+
continue
|
|
1416
|
+
if any(arg.startswith(option + "=") for option in value_options if option.startswith("--")):
|
|
1417
|
+
continue
|
|
1418
|
+
if arg.startswith("-"):
|
|
1419
|
+
continue
|
|
1420
|
+
return arg in {"diff", "grep", "log", "show"}
|
|
1421
|
+
return False
|
|
1422
|
+
|
|
1423
|
+
|
|
875
1424
|
def build_parser() -> argparse.ArgumentParser:
|
|
876
1425
|
parser = argparse.ArgumentParser(
|
|
877
1426
|
description="Redact secrets and budget grep/diff/log output before sending it to Claude."
|
|
@@ -896,6 +1445,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
896
1445
|
action="store_true",
|
|
897
1446
|
help="show raw absolute paths instead of basename#path:<hash>; local debugging only because private paths may be exposed",
|
|
898
1447
|
)
|
|
1448
|
+
parser.add_argument(
|
|
1449
|
+
"--context",
|
|
1450
|
+
choices=SANITIZATION_MODES,
|
|
1451
|
+
default="unknown_text",
|
|
1452
|
+
help=(
|
|
1453
|
+
"sanitization origin policy (default: unknown_text); path-aware modes "
|
|
1454
|
+
"only anonymize structurally proven locations"
|
|
1455
|
+
),
|
|
1456
|
+
)
|
|
1457
|
+
parser.add_argument(
|
|
1458
|
+
"--private-root",
|
|
1459
|
+
action="append",
|
|
1460
|
+
default=[],
|
|
1461
|
+
help=(
|
|
1462
|
+
"private root eligible for path anonymization in filesystem_listing "
|
|
1463
|
+
"mode; may be repeated"
|
|
1464
|
+
),
|
|
1465
|
+
)
|
|
1466
|
+
parser.add_argument(
|
|
1467
|
+
"--context-guard-wrapper-v1",
|
|
1468
|
+
choices=("command_search_diff",),
|
|
1469
|
+
dest="wrapper_context",
|
|
1470
|
+
help=argparse.SUPPRESS,
|
|
1471
|
+
)
|
|
899
1472
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
|
900
1473
|
return parser
|
|
901
1474
|
|
|
@@ -907,6 +1480,60 @@ def main() -> int:
|
|
|
907
1480
|
command = args.command
|
|
908
1481
|
if command and command[0] == "--":
|
|
909
1482
|
command = command[1:]
|
|
1483
|
+
command_environment: dict[str, str] | None = None
|
|
1484
|
+
if args.wrapper_context is not None:
|
|
1485
|
+
env_runtime = shutil.which("env", path=os.defpath)
|
|
1486
|
+
bash_runtime = shutil.which("bash", path=os.defpath)
|
|
1487
|
+
trusted_prefix = [
|
|
1488
|
+
os.path.realpath(env_runtime) if env_runtime else "",
|
|
1489
|
+
"-u", "BASH_ENV",
|
|
1490
|
+
"-u", "ENV",
|
|
1491
|
+
"-u", "PYTHONHOME",
|
|
1492
|
+
"-u", "PYTHONPATH",
|
|
1493
|
+
"-u", "PYTHONSTARTUP",
|
|
1494
|
+
"-u", "SHELLOPTS",
|
|
1495
|
+
"-u", "BASHOPTS",
|
|
1496
|
+
"-u", "PS4",
|
|
1497
|
+
os.path.realpath(bash_runtime) if bash_runtime else "",
|
|
1498
|
+
"--noprofile",
|
|
1499
|
+
"--norc",
|
|
1500
|
+
"-p",
|
|
1501
|
+
"-c",
|
|
1502
|
+
]
|
|
1503
|
+
legacy_shape = len(command) == 3 and command[0:2] == ["bash", "-c"] and bool(command[2])
|
|
1504
|
+
trusted_shape = (
|
|
1505
|
+
bool(env_runtime)
|
|
1506
|
+
and bool(bash_runtime)
|
|
1507
|
+
and len(command) == len(trusted_prefix) + 1
|
|
1508
|
+
and command[:-1] == trusted_prefix
|
|
1509
|
+
and bool(command[-1])
|
|
1510
|
+
)
|
|
1511
|
+
if not legacy_shape and not trusted_shape:
|
|
1512
|
+
print(
|
|
1513
|
+
"context-guard-sanitize-output: invalid context-guard wrapper v1 shape",
|
|
1514
|
+
file=sys.stderr,
|
|
1515
|
+
)
|
|
1516
|
+
return 2
|
|
1517
|
+
if legacy_shape:
|
|
1518
|
+
command = [*trusted_prefix, command[2]]
|
|
1519
|
+
command_environment = sanitized_wrapper_environment()
|
|
1520
|
+
context = coerce_sanitization_context(
|
|
1521
|
+
args.wrapper_context,
|
|
1522
|
+
private_roots=args.private_root,
|
|
1523
|
+
)
|
|
1524
|
+
else:
|
|
1525
|
+
context = coerce_sanitization_context(
|
|
1526
|
+
args.context,
|
|
1527
|
+
private_roots=args.private_root,
|
|
1528
|
+
)
|
|
1529
|
+
if (
|
|
1530
|
+
context.mode == "unknown_text"
|
|
1531
|
+
and command_uses_search_diff_output(command)
|
|
1532
|
+
):
|
|
1533
|
+
context = SanitizationContext(
|
|
1534
|
+
mode="command_search_diff",
|
|
1535
|
+
private_roots=context.private_roots,
|
|
1536
|
+
)
|
|
910
1537
|
|
|
911
1538
|
proc: subprocess.Popen[bytes] | None = None
|
|
912
1539
|
command_stream: TimedCommandStream | None = None
|
|
@@ -916,6 +1543,7 @@ def main() -> int:
|
|
|
916
1543
|
command,
|
|
917
1544
|
args.timeout_seconds,
|
|
918
1545
|
max_line_chars=COMMAND_MAX_UNTERMINATED_LINE_CHARS,
|
|
1546
|
+
environment=command_environment,
|
|
919
1547
|
)
|
|
920
1548
|
if isinstance(stream, TimedCommandStream):
|
|
921
1549
|
command_stream = stream
|
|
@@ -927,14 +1555,17 @@ def main() -> int:
|
|
|
927
1555
|
print("context-guard-sanitize-output: missing command or stdin", file=sys.stderr)
|
|
928
1556
|
return 2
|
|
929
1557
|
|
|
1558
|
+
args.context = context
|
|
930
1559
|
output, _redactions, _line_count = sanitize_stream(stream, args)
|
|
931
1560
|
rc: int | None = None
|
|
932
1561
|
if proc is not None:
|
|
933
1562
|
rc = command_stream.returncode() if command_stream is not None else proc.wait()
|
|
934
1563
|
if command_stream is not None and command_stream.timed_out and not command_stream.timeout_reported:
|
|
935
|
-
timeout_line, _redacted = LineSanitizer(
|
|
936
|
-
|
|
937
|
-
|
|
1564
|
+
timeout_line, _redacted = LineSanitizer(
|
|
1565
|
+
show_paths=args.show_paths,
|
|
1566
|
+
context=context,
|
|
1567
|
+
private_roots=args.private_root,
|
|
1568
|
+
).sanitize(command_stream.timeout_message())
|
|
938
1569
|
command_stream.timeout_reported = True
|
|
939
1570
|
output = output + timeout_line
|
|
940
1571
|
|