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,46 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Hullproof hunt output: what a model returns for one entry point",
4
+ "description": "Written to work as a strict structured output schema (every property required, no extra properties, no length keywords). The engine checks the limits itself in src/confirm/lead.ts.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["leads"],
8
+ "properties": {
9
+ "leads": {
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "additionalProperties": false,
14
+ "required": ["lead_id", "entry_point", "requirement_ids", "claim", "path", "quoted_code", "evidence_proposal"],
15
+ "properties": {
16
+ "lead_id": { "type": "string" },
17
+ "entry_point": { "type": "string" },
18
+ "requirement_ids": { "type": "array", "items": { "type": "string" } },
19
+ "claim": { "type": "string" },
20
+ "path": {
21
+ "type": "array",
22
+ "items": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["file", "line", "note"],
26
+ "properties": { "file": { "type": "string" }, "line": { "type": "integer" }, "note": { "type": "string" } }
27
+ }
28
+ },
29
+ "quoted_code": { "type": "string" },
30
+ "evidence_proposal": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "required": ["kind", "query_json", "rule_yaml", "control_code", "control_extension"],
34
+ "properties": {
35
+ "kind": { "type": "string", "enum": ["map_query", "semgrep_rule", "generated_test", "none"] },
36
+ "query_json": { "type": ["string", "null"] },
37
+ "rule_yaml": { "type": ["string", "null"] },
38
+ "control_code": { "type": ["string", "null"] },
39
+ "control_extension": { "type": ["string", "null"] }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "hunt/result.json: what the engine confirmed from the saved hunt answers",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["schema_version", "engine_version", "commit", "runs", "entries_hunted", "calls", "incomplete", "malformed", "hallucinations", "confirmed", "leads", "usage"],
7
+ "properties": {
8
+ "schema_version": { "const": "1.0.0" },
9
+ "engine_version": { "type": "string" },
10
+ "commit": { "type": ["string", "null"] },
11
+ "runs": { "type": "integer", "minimum": 0 },
12
+ "entries_hunted": { "type": "integer", "minimum": 0 },
13
+ "calls": { "type": "object", "additionalProperties": false, "required": ["complete", "incomplete", "missing"], "properties": { "complete": { "type": "integer" }, "incomplete": { "type": "integer" }, "missing": { "type": "integer" } } },
14
+ "incomplete": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["entry_id", "run", "stop", "detail"], "properties": { "entry_id": { "type": "string" }, "run": { "type": "integer" }, "stop": { "type": "string" }, "detail": { "type": "string" } } } },
15
+ "malformed": { "$ref": "#/$defs/dropped" },
16
+ "hallucinations": { "$ref": "#/$defs/dropped" },
17
+ "confirmed": { "type": "array", "items": { "$ref": "#/$defs/lead" } },
18
+ "leads": { "type": "array", "items": { "$ref": "#/$defs/lead" } },
19
+ "usage": { "type": "object", "additionalProperties": false, "required": ["calls", "input_tokens", "output_tokens"], "properties": { "calls": { "type": "integer" }, "input_tokens": { "type": "integer" }, "output_tokens": { "type": "integer" } } }
20
+ },
21
+ "$defs": {
22
+ "dropped": {
23
+ "type": "object", "additionalProperties": false, "required": ["count", "items"],
24
+ "properties": {
25
+ "count": { "type": "integer", "minimum": 0 },
26
+ "items": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["run", "entry_id", "lead_id", "reason"], "properties": { "run": { "type": "integer" }, "entry_id": { "type": "string" }, "lead_id": { "type": "string" }, "reason": { "type": "string" } } } }
27
+ }
28
+ },
29
+ "lead": {
30
+ "type": "object", "additionalProperties": false,
31
+ "required": ["key", "entry_point", "requirement_ids", "claim", "path", "sink", "runs_raised", "runs", "confirmation"],
32
+ "properties": {
33
+ "key": { "type": "string" },
34
+ "entry_point": { "type": "string" },
35
+ "requirement_ids": { "type": "array", "items": { "type": "string", "pattern": "^SEC-[A-Z]+-[0-9]{3}$" } },
36
+ "claim": { "type": "string" },
37
+ "path": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["file", "line", "note"], "properties": { "file": { "type": "string" }, "line": { "type": "integer", "minimum": 1 }, "note": { "type": "string" } } } },
38
+ "sink": { "type": "object", "additionalProperties": false, "required": ["file", "line"], "properties": { "file": { "type": "string" }, "line": { "type": "integer", "minimum": 1 } } },
39
+ "runs_raised": { "type": "integer", "minimum": 1 },
40
+ "runs": { "type": "array", "items": { "type": "integer" } },
41
+ "confirmation": {
42
+ "type": "object",
43
+ "required": ["status", "method", "detail"],
44
+ "properties": {
45
+ "status": { "enum": ["confirmed", "not_confirmed", "not_run"] },
46
+ "method": { "enum": ["map_query", "semgrep_rule", "generated_test", "none"] },
47
+ "detail": { "type": "string" },
48
+ "run": { "type": "integer" },
49
+ "tool": { "type": "string" },
50
+ "rule_id": { "type": "string" },
51
+ "tool_version": { "type": "string" },
52
+ "config_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hullproof.invalid/engine/results.schema.json",
4
+ "title": "Hullproof Engine per requirement results (results.json)",
5
+ "description": "The JSON form the kit gate (hullproof-gate.mjs, loadResults) already reads: an array of {id, result, notes}, sorted by id, one row per requirement ID.",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": false,
10
+ "required": ["id", "result", "notes"],
11
+ "properties": {
12
+ "id": {"type": "string", "pattern": "^SEC-[A-Z]+-[0-9]{3}$"},
13
+ "result": {"type": "string", "pattern": "^(FAIL|PASS \\(static\\)|NOT APPLICABLE|NOT ASSESSED: (NEEDS DASHBOARD|NEEDS BUILD|NEEDS DYNAMIC TEST|ASK OWNER|ATTESTATION|UNKNOWN))$"},
14
+ "notes": {"type": "string", "maxLength": 600, "description": "NOT APPLICABLE needs a reason here. Plain text, no secret values."}
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hullproof.invalid/engine/scan-hits.schema.json",
4
+ "title": "Hullproof Engine normalised scanner hits (scan/<tool>.json)",
5
+ "description": "One file per scanner. Hits are sorted by file, line, tool, rule_id. No secret value, no tool message text, no absolute path, no timing.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema_version", "tool", "hits"],
9
+ "properties": {
10
+ "schema_version": {"const": "1.0.0"},
11
+ "tool": {"$ref": "#/$defs/tool"},
12
+ "hits": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object", "additionalProperties": false,
16
+ "required": ["tool", "rule_id", "file", "line", "sec_ids", "severity_hint", "message_key"],
17
+ "properties": {
18
+ "tool": {"$ref": "#/$defs/tool"},
19
+ "rule_id": {"type": "string", "minLength": 1, "maxLength": 200},
20
+ "file": {"type": "string", "minLength": 1, "maxLength": 300, "pattern": "^[^/\\\\]"},
21
+ "line": {"type": "integer", "minimum": 0, "description": "1 based; 0 only when the tool reports no line"},
22
+ "sec_ids": {"type": "array", "items": {"type": "string", "pattern": "^SEC-[A-Z]+-[0-9]{3}$"}, "description": "empty means unmapped: shown in the report, never in the gate"},
23
+ "severity_hint": {"type": ["string", "null"], "maxLength": 40},
24
+ "message_key": {"type": "string", "minLength": 1, "maxLength": 240}
25
+ }
26
+ }
27
+ }
28
+ },
29
+ "$defs": {
30
+ "tool": {"enum": ["semgrep", "osv-scanner", "gitleaks", "trivy", "checkov"]}
31
+ }
32
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hullproof.invalid/engine/scan-meta.schema.json",
4
+ "title": "Hullproof Engine scanner reproducibility record (scan/<tool>.meta.json)",
5
+ "description": "What ran, with which version and config, on which commit. Written for every adapter, including skipped ones.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema_version", "tool", "status", "version", "tested_version_range", "version_tested", "argv", "config_sha256", "commit", "files_scanned", "files_tracked", "exit_code", "db_date", "not_assessed_sec_ids"],
9
+ "properties": {
10
+ "schema_version": {"const": "1.0.0"},
11
+ "tool": {"enum": ["semgrep", "osv-scanner", "gitleaks", "trivy", "checkov"]},
12
+ "status": {"enum": ["ran", "skipped: not installed", "skipped: not applicable", "skipped: network not allowed", "failed"]},
13
+ "version": {"type": ["string", "null"]},
14
+ "tested_version_range": {"type": "string", "minLength": 1},
15
+ "version_tested": {"type": "boolean"},
16
+ "argv": {"type": "array", "items": {"type": "string"}},
17
+ "config_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
18
+ "commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"},
19
+ "files_scanned": {"type": ["integer", "null"], "minimum": 0},
20
+ "files_tracked": {"type": ["integer", "null"], "minimum": 0},
21
+ "exit_code": {"type": ["integer", "null"]},
22
+ "db_date": {"type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
23
+ "not_assessed_sec_ids": {"type": "array", "items": {"type": "string", "pattern": "^SEC-[A-Z]+-[0-9]{3}$"}}
24
+ }
25
+ }
@@ -0,0 +1,93 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hullproof.invalid/engine/stack.schema.json",
4
+ "title": "Hullproof Engine stack.json",
5
+ "description": "Written by hullproof discover. Arrays of items are sorted by id; evidence by confidence, then file, then line; skipped by path; unknown by file then note. No timestamp, so the same files and detector files give the same bytes.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema_version", "engine_version", "detectors_sha256", "commit", "confirmed_by_user", "files", "limits", "skipped",
9
+ "languages", "runtimes", "frameworks", "package_managers", "data_stores", "auth", "payments", "ai", "hosting", "iac", "containers", "ci", "mobile", "unknown"],
10
+ "properties": {
11
+ "schema_version": {"const": "1.0.0"},
12
+ "engine_version": {"type": "string", "minLength": 1},
13
+ "detectors_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
14
+ "commit": {"type": ["string", "null"], "pattern": "^[0-9a-f]{40}$"},
15
+ "confirmed_by_user": {"type": "boolean"},
16
+ "files": {
17
+ "type": "object", "additionalProperties": false, "required": ["listed", "scanned_for_imports"],
18
+ "properties": {"listed": {"type": "integer", "minimum": 0}, "scanned_for_imports": {"type": "integer", "minimum": 0}}
19
+ },
20
+ "limits": {
21
+ "type": "object", "additionalProperties": false, "required": ["max_files", "max_bytes_per_file", "file_cap_reached", "truncated_files"],
22
+ "properties": {
23
+ "max_files": {"type": "integer", "minimum": 1},
24
+ "max_bytes_per_file": {"type": "integer", "minimum": 1},
25
+ "file_cap_reached": {"type": "boolean"},
26
+ "truncated_files": {"type": "array", "items": {"$ref": "#/$defs/path"}}
27
+ }
28
+ },
29
+ "skipped": {
30
+ "type": "array",
31
+ "items": {
32
+ "type": "object", "additionalProperties": false, "required": ["path", "reason"],
33
+ "properties": {
34
+ "path": {"type": "string", "minLength": 1},
35
+ "reason": {"enum": ["gitignore", "dependency folder", "build output", "tool folder", "symlink"]}
36
+ }
37
+ }
38
+ },
39
+ "languages": {"$ref": "#/$defs/items"},
40
+ "runtimes": {"$ref": "#/$defs/items"},
41
+ "frameworks": {"$ref": "#/$defs/items"},
42
+ "package_managers": {"$ref": "#/$defs/items"},
43
+ "data_stores": {"$ref": "#/$defs/items"},
44
+ "auth": {"$ref": "#/$defs/items"},
45
+ "payments": {"$ref": "#/$defs/items"},
46
+ "ai": {"$ref": "#/$defs/items"},
47
+ "hosting": {"$ref": "#/$defs/items"},
48
+ "iac": {"$ref": "#/$defs/items"},
49
+ "containers": {"$ref": "#/$defs/items"},
50
+ "ci": {"$ref": "#/$defs/items"},
51
+ "mobile": {"$ref": "#/$defs/items"},
52
+ "unknown": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "object", "additionalProperties": false, "required": ["file", "note"],
56
+ "properties": {"file": {"$ref": "#/$defs/path"}, "note": {"type": "string", "minLength": 1}}
57
+ }
58
+ }
59
+ },
60
+ "$defs": {
61
+ "path": {"type": "string", "minLength": 1, "pattern": "^[^/\\\\]", "description": "repository relative POSIX path"},
62
+ "confidence": {"enum": ["high", "medium", "low"], "description": "high: a manifest dependency or a file whose only purpose is this thing. medium: a source import, an SDK dependency, or a content pattern. low: a weak hint."},
63
+ "evidence": {
64
+ "type": "object", "additionalProperties": false, "required": ["file", "rule", "confidence"],
65
+ "description": "Exactly one of line or key is present.",
66
+ "properties": {
67
+ "file": {"$ref": "#/$defs/path"},
68
+ "line": {"type": "integer", "minimum": 1},
69
+ "key": {"type": "string", "minLength": 1},
70
+ "rule": {"type": "string", "pattern": "^[a-z_]+\\.[a-z0-9.-]+\\.(dep|import|content|file|extension)(\\.[a-z0-9]+)?$"},
71
+ "confidence": {"$ref": "#/$defs/confidence"}
72
+ }
73
+ },
74
+ "items": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "object", "additionalProperties": false, "required": ["id", "confidence", "evidence", "evidence_count"],
78
+ "properties": {
79
+ "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9.-]*$"},
80
+ "confidence": {"$ref": "#/$defs/confidence"},
81
+ "evidence": {"type": "array", "minItems": 1, "maxItems": 5, "items": {"$ref": "#/$defs/evidence"}},
82
+ "evidence_count": {"type": "integer", "minimum": 1},
83
+ "versions": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "package@declared version or range, as written in the manifest"},
84
+ "file_count": {"type": "integer", "minimum": 1},
85
+ "kind": {"enum": ["platform", "cloud"]},
86
+ "clouds": {"type": "array", "items": {"type": "string", "minLength": 1}},
87
+ "lockfiles": {"type": "array", "items": {"$ref": "#/$defs/path"}},
88
+ "manifests": {"type": "array", "items": {"$ref": "#/$defs/path"}}
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,32 @@
1
+ # Executable checks
2
+
3
+ A check settles part of one or more requirements from the files of a commit. It returns PASS (static), FAIL or a NOT ASSESSED sub state through its `on_true` and `on_false` words, or it does not run and says why in `checks.json` under `not_run` (`not applicable: ...` or `cannot run: ...`). No check runs a program, uses the network or a model.
4
+
5
+ Rules every check follows:
6
+
7
+ 1. `PASS (static)` only when every requirement it names has repo authority. A check may FAIL a dashboard or runtime requirement for the part a file settles and never PASS it (STANDARD.md, "A file can fail what it cannot pass"). `test/checks.test.ts` enforces this against the kit's controls file.
8
+ 2. A check that does not apply never becomes NOT APPLICABLE for its requirement: the requirement stays NOT ASSESSED.
9
+ 3. Every check has `test/fixtures/checks/<name>/pass` and `.../fail`.
10
+ 4. Checks never search `.hullproof/`, the kit's own folders or `docs/security/` (`LEFT_OUT` in `context.ts`).
11
+
12
+ Two forms:
13
+
14
+ 1. Declarative: `checks/<area>.<name>.json`, shaped by `schemas/check.schema.json`, run by `ops.ts`.
15
+ 2. TypeScript, for what no op can say: `src/checks/code/*.ts`, each exporting a `CodeCheck` (`types.ts`) and listed in `CODE_CHECKS` in `run.ts`.
16
+
17
+ ## Op parameters
18
+
19
+ | Op | Params | True when |
20
+ |---|---|---|
21
+ | `file_exists` | `files` (globs), `exclude`, `note` | a listed file matches |
22
+ | `file_absent` | same | no listed file matches; the matches are the evidence |
23
+ | `file_exists_for_each` | `manifests`: manifest name to lockfile names, `note` | every manifest has a lockfile in its folder or a parent |
24
+ | `regex_present` | `files`, `exclude`, `only_if` (a file is searched only if it matches), `pattern`, `ignore_case`, `each_file` (every file must match), `when_no_files` (`not_applicable`, `true`, `false`), `precondition` {`files`, `exclude`, `pattern`} (no file meets it: not applicable; when nothing is found, its files are the evidence), `note`, `missing_note`, `precondition_note`, `not_applicable_reason` | the pattern is found (in every file with `each_file`) |
25
+ | `regex_absent` | the same | the pattern is found nowhere; the matches are the evidence |
26
+ | `sql_every_table_has` | `files`, `schemas` (default `public`) | every table created in those schemas has row level security enabled later in file order |
27
+ | `workflow_uses_pinned_sha` | `files` (default GitHub workflows) | every third party `uses:` ends in a 40 character SHA |
28
+ | `workflow_permissions` | `files` | every workflow declares permissions at the top or on every job, and none says `write-all` |
29
+ | `version_in_table` | `table` (kit relative path), `frameworks`: stack id to {`heading`, `package`, `digits`} | every declared version of a listed framework is on a line the kit's support table marks Supported |
30
+ | `json_value` | | not built; no check needs it yet |
31
+
32
+ Regex ops record an `absence_search` (pattern, file globs, files searched, matches) on every result.
@@ -0,0 +1,49 @@
1
+ // check.authz.admin-role-check: every server handler under an admin path checks an admin role on the server.
2
+ // A session check alone (any signed in user) is not a role check. Middleware or a shared guard that checks the role for
3
+ // the whole admin path is not followed in Phase 1; such a project sees this FAIL and the report says how to confirm it.
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { CheckEvidence } from '../../types/index.ts';
6
+ import { byFileLine, lineOf, type CheckContext } from '../context.ts';
7
+ import type { OpOutcome } from '../ops.ts';
8
+ import type { CodeCheck } from '../types.ts';
9
+ import { handlerFiles } from './shared.ts';
10
+
11
+ const ADMIN_PATH = /(^|\/)(admin|backoffice|staff|superuser)(\/|\.|$)/i;
12
+ const ROLE_CHECK = new RegExp([
13
+ String.raw`\brole\s*(?:===?|!==?|==)\s*["'\x60](?:admin|owner|superadmin|staff)`,
14
+ String.raw`["'\x60](?:admin|owner|superadmin|staff)["'\x60]\s*(?:===?|!==?)\s*[\w.?]*role`,
15
+ String.raw`\b(?:is_?admin|isAdmin|requireAdmin|require_admin|assertAdmin|ensureAdmin|adminOnly|admin_required|requireRole|require_role|checkRole|withRole|hasRole|has_role|hasPermission|has_perm|is_staff|is_superuser|authorize)\b`,
16
+ String.raw`@(?:PreAuthorize|Secured|RolesAllowed|Roles|staff_member_required|user_passes_test|permission_required)`,
17
+ String.raw`\bcurrent_user\.admin\?|\buser\.admin\b|roles?\.includes\(\s*["']admin`,
18
+ ].join('|'));
19
+ const HANDLER = /export\s+(?:async\s+)?(?:function\s+|const\s+)(GET|POST|PUT|PATCH|DELETE|default|handler)\b|@(?:app|router|api_view|\w+)\.(?:get|post|put|patch|delete|route)\(|\bdef\s+(?:get|post|put|patch|delete|index|show|create|update|destroy)\b|\b(?:router|app)\.(?:get|post|put|patch|delete)\(/g;
20
+
21
+ export function run(ctx: CheckContext): OpOutcome {
22
+ const files = handlerFiles(ctx).filter((f) => ADMIN_PATH.test(f));
23
+ if (files.length === 0) return { value: 'not_applicable', reason: 'no server handler under an admin path', evidence: [], absence: null };
24
+ const bad: CheckEvidence[] = [];
25
+ for (const file of files) {
26
+ const text = ctx.text(file);
27
+ if (ROLE_CHECK.test(text)) continue;
28
+ const handlers = [...text.matchAll(HANDLER)];
29
+ const lines = handlers.length ? handlers.map((h) => lineOf(text, h.index!)) : [1];
30
+ for (const line of lines) bad.push({ file, line, note: 'admin handler with no server side admin role check' });
31
+ }
32
+ return {
33
+ value: bad.length === 0,
34
+ evidence: bad.sort(byFileLine),
35
+ absence: { pattern: ROLE_CHECK.source, files_searched: files.length, file_globs: ['handler files under an admin path'], matches: files.length - new Set(bad.map((b) => b.file)).size },
36
+ };
37
+ }
38
+
39
+ export const check: CodeCheck = {
40
+ id: 'check.authz.admin-role-check',
41
+ sec_ids: ['SEC-AUTHZ-020'],
42
+ applies_when: {},
43
+ op: 'code',
44
+ on_true: 'NOT ASSESSED: NEEDS DYNAMIC TEST',
45
+ on_false: 'FAIL',
46
+ fixtures: { pass: 'test/fixtures/checks/admin-role-check/pass', fail: 'test/fixtures/checks/admin-role-check/fail' },
47
+ source: fileURLToPath(import.meta.url),
48
+ run,
49
+ };
@@ -0,0 +1,95 @@
1
+ // Two checks from one detector: a record read by an id that came from the request (or from a model's tool arguments)
2
+ // must also be filtered by its owner. check.authz.owner-filter-on-id-read covers request handlers (SEC-AUTHZ-003);
3
+ // check.ai.tool-owner-filter covers functions handed to a model as tools (SEC-AI-021).
4
+ // Detected read forms: SQL text (any driver), Prisma and Drizzle style where objects, Supabase .eq('id'), Mongoose
5
+ // findById, Django .get(pk=) and get_object_or_404, Rails .find(params[:id]), Laravel ::find / findOrFail.
6
+ // It reads code, so it can FAIL those runtime requirements for the part a file settles and never PASS them.
7
+ import { fileURLToPath } from 'node:url';
8
+ import type { CheckEvidence } from '../../types/index.ts';
9
+ import { byFileLine, lineOf, type CheckContext } from '../context.ts';
10
+ import type { OpOutcome } from '../ops.ts';
11
+ import type { CodeCheck } from '../types.ts';
12
+ import { handlerFiles, NOT_HANDLER } from './shared.ts';
13
+
14
+ const READS: { re: RegExp; what: string }[] = [
15
+ { re: /\bSELECT\b[^;"'`]*?\bWHERE\s+(?:\w+\.)?(?:id|pk|uuid)\s*=\s*(?:\?|\$\d+|:\w+|%s|@\w+|\$\{)/gi, what: 'SQL read by id' },
16
+ { re: /\.(?:findUnique|findFirst|findOne|findOneBy|findOneOrFail)\(\s*\{\s*where:\s*\{\s*id\s*[:,}]/g, what: 'ORM read by id' },
17
+ { re: /\.(?:findById|findByPk|findOneById)\(/g, what: 'ORM read by id' },
18
+ { re: /\.eq\(\s*["'](?:id|uuid)["']\s*,/g, what: 'Supabase read by id' },
19
+ { re: /\.objects\.get\(\s*(?:id|pk)\s*=|get_object_or_404\(\s*\w+\s*,\s*(?:id|pk)\s*=/g, what: 'Django read by id' },
20
+ { re: /\.find\(\s*params\[:id\]\s*\)|::(?:find|findOrFail)\(\s*\$(?:id|request)/g, what: 'Rails or Laravel read by id' },
21
+ ];
22
+ // Words that show the read is scoped to the caller.
23
+ const OWNER = /owner|user_?id|userId|tenant|account_?id|accountId|org_?id|orgId|organization|workspace|created_by|createdBy|author|auth\.uid|current_?user|request\.user|session\.user|\.user\.id|ctx\.user|scope|policy/i;
24
+ // Words that show the id came from the request or from model arguments.
25
+ const INPUT = /params|args|arguments|req\.|request\.|query|body|input|searchParams|\$_(GET|POST|REQUEST)|kwargs/;
26
+ const WRITE = /\b(UPDATE|DELETE|INSERT)\b/i;
27
+
28
+ // The statement around a match: from the match to the first semicolon or blank line, at most 6 lines.
29
+ function statement(text: string, at: number): string {
30
+ const lines = text.slice(at).split('\n').slice(0, 6);
31
+ const out: string[] = [];
32
+ for (const l of lines) { if (out.length && l.trim() === '') break; out.push(l); if (/;\s*$/.test(l)) break; }
33
+ return out.join('\n');
34
+ }
35
+
36
+ function scan(files: string[], ctx: CheckContext): CheckEvidence[] {
37
+ const bad: CheckEvidence[] = [];
38
+ for (const file of files) {
39
+ const text = ctx.text(file);
40
+ for (const { re, what } of READS) {
41
+ re.lastIndex = 0;
42
+ for (let m = re.exec(text); m; m = re.exec(text)) {
43
+ // For SQL, judge only the WHERE clause onward: an owner column in the select list filters nothing.
44
+ const where = m[0].search(/\bWHERE\b/i);
45
+ const stmt = statement(text, m.index + Math.max(0, where));
46
+ if (WRITE.test(m[0]) || OWNER.test(stmt) || !INPUT.test(stmt)) continue;
47
+ bad.push({ file, line: lineOf(text, m.index), note: `${what} with an id from the request and no owner filter` });
48
+ }
49
+ }
50
+ }
51
+ return bad.sort(byFileLine);
52
+ }
53
+
54
+ const AI_TOOL_GLOBS = ['**/{ai,llm,agent,agents,mcp,tools}/**/*.{ts,js,mjs,py}', '**/*tool*.{ts,js,mjs,py}'];
55
+ const aiToolFiles = (ctx: CheckContext): string[] =>
56
+ ctx.select(AI_TOOL_GLOBS, NOT_HANDLER).filter((f) => /\btools?\b|tool_call|function_call|bind_tools|@tool\b/.test(ctx.text(f)));
57
+
58
+ function outcome(files: string[], ctx: CheckContext, none: string): OpOutcome {
59
+ if (files.length === 0) return { value: 'not_applicable', reason: none, evidence: [], absence: null };
60
+ const bad = scan(files, ctx);
61
+ return {
62
+ value: bad.length === 0,
63
+ evidence: bad,
64
+ absence: { pattern: 'reads by id (SQL, ORM, Supabase, Django, Rails, Laravel) with no owner, user or tenant term in the statement', files_searched: files.length, file_globs: [], matches: bad.length },
65
+ };
66
+ }
67
+
68
+ const source = fileURLToPath(import.meta.url);
69
+
70
+ export const ownerFilter: CodeCheck = {
71
+ id: 'check.authz.owner-filter-on-id-read',
72
+ sec_ids: ['SEC-AUTHZ-003'],
73
+ applies_when: {},
74
+ op: 'code',
75
+ on_true: 'NOT ASSESSED: NEEDS DYNAMIC TEST',
76
+ on_false: 'FAIL',
77
+ fixtures: { pass: 'test/fixtures/checks/owner-filter-on-id-read/pass', fail: 'test/fixtures/checks/owner-filter-on-id-read/fail' },
78
+ source,
79
+ run: (ctx) => {
80
+ const ai = new Set(aiToolFiles(ctx));
81
+ return outcome(handlerFiles(ctx).filter((f) => !ai.has(f)), ctx, 'no request handler found');
82
+ },
83
+ };
84
+
85
+ export const toolOwnerFilter: CodeCheck = {
86
+ id: 'check.ai.tool-owner-filter',
87
+ sec_ids: ['SEC-AI-021'],
88
+ applies_when: {},
89
+ op: 'code',
90
+ on_true: 'NOT ASSESSED: NEEDS DYNAMIC TEST',
91
+ on_false: 'FAIL',
92
+ fixtures: { pass: 'test/fixtures/checks/tool-owner-filter/pass', fail: 'test/fixtures/checks/tool-owner-filter/fail' },
93
+ source,
94
+ run: (ctx) => outcome(aiToolFiles(ctx), ctx, 'no file that defines tools for a model was found'),
95
+ };
@@ -0,0 +1,66 @@
1
+ // Shared helpers for the TypeScript checks: which files are server request handlers, and small text scanners.
2
+ import type { CheckContext } from '../context.ts';
3
+
4
+ // Server side request handlers across common stacks. Stack specific globs are marked by the framework they serve.
5
+ export const HANDLER_GLOBS = [
6
+ '**/app/**/route.{ts,js,mjs,tsx,jsx}', // Next.js App Router
7
+ '**/pages/api/**/*.{ts,js,mjs}', // Next.js Pages Router
8
+ '**/app/actions/**/*.{ts,js}', // Next.js Server Actions folder
9
+ '**/api/**/*.{ts,js,mjs,py,go,php,rb}', // Vercel and Netlify functions, generic api folders
10
+ '**/routes/**/*.{ts,js,mjs,py,php,rb}', // Express, Remix, SvelteKit, FastAPI routers, Laravel routes
11
+ '**/routers/**/*.py', '**/views.py', '**/views/**/*.py', '**/app.py', '**/main.py', // Django, Flask, FastAPI
12
+ '**/controllers/**/*.{ts,js,rb,php}', 'app/Http/Controllers/**/*.php', // Rails, Laravel, Nest, Adonis
13
+ '**/*Controller.{java,kt,cs}', // Spring, ASP.NET
14
+ '**/handlers/**/*.{go,ts,js}', '**/*handler*.go', // Go
15
+ 'supabase/functions/**/*.ts', 'netlify/functions/**/*.{ts,js}', 'functions/**/*.{ts,js,py}', // edge and cloud functions
16
+ '**/server/**/*.{ts,js}', '**/hooks.server.{ts,js}', '**/+server.{ts,js}', '**/+page.server.{ts,js}',
17
+ ];
18
+ export const NOT_HANDLER = ['**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/test/**', '**/tests/**', '**/*.d.ts'];
19
+
20
+ // Handler files, without browser only modules ("use client").
21
+ export function handlerFiles(ctx: CheckContext): string[] {
22
+ return ctx.select(HANDLER_GLOBS, NOT_HANDLER).filter((f) => !/^\s*["']use client["']/.test(ctx.text(f)));
23
+ }
24
+
25
+ // The index just past the bracket that closes the one at `open`, or -1. Skips string literals and comments, roughly.
26
+ export function closeOf(text: string, open: number): number {
27
+ const pair: Record<string, string> = { '(': ')', '{': '}', '[': ']' };
28
+ const stack: string[] = [];
29
+ for (let i = open; i < text.length; i++) {
30
+ const c = text[i];
31
+ if (c === '"' || c === "'" || c === '`') {
32
+ for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++;
33
+ continue;
34
+ }
35
+ if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue; }
36
+ if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }
37
+ if (pair[c]) stack.push(pair[c]);
38
+ else if (c === stack[stack.length - 1]) { stack.pop(); if (stack.length === 0) return i + 1; }
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ // The body of the `if` whose keyword starts at `at`: a braced block, an indented Python block, or the rest of the statement.
44
+ export function ifBody(text: string, at: number): string {
45
+ const py = /^if\s+[^\n(]*:\s*(#.*)?$/m.exec(text.slice(at, text.indexOf('\n', at) < 0 ? text.length : text.indexOf('\n', at)));
46
+ if (py) {
47
+ const lineEnd = text.indexOf('\n', at);
48
+ const lineStart = text.lastIndexOf('\n', at) + 1;
49
+ const indent = /^\s*/.exec(text.slice(lineStart))![0].length;
50
+ const rest = text.slice(lineEnd + 1).split('\n');
51
+ const body: string[] = [];
52
+ for (const l of rest) { if (l.trim() === '') continue; if (/^\s*/.exec(l)![0].length <= indent) break; body.push(l); }
53
+ return text.slice(at, lineEnd) + '\n' + body.join('\n');
54
+ }
55
+ const paren = text.indexOf('(', at);
56
+ const condEnd = paren < 0 ? -1 : closeOf(text, paren);
57
+ if (condEnd < 0) return text.slice(at, at + 200);
58
+ const after = text.slice(condEnd).search(/\S/);
59
+ const start = condEnd + (after < 0 ? 0 : after);
60
+ if (text[start] === '{') { const e = closeOf(text, start); return text.slice(at, e < 0 ? start + 400 : e); }
61
+ const end = text.slice(start).search(/[;\n]/);
62
+ return text.slice(at, end < 0 ? text.length : start + end + 1);
63
+ }
64
+
65
+ // A statement that stops the request: return, throw, raise, abort, or an explicit 4xx or 5xx response.
66
+ export const EXITS = /\b(return|throw|raise|abort|panic|exit|die)\b|\bstatus\(\s*[45]\d\d|\bhttp\.Error\b|res\.sendStatus\(\s*[45]/;
@@ -0,0 +1,75 @@
1
+ // check.api.webhook-verified-before-use: a webhook handler checks the sender's signature, and a failed check stops the
2
+ // request before the event is used. Works on any stack whose handlers are found by handlerFiles() (JS, TS, Python, Go,
3
+ // PHP, Ruby). It reads code, so it can FAIL SEC-API-101 for the part a file settles and never PASS it.
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { CheckEvidence } from '../../types/index.ts';
6
+ import { byFileLine, lineOf, type CheckContext } from '../context.ts';
7
+ import type { OpOutcome } from '../ops.ts';
8
+ import type { CodeCheck } from '../types.ts';
9
+ import { EXITS, handlerFiles, ifBody } from './shared.ts';
10
+
11
+ // A file is a webhook handler when its path says webhook or it reads a provider signature header.
12
+ const WEBHOOK_PATH = /webhook/i;
13
+ const SIGNATURE_HEADER = /\b[xX]-[a-zA-Z-]*[sS]ignature\b|stripe-signature|paystack-signature|svix-signature|webhook-signature|HTTP_X_[A-Z_]*SIGNATURE/;
14
+ // Calls that check a signature and return a result the caller must act on.
15
+ const VERIFY_CALL = /\b((?:\w+\.)?(?:verify\w*|validate\w*[sS]ignature\w*|check\w*[sS]ignature\w*|is\w*[vV]alid\w*[sS]ignature\w*|compare_digest|hash_equals|secure_compare|timingSafeEqual|hmac\.Equal|ValidatePayload))\s*\(/g;
16
+ // Calls documented by their providers to throw (or raise) on a bad signature, so code after them runs only when valid.
17
+ const THROWING = /constructEvent|construct_event|webhooks\.verify|wh\.verify|Webhook\.construct|unwrap\(|constructEventAsync|validateRequest/;
18
+
19
+ export function scanFile(file: string, text: string): CheckEvidence[] {
20
+ const bad: CheckEvidence[] = [];
21
+ let calls = 0;
22
+ VERIFY_CALL.lastIndex = 0;
23
+ for (let m = VERIFY_CALL.exec(text); m; m = VERIFY_CALL.exec(text)) {
24
+ if (/function\s+$|def\s+$|func\s+(\([^)]*\)\s*)?$/.test(text.slice(Math.max(0, m.index - 30), m.index))) continue; // a definition, not a call
25
+ calls++;
26
+ const lineStart = text.lastIndexOf('\n', m.index) + 1;
27
+ const lineText = text.slice(lineStart, text.indexOf('\n', m.index) < 0 ? text.length : text.indexOf('\n', m.index));
28
+ if (THROWING.test(m[1]) || THROWING.test(lineText)) continue;
29
+ // The call sits in an if condition: `if (!verify(...)) return ...` or `if not verify(...): raise ...`.
30
+ const ifAt = /\bif\b/.exec(lineText);
31
+ if (ifAt && lineStart + ifAt.index < m.index) {
32
+ const body = ifBody(text, lineStart + ifAt.index);
33
+ if (!EXITS.test(body.slice(body.indexOf(m[0]) + m[0].length))) bad.push({ file, line: lineOf(text, m.index), note: 'a failed signature check does not stop the request' });
34
+ continue;
35
+ }
36
+ // The result is stored: `const ok = verify(...)`, `ok := verify(...)`, `ok = verify(...)`.
37
+ const assigned = /(?:const|let|var|val)?\s*([A-Za-z_$][\w$]*)\s*(?::=|=)\s*(?:await\s+)?[\w.$]*$/.exec(text.slice(lineStart, m.index));
38
+ if (!assigned) continue; // a bare call: its result is not used as a gate, and most such helpers throw
39
+ const name = assigned[1];
40
+ const after = text.slice(m.index);
41
+ const guard = new RegExp(`\\bif\\s*\\(?\\s*(?:!\\s*${name}\\b|not\\s+${name}\\b|${name}\\s*(?:===?|is)\\s*(?:false|False|nil|None)|${name}\\s*!==?\\s*true)`, 'g');
42
+ const guards = [...after.matchAll(guard)];
43
+ if (guards.length === 0) { bad.push({ file, line: lineOf(text, m.index), note: `the result ${name} of the signature check is never used to stop the request` }); continue; }
44
+ const stops = guards.some((g) => EXITS.test(ifBody(text, m!.index + g.index!)));
45
+ if (!stops) bad.push({ file, line: lineOf(text, m.index + guards[0].index!), note: `when ${name} is false the handler carries on and uses the event` });
46
+ }
47
+ if (calls === 0 && !THROWING.test(text)) {
48
+ const handler = /export\s+(?:async\s+)?(?:function|const)\s+(?:POST|PUT|handler|default)\b|@(?:app|router)\.post|\bdef\s+\w*webhook\w*|func\s+\w*[Ww]ebhook\w*/.exec(text);
49
+ bad.push({ file, line: handler ? lineOf(text, handler.index) : 1, note: 'no call that checks the sender signature was found in this webhook handler' });
50
+ }
51
+ return bad;
52
+ }
53
+
54
+ export function run(ctx: CheckContext): OpOutcome {
55
+ const files = handlerFiles(ctx).filter((f) => WEBHOOK_PATH.test(f) || SIGNATURE_HEADER.test(ctx.text(f)));
56
+ if (files.length === 0) return { value: 'not_applicable', reason: 'no webhook handler found', evidence: [], absence: null };
57
+ const bad = files.flatMap((f) => scanFile(f, ctx.text(f))).sort(byFileLine);
58
+ return {
59
+ value: bad.length === 0,
60
+ evidence: bad.length ? bad : files.map((file) => ({ file, line: 1, note: 'signature check found and a failure stops the request' })),
61
+ absence: { pattern: `${VERIFY_CALL.source} followed by a guard that returns or throws`, files_searched: files.length, file_globs: ['handler files whose path says webhook or that read a signature header'], matches: files.length - new Set(bad.map((b) => b.file)).size },
62
+ };
63
+ }
64
+
65
+ export const check: CodeCheck = {
66
+ id: 'check.api.webhook-verified-before-use',
67
+ sec_ids: ['SEC-API-101', 'SEC-API-126'],
68
+ applies_when: {},
69
+ op: 'code',
70
+ on_true: 'NOT ASSESSED: NEEDS DYNAMIC TEST',
71
+ on_false: 'FAIL',
72
+ fixtures: { pass: 'test/fixtures/checks/webhook-verified-before-use/pass', fail: 'test/fixtures/checks/webhook-verified-before-use/fail' },
73
+ source: fileURLToPath(import.meta.url),
74
+ run,
75
+ };