@matt82198/aesop 0.1.0-beta.2 → 0.1.0-beta.4
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 +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- package/ui/templates/dashboard.html +1202 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Regression harness for secret_scan.py — validates scanner behavior across TP/FP cases.
|
|
4
|
+
|
|
5
|
+
Usage: python scanner_selftest.py [--temp-dir DIR]
|
|
6
|
+
|
|
7
|
+
Runs test cases against the scanner and reports pass/fail. Uses system temp by default.
|
|
8
|
+
|
|
9
|
+
# secretscan: allow-pattern-docs
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import shutil
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_test(name, file_content, filename, expected_exit, expected_keyword):
|
|
20
|
+
"""Generate a test file, run scanner, verify exit code and output."""
|
|
21
|
+
# Create temp directory for this test
|
|
22
|
+
test_dir = Path(test_temp_dir) / name
|
|
23
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
test_file = test_dir / filename
|
|
26
|
+
test_file.write_text(file_content)
|
|
27
|
+
|
|
28
|
+
# Run secret_scan.py
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[sys.executable, scanner_path, str(test_file)],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=10
|
|
35
|
+
)
|
|
36
|
+
except subprocess.TimeoutExpired:
|
|
37
|
+
# Retry once after 30s as per instructions
|
|
38
|
+
import time
|
|
39
|
+
time.sleep(30)
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
[sys.executable, scanner_path, str(test_file)],
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=10
|
|
46
|
+
)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return False, f"TIMEOUT/ERROR: {e}"
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return False, f"ERROR: {e}"
|
|
51
|
+
|
|
52
|
+
# Check exit code
|
|
53
|
+
if result.returncode != expected_exit:
|
|
54
|
+
return False, f"Exit {result.returncode} (expected {expected_exit}). Output: {result.stdout} {result.stderr}"
|
|
55
|
+
|
|
56
|
+
# Check for expected keyword in output
|
|
57
|
+
output = result.stdout + result.stderr
|
|
58
|
+
if expected_keyword and expected_keyword not in output:
|
|
59
|
+
return False, f"Keyword '{expected_keyword}' not in output: {output}"
|
|
60
|
+
|
|
61
|
+
return True, "OK"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
global test_temp_dir, scanner_path
|
|
66
|
+
|
|
67
|
+
# Paths
|
|
68
|
+
scripts_dir = Path(__file__).parent
|
|
69
|
+
scanner_path = scripts_dir / "secret_scan.py"
|
|
70
|
+
|
|
71
|
+
if not scanner_path.exists():
|
|
72
|
+
print(f"ERROR: {scanner_path} not found")
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
# Create temp directory in system temp
|
|
76
|
+
with tempfile.TemporaryDirectory(prefix="scanner_selftest_") as tmpdir:
|
|
77
|
+
test_temp_dir = Path(tmpdir)
|
|
78
|
+
|
|
79
|
+
# Test cases: (name, file_content, filename, expected_exit, expected_keyword_in_output)
|
|
80
|
+
cases = [
|
|
81
|
+
(
|
|
82
|
+
"tp_ghp_token",
|
|
83
|
+
f"{'# GitHub token:'}\\ntoken = '{'ghp_' + 'a' * 32}'\\n",
|
|
84
|
+
"test.py",
|
|
85
|
+
1,
|
|
86
|
+
"token"
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
"tp_akia_key",
|
|
90
|
+
f"{'# AWS key:'}\\naccess_key = '{'AKIA' + 'A' * 16}'\\n",
|
|
91
|
+
"config.py",
|
|
92
|
+
1,
|
|
93
|
+
"key"
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"tp_password_literal",
|
|
97
|
+
f"password = '{'hunter2secret99'}'\\n",
|
|
98
|
+
"app.py",
|
|
99
|
+
1,
|
|
100
|
+
"password"
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"tp_env_secret",
|
|
104
|
+
f"{'SECRET_KEY=realvalue123456'}\\n",
|
|
105
|
+
".env",
|
|
106
|
+
1,
|
|
107
|
+
"FOUND"
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
"tp_credentials_filename",
|
|
111
|
+
"some data here\n",
|
|
112
|
+
".credentials.json",
|
|
113
|
+
1,
|
|
114
|
+
"FOUND"
|
|
115
|
+
),
|
|
116
|
+
(
|
|
117
|
+
"fp_java_orderTokens",
|
|
118
|
+
"for (String oToken : orderTokens) {\\n process(oToken);\\n}\\n",
|
|
119
|
+
"OrderProcessor.java",
|
|
120
|
+
0,
|
|
121
|
+
""
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
"fp_python_fstring",
|
|
125
|
+
"result = f\"txn:{inv.txn_id}\"\\n",
|
|
126
|
+
"invoice.py",
|
|
127
|
+
0,
|
|
128
|
+
""
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"fp_clean_file",
|
|
132
|
+
"#!/usr/bin/env python3\\ndef hello():\\n return 'world'\\n",
|
|
133
|
+
"clean.py",
|
|
134
|
+
0,
|
|
135
|
+
"CLEAN"
|
|
136
|
+
),
|
|
137
|
+
(
|
|
138
|
+
# Pragma is scoped to doc-shaped rules ONLY: a GitHub token is a
|
|
139
|
+
# fatal class and must block even with the pragma present.
|
|
140
|
+
"tp_pragma_github_token_still_fatal",
|
|
141
|
+
f"# secretscan: allow-pattern-docs\\ntoken = '{'ghp_' + 'b' * 32}'\\n",
|
|
142
|
+
"docs.py",
|
|
143
|
+
1,
|
|
144
|
+
"HIGH"
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
# Fatal class: PEM private key + pragma must still exit 1.
|
|
148
|
+
# Header fragment-assembled so this source never holds the shape.
|
|
149
|
+
"tp_pragma_pem_still_fatal",
|
|
150
|
+
"# secretscan: allow-pattern-docs\\n"
|
|
151
|
+
+ "-----BEGIN RSA " + "PRIVATE" + " KEY-----" + "\\n"
|
|
152
|
+
+ "MIIEdummy1234567890abcdef\\n"
|
|
153
|
+
+ "-----END RSA " + "PRIVATE" + " KEY-----" + "\\n",
|
|
154
|
+
"keydoc.txt",
|
|
155
|
+
1,
|
|
156
|
+
"pem_private_key"
|
|
157
|
+
),
|
|
158
|
+
(
|
|
159
|
+
# Doc-shaped rule: generic assignment IS softened by pragma.
|
|
160
|
+
"tp_pragma_generic_softened",
|
|
161
|
+
"# secretscan: allow-pattern-docs\\n"
|
|
162
|
+
+ "pass" + "word = \"this_is_a_doc_sample_value_12345\"\\n",
|
|
163
|
+
"pattern_docs.py",
|
|
164
|
+
0,
|
|
165
|
+
"ALLOWED-DOC"
|
|
166
|
+
),
|
|
167
|
+
(
|
|
168
|
+
# Doc-shaped rule: env access IS softened by pragma.
|
|
169
|
+
"tp_pragma_env_access_softened",
|
|
170
|
+
"# secretscan: allow-pattern-docs\\n"
|
|
171
|
+
+ "v = " + "os.envi" + "ron['API_" + "TOKEN']" + "\\n",
|
|
172
|
+
"env_docs.py",
|
|
173
|
+
0,
|
|
174
|
+
"ALLOWED-DOC"
|
|
175
|
+
),
|
|
176
|
+
(
|
|
177
|
+
"fp_java_baseurl_constant",
|
|
178
|
+
'private static final String BASE_URL = "https://api.example.com";\\n',
|
|
179
|
+
"Config.java",
|
|
180
|
+
0,
|
|
181
|
+
"CLEAN"
|
|
182
|
+
),
|
|
183
|
+
(
|
|
184
|
+
"fp_python_qbo_version",
|
|
185
|
+
"QBO_MINOR_VERSION = 65\\n",
|
|
186
|
+
"version.py",
|
|
187
|
+
0,
|
|
188
|
+
"CLEAN"
|
|
189
|
+
),
|
|
190
|
+
(
|
|
191
|
+
"fp_python_recv_batchsize",
|
|
192
|
+
"RECV_BATCH_SIZE = 200\\n",
|
|
193
|
+
"processor.py",
|
|
194
|
+
0,
|
|
195
|
+
"CLEAN"
|
|
196
|
+
),
|
|
197
|
+
(
|
|
198
|
+
"fp_env_amount_limit",
|
|
199
|
+
"AMOUNT_LIMIT=99999999\\n",
|
|
200
|
+
".env",
|
|
201
|
+
0,
|
|
202
|
+
"CLEAN"
|
|
203
|
+
),
|
|
204
|
+
(
|
|
205
|
+
"tp_env_secret_key_envfile",
|
|
206
|
+
"SECRET_KEY=realvalue123456\\n",
|
|
207
|
+
".env",
|
|
208
|
+
1,
|
|
209
|
+
"FOUND"
|
|
210
|
+
),
|
|
211
|
+
(
|
|
212
|
+
"fp_connection_localhost_fixture",
|
|
213
|
+
"# Test fixture: localhost DB\\ndb_url = 'postgresql://user:testpass@localhost:5432/testdb'\\n",
|
|
214
|
+
"test_db.py",
|
|
215
|
+
0,
|
|
216
|
+
"CLEAN"
|
|
217
|
+
),
|
|
218
|
+
(
|
|
219
|
+
"fp_connection_127_fixture",
|
|
220
|
+
"# Test fixture: loopback address\\ndb_url = 'mysql://admin:changeme@127.0.0.1:3306/test'\\n",
|
|
221
|
+
"test_config.py",
|
|
222
|
+
0,
|
|
223
|
+
"CLEAN"
|
|
224
|
+
),
|
|
225
|
+
(
|
|
226
|
+
"fp_connection_example_fixture",
|
|
227
|
+
"# Example URL from docs\\nurl = 'postgres://user:password@example.com:5432/db'\\n",
|
|
228
|
+
"docs_example.py",
|
|
229
|
+
0,
|
|
230
|
+
"CLEAN"
|
|
231
|
+
),
|
|
232
|
+
(
|
|
233
|
+
# Fixture fragment-assembled so this selftest source never holds a
|
|
234
|
+
# contiguous connection-string shape (fatal class, pragma-immune).
|
|
235
|
+
"tp_connection_real_credentials",
|
|
236
|
+
"# Real production DB\\ndb_url = '"
|
|
237
|
+
+ "postgresql:" + "//produser:RealPassword123"
|
|
238
|
+
+ "@db.prod.example.com:5432/production" + "'\\n",
|
|
239
|
+
"config_prod.py",
|
|
240
|
+
1,
|
|
241
|
+
"connection_string"
|
|
242
|
+
),
|
|
243
|
+
(
|
|
244
|
+
# Large-file bypass closed: >1MB file with a key in the first 1MB
|
|
245
|
+
# must still block (bounded FATAL_RULES scan; key runtime-assembled).
|
|
246
|
+
"tp_large_file_with_key",
|
|
247
|
+
"k = '" + "AKIA" + "A" * 16 + "'\n" + "x" * (1024 * 1024 + 100) + "\n",
|
|
248
|
+
"bigfile.txt",
|
|
249
|
+
1,
|
|
250
|
+
"aws_access_key"
|
|
251
|
+
),
|
|
252
|
+
(
|
|
253
|
+
# Binary bypass closed: an embedded null byte no longer exempts the
|
|
254
|
+
# file; latin-1 decode + FATAL_RULES scan must still block.
|
|
255
|
+
"tp_binary_file_with_key",
|
|
256
|
+
"\x00binaryjunk\n" + "v = '" + "AKIA" + "A" * 16 + "'\n",
|
|
257
|
+
"blob.bin",
|
|
258
|
+
1,
|
|
259
|
+
"aws_access_key"
|
|
260
|
+
),
|
|
261
|
+
(
|
|
262
|
+
# Large clean file: exit 0, with SKIPPED-LARGE note on stderr.
|
|
263
|
+
"fp_large_clean_file",
|
|
264
|
+
"x" * (1024 * 1024 + 100) + "\n",
|
|
265
|
+
"bigclean.log",
|
|
266
|
+
0,
|
|
267
|
+
"SKIPPED-LARGE"
|
|
268
|
+
),
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
results = []
|
|
272
|
+
for name, content, filename, exp_exit, exp_kw in cases:
|
|
273
|
+
passed, msg = run_test(name, content, filename, exp_exit, exp_kw)
|
|
274
|
+
results.append((name, passed, msg))
|
|
275
|
+
status = "PASS" if passed else "FAIL"
|
|
276
|
+
print(f"{status:5} {name:30} {msg}")
|
|
277
|
+
|
|
278
|
+
# Self-scan invariant: the scanner must scan its OWN source CLEAN with
|
|
279
|
+
# zero pragma reliance (fatal-class pattern literals are runtime-assembled).
|
|
280
|
+
try:
|
|
281
|
+
r = subprocess.run(
|
|
282
|
+
[sys.executable, str(scanner_path), str(scanner_path)],
|
|
283
|
+
capture_output=True, text=True, timeout=10,
|
|
284
|
+
)
|
|
285
|
+
self_ok = (r.returncode == 0 and "CLEAN" in r.stdout
|
|
286
|
+
and "ALLOWED-DOC" not in r.stdout)
|
|
287
|
+
msg = "OK" if self_ok else f"Exit {r.returncode}. Output: {r.stdout} {r.stderr}"
|
|
288
|
+
except Exception as e:
|
|
289
|
+
self_ok, msg = False, f"ERROR: {e}"
|
|
290
|
+
results.append(("self_scan_clean_no_pragma", self_ok, msg))
|
|
291
|
+
print(f"{'PASS' if self_ok else 'FAIL':5} {'self_scan_clean_no_pragma':30} {msg}")
|
|
292
|
+
|
|
293
|
+
# Summary
|
|
294
|
+
passed_count = sum(1 for _, p, _ in results if p)
|
|
295
|
+
total_count = len(results)
|
|
296
|
+
print(f"\nSELFTEST: {passed_count}/{total_count} passed")
|
|
297
|
+
|
|
298
|
+
# Exit 0 only if all pass
|
|
299
|
+
return 0 if passed_count == total_count else 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
sys.exit(main())
|
package/tools/secret_scan.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# secretscan: allow-pattern-docs
|
|
3
2
|
"""
|
|
4
3
|
secret_scan.py — Pre-push secret/credential detection gate.
|
|
5
4
|
|
|
@@ -11,14 +10,19 @@ Modes:
|
|
|
11
10
|
Exit codes: 0=clean, 1=findings, 2=usage error
|
|
12
11
|
Output: one line per finding or summary (never prints full secrets)
|
|
13
12
|
|
|
14
|
-
Pragma escape hatch (
|
|
13
|
+
Pragma escape hatch (STRICTLY SCOPED to doc-shaped rules):
|
|
15
14
|
If the literal string 'secretscan: allow-pattern-docs' appears in a file's first 10 lines
|
|
16
|
-
(any comment syntax: #, //, <!-- -->),
|
|
17
|
-
as ALLOWED-DOC and do
|
|
18
|
-
.
|
|
19
|
-
|
|
15
|
+
(any comment syntax: #, //, <!-- -->), findings from the two DOC-SHAPED rules ONLY
|
|
16
|
+
(generic_secret_assignment, env_access) are reported as ALLOWED-DOC and do not cause
|
|
17
|
+
exit 1. Fatal classes (PEM private keys, AWS/GitHub/Slack/OpenAI-Anthropic tokens,
|
|
18
|
+
connection strings) and filename-based findings stay fatal REGARDLESS of the pragma.
|
|
19
|
+
The pragma appears in git diffs and is a reviewable act.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Self-scan invariant: this file must scan CLEAN with NO pragma. Any pattern literal that
|
|
22
|
+
would match its own regex is runtime-assembled from fragments (see pem_private_key) so
|
|
23
|
+
the pattern text never appears contiguously in this source.
|
|
24
|
+
|
|
25
|
+
NOTE: Public repo version has NO vault allowlist.
|
|
22
26
|
"""
|
|
23
27
|
|
|
24
28
|
import argparse
|
|
@@ -30,8 +34,10 @@ from pathlib import Path
|
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
# Regex patterns for secret detection (case-insensitive where sensible)
|
|
37
|
+
# NOTE: pem_private_key is runtime-assembled from fragments so this source file
|
|
38
|
+
# never contains a contiguous PEM-header shape (self-scan invariant, no pragma).
|
|
33
39
|
PATTERNS = {
|
|
34
|
-
"pem_private_key": (r"-----BEGIN .* PRIVATE KEY-----", re.IGNORECASE),
|
|
40
|
+
"pem_private_key": (r"-----BEGIN .* " + "PRIVATE" + " KEY-----", re.IGNORECASE),
|
|
35
41
|
"aws_access_key": (r"AKIA[0-9A-Z]{16}", 0),
|
|
36
42
|
"aws_secret_pattern": (
|
|
37
43
|
r"aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*[^\s\$\<\{]",
|
|
@@ -86,7 +92,7 @@ def has_pragma(filepath):
|
|
|
86
92
|
|
|
87
93
|
|
|
88
94
|
def is_binary_file(filepath):
|
|
89
|
-
"""Check if file is binary (contains null bytes)."""
|
|
95
|
+
"""Check if file is binary (contains null bytes in first 8KB)."""
|
|
90
96
|
try:
|
|
91
97
|
with open(filepath, "rb") as f:
|
|
92
98
|
return b"\x00" in f.read(8192)
|
|
@@ -95,17 +101,18 @@ def is_binary_file(filepath):
|
|
|
95
101
|
|
|
96
102
|
|
|
97
103
|
def should_skip_file(filepath):
|
|
98
|
-
"""Check if file should be skipped (
|
|
99
|
-
|
|
104
|
+
"""Check if file should be skipped entirely (.git/, __pycache__, .pyc, .pyo)."""
|
|
105
|
+
# Skip .git directories — match a path COMPONENT, not a substring
|
|
106
|
+
# (".git" in str(path) also matched ".gitignore" and skipped scanning it).
|
|
107
|
+
if ".git" in filepath.parts:
|
|
100
108
|
return True
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
except Exception:
|
|
110
|
+
# Skip __pycache__ directories
|
|
111
|
+
if "__pycache__" in filepath.parts:
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
# Skip compiled Python artifacts
|
|
115
|
+
if filepath.name.endswith(".pyc") or filepath.name.endswith(".pyo"):
|
|
109
116
|
return True
|
|
110
117
|
|
|
111
118
|
return False
|
|
@@ -142,14 +149,36 @@ def scan_file(filepath):
|
|
|
142
149
|
"""
|
|
143
150
|
Scan a single file for secrets.
|
|
144
151
|
Returns list of (line_num, rule, match_str, is_fatal).
|
|
145
|
-
is_fatal=True for credential filenames
|
|
152
|
+
is_fatal=True for credential filenames and fatal rule categories;
|
|
153
|
+
is_fatal=False only if pragma present AND rule is in SOFTENED_BY_PRAGMA.
|
|
154
|
+
|
|
155
|
+
Large files (>1MB) and binary files are scanned for FATAL_RULES patterns:
|
|
156
|
+
- Large files: scan first 1MB; emit SKIPPED-LARGE to stderr if file is larger
|
|
157
|
+
- Binary files: decode as latin-1; emit SKIPPED-BINARY to stderr if not fully scanned
|
|
146
158
|
"""
|
|
159
|
+
# Rules that CAN be softened by pragma (doc-shaped rules only)
|
|
160
|
+
SOFTENED_BY_PRAGMA = {"generic_secret_assignment", "env_access"}
|
|
161
|
+
|
|
162
|
+
# Rules that are ALWAYS fatal, pragma never applies
|
|
163
|
+
FATAL_RULES = {
|
|
164
|
+
"pem_private_key",
|
|
165
|
+
"aws_access_key",
|
|
166
|
+
"github_token",
|
|
167
|
+
"slack_token",
|
|
168
|
+
"openai_anthropic_key",
|
|
169
|
+
"connection_string",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
SIZE_THRESHOLD = 1024 * 1024 # 1MB
|
|
173
|
+
MAX_READ_SIZE = 2 * 1024 * 1024 # 2MB max to read
|
|
174
|
+
SCAN_PREFIX_SIZE = 1024 * 1024 # Scan first 1MB of large files
|
|
175
|
+
|
|
147
176
|
findings = []
|
|
148
177
|
|
|
149
178
|
if should_skip_file(filepath):
|
|
150
179
|
return findings
|
|
151
180
|
|
|
152
|
-
# Check for pragma (applies only to rule-based findings, not filename findings)
|
|
181
|
+
# Check for pragma (applies only to specific rule-based findings, not filename findings)
|
|
153
182
|
has_file_pragma = has_pragma(filepath)
|
|
154
183
|
|
|
155
184
|
# Check if filename matches credential patterns (always fatal, pragma does NOT apply)
|
|
@@ -162,24 +191,95 @@ def scan_file(filepath):
|
|
|
162
191
|
break
|
|
163
192
|
|
|
164
193
|
try:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
194
|
+
# Check file size and binary status
|
|
195
|
+
stat = filepath.stat()
|
|
196
|
+
file_size = stat.st_size
|
|
197
|
+
is_binary = is_binary_file(filepath)
|
|
198
|
+
is_large = file_size > SIZE_THRESHOLD
|
|
199
|
+
|
|
200
|
+
if is_large:
|
|
201
|
+
# Scan first 1MB of large file for FATAL_RULES
|
|
202
|
+
with open(filepath, "rb") as f:
|
|
203
|
+
content = f.read(SCAN_PREFIX_SIZE)
|
|
204
|
+
|
|
205
|
+
# Decode as latin-1 (handles any binary content)
|
|
206
|
+
try:
|
|
207
|
+
content_str = content.decode("latin-1")
|
|
208
|
+
except Exception:
|
|
209
|
+
content_str = content.decode("utf-8", errors="ignore")
|
|
210
|
+
|
|
211
|
+
# Emit skip notice to stderr
|
|
212
|
+
print(f"SKIPPED-LARGE {filepath} (scanned first {SCAN_PREFIX_SIZE // 1024}KB)", file=sys.stderr)
|
|
213
|
+
|
|
214
|
+
# Scan content for FATAL_RULES only
|
|
215
|
+
for line_num, line in enumerate(content_str.split("\n"), start=1):
|
|
216
|
+
for rule_name in FATAL_RULES:
|
|
217
|
+
if rule_name not in PATTERNS:
|
|
170
218
|
continue
|
|
171
|
-
|
|
219
|
+
pattern, flags = PATTERNS[rule_name]
|
|
172
220
|
matches = re.finditer(pattern, line, flags)
|
|
173
221
|
for match in matches:
|
|
174
222
|
match_str = match.group(0)
|
|
175
|
-
|
|
176
|
-
# Skip if it's a placeholder
|
|
177
223
|
if is_placeholder(match_str):
|
|
178
224
|
continue
|
|
225
|
+
findings.append((line_num, rule_name, match_str, True))
|
|
226
|
+
|
|
227
|
+
elif is_binary:
|
|
228
|
+
# Scan binary file as latin-1 for FATAL_RULES only
|
|
229
|
+
with open(filepath, "rb") as f:
|
|
230
|
+
content = f.read(MAX_READ_SIZE)
|
|
231
|
+
|
|
232
|
+
# Decode as latin-1 (preserves all bytes)
|
|
233
|
+
try:
|
|
234
|
+
content_str = content.decode("latin-1")
|
|
235
|
+
except Exception:
|
|
236
|
+
content_str = content.decode("utf-8", errors="ignore")
|
|
237
|
+
|
|
238
|
+
# Emit skip notice to stderr
|
|
239
|
+
print(f"SKIPPED-BINARY {filepath} (scanned via latin-1)", file=sys.stderr)
|
|
240
|
+
|
|
241
|
+
# Scan content for FATAL_RULES only
|
|
242
|
+
for line_num, line in enumerate(content_str.split("\n"), start=1):
|
|
243
|
+
for rule_name in FATAL_RULES:
|
|
244
|
+
if rule_name not in PATTERNS:
|
|
245
|
+
continue
|
|
246
|
+
pattern, flags = PATTERNS[rule_name]
|
|
247
|
+
matches = re.finditer(pattern, line, flags)
|
|
248
|
+
for match in matches:
|
|
249
|
+
match_str = match.group(0)
|
|
250
|
+
if is_placeholder(match_str):
|
|
251
|
+
continue
|
|
252
|
+
findings.append((line_num, rule_name, match_str, True))
|
|
253
|
+
|
|
254
|
+
else:
|
|
255
|
+
# Normal text file: scan all rules
|
|
256
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
257
|
+
for line_num, line in enumerate(f, start=1):
|
|
258
|
+
for rule_name, (pattern, flags) in PATTERNS.items():
|
|
259
|
+
# Skip env_assignment rule if not an .env-like file
|
|
260
|
+
if rule_name == "env_assignment" and not is_env_file(filepath):
|
|
261
|
+
continue
|
|
179
262
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
263
|
+
matches = re.finditer(pattern, line, flags)
|
|
264
|
+
for match in matches:
|
|
265
|
+
match_str = match.group(0)
|
|
266
|
+
|
|
267
|
+
# Skip if it's a placeholder
|
|
268
|
+
if is_placeholder(match_str):
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Determine fatality based on rule category
|
|
272
|
+
if rule_name in FATAL_RULES:
|
|
273
|
+
# These are always fatal, pragma never applies
|
|
274
|
+
is_fatal = True
|
|
275
|
+
elif has_file_pragma and rule_name in SOFTENED_BY_PRAGMA:
|
|
276
|
+
# Only these rules can be softened by pragma
|
|
277
|
+
is_fatal = False
|
|
278
|
+
else:
|
|
279
|
+
# Other rules are fatal unless pragma and softened
|
|
280
|
+
is_fatal = not has_file_pragma if rule_name in SOFTENED_BY_PRAGMA else True
|
|
281
|
+
|
|
282
|
+
findings.append((line_num, rule_name, match_str, is_fatal))
|
|
183
283
|
|
|
184
284
|
except Exception:
|
|
185
285
|
pass
|