@nifrajs/cli 3.5.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +4 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +13 -0
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +49 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +291 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -63
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +262 -82
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +62 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +275 -35
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +52 -13
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +48 -22
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- 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/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +5 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -27
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +67 -17
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.js +1 -1
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +2 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +132 -108
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +17 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +14 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +45 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-render.js +2 -2
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +110 -23
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -9
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +16 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +81 -53
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +1155 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +5 -0
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +36 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +12 -8
- package/dist/review.js.map +1 -1
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +71 -38
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +132 -87
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +44 -19
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +29 -13
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +3807 -580
- package/docs/types.json +14229 -10939
- package/package.json +19 -12
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +17 -0
- package/src/check-scan.ts +358 -63
- package/src/check.ts +97 -62
- package/src/cli.ts +296 -87
- package/src/command-catalog.ts +389 -48
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +54 -12
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +53 -32
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +90 -27
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +140 -33
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +1 -1
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +160 -132
- package/src/mcp-http.ts +19 -5
- package/src/mcp-io.ts +57 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-render.ts +2 -2
- package/src/mcp-root.ts +117 -22
- package/src/mcp-run.ts +104 -10
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +106 -64
- package/src/migrate-layout.ts +1338 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +5 -0
- package/src/recipes/4.0.0.ts +37 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +13 -8
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +71 -39
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +147 -95
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +50 -19
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +13 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +27 -13
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NF-C032: what looks like a credential in browser code or in `public/`, checked from source with the
|
|
3
|
+
* scanner every client build runs (`@nifrajs/web/zones`). The build also matches the values of the
|
|
4
|
+
* build environment's private variables in its output; that needs the build's environment, so it
|
|
5
|
+
* stays the build's.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import {
|
|
10
|
+
publicScanFiles,
|
|
11
|
+
type SecretExemption,
|
|
12
|
+
type SecretScanFile,
|
|
13
|
+
scanForSecrets,
|
|
14
|
+
} from "@nifrajs/web/zones"
|
|
15
|
+
import { CONFIG_FILE } from "../app-files.ts"
|
|
16
|
+
import { stripComments } from "../check-scan.ts"
|
|
17
|
+
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
18
|
+
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
19
|
+
import type { CheckRule } from "./index.ts"
|
|
20
|
+
import { zonedFiles } from "./zones.ts"
|
|
21
|
+
|
|
22
|
+
const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
|
|
23
|
+
|
|
24
|
+
const EXEMPTIONS_START = /\bexport\s+const\s+secretExemptions\b[^=]*=\s*\[/
|
|
25
|
+
|
|
26
|
+
type Token = { readonly kind: "string" | "word" | "punct"; readonly value: string }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The tokens of an array literal, from just after its `[` to the `]` that closes it. A string is one
|
|
30
|
+
* token, so a bracket or a colon inside one (`"routes/[lang]/index.tsx"`) is text, never syntax.
|
|
31
|
+
*/
|
|
32
|
+
function arrayTokens(src: string, from: number): Token[] {
|
|
33
|
+
const tokens: Token[] = []
|
|
34
|
+
let depth = 0
|
|
35
|
+
let i = from
|
|
36
|
+
while (i < src.length) {
|
|
37
|
+
const c = src[i] as string
|
|
38
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
39
|
+
let value = ""
|
|
40
|
+
let j = i + 1
|
|
41
|
+
while (j < src.length && src[j] !== c) {
|
|
42
|
+
if (src[j] === "\\") {
|
|
43
|
+
value += src[j + 1] ?? ""
|
|
44
|
+
j += 2
|
|
45
|
+
} else value += src[j++]
|
|
46
|
+
}
|
|
47
|
+
tokens.push({ kind: "string", value })
|
|
48
|
+
i = j + 1
|
|
49
|
+
} else if (/[\w$]/.test(c)) {
|
|
50
|
+
let j = i
|
|
51
|
+
while (j < src.length && /[\w$]/.test(src[j] as string)) j++
|
|
52
|
+
tokens.push({ kind: "word", value: src.slice(i, j) })
|
|
53
|
+
i = j
|
|
54
|
+
} else {
|
|
55
|
+
if (c === "]" || c === "}" || c === ")") {
|
|
56
|
+
if (depth === 0) break
|
|
57
|
+
depth--
|
|
58
|
+
} else if (c === "[" || c === "{" || c === "(") depth++
|
|
59
|
+
if (!/\s/.test(c)) tokens.push({ kind: "punct", value: c })
|
|
60
|
+
i++
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return tokens
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isPunct = (token: Token | undefined, value: string): boolean =>
|
|
67
|
+
token?.kind === "punct" && token.value === value
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The app's `secretExemptions`, read statically from `nifra.config.ts` (check never imports app code):
|
|
71
|
+
* an array literal of object literals with string fields, the form the build error suggests.
|
|
72
|
+
*/
|
|
73
|
+
export function readSecretExemptions(appRoot: string): SecretExemption[] {
|
|
74
|
+
const path = join(appRoot, CONFIG_FILE)
|
|
75
|
+
if (!existsSync(path)) return []
|
|
76
|
+
const text = stripComments(readFileSync(path, "utf8"))
|
|
77
|
+
const opened = EXEMPTIONS_START.exec(text)
|
|
78
|
+
if (opened === null) return []
|
|
79
|
+
const tokens = arrayTokens(text, opened.index + opened[0].length)
|
|
80
|
+
const exemptions: SecretExemption[] = []
|
|
81
|
+
let depth = 0
|
|
82
|
+
let fields: Record<string, string> | undefined
|
|
83
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
84
|
+
const token = tokens[i] as Token
|
|
85
|
+
if (token.kind === "punct" && "[{(".includes(token.value)) {
|
|
86
|
+
depth++
|
|
87
|
+
if (depth === 1 && token.value === "{") fields = {}
|
|
88
|
+
} else if (token.kind === "punct" && "]})".includes(token.value)) {
|
|
89
|
+
if (depth === 1 && fields !== undefined) {
|
|
90
|
+
const { rule, reason, file, env } = fields
|
|
91
|
+
if (rule !== undefined && reason !== undefined) {
|
|
92
|
+
exemptions.push({
|
|
93
|
+
rule: rule as SecretExemption["rule"],
|
|
94
|
+
reason,
|
|
95
|
+
...(file === undefined ? {} : { file }),
|
|
96
|
+
...(env === undefined ? {} : { env }),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
fields = undefined
|
|
100
|
+
}
|
|
101
|
+
depth--
|
|
102
|
+
} else if (
|
|
103
|
+
depth === 1 &&
|
|
104
|
+
fields !== undefined &&
|
|
105
|
+
token.kind !== "punct" &&
|
|
106
|
+
isPunct(tokens[i + 1], ":") &&
|
|
107
|
+
tokens[i + 2]?.kind === "string" &&
|
|
108
|
+
(isPunct(tokens[i + 3], ",") || isPunct(tokens[i + 3], "}"))
|
|
109
|
+
) {
|
|
110
|
+
fields[token.value] ??= (tokens[i + 2] as Token).value
|
|
111
|
+
i += 2
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return exemptions
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const secretSourceRule: CheckRule = {
|
|
118
|
+
code: "NF-C032",
|
|
119
|
+
title: "Credential in browser code",
|
|
120
|
+
async scan(ctx) {
|
|
121
|
+
const { files } = zonedFiles(ctx)
|
|
122
|
+
const apps = new Map<string, SecretScanFile[]>()
|
|
123
|
+
for (const { file, app, classification } of files) {
|
|
124
|
+
if (!apps.has(app)) apps.set(app, [])
|
|
125
|
+
if (!BROWSER_CODE.has(classification.zone)) continue
|
|
126
|
+
const text = ctx.sources.read(file)
|
|
127
|
+
if (text === undefined) continue
|
|
128
|
+
const name = app === "" ? file : file.slice(app.length + 1)
|
|
129
|
+
apps.get(app)?.push({ name, text, firstParty: true })
|
|
130
|
+
}
|
|
131
|
+
const findings: Diagnostic[] = []
|
|
132
|
+
for (const [app, sources] of apps) {
|
|
133
|
+
const appRoot = join(ctx.root, app)
|
|
134
|
+
const publicDir = join(appRoot, "public")
|
|
135
|
+
try {
|
|
136
|
+
const found = scanForSecrets({
|
|
137
|
+
sources,
|
|
138
|
+
artifacts: existsSync(publicDir) ? publicScanFiles(publicDir, "public") : [],
|
|
139
|
+
exemptions: readSecretExemptions(appRoot),
|
|
140
|
+
})
|
|
141
|
+
for (const finding of found) {
|
|
142
|
+
const file = app === "" ? finding.file : `${app}/${finding.file}`
|
|
143
|
+
findings.push(
|
|
144
|
+
diagnostic({
|
|
145
|
+
code: "NF-C032",
|
|
146
|
+
severity: "error",
|
|
147
|
+
file,
|
|
148
|
+
line: finding.line,
|
|
149
|
+
message: `${file} carries what looks like a credential (${finding.what}), and it reaches the browser. Read it from the environment in a route's backend half or under backend/, and rotate it if it was ever committed`,
|
|
150
|
+
evidence: [`rule: ${finding.rule}`, `match: ${finding.preview}`],
|
|
151
|
+
verify: "nifra check",
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// A malformed exemption: the build refuses it with the same message.
|
|
157
|
+
findings.push(
|
|
158
|
+
diagnostic({
|
|
159
|
+
code: "NF-C032",
|
|
160
|
+
severity: "error",
|
|
161
|
+
file: join(app, CONFIG_FILE),
|
|
162
|
+
message: error instanceof Error ? error.message : String(error),
|
|
163
|
+
verify: "nifra check",
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return findings.sort(
|
|
169
|
+
(a, b) => codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0),
|
|
170
|
+
)
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const secretRules = Object.freeze([secretSourceRule])
|
package/src/rules/security.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type * as TSApi from "typescript"
|
|
2
|
+
import { isBrowserSource } from "../check-scan.ts"
|
|
2
3
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
3
|
-
import {
|
|
4
|
+
import { scriptKindOf } from "../internal/script-kind.ts"
|
|
4
5
|
import { commentBlockHasMarker } from "./comment-markers.ts"
|
|
5
6
|
import type { CheckRule, SourceIndex } from "./index.ts"
|
|
7
|
+
import { loadRuleTypeScript } from "./typescript.ts"
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* A security rule that cannot run says so as an advisory finding instead of silently returning no
|
|
@@ -42,17 +44,33 @@ function reviewedEvidence(lines: readonly string[], line: number): readonly stri
|
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* NF-S002 severity by file role. Server-side code compares real secret material - a timing oracle
|
|
45
|
-
* there is exploitable, so it fails the gate.
|
|
47
|
+
* there is exploitable, so it fails the gate. Browser code (a route's frontend half, `frontend/`)
|
|
46
48
|
* compares values the client already holds, so the same shape is advisory rather than a gate
|
|
47
|
-
* failure.
|
|
48
|
-
*
|
|
49
|
+
* failure. `shared/` also runs on the server, and a file that cannot be classified is treated as
|
|
50
|
+
* server - fail closed.
|
|
49
51
|
*/
|
|
50
52
|
function secretComparisonSeverity(file: string): "error" | "warn" {
|
|
51
53
|
const path = file.replaceAll("\\", "/")
|
|
52
|
-
if (/\.server\.[cm]?[tj]sx?$/.test(path)) return "error"
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path)) return "error"
|
|
55
|
+
if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path)) return "error"
|
|
56
|
+
return "warn"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CONFIRMATION_NAME =
|
|
60
|
+
/^(?:confirm|confirmation|confirmPassword|passwordConfirmation|passwordConfirm|passwordRepeat|repeatPassword)$/i
|
|
61
|
+
|
|
62
|
+
/** Client form confirmation compares two values already held by the browser, not secret material. */
|
|
63
|
+
function isClientConfirmationPair(
|
|
64
|
+
file: string,
|
|
65
|
+
left: string | undefined,
|
|
66
|
+
right: string | undefined,
|
|
67
|
+
): boolean {
|
|
68
|
+
if (secretComparisonSeverity(file) !== "warn") return false
|
|
69
|
+
const names = [left, right]
|
|
70
|
+
return (
|
|
71
|
+
names.some((name) => name?.toLowerCase() === "password") &&
|
|
72
|
+
names.some((name) => name !== undefined && CONFIRMATION_NAME.test(name))
|
|
73
|
+
)
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
function nameOf(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
@@ -63,18 +81,20 @@ function nameOf(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
|
63
81
|
|
|
64
82
|
/**
|
|
65
83
|
* The operand name to match secret-like patterns against for NF-S002, or undefined when the operand
|
|
66
|
-
* cannot hold runtime secret material. A property access to
|
|
84
|
+
* cannot hold runtime secret material. A property access to a PascalCase member
|
|
67
85
|
* (`ts.SyntaxKind.PlusToken`, `MediaKind.Audio`) reads an enum/type-constant discriminant, never a
|
|
68
86
|
* secret string or byte buffer, so comparing against it is a kind check a timing oracle does not
|
|
69
|
-
* apply to.
|
|
70
|
-
*
|
|
71
|
-
* `
|
|
87
|
+
* apply to. An `UPPER_SNAKE` member is the configuration spelling (`process.env.API_TOKEN`,
|
|
88
|
+
* `env.WEBHOOK_SECRET`) and stays matched, as does a string-literal element access
|
|
89
|
+
* (`process.env["API_KEY"]`).
|
|
72
90
|
*/
|
|
73
91
|
function secretName(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
74
92
|
if (ts.isPropertyAccessExpression(node)) {
|
|
75
93
|
const member = node.name.text
|
|
76
|
-
return /^[A-Z]/.test(member) ? undefined : member
|
|
94
|
+
return /^[A-Z]/.test(member) && !/^[A-Z][A-Z0-9_]*$/.test(member) ? undefined : member
|
|
77
95
|
}
|
|
96
|
+
if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression))
|
|
97
|
+
return node.argumentExpression.text
|
|
78
98
|
return nameOf(ts, node)
|
|
79
99
|
}
|
|
80
100
|
|
|
@@ -99,8 +119,7 @@ function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): b
|
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
function parse(ts: typeof TSApi, file: string, source: string): TSApi.SourceFile {
|
|
102
|
-
|
|
103
|
-
return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
|
|
122
|
+
return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKindOf(ts, file))
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
function parsedFile(
|
|
@@ -123,62 +142,138 @@ function parsedFile(
|
|
|
123
142
|
return parsed
|
|
124
143
|
}
|
|
125
144
|
|
|
145
|
+
interface SecretComparison {
|
|
146
|
+
readonly node: TSApi.BinaryExpression
|
|
147
|
+
readonly left: string | undefined
|
|
148
|
+
readonly right: string | undefined
|
|
149
|
+
readonly line: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function secretComparisons(
|
|
153
|
+
ts: typeof TSApi,
|
|
154
|
+
file: string,
|
|
155
|
+
tree: TSApi.SourceFile,
|
|
156
|
+
): readonly SecretComparison[] {
|
|
157
|
+
const operators = [
|
|
158
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
159
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
160
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
161
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
162
|
+
]
|
|
163
|
+
const found: SecretComparison[] = []
|
|
164
|
+
const visit = (node: TSApi.Node): void => {
|
|
165
|
+
if (
|
|
166
|
+
ts.isBinaryExpression(node) &&
|
|
167
|
+
operators.includes(node.operatorToken.kind) &&
|
|
168
|
+
!isPresenceComparison(ts, node)
|
|
169
|
+
) {
|
|
170
|
+
const left = secretName(ts, node.left)
|
|
171
|
+
const right = secretName(ts, node.right)
|
|
172
|
+
if (
|
|
173
|
+
((left !== undefined && SECRET.test(left)) ||
|
|
174
|
+
(right !== undefined && SECRET.test(right))) &&
|
|
175
|
+
!isClientConfirmationPair(file, left, right)
|
|
176
|
+
) {
|
|
177
|
+
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
178
|
+
found.push({ node, left, right, line })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
ts.forEachChild(node, visit)
|
|
182
|
+
}
|
|
183
|
+
visit(tree)
|
|
184
|
+
return found
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function timingSafeHelper(file: string): string {
|
|
188
|
+
const typed = /\.[cm]?tsx?$/.test(file)
|
|
189
|
+
return [
|
|
190
|
+
'import { timingSafeEqual as nifraTimingSafeEqualBytes } from "node:crypto"',
|
|
191
|
+
"",
|
|
192
|
+
typed
|
|
193
|
+
? "function nifraTimingSafeEqual(left: string, right: string): boolean {"
|
|
194
|
+
: "function nifraTimingSafeEqual(left, right) {",
|
|
195
|
+
" const leftBytes = Buffer.from(left)",
|
|
196
|
+
" const rightBytes = Buffer.from(right)",
|
|
197
|
+
" return leftBytes.length === rightBytes.length && nifraTimingSafeEqualBytes(leftBytes, rightBytes)",
|
|
198
|
+
"}",
|
|
199
|
+
].join("\n")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
|
|
204
|
+
* add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
|
|
205
|
+
* them, so a line number a check run reported no longer names the same statement after the first fix.
|
|
206
|
+
*/
|
|
207
|
+
export function rewriteSecretComparisons(ts: typeof TSApi, file: string, source: string): string {
|
|
208
|
+
const tree = parse(ts, file, source)
|
|
209
|
+
const lines = tree.text.split("\n")
|
|
210
|
+
const sites = secretComparisons(ts, file, tree).filter((site) => !hasReview(lines, site.line))
|
|
211
|
+
// A comparison nested inside another (`(token === a) === flag`) is left for the next run, so every
|
|
212
|
+
// edit below covers a disjoint range and applying them from the end keeps earlier offsets valid.
|
|
213
|
+
const outer = sites.filter(
|
|
214
|
+
({ node }) =>
|
|
215
|
+
!sites.some(
|
|
216
|
+
(other) => other.node !== node && other.node.pos >= node.pos && other.node.end <= node.end,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
if (outer.length === 0) return source
|
|
220
|
+
let text = source
|
|
221
|
+
for (const { node } of outer.toSorted((a, b) => b.node.end - a.node.end)) {
|
|
222
|
+
const call = `nifraTimingSafeEqual(${node.left.getText(tree)}, ${node.right.getText(tree)})`
|
|
223
|
+
const negated =
|
|
224
|
+
node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
|
|
225
|
+
node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken
|
|
226
|
+
text = `${text.slice(0, node.getStart(tree))}${negated ? `!${call}` : call}${text.slice(node.end)}`
|
|
227
|
+
}
|
|
228
|
+
if (source.includes("function nifraTimingSafeEqual")) return text
|
|
229
|
+
// After a shebang and the directive prologue: an import above `"use client"` turns it into a no-op.
|
|
230
|
+
let at = 0
|
|
231
|
+
for (const statement of tree.statements) {
|
|
232
|
+
if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) break
|
|
233
|
+
at = statement.end
|
|
234
|
+
}
|
|
235
|
+
if (at === 0 && text.startsWith("#!")) at = text.includes("\n") ? text.indexOf("\n") : text.length
|
|
236
|
+
const helper = timingSafeHelper(file)
|
|
237
|
+
return at === 0 ? `${helper}\n\n${text}` : `${text.slice(0, at)}\n\n${helper}\n${text.slice(at)}`
|
|
238
|
+
}
|
|
239
|
+
|
|
126
240
|
export const secretComparisonRule: CheckRule = {
|
|
127
241
|
code: "NF-S002",
|
|
128
242
|
title: "Non-constant-time secret comparison",
|
|
129
243
|
async scan(ctx) {
|
|
130
|
-
const
|
|
244
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
245
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
246
|
+
const ts = loaded.compiler
|
|
131
247
|
if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
|
|
132
248
|
const findings: Diagnostic[] = []
|
|
133
249
|
for (const file of ctx.project.source.files) {
|
|
134
250
|
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
135
251
|
if (parsed === undefined) continue
|
|
136
252
|
const { tree, lines } = parsed
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
file,
|
|
161
|
-
line,
|
|
162
|
-
message: reviewed
|
|
163
|
-
? "secret comparison is explicitly marked as reviewed"
|
|
164
|
-
: "secret-like values must use a length check and timing-safe comparison",
|
|
165
|
-
evidence: [left ?? right ?? "secret comparison", ...reviewedEvidence(lines, line)],
|
|
166
|
-
...(reviewed
|
|
167
|
-
? {}
|
|
168
|
-
: {
|
|
169
|
-
fix: {
|
|
170
|
-
recipe: "security.timing-safe-equal",
|
|
171
|
-
command: "nifra fix --code NF-S002",
|
|
172
|
-
},
|
|
173
|
-
}),
|
|
174
|
-
verify: "nifra check --lints-only",
|
|
175
|
-
}),
|
|
176
|
-
)
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
ts.forEachChild(node, visit)
|
|
253
|
+
for (const { left, right, line } of secretComparisons(ts, file, tree)) {
|
|
254
|
+
const reviewed = hasReview(lines, line)
|
|
255
|
+
findings.push(
|
|
256
|
+
diagnostic({
|
|
257
|
+
code: "NF-S002",
|
|
258
|
+
severity: reviewed ? "info" : secretComparisonSeverity(file),
|
|
259
|
+
file,
|
|
260
|
+
line,
|
|
261
|
+
message: reviewed
|
|
262
|
+
? "secret comparison is explicitly marked as reviewed"
|
|
263
|
+
: "secret-like values must use a length check and timing-safe comparison",
|
|
264
|
+
evidence: [left ?? right ?? "secret comparison", ...reviewedEvidence(lines, line)],
|
|
265
|
+
...(reviewed
|
|
266
|
+
? {}
|
|
267
|
+
: {
|
|
268
|
+
fix: {
|
|
269
|
+
recipe: "security.timing-safe-equal",
|
|
270
|
+
command: "nifra fix --code NF-S002",
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
verify: "nifra check --lints-only",
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
180
276
|
}
|
|
181
|
-
visit(tree)
|
|
182
277
|
}
|
|
183
278
|
return findings
|
|
184
279
|
},
|
|
@@ -188,7 +283,9 @@ export const piiLogRule: CheckRule = {
|
|
|
188
283
|
code: "NF-S003",
|
|
189
284
|
title: "Sensitive value in log call",
|
|
190
285
|
async scan(ctx) {
|
|
191
|
-
const
|
|
286
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
287
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
288
|
+
const ts = loaded.compiler
|
|
192
289
|
if (ts === undefined) return didNotRun("NF-S003", "Sensitive-value-in-log scan")
|
|
193
290
|
const findings: Diagnostic[] = []
|
|
194
291
|
for (const file of ctx.project.source.files) {
|
|
@@ -232,11 +329,37 @@ export const piiLogRule: CheckRule = {
|
|
|
232
329
|
},
|
|
233
330
|
}
|
|
234
331
|
|
|
332
|
+
/**
|
|
333
|
+
* A function's own name, or for an anonymous arrow/function expression the name it is bound to
|
|
334
|
+
* (`const requireAuth = async () => …`, `{ canEdit: () => … }`), which is how a gate is usually written.
|
|
335
|
+
*/
|
|
336
|
+
function functionName(ts: typeof TSApi, fn: TSApi.Node): string | undefined {
|
|
337
|
+
if (ts.isFunctionLike(fn) && fn.name !== undefined)
|
|
338
|
+
return ts.isIdentifier(fn.name) ? fn.name.text : undefined
|
|
339
|
+
let binding = fn.parent
|
|
340
|
+
while (
|
|
341
|
+
binding !== undefined &&
|
|
342
|
+
(ts.isParenthesizedExpression(binding) ||
|
|
343
|
+
ts.isAsExpression(binding) ||
|
|
344
|
+
ts.isSatisfiesExpression(binding))
|
|
345
|
+
)
|
|
346
|
+
binding = binding.parent
|
|
347
|
+
if (
|
|
348
|
+
binding !== undefined &&
|
|
349
|
+
(ts.isVariableDeclaration(binding) || ts.isPropertyAssignment(binding)) &&
|
|
350
|
+
ts.isIdentifier(binding.name)
|
|
351
|
+
)
|
|
352
|
+
return binding.name.text
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
|
|
235
356
|
export const failOpenGateRule: CheckRule = {
|
|
236
357
|
code: "NF-S001",
|
|
237
358
|
title: "Fail-open gate",
|
|
238
359
|
async scan(ctx) {
|
|
239
|
-
const
|
|
360
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
361
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
362
|
+
const ts = loaded.compiler
|
|
240
363
|
if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
|
|
241
364
|
const findings: Diagnostic[] = []
|
|
242
365
|
for (const file of ctx.project.source.files) {
|
|
@@ -247,10 +370,7 @@ export const failOpenGateRule: CheckRule = {
|
|
|
247
370
|
if (ts.isCatchClause(node)) {
|
|
248
371
|
let parent: TSApi.Node | undefined = node.parent
|
|
249
372
|
while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
|
|
250
|
-
const name =
|
|
251
|
-
ts.isFunctionLike(parent) && parent.name && ts.isIdentifier(parent.name)
|
|
252
|
-
? parent.name.text
|
|
253
|
-
: undefined
|
|
373
|
+
const name = parent === undefined ? undefined : functionName(ts, parent)
|
|
254
374
|
// `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
|
|
255
375
|
// case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them
|
|
256
376
|
// authorization decisions. The other roots stay case-insensitive.
|
|
@@ -317,7 +437,9 @@ export const corsOriginPredicateRule: CheckRule = {
|
|
|
317
437
|
code: "NF-S004",
|
|
318
438
|
title: "CORS origin predicate ignores the origin",
|
|
319
439
|
async scan(ctx) {
|
|
320
|
-
const
|
|
440
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
441
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
442
|
+
const ts = loaded.compiler
|
|
321
443
|
if (ts === undefined) return didNotRun("NF-S004", "CORS origin predicate scan")
|
|
322
444
|
const findings: Diagnostic[] = []
|
|
323
445
|
for (const file of ctx.project.source.files) {
|
|
@@ -378,7 +500,9 @@ export const externalRedirectRule: CheckRule = {
|
|
|
378
500
|
code: "NF-S005",
|
|
379
501
|
title: "External redirect opt-out",
|
|
380
502
|
async scan(ctx) {
|
|
381
|
-
const
|
|
503
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
504
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
505
|
+
const ts = loaded.compiler
|
|
382
506
|
if (ts === undefined) return didNotRun("NF-S005", "External redirect opt-out scan")
|
|
383
507
|
const findings: Diagnostic[] = []
|
|
384
508
|
for (const file of ctx.project.source.files) {
|
|
@@ -435,7 +559,9 @@ export const assuranceEscapeHatchRule: CheckRule = {
|
|
|
435
559
|
code: "NF-S006",
|
|
436
560
|
title: "Security escape hatch enabled",
|
|
437
561
|
async scan(ctx) {
|
|
438
|
-
const
|
|
562
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
563
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
564
|
+
const ts = loaded.compiler
|
|
439
565
|
if (ts === undefined) return didNotRun("NF-S006", "Security escape hatch scan")
|
|
440
566
|
const findings: Diagnostic[] = []
|
|
441
567
|
for (const file of ctx.project.source.files) {
|
|
@@ -478,7 +604,9 @@ export const unprefixedSecureCookieRule: CheckRule = {
|
|
|
478
604
|
code: "NF-S007",
|
|
479
605
|
title: "Secure cookie without a __Host-/__Secure- prefix",
|
|
480
606
|
async scan(ctx) {
|
|
481
|
-
const
|
|
607
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
608
|
+
if (loaded.diagnostics !== undefined) return loaded.diagnostics
|
|
609
|
+
const ts = loaded.compiler
|
|
482
610
|
if (ts === undefined) return didNotRun("NF-S007", "Secure cookie prefix scan")
|
|
483
611
|
const findings: Diagnostic[] = []
|
|
484
612
|
for (const file of ctx.project.source.files) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Diagnostic } from "../diagnostics.ts"
|
|
2
|
+
import {
|
|
3
|
+
loadProjectTypeScript,
|
|
4
|
+
type TypeScriptApi,
|
|
5
|
+
type TypeScriptSession,
|
|
6
|
+
unsupportedTypeScriptDiagnostic,
|
|
7
|
+
} from "../internal/typescript-import.ts"
|
|
8
|
+
import type { RuleContext } from "./index.ts"
|
|
9
|
+
|
|
10
|
+
interface RuleTypeScriptLoad {
|
|
11
|
+
readonly compiler: TypeScriptApi | undefined
|
|
12
|
+
readonly session: TypeScriptSession | undefined
|
|
13
|
+
readonly diagnostics: readonly Diagnostic[] | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SessionCacheEntry {
|
|
17
|
+
readonly pending: ReturnType<typeof loadProjectTypeScript>
|
|
18
|
+
leases: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sessionCache = new WeakMap<RuleContext["sources"], SessionCacheEntry>()
|
|
22
|
+
|
|
23
|
+
function cachedLoad(ctx: RuleContext): SessionCacheEntry {
|
|
24
|
+
const cached = sessionCache.get(ctx.sources)
|
|
25
|
+
if (cached !== undefined) return cached
|
|
26
|
+
const entry: SessionCacheEntry = {
|
|
27
|
+
pending: loadProjectTypeScript(ctx.root, undefined, ctx.sources),
|
|
28
|
+
leases: 0,
|
|
29
|
+
}
|
|
30
|
+
sessionCache.set(ctx.sources, entry)
|
|
31
|
+
return entry
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Retain a direct rule-registry session until the whole registry run has completed. */
|
|
35
|
+
export function retainRuleTypeScript(ctx: RuleContext): void {
|
|
36
|
+
const entry = cachedLoad(ctx)
|
|
37
|
+
entry.leases += 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Release a direct rule-registry session and terminate its TypeScript 7 worker when unused. */
|
|
41
|
+
export async function releaseRuleTypeScript(ctx: RuleContext): Promise<void> {
|
|
42
|
+
const entry = sessionCache.get(ctx.sources)
|
|
43
|
+
if (entry === undefined) return
|
|
44
|
+
entry.leases = Math.max(0, entry.leases - 1)
|
|
45
|
+
if (entry.leases !== 0) return
|
|
46
|
+
sessionCache.delete(ctx.sources)
|
|
47
|
+
try {
|
|
48
|
+
const loaded = await entry.pending
|
|
49
|
+
await loaded.session?.close()
|
|
50
|
+
} catch {
|
|
51
|
+
// The scanner that consumed the same pending load owns the diagnostic for load failures.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Keep every AST-backed rule on the same project compiler session. */
|
|
56
|
+
export async function loadRuleTypeScript(ctx: RuleContext): Promise<RuleTypeScriptLoad> {
|
|
57
|
+
if (ctx.project.check.unsupportedTypeScriptVersion !== undefined) {
|
|
58
|
+
// The check-wide loader already supplied the single structured failure diagnostic.
|
|
59
|
+
return { compiler: undefined, session: undefined, diagnostics: [] }
|
|
60
|
+
}
|
|
61
|
+
if (ctx.typescriptSession !== undefined) {
|
|
62
|
+
return {
|
|
63
|
+
compiler: ctx.typescriptSession.compiler,
|
|
64
|
+
session: ctx.typescriptSession,
|
|
65
|
+
diagnostics: undefined,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const loaded = await cachedLoad(ctx).pending
|
|
69
|
+
if (loaded.unsupported !== undefined) {
|
|
70
|
+
return {
|
|
71
|
+
compiler: undefined,
|
|
72
|
+
session: undefined,
|
|
73
|
+
diagnostics: [unsupportedTypeScriptDiagnostic(loaded.unsupported.version)],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { compiler: loaded.compiler, session: loaded.session, diagnostics: undefined }
|
|
77
|
+
}
|