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,36 @@
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
+ export const LEVELS = ['L0', 'L1', 'L2', 'L3', 'L4'];
4
+ export const rank = (l) => LEVELS.indexOf(l);
5
+ export const isLevel = (x) => typeof x === 'string' && LEVELS.includes(x);
6
+ export const LEVEL_NAMES = { L0: 'Generic', L1: 'Detected', L2: 'Mapped', L3: 'Checked', L4: 'Provable' };
7
+ // Thresholds a recorded result must reach. Written once, here, and quoted in the guide.
8
+ export const L3_MIN_RECALL = 0.5; // share of planted flaws the pack's rules and checks must find, on a blind answer key
9
+ 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
10
+ export const MIN_RUNS = 3; // identical runs needed to call the output stable
11
+ const stable = (m) => m.stability.runs >= MIN_RUNS && m.stability.identical;
12
+ const exact = (m) => !!m.mapped && m.mapped.expected >= 1 && m.mapped.matched === m.mapped.expected && m.mapped.extra === 0 && stable(m);
13
+ // 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.
14
+ const realAppShort = (m) => m.kind === 'third_party' && !!m.mapped && (m.mapped.expected < 1 || m.mapped.matched / m.mapped.expected < REAL_APP_MIN_RECALL || !stable(m));
15
+ const recallOk = (m) => !!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);
16
+ // The highest level the result supports for a scope (null: the whole pack). Levels build on each other, so a gap stops the climb.
17
+ export function supportedLevel(r, scope = null) {
18
+ const found = new Set(r.detect.found);
19
+ if (!r.detect.expected.length || !r.detect.expected.every((x) => found.has(x)))
20
+ return 'L0';
21
+ if (!r.measurements.some(exact) || r.measurements.some(realAppShort))
22
+ return 'L1';
23
+ if (!r.measurements.some(recallOk))
24
+ return 'L2';
25
+ if (scope === null)
26
+ return 'L3';
27
+ const pr = r.proof.find((p) => p.scope === scope);
28
+ return pr && pr.planted >= 1 && pr.proven === pr.planted && pr.false_proofs_fixed === 0 && pr.runs >= MIN_RUNS && pr.identical ? 'L4' : 'L3';
29
+ }
30
+ // The measurements the coverage table shows: what the map holds comes from the best exact measurement, the flaws from the best flaw
31
+ // measurement (one with a blind key and a pass first). They can be two different fixtures; the table says so.
32
+ export const mappedFrom = (r) => 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;
33
+ export const flawsFrom = (r) => r.measurements.find(recallOk) ?? r.measurements.find((m) => m.flaws) ?? null;
34
+ // The level a manifest claims for the whole pack and for each scope, as [scope or null, level] pairs.
35
+ export const claims = (m) => [[null, m.level], ...(m.scopes ?? []).map((s) => [s.where, s.level])];
36
+ export const topLevel = (m) => claims(m).map((c) => c[1]).reduce((a, b) => (rank(b) > rank(a) ? b : a));
@@ -0,0 +1,271 @@
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.js';
8
+ import { PRO_MAP_CHECKS } from '../checks/pro-map.js';
9
+ import { DETECTORS_DIR } from '../discover/detectors.js';
10
+ import { cmp } from '../util.js';
11
+ import { claims, isLevel, LEVELS, rank, supportedLevel, topLevel } from './manifest.js';
12
+ export const ENGINE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
13
+ export const PACKS_DIR = join(ENGINE_ROOT, 'packs');
14
+ // What a part must have at each level. L0 needs nothing but the manifest and a fixture result; every pack needs the fixture result.
15
+ const NEEDS = { detectors: 'L1', extractors: 'L2', auth_patterns: 'L2', checks: 'L3', mapping: 'L3', start_recipe: 'L4' };
16
+ export function defaultEnv(root = ENGINE_ROOT) {
17
+ const detectorIds = new Set();
18
+ for (const n of readdirSync(DETECTORS_DIR).filter((f) => f.endsWith('.json'))) {
19
+ for (const e of JSON.parse(readFileSync(join(DETECTORS_DIR, n), 'utf8')).entries)
20
+ detectorIds.add(e.id);
21
+ }
22
+ return { root, detectorIds, checkIds: new Set(loadChecks().map((c) => c.id)), recipes: RECIPES, hasPro: PRO_MAP_CHECKS.length > 0 };
23
+ }
24
+ // Start recipes the proof sandbox builds (src/prove/recipe.ts). A pack at L4 names one of these.
25
+ export const RECIPES = new Set(['node-express', 'python-flask']);
26
+ const SEC_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
27
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
28
+ export function readJson(file) {
29
+ try {
30
+ return JSON.parse(readFileSync(file, 'utf8'));
31
+ }
32
+ catch (e) {
33
+ return e.message;
34
+ }
35
+ }
36
+ // The Semgrep rule ids in a rule file, read without YAML parsing: lines of the form " - id: name".
37
+ export const ruleIds = (text) => [...text.matchAll(/^\s*-\s+id:\s*([A-Za-z0-9._-]+)\s*$/gm)].map((m) => m[1]);
38
+ export function validatePack(folder, env) {
39
+ const id = folder.split('/').filter(Boolean).pop() ?? folder;
40
+ const issues = [];
41
+ const skipped = [];
42
+ const bad = (code, message) => { issues.push({ pack: id, code, message }); };
43
+ const out = (manifest, result) => ({ dir: folder, manifest, result, issues: issues.sort((a, b) => cmp(a.code, b.code) || cmp(a.message, b.message)), skipped });
44
+ const mf = join(folder, 'pack.json');
45
+ if (!existsSync(mf)) {
46
+ bad('manifest_missing', 'there is no pack.json');
47
+ return out(null, null);
48
+ }
49
+ const raw = readJson(mf);
50
+ if (typeof raw === 'string') {
51
+ bad('manifest_unreadable', `pack.json is not valid JSON: ${raw}`);
52
+ return out(null, null);
53
+ }
54
+ const m = raw;
55
+ // ---- identity and levels
56
+ if (m.schema !== 1)
57
+ bad('schema', 'schema must be 1');
58
+ if (typeof m.id !== 'string' || !SEC_ID.test(m.id))
59
+ bad('id', 'id must be lower case words joined with hyphens');
60
+ else if (m.id !== id)
61
+ bad('id', `id "${m.id}" does not match the folder name "${id}"`);
62
+ for (const k of ['name', 'summary'])
63
+ if (typeof m[k] !== 'string' || !m[k].trim())
64
+ bad('field', `${k} is missing`);
65
+ if (m.edition !== 'free' && m.edition !== 'pro')
66
+ bad('field', 'edition must be free or pro');
67
+ for (const k of ['languages', 'frameworks'])
68
+ if (!Array.isArray(m[k]) || m[k].length === 0 || m[k].some((x) => typeof x !== 'string'))
69
+ bad('field', `${k} must list at least one stack id`);
70
+ if (!isLevel(m.level))
71
+ 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())
74
+ bad('level', 'a scope has no "where"');
75
+ else if (!isLevel(s.level))
76
+ bad('level', `scope "${s.where}" has level "${String(s.level)}", which is not one of ${LEVELS.join(', ')}`);
77
+ else if (isLevel(m.level) && rank(s.level) <= rank(m.level))
78
+ 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`);
79
+ }
80
+ if (m.limits !== undefined && (!Array.isArray(m.limits) || m.limits.some((x) => typeof x !== 'string' || !x.trim())))
81
+ bad('field', 'limits must be a list of sentences');
82
+ const levelsOk = isLevel(m.level) && (m.scopes ?? []).every((s) => isLevel(s.level));
83
+ const parts = m.parts ?? {};
84
+ if (!m.parts || typeof m.parts !== 'object')
85
+ bad('parts_missing', 'parts is missing');
86
+ // ---- every part the declared level needs must be there
87
+ if (levelsOk) {
88
+ const top = topLevel(m);
89
+ for (const [part, at] of Object.entries(NEEDS)) {
90
+ const have = parts[part];
91
+ const present = Array.isArray(have) ? have.length > 0 : have !== undefined && have !== null;
92
+ if (rank(top) >= rank(at) && !present)
93
+ bad('part_missing', `the pack declares ${top}, which needs "${part}" (from ${at}), and the manifest has none`);
94
+ }
95
+ }
96
+ // ---- files named by a part must exist (a Pro part is skipped when the Pro folders are not in this tree)
97
+ const here = (rel) => existsSync(join(env.root, rel));
98
+ const fileRef = (part, r) => {
99
+ if (!r || typeof r.file !== 'string') {
100
+ bad('file', `${part}: an entry has no file`);
101
+ return;
102
+ }
103
+ if (here(r.file))
104
+ return;
105
+ if (r.pro && !env.hasPro) {
106
+ skipped.push(`${part}: ${r.file} (Pro, not in this edition)`);
107
+ return;
108
+ }
109
+ bad('file_missing', `${part}: ${r.file} does not exist`);
110
+ };
111
+ for (const part of ['extractors', 'auth_patterns', 'sources_sinks', 'mapping'])
112
+ for (const r of parts[part] ?? [])
113
+ fileRef(part, r);
114
+ for (const d of parts.detectors ?? [])
115
+ if (!env.detectorIds.has(d))
116
+ bad('detector_unknown', `detectors: "${d}" is not an id in detectors/*.json`);
117
+ for (const f of m.frameworks ?? [])
118
+ if (!env.detectorIds.has(f))
119
+ bad('detector_unknown', `frameworks: "${f}" is not an id in detectors/*.json`);
120
+ // ---- rules need tests: every rule id in the file has a "ruleid" line and an "ok" line in the test files
121
+ for (const r of parts.rules ?? []) {
122
+ if (!r || typeof r.file !== 'string') {
123
+ bad('file', 'rules: an entry has no file');
124
+ continue;
125
+ }
126
+ if (!here(r.file)) {
127
+ if (r.pro && !env.hasPro)
128
+ skipped.push(`rules: ${r.file} (Pro, not in this edition)`);
129
+ else
130
+ bad('file_missing', `rules: ${r.file} does not exist`);
131
+ continue;
132
+ }
133
+ if (!Array.isArray(r.tests) || r.tests.length === 0) {
134
+ bad('rule_without_test', `rules: ${r.file} has no test files`);
135
+ continue;
136
+ }
137
+ // An npm install carries the rules but not the test folder, so the rule tests cannot be read there.
138
+ if (!existsSync(join(env.root, 'test/fixtures'))) {
139
+ skipped.push(`rules: tests for ${r.file} (no fixture folder in this tree)`);
140
+ continue;
141
+ }
142
+ const missing = r.tests.filter((t) => !here(t));
143
+ for (const t of missing)
144
+ bad('file_missing', `rules: test file ${t} for ${r.file} does not exist`);
145
+ if (missing.length)
146
+ continue;
147
+ const testText = r.tests.map((t) => readFileSync(join(env.root, t), 'utf8')).join('\n');
148
+ for (const rid of ruleIds(readFileSync(join(env.root, r.file), 'utf8'))) {
149
+ if (!new RegExp(`ruleid: ${rid}\\b`).test(testText))
150
+ bad('rule_without_test', `rules: ${rid} in ${r.file} has no "ruleid: ${rid}" line in its test files`);
151
+ if (!new RegExp(`ok: ${rid}\\b`).test(testText))
152
+ bad('rule_without_test', `rules: ${rid} in ${r.file} has no "ok: ${rid}" line in its test files`);
153
+ }
154
+ }
155
+ // ---- checks must exist in this engine (a Pro check is skipped when the Pro folders are absent)
156
+ for (const c of parts.checks ?? []) {
157
+ if (!c || typeof c.id !== 'string') {
158
+ bad('check_unknown', 'checks: an entry has no id');
159
+ continue;
160
+ }
161
+ if (env.checkIds.has(c.id))
162
+ continue;
163
+ if (c.pro && !env.hasPro)
164
+ skipped.push(`checks: ${c.id} (Pro, not in this edition)`);
165
+ else
166
+ bad('check_unknown', `checks: ${c.id} is not a check this engine loads`);
167
+ }
168
+ // ---- start recipe
169
+ const rc = parts.start_recipe;
170
+ if (rc) {
171
+ if (!env.recipes.has(rc.recipe))
172
+ bad('recipe_unknown', `start_recipe: "${rc.recipe}" is not a recipe the proof sandbox builds (${[...env.recipes].sort(cmp).join(', ')})`);
173
+ fileRef('start_recipe', rc);
174
+ }
175
+ // ---- fixture and its recorded result
176
+ const fx = m.fixture;
177
+ let result = null;
178
+ if (!fx || typeof fx !== 'object')
179
+ bad('fixture_missing', 'fixture is missing');
180
+ else {
181
+ for (const k of ['dir', 'fixed_dir', 'result'])
182
+ if (typeof fx[k] !== 'string' || !fx[k])
183
+ bad('fixture_missing', `fixture.${k} is missing`);
184
+ if (!Array.isArray(fx.answer_keys) || fx.answer_keys.length === 0 || fx.answer_keys.some((k) => !['in_repo', 'outside_repo', 'independent'].includes(k)))
185
+ bad('fixture_missing', 'fixture.answer_keys must list in_repo, outside_repo or independent');
186
+ // Fixtures marked internal are in neither edition. Their folders are checked only in a tree that has the internal fixture area.
187
+ const internalArea = here('test/fixtures/packs');
188
+ for (const k of ['dir', 'fixed_dir'])
189
+ if (typeof fx[k] === 'string' && fx[k] && !here(fx[k])) {
190
+ if (fx.internal && !internalArea)
191
+ skipped.push(`fixture.${k}: ${fx[k]} (internal fixture, not in this edition)`);
192
+ else if (fx.internal !== true && !existsSync(join(env.root, 'test/fixtures')))
193
+ skipped.push(`fixture.${k}: ${fx[k]} (no fixture folder in this tree)`);
194
+ else
195
+ bad('fixture_missing', `fixture.${k}: ${fx[k]} does not exist`);
196
+ }
197
+ const rf = typeof fx.result === 'string' ? join(env.root, fx.result) : '';
198
+ if (!rf || !existsSync(rf))
199
+ 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`);
200
+ else {
201
+ const r = readJson(rf);
202
+ if (typeof r === 'string')
203
+ bad('fixture_result_invalid', `${fx.result} is not valid JSON: ${r}`);
204
+ else {
205
+ const why = checkResult(r, m.id);
206
+ if (why.length)
207
+ for (const w of why)
208
+ bad('fixture_result_invalid', `${fx.result}: ${w}`);
209
+ else
210
+ result = r;
211
+ }
212
+ }
213
+ }
214
+ // ---- the declared levels against what the result supports
215
+ if (levelsOk && result) {
216
+ for (const [scope, lv] of claims(m)) {
217
+ const sup = supportedLevel(result, scope);
218
+ if (rank(lv) > rank(sup))
219
+ bad('level_too_high', `${scope ? `scope "${scope}"` : 'the pack'} declares ${lv}, and the recorded fixture result supports ${sup}`);
220
+ }
221
+ }
222
+ return out(m, result);
223
+ }
224
+ function checkResult(r, id) {
225
+ const w = [];
226
+ if (r.schema !== 1)
227
+ w.push('schema must be 1');
228
+ if (r.pack !== id)
229
+ w.push(`pack is "${String(r.pack)}", not "${id}"`);
230
+ if (typeof r.measured !== 'string' || !DATE.test(r.measured))
231
+ w.push('measured must be a date, YYYY-MM-DD');
232
+ if (!r.detect || !Array.isArray(r.detect.expected) || !Array.isArray(r.detect.found))
233
+ w.push('detect.expected and detect.found are required');
234
+ if (!Array.isArray(r.measurements) || r.measurements.length === 0)
235
+ w.push('measurements is empty; a pack with no measurement on a fixture is not listed');
236
+ const nums = (o, keys, name) => { if (o && keys.some((k) => !Number.isInteger(o[k]) || o[k] < 0))
237
+ w.push(`${name} needs whole numbers for ${keys.join(', ')}`); };
238
+ for (const m of Array.isArray(r.measurements) ? r.measurements : []) {
239
+ const n = `measurement "${String(m?.name)}"`;
240
+ if (!m || typeof m.name !== 'string' || !['synthetic', 'demo', 'third_party'].includes(m.kind) || typeof m.blind_key !== 'boolean') {
241
+ w.push(`${n} needs name, kind and blind_key`);
242
+ continue;
243
+ }
244
+ if (typeof m.source !== 'string' || !m.source.trim())
245
+ w.push(`${n}: source says nothing about how the numbers were made`);
246
+ nums(m.mapped, ['expected', 'matched', 'extra'], `${n} mapped`);
247
+ if (m.mapped && (typeof m.mapped.what !== 'string' || !m.mapped.what))
248
+ w.push(`${n}: mapped.what is missing`);
249
+ nums(m.flaws, ['planted', 'found', 'false_findings'], `${n} flaws`);
250
+ if (!m.stability || !Number.isInteger(m.stability.runs) || typeof m.stability.identical !== 'boolean')
251
+ w.push(`${n}: stability.runs and stability.identical are required`);
252
+ if (m.mapped && m.mapped.matched > m.mapped.expected)
253
+ w.push(`${n}: matched is more than expected`);
254
+ if (m.flaws && m.flaws.found > m.flaws.planted)
255
+ w.push(`${n}: flaws.found is more than planted`);
256
+ }
257
+ if (!Array.isArray(r.proof))
258
+ w.push('proof must be a list (empty when no proof was run)');
259
+ else
260
+ for (const p of r.proof)
261
+ nums(p, ['planted', 'proven', 'runs', 'false_proofs_fixed'], 'proof');
262
+ return w;
263
+ }
264
+ export function listPacks(dir = PACKS_DIR) {
265
+ if (!existsSync(dir))
266
+ return [];
267
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(dir, d.name)).sort(cmp);
268
+ }
269
+ export function validateAll(dir = PACKS_DIR, env = defaultEnv()) {
270
+ return listPacks(dir).map((p) => validatePack(p, env));
271
+ }
@@ -0,0 +1,276 @@
1
+ // The container backend. It speaks the docker command line, so Colima, Docker Desktop and Podman work through the same commands.
2
+ // Safety rules 4 and 6 of the design are built into runArgs() and checked again by assertSafeRunArgs() before any container starts:
3
+ // non root, read only root filesystem, no host mounts (the copy goes in at build time, nothing is mounted), no capabilities, no
4
+ // privilege escalation, memory, cpu, process and time limits, and no network while tests run. The build phase may reach the package
5
+ // registry (lockfile install, no install scripts). A Postgres store runs on an internal network that has no route out.
6
+ import { spawn } from 'node:child_process';
7
+ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { assertSandboxTarget } from './safety.js';
12
+ const MAX_OUT = 4 * 1024 * 1024;
13
+ export class CliRuntime {
14
+ bin;
15
+ constructor(bin = 'docker') { this.bin = bin; }
16
+ run(args, o = {}) {
17
+ return new Promise((resolve) => {
18
+ let out = '';
19
+ let err = '';
20
+ let timedOut = false;
21
+ let done = false;
22
+ const fin = (e) => { if (!done) {
23
+ done = true;
24
+ resolve(e);
25
+ } };
26
+ let child;
27
+ try {
28
+ child = spawn(this.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
29
+ }
30
+ catch (e) {
31
+ return fin({ code: null, stdout: '', stderr: e.message, timedOut: false });
32
+ }
33
+ const timer = o.timeoutMs ? setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, o.timeoutMs) : null;
34
+ child.stdout.on('data', (d) => { if (out.length < MAX_OUT)
35
+ out += d.toString('utf8'); });
36
+ child.stderr.on('data', (d) => { if (err.length < MAX_OUT)
37
+ err += d.toString('utf8'); });
38
+ child.on('error', (e) => { if (timer)
39
+ clearTimeout(timer); fin({ code: null, stdout: out, stderr: e.code === 'ENOENT' ? `${this.bin}: command not found` : e.message, timedOut }); });
40
+ child.on('close', (code) => { if (timer)
41
+ clearTimeout(timer); fin({ code, stdout: out, stderr: err, timedOut }); });
42
+ child.stdin.on('error', () => undefined);
43
+ child.stdin.end(o.input ?? '');
44
+ });
45
+ }
46
+ }
47
+ export const LABEL = 'hullproof.prove';
48
+ export const APP_USER = '10001:10001';
49
+ export const EXEC_DIR = '/app/.hullproof-exec';
50
+ const EXEC_FILES = ['spec.ts', 'executor.ts', 'exec-main.ts'];
51
+ // The step executor runs inside the container as TypeScript, so its sources are always read from src/prove (shipped with the package),
52
+ // also when the engine itself runs from dist/.
53
+ const HERE = fileURLToPath(new URL('../../src/prove/', import.meta.url));
54
+ const PG_IMAGE = 'postgres:16-alpine';
55
+ const PG_USER = '70:70';
56
+ const clip = (s, n = 600) => { const t = s.trim().replace(/\s+/g, ' '); return t.length <= n ? t : '…' + t.slice(t.length - n); };
57
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+ // ---- commands, as pure functions so they can be tested without a runtime --------------------------------------------------------
59
+ // A Python app runs in a Python image that also holds the node binary, because the step executor and the health probe are Node scripts
60
+ // that run inside the sandbox. The binary is copied from the same image the Node recipe uses, at build time, so nothing else is fetched.
61
+ export const NODE_IMAGE = 'node:22-slim';
62
+ export function dockerfile(r) {
63
+ const py = r.stack === 'python-flask';
64
+ return [
65
+ ...(py ? [`FROM ${NODE_IMAGE} AS node`] : []),
66
+ `FROM ${r.base_image}`,
67
+ ...(py ? ['COPY --from=node /usr/local/bin/node /usr/local/bin/node', 'ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1 HOME=/tmp'] : []),
68
+ 'WORKDIR /app', 'COPY . /app', ...(r.install ? [`RUN ${r.install.join(' ')}`] : []), `USER ${APP_USER}`, '',
69
+ ].join('\n');
70
+ }
71
+ export const buildArgs = (tag, runId, ctx) => ['build', '--label', `${LABEL}=${runId}`, '--tag', tag, '--file', join(ctx, 'Dockerfile.hullproof'), ctx];
72
+ export function runArgs(a) {
73
+ const args = [
74
+ 'run', '--detach', '--init', '--name', a.name, '--label', `${LABEL}=${a.runId}`,
75
+ '--network', a.network, '--read-only', '--user', a.user, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
76
+ '--pids-limit', String(a.limits.pids), '--memory', a.limits.memory, '--memory-swap', a.limits.memory, '--cpus', a.limits.cpus,
77
+ '--ulimit', 'nofile=1024:1024', '--ulimit', 'core=0',
78
+ ...a.tmpfs.flatMap((t) => ['--tmpfs', t]),
79
+ ...Object.entries(a.env).flatMap(([k, v]) => ['--env', `${k}=${v}`]),
80
+ a.image, ...a.cmd,
81
+ ];
82
+ assertSafeRunArgs(args.slice(0, args.length - 1 - a.cmd.length), a.network);
83
+ return args;
84
+ }
85
+ const FORBIDDEN = new Set(['--privileged', '-v', '--volume', '--volumes-from', '--mount', '-p', '--publish', '-P', '--publish-all', '--device', '--cap-add', '--pid', '--ipc', '--uts', '--userns', '--add-host', '--env-file', '--cgroupns', '--group-add', '--sysctl']);
86
+ // Refuses anything that would weaken the sandbox. `head` is the argument list up to, and not including, the image name. The builder
87
+ // calls this on its own output; tests call it on bad input.
88
+ export function assertSafeRunArgs(head, allowedNetwork) {
89
+ const bad = [];
90
+ const val = (flag) => { const i = head.indexOf(flag); return i >= 0 ? head[i + 1] : undefined; };
91
+ for (const a of head)
92
+ if (FORBIDDEN.has(a) || /^--(?:privileged|volume|mount|publish|device|cap-add|pid|ipc|uts|userns|add-host|env-file)=/.test(a))
93
+ bad.push(a);
94
+ if (head.some((a) => /unconfined/.test(a)))
95
+ bad.push('unconfined security option');
96
+ if (!head.includes('--read-only'))
97
+ bad.push('missing --read-only');
98
+ if (val('--cap-drop') !== 'ALL')
99
+ bad.push('capabilities are not all dropped');
100
+ if (!head.some((a, i) => a === '--security-opt' && head[i + 1] === 'no-new-privileges'))
101
+ bad.push('missing no-new-privileges');
102
+ for (const f of ['--pids-limit', '--memory', '--cpus'])
103
+ if (!val(f))
104
+ bad.push(`missing ${f}`);
105
+ const user = val('--user');
106
+ if (!user || /^(?:0|root)(?::|$)/.test(user))
107
+ bad.push('the container would run as root');
108
+ const net = val('--network');
109
+ if (net !== 'none' && net !== allowedNetwork)
110
+ bad.push(`network ${net ?? 'default'} is not none or the internal network`);
111
+ if (net !== 'none' && !/^hp-prove-net-/.test(net ?? ''))
112
+ bad.push('a network other than none must be a hullproof internal network');
113
+ if (bad.length)
114
+ throw new Error(`unsafe container arguments refused: ${bad.join(', ')}`);
115
+ }
116
+ const healthScript = (port, path) => `fetch('http://127.0.0.1:${port}${path}',{signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.status<500?0:2),()=>process.exit(1))`;
117
+ export class ContainerRunner {
118
+ name = 'container';
119
+ rt;
120
+ o;
121
+ constructor(rt, o = {}) { this.rt = rt; this.o = o; }
122
+ async available() {
123
+ const r = await this.rt.run(['info'], { timeoutMs: 20_000 });
124
+ if (r.code === 0)
125
+ return { ok: true };
126
+ const missing = /not found|ENOENT/.test(r.stderr);
127
+ return { ok: false, reason: missing ? `no container runtime: ${this.rt.bin} was not found. Install one, for example: brew install colima docker, then colima start.` : `the container runtime is not running (${clip(r.stderr, 160) || 'no answer'}). For Colima run: colima start.` };
128
+ }
129
+ async prepare(req) {
130
+ const runId = randomBytes(5).toString('hex');
131
+ const image = `hullproof-prove-${runId}`;
132
+ mkdirSync(join(req.copyDir, '.hullproof-exec'), { recursive: true });
133
+ for (const f of EXEC_FILES)
134
+ copyFileSync(join(HERE, f), join(req.copyDir, '.hullproof-exec', f));
135
+ writeFileSync(join(req.copyDir, 'Dockerfile.hullproof'), dockerfile(req.recipe));
136
+ const b = await this.rt.run(buildArgs(image, runId, req.copyDir), { timeoutMs: 600_000 });
137
+ if (b.code !== 0) {
138
+ await this.rt.run(['rmi', '-f', image]);
139
+ return { ok: false, code: 'build_failed', reason: `the image did not build${b.timedOut ? ' in time' : ''}: ${clip(b.stderr || b.stdout)}` };
140
+ }
141
+ return { ok: true, prepared: new ContainerPrepared(this.rt, runId, image, req, this.o.sleepMs ?? 500) };
142
+ }
143
+ }
144
+ class ContainerPrepared {
145
+ n = 0;
146
+ live = new Set();
147
+ rt;
148
+ runId;
149
+ image;
150
+ req;
151
+ pause;
152
+ constructor(rt, runId, image, req, pause) { this.rt = rt; this.runId = runId; this.image = image; this.req = req; this.pause = pause; }
153
+ async start() {
154
+ const { recipe, limits } = this.req;
155
+ const id = `${this.runId}-${++this.n}`;
156
+ const name = `hp-prove-${id}`;
157
+ const inst = new ContainerInstance(this.rt, name, recipe, limits);
158
+ this.live.add(inst);
159
+ const fail = async (code, reason) => { const logs = await this.rt.run(['logs', '--tail', '15', name]); await inst.stop(); this.live.delete(inst); return { ok: false, code, reason: `${reason}${logs.stdout + logs.stderr ? `. Last output: ${clip(logs.stdout + ' ' + logs.stderr, 400)}` : ''}` }; };
160
+ const data = (s) => s.replace(/\{DATA\}/g, '/data');
161
+ let network = 'none';
162
+ const env = {};
163
+ const tmpfs = ['/tmp:rw,noexec,nosuid,nodev,size=64m'];
164
+ if (recipe.data_store.kind === 'sqlite')
165
+ tmpfs.push(`/data:rw,noexec,nosuid,nodev,size=64m,uid=10001,gid=10001`);
166
+ if (recipe.data_store.kind === 'postgres') {
167
+ network = `hp-prove-net-${id}`;
168
+ const pg = `hp-prove-pg-${id}`;
169
+ const net = await this.rt.run(['network', 'create', '--internal', '--label', `${LABEL}=${this.runId}`, network]);
170
+ if (net.code !== 0) {
171
+ this.live.delete(inst);
172
+ return { ok: false, code: 'start_failed', reason: `the internal network could not be created: ${clip(net.stderr)}` };
173
+ }
174
+ inst.network = network;
175
+ inst.extra.push(pg);
176
+ const pgRun = await this.rt.run(runArgs({ name: pg, runId: this.runId, image: PG_IMAGE, network, user: PG_USER, tmpfs: ['/var/lib/postgresql/data:rw,nosuid,nodev,size=128m,uid=70,gid=70', '/var/run/postgresql:rw,nosuid,nodev,size=8m,uid=70,gid=70', '/tmp:rw,nosuid,nodev,size=32m'], env: { POSTGRES_PASSWORD: 'dummy', POSTGRES_DB: 'app' }, limits, cmd: [] }));
177
+ if (pgRun.code !== 0)
178
+ return fail('start_failed', `Postgres did not start: ${clip(pgRun.stderr)}`);
179
+ let up = false;
180
+ for (let t = Date.now(); Date.now() - t < limits.healthMs && !up; await this.sleep())
181
+ up = (await this.rt.run(['exec', pg, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-d', 'app'], { timeoutMs: 5000 })).code === 0;
182
+ if (!up)
183
+ return fail('health_failed', 'Postgres did not become ready in time');
184
+ for (const f of [...recipe.data_store.migrations, ...(recipe.data_store.seed_sql ? [recipe.data_store.seed_sql] : [])]) {
185
+ const sql = readFileSync(join(this.req.sourceDir, f), 'utf8');
186
+ const r = await this.rt.run(['exec', '-i', pg, 'psql', '-v', 'ON_ERROR_STOP=1', '-q', '-U', 'postgres', '-d', 'app'], { input: sql, timeoutMs: 60_000 });
187
+ if (r.code !== 0)
188
+ return fail('start_failed', `${f} did not apply: ${clip(r.stderr, 300)}`);
189
+ }
190
+ env.__PGURL = `postgres://postgres:dummy@${pg}:5432/app`;
191
+ }
192
+ for (const [k, v] of Object.entries(recipe.env))
193
+ env[k] = data(v).replace(/\{PGURL\}/g, env.__PGURL ?? '');
194
+ delete env.__PGURL;
195
+ const script = `${recipe.seed ? recipe.seed + ' && ' : ''}exec ${recipe.start}`;
196
+ const run = await this.rt.run(runArgs({ name, runId: this.runId, image: this.image, network, user: APP_USER, tmpfs, env, limits, cmd: ['sh', '-c', script] }));
197
+ if (run.code !== 0)
198
+ return fail('start_failed', `the container did not start: ${clip(run.stderr)}`);
199
+ for (let t = Date.now(); Date.now() - t < limits.healthMs; await this.sleep()) {
200
+ const h = await this.rt.run(['exec', name, 'node', '-e', healthScript(recipe.port, recipe.health_path)], { timeoutMs: 6000 });
201
+ if (h.code === 0) {
202
+ inst.arm();
203
+ return { ok: true, instance: inst };
204
+ }
205
+ const st = await this.rt.run(['inspect', '--format', '{{.State.Running}}', name]);
206
+ if (st.stdout.trim() === 'false')
207
+ return fail('start_failed', 'the app stopped before it answered (the seed step or the start command failed)');
208
+ }
209
+ return fail('health_failed', `the app did not answer on ${recipe.health_path} within ${Math.round(limits.healthMs / 1000)} seconds`);
210
+ }
211
+ sleep() { return sleep(this.pause); }
212
+ async dispose() {
213
+ for (const i of this.live)
214
+ await i.stop();
215
+ await this.rt.run(['rmi', '-f', this.image]);
216
+ const left = [];
217
+ for (const [what, args] of [['container', ['ps', '-a', '-q']], ['image', ['images', '-q']], ['network', ['network', 'ls', '-q']], ['volume', ['volume', 'ls', '-q']]]) {
218
+ const r = await this.rt.run([...args, '--filter', `label=${LABEL}=${this.runId}`]);
219
+ for (const l of r.stdout.split('\n').map((x) => x.trim()).filter(Boolean))
220
+ left.push(`${what} ${l}`);
221
+ }
222
+ return { left_behind: left };
223
+ }
224
+ }
225
+ class ContainerInstance {
226
+ baseUrl;
227
+ network = null;
228
+ extra = [];
229
+ stopped = false;
230
+ timer = null;
231
+ rt;
232
+ name;
233
+ recipe;
234
+ limits;
235
+ constructor(rt, name, recipe, limits) {
236
+ this.rt = rt;
237
+ this.name = name;
238
+ this.recipe = recipe;
239
+ this.limits = limits;
240
+ this.baseUrl = `http://127.0.0.1:${recipe.port}`;
241
+ }
242
+ arm() { this.timer = setTimeout(() => { void this.stop(); }, this.limits.instanceMs); this.timer.unref(); }
243
+ async exec(cmd, o = {}) {
244
+ if (this.stopped)
245
+ return { code: null, stdout: '', stderr: 'the instance time limit was reached or it was stopped', timedOut: false };
246
+ const ms = o.timeoutMs ?? this.limits.execMs;
247
+ // `timeout -s KILL` inside the container ends the process there. Killing the docker client alone would leave it running.
248
+ return this.rt.run(['exec', '-i', this.name, 'timeout', '-s', 'KILL', String(Math.max(1, Math.ceil(ms / 1000))), ...cmd], { input: o.input, timeoutMs: ms + 10_000 });
249
+ }
250
+ async runSpec(spec) {
251
+ assertSandboxTarget(this.baseUrl, this.recipe.port);
252
+ const r = await this.exec(['node', '--experimental-strip-types', `${EXEC_DIR}/exec-main.ts`, this.baseUrl], { input: JSON.stringify(spec) });
253
+ if (r.timedOut || r.code === 137)
254
+ return { ok: false, reason: 'the step executor ran past the time limit and was killed' };
255
+ const line = r.stdout.trim().split('\n').pop() ?? '';
256
+ try {
257
+ const j = JSON.parse(line);
258
+ if (!j.ok)
259
+ return { ok: false, reason: j.error ?? 'the step executor failed' };
260
+ return { ok: true, outcome: j.outcome, detail: j.detail ?? '', steps: j.steps ?? [] };
261
+ }
262
+ catch {
263
+ return { ok: false, reason: `the step executor gave no result (${clip(r.stderr, 200) || 'no output'})` };
264
+ }
265
+ }
266
+ async stop() {
267
+ if (this.timer)
268
+ clearTimeout(this.timer);
269
+ this.stopped = true;
270
+ for (const c of [this.name, ...this.extra])
271
+ await this.rt.run(['rm', '-f', '--volumes', c]);
272
+ if (this.network)
273
+ await this.rt.run(['network', 'rm', this.network]);
274
+ return { left_behind: [] };
275
+ }
276
+ }
@@ -0,0 +1,20 @@
1
+ // Runs inside the sandbox: `node --experimental-strip-types /hp/exec-main.ts http://127.0.0.1:PORT` with a spec as JSON on standard
2
+ // input. Prints one JSON line: the outcome, the detail and the redacted step records. Refuses any address but the sandbox's own loopback.
3
+ import { executeSpec } from './executor.js';
4
+ import { validateSpec } from './spec.js';
5
+ const chunks = [];
6
+ for await (const c of process.stdin)
7
+ chunks.push(c);
8
+ try {
9
+ const v = validateSpec(JSON.parse(Buffer.concat(chunks).toString('utf8')));
10
+ if (!v.ok) {
11
+ process.stdout.write(JSON.stringify({ ok: false, error: `invalid spec: ${v.errors.join('; ')}` }) + '\n');
12
+ process.exit(0);
13
+ }
14
+ const r = await executeSpec(v.spec, { baseUrl: process.argv[2] ?? '' });
15
+ process.stdout.write(JSON.stringify({ ok: true, ...r }) + '\n');
16
+ }
17
+ catch (e) {
18
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message.slice(0, 300) }) + '\n');
19
+ }
20
+ process.exit(0);