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,235 @@
1
+ // Fact extractors: declared dependencies from manifests, and imports from source files. Small built in readers only,
2
+ // line based where the format allows, so every fact carries a file and a line (or a JSON key).
3
+
4
+ export interface DepFact { eco: string; name: string; version: string | null; file: string; line?: number; key?: string }
5
+ export interface ImportFact { lang: string; module: string; file: string; line: number }
6
+
7
+ const base = (f: string): string => f.slice(f.lastIndexOf('/') + 1);
8
+ const lines = (t: string): string[] => t.split(/\r?\n/);
9
+ const pyName = (n: string): string => n.toLowerCase().replace(/_/g, '-');
10
+
11
+ function jsonDeps(eco: string, file: string, text: string, sections: string[]): DepFact[] {
12
+ let j: Record<string, unknown>;
13
+ try { j = JSON.parse(text); } catch { return []; }
14
+ const out: DepFact[] = [];
15
+ for (const s of sections) {
16
+ const deps = j?.[s];
17
+ if (!deps || typeof deps !== 'object') continue;
18
+ for (const [name, v] of Object.entries(deps as Record<string, unknown>)) {
19
+ out.push({ eco, name, version: typeof v === 'string' ? v : null, file, key: `${s}.${name}` });
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function eachLine(text: string, fn: (line: string, n: number) => void): void {
26
+ lines(text).forEach((l, i) => fn(l, i + 1));
27
+ }
28
+
29
+ function requirementString(s: string): { name: string; version: string | null } | null {
30
+ const m = /^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)(?:\[[^\]]*\])?\s*((?:===|[<>=!~]=?)[^;#,\s]*(?:\s*,\s*[<>=!~]=?[^;#,\s]*)*)?\s*(?:;.*)?$/.exec(s);
31
+ return m ? { name: pyName(m[1]), version: m[2] ?? null } : null;
32
+ }
33
+
34
+ function requirementsTxt(file: string, text: string): DepFact[] {
35
+ const out: DepFact[] = [];
36
+ eachLine(text, (l, n) => {
37
+ const t = l.replace(/\s+#.*$/, '').trim();
38
+ if (!t || t.startsWith('#') || t.startsWith('-') || t.includes('://')) return;
39
+ const r = requirementString(t);
40
+ if (r) out.push({ eco: 'pypi', ...r, file, line: n });
41
+ });
42
+ return out;
43
+ }
44
+
45
+ function pyproject(file: string, text: string): DepFact[] {
46
+ const out: DepFact[] = [];
47
+ let section = '';
48
+ let arrayKey: string | null = null;
49
+ eachLine(text, (l, n) => {
50
+ const sec = /^\s*\[{1,2}([^\]]+)\]{1,2}\s*$/.exec(l);
51
+ if (sec) { section = sec[1].trim(); arrayKey = null; return; }
52
+ let rest = l;
53
+ if (arrayKey === null) {
54
+ const a = /^\s*"?([A-Za-z0-9_.-]+)"?\s*=\s*\[(.*)$/.exec(l);
55
+ if (a) { arrayKey = a[1]; rest = a[2]; }
56
+ else if (/(^|\.)dependencies$/.test(section) && section !== 'project') {
57
+ const p = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*)$/.exec(l); // poetry style: name = "^1.0" or { version = "1.0" }
58
+ if (p && p[1].toLowerCase() !== 'python') {
59
+ const v = /^"([^"]*)"/.exec(p[2])?.[1] ?? /version\s*=\s*"([^"]*)"/.exec(p[2])?.[1] ?? null;
60
+ out.push({ eco: 'pypi', name: pyName(p[1]), version: v, file, line: n });
61
+ }
62
+ return;
63
+ } else return;
64
+ }
65
+ const depArray = (section === 'project' && arrayKey === 'dependencies') || section === 'project.optional-dependencies'
66
+ || section === 'dependency-groups' || (section === 'build-system' && arrayKey === 'requires');
67
+ if (depArray) {
68
+ for (const m of rest.matchAll(/"([^"]+)"|'([^']+)'/g)) {
69
+ const r = requirementString(m[1] ?? m[2]);
70
+ if (r) out.push({ eco: 'pypi', ...r, file, line: n });
71
+ }
72
+ }
73
+ if (rest.includes(']')) arrayKey = null;
74
+ });
75
+ return out;
76
+ }
77
+
78
+ function sectionKeyValues(eco: string, file: string, text: string, isDepSection: (s: string) => boolean, norm: (s: string) => string): DepFact[] {
79
+ const out: DepFact[] = [];
80
+ let section = '';
81
+ eachLine(text, (l, n) => {
82
+ const sec = /^\s*\[([^\]]+)\]\s*$/.exec(l);
83
+ if (sec) { section = sec[1].trim(); return; }
84
+ if (!isDepSection(section)) return;
85
+ const p = /^\s*"?([A-Za-z0-9_.-]+)"?\s*=\s*(.*)$/.exec(l);
86
+ if (!p) return;
87
+ const v = /^"([^"]*)"/.exec(p[2])?.[1] ?? /version\s*=\s*"([^"]*)"/.exec(p[2])?.[1] ?? null;
88
+ out.push({ eco, name: norm(p[1]), version: v, file, line: n });
89
+ });
90
+ return out;
91
+ }
92
+
93
+ function regexLines(eco: string, file: string, text: string, re: RegExp, nameOf: (m: RegExpExecArray) => string, verOf: (m: RegExpExecArray) => string | null): DepFact[] {
94
+ const out: DepFact[] = [];
95
+ eachLine(text, (l, n) => {
96
+ const m = re.exec(l);
97
+ if (m) out.push({ eco, name: nameOf(m), version: verOf(m), file, line: n });
98
+ });
99
+ return out;
100
+ }
101
+
102
+ function goMod(file: string, text: string): DepFact[] {
103
+ const out: DepFact[] = [];
104
+ let inBlock = false;
105
+ eachLine(text, (l, n) => {
106
+ if (/^\s*require\s*\(\s*$/.test(l)) { inBlock = true; return; }
107
+ if (inBlock && /^\s*\)/.test(l)) { inBlock = false; return; }
108
+ const m = inBlock ? /^\s*(\S+)\s+(v\S+)/.exec(l) : /^\s*require\s+(\S+)\s+(v\S+)/.exec(l);
109
+ if (m) out.push({ eco: 'go', name: m[1], version: m[2], file, line: n });
110
+ });
111
+ return out;
112
+ }
113
+
114
+ function pomXml(file: string, text: string): DepFact[] {
115
+ const out: DepFact[] = [];
116
+ for (const m of text.matchAll(/<(dependency|parent|plugin)>([\s\S]*?)<\/\1>/g)) {
117
+ const body = m[2];
118
+ const g = /<groupId>\s*([^<\s]+)\s*<\/groupId>/.exec(body)?.[1] ?? '';
119
+ const a = /<artifactId>\s*([^<\s]+)\s*<\/artifactId>/.exec(body);
120
+ if (!a) continue;
121
+ const v = /<version>\s*([^<\s]+)\s*<\/version>/.exec(body)?.[1] ?? null;
122
+ const at = (m.index ?? 0) + m[0].indexOf(a[0]);
123
+ out.push({ eco: 'maven', name: `${g}:${a[1]}`, version: v, file, line: text.slice(0, at).split('\n').length });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function pubspec(file: string, text: string): DepFact[] {
129
+ const out: DepFact[] = [];
130
+ let inDeps = false;
131
+ eachLine(text, (l, n) => {
132
+ if (/^\S/.test(l)) { inDeps = /^(dependencies|dev_dependencies|dependency_overrides):\s*$/.test(l); return; }
133
+ if (!inDeps) return;
134
+ const m = /^ {2}([A-Za-z0-9_]+):\s*(.*)$/.exec(l);
135
+ if (m) out.push({ eco: 'pub', name: m[1], version: m[2].trim() || null, file, line: n });
136
+ });
137
+ return out;
138
+ }
139
+
140
+ function terraform(file: string, text: string): DepFact[] {
141
+ const out: DepFact[] = [];
142
+ eachLine(text, (l, n) => {
143
+ // Provider blocks, and the provider prefix of resource and data types (aws_s3_bucket is provider aws).
144
+ const p = /^\s*provider\s+"([a-z0-9_-]+)"/.exec(l) ?? /^\s*(?:resource|data)\s+"([a-z0-9]+)_/.exec(l);
145
+ if (p) out.push({ eco: 'terraform', name: p[1], version: null, file, line: n });
146
+ });
147
+ return out;
148
+ }
149
+
150
+ // Every manifest format the engine reads, by file name.
151
+ export function extractDeps(file: string, text: string): DepFact[] {
152
+ const b = base(file);
153
+ if (b === 'package.json') return jsonDeps('npm', file, text, ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']);
154
+ if (b === 'composer.json') return jsonDeps('composer', file, text, ['require', 'require-dev']);
155
+ if (/^requirements.*\.(txt|in)$/.test(b) || /(^|\/)requirements\/[^/]+\.(txt|in)$/.test(file)) return requirementsTxt(file, text);
156
+ if (b === 'pyproject.toml') return pyproject(file, text);
157
+ if (b === 'Pipfile') return sectionKeyValues('pypi', file, text, (s) => s === 'packages' || s === 'dev-packages', pyName);
158
+ if (b === 'setup.py') return regexLines('pypi', file, text, /^\s*['"]([A-Za-z][A-Za-z0-9_.-]*)(?:\[[^\]]*\])?\s*((?:[<>=!~]=?)[^'"]*)?['"],?\s*$/, (m) => pyName(m[1]), (m) => m[2] ?? null);
159
+ if (b === 'go.mod') return goMod(file, text);
160
+ if (b === 'Cargo.toml') return sectionKeyValues('cargo', file, text, (s) => /(^|\.)(dev-|build-)?dependencies$/.test(s), (s) => s);
161
+ if (b === 'Gemfile') return regexLines('rubygems', file, text, /^\s*gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/, (m) => m[1], (m) => m[2] ?? null);
162
+ if (b === 'pom.xml') return pomXml(file, text);
163
+ if (b === 'build.gradle' || b === 'build.gradle.kts') {
164
+ return regexLines('maven', file, text, /\b(?:implementation|api|compileOnly|runtimeOnly|testImplementation|testRuntimeOnly|androidTestImplementation|kapt|ksp|annotationProcessor|classpath|compile)\s*\(?\s*(?:platform\s*\(\s*)?['"]([^'":\s]+):([^'":\s]+)(?::([^'"\s]+))?['"]/, (m) => `${m[1]}:${m[2]}`, (m) => m[3] ?? null);
165
+ }
166
+ if (b === 'pubspec.yaml') return pubspec(file, text);
167
+ if (/\.(cs|fs|vb)proj$/.test(b)) return regexLines('nuget', file, text, /<PackageReference\s+Include="([^"]+)"(?:\s+Version="([^"]+)")?/i, (m) => m[1], (m) => m[2] ?? null);
168
+ if (b === 'packages.config') return regexLines('nuget', file, text, /<package\s+id="([^"]+)"\s+version="([^"]+)"/i, (m) => m[1], (m) => m[2]);
169
+ if (b === 'Package.swift') {
170
+ return regexLines('swiftpm', file, text, /\.package\s*\([^)]*?url:\s*"(?:https?:\/\/|git@)?([^"]+?)(?:\.git)?"(?:[^)]*?(?:from|exact):\s*"([^"]+)")?/, (m) => m[1].replace(':', '/').toLowerCase(), (m) => m[2] ?? null);
171
+ }
172
+ if (b === 'Podfile') return regexLines('cocoapods', file, text, /^\s*pod\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/, (m) => m[1], (m) => m[2] ?? null);
173
+ if (b === 'mix.exs') return regexLines('hex', file, text, /\{\s*:([a-z0-9_]+)\s*,\s*"([^"]+)"/, (m) => m[1], (m) => m[2]);
174
+ if (b.endsWith('.tf')) return terraform(file, text);
175
+ return [];
176
+ }
177
+
178
+ export const MANIFEST_NAMES = /(^|\/)(package\.json|composer\.json|requirements[^/]*\.(txt|in)|requirements\/[^/]+\.(txt|in)|pyproject\.toml|Pipfile|setup\.py|go\.mod|Cargo\.toml|Gemfile|pom\.xml|build\.gradle(\.kts)?|pubspec\.yaml|[^/]+\.(cs|fs|vb)proj|packages\.config|Package\.swift|Podfile|mix\.exs|[^/]+\.tf)$/;
179
+
180
+ // Import scan. Language key by extension, the line patterns, and the separator used for prefix matching in rules.
181
+ interface ImportLang { exts: string[]; res: RegExp[]; block?: { start: RegExp; end: RegExp; item: RegExp } }
182
+ export const IMPORT_LANGS: Record<string, ImportLang & { sep: string }> = {
183
+ js: { exts: ['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'mts', 'cts', 'vue', 'svelte', 'astro'], sep: '/',
184
+ res: [/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"\s]+)['"]/g] },
185
+ python: { exts: ['py'], sep: '.', res: [/^\s*import\s+([A-Za-z_][\w.]*)/g, /^\s*from\s+([A-Za-z_][\w.]*)\s+import\b/g] },
186
+ go: { exts: ['go'], sep: '/', res: [/^\s*import\s+(?:[\w.]+\s+)?"([^"]+)"/g],
187
+ block: { start: /^\s*import\s*\(\s*$/, end: /^\s*\)/, item: /^\s*(?:[\w.]+\s+)?"([^"]+)"/ } },
188
+ ruby: { exts: ['rb'], sep: '/', res: [/^\s*require\s+['"]([^'"]+)['"]/g] },
189
+ php: { exts: ['php'], sep: '\\', res: [/^\s*use\s+\\?([A-Za-z_][\w\\]*)/g] },
190
+ jvm: { exts: ['java', 'kt', 'scala', 'groovy'], sep: '.', res: [/^\s*import\s+(?:static\s+)?([\w.]+)/g] },
191
+ swift: { exts: ['swift'], sep: '.', res: [/^\s*(?:@testable\s+)?import\s+(?:(?:class|struct|enum|protocol|func|var|typealias)\s+)?(\w+)/g] },
192
+ dart: { exts: ['dart'], sep: '/', res: [/^\s*import\s+['"](?:package:)?((?:dart:)?[^'"]+)['"]/g] },
193
+ csharp: { exts: ['cs'], sep: '.', res: [/^\s*(?:global\s+)?using\s+(?:static\s+)?([A-Z][\w.]*)\s*;/g] },
194
+ rust: { exts: ['rs'], sep: '::', res: [/^\s*(?:pub\s+)?use\s+([a-z_]\w*)::/g, /^\s*extern\s+crate\s+(\w+)/g] },
195
+ };
196
+ const LANG_BY_EXT = new Map<string, string>();
197
+ for (const [k, v] of Object.entries(IMPORT_LANGS)) for (const e of v.exts) LANG_BY_EXT.set(e, k);
198
+
199
+ export const importLangOf = (file: string): string | null => {
200
+ const b = base(file);
201
+ const dot = b.lastIndexOf('.');
202
+ return dot > 0 ? LANG_BY_EXT.get(b.slice(dot + 1).toLowerCase()) ?? null : null;
203
+ };
204
+
205
+ // First line per module per file. Relative and alias imports (".", "/", "~", "@/", "#") are dropped.
206
+ export function extractImports(file: string, text: string): ImportFact[] {
207
+ const lang = importLangOf(file);
208
+ if (!lang) return [];
209
+ const spec = IMPORT_LANGS[lang];
210
+ const seen = new Map<string, number>();
211
+ let inBlock = false;
212
+ eachLine(text, (l, n) => {
213
+ const found: string[] = [];
214
+ if (spec.block) {
215
+ if (!inBlock && spec.block.start.test(l)) { inBlock = true; return; }
216
+ if (inBlock) {
217
+ if (spec.block.end.test(l)) { inBlock = false; return; }
218
+ const m = spec.block.item.exec(l);
219
+ if (m) found.push(m[1]);
220
+ }
221
+ }
222
+ for (const re of spec.res) for (const m of l.matchAll(re)) found.push(m[1]);
223
+ for (const mod of found) {
224
+ if (lang === 'js' && /^(\.|\/|~|@\/|#)/.test(mod)) continue;
225
+ if (!seen.has(mod)) seen.set(mod, n);
226
+ }
227
+ });
228
+ return [...seen].map(([module, line]) => ({ lang, module, file, line }));
229
+ }
230
+
231
+ // Does a fact name match a rule pattern? Exact, or a prefix on the separator, or "prefix*".
232
+ export function nameMatches(name: string, pattern: string, sep: string): boolean {
233
+ if (pattern.endsWith('*')) return name.startsWith(pattern.slice(0, -1));
234
+ return name === pattern || (sep !== '' && name.startsWith(pattern + sep));
235
+ }
@@ -0,0 +1,31 @@
1
+ // Reads the HEAD commit from the .git folder without running git.
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ const SHA = /^[0-9a-f]{40}$/;
6
+ const read = (p: string): string | null => { try { return readFileSync(p, 'utf8'); } catch { return null; } };
7
+
8
+ export function readHeadCommit(root: string): string | null {
9
+ let gitDir = join(root, '.git');
10
+ if (!existsSync(gitDir)) return null;
11
+ if (statSync(gitDir).isFile()) {
12
+ const m = /^gitdir:\s*(.+)$/m.exec(read(gitDir) ?? '');
13
+ if (!m) return null;
14
+ gitDir = resolve(root, m[1].trim());
15
+ }
16
+ const head = (read(join(gitDir, 'HEAD')) ?? '').trim();
17
+ if (SHA.test(head)) return head;
18
+ const ref = /^ref:\s*(\S+)$/.exec(head)?.[1];
19
+ if (!ref) return null;
20
+ const common = read(join(gitDir, 'commondir'));
21
+ const dirs = [gitDir, ...(common ? [resolve(gitDir, common.trim())] : [])];
22
+ for (const d of dirs) {
23
+ const loose = (read(join(d, ref)) ?? '').trim();
24
+ if (SHA.test(loose)) return loose;
25
+ for (const line of (read(join(d, 'packed-refs')) ?? '').split('\n')) {
26
+ const [sha, name] = line.trim().split(' ');
27
+ if (name === ref && SHA.test(sha)) return sha;
28
+ }
29
+ }
30
+ return null;
31
+ }
@@ -0,0 +1,37 @@
1
+ // Files of the Hullproof kit that were copied into the scanned project and are still byte for byte what the kit says.
2
+ // They hold planted samples on purpose (the kit's own test files, hook self tests), so scanning them reports the kit's samples as findings in the
3
+ // user's project. The list comes from the manifest of the kit the run was given (--kit), never from a manifest inside the scanned project, because a
4
+ // project could name its own files in a manifest of its own and hide them from the scan. A file that differs by one byte is not skipped.
5
+ import { createHash } from 'node:crypto';
6
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { dirname, join, relative, resolve, sep } from 'node:path';
8
+
9
+ const MANIFEST_REL = 'docs/hullproof/.kit-manifest';
10
+ const LINE = /^([0-9a-f]{64}) (\S.*)$/;
11
+
12
+ export function kitOwnedFiles(repo: string, kitDir: string | null): Set<string> {
13
+ const out = new Set<string>();
14
+ if (!kitDir) return out;
15
+ let manifest: string;
16
+ try { manifest = readFileSync(join(kitDir, MANIFEST_REL), 'utf8'); } catch { return out; }
17
+ // A kit folder inside the scanned project is the project's own content, so its manifest is not a trusted list.
18
+ try {
19
+ const rel = relative(realpathSync(resolve(repo)), realpathSync(resolve(kitDir)));
20
+ if (rel === '' || (!rel.startsWith('..') && !rel.startsWith(sep) && rel.split(sep)[0] !== '..')) return out;
21
+ } catch { return out; }
22
+ for (const line of manifest.split('\n')) {
23
+ const m = LINE.exec(line);
24
+ if (!m || m[2].includes('..') || m[2].startsWith('/')) continue;
25
+ const f = join(repo, m[2]);
26
+ try {
27
+ const st = lstatSync(f);
28
+ if (!st.isFile()) continue;
29
+ if (createHash('sha256').update(readFileSync(f)).digest('hex') === m[1]) out.add(m[2]);
30
+ } catch { /* not in the project */ }
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // The folders that hold the skipped kit files, collapsed to three levels, for the list of what discovery left out.
36
+ export const kitFolders = (files: Iterable<string>): string[] =>
37
+ [...new Set([...files].map((f) => dirname(f).split('/').slice(0, 3).join('/') + '/'))].sort();
@@ -0,0 +1,88 @@
1
+ // File listing for discovery. Reads the folder tree directly and applies .gitignore files itself, so discovery never runs
2
+ // git (a repository's git config can make git run code). Dependency, build output and tool folders are skipped and reported.
3
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import type { Skipped } from '../types/stack.ts';
6
+ import { cmp, globBody } from '../util.ts';
7
+ import { kitFolders } from './kit-files.ts';
8
+
9
+ const SKIP_DIRS: Record<string, Skipped['reason']> = {
10
+ node_modules: 'dependency folder', bower_components: 'dependency folder', jspm_packages: 'dependency folder',
11
+ vendor: 'dependency folder', '.venv': 'dependency folder', venv: 'dependency folder', Pods: 'dependency folder',
12
+ Carthage: 'dependency folder', '.bundle': 'dependency folder', '.pnpm-store': 'dependency folder', '.yarn': 'dependency folder',
13
+ dist: 'build output', build: 'build output', out: 'build output', '.next': 'build output', '.nuxt': 'build output',
14
+ '.svelte-kit': 'build output', '.output': 'build output', '.vercel': 'build output', '.netlify': 'build output',
15
+ target: 'build output', '.gradle': 'build output', DerivedData: 'build output', coverage: 'build output',
16
+ '__pycache__': 'build output', '.pytest_cache': 'build output', '.mypy_cache': 'build output', '.tox': 'build output',
17
+ '.dart_tool': 'build output', '.expo': 'build output', '.turbo': 'build output', '.cache': 'build output',
18
+ '.parcel-cache': 'build output', '.terraform': 'build output', '.serverless': 'build output', '.aws-sam': 'build output',
19
+ 'cdk.out': 'build output',
20
+ '.git': 'tool folder', '.hg': 'tool folder', '.svn': 'tool folder', '.hullproof': 'tool folder', '.idea': 'tool folder',
21
+ };
22
+
23
+ interface IgnoreRule { base: string; re: RegExp; negate: boolean; dirOnly: boolean }
24
+
25
+ export function parseGitignore(text: string, base: string): IgnoreRule[] {
26
+ const rules: IgnoreRule[] = [];
27
+ for (const raw of text.split(/\r?\n/)) {
28
+ let p = raw.replace(/(?<!\\)\s+$/, '');
29
+ if (!p || p.startsWith('#')) continue;
30
+ const negate = p.startsWith('!');
31
+ if (negate) p = p.slice(1);
32
+ if (p.startsWith('\\')) p = p.slice(1);
33
+ const dirOnly = p.endsWith('/');
34
+ if (dirOnly) p = p.slice(0, -1);
35
+ if (!p) continue;
36
+ const anchored = p.includes('/');
37
+ if (p.startsWith('/')) p = p.slice(1);
38
+ const body = globBody(p); // ponytail: also reads {a,b} as alternation, which gitignore does not; rare in practice
39
+ rules.push({ base, negate, dirOnly, re: new RegExp(anchored ? `^${body}$` : `^(?:.*/)?${body}$`) });
40
+ }
41
+ return rules;
42
+ }
43
+
44
+ function ignored(rel: string, isDir: boolean, rules: IgnoreRule[]): boolean {
45
+ let out = false;
46
+ for (const r of rules) {
47
+ if (r.dirOnly && !isDir) continue;
48
+ let sub: string;
49
+ if (!r.base) sub = rel;
50
+ else if (rel.startsWith(r.base + '/')) sub = rel.slice(r.base.length + 1);
51
+ else continue;
52
+ if (r.re.test(sub)) out = !r.negate;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export interface WalkResult { files: string[]; skipped: Skipped[]; capReached: boolean }
58
+
59
+ export function walk(root: string, maxFiles: number, skipFiles?: ReadonlySet<string>): WalkResult {
60
+ const files: string[] = [];
61
+ const skipped: Skipped[] = [];
62
+ let capReached = false;
63
+ const absRoot = resolve(root);
64
+
65
+ const visit = (dirRel: string, inherited: IgnoreRule[]): void => {
66
+ const abs = dirRel ? join(absRoot, dirRel) : absRoot;
67
+ let rules = inherited;
68
+ try { rules = inherited.concat(parseGitignore(readFileSync(join(abs, '.gitignore'), 'utf8'), dirRel)); } catch { /* no .gitignore here */ }
69
+ const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) => cmp(a.name, b.name));
70
+ for (const e of entries) {
71
+ if (capReached) return;
72
+ const rel = dirRel ? `${dirRel}/${e.name}` : e.name;
73
+ if (e.isSymbolicLink()) { skipped.push({ path: rel, reason: 'symlink' }); continue; }
74
+ const isDir = e.isDirectory();
75
+ if (!isDir && !e.isFile()) continue;
76
+ if (isDir && SKIP_DIRS[e.name]) { skipped.push({ path: rel + '/', reason: SKIP_DIRS[e.name] }); continue; }
77
+ if (ignored(rel, isDir, rules)) { skipped.push({ path: isDir ? rel + '/' : rel, reason: 'gitignore' }); continue; }
78
+ if (isDir) visit(rel, rules);
79
+ else if (skipFiles?.has(rel)) continue;
80
+ else if (files.length >= maxFiles) capReached = true;
81
+ else files.push(rel);
82
+ }
83
+ };
84
+ if (!statSync(absRoot).isDirectory()) throw new Error(`not a folder: ${root}`);
85
+ visit('', []);
86
+ if (skipFiles?.size) for (const d of kitFolders(skipFiles)) skipped.push({ path: d, reason: 'unchanged kit files' });
87
+ return { files, skipped, capReached };
88
+ }
package/src/env.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Reads provider settings from a .env.local file without any dependency. Real environment variables always win. Only the names
2
+ // in HUNT_ENV_NAMES are read, and no value is ever logged or returned in a message: callers get the names that came from a file.
3
+ import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const HUNT_ENV_NAMES = [
8
+ 'HULLPROOF_PROVIDER', 'HULLPROOF_MODEL', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY',
9
+ 'HULLPROOF_OPENAI_COMPAT_BASE_URL', 'HULLPROOF_OPENAI_COMPAT_KEY',
10
+ ] as const;
11
+ const ALLOWED = new Set<string>(HUNT_ENV_NAMES);
12
+ const ENGINE_ROOT = fileURLToPath(new URL('../', import.meta.url));
13
+ const MAX_BYTES = 64 * 1024;
14
+
15
+ // Parses KEY=VALUE lines: blank lines and # comments skipped, an optional "export " prefix, matching quotes removed.
16
+ export function parseEnvText(text: string): Record<string, string> {
17
+ const out: Record<string, string> = {};
18
+ for (const raw of text.split(/\r?\n/)) {
19
+ const line = raw.trim();
20
+ if (!line || line.startsWith('#')) continue;
21
+ const m = /^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
22
+ if (!m || !ALLOWED.has(m[1])) continue;
23
+ let v = m[2].trim();
24
+ const q = v[0] === '"' || v[0] === "'" ? v.indexOf(v[0], 1) : -1;
25
+ if (q > 0) v = v.slice(1, q);
26
+ else v = v.replace(/\s+#.*$/, '');
27
+ if (v) out[m[1]] = v;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ // The scanned repository's .env.local first, then the engine's. A name already set (and not empty) in `base` is never replaced.
33
+ export function loadHuntEnv(repo: string | null, base: NodeJS.ProcessEnv = process.env, engineRoot: string = ENGINE_ROOT): { env: Record<string, string | undefined>; fromFile: string[] } {
34
+ const env: Record<string, string | undefined> = { ...base };
35
+ const fromFile: string[] = [];
36
+ // CI sets this: the scanned repository is untrusted there, so a .env.local inside it must not supply a provider address or key.
37
+ if (base.HULLPROOF_NO_ENV_FILE === '1') return { env, fromFile };
38
+ const roots = [...(repo ? [resolve(repo)] : []), resolve(engineRoot)];
39
+ for (const root of roots) {
40
+ const p = join(root, '.env.local');
41
+ try {
42
+ if (!existsSync(p) || statSync(p).size > MAX_BYTES) continue;
43
+ for (const [k, v] of Object.entries(parseEnvText(readFileSync(p, 'utf8')))) {
44
+ if (env[k]) continue;
45
+ env[k] = v;
46
+ fromFile.push(k);
47
+ }
48
+ } catch { /* an unreadable file is the same as no file */ }
49
+ }
50
+ return { env, fromFile: [...new Set(fromFile)].sort() };
51
+ }
@@ -0,0 +1,169 @@
1
+ // The release gate of STANDARD.md (G-1 to G-8 and the three outcomes), on top of the kit's own checklist gate.
2
+ // The checklist part (G-3 to G-5 on the results rows) and the record cross checks are the kit's functions from
3
+ // tools/hullproof/ci/hullproof-gate.mjs, imported, not copied. This file adds what the kit gate leaves out: G-1, G-2,
4
+ // G-6, G-7, G-8, written risk acceptance within the kit's limits, and the verdict.
5
+ // It blocks only on FAIL rows (from FAIL checks and mapped rule hits, never unmapped observations) and on requirements
6
+ // nothing settled, exactly as the kit gate counts them.
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { cmp } from '../util.ts';
11
+ import type { FindingsFile, ResultRow, Severity, ToolMeta } from '../types/index.ts';
12
+ import { GATE_REL, inScope, SEV_RANK, VALIDATOR_REL, type Control, type Scope } from '../report/kit.ts';
13
+
14
+ export interface KitGate {
15
+ evaluate(controls: unknown[], rows: string[][], scope: string): { counts: Record<string, number>; ok: boolean };
16
+ collapseRows(rows: string[][]): { got: Map<string, { state: string; raw: string; note: string }>; duplicates: number };
17
+ crossChecks(controlMap: Map<string, unknown>, got: Map<string, unknown>, findings: unknown): { problems: string[] };
18
+ isStaticPass(raw: string): boolean;
19
+ validateFindingsText(text: string, o: { controls: Map<string, unknown> }): { c: { errorCount: number; lines(): string[] }; data: FindingsFile | null };
20
+ }
21
+
22
+ export async function loadKitGate(kitDir: string): Promise<KitGate> {
23
+ const g = await import(pathToFileURL(join(kitDir, GATE_REL)).href);
24
+ const v = await import(pathToFileURL(join(kitDir, VALIDATOR_REL)).href);
25
+ return { evaluate: g.evaluate, collapseRows: g.collapseRows, crossChecks: g.crossChecks, isStaticPass: g.isStaticPass, validateFindingsText: v.validateFindingsText };
26
+ }
27
+
28
+ // What the owner supplies for the conditions no file in the repository can settle. Paths are relative to the repository.
29
+ export interface OwnerFile {
30
+ audit_report?: string; // G-2: an audit report in the AUDIT-REPORT format that names this commit
31
+ rollback?: string; // G-8: where the rollback path for this release is written down
32
+ results?: OwnerRow[]; // rows settled with owner evidence (exports, staging tests); never overrides an engine FAIL
33
+ acceptances?: Acceptance[]; // written risk acceptance, within the limits of STANDARD.md
34
+ }
35
+ // An owner row may say PASS, which the engine never writes: the owner holds the runtime evidence.
36
+ export type OwnerRow = Omit<ResultRow, 'result'> & { result: ResultRow['result'] | 'PASS' };
37
+ export interface Acceptance { id: string; owner: string; approver: string; compensating_control: string; effective: string; expires: string }
38
+
39
+ export interface GateInput {
40
+ kitGate: KitGate; controls: Control[]; scope: Scope; rows: ResultRow[]; findingsText: string; metas: ToolMeta[];
41
+ owner: OwnerFile | null; repo: string; commit: string; today: string;
42
+ }
43
+
44
+ export type Verdict = 'NOT READY' | 'READY WITH ACCEPTED RISK' | 'READY';
45
+ export interface Condition { id: string; met: boolean; why: string }
46
+ export interface GateOutcome {
47
+ verdict: Verdict; label: string; exit: 0 | 1 | 2; problems: string[]; conditions: Condition[];
48
+ counts: Record<string, number>; open: { id: string; severity: Severity; state: string; accepted: boolean }[];
49
+ acceptancesUsed: string[]; acceptanceProblems: string[]; runSupportsReady: boolean; checklistLine: string;
50
+ }
51
+
52
+ // Hullproof policy (STANDARD.md, Protected classes): no acceptance for credentials and secrets, authentication,
53
+ // tenant isolation or payments. The class is read from the control's domains; authorization counts as tenant isolation.
54
+ const PROTECTED = new Set(['secrets', 'auth', 'authorization', 'tenancy', 'payments']);
55
+ const DAY = 86_400_000;
56
+ const days = (a: string, b: string): number => Math.round((Date.parse(b) - Date.parse(a)) / DAY);
57
+ const isDate = (s: unknown): s is string => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
58
+
59
+ function currentAcceptances(list: Acceptance[], byId: Map<string, Control>, today: string): { ok: Map<string, Acceptance>; problems: string[] } {
60
+ const ok = new Map<string, Acceptance>();
61
+ const problems: string[] = [];
62
+ let critical = 0;
63
+ for (const a of [...list].sort((x, y) => cmp(x.id, y.id))) {
64
+ const c = byId.get(a.id);
65
+ const bad = (why: string): void => { problems.push(`${a.id}: acceptance not current (${why})`); };
66
+ if (!c) { bad('not a requirement in scope'); continue; }
67
+ if (c.severity === 'BLOCKER') { bad('a BLOCKER is never accepted'); continue; }
68
+ if (c.severity !== 'CRITICAL' && c.severity !== 'HIGH') { bad('only CRITICAL and HIGH need acceptance'); continue; }
69
+ if (c.domains.some((d) => PROTECTED.has(d))) { bad('protected class: secrets, authentication, tenant isolation or payments'); continue; }
70
+ if (![a.owner, a.approver, a.compensating_control].every((s) => typeof s === 'string' && s.trim())) { bad('owner, approver and compensating control are all needed'); continue; }
71
+ if (a.owner.trim().toLowerCase() === a.approver.trim().toLowerCase()) { bad('the approver must not be the risk owner'); continue; }
72
+ if (!isDate(a.effective) || !isDate(a.expires)) { bad('effective and expires must be YYYY-MM-DD dates'); continue; }
73
+ const limit = c.severity === 'CRITICAL' ? 90 : 180;
74
+ if (days(a.effective, a.expires) > limit) { bad(`lasts more than ${limit} days`); continue; }
75
+ if (a.effective > today || a.expires < today) { bad(a.expires < today ? 'expired' : 'not in effect yet'); continue; }
76
+ if (c.severity === 'CRITICAL' && ++critical > 3) { bad('more than 3 CRITICAL acceptances for one release'); continue; }
77
+ ok.set(a.id, a);
78
+ }
79
+ return { ok, problems };
80
+ }
81
+
82
+ const isGated = (c: Control): boolean => c.severity === 'BLOCKER' || c.severity === 'CRITICAL';
83
+
84
+ export function evaluateGate(g: GateInput): GateOutcome {
85
+ const scoped = g.controls.filter((c) => inScope(c, g.scope));
86
+ const byId = new Map(scoped.map((c) => [c.id, c]));
87
+ const problems: string[] = [];
88
+
89
+ // Rows: the engine's, then the owner's for anything the engine did not FAIL.
90
+ const engine = new Map<string, OwnerRow>(g.rows.map((r) => [r.id, r]));
91
+ for (const r of g.owner?.results ?? []) {
92
+ if (engine.get(r.id)?.result === 'FAIL') continue;
93
+ engine.set(r.id, r);
94
+ }
95
+ const rows = [...engine.values()].sort((a, b) => cmp(a.id, b.id)).map((r) => [r.id, r.result, r.notes]);
96
+
97
+ // The kit's checklist gate on the in scope controls; scope 'all' because the stage filter is already applied.
98
+ const { counts, ok } = g.kitGate.evaluate(scoped, rows, 'all');
99
+ const checklistLine = `hullproof checklist (${g.scope.edition}, ${g.scope.stage}): checked ${counts.checked}, pass ${counts.pass}, not applicable ${counts.notApplicable}, fail ${counts.fail}, not assessed ${counts.notAssessed} (missing rows ${counts.missing}, PASS (static) rejected ${counts.passStaticRejected}, NOT APPLICABLE without a reason ${counts.naWithoutReason}), duplicate rows ${counts.duplicates}, unknown ids ${counts.unknownIds}`;
100
+
101
+ // The findings record: validated by the kit, then cross checked against the rows by the kit.
102
+ // Validation and cross checks see every known requirement: an observation may name one outside this run's scope.
103
+ const all = new Map<string, unknown>(g.controls.map((c) => [c.id, c]));
104
+ const v = g.kitGate.validateFindingsText(g.findingsText, { controls: all });
105
+ if (v.c.errorCount) problems.push(...v.c.lines().map((l) => `findings ${l}`));
106
+ const findings = v.data;
107
+ const { got } = g.kitGate.collapseRows(rows);
108
+ if (findings && !v.c.errorCount) problems.push(...g.kitGate.crossChecks(all, got, findings).problems);
109
+ const runSupportsReady = !!findings && findings.report.run_status === 'complete' && findings.report.scope === 'full' && findings.report.profile !== 'quick';
110
+
111
+ // Open gated items, the same way the kit's evaluate counts them.
112
+ const open: GateOutcome['open'] = [];
113
+ for (const c of scoped.filter(isGated)) {
114
+ const r = got.get(c.id);
115
+ const settled = r !== undefined && (
116
+ (r.state === 'PASS' && !(g.kitGate.isStaticPass(r.raw) && c.authority !== 'repo')) || (r.state === 'NOT APPLICABLE' && r.note !== ''));
117
+ if (!settled) open.push({ id: c.id, severity: c.severity, state: r?.raw ?? 'missing row', accepted: false });
118
+ }
119
+ if ((open.length === 0) !== ok) problems.push('internal: the open item list does not agree with the kit gate');
120
+
121
+ const acc = currentAcceptances(g.owner?.acceptances ?? [], byId, g.today);
122
+ for (const o of open) if (o.severity === 'CRITICAL' && acc.ok.has(o.id)) o.accepted = true;
123
+ const highFails = (findings?.records ?? []).filter((r) => r.kind === 'fail' && r.severity === 'HIGH')
124
+ .flatMap((r) => (r.kind === 'fail' ? r.sec_ids : [])).filter((id) => byId.get(id)?.severity === 'HIGH');
125
+ const highOpen = [...new Set(highFails)].filter((id) => !acc.ok.has(id)).sort(cmp);
126
+ const used = [...new Set([...open.filter((o) => o.accepted).map((o) => o.id), ...highFails.filter((id) => acc.ok.has(id))])].sort(cmp);
127
+
128
+ const stagePath = join(g.repo, 'docs/security/STAGE.md');
129
+ const inRepo = (p: string | undefined): string | null => { if (!p) return null; const abs = resolve(g.repo, p); return abs.startsWith(resolve(g.repo)) && existsSync(abs) ? abs : null; };
130
+ const report = inRepo(g.owner?.audit_report);
131
+ const rollback = inRepo(g.owner?.rollback);
132
+ const ranOk = (t: string): boolean => g.metas.some((m) => m.tool === t && m.status === 'ran' && m.files_scanned !== 0);
133
+ const g7missing = ['gitleaks', 'semgrep', 'osv-scanner'].filter((t) => !ranOk(t));
134
+ const blockerOpen = open.filter((o) => o.severity === 'BLOCKER');
135
+ const blockerFail = blockerOpen.filter((o) => o.state.toUpperCase().startsWith('FAIL'));
136
+ const criticalOpen = open.filter((o) => o.severity === 'CRITICAL' && !o.accepted);
137
+
138
+ const conditions: Condition[] = [
139
+ { id: 'G-1', met: existsSync(stagePath) && g.scope.stageSource === 'STAGE.md', why: existsSync(stagePath) ? (g.scope.stageSource === 'STAGE.md' ? `stage ${g.scope.stage} recorded in docs/security/STAGE.md` : 'docs/security/STAGE.md names no stage') : 'docs/security/STAGE.md is missing' },
140
+ { id: 'G-2', met: !!report && readFileSync(report, 'utf8').includes(g.commit), why: report ? (readFileSync(report, 'utf8').includes(g.commit) ? 'audit report names this commit' : 'the audit report does not name this commit') : 'no audit report supplied (owner file, audit_report)' },
141
+ { id: 'G-3', met: blockerFail.length === 0, why: blockerFail.length ? `${blockerFail.length} BLOCKER requirement(s) FAIL` : 'no BLOCKER requirement FAILs' },
142
+ { id: 'G-4', met: blockerOpen.length === 0, why: blockerOpen.length ? `${blockerOpen.length} BLOCKER requirement(s) open (FAIL or NOT ASSESSED)` : 'every BLOCKER requirement is settled' },
143
+ { id: 'G-5', met: criticalOpen.length === 0, why: criticalOpen.length ? `${criticalOpen.length} CRITICAL requirement(s) open and not under a current acceptance` : 'every CRITICAL requirement is settled or accepted' },
144
+ g.scope.edition === 'free'
145
+ ? { id: 'G-6', met: true, why: 'outside the free scope' }
146
+ : { id: 'G-6', met: highOpen.length === 0, why: highOpen.length ? `${highOpen.length} HIGH requirement(s) with a fail record and no current acceptance` : 'no open HIGH finding' },
147
+ { id: 'G-7', met: g7missing.length === 0, why: g7missing.length ? `did not run on this commit: ${g7missing.join(', ')}` : 'gitleaks, semgrep and osv-scanner ran on this commit' },
148
+ { id: 'G-8', met: !!rollback, why: rollback ? 'rollback path supplied' : 'no rollback path supplied (owner file, rollback)' },
149
+ ];
150
+
151
+ const allMet = conditions.every((c) => c.met) && runSupportsReady;
152
+ const verdict: Verdict = !allMet ? 'NOT READY' : used.length ? 'READY WITH ACCEPTED RISK' : 'READY';
153
+ const label = verdict !== 'NOT READY' && g.scope.edition === 'free' ? `${verdict} (FREE SCOPE)` : verdict;
154
+ return {
155
+ verdict, label, exit: problems.length ? 2 : verdict === 'NOT READY' ? 1 : 0, problems, conditions, counts,
156
+ open: open.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity] || cmp(a.id, b.id)), acceptancesUsed: used, acceptanceProblems: acc.problems,
157
+ runSupportsReady, checklistLine,
158
+ };
159
+ }
160
+
161
+ export function gateLines(o: GateOutcome): string[] {
162
+ const lines = [o.checklistLine];
163
+ for (const c of o.conditions) lines.push(`${c.id}: ${c.met ? 'met' : 'not met'}, ${c.why}`);
164
+ if (!o.runSupportsReady) lines.push('note: this run cannot support READY (the findings record says the run is incomplete, partial or quick)');
165
+ for (const p of o.acceptanceProblems) lines.push(`note: ${p}`);
166
+ if (o.problems.length) { lines.push('RESULT: stopped, the records or a cross check need attention (exit 2)'); return lines; }
167
+ lines.push(`RESULT: ${o.label}`);
168
+ return lines;
169
+ }