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,86 @@
1
+ // Taint inside one function: which names hold request data. Seeds are the handler parameters; a name becomes tainted when
2
+ // it is assigned from an expression that contains a tainted name or a request source. Followed to a fixed point. No calls
3
+ // are followed, so a value that passes through a helper function and comes back is not tracked (a stated limit).
4
+ import type { Lang } from './lex.ts';
5
+ import { statementEnd } from './lex.ts';
6
+ import { SESSION_JS, SESSION_PY, type Pattern } from './vocab.ts';
7
+
8
+ const KEYWORDS = new Set(['const', 'let', 'var', 'await', 'async', 'new', 'return', 'typeof', 'if', 'else', 'null', 'undefined', 'true', 'false', 'None', 'True', 'False', 'not', 'and', 'or', 'in', 'is', 'lambda', 'for', 'while', 'self', 'cls']);
9
+
10
+ // Identifiers in an expression that are values and not property names (not preceded by a dot).
11
+ export function identifiers(expr: string): string[] {
12
+ const out: string[] = [];
13
+ for (const m of expr.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) if (!KEYWORDS.has(m[1])) out.push(m[1]);
14
+ return out;
15
+ }
16
+
17
+ const SOURCE_JS = /\b(?:req|request|ctx|event)\s*[.\[]|\bsearchParams\b|\bformData\b|\bnextUrl\b|\bc\.req\b|\bawait\s+\w+\.(?:json|formData|text)\s*\(\)|\bURL\s*\(\s*(?:req|request)\.url/;
18
+ const SOURCE_PY = /\brequest\s*[.\[]|\brequest\b\.|\bflask\.request\b|\binput\s*\(|\bsys\.argv\b/;
19
+
20
+ // Values that come from the session and not from the request: req.user, current_user, session[...] and the like.
21
+ export const TRUSTED_CHAIN = /\b(?:req|request|ctx|event)\s*\.\s*(?:user|session|auth|locals\s*\.\s*user|state\s*\.\s*user)\b|\bcurrent_user\b|\bg\.user\b|\bsession\s*[\[.]|\bc\.get\(\s*['"]user/g;
22
+
23
+ export interface Taint { names: Set<string>; validated: Set<string>; trusted: Set<string> }
24
+
25
+ // `seeds`: names that hold request data on entry. `validated` collects names assigned from a schema parse, which are
26
+ // still request data but are shaped by a schema (so passing one whole into a create is not a free for all).
27
+ export function taintOf(masked: string, original: string, seeds: string[], lang: Lang, extraSession: Pattern[] = []): Taint {
28
+ const names = new Set(seeds);
29
+ const validated = new Set<string>();
30
+ const trusted = new Set<string>();
31
+ const isSource = lang === 'js' ? SOURCE_JS : SOURCE_PY;
32
+ const sessionPats = [...(lang === 'js' ? SESSION_JS : SESSION_PY), ...extraSession].filter((p) => p.confidence !== 'low');
33
+ const fromSession = (expr: string): boolean => sessionPats.some((p) => { p.re.lastIndex = 0; return p.re.test(expr); });
34
+ const tainted = (expr: string): boolean => { const e = expr.replace(TRUSTED_CHAIN, ' '); return isSource.test(e) || identifiers(e).some((i) => names.has(i) && !trusted.has(i)); };
35
+ const parsed = (expr: string): boolean => /\.(?:safeParse|parse|parseAsync|safeParseAsync|model_validate|parse_obj|load|validate)\s*\(/.test(expr) || /\bvalidated|\.data\b/.test(expr) && identifiers(expr).some((i) => validated.has(i));
36
+ const assignments: { lhs: string[]; rhs: string }[] = [];
37
+ if (lang === 'js') {
38
+ for (const m of masked.matchAll(/\b(?:const|let|var)\s+(?:(\w+)|\{([^}]*(?:\{[^}]*\}[^}]*)*)\}|\[([^\]]*)\])\s*(?::[^=\n]+)?=(?!=)/g)) {
39
+ const rhsStart = m.index! + m[0].length;
40
+ const rhsEnd = statementEnd(masked, rhsStart);
41
+ const lhs = m[1] ? [m[1]] : identifiers((m[2] ?? m[3] ?? '').replace(/(\w+)\s*:/g, ' ')).concat(identifiers((m[2] ?? '')));
42
+ assignments.push({ lhs, rhs: original.slice(rhsStart, rhsEnd) });
43
+ }
44
+ for (const m of masked.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*=(?![=>])/g)) {
45
+ const rhsStart = m.index! + m[0].length;
46
+ assignments.push({ lhs: [m[1]], rhs: original.slice(rhsStart, statementEnd(masked, rhsStart)) });
47
+ }
48
+ } else {
49
+ for (const m of masked.matchAll(/^[ \t]*([A-Za-z_][\w]*(?:\s*,\s*[A-Za-z_]\w*)*)\s*(?::[^=\n]+)?=(?!=)/gm)) {
50
+ const rhsStart = m.index! + m[0].length;
51
+ assignments.push({ lhs: m[1].split(',').map((s) => s.trim()), rhs: original.slice(rhsStart, statementEnd(masked, rhsStart)) });
52
+ }
53
+ for (const m of masked.matchAll(/\bfor\s+([A-Za-z_]\w*)\s+in\s+([^\n:]+):/g)) assignments.push({ lhs: [m[1]], rhs: m[2] });
54
+ }
55
+ for (let pass = 0; pass < 4; pass++) {
56
+ let changed = false;
57
+ for (const a of assignments) {
58
+ if (fromSession(a.rhs)) { for (const l of a.lhs) if (!trusted.has(l)) { trusted.add(l); names.delete(l); changed = true; } continue; }
59
+ if (!tainted(a.rhs)) continue;
60
+ const fromSchema = parsed(a.rhs);
61
+ for (const l of a.lhs) {
62
+ if (!names.has(l)) { names.add(l); changed = true; }
63
+ if (fromSchema && !validated.has(l)) { validated.add(l); changed = true; }
64
+ }
65
+ }
66
+ if (!changed) break;
67
+ }
68
+ return { names, validated, trusted };
69
+ }
70
+
71
+ export const isTainted = (t: Taint, expr: string, lang: Lang): boolean => {
72
+ const e = expr.replace(TRUSTED_CHAIN, ' ');
73
+ return (lang === 'js' ? SOURCE_JS : SOURCE_PY).test(e) || identifiers(e).some((i) => t.names.has(i) && !t.trusted.has(i));
74
+ };
75
+
76
+ // Whether a session derived value reaches this text: a trusted name, a chain such as req.user or current_user, or a field of
77
+ // a context object that the framework hands in (ctx.userId, auth.uid, session.user.id).
78
+ export const hasSessionValue = (t: Taint, expr: string): boolean => {
79
+ TRUSTED_CHAIN.lastIndex = 0;
80
+ if (TRUSTED_CHAIN.test(expr) || identifiers(expr).some((i) => t.trusted.has(i))) return true;
81
+ for (const m of expr.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)((?:\s*\??\.\s*[\w$]+)+)/g)) {
82
+ if (t.names.has(m[1]) && !t.trusted.has(m[1])) continue;
83
+ if (/^(?:ctx|context|auth|session|principal|identity|viewer|currentUser|me|actor|caller|user|usr|account|current)$/i.test(m[1]) && /user|id|uid|sub|account|tenant|org|owner/i.test(m[2])) return true;
84
+ }
85
+ return false;
86
+ };
@@ -0,0 +1,296 @@
1
+ // Data store touches and outbound calls inside one handler body. SQL text is found by its string literal; ORM, Supabase and
2
+ // Firebase calls are found by their call shape. Everything is a pattern, so each touch carries a confidence.
3
+ import type { Confidence, DataTouch, Outbound, OutboundKind, StoreKind } from '../types/index.ts';
4
+ import type { Lang } from './lex.ts';
5
+ import { closeOf, splitArgs, statementEnd } from './lex.ts';
6
+ import type { Taint } from './taint.ts';
7
+ import { hasSessionValue, identifiers, isTainted } from './taint.ts';
8
+ import { OWNER, PRIVILEGED, REDIRECT_GUARD, URL_GUARD } from './vocab.ts';
9
+
10
+ export interface Region { masked: string; text: string; base: number; line(index: number): number }
11
+
12
+ const clip = (s: string, n: number): string => (s.length <= n ? s : s.slice(0, n - 1) + '…');
13
+ const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim();
14
+
15
+ interface Literal { open: number; start: number; end: number; quote: string; fString: boolean }
16
+ // String literals in a region, found from the quotes that mask() keeps.
17
+ function literals(masked: string, lang: Lang): Literal[] {
18
+ const out: Literal[] = [];
19
+ for (let i = 0; i < masked.length; i++) {
20
+ const c = masked[i];
21
+ if (c !== '"' && c !== "'" && c !== '`') continue;
22
+ const triple = lang === 'py' && masked.startsWith(c.repeat(3), i);
23
+ const close = triple ? masked.indexOf(c.repeat(3), i + 3) : masked.indexOf(c, i + 1);
24
+ if (close < 0) break;
25
+ const prefix = lang === 'py' ? masked.slice(Math.max(0, i - 2), i) : '';
26
+ out.push({ open: i, start: i + (triple ? 3 : 1), end: close, quote: c, fString: /[fF]$/.test(prefix) || /[fF][rRbB]$/.test(prefix) || /[rRbB][fF]$/.test(prefix) });
27
+ i = close + (triple ? 2 : 0);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const SQL_START = /^\s*\(?\s*(SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO|WITH\s+\w+\s+AS)\b/i;
33
+ const SQL_ID = /(?:^|[\s(,.])(?:\w+\.)?(?:id|uuid|pk|_id)\s*=\s*(?:\?|\$\d+|:\w+|%s|@\w+|\$\{|\{|'\s*\+|"\s*\+|'\s*$|"\s*$|\s*$)/i;
34
+ const SQL_END = /\b(?:ORDER\s+BY|GROUP\s+BY|LIMIT|RETURNING|OFFSET|HAVING)\b|;/i;
35
+
36
+ function sqlTouch(lit: Literal, region: Region, t: Taint, lang: Lang): DataTouch | null {
37
+ const sql = region.text.slice(lit.start, lit.end);
38
+ const m = SQL_START.exec(sql);
39
+ if (!m || sql.length < 12) return null;
40
+ const word = m[1].toUpperCase();
41
+ const op: DataTouch['op'] = word.startsWith('SELECT') || word.startsWith('WITH') ? 'read' : word.startsWith('DELETE') ? 'delete' : 'write';
42
+ const target = (/\b(?:FROM|INTO|UPDATE)\s+(?:ONLY\s+)?([\w."]+)/i.exec(sql)?.[1] ?? null)?.replace(/"/g, '') ?? null;
43
+ const w = /\bWHERE\b/i.exec(sql);
44
+ let where = '';
45
+ if (w) { const rest = sql.slice(w.index + 5); const e = SQL_END.exec(rest); where = e ? rest.slice(0, e.index) : rest; }
46
+ const interpolated = lang === 'js' ? lit.quote === '`' && /\$\{/.test(sql) : lit.fString && /\{[^{}]+\}/.test(sql);
47
+ const interpExprs = lang === 'js' ? [...sql.matchAll(/\$\{([^}]*)\}/g)].map((x) => x[1]) : lit.fString ? [...sql.matchAll(/\{([^{}]+)\}/g)].map((x) => x[1]) : [];
48
+ // The statement around the literal: back to the previous ";", "{" or "}" and forward to the end of the statement.
49
+ let begin = lit.open;
50
+ for (let k = lit.open - 1; k >= 0 && lit.open - k < 700; k--) { const c = region.masked[k]; if (c === ';' || c === '{' || c === '}') { begin = k + 1; break; } begin = k; }
51
+ while (begin < lit.open && /\s/.test(region.masked[begin])) begin++;
52
+ const end = statementEnd(region.masked, begin);
53
+ const around = region.masked.slice(begin, end);
54
+ const concat = /^\s*(?:\+|%|\.format\s*\()/.test(region.masked.slice(lit.end + 1, lit.end + 14)) || /\+\s*$/.test(region.masked.slice(Math.max(0, lit.open - 6), lit.open));
55
+ const requestDerived = interpExprs.some((e) => isTainted(t, e, lang)) || isTainted(t, around, lang);
56
+ const rawSql = (interpolated && interpExprs.some((e) => isTainted(t, e, lang))) || (concat && isTainted(t, around, lang));
57
+ const params = positionalArgs(region, lit, begin, end);
58
+ const valueTainted = (expr: string, offset: number): boolean => {
59
+ // The value of a column or limit: an interpolation in the text, or the parameter at its placeholder position.
60
+ const inline = [...expr.matchAll(lang === 'js' ? /\$\{([^}]*)\}/g : /\{([^{}]+)\}/g)].map((x) => x[1]);
61
+ if (inline.length) return inline.some((e) => isTainted(t, e, lang));
62
+ const ph = /\?|%s|\$(\d+)/.exec(expr);
63
+ if (!ph) return false;
64
+ if (!params) return false;
65
+ const before = sql.slice(0, offset + ph.index);
66
+ const idx = ph[1] ? Number(ph[1]) - 1 : (before.match(/\?|%s/g) ?? []).length;
67
+ return params[idx] !== undefined && isTainted(t, params[idx], lang);
68
+ };
69
+ const sets: string[] = [];
70
+ if (op === 'write') {
71
+ const set = /\bSET\b/i.exec(sql);
72
+ if (set) {
73
+ const wh = /\bWHERE\b/i.exec(sql.slice(set.index));
74
+ const body = sql.slice(set.index + 3, wh ? set.index + wh.index : sql.length);
75
+ for (const part of splitTopOffsets(body, set.index + 3)) {
76
+ const cm = /^\s*(?:\w+\.)?"?(\w+)"?\s*=\s*([\s\S]*)$/.exec(part.text);
77
+ if (cm && PRIVILEGED.test(cm[1]) && valueTainted(cm[2], part.offset + part.text.indexOf(cm[2]))) sets.push(cm[1]);
78
+ }
79
+ } else {
80
+ const ins = /\(([^)]*)\)\s*VALUES\s*\(/i.exec(sql);
81
+ if (ins) {
82
+ const cols = ins[1].split(',').map((x) => x.trim().replace(/"/g, ''));
83
+ const vopen = ins.index + ins[0].length - 1;
84
+ const vclose = closeIn(sql, vopen);
85
+ const vals = splitTopOffsets(sql.slice(vopen + 1, vclose), vopen + 1);
86
+ cols.forEach((c, i) => { if (PRIVILEGED.test(c) && vals[i] && valueTainted(vals[i].text, vals[i].offset)) sets.push(c); });
87
+ }
88
+ }
89
+ }
90
+ // by_id: the id comparison itself takes a request value (not just any request value somewhere in the statement).
91
+ let byId = false;
92
+ if (w) {
93
+ const idm = new RegExp(SQL_ID.source, 'i').exec(sql.slice(w.index + 5));
94
+ if (idm) {
95
+ const eq = idm.index + idm[0].indexOf('=') + 1;
96
+ const offset = w.index + 5 + eq;
97
+ byId = valueTainted(sql.slice(offset, offset + 30), offset) || (concat && requestDerived && /=\s*['"]?\s*$/.test(sql.slice(offset - 1, w.index + 5 + idm.index + idm[0].length + 1)));
98
+ }
99
+ }
100
+ let limitFromRequest = false;
101
+ const lm = /\bLIMIT\s+(\?|%s|\$\d+|:\w+|\$\{[^}]*\}|\{[^{}]*\})/i.exec(sql);
102
+ if (lm) limitFromRequest = valueTainted(lm[1], lm.index + 6);
103
+ return {
104
+ store: 'sql', op, target, line: region.line(lit.open), by_id: byId, limit_from_request: limitFromRequest,
105
+ request_derived: requestDerived, owner_scoped: OWNER.test(where) && hasSessionValue(t, `${around} ${interpExprs.join(' ')}`), raw_sql: rawSql, sets: [...new Set(sets)].sort(), mass_assign: false, confidence: 'high',
106
+ };
107
+ }
108
+
109
+ function closeIn(s: string, open: number): number {
110
+ let depth = 0;
111
+ for (let i = open; i < s.length; i++) { if (s[i] === '(') depth++; else if (s[i] === ')') { depth--; if (depth === 0) return i; } }
112
+ return s.length;
113
+ }
114
+ function splitTopOffsets(s: string, base: number): { text: string; offset: number }[] {
115
+ const out: { text: string; offset: number }[] = [];
116
+ let depth = 0;
117
+ let from = 0;
118
+ for (let i = 0; i <= s.length; i++) {
119
+ const c = s[i];
120
+ if (c === '(') depth++;
121
+ else if (c === ')') depth--;
122
+ if ((c === ',' && depth === 0) || i === s.length) { if (s.slice(from, i).trim()) out.push({ text: s.slice(from, i), offset: base + from }); from = i + 1; }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ // The parameter expressions that go with a SQL string, in order: the array or tuple given to run, get, all, execute or query
128
+ // after it (JavaScript), or the second argument of the call that holds it (Python and others).
129
+ function positionalArgs(region: Region, lit: Literal, begin: number, end: number): string[] | null {
130
+ const m = region.masked;
131
+ const after = /^\s*,?\s*\)?\s*\.\s*(?:run|get|all|execute|query|exec|values|iterate|pluck|raw)\s*\(/.exec(m.slice(lit.end + 1, end));
132
+ let open = -1;
133
+ if (after) open = lit.end + 1 + after[0].length - 1;
134
+ else {
135
+ // The literal is an argument of a call: execute("...", (a, b)).
136
+ for (let k = lit.open - 1; k >= begin; k--) { if (m[k] === '(') { open = k; break; } if (m[k] === ')' ) break; }
137
+ }
138
+ if (open < 0) return null;
139
+ const args = splitArgs(m, open);
140
+ let rest = args;
141
+ if (!after) rest = args.filter((a) => a.start > lit.open);
142
+ if (!rest.length) return null;
143
+ const first = m.slice(rest[0].start, rest[0].end);
144
+ if (/^[\[(]/.test(first) && (rest.length === 1 || !after)) {
145
+ const inner = splitArgs(m, rest[0].start);
146
+ if (inner.length || /^[\[(]\s*[\])]$/.test(first)) return inner.map((a) => region.text.slice(a.start, a.end));
147
+ }
148
+ return rest.map((a) => region.text.slice(a.start, a.end));
149
+ }
150
+
151
+ const NOT_MODEL = new Set(['Object', 'Array', 'Promise', 'Math', 'JSON', 'Number', 'String', 'Symbol', 'Buffer', 'Date', 'Map', 'Set', 'WeakMap', 'Reflect', 'Proxy', 'Intl', 'URL', 'Error', 'Response', 'NextResponse', 'Request', 'Headers', 'FormData', 'File', 'Blob', 'Boolean', 'RegExp', 'Uint8Array', 'process', 'console', 'React', 'Cache', 'Event', 'AbortController', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'Atomics', 'BigInt']);
152
+ const OP_WORDS: [RegExp, DataTouch['op']][] = [[/^(?:find|get|select|count|aggregate|exists|query|read|list|distinct|group|all|filter|exclude|values|first|last|only|defer|order_by|annotate|iterator|one)/i, 'read'], [/^(?:create|insert|update|upsert|save|set|add|bulk|replace|merge|increment|decrement)/i, 'write'], [/^(?:delete|destroy|remove|drop|truncate)/i, 'delete']];
153
+ const opOf = (name: string): DataTouch['op'] => OP_WORDS.find(([re]) => re.test(name))?.[1] ?? 'unknown';
154
+
155
+ function callArgs(region: Region, open: number): string {
156
+ const c = closeOf(region.masked, open);
157
+ return region.text.slice(open, c < 0 ? Math.min(region.text.length, open + 400) : c);
158
+ }
159
+
160
+ function ormTouch(store: StoreKind, name: string, target: string | null, args: string, at: number, region: Region, t: Taint, lang: Lang, confidence: Confidence, byIdName: boolean): DataTouch {
161
+ const op = opOf(name);
162
+ const derived = isTainted(t, args, lang);
163
+ let byId = byIdName && derived;
164
+ // key: value pairs of the argument object (JavaScript) or keyword arguments (Python); a shorthand key is its own value.
165
+ const pairs: [string, string][] = [];
166
+ if (lang === 'js') for (const m of args.matchAll(/(?:^|[{,\s])([A-Za-z_$][\w$]*)\s*(?::\s*([^,}\n]+)|(?=\s*[,}]))/g)) pairs.push([m[1], m[2] ?? m[1]]);
167
+ else for (const m of args.matchAll(/(?:^|[(,\s])([A-Za-z_]\w*)\s*=\s*([^,)\n]+)/g)) pairs.push([m[1], m[2]]);
168
+ // A shorthand key inside a nested object, as in { where: { id } }, is swallowed by the value of the outer key above.
169
+ if (lang === 'js') for (const m of args.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g)) pairs.push([m[1], m[1]]);
170
+ for (const [k, v] of pairs) if (/^(?:id|_id|uuid|pk)$/.test(k) && isTainted(t, v, lang)) byId = true;
171
+ const sets = op === 'write' ? pairs.filter(([k, v]) => PRIVILEGED.test(k) && isTainted(t, v, lang)).map(([k]) => k) : [];
172
+ const limit = pairs.some(([k, v]) => /^(?:take|limit|first|pageSize|page_size|per_page|perPage|size|max|count)$/.test(k) && isTainted(t, v, lang)) || /\.\s*(?:limit|take|range|slice)\s*\(\s*[^)]*\)/.test(args) && [...args.matchAll(/\.\s*(?:limit|take|range|slice)\s*\(([^)]*)\)/g)].some((m) => isTainted(t, m[1], lang));
173
+ const spread = /\.\.\.\s*([\w$.]+)|\*\*\s*([\w.]+)|data\s*:\s*([\w$.]+)\s*[,}]|\(\s*([\w$.]+)\s*\)\s*$/g;
174
+ let mass = false;
175
+ if (op === 'write') for (const m of args.matchAll(spread)) { const v = m[1] ?? m[2] ?? m[3] ?? m[4]; if (v && identifiers(v).some((i) => t.names.has(i) && !t.trusted.has(i) && !t.validated.has(i)) && !/^\s*(?:id|where)/.test(v)) mass = true; }
176
+ return { store, op, target, line: region.line(at), by_id: byId, limit_from_request: limit, request_derived: derived, owner_scoped: OWNER.test(args) && hasSessionValue(t, args), raw_sql: false, sets: [...new Set(sets)].sort(), mass_assign: mass, confidence };
177
+ }
178
+
179
+ export function dataTouches(region: Region, t: Taint, lang: Lang): DataTouch[] {
180
+ const out: DataTouch[] = [];
181
+ const { masked } = region;
182
+ for (const lit of literals(masked, lang)) { const d = sqlTouch(lit, region, t, lang); if (d) out.push(d); }
183
+ const seen = new Set<number>();
184
+ const add = (m: RegExpMatchArray, build: (open: number) => DataTouch | null): void => {
185
+ const open = m.index! + m[0].length - 1;
186
+ if (seen.has(open)) return;
187
+ const d = build(open);
188
+ if (d) { seen.add(open); out.push(d); }
189
+ };
190
+ if (lang === 'js') {
191
+ for (const m of masked.matchAll(/\b(prisma|db|client|tx|trx|database|orm)\s*\.\s*([A-Za-z_]\w*)\s*\.\s*(findUnique|findFirst|findMany|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|upsert|delete|deleteMany|count|aggregate|groupBy)\s*\(/g))
192
+ add(m, (open) => ormTouch('prisma', m[3], m[2], callArgs(region, open), m.index!, region, t, lang, 'high', false));
193
+ for (const m of masked.matchAll(/\b([A-Za-z_$][\w$]*)\s*\.\s*(find|findOne|findById|findByPk|findAll|findAndCountAll|findOneOrFail|findOneBy|findBy|findById\w*|findOneAnd\w+|updateOne|updateMany|deleteOne|deleteMany|create|createMany|insertMany|insertOne|bulkCreate|countDocuments|count|aggregate|destroy|update|upsert|save|remove)\s*\(/g)) {
194
+ const recv = m[1];
195
+ const modelLike = (/^[A-Z]\w*$/.test(recv) && !NOT_MODEL.has(recv)) || /(?:Model|Collection|Repo|Repository)$/.test(recv);
196
+ if (!modelLike) continue;
197
+ add(m, (open) => ormTouch(/Collection$/.test(recv) || /^(?:findOneAnd|insertOne|insertMany|updateOne|deleteOne|countDocuments)/.test(m[2]) ? 'mongo' : 'orm', m[2], recv, callArgs(region, open), m.index!, region, t, lang, 'medium', /^findById|^findByPk/.test(m[2])));
198
+ }
199
+ for (const m of masked.matchAll(/\.\s*from\s*\(\s*(['"])/g)) {
200
+ const quoteAt = m.index! + m[0].length - 1;
201
+ const close = masked.indexOf(m[1], quoteAt + 1);
202
+ const table = region.text.slice(quoteAt + 1, close);
203
+ const end = statementEnd(masked, m.index!); // from the dot of .from(, so the whole chain is read
204
+ const chain = region.text.slice(close, end);
205
+ const verb = /\.\s*(select|insert|update|delete|upsert)\s*\(/.exec(chain);
206
+ if (!verb) continue;
207
+ const op: DataTouch['op'] = verb[1] === 'select' ? 'read' : verb[1] === 'delete' ? 'delete' : 'write';
208
+ const eqs = [...chain.matchAll(/\.\s*(?:eq|match|in|filter)\s*\(\s*['"]([\w.]+)['"]\s*,\s*([^)]*)\)/g)];
209
+ const byId = eqs.some((e) => /^(?:id|uuid|_id)$/.test(e[1]) && isTainted(t, e[2], lang));
210
+ const sets: string[] = [];
211
+ if (op === 'write') for (const c of chain.matchAll(/(?:^|[{,\s])([A-Za-z_]\w*)\s*:/g)) if (PRIVILEGED.test(c[1])) sets.push(c[1]);
212
+ out.push({ store: /\.storage\s*\.\s*from/.test(masked.slice(Math.max(0, m.index! - 12), m.index! + 6)) ? 'object_storage' : 'supabase', op, target: table, line: region.line(m.index!), by_id: byId, limit_from_request: [...chain.matchAll(/\.\s*(?:limit|range)\s*\(([^)]*)\)/g)].some((x) => isTainted(t, x[1], lang)), request_derived: isTainted(t, chain, lang), owner_scoped: OWNER.test(chain.replace(/\.\s*(?:select|insert)\s*\([^)]*\)/g, '')) && hasSessionValue(t, chain), raw_sql: false, sets: [...new Set(sets)].sort(), mass_assign: op === 'write' && /\(\s*(?:body|req\.body|await\s+\w+\.json\(\))\s*\)/.test(chain), confidence: 'high' });
213
+ }
214
+ for (const m of masked.matchAll(/\b(?:redis|cache|kv)\s*\.\s*(get|set|del|hget|hset|hgetall|incr|expire|setex)\s*\(/g)) out.push({ store: 'redis', op: opOf(m[1]), target: null, line: region.line(m.index!), by_id: false, limit_from_request: false, request_derived: isTainted(t, callArgs(region, m.index! + m[0].length - 1), lang), owner_scoped: false, raw_sql: false, sets: [], mass_assign: false, confidence: 'medium' });
215
+ for (const m of masked.matchAll(/\bfs(?:\.promises)?\s*\.\s*(readFile|readFileSync|createReadStream|writeFile|writeFileSync|createWriteStream|unlink|unlinkSync|readdir|readdirSync|appendFile|rm|rmSync)\s*\(/g)) {
216
+ const args = callArgs(region, m.index! + m[0].length - 1);
217
+ out.push({ store: 'filesystem', op: /^(?:read|create.*Read)/.test(m[1]) ? 'read' : /unlink|rm/.test(m[1]) ? 'delete' : 'write', target: null, line: region.line(m.index!), by_id: false, limit_from_request: false, request_derived: isTainted(t, args, lang), owner_scoped: false, raw_sql: false, sets: [], mass_assign: false, confidence: 'high' });
218
+ }
219
+ } else {
220
+ for (const m of masked.matchAll(/\b([A-Z]\w*)\s*\.\s*objects\s*\.\s*(get|filter|all|exclude|create|update|delete|get_or_create|update_or_create|bulk_create|values|first|last|count|exists|select_related|prefetch_related)\s*\(/g))
221
+ add(m, (open) => {
222
+ // The verb at the end of the chain decides the operation: Note.objects.filter(pk=pk).delete().
223
+ const chain = region.masked.slice(open, statementEnd(region.masked, m.index!));
224
+ const verb = /\)\s*\.\s*(delete|update|create|bulk_create|bulk_update|update_or_create|get_or_create)\s*\(/.exec(chain);
225
+ const tail = verb ? region.text.slice(open, open + chain.length) : callArgs(region, open);
226
+ const d = ormTouch('orm', verb ? verb[1] : m[2], m[1], verb ? tail : callArgs(region, open), m.index!, region, t, lang, 'high', false);
227
+ // A slice such as [:limit] after the query is a limit; it counts when the bound is request data.
228
+ const slice = [...chain.matchAll(/\[\s*(?:[^\]:]*:)\s*([^\]]+)\]/g)].some((x) => isTainted(t, region.text.slice(open + (x.index ?? 0), open + (x.index ?? 0) + x[0].length), lang));
229
+ return slice ? { ...d, limit_from_request: true } : d;
230
+ });
231
+ for (const m of masked.matchAll(/\bget_object_or_404\s*\(/g)) add(m, (open) => ormTouch('orm', 'get', null, callArgs(region, open), m.index!, region, t, lang, 'high', false));
232
+ for (const m of masked.matchAll(/\b([A-Z]\w*)\s*\.\s*query\s*\.\s*(get|get_or_404|filter|filter_by|all|first|first_or_404|delete|one|one_or_none|count)\s*\(/g))
233
+ add(m, (open) => ormTouch('orm', m[2], m[1], callArgs(region, open), m.index!, region, t, lang, 'high', /^get/.test(m[2])));
234
+ for (const m of masked.matchAll(/\b(?:db\.)?session\s*\.\s*(query|get|add|delete|merge|execute|scalar|scalars)\s*\(/g))
235
+ add(m, (open) => ormTouch('orm', m[1] === 'query' ? 'select' : m[1], null, callArgs(region, open), m.index!, region, t, lang, 'medium', m[1] === 'get'));
236
+ for (const m of masked.matchAll(/\b(\w*(?:collection|coll|db))\s*\.\s*(find|find_one|insert_one|insert_many|update_one|update_many|delete_one|delete_many|count_documents|aggregate|replace_one)\s*\(/g))
237
+ add(m, (open) => ormTouch('mongo', m[2], m[1], callArgs(region, open), m.index!, region, t, lang, 'medium', false));
238
+ for (const m of masked.matchAll(/\.\s*table\s*\(\s*['"]([\w.]+)['"]\s*\)/g)) out.push({ store: 'supabase', op: 'unknown', target: m[1], line: region.line(m.index!), by_id: false, limit_from_request: false, request_derived: false, owner_scoped: false, raw_sql: false, sets: [], mass_assign: false, confidence: 'medium' });
239
+ for (const m of masked.matchAll(/\b(?:open|os\.remove|os\.unlink|shutil\.\w+|send_from_directory|send_file|Path)\s*\(/g)) {
240
+ const args = callArgs(region, m.index! + m[0].length - 1);
241
+ out.push({ store: 'filesystem', op: /remove|unlink|rmtree/.test(m[0]) ? 'delete' : 'read', target: null, line: region.line(m.index!), by_id: false, limit_from_request: false, request_derived: isTainted(t, args, lang), owner_scoped: false, raw_sql: false, sets: [], mass_assign: false, confidence: 'medium' });
242
+ }
243
+ }
244
+ out.sort((a, b) => a.line - b.line || (a.store < b.store ? -1 : a.store > b.store ? 1 : 0) || ((a.target ?? '') < (b.target ?? '') ? -1 : 1));
245
+ return out;
246
+ }
247
+
248
+
249
+ const HTTP_JS = /(?<![.\w$])(?:fetch|ofetch|\$fetch)\s*\(|\b(?:axios|got|ky|needle|superagent|undici|http|https)\s*(?:\.\s*(?:get|post|put|patch|delete|request|head|fetch|stream))?\s*\(|\bnew\s+(?:Request|XMLHttpRequest)\s*\(|\bhttps?\.(?:get|request)\s*\(/g;
250
+ const HTTP_PY = /\b(?:requests|httpx|aiohttp|urllib3?)\s*\.\s*(?:get|post|put|patch|delete|request|head|urlopen|fetch)\s*\(|\burlopen\s*\(|\burllib\.request\.\w+\s*\(|\bhttp\.client\.\w+\s*\(|\bpycurl\b/g;
251
+ // A client object from an HTTP library the file imports: async with httpx.AsyncClient() as client: client.get(url)
252
+ const HTTP_PY_CLIENT = /\b(?:client|session|async_client|http_client|http)\s*\.\s*(?:get|post|put|patch|delete|request|head)\s*\(/g;
253
+ const AI_RE = /\b(?:openai|anthropic|genai|ollama|cohere|mistral|groq|replicate)\s*\.\s*(?:chat\.completions\.create|completions\.create|messages\.create|responses\.create|generate_content|generateContent|chat|generate|invoke|stream|embeddings\.create|images\.generate)\s*\(|\b(?:generateText|streamText|generateObject|streamObject|embed|embedMany)\s*\(|\bChatCompletion\.create\s*\(/g;
254
+ const EMAIL_RE = /\b(?:sendMail|sendEmail|send_mail|send_email)\s*\(|\.emails\s*\.\s*send\s*\(|\bsgMail\s*\.\s*send\s*\(|\bresend\s*\.|\btransporter\s*\.\s*sendMail\s*\(|\bses\s*\.\s*send\w*\s*\(|\bmailgun\b|\bpostmark\b/g;
255
+ const SMS_RE = /\btwilio\b|\bsns\s*\.\s*publish\s*\(|\bsendSms\w*\s*\(|\bsend_sms\w*\s*\(|\bvonage\b/g;
256
+ const PAY_RE = /\bstripe\s*\.\s*\w+\s*\.\s*(?:create|retrieve|update|capture|refund|cancel)\s*\(|\bpaystack\w*\s*\.|\bpaddle\w*\s*\.\s*\w+|\bflutterwave\b|\bgateway\s*\.\s*charge\s*\(/g;
257
+ const PROC_JS = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(|\beval\s*\(|\bnew\s+Function\s*\(|\bvm\.run\w*\s*\(/g;
258
+ const PROC_PY = /\b(?:subprocess\s*\.\s*\w+|os\s*\.\s*(?:system|popen|exec\w*)|eval|exec|commands\.getoutput)\s*\(/g;
259
+ const REDIRECT = /(?<![.\w$])redirect\s*\(|\b(?:NextResponse|Response|res|reply|ctx|c)\s*\.\s*redirect\s*\(|\bHttpResponseRedirect\s*\(|\bRedirectResponse\s*\(|\bres\.(?:writeHead|status)\s*\(\s*30\d|\bhead\s*\(\s*['"]Location['"]|\bset(?:Header)?\s*\(\s*['"]Location['"]/g;
260
+ const REDIRECT_KEY = /(?:^|[{,\s])(redirect|redirectTo|redirect_to|redirectUrl|redirect_url|returnTo|return_to|next|nextUrl|callbackUrl|callback_url|location|url)\s*:\s*([^,}\n]+)/g;
261
+
262
+ function firstArg(region: Region, open: number): string {
263
+ const a = splitArgs(region.masked, open);
264
+ return a.length ? oneLine(region.text.slice(a[0].start, a[0].end)) : '';
265
+ }
266
+
267
+ export function outboundCalls(region: Region, t: Taint, lang: Lang, returnsJson: boolean, imports = ''): Outbound[] {
268
+ const out: Outbound[] = [];
269
+ const fnText = region.text;
270
+ const add = (kind: OutboundKind, re: RegExp, guard: RegExp | null, confidence: Confidence): void => {
271
+ re.lastIndex = 0;
272
+ for (const m of region.masked.matchAll(re)) {
273
+ const open = region.masked.indexOf('(', m.index!);
274
+ const target = open < 0 ? '' : firstArg(region, open);
275
+ const full = open < 0 ? '' : callArgs(region, open);
276
+ out.push({ kind, line: region.line(m.index!), target: clip(target || oneLine(m[0]), 120), request_derived: kind === 'ai_model' || kind === 'email' || kind === 'sms' || kind === 'payment' ? isTainted(t, full, lang) : isTainted(t, target || full, lang), guarded: guard ? guard.test(fnText) : false, confidence });
277
+ }
278
+ };
279
+ add('http', lang === 'js' ? HTTP_JS : HTTP_PY, URL_GUARD, 'high');
280
+ if (lang === 'py' && /httpx|aiohttp|requests/.test(imports)) add('http', HTTP_PY_CLIENT, URL_GUARD, 'medium');
281
+ add('ai_model', AI_RE, null, 'medium');
282
+ add('email', EMAIL_RE, null, 'medium');
283
+ add('sms', SMS_RE, null, 'medium');
284
+ add('payment', PAY_RE, null, 'medium');
285
+ add('process', lang === 'js' ? PROC_JS : PROC_PY, null, 'high');
286
+ add('redirect', REDIRECT, REDIRECT_GUARD, 'high');
287
+ if (returnsJson) {
288
+ for (const m of fnText.matchAll(REDIRECT_KEY)) {
289
+ if (m[1] === 'url' || m[1] === 'location') continue;
290
+ out.push({ kind: 'redirect', line: region.line(m.index!), target: clip(oneLine(m[2]), 120), request_derived: isTainted(t, m[2], lang), guarded: REDIRECT_GUARD.test(fnText), confidence: 'medium' });
291
+ }
292
+ }
293
+ const seen = new Set<string>();
294
+ return out.filter((o) => { const k = `${o.kind}:${o.line}:${o.target}`; if (seen.has(k)) return false; seen.add(k); return true; })
295
+ .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));
296
+ }
@@ -0,0 +1,106 @@
1
+ // Vocabulary the extractors match: names of calls, decorators and libraries that mean a protection, a request input, a
2
+ // data store or an outbound call. Matched against masked text (comments and string contents blanked). Data, kept apart
3
+ // from the logic so it can be read and extended in one place. Confidence rule: a known library API is high, a name from
4
+ // this list is medium, a name that only suggests the thing is low.
5
+ import type { Confidence } from '../types/index.ts';
6
+
7
+ // Packages whose import in a file makes a vocabulary hit in that file high confidence.
8
+ export const AUTH_PACKAGES = /next-auth|@auth\/|@clerk\/|@supabase\/|firebase-admin|firebase\/auth|passport|jsonwebtoken|\bjose\b|@kinde|lucia|better-auth|iron-session|@auth0|express-jwt|koa-jwt|@fastify\/jwt|@fastify\/auth|@nestjs\/passport|@nestjs\/jwt|flask_login|flask_jwt_extended|flask_httpauth|fastapi\.security|django\.contrib\.auth|rest_framework|authlib|python-jose|jwt\b/;
9
+ export const RATE_PACKAGES = /express-rate-limit|rate-limiter-flexible|@upstash\/ratelimit|@nestjs\/throttler|express-slow-down|flask_limiter|slowapi|django_ratelimit|ratelimit|limits\b|fastify-rate-limit|@fastify\/rate-limit|hono-rate-limiter/;
10
+
11
+ export interface Pattern { re: RegExp; via: string; confidence: Confidence }
12
+ const p = (re: RegExp, via: string, confidence: Confidence): Pattern => ({ re, via, confidence });
13
+
14
+ // Session read, JavaScript and TypeScript. A match means the handler asks who the caller is.
15
+ export const SESSION_JS: Pattern[] = [
16
+ p(/\bAuthGuard\s*\(|@UseGuards\s*\([^)]*(?:Auth|Jwt|JWT|Session|Firebase|Clerk|Supabase)\w*Guard/, 'AuthGuard', 'high'),
17
+ p(/\bgetServerSession\s*\(/, 'getServerSession', 'high'),
18
+ p(/\b(?:supabase|client|sb)\w*\.auth\.(?:getUser|getSession|getClaims)\s*\(|\bauth\.(?:getUser|getSession)\s*\(/, 'auth.getUser', 'high'),
19
+ p(/\bgetToken\s*\(\s*\{/, 'getToken', 'high'),
20
+ p(/\b(?:jwt\.verify|jwtVerify|verifyIdToken|verifySessionCookie|jwt\.decode)\s*\(/, 'jwt verify', 'high'),
21
+ p(/\bpassport\.authenticate\s*\(/, 'passport.authenticate', 'high'),
22
+ p(/\b(?:getAuth|clerkClient|currentUser|auth)\s*\(\s*(?:req|request)?\s*\)/, 'auth()', 'medium'),
23
+ p(/\b(?:req|request)\.(?:user|session|auth)\b|\bctx\.state\.user\b|\bres\.locals\.(?:user|session)\b|\bc\.get\(\s*['"](?:user|session|jwtPayload)/, 'request user', 'medium'),
24
+ p(/\b(?:require|ensure|assert)(?:Auth|User|Login|Session|Authenticated|SignedIn)\w*\b/, 'require auth', 'medium'),
25
+ p(/\b(?:is|check)(?:Authenticated|LoggedIn|Auth|Authorized|SignedIn)\w*\b/, 'is authenticated', 'medium'),
26
+ p(/\b(?:get|find|load|fetch|resolve)?(?:Current|Session|Auth|Logged|Viewer|Signed)(?:In)?User\w*\s*\(|\bcurrentUser\w*\s*\(|\buserFrom(?:Token|Session|Cookie|Request)\w*\s*\(|\bgetUserFrom\w*\s*\(|\bgetViewer\w*\s*\(/, 'current user', 'medium'),
27
+ p(/\bgetSession\s*\(/, 'getSession', 'medium'),
28
+ p(/\bauthenticate\w*\s*\(|\bverifyToken\w*\s*\(|\bverifyJwt\w*\s*\(|\bwithAuth\w*\b|\bauthMiddleware\b|\bisAuthorized\s*\(|\bprotect\s*\(/, 'authenticate', 'medium'),
29
+ p(/\b(?:req|request)\.cookies\.get\s*\(\s*['"][^'"]*(?:session|token|auth|jwt|sid)[^'"]*['"]|\bcookies\s*\(\s*\)\s*\.\s*get\s*\(\s*['"][^'"]*(?:session|token|auth|jwt|sid)[^'"]*['"]|\bcookies\.get\s*\(\s*['"][^'"]*(?:session|token|auth|jwt|sid)/i, 'session cookie read', 'low'),
30
+ p(/\bheaders?\b[^;\n]{0,40}\bauthorization\b|\bauthorization\b\s*[\]:)]|['"]authorization['"]/i, 'authorization header', 'low'),
31
+ ];
32
+
33
+ export const SESSION_PY: Pattern[] = [
34
+ p(/@(?:\w+\.)?(?:login_required|jwt_required|auth_required|token_required|permission_required|user_passes_test|authenticated|requires_auth)\b/, 'login decorator', 'high'),
35
+ p(/\bDepends\s*\(\s*(?:get_current_\w*|oauth2_scheme|get_user\w*|require_\w*|current_\w*|verify_\w*|auth\w*|get_token\w*|\w*(?:auth|token|jwt|bearer)\w*)\s*\)|\bSecurity\s*\(|\bHTTPBearer\b|\bOAuth2PasswordBearer\b/, 'Depends(auth)', 'high'),
36
+ p(/\b(?:get_jwt_identity|get_jwt|verify_jwt_in_request|jwt\.decode|decode_token)\s*\(/, 'jwt', 'high'),
37
+ p(/@(?:\w+\.)?(?:authorize\w*|authenticate\w*|api_key_required|requires_api_key)\b/, 'auth decorator by name', 'medium'),
38
+ p(/@(?:\w+\.)?(?:require_(?:user|login|auth\w*|session|token|member|account|jwt)|ensure_(?:user|login|auth\w*)|login_?required|auth_?required|authenticated_only)\b/, 'login decorator by name', 'medium'),
39
+ p(/\bpermission_classes\s*=\s*[\[(][^\])]*(?:IsAuthenticated|IsAdminUser|DjangoModelPermissions|IsAuthenticatedOrReadOnly)|\bLoginRequiredMixin\b|\bauthentication_classes\s*=\s*[\[(][^\])]*\w/, 'permission_classes', 'high'),
40
+ p(/\bcurrent_user\b|\bg\.user\b|\brequest\.user\b|\brequest\.session\b|\bsession\s*\[|\bsession\.get\s*\(|\bauth\.get_user\b|\brequest\.auth\b/, 'current user', 'medium'),
41
+ p(/\b(?:get|require|ensure)_(?:current_)?(?:user|session)(?!_by_)\w*\s*\(|\bauthenticate\s*\(|\bis_authenticated\b|\bverify_token\w*\s*\(/, 'get user', 'medium'),
42
+ p(/request\.headers(?:\.get\s*\(\s*|\s*\[\s*)['"]Authorization/i, 'authorization header', 'low'),
43
+ ];
44
+
45
+ export const ROLE_JS: Pattern[] = [
46
+ p(/\brole\s*(?:===?|!==?)\s*["'`](?:admin|owner|superadmin|staff|moderator|manager)/i, 'role compare', 'high'),
47
+ p(/["'`](?:admin|owner|superadmin|staff)["'`]\s*(?:===?|!==?)\s*[\w.?]*role|roles?\.(?:includes|some)\(\s*["'`](?:admin|owner|staff)/i, 'role compare', 'high'),
48
+ p(/@(?:Roles|PreAuthorize|Secured|RolesAllowed|HasRole|RequirePermissions|Permissions)\s*\(/, 'role decorator', 'high'),
49
+ p(/\b(?:requireRole|require_role|checkRole|withRole|hasRole|hasPermission|hasAnyRole|assertRole|requirePermission|requireAdmin|assertAdmin|ensureAdmin|adminOnly|adminMiddleware|checkAdmin|isSuperAdmin)\w*\b|\b(?:can|ability\.can|authorize)\s*\(|\b(?:isAdmin|is_admin|isStaff|isSuperUser)\b/, 'role check', 'medium'),
50
+ p(/\bis(?:Accounting|Moderator|Manager|Owner|Superuser|SuperAdmin|Deluxe|Admin)\w*\s*\(/, 'role check', 'medium'),
51
+ ];
52
+ export const ROLE_PY: Pattern[] = [
53
+ p(/@(?:\w+\.)?(?:roles_required|role_required|admin_required|staff_member_required|superuser_required|permission_required|user_passes_test|require_role|requires_roles|roles_accepted)\b/, 'role decorator', 'high'),
54
+ p(/\bDepends\s*\(\s*\w*(?:superuser|admin|staff|moderator)\w*\s*\)/, 'Depends(admin)', 'medium'),
55
+ p(/@(?:\w+\.)?(?:require_(?:admin|role\w*|permission\w*|staff|superuser|owner)|admin_only|admin_?required|only_admin\w*)\b/, 'role decorator by name', 'medium'),
56
+ p(/\b(?:IsAdminUser|DjangoModelPermissions|PermissionRequiredMixin|UserPassesTestMixin|IsSuperuser|IsStaff)\b/, 'permission class', 'high'),
57
+ p(/\.(?:is_staff|is_superuser|is_admin)\b|\bhas_perms?\s*\(|\bcurrent_user\.(?:role|is_admin)|\buser\.role\s*(?:==|!=|in)\b|\brole\s*(?:==|!=)\s*['"](?:admin|staff|owner)/i, 'role compare', 'medium'),
58
+ p(/\b(?:require|check|assert|ensure)_(?:admin|role|permission|staff|superuser)\w*\s*\(|\bDepends\s*\(\s*(?:require_admin|admin_required|get_admin\w*|check_admin\w*|require_role\w*|get_current_admin\w*)\)/, 'role check', 'medium'),
59
+ ];
60
+
61
+ export const SIGNATURE: Pattern[] = [
62
+ p(/\b(?:constructEvent|construct_event|constructEventAsync|webhooks\.verify|wh\.verify|Webhook\.construct|verifyWebhook\w*|validateWebhook\w*|verifySignature\w*|verify_signature\w*|validateSignature\w*|validate_signature\w*|checkSignature\w*|verifyHmac\w*|verifyRequestSignature|isValidSignature\w*|svix|verify_webhook\w*)\b/, 'signature verify', 'high'),
63
+ p(/\b(?:timingSafeEqual|compare_digest|hash_equals|secure_compare|hmac\.Equal)\s*\(|\bcreateHmac\s*\(|\bhmac\.new\s*\(/, 'hmac compare', 'high'),
64
+ p(/\bverify(?!\w*pass(?:word|wd)?\b)\w*\s*\(\s*(?:raw|body|payload|req|request|signature|sig)\b/i, 'verify call', 'medium'),
65
+ ];
66
+
67
+ export const RATE: Pattern[] = [
68
+ p(/\b(?:rateLimit|rateLimiter|slowDown|checkRateLimit|withRateLimit|ratelimit|Ratelimit|RateLimiterMemory|RateLimiterRedis|RateLimiter|throttle)\w*\s*[(.]|\b\w*(?:rate_?limit|ratelimit|limiter)\w*\s*\(|\b\w*(?:[lL]imiter|RateLimit|rateLimit|ratelimit|[tT]hrottler?)\w*\b/i, 'rate limit', 'medium'),
69
+ p(/@(?:Throttle|SkipThrottle|UseGuards\s*\(\s*ThrottlerGuard|\w*limiter\.limit|limiter\.limit|ratelimit|rate_limit|ratelimited|throttle)\b|\bthrottle_classes\s*=|\bThrottlerGuard\b|\bdefault_limits\b|\bDepends\s*\(\s*\w*(?:rate|limit)\w*\s*\)|\blimiter\.limit\s*\(/i, 'rate limit decorator', 'high'),
70
+ ];
71
+
72
+ export const VALIDATION_JS: Pattern[] = [
73
+ p(/\.(?:safeParse|safeParseAsync|parseAsync|spa)\s*\(/, 'schema parse', 'high'),
74
+ p(/(?:\b(?!JSON|Date|Number|Math|url|URL|path|qs|querystring|yaml|toml|Intl|parseInt)[A-Za-z_$][\w$]*|\))\.parse\s*\(\s*(?:await\s+)?(?:req|request|body|params|query|input|data|formData|Object\.fromEntries|await)/, 'schema parse', 'high'),
75
+ p(/\b(?:Joi|joi)\.\w+|\.validateAsync\s*\(|\byup\b|\bvalibot\b|\bajv\b|\bcelebrate\s*\(|\bcheckSchema\s*\(|\bvalidationResult\s*\(|\bvalidateRequest\s*\(|\bvalidateBody\s*\(|\bvalidateOrReject\s*\(|\bplainToInstance\s*\(|\bplainToClass\s*\(|\bTypeBox\b|\bType\.Object\s*\(/, 'validation library', 'high'),
76
+ p(/\b(?:body|param|query|check|header|cookie)\s*\(\s*['"][\w.\[\]*]+['"]\s*\)\s*\.\s*(?:is\w+|not\w*|trim|escape|exists|notEmpty|optional|matches|isLength|custom)\b/, 'express-validator', 'high'),
77
+ p(/\.input\s*\(\s*(?:z\.|\w+Schema)/, 'trpc input', 'high'),
78
+ p(/\bschema\s*:\s*\{|\bschema\s*:\s*\w+Schema\b/, 'route schema', 'medium'),
79
+ p(/\b(?:validate|sanitize|validateInput|validatePayload|assertValid|isValid\w*)\s*\(/, 'validate call', 'low'),
80
+ ];
81
+ export const VALIDATION_PY: Pattern[] = [
82
+ p(/\.(?:model_validate|model_validate_json|parse_obj|parse_raw|validate_python)\s*\(/, 'pydantic', 'high'),
83
+ p(/\b\w*Schema\s*\(\s*[^)]*\)\s*\.\s*(?:load|validate|loads)\s*\(|\bschema\.(?:load|validate)\s*\(/, 'marshmallow', 'high'),
84
+ p(/\.(?:is_valid|validate_on_submit|full_clean)\s*\(/, 'form or serializer valid', 'high'),
85
+ p(/\b(?:validate|jsonschema\.validate|cerberus|voluptuous|wtforms|FlaskForm)\b/, 'validation library', 'medium'),
86
+ ];
87
+
88
+ export const AUDIT: Pattern[] = [
89
+ p(/\b\w*(?:[aA]udit|ACTIVITY_?LOG|[aA]ctivity_?[lL]og|[sS]ecurity_?[lL]og|[aA]dmin_?[lL]og|[lL]og_?[aA]dmin_?[aA]ction)\w*\b|\b(?:record_?event|log_?event|logActivity|logSecurityEvent|recordEvent)\b/, 'audit log', 'medium'),
90
+ p(/INSERT\s+INTO\s+(?:\w+\.)?(?:audit|activity|security_events?|admin_log)/i, 'audit insert', 'high'),
91
+ ];
92
+
93
+ // Privileged column names: setting one of these from request data is the mass assignment weakness (SEC-AUTHZ-004).
94
+ export const PRIVILEGED = /^(?:role|roles|plan|tier|is_?admin|admin|is_?staff|is_?superuser|is_?super_?admin|permissions?|balance|credits?|subscription\w*|entitlements?|email_?verified|verified|account_?type|user_?type|org_?id|tenant_?id|owner_?id|stripe_?customer_?id|price|amount)$/i;
95
+ export const OWNER = /owner|user_?id|userId|tenant|account_?id|accountId|org_?id|orgId|organi[sz]ation|workspace|created_?by|createdBy|author|auth\.uid|current_?user|request\.user|session\.user|\.user\.id|ctx\.user|\buid\b|\bscope\b|policy|member|\.user\b|user\s*=|owner\s*=/i;
96
+
97
+ export const ADMIN_PATH = /(^|[\/_.-])(admin|administrator|backoffice|back-office|staff|superuser|internal|manage|management|moderator)([\/_.-]|$)/i;
98
+ export const AUTH_ENDPOINT = /(^|[\/_.-])(login|log-in|signin|sign-in|signup|sign-up|register|registration|logout|log-out|signout|sign-out|reset|forgot|forgotten|password|token|oauth|otp|verify|callback|magic|session|2fa|mfa|authenticate)([\/_.\-:]|$)/i;
99
+ export const PUBLIC_PATH = /^\/(?:api\/|v\d+\/|rest\/)?(health|healthz|ping|status|metrics|ready|readiness|liveness|robots\.txt|favicon\.ico|sitemap\.xml|public|static|assets|docs|openapi|swagger|\.well-known)(\/|$|\.)/i;
100
+
101
+ // Guards for allowlist style checks inside one function.
102
+ export const URL_GUARD = /[aA]llow(?:ed)?_?(?:[lL]ist|[hH]osts?|[dD]omains?|[oO]rigins?|[uU]rls?)|\.Hostname\s*\(\)|allow(?:ed)?_?(?:list|hosts?|domains?|origins?|urls?)|ALLOWED_\w+|whitelist|isPrivate|private-?ip|isSafeUrl|isAllowedUrl|validateUrl|assertPublicUrl|safeFetch|ssrf|ip\.isPrivate|ipaddress|is_private|hostname\s*(?:===?|!==?|in)\b|\.hostname\.(?:endsWith|includes|startsWith)|\bnew URL\([^)]*\)\.(?:hostname|host|origin)\s*(?:===?|!==?)|\bhosts?\.(?:has|includes)\(|\bdomains?\.(?:has|includes)\(|netloc\s*(?:==|in|!=)/;
103
+ export const REDIRECT_GUARD = /\b(?:redirect_to|redirect_back|redirect)\s*\(?\s*(?:Routes\.\w+|\w+_(?:path|url)\b)|HasPrefix\(\s*\w+\s*,\s*"\/"|start_with\?\s*\(?\s*['"]\/|\burl_from\s*\(|allow_other_host:\s*false|str_starts_with\s*\(\s*\$\w+\s*,\s*['"]\/|Str::startsWith\(\s*\$\w+\s*,\s*['"]\/|[aA]llowed(?:Redirect|Host|Url)s?\b|startsWith\(\s*['"]\/(?!\/)|allow(?:ed)?_?(?:list|redirects?|hosts?|urls?)|ALLOWED_\w+|whitelist|is_safe_url|url_has_allowed_host_and_scheme|isSafeRedirect|isAllowedRedirect|safe_redirect|safeRedirect|sanitizeRedirect|\.origin\s*(?:===?|!==?)|\.hostname\s*(?:===?|!==?)|\.host\s*(?:===?|!==?)\s*[\w.:]+(?:domain|host|origin)|netloc\s*(?:==|in|!=)|\bnew URL\([^)]*\)\.(?:host|hostname|origin)|startswith\(\s*['"]\//;
104
+ export const SIZE_GUARD = /\bmax:\d+|(?:\.size|\.length|byteLength|content_?length|\.len\b)\s*(?:>=?|<=?)|(?:>=?|<=?)\s*(?:MAX_\w+|\w*[mM]ax\w*)|limits\s*:\s*\{[^}]*fileSize|maxFileSize|max_?file_?size|MAX_CONTENT_LENGTH|FILE_UPLOAD_MAX_MEMORY_SIZE|DATA_UPLOAD_MAX|max_?size|fileSize|MaxFileSizeValidator|maxSize|ParseFilePipe|\bmax_length\b|\bcontent_length_range\b|\blimit\s*:\s*['"]?\d/i;
105
+ export const TYPE_GUARD = /\bmimes:|\bmimetypes:|\|image\b|\bextensions:|(?:mimetype|mime_?type|content_?type|\.type|extname|\.ext\b|splitext)\b[^;\n]{0,80}(?:===|!==|==|!=|\.includes|\.startsWith|\.endsWith|\.match|\bin\b|\.test\()|(?:includes|has|test|startsWith|endsWith)\([^)]*\b(?:mimetype|mime|\.type|ext|content_?type)|fileFilter|allowed(?:Types|Extensions|Mimes?|_extensions|_types)|ALLOWED_\w+|fileTypeFromBuffer|file-type|imghdr|\bmagic\b|secure_filename|FileTypeValidator|ParseFilePipe|\bfiletype\.|python-magic/i;
106
+ export const CLAMP = /Math\.(?:min|max)\s*\(|\bmin\s*\(|\]\s*\.min\b|\.clamp\b|\.min\s*\(|\bclamp\s*\(|\bMAX_\w+|\.max\s*\(\s*\d+|\bmaxLimit\b|\bMAX_PAGE\w*|\bPAGE_SIZE_MAX\w*|le\s*=\s*\d+|\bmax_value\b|Number\.isInteger|\bmax_page_size\b|\bpaginate_by\b|\bPAGE_SIZE\b|\bmaximum\b|\.int\(\)\.max|\.positive\(\)\.max|\.lte\s*\(/;
@@ -0,0 +1,47 @@
1
+ // Reads the kit's security-controls.json, the source of truth for requirement IDs, edition and the tests each control
2
+ // ships. The engine never copies it. Path order: the explicit argument, HULLPROOF_CONTROLS, HULLPROOF_KIT, then the Hullproof Free kit
3
+ // in a folder called hullproof-free next to this repository.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ export const DEFAULT_CONTROLS = fileURLToPath(
9
+ new URL('../../../hullproof-free/kit/docs/hullproof/security-controls.json', import.meta.url));
10
+
11
+ export interface Control { id: string; edition: 'free' | 'pro'; status: string; tests: { id: string; kind: string; path: string }[] }
12
+
13
+ export interface Controls {
14
+ path: string;
15
+ byId: Map<string, Control>;
16
+ ruleToIds: Map<string, string[]>; // shipped test id (a Semgrep rule id) -> control ids that list it
17
+ }
18
+
19
+ const cache = new Map<string, Controls | null>();
20
+
21
+ const CONTROLS_REL = 'docs/hullproof/security-controls.json';
22
+ export const controlsPathFromEnv = (): string =>
23
+ process.env.HULLPROOF_CONTROLS ?? (process.env.HULLPROOF_KIT ? join(process.env.HULLPROOF_KIT, CONTROLS_REL) : DEFAULT_CONTROLS);
24
+
25
+ export function loadControls(path = controlsPathFromEnv()): Controls | null {
26
+ if (cache.has(path)) return cache.get(path)!;
27
+ let out: Controls | null = null;
28
+ if (existsSync(path)) {
29
+ const doc = JSON.parse(readFileSync(path, 'utf8')) as { controls: Control[] };
30
+ const byId = new Map(doc.controls.map((c) => [c.id, c]));
31
+ const ruleToIds = new Map<string, string[]>();
32
+ for (const c of doc.controls) for (const t of c.tests ?? []) ruleToIds.set(t.id, [...(ruleToIds.get(t.id) ?? []), c.id]);
33
+ out = { path, byId, ruleToIds };
34
+ }
35
+ cache.set(path, out);
36
+ return out;
37
+ }
38
+
39
+ // IDs in scope for an edition. Free keeps only IDs the controls file marks free; an ID the file does not list yet
40
+ // (one written in a standard document but not built into the file) is treated as Pro. Without a controls file nothing is filtered.
41
+ export function inEdition(ids: string[], edition: 'free' | 'pro', controls: Controls | null): string[] {
42
+ if (edition === 'pro' || !controls) return ids;
43
+ return ids.filter((id) => controls.byId.get(id)?.edition === 'free');
44
+ }
45
+
46
+ export const notInControls = (ids: Iterable<string>, controls: Controls | null): string[] =>
47
+ controls ? [...ids].filter((id) => !controls.byId.has(id)) : [];
@@ -0,0 +1,55 @@
1
+ // The dependency gate rule for OSV Scanner advisories (SEC-SUPPLY-002). Configurable. Default, set on
2
+ // 2026-10-10: CRITICAL and HIGH advisories, and any advisory past its fix deadline, fail SEC-SUPPLY-002 and hold the
3
+ // gate; MEDIUM and LOW inside their deadline are reported and do not hold the gate. The two fields for missing data
4
+ // (unknown_as, unknown_published) are an engine addition outside that decision.
5
+ //
6
+ // Severity class comes from the advisory level as in STANDARD.md "Dependency advisories": a CVSS base score
7
+ // (9.0 to 10.0 CRITICAL, 7.0 to 8.9 HIGH, 4.0 to 6.9 MEDIUM, 0.1 to 3.9 LOW), else the GHSA level word.
8
+ // Deadlines are the maximum fix times in the Standard, in calendar days, counted from the advisory's publication date.
9
+
10
+ export type SeverityClass = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
11
+
12
+ export interface DependencyPolicy {
13
+ fail_always: SeverityClass[]; // these fail whatever their age
14
+ deadline_days: Record<Exclude<SeverityClass, 'UNKNOWN'>, number>;
15
+ unknown_as: Exclude<SeverityClass, 'UNKNOWN'>; // class used for deadline and fail_always when the advisory has none
16
+ unknown_published: 'fail' | 'report'; // what to do when the advisory has no publication date
17
+ }
18
+
19
+ export const DEFAULT_POLICY: DependencyPolicy = {
20
+ fail_always: ['CRITICAL', 'HIGH'],
21
+ deadline_days: { CRITICAL: 7, HIGH: 30, MEDIUM: 90, LOW: 180 },
22
+ unknown_as: 'HIGH',
23
+ unknown_published: 'fail',
24
+ };
25
+
26
+ export function classFromScore(score: number): SeverityClass {
27
+ if (score >= 9) return 'CRITICAL';
28
+ if (score >= 7) return 'HIGH';
29
+ if (score >= 4) return 'MEDIUM';
30
+ if (score > 0) return 'LOW';
31
+ return 'UNKNOWN';
32
+ }
33
+
34
+ export function classFromWord(word: string | undefined): SeverityClass {
35
+ switch ((word ?? '').toUpperCase()) {
36
+ case 'CRITICAL': return 'CRITICAL';
37
+ case 'HIGH': return 'HIGH';
38
+ case 'MODERATE': case 'MEDIUM': return 'MEDIUM';
39
+ case 'LOW': return 'LOW';
40
+ default: return 'UNKNOWN';
41
+ }
42
+ }
43
+
44
+ const DAY = 86_400_000;
45
+ const days = (from: string, to: string): number => Math.floor((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY);
46
+
47
+ export interface Decision { deadline_days: number; past_deadline: boolean; decision: 'fail' | 'report' }
48
+
49
+ export function decide(cls: SeverityClass, published: string | null, asOf: string, policy: DependencyPolicy = DEFAULT_POLICY): Decision {
50
+ const eff = cls === 'UNKNOWN' ? policy.unknown_as : cls;
51
+ const deadline_days = policy.deadline_days[eff];
52
+ const past_deadline = published === null ? policy.unknown_published === 'fail' : days(published, asOf) > deadline_days;
53
+ const fail = policy.fail_always.includes(eff) || past_deadline;
54
+ return { deadline_days, past_deadline, decision: fail ? 'fail' : 'report' };
55
+ }