@nifrajs/web 3.5.0 → 4.0.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 (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 +111 -15
  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 +485 -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 +386 -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 +72 -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 +187 -36
  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 +111 -18
  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 +623 -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 +670 -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 +110 -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 +207 -36
  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,623 @@
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
+ const EXTRA_SECRET_NAME =
158
+ /(?:accesskey|signingkey|encryptionkey|masterkey|serviceaccountkey|connectionstring)$/
159
+
160
+ /** Values that look like keys but are public by design. */
161
+ const PUBLIC_VALUE =
162
+ /^(?: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+)$/
163
+
164
+ /** Shannon entropy in bits per character. */
165
+ function entropy(value: string): number {
166
+ const counts = new Map<string, number>()
167
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
168
+ let bits = 0
169
+ for (const count of counts.values()) {
170
+ const p = count / value.length
171
+ bits -= p * Math.log2(p)
172
+ }
173
+ return bits
174
+ }
175
+
176
+ /** Random-looking: long, unbroken, mixed character classes, high entropy. Excludes paths and URLs. */
177
+ function looksRandom(value: string, minLength: number): boolean {
178
+ if (value.length < minLength || /\s/.test(value)) return false
179
+ if (/^(?:[/~.]|[A-Za-z]:[\\/])/.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value))
180
+ return false
181
+ const classes =
182
+ Number(/[a-z]/.test(value)) +
183
+ Number(/[A-Z]/.test(value)) +
184
+ Number(/[0-9]/.test(value)) +
185
+ Number(/[^A-Za-z0-9]/.test(value))
186
+ return classes >= 2 && entropy(value) >= 3.5
187
+ }
188
+
189
+ function jwtRole(payload: string): unknown {
190
+ try {
191
+ const json = atob(payload.replaceAll("-", "+").replaceAll("_", "/"))
192
+ return (JSON.parse(json) as { role?: unknown }).role
193
+ } catch {
194
+ return undefined
195
+ }
196
+ }
197
+
198
+ function formatMatches(text: string): Match[] {
199
+ const matches: Match[] = []
200
+ for (const { what, pattern } of KEY_FORMATS) {
201
+ for (const found of text.matchAll(pattern)) {
202
+ matches.push({ rule: "key-format", index: found.index, text: found[0], what })
203
+ }
204
+ }
205
+ for (const found of text.matchAll(JWT)) {
206
+ // A Supabase-style service key: a JWT whose role bypasses row-level security.
207
+ if (jwtRole(found[1] ?? "") === "service_role") {
208
+ matches.push({
209
+ rule: "key-format",
210
+ index: found.index,
211
+ text: found[0],
212
+ what: "service-role JWT",
213
+ })
214
+ }
215
+ }
216
+ for (const found of text.matchAll(PRIVATE_KEY)) {
217
+ matches.push({ rule: "private-key", index: found.index, text: found[0], what: "private key" })
218
+ }
219
+ for (const found of text.matchAll(CREDENTIAL_URL)) {
220
+ const user = found[1] ?? ""
221
+ const password = found[2] ?? ""
222
+ // @nifra-gate-reviewed: a password equal to its user name is a placeholder in the text being
223
+ // scanned; nothing is authenticated here.
224
+ if (PLACEHOLDER.test(password) || password === user || /[${}<>*]/.test(password)) continue
225
+ matches.push({
226
+ rule: "credential-url",
227
+ index: found.index,
228
+ text: found[0],
229
+ what: "URL with a password",
230
+ })
231
+ }
232
+ return matches
233
+ }
234
+
235
+ function assignedMatches(text: string): Match[] {
236
+ const matches: Match[] = []
237
+ for (const found of text.matchAll(ASSIGNED)) {
238
+ const name = found[1] ?? ""
239
+ if (!isSensitiveFieldName(name) && !EXTRA_SECRET_NAME.test(name.toLowerCase())) continue
240
+ const value = found[2] ?? found[3] ?? found[4] ?? ""
241
+ if (PUBLIC_VALUE.test(value) || PLACEHOLDER.test(value) || !looksRandom(value, 16)) continue
242
+ matches.push({
243
+ rule: "assigned-secret",
244
+ index: found.index + found[0].indexOf(value),
245
+ text: value,
246
+ what: `literal assigned to "${name}"`,
247
+ })
248
+ }
249
+ return matches
250
+ }
251
+
252
+ // Environment variables a shell, an OS or a CI system sets: none is an app secret unless its name
253
+ // says so (`GITHUB_TOKEN`, `VERCEL_OIDC_TOKEN`).
254
+ const AMBIENT_ENV =
255
+ /^(?: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)(?:_.*)?)$/
256
+
257
+ const SECRET_ENV_TOKENS: ReadonlySet<string> = new Set([
258
+ "SECRET",
259
+ "SECRETS",
260
+ "TOKEN",
261
+ "PASSWORD",
262
+ "PASSWD",
263
+ "PASS",
264
+ "PRIVATE",
265
+ "CREDENTIAL",
266
+ "CREDENTIALS",
267
+ "APIKEY",
268
+ "KEY",
269
+ "DSN",
270
+ "SALT",
271
+ "PEPPER",
272
+ "WEBHOOK",
273
+ ])
274
+ const CONNECTION_ENV =
275
+ /(?:DATABASE|DB|REDIS|MONGO(?:DB)?|POSTGRES(?:QL)?|PG|MYSQL|AMQP|RABBITMQ)_?(?:URL|URI)$|CONNECTION_?STRING/
276
+
277
+ const secretEnvName = (name: string): boolean =>
278
+ CONNECTION_ENV.test(name.toUpperCase()) ||
279
+ name
280
+ .toUpperCase()
281
+ .split(/[^A-Z0-9]+/)
282
+ .some((token) => SECRET_ENV_TOKENS.has(token))
283
+
284
+ function base64Variants(value: string): string[] {
285
+ const bytes = new TextEncoder().encode(value)
286
+ const variants: string[] = []
287
+ // A value inside a larger encoded blob lands at one of three byte alignments. Keep only the
288
+ // characters the value's own bytes determine: none that mix in a neighbour or the padding.
289
+ for (const shift of [0, 1, 2]) {
290
+ const shifted = new Uint8Array(shift + bytes.length)
291
+ shifted.set(bytes, shift)
292
+ let binary = ""
293
+ for (const byte of shifted) binary += String.fromCharCode(byte)
294
+ const encoded = btoa(binary)
295
+ const core = encoded.slice(shift === 0 ? 0 : shift + 1, Math.floor(shifted.length / 3) * 4)
296
+ if (core.length >= 8) {
297
+ variants.push(core)
298
+ const url = core.replaceAll("+", "-").replaceAll("/", "_")
299
+ if (url !== core) variants.push(url)
300
+ }
301
+ }
302
+ return variants
303
+ }
304
+
305
+ interface EnvNeedle {
306
+ readonly env: string
307
+ readonly form: string
308
+ readonly needle: string
309
+ }
310
+
311
+ /** The non-public values a build must not publish, in every encoding a page or bundle carries. */
312
+ function envNeedles(
313
+ env: Readonly<Record<string, string | undefined>>,
314
+ prefix: string,
315
+ ): EnvNeedle[] {
316
+ const publicValues = new Set<string>()
317
+ if (prefix !== "") {
318
+ for (const [name, value] of Object.entries(env)) {
319
+ if (name.startsWith(prefix) && value !== undefined) publicValues.add(value)
320
+ }
321
+ }
322
+ const needles: EnvNeedle[] = []
323
+ for (const [name, value] of Object.entries(env)) {
324
+ if (value === undefined || value.length < 8 || (prefix !== "" && name.startsWith(prefix))) {
325
+ continue
326
+ }
327
+ // The same value published under a public name is public by declaration.
328
+ if (publicValues.has(value)) continue
329
+ const named = secretEnvName(name)
330
+ if (!named && AMBIENT_ENV.test(name)) continue
331
+ const credentialUrl = /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]+:[^/@\s]+@/i.test(value)
332
+ if (!named && !credentialUrl && !looksRandom(value, 20)) continue
333
+ const forms: [string, string][] = [["raw", value]]
334
+ const add = (form: string, encoded: string): void => {
335
+ if (encoded !== value && !forms.some(([, existing]) => existing === encoded)) {
336
+ forms.push([form, encoded])
337
+ }
338
+ }
339
+ add("URL-encoded", encodeURIComponent(value))
340
+ add("JSON-escaped", JSON.stringify(value).slice(1, -1))
341
+ add(
342
+ "HTML-escaped",
343
+ value
344
+ .replaceAll("&", "&amp;")
345
+ .replaceAll("<", "&lt;")
346
+ .replaceAll(">", "&gt;")
347
+ .replaceAll('"', "&quot;"),
348
+ )
349
+ for (const encoded of base64Variants(value)) add("base64", encoded)
350
+ for (const [form, needle] of forms) needles.push({ env: name, form, needle })
351
+ }
352
+ return needles
353
+ }
354
+
355
+ function envMatches(text: string, needles: readonly EnvNeedle[]): Match[] {
356
+ const matches: Match[] = []
357
+ for (const { env, form, needle } of needles) {
358
+ const index = text.indexOf(needle)
359
+ if (index === -1) continue
360
+ matches.push({
361
+ rule: "private-env-value",
362
+ index,
363
+ text: needle,
364
+ what: `value of ${env}${form === "raw" ? "" : ` (${form})`}`,
365
+ env,
366
+ })
367
+ }
368
+ return matches
369
+ }
370
+
371
+ const lineOf = (text: string, index: number): number => {
372
+ let line = 1
373
+ for (let i = text.indexOf("\n"); i !== -1 && i < index; i = text.indexOf("\n", i + 1)) line++
374
+ return line
375
+ }
376
+
377
+ const preview = (match: Match): string =>
378
+ match.rule === "private-env-value"
379
+ ? `${match.text.length} chars`
380
+ : `${match.text.slice(0, 4)}... (${match.text.length} chars)`
381
+
382
+ /** Refuse an exemption that does not name its rule, its location and its reason. */
383
+ export function assertSecretExemptions(exemptions: readonly SecretExemption[]): void {
384
+ exemptions.forEach((exemption, i) => {
385
+ const where = `secretExemptions[${i}]`
386
+ if (!RULES.has(exemption.rule)) {
387
+ throw new Error(
388
+ `[nifra/web] ${where}.rule must be one of ${[...RULES].join(", ")}; got ${JSON.stringify(exemption.rule)}`,
389
+ )
390
+ }
391
+ if (typeof exemption.reason !== "string" || exemption.reason.trim() === "") {
392
+ throw new Error(`[nifra/web] ${where} needs a reason: why this may reach the browser`)
393
+ }
394
+ const file = typeof exemption.file === "string" && exemption.file !== ""
395
+ const env = typeof exemption.env === "string" && exemption.env !== ""
396
+ if (!file && !(env && exemption.rule === "private-env-value")) {
397
+ throw new Error(
398
+ `[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`,
399
+ )
400
+ }
401
+ })
402
+ }
403
+
404
+ /**
405
+ * Scan what a build publishes. Sources are scanned first: a match there is reported at its source
406
+ * location, and the same text in an emitted file (where names are hashed) is not reported again,
407
+ * exempted or not.
408
+ */
409
+ export function scanForSecrets(input: SecretScanInput): SecretFinding[] {
410
+ const exemptions = input.exemptions ?? []
411
+ assertSecretExemptions(exemptions)
412
+ const needles = envNeedles(input.env ?? {}, input.publicEnvPrefix ?? "PUBLIC_")
413
+ const seen = new Set<string>()
414
+ const findings: SecretFinding[] = []
415
+ const exempt = (file: string, match: Match): boolean =>
416
+ exemptions.some(
417
+ (exemption) =>
418
+ exemption.rule === match.rule &&
419
+ (exemption.file === file || (match.env !== undefined && exemption.env === match.env)),
420
+ )
421
+ const originTexts = new Map<string, string | undefined>()
422
+ const originText = (origin: SecretOrigin): string | undefined => {
423
+ if (!originTexts.has(origin.path)) {
424
+ let text: string | undefined
425
+ try {
426
+ text = readFileSync(origin.path, "utf8")
427
+ } catch {
428
+ text = undefined
429
+ }
430
+ originTexts.set(origin.path, text)
431
+ }
432
+ return originTexts.get(origin.path)
433
+ }
434
+ const scan = (file: SecretScanFile, isSource: boolean): void => {
435
+ const matches = [
436
+ ...formatMatches(file.text),
437
+ ...(isSource && file.firstParty === true ? assignedMatches(file.text) : []),
438
+ ...envMatches(file.text, needles),
439
+ ]
440
+ for (const match of matches) {
441
+ const key = `${match.rule}\0${match.text}`
442
+ if (!isSource && seen.has(key)) continue
443
+ seen.add(key)
444
+ let at = { name: file.name, text: file.text, index: match.index }
445
+ for (const origin of file.origins ?? []) {
446
+ const text = originText(origin)
447
+ const index = text?.indexOf(match.text) ?? -1
448
+ if (text !== undefined && index !== -1) {
449
+ at = { name: origin.name, text, index }
450
+ break
451
+ }
452
+ }
453
+ if (exempt(at.name, match) || (at.name !== file.name && exempt(file.name, match))) continue
454
+ findings.push({
455
+ rule: match.rule,
456
+ file: at.name,
457
+ line: lineOf(at.text, at.index),
458
+ ...(at.name === file.name ? {} : { via: file.name }),
459
+ what: match.what,
460
+ preview: preview(match),
461
+ })
462
+ }
463
+ }
464
+ for (const file of input.sources ?? []) scan(file, true)
465
+ for (const file of input.artifacts ?? []) scan(file, false)
466
+ return findings.sort(
467
+ (a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line || codeUnitOrder(a.rule, b.rule),
468
+ )
469
+ }
470
+
471
+ const AUTH_SCHEME = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{16,}/g
472
+ const CREDENTIAL_HEADER =
473
+ /\b(cookie|set-cookie|authorization|proxy-authorization|x-api-key|api-key)(["']?\s*(?:=>|:|=)\s*["'`]?)[^"'`\r\n,}]{8,}/gi
474
+
475
+ /**
476
+ * A scrubber for text a dev tool hands to a person or an agent: logs, error messages, stacks,
477
+ * codeframes. Broader than the build scan because a log line is not a published artifact: every JWT
478
+ * (a session token is a credential), an `Authorization`/`Cookie` value, and every non-public env
479
+ * value in each encoding the build scan matches. Each hit becomes `[redacted:<what>]`.
480
+ */
481
+ export function createRedactor(
482
+ env: Readonly<Record<string, string | undefined>>,
483
+ publicEnvPrefix = "PUBLIC_",
484
+ ): (text: string) => string {
485
+ const needles = envNeedles(env, publicEnvPrefix).sort((a, b) => b.needle.length - a.needle.length)
486
+ return (text) => {
487
+ if (text.length < 8) return text
488
+ let out = text
489
+ for (const { env: name, needle } of needles) {
490
+ if (out.includes(needle)) out = out.replaceAll(needle, `[redacted:${name}]`)
491
+ }
492
+ out = out.replace(PRIVATE_KEY, "[redacted:private key]")
493
+ for (const { what, pattern } of KEY_FORMATS) out = out.replace(pattern, `[redacted:${what}]`)
494
+ out = out.replace(JWT, "[redacted:JWT]")
495
+ out = out.replace(CREDENTIAL_URL, (match, user: string, password: string) =>
496
+ // @nifra-gate-reviewed: the same placeholder test as the scan; nothing is authenticated here.
497
+ PLACEHOLDER.test(password) || password === user
498
+ ? match
499
+ : match.replace(`:${password}@`, ":[redacted]@"),
500
+ )
501
+ out = out.replace(AUTH_SCHEME, "$1 [redacted]")
502
+ return out.replace(CREDENTIAL_HEADER, "$1$2[redacted]")
503
+ }
504
+ }
505
+
506
+ /** The build environment: Bun's, else Node's. */
507
+ export const buildEnvironment = (): Readonly<Record<string, string | undefined>> =>
508
+ (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
509
+
510
+ /** Scan and throw the build-failing message on any finding. */
511
+ export function assertNoSecrets(input: SecretScanInput): void {
512
+ const message = formatSecretFindings(scanForSecrets(input))
513
+ if (message !== undefined) throw new Error(message)
514
+ }
515
+
516
+ /** The build-failing message for secret findings, or `undefined` when there are none. */
517
+ export function formatSecretFindings(findings: readonly SecretFinding[]): string | undefined {
518
+ if (findings.length === 0) return undefined
519
+ const lines = findings.map(
520
+ (finding) =>
521
+ ` - ${finding.file}:${finding.line}${finding.via === undefined ? "" : ` (in ${finding.via})`} ${finding.what} [${finding.rule}] ${finding.preview}`,
522
+ )
523
+ return [
524
+ "[nifra/web] the build would publish what looks like a credential:",
525
+ ...lines,
526
+ "Keep credentials on the server: read them from the environment in a route's backend half or under",
527
+ "backend/, and rotate any that were committed. A reviewed false positive is exempted in",
528
+ 'nifra.config.ts: export const secretExemptions = [{ rule, file, reason: "why it may ship" }].',
529
+ ].join("\n")
530
+ }
531
+
532
+ const INLINE_MAP =
533
+ /\/[/*]# sourceMappingURL=data:application\/json;(?:charset=[^;,]+;)?base64,([A-Za-z0-9+/=]+)/
534
+
535
+ /** A published file's text, or `undefined` for a binary one (a NUL byte in its first 8 KB). */
536
+ export function textOf(bytes: Uint8Array): string | undefined {
537
+ if (bytes.subarray(0, 8192).includes(0)) return undefined
538
+ return new TextDecoder().decode(bytes)
539
+ }
540
+
541
+ /** An emitted bundle, plus its inline source map decoded: base64 would hide a key from every rule. */
542
+ export function emittedScanFiles(
543
+ name: string,
544
+ text: string,
545
+ origins?: readonly SecretOrigin[],
546
+ ): SecretScanFile[] {
547
+ const files: SecretScanFile[] = [{ name, text, ...(origins ? { origins } : {}) }]
548
+ const inline = INLINE_MAP.exec(text)?.[1]
549
+ if (inline !== undefined) {
550
+ try {
551
+ files.push({ name: `${name} (inline source map)`, text: atob(inline) })
552
+ } catch {
553
+ // An unreadable inline map is the output accounting's finding, not this scan's.
554
+ }
555
+ }
556
+ return files
557
+ }
558
+
559
+ /** How a finding names a module: app-relative inside the app, from `node_modules/` for a package. */
560
+ export function originName(appRoot: string, file: string): string {
561
+ const rel = relative(appRoot, file).replaceAll("\\", "/")
562
+ if (!rel.startsWith("../") && !isAbsolute(rel)) return rel
563
+ const at = file.replaceAll("\\", "/").lastIndexOf("/node_modules/")
564
+ return at === -1 ? file : file.replaceAll("\\", "/").slice(at + 1)
565
+ }
566
+
567
+ /**
568
+ * The scan's view of a client graph: the first-party browser modules as sources, and for each emitted
569
+ * file (by basename) the modules bundled into it.
570
+ */
571
+ export function graphScanInput(
572
+ graph: ClientModuleGraph,
573
+ sourceOf: (id: string) => ModuleSource,
574
+ classifier: ZoneClassifier,
575
+ ): { sources: SecretScanFile[]; originsOf: (outputName: string) => SecretOrigin[] } {
576
+ const sources: SecretScanFile[] = []
577
+ const fileOf = (id: string): string | undefined => {
578
+ const source = sourceOf(id)
579
+ return source.kind === "file" ? source.file : undefined
580
+ }
581
+ for (const id of Object.keys(graph.modules)) {
582
+ const file = fileOf(id)
583
+ if (file === undefined) continue
584
+ const zone = classifier.classify(file).zone
585
+ if (zone !== "route-frontend" && zone !== "frontend" && zone !== "shared") continue
586
+ let text: string
587
+ try {
588
+ text = readFileSync(file, "utf8")
589
+ } catch {
590
+ continue
591
+ }
592
+ sources.push({ name: originName(classifier.appRoot, file), text, firstParty: true })
593
+ }
594
+ const byOutput = new Map<string, SecretOrigin[]>()
595
+ for (const [output, chunk] of Object.entries(graph.chunks)) {
596
+ const origins = chunk.modules
597
+ .map(fileOf)
598
+ .filter((file): file is string => file !== undefined)
599
+ .map((path) => ({ name: originName(classifier.appRoot, path), path }))
600
+ byOutput.set(basename(output), origins)
601
+ }
602
+ return { sources, originsOf: (outputName) => byOutput.get(basename(outputName)) ?? [] }
603
+ }
604
+
605
+ /** Every file under a `public/` directory, named as the app sees it (`public/robots.txt`). */
606
+ export function publicScanFiles(dir: string, label: string): SecretScanFile[] {
607
+ const files: SecretScanFile[] = []
608
+ for (const entry of readdirSync(dir, { recursive: true }) as string[]) {
609
+ const path = join(dir, entry)
610
+ let bytes: Uint8Array
611
+ try {
612
+ if (!statSync(path).isFile()) continue
613
+ bytes = readFileSync(path)
614
+ } catch {
615
+ continue
616
+ }
617
+ const text = textOf(bytes)
618
+ if (text !== undefined) {
619
+ files.push({ name: `${label}/${entry.replaceAll("\\", "/")}`, text })
620
+ }
621
+ }
622
+ return files
623
+ }