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,206 @@
1
+ // Builds codemap.json: runs the extractors over every file, joins router mounts and gates, adds the Supabase and Firebase
2
+ // maps, and writes the per language coverage that says what the map did not read. Deterministic: sorted, no clock.
3
+ import type { CheckContext } from '../checks/context.ts';
4
+ import type { CodeMap, Coverage, EntryPoint, Gate, Protection } from '../types/index.ts';
5
+ import { cmp } from '../util.ts';
6
+ import { ENGINE_VERSION } from '../version.ts';
7
+ import { firebaseMap, supabaseMap } from './config.ts';
8
+ import { joinPath, type Extracted, type Mount } from './entry.ts';
9
+ import { extractExpress } from './js-express.ts';
10
+ import { extractAiTools, extractJobs, extractNest } from './js-misc.ts';
11
+ import { extractNext } from './js-next.ts';
12
+ import { extractExpo } from './expo.ts';
13
+ import { extractGoAll, goFiles } from './go.ts';
14
+ import { extractLaravelAll, laravelFiles } from './laravel.ts';
15
+ import { FnIndex } from './light-index.ts';
16
+ import { extractRailsAll, railsFiles } from './rails.ts';
17
+ import { extractSpring, springFiles } from './spring.ts';
18
+ import { dotnetFiles, extractDotnet } from './dotnet.ts';
19
+ import { extractMobile, mobileFacts } from './mobile.ts';
20
+ import { cargoConfig, extractRust, rustFiles } from './rust.ts';
21
+ import { authHelperCandidates } from './candidates.ts';
22
+ import { extractPython } from './py.ts';
23
+ import { extractDjango } from './py-django.ts';
24
+ import { langOf, Repo } from './repo.ts';
25
+
26
+ // Web frameworks a built extractor reads, and ones it does not, by language group (stack.json framework ids).
27
+ const READ_BY: Record<string, string[]> = { 'node-ts': ['nextjs', 'express', 'fastify', 'koa', 'hono', 'nestjs', 'trpc', 'react', 'expo'], python: ['flask', 'fastapi', 'django', 'quart', 'sanic', 'starlette'], go: ['chi', 'echo', 'fiber', 'gin', 'gorilla-mux'], ruby: ['rails'], php: ['laravel'], java: ['spring', 'spring-boot'], csharp: ['aspnet-core'] };
28
+ const UNREAD: Record<string, string[]> = {
29
+ 'node-ts': ['adonisjs', 'apollo-server', 'hapi', 'loopback', 'remix', 'sveltekit', 'nuxt', 'astro', 'socket.io', 'graphql'],
30
+ python: ['aiohttp', 'bottle', 'falcon', 'litestar', 'pyramid', 'tornado', 'gradio', 'streamlit'],
31
+ go: [], ruby: ['sinatra', 'grape', 'hanami', 'roda'],
32
+ php: ['symfony', 'slim', 'codeigniter', 'cakephp', 'yii', 'drupal', 'wordpress'],
33
+ java: ['quarkus', 'micronaut', 'javalin'], kotlin: ['ktor', 'spring', 'spring-boot'],
34
+ csharp: [], rust: ['actix-web', 'axum', 'rocket', 'warp'], elixir: ['phoenix', 'ecto'],
35
+ dart: ['dart-frog', 'shelf'], swift: ['vapor'],
36
+ };
37
+ const SERVER_LANGS = new Set(['java', 'kotlin', 'csharp', 'rust', 'scala', 'elixir', 'swift', 'dart', 'perl', 'c', 'cpp', 'lua', 'haskell', 'clojure', 'groovy', 'erlang', 'fsharp', 'ocaml', 'julia', 'r', 'visual-basic', 'zig', 'nim']);
38
+
39
+ const inPrefix = (path: string, prefix: string): boolean => prefix === '*' || prefix === '/' || path === prefix || path.startsWith(prefix.replace(/\/$/, '') + '/') || path.startsWith(prefix.replace(/\/$/, '') + '*');
40
+ const ROUTE_FW = new Set(['express', 'flask', 'fastapi', 'quart', 'sanic', 'flask-restful']);
41
+ const WEB_KINDS = new Set(['http_route', 'webhook', 'page']);
42
+
43
+ const confOf = (c: Protection['confidence'], cap: Protection['confidence']): Protection['confidence'] => (c === 'low' || cap === 'low' ? 'low' : c === 'medium' || cap === 'medium' ? 'medium' : 'high');
44
+
45
+ function addProtections(ep: EntryPoint, ps: Protection[]): void {
46
+ const have = new Set(ep.protections.map((p) => `${p.kind}|${p.scope}|${p.file}|${p.line}|${p.via}`));
47
+ for (const p of ps) { const k = `${p.kind}|${p.scope}|${p.file}|${p.line}|${p.via}`; if (!have.has(k)) { have.add(k); ep.protections.push(p); } }
48
+ ep.protections.sort((a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.kind, b.kind) || cmp(a.via, b.via) || cmp(a.scope, b.scope));
49
+ }
50
+
51
+ function prefixChain(mounts: Mount[]): Map<string, { prefix: string; protections: Protection[] }> {
52
+ const byTarget = new Map<string, Mount[]>();
53
+ for (const m of mounts) byTarget.set(m.target, [...(byTarget.get(m.target) ?? []), m]);
54
+ const resolve = (file: string, depth: number): { prefix: string; protections: Protection[] } | null => {
55
+ const ms = (byTarget.get(file) ?? []).slice().sort((a, b) => cmp(a.from, b.from) || a.line - b.line);
56
+ if (!ms.length || depth > 5) return null;
57
+ const m = ms[0];
58
+ const up = resolve(m.from, depth + 1);
59
+ return { prefix: joinPath(up?.prefix ?? '', m.prefix), protections: [...(up?.protections ?? []), ...m.protections] };
60
+ };
61
+ const out = new Map<string, { prefix: string; protections: Protection[] }>();
62
+ for (const t of byTarget.keys()) { const r = resolve(t, 0); if (r) out.set(t, r); }
63
+ return out;
64
+ }
65
+
66
+ export function buildCodeMap(ctx: CheckContext): CodeMap {
67
+ const repo = new Repo(ctx);
68
+ const all: Extracted = { entries: [], gates: [], mounts: [] };
69
+ const merge = (x: Extracted): void => { all.entries.push(...x.entries); all.gates.push(...x.gates); all.mounts.push(...x.mounts); };
70
+ // A file the extractor throws on is skipped and counted, so the coverage row can say so.
71
+ const failed: { file: string; message: string }[] = [];
72
+ const guard = (file: string, f: () => Extracted): void => { try { merge(f()); } catch (e) { failed.push({ file, message: (e as Error).message }); } };
73
+ for (const file of ctx.files) {
74
+ const lang = langOf(file);
75
+ if (!lang) continue;
76
+ const v = repo.view(file);
77
+ if (!v) continue;
78
+ if (lang === 'js') {
79
+ if (repo.isClient(v)) { if (/(?:^|\/)page\.[cm]?[jt]sx?$/.test(file)) guard(file, () => extractNext(repo, v)); continue; }
80
+ guard(file, () => extractNext(repo, v));
81
+ guard(file, () => extractExpress(repo, v));
82
+ guard(file, () => extractNest(repo, v));
83
+ guard(file, () => extractJobs(repo, v));
84
+ guard(file, () => extractAiTools(repo, v));
85
+ } else if (lang === 'py') guard(file, () => extractPython(repo, v));
86
+ }
87
+ guard('(django url configuration)', () => extractDjango(repo));
88
+ if (goFiles(repo).length) guard('(go routes)', () => extractGoAll(repo, new FnIndex(repo, 'go'), repo.local));
89
+ if (railsFiles(repo).routes.length) guard('(rails routes)', () => extractRailsAll(repo, new FnIndex(repo, 'rb'), repo.local));
90
+ if (laravelFiles(repo).length) guard('(laravel routes)', () => extractLaravelAll(repo, new FnIndex(repo, 'php'), repo.local));
91
+ if (springFiles(repo).length) guard('(spring controllers)', () => extractSpring(repo, new FnIndex(repo, 'java'), repo.local));
92
+ if (dotnetFiles(repo).length) guard('(aspnet routes)', () => extractDotnet(repo, new FnIndex(repo, 'cs'), repo.local));
93
+ guard('(android and ios configuration)', () => extractMobile(repo));
94
+ if (rustFiles(repo).length) guard('(rust routes)', () => extractRust(repo));
95
+ guard('(expo router)', () => extractExpo(repo));
96
+
97
+ // Router mounts: routes registered in a mounted file live under the mount prefix and inherit its protections.
98
+ const chains = prefixChain(all.mounts);
99
+ for (const ep of all.entries) {
100
+ const c = chains.get(ep.file);
101
+ if (!c || !ROUTE_FW.has(ep.framework) || ep.path === null) continue;
102
+ ep.path = joinPath(c.prefix, ep.path);
103
+ if (ep.confidence === 'high') ep.confidence = 'medium';
104
+ addProtections(ep, c.protections.map((p) => ({ ...p, scope: 'router' as const })));
105
+ }
106
+
107
+ // Gates: middleware and guards that cover an entry point by path, file order or framework.
108
+ const gates: Gate[] = all.gates.slice().sort((a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.id, b.id));
109
+ for (const ep of all.entries) {
110
+ if (!WEB_KINDS.has(ep.kind) && ep.kind !== 'server_action' && ep.kind !== 'graphql_resolver' && ep.kind !== 'rpc') continue;
111
+ for (const g of gates) {
112
+ if (g.framework === 'spring' || g.framework === 'aspnet') continue; // the extractor applied these rules itself, in order, first match wins
113
+ if (g.framework === 'express' && (g.file !== ep.file || g.line > ep.line)) continue;
114
+ if (g.framework === 'next-app' && !(ep.framework === 'next-app' || ep.framework === 'next-pages') || (g.framework === 'next-app' && ep.kind === 'server_action')) continue;
115
+ if (g.framework === 'flask' && ep.framework !== 'flask' && ep.framework !== 'quart') continue;
116
+ if (g.framework === 'nest' && ep.framework !== 'nest') continue;
117
+ if (g.framework === 'django' && ep.framework !== 'django') continue;
118
+ if (g.methods.length && ep.method && ep.method !== 'ANY' && !g.methods.includes(ep.method)) continue;
119
+ if (ep.path === null && !g.paths.includes('*')) continue;
120
+ if (!g.paths.some((p) => ep.path === null ? p === '*' : inPrefix(ep.path!, p))) continue;
121
+ const cap = g.paths.includes('*') || g.kind === 'guard' || g.kind === 'global_pipe' ? g.confidence : 'medium';
122
+ addProtections(ep, g.protections.map((p) => ({ ...p, scope: g.kind === 'guard' || g.kind === 'global_pipe' ? 'global' as const : p.scope === 'router' ? 'router' as const : 'middleware' as const, confidence: confOf(p.confidence, cap) })));
123
+ }
124
+ }
125
+
126
+ // Vercel cron paths: an entry point called by the platform scheduler.
127
+ try {
128
+ const vj = JSON.parse(ctx.text('vercel.json') || '{}') as { crons?: { path?: string }[] };
129
+ for (const c of vj.crons ?? []) for (const ep of all.entries) if (c.path && ep.path && ep.path === joinPath('', c.path.split('?')[0])) ep.tags = [...new Set([...ep.tags, 'cron'])].sort();
130
+ } catch { /* a vercel.json that does not parse adds no cron tags */ }
131
+
132
+ const seen = new Map<string, EntryPoint>();
133
+ for (const ep of all.entries) if (!seen.has(ep.id)) seen.set(ep.id, ep);
134
+ const entries = [...seen.values()].sort((a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.method ?? '', b.method ?? '') || cmp(a.path ?? '', b.path ?? '') || cmp(a.name ?? '', b.name ?? '') || cmp(a.kind, b.kind));
135
+
136
+ const by = (key: (e: EntryPoint) => string): Record<string, number> => { const o: Record<string, number> = {}; for (const e of entries) o[key(e)] = (o[key(e)] ?? 0) + 1; return Object.fromEntries(Object.entries(o).sort(([a], [b]) => cmp(a, b))); };
137
+ const facts = mobileFacts(repo);
138
+ return {
139
+ schema_version: '1.0.0', engine_version: ENGINE_VERSION, commit: ctx.stack.commit,
140
+ coverage: coverage(ctx, entries, failed, facts, repo), entry_points: entries,
141
+ gates: gates.map((g) => ({ ...g, protections: g.protections.slice().sort((a, b) => a.line - b.line || cmp(a.kind, b.kind) || cmp(a.via, b.via)) })),
142
+ config: { supabase: supabaseMap(repo), firebase: firebaseMap(repo), android: facts.android, ios: facts.ios, cargo: cargoConfig(repo), secrets: facts.secrets, local_vocab: { auth_helpers: repo.local.auth_helpers, role_helpers: repo.local.role_helpers }, auth_helper_candidates: authHelperCandidates(repo, entries) },
143
+ counts: { entry_points: entries.length, by_kind: by((e) => e.kind), by_framework: by((e) => e.framework) },
144
+ };
145
+ }
146
+
147
+ function coverage(ctx: CheckContext, entries: EntryPoint[], failed: { file: string; message: string }[], facts: ReturnType<typeof mobileFacts>, repo: Repo): Coverage[] {
148
+ const frameworks = new Set([...ctx.stack.frameworks.map((f) => f.id), ...(ctx.stack.mobile ?? []).map((f) => f.id)]);
149
+ const rows: Coverage[] = [];
150
+ const nodeTotal = entries.filter((e) => /\.[cm]?[jt]sx?$/.test(e.file)).length;
151
+ const count = (re: RegExp): number => entries.filter((e) => re.test(e.file)).length;
152
+ for (const l of ctx.stack.languages) {
153
+ const files = l.file_count ?? 0;
154
+ if (l.id === 'javascript' || l.id === 'typescript') {
155
+ const n = count(l.id === 'typescript' ? /\.[cm]?tsx?$/ : /\.[cm]?jsx?$/);
156
+ const unread = UNREAD['node-ts'].filter((f) => frameworks.has(f));
157
+ rows.push({ language: l.id, files, extractor: 'node-ts', status: unread.length ? 'partial' : 'mapped', entry_points: n, note: unread.length ? `Frameworks seen that this extractor does not read: ${unread.join(', ')}. Entry points they register may be missing.` : n === 0 && nodeTotal === 0 && READ_BY['node-ts'].some((f) => frameworks.has(f) && f !== 'react') ? 'A web framework was detected and no entry point was found. The routes may be registered in a way the extractor does not read.' : frameworks.has('expo') ? 'Next.js, Express family, NestJS, tRPC, Expo Router screens, API routes and deep links, jobs and AI tools are read.' : 'Next.js, Express family, NestJS, tRPC, jobs and AI tools are read.' });
158
+ } else if (l.id === 'python') {
159
+ const n = count(/\.py$/);
160
+ const unread = UNREAD.python.filter((f) => frameworks.has(f));
161
+ rows.push({ language: l.id, files, extractor: 'python', status: unread.length ? 'partial' : 'mapped', entry_points: n, note: unread.length ? `Frameworks seen that this extractor does not read: ${unread.join(', ')}. Entry points they register may be missing.` : n === 0 && READ_BY.python.some((f) => frameworks.has(f)) ? 'A web framework was detected and no entry point was found. The routes may be registered in a way the extractor does not read.' : 'Flask, FastAPI, Django and REST framework, Celery and scheduler jobs, and AI tools are read.' });
162
+ } else if (l.id === 'go' || l.id === 'ruby' || l.id === 'php') {
163
+ const ext = l.id === 'go' ? /\.go$/ : l.id === 'ruby' ? /\.rb$/ : /\.php$/;
164
+ const n = count(ext);
165
+ const unread = UNREAD[l.id].filter((f) => frameworks.has(f));
166
+ const readSeen = READ_BY[l.id].filter((f) => frameworks.has(f));
167
+ const name = l.id === 'go' ? 'go' : l.id === 'ruby' ? 'rails' : 'laravel';
168
+ const what = l.id === 'go' ? 'net/http, chi, gin, echo, fiber and gorilla/mux routes' : l.id === 'ruby' ? 'Rails routes (config/routes.rb) and controller filters' : 'Laravel routes (routes/*.php) and controller middleware';
169
+ let status: Coverage['status'] = unread.length ? 'partial' : 'mapped';
170
+ let note = unread.length ? `Frameworks seen that this extractor does not read: ${unread.join(', ')}. Entry points they register may be missing.` : `${what[0].toUpperCase()}${what.slice(1)} are read.`;
171
+ if (l.id !== 'go' && !readSeen.length && n === 0 && !unread.length) { status = 'partial'; note = `No ${l.id === 'ruby' ? 'Rails' : 'Laravel'} project was found, so no routes were read. Web code in ${l.id} files is not in this map.`; }
172
+ else if (readSeen.length && n === 0) note = 'A web framework was detected and no entry point was found. The routes may be registered in a way the extractor does not read.';
173
+ rows.push({ language: l.id, files, extractor: name, status, entry_points: n, note });
174
+ } else if (l.id === 'java') {
175
+ const n = count(/\.java$/);
176
+ const unread = UNREAD.java.filter((f) => frameworks.has(f));
177
+ const springSeen = READ_BY.java.some((f) => frameworks.has(f));
178
+ rows.push({ language: 'java', files, extractor: 'spring', status: unread.length || !springSeen ? 'partial' : 'mapped', entry_points: n,
179
+ note: unread.length ? `Frameworks seen that this extractor does not read: ${unread.join(', ')}. Entry points they register may be missing.` : !springSeen ? 'No Spring project was found, so no routes were read. Web code in Java files is not in this map.' : n === 0 ? 'A web framework was detected and no entry point was found. The routes may be registered in a way the extractor does not read.' : 'Spring MVC controllers (@RestController, @Controller and the mapping annotations) are read, with method security annotations and the Spring Security filter chain rules.' });
180
+ } else if (l.id === 'csharp') {
181
+ const n = count(/\.cs$/);
182
+ const aspSeen = READ_BY.csharp.some((f) => frameworks.has(f));
183
+ rows.push({ language: 'csharp', files, extractor: 'aspnet', status: aspSeen ? 'mapped' : 'partial', entry_points: n,
184
+ note: !aspSeen ? 'No ASP.NET Core project was found, so no routes were read. Web code in C# files is not in this map.' : n === 0 ? 'A web framework was detected and no entry point was found. The routes may be registered in a way the extractor does not read.' : 'ASP.NET Core controllers ([Route] and the [HttpGet] family) and minimal APIs (MapGet and the others, with MapGroup) are read, with [Authorize], [AllowAnonymous], RequireAuthorization and a fallback policy.' });
185
+ } else if (l.id === 'kotlin' || l.id === 'swift' || l.id === 'rust') {
186
+ const fw = l.id === 'kotlin' ? 'android' : l.id === 'swift' ? 'ios' : 'axum|actix-web';
187
+ const n = entries.filter((e) => new RegExp(`^(?:${fw})$`).test(e.framework)).length;
188
+ const what = l.id === 'kotlin' ? 'AndroidManifest.xml (exported components and deep links) and the network security configuration' : l.id === 'swift' ? 'Info.plist (URL schemes, App Transport Security) and the entitlements (associated domains)' : 'Cargo.toml, and axum and actix web routes';
189
+ const read = l.id === 'rust' ? rustFiles(repo).length > 0 || (cargoConfig(repo)?.manifests.length ?? 0) > 0 : l.id === 'kotlin' ? (facts.android?.manifests.length ?? 0) > 0 : (facts.ios?.plists.length ?? 0) > 0;
190
+ rows.push(read
191
+ ? { language: l.id, files, extractor: l.id === 'kotlin' ? 'android' : l.id === 'swift' ? 'ios' : 'rust', status: 'partial', entry_points: n, note: `${what[0].toUpperCase()}${what.slice(1)} are read. ${l.id === 'rust' ? 'Handler bodies are not read.' : 'The source files are not read, so what a handler does with an incoming intent or URL is not in this map.'}` }
192
+ : { language: l.id, files, extractor: null, status: 'not_mapped', entry_points: 0, note: `No extractor input found for ${l.id}${(UNREAD[l.id] ?? []).filter((f) => frameworks.has(f)).length ? ` (frameworks seen: ${(UNREAD[l.id] ?? []).filter((f) => frameworks.has(f)).join(', ')})` : ''}. Entry points in ${l.id} files are not in this map.` });
193
+ } else if (SERVER_LANGS.has(l.id)) {
194
+ const seen = (UNREAD[l.id] ?? []).filter((f) => frameworks.has(f));
195
+ rows.push({ language: l.id, files, extractor: null, status: 'not_mapped', entry_points: 0, note: `No extractor for ${l.id} yet${seen.length ? ` (frameworks seen: ${seen.join(', ')})` : ''}. Entry points in ${l.id} files are not in this map.` });
196
+ }
197
+ }
198
+ // Files an extractor threw on: the language they belong to is only partly mapped.
199
+ const langOfFailed = (file: string): string => (/\.py$/.test(file) || /django/.test(file) ? 'python' : /\.go$|\(go /.test(file) ? 'go' : /\.rb$|\(rails /.test(file) ? 'ruby' : /\.php$|\(laravel /.test(file) ? 'php' : /\.tsx?$/.test(file) ? 'typescript' : 'javascript');
200
+ for (const f of failed) {
201
+ const lang = langOfFailed(f.file);
202
+ const row = rows.find((r) => r.language === lang) ?? rows[0];
203
+ if (row && !row.note.includes('could not be read')) { row.status = 'partial'; row.note += ` ${failed.filter((x) => langOfFailed(x.file) === lang).length} file(s) could not be read by the extractor, first: ${f.file}.`; }
204
+ }
205
+ return rows.sort((a, b) => cmp(a.language, b.language));
206
+ }
@@ -0,0 +1,47 @@
1
+ // Names in entry points that look like sign in helpers and that the vocabulary did not recognise. The owner reads the list
2
+ // and copies the real ones into auth_helpers in .hullproof/kit.local.json (see local-vocab.ts). Only entry points that
3
+ // change state or read data and show no session read are looked at, so a list on a repository the map already understands
4
+ // is short or empty. The list is sorted by how many entry points use the name, then by name.
5
+ import type { AuthHelperCandidate, EntryPoint } from '../types/index.ts';
6
+ import type { Repo } from './repo.ts';
7
+
8
+ const AUTHISH = /auth|login|logged|session|token|jwt|bearer|guard|protect|permission|member|tenant|signed|credential|principal|identity|viewer|whoami|current|require|ensure|verify|owner|account|user/i;
9
+ const SKIP = /^(?:console|log|logger|status|json|send|redirect|render|parse|stringify|then|catch|require|import|function|return|if|for|while|switch|await|async|new|super|print|puts|len|str|int|dict|list|set|get|post|put|patch|delete|use|map|filter|reduce|find|from|select|where|insert|update|create|emit|on|once|next|type|isinstance|self|this|params|param|query|body|request|response|req|res|ctx|c|r|w|id|name|email|password|user|users|account|accounts|session|token|userId|user_id|accountId|find_by|find_by_id|findOne|findById|first|last|all|count|exists|save|destroy|build|new_record|dispatch|useState|useEffect|useMemo|useCallback|useRef|useRouter|useParams|useSearchParams|useLocalSearchParams|useNavigation|useRoute|useContext|useQuery|useMutation|useTranslation|useForm|useWatch|useTheme|useColorScheme|useWindowDimensions)$/;
10
+ const MAX = 25;
11
+
12
+ export function authHelperCandidates(repo: Repo, entries: EntryPoint[]): AuthHelperCandidate[] {
13
+ const seen = new Map<string, { n: number; file: string; line: number }>();
14
+ const judged = entries.filter((e) => !e.test_code && ['http_route', 'server_action', 'graphql_resolver', 'rpc'].includes(e.kind) && e.kind !== 'webhook' && (e.tags.includes('state_changing') || e.data.length > 0)
15
+ && !e.tags.includes('auth_endpoint') && !e.tags.includes('public') && !e.protections.some((p) => p.kind === 'session_read' && p.confidence !== 'low' && p.enforced !== false));
16
+ for (const e of judged) {
17
+ const file = e.handler?.file ?? e.file;
18
+ const first = e.handler?.line ?? e.line;
19
+ const lines = repo.ctx.text(file).split('\n').slice(Math.max(0, first - 4), Math.max(first, e.end_line));
20
+ const names = new Set<string>();
21
+ const ruby = /\.rb$/.test(file);
22
+ if (ruby) {
23
+ // Class level filters of the controller that run before this action.
24
+ const action = e.name?.split('#')[1];
25
+ for (const fl of repo.ctx.text(file).matchAll(/^[ \t]*(?:prepend_)?before_action\b([^\n]*)$/gm)) {
26
+ const only = /\bonly:\s*(%i\[([^\]]*)\]|\[([^\]]*)\]|:(\w+))/.exec(fl[1]);
27
+ const except = /\bexcept:\s*(%i\[([^\]]*)\]|\[([^\]]*)\]|:(\w+))/.exec(fl[1]);
28
+ const list = (m: RegExpExecArray | null): string[] => (m ? (m[2] ?? m[3] ?? m[4] ?? '').split(/[\s,]+/).map((x) => x.replace(/^:/, '')).filter(Boolean) : []);
29
+ if (action && ((only && !list(only).includes(action)) || (except && list(except).includes(action)))) continue;
30
+ for (const n of fl[1].replace(/\b(?:only|except|if|unless)\s*:.*$/, '').matchAll(/:(\w+[?!]?)/g)) names.add(n[1]);
31
+ }
32
+ }
33
+ for (const l of lines) {
34
+ if (/^\s*(?:func|def|function|public|private|protected|class|module|before_action|prepend_before_action|skip_before_action|skip_before_filter|allow_unauthenticated_access)\b/.test(l)) continue;
35
+ const code = l.replace(/\/\/.*$|#(?!\[).*$/, '');
36
+ for (const m of code.matchAll(ruby ? /(?:@|->|::|\.|\b)([A-Za-z_$][\w$]*[!?]?)\s*(?:\(|\b(?=\s*$)|(?=\s*,|\s*\)))/g : /(?:@|->|::|\.|\b)([A-Za-z_$][\w$]*[!?]?)\s*(?:\(|(?=\s*,|\s*\)))/g)) names.add(m[1]);
37
+ for (const m of code.matchAll(/:(\w+[!?]?)/g)) if (/^\s*(?:before_action|skip_before_action)/.test(code)) names.add(m[1]);
38
+ }
39
+ for (const n of names) {
40
+ if (n.length < 4 || SKIP.test(n) || !AUTHISH.test(n)) continue;
41
+ const cur = seen.get(n);
42
+ if (cur) cur.n++; else seen.set(n, { n: 1, file: e.file, line: e.line });
43
+ }
44
+ }
45
+ return [...seen.entries()].map(([name, v]) => ({ name, entry_points: v.n, file: v.file, line: v.line }))
46
+ .sort((a, b) => b.entry_points - a.entry_points || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)).slice(0, MAX);
47
+ }
@@ -0,0 +1,55 @@
1
+ // Shared reading helpers for the annotation based languages (Java and Kotlin in Spring, C# attributes): split a class body into
2
+ // declaration blocks, each with the annotations in front of it, and read string literals out of annotation arguments. Works on masked
3
+ // text for structure and on the original text for the string values, which sit at the same positions.
4
+ import { closeOf } from './lex.ts';
5
+
6
+ export interface Anno { name: string; start: number; end: number; args: { start: number; end: number } | null }
7
+ export interface Block { annos: Anno[]; start: number; end: number; declStart: number }
8
+
9
+ // Annotations of the form @Name or @Name(...) at brace depth 0 of the range [from, to), grouped into blocks of consecutive annotations.
10
+ // `declStart` is the first character after the last annotation of the block (the declaration the annotations belong to).
11
+ export function annotationBlocks(masked: string, from: number, to: number): Block[] {
12
+ const out: Block[] = [];
13
+ let depth = 0;
14
+ let cur: Block | null = null;
15
+ for (let i = from; i < to; i++) {
16
+ const c = masked[i];
17
+ if (c === '{' || c === '(' || c === '[') { depth++; continue; }
18
+ if (c === '}' || c === ')' || c === ']') { depth--; continue; }
19
+ if (depth !== 0 || c !== '@') continue;
20
+ const m = /^@([A-Za-z_][\w.]*)/.exec(masked.slice(i, i + 80));
21
+ if (!m) continue;
22
+ let end = i + m[0].length;
23
+ let args: Anno['args'] = null;
24
+ let k = end;
25
+ while (/[ \t]/.test(masked[k] ?? '')) k++;
26
+ if (masked[k] === '(') {
27
+ const close = closeOf(masked, k);
28
+ if (close > 0) { args = { start: k + 1, end: close - 1 }; end = close; }
29
+ }
30
+ const gap = cur ? masked.slice(cur.annos[cur.annos.length - 1].end, i) : '';
31
+ if (cur && /^\s*$/.test(gap)) cur.annos.push({ name: m[1], start: i, end, args });
32
+ else { cur = { annos: [{ name: m[1], start: i, end, args }], start: i, end: 0, declStart: 0 }; out.push(cur); }
33
+ i = end - 1;
34
+ }
35
+ for (const b of out) { b.end = b.annos[b.annos.length - 1].end; let d = b.end; while (/\s/.test(masked[d] ?? '')) d++; b.declStart = d; }
36
+ return out;
37
+ }
38
+
39
+ // All "string" literals in the text, with the escapes of a quoted string left as written.
40
+ export const strings = (text: string): string[] => [...text.matchAll(/"((?:[^"\\\n]|\\.)*)"/g)].map((m) => m[1]);
41
+
42
+ // Top level comma split of a parameter or argument list (commas inside (), [], {} and <> do not split).
43
+ export function splitTop(text: string): string[] {
44
+ const out: string[] = [];
45
+ let depth = 0;
46
+ let from = 0;
47
+ for (let i = 0; i < text.length; i++) {
48
+ const c = text[i];
49
+ if (c === '(' || c === '[' || c === '{' || c === '<') depth++;
50
+ else if (c === ')' || c === ']' || c === '}' || (c === '>' && text[i - 1] !== '=' && text[i - 1] !== '-')) depth = Math.max(0, depth - 1);
51
+ else if (c === ',' && depth === 0) { out.push(text.slice(from, i)); from = i + 1; }
52
+ }
53
+ if (text.slice(from).trim()) out.push(text.slice(from));
54
+ return out.map((x) => x.trim()).filter(Boolean);
55
+ }
@@ -0,0 +1,207 @@
1
+ // Configuration maps: Supabase tables from migration SQL (row level security, policies, grants, columns) and Firebase rules
2
+ // (Firestore, Storage and Realtime Database) as one record per match path.
3
+ import type { FirebaseRule, SupabaseTable } from '../types/index.ts';
4
+ import { cmp } from '../util.ts';
5
+ import { Lines } from './lex.ts';
6
+ import type { Repo } from './repo.ts';
7
+
8
+ interface Stmt { text: string; line: number }
9
+
10
+ // SQL statements with their start line. Comments are dropped, and ";" inside quotes, dollar quoted bodies and brackets does not split.
11
+ export function sqlStatements(sql: string): Stmt[] {
12
+ const lines = new Lines(sql);
13
+ const out: Stmt[] = [];
14
+ let i = 0;
15
+ let start = 0;
16
+ let depth = 0;
17
+ const n = sql.length;
18
+ let buf = '';
19
+ const flush = (end: number): void => {
20
+ const t = buf.trim();
21
+ if (t) out.push({ text: t, line: lines.at(start + (buf.length - buf.trimStart().length)) });
22
+ buf = '';
23
+ start = end;
24
+ };
25
+ while (i < n) {
26
+ const c = sql[i];
27
+ if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') i++; continue; }
28
+ if (c === '/' && sql[i + 1] === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
29
+ if (c === "'") { let j = i + 1; while (j < n && !(sql[j] === "'" && sql[j + 1] !== "'")) { if (sql[j] === "'" ) j++; j++; } buf += sql.slice(i, j + 1); i = j + 1; continue; }
30
+ if (c === '$') {
31
+ const tag = /^\$[A-Za-z_]*\$/.exec(sql.slice(i, i + 40));
32
+ if (tag) { const e = sql.indexOf(tag[0], i + tag[0].length); const end = e < 0 ? n : e + tag[0].length; buf += sql.slice(i, end); i = end; continue; }
33
+ }
34
+ if (c === '(') depth++;
35
+ else if (c === ')') depth--;
36
+ if (c === ';' && depth <= 0) { i++; flush(i); depth = 0; continue; }
37
+ buf += c;
38
+ i++;
39
+ }
40
+ flush(n);
41
+ return out;
42
+ }
43
+
44
+ const IDENT = String.raw`(?:"[^"]+"|[A-Za-z_][\w$]*)`;
45
+ const QNAME = String.raw`(${IDENT}(?:\s*\.\s*${IDENT})?)`;
46
+ const unq = (s: string): string => s.replace(/"/g, '').replace(/\s+/g, '').replace(/^public\./i, '');
47
+
48
+ function splitTop(s: string, sep = ','): string[] {
49
+ const out: string[] = [];
50
+ let depth = 0;
51
+ let cur = '';
52
+ for (const c of s) {
53
+ if (c === '(') depth++;
54
+ else if (c === ')') depth--;
55
+ if (c === sep && depth === 0) { out.push(cur); cur = ''; } else cur += c;
56
+ }
57
+ if (cur.trim()) out.push(cur);
58
+ return out;
59
+ }
60
+ function parenBody(s: string, from: number): string | null {
61
+ const open = s.indexOf('(', from);
62
+ if (open < 0) return null;
63
+ let depth = 0;
64
+ for (let i = open; i < s.length; i++) { if (s[i] === '(') depth++; else if (s[i] === ')') { depth--; if (depth === 0) return s.slice(open + 1, i); } }
65
+ return null;
66
+ }
67
+
68
+ export function supabaseMap(repo: Repo): { tables: SupabaseTable[] } | null {
69
+ const files = repo.ctx.files.filter((f) => /(?:^|\/)supabase\/(?:migrations\/.*|schema|seed)\.sql$|(?:^|\/)migrations\/.*\.sql$|(?:^|\/)schema\.sql$/i.test(f) && !/seed/i.test(f)).sort(cmp);
70
+ if (!files.length) return null;
71
+ const tables = new Map<string, SupabaseTable>();
72
+ const get = (name: string, file: string, line: number): SupabaseTable => {
73
+ let t = tables.get(name);
74
+ if (!t) { t = { name, file, line, rls_enabled: null, columns: [], policies: [], grants: [] }; tables.set(name, t); }
75
+ return t;
76
+ };
77
+ for (const file of files) {
78
+ for (const st of sqlStatements(repo.ctx.text(file))) {
79
+ const s = st.text;
80
+ let m: RegExpExecArray | null;
81
+ if ((m = new RegExp(String.raw`^create\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?${QNAME}`, 'i').exec(s))) {
82
+ const name = unq(m[1]);
83
+ const t = get(name, file, st.line);
84
+ t.file = file; t.line = st.line; t.policies = []; t.grants = []; t.rls_enabled = null;
85
+ const body = parenBody(s, m[0].length) ?? '';
86
+ t.columns = splitTop(body).map((c) => c.trim()).filter((c) => c && !/^(?:primary\s+key|foreign\s+key|unique|constraint|check|like|exclude)\b/i.test(c)).map((c) => unq(/^(?:"[^"]+"|[A-Za-z_][\w$]*)/.exec(c)?.[0] ?? '')).filter(Boolean).sort();
87
+ } else if ((m = new RegExp(String.raw`^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${QNAME}\s+(enable|disable|force|no\s+force)\s+row\s+level\s+security`, 'i').exec(s))) {
88
+ const t = get(unq(m[1]), file, st.line);
89
+ const w = m[2].toLowerCase();
90
+ if (w === 'enable') t.rls_enabled = true; else if (w === 'disable') t.rls_enabled = false;
91
+ } else if ((m = new RegExp(String.raw`^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${QNAME}\s+add\s+column\s+(?:if\s+not\s+exists\s+)?(${IDENT})`, 'i').exec(s))) {
92
+ const t = get(unq(m[1]), file, st.line);
93
+ t.columns = [...new Set([...t.columns, unq(m[2])])].sort();
94
+ } else if ((m = new RegExp(String.raw`^create\s+policy\s+(${IDENT}|'[^']*')\s+on\s+${QNAME}`, 'i').exec(s))) {
95
+ const t = get(unq(m[2]), file, st.line);
96
+ const rest = s.slice(m[0].length);
97
+ const op = /\bfor\s+(all|select|insert|update|delete)\b/i.exec(rest)?.[1]?.toLowerCase() ?? 'all';
98
+ const rolesText = /\bto\s+([\w",\s]+?)(?=\s+(?:using|with)\b|$)/i.exec(rest)?.[1] ?? 'public';
99
+ const roles = rolesText.split(',').map((r) => r.trim().replace(/"/g, '').toLowerCase()).filter(Boolean);
100
+ const u = /\busing\s*\(/i.exec(rest);
101
+ const c = /\bwith\s+check\s*\(/i.exec(rest);
102
+ t.policies.push({
103
+ name: m[1].replace(/["']/g, ''), op, roles: roles.sort(), using: u ? (parenBody(rest, u.index) ?? '').replace(/\s+/g, ' ').trim() : null,
104
+ check: c ? (parenBody(rest, c.index) ?? '').replace(/\s+/g, ' ').trim() : null, line: st.line, permissive: !/\bas\s+restrictive\b/i.test(rest),
105
+ });
106
+ } else if ((m = new RegExp(String.raw`^drop\s+policy\s+(?:if\s+exists\s+)?(${IDENT}|'[^']*')\s+on\s+${QNAME}`, 'i').exec(s))) {
107
+ const t = tables.get(unq(m[2]));
108
+ if (t) t.policies = t.policies.filter((p) => p.name !== m![1].replace(/["']/g, ''));
109
+ } else if ((m = new RegExp(String.raw`^drop\s+table\s+(?:if\s+exists\s+)?${QNAME}`, 'i').exec(s))) {
110
+ tables.delete(unq(m[1]));
111
+ } else if ((m = /^grant\s+([\s\S]+?)\s+on\s+(?:table\s+)?([\s\S]+?)\s+to\s+([\s\S]+?)(?:\s+with\s+grant\s+option)?$/i.exec(s)) && !/^grant\s+\w+\s+to\s/i.test(s) && !/\bon\s+(?:function|sequence|schema|type|all\s+functions|all\s+sequences)/i.test(s)) {
112
+ const all = /^all\s+tables\s+in\s+schema/i.test(m[2]);
113
+ const targets = all ? ['*'] : splitTop(m[2]).map((x) => unq(x.trim().replace(/^table\s+/i, '')));
114
+ const privs = splitTop(m[1]).map((p) => p.trim()).filter(Boolean).map((p) => {
115
+ const pm = /^(all(?:\s+privileges)?|select|insert|update|delete|truncate|references|trigger)\s*(?:\(([^)]*)\))?$/i.exec(p);
116
+ return pm ? { priv: pm[1].toLowerCase().replace(/\s+privileges/, ''), cols: pm[2] ? pm[2].split(',').map((c) => c.trim().replace(/"/g, '')).sort() : null } : null;
117
+ }).filter((x): x is { priv: string; cols: string[] | null } => x !== null);
118
+ const roles = splitTop(m[3]).map((r) => r.trim().replace(/"/g, '').toLowerCase());
119
+ for (const tn of targets) {
120
+ const t = get(tn, file, st.line);
121
+ for (const role of roles) {
122
+ for (const p of privs) t.grants.push({ role, privileges: [p.priv], columns: p.cols, line: st.line });
123
+ }
124
+ }
125
+ } else if ((m = /^revoke\s+([\s\S]+?)\s+on\s+(?:table\s+)?([\s\S]+?)\s+from\s+([\s\S]+)$/i.exec(s))) {
126
+ const targets = splitTop(m[2]).map((x) => unq(x.trim().replace(/^table\s+/i, '')));
127
+ const roles = splitTop(m[3]).map((r) => r.trim().replace(/"/g, '').toLowerCase());
128
+ const privs = splitTop(m[1]).map((p) => p.trim().toLowerCase().replace(/\s+privileges/, '').replace(/\s*\(.*\)/, ''));
129
+ for (const tn of targets) {
130
+ const t = tables.get(tn);
131
+ if (!t) continue;
132
+ t.grants = t.grants.filter((g) => !(roles.includes(g.role) && (privs.includes('all') || g.privileges.some((p) => privs.includes(p)))));
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // Default privileges and schema wide grants ("*") apply to every table.
138
+ const star = tables.get('*');
139
+ tables.delete('*');
140
+ const list = [...tables.values()].filter((t) => t.name !== '').sort((a, b) => cmp(a.name, b.name));
141
+ if (star) for (const t of list) t.grants = [...t.grants, ...star.grants.map((g) => ({ ...g }))];
142
+ for (const t of list) {
143
+ t.policies.sort((a, b) => a.line - b.line || cmp(a.name, b.name));
144
+ t.grants.sort((a, b) => cmp(a.role, b.role) || cmp(a.privileges[0], b.privileges[0]) || a.line - b.line);
145
+ }
146
+ return { tables: list };
147
+ }
148
+
149
+ const OWNER_VAR = /uid|user_?id|owner|account|author|member|tenant/i;
150
+
151
+ export function firebaseMap(repo: Repo): { rules: FirebaseRule[] } | null {
152
+ const files = repo.ctx.files.filter((f) => /(?:^|\/)[^/]*\.rules$|(?:^|\/)database\.rules\.json$|(?:^|\/)[^/]*\.rules\.json$/.test(f)).sort(cmp);
153
+ if (!files.length) return null;
154
+ const rules: FirebaseRule[] = [];
155
+ for (const file of files) {
156
+ const text = repo.ctx.text(file);
157
+ if (/\.json$/.test(file)) { realtimeRules(file, text, rules); continue; }
158
+ const service: FirebaseRule['service'] = /storage/i.test(file) || /service\s+firebase\.storage/.test(text) ? 'storage' : 'firestore';
159
+ // Rules files have // and /* */ comments and paths with slashes, which the JavaScript masker would read as regular expressions.
160
+ const masked = text.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, ' '));
161
+ const lines = new Lines(text);
162
+ const stack: { path: string; close: number }[] = [];
163
+ const re = /\bmatch\s+((?:\/(?:\{[^}]*\}|[^\s{/])*)+)\s*\{|\ballow\s+([\w,\s]+?)\s*(?::\s*if\s+([^;]*?))?\s*;/g;
164
+ for (let m = re.exec(masked); m; m = re.exec(masked)) {
165
+ while (stack.length && m.index >= stack[stack.length - 1].close) stack.pop();
166
+ if (m[1] !== undefined) {
167
+ const open = m.index + m[0].length - 1;
168
+ let depth = 0;
169
+ let close = masked.length;
170
+ for (let i = open; i < masked.length; i++) { if (masked[i] === '{') depth++; else if (masked[i] === '}') { depth--; if (depth === 0) { close = i; break; } } }
171
+ stack.push({ path: m[1].trim(), close });
172
+ } else {
173
+ const ops = m[2].split(',').map((o) => o.trim()).filter(Boolean).sort();
174
+ const cond = (text.slice(m.index, m.index + m[0].length).replace(/^allow\s+[\w,\s]+?\s*(?::\s*if\s+)?/, '').replace(/;$/, '')).replace(/\s+/g, ' ').trim();
175
+ const fullPath = stack.map((s) => s.path).join('').replace(/\/+/g, '/');
176
+ const wild = [...fullPath.matchAll(/\{(\w+)(?:=\*\*)?\}/g)].map((x) => x[1]);
177
+ const uidWild = wild.filter((w) => OWNER_VAR.test(w));
178
+ const ownerBound = new RegExp(`request\\.auth\\.uid\\s*==\\s*(?:${wild.join('|') || '@@'})\\b|\\b(?:${wild.join('|') || '@@'})\\s*==\\s*request\\.auth\\.uid|resource(?:\\.data)?\\.\\w+\\s*==\\s*request\\.auth\\.uid|request\\.resource\\.data\\.\\w+\\s*==\\s*request\\.auth\\.uid|request\\.auth\\.uid\\s+in\\s|\\b(?:isOwner|owns|isUser|isOwnerOf|belongsToUser)\\s*\\(`).test(cond)
179
+ || (/\b[a-z]\w*\s*\(/i.test(cond) && !/request\.auth\s*!=\s*null|request\.auth\s*==\s*null/.test(cond) && /owner|isUser|uid/i.test(cond));
180
+ const open = cond === '' || /^true$/.test(cond) || /^request\.time\s*<\s*timestamp\.date\(/.test(cond);
181
+ rules.push({ service, file, line: lines.at(m.index), path: fullPath || '/', ops, condition: cond.slice(0, 200), uses_auth: /request\.auth/.test(cond), owner_bound: ownerBound, always_true: open, owner_wildcard: uidWild.length > 0 });
182
+ }
183
+ }
184
+ }
185
+ return { rules: rules.sort((a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.ops.join(), b.ops.join())) };
186
+ }
187
+
188
+ function realtimeRules(file: string, text: string, out: FirebaseRule[]): void {
189
+ let json: unknown;
190
+ try { json = JSON.parse(text); } catch { return; }
191
+ const lines = new Lines(text);
192
+ let cursor = 0;
193
+ const walkNode = (node: unknown, path: string[]): void => {
194
+ if (!node || typeof node !== 'object') return;
195
+ for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
196
+ if ((k === '.read' || k === '.write') && (typeof v === 'string' || typeof v === 'boolean')) {
197
+ const cond = String(v);
198
+ const at = text.indexOf(`"${k}"`, cursor);
199
+ if (at >= 0) cursor = at + 1;
200
+ const wild = path.filter((p) => p.startsWith('$'));
201
+ const ownerBound = /\$\w+\s*===?\s*auth\.uid|auth\.uid\s*===?\s*\$\w+|\.val\(\)\s*===?\s*auth\.uid|auth\.uid\s*===?\s*(?:data|newData)/.test(cond);
202
+ out.push({ service: 'realtime', file, line: at >= 0 ? lines.at(at) : 1, path: '/' + path.join('/'), ops: [k.slice(1)], condition: cond.slice(0, 200), uses_auth: /\bauth\b/.test(cond), owner_bound: ownerBound, always_true: cond.trim() === 'true', owner_wildcard: wild.some((w) => OWNER_VAR.test(w)) });
203
+ } else if (!k.startsWith('.')) walkNode(v, [...path, k]);
204
+ }
205
+ };
206
+ walkNode((json as { rules?: unknown }).rules, []);
207
+ }