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,355 @@
1
+ import { closeOf, splitArgs, statementEnd } from './lex.js';
2
+ import { hasSessionValue, identifiers, isTainted } from './taint.js';
3
+ import { OWNER, PRIVILEGED, REDIRECT_GUARD, URL_GUARD } from './vocab.js';
4
+ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1) + '…');
5
+ const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
6
+ // String literals in a region, found from the quotes that mask() keeps.
7
+ function literals(masked, lang) {
8
+ const out = [];
9
+ for (let i = 0; i < masked.length; i++) {
10
+ const c = masked[i];
11
+ if (c !== '"' && c !== "'" && c !== '`')
12
+ continue;
13
+ const triple = lang === 'py' && masked.startsWith(c.repeat(3), i);
14
+ const close = triple ? masked.indexOf(c.repeat(3), i + 3) : masked.indexOf(c, i + 1);
15
+ if (close < 0)
16
+ break;
17
+ const prefix = lang === 'py' ? masked.slice(Math.max(0, i - 2), i) : '';
18
+ 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) });
19
+ i = close + (triple ? 2 : 0);
20
+ }
21
+ return out;
22
+ }
23
+ const SQL_START = /^\s*\(?\s*(SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|REPLACE\s+INTO|WITH\s+\w+\s+AS)\b/i;
24
+ const SQL_ID = /(?:^|[\s(,.])(?:\w+\.)?(?:id|uuid|pk|_id)\s*=\s*(?:\?|\$\d+|:\w+|%s|@\w+|\$\{|\{|'\s*\+|"\s*\+|'\s*$|"\s*$|\s*$)/i;
25
+ const SQL_END = /\b(?:ORDER\s+BY|GROUP\s+BY|LIMIT|RETURNING|OFFSET|HAVING)\b|;/i;
26
+ function sqlTouch(lit, region, t, lang) {
27
+ const sql = region.text.slice(lit.start, lit.end);
28
+ const m = SQL_START.exec(sql);
29
+ if (!m || sql.length < 12)
30
+ return null;
31
+ const word = m[1].toUpperCase();
32
+ const op = word.startsWith('SELECT') || word.startsWith('WITH') ? 'read' : word.startsWith('DELETE') ? 'delete' : 'write';
33
+ const target = (/\b(?:FROM|INTO|UPDATE)\s+(?:ONLY\s+)?([\w."]+)/i.exec(sql)?.[1] ?? null)?.replace(/"/g, '') ?? null;
34
+ const w = /\bWHERE\b/i.exec(sql);
35
+ let where = '';
36
+ if (w) {
37
+ const rest = sql.slice(w.index + 5);
38
+ const e = SQL_END.exec(rest);
39
+ where = e ? rest.slice(0, e.index) : rest;
40
+ }
41
+ const interpolated = lang === 'js' ? lit.quote === '`' && /\$\{/.test(sql) : lit.fString && /\{[^{}]+\}/.test(sql);
42
+ const interpExprs = lang === 'js' ? [...sql.matchAll(/\$\{([^}]*)\}/g)].map((x) => x[1]) : lit.fString ? [...sql.matchAll(/\{([^{}]+)\}/g)].map((x) => x[1]) : [];
43
+ // The statement around the literal: back to the previous ";", "{" or "}" and forward to the end of the statement.
44
+ let begin = lit.open;
45
+ for (let k = lit.open - 1; k >= 0 && lit.open - k < 700; k--) {
46
+ const c = region.masked[k];
47
+ if (c === ';' || c === '{' || c === '}') {
48
+ begin = k + 1;
49
+ break;
50
+ }
51
+ begin = k;
52
+ }
53
+ while (begin < lit.open && /\s/.test(region.masked[begin]))
54
+ begin++;
55
+ const end = statementEnd(region.masked, begin);
56
+ const around = region.masked.slice(begin, end);
57
+ 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));
58
+ const requestDerived = interpExprs.some((e) => isTainted(t, e, lang)) || isTainted(t, around, lang);
59
+ const rawSql = (interpolated && interpExprs.some((e) => isTainted(t, e, lang))) || (concat && isTainted(t, around, lang));
60
+ const params = positionalArgs(region, lit, begin, end);
61
+ const valueTainted = (expr, offset) => {
62
+ // The value of a column or limit: an interpolation in the text, or the parameter at its placeholder position.
63
+ const inline = [...expr.matchAll(lang === 'js' ? /\$\{([^}]*)\}/g : /\{([^{}]+)\}/g)].map((x) => x[1]);
64
+ if (inline.length)
65
+ return inline.some((e) => isTainted(t, e, lang));
66
+ const ph = /\?|%s|\$(\d+)/.exec(expr);
67
+ if (!ph)
68
+ return false;
69
+ if (!params)
70
+ return false;
71
+ const before = sql.slice(0, offset + ph.index);
72
+ const idx = ph[1] ? Number(ph[1]) - 1 : (before.match(/\?|%s/g) ?? []).length;
73
+ return params[idx] !== undefined && isTainted(t, params[idx], lang);
74
+ };
75
+ const sets = [];
76
+ if (op === 'write') {
77
+ const set = /\bSET\b/i.exec(sql);
78
+ if (set) {
79
+ const wh = /\bWHERE\b/i.exec(sql.slice(set.index));
80
+ const body = sql.slice(set.index + 3, wh ? set.index + wh.index : sql.length);
81
+ for (const part of splitTopOffsets(body, set.index + 3)) {
82
+ const cm = /^\s*(?:\w+\.)?"?(\w+)"?\s*=\s*([\s\S]*)$/.exec(part.text);
83
+ if (cm && PRIVILEGED.test(cm[1]) && valueTainted(cm[2], part.offset + part.text.indexOf(cm[2])))
84
+ sets.push(cm[1]);
85
+ }
86
+ }
87
+ else {
88
+ const ins = /\(([^)]*)\)\s*VALUES\s*\(/i.exec(sql);
89
+ if (ins) {
90
+ const cols = ins[1].split(',').map((x) => x.trim().replace(/"/g, ''));
91
+ const vopen = ins.index + ins[0].length - 1;
92
+ const vclose = closeIn(sql, vopen);
93
+ const vals = splitTopOffsets(sql.slice(vopen + 1, vclose), vopen + 1);
94
+ cols.forEach((c, i) => { if (PRIVILEGED.test(c) && vals[i] && valueTainted(vals[i].text, vals[i].offset))
95
+ sets.push(c); });
96
+ }
97
+ }
98
+ }
99
+ // by_id: the id comparison itself takes a request value (not just any request value somewhere in the statement).
100
+ let byId = false;
101
+ if (w) {
102
+ const idm = new RegExp(SQL_ID.source, 'i').exec(sql.slice(w.index + 5));
103
+ if (idm) {
104
+ const eq = idm.index + idm[0].indexOf('=') + 1;
105
+ const offset = w.index + 5 + eq;
106
+ 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)));
107
+ }
108
+ }
109
+ let limitFromRequest = false;
110
+ const lm = /\bLIMIT\s+(\?|%s|\$\d+|:\w+|\$\{[^}]*\}|\{[^{}]*\})/i.exec(sql);
111
+ if (lm)
112
+ limitFromRequest = valueTainted(lm[1], lm.index + 6);
113
+ return {
114
+ store: 'sql', op, target, line: region.line(lit.open), by_id: byId, limit_from_request: limitFromRequest,
115
+ 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',
116
+ };
117
+ }
118
+ function closeIn(s, open) {
119
+ let depth = 0;
120
+ for (let i = open; i < s.length; i++) {
121
+ if (s[i] === '(')
122
+ depth++;
123
+ else if (s[i] === ')') {
124
+ depth--;
125
+ if (depth === 0)
126
+ return i;
127
+ }
128
+ }
129
+ return s.length;
130
+ }
131
+ function splitTopOffsets(s, base) {
132
+ const out = [];
133
+ let depth = 0;
134
+ let from = 0;
135
+ for (let i = 0; i <= s.length; i++) {
136
+ const c = s[i];
137
+ if (c === '(')
138
+ depth++;
139
+ else if (c === ')')
140
+ depth--;
141
+ if ((c === ',' && depth === 0) || i === s.length) {
142
+ if (s.slice(from, i).trim())
143
+ out.push({ text: s.slice(from, i), offset: base + from });
144
+ from = i + 1;
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+ // The parameter expressions that go with a SQL string, in order: the array or tuple given to run, get, all, execute or query
150
+ // after it (JavaScript), or the second argument of the call that holds it (Python and others).
151
+ function positionalArgs(region, lit, begin, end) {
152
+ const m = region.masked;
153
+ const after = /^\s*,?\s*\)?\s*\.\s*(?:run|get|all|execute|query|exec|values|iterate|pluck|raw)\s*\(/.exec(m.slice(lit.end + 1, end));
154
+ let open = -1;
155
+ if (after)
156
+ open = lit.end + 1 + after[0].length - 1;
157
+ else {
158
+ // The literal is an argument of a call: execute("...", (a, b)).
159
+ for (let k = lit.open - 1; k >= begin; k--) {
160
+ if (m[k] === '(') {
161
+ open = k;
162
+ break;
163
+ }
164
+ if (m[k] === ')')
165
+ break;
166
+ }
167
+ }
168
+ if (open < 0)
169
+ return null;
170
+ const args = splitArgs(m, open);
171
+ let rest = args;
172
+ if (!after)
173
+ rest = args.filter((a) => a.start > lit.open);
174
+ if (!rest.length)
175
+ return null;
176
+ const first = m.slice(rest[0].start, rest[0].end);
177
+ if (/^[\[(]/.test(first) && (rest.length === 1 || !after)) {
178
+ const inner = splitArgs(m, rest[0].start);
179
+ if (inner.length || /^[\[(]\s*[\])]$/.test(first))
180
+ return inner.map((a) => region.text.slice(a.start, a.end));
181
+ }
182
+ return rest.map((a) => region.text.slice(a.start, a.end));
183
+ }
184
+ 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']);
185
+ const OP_WORDS = [[/^(?: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']];
186
+ const opOf = (name) => OP_WORDS.find(([re]) => re.test(name))?.[1] ?? 'unknown';
187
+ function callArgs(region, open) {
188
+ const c = closeOf(region.masked, open);
189
+ return region.text.slice(open, c < 0 ? Math.min(region.text.length, open + 400) : c);
190
+ }
191
+ function ormTouch(store, name, target, args, at, region, t, lang, confidence, byIdName) {
192
+ const op = opOf(name);
193
+ const derived = isTainted(t, args, lang);
194
+ let byId = byIdName && derived;
195
+ // key: value pairs of the argument object (JavaScript) or keyword arguments (Python); a shorthand key is its own value.
196
+ const pairs = [];
197
+ if (lang === 'js')
198
+ for (const m of args.matchAll(/(?:^|[{,\s])([A-Za-z_$][\w$]*)\s*(?::\s*([^,}\n]+)|(?=\s*[,}]))/g))
199
+ pairs.push([m[1], m[2] ?? m[1]]);
200
+ else
201
+ for (const m of args.matchAll(/(?:^|[(,\s])([A-Za-z_]\w*)\s*=\s*([^,)\n]+)/g))
202
+ pairs.push([m[1], m[2]]);
203
+ // A shorthand key inside a nested object, as in { where: { id } }, is swallowed by the value of the outer key above.
204
+ if (lang === 'js')
205
+ for (const m of args.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g))
206
+ pairs.push([m[1], m[1]]);
207
+ for (const [k, v] of pairs)
208
+ if (/^(?:id|_id|uuid|pk)$/.test(k) && isTainted(t, v, lang))
209
+ byId = true;
210
+ const sets = op === 'write' ? pairs.filter(([k, v]) => PRIVILEGED.test(k) && isTainted(t, v, lang)).map(([k]) => k) : [];
211
+ 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));
212
+ const spread = /\.\.\.\s*([\w$.]+)|\*\*\s*([\w.]+)|data\s*:\s*([\w$.]+)\s*[,}]|\(\s*([\w$.]+)\s*\)\s*$/g;
213
+ let mass = false;
214
+ if (op === 'write')
215
+ for (const m of args.matchAll(spread)) {
216
+ const v = m[1] ?? m[2] ?? m[3] ?? m[4];
217
+ if (v && identifiers(v).some((i) => t.names.has(i) && !t.trusted.has(i) && !t.validated.has(i)) && !/^\s*(?:id|where)/.test(v))
218
+ mass = true;
219
+ }
220
+ 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 };
221
+ }
222
+ export function dataTouches(region, t, lang) {
223
+ const out = [];
224
+ const { masked } = region;
225
+ for (const lit of literals(masked, lang)) {
226
+ const d = sqlTouch(lit, region, t, lang);
227
+ if (d)
228
+ out.push(d);
229
+ }
230
+ const seen = new Set();
231
+ const add = (m, build) => {
232
+ const open = m.index + m[0].length - 1;
233
+ if (seen.has(open))
234
+ return;
235
+ const d = build(open);
236
+ if (d) {
237
+ seen.add(open);
238
+ out.push(d);
239
+ }
240
+ };
241
+ if (lang === 'js') {
242
+ 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))
243
+ add(m, (open) => ormTouch('prisma', m[3], m[2], callArgs(region, open), m.index, region, t, lang, 'high', false));
244
+ 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)) {
245
+ const recv = m[1];
246
+ const modelLike = (/^[A-Z]\w*$/.test(recv) && !NOT_MODEL.has(recv)) || /(?:Model|Collection|Repo|Repository)$/.test(recv);
247
+ if (!modelLike)
248
+ continue;
249
+ 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])));
250
+ }
251
+ for (const m of masked.matchAll(/\.\s*from\s*\(\s*(['"])/g)) {
252
+ const quoteAt = m.index + m[0].length - 1;
253
+ const close = masked.indexOf(m[1], quoteAt + 1);
254
+ const table = region.text.slice(quoteAt + 1, close);
255
+ const end = statementEnd(masked, m.index); // from the dot of .from(, so the whole chain is read
256
+ const chain = region.text.slice(close, end);
257
+ const verb = /\.\s*(select|insert|update|delete|upsert)\s*\(/.exec(chain);
258
+ if (!verb)
259
+ continue;
260
+ const op = verb[1] === 'select' ? 'read' : verb[1] === 'delete' ? 'delete' : 'write';
261
+ const eqs = [...chain.matchAll(/\.\s*(?:eq|match|in|filter)\s*\(\s*['"]([\w.]+)['"]\s*,\s*([^)]*)\)/g)];
262
+ const byId = eqs.some((e) => /^(?:id|uuid|_id)$/.test(e[1]) && isTainted(t, e[2], lang));
263
+ const sets = [];
264
+ if (op === 'write')
265
+ for (const c of chain.matchAll(/(?:^|[{,\s])([A-Za-z_]\w*)\s*:/g))
266
+ if (PRIVILEGED.test(c[1]))
267
+ sets.push(c[1]);
268
+ 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' });
269
+ }
270
+ for (const m of masked.matchAll(/\b(?:redis|cache|kv)\s*\.\s*(get|set|del|hget|hset|hgetall|incr|expire|setex)\s*\(/g))
271
+ 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' });
272
+ 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)) {
273
+ const args = callArgs(region, m.index + m[0].length - 1);
274
+ 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' });
275
+ }
276
+ }
277
+ else {
278
+ 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))
279
+ add(m, (open) => {
280
+ // The verb at the end of the chain decides the operation: Note.objects.filter(pk=pk).delete().
281
+ const chain = region.masked.slice(open, statementEnd(region.masked, m.index));
282
+ const verb = /\)\s*\.\s*(delete|update|create|bulk_create|bulk_update|update_or_create|get_or_create)\s*\(/.exec(chain);
283
+ const tail = verb ? region.text.slice(open, open + chain.length) : callArgs(region, open);
284
+ const d = ormTouch('orm', verb ? verb[1] : m[2], m[1], verb ? tail : callArgs(region, open), m.index, region, t, lang, 'high', false);
285
+ // A slice such as [:limit] after the query is a limit; it counts when the bound is request data.
286
+ 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));
287
+ return slice ? { ...d, limit_from_request: true } : d;
288
+ });
289
+ for (const m of masked.matchAll(/\bget_object_or_404\s*\(/g))
290
+ add(m, (open) => ormTouch('orm', 'get', null, callArgs(region, open), m.index, region, t, lang, 'high', false));
291
+ 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))
292
+ add(m, (open) => ormTouch('orm', m[2], m[1], callArgs(region, open), m.index, region, t, lang, 'high', /^get/.test(m[2])));
293
+ for (const m of masked.matchAll(/\b(?:db\.)?session\s*\.\s*(query|get|add|delete|merge|execute|scalar|scalars)\s*\(/g))
294
+ add(m, (open) => ormTouch('orm', m[1] === 'query' ? 'select' : m[1], null, callArgs(region, open), m.index, region, t, lang, 'medium', m[1] === 'get'));
295
+ 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))
296
+ add(m, (open) => ormTouch('mongo', m[2], m[1], callArgs(region, open), m.index, region, t, lang, 'medium', false));
297
+ for (const m of masked.matchAll(/\.\s*table\s*\(\s*['"]([\w.]+)['"]\s*\)/g))
298
+ 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' });
299
+ for (const m of masked.matchAll(/\b(?:open|os\.remove|os\.unlink|shutil\.\w+|send_from_directory|send_file|Path)\s*\(/g)) {
300
+ const args = callArgs(region, m.index + m[0].length - 1);
301
+ 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' });
302
+ }
303
+ }
304
+ 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));
305
+ return out;
306
+ }
307
+ 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;
308
+ 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;
309
+ // A client object from an HTTP library the file imports: async with httpx.AsyncClient() as client: client.get(url)
310
+ const HTTP_PY_CLIENT = /\b(?:client|session|async_client|http_client|http)\s*\.\s*(?:get|post|put|patch|delete|request|head)\s*\(/g;
311
+ 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;
312
+ 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;
313
+ const SMS_RE = /\btwilio\b|\bsns\s*\.\s*publish\s*\(|\bsendSms\w*\s*\(|\bsend_sms\w*\s*\(|\bvonage\b/g;
314
+ 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;
315
+ const PROC_JS = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(|\beval\s*\(|\bnew\s+Function\s*\(|\bvm\.run\w*\s*\(/g;
316
+ const PROC_PY = /\b(?:subprocess\s*\.\s*\w+|os\s*\.\s*(?:system|popen|exec\w*)|eval|exec|commands\.getoutput)\s*\(/g;
317
+ 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;
318
+ 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;
319
+ function firstArg(region, open) {
320
+ const a = splitArgs(region.masked, open);
321
+ return a.length ? oneLine(region.text.slice(a[0].start, a[0].end)) : '';
322
+ }
323
+ export function outboundCalls(region, t, lang, returnsJson, imports = '') {
324
+ const out = [];
325
+ const fnText = region.text;
326
+ const add = (kind, re, guard, confidence) => {
327
+ re.lastIndex = 0;
328
+ for (const m of region.masked.matchAll(re)) {
329
+ const open = region.masked.indexOf('(', m.index);
330
+ const target = open < 0 ? '' : firstArg(region, open);
331
+ const full = open < 0 ? '' : callArgs(region, open);
332
+ 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 });
333
+ }
334
+ };
335
+ add('http', lang === 'js' ? HTTP_JS : HTTP_PY, URL_GUARD, 'high');
336
+ if (lang === 'py' && /httpx|aiohttp|requests/.test(imports))
337
+ add('http', HTTP_PY_CLIENT, URL_GUARD, 'medium');
338
+ add('ai_model', AI_RE, null, 'medium');
339
+ add('email', EMAIL_RE, null, 'medium');
340
+ add('sms', SMS_RE, null, 'medium');
341
+ add('payment', PAY_RE, null, 'medium');
342
+ add('process', lang === 'js' ? PROC_JS : PROC_PY, null, 'high');
343
+ add('redirect', REDIRECT, REDIRECT_GUARD, 'high');
344
+ if (returnsJson) {
345
+ for (const m of fnText.matchAll(REDIRECT_KEY)) {
346
+ if (m[1] === 'url' || m[1] === 'location')
347
+ continue;
348
+ 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' });
349
+ }
350
+ }
351
+ const seen = new Set();
352
+ return out.filter((o) => { const k = `${o.kind}:${o.line}:${o.target}`; if (seen.has(k))
353
+ return false; seen.add(k); return true; })
354
+ .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));
355
+ }
@@ -0,0 +1,88 @@
1
+ // Packages whose import in a file makes a vocabulary hit in that file high confidence.
2
+ 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/;
3
+ 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/;
4
+ const p = (re, via, confidence) => ({ re, via, confidence });
5
+ // Session read, JavaScript and TypeScript. A match means the handler asks who the caller is.
6
+ export const SESSION_JS = [
7
+ p(/\bAuthGuard\s*\(|@UseGuards\s*\([^)]*(?:Auth|Jwt|JWT|Session|Firebase|Clerk|Supabase)\w*Guard/, 'AuthGuard', 'high'),
8
+ p(/\bgetServerSession\s*\(/, 'getServerSession', 'high'),
9
+ p(/\b(?:supabase|client|sb)\w*\.auth\.(?:getUser|getSession|getClaims)\s*\(|\bauth\.(?:getUser|getSession)\s*\(/, 'auth.getUser', 'high'),
10
+ p(/\bgetToken\s*\(\s*\{/, 'getToken', 'high'),
11
+ p(/\b(?:jwt\.verify|jwtVerify|verifyIdToken|verifySessionCookie|jwt\.decode)\s*\(/, 'jwt verify', 'high'),
12
+ p(/\bpassport\.authenticate\s*\(/, 'passport.authenticate', 'high'),
13
+ p(/\b(?:getAuth|clerkClient|currentUser|auth)\s*\(\s*(?:req|request)?\s*\)/, 'auth()', 'medium'),
14
+ 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'),
15
+ p(/\b(?:require|ensure|assert)(?:Auth|User|Login|Session|Authenticated|SignedIn)\w*\b/, 'require auth', 'medium'),
16
+ p(/\b(?:is|check)(?:Authenticated|LoggedIn|Auth|Authorized|SignedIn)\w*\b/, 'is authenticated', 'medium'),
17
+ 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'),
18
+ p(/\bgetSession\s*\(/, 'getSession', 'medium'),
19
+ p(/\bauthenticate\w*\s*\(|\bverifyToken\w*\s*\(|\bverifyJwt\w*\s*\(|\bwithAuth\w*\b|\bauthMiddleware\b|\bisAuthorized\s*\(|\bprotect\s*\(/, 'authenticate', 'medium'),
20
+ 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'),
21
+ p(/\bheaders?\b[^;\n]{0,40}\bauthorization\b|\bauthorization\b\s*[\]:)]|['"]authorization['"]/i, 'authorization header', 'low'),
22
+ ];
23
+ export const SESSION_PY = [
24
+ p(/@(?:\w+\.)?(?:login_required|jwt_required|auth_required|token_required|permission_required|user_passes_test|authenticated|requires_auth)\b/, 'login decorator', 'high'),
25
+ 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'),
26
+ p(/\b(?:get_jwt_identity|get_jwt|verify_jwt_in_request|jwt\.decode|decode_token)\s*\(/, 'jwt', 'high'),
27
+ p(/@(?:\w+\.)?(?:authorize\w*|authenticate\w*|api_key_required|requires_api_key)\b/, 'auth decorator by name', 'medium'),
28
+ 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'),
29
+ p(/\bpermission_classes\s*=\s*[\[(][^\])]*(?:IsAuthenticated|IsAdminUser|DjangoModelPermissions|IsAuthenticatedOrReadOnly)|\bLoginRequiredMixin\b|\bauthentication_classes\s*=\s*[\[(][^\])]*\w/, 'permission_classes', 'high'),
30
+ 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'),
31
+ p(/\b(?:get|require|ensure)_(?:current_)?(?:user|session)(?!_by_)\w*\s*\(|\bauthenticate\s*\(|\bis_authenticated\b|\bverify_token\w*\s*\(/, 'get user', 'medium'),
32
+ p(/request\.headers(?:\.get\s*\(\s*|\s*\[\s*)['"]Authorization/i, 'authorization header', 'low'),
33
+ ];
34
+ export const ROLE_JS = [
35
+ p(/\brole\s*(?:===?|!==?)\s*["'`](?:admin|owner|superadmin|staff|moderator|manager)/i, 'role compare', 'high'),
36
+ p(/["'`](?:admin|owner|superadmin|staff)["'`]\s*(?:===?|!==?)\s*[\w.?]*role|roles?\.(?:includes|some)\(\s*["'`](?:admin|owner|staff)/i, 'role compare', 'high'),
37
+ p(/@(?:Roles|PreAuthorize|Secured|RolesAllowed|HasRole|RequirePermissions|Permissions)\s*\(/, 'role decorator', 'high'),
38
+ 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'),
39
+ p(/\bis(?:Accounting|Moderator|Manager|Owner|Superuser|SuperAdmin|Deluxe|Admin)\w*\s*\(/, 'role check', 'medium'),
40
+ ];
41
+ export const ROLE_PY = [
42
+ 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'),
43
+ p(/\bDepends\s*\(\s*\w*(?:superuser|admin|staff|moderator)\w*\s*\)/, 'Depends(admin)', 'medium'),
44
+ p(/@(?:\w+\.)?(?:require_(?:admin|role\w*|permission\w*|staff|superuser|owner)|admin_only|admin_?required|only_admin\w*)\b/, 'role decorator by name', 'medium'),
45
+ p(/\b(?:IsAdminUser|DjangoModelPermissions|PermissionRequiredMixin|UserPassesTestMixin|IsSuperuser|IsStaff)\b/, 'permission class', 'high'),
46
+ 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'),
47
+ 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'),
48
+ ];
49
+ export const SIGNATURE = [
50
+ 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'),
51
+ p(/\b(?:timingSafeEqual|compare_digest|hash_equals|secure_compare|hmac\.Equal)\s*\(|\bcreateHmac\s*\(|\bhmac\.new\s*\(/, 'hmac compare', 'high'),
52
+ p(/\bverify(?!\w*pass(?:word|wd)?\b)\w*\s*\(\s*(?:raw|body|payload|req|request|signature|sig)\b/i, 'verify call', 'medium'),
53
+ ];
54
+ export const RATE = [
55
+ 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'),
56
+ 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'),
57
+ ];
58
+ export const VALIDATION_JS = [
59
+ p(/\.(?:safeParse|safeParseAsync|parseAsync|spa)\s*\(/, 'schema parse', 'high'),
60
+ 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'),
61
+ 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'),
62
+ 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'),
63
+ p(/\.input\s*\(\s*(?:z\.|\w+Schema)/, 'trpc input', 'high'),
64
+ p(/\bschema\s*:\s*\{|\bschema\s*:\s*\w+Schema\b/, 'route schema', 'medium'),
65
+ p(/\b(?:validate|sanitize|validateInput|validatePayload|assertValid|isValid\w*)\s*\(/, 'validate call', 'low'),
66
+ ];
67
+ export const VALIDATION_PY = [
68
+ p(/\.(?:model_validate|model_validate_json|parse_obj|parse_raw|validate_python)\s*\(/, 'pydantic', 'high'),
69
+ p(/\b\w*Schema\s*\(\s*[^)]*\)\s*\.\s*(?:load|validate|loads)\s*\(|\bschema\.(?:load|validate)\s*\(/, 'marshmallow', 'high'),
70
+ p(/\.(?:is_valid|validate_on_submit|full_clean)\s*\(/, 'form or serializer valid', 'high'),
71
+ p(/\b(?:validate|jsonschema\.validate|cerberus|voluptuous|wtforms|FlaskForm)\b/, 'validation library', 'medium'),
72
+ ];
73
+ export const AUDIT = [
74
+ 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'),
75
+ p(/INSERT\s+INTO\s+(?:\w+\.)?(?:audit|activity|security_events?|admin_log)/i, 'audit insert', 'high'),
76
+ ];
77
+ // Privileged column names: setting one of these from request data is the mass assignment weakness (SEC-AUTHZ-004).
78
+ 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;
79
+ 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;
80
+ export const ADMIN_PATH = /(^|[\/_.-])(admin|administrator|backoffice|back-office|staff|superuser|internal|manage|management|moderator)([\/_.-]|$)/i;
81
+ 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;
82
+ 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;
83
+ // Guards for allowlist style checks inside one function.
84
+ 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|!=)/;
85
+ 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*['"]\//;
86
+ 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;
87
+ 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;
88
+ 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,34 @@
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
+ export const DEFAULT_CONTROLS = fileURLToPath(new URL('../../../hullproof-free/kit/docs/hullproof/security-controls.json', import.meta.url));
8
+ const cache = new Map();
9
+ const CONTROLS_REL = 'docs/hullproof/security-controls.json';
10
+ export const controlsPathFromEnv = () => process.env.HULLPROOF_CONTROLS ?? (process.env.HULLPROOF_KIT ? join(process.env.HULLPROOF_KIT, CONTROLS_REL) : DEFAULT_CONTROLS);
11
+ export function loadControls(path = controlsPathFromEnv()) {
12
+ if (cache.has(path))
13
+ return cache.get(path);
14
+ let out = null;
15
+ if (existsSync(path)) {
16
+ const doc = JSON.parse(readFileSync(path, 'utf8'));
17
+ const byId = new Map(doc.controls.map((c) => [c.id, c]));
18
+ const ruleToIds = new Map();
19
+ for (const c of doc.controls)
20
+ for (const t of c.tests ?? [])
21
+ ruleToIds.set(t.id, [...(ruleToIds.get(t.id) ?? []), c.id]);
22
+ out = { path, byId, ruleToIds };
23
+ }
24
+ cache.set(path, out);
25
+ return out;
26
+ }
27
+ // IDs in scope for an edition. Free keeps only IDs the controls file marks free; an ID the file does not list yet
28
+ // (one written in a standard document but not built into the file) is treated as Pro. Without a controls file nothing is filtered.
29
+ export function inEdition(ids, edition, controls) {
30
+ if (edition === 'pro' || !controls)
31
+ return ids;
32
+ return ids.filter((id) => controls.byId.get(id)?.edition === 'free');
33
+ }
34
+ export const notInControls = (ids, controls) => controls ? [...ids].filter((id) => !controls.byId.has(id)) : [];
@@ -0,0 +1,44 @@
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
+ export const DEFAULT_POLICY = {
10
+ fail_always: ['CRITICAL', 'HIGH'],
11
+ deadline_days: { CRITICAL: 7, HIGH: 30, MEDIUM: 90, LOW: 180 },
12
+ unknown_as: 'HIGH',
13
+ unknown_published: 'fail',
14
+ };
15
+ export function classFromScore(score) {
16
+ if (score >= 9)
17
+ return 'CRITICAL';
18
+ if (score >= 7)
19
+ return 'HIGH';
20
+ if (score >= 4)
21
+ return 'MEDIUM';
22
+ if (score > 0)
23
+ return 'LOW';
24
+ return 'UNKNOWN';
25
+ }
26
+ export function classFromWord(word) {
27
+ switch ((word ?? '').toUpperCase()) {
28
+ case 'CRITICAL': return 'CRITICAL';
29
+ case 'HIGH': return 'HIGH';
30
+ case 'MODERATE':
31
+ case 'MEDIUM': return 'MEDIUM';
32
+ case 'LOW': return 'LOW';
33
+ default: return 'UNKNOWN';
34
+ }
35
+ }
36
+ const DAY = 86_400_000;
37
+ const days = (from, to) => Math.floor((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY);
38
+ export function decide(cls, published, asOf, policy = DEFAULT_POLICY) {
39
+ const eff = cls === 'UNKNOWN' ? policy.unknown_as : cls;
40
+ const deadline_days = policy.deadline_days[eff];
41
+ const past_deadline = published === null ? policy.unknown_published === 'fail' : days(published, asOf) > deadline_days;
42
+ const fail = policy.fail_always.includes(eff) || past_deadline;
43
+ return { deadline_days, past_deadline, decision: fail ? 'fail' : 'report' };
44
+ }
@@ -0,0 +1,44 @@
1
+ // Maps one scanner hit to requirement IDs. Order: the rule's own metadata (Semgrep sec_ids), then the controls file's
2
+ // tests[] entry for the rule id, then the tool's table in src/mapping/maps/<tool>.json (exact rule id, then "*").
3
+ // A hit with no mapping keeps sec_ids [] and is marked unmapped; it is never dropped.
4
+ import { readFileSync } from 'node:fs';
5
+ import { globToRegExp, sortUnique } from '../util.js';
6
+ import { inEdition } from './controls.js';
7
+ const tables = new Map();
8
+ export function loadTable(tool) {
9
+ if (!tables.has(tool)) {
10
+ tables.set(tool, JSON.parse(readFileSync(new URL(`./maps/${tool}.json`, import.meta.url), 'utf8')));
11
+ }
12
+ return tables.get(tool);
13
+ }
14
+ export function mapHit(h, edition, controls) {
15
+ const all = rawMap(h, controls);
16
+ const sec_ids = sortUnique(inEdition(all.sec_ids, edition, controls));
17
+ return sec_ids.length ? { sec_ids, mapped_by: all.mapped_by } : { sec_ids: [], mapped_by: 'none' };
18
+ }
19
+ function rawMap(h, controls) {
20
+ if (h.metadataSecIds?.length)
21
+ return { sec_ids: h.metadataSecIds, mapped_by: 'rule metadata' };
22
+ const fromControls = controls?.ruleToIds.get(h.rule_id);
23
+ if (fromControls?.length)
24
+ return { sec_ids: fromControls, mapped_by: 'controls tests' };
25
+ if (h.tool === 'semgrep')
26
+ return { sec_ids: [], mapped_by: 'none' };
27
+ const t = loadTable(h.tool);
28
+ const exact = t.rows.find((r) => r.rule_id === h.rule_id);
29
+ const any = t.rows.find((r) => r.rule_id === '*');
30
+ let ids = exact?.sec_ids ?? any?.sec_ids ?? [];
31
+ let by = exact ? 'map rule_id' : any ? 'map default' : 'none';
32
+ if (h.tool === 'gitleaks') {
33
+ const client = (t.client_code_paths ?? []).some((g) => globToRegExp(g).test(h.file));
34
+ ids = [...(h.tracked === false ? [] : ids), ...(client ? t.client_code_sec_ids ?? [] : [])];
35
+ if (!ids.length)
36
+ by = 'none';
37
+ }
38
+ return { sec_ids: ids, mapped_by: by };
39
+ }
40
+ // Every ID a tool can produce through its table (used for not_assessed_sec_ids when the tool does not run).
41
+ export const tableSecIds = (tool) => {
42
+ const t = loadTable(tool);
43
+ return sortUnique([...t.rows.flatMap((r) => r.sec_ids), ...(t.client_code_sec_ids ?? [])]);
44
+ };
@@ -0,0 +1,12 @@
1
+ {
2
+ "tool": "gitleaks",
3
+ "note": "Every Gitleaks rule maps the same way. A hit in a tracked file or in history fails SEC-SECRETS-004. A hit in a file that matches client_code_paths also fails SEC-SECRETS-001, whether tracked or not, because that is where a secret reaches the browser or the app package. A hit in an untracked file outside those paths is kept as an unmapped observation: it is on this machine, not in the repository.",
4
+ "rows": [
5
+ {"rule_id": "*", "sec_ids": ["SEC-SECRETS-004"]}
6
+ ],
7
+ "client_code_paths": [
8
+ ".next/static/**", "**/.next/static/**", "public/**", "**/public/**", "static/**", "dist/**", "**/dist/**",
9
+ "build/**", "**/build/**", "out/**", "**/*.map", "**/*.bundle", "**/*.jsbundle"
10
+ ],
11
+ "client_code_sec_ids": ["SEC-SECRETS-001"]
12
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "tool": "osv-scanner",
3
+ "note": "Decided 2026-10-10 (design question 9): a known vulnerable dependency fails SEC-SUPPLY-002. Whether a given advisory fails or is only reported is set by the dependency policy in src/mapping/dependency-policy.ts.",
4
+ "rows": [
5
+ {"rule_id": "*", "sec_ids": ["SEC-SUPPLY-002"]}
6
+ ]
7
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "tool": "trivy",
3
+ "note": "One row per Trivy check id. Every id and title here was read from Trivy 0.75.0 JSON output (embedded checks, run with --skip-check-update) on terragoat or on test/fixtures/scanners/app; none is copied from documentation. The title is kept only so a reviewer can check the row; the engine never prints it. A Trivy hit whose id has no row is kept as an unmapped observation.",
4
+ "rows": [
5
+ {
6
+ "rule_id": "DS-0031",
7
+ "sec_ids": [
8
+ "SEC-CLOUD-016"
9
+ ],
10
+ "trivy_title": "Secrets passed via `build-args` or envs or copied secret files"
11
+ }
12
+ ]
13
+ }