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,103 @@
1
+ // Runs the scanner adapters and writes, per tool, scan/<tool>.json (HitsFile), scan/<tool>.meta.json (ToolMeta) and
2
+ // scan/<tool>.detail.json (DetailFile). Nothing is installed. A missing tool, a tool that needs the network when the
3
+ // network is not allowed, a tool with nothing to read, and a tool that fails are each recorded with the reason.
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import type { Hit, HitsFile, ScannerId, ToolMeta } from '../types/index.ts';
8
+ import { cmp, stableJson } from '../util.ts';
9
+ import { readHeadCommit } from '../discover/git.ts';
10
+ import { loadControls, notInControls } from '../mapping/controls.ts';
11
+ import { detectVersion, exec, readTracked, RULES_DIR, toolEnv, versionTested } from './common.ts';
12
+ import { adapter as semgrep } from './semgrep.ts';
13
+ import { adapter as gitleaks } from './gitleaks.ts';
14
+ import { adapter as osv } from './osv-scanner.ts';
15
+ import { adapter as trivy } from './trivy.ts';
16
+ import type { Adapter, AdapterResult, DetailFile, DetailHit, Env, ScanOptions } from './types.ts';
17
+
18
+ export const ADAPTERS: Adapter[] = [semgrep, gitleaks, osv, trivy];
19
+
20
+ export interface ScanOutput { hits: HitsFile; meta: ToolMeta; detail: DetailFile; seconds: number }
21
+
22
+ const hitKey = (h: Hit): string[] => [h.file, String(h.line).padStart(9, '0'), h.tool, h.rule_id];
23
+ const byKey = (a: string[], b: string[]): number => { for (let i = 0; i < a.length; i++) { const c = cmp(a[i], b[i]); if (c) return c; } return 0; };
24
+
25
+ export function toContractHits(details: DetailHit[]): Hit[] {
26
+ const seen = new Map<string, Hit>();
27
+ for (const d of details) {
28
+ const h: Hit = { tool: d.tool, rule_id: d.rule_id, file: d.file, line: d.line, sec_ids: d.sec_ids, severity_hint: d.severity_hint, message_key: d.message_key, ...(d.noise ? { noise: d.noise } : {}) };
29
+ const k = stableJson(h);
30
+ if (!seen.has(k)) seen.set(k, h);
31
+ }
32
+ return [...seen.values()].sort((a, b) => byKey(hitKey(a), hitKey(b)) || cmp(stableJson(a), stableJson(b)));
33
+ }
34
+
35
+ const sortDetails = (xs: DetailHit[]): DetailHit[] =>
36
+ xs.map((d) => [d, stableJson(d)] as const).sort((a, b) => byKey(hitKey(a[0]), hitKey(b[0])) || cmp(a[1], b[1])).map((x) => x[0]);
37
+
38
+ export function runScanner(a: Adapter, opts: ScanOptions): ScanOutput {
39
+ const started = Date.now();
40
+ const o: ScanOptions = { ...opts, rulesDir: opts.rulesDir ?? RULES_DIR };
41
+ const env0 = toolEnv(o.env ?? process.env, o.allowNetwork);
42
+ const controls = loadControls(o.controlsPath);
43
+ const tracked = readTracked(o.repo);
44
+ const version = detectVersion(a.versionCommand, env0);
45
+ let status: ToolMeta['status'] = 'ran';
46
+ let reason: string | null = null;
47
+ let res: AdapterResult | null = null;
48
+
49
+ if (!version) { status = 'skipped: not installed'; reason = `${a.tool} is not installed on this machine (not found on PATH). The engine does not install tools.`; }
50
+ else if (a.needsNetwork && !o.allowNetwork) { status = 'skipped: network not allowed'; reason = `${a.tool} sends package names and versions to osv.dev and needs its advisory data. Run with --allow-network to include it.`; }
51
+ else if (!a.applies(o.stack)) { status = 'skipped: not applicable'; reason = a.notApplicableReason(o); }
52
+ else {
53
+ const scratch = mkdtempSync(join(tmpdir(), 'hullproof-scan-'));
54
+ const env: Env = {
55
+ version, tracked, rulesDir: o.rulesDir!, scratch,
56
+ run: (argv) => exec(argv, o.repo, env0),
57
+ };
58
+ try { res = a.execute(o, env); if (res && o.skipFiles?.size) res = { ...res, hits: res.hits.filter((h) => !o.skipFiles!.has(h.file)) }; }
59
+ catch (e) { status = 'failed'; reason = `${a.tool} did not finish: ${(e as Error).message}`; }
60
+ finally { rmSync(scratch, { recursive: true, force: true }); }
61
+ }
62
+
63
+ const details = sortDetails(res?.hits ?? []);
64
+ const scanned = res?.scanned ?? null;
65
+ const scannedSet = new Set(scanned ?? []);
66
+ const files: DetailFile['files'] = {
67
+ scanned: scanned ? scanned.length : null,
68
+ tracked: tracked ? tracked.size : null,
69
+ scanned_not_tracked: scanned && tracked ? scanned.filter((f) => !tracked.has(f)).length : null,
70
+ tracked_not_scanned: scanned && tracked ? [...tracked].filter((f) => !scannedSet.has(f)).length : null,
71
+ };
72
+ const ran = status === 'ran';
73
+ const meta: ToolMeta = {
74
+ schema_version: '1.0.0', tool: a.id, status, version, tested_version_range: a.testedVersions,
75
+ version_tested: versionTested(version, a.testedVersions), argv: res?.commands[0] ?? [], config_sha256: res?.configSha256 ?? null,
76
+ commit: readHeadCommit(o.repo), files_scanned: files.scanned, files_tracked: files.tracked, exit_code: res?.exitCode ?? null,
77
+ db_date: res?.dbDate ?? null, not_assessed_sec_ids: ran ? [] : a.possibleSecIds(o),
78
+ };
79
+ const detail: DetailFile = {
80
+ schema_version: '0.1.0', tool: a.id, status, not_run_reason: reason, tool_version: version, commands: res?.commands ?? [],
81
+ files, as_of: res?.asOf ?? null, policy: res?.policy ?? null,
82
+ sec_ids_not_in_controls: [...new Set(notInControls(details.flatMap((d) => [...d.sec_ids, ...(d.advisory?.mapped_sec_ids ?? [])]), controls))].sort(cmp),
83
+ tool_errors: (res?.toolErrors ?? []).sort((x, y) => cmp(stableJson(x), stableJson(y))),
84
+ hits: details,
85
+ };
86
+ return { hits: { schema_version: '1.0.0', tool: a.id, hits: toContractHits(details) }, meta, detail, seconds: (Date.now() - started) / 1000 };
87
+ }
88
+
89
+ export function writeScan(outDir: string, s: ScanOutput): void {
90
+ const dir = join(outDir, 'scan');
91
+ mkdirSync(dir, { recursive: true });
92
+ writeFileSync(join(dir, `${s.meta.tool}.json`), stableJson(s.hits));
93
+ writeFileSync(join(dir, `${s.meta.tool}.meta.json`), stableJson(s.meta));
94
+ writeFileSync(join(dir, `${s.meta.tool}.detail.json`), stableJson(s.detail));
95
+ }
96
+
97
+ // Runs every adapter (or the ones named) and writes their files. Run times go to scan/run.json, outside the contract.
98
+ export function runScanners(opts: ScanOptions, tools?: ScannerId[]): ScanOutput[] {
99
+ const out = ADAPTERS.filter((a) => !tools || tools.includes(a.id)).map((a) => runScanner(a, opts));
100
+ for (const s of out) writeScan(opts.out, s);
101
+ writeFileSync(join(opts.out, 'scan', 'run.json'), JSON.stringify({ seconds: Object.fromEntries(out.map((s) => [s.meta.tool, s.seconds])) }, null, 2) + '\n');
102
+ return out;
103
+ }
@@ -0,0 +1,90 @@
1
+ // OSV Scanner adapter. It sends package names and versions to osv.dev, so it runs only with --allow-network; without it
2
+ // the tool is NOT RUN with that reason. One hit per advisory group per package, with the advisory id, package, version,
3
+ // severity class, fixed versions and the dependency policy decision. The result is reproducible only for the same
4
+ // advisory data date, recorded as db_date.
5
+ import { cmp, sortUnique } from '../util.ts';
6
+ import { relPath } from './common.ts';
7
+ import { inEdition, loadControls } from '../mapping/controls.ts';
8
+ import { mapHit, tableSecIds } from '../mapping/map.ts';
9
+ import { classFromScore, classFromWord, decide, DEFAULT_POLICY, type SeverityClass } from '../mapping/dependency-policy.ts';
10
+ import type { Adapter, DetailHit, ScanOptions } from './types.ts';
11
+
12
+ interface Osv {
13
+ id: string; published?: string; modified?: string;
14
+ database_specific?: { severity?: string };
15
+ affected?: { package?: { name?: string; ecosystem?: string }; ranges?: { events?: { fixed?: string }[] }[] }[];
16
+ }
17
+ interface OsvJson {
18
+ results?: { source: { path: string }; packages?: { package: { name: string; version?: string; commit?: string; ecosystem: string }; vulnerabilities?: Osv[]; groups?: { ids: string[]; aliases?: string[]; max_severity?: string }[] }[] }[];
19
+ }
20
+
21
+ const RANK: Record<SeverityClass, number> = { UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
22
+ export const today = (): string => new Date().toISOString().slice(0, 10);
23
+
24
+ export const adapter: Adapter = {
25
+ id: 'osv-scanner',
26
+ tool: 'osv-scanner',
27
+ versionCommand: ['osv-scanner', '--version'],
28
+ testedVersions: '2.6.x',
29
+ needsNetwork: true,
30
+ applies: (stack) => stack.package_managers.length > 0,
31
+ notApplicableReason: (o) => (adapter.applies(o.stack) ? null : 'No package manifest or lockfile was found, so there are no dependencies to check.'),
32
+ configFiles: () => [],
33
+ command: () => ['osv-scanner', 'scan', 'source', '--format', 'json', '--recursive', '.'],
34
+ parse: (stdout, ctx) => parseOsv(stdout, ctx as ScanOptions, (ctx as ScanOptions).asOf ?? today()).hits,
35
+ possibleSecIds: (o) => sortUnique(inEdition(tableSecIds('osv-scanner'), o.edition, loadControls(o.controlsPath))),
36
+
37
+ execute(o, env) {
38
+ const asOf = o.asOf ?? today();
39
+ const argv = adapter.command(o);
40
+ const r = env.run(argv);
41
+ // 0: no advisories, 1: advisories found, 128: no packages found. Anything else is a failure.
42
+ if (r.status !== 0 && r.status !== 1 && r.status !== 128) throw new Error(`osv-scanner exited with ${r.status ?? r.error}`);
43
+ const out = r.status === 128 ? { hits: [], scanned: [] } : parseOsv(r.stdout, o, asOf);
44
+ return { hits: out.hits, commands: [argv], configSha256: null, scanned: out.scanned, exitCode: r.status, toolErrors: [], dbDate: asOf, asOf, policy: o.policy ?? DEFAULT_POLICY };
45
+ },
46
+ };
47
+
48
+ export function parseOsv(stdout: string, o: ScanOptions, asOf: string): { hits: DetailHit[]; scanned: string[] } {
49
+ const controls = loadControls(o.controlsPath);
50
+ const policy = o.policy ?? DEFAULT_POLICY;
51
+ const j = JSON.parse(stdout) as OsvJson;
52
+ const hits: DetailHit[] = [];
53
+ const scanned = new Set<string>();
54
+ for (const res of j.results ?? []) {
55
+ const file = relPath(res.source.path, o.repo);
56
+ scanned.add(file);
57
+ for (const p of res.packages ?? []) {
58
+ for (const g of p.groups ?? []) {
59
+ const ids = [...g.ids].sort(cmp);
60
+ const id = ids.find((x) => x.startsWith('GHSA-')) ?? ids[0];
61
+ const vulns = (p.vulnerabilities ?? []).filter((v) => ids.includes(v.id));
62
+ const score = Number.parseFloat(g.max_severity ?? '');
63
+ const cls: SeverityClass = score > 0 ? classFromScore(score)
64
+ : vulns.map((v) => classFromWord(v.database_specific?.severity)).reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'UNKNOWN');
65
+ const dates = vulns.map((v) => v.published?.slice(0, 10)).filter((d): d is string => !!d).sort(cmp);
66
+ const published = dates[0] ?? null;
67
+ const fixed = sortUnique(vulns.flatMap((v) => (v.affected ?? [])
68
+ .filter((a) => a.package?.name === p.package.name)
69
+ .flatMap((a) => (a.ranges ?? []).flatMap((rg) => (rg.events ?? []).map((e) => e.fixed).filter((x): x is string => !!x)))));
70
+ const d = decide(cls, published, asOf, policy);
71
+ const version = p.package.version || p.package.commit || '';
72
+ const mapped = mapHit({ tool: 'osv-scanner', rule_id: id, file }, o.edition, controls);
73
+ const fails = d.decision === 'fail';
74
+ const modified = vulns.map((v) => v.modified ?? '').sort(cmp).pop() || 'unknown';
75
+ hits.push({
76
+ tool: 'osv-scanner', rule_id: id, file, line: 0, end_line: 0, sec_ids: fails ? mapped.sec_ids : [],
77
+ severity_hint: cls, message_key: `osv-scanner:${id}`, rule_version: `advisory modified ${modified}`,
78
+ evidence: `${p.package.name}@${version} (${p.package.ecosystem})`,
79
+ mapped_by: fails ? mapped.mapped_by : 'policy report only', unmapped: fails && !mapped.sec_ids.length,
80
+ advisory: {
81
+ id, aliases: sortUnique([...(g.aliases ?? []), ...ids].filter((x) => x !== id)), package: p.package.name,
82
+ ecosystem: p.package.ecosystem, version, severity_class: cls, cvss_score: score > 0 ? score : null,
83
+ fixed_versions: fixed, published, ...d, mapped_sec_ids: mapped.sec_ids,
84
+ },
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return { hits, scanned: [...scanned].sort(cmp) };
90
+ }
@@ -0,0 +1,35 @@
1
+ // Development tool. Runs every adapter twice on each target folder, writing into two
2
+ // temporary output folders (never into the target), and prints per tool: status, version, hits, mapped and unmapped
3
+ // hits, files scanned and tracked, run time of each run, and whether both runs gave the same bytes.
4
+ // Usage: node src/scanners/run-targets.ts [--allow-network] [--history] DIR...
5
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { basename, join, resolve } from 'node:path';
8
+ import { discover } from '../discover/discover.ts';
9
+ import { sha256 } from './common.ts';
10
+ import { runScanners } from './index.ts';
11
+
12
+ const args = process.argv.slice(2);
13
+ const allowNetwork = args.includes('--allow-network');
14
+ const history = args.includes('--history');
15
+ const rows: Record<string, unknown>[] = [];
16
+
17
+ for (const target of args.filter((a) => !a.startsWith('--')).map((a) => resolve(a))) {
18
+ const stack = discover(target);
19
+ const outs = [0, 1].map(() => mkdtempSync(join(tmpdir(), 'hullproof-runs-')));
20
+ const runs = outs.map((out) => runScanners({ repo: target, out, stack, allowNetwork, edition: 'pro', history }));
21
+ for (const [i, s] of runs[0].entries()) {
22
+ const files = ['json', 'meta.json', 'detail.json'].map((ext) => `scan/${s.meta.tool}.${ext}`);
23
+ const same = files.every((f) => sha256(readFileSync(join(outs[0], f))) === sha256(readFileSync(join(outs[1], f))));
24
+ const hits = s.detail.hits;
25
+ rows.push({
26
+ target: basename(target), tool: s.meta.tool, status: s.meta.status, version: s.meta.version, reason: s.detail.not_run_reason,
27
+ hits: s.hits.hits.length, detail_hits: hits.length, mapped: hits.filter((h) => h.sec_ids.length).length,
28
+ unmapped: hits.filter((h) => h.unmapped).length, report_only: hits.filter((h) => h.mapped_by === 'policy report only').length,
29
+ files: s.detail.files, tool_errors: s.detail.tool_errors.length, seconds: [runs[0][i].seconds, runs[1][i].seconds],
30
+ identical: same, sha256: sha256(readFileSync(join(outs[0], files[0]))).slice(0, 12),
31
+ });
32
+ }
33
+ for (const o of outs) rmSync(o, { recursive: true, force: true });
34
+ }
35
+ console.log(JSON.stringify(rows, null, 2));
@@ -0,0 +1,80 @@
1
+ // Secret hits that are almost surely not a live secret: a placeholder or a published example value, or a shapeless rule
2
+ // (a generic key, a curl header, a bare JWT) matching in documentation, fixture data, an example env file, a lockfile,
3
+ // vendored or generated code, or a migration. The hit is kept and reported as an example value; it does not hold the gate.
4
+ //
5
+ // What stays held, on purpose: a rule with a vendor shape (AWS, GitHub, Stripe, a private key block and so on) anywhere
6
+ // except where its value is a placeholder, and every rule in source and config files and in test source files.
7
+
8
+ // Rules with no vendor shape: they match on a variable name or a loose pattern, so a path says more than the value does.
9
+ export const WEAK_RULES = new Set(['generic-api-key', 'curl-auth-header', 'curl-auth-user', 'jwt', 'hullproof-url-with-password']);
10
+
11
+ const FOLDERS = (s: string): Set<string> => new Set(s.split(' '));
12
+ const DOC_FOLDERS = FOLDERS('docs doc documentation api-docs apidocs');
13
+ const DOC_EXT = /\.(?:md|mdx|rst|adoc|txt|html?|ya?ml|json)$/i;
14
+ const DOC_FILE = /\.(?:md|mdx|rst|adoc)$|^(?:readme|changelog|changes|history|contributing|license|notice)(?:\.\w+)?$/i;
15
+ const FIXTURE_FOLDERS = FOLDERS('fixtures fixture __fixtures__ testdata test-data test_data testfixtures __snapshots__ cassettes');
16
+ const TEST_FOLDERS = FOLDERS('test tests __tests__ spec specs e2e cypress');
17
+ const DATA_EXT = /\.(?:json|ya?ml|txt|csv|xml|toml|snap|har|md)$/i;
18
+ const MIGRATION_FOLDERS = FOLDERS('migrations migrate alembic');
19
+ const VENDOR_FOLDERS = FOLDERS('vendor vendors third_party third-party node_modules bower_components site-packages pods plugins webjars');
20
+ const LOCKFILES = /^(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|cargo\.lock|composer\.lock|poetry\.lock|pipfile\.lock|gemfile\.lock|go\.sum|packages\.lock\.json|gradle\.lockfile|mix\.lock|pubspec\.lock|podfile\.lock|flake\.lock)$/i;
21
+ const EXAMPLE_FILE = /(?:^|\/)\.env\.(?:example|sample|template|dist|tpl)$|\.(?:example|sample|template|dist|tpl)$|\.(?:example|sample|template)\.\w+$/i;
22
+ const GENERATED = /(?:\.min\.(?:js|css)|\.pb\.go|_pb2\.py|\.g\.dart|\.generated\.\w+|_generated\.\w+|\.snap)$/i;
23
+
24
+ // Why a repository relative path makes a shapeless rule's hit an example value, or null.
25
+ export function pathNoise(file: string): string | null {
26
+ const parts = file.split('/');
27
+ const name = parts.pop() ?? '';
28
+ const dirs = parts.map((p) => p.toLowerCase());
29
+ const has = (set: Set<string>): string | undefined => parts.find((p) => set.has(p.toLowerCase()));
30
+ if (LOCKFILES.test(name)) return 'lockfile';
31
+ if (DOC_FILE.test(name)) return 'documentation file';
32
+ if (EXAMPLE_FILE.test(file)) return 'example or sample file';
33
+ const doc = has(DOC_FOLDERS);
34
+ if (doc && DOC_EXT.test(name)) return `documentation folder ${doc}/`;
35
+ const fx = has(FIXTURE_FOLDERS);
36
+ if (fx) return `fixture data folder ${fx}/`;
37
+ if (/\.(?:fixtures?)\.\w+$/i.test(name)) return 'fixture file';
38
+ const t = has(TEST_FOLDERS);
39
+ if (t && DATA_EXT.test(name)) return `test data in ${t}/`;
40
+ const mg = has(MIGRATION_FOLDERS) ?? (dirs.includes('db') && dirs.includes('migrate') ? 'migrate' : undefined);
41
+ if (mg) return `migration folder ${mg}/`;
42
+ const vd = has(VENDOR_FOLDERS);
43
+ if (vd) return `vendored code in ${vd}/`;
44
+ if (GENERATED.test(name) || dirs.includes('__generated__')) return 'generated file';
45
+ return null;
46
+ }
47
+
48
+ // Values that are published examples, never a credential.
49
+ const KNOWN_EXAMPLES = [
50
+ 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'ASIAIOSFODNN7EXAMPLE',
51
+ // The jwt.io sample token (John Doe, iat 1516239022).
52
+ 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ',
53
+ ];
54
+ const PLACEHOLDER = /^(?:(.)\1{5,}|(?:0123456789|1234567890|12345678|abcdef|abcdefgh)[\w-]*|.*(?:example|your[-_.]|changeme|change[-_]me|placeholder|dummy|sample|redacted|fake|insert|replace|todo)[\w-]*|x{4,}[\w-]*|\*{4,})$/i;
55
+ const GUID_SEQ = /^(?:12345678-1234-1234-1234-1234567890ab|12345678-1234-1234-1234-123456789abc|00000000-0000-0000-0000-000000000000)$/i;
56
+
57
+ // The values on a line: the text inside quotes (after a "name:" or "name=" prefix if it has one), or else what follows a : or =.
58
+ // A line with no such value is never judged a placeholder, so a short real password beside a variable named EXAMPLE_PASSWORD still holds.
59
+ function valuesOf(line: string): string[] {
60
+ const out: string[] = [];
61
+ for (const m of line.matchAll(/(["'`])((?:(?!\1).){4,})\1/g)) out.push(m[2].replace(/^.*?[:=]\s*(?=\S)/, (pre) => (/^[\w.-]+\s*[:=]\s*$/.test(pre) ? '' : pre)).trim());
62
+ if (!out.length) for (const m of line.matchAll(/[:=]\s*([^\s"'`,;]{4,})/g)) out.push(m[1]);
63
+ return out;
64
+ }
65
+
66
+ // A line holds a placeholder when it has values and every one is placeholder shaped or a known example. The secret itself is
67
+ // redacted by the scanner, so the line is the only place the value can be judged.
68
+ export function valueNoise(line: string): string | null {
69
+ if (KNOWN_EXAMPLES.some((k) => line.includes(k))) return 'published example value';
70
+ const vs = valuesOf(line);
71
+ if (!vs.length) return null;
72
+ return vs.every((v) => PLACEHOLDER.test(v) || GUID_SEQ.test(v) || /^(?:<[\w\s.-]+>|\$\{[^}]+\}|\{\{[^}]+\}\}|\$\w+)$/.test(v)) ? 'placeholder value' : null;
73
+ }
74
+
75
+ // The reason a Gitleaks hit is an example value, or null when it must hold. `line` is the source line, or null when it is not known.
76
+ export function secretNoise(rule: string, file: string, line: string | null): string | null {
77
+ const v = line === null ? null : valueNoise(line);
78
+ if (v) return v;
79
+ return WEAK_RULES.has(rule) ? pathNoise(file) : null;
80
+ }
@@ -0,0 +1,139 @@
1
+ // Semgrep adapter. Runs the Hullproof rules in rules/semgrep/, plus an optional external rule pack folder (for example a rule pack of your own),
2
+ // on the files git tracks, named one by one, with --no-git-ignore so Semgrep itself never calls git and the repository's
3
+ // own ignore files cannot hide a tracked file.
4
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
5
+ import { basename, join } from 'node:path';
6
+ import { cmp, sortUnique } from '../util.ts';
7
+ import { dirSha256, fileSha256, isRegularFile, relPath, sha256, sourceLine } from './common.ts';
8
+ import { inEdition, loadControls } from '../mapping/controls.ts';
9
+ import { mapHit } from '../mapping/map.ts';
10
+ import type { Adapter, DetailHit, ScanOptions, ToolError } from './types.ts';
11
+
12
+ export interface RuleInfo { id: string; file: string; sec_ids: string[]; version: string }
13
+
14
+ const yamlIn = (dir: string): string[] => readdirSync(dir).filter((n) => /\.ya?ml$/.test(n)).sort(cmp);
15
+
16
+ // The rule files Semgrep runs, by file name: the engine's folder, then the pack. A pack file with the same name as an
17
+ // engine file replaces it, so a pack may be a whole kit rules folder (identical files change nothing) or only the
18
+ // files it adds. Throws when the pack folder is missing or holds no rule file.
19
+ export function ruleFiles(dir: string, pack: string | null = null): string[] {
20
+ const byName = new Map<string, string>(yamlIn(dir).map((n) => [n, join(dir, n)]));
21
+ if (pack) {
22
+ if (!existsSync(pack)) throw new Error(`the rule pack folder ${basename(pack)} does not exist`);
23
+ const names = yamlIn(pack);
24
+ if (!names.length) throw new Error(`the rule pack folder ${basename(pack)} holds no .yaml rule file`);
25
+ for (const n of names) byName.set(n, join(pack, n));
26
+ }
27
+ return [...byName.keys()].sort(cmp).map((n) => byName.get(n)!);
28
+ }
29
+
30
+ // Reads rule ids and metadata.sec_ids from the rule files. The kit writes `- id: x` and `sec_ids: [A, B]` on one line
31
+ // each; test/scanners.test.ts checks every rule has both.
32
+ export function readRules(dir: string, pack: string | null = null): RuleInfo[] {
33
+ const out: RuleInfo[] = [];
34
+ for (const path of ruleFiles(dir, pack)) {
35
+ const f = basename(path);
36
+ const version = 'sha256:' + fileSha256(path).slice(0, 16);
37
+ let cur: RuleInfo | null = null;
38
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
39
+ const id = /^\s*-\s+id:\s*([\w.-]+)\s*$/.exec(line)?.[1];
40
+ if (id) { cur = { id, file: f, sec_ids: [], version }; out.push(cur); continue; }
41
+ const ids = /^\s*sec_ids:\s*\[(.*)\]\s*$/.exec(line)?.[1];
42
+ if (ids !== undefined && cur) cur.sec_ids = sortUnique(ids.split(',').map((s) => s.trim()).filter(Boolean));
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const TIMEOUT_SECONDS = '30';
49
+ // Minified or generated files (a line longer than this) make Semgrep time out at random, which breaks byte identical
50
+ // output. They are left out by this fixed rule and listed as tool errors of type "skipped: minified".
51
+ const MAX_LINE = 1000;
52
+ const minified = (p: string): boolean => {
53
+ try { return readFileSync(p, 'utf8').split('\n').some((l) => l.length > MAX_LINE); } catch { return false; }
54
+ };
55
+ const MAX_TARGET_BYTES = '1000000';
56
+
57
+ interface SemgrepJson {
58
+ version: string;
59
+ results: { check_id: string; path: string; start: { line: number }; end: { line: number }; extra: { severity?: string; metadata?: { sec_ids?: string[] } } }[];
60
+ errors: { type?: unknown; path?: string; rule_id?: string }[];
61
+ paths: { scanned: string[] };
62
+ }
63
+
64
+ export const adapter: Adapter = {
65
+ id: 'semgrep',
66
+ tool: 'semgrep',
67
+ versionCommand: ['semgrep', '--version'],
68
+ testedVersions: '1.176.x',
69
+ needsNetwork: false,
70
+ applies: (stack) => stack.languages.length > 0 || stack.ci.length > 0,
71
+ notApplicableReason: (o) => (adapter.applies(o.stack) ? null : 'No source language or CI workflow was found, so there is nothing for the Semgrep rules to read.'),
72
+ configFiles: (ctx) => [join((ctx as ScanOptions).rulesDir ?? '', 'semgrep')],
73
+ command: (ctx) => ['semgrep', 'scan', '--config', join((ctx as ScanOptions).rulesDir ?? '', 'semgrep'), '--json', '--quiet',
74
+ '--metrics=off', '--disable-version-check', '--no-git-ignore', '--timeout', TIMEOUT_SECONDS, '--timeout-threshold', '3',
75
+ '--max-target-bytes', MAX_TARGET_BYTES],
76
+ parse: (stdout, ctx) => parseSemgrep(stdout, ctx as ScanOptions, readRules(join((ctx as ScanOptions).rulesDir ?? '', 'semgrep'), (ctx as ScanOptions).rulePack ?? null)).hits,
77
+ possibleSecIds: (o) => sortUnique(inEdition(readRules(join(o.rulesDir!, 'semgrep'), o.rulePack ?? null).flatMap((r) => r.sec_ids), o.edition, loadControls(o.controlsPath))),
78
+
79
+ execute(o, env) {
80
+ const engineRules = join(env.rulesDir, 'semgrep');
81
+ const pack = o.rulePack ?? null;
82
+ // With a pack, the merged rule files go into one folder in the scratch space, so Semgrep reads one --config folder
83
+ // and the config sha256 covers exactly the rules that ran.
84
+ let rulesDir = engineRules;
85
+ if (pack) {
86
+ const rules = readRules(engineRules, pack);
87
+ const twice = rules.map((r) => r.id).filter((id, i, a) => a.indexOf(id) !== i);
88
+ if (twice.length) throw new Error(`rule id ${twice[0]} is in two rule files once the pack is added`);
89
+ rulesDir = join(env.scratch, 'rules');
90
+ mkdirSync(rulesDir);
91
+ for (const f of ruleFiles(engineRules, pack)) copyFileSync(f, join(rulesDir, basename(f)));
92
+ }
93
+ // ponytail: every tracked file goes on the command line; split into batches if a repository ever passes ARG_MAX.
94
+ const listed = o.targets ? new Set(o.targets) : env.tracked;
95
+ const files = listed ? [...listed].filter((f) => isRegularFile(join(o.repo, f))).sort(cmp) : [];
96
+ const skipped = files.filter((f) => /\.(js|mjs|cjs|ts|jsx|tsx|json|ya?ml|html|py)$/.test(f) && minified(join(o.repo, f)));
97
+ const targets = listed ? files.filter((f) => !skipped.includes(f)) : ['.'];
98
+ const base = adapter.command({ ...o, rulesDir: env.rulesDir } as ScanOptions);
99
+ const shown = [...base.map((a) => (a === join(env.rulesDir, 'semgrep') ? (pack ? '<engine>/rules/semgrep + <rule-pack>' : '<engine>/rules/semgrep') : a)),
100
+ listed ? `<${targets.length} ${o.targets ? 'listed' : 'tracked'} files, list sha256 ${sha256(targets.join('\n')).slice(0, 16)}>` : '.'];
101
+ // No file to read: Semgrep with no target would read the whole folder, so it is not called.
102
+ if (listed && targets.length === 0) return { hits: [], toolErrors: [], commands: [shown], configSha256: dirSha256(rulesDir), scanned: [], exitCode: 0 };
103
+ const r = env.run([...base.map((a) => (a === join(env.rulesDir, 'semgrep') ? rulesDir : a)), ...targets]);
104
+ let parsed: SemgrepJson;
105
+ try { parsed = JSON.parse(r.stdout) as SemgrepJson; } catch { throw new Error(`semgrep printed no JSON (exit ${r.status})`); }
106
+ const out = parseSemgrep(r.stdout, o, readRules(rulesDir));
107
+ return {
108
+ hits: out.hits, toolErrors: [...out.errors, ...skipped.map((file) => ({ type: 'skipped: minified', file, rule_id: '' }))], commands: [shown], configSha256: dirSha256(rulesDir),
109
+ scanned: sortUnique(parsed.paths.scanned.map((p) => relPath(p, o.repo))), exitCode: r.status,
110
+ };
111
+ },
112
+ };
113
+
114
+ export function parseSemgrep(stdout: string, o: ScanOptions, rules: RuleInfo[]): { hits: DetailHit[]; errors: ToolError[] } {
115
+ const j = JSON.parse(stdout) as SemgrepJson;
116
+ const controls = loadControls(o.controlsPath);
117
+ // check_id is prefixed with the rules folder written as a dotted path; the rule id is the known id it ends with.
118
+ const ruleOf = (checkId: string): RuleInfo | undefined =>
119
+ rules.find((r) => checkId === r.id || checkId.endsWith('.' + r.id));
120
+ const hits = j.results.map((x): DetailHit => {
121
+ const rule = ruleOf(x.check_id);
122
+ const rule_id = rule?.id ?? x.check_id.split('.').pop()!;
123
+ const file = relPath(x.path, o.repo);
124
+ const meta = x.extra.metadata?.sec_ids ?? rule?.sec_ids ?? [];
125
+ const m = mapHit({ tool: 'semgrep', rule_id, file, metadataSecIds: meta }, o.edition, controls);
126
+ const secretRule = meta.some((id) => id.startsWith('SEC-SECRETS-'));
127
+ return {
128
+ tool: 'semgrep', rule_id, file, line: x.start.line, end_line: x.end.line, sec_ids: m.sec_ids,
129
+ severity_hint: x.extra.severity ?? null, message_key: `semgrep:${rule_id}`, rule_version: rule?.version ?? 'unknown',
130
+ evidence: secretRule ? null : sourceLine(o.repo, file, x.start.line), mapped_by: m.mapped_by, unmapped: m.sec_ids.length === 0,
131
+ };
132
+ });
133
+ const errors = j.errors.map((e): ToolError => ({
134
+ type: typeof e.type === 'string' ? e.type : Array.isArray(e.type) ? String(e.type[0]) : 'unknown',
135
+ file: e.path ? relPath(e.path, o.repo) : '',
136
+ rule_id: e.rule_id ? (ruleOf(e.rule_id)?.id ?? e.rule_id.split('.').pop()!) : '',
137
+ }));
138
+ return { hits, errors };
139
+ }
@@ -0,0 +1,66 @@
1
+ // Trivy adapter: `trivy config` only, so misconfiguration checks on IaC, Kubernetes, Helm and Dockerfiles. No image is
2
+ // pulled. The checks are the ones built into the Trivy binary (--skip-check-update, an empty cache folder), so the rule
3
+ // set is fixed by the Trivy version, and network is blocked unless allowed (Terraform remote modules then do not load).
4
+ import { join } from 'node:path';
5
+ import { cmp, sortUnique } from '../util.ts';
6
+ import { isRegularFile, relPath, sourceLine } from './common.ts';
7
+ import { inEdition, loadControls } from '../mapping/controls.ts';
8
+ import { mapHit, tableSecIds } from '../mapping/map.ts';
9
+ import type { Adapter, DetailHit, ScanOptions } from './types.ts';
10
+
11
+ // Pinned so a Trivy update cannot add a scanner silently. The list is Trivy 0.75.0's default.
12
+ const MISCONFIG_SCANNERS = 'azure-arm,cloudformation,dockerfile,helm,kubernetes,terraform,terraformplan-json,terraformplan-snapshot,ansible';
13
+
14
+ interface Misconf { ID: string; Severity: string; Status: string; CauseMetadata?: { StartLine?: number; EndLine?: number } }
15
+ interface TrivyJson { Results?: { Target: string; Class: string; Misconfigurations?: Misconf[] }[] }
16
+
17
+ export const adapter: Adapter = {
18
+ id: 'trivy',
19
+ tool: 'trivy',
20
+ versionCommand: ['trivy', '--version'],
21
+ testedVersions: '0.75.x',
22
+ needsNetwork: false,
23
+ applies: (stack) => stack.iac.length > 0 || stack.containers.length > 0,
24
+ notApplicableReason: (o) => (adapter.applies(o.stack) ? null : 'No infrastructure as code, Kubernetes, Helm or container file was found, so Trivy has nothing to check.'),
25
+ configFiles: () => [],
26
+ command: () => ['trivy', 'config', '--format', 'json', '--quiet', '--skip-check-update', '--skip-version-check',
27
+ '--disable-telemetry', '--include-non-failures', '--misconfig-scanners', MISCONFIG_SCANNERS, '--cache-dir', '<empty-dir>', '.'],
28
+ parse: (stdout, ctx) => parseTrivy(stdout, ctx as ScanOptions, 'unknown').hits,
29
+ possibleSecIds: (o) => sortUnique(inEdition(tableSecIds('trivy'), o.edition, loadControls(o.controlsPath))),
30
+
31
+ execute(o, env) {
32
+ const argv = adapter.command(o).map((a) => (a === '<empty-dir>' ? env.scratch : a));
33
+ const shown = adapter.command(o);
34
+ const r = env.run(argv);
35
+ if (r.status !== 0) throw new Error(`trivy exited with ${r.status ?? r.error}`);
36
+ const out = parseTrivy(r.stdout, o, `trivy ${env.version} built in checks`, env.tracked);
37
+ return { hits: out.hits, commands: [shown], configSha256: null, scanned: out.scanned, exitCode: r.status, toolErrors: [] };
38
+ },
39
+ };
40
+
41
+ // Terraform results come per module folder as well as per file; a folder target stands for the tracked .tf files in it.
42
+ export function parseTrivy(stdout: string, o: ScanOptions, ruleVersion: string, tracked: Set<string> | null = null): { hits: DetailHit[]; scanned: string[] } {
43
+ const controls = loadControls(o.controlsPath);
44
+ const j = JSON.parse(stdout) as TrivyJson;
45
+ const hits: DetailHit[] = [];
46
+ const scanned = new Set<string>();
47
+ for (const res of j.Results ?? []) {
48
+ if (res.Class !== 'config') continue;
49
+ const target = relPath(res.Target, o.repo);
50
+ if (tracked && !isRegularFile(join(o.repo, target))) {
51
+ const prefix = target === '.' ? '' : target + '/';
52
+ for (const f of tracked) if (f.startsWith(prefix) && !f.slice(prefix.length).includes('/') && /\.tf(\.json)?$/.test(f)) scanned.add(f);
53
+ } else scanned.add(target);
54
+ for (const m of res.Misconfigurations ?? []) {
55
+ if (m.Status !== 'FAIL') continue;
56
+ const line = m.CauseMetadata?.StartLine ?? 0;
57
+ const mapped = mapHit({ tool: 'trivy', rule_id: m.ID, file: target }, o.edition, controls);
58
+ hits.push({
59
+ tool: 'trivy', rule_id: m.ID, file: target, line, end_line: m.CauseMetadata?.EndLine ?? line, sec_ids: mapped.sec_ids,
60
+ severity_hint: m.Severity ?? null, message_key: `trivy:${m.ID}`, rule_version: ruleVersion,
61
+ evidence: sourceLine(o.repo, target, line), mapped_by: mapped.mapped_by, unmapped: !mapped.sec_ids.length,
62
+ });
63
+ }
64
+ }
65
+ return { hits, scanned: [...scanned].sort(cmp) };
66
+ }
@@ -0,0 +1,98 @@
1
+ // Types local to the scanner adapters. The contract types (Hit, HitsFile, ToolMeta, RunContext, ScannerAdapter) live in
2
+ // src/types/scan.ts and are not changed here. Fields the contract has no room for yet (rule version, evidence, advisory
3
+ // data, the reason a tool did not run) go into scan/<tool>.detail.json, described by DetailFile below.
4
+ import type { Hit, RunContext, ScannerAdapter, ScannerId, ToolMeta } from '../types/index.ts';
5
+ import type { DependencyPolicy, SeverityClass } from '../mapping/dependency-policy.ts';
6
+
7
+ export interface ScanOptions extends RunContext {
8
+ rulesDir?: string; // default: <engine>/rules
9
+ rulePack?: string | null; // optional external Semgrep rule pack folder (for example a rule pack of your own); see src/scanners/semgrep.ts
10
+ controlsPath?: string; // default: see src/mapping/controls.ts
11
+ asOf?: string; // YYYY-MM-DD, the date OSV advisories are judged against. Default: today (UTC).
12
+ policy?: DependencyPolicy;
13
+ env?: NodeJS.ProcessEnv; // base environment (tests use it to hide a tool from PATH)
14
+ skipFiles?: ReadonlySet<string>; // unchanged kit files in the project (src/discover/kit-files.ts): hits in them are dropped
15
+ targets?: string[]; // Semgrep only: read just these repository relative files (the pre-commit hook passes the staged files)
16
+ }
17
+
18
+ export interface SecretDetail {
19
+ redacted: string; // the match as Gitleaks printed it with --redact, masked again by us
20
+ fingerprint: string; // Gitleaks fingerprint: [commit:]file:rule:line. Holds no secret.
21
+ source: 'dir' | 'git';
22
+ commit: string | null;
23
+ tracked: boolean; // false: the file is on disk but not tracked by git
24
+ }
25
+
26
+ export interface AdvisoryDetail {
27
+ id: string;
28
+ aliases: string[];
29
+ package: string;
30
+ ecosystem: string;
31
+ version: string;
32
+ severity_class: SeverityClass;
33
+ cvss_score: number | null;
34
+ fixed_versions: string[];
35
+ published: string | null; // YYYY-MM-DD
36
+ deadline_days: number;
37
+ past_deadline: boolean;
38
+ decision: 'fail' | 'report';
39
+ mapped_sec_ids: string[]; // what the hit maps to when it fails; sec_ids is [] when the decision is report
40
+ }
41
+
42
+ export interface DetailHit extends Hit {
43
+ rule_version: string;
44
+ end_line: number;
45
+ evidence: string | null; // short, masked; null when it could expose a secret
46
+ mapped_by: 'rule metadata' | 'controls tests' | 'map rule_id' | 'map default' | 'policy report only' | 'none';
47
+ unmapped: boolean;
48
+ secret?: SecretDetail;
49
+ advisory?: AdvisoryDetail;
50
+ }
51
+
52
+ export interface ToolError { type: string; file: string; rule_id: string }
53
+
54
+ export interface DetailFile {
55
+ schema_version: '0.1.0';
56
+ tool: ScannerId;
57
+ status: ToolMeta['status'];
58
+ not_run_reason: string | null;
59
+ tool_version: string | null;
60
+ commands: string[][]; // every command run, absolute paths replaced by <placeholders>
61
+ files: { scanned: number | null; tracked: number | null; scanned_not_tracked: number | null; tracked_not_scanned: number | null };
62
+ as_of: string | null; // OSV only
63
+ policy: DependencyPolicy | null; // OSV only
64
+ sec_ids_not_in_controls: string[];
65
+ tool_errors: ToolError[];
66
+ hits: DetailHit[];
67
+ }
68
+
69
+ export interface Exec { argv: string[]; stdout: string; stderr: string; status: number | null; error?: string }
70
+
71
+ // What an adapter needs on top of the shared ScannerAdapter interface.
72
+ export interface Adapter extends ScannerAdapter {
73
+ tool: string; // executable name
74
+ // Runs the tool (one or more commands) and returns detail hits. Throws on a tool failure.
75
+ execute(opts: ScanOptions, env: Env): AdapterResult;
76
+ possibleSecIds(opts: ScanOptions): string[];
77
+ notApplicableReason(opts: ScanOptions): string | null;
78
+ }
79
+
80
+ export interface Env {
81
+ version: string;
82
+ tracked: Set<string> | null;
83
+ rulesDir: string;
84
+ scratch: string; // empty temp folder, removed after the run
85
+ run(argv: string[]): Exec;
86
+ }
87
+
88
+ export interface AdapterResult {
89
+ hits: DetailHit[];
90
+ commands: string[][];
91
+ configSha256: string | null;
92
+ scanned: string[] | null; // repository relative files the tool read, when it says
93
+ exitCode: number | null;
94
+ toolErrors: ToolError[];
95
+ dbDate?: string | null;
96
+ asOf?: string | null;
97
+ policy?: DependencyPolicy | null;
98
+ }