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,142 @@
1
+ // hullproof hooks install and uninstall: a git pre-commit hook for plain git, a Husky entry, or a pre-commit framework entry.
2
+ // The hook only calls `hullproof hooks run` (src/hooks/run.ts). Only exit code 1 from it blocks a commit; a missing command or an
3
+ // engine error lets the commit through, because the hook must never be the reason a team cannot work. `git commit --no-verify` skips it.
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ export type HookMode = 'git' | 'husky' | 'pre-commit';
9
+ export const MARKER_BEGIN = '# >>> hullproof >>>';
10
+ export const MARKER_END = '# <<< hullproof <<<';
11
+ const HOOK_TAG = '# hullproof pre-commit hook';
12
+
13
+ const sh = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`;
14
+ const git = (repo: string, ...a: string[]): string | null => {
15
+ const r = spawnSync('git', ['-C', repo, ...a], { encoding: 'utf8' });
16
+ return r.status === 0 ? r.stdout.trim() : null;
17
+ };
18
+
19
+ export interface Detected { root: string; mode: HookMode; husky: boolean; framework: boolean; hooksPath: string | null; huskyV8: boolean }
20
+
21
+ export function detect(repo: string): Detected | string {
22
+ const root = git(repo, 'rev-parse', '--show-toplevel');
23
+ if (!root) return `${resolve(repo)} is not inside a git repository`;
24
+ const hooksPath = git(root, 'config', '--get', 'core.hooksPath');
25
+ let pkgHusky = false;
26
+ try {
27
+ const p = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as Record<string, Record<string, string> | undefined>;
28
+ pkgHusky = !!(p.devDependencies?.husky || p.dependencies?.husky);
29
+ } catch { /* no package.json */ }
30
+ const husky = existsSync(join(root, '.husky')) || pkgHusky || (hooksPath ?? '').replace(/^\.\//, '').startsWith('.husky');
31
+ const framework = existsSync(join(root, '.pre-commit-config.yaml'));
32
+ return { root, husky, framework, hooksPath, huskyV8: existsSync(join(root, '.husky/_/husky.sh')), mode: husky ? 'husky' : framework ? 'pre-commit' : 'git' };
33
+ }
34
+
35
+ // How the hook calls the engine: `hullproof` when it is on PATH now, else this very engine through node, by absolute path.
36
+ export function runner(): string {
37
+ const onPath = spawnSync('sh', ['-c', 'command -v hullproof'], { encoding: 'utf8' });
38
+ if (onPath.status === 0 && onPath.stdout.trim()) return 'hullproof';
39
+ let cli = process.argv[1] ?? '';
40
+ try { cli = realpathSync(cli); } catch { /* keep as given */ }
41
+ return `${sh(process.execPath)} ${sh(cli)}`;
42
+ }
43
+
44
+ const callLines = (cmd: string): string[] => [
45
+ `${cmd} hooks run`,
46
+ 'status=$?',
47
+ // Only a block (1) stops the commit. 127 (command not found) and any other code let it through.
48
+ 'if [ "$status" -eq 1 ]; then exit 1; fi',
49
+ ];
50
+
51
+ export const plainHook = (cmd: string): string => ['#!/bin/sh', `${HOOK_TAG}. Installed by hullproof hooks install, removed by hullproof hooks uninstall.`, '# Skip it once with: git commit --no-verify', ...callLines(cmd), 'exit 0', ''].join('\n');
52
+ export const huskyBlock = (cmd: string): string => [MARKER_BEGIN, ...callLines(cmd), MARKER_END, ''].join('\n');
53
+ export const frameworkBlock = (cmd: string): string => [
54
+ ` ${MARKER_BEGIN}`,
55
+ ' - repo: local',
56
+ ' hooks:',
57
+ ' - id: hullproof',
58
+ ' name: hullproof (secrets and blocking findings in staged files)',
59
+ ` entry: ${cmd} hooks run`,
60
+ ' language: system',
61
+ ' pass_filenames: false',
62
+ ' always_run: true',
63
+ ` ${MARKER_END}`, '',
64
+ ].join('\n');
65
+
66
+ export interface InstallResult { code: number; lines: string[] }
67
+ export interface InstallOptions { repo: string; mode: HookMode | null; dryRun: boolean; uninstall: boolean }
68
+
69
+ const stripBlock = (text: string): string => {
70
+ const re = new RegExp(`\\n?[ \\t]*${MARKER_BEGIN.replace(/[>]/g, '\\>')}[\\s\\S]*?${MARKER_END.replace(/[<]/g, '\\<')}\\n?`, 'g');
71
+ return text.replace(re, '\n').replace(/\n{3,}/g, '\n\n');
72
+ };
73
+
74
+ export function installHooks(o: InstallOptions): InstallResult {
75
+ const d = detect(o.repo);
76
+ if (typeof d === 'string') return { code: 2, lines: [`hullproof hooks: ${d}`] };
77
+ const mode = o.mode ?? d.mode;
78
+ const lines: string[] = [`hullproof hooks: mode ${mode}${o.mode ? '' : ` (detected: Husky ${d.husky ? 'yes' : 'no'}, pre-commit framework ${d.framework ? 'yes' : 'no'})`}`];
79
+ const cmd = runner();
80
+ const write = (path: string, text: string, exec = false): void => {
81
+ if (o.dryRun) { lines.push(`would write ${path}`); return; }
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ writeFileSync(path, text);
84
+ if (exec) chmodSync(path, 0o755);
85
+ lines.push(`wrote ${path}`);
86
+ };
87
+
88
+ if (mode === 'git') {
89
+ const hooksDir = git(d.root, 'rev-parse', '--git-path', 'hooks');
90
+ if (!hooksDir) return { code: 2, lines: [...lines, 'hullproof hooks: could not find the git hooks folder'] };
91
+ const path = resolve(d.root, hooksDir, 'pre-commit');
92
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
93
+ if (o.uninstall) {
94
+ if (current === null) lines.push('no pre-commit hook to remove');
95
+ else if (!current.includes(HOOK_TAG)) return { code: 2, lines: [...lines, `${path} is not a Hullproof hook, so it was left alone`] };
96
+ else if (o.dryRun) lines.push(`would remove ${path}`);
97
+ else { rmSync(path); lines.push(`removed ${path}`); }
98
+ return { code: 0, lines };
99
+ }
100
+ if (current !== null && !current.includes(HOOK_TAG)) {
101
+ return { code: 2, lines: [...lines, `${path} already exists and is not a Hullproof hook. It was not changed.`, 'Add this to it, or use the Husky or pre-commit framework mode:', ...callLines(cmd)] };
102
+ }
103
+ write(path, plainHook(cmd), true);
104
+ return { code: 0, lines };
105
+ }
106
+
107
+ if (mode === 'husky') {
108
+ const path = join(d.root, '.husky/pre-commit');
109
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
110
+ if (o.uninstall) {
111
+ if (current === null || !current.includes(MARKER_BEGIN)) lines.push('no Hullproof entry in .husky/pre-commit');
112
+ else write(path, stripBlock(current));
113
+ return { code: 0, lines };
114
+ }
115
+ if (current?.includes(MARKER_BEGIN)) { lines.push('.husky/pre-commit already has the Hullproof entry'); return { code: 0, lines }; }
116
+ const head = current ?? `#!/usr/bin/env sh\n${d.huskyV8 ? '. "$(dirname -- "$0")/_/husky.sh"\n' : ''}`;
117
+ write(path, `${head.replace(/\n*$/, '\n')}${huskyBlock(cmd)}`, true);
118
+ return { code: 0, lines };
119
+ }
120
+
121
+ // pre-commit framework
122
+ const path = join(d.root, '.pre-commit-config.yaml');
123
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
124
+ if (o.uninstall) {
125
+ if (current === null || !current.includes(MARKER_BEGIN)) lines.push('no Hullproof entry in .pre-commit-config.yaml');
126
+ else write(path, stripBlock(current));
127
+ return { code: 0, lines };
128
+ }
129
+ if (current?.includes(MARKER_BEGIN)) { lines.push('.pre-commit-config.yaml already has the Hullproof entry'); return { code: 0, lines }; }
130
+ const block = frameworkBlock(cmd);
131
+ // The entry is appended at the end of the file, which is only right when `repos:` is the last top level key and a block list.
132
+ const topKeys = (current ?? '').split('\n').filter((l) => /^[A-Za-z_][\w-]*\s*:/.test(l));
133
+ const last = topKeys.at(-1) ?? '';
134
+ const appendable = current === null || (/^repos\s*:\s*$/.test(last) && last === topKeys.find((k) => k.startsWith('repos')));
135
+ if (!appendable) {
136
+ return { code: 0, lines: [...lines, 'could not add the entry safely (the file has keys after `repos:` or a flow style list). Add this under `repos:` yourself:', '', block.replace(/^ {2}# .*\n/gm, '')] };
137
+ }
138
+ const base = current ?? 'repos:\n';
139
+ write(path, `${base.replace(/\n*$/, '\n')}${block}`);
140
+ lines.push('run `pre-commit install` once so the framework runs it on each commit');
141
+ return { code: 0, lines };
142
+ }
@@ -0,0 +1,121 @@
1
+ // hullproof hooks run: the fast check the pre-commit hook calls. It looks at the staged files only, for two things: a secret, and a
2
+ // finding of the highest severity (BLOCKER) that a deterministic check or a mapped Semgrep rule raises in a staged file.
3
+ // Nothing leaves the machine: no network, no model, no upload. The staged content is exported from the index into a temporary
4
+ // folder, so the check sees what the commit will hold and not the working tree. Findings in files that are not staged never block.
5
+ // Exit 0 pass, 1 blocked. Any error is reported and lets the commit through (fail open), unless HULLPROOF_HOOK_STRICT=1.
6
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, resolve } from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { makeContext } from '../checks/context.ts';
11
+ import { checkSha, loadChecks, runChecks } from '../checks/run.ts';
12
+ import { discover } from '../discover/discover.ts';
13
+ import { buildFindings, type BuildInput } from '../report/build.ts';
14
+ import { loadKit, readStage, resolveKitDir, type Edition } from '../report/kit.ts';
15
+ import { RULES_DIR } from '../scanners/common.ts';
16
+ import { runScanner } from '../scanners/index.ts';
17
+ import { adapter as gitleaks } from '../scanners/gitleaks.ts';
18
+ import { adapter as semgrep } from '../scanners/semgrep.ts';
19
+ import type { ScanOptions } from '../scanners/types.ts';
20
+ import { SEV_RANK } from '../report/kit.ts';
21
+ import type { CheckResultsFile, FailRecord, Hit, ToolMeta } from '../types/index.ts';
22
+
23
+ const SOURCE_FILES = /\.(js|mjs|cjs|ts|jsx|tsx|py|rb|php|go|java|kt|swift|yaml|yml|json|sql|tf|html)$/;
24
+
25
+ export interface HookRun { code: number; lines: string[] }
26
+ export interface HookOptions { repo: string; kit: string | null; semgrep: boolean; log?: (s: string) => void }
27
+
28
+ const git = (repo: string, args: string[], extra: NodeJS.ProcessEnv = {}): { ok: boolean; out: string } => {
29
+ // --no-ext-diff and --no-textconv are given where they matter, so a driver named in the repository configuration cannot run.
30
+ const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', ...extra } });
31
+ return { ok: r.status === 0, out: r.stdout ?? '' };
32
+ };
33
+
34
+ export async function runHook(o: HookOptions): Promise<HookRun> {
35
+ const started = Date.now();
36
+ const top = git(o.repo, ['rev-parse', '--show-toplevel']);
37
+ if (!top.ok) return { code: 2, lines: ['hullproof: not inside a git repository'] };
38
+ const root = top.out.trim();
39
+ const staged = git(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR', '--no-ext-diff', '--no-textconv', '-z']).out.split('\0').filter(Boolean).sort();
40
+ if (staged.length === 0) return { code: 0, lines: [] };
41
+ const stagedSet = new Set(staged);
42
+
43
+ const tmp = mkdtempSync(join(tmpdir(), 'hullproof-hook-'));
44
+ try {
45
+ const tree = join(tmp, 'tree');
46
+ mkdirSync(tree);
47
+ // The index as it will be committed, exported to a folder; its index file is copied so checks that ask which files git tracks
48
+ // get the right answer.
49
+ const exp = git(root, ['checkout-index', '-a', '-f', `--prefix=${tree}/`]);
50
+ if (!exp.ok) throw new Error('could not read the staged files from the git index');
51
+ const idxPath = resolve(root, git(root, ['rev-parse', '--git-path', 'index']).out.trim());
52
+ mkdirSync(join(tree, '.git'));
53
+ if (existsSync(idxPath)) copyFileSync(idxPath, join(tree, '.git/index'));
54
+ writeFileSync(join(tree, '.git/HEAD'), 'ref: refs/heads/hook\n');
55
+
56
+ const kitDir = resolveKitDir(root, o.kit);
57
+ const kit = kitDir ? loadKit(kitDir) : null;
58
+ const edition: Edition = kit?.controls.some((c) => c.edition === 'pro') ? 'pro' : 'free';
59
+ const stack = discover(tree);
60
+ const controlsPath = kitDir ? join(kitDir, 'docs/hullproof/security-controls.json') : undefined;
61
+ const scanOpts = (extra: Partial<ScanOptions> = {}): ScanOptions => ({ repo: tree, out: join(tmp, 'out'), stack, allowNetwork: false, edition, history: false, kit: kitDir, controlsPath, ...extra });
62
+ const metas: ToolMeta[] = [];
63
+ const hits: Hit[] = [];
64
+ const notes: string[] = [];
65
+ const run = (a: typeof gitleaks, extra: Partial<ScanOptions> = {}): void => {
66
+ const s = runScanner(a, scanOpts(extra));
67
+ metas.push(s.meta);
68
+ hits.push(...s.hits.hits);
69
+ if (s.meta.status !== 'ran') notes.push(`${a.tool} did not run (${s.meta.status})`);
70
+ };
71
+
72
+ // 1. Secrets, which is the quick part: Gitleaks over the exported tree, kept to the staged files.
73
+ run(gitleaks);
74
+ const secretHits = hits.filter((h) => stagedSet.has(h.file));
75
+ const secretsBlock = blockers(secretHits, EMPTY, kit, edition, tree, metas, stagedSet);
76
+ if (secretsBlock.length || (secretHits.length && !kit)) {
77
+ const lines = secretsBlock.length ? render(secretsBlock) : secretHits.map((h) => ` BLOCKER ${h.file}:${h.line} a secret shaped value (rule ${h.rule_id})`);
78
+ return done(1, lines, staged.length, started, notes);
79
+ }
80
+ if (!kit) return done(0, [], staged.length, started, [...notes, 'no kit was found (pass --kit or set HULLPROOF_KIT), so only secrets were checked']);
81
+
82
+ // 2. Blocking findings from the deterministic checks and, when it is on, Semgrep on the staged source files.
83
+ const sources = staged.filter((f) => SOURCE_FILES.test(f));
84
+ if (o.semgrep && sources.length) run(semgrep, { targets: sources });
85
+ const ctx = makeContext(tree, stack, hits, kitDir);
86
+ const checkDefs = loadChecks();
87
+ const checks = runChecks(ctx, checkDefs);
88
+ const found = blockers(hits.filter((h) => stagedSet.has(h.file)), checks, kit, edition, tree, metas, stagedSet, checkDefs);
89
+ return found.length ? done(1, render(found), staged.length, started, notes) : done(0, [], staged.length, started, notes);
90
+ } finally {
91
+ rmSync(tmp, { recursive: true, force: true });
92
+ }
93
+ }
94
+
95
+ const EMPTY: CheckResultsFile = { schema_version: '1.0.0', commit: null, results: [], not_run: [] };
96
+ type Def = ReturnType<typeof loadChecks>;
97
+
98
+ // Fail records of BLOCKER severity that point at a staged file. The same record builder the scan uses, so a rule or a check gives
99
+ // the same title, requirement and fix here as in the report.
100
+ function blockers(hits: Hit[], checks: CheckResultsFile, kit: ReturnType<typeof loadKit> | null, edition: Edition, tree: string, metas: ToolMeta[], staged: Set<string>, defs: Def = []): FailRecord[] {
101
+ if (!kit) return [];
102
+ const input: BuildInput = {
103
+ kit, scope: { edition, stage: readStage(tree).stage, stageSource: 'default' }, git: { commit: '0'.repeat(40), tree: '0'.repeat(40), clean: false },
104
+ checks, hits, metas, checkShas: Object.fromEntries(defs.map((c) => [c.id, checkSha(c)])), rulesDirs: [join(RULES_DIR, 'semgrep')],
105
+ };
106
+ const { file } = buildFindings(input);
107
+ return file.records.filter((r): r is FailRecord => r.kind === 'fail' && SEV_RANK[r.severity] >= SEV_RANK.BLOCKER && r.evidence.some((e) => staged.has(e.file)));
108
+ }
109
+
110
+ const render = (fs: FailRecord[]): string[] =>
111
+ fs.flatMap((f) => {
112
+ const e = f.evidence.find((x) => x.file) ?? f.evidence[0];
113
+ return [` ${f.severity} ${e ? `${e.file}:${e.line}` : ''} ${f.title} (${f.sec_ids.join(', ')})`, ` Fix: ${f.remediation.strategy}`];
114
+ });
115
+
116
+ function done(code: number, problems: string[], n: number, started: number, notes: string[]): HookRun {
117
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
118
+ const lines = [...(code === 1 ? [`hullproof: BLOCKED, ${problems.filter((l) => l.startsWith(' ')&& !l.startsWith(' ')).length} problem(s) in the staged files`, ...problems] : []), ...notes.map((x) => `hullproof: note: ${x}`),
119
+ `hullproof: checked ${n} staged file${n === 1 ? '' : 's'} in ${secs}s on this machine, nothing was sent anywhere.${code === 1 ? ' To commit anyway: git commit --no-verify' : ''}`];
120
+ return { code, lines };
121
+ }
@@ -0,0 +1,219 @@
1
+ // Reads one handler (or one decorator and wrapper region) and returns its protections, request inputs, data touches and
2
+ // outbound calls. Pattern based; every fact carries a confidence. See docs/PHASE-2-DESIGN.md sections 2 and 6.
3
+ import type { Confidence, DataTouch, InputRead, InputSource, Outbound, Protection, ProtectionKind, Scope } from '../types/index.ts';
4
+ import { scanFile } from '../checks/code/webhook-verify.ts';
5
+ import { EXITS, ifBody } from '../checks/code/shared.ts';
6
+ import type { Lang, Lines } from './lex.ts';
7
+ import { dataTouches, outboundCalls, type Region } from './touches.ts';
8
+ import { taintOf, type Taint } from './taint.ts';
9
+ import type { LocalVocab } from './local-vocab.ts';
10
+ import {
11
+ AUDIT, AUTH_PACKAGES, RATE, RATE_PACKAGES, ROLE_JS, ROLE_PY, SESSION_JS, SESSION_PY, SIGNATURE, VALIDATION_JS, VALIDATION_PY,
12
+ type Pattern,
13
+ } from './vocab.ts';
14
+
15
+ export interface FileView { file: string; text: string; masked: string; lang: Lang; lines: Lines; imports: string }
16
+ export interface Spec {
17
+ start: number; end: number; // the handler, parameters included
18
+ seeds: string[]; // names that hold request data on entry
19
+ decorators?: { view: FileView; start: number; end: number; scope: Scope }[]; // decorator, middleware or wrapper text outside the handler body
20
+ extraCalls?: string[]; // names to look into besides the calls in the body (the wrapper function, middleware names)
21
+ gate?: boolean; // the region is a middleware or guard: a check that never rejects does not enforce anything
22
+ returnsJson?: boolean;
23
+ }
24
+ export interface HelperRef { view: FileView; start: number; end: number }
25
+ export interface Helpers { find(name: string, fromFile?: string): HelperRef | null; local?: LocalVocab | null }
26
+
27
+ export interface Analysis {
28
+ protections: Protection[]; reads: InputRead[]; data: DataTouch[]; outbound: Outbound[]; taint: Taint;
29
+ }
30
+
31
+ const usesText = (re: RegExp): boolean => /['"`]/.test(re.source);
32
+ const CONF_RANK: Record<Confidence, number> = { high: 0, medium: 1, low: 2 };
33
+ const minConf = (a: Confidence, b: Confidence): Confidence => (CONF_RANK[a] >= CONF_RANK[b] ? a : b);
34
+
35
+ function facts(kind: ProtectionKind, pats: Pattern[], view: FileView, from: number, to: number, scope: Scope, upgrade: boolean, viaPrefix = ''): Protection[] {
36
+ const out: Protection[] = [];
37
+ const seen = new Set<string>();
38
+ const masked = view.masked.slice(from, to);
39
+ const text = view.text.slice(from, to);
40
+ for (const p of pats) {
41
+ p.re.lastIndex = 0;
42
+ const m = p.re.exec(usesText(p.re) ? text : masked);
43
+ if (!m || seen.has(p.via)) continue;
44
+ const ln = view.lines.at(from + m.index);
45
+ if (out.some((o) => o.line === ln)) continue;
46
+ seen.add(p.via);
47
+ const conf = upgrade && p.confidence === 'medium' ? 'high' : p.confidence;
48
+ out.push({ kind, scope, file: view.file, line: ln, via: viaPrefix + p.via, enforced: null, confidence: viaPrefix ? minConf(conf, 'medium') : conf });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Whether a failed check provably stops the request. true: the check sits in an if whose body exits, or it is a throwing
54
+ // guard (require..., a decorator). false: it is tested and the body does not exit. null: cannot tell.
55
+ function enforcedFor(view: FileView, at: number, via: string): boolean | null {
56
+ const { text } = view;
57
+ const lineStart = text.lastIndexOf('\n', at) + 1;
58
+ const lineEnd = text.indexOf('\n', at) < 0 ? text.length : text.indexOf('\n', at);
59
+ const before = text.slice(lineStart, at);
60
+ if (/^@|decorator|Depends|permission_classes|login/.test(via) && /(^|\n)\s*@|Depends\s*\(|permission_classes/.test(text.slice(lineStart, lineEnd))) return true;
61
+ const ifAt = /\b(?:if|unless|elif|assert)\b/.exec(before);
62
+ if (ifAt) {
63
+ const body = view.lang === 'py' ? text.slice(lineStart + ifAt.index, Math.min(text.length, lineEnd + 400)).split('\n').slice(0, 4).join('\n') : ifBody(text, lineStart + ifAt.index);
64
+ return EXITS.test(body);
65
+ }
66
+ if ((/\b(?:require|ensure|assert)\w*/i.test(via) || /^local (?:auth|role) helper$/.test(via)) && !/=/.test(before)) return true;
67
+ const lhs = /(?:\b(?:const|let|var|val)\s+)?(\{[^}]*(?:\{[^}]*\}[^}]*)*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*(?:await\s+)?[\w.$]*\s*$/.exec(before);
68
+ if (!lhs) return null;
69
+ const names = [...lhs[1].matchAll(/[A-Za-z_$][\w$]*/g)].map((x) => x[0]).filter((n) => !['const', 'let', 'var', 'data', 'error', 'session'].includes(n) || lhs[1] === n);
70
+ const rest = text.slice(lineEnd, Math.min(text.length, lineEnd + 1500));
71
+ for (const n of names) {
72
+ const guard = view.lang === 'py'
73
+ ? new RegExp(`\\bif\\s+(?:not\\s+${n}\\b|${n}\\s+is\\s+None|${n}\\s*==\\s*None)`)
74
+ : new RegExp(`\\bif\\s*\\(\\s*(?:!\\s*${n}\\b|${n}\\s*(?:===?|==)\\s*(?:null|undefined)\\b|!\\s*${n}\\s*\\|\\||!${n}\\?)`);
75
+ const g = guard.exec(rest);
76
+ if (!g) continue;
77
+ const body = view.lang === 'py' ? rest.slice(g.index, g.index + 300).split('\n').slice(0, 4).join('\n') : ifBody(rest, g.index);
78
+ return EXITS.test(body);
79
+ }
80
+ return null;
81
+ }
82
+
83
+ const READS: [InputSource, RegExp, boolean][] = [
84
+ ['body', /\b(?:req|request|ctx\.request|c\.req)\s*\.\s*(?:body|json|get_json|data|text)\b|\bawait\s+\w+\.json\s*\(\)|\bJSON\.parse\s*\(\s*(?:raw|body|await)/, false],
85
+ ['query', /\b(?:req|request)\.(?:query|args|GET|query_params|nextUrl)\b|\bsearchParams\b|\bc\.req\.query\b|\bctx\.query\b|\bparse_qs\b/, false],
86
+ ['params', /\b(?:req|request|ctx|c\.req)\.params?\b|\bparams\s*[.\[]|\bc\.req\.param\b|\bview_args\b|\bkwargs\b|\bPath\s*\(/, false],
87
+ ['headers', /\b(?:req|request)\.headers\b|\bheaders\s*\(\s*\)|\bc\.req\.header\b|\bgetHeader\s*\(|\brequest\.META\b/, false],
88
+ ['cookies', /\b(?:req|request)\.cookies\b|\bcookies\s*\(\s*\)|\bgetCookie\s*\(|\bc\.req\.cookie\b/, false],
89
+ ['form', /\bformData\b|\brequest\.form\b|\brequest\.POST\b|\bFormData\s*\(|\bmultipart\b/i, false],
90
+ ['file', /\.get\s*\(\s*['"](?:file|files|upload|image|avatar|attachment|document|photo)s?['"]\s*\)|\bas\s+File\b|\bmulter\b|\breq\.files?\b|\brequest\.files\b|\bformidable\b|\bbusboy\b|\binstanceof\s+File\b|\bUploadFile\b|\brequest\.FILES\b|\.file\s*\(\s*['"]|\bupload\.(?:single|array|fields|any)\b|\bFileInterceptor\b|\bFilesInterceptor\b|\bgetAll\s*\(\s*['"]file/, false],
91
+ ];
92
+ function readsOf(view: FileView, from: number, to: number): InputRead[] {
93
+ const masked = view.masked.slice(from, to);
94
+ const out: InputRead[] = [];
95
+ for (const [source, re] of READS) {
96
+ const m = re.exec(masked);
97
+ if (m) out.push({ source, line: view.lines.at(from + m.index) });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ const PARSE_CALL = /\bawait\s+\w+\.json\s*\(\)|\b(?:req|request)\.(?:json|get_json)\s*\(|\bJSON\.parse\s*\(|\bjson\.loads\s*\(|\.formData\s*\(\)|\brequest\.(?:json|form)\b/;
103
+
104
+ // A helper body that turns a request away: an error status, a throw, an abort or a redirect.
105
+ const REJECT = /\b(?:401|403|unauthori[sz]ed|forbidden)\b|\bthrow\b|\braise\b|\babort\s*\(|\bnext\s*\(\s*(?:new\s+)?\w*Error|\bcreateError\b|\bHTTPException\b|\bPermissionDenied\b|\bAuthenticationFailed\b|\bredirect\s*\(|\bstatus\s*\(\s*4\d\d|\bdenyAll\b/i;
106
+
107
+ const CALL = /(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
108
+ const SKIP_CALLS = new Set(['if', 'for', 'while', 'switch', 'function', 'return', 'catch', 'await', 'async', 'typeof', 'new', 'super', 'require', 'import', 'print', 'len', 'str', 'int', 'dict', 'list', 'set', 'tuple', 'range', 'isinstance', 'type', 'super', 'sorted', 'map', 'filter', 'json', 'Number', 'String', 'Boolean', 'Array', 'Object', 'Date', 'Promise', 'Error', 'parseInt', 'parseFloat', 'setTimeout', 'fetch', 'def', 'class', 'with', 'except', 'elif', 'lambda', 'not', 'and', 'or']);
109
+
110
+ export function analyze(view: FileView, spec: Spec, helpers: Helpers): Analysis {
111
+ const lang = view.lang;
112
+ const { start, end } = spec;
113
+ const upgradeSession = AUTH_PACKAGES.test(view.imports);
114
+ const upgradeRate = RATE_PACKAGES.test(view.imports);
115
+ const local = helpers.local ?? null;
116
+ const sessionPats = [...(lang === 'js' ? SESSION_JS : SESSION_PY), ...(local?.auth ? [local.auth] : [])];
117
+ const rolePats = [...(lang === 'js' ? ROLE_JS : ROLE_PY), ...(local?.role ? [local.role] : [])];
118
+ const valPats = lang === 'js' ? VALIDATION_JS : VALIDATION_PY;
119
+
120
+ const prot: Protection[] = [];
121
+ const own = (kind: ProtectionKind, pats: Pattern[], upgrade: boolean): void => {
122
+ for (const f of facts(kind, pats, view, start, end, 'handler', upgrade)) {
123
+ prot.push({ ...f, enforced: kind === 'session_read' || kind === 'role_check' ? enforcedFor(view, firstIndex(view, start, end, pats, f.via, start), f.via) : null });
124
+ }
125
+ };
126
+ own('session_read', sessionPats, upgradeSession);
127
+ own('role_check', rolePats, false);
128
+ own('rate_limit', RATE, upgradeRate);
129
+ own('input_validation', valPats, false);
130
+ own('audit_log', AUDIT, false);
131
+
132
+ // Signature checks: where it is, whether a failed check stops the request, and whether the body is parsed first.
133
+ const sig = facts('signature_verify', SIGNATURE, view, start, end, 'handler', false);
134
+ if (sig.length) {
135
+ const region = view.text.slice(start, end);
136
+ const bad = scanFile(view.file, region);
137
+ const onlyMissing = bad.length > 0 && bad.every((b) => /^no call that checks/.test(b.note));
138
+ const enforced = bad.length === 0 ? true : onlyMissing ? null : false;
139
+ const first = sig.reduce((a, b) => (a.line <= b.line ? a : b));
140
+ const parse = PARSE_CALL.exec(view.masked.slice(start, end));
141
+ const parseLine = parse ? view.lines.at(start + parse.index) : null;
142
+ for (const f of sig) prot.push({ ...f, enforced, after_parse: parseLine !== null && parseLine < first.line });
143
+ }
144
+
145
+ // Decorators, middleware and wrappers outside the handler body.
146
+ const extra = new Set<string>(spec.extraCalls ?? []);
147
+ for (const d of spec.decorators ?? []) {
148
+ const dv = d.view;
149
+ const dl = dv.lang;
150
+ const keep = (kind: ProtectionKind, pats: Pattern[], up: boolean): void => { for (const f of facts(kind, pats, dv, d.start, d.end, d.scope, up)) prot.push({ ...f, enforced: kind === 'session_read' || kind === 'role_check' ? (f.confidence === 'high' ? true : null) : null }); };
151
+ keep('session_read', [...(dl === 'js' ? SESSION_JS : SESSION_PY), ...(local?.auth ? [local.auth] : [])], AUTH_PACKAGES.test(dv.imports));
152
+ keep('role_check', [...(dl === 'js' ? ROLE_JS : ROLE_PY), ...(local?.role ? [local.role] : [])], false);
153
+ keep('rate_limit', RATE, RATE_PACKAGES.test(dv.imports));
154
+ keep('input_validation', dl === 'js' ? VALIDATION_JS : VALIDATION_PY, false);
155
+ keep('audit_log', AUDIT, false);
156
+ for (const f of facts('signature_verify', SIGNATURE, dv, d.start, d.end, d.scope, false)) prot.push({ ...f, enforced: null });
157
+ for (const m of dv.masked.slice(d.start, d.end).matchAll(CALL)) if (!SKIP_CALLS.has(m[1])) extra.add(m[1]);
158
+ }
159
+
160
+ // One hop into the repository's own helper functions the handler calls.
161
+ const have = (k: ProtectionKind): boolean => prot.some((p) => p.kind === k);
162
+ const names = new Set<string>();
163
+ for (const m of view.masked.slice(start, end).matchAll(CALL)) if (!SKIP_CALLS.has(m[1])) names.add(m[1]);
164
+ for (const n of extra) names.add(n);
165
+ let hops = 0;
166
+ for (const name of [...names].sort()) {
167
+ if (hops >= 30) break;
168
+ const h = helpers.find(name, view.file);
169
+ if (!h) continue;
170
+ hops++;
171
+ const hv = h.view;
172
+ const hf: [ProtectionKind, Pattern[], boolean][] = [
173
+ ['session_read', [...(hv.lang === 'js' ? SESSION_JS : SESSION_PY), ...(local?.auth ? [local.auth] : [])], AUTH_PACKAGES.test(hv.imports)],
174
+ ['role_check', [...(hv.lang === 'js' ? ROLE_JS : ROLE_PY), ...(local?.role ? [local.role] : [])], false], ['rate_limit', RATE, RATE_PACKAGES.test(hv.imports)],
175
+ ['input_validation', hv.lang === 'js' ? VALIDATION_JS : VALIDATION_PY, false], ['audit_log', AUDIT, false],
176
+ ];
177
+ for (const [kind, pats, up] of hf) {
178
+ if (kind === 'session_read' && have('session_read')) continue;
179
+ if (have(kind) && kind !== 'audit_log') continue;
180
+ const found = facts(kind, pats.filter((p) => p.confidence !== 'low'), hv, h.start, h.end, 'handler', up, `helper:${name} `);
181
+ if (found.length) {
182
+ const idx = view.masked.slice(start, end).search(new RegExp(`(?<![.\\w$])${name.replace(/\$/g, '\\$')}\\s*\\(`));
183
+ const at = idx < 0 ? view.lines.at(start) : view.lines.at(start + idx);
184
+ const rejects = REJECT.test(hv.text.slice(h.start, h.end));
185
+ const enforced = rejects ? true : spec.gate && (kind === 'session_read' || kind === 'role_check') ? false : null;
186
+ prot.push({ ...found[0], file: view.file, line: at, via: `helper:${name} ${found[0].via.replace(`helper:${name} `, '')}`, enforced });
187
+ }
188
+ }
189
+ }
190
+
191
+ // Taint, then data touches and outbound calls.
192
+ const maskedBody = view.masked.slice(start, end);
193
+ const textBody = view.text.slice(start, end);
194
+ const taint = taintOf(maskedBody, textBody, spec.seeds, lang, local?.auth ? [local.auth] : []);
195
+ const region: Region = { masked: maskedBody, text: textBody, base: start, line: (i) => view.lines.at(start + i) };
196
+ const data = dataTouches(region, taint, lang);
197
+ const outbound = outboundCalls(region, taint, lang, spec.returnsJson ?? true, view.imports);
198
+
199
+ // owner_filter: a data touch that names an owner is a protection on that read or write.
200
+ for (const d of data) if (d.owner_scoped && (d.by_id || d.request_derived || d.op !== 'read')) prot.push({ kind: 'owner_filter', scope: 'handler', file: view.file, line: d.line, via: `${d.store} ${d.target ?? ''}`.trim(), enforced: null, confidence: d.store === 'sql' ? 'medium' : 'medium' });
201
+
202
+ prot.sort((a, b) => a.line - b.line || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || (a.via < b.via ? -1 : a.via > b.via ? 1 : 0));
203
+ return { protections: dedupe(prot), reads: readsOf(view, start, end), data, outbound, taint };
204
+ }
205
+
206
+ function dedupe(ps: Protection[]): Protection[] {
207
+ const seen = new Set<string>();
208
+ return ps.filter((p) => { const k = `${p.kind}|${p.scope}|${p.line}|${p.via}`; if (seen.has(k)) return false; seen.add(k); return true; });
209
+ }
210
+
211
+ // The character index of the first match of the pattern with this `via` inside [from, to), for enforcedFor.
212
+ function firstIndex(view: FileView, from: number, to: number, pats: Pattern[], via: string, fallback: number): number {
213
+ const p = pats.find((x) => x.via === via);
214
+ if (!p) return fallback;
215
+ p.re.lastIndex = 0;
216
+ const src = usesText(p.re) ? view.text.slice(from, to) : view.masked.slice(from, to);
217
+ const m = p.re.exec(src);
218
+ return m ? from + m.index : fallback;
219
+ }