@matt82198/aesop 0.1.0-beta.3 → 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.
Files changed (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. 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())
@@ -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 (defensive only):
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: #, //, <!-- -->), pattern-based findings in that file are reported
17
- as ALLOWED-DOC and do NOT cause exit 1. Filename-based findings (credential files like
18
- .credentials.json, *.pem, id_rsa*, *.p12) stay fatal regardless. Use only for deliberate
19
- pattern documentation; the pragma appears in git diffs and is a reviewable act.
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
- NOTE: Public repo version has NO vault allowlist. All secrets block unless permitted by pragma.
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 (binary, too large, in .git/)."""
99
- if ".git" in filepath.parts or ".git" in str(filepath):
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
- try:
103
- stat = filepath.stat()
104
- if stat.st_size > 1024 * 1024: # >1MB
105
- return True
106
- if is_binary_file(filepath):
107
- return True
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; is_fatal=False if pragma present and rule-based.
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
- with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
166
- for line_num, line in enumerate(f, start=1):
167
- for rule_name, (pattern, flags) in PATTERNS.items():
168
- # Skip env_assignment rule if not an .env-like file
169
- if rule_name == "env_assignment" and not is_env_file(filepath):
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
- # Mark as non-fatal if pragma present (rule-based only)
181
- is_fatal = not has_file_pragma
182
- findings.append((line_num, rule_name, match_str, is_fatal))
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