@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  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/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -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,25 @@ 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/, node_modules/, __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 node_modules — third-party deps, always git-ignored, never our code.
111
+ # CI installs them via `npm ci` for the dashboard build, so a whole-tree
112
+ # scan would otherwise walk thousands of package files (README example
113
+ # connection strings, files literally named token.js → false positives).
114
+ if "node_modules" in filepath.parts:
115
+ return True
116
+
117
+ # Skip __pycache__ directories
118
+ if "__pycache__" in filepath.parts:
119
+ return True
120
+
121
+ # Skip compiled Python artifacts
122
+ if filepath.name.endswith(".pyc") or filepath.name.endswith(".pyo"):
109
123
  return True
110
124
 
111
125
  return False
@@ -142,14 +156,36 @@ def scan_file(filepath):
142
156
  """
143
157
  Scan a single file for secrets.
144
158
  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.
159
+ is_fatal=True for credential filenames and fatal rule categories;
160
+ is_fatal=False only if pragma present AND rule is in SOFTENED_BY_PRAGMA.
161
+
162
+ Large files (>1MB) and binary files are scanned for FATAL_RULES patterns:
163
+ - Large files: scan first 1MB; emit SKIPPED-LARGE to stderr if file is larger
164
+ - Binary files: decode as latin-1; emit SKIPPED-BINARY to stderr if not fully scanned
146
165
  """
166
+ # Rules that CAN be softened by pragma (doc-shaped rules only)
167
+ SOFTENED_BY_PRAGMA = {"generic_secret_assignment", "env_access"}
168
+
169
+ # Rules that are ALWAYS fatal, pragma never applies
170
+ FATAL_RULES = {
171
+ "pem_private_key",
172
+ "aws_access_key",
173
+ "github_token",
174
+ "slack_token",
175
+ "openai_anthropic_key",
176
+ "connection_string",
177
+ }
178
+
179
+ SIZE_THRESHOLD = 1024 * 1024 # 1MB
180
+ MAX_READ_SIZE = 2 * 1024 * 1024 # 2MB max to read
181
+ SCAN_PREFIX_SIZE = 1024 * 1024 # Scan first 1MB of large files
182
+
147
183
  findings = []
148
184
 
149
185
  if should_skip_file(filepath):
150
186
  return findings
151
187
 
152
- # Check for pragma (applies only to rule-based findings, not filename findings)
188
+ # Check for pragma (applies only to specific rule-based findings, not filename findings)
153
189
  has_file_pragma = has_pragma(filepath)
154
190
 
155
191
  # Check if filename matches credential patterns (always fatal, pragma does NOT apply)
@@ -162,24 +198,95 @@ def scan_file(filepath):
162
198
  break
163
199
 
164
200
  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):
201
+ # Check file size and binary status
202
+ stat = filepath.stat()
203
+ file_size = stat.st_size
204
+ is_binary = is_binary_file(filepath)
205
+ is_large = file_size > SIZE_THRESHOLD
206
+
207
+ if is_large:
208
+ # Scan first 1MB of large file for FATAL_RULES
209
+ with open(filepath, "rb") as f:
210
+ content = f.read(SCAN_PREFIX_SIZE)
211
+
212
+ # Decode as latin-1 (handles any binary content)
213
+ try:
214
+ content_str = content.decode("latin-1")
215
+ except Exception:
216
+ content_str = content.decode("utf-8", errors="ignore")
217
+
218
+ # Emit skip notice to stderr
219
+ print(f"SKIPPED-LARGE {filepath} (scanned first {SCAN_PREFIX_SIZE // 1024}KB)", file=sys.stderr)
220
+
221
+ # Scan content for FATAL_RULES only
222
+ for line_num, line in enumerate(content_str.split("\n"), start=1):
223
+ for rule_name in FATAL_RULES:
224
+ if rule_name not in PATTERNS:
170
225
  continue
171
-
226
+ pattern, flags = PATTERNS[rule_name]
227
+ matches = re.finditer(pattern, line, flags)
228
+ for match in matches:
229
+ match_str = match.group(0)
230
+ if is_placeholder(match_str):
231
+ continue
232
+ findings.append((line_num, rule_name, match_str, True))
233
+
234
+ elif is_binary:
235
+ # Scan binary file as latin-1 for FATAL_RULES only
236
+ with open(filepath, "rb") as f:
237
+ content = f.read(MAX_READ_SIZE)
238
+
239
+ # Decode as latin-1 (preserves all bytes)
240
+ try:
241
+ content_str = content.decode("latin-1")
242
+ except Exception:
243
+ content_str = content.decode("utf-8", errors="ignore")
244
+
245
+ # Emit skip notice to stderr
246
+ print(f"SKIPPED-BINARY {filepath} (scanned via latin-1)", file=sys.stderr)
247
+
248
+ # Scan content for FATAL_RULES only
249
+ for line_num, line in enumerate(content_str.split("\n"), start=1):
250
+ for rule_name in FATAL_RULES:
251
+ if rule_name not in PATTERNS:
252
+ continue
253
+ pattern, flags = PATTERNS[rule_name]
172
254
  matches = re.finditer(pattern, line, flags)
173
255
  for match in matches:
174
256
  match_str = match.group(0)
175
-
176
- # Skip if it's a placeholder
177
257
  if is_placeholder(match_str):
178
258
  continue
259
+ findings.append((line_num, rule_name, match_str, True))
260
+
261
+ else:
262
+ # Normal text file: scan all rules
263
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
264
+ for line_num, line in enumerate(f, start=1):
265
+ for rule_name, (pattern, flags) in PATTERNS.items():
266
+ # Skip env_assignment rule if not an .env-like file
267
+ if rule_name == "env_assignment" and not is_env_file(filepath):
268
+ continue
179
269
 
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))
270
+ matches = re.finditer(pattern, line, flags)
271
+ for match in matches:
272
+ match_str = match.group(0)
273
+
274
+ # Skip if it's a placeholder
275
+ if is_placeholder(match_str):
276
+ continue
277
+
278
+ # Determine fatality based on rule category
279
+ if rule_name in FATAL_RULES:
280
+ # These are always fatal, pragma never applies
281
+ is_fatal = True
282
+ elif has_file_pragma and rule_name in SOFTENED_BY_PRAGMA:
283
+ # Only these rules can be softened by pragma
284
+ is_fatal = False
285
+ else:
286
+ # Other rules are fatal unless pragma and softened
287
+ is_fatal = not has_file_pragma if rule_name in SOFTENED_BY_PRAGMA else True
288
+
289
+ findings.append((line_num, rule_name, match_str, is_fatal))
183
290
 
184
291
  except Exception:
185
292
  pass