@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.
- package/dist/backend-only.d.ts +15 -0
- package/dist/backend-only.d.ts.map +1 -0
- package/dist/backend-only.js +16 -0
- package/dist/backend-only.js.map +1 -0
- package/dist/boundary.d.ts +5 -0
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +11 -2
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +18 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +82 -34
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +4 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +68 -13
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +57 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +356 -85
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/cdn.d.ts +74 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +132 -0
- package/dist/cdn.js.map +1 -0
- package/dist/client.d.ts +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +111 -15
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +4 -3
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +12 -3
- package/dist/conformance.js.map +1 -1
- package/dist/csp.d.ts +93 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +122 -0
- package/dist/csp.js.map +1 -0
- package/dist/dev-client.d.ts +76 -0
- package/dist/dev-client.d.ts.map +1 -0
- package/dist/dev-client.js +280 -0
- package/dist/dev-client.js.map +1 -0
- package/dist/dev-diagnostics.d.ts +26 -2
- package/dist/dev-diagnostics.d.ts.map +1 -1
- package/dist/dev-diagnostics.js +38 -7
- package/dist/dev-diagnostics.js.map +1 -1
- package/dist/dev-error.d.ts +4 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +46 -4
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-feed.d.ts +298 -0
- package/dist/dev-feed.d.ts.map +1 -0
- package/dist/dev-feed.js +797 -0
- package/dist/dev-feed.js.map +1 -0
- package/dist/dev-indicator.d.ts +38 -0
- package/dist/dev-indicator.d.ts.map +1 -0
- package/dist/dev-indicator.js +93 -0
- package/dist/dev-indicator.js.map +1 -0
- package/dist/dev-session.d.ts +77 -0
- package/dist/dev-session.d.ts.map +1 -0
- package/dist/dev-session.js +580 -0
- package/dist/dev-session.js.map +1 -0
- package/dist/dev-sourcemap.d.ts +27 -0
- package/dist/dev-sourcemap.d.ts.map +1 -0
- package/dist/dev-sourcemap.js +235 -0
- package/dist/dev-sourcemap.js.map +1 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +13 -5
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/dev.d.ts +15 -2
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +69 -44
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +35 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -0
- package/dist/diagnostic-catalog.js +203 -0
- package/dist/diagnostic-catalog.js.map +1 -0
- package/dist/diagnostic-prompt.d.ts +49 -0
- package/dist/diagnostic-prompt.d.ts.map +1 -0
- package/dist/diagnostic-prompt.js +303 -0
- package/dist/diagnostic-prompt.js.map +1 -0
- package/dist/diagnostic.d.ts +17 -23
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +31 -39
- package/dist/diagnostic.js.map +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +4 -1
- package/dist/draft.js.map +1 -1
- package/dist/fn.d.ts +5 -0
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +13 -3
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +4 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +13 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +48 -0
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
- package/dist/internal/cdn/cloudflare.js +94 -0
- package/dist/internal/cdn/cloudflare.js.map +1 -0
- package/dist/internal/cdn/core.d.ts +91 -0
- package/dist/internal/cdn/core.d.ts.map +1 -0
- package/dist/internal/cdn/core.js +220 -0
- package/dist/internal/cdn/core.js.map +1 -0
- package/dist/internal/cdn/fastly.d.ts +17 -0
- package/dist/internal/cdn/fastly.d.ts.map +1 -0
- package/dist/internal/cdn/fastly.js +43 -0
- package/dist/internal/cdn/fastly.js.map +1 -0
- package/dist/internal/cdn/vercel.d.ts +31 -0
- package/dist/internal/cdn/vercel.d.ts.map +1 -0
- package/dist/internal/cdn/vercel.js +56 -0
- package/dist/internal/cdn/vercel.js.map +1 -0
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +141 -25
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-csp.d.ts +24 -0
- package/dist/internal/dev-csp.d.ts.map +1 -0
- package/dist/internal/dev-csp.js +160 -0
- package/dist/internal/dev-csp.js.map +1 -0
- package/dist/internal/dev-reserved.d.ts +25 -0
- package/dist/internal/dev-reserved.d.ts.map +1 -0
- package/dist/internal/dev-reserved.js +39 -0
- package/dist/internal/dev-reserved.js.map +1 -0
- package/dist/internal/html-spans.d.ts +10 -0
- package/dist/internal/html-spans.d.ts.map +1 -0
- package/dist/internal/html-spans.js +46 -0
- package/dist/internal/html-spans.js.map +1 -0
- package/dist/internal/loading-runtime.d.ts +44 -0
- package/dist/internal/loading-runtime.d.ts.map +1 -0
- package/dist/internal/loading-runtime.js +206 -0
- package/dist/internal/loading-runtime.js.map +1 -0
- package/dist/internal/matches-runtime.d.ts +8 -0
- package/dist/internal/matches-runtime.d.ts.map +1 -0
- package/dist/internal/matches-runtime.js +61 -0
- package/dist/internal/matches-runtime.js.map +1 -0
- package/dist/internal/mount-shadow.d.ts +36 -0
- package/dist/internal/mount-shadow.d.ts.map +1 -0
- package/dist/internal/mount-shadow.js +65 -0
- package/dist/internal/mount-shadow.js.map +1 -0
- package/dist/internal/node-builtins.d.ts +3 -0
- package/dist/internal/node-builtins.d.ts.map +1 -0
- package/dist/internal/node-builtins.js +79 -0
- package/dist/internal/node-builtins.js.map +1 -0
- package/dist/internal/output-guard.d.ts +65 -0
- package/dist/internal/output-guard.d.ts.map +1 -0
- package/dist/internal/output-guard.js +630 -0
- package/dist/internal/output-guard.js.map +1 -0
- package/dist/internal/page-execution.d.ts +20 -8
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +571 -105
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts +21 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +104 -22
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +12 -0
- package/dist/internal/private-env.d.ts.map +1 -0
- package/dist/internal/private-env.js +335 -0
- package/dist/internal/private-env.js.map +1 -0
- package/dist/internal/public-url.d.ts +3 -0
- package/dist/internal/public-url.d.ts.map +1 -0
- package/dist/internal/public-url.js +18 -0
- package/dist/internal/public-url.js.map +1 -0
- package/dist/internal/render-document.d.ts +25 -19
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +77 -61
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/response-controls.d.ts +75 -0
- package/dist/internal/response-controls.d.ts.map +1 -0
- package/dist/internal/response-controls.js +256 -0
- package/dist/internal/response-controls.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +11 -16
- package/dist/internal/runtime-contract.d.ts.map +1 -1
- package/dist/internal/runtime-contract.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +104 -0
- package/dist/internal/secret-scan.d.ts.map +1 -0
- package/dist/internal/secret-scan.js +485 -0
- package/dist/internal/secret-scan.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +6 -29
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +6 -66
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/source-map.d.ts +47 -0
- package/dist/internal/source-map.d.ts.map +1 -0
- package/dist/internal/source-map.js +225 -0
- package/dist/internal/source-map.js.map +1 -0
- package/dist/internal/target-compat.d.ts +27 -0
- package/dist/internal/target-compat.d.ts.map +1 -0
- package/dist/internal/target-compat.js +77 -0
- package/dist/internal/target-compat.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +6 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -1
- package/dist/internal/timing-safe-equal.js +10 -0
- package/dist/internal/timing-safe-equal.js.map +1 -1
- package/dist/internal/vitals-runtime.d.ts +18 -0
- package/dist/internal/vitals-runtime.d.ts.map +1 -0
- package/dist/internal/vitals-runtime.js +38 -0
- package/dist/internal/vitals-runtime.js.map +1 -0
- package/dist/internal/web-app.d.ts +38 -9
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +199 -59
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/zone-graph.d.ts +89 -0
- package/dist/internal/zone-graph.d.ts.map +1 -0
- package/dist/internal/zone-graph.js +492 -0
- package/dist/internal/zone-graph.js.map +1 -0
- package/dist/isr.d.ts +83 -6
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +263 -36
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +31 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +220 -13
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +386 -9
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +26 -5
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +17 -7
- package/dist/module-graph.js.map +1 -1
- package/dist/navigation.d.ts +17 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +11 -5
- package/dist/navigation.js.map +1 -1
- package/dist/nonce.d.ts +47 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +63 -0
- package/dist/nonce.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +4 -3
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +9 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +18 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/svg.d.ts +22 -3
- package/dist/plugins/svg.d.ts.map +1 -1
- package/dist/plugins/svg.js +112 -1
- package/dist/plugins/svg.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +107 -3
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +378 -17
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +61 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
- package/dist/plugins/vite-zone-guard.js +208 -0
- package/dist/plugins/vite-zone-guard.js.map +1 -0
- package/dist/plugins/zone-guard.d.ts +27 -0
- package/dist/plugins/zone-guard.d.ts.map +1 -0
- package/dist/plugins/zone-guard.js +72 -0
- package/dist/plugins/zone-guard.js.map +1 -0
- package/dist/prerender.d.ts +10 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +35 -3
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +9 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +67 -9
- package/dist/public-dir.js.map +1 -1
- package/dist/pwa-manifest.d.ts +68 -0
- package/dist/pwa-manifest.d.ts.map +1 -0
- package/dist/pwa-manifest.js +178 -0
- package/dist/pwa-manifest.js.map +1 -0
- package/dist/query.js +4 -4
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +6 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -0
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +26 -3
- package/dist/route-manifest.js.map +1 -1
- package/dist/route-types.d.ts +24 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +195 -0
- package/dist/route-types.js.map +1 -0
- package/dist/router.d.ts +28 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +129 -40
- package/dist/router.js.map +1 -1
- package/dist/vitals.d.ts +33 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +16 -0
- package/dist/vitals.js.map +1 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +187 -36
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +70 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +365 -0
- package/dist/zones.js.map +1 -0
- package/package.json +67 -14
- package/src/backend-only.ts +16 -0
- package/src/boundary.ts +18 -5
- package/src/build-plan.ts +90 -35
- package/src/build-vite.ts +77 -11
- package/src/build.ts +466 -96
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +111 -18
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -0
- package/src/dev-client.ts +349 -0
- package/src/dev-diagnostics.ts +60 -7
- package/src/dev-error.ts +49 -5
- package/src/dev-feed.ts +1140 -0
- package/src/dev-indicator.ts +113 -0
- package/src/dev-session.ts +729 -0
- package/src/dev-sourcemap.ts +281 -0
- package/src/dev-ssr-graph.ts +16 -5
- package/src/dev.ts +86 -43
- package/src/diagnostic-catalog.ts +243 -0
- package/src/diagnostic-prompt.ts +355 -0
- package/src/diagnostic.ts +63 -66
- package/src/draft.ts +4 -1
- package/src/fn.ts +19 -3
- package/src/fs.ts +13 -2
- package/src/index.ts +42 -10
- package/src/internal/cdn/cloudflare.ts +146 -0
- package/src/internal/cdn/core.ts +316 -0
- package/src/internal/cdn/fastly.ts +62 -0
- package/src/internal/cdn/vercel.ts +95 -0
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/codegen.ts +161 -24
- package/src/internal/dev-csp.ts +176 -0
- package/src/internal/dev-reserved.ts +52 -0
- package/src/internal/html-spans.ts +50 -0
- package/src/internal/loading-runtime.ts +244 -0
- package/src/internal/matches-runtime.ts +62 -0
- package/src/internal/mount-shadow.ts +88 -0
- package/src/internal/node-builtins.ts +79 -0
- package/src/internal/output-guard.ts +701 -0
- package/src/internal/page-execution.ts +795 -185
- package/src/internal/parity.ts +150 -36
- package/src/internal/private-env.ts +334 -0
- package/src/internal/public-url.ts +22 -0
- package/src/internal/render-document.ts +114 -67
- package/src/internal/response-controls.ts +284 -0
- package/src/internal/runtime-contract.ts +10 -15
- package/src/internal/secret-scan.ts +623 -0
- package/src/internal/server-boundary.ts +6 -69
- package/src/internal/source-map.ts +256 -0
- package/src/internal/target-compat.ts +92 -0
- package/src/internal/timing-safe-equal.ts +13 -0
- package/src/internal/vitals-runtime.ts +53 -0
- package/src/internal/web-app.ts +262 -84
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +325 -37
- package/src/llms-txt.ts +28 -4
- package/src/manifest.ts +670 -16
- package/src/module-graph.ts +53 -9
- package/src/navigation.ts +29 -6
- package/src/nonce.ts +104 -0
- package/src/plugins/css-modules.ts +5 -3
- package/src/plugins/kit.ts +24 -0
- package/src/plugins/svg.ts +135 -4
- package/src/plugins/vite-leak-guard.ts +529 -16
- package/src/plugins/vite-zone-guard.ts +263 -0
- package/src/plugins/zone-guard.ts +110 -0
- package/src/prerender.ts +49 -3
- package/src/public-dir.ts +69 -8
- package/src/pwa-manifest.ts +226 -0
- package/src/query.ts +4 -4
- package/src/render-seam.ts +46 -1
- package/src/route-manifest.ts +15 -3
- package/src/route-types.ts +264 -0
- package/src/router.ts +163 -63
- package/src/vitals.ts +44 -0
- package/src/vite.ts +207 -36
- package/src/zones.ts +430 -0
- package/dist/plugins/vite-server-only.d.ts +0 -49
- package/dist/plugins/vite-server-only.d.ts.map +0 -1
- package/dist/plugins/vite-server-only.js +0 -57
- package/dist/plugins/vite-server-only.js.map +0 -1
- package/dist/server-only.d.ts +0 -21
- package/dist/server-only.d.ts.map +0 -1
- package/dist/server-only.js +0 -25
- package/dist/server-only.js.map +0 -1
- package/src/plugins/vite-server-only.ts +0 -75
- 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("&", "&")
|
|
345
|
+
.replaceAll("<", "<")
|
|
346
|
+
.replaceAll(">", ">")
|
|
347
|
+
.replaceAll('"', """),
|
|
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
|
+
}
|