hullproof 0.0.0-stage → 0.5.0

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 (363) hide show
  1. package/LICENSE +222 -0
  2. package/NOTICE +32 -0
  3. package/README.md +313 -2
  4. package/checks/db.firebase-rules-not-open.json +24 -0
  5. package/checks/db.rls-every-table.json +25 -0
  6. package/checks/log.no-credential-in-logs.json +30 -0
  7. package/checks/secrets.no-env-file-committed.json +31 -0
  8. package/checks/secrets.no-secret-in-public-env.json +37 -0
  9. package/checks/storage.firebase-rules-not-open.json +22 -0
  10. package/checks/supply.no-untrusted-event-in-run.json +23 -0
  11. package/checks/web.cors-no-reflected-origin.json +31 -0
  12. package/detectors/ai.json +25 -0
  13. package/detectors/auth.json +27 -0
  14. package/detectors/ci.json +19 -0
  15. package/detectors/containers.json +8 -0
  16. package/detectors/data_stores.json +38 -0
  17. package/detectors/frameworks.json +94 -0
  18. package/detectors/hosting.json +44 -0
  19. package/detectors/iac.json +43 -0
  20. package/detectors/languages.json +48 -0
  21. package/detectors/mobile.json +22 -0
  22. package/detectors/package_managers.json +39 -0
  23. package/detectors/payments.json +19 -0
  24. package/detectors/runtimes.json +17 -0
  25. package/dist/checks/code/admin-role.js +46 -0
  26. package/dist/checks/code/owner-filter.js +91 -0
  27. package/dist/checks/code/shared.js +84 -0
  28. package/dist/checks/code/webhook-verify.js +77 -0
  29. package/dist/checks/context.js +64 -0
  30. package/dist/checks/map/config-checks.js +74 -0
  31. package/dist/checks/map/entry-checks.js +97 -0
  32. package/dist/checks/map/shared.js +44 -0
  33. package/dist/checks/ops.js +307 -0
  34. package/dist/checks/pro-map.js +1 -0
  35. package/dist/checks/run.js +92 -0
  36. package/dist/checks/types.js +1 -0
  37. package/dist/ci/comment.js +44 -0
  38. package/dist/ci/sarif-check.js +157 -0
  39. package/dist/ci/sarif.js +61 -0
  40. package/dist/cli.js +358 -0
  41. package/dist/commands/ci.js +115 -0
  42. package/dist/commands/git.js +58 -0
  43. package/dist/commands/hooks.js +30 -0
  44. package/dist/commands/hunt-pro.js +1 -0
  45. package/dist/commands/hunt.js +86 -0
  46. package/dist/commands/map.js +33 -0
  47. package/dist/commands/mcp.js +17 -0
  48. package/dist/commands/pack.js +48 -0
  49. package/dist/commands/prove-pro.js +1 -0
  50. package/dist/commands/prove.js +173 -0
  51. package/dist/commands/report-gate.js +92 -0
  52. package/dist/commands/scan.js +158 -0
  53. package/dist/confirm/agreement.js +18 -0
  54. package/dist/confirm/chain.js +84 -0
  55. package/dist/confirm/confirm.js +112 -0
  56. package/dist/confirm/honesty.js +75 -0
  57. package/dist/confirm/lead.js +92 -0
  58. package/dist/confirm/merge.js +88 -0
  59. package/dist/confirm/query.js +145 -0
  60. package/dist/confirm/rule.js +86 -0
  61. package/dist/confirm/store.js +42 -0
  62. package/dist/confirm/summary.js +6 -0
  63. package/dist/confirm/synthesis.js +28 -0
  64. package/dist/discover/detectors.js +50 -0
  65. package/dist/discover/discover.js +270 -0
  66. package/dist/discover/extract.js +282 -0
  67. package/dist/discover/git.js +40 -0
  68. package/dist/discover/kit-files.js +47 -0
  69. package/dist/discover/walk.js +110 -0
  70. package/dist/env.js +57 -0
  71. package/dist/gate/gate.js +173 -0
  72. package/dist/hooks/install.js +153 -0
  73. package/dist/hooks/run.js +112 -0
  74. package/dist/map/analyze.js +201 -0
  75. package/dist/map/build.js +253 -0
  76. package/dist/map/candidates.js +49 -0
  77. package/dist/map/cblocks.js +77 -0
  78. package/dist/map/config.js +291 -0
  79. package/dist/map/dotnet.js +242 -0
  80. package/dist/map/entry.js +78 -0
  81. package/dist/map/expo.js +190 -0
  82. package/dist/map/go.js +292 -0
  83. package/dist/map/js-express.js +143 -0
  84. package/dist/map/js-misc.js +295 -0
  85. package/dist/map/js-next.js +292 -0
  86. package/dist/map/laravel.js +389 -0
  87. package/dist/map/lex-more.js +305 -0
  88. package/dist/map/lex.js +425 -0
  89. package/dist/map/light-index.js +191 -0
  90. package/dist/map/light-profiles.js +324 -0
  91. package/dist/map/light.js +574 -0
  92. package/dist/map/local-vocab.js +27 -0
  93. package/dist/map/mobile.js +326 -0
  94. package/dist/map/py-django.js +281 -0
  95. package/dist/map/py.js +391 -0
  96. package/dist/map/rails.js +391 -0
  97. package/dist/map/repo.js +178 -0
  98. package/dist/map/rust.js +312 -0
  99. package/dist/map/spring.js +207 -0
  100. package/dist/map/taint.js +96 -0
  101. package/dist/map/touches.js +355 -0
  102. package/dist/map/vocab.js +88 -0
  103. package/dist/mapping/controls.js +34 -0
  104. package/dist/mapping/dependency-policy.js +44 -0
  105. package/dist/mapping/map.js +44 -0
  106. package/dist/mapping/maps/gitleaks.json +12 -0
  107. package/dist/mapping/maps/osv-scanner.json +7 -0
  108. package/dist/mapping/maps/trivy.json +13 -0
  109. package/dist/mcp/confine.js +59 -0
  110. package/dist/mcp/protocol.js +140 -0
  111. package/dist/mcp/schema.js +43 -0
  112. package/dist/mcp/server.js +37 -0
  113. package/dist/mcp/tools.js +238 -0
  114. package/dist/packs/coverage.js +79 -0
  115. package/dist/packs/manifest.js +36 -0
  116. package/dist/packs/validate.js +271 -0
  117. package/dist/prove/container.js +276 -0
  118. package/dist/prove/exec-main.js +20 -0
  119. package/dist/prove/executor.js +149 -0
  120. package/dist/prove/local.js +131 -0
  121. package/dist/prove/probes.js +65 -0
  122. package/dist/prove/recipe.js +278 -0
  123. package/dist/prove/run.js +136 -0
  124. package/dist/prove/runner.js +1 -0
  125. package/dist/prove/safety.js +126 -0
  126. package/dist/prove/spec.js +357 -0
  127. package/dist/prove/store.js +65 -0
  128. package/dist/report/build.js +297 -0
  129. package/dist/report/entry-points.js +75 -0
  130. package/dist/report/hunt-section.js +71 -0
  131. package/dist/report/kit.js +41 -0
  132. package/dist/report/load.js +35 -0
  133. package/dist/report/messages.js +273 -0
  134. package/dist/report/prove-section.js +65 -0
  135. package/dist/report/report.js +156 -0
  136. package/dist/report/surface.js +21 -0
  137. package/dist/report/test-code.js +51 -0
  138. package/dist/report/tiers.js +44 -0
  139. package/dist/scanners/common.js +162 -0
  140. package/dist/scanners/gitleaks.js +90 -0
  141. package/dist/scanners/index.js +113 -0
  142. package/dist/scanners/osv-scanner.js +77 -0
  143. package/dist/scanners/run-targets.js +34 -0
  144. package/dist/scanners/secret-noise.js +89 -0
  145. package/dist/scanners/semgrep.js +142 -0
  146. package/dist/scanners/trivy.js +65 -0
  147. package/dist/scanners/types.js +1 -0
  148. package/dist/types/check.js +1 -0
  149. package/dist/types/codemap.js +1 -0
  150. package/dist/types/findings.js +5 -0
  151. package/dist/types/hunt.js +1 -0
  152. package/dist/types/index.js +1 -0
  153. package/dist/types/prove.js +1 -0
  154. package/dist/types/results.js +2 -0
  155. package/dist/types/scan.js +1 -0
  156. package/dist/types/stack.js +5 -0
  157. package/dist/util.js +71 -0
  158. package/dist/version.js +2 -0
  159. package/package.json +46 -4
  160. package/packs/android/fixture-result.json +43 -0
  161. package/packs/android/pack.json +56 -0
  162. package/packs/dotnet/fixture-result.json +62 -0
  163. package/packs/dotnet/pack.json +88 -0
  164. package/packs/expo/answer-key.json +51 -0
  165. package/packs/expo/fixture-result.json +62 -0
  166. package/packs/expo/pack.json +79 -0
  167. package/packs/firebase/answer-key.json +27 -0
  168. package/packs/firebase/fixture-result.json +43 -0
  169. package/packs/firebase/pack.json +58 -0
  170. package/packs/go/answer-key.json +101 -0
  171. package/packs/go/fixture-result.json +64 -0
  172. package/packs/go/pack.json +99 -0
  173. package/packs/ios/fixture-result.json +43 -0
  174. package/packs/ios/pack.json +55 -0
  175. package/packs/laravel/answer-key.json +84 -0
  176. package/packs/laravel/fixture-result.json +60 -0
  177. package/packs/laravel/pack.json +91 -0
  178. package/packs/node-ts/answer-key.json +220 -0
  179. package/packs/node-ts/fixture-result.json +114 -0
  180. package/packs/node-ts/pack.json +134 -0
  181. package/packs/python/answer-key.json +195 -0
  182. package/packs/python/fixture-result.json +119 -0
  183. package/packs/python/pack.json +121 -0
  184. package/packs/rails/answer-key.json +84 -0
  185. package/packs/rails/fixture-result.json +60 -0
  186. package/packs/rails/pack.json +78 -0
  187. package/packs/rust/fixture-result.json +43 -0
  188. package/packs/rust/pack.json +69 -0
  189. package/packs/spring/fixture-result.json +63 -0
  190. package/packs/spring/pack.json +103 -0
  191. package/packs/supabase/answer-key.json +52 -0
  192. package/packs/supabase/fixture-result.json +58 -0
  193. package/packs/supabase/pack.json +60 -0
  194. package/rules/gitleaks.toml +256 -0
  195. package/rules/semgrep/api-route-no-auth.yaml +31 -0
  196. package/rules/semgrep/cors-reflect-origin.yaml +48 -0
  197. package/rules/semgrep/eval-or-new-function.yaml +185 -0
  198. package/rules/semgrep/log-session-token.yaml +48 -0
  199. package/rules/semgrep/math-random-tokens.yaml +24 -0
  200. package/rules/semgrep/next-public-secret-name.yaml +32 -0
  201. package/rules/semgrep/postgrest-filter-injection.yaml +78 -0
  202. package/rules/semgrep/raw-sql-concat.yaml +42 -0
  203. package/rules/semgrep/read-by-id-no-owner.yaml +49 -0
  204. package/rules/semgrep/server-action-no-auth.yaml +26 -0
  205. package/rules/semgrep/sql-variants.yaml +99 -0
  206. package/rules/semgrep/ssrf-unguarded-url.yaml +137 -0
  207. package/rules/semgrep/subprocess-injection.yaml +163 -0
  208. package/rules/semgrep/taint-go.yaml +333 -0
  209. package/rules/semgrep/taint-java.yaml +317 -0
  210. package/rules/semgrep/taint-javascript.yaml +412 -0
  211. package/rules/semgrep/taint-php.yaml +361 -0
  212. package/rules/semgrep/taint-python.yaml +414 -0
  213. package/rules/semgrep/tool-endpoint-admin-client.yaml +86 -0
  214. package/rules/semgrep/user-metadata-authz.yaml +22 -0
  215. package/rules/semgrep/verify-result-unused.yaml +62 -0
  216. package/rules/semgrep/webhook-parse-before-verify.yaml +29 -0
  217. package/rules/test-artifact-ids.json +4 -0
  218. package/rules/tiers.json +427 -0
  219. package/schemas/check-results.schema.json +55 -0
  220. package/schemas/check.schema.json +41 -0
  221. package/schemas/codemap.schema.json +216 -0
  222. package/schemas/findings.schema.json +1048 -0
  223. package/schemas/hunt-output.schema.json +46 -0
  224. package/schemas/hunt-result.schema.json +58 -0
  225. package/schemas/results.schema.json +17 -0
  226. package/schemas/scan-hits.schema.json +32 -0
  227. package/schemas/scan-meta.schema.json +25 -0
  228. package/schemas/stack.schema.json +93 -0
  229. package/src/checks/README.md +32 -0
  230. package/src/checks/code/admin-role.ts +49 -0
  231. package/src/checks/code/owner-filter.ts +95 -0
  232. package/src/checks/code/shared.ts +66 -0
  233. package/src/checks/code/webhook-verify.ts +75 -0
  234. package/src/checks/context.ts +72 -0
  235. package/src/checks/map/config-checks.ts +79 -0
  236. package/src/checks/map/entry-checks.ts +116 -0
  237. package/src/checks/map/shared.ts +63 -0
  238. package/src/checks/ops.ts +280 -0
  239. package/src/checks/pro-map.ts +4 -0
  240. package/src/checks/run.ts +80 -0
  241. package/src/checks/types.ts +12 -0
  242. package/src/ci/comment.ts +52 -0
  243. package/src/ci/sarif-check.ts +100 -0
  244. package/src/ci/sarif.ts +72 -0
  245. package/src/cli.ts +273 -0
  246. package/src/commands/ci.ts +104 -0
  247. package/src/commands/git.ts +59 -0
  248. package/src/commands/hooks.ts +27 -0
  249. package/src/commands/hunt-pro.ts +4 -0
  250. package/src/commands/hunt.ts +68 -0
  251. package/src/commands/map.ts +27 -0
  252. package/src/commands/mcp.ts +18 -0
  253. package/src/commands/pack.ts +34 -0
  254. package/src/commands/prove-pro.ts +4 -0
  255. package/src/commands/prove.ts +126 -0
  256. package/src/commands/report-gate.ts +75 -0
  257. package/src/commands/scan.ts +156 -0
  258. package/src/confirm/agreement.ts +23 -0
  259. package/src/confirm/chain.ts +67 -0
  260. package/src/confirm/confirm.ts +107 -0
  261. package/src/confirm/honesty.ts +65 -0
  262. package/src/confirm/lead.ts +76 -0
  263. package/src/confirm/merge.ts +89 -0
  264. package/src/confirm/query.ts +126 -0
  265. package/src/confirm/rule.ts +76 -0
  266. package/src/confirm/store.ts +42 -0
  267. package/src/confirm/summary.ts +9 -0
  268. package/src/confirm/synthesis.ts +33 -0
  269. package/src/discover/detectors.ts +71 -0
  270. package/src/discover/discover.ts +253 -0
  271. package/src/discover/extract.ts +235 -0
  272. package/src/discover/git.ts +31 -0
  273. package/src/discover/kit-files.ts +37 -0
  274. package/src/discover/walk.ts +88 -0
  275. package/src/env.ts +51 -0
  276. package/src/gate/gate.ts +169 -0
  277. package/src/hooks/install.ts +142 -0
  278. package/src/hooks/run.ts +121 -0
  279. package/src/map/analyze.ts +219 -0
  280. package/src/map/build.ts +206 -0
  281. package/src/map/candidates.ts +47 -0
  282. package/src/map/cblocks.ts +55 -0
  283. package/src/map/config.ts +207 -0
  284. package/src/map/dotnet.ts +205 -0
  285. package/src/map/entry.ts +80 -0
  286. package/src/map/expo.ts +171 -0
  287. package/src/map/go.ts +271 -0
  288. package/src/map/js-express.ts +135 -0
  289. package/src/map/js-misc.ts +221 -0
  290. package/src/map/js-next.ts +282 -0
  291. package/src/map/laravel.ts +327 -0
  292. package/src/map/lex-more.ts +152 -0
  293. package/src/map/lex.ts +274 -0
  294. package/src/map/light-index.ts +134 -0
  295. package/src/map/light-profiles.ts +363 -0
  296. package/src/map/light.ts +484 -0
  297. package/src/map/local-vocab.ts +30 -0
  298. package/src/map/mobile.ts +263 -0
  299. package/src/map/py-django.ts +239 -0
  300. package/src/map/py.ts +329 -0
  301. package/src/map/rails.ts +291 -0
  302. package/src/map/repo.ts +150 -0
  303. package/src/map/rust.ts +256 -0
  304. package/src/map/spring.ts +185 -0
  305. package/src/map/taint.ts +86 -0
  306. package/src/map/touches.ts +296 -0
  307. package/src/map/vocab.ts +106 -0
  308. package/src/mapping/controls.ts +47 -0
  309. package/src/mapping/dependency-policy.ts +55 -0
  310. package/src/mapping/map.ts +59 -0
  311. package/src/mapping/maps/gitleaks.json +12 -0
  312. package/src/mapping/maps/osv-scanner.json +7 -0
  313. package/src/mapping/maps/trivy.json +13 -0
  314. package/src/mcp/confine.ts +44 -0
  315. package/src/mcp/protocol.ts +144 -0
  316. package/src/mcp/schema.ts +35 -0
  317. package/src/mcp/server.ts +32 -0
  318. package/src/mcp/tools.ts +235 -0
  319. package/src/packs/coverage.ts +98 -0
  320. package/src/packs/manifest.ts +97 -0
  321. package/src/packs/validate.ts +204 -0
  322. package/src/prove/container.ts +237 -0
  323. package/src/prove/exec-main.ts +16 -0
  324. package/src/prove/executor.ts +130 -0
  325. package/src/prove/local.ts +100 -0
  326. package/src/prove/probes.ts +68 -0
  327. package/src/prove/recipe.ts +215 -0
  328. package/src/prove/run.ts +124 -0
  329. package/src/prove/runner.ts +28 -0
  330. package/src/prove/safety.ts +98 -0
  331. package/src/prove/spec.ts +256 -0
  332. package/src/prove/store.ts +48 -0
  333. package/src/report/build.ts +304 -0
  334. package/src/report/entry-points.ts +78 -0
  335. package/src/report/hunt-section.ts +67 -0
  336. package/src/report/kit.ts +59 -0
  337. package/src/report/load.ts +49 -0
  338. package/src/report/messages.ts +270 -0
  339. package/src/report/prove-section.ts +61 -0
  340. package/src/report/report.ts +160 -0
  341. package/src/report/surface.ts +32 -0
  342. package/src/report/test-code.ts +56 -0
  343. package/src/report/tiers.ts +50 -0
  344. package/src/scanners/common.ts +149 -0
  345. package/src/scanners/gitleaks.ts +89 -0
  346. package/src/scanners/index.ts +103 -0
  347. package/src/scanners/osv-scanner.ts +90 -0
  348. package/src/scanners/run-targets.ts +35 -0
  349. package/src/scanners/secret-noise.ts +80 -0
  350. package/src/scanners/semgrep.ts +139 -0
  351. package/src/scanners/trivy.ts +66 -0
  352. package/src/scanners/types.ts +98 -0
  353. package/src/types/check.ts +52 -0
  354. package/src/types/codemap.ts +142 -0
  355. package/src/types/findings.ts +107 -0
  356. package/src/types/hunt.ts +97 -0
  357. package/src/types/index.ts +10 -0
  358. package/src/types/prove.ts +83 -0
  359. package/src/types/results.ts +15 -0
  360. package/src/types/scan.ts +63 -0
  361. package/src/types/stack.ts +61 -0
  362. package/src/util.ts +59 -0
  363. package/src/version.ts +2 -0
@@ -0,0 +1,98 @@
1
+ // The coverage table: one row per stack pack, from the levels the packs declare and the results recorded on their fixtures. Nothing
2
+ // here is typed by hand. The table is printed by `hullproof pack table`, shown in the README between two markers (a test keeps the two
3
+ // equal), and shown in the report for the stacks found in the repository. Plain words only: no dash punctuation.
4
+ import type { Stack } from '../types/index.ts';
5
+ import { cmp } from '../util.ts';
6
+ import { flawsFrom, LEVEL_NAMES, mappedFrom, rank, supportedLevel, topLevel, type FixtureResult, type Level, type Measurement, type PackManifest } from './manifest.ts';
7
+ import { defaultEnv, validateAll, type Checked, type Env } from './validate.ts';
8
+
9
+ export interface Row {
10
+ id: string; name: string; level: Level; scopes: { where: string; level: Level }[]; supported: Level;
11
+ onlySynthetic: boolean; limits: string[]; entry: string; flaws: string; falseFindings: string; stable: string; fixture: string; languages: string[]; frameworks: string[];
12
+ }
13
+
14
+ const KIND: Record<Measurement['kind'], string> = { synthetic: 'synthetic app', demo: 'demo app', third_party: 'third party app' };
15
+ const of = (a: number, b: number): string => `${a} of ${b}`;
16
+
17
+ const kindText = (m: Measurement): string => `${m.kind === 'demo' ? 'demo app, counted over all its stacks' : KIND[m.kind]}${m.blind_key ? ' with a blind answer key' : ''}`;
18
+
19
+ export function rowOf(m: PackManifest, r: FixtureResult): Row {
20
+ const mp = mappedFrom(r);
21
+ const fl = flawsFrom(r);
22
+ const real = r.measurements.filter((x) => x.kind === 'third_party' && x.mapped);
23
+ const realText = real.length ? `route sample on ${real.length} real ${real.length === 1 ? 'app' : 'apps'}` : '';
24
+ const fixture = [fl ? `flaws on a ${kindText(fl)}` : '', mp && mp !== fl ? `map on a ${kindText(mp)}` : fl && !fl.blind_key ? 'no blind key' : '', realText].filter(Boolean).join(', ') || 'none';
25
+ return {
26
+ id: m.id, name: m.name, level: m.level, scopes: m.scopes ?? [], supported: supportedLevel(r, null),
27
+ entry: mp?.mapped ? `${of(mp.mapped.matched, mp.mapped.expected)} ${mp.mapped.what}` : 'not measured',
28
+ flaws: fl?.flaws ? of(fl.flaws.found, fl.flaws.planted) : 'not measured',
29
+ falseFindings: fl?.flaws ? String(fl.flaws.false_findings) : 'not measured',
30
+ stable: mp ? (mp.stability.identical ? `${mp.stability.runs} of ${mp.stability.runs} identical` : 'not identical') : 'not measured',
31
+ fixture,
32
+ onlySynthetic: r.measurements.every((x) => x.kind === 'synthetic'), limits: m.limits ?? [], languages: m.languages, frameworks: m.frameworks,
33
+ };
34
+ }
35
+
36
+ export function coverageRows(checked: Checked[]): Row[] {
37
+ return checked.filter((c): c is Checked & { manifest: PackManifest; result: FixtureResult } => !!c.manifest && !!c.result)
38
+ .map((c) => rowOf(c.manifest, c.result))
39
+ .sort((a, b) => rank(b.level) - rank(a.level) || cmp(a.name, b.name));
40
+ }
41
+
42
+ export const LEVEL_LEGEND: [Level, string][] = [
43
+ ['L0', 'Secrets, dependencies, infrastructure and cloud configuration, and the generic rules. Runs on any stack.'],
44
+ ['L1', 'The stack is recognised and its configuration files are checked.'],
45
+ ['L2', 'Entry points and what protects them are written to the code map.'],
46
+ ['L3', 'Executable checks and stack rules, measured on a fixture with a blind answer key.'],
47
+ ['L4', 'A start recipe, so the proof sandbox can run the app and prove a flaw.'],
48
+ ];
49
+
50
+ const scopeText = (r: Row): string => (r.scopes.length ? r.scopes.map((s) => `${s.level} for ${s.where}`).join('; ') : 'none');
51
+
52
+ // The Markdown table with the measured figures. Everything in it comes from the rows, so it cannot say more than the recorded results support.
53
+ export function coverageMarkdown(rows: Row[]): string {
54
+ const head = '| Stack | Level | Higher level for | Mapped on the fixture | Planted flaws found | False findings | Fixture | Not covered |';
55
+ const sep = '|---|---|---|---|---|---|---|---|';
56
+ const body = rows.map((r) => `| ${r.name} | ${r.level} ${LEVEL_NAMES[r.level]} | ${scopeText(r)} | ${r.entry} | ${r.flaws} | ${r.falseFindings} | ${r.fixture} | ${r.limits.join(' ') || 'nothing recorded'} |`);
57
+ return [head, sep, ...body].join('\n');
58
+ }
59
+
60
+ // The README table states levels and what each was measured on, with no figures: the README makes no detection claim.
61
+ export function levelsMarkdown(rows: Row[]): string {
62
+ const head = '| Stack | Level | Higher level for | Measured on | Not covered |';
63
+ const sep = '|---|---|---|---|---|';
64
+ return [head, sep, ...rows.map((r) => `| ${r.name} | ${r.level} ${LEVEL_NAMES[r.level]} | ${scopeText(r)} | ${r.fixture} | ${r.limits.join(' ') || 'nothing recorded'} |`)].join('\n');
65
+ }
66
+
67
+ export const legendMarkdown = (): string => ['| Level | What it means |', '|---|---|', ...LEVEL_LEGEND.map(([l, t]) => `| ${l} ${LEVEL_NAMES[l]} | ${t} |`)].join('\n');
68
+
69
+ export const README_START = '<!-- pack-coverage:start -->';
70
+ export const README_END = '<!-- pack-coverage:end -->';
71
+
72
+ // The block that goes between the README markers.
73
+ export function readmeBlock(rows: Row[]): string {
74
+ return `${README_START}\n${legendMarkdown()}\n\n${levelsMarkdown(rows)}\n${README_END}`;
75
+ }
76
+
77
+ export function currentRows(env: Env = defaultEnv()): Row[] { return coverageRows(validateAll(undefined, env)); }
78
+
79
+ // Packs that apply to a repository, judged from stack.json: a pack applies when any of its frameworks or languages was found.
80
+ export function rowsForStack(stack: Stack, rows: Row[] = currentRows()): Row[] {
81
+ const ids = new Set<string>([...stack.languages, ...stack.frameworks, ...stack.mobile, ...stack.data_stores, ...stack.auth].map((i) => i.id));
82
+ return rows.filter((r) => r.frameworks.some((f) => ids.has(f)) || r.languages.some((l) => ids.has(l)));
83
+ }
84
+
85
+ // The report section: levels for the stacks found here, and the plain statement that everything else is level 0.
86
+ export function reportSection(stack: Stack, rows: Row[] = currentRows()): string[] {
87
+ const hit = rowsForStack(stack, rows);
88
+ const out = ['## Stack coverage', '', 'Every stack gets the generic checks (level L0). The deeper levels below are what the engine has for the stacks found in this repository, as measured on its own fixtures. A level is a statement about depth, not about how many flaws exist in your app.', ''];
89
+ if (!hit.length) { out.push('No stack in this repository has a pack beyond L0. Secrets, dependencies, infrastructure files and the generic rules were checked.', ''); return out; }
90
+ out.push('| Stack | Level | Higher level for | Not covered |', '|---|---|---|---|', ...hit.map((r) => `| ${r.name} | ${r.level} ${LEVEL_NAMES[r.level]} | ${scopeText(r)} | ${r.limits.join(' ') || 'nothing recorded'} |`), '');
91
+ const synthetic = hit.filter((r) => r.onlySynthetic);
92
+ if (synthetic.length) out.push(`Measured on synthetic fixtures only: ${synthetic.map((r) => r.name).join(', ')}. No real application has been measured for them yet.`, '');
93
+ const sampled = hit.filter((r) => !r.onlySynthetic && /route sample on/.test(r.fixture));
94
+ if (sampled.length) out.push(`Also checked on real applications, by a sample of their route registrations only (planted flaws were not measured there): ${sampled.map((r) => r.name).join(', ')}.`, '');
95
+ return out;
96
+ }
97
+
98
+ export { topLevel };
@@ -0,0 +1,97 @@
1
+ // The stack pack manifest (packs/<id>/pack.json) and the fixture result (packs/<id>/fixture-result.json): their types, the
2
+ // depth levels, and the one function that says which level a recorded result supports. Format: docs/PACK-GUIDE.md.
3
+
4
+ export const LEVELS = ['L0', 'L1', 'L2', 'L3', 'L4'] as const;
5
+ export type Level = typeof LEVELS[number];
6
+ export const rank = (l: Level): number => LEVELS.indexOf(l);
7
+ export const isLevel = (x: unknown): x is Level => typeof x === 'string' && (LEVELS as readonly string[]).includes(x);
8
+
9
+ export const LEVEL_NAMES: Record<Level, string> = { L0: 'Generic', L1: 'Detected', L2: 'Mapped', L3: 'Checked', L4: 'Provable' };
10
+
11
+ // Thresholds a recorded result must reach. Written once, here, and quoted in the guide.
12
+ export const L3_MIN_RECALL = 0.5; // share of planted flaws the pack's rules and checks must find, on a blind answer key
13
+ export const REAL_APP_MIN_RECALL = 0.9; // share of a sample of real route registrations the map must hold, on every real app measured for the pack
14
+ export const MIN_RUNS = 3; // identical runs needed to call the output stable
15
+
16
+ export interface FileRef { file: string; pro?: boolean }
17
+ export interface RuleRef { file: string; tests: string[]; pro?: boolean }
18
+ export interface CheckRef { id: string; pro?: boolean }
19
+ export interface RecipeRef { file: string; recipe: string; pro?: boolean }
20
+
21
+ export interface PackManifest {
22
+ schema: 1;
23
+ id: string;
24
+ name: string;
25
+ summary: string;
26
+ edition: 'free' | 'pro'; // where the pack's own files ship; a part marked pro is skipped when the Pro folders are absent
27
+ languages: string[]; // stack.json language ids
28
+ frameworks: string[]; // stack.json framework, mobile or data store ids the pack covers
29
+ level: Level; // what the pack declares for everything it lists
30
+ scopes?: { where: string; level: Level }[]; // narrower scopes that reach a higher level
31
+ limits?: string[]; // what the pack does not cover, in plain words; shown in the coverage table
32
+ parts: {
33
+ detectors?: string[]; // detector ids (detectors/*.json) the pack relies on
34
+ extractors?: FileRef[]; // entry point extractors and config readers
35
+ auth_patterns?: FileRef[]; // the files that name session, role and signature patterns
36
+ sources_sinks?: FileRef[]; // taint rule files (sources and sinks)
37
+ rules?: RuleRef[]; // Semgrep rule files, each with the fixture files that test them
38
+ checks?: CheckRef[]; // executable checks that apply to the stack
39
+ mapping?: FileRef[]; // where the checks and rules map to requirement IDs
40
+ start_recipe?: RecipeRef; // how the proof sandbox starts the app
41
+ };
42
+ fixture: {
43
+ dir: string; // the fixture app with planted flaws
44
+ fixed_dir: string; // the fixed copy
45
+ internal?: boolean; // the fixture is not in any edition, so its folders are checked only where they exist
46
+ answer_keys: ('in_repo' | 'outside_repo' | 'independent')[]; // where the keys are: in packs/<id>/answer-key.json, in ../bench-targets/answer-keys/<id>.json (local only), or an independent source
47
+ result: string; // packs/<id>/fixture-result.json
48
+ };
49
+ }
50
+
51
+ // One measurement on one fixture. A pack can have several (a synthetic app and a demo app, for example).
52
+ export interface Measurement {
53
+ name: string; // what was measured: a folder or an app
54
+ kind: 'synthetic' | 'demo' | 'third_party';
55
+ blind_key: boolean; // the answer key was written apart from the extractor and kept out of the repository, or is independent
56
+ source: string; // how the numbers were made: a script, or the report they were recorded from
57
+ mapped: { what: string; expected: number; matched: number; extra: number } | null; // what the map holds for this stack: entry points, or tables and policies
58
+ flaws: { planted: number; found: number; false_findings: number } | null; // false findings: on the fixed copy, or judged by hand on the planted app when there is none
59
+ stability: { runs: number; identical: boolean };
60
+ }
61
+
62
+ export interface FixtureResult {
63
+ schema: 1;
64
+ pack: string;
65
+ measured: string; // YYYY-MM-DD
66
+ engine: string;
67
+ detect: { expected: string[]; found: string[] };
68
+ measurements: Measurement[];
69
+ proof: { scope: string; planted: number; proven: number; runs: number; false_proofs_fixed: number; identical: boolean; source: string }[];
70
+ notes: string[];
71
+ }
72
+
73
+ const stable = (m: Measurement): boolean => m.stability.runs >= MIN_RUNS && m.stability.identical;
74
+ const exact = (m: Measurement): boolean => !!m.mapped && m.mapped.expected >= 1 && m.mapped.matched === m.mapped.expected && m.mapped.extra === 0 && stable(m);
75
+ // A real application is the hardest test of the extractor. A pack that fell short on one cannot claim L2, whatever the synthetic fixtures say.
76
+ const realAppShort = (m: Measurement): boolean => m.kind === 'third_party' && !!m.mapped && (m.mapped.expected < 1 || m.mapped.matched / m.mapped.expected < REAL_APP_MIN_RECALL || !stable(m));
77
+ const recallOk = (m: Measurement): boolean => !!m.flaws && m.blind_key && m.flaws.planted >= 1 && m.flaws.found / m.flaws.planted >= L3_MIN_RECALL && m.flaws.false_findings === 0 && stable(m);
78
+
79
+ // The highest level the result supports for a scope (null: the whole pack). Levels build on each other, so a gap stops the climb.
80
+ export function supportedLevel(r: FixtureResult, scope: string | null = null): Level {
81
+ const found = new Set(r.detect.found);
82
+ if (!r.detect.expected.length || !r.detect.expected.every((x) => found.has(x))) return 'L0';
83
+ if (!r.measurements.some(exact) || r.measurements.some(realAppShort)) return 'L1';
84
+ if (!r.measurements.some(recallOk)) return 'L2';
85
+ if (scope === null) return 'L3';
86
+ const pr = r.proof.find((p) => p.scope === scope);
87
+ return pr && pr.planted >= 1 && pr.proven === pr.planted && pr.false_proofs_fixed === 0 && pr.runs >= MIN_RUNS && pr.identical ? 'L4' : 'L3';
88
+ }
89
+
90
+ // The measurements the coverage table shows: what the map holds comes from the best exact measurement, the flaws from the best flaw
91
+ // measurement (one with a blind key and a pass first). They can be two different fixtures; the table says so.
92
+ export const mappedFrom = (r: FixtureResult): Measurement | null => r.measurements.find((m) => exact(m) && m.blind_key && m.mapped && m.kind !== 'third_party') ?? r.measurements.find((m) => exact(m) && m.kind !== 'third_party') ?? r.measurements.find((m) => exact(m)) ?? r.measurements.find((m) => m.mapped) ?? null;
93
+ export const flawsFrom = (r: FixtureResult): Measurement | null => r.measurements.find(recallOk) ?? r.measurements.find((m) => m.flaws) ?? null;
94
+
95
+ // The level a manifest claims for the whole pack and for each scope, as [scope or null, level] pairs.
96
+ export const claims = (m: PackManifest): [string | null, Level][] => [[null, m.level], ...(m.scopes ?? []).map((s): [string, Level] => [s.where, s.level])];
97
+ export const topLevel = (m: PackManifest): Level => claims(m).map((c) => c[1]).reduce((a, b) => (rank(b) > rank(a) ? b : a));
@@ -0,0 +1,204 @@
1
+ // hullproof pack validate: reads packs/<id>/pack.json and fixture-result.json and rejects a pack that is missing a part, lists a rule
2
+ // without a test, has no fixture result, uses a bad level, or claims a level its recorded fixture result does not support.
3
+ // Reads files only. Runs no scanner, no model and no network.
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadChecks } from '../checks/run.ts';
8
+ import { PRO_MAP_CHECKS } from '../checks/pro-map.ts';
9
+ import { DETECTORS_DIR } from '../discover/detectors.ts';
10
+ import { cmp } from '../util.ts';
11
+ import { claims, isLevel, LEVELS, rank, supportedLevel, topLevel, type FileRef, type FixtureResult, type Level, type PackManifest } from './manifest.ts';
12
+
13
+ export const ENGINE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
14
+ export const PACKS_DIR = join(ENGINE_ROOT, 'packs');
15
+
16
+ export interface Issue { pack: string; code: string; message: string }
17
+ export interface Checked { dir: string; manifest: PackManifest | null; result: FixtureResult | null; issues: Issue[]; skipped: string[] }
18
+
19
+ // What a part must have at each level. L0 needs nothing but the manifest and a fixture result; every pack needs the fixture result.
20
+ const NEEDS: Record<string, Level> = { detectors: 'L1', extractors: 'L2', auth_patterns: 'L2', checks: 'L3', mapping: 'L3', start_recipe: 'L4' };
21
+
22
+ export interface Env {
23
+ root: string;
24
+ detectorIds: Set<string>;
25
+ checkIds: Set<string>;
26
+ recipes: Set<string>;
27
+ hasPro: boolean; // this is the Pro edition: the Pro only checks are loaded
28
+ }
29
+
30
+ export function defaultEnv(root: string = ENGINE_ROOT): Env {
31
+ const detectorIds = new Set<string>();
32
+ for (const n of readdirSync(DETECTORS_DIR).filter((f) => f.endsWith('.json'))) {
33
+ for (const e of (JSON.parse(readFileSync(join(DETECTORS_DIR, n), 'utf8')) as { entries: { id: string }[] }).entries) detectorIds.add(e.id);
34
+ }
35
+ return { root, detectorIds, checkIds: new Set(loadChecks().map((c) => c.id)), recipes: RECIPES, hasPro: PRO_MAP_CHECKS.length > 0 };
36
+ }
37
+
38
+ // Start recipes the proof sandbox builds (src/prove/recipe.ts). A pack at L4 names one of these.
39
+ export const RECIPES = new Set(['node-express', 'python-flask']);
40
+
41
+ const SEC_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
42
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
43
+
44
+ export function readJson<T>(file: string): T | string {
45
+ try { return JSON.parse(readFileSync(file, 'utf8')) as T; } catch (e) { return (e as Error).message; }
46
+ }
47
+
48
+ // The Semgrep rule ids in a rule file, read without YAML parsing: lines of the form " - id: name".
49
+ export const ruleIds = (text: string): string[] => [...text.matchAll(/^\s*-\s+id:\s*([A-Za-z0-9._-]+)\s*$/gm)].map((m) => m[1]);
50
+
51
+ export function validatePack(folder: string, env: Env): Checked {
52
+ const id = folder.split('/').filter(Boolean).pop() ?? folder;
53
+ const issues: Issue[] = [];
54
+ const skipped: string[] = [];
55
+ const bad = (code: string, message: string): void => { issues.push({ pack: id, code, message }); };
56
+ const out = (manifest: PackManifest | null, result: FixtureResult | null): Checked => ({ dir: folder, manifest, result, issues: issues.sort((a, b) => cmp(a.code, b.code) || cmp(a.message, b.message)), skipped });
57
+
58
+ const mf = join(folder, 'pack.json');
59
+ if (!existsSync(mf)) { bad('manifest_missing', 'there is no pack.json'); return out(null, null); }
60
+ const raw = readJson<PackManifest>(mf);
61
+ if (typeof raw === 'string') { bad('manifest_unreadable', `pack.json is not valid JSON: ${raw}`); return out(null, null); }
62
+ const m = raw;
63
+
64
+ // ---- identity and levels
65
+ if (m.schema !== 1) bad('schema', 'schema must be 1');
66
+ if (typeof m.id !== 'string' || !SEC_ID.test(m.id)) bad('id', 'id must be lower case words joined with hyphens');
67
+ else if (m.id !== id) bad('id', `id "${m.id}" does not match the folder name "${id}"`);
68
+ for (const k of ['name', 'summary'] as const) if (typeof m[k] !== 'string' || !m[k].trim()) bad('field', `${k} is missing`);
69
+ if (m.edition !== 'free' && m.edition !== 'pro') bad('field', 'edition must be free or pro');
70
+ for (const k of ['languages', 'frameworks'] as const) if (!Array.isArray(m[k]) || m[k].length === 0 || m[k].some((x) => typeof x !== 'string')) bad('field', `${k} must list at least one stack id`);
71
+ if (!isLevel(m.level)) bad('level', `level "${String(m.level)}" is not one of ${LEVELS.join(', ')}`);
72
+ for (const s of m.scopes ?? []) {
73
+ if (typeof s.where !== 'string' || !s.where.trim()) bad('level', 'a scope has no "where"');
74
+ else if (!isLevel(s.level)) bad('level', `scope "${s.where}" has level "${String(s.level)}", which is not one of ${LEVELS.join(', ')}`);
75
+ else if (isLevel(m.level) && rank(s.level) <= rank(m.level)) bad('level', `scope "${s.where}" is L${rank(s.level)}, which is not higher than the pack level; a scope exists only to reach a higher level`);
76
+ }
77
+ if (m.limits !== undefined && (!Array.isArray(m.limits) || m.limits.some((x) => typeof x !== 'string' || !x.trim()))) bad('field', 'limits must be a list of sentences');
78
+ const levelsOk = isLevel(m.level) && (m.scopes ?? []).every((s) => isLevel(s.level));
79
+ const parts = m.parts ?? {};
80
+ if (!m.parts || typeof m.parts !== 'object') bad('parts_missing', 'parts is missing');
81
+
82
+ // ---- every part the declared level needs must be there
83
+ if (levelsOk) {
84
+ const top = topLevel(m);
85
+ for (const [part, at] of Object.entries(NEEDS)) {
86
+ const have = (parts as Record<string, unknown>)[part];
87
+ const present = Array.isArray(have) ? have.length > 0 : have !== undefined && have !== null;
88
+ if (rank(top) >= rank(at) && !present) bad('part_missing', `the pack declares ${top}, which needs "${part}" (from ${at}), and the manifest has none`);
89
+ }
90
+ }
91
+
92
+ // ---- files named by a part must exist (a Pro part is skipped when the Pro folders are not in this tree)
93
+ const here = (rel: string): boolean => existsSync(join(env.root, rel));
94
+ const fileRef = (part: string, r: FileRef): void => {
95
+ if (!r || typeof r.file !== 'string') { bad('file', `${part}: an entry has no file`); return; }
96
+ if (here(r.file)) return;
97
+ if (r.pro && !env.hasPro) { skipped.push(`${part}: ${r.file} (Pro, not in this edition)`); return; }
98
+ bad('file_missing', `${part}: ${r.file} does not exist`);
99
+ };
100
+ for (const part of ['extractors', 'auth_patterns', 'sources_sinks', 'mapping'] as const) for (const r of parts[part] ?? []) fileRef(part, r);
101
+
102
+ for (const d of parts.detectors ?? []) if (!env.detectorIds.has(d)) bad('detector_unknown', `detectors: "${d}" is not an id in detectors/*.json`);
103
+ for (const f of m.frameworks ?? []) if (!env.detectorIds.has(f)) bad('detector_unknown', `frameworks: "${f}" is not an id in detectors/*.json`);
104
+
105
+ // ---- rules need tests: every rule id in the file has a "ruleid" line and an "ok" line in the test files
106
+ for (const r of parts.rules ?? []) {
107
+ if (!r || typeof r.file !== 'string') { bad('file', 'rules: an entry has no file'); continue; }
108
+ if (!here(r.file)) { if (r.pro && !env.hasPro) skipped.push(`rules: ${r.file} (Pro, not in this edition)`); else bad('file_missing', `rules: ${r.file} does not exist`); continue; }
109
+ if (!Array.isArray(r.tests) || r.tests.length === 0) { bad('rule_without_test', `rules: ${r.file} has no test files`); continue; }
110
+ // An npm install carries the rules but not the test folder, so the rule tests cannot be read there.
111
+ if (!existsSync(join(env.root, 'test/fixtures'))) { skipped.push(`rules: tests for ${r.file} (no fixture folder in this tree)`); continue; }
112
+ const missing = r.tests.filter((t) => !here(t));
113
+ for (const t of missing) bad('file_missing', `rules: test file ${t} for ${r.file} does not exist`);
114
+ if (missing.length) continue;
115
+ const testText = r.tests.map((t) => readFileSync(join(env.root, t), 'utf8')).join('\n');
116
+ for (const rid of ruleIds(readFileSync(join(env.root, r.file), 'utf8'))) {
117
+ if (!new RegExp(`ruleid: ${rid}\\b`).test(testText)) bad('rule_without_test', `rules: ${rid} in ${r.file} has no "ruleid: ${rid}" line in its test files`);
118
+ if (!new RegExp(`ok: ${rid}\\b`).test(testText)) bad('rule_without_test', `rules: ${rid} in ${r.file} has no "ok: ${rid}" line in its test files`);
119
+ }
120
+ }
121
+
122
+ // ---- checks must exist in this engine (a Pro check is skipped when the Pro folders are absent)
123
+ for (const c of parts.checks ?? []) {
124
+ if (!c || typeof c.id !== 'string') { bad('check_unknown', 'checks: an entry has no id'); continue; }
125
+ if (env.checkIds.has(c.id)) continue;
126
+ if (c.pro && !env.hasPro) skipped.push(`checks: ${c.id} (Pro, not in this edition)`); else bad('check_unknown', `checks: ${c.id} is not a check this engine loads`);
127
+ }
128
+
129
+ // ---- start recipe
130
+ const rc = parts.start_recipe;
131
+ if (rc) {
132
+ if (!env.recipes.has(rc.recipe)) bad('recipe_unknown', `start_recipe: "${rc.recipe}" is not a recipe the proof sandbox builds (${[...env.recipes].sort(cmp).join(', ')})`);
133
+ fileRef('start_recipe', rc);
134
+ }
135
+
136
+ // ---- fixture and its recorded result
137
+ const fx = m.fixture;
138
+ let result: FixtureResult | null = null;
139
+ if (!fx || typeof fx !== 'object') bad('fixture_missing', 'fixture is missing');
140
+ else {
141
+ for (const k of ['dir', 'fixed_dir', 'result'] as const) if (typeof fx[k] !== 'string' || !fx[k]) bad('fixture_missing', `fixture.${k} is missing`);
142
+ if (!Array.isArray(fx.answer_keys) || fx.answer_keys.length === 0 || fx.answer_keys.some((k) => !['in_repo', 'outside_repo', 'independent'].includes(k))) bad('fixture_missing', 'fixture.answer_keys must list in_repo, outside_repo or independent');
143
+ // Fixtures marked internal are in neither edition. Their folders are checked only in a tree that has the internal fixture area.
144
+ const internalArea = here('test/fixtures/packs');
145
+ for (const k of ['dir', 'fixed_dir'] as const) if (typeof fx[k] === 'string' && fx[k] && !here(fx[k])) {
146
+ if (fx.internal && !internalArea) skipped.push(`fixture.${k}: ${fx[k]} (internal fixture, not in this edition)`);
147
+ else if (fx.internal !== true && !existsSync(join(env.root, 'test/fixtures'))) skipped.push(`fixture.${k}: ${fx[k]} (no fixture folder in this tree)`);
148
+ else bad('fixture_missing', `fixture.${k}: ${fx[k]} does not exist`);
149
+ }
150
+ const rf = typeof fx.result === 'string' ? join(env.root, fx.result) : '';
151
+ if (!rf || !existsSync(rf)) bad('fixture_result_missing', `there is no fixture result (${fx.result ?? 'no path'}); a pack with no recorded result on its fixture is not listed`);
152
+ else {
153
+ const r = readJson<FixtureResult>(rf);
154
+ if (typeof r === 'string') bad('fixture_result_invalid', `${fx.result} is not valid JSON: ${r}`);
155
+ else {
156
+ const why = checkResult(r, m.id);
157
+ if (why.length) for (const w of why) bad('fixture_result_invalid', `${fx.result}: ${w}`);
158
+ else result = r;
159
+ }
160
+ }
161
+ }
162
+
163
+ // ---- the declared levels against what the result supports
164
+ if (levelsOk && result) {
165
+ for (const [scope, lv] of claims(m)) {
166
+ const sup = supportedLevel(result, scope);
167
+ if (rank(lv) > rank(sup)) bad('level_too_high', `${scope ? `scope "${scope}"` : 'the pack'} declares ${lv}, and the recorded fixture result supports ${sup}`);
168
+ }
169
+ }
170
+ return out(m, result);
171
+ }
172
+
173
+ function checkResult(r: FixtureResult, id: string): string[] {
174
+ const w: string[] = [];
175
+ if (r.schema !== 1) w.push('schema must be 1');
176
+ if (r.pack !== id) w.push(`pack is "${String(r.pack)}", not "${id}"`);
177
+ if (typeof r.measured !== 'string' || !DATE.test(r.measured)) w.push('measured must be a date, YYYY-MM-DD');
178
+ if (!r.detect || !Array.isArray(r.detect.expected) || !Array.isArray(r.detect.found)) w.push('detect.expected and detect.found are required');
179
+ if (!Array.isArray(r.measurements) || r.measurements.length === 0) w.push('measurements is empty; a pack with no measurement on a fixture is not listed');
180
+ const nums = (o: object | null, keys: string[], name: string): void => { if (o && keys.some((k) => !Number.isInteger((o as Record<string, unknown>)[k]) || (o as Record<string, number>)[k] < 0)) w.push(`${name} needs whole numbers for ${keys.join(', ')}`); };
181
+ for (const m of Array.isArray(r.measurements) ? r.measurements : []) {
182
+ const n = `measurement "${String(m?.name)}"`;
183
+ if (!m || typeof m.name !== 'string' || !['synthetic', 'demo', 'third_party'].includes(m.kind) || typeof m.blind_key !== 'boolean') { w.push(`${n} needs name, kind and blind_key`); continue; }
184
+ if (typeof m.source !== 'string' || !m.source.trim()) w.push(`${n}: source says nothing about how the numbers were made`);
185
+ nums(m.mapped, ['expected', 'matched', 'extra'], `${n} mapped`);
186
+ if (m.mapped && (typeof m.mapped.what !== 'string' || !m.mapped.what)) w.push(`${n}: mapped.what is missing`);
187
+ nums(m.flaws, ['planted', 'found', 'false_findings'], `${n} flaws`);
188
+ if (!m.stability || !Number.isInteger(m.stability.runs) || typeof m.stability.identical !== 'boolean') w.push(`${n}: stability.runs and stability.identical are required`);
189
+ if (m.mapped && m.mapped.matched > m.mapped.expected) w.push(`${n}: matched is more than expected`);
190
+ if (m.flaws && m.flaws.found > m.flaws.planted) w.push(`${n}: flaws.found is more than planted`);
191
+ }
192
+ if (!Array.isArray(r.proof)) w.push('proof must be a list (empty when no proof was run)');
193
+ else for (const p of r.proof) nums(p, ['planted', 'proven', 'runs', 'false_proofs_fixed'], 'proof');
194
+ return w;
195
+ }
196
+
197
+ export function listPacks(dir: string = PACKS_DIR): string[] {
198
+ if (!existsSync(dir)) return [];
199
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(dir, d.name)).sort(cmp);
200
+ }
201
+
202
+ export function validateAll(dir: string = PACKS_DIR, env: Env = defaultEnv()): Checked[] {
203
+ return listPacks(dir).map((p) => validatePack(p, env));
204
+ }