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,484 @@
1
+ // Body analysis for Go, Ruby and PHP handlers: protections, request inputs, data touches and outbound calls, from the
2
+ // vocabulary in light-profiles.ts. Same output as analyze.ts for JavaScript and Python, and the same stated limits: pattern
3
+ // reading inside one function, one hop into the repository's own helpers, request taint by assignment. See
4
+ // docs/PHASE-2-DESIGN.md sections 2 and 6.
5
+ import type { Confidence, DataTouch, InputRead, Outbound, Protection, ProtectionKind, Scope } from '../types/index.ts';
6
+ import type { Analysis, FileView, HelperRef } from './analyze.ts';
7
+ import { PROFILES, type Profile } from './light-profiles.ts';
8
+ import { closeOf } from './lex.ts';
9
+ import type { MoreLang } from './lex-more.ts';
10
+ import type { LocalVocab } from './local-vocab.ts';
11
+ import { OWNER, PRIVILEGED, REDIRECT_GUARD, URL_GUARD, type Pattern } from './vocab.ts';
12
+
13
+ export interface LightDeco { view: FileView; start: number; end: number; scope: Scope }
14
+ export interface LightSpec {
15
+ start: number; end: number; // the handler, parameters included
16
+ seeds: string[]; // names that hold request data on entry
17
+ decorators?: LightDeco[]; // middleware, filters and attributes outside the handler body
18
+ extra?: Protection[]; // protections the extractor read itself (a before_action, a route middleware)
19
+ lookup?: (name: string, fromFile: string) => HelperRef | null; // the repository's own functions, for the one hop
20
+ local?: LocalVocab | null;
21
+ policyScoped?: boolean; // a policy covers every record the handler loads (load_and_authorize_resource)
22
+ bound?: { name: string; target: string; owner?: boolean }[]; // records loaded by id before the handler runs (Laravel route model binding, a Rails set_x filter)
23
+ }
24
+
25
+ const CONF_RANK: Record<Confidence, number> = { high: 0, medium: 1, low: 2 };
26
+ const minConf = (a: Confidence, b: Confidence): Confidence => (CONF_RANK[a] >= CONF_RANK[b] ? a : b);
27
+ const usesText = (re: RegExp): boolean => /['"`]/.test(re.source);
28
+ const ID_COL = /(?:^|[^\w])(?:id|ID|Id|pk)(?![\w])|\w_id\b|\bid:|\buuid\s*(?::|=|\?)|\[:id\]|:id\b/;
29
+ const SQL_WORD = /\b(SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b/i;
30
+ const OWNER_MORE: Record<MoreLang, RegExp> = {
31
+ go: /UserID|OwnerID|userID|ownerID|TenantID|tenantID|AccountID|accountID|user_id|owner_id|tenant_id|team_id/,
32
+ rb: /current_user|current_account|current_member/,
33
+ php: /->user\b|\bAuth::(?:id|user)|auth\(\)->(?:id|user)|user_id|owner_id|tenant_id|team_id|account_id/,
34
+ java: /findBy\w*(?:Owner|User|Tenant|Account)\w*|\bgetOwner\w*\b|\bownerId\b|\bOwnerId\b/,
35
+ cs: /\bOwnerId\b|\bUserId\b|\bTenantId\b|\bAccountId\b/,
36
+ };
37
+ const ASSIGN: Record<MoreLang, RegExp> = {
38
+ go: /^\s*(?:var\s+)?([A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\s*(?::=|=)(?!=)\s*/,
39
+ rb: /^\s*((?:@{0,2}[A-Za-z_]\w*)(?:\s*,\s*@{0,2}[A-Za-z_]\w*)*)\s*(?:\|\|=|=)(?![=~>])\s*/,
40
+ php: /^\s*(\$[A-Za-z_]\w*(?:\s*,\s*\$[A-Za-z_]\w*)*|list\([^)]*\)|\[[^\]]*\])\s*(?:\.=|\?\?=|=)(?![=>])\s*/,
41
+ java: /^\s*(?:final\s+)?(?:[\w.]+(?:\s*<[^;=()]*>)?(?:\[\])*\s+)?([A-Za-z_]\w*)\s*=(?![=>])\s*/,
42
+ cs: /^\s*(?:await\s+)?(?:(?:var|[\w.]+(?:\s*<[^;=()]*>)?[?]?(?:\[\])*)\s+)?([A-Za-z_]\w*)\s*=(?![=>])\s*/,
43
+ };
44
+ const KEYWORDS = new Set(['nil', 'null', 'true', 'false', 'self', 'this', 'new', 'return', 'if', 'else', 'unless', 'do', 'end', 'func', 'var', 'string', 'int', 'bool', 'err', 'ok', 'ctx', 'then', 'and', 'or', 'not', 'array', 'isset', 'empty']);
45
+
46
+ // Logical statements of the region: a line, joined with the lines that continue it.
47
+ export function statements(m: string, from: number, to: number, lang: MoreLang): [number, number][] {
48
+ const out: [number, number][] = [];
49
+ let i = from;
50
+ while (i < to) {
51
+ while (i < to && /\s/.test(m[i])) i++;
52
+ if (i >= to) break;
53
+ let j = i;
54
+ let depth = 0;
55
+ for (; j < to; j++) {
56
+ const c = m[j];
57
+ if (c === '(' || c === '[') depth++;
58
+ else if (c === ')' || c === ']') depth = Math.max(0, depth - 1);
59
+ else if (c === ';' && depth === 0 && lang !== 'rb') { j++; break; }
60
+ else if ((c === '{' || c === '}') && depth === 0 && (lang === 'php' || lang === 'java' || lang === 'cs')) { j++; break; }
61
+ else if (c === '\n' && depth === 0) {
62
+ const prev = m.slice(i, j).trimEnd().slice(-1);
63
+ const rest = m.slice(j + 1, j + 200);
64
+ const nx = /\S/.exec(rest);
65
+ const nxt = nx ? rest.slice(nx.index, nx.index + 2) : '';
66
+ if (nx && (nxt[0] === '.' || nxt === '->' || nxt === '::' || nxt === '&&' || nxt === '||' || nxt[0] === '?' || (nxt[0] === ':' && lang !== 'rb' && nxt[1] !== ':'))) continue;
67
+ if (',+&|\\.(=?:'.includes(prev) && prev !== '' && !(lang === 'go' && prev === ':')) continue;
68
+ if (lang === 'rb' && /(?:^|\s)(?:and|or|not)$/.test(m.slice(i, j).trimEnd())) continue;
69
+ break;
70
+ }
71
+ }
72
+ out.push([i, Math.min(j, to)]);
73
+ i = j + 1;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ const idents = (p: Profile, expr: string): string[] => [...expr.matchAll(new RegExp(p.identRe.source, 'g'))].map((m) => m[1]).filter((x) => !KEYWORDS.has(x));
79
+
80
+ function pick(view: FileView, pats: Pattern[], from: number, to: number): { p: Pattern; index: number }[] {
81
+ const out: { p: Pattern; index: number }[] = [];
82
+ const seen = new Set<string>();
83
+ for (const pt of pats) {
84
+ pt.re.lastIndex = 0;
85
+ const src = usesText(pt.re) ? view.text : view.masked;
86
+ const g = new RegExp(pt.re.source, pt.re.flags.includes('g') ? pt.re.flags : pt.re.flags + 'g');
87
+ g.lastIndex = from;
88
+ for (let m = g.exec(src); m && m.index < to; m = g.exec(src)) {
89
+ if (m[0] === '') { g.lastIndex++; continue; }
90
+ const idx = m.index;
91
+ // A match that starts inside a comment (blank in the masked copy) does not count.
92
+ if (usesText(pt.re) && view.masked[idx] === ' ' && view.text[idx] !== ' ' && view.text[idx] !== '\n' && view.text[idx] !== '\t') continue;
93
+ if (seen.has(pt.via)) break;
94
+ seen.add(pt.via);
95
+ out.push({ p: pt, index: idx });
96
+ break;
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function protectionsIn(view: FileView, prof: Profile, local: LocalVocab | null, from: number, to: number, scope: Scope, viaPrefix = '', floor: Confidence | null = null, deco = false): Protection[] {
103
+ const kinds: [ProtectionKind, Pattern[]][] = [
104
+ ['session_read', [...prof.session, ...(deco ? prof.decoSession : []), ...(local?.auth ? [local.auth] : [])]],
105
+ ['role_check', [...prof.role, ...(deco ? prof.decoRole : []), ...(local?.role ? [local.role] : [])]],
106
+ ['rate_limit', prof.rate], ['input_validation', prof.validation], ['audit_log', prof.audit],
107
+ ];
108
+ const out: Protection[] = [];
109
+ for (const [kind, pats] of kinds) {
110
+ for (const { p, index } of pick(view, floor ? pats.filter((x) => x.confidence !== 'low') : pats, from, to)) {
111
+ const conf = viaPrefix || floor ? minConf(p.confidence, 'medium') : p.confidence;
112
+ out.push({ kind, scope, file: view.file, line: view.lines.at(index), via: viaPrefix + p.via, enforced: null, confidence: conf });
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // The lines after line `n` (1 based) up to the end of the block they belong to: a closing brace, end, else or elsif.
119
+ function afterBlock(lines: string[], n: number, max: number): string {
120
+ const out: string[] = [];
121
+ for (let i = n; i < Math.min(lines.length, n + max); i++) {
122
+ if (/^\s*(?:\}|end\b|else\b|elsif\b|elseif\b)/.test(lines[i])) break;
123
+ out.push(lines[i]);
124
+ }
125
+ return out.join('\n');
126
+ }
127
+
128
+ // Whether a failed check provably stops the request, from the text around the match.
129
+ function enforcedAt(view: FileView, prof: Profile, p: Protection): boolean | null {
130
+ const lines = view.text.split('\n');
131
+ const row = lines[p.line - 1] ?? '';
132
+ const after = (n: number): string => afterBlock(lines, p.line, n);
133
+ if (/^\s*(?:if|unless|elsif|elseif|when)\b/.test(row)) return prof.exits.test(row.replace(/^[^{]*\{/, '') + '\n' + after(4));
134
+ const modifier = /\s(?:if|unless)\s/.exec(row);
135
+ if (modifier && !ASSIGN[prof.lang].test(row)) return prof.exits.test(row.slice(0, modifier.index)); // statement if condition: the statement is the body
136
+ if (/^\s*(?:before_action|prepend_before_action|\$this->middleware|->middleware|abort_(?:if|unless)|throw_(?:if|unless)|\$this->authorize|\$request->validate|Gate::authorize)/.test(row)) return true;
137
+ const as = ASSIGN[prof.lang].exec(row);
138
+ if (as) {
139
+ const names = [...as[1].matchAll(/[@$]?[A-Za-z_]\w*/g)].map((m) => m[0]).concat(prof.guardVars);
140
+ const rest = after(8);
141
+ for (const n of new Set(names)) {
142
+ const bare = n.replace(/^[@$]/, '');
143
+ const guard = new RegExp(`\\b(?:if|unless)\\s*\\(?\\s*(?:!\\s*${n.replace('$', '\\$')}\\b|not\\s+${bare}\\b|${n.replace('$', '\\$')}\\s*(?:==|===)\\s*(?:nil|null)\\b|${n.replace('$', '\\$')}\\.nil\\?|${bare}\\s*!=\\s*nil|err\\s*!=\\s*nil|!ok\\b|!\\s*empty\\s*\\(\\s*${n.replace('$', '\\$')}\\s*\\)|${n.replace('$', '\\$')}\\s*===?\\s*false)`);
144
+ const g = guard.exec(rest);
145
+ if (g) return prof.exits.test(rest.slice(g.index, g.index + 320));
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ export function readsOf(view: FileView, prof: Profile, from: number, to: number): InputRead[] {
152
+ const out: InputRead[] = [];
153
+ for (const [source, re] of prof.reads) {
154
+ const g = new RegExp(re.source, re.flags.replace('g', ''));
155
+ const src = usesText(re) ? view.text.slice(from, to) : view.masked.slice(from, to);
156
+ const m = g.exec(src);
157
+ if (m) out.push({ source, line: view.lines.at(from + m.index) });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ interface Taint { names: Set<string>; validated: Set<string>; trusted: Set<string> }
163
+
164
+ function taintOf(prof: Profile, view: FileView, stmts: [number, number][], seeds: string[]): Taint {
165
+ const names = new Set(seeds);
166
+ const validated = new Set<string>();
167
+ const trusted = new Set<string>();
168
+ const tainted = (expr: string): boolean => {
169
+ const e = expr.replace(new RegExp(prof.trusted.source, 'g'), ' ');
170
+ return prof.sources.test(e) || idents(prof, e).some((i) => names.has(i) && !trusted.has(i));
171
+ };
172
+ const recs: { lhs: string[]; rhs: string; line: string }[] = [];
173
+ for (const [s, e] of stmts) {
174
+ const m = view.masked.slice(s, e);
175
+ const a = ASSIGN[prof.lang].exec(m);
176
+ if (a) recs.push({ lhs: [...a[1].matchAll(/[@$]?[A-Za-z_]\w*/g)].map((x) => x[0]).filter((x) => !KEYWORDS.has(x)), rhs: view.text.slice(s + a[0].length, e), line: m });
177
+ if (prof.lang === 'go') {
178
+ const b = /\.(?:Decode|ShouldBindJSON|ShouldBindQuery|BindJSON|ShouldBind|Bind|BodyParser|QueryParser|ParamsParser|Unmarshal|BindQuery)\s*\(\s*(?:[^,()]*,\s*)?&?(\w+)/.exec(m);
179
+ if (b) { names.add(b[1]); if (/validator|Validate|binding:/.test(view.text.slice(s, e))) validated.add(b[1]); }
180
+ } else {
181
+ if (prof.lang === 'java' || prof.lang === 'cs') {
182
+ const f = prof.lang === 'java' ? /\bfor\s*\(\s*(?:final\s+)?[\w.<>\[\],? ]+?\s+(\w+)\s*:\s*([^)]+)\)/.exec(m) : /\bforeach\s*\(\s*(?:var|[\w.<>\[\],?]+)\s+(\w+)\s+in\s+([^)]+)\)/.exec(m);
183
+ if (f) recs.push({ lhs: [f[1]], rhs: f[2], line: m });
184
+ } else {
185
+ const f = prof.lang === 'php' ? /\bforeach\s*\(\s*(.+?)\s+as\s+(?:(\$\w+)\s*=>\s*)?(\$\w+)/.exec(m) : /([@\w.]+(?:\([^)]*\))?)\.each\s+do\s*\|\s*(\w+)/.exec(m);
186
+ if (f) recs.push({ lhs: prof.lang === 'php' ? [f[3]] : [f[2]], rhs: f[1], line: m });
187
+ }
188
+ }
189
+ }
190
+ for (let pass = 0; pass < 4; pass++) {
191
+ let changed = false;
192
+ for (const r of recs) {
193
+ if (prof.trusted.test(r.rhs) && !/\bparams\b|\$request->(?:input|all|get)/.test(r.rhs)) { for (const l of r.lhs) if (!trusted.has(l)) { trusted.add(l); names.delete(l); changed = true; } continue; }
194
+ if (!tainted(r.rhs)) continue;
195
+ const schema = /->validated\s*\(|->safe\s*\(|\.permit\s*\(|\bValidator::make|->validate\s*\(/.test(r.rhs);
196
+ for (const l of r.lhs) {
197
+ if (!names.has(l)) { names.add(l); changed = true; }
198
+ if (schema && !validated.has(l)) { validated.add(l); changed = true; }
199
+ }
200
+ }
201
+ if (!changed) break;
202
+ }
203
+ return { names, validated, trusted };
204
+ }
205
+
206
+ // The model a GORM call works on: Model(&Note{}), Table("notes"), Find(&notes) where notes was declared as []Note.
207
+ function goTarget(stmt: string, fileText: string): string | null {
208
+ const t = /\.Table\s*\(\s*"(\w+)"/.exec(stmt)?.[1];
209
+ if (t) return t;
210
+ const lit = /&(\w+)\s*\{/.exec(stmt)?.[1];
211
+ if (lit) return lit;
212
+ const v = /&(\w+)\b/.exec(stmt)?.[1];
213
+ if (!v) return null;
214
+ return new RegExp(`\\bvar\\s+${v}\\s+(?:\\[\\])?\\*?([A-Z]\\w*)`).exec(fileText)?.[1] ?? new RegExp(`\\b${v}\\s*:?=\\s*(?:\\[\\])?&?([A-Z]\\w*)\\s*\\{`).exec(fileText)?.[1] ?? new RegExp(`\\b${v}\\s+(?:\\[\\])?\\*?([A-Z]\\w*)\\b`).exec(fileText)?.[1] ?? null;
215
+ }
216
+
217
+ const snake = (s: string): string => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
218
+ const clip = (s: string, n: number): string => { const t = s.replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
219
+
220
+ interface VarInfo { target: string | null; byId: boolean; owner: boolean }
221
+
222
+ export function analyzeLight(view: FileView, spec: LightSpec): Analysis {
223
+ const prof = PROFILES[view.lang as MoreLang];
224
+ const { start, end } = spec;
225
+ const local = spec.local ?? null;
226
+ const stmts = statements(view.masked, start, end, prof.lang);
227
+ const t = taintOf(prof, view, stmts, spec.seeds);
228
+ const taintedExpr = (expr: string): boolean => {
229
+ const e = expr.replace(new RegExp(prof.trusted.source, 'g'), ' ');
230
+ return prof.sources.test(e) || idents(prof, e).some((i) => t.names.has(i) && !t.trusted.has(i));
231
+ };
232
+ const hasSession = (expr: string): boolean => prof.trusted.test(expr) || idents(prof, expr).some((i) => t.trusted.has(i));
233
+
234
+ // ---- protections
235
+ const prot: Protection[] = [];
236
+ for (const f of protectionsIn(view, prof, local, start, end, 'handler')) {
237
+ prot.push({ ...f, enforced: f.kind === 'session_read' || f.kind === 'role_check' ? enforcedAt(view, prof, f) : null });
238
+ }
239
+ // Signature checks: where the check is, and whether a failed check stops the request.
240
+ for (const { p, index } of pick(view, prof.signature, start, end)) {
241
+ const line = view.lines.at(index);
242
+ const bodyAt = prof.sources.exec(view.masked.slice(start, end));
243
+ const parseLine = bodyAt ? view.lines.at(start + bodyAt.index) : null;
244
+ const fact: Protection = { kind: 'signature_verify', scope: 'handler', file: view.file, line, via: p.via, enforced: null, confidence: p.confidence };
245
+ let enforced = enforcedAt(view, prof, fact);
246
+ if (enforced === null && /\bconstruct\w*Event|\bverify\w*Webhook|\bWebhook::construct/i.test(p.via + view.text.slice(index, index + 40))) enforced = true;
247
+ prot.push({ ...fact, enforced, after_parse: parseLine !== null && parseLine < line && /\b(?:json|JSON\.parse|json_decode|Unmarshal|Decode|ShouldBindJSON|BindJSON)\b/.test(view.masked.slice(start, view.lines.start(line))) });
248
+ }
249
+ for (const d of spec.decorators ?? []) {
250
+ for (const f of protectionsIn(d.view, prof, local, d.start, d.end, d.scope, '', null, true)) prot.push({ ...f, enforced: f.confidence === 'high' ? true : null });
251
+ for (const { p, index } of pick(d.view, prof.signature, d.start, d.end)) prot.push({ kind: 'signature_verify', scope: d.scope, file: d.view.file, line: d.view.lines.at(index), via: p.via, enforced: null, confidence: p.confidence });
252
+ }
253
+ prot.push(...(spec.extra ?? []));
254
+
255
+ // One hop into the repository's own functions the handler calls.
256
+ const have = (k: ProtectionKind): boolean => prot.some((q) => q.kind === k);
257
+ if (spec.lookup) {
258
+ const names = new Set<string>();
259
+ const body = view.masked.slice(start, end);
260
+ for (const m of body.matchAll(prof.lang === 'rb' ? /(?<![.\w@:])([a-z_]\w*[?!]?)(?=[\s(]|$)/gm : prof.lang === 'php' ? /(?:\$this->|self::|static::|(?<![>:\w$]))([A-Za-z_]\w*)\s*\(/g : /(?<![\w])(?:\w+\.)?([A-Za-z_]\w*)\s*\(/g)) names.add(m[1]);
261
+ for (const n of spec.decorators ?? []) for (const m of n.view.masked.slice(n.start, n.end).matchAll(/([A-Za-z_]\w*[?!]?)/g)) names.add(m[1]);
262
+ let hops = 0;
263
+ for (const name of [...names].sort()) {
264
+ if (hops >= 30 || KEYWORDS.has(name)) continue;
265
+ const h = spec.lookup(name, view.file);
266
+ if (!h) continue;
267
+ hops++;
268
+ const rejects = prof.exits.test(h.view.text.slice(h.start, h.end)) && /\b(?:401|403|forbidden|unauthori[sz]ed|abort|raise|halt|redirect|head\s+:|status\(|Error)\b/i.test(h.view.text.slice(h.start, h.end));
269
+ for (const f of protectionsIn(h.view, prof, local, h.start, h.end, 'handler', `helper:${name} `, 'medium')) {
270
+ if (f.kind === 'session_read' && have('session_read')) continue;
271
+ if (have(f.kind) && f.kind !== 'audit_log') continue;
272
+ const idx = view.masked.slice(start, end).search(new RegExp(`(?<![\\w])${name.replace(/[$]/g, '\\$')}\\b`));
273
+ prot.push({ ...f, file: view.file, line: idx < 0 ? view.lines.at(start) : view.lines.at(start + idx), via: f.via, enforced: rejects ? true : null });
274
+ }
275
+ }
276
+ }
277
+
278
+ // ---- data touches
279
+ const data: DataTouch[] = [];
280
+ const vars = new Map<string, VarInfo>();
281
+ for (const b of spec.bound ?? []) vars.set(b.name, { target: b.target, byId: true, owner: b.owner ?? false });
282
+ const sqlVars = new Map<string, Partial<DataTouch> & { raw: string }>();
283
+ const policy = spec.policyScoped || /\bauthorize\b|\bauthorize!|\$this->authorize|\bGate::(?:authorize|allows)|\bpolicy\s*\(|\bpolicy_scope|->can\s*\(/.test(view.masked.slice(start, end));
284
+ const ownerNear = (s: string): boolean => (OWNER.test(s) || OWNER_MORE[prof.lang].test(s)) && hasSession(s);
285
+
286
+ for (const [s, e] of stmts) {
287
+ const ms = view.masked.slice(s, e);
288
+ const ts = view.text.slice(s, e);
289
+ const line = (i: number): number => view.lines.at(s + i);
290
+ const lhs = ASSIGN[prof.lang].exec(ms);
291
+ const lhsNames = lhs ? [...lhs[1].matchAll(/[@$]?[A-Za-z_]\w*/g)].map((x) => x[0]).filter((x) => !KEYWORDS.has(x)) : [];
292
+ const stmtTainted = taintedExpr(lhs ? ts.slice(lhs[0].length) : ts);
293
+ const emitted: DataTouch[] = [];
294
+ const lim = /(?:\.|->|\b)(?:limit|per|take|paginate|simplePaginate|forPage|Limit|per_page|PerPage|page_size|pageSize)\s*\(\s*([^)]*)\)|\b(?:per_page|limit|page_size|per):\s*([^,)\n]+)/i.exec(ts);
295
+ const limitReq = !!lim && taintedExpr(lim[1] ?? lim[2] ?? '');
296
+
297
+ // SQL text in a literal
298
+ const sqlLit = SQL_WORD.exec(ts);
299
+ if (sqlLit && /["'`]/.test(ms)) {
300
+ const kw = sqlLit[1].toUpperCase();
301
+ const op: DataTouch['op'] = kw.startsWith('SELECT') ? 'read' : kw.startsWith('DELETE') ? 'delete' : 'write';
302
+ const table = /\b(?:FROM|INTO|UPDATE|JOIN)\s+[`"]?([\w.]+)/i.exec(ts)?.[1] ?? null;
303
+ const args = ts.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (q) => (SQL_WORD.test(q) || /WHERE|SET|VALUES|FROM/i.test(q) ? ' ' : q));
304
+ const interp = (prof.lang === 'go' && (/\bfmt\.Sprintf\s*\(/.test(ts) || /["`]\s*\+\s*\w/.test(ts))) || (prof.lang === 'rb' && /#\{/.test(ts)) || (prof.lang === 'php' && (/\$\w+/.test(ts.replace(/'[^']*'/g, '')) && /"[^"]*\$\w+|\.\s*\$/.test(ts))) || (prof.lang === 'cs' && /\$@?"[^"]*\{/.test(ts)) || (prof.lang === 'java' && /\.formatted\s*\(|String\s*\.\s*format\s*\(/.test(ts)) || /["']\s*\.\s*\$|\+\s*[A-Za-z_]/.test(ts);
305
+ const reqDerived = taintedExpr(args) || (interp && taintedExpr(ts));
306
+ const sets = op === 'write' && /\bSET\b/i.test(ts) ? [...new Set([...ts.matchAll(/\b([a-z_]+)\s*=\s*(?:\?|\$\d+|:\w+|%s|['"]?\s*[#{$]|["']\s*\+|\+)/gi)].map((x) => x[1].toLowerCase()).filter((x) => PRIVILEGED.test(x)))].sort() : [];
307
+ const whereAt = ts.search(/\bWHERE\b/i);
308
+ const whereText = whereAt < 0 ? '' : ts.slice(whereAt);
309
+ const info = { store: 'sql' as const, op, target: table, by_id: reqDerived && ID_COL.test(whereText), limit_from_request: /\bLIMIT\b/i.test(ts) && reqDerived, request_derived: reqDerived, owner_scoped: ownerNear(whereText + ' ' + ts), raw_sql: true, sets, mass_assign: false, confidence: 'medium' as Confidence };
310
+ if (prof.sqlCall.test(ms) || prof.data.some((r) => r.store === 'sql' && new RegExp(r.re.source).test(ms))) emitted.push({ ...info, line: line(sqlLit.index) });
311
+ else if (lhs) for (const n of lhsNames) sqlVars.set(n, { ...info, raw: ts });
312
+ } else if (prof.sqlCall.test(ms)) {
313
+ // The query text was built in an earlier statement.
314
+ for (const n of idents(prof, ms)) {
315
+ const prior = sqlVars.get(n);
316
+ if (prior) { emitted.push({ ...(prior as DataTouch), request_derived: prior.request_derived || taintedExpr(ts), by_id: !!prior.by_id || (taintedExpr(ts) && ID_COL.test(prior.raw)), line: line(0) }); break; }
317
+ }
318
+ }
319
+ if (!emitted.length) {
320
+ const seen = new Set<number>();
321
+ for (const rule of prof.data) {
322
+ const g = new RegExp(rule.re.source, 'g');
323
+ const hay = /['"`]/.test(rule.re.source) ? ts : ms; // a rule that reads a table name needs the string text
324
+ for (let m = g.exec(hay); m; m = g.exec(hay)) {
325
+ if (m[0] === '') { g.lastIndex++; continue; }
326
+ if (hay !== ms && ms[m.index] === ' ' && ts[m.index] !== ' ') continue; // starts inside a comment
327
+ if (seen.has(m.index)) continue;
328
+ if (rule.store === 'sql' && sqlLit) continue;
329
+ const verb = m[rule.verb] ?? '';
330
+ if (/^(?:new|build)$/.test(verb)) { for (const n of lhsNames) vars.set(n, { target: rule.target ? m[rule.target] : null, byId: false, owner: false }); continue; }
331
+ seen.add(m.index);
332
+ const tail = ms.slice(m.index);
333
+ const chain = [verb, ...[...tail.matchAll(/(?:\.|->|::)\s*([A-Za-z_]\w*[?!]?)/g)].map((x) => x[1])];
334
+ const op: DataTouch['op'] = chain.some((c) => prof.verbs.del.test(c)) ? 'delete' : chain.some((c) => prof.verbs.write.test(c)) ? 'write' : chain.some((c) => prof.verbs.read.test(c)) ? 'read' : 'unknown';
335
+ const open = m[0].endsWith('(') ? s + m.index + m[0].length - 1 : -1;
336
+ const close = open >= 0 ? closeOf(view.masked, open) : -1;
337
+ const args = open >= 0 && close > 0 ? view.text.slice(open + 1, close - 1) : view.text.slice(s + m.index + m[0].length, e);
338
+ const ruleTarget: string | null = rule.target !== null ? ((m[rule.target] as string | undefined) ?? null) : null;
339
+ const tgt0: string | null = ruleTarget ?? (prof.lang === 'go' && rule.store === 'orm' ? goTarget(ts.slice(m.index), view.text) : null);
340
+ const tgt = tgt0 !== null && (prof.lang === 'java' || prof.lang === 'cs') ? tgt0.replace(/^_/, '').replace(/(?:Repository|Repo|Dao|DAO)$/, '') || tgt0 : tgt0;
341
+ const reqDerived = taintedExpr(args) || (chain.length > 1 && taintedExpr(ts.slice(m.index)));
342
+ const keyedVerb = /^(?:find|findOrFail|findMany|findOrNew|sole|findSole|destroy|FindOne|Get[A-Z]\w*|Show\w*|Load\w*)$/.test(verb);
343
+ const createVerb = /^(?:create!?|insert\w*|Create|Save|forceCreate|build|new|insert_all|upsert\w*|updateOrCreate|firstOrCreate|firstOrNew|find_or_create_by|find_or_initialize_by)$/.test(verb);
344
+ const byId = reqDerived && (keyedVerb || (!createVerb && rule.store !== 'filesystem' && ID_COL.test(ts.slice(m.index))));
345
+ const ownerScoped = ownerNear(ts) && (OWNER.test(ts) || OWNER_MORE[prof.lang].test(ts));
346
+ const writeArgs = lastWriteArgs(view, prof, s, ms, op) ?? args;
347
+ const priv = privilegedIn(prof, ts, writeArgs, taintedExpr, t, view, tgt);
348
+ const d: DataTouch = {
349
+ store: rule.store, op, target: tgt ? clip(tgt, 60) : null, line: line(m.index), by_id: byId, limit_from_request: limitReq, request_derived: reqDerived,
350
+ owner_scoped: ownerScoped, raw_sql: rule.store === 'sql' || /\b(?:find_by_sql|whereRaw|selectRaw|orderByRaw)\b/.test(ms), sets: op === 'write' ? priv.sets : [], mass_assign: op === 'write' && priv.mass, confidence: rule.conf,
351
+ };
352
+ // A write on a variable that an earlier statement loaded by id inherits what that load did.
353
+ if (op === 'write' || op === 'delete') {
354
+ for (const n of idents(prof, args)) {
355
+ const v = vars.get(n);
356
+ if (v) { d.by_id = d.by_id || v.byId; d.owner_scoped = d.owner_scoped || v.owner; d.target = d.target ?? v.target; }
357
+ }
358
+ }
359
+ emitted.push(d);
360
+ if (lhs) for (const n of lhsNames) if (n !== 'err' && n !== 'ok') vars.set(n, { target: tgt, byId, owner: ownerScoped });
361
+ }
362
+ }
363
+ // Methods called on a record that an earlier statement loaded: @note.update(...), $note->delete(), note.destroy.
364
+ const inst = prof.lang === 'rb' ? /(@?\w+)\.(update|update!|update_attributes|update_attribute|update_column|update_columns|destroy|destroy!|delete|save|save!|toggle!|increment!|decrement!|touch)\b/ : prof.lang === 'php' ? /(\$\w+)\s*->\s*(update|delete|save|fill|forceDelete|restore|increment|decrement|touch|push|forceFill)\s*\(/ : null;
365
+ const im = inst ? inst.exec(ms) : null;
366
+ if (im) {
367
+ const v = vars.get(im[1]);
368
+ if (v && !emitted.length) {
369
+ const open = ms.indexOf('(', im.index);
370
+ const close = open >= 0 ? closeOf(view.masked, s + open) : -1;
371
+ const args = open >= 0 && close > 0 ? view.text.slice(s + open + 1, close - 1) : ts.slice(im.index + im[0].length);
372
+ const op: DataTouch['op'] = prof.verbs.del.test(im[2]) ? 'delete' : 'write';
373
+ const priv = privilegedIn(prof, ts, args, taintedExpr, t, view, v.target);
374
+ emitted.push({ store: 'orm', op, target: v.target, line: line(im.index), by_id: v.byId, limit_from_request: false, request_derived: taintedExpr(args) || v.byId, owner_scoped: v.owner, raw_sql: false, sets: op === 'write' ? priv.sets : [], mass_assign: op === 'write' && priv.mass, confidence: 'medium' });
375
+ }
376
+ }
377
+ // Assignments of a privileged field from request data: $user->role = $request->role; user.Role = req.Role
378
+ const fa = prof.lang === 'php' ? /(\$\w+)\s*->\s*(\w+)\s*=\s*([^;]+)/.exec(ms) : prof.lang === 'rb' ? /(@?\w+)\.(\w+)\s*=\s*(.+)/.exec(ms) : prof.lang === 'java' ? /(\w+)\.set([A-Z]\w*)\s*\(([^;]+)\)/.exec(ms) : /(\w+)\.(\w+)\s*=\s*(.+)/.exec(ms);
379
+ if (fa && PRIVILEGED.test(snake(fa[2])) && taintedExpr(fa[3]) && !emitted.length) {
380
+ const v = vars.get(fa[1]);
381
+ emitted.push({ store: 'orm', op: 'write', target: v?.target ?? null, line: line(fa.index), by_id: v?.byId ?? false, limit_from_request: false, request_derived: true, owner_scoped: v?.owner ?? false, raw_sql: false, sets: [snake(fa[2])], mass_assign: false, confidence: 'medium' });
382
+ }
383
+ }
384
+ for (const d of emitted) {
385
+ if (policy && d.by_id && !d.owner_scoped && d.op !== 'unknown') d.owner_scoped = true;
386
+ if (d.op !== 'unknown' || d.sets.length || d.mass_assign) data.push(d);
387
+ }
388
+ }
389
+
390
+ // ---- outbound
391
+ const outbound: Outbound[] = [];
392
+ const fnText = view.text.slice(start, end);
393
+ const masked = view.masked.slice(start, end);
394
+ for (const rule of prof.outbound) {
395
+ const g = new RegExp(rule.re.source, 'g');
396
+ for (let m = g.exec(masked); m; m = g.exec(masked)) {
397
+ if (m[0] === '') { g.lastIndex++; continue; }
398
+ const at = start + m.index;
399
+ const open = m[0].endsWith('(') ? at + m[0].length - 1 : view.masked.indexOf('(', at);
400
+ const lineEnd = view.masked.indexOf('\n', at);
401
+ const hasParen = open >= 0 && open < (lineEnd < 0 ? view.masked.length : lineEnd);
402
+ const close = hasParen ? closeOf(view.masked, open) : -1;
403
+ const full = hasParen && close > 0 ? view.text.slice(open + 1, close - 1) : view.text.slice(at + m[0].length, lineEnd < 0 ? end : lineEnd);
404
+ const parts = full.split(/,(?![^(]*\))/);
405
+ const picked = rule.arg !== undefined && parts.length > rule.arg ? parts[rule.arg] : parts[0];
406
+ const target = clip(picked || m[0], 120);
407
+ const derived = taintedExpr(rule.arg !== undefined ? picked : full);
408
+ outbound.push({ kind: rule.kind, line: view.lines.at(at), target, request_derived: derived, guarded: rule.guard === 'url' ? URL_GUARD.test(fnText) : rule.guard === 'redirect' ? REDIRECT_GUARD.test(fnText) : false, confidence: rule.conf });
409
+ }
410
+ }
411
+ const seenO = new Set<string>();
412
+ const out = outbound.filter((o) => { const k = `${o.kind}:${o.line}:${o.target}`; if (seenO.has(k)) return false; seenO.add(k); return true; })
413
+ .sort((a, b) => a.line - b.line || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
414
+
415
+ 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: 'medium' });
416
+ if (policy) {
417
+ const pf = pick(view, prof.role, start, end).find((x) => /authorize|policy|Gate|can/i.test(view.text.slice(x.index, x.index + 40)));
418
+ if (pf && data.some((d) => d.by_id)) prot.push({ kind: 'owner_filter', scope: 'handler', file: view.file, line: view.lines.at(pf.index), via: 'policy authorize', enforced: null, confidence: 'medium' });
419
+ }
420
+
421
+ data.sort((a, b) => a.line - b.line || (a.store < b.store ? -1 : a.store > b.store ? 1 : 0) || ((a.target ?? '') < (b.target ?? '') ? -1 : 1));
422
+ const seenD = new Set<string>();
423
+ const dd = data.filter((d) => { const k = `${d.line}:${d.store}:${d.op}:${d.target}`; if (seenD.has(k)) return false; seenD.add(k); return true; });
424
+ 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));
425
+ const seenP = new Set<string>();
426
+ const protections = prot.filter((q) => { const k = `${q.kind}|${q.scope}|${q.file}|${q.line}|${q.via}`; if (seenP.has(k)) return false; seenP.add(k); return true; });
427
+ return { protections, reads: readsOf(view, prof, start, end), data: dd, outbound: out, taint: t };
428
+ }
429
+
430
+ // The arguments of the last call in the statement that writes or deletes, so find(params[:id]).update(role: x) reads update's.
431
+ function lastWriteArgs(view: FileView, prof: Profile, s: number, ms: string, op: DataTouch['op']): string | null {
432
+ if (op !== 'write' && op !== 'delete') return null;
433
+ const verbRe = op === 'write' ? prof.verbs.write : prof.verbs.del;
434
+ let best: string | null = null;
435
+ for (const m of ms.matchAll(/(?:\.|->|::)\s*([A-Za-z_]\w*[?!]?)\s*\(/g)) {
436
+ if (!verbRe.test(m[1])) continue;
437
+ const open = s + m.index! + m[0].length - 1;
438
+ const close = closeOf(view.masked, open);
439
+ if (close > 0) best = view.text.slice(open + 1, close - 1);
440
+ }
441
+ return best;
442
+ }
443
+
444
+ // Privileged columns a write sets from request data, and whether a whole request object goes into it.
445
+ function privilegedIn(prof: Profile, stmt: string, args: string, tainted: (e: string) => boolean, t: Taint, view: FileView, _target: string | null): { sets: string[]; mass: boolean } {
446
+ const sets = new Set<string>();
447
+ let mass = false;
448
+ const pairs: [string, string][] = [];
449
+ if (prof.lang === 'php') for (const m of args.matchAll(/['"](\w+)['"]\s*=>\s*([^,\]\n]+)/g)) pairs.push([m[1], m[2]]);
450
+ else if (prof.lang === 'rb') { for (const m of args.matchAll(/(?:^|[(,\s])(\w+):\s*([^,)\n]+)/g)) pairs.push([m[1], m[2]]); for (const m of args.matchAll(/:(\w+)\s*=>\s*([^,)\n]+)/g)) pairs.push([m[1], m[2]]); for (const m of stmt.matchAll(/\.update_(?:column|attribute)s?\s*\(\s*:(\w+)\s*,\s*([^)]+)\)/g)) pairs.push([m[1], m[2]]); }
451
+ else { for (const m of args.matchAll(/(?:^|[{,\s])["']?(\w+)["']?\s*:\s*([^,}\n]+)/g)) pairs.push([m[1], m[2]]); for (const m of stmt.matchAll(/\.Update(?:Column)?\s*\(\s*["'](\w+)["']\s*,\s*([^)]+)\)/g)) pairs.push([m[1], m[2]]); }
452
+ for (const [k, v] of pairs) if (PRIVILEGED.test(snake(k)) && tainted(v)) sets.add(snake(k));
453
+ if (prof.lang === 'rb') {
454
+ // params.require(:user).permit(:email, :role) or a *_params method that holds the list
455
+ const lists: string[] = [];
456
+ for (const m of (args + ' ' + stmt).matchAll(/\b(\w+_params)\b/g)) {
457
+ const def = new RegExp(`def\\s+${m[1]}\\b([\\s\\S]*?)\\n\\s*end\\b`).exec(view.text);
458
+ if (def) lists.push(def[1]);
459
+ }
460
+ lists.push(args);
461
+ for (const l of lists) {
462
+ if (/\.permit!/.test(l)) mass = true;
463
+ for (const pm of l.matchAll(/\.permit\s*\(([^)]*)\)/g)) for (const s of pm[1].matchAll(/:(\w+)/g)) if (PRIVILEGED.test(s[1])) sets.add(s[1]);
464
+ }
465
+ if (/^\s*params(?:\[\s*:\w+\s*\])?\s*$/.test(args) || /\bto_unsafe_h\b|\bparams\.to_h\b/.test(args)) mass = true;
466
+ } else if (prof.lang === 'php') {
467
+ if (/\$request->(?:all|input)\s*\(\s*\)|\$request->post\s*\(\s*\)|\$request->except\s*\(|\$_POST\b|\$_REQUEST\b|\$request->toArray\s*\(/.test(args) || /^\s*\$request\s*$/.test(args)) mass = true;
468
+ for (const m of args.matchAll(/\$request->only\s*\(\s*\[([^\]]*)\]/g)) for (const s of m[1].matchAll(/['"](\w+)['"]/g)) if (PRIVILEGED.test(snake(s[1]))) sets.add(snake(s[1]));
469
+ const v = /^\s*(\$\w+)\s*$/.exec(args)?.[1];
470
+ if (v && t.names.has(v) && !t.validated.has(v) && !t.trusted.has(v)) mass = true;
471
+ } else {
472
+ // A decoded struct handed whole to a write: its fields decide.
473
+ const v = /^\s*&?(\w+)\s*$/.exec(args.split(',').pop() ?? '')?.[1];
474
+ if (v && t.names.has(v) && !t.trusted.has(v)) {
475
+ const typ = new RegExp(`\\bvar\\s+${v}\\s+\\*?([A-Z]\\w*)|\\b${v}\\s*:?=\\s*&?([A-Z]\\w*)\\s*\\{|\\b${v}\\s+\\*?([A-Z]\\w*)\\b`).exec(view.text);
476
+ const tn = typ ? typ[1] ?? typ[2] ?? typ[3] : null;
477
+ const body = tn ? new RegExp(`type\\s+${tn}\\s+struct\\s*\\{([^}]*)\\}`).exec(view.text)?.[1] ?? null : null;
478
+ if (body !== null) { for (const f of body.matchAll(/^\s*(\w+)\s+[\w.*[\]]+/gm)) if (PRIVILEGED.test(snake(f[1]))) sets.add(snake(f[1])); }
479
+ else if (/map\[string\]/.test(view.text.slice(0, view.text.length)) && new RegExp(`\\b${v}\\s*:?=\\s*(?:make\\s*\\()?map\\[string\\]`).test(view.text)) mass = true;
480
+ else if (!tn) mass = true;
481
+ }
482
+ }
483
+ return { sets: [...sets].sort(), mass };
484
+ }
@@ -0,0 +1,30 @@
1
+ // Names of sign in and role helpers that the built in vocabulary does not know, written by the project owner in
2
+ // <repository>/.hullproof/kit.local.json:
3
+ //
4
+ // { "auth_helpers": ["requireMember", "Auth::member"], "role_helpers": ["isBillingAdmin"] }
5
+ //
6
+ // A call, decorator, middleware or filter with one of these names counts as a session read (or a role check) with medium
7
+ // confidence, in every language the map reads. The file is an input and the names are copied into codemap.json
8
+ // (`config.local_vocab`), so the same repository and the same file give the same bytes. A file that does not parse, or a
9
+ // name that is not a plain identifier, is ignored.
10
+ import { readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import type { Pattern } from './vocab.ts';
13
+
14
+ export interface LocalVocab { auth_helpers: string[]; role_helpers: string[]; auth: Pattern | null; role: Pattern | null }
15
+
16
+ export const LOCAL_CONFIG = '.hullproof/kit.local.json';
17
+ const NAME = /^[A-Za-z_$][\w$]*(?:(?:::|\\|\.|->)[A-Za-z_$][\w$]*)*[!?]?$/;
18
+ const MAX_NAMES = 200;
19
+
20
+ const esc = (s: string): string => s.replace(/[\\^$.*+?()[\]{}|-]/g, '\\$&');
21
+ const names = (v: unknown): string[] => (Array.isArray(v) ? [...new Set(v.filter((x): x is string => typeof x === 'string' && x.length <= 80 && NAME.test(x)))].sort().slice(0, MAX_NAMES) : []);
22
+ const pattern = (list: string[], via: string): Pattern | null => (list.length ? { re: new RegExp(`(?<![\\w$])(?:${list.map(esc).join('|')})(?![\\w$])`), via, confidence: 'medium' } : null);
23
+
24
+ export function loadLocalVocab(repoAbs: string): LocalVocab {
25
+ let raw: Record<string, unknown> = {};
26
+ try { raw = JSON.parse(readFileSync(join(repoAbs, LOCAL_CONFIG), 'utf8')) as Record<string, unknown>; } catch { /* no file, or one that does not parse: no local names */ }
27
+ const auth_helpers = names(raw?.auth_helpers);
28
+ const role_helpers = names(raw?.role_helpers);
29
+ return { auth_helpers, role_helpers, auth: pattern(auth_helpers, 'local auth helper'), role: pattern(role_helpers, 'local role helper') };
30
+ }