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,158 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // hullproof scan: discover, scanner adapters, checks, then results.json, the findings record and surface.json, all under
10
+ // <out>/<commit>/. Every contract file is byte identical for the same commit, engine, kit files and tool versions.
11
+ // Run times go to run.json and scan/run.json only.
12
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
+ import { basename, join, resolve } from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { discover } from '../discover/discover.js';
16
+ import { makeContext } from '../checks/context.js';
17
+ import { getMap } from '../checks/map/shared.js';
18
+ import { checkSha, loadChecks, runChecks } from '../checks/run.js';
19
+ import { buildFindings, buildResults, findingsJson, findingsMarkdown } from '../report/build.js';
20
+ import { loadKit, readStage, resolveKitDir, VALIDATOR_REL } from '../report/kit.js';
21
+ import { surface } from '../report/surface.js';
22
+ import { runScanners } from '../scanners/index.js';
23
+ import { dirSha256, RULES_DIR } from '../scanners/common.js';
24
+ import { ruleFiles } from '../scanners/semgrep.js';
25
+ import { stableJson } from '../util.js';
26
+ import { ENGINE_VERSION } from '../version.js';
27
+ import { gitSafety, gitState } from './git.js';
28
+ import { kitOwnedFiles } from '../discover/kit-files.js';
29
+ export const KIT_LOCAL = 'kit.local.json';
30
+ export const outDirFor = (repo, out, commit) => join(resolve(out ?? join(repo, '.hullproof')), commit);
31
+ function defaultScanners(o, stack, dir, controlsPath, kitDir, rulePack, skipFiles) {
32
+ const outs = runScanners({ repo: o.repo, out: dir, stack, allowNetwork: o.allowNetwork, edition: o.edition, controlsPath, history: true, kit: kitDir, rulePack, skipFiles }, o.tools ?? undefined);
33
+ return { hits: outs.flatMap((s) => s.hits.hits), metas: outs.map((s) => s.meta) };
34
+ }
35
+ // The rule pack folder from the option or HULLPROOF_RULE_PACK, checked before anything runs. A string is a bad input.
36
+ export function resolveRulePack(given) {
37
+ const raw = given ?? process.env.HULLPROOF_RULE_PACK ?? null;
38
+ if (!raw)
39
+ return { path: null };
40
+ const path = resolve(raw);
41
+ if (!existsSync(path) || !statSync(path).isDirectory())
42
+ return `the rule pack folder ${raw} does not exist`;
43
+ try {
44
+ ruleFiles(join(RULES_DIR, 'semgrep'), path);
45
+ }
46
+ catch (e) {
47
+ return e.message;
48
+ }
49
+ return { path };
50
+ }
51
+ export async function scan(o) {
52
+ const log = o.log ?? ((s) => process.stderr.write(s + '\n'));
53
+ const repo = resolve(o.repo);
54
+ if (!existsSync(repo)) {
55
+ log(`hullproof scan: ${repo} does not exist`);
56
+ return 2;
57
+ }
58
+ const safety = gitSafety(repo);
59
+ if (!safety.ok) {
60
+ log(`hullproof scan: ${safety.message}`);
61
+ return 2;
62
+ }
63
+ if (safety.credentialUrl)
64
+ log('hullproof scan: note: a remote URL holds embedded credentials. Treat that credential as exposed and rotate it. The line is not printed.');
65
+ const git = gitState(repo);
66
+ if (!git) {
67
+ log('hullproof scan: could not read the commit and tree with git (is there a commit?)');
68
+ return 2;
69
+ }
70
+ const kitDir = resolveKitDir(repo, o.kit);
71
+ if (!kitDir) {
72
+ log('hullproof scan: no kit found. Pass --kit <folder holding docs/hullproof/security-controls.json> or set HULLPROOF_KIT.');
73
+ return 2;
74
+ }
75
+ const kit = loadKit(kitDir);
76
+ const pack = resolveRulePack(o.rulePack);
77
+ if (typeof pack === 'string') {
78
+ log(`hullproof scan: ${pack}`);
79
+ return 2;
80
+ }
81
+ const st = o.stage ? { stage: o.stage, source: 'flag' } : readStage(repo);
82
+ const scope = { edition: o.edition, stage: st.stage, stageSource: st.source };
83
+ const started = Date.now();
84
+ const dir = outDirFor(repo, o.out, git.commit);
85
+ // Start from an empty folder so files from an earlier run with other tools never mix in.
86
+ // The hunt folder holds saved model answers that cost the user tokens to get, and the prove folder holds specs and the transcripts
87
+ // that are the evidence of a proof, so a new scan never deletes either.
88
+ if (existsSync(dir))
89
+ for (const n of readdirSync(dir))
90
+ if (n !== 'hunt' && n !== 'prove')
91
+ rmSync(join(dir, n), { recursive: true, force: true });
92
+ mkdirSync(dir, { recursive: true });
93
+ const kitFiles = kitOwnedFiles(repo, kitDir);
94
+ if (kitFiles.size)
95
+ log(`hullproof scan: ${kitFiles.size} unchanged Hullproof kit files in the project are left out of the scan (they hold planted test samples)`);
96
+ const stack = discover(repo, { skipFiles: kitFiles });
97
+ writeFileSync(join(dir, 'stack.json'), stableJson(stack));
98
+ const controlsPath = join(kitDir, 'docs/hullproof/security-controls.json');
99
+ const sc = o.scanners ? o.scanners(stack, dir) : defaultScanners(o, stack, dir, controlsPath, kitDir, pack.path, kitFiles);
100
+ const hits = sc?.hits ?? [];
101
+ const metas = (sc?.metas ?? []).slice().sort((a, b) => (a.tool < b.tool ? -1 : 1));
102
+ if (o.scanners) {
103
+ // Stub scanners (tests) do not write their files; write them here in the adapters' layout.
104
+ mkdirSync(join(dir, 'scan'), { recursive: true });
105
+ for (const m of metas) {
106
+ writeFileSync(join(dir, 'scan', `${m.tool}.meta.json`), stableJson(m));
107
+ writeFileSync(join(dir, 'scan', `${m.tool}.json`), stableJson({ schema_version: '1.0.0', tool: m.tool, hits: hits.filter((h) => h.tool === m.tool) }));
108
+ }
109
+ }
110
+ const checkDefs = loadChecks();
111
+ const ctx = makeContext(repo, stack, hits, kitDir);
112
+ const checks = runChecks(ctx, checkDefs);
113
+ writeFileSync(join(dir, 'checks.json'), stableJson(checks));
114
+ const codemap = getMap(ctx);
115
+ writeFileSync(join(dir, 'codemap.json'), stableJson(codemap));
116
+ const input = { kit, scope, git, checks, hits, metas, checkShas: Object.fromEntries(checkDefs.map((c) => [c.id, checkSha(c)])), rulesDirs: [join(RULES_DIR, 'semgrep'), ...(pack.path ? [pack.path] : [])] };
117
+ const results = buildResults(input);
118
+ writeFileSync(join(dir, 'results.json'), stableJson(results));
119
+ const { file: findings, dropped } = buildFindings(input);
120
+ const findingsMd = findingsMarkdown(findings, findingsJson(findings));
121
+ writeFileSync(join(dir, 'report.findings.md'), findingsMd);
122
+ writeFileSync(join(dir, 'surface.json'), stableJson(surface(ctx)));
123
+ const info = {
124
+ schema_version: '1.0.0', engine_version: ENGINE_VERSION, commit: git.commit, tree: git.tree, tree_clean: git.clean,
125
+ edition: scope.edition, stage: scope.stage, stage_source: scope.stageSource,
126
+ kit: { controls_sha256: kit.controlsSha256, standard_version: kit.standardVersion, controls_found: true },
127
+ scanners_available: true, tools: metas.map((m) => m.tool),
128
+ rule_pack: pack.path ? { name: basename(pack.path), files: ruleFiles(pack.path).map((f) => basename(f)), sha256: dirSha256(pack.path) } : null,
129
+ checks: { total: checkDefs.length, ran: checks.results.length, not_run: checks.not_run.length },
130
+ codemap: { entry_points: codemap.counts.entry_points, incomplete: codemap.coverage.filter((c) => c.status !== 'mapped').map((c) => ({ language: c.language, status: c.status })) },
131
+ dropped,
132
+ };
133
+ writeFileSync(join(dir, 'scan.json'), stableJson(info));
134
+ writeFileSync(join(dir, 'run.json'), JSON.stringify({ seconds: (Date.now() - started) / 1000 }) + '\n');
135
+ // The kit this scan used, so report and gate find the same kit without --kit. A machine path, so it lives outside the
136
+ // byte identical contract (like run.json); the controls sha256 in it is also in scan.json.
137
+ writeFileSync(join(dir, KIT_LOCAL), JSON.stringify({ kit_dir: kitDir, controls_sha256: kit.controlsSha256 }) + '\n');
138
+ // Self check with the kit's own validator: a record file the kit rejects is a bug, never a result.
139
+ const problems = await validateWithKit(kitDir, findingsMd, kit.byId);
140
+ if (problems.length) {
141
+ for (const p of problems)
142
+ log(`hullproof scan: findings record: ${p}`);
143
+ return 2;
144
+ }
145
+ const fails = results.filter((r) => r.result === 'FAIL').length;
146
+ const records = findings.records.filter((r) => r.kind === 'fail').length;
147
+ log(`hullproof scan: wrote ${dir}`);
148
+ log(`hullproof scan: requirements in scope ${results.length}, FAIL ${fails}, PASS (static) ${results.filter((r) => r.result === 'PASS (static)').length}, NOT ASSESSED ${results.filter((r) => r.result.startsWith('NOT ASSESSED')).length}; fail records ${records}; tools ran ${metas.filter((m) => m.status === 'ran').map((m) => m.tool).join(', ') || 'none'}`);
149
+ return 0;
150
+ }
151
+ export async function validateWithKit(kitDir, text, byId) {
152
+ const path = join(kitDir, VALIDATOR_REL);
153
+ if (!existsSync(path))
154
+ return [];
155
+ const v = await import(__rewriteRelativeImportExtension(pathToFileURL(path).href));
156
+ const res = v.validateFindingsText(text, { controls: byId });
157
+ return res.c.errorCount ? res.c.lines() : [];
158
+ }
@@ -0,0 +1,18 @@
1
+ import { cmp } from '../util.js';
2
+ export const sinkOf = (l) => { const s = l.path[l.path.length - 1]; return { file: s.file, line: s.line }; };
3
+ export const leadKey = (l) => { const s = sinkOf(l); return `${l.entry_point}\u0000${l.requirement_ids.join('+')}\u0000${s.file}:${s.line}`; };
4
+ export const readableKey = (k) => k.split('\u0000').join(' | ');
5
+ // Groups leads by identity. Within a group the leads are in run order, then lead_id order, so the first proposal tried is stable.
6
+ export function groupLeads(raised) {
7
+ const by = new Map();
8
+ for (const r of raised) {
9
+ const k = leadKey(r.lead);
10
+ by.set(k, [...(by.get(k) ?? []), r]);
11
+ }
12
+ return [...by.entries()].map(([key, leads]) => {
13
+ leads.sort((a, b) => a.run - b.run || cmp(a.lead.lead_id, b.lead.lead_id));
14
+ return { key, leads, runs: [...new Set(leads.map((l) => l.run))] };
15
+ });
16
+ }
17
+ // Most runs first, then by key. A pure ordering: no group is added or dropped.
18
+ export const byAgreement = (xs) => xs.slice().sort((a, b) => b.runs.length - a.runs.length || cmp(a.key, b.key));
@@ -0,0 +1,84 @@
1
+ const MAX_LINES = 8;
2
+ // Names that plausibly act on the caller or the request. A wrapper such as withErrors or asyncHandler does not match.
3
+ const RELEVANT = /(auth|user|owner|session|token|guard|verify|check|permission|role|tenant|account|csrf|protect|secure|policy|allow|deny|require|login|acl|admin|scope|bind|append|limit|throttle|sanit|validate|can[A-Z]|is[A-Z])/;
4
+ const REGISTER = /^(app|router|server|api|[A-Za-z_$][\w$]*Router)\.(get|post|put|patch|delete|all|options|head|use|route)$/;
5
+ // Top level arguments of the first call that opens in `text`, as strings.
6
+ function firstCallArgs(text) {
7
+ const open = text.indexOf('(');
8
+ if (open < 0)
9
+ return [];
10
+ const args = [];
11
+ let depth = 0, cur = '', quote = '';
12
+ for (let i = open + 1; i < text.length; i++) {
13
+ const c = text[i];
14
+ if (quote) {
15
+ cur += c;
16
+ if (c === quote && text[i - 1] !== '\\')
17
+ quote = '';
18
+ continue;
19
+ }
20
+ if (c === '"' || c === "'" || c === '`') {
21
+ quote = c;
22
+ cur += c;
23
+ continue;
24
+ }
25
+ if (c === '(' || c === '[' || c === '{')
26
+ depth++;
27
+ if (c === ')' || c === ']' || c === '}') {
28
+ if (depth === 0) {
29
+ args.push(cur.trim());
30
+ return args;
31
+ }
32
+ depth--;
33
+ }
34
+ if (c === ',' && depth === 0) {
35
+ args.push(cur.trim());
36
+ cur = '';
37
+ continue;
38
+ }
39
+ cur += c;
40
+ }
41
+ return args;
42
+ }
43
+ const callName = (arg) => /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/.exec(arg)?.[1] ?? null;
44
+ // Calls on the chain, in order. Express style: every argument between the path and the last argument (the handler). Wrapper style
45
+ // (`export const POST = withX(...)`): the wrapper name. Decorator style (Flask): every decorator that is not the route.
46
+ export function chainCalls(text, line) {
47
+ const lines = text.split('\n').slice(line - 1, line - 1 + MAX_LINES);
48
+ const first = lines.join('\n');
49
+ const out = [];
50
+ const reg = /^\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/.exec(first);
51
+ if (reg && REGISTER.test(reg[1])) {
52
+ const args = firstCallArgs(first);
53
+ for (const a of args.slice(1, -1)) {
54
+ const n = callName(a);
55
+ if (n)
56
+ out.push(n);
57
+ }
58
+ return out;
59
+ }
60
+ const wrap = /^\s*(?:export\s+)?(?:const|let)\s+\w+\s*=\s*(?:async\s*)?([A-Za-z_$][\w$]*)\s*\(/.exec(first);
61
+ if (wrap)
62
+ out.push(wrap[1]);
63
+ for (const l of lines) {
64
+ const d = /^\s*@([A-Za-z_][\w.]*)/.exec(l);
65
+ if (d && !/\.route$|\.(get|post|put|delete|patch)$/.test(d[1]))
66
+ out.push(d[1]);
67
+ if (/^\s*(async\s+)?def\s/.test(l))
68
+ break;
69
+ }
70
+ return out;
71
+ }
72
+ const known = (name, ep, map) => {
73
+ const last = name.split('.').pop().toLowerCase();
74
+ const vias = [...ep.protections, ...map.gates.flatMap((g) => g.protections)].map((p) => p.via.toLowerCase());
75
+ const vocab = [...map.config.local_vocab.auth_helpers, ...map.config.local_vocab.role_helpers].map((v) => v.toLowerCase());
76
+ return vias.some((v) => v.includes(last)) || vocab.includes(last);
77
+ };
78
+ // Names on the chain of `ep` that look like they act on the caller or the request and that the map does not know.
79
+ export function unknownProtections(ep, map, files) {
80
+ const text = files.read(ep.file);
81
+ if (!text)
82
+ return [];
83
+ return [...new Set(chainCalls(text, ep.line).filter((n) => RELEVANT.test(n.split('.').pop()) && !known(n, ep, map)))].sort();
84
+ }
@@ -0,0 +1,112 @@
1
+ // The confirmation engine. From the saved answers of a hunt, the repository and the code map it decides, with no model and no
2
+ // clock, which leads are confirmed findings and which stay leads. The same inputs give the same result.json byte for byte.
3
+ // The result holds nothing a model wrote except the claim and the notes, which are plain text and never decide anything.
4
+ import { createHash } from 'node:crypto';
5
+ import { cmp } from '../util.js';
6
+ import { ENGINE_VERSION } from '../version.js';
7
+ import { byAgreement, groupLeads, sinkOf } from './agreement.js';
8
+ import { unknownProtections } from './chain.js';
9
+ import { checkLead, FileCache } from './honesty.js';
10
+ import { parseOutput } from './lead.js';
11
+ import { runQuery } from './query.js';
12
+ import { confirmRule, spawnSemgrep } from './rule.js';
13
+ function tryGroup(g, input, files) {
14
+ let last = { status: 'not_confirmed', method: 'none', detail: 'the lead proposed no evidence the engine can check' };
15
+ for (const { run, lead } of g.leads) {
16
+ const p = lead.evidence_proposal;
17
+ if (p.kind === 'map_query') {
18
+ const o = runQuery(input.map, p.query, lead.entry_point, lead.path);
19
+ if (o.confirmed) {
20
+ // The map cannot vouch for a claim about input, ownership or protection when the route chain holds a helper it does not know.
21
+ const ep = input.map.entry_points.find((e) => e.id === lead.entry_point);
22
+ const unknown = ep ? unknownProtections(ep, input.map, files) : [];
23
+ if (unknown.length) {
24
+ last = { status: 'not_confirmed', method: 'map_query', reason: 'unknown_protection', helpers: unknown, detail: `unknown_protection: the route chain holds ${unknown.join(', ')}, which the code map does not recognise, so the map cannot say this claim is true` };
25
+ continue;
26
+ }
27
+ const cfg = createHash('sha256').update(JSON.stringify(p.query)).digest('hex');
28
+ return { status: 'confirmed', method: 'map_query', run, detail: o.detail, tool: 'hullproof-hunt', rule_id: `hunt.query.${cfg.slice(0, 12)}`, tool_version: ENGINE_VERSION, config_sha256: cfg };
29
+ }
30
+ last = { status: 'not_confirmed', method: 'map_query', detail: o.detail };
31
+ }
32
+ else if (p.kind === 'semgrep_rule') {
33
+ const o = confirmRule(lead, input.repo, input.semgrep ?? spawnSemgrep);
34
+ if (o.status === 'confirmed')
35
+ return { status: 'confirmed', method: 'semgrep_rule', run, detail: o.detail, tool: 'semgrep', rule_id: o.rule_id, tool_version: o.tool_version, config_sha256: o.config_sha256 };
36
+ last = o.status === 'not_run' ? { status: 'not_run', method: 'semgrep_rule', detail: o.detail } : { status: 'not_confirmed', method: 'semgrep_rule', detail: o.detail };
37
+ }
38
+ else if (p.kind === 'generated_test') {
39
+ if (last.status === 'not_confirmed' && last.method === 'none')
40
+ last = { status: 'not_confirmed', method: 'generated_test', detail: 'a generated test has to be written and run with hullproof prove; until it proves the flaw 3 of 3 the lead stays a lead' };
41
+ }
42
+ else if (p.kind === 'none' && p.rejected && last.status === 'not_confirmed' && last.method === 'none') {
43
+ last = { status: 'not_confirmed', method: 'none', detail: `the proposal was not usable: ${p.rejected}` };
44
+ }
45
+ }
46
+ return last;
47
+ }
48
+ export function confirmHunt(input) {
49
+ const files = new FileCache(input.repo);
50
+ const incomplete = [];
51
+ const malformed = [];
52
+ const hallucinations = [];
53
+ const raised = [];
54
+ const usage = { calls: 0, input_tokens: 0, output_tokens: 0 };
55
+ const seen = new Set();
56
+ let complete = 0;
57
+ for (const a of input.answers) {
58
+ seen.add(`${a.run}\u0000${a.entry_id}`);
59
+ usage.calls++;
60
+ usage.input_tokens += a.usage.input_tokens;
61
+ usage.output_tokens += a.usage.output_tokens;
62
+ if (a.status !== 'complete') {
63
+ incomplete.push({ entry_id: a.entry_id, run: a.run, stop: a.stop ?? 'unknown', detail: a.detail ?? '' });
64
+ continue;
65
+ }
66
+ complete++;
67
+ const p = parseOutput(a.output, a.entry_id);
68
+ for (const m of p.malformed)
69
+ malformed.push({ run: a.run, entry_id: a.entry_id, lead_id: m.lead_id, reason: m.reason });
70
+ for (const lead of p.leads) {
71
+ const h = checkLead(lead, files, input.map);
72
+ if (h.ok)
73
+ raised.push({ run: a.run, lead });
74
+ else
75
+ hallucinations.push({ run: a.run, entry_id: a.entry_id, lead_id: lead.lead_id, reason: h.reason });
76
+ }
77
+ }
78
+ // Calls that were planned and never saved: the run was interrupted or not finished.
79
+ let missing = 0;
80
+ if (input.plan) {
81
+ for (let r = 1; r <= input.plan.runs; r++) {
82
+ for (const e of input.plan.entries) {
83
+ if (seen.has(`${r}\u0000${e}`))
84
+ continue;
85
+ missing++;
86
+ incomplete.push({ entry_id: e, run: r, stop: 'not_run', detail: 'no answer was saved for this call' });
87
+ }
88
+ }
89
+ }
90
+ const out = (g, c) => {
91
+ const first = g.leads[0].lead;
92
+ return { key: g.key, ...(first.claim_truncated ? { claim_truncated: true } : {}), entry_point: first.entry_point, requirement_ids: first.requirement_ids, claim: first.claim, path: first.path, sink: sinkOf(first), runs_raised: g.runs.length, runs: g.runs, confirmation: c };
93
+ };
94
+ const groups = byAgreement(groupLeads(raised));
95
+ const confirmed = [];
96
+ const leads = [];
97
+ for (const g of groups) {
98
+ const c = tryGroup(g, input, files);
99
+ (c.status === 'confirmed' ? confirmed : leads).push(out(g, c));
100
+ }
101
+ const by = (a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id);
102
+ return {
103
+ schema_version: '1.0.0', engine_version: ENGINE_VERSION, commit: input.commit,
104
+ runs: Math.max(input.plan?.runs ?? 0, ...input.answers.map((a) => a.run), 0),
105
+ entries_hunted: new Set(input.answers.map((a) => a.entry_id)).size,
106
+ calls: { complete, incomplete: input.answers.length - complete, missing },
107
+ incomplete: incomplete.sort(by),
108
+ malformed: { count: malformed.length, items: malformed.sort((a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id) || cmp(a.lead_id, b.lead_id)) },
109
+ hallucinations: { count: hallucinations.length, items: hallucinations.sort((a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id) || cmp(a.lead_id, b.lead_id)) },
110
+ confirmed, leads, usage,
111
+ };
112
+ }
@@ -0,0 +1,75 @@
1
+ // The honesty check. A lead is kept only when every file it cites exists in the repository, every cited line is inside its file,
2
+ // and the code it quotes is really there beside the sink (the last step of its path). A lead that fails is dropped and counted
3
+ // as a hallucination. Nothing here reads the lead's claim: it only asks whether the cited code exists.
4
+ import { lstatSync, readFileSync } from 'node:fs';
5
+ import { resolve, sep } from 'node:path';
6
+ import { LIMITS } from './lead.js';
7
+ export const QUOTE_SLACK = 2; // the quote may start or end this many lines from the cited sink line
8
+ const MAX_FILE_BYTES = 2_000_000;
9
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
10
+ export class FileCache {
11
+ repo;
12
+ files = new Map();
13
+ constructor(repo) { this.repo = resolve(repo); }
14
+ // The text of a repository file, or null: absolute paths, "..", links, folders and files over 2 MB are all null.
15
+ read(file) {
16
+ if (this.files.has(file))
17
+ return this.files.get(file);
18
+ let text = null;
19
+ if (file && !file.startsWith('/') && !file.includes('\\') && !file.includes('\0') && !file.split('/').includes('..')) {
20
+ const abs = resolve(this.repo, file);
21
+ try {
22
+ const st = lstatSync(abs);
23
+ if (abs.startsWith(this.repo + sep) && st.isFile() && st.size <= MAX_FILE_BYTES)
24
+ text = readFileSync(abs, 'utf8');
25
+ }
26
+ catch {
27
+ text = null;
28
+ }
29
+ }
30
+ this.files.set(file, text);
31
+ return text;
32
+ }
33
+ }
34
+ // Line ranges [start, end] (1 based) of every place the normalised quote occurs in the normalised file.
35
+ function occurrences(text, quote) {
36
+ const starts = []; // offset in the joined text where each original line begins
37
+ let joined = '';
38
+ text.split('\n').forEach((raw) => {
39
+ const t = norm(raw);
40
+ starts.push(joined.length);
41
+ joined += t + ' ';
42
+ });
43
+ const lineAt = (off) => {
44
+ let lo = 0, hi = starts.length - 1;
45
+ while (lo < hi) {
46
+ const m = (lo + hi + 1) >> 1;
47
+ if (starts[m] <= off)
48
+ lo = m;
49
+ else
50
+ hi = m - 1;
51
+ }
52
+ return lo + 1;
53
+ };
54
+ const out = [];
55
+ for (let at = joined.indexOf(quote); at >= 0; at = joined.indexOf(quote, at + 1))
56
+ out.push([lineAt(at), lineAt(at + quote.length - 1)]);
57
+ return out;
58
+ }
59
+ export function checkLead(lead, files, map) {
60
+ if (!map.entry_points.some((e) => e.id === lead.entry_point))
61
+ return { ok: false, reason: 'the entry point is not in the code map' };
62
+ for (const s of lead.path) {
63
+ const t = files.read(s.file);
64
+ if (t === null)
65
+ return { ok: false, reason: `the cited file ${s.file} does not exist in the repository` };
66
+ if (s.line > t.split('\n').length)
67
+ return { ok: false, reason: `line ${s.line} is past the end of ${s.file}` };
68
+ }
69
+ const sink = lead.path[lead.path.length - 1];
70
+ const quote = norm(lead.quoted_code);
71
+ if (quote.length < LIMITS.minQuote || !/[A-Za-z0-9]/.test(quote))
72
+ return { ok: false, reason: 'the quoted code is too short to check' };
73
+ const near = occurrences(files.read(sink.file), quote).some(([a, b]) => b >= sink.line - QUOTE_SLACK && a <= sink.line + QUOTE_SLACK);
74
+ return near ? { ok: true } : { ok: false, reason: `the quoted code is not in ${sink.file} at line ${sink.line}` };
75
+ }
@@ -0,0 +1,92 @@
1
+ // The lead schema: what a model returns for one entry point, checked against the limits below. A lead that breaks a limit is
2
+ // malformed and is counted apart from a hallucination (a lead whose cited code is not there, see honesty.ts).
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseQuery } from './query.js';
6
+ export const LIMITS = { leadId: 80, claim: 500, note: 200, path: 12, requirements: 6, quote: 2000, leadsPerAnswer: 10, ruleYaml: 8000, controlCode: 20000, queryJson: 6000, minQuote: 8 };
7
+ export const CONTROL_EXTENSIONS = ['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'go', 'rb', 'php', 'java'];
8
+ // The schema handed to a provider as the structured output shape.
9
+ export const MODEL_OUTPUT_SCHEMA = JSON.parse(readFileSync(fileURLToPath(new URL('../../schemas/hunt-output.schema.json', import.meta.url)), 'utf8'));
10
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
11
+ const SEC = /^SEC-[A-Z]+-\d{3}$/;
12
+ // One line of plain text: control characters removed, spaces collapsed, clipped.
13
+ export const plain = (s, max) => {
14
+ const t = s.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
15
+ return t.length <= max ? t : t.slice(0, max - 1).trimEnd() + '…';
16
+ };
17
+ function proposal(p) {
18
+ if (!isObj(p) || typeof p.kind !== 'string')
19
+ return { kind: 'none', rejected: 'no evidence proposal' };
20
+ if (p.kind === 'generated_test')
21
+ return { kind: 'generated_test' };
22
+ if (p.kind === 'none')
23
+ return { kind: 'none' };
24
+ if (p.kind === 'map_query') {
25
+ if (typeof p.query_json !== 'string' || !p.query_json.trim())
26
+ return { kind: 'none', rejected: 'map_query without query_json' };
27
+ if (p.query_json.length > LIMITS.queryJson)
28
+ return { kind: 'none', rejected: 'query_json is too long' };
29
+ let v;
30
+ try {
31
+ v = JSON.parse(p.query_json);
32
+ }
33
+ catch {
34
+ return { kind: 'none', rejected: 'query_json is not JSON' };
35
+ }
36
+ const q = parseQuery(v);
37
+ return q.ok ? { kind: 'map_query', query: q.query } : { kind: 'none', rejected: `query: ${q.reason}` };
38
+ }
39
+ if (p.kind === 'semgrep_rule') {
40
+ const { rule_yaml: r, control_code: c, control_extension: e } = p;
41
+ if (typeof r !== 'string' || !r.trim() || r.length > LIMITS.ruleYaml)
42
+ return { kind: 'none', rejected: 'rule_yaml is missing or too long' };
43
+ if (typeof c !== 'string' || !c.trim() || c.length > LIMITS.controlCode)
44
+ return { kind: 'none', rejected: 'a rule needs control_code (the safe sample)' };
45
+ const ext = typeof e === 'string' ? e.replace(/^\./, '') : '';
46
+ if (!CONTROL_EXTENSIONS.includes(ext))
47
+ return { kind: 'none', rejected: 'control_extension is not one of the allowed extensions' };
48
+ return { kind: 'semgrep_rule', rule_yaml: r, control_code: c, control_extension: ext };
49
+ }
50
+ return { kind: 'none', rejected: `unknown proposal kind` };
51
+ }
52
+ // The leads of one saved output, for the entry point that was hunted. A lead that names another entry point is malformed.
53
+ export function parseOutput(raw, entryId) {
54
+ if (!isObj(raw) || !Array.isArray(raw.leads))
55
+ return { leads: [], malformed: [{ lead_id: '', reason: 'the output has no leads list' }] };
56
+ const leads = [];
57
+ const malformed = [];
58
+ raw.leads.forEach((x, i) => {
59
+ const id = isObj(x) && typeof x.lead_id === 'string' ? plain(x.lead_id, LIMITS.leadId) : `#${i + 1}`;
60
+ const bad = (reason) => { malformed.push({ lead_id: id, reason }); };
61
+ if (i >= LIMITS.leadsPerAnswer)
62
+ return bad(`more than ${LIMITS.leadsPerAnswer} leads in one answer`);
63
+ if (!isObj(x))
64
+ return bad('a lead must be an object');
65
+ if (typeof x.lead_id !== 'string' || !x.lead_id.trim() || x.lead_id.length > LIMITS.leadId)
66
+ return bad('lead_id is missing or too long');
67
+ if (x.entry_point !== entryId)
68
+ return bad('entry_point is not the entry point that was hunted');
69
+ // An empty list is allowed: the lead names no requirement. It can still be confirmed, and then it is reported and stays out of the gate (merge.ts).
70
+ if (!Array.isArray(x.requirement_ids) || x.requirement_ids.length > LIMITS.requirements || !x.requirement_ids.every((r) => typeof r === 'string' && SEC.test(r)))
71
+ return bad('requirement_ids needs at most 6 ids written like SEC, an area and three digits');
72
+ if (typeof x.claim !== 'string' || !x.claim.trim())
73
+ return bad('claim is missing');
74
+ if (!Array.isArray(x.path) || x.path.length === 0 || x.path.length > LIMITS.path)
75
+ return bad('path needs 1 to 12 steps');
76
+ const path = [];
77
+ for (const s of x.path) {
78
+ if (!isObj(s) || typeof s.file !== 'string' || !s.file || !Number.isInteger(s.line) || s.line < 1 || typeof s.note !== 'string')
79
+ return bad('a path step needs file, line and note');
80
+ path.push({ file: s.file, line: s.line, note: plain(s.note, LIMITS.note) });
81
+ }
82
+ if (typeof x.quoted_code !== 'string' || x.quoted_code.length > LIMITS.quote)
83
+ return bad('quoted_code is missing or too long');
84
+ leads.push({
85
+ lead_id: plain(x.lead_id, LIMITS.leadId), entry_point: entryId, requirement_ids: [...new Set(x.requirement_ids)].sort(),
86
+ claim: plain(x.claim, LIMITS.claim), path, quoted_code: x.quoted_code, evidence_proposal: proposal(x.evidence_proposal),
87
+ // A claim over the limit is shortened and marked, not dropped. The mark is only written when true, so older results keep their bytes.
88
+ ...(plain(x.claim, Infinity).length > LIMITS.claim ? { claim_truncated: true } : {}),
89
+ });
90
+ });
91
+ return { leads, malformed };
92
+ }
@@ -0,0 +1,88 @@
1
+ // Puts confirmed hunt findings, and findings proven by a test in the sandbox, into the rows and the findings record that the report and
2
+ // the gate read. Only the `confirmed` list of the hunt result and the specs the prove result marks `proven` (3 of 3 runs) are read. Leads (the `leads` list), their claims, their agreement counts and the synthesis never reach this code, so
3
+ // no lead can change a row, a record or the verdict. A confirmed finding becomes a fail record with machine_evidence, as findings
4
+ // schema 1.1.0 asks, and makes its requirement a FAIL row.
5
+ import { createHash } from 'node:crypto';
6
+ import { cmp } from '../util.js';
7
+ import { findingsJson, findingsMarkdown } from '../report/build.js';
8
+ import { highest, inScope } from '../report/kit.js';
9
+ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…');
10
+ const fpPath = (f) => f.replace(/[\s:]/g, '_');
11
+ const slug = (s, max = 12) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').split('-').filter(Boolean).slice(0, max).join('-') || 'x';
12
+ const METHOD = { map_query: 'a query over the code map', semgrep_rule: 'a generated Semgrep rule with a negative control', sandbox_test: 'a test run in a local sandbox, 3 of 3 runs, with the transcripts kept' };
13
+ // A spec the sandbox proved, as a confirmed finding. When it tests a hunt lead, the lead's own path and requirements are kept and the
14
+ // test replaces the weaker evidence: a test that fails the way the lead claims, 3 of 3 times, is the strongest confirmation there is.
15
+ function provenLead(sp, p, base) {
16
+ const conf = {
17
+ status: 'confirmed', method: 'sandbox_test', run: p.runs_required, tool: 'hullproof-prove', rule_id: `prove.test.${sp.spec_sha256.slice(0, 12)}`,
18
+ tool_version: p.engine_version, config_sha256: sp.spec_sha256,
19
+ detail: `spec ${sp.spec_id} passed its positive control and showed the flaw in ${sp.runs.length} of ${p.runs_required} runs (transcripts ${sp.runs.map((r) => r.transcript_sha256.slice(0, 12)).join(', ')})`,
20
+ };
21
+ if (base)
22
+ return { ...base, confirmation: conf };
23
+ return { key: sp.lead_key ?? `spec:${sp.spec_id}`, entry_point: sp.entry_point, requirement_ids: sp.requirement_ids, claim: sp.claim, path: [{ file: sp.sink.file, line: sp.sink.line, note: `proven by test ${sp.spec_id}` }], sink: sp.sink, runs_raised: 0, runs: [], confirmation: conf };
24
+ }
25
+ // A lead that names no requirement can borrow the requirements of the engine's own map checks that flagged the same entry point (a FAIL in the
26
+ // same file). It only borrows when the scan folder carries the checks and the code map; the finding says so.
27
+ function borrowed(s, entryPoint) {
28
+ const e = s.codemap?.entry_points.find((x) => x.id === entryPoint);
29
+ if (!e || !s.checks)
30
+ return { ids: [], checks: [] };
31
+ const hit = s.checks.results.filter((r) => r.result === 'FAIL' && r.check_id.startsWith('check.map.') && r.evidence.some((ev) => ev.file === e.file));
32
+ return { ids: [...new Set(hit.flatMap((r) => r.sec_ids))].sort(cmp), checks: hit.map((r) => r.check_id).sort(cmp) };
33
+ }
34
+ export function mergeHunt(s, kit, hunt, prove = null) {
35
+ const scope = { edition: s.info.edition, stage: s.info.stage, stageSource: s.info.stage_source };
36
+ const rows = new Map(s.results.map((r) => [r.id, r]));
37
+ const records = [...s.findings.records];
38
+ const tails = new Set(records.filter((r) => r.kind === 'fail').map((r) => r.fingerprint.slice(r.fingerprint.indexOf(':') + 1)));
39
+ const inGate = [];
40
+ const outOfScope = [];
41
+ const byKey = new Map([...(hunt?.confirmed ?? []), ...(hunt?.leads ?? [])].map((l) => [l.key, l]));
42
+ const proven = (prove?.specs ?? []).filter((x) => x.outcome === 'proven' && x.runs.length >= prove.runs_required && x.runs.every((r) => r.outcome === 'proven')).map((x) => provenLead(x, prove, x.lead_key ? byKey.get(x.lead_key) : undefined));
43
+ const provenKeys = new Set(proven.map((l) => l.key));
44
+ const confirmed = [...(hunt?.confirmed ?? []).filter((l) => !provenKeys.has(l.key)), ...proven];
45
+ for (const c of confirmed) {
46
+ if (c.confirmation.status !== 'confirmed')
47
+ continue;
48
+ const lend = c.requirement_ids.length ? { ids: [], checks: [] } : borrowed(s, c.entry_point);
49
+ const ids = (c.requirement_ids.length ? c.requirement_ids : lend.ids).filter((id) => { const k = kit.byId.get(id); return !!k && inScope(k, scope); }).sort(cmp);
50
+ if (!ids.length) {
51
+ outOfScope.push(c);
52
+ continue;
53
+ }
54
+ const ev = c.confirmation;
55
+ const first = kit.byId.get(ids[0]);
56
+ let tail = `${fpPath(c.sink.file)}:${slug(`${ev.method === 'sandbox_test' ? 'proven' : 'hunt'} ${c.entry_point} ${c.sink.line}`)}`;
57
+ if (tails.has(tail))
58
+ tail = `${tail}-${createHash('sha256').update(c.key).digest('hex').slice(0, 6).replace(/[^a-z0-9]/g, 'a')}`;
59
+ tails.add(tail);
60
+ const trace = c.path.map((p, i) => ({ kind: i === c.path.length - 1 ? 'sink' : i === 0 ? 'entrypoint' : 'handoff', file: p.file, line: p.line, note: clip(p.note || 'step', 300) }));
61
+ const rec = {
62
+ fingerprint: `${ids[0]}:${tail}`, kind: 'fail', sec_ids: ids, title: clip(`${ev.method === 'sandbox_test' ? 'Proven by test' : 'Confirmed lead'}: ${c.claim}`, 160), class: 'finding', gap_in_standard: false,
63
+ severity: highest(ids.map((id) => kit.byId.get(id).severity)), confidence: 'SUSPECTED', demonstrated: false,
64
+ root_cause: clip(c.claim, 120), raised_by: [ev.method === 'sandbox_test' ? 'hullproof-prove' : 'hullproof-hunt'],
65
+ impact_statement: clip(`${c.claim} ${ev.method === 'sandbox_test' ? 'The engine proved it with' : 'A model proposed this and the engine confirmed it with'} ${METHOD[ev.method]}: ${ev.detail}.${lend.checks.length && ids.length ? ` The lead named no requirement, so the requirement is taken from ${lend.checks.join(', ')}, which flagged this entry point.` : ''}`, 600),
66
+ remediation: { strategy: clip(`Fix the code at ${c.sink.file}:${c.sink.line} so it meets ${ids.join(', ')}: ${first.title}.`, 800), confirm: ev.method === 'sandbox_test' ? 'Run hullproof prove again after the fix, then hullproof report. The finding is gone when the test no longer shows the flaw. Test the fixed request by hand as well.' : 'Run hullproof hunt with --replay and then hullproof report again. The finding is gone when the lead no longer confirms. Test the fixed request by hand as well.' },
67
+ evidence: c.path.slice(0, 8).map((p) => ({ file: p.file, line: p.line, note: clip(p.note || 'step', 300) })),
68
+ trace: trace.slice(0, 12),
69
+ machine_evidence: { tool: ev.tool, rule_id: ev.rule_id, tool_version: ev.tool_version, config_sha256: ev.config_sha256 },
70
+ };
71
+ records.push(rec);
72
+ inGate.push(c);
73
+ for (const id of ids) {
74
+ const old = rows.get(id);
75
+ if (old?.result === 'FAIL')
76
+ continue;
77
+ rows.set(id, { id, result: 'FAIL', notes: clip(`Failed by ${ev.method === 'sandbox_test' ? 'a finding proven by test' : 'hunt lead confirmed by'} ${ev.rule_id} at ${c.sink.file}:${c.sink.line}.`, 600) });
78
+ }
79
+ }
80
+ records.sort((a, b) => cmp(a.fingerprint, b.fingerprint));
81
+ // A BLOCKER or CRITICAL record needs a verifier outcome in a complete run. No verifier ran, so such a run is recorded the way the
82
+ // scan records it: incomplete, reason deterministic_only, with the agent calls of the kit's own audit at 0. The model calls of the
83
+ // hunt are counted in hunt/result.json, not here.
84
+ const needsVerifier = records.some((r) => r.kind === 'fail' && r.sec_ids.some((id) => { const v = kit.byId.get(id)?.severity; return v === 'BLOCKER' || v === 'CRITICAL'; }));
85
+ const report = needsVerifier && s.findings.report.run_status === 'complete' ? { ...s.findings.report, run_status: 'incomplete', incomplete_reason: 'deterministic_only' } : s.findings.report;
86
+ const findings = { ...s.findings, report, records };
87
+ return { results: [...rows.values()].sort((a, b) => cmp(a.id, b.id)), findings, findingsText: findingsMarkdown(findings, findingsJson(findings)), inGate, outOfScope, proven };
88
+ }