@nifrajs/web 3.5.0 → 4.0.2

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 (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
@@ -0,0 +1,638 @@
1
+ /**
2
+ * Secret detection for everything a client build publishes: a heuristic net under the zone rules,
3
+ * not a guarantee. The zone rules keep backend code out of the browser; this catches a credential
4
+ * typed straight into browser code, a library carrying one, and a server-only environment value that
5
+ * reached the output some other way (a `define`, a prerendered page, a copied `public/` file).
6
+ *
7
+ * - Source rules run on the browser build's own modules before minification, while names still exist.
8
+ * - Format rules and private-value matching also run on every emitted file, every `public/` file and
9
+ * every prerendered page and `_data.json`, source maps included.
10
+ *
11
+ * A finding never prints the value it found: only the rule, the location and a redacted preview.
12
+ */
13
+ import { readdirSync, readFileSync, statSync } from "node:fs"
14
+ import { basename, isAbsolute, join, relative } from "node:path"
15
+ import type { ClientModuleGraph } from "../module-graph.ts"
16
+ import type { ZoneClassifier } from "../zones.ts"
17
+ import { codeUnitOrder } from "./code-unit-order.ts"
18
+ import { isSensitiveFieldName } from "./output-guard.ts"
19
+ import type { ModuleSource } from "./zone-graph.ts"
20
+
21
+ export type SecretRule =
22
+ /** A PEM private key block. */
23
+ | "private-key"
24
+ /** A URL carrying a password: `postgres://user:password@host`. */
25
+ | "credential-url"
26
+ /** A known secret token format: AWS, Stripe secret keys, GitHub, Slack, OpenAI, Anthropic, ... */
27
+ | "key-format"
28
+ /** A high-entropy string literal assigned to a secret-like name. Source files only. */
29
+ | "assigned-secret"
30
+ /** The value of a non-public build environment variable, raw or URL, base64, JSON or HTML encoded. */
31
+ | "private-env-value"
32
+
33
+ const RULES: ReadonlySet<string> = new Set<SecretRule>([
34
+ "private-key",
35
+ "credential-url",
36
+ "key-format",
37
+ "assigned-secret",
38
+ "private-env-value",
39
+ ])
40
+
41
+ /**
42
+ * One reviewed false positive. It names the rule, where the finding is and why it may ship: there is
43
+ * no exemption by value, so a credential that turns up somewhere else still fails the build.
44
+ */
45
+ export interface SecretExemption {
46
+ readonly rule: SecretRule
47
+ /** The finding's file as reported: app-relative for a source or `public/` file, output-relative for
48
+ * an emitted file. */
49
+ readonly file?: string
50
+ /** For `private-env-value`: the variable whose value may ship. */
51
+ readonly env?: string
52
+ readonly reason: string
53
+ }
54
+
55
+ export interface SecretFinding {
56
+ readonly rule: SecretRule
57
+ /** The source module the match came from when it is known, else the published file. */
58
+ readonly file: string
59
+ readonly line: number
60
+ /** The published file the match is in, when {@link file} names the module it came from. */
61
+ readonly via?: string
62
+ /** What matched, never the value: `"AWS access key id"`, `"value of DATABASE_URL (base64)"`. */
63
+ readonly what: string
64
+ /** The first characters and the length, enough to recognize a key without publishing it. */
65
+ readonly preview: string
66
+ }
67
+
68
+ /** A module bundled into a published file. */
69
+ export interface SecretOrigin {
70
+ /** As a finding names it: app-relative, or `node_modules/<package>/...`. */
71
+ readonly name: string
72
+ readonly path: string
73
+ }
74
+
75
+ export interface SecretScanFile {
76
+ /** Where the file is, as a finding should name it. */
77
+ readonly name: string
78
+ readonly text: string
79
+ /** A first-party source file: the assigned-secret rule applies. */
80
+ readonly firstParty?: boolean
81
+ /** For an emitted bundle: the modules in it, so a finding names the one a match came from (a
82
+ * content-hashed file name is no place for an exemption). */
83
+ readonly origins?: readonly SecretOrigin[]
84
+ }
85
+
86
+ export interface SecretScanInput {
87
+ /** Source modules the browser build loaded, scanned before minification. */
88
+ readonly sources?: readonly SecretScanFile[]
89
+ /** Files that will be served: emitted bundles, maps, `public/` copies, prerendered pages. */
90
+ readonly artifacts?: readonly SecretScanFile[]
91
+ /** The build environment whose non-public values must not ship (default: none). */
92
+ readonly env?: Readonly<Record<string, string | undefined>>
93
+ /** The public-env prefix (default `"PUBLIC_"`); prefixed variables are public by declaration. */
94
+ readonly publicEnvPrefix?: string
95
+ readonly exemptions?: readonly SecretExemption[]
96
+ }
97
+
98
+ interface Match {
99
+ readonly rule: SecretRule
100
+ readonly index: number
101
+ readonly text: string
102
+ readonly what: string
103
+ /** The environment variable a private-env-value match came from. */
104
+ readonly env?: string
105
+ }
106
+
107
+ interface FormatRule {
108
+ readonly what: string
109
+ readonly pattern: RegExp
110
+ }
111
+
112
+ /** High-precision token formats. Publishable keys (Stripe `pk_`, Google `AIza`, Supabase anon JWTs)
113
+ * are public by design and deliberately absent. */
114
+ const KEY_FORMATS: readonly FormatRule[] = [
115
+ { what: "AWS access key id", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
116
+ { what: "Stripe secret key", pattern: /\b[sr]k_(?:live|test)_[0-9A-Za-z]{16,}/g },
117
+ { what: "Stripe webhook secret", pattern: /\bwhsec_[0-9A-Za-z+/=]{24,}/g },
118
+ {
119
+ what: "GitHub token",
120
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})/g,
121
+ },
122
+ { what: "Slack token", pattern: /\bxox[abeoprs]-[A-Za-z0-9-]{10,}/g },
123
+ {
124
+ what: "Slack webhook URL",
125
+ pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{16,}/g,
126
+ },
127
+ {
128
+ what: "OpenAI API key",
129
+ pattern:
130
+ /\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})/g,
131
+ },
132
+ { what: "Anthropic API key", pattern: /\bsk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{80,}/g },
133
+ { what: "SendGrid API key", pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
134
+ { what: "Twilio API key", pattern: /\bSK[0-9a-f]{32}\b/g },
135
+ { what: "Mailgun API key", pattern: /\bkey-[0-9a-f]{32}\b/g },
136
+ { what: "npm token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
137
+ ]
138
+
139
+ /** A PEM block with a body: a parser's bare `-----BEGIN PRIVATE KEY-----` constant is not a key. */
140
+ const PRIVATE_KEY =
141
+ /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[A-Za-z0-9+/=\s\\]{64,}?-----END/g
142
+
143
+ const CREDENTIAL_URL =
144
+ /\b[a-z][a-z0-9+.-]{1,20}:\/\/([^\s/?#@:"'`<>\\]{1,64}):([^\s/?#@"'`<>\\]{3,128})@[^\s/?#"'`<>\\]+/gi
145
+
146
+ const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.(eyJ[A-Za-z0-9_-]{8,})\.[A-Za-z0-9_-]{16,}/g
147
+
148
+ const PLACEHOLDER =
149
+ /^(?:user(?:name)?|pass(?:word)?|pwd|secret|admin|root|guest|test|demo|example|changeme|change-me|xxx+|\*+|\.\.\.+|my-?password|your-?password|<[^>]*>|\$\{[^}]*\}|\{\{[^}]*\}\}|%[A-Za-z_]+%?)$/i
150
+
151
+ /** A literal assigned to a name: `apiKey: "..."`, `const secret = '...'`. Template literals with an
152
+ * interpolation are not literals. The lookbehind starts a name only where an identifier starts: a
153
+ * match from inside a long word run backtracks the rest of that run, quadratic in its length. */
154
+ const ASSIGNED =
155
+ /(?<![\w$])([A-Za-z_$][\w$]*)(["']?)\s*([:=]|\?\?=|\|\|=)\s*(?:"([^"\\\r\n]{16,256})"|'([^'\\\r\n]{16,256})'|`([^`\\$\r\n]{16,256})`)/g
156
+
157
+ /** Whether a match's name is what the literal is assigned to. A quoted name must be the whole string,
158
+ * not the tail of one (`? "new-password" : "current-password"`), and a key is never preceded by a
159
+ * ternary's `?` or a member access's `.` (`ready ? config.apiKey : "..."`). */
160
+ function namesTheTarget(text: string, start: number, quote: string, operator: string): boolean {
161
+ let before = start - 1
162
+ if (quote !== "") {
163
+ if (text[before] !== quote) return false
164
+ before--
165
+ }
166
+ if (operator !== ":") return true
167
+ while (before >= 0 && /\s/.test(text[before] ?? "")) before--
168
+ return text[before] !== "?" && text[before] !== "."
169
+ }
170
+
171
+ const EXTRA_SECRET_NAME =
172
+ /(?:accesskey|signingkey|encryptionkey|masterkey|serviceaccountkey|connectionstring)$/
173
+
174
+ /** Values that look like keys but are public by design. */
175
+ const PUBLIC_VALUE =
176
+ /^(?:AIza[0-9A-Za-z_-]{35}|pk_(?:live|test)_[0-9A-Za-z]+|6L[0-9A-Za-z_-]{38}|G-[A-Z0-9]{6,}|UA-\d+-\d+)$/
177
+
178
+ /** Shannon entropy in bits per character. */
179
+ function entropy(value: string): number {
180
+ const counts = new Map<string, number>()
181
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
182
+ let bits = 0
183
+ for (const count of counts.values()) {
184
+ const p = count / value.length
185
+ bits -= p * Math.log2(p)
186
+ }
187
+ return bits
188
+ }
189
+
190
+ /** Random-looking: long, unbroken, mixed character classes, high entropy. Excludes paths and URLs. */
191
+ function looksRandom(value: string, minLength: number): boolean {
192
+ if (value.length < minLength || /\s/.test(value)) return false
193
+ if (/^(?:[/~.]|[A-Za-z]:[\\/])/.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value))
194
+ return false
195
+ const classes =
196
+ Number(/[a-z]/.test(value)) +
197
+ Number(/[A-Z]/.test(value)) +
198
+ Number(/[0-9]/.test(value)) +
199
+ Number(/[^A-Za-z0-9]/.test(value))
200
+ return classes >= 2 && entropy(value) >= 3.5
201
+ }
202
+
203
+ function jwtRole(payload: string): unknown {
204
+ try {
205
+ const json = atob(payload.replaceAll("-", "+").replaceAll("_", "/"))
206
+ return (JSON.parse(json) as { role?: unknown }).role
207
+ } catch {
208
+ return undefined
209
+ }
210
+ }
211
+
212
+ function formatMatches(text: string): Match[] {
213
+ const matches: Match[] = []
214
+ for (const { what, pattern } of KEY_FORMATS) {
215
+ for (const found of text.matchAll(pattern)) {
216
+ matches.push({ rule: "key-format", index: found.index, text: found[0], what })
217
+ }
218
+ }
219
+ for (const found of text.matchAll(JWT)) {
220
+ // A Supabase-style service key: a JWT whose role bypasses row-level security.
221
+ if (jwtRole(found[1] ?? "") === "service_role") {
222
+ matches.push({
223
+ rule: "key-format",
224
+ index: found.index,
225
+ text: found[0],
226
+ what: "service-role JWT",
227
+ })
228
+ }
229
+ }
230
+ for (const found of text.matchAll(PRIVATE_KEY)) {
231
+ matches.push({ rule: "private-key", index: found.index, text: found[0], what: "private key" })
232
+ }
233
+ for (const found of text.matchAll(CREDENTIAL_URL)) {
234
+ const user = found[1] ?? ""
235
+ const password = found[2] ?? ""
236
+ // @nifra-gate-reviewed: a password equal to its user name is a placeholder in the text being
237
+ // scanned; nothing is authenticated here.
238
+ if (PLACEHOLDER.test(password) || password === user || /[${}<>*]/.test(password)) continue
239
+ matches.push({
240
+ rule: "credential-url",
241
+ index: found.index,
242
+ text: found[0],
243
+ what: "URL with a password",
244
+ })
245
+ }
246
+ return matches
247
+ }
248
+
249
+ function assignedMatches(text: string): Match[] {
250
+ const matches: Match[] = []
251
+ for (const found of text.matchAll(ASSIGNED)) {
252
+ const name = found[1] ?? ""
253
+ if (!isSensitiveFieldName(name) && !EXTRA_SECRET_NAME.test(name.toLowerCase())) continue
254
+ if (!namesTheTarget(text, found.index, found[2] ?? "", found[3] ?? "")) continue
255
+ const value = found[4] ?? found[5] ?? found[6] ?? ""
256
+ if (PUBLIC_VALUE.test(value) || PLACEHOLDER.test(value) || !looksRandom(value, 16)) continue
257
+ matches.push({
258
+ rule: "assigned-secret",
259
+ index: found.index + found[0].indexOf(value),
260
+ text: value,
261
+ what: `literal assigned to "${name}"`,
262
+ })
263
+ }
264
+ return matches
265
+ }
266
+
267
+ // Environment variables a shell, an OS or a CI system sets: none is an app secret unless its name
268
+ // says so (`GITHUB_TOKEN`, `VERCEL_OIDC_TOKEN`).
269
+ const AMBIENT_ENV =
270
+ /^(?:PATH|HOME|PWD|OLDPWD|SHELL|USER|LOGNAME|TMPDIR|TMP|TEMP|LANG|LANGUAGE|EDITOR|VISUAL|PAGER|HOSTNAME|SHLVL|DISPLAY|MANPATH|INFOPATH|COLORTERM|COMMAND_MODE|_|(?:LC|NPM|npm|BUN|NODE|XPC|TERM|__CF|HOMEBREW|SSH|GPG|ITERM|VSCODE|JAVA|ANDROID|CONDA|PYENV|NVM|ZSH|GITHUB|RUNNER|CI|VERCEL|CF_PAGES|NETLIFY|RENDER|RAILWAY|FLY|CLAUDE|CLAUDECODE|GIT|DENO|PNPM|YARN)(?:_.*)?)$/
271
+
272
+ const SECRET_ENV_TOKENS: ReadonlySet<string> = new Set([
273
+ "SECRET",
274
+ "SECRETS",
275
+ "TOKEN",
276
+ "PASSWORD",
277
+ "PASSWD",
278
+ "PASS",
279
+ "PRIVATE",
280
+ "CREDENTIAL",
281
+ "CREDENTIALS",
282
+ "APIKEY",
283
+ "KEY",
284
+ "DSN",
285
+ "SALT",
286
+ "PEPPER",
287
+ "WEBHOOK",
288
+ ])
289
+ const CONNECTION_ENV =
290
+ /(?:DATABASE|DB|REDIS|MONGO(?:DB)?|POSTGRES(?:QL)?|PG|MYSQL|AMQP|RABBITMQ)_?(?:URL|URI)$|CONNECTION_?STRING/
291
+
292
+ const secretEnvName = (name: string): boolean =>
293
+ CONNECTION_ENV.test(name.toUpperCase()) ||
294
+ name
295
+ .toUpperCase()
296
+ .split(/[^A-Z0-9]+/)
297
+ .some((token) => SECRET_ENV_TOKENS.has(token))
298
+
299
+ function base64Variants(value: string): string[] {
300
+ const bytes = new TextEncoder().encode(value)
301
+ const variants: string[] = []
302
+ // A value inside a larger encoded blob lands at one of three byte alignments. Keep only the
303
+ // characters the value's own bytes determine: none that mix in a neighbour or the padding.
304
+ for (const shift of [0, 1, 2]) {
305
+ const shifted = new Uint8Array(shift + bytes.length)
306
+ shifted.set(bytes, shift)
307
+ let binary = ""
308
+ for (const byte of shifted) binary += String.fromCharCode(byte)
309
+ const encoded = btoa(binary)
310
+ const core = encoded.slice(shift === 0 ? 0 : shift + 1, Math.floor(shifted.length / 3) * 4)
311
+ if (core.length >= 8) {
312
+ variants.push(core)
313
+ const url = core.replaceAll("+", "-").replaceAll("/", "_")
314
+ if (url !== core) variants.push(url)
315
+ }
316
+ }
317
+ return variants
318
+ }
319
+
320
+ interface EnvNeedle {
321
+ readonly env: string
322
+ readonly form: string
323
+ readonly needle: string
324
+ }
325
+
326
+ /** The non-public values a build must not publish, in every encoding a page or bundle carries. */
327
+ function envNeedles(
328
+ env: Readonly<Record<string, string | undefined>>,
329
+ prefix: string,
330
+ ): EnvNeedle[] {
331
+ const publicValues = new Set<string>()
332
+ if (prefix !== "") {
333
+ for (const [name, value] of Object.entries(env)) {
334
+ if (name.startsWith(prefix) && value !== undefined) publicValues.add(value)
335
+ }
336
+ }
337
+ const needles: EnvNeedle[] = []
338
+ for (const [name, value] of Object.entries(env)) {
339
+ if (value === undefined || value.length < 8 || (prefix !== "" && name.startsWith(prefix))) {
340
+ continue
341
+ }
342
+ // The same value published under a public name is public by declaration.
343
+ if (publicValues.has(value)) continue
344
+ const named = secretEnvName(name)
345
+ if (!named && AMBIENT_ENV.test(name)) continue
346
+ const credentialUrl = /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]+:[^/@\s]+@/i.test(value)
347
+ if (!named && !credentialUrl && !looksRandom(value, 20)) continue
348
+ const forms: [string, string][] = [["raw", value]]
349
+ const add = (form: string, encoded: string): void => {
350
+ if (encoded !== value && !forms.some(([, existing]) => existing === encoded)) {
351
+ forms.push([form, encoded])
352
+ }
353
+ }
354
+ add("URL-encoded", encodeURIComponent(value))
355
+ add("JSON-escaped", JSON.stringify(value).slice(1, -1))
356
+ add(
357
+ "HTML-escaped",
358
+ value
359
+ .replaceAll("&", "&amp;")
360
+ .replaceAll("<", "&lt;")
361
+ .replaceAll(">", "&gt;")
362
+ .replaceAll('"', "&quot;"),
363
+ )
364
+ for (const encoded of base64Variants(value)) add("base64", encoded)
365
+ for (const [form, needle] of forms) needles.push({ env: name, form, needle })
366
+ }
367
+ return needles
368
+ }
369
+
370
+ function envMatches(text: string, needles: readonly EnvNeedle[]): Match[] {
371
+ const matches: Match[] = []
372
+ for (const { env, form, needle } of needles) {
373
+ const index = text.indexOf(needle)
374
+ if (index === -1) continue
375
+ matches.push({
376
+ rule: "private-env-value",
377
+ index,
378
+ text: needle,
379
+ what: `value of ${env}${form === "raw" ? "" : ` (${form})`}`,
380
+ env,
381
+ })
382
+ }
383
+ return matches
384
+ }
385
+
386
+ const lineOf = (text: string, index: number): number => {
387
+ let line = 1
388
+ for (let i = text.indexOf("\n"); i !== -1 && i < index; i = text.indexOf("\n", i + 1)) line++
389
+ return line
390
+ }
391
+
392
+ const preview = (match: Match): string =>
393
+ match.rule === "private-env-value"
394
+ ? `${match.text.length} chars`
395
+ : `${match.text.slice(0, 4)}... (${match.text.length} chars)`
396
+
397
+ /** Refuse an exemption that does not name its rule, its location and its reason. */
398
+ export function assertSecretExemptions(exemptions: readonly SecretExemption[]): void {
399
+ exemptions.forEach((exemption, i) => {
400
+ const where = `secretExemptions[${i}]`
401
+ if (!RULES.has(exemption.rule)) {
402
+ throw new Error(
403
+ `[nifra/web] ${where}.rule must be one of ${[...RULES].join(", ")}; got ${JSON.stringify(exemption.rule)}`,
404
+ )
405
+ }
406
+ if (typeof exemption.reason !== "string" || exemption.reason.trim() === "") {
407
+ throw new Error(`[nifra/web] ${where} needs a reason: why this may reach the browser`)
408
+ }
409
+ const file = typeof exemption.file === "string" && exemption.file !== ""
410
+ const env = typeof exemption.env === "string" && exemption.env !== ""
411
+ if (!file && !(env && exemption.rule === "private-env-value")) {
412
+ throw new Error(
413
+ `[nifra/web] ${where} must name a file${exemption.rule === "private-env-value" ? " or an env variable" : ""}; an exemption covers one location, never a value everywhere`,
414
+ )
415
+ }
416
+ })
417
+ }
418
+
419
+ /**
420
+ * Scan what a build publishes. Sources are scanned first: a match there is reported at its source
421
+ * location, and the same text in an emitted file (where names are hashed) is not reported again,
422
+ * exempted or not.
423
+ */
424
+ export function scanForSecrets(input: SecretScanInput): SecretFinding[] {
425
+ const exemptions = input.exemptions ?? []
426
+ assertSecretExemptions(exemptions)
427
+ const needles = envNeedles(input.env ?? {}, input.publicEnvPrefix ?? "PUBLIC_")
428
+ const seen = new Set<string>()
429
+ const findings: SecretFinding[] = []
430
+ const exempt = (file: string, match: Match): boolean =>
431
+ exemptions.some(
432
+ (exemption) =>
433
+ exemption.rule === match.rule &&
434
+ (exemption.file === file || (match.env !== undefined && exemption.env === match.env)),
435
+ )
436
+ const originTexts = new Map<string, string | undefined>()
437
+ const originText = (origin: SecretOrigin): string | undefined => {
438
+ if (!originTexts.has(origin.path)) {
439
+ let text: string | undefined
440
+ try {
441
+ text = readFileSync(origin.path, "utf8")
442
+ } catch {
443
+ text = undefined
444
+ }
445
+ originTexts.set(origin.path, text)
446
+ }
447
+ return originTexts.get(origin.path)
448
+ }
449
+ const scan = (file: SecretScanFile, isSource: boolean): void => {
450
+ const matches = [
451
+ ...formatMatches(file.text),
452
+ ...(isSource && file.firstParty === true ? assignedMatches(file.text) : []),
453
+ ...envMatches(file.text, needles),
454
+ ]
455
+ for (const match of matches) {
456
+ const key = `${match.rule}\0${match.text}`
457
+ if (!isSource && seen.has(key)) continue
458
+ seen.add(key)
459
+ let at = { name: file.name, text: file.text, index: match.index }
460
+ for (const origin of file.origins ?? []) {
461
+ const text = originText(origin)
462
+ const index = text?.indexOf(match.text) ?? -1
463
+ if (text !== undefined && index !== -1) {
464
+ at = { name: origin.name, text, index }
465
+ break
466
+ }
467
+ }
468
+ if (exempt(at.name, match) || (at.name !== file.name && exempt(file.name, match))) continue
469
+ findings.push({
470
+ rule: match.rule,
471
+ file: at.name,
472
+ line: lineOf(at.text, at.index),
473
+ ...(at.name === file.name ? {} : { via: file.name }),
474
+ what: match.what,
475
+ preview: preview(match),
476
+ })
477
+ }
478
+ }
479
+ for (const file of input.sources ?? []) scan(file, true)
480
+ for (const file of input.artifacts ?? []) scan(file, false)
481
+ return findings.sort(
482
+ (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line || codeUnitOrder(a.rule, b.rule),
483
+ )
484
+ }
485
+
486
+ const AUTH_SCHEME = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{16,}/g
487
+ const CREDENTIAL_HEADER =
488
+ /\b(cookie|set-cookie|authorization|proxy-authorization|x-api-key|api-key)(["']?\s*(?:=>|:|=)\s*["'`]?)[^"'`\r\n,}]{8,}/gi
489
+
490
+ /**
491
+ * A scrubber for text a dev tool hands to a person or an agent: logs, error messages, stacks,
492
+ * codeframes. Broader than the build scan because a log line is not a published artifact: every JWT
493
+ * (a session token is a credential), an `Authorization`/`Cookie` value, and every non-public env
494
+ * value in each encoding the build scan matches. Each hit becomes `[redacted:<what>]`.
495
+ */
496
+ export function createRedactor(
497
+ env: Readonly<Record<string, string | undefined>>,
498
+ publicEnvPrefix = "PUBLIC_",
499
+ ): (text: string) => string {
500
+ const needles = envNeedles(env, publicEnvPrefix).sort((a, b) => b.needle.length - a.needle.length)
501
+ return (text) => {
502
+ if (text.length < 8) return text
503
+ let out = text
504
+ for (const { env: name, needle } of needles) {
505
+ if (out.includes(needle)) out = out.replaceAll(needle, `[redacted:${name}]`)
506
+ }
507
+ out = out.replace(PRIVATE_KEY, "[redacted:private key]")
508
+ for (const { what, pattern } of KEY_FORMATS) out = out.replace(pattern, `[redacted:${what}]`)
509
+ out = out.replace(JWT, "[redacted:JWT]")
510
+ out = out.replace(CREDENTIAL_URL, (match, user: string, password: string) =>
511
+ // @nifra-gate-reviewed: the same placeholder test as the scan; nothing is authenticated here.
512
+ PLACEHOLDER.test(password) || password === user
513
+ ? match
514
+ : match.replace(`:${password}@`, ":[redacted]@"),
515
+ )
516
+ out = out.replace(AUTH_SCHEME, "$1 [redacted]")
517
+ return out.replace(CREDENTIAL_HEADER, "$1$2[redacted]")
518
+ }
519
+ }
520
+
521
+ /** The build environment: Bun's, else Node's. */
522
+ export const buildEnvironment = (): Readonly<Record<string, string | undefined>> =>
523
+ (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
524
+
525
+ /** Scan and throw the build-failing message on any finding. */
526
+ export function assertNoSecrets(input: SecretScanInput): void {
527
+ const message = formatSecretFindings(scanForSecrets(input))
528
+ if (message !== undefined) throw new Error(message)
529
+ }
530
+
531
+ /** The build-failing message for secret findings, or `undefined` when there are none. */
532
+ export function formatSecretFindings(findings: readonly SecretFinding[]): string | undefined {
533
+ if (findings.length === 0) return undefined
534
+ const lines = findings.map(
535
+ (finding) =>
536
+ ` - ${finding.file}:${finding.line}${finding.via === undefined ? "" : ` (in ${finding.via})`} ${finding.what} [${finding.rule}] ${finding.preview}`,
537
+ )
538
+ return [
539
+ "[nifra/web] the build would publish what looks like a credential:",
540
+ ...lines,
541
+ "Keep credentials on the server: read them from the environment in a route's backend half or under",
542
+ "backend/, and rotate any that were committed. A reviewed false positive is exempted in",
543
+ 'nifra.config.ts: export const secretExemptions = [{ rule, file, reason: "why it may ship" }].',
544
+ ].join("\n")
545
+ }
546
+
547
+ const INLINE_MAP =
548
+ /\/[/*]# sourceMappingURL=data:application\/json;(?:charset=[^;,]+;)?base64,([A-Za-z0-9+/=]+)/
549
+
550
+ /** A published file's text, or `undefined` for a binary one (a NUL byte in its first 8 KB). */
551
+ export function textOf(bytes: Uint8Array): string | undefined {
552
+ if (bytes.subarray(0, 8192).includes(0)) return undefined
553
+ return new TextDecoder().decode(bytes)
554
+ }
555
+
556
+ /** An emitted bundle, plus its inline source map decoded: base64 would hide a key from every rule. */
557
+ export function emittedScanFiles(
558
+ name: string,
559
+ text: string,
560
+ origins?: readonly SecretOrigin[],
561
+ ): SecretScanFile[] {
562
+ const files: SecretScanFile[] = [{ name, text, ...(origins ? { origins } : {}) }]
563
+ const inline = INLINE_MAP.exec(text)?.[1]
564
+ if (inline !== undefined) {
565
+ try {
566
+ files.push({ name: `${name} (inline source map)`, text: atob(inline) })
567
+ } catch {
568
+ // An unreadable inline map is the output accounting's finding, not this scan's.
569
+ }
570
+ }
571
+ return files
572
+ }
573
+
574
+ /** How a finding names a module: app-relative inside the app, from `node_modules/` for a package. */
575
+ export function originName(appRoot: string, file: string): string {
576
+ const rel = relative(appRoot, file).replaceAll("\\", "/")
577
+ if (!rel.startsWith("../") && !isAbsolute(rel)) return rel
578
+ const at = file.replaceAll("\\", "/").lastIndexOf("/node_modules/")
579
+ return at === -1 ? file : file.replaceAll("\\", "/").slice(at + 1)
580
+ }
581
+
582
+ /**
583
+ * The scan's view of a client graph: the first-party browser modules as sources, and for each emitted
584
+ * file (by basename) the modules bundled into it.
585
+ */
586
+ export function graphScanInput(
587
+ graph: ClientModuleGraph,
588
+ sourceOf: (id: string) => ModuleSource,
589
+ classifier: ZoneClassifier,
590
+ ): { sources: SecretScanFile[]; originsOf: (outputName: string) => SecretOrigin[] } {
591
+ const sources: SecretScanFile[] = []
592
+ const fileOf = (id: string): string | undefined => {
593
+ const source = sourceOf(id)
594
+ return source.kind === "file" ? source.file : undefined
595
+ }
596
+ for (const id of Object.keys(graph.modules)) {
597
+ const file = fileOf(id)
598
+ if (file === undefined) continue
599
+ const zone = classifier.classify(file).zone
600
+ if (zone !== "route-frontend" && zone !== "frontend" && zone !== "shared") continue
601
+ let text: string
602
+ try {
603
+ text = readFileSync(file, "utf8")
604
+ } catch {
605
+ continue
606
+ }
607
+ sources.push({ name: originName(classifier.appRoot, file), text, firstParty: true })
608
+ }
609
+ const byOutput = new Map<string, SecretOrigin[]>()
610
+ for (const [output, chunk] of Object.entries(graph.chunks)) {
611
+ const origins = chunk.modules
612
+ .map(fileOf)
613
+ .filter((file): file is string => file !== undefined)
614
+ .map((path) => ({ name: originName(classifier.appRoot, path), path }))
615
+ byOutput.set(basename(output), origins)
616
+ }
617
+ return { sources, originsOf: (outputName) => byOutput.get(basename(outputName)) ?? [] }
618
+ }
619
+
620
+ /** Every file under a `public/` directory, named as the app sees it (`public/robots.txt`). */
621
+ export function publicScanFiles(dir: string, label: string): SecretScanFile[] {
622
+ const files: SecretScanFile[] = []
623
+ for (const entry of readdirSync(dir, { recursive: true }) as string[]) {
624
+ const path = join(dir, entry)
625
+ let bytes: Uint8Array
626
+ try {
627
+ if (!statSync(path).isFile()) continue
628
+ bytes = readFileSync(path)
629
+ } catch {
630
+ continue
631
+ }
632
+ const text = textOf(bytes)
633
+ if (text !== undefined) {
634
+ files.push({ name: `${label}/${entry.replaceAll("\\", "/")}`, text })
635
+ }
636
+ }
637
+ return files
638
+ }