@nifrajs/cli 4.0.2 → 5.1.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/README.md +10 -2
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -2
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -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 +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +218 -44
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +94 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/dev-feed-tool.ts +112 -1
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/rules/security.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type * as TSApi from "typescript"
|
|
2
|
+
import { BACKEND_APP_FILE } from "../app-files.ts"
|
|
2
3
|
import { isBrowserSource } from "../check-scan.ts"
|
|
3
4
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
5
|
+
import { createWebAppProbe } from "../internal/nifra-project.ts"
|
|
4
6
|
import { scriptKindOf } from "../internal/script-kind.ts"
|
|
5
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
commentBlockExpectation,
|
|
9
|
+
commentBlockHasMarker,
|
|
10
|
+
MISSING_EXPECTATION_REASON,
|
|
11
|
+
} from "./comment-markers.ts"
|
|
6
12
|
import type { CheckRule, SourceIndex } from "./index.ts"
|
|
7
13
|
import { loadRuleTypeScript } from "./typescript.ts"
|
|
8
14
|
|
|
@@ -25,35 +31,65 @@ function didNotRun(code: string, title: string): readonly Diagnostic[] {
|
|
|
25
31
|
|
|
26
32
|
const SECRET = /(?:token|secret|apiKey|api_key|signature|hmac|password)/i
|
|
27
33
|
const PII = /(?:email|phone|ssn|password|token|authorization)/i
|
|
28
|
-
|
|
34
|
+
// Retired in 5.0.0: it silenced any security finding without a reason. It now silences nothing and
|
|
35
|
+
// only makes the finding name the form that replaced it.
|
|
36
|
+
const RETIRED_REVIEW_MARKER = "@nifra-gate-reviewed"
|
|
29
37
|
const parseCache = new WeakMap<
|
|
30
38
|
SourceIndex,
|
|
31
39
|
Map<string, { readonly tree: TSApi.SourceFile; readonly lines: readonly string[] }>
|
|
32
40
|
>()
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The reason a `nifra-expect <code>: <reason>` comment on or above `line` gives for a security
|
|
44
|
+
* finding. Every security hatch needs the reason: a comment without one silences nothing.
|
|
45
|
+
*/
|
|
46
|
+
function expectedReason(lines: readonly string[], line: number, code: string): string | undefined {
|
|
47
|
+
return commentBlockExpectation(lines, line, [code])?.reason
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
/** The evidence a silenced finding carries: the comment that silenced it, reason included. */
|
|
51
|
+
function expectationEvidence(
|
|
52
|
+
lines: readonly string[],
|
|
53
|
+
line: number,
|
|
54
|
+
code: string,
|
|
55
|
+
): readonly string[] {
|
|
56
|
+
const reason = expectedReason(lines, line, code)
|
|
57
|
+
return reason === undefined ? [] : [`nifra-expect ${code}: ${reason}`]
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
/** An unsilenced finding's message, plus why a hatch comment written for it does not count. */
|
|
61
|
+
function unexpectedMessage(
|
|
62
|
+
message: string,
|
|
63
|
+
lines: readonly string[],
|
|
64
|
+
line: number,
|
|
65
|
+
code: string,
|
|
66
|
+
): string {
|
|
67
|
+
if (commentBlockExpectation(lines, line, [code]) !== undefined)
|
|
68
|
+
return `${message}; ${MISSING_EXPECTATION_REASON}`
|
|
69
|
+
if (commentBlockHasMarker(lines, line, RETIRED_REVIEW_MARKER))
|
|
70
|
+
return `${message}; ${RETIRED_REVIEW_MARKER} no longer silences a finding - after review, write // nifra-expect ${code}: <reason>`
|
|
71
|
+
return message
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parentOf = (dir: string): string => dir.slice(0, Math.max(dir.lastIndexOf("/"), 0))
|
|
75
|
+
|
|
45
76
|
/**
|
|
46
|
-
* NF-S002
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* server
|
|
77
|
+
* Whether NF-S001 and NF-S002 may treat `file` as browser code, where a finding is advisory: a
|
|
78
|
+
* browser compares values the client already holds, and a gate there that fails open grants nothing
|
|
79
|
+
* the server does not check again. It must be browser code by path (a route's frontend half,
|
|
80
|
+
* `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
|
|
81
|
+
* without a web dependency runs on the server. `shared/` also runs on the server, and a file that
|
|
82
|
+
* cannot be classified is treated as server - fail closed.
|
|
51
83
|
*/
|
|
52
|
-
function
|
|
84
|
+
export function provenBrowserFile(
|
|
85
|
+
root: string,
|
|
86
|
+
file: string,
|
|
87
|
+
inWebApp: (dir: string) => boolean = createWebAppProbe(root),
|
|
88
|
+
): boolean {
|
|
53
89
|
const path = file.replaceAll("\\", "/")
|
|
54
|
-
if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path)) return
|
|
55
|
-
if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path)) return
|
|
56
|
-
return
|
|
90
|
+
if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path)) return false
|
|
91
|
+
if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path)) return false
|
|
92
|
+
return inWebApp(parentOf(path))
|
|
57
93
|
}
|
|
58
94
|
|
|
59
95
|
const CONFIRMATION_NAME =
|
|
@@ -61,11 +97,11 @@ const CONFIRMATION_NAME =
|
|
|
61
97
|
|
|
62
98
|
/** Client form confirmation compares two values already held by the browser, not secret material. */
|
|
63
99
|
function isClientConfirmationPair(
|
|
64
|
-
|
|
100
|
+
browser: boolean,
|
|
65
101
|
left: string | undefined,
|
|
66
102
|
right: string | undefined,
|
|
67
103
|
): boolean {
|
|
68
|
-
if (
|
|
104
|
+
if (!browser) return false
|
|
69
105
|
const names = [left, right]
|
|
70
106
|
return (
|
|
71
107
|
names.some((name) => name?.toLowerCase() === "password") &&
|
|
@@ -102,13 +138,16 @@ function secretName(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
|
102
138
|
* Presence checks (`token === undefined`, `secret == null`, `apiKey !== ""`) and `typeof` guards are
|
|
103
139
|
* not equality over secret material - rewriting them to a timing-safe comparison would break the code.
|
|
104
140
|
* A comparison against a numeric literal (`signature !== 1`, `signatureLength !== 64`) is a
|
|
105
|
-
* length/version/discriminant check,
|
|
106
|
-
*
|
|
141
|
+
* length/version/discriminant check, and one against `true`/`false` (`persistRefreshToken === true`)
|
|
142
|
+
* is a flag check: secrets are strings or bytes, so a number or a boolean on either side proves this
|
|
143
|
+
* is not the compare a timing oracle applies to.
|
|
107
144
|
*/
|
|
108
145
|
function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): boolean {
|
|
109
146
|
for (const side of [node.left, node.right]) {
|
|
110
147
|
if (ts.isIdentifier(side) && side.text === "undefined") return true
|
|
111
148
|
if (side.kind === ts.SyntaxKind.NullKeyword) return true
|
|
149
|
+
if (side.kind === ts.SyntaxKind.TrueKeyword || side.kind === ts.SyntaxKind.FalseKeyword)
|
|
150
|
+
return true
|
|
112
151
|
if (ts.isStringLiteralLike(side) && side.text === "") return true
|
|
113
152
|
if (ts.isTypeOfExpression(side)) return true
|
|
114
153
|
if (ts.isVoidExpression(side)) return true
|
|
@@ -149,10 +188,143 @@ interface SecretComparison {
|
|
|
149
188
|
readonly line: number
|
|
150
189
|
}
|
|
151
190
|
|
|
191
|
+
// The last word of a name that states a fact about a secret rather than holding it: `tokenType`,
|
|
192
|
+
// `SECRET_CAP_SQLSTATE`, `apiKeyIds`, `apiKeyHeaderName`. Exact words only, so the secret word anywhere
|
|
193
|
+
// else still matches (`tokenValue`, `passwordHash`, `apiKeyHeader`). `code`, `url`, `path` and `prefix`
|
|
194
|
+
// stay out: an OTP, a capability URL and a prefix compare all leak secret material.
|
|
195
|
+
const NON_MATERIAL_WORD: ReadonlySet<string> = new Set([
|
|
196
|
+
"type",
|
|
197
|
+
"kind",
|
|
198
|
+
"name",
|
|
199
|
+
"id",
|
|
200
|
+
"ids",
|
|
201
|
+
"length",
|
|
202
|
+
"len",
|
|
203
|
+
"count",
|
|
204
|
+
"size",
|
|
205
|
+
"mode",
|
|
206
|
+
"policy",
|
|
207
|
+
"version",
|
|
208
|
+
"sqlstate",
|
|
209
|
+
"status",
|
|
210
|
+
"enabled",
|
|
211
|
+
"required",
|
|
212
|
+
"ttl",
|
|
213
|
+
"expiry",
|
|
214
|
+
"expires",
|
|
215
|
+
])
|
|
216
|
+
|
|
217
|
+
function lastWord(name: string): string {
|
|
218
|
+
const words = name
|
|
219
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
220
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
221
|
+
.split(/[\s_$-]+/)
|
|
222
|
+
return (words.findLast((word) => word !== "") ?? "").toLowerCase()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isSecretName(name: string | undefined): boolean {
|
|
226
|
+
return name !== undefined && SECRET.test(name) && !NON_MATERIAL_WORD.has(lastWord(name))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Whether `name` binds a declaration in `statements` itself (not in a nested scope). */
|
|
230
|
+
function declaresIn(
|
|
231
|
+
ts: typeof TSApi,
|
|
232
|
+
statements: TSApi.NodeArray<TSApi.Statement>,
|
|
233
|
+
name: string,
|
|
234
|
+
): boolean {
|
|
235
|
+
return statements.some((statement) => {
|
|
236
|
+
if (ts.isVariableStatement(statement))
|
|
237
|
+
return statement.declarationList.declarations.some((d) => bindsName(ts, d.name, name))
|
|
238
|
+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement))
|
|
239
|
+
return statement.name?.text === name
|
|
240
|
+
return false
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function bindsName(ts: typeof TSApi, binding: TSApi.BindingName, name: string): boolean {
|
|
245
|
+
if (ts.isIdentifier(binding)) return binding.text === name
|
|
246
|
+
return binding.elements.some(
|
|
247
|
+
(element) => !ts.isOmittedExpression(element) && bindsName(ts, element.name, name),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether `id` names a plain parameter of an enclosing function, not a value the file builds: the
|
|
253
|
+
* nearest declaration of the name, walking out, must be that parameter. A loop or catch variable, a
|
|
254
|
+
* local or a destructured parameter in between disproves it.
|
|
255
|
+
*/
|
|
256
|
+
function isParameterRef(ts: typeof TSApi, id: TSApi.Identifier): boolean {
|
|
257
|
+
for (let node: TSApi.Node | undefined = id.parent; node !== undefined; node = node.parent) {
|
|
258
|
+
if (ts.isFunctionLike(node)) {
|
|
259
|
+
for (const parameter of node.parameters) {
|
|
260
|
+
if (ts.isIdentifier(parameter.name)) {
|
|
261
|
+
if (parameter.name.text === id.text) return true
|
|
262
|
+
} else if (bindsName(ts, parameter.name, id.text)) return false
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if ((ts.isBlock(node) || ts.isSourceFile(node)) && declaresIn(ts, node.statements, id.text))
|
|
266
|
+
return false
|
|
267
|
+
if (
|
|
268
|
+
(ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) &&
|
|
269
|
+
node.initializer !== undefined &&
|
|
270
|
+
ts.isVariableDeclarationList(node.initializer) &&
|
|
271
|
+
node.initializer.declarations.some((d) => bindsName(ts, d.name, id.text))
|
|
272
|
+
)
|
|
273
|
+
return false
|
|
274
|
+
if (
|
|
275
|
+
ts.isCatchClause(node) &&
|
|
276
|
+
node.variableDeclaration !== undefined &&
|
|
277
|
+
bindsName(ts, node.variableDeclaration.name, id.text)
|
|
278
|
+
)
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const CONFIG_KEY = /^[A-Z][A-Z0-9_]*$/
|
|
285
|
+
const CONTEXT_NAMES: ReadonlySet<string> = new Set(["c", "ctx", "context"])
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Whether `node` reads deployment configuration: `process.env.X`, `Bun.env.X`, `import.meta.env.X`,
|
|
289
|
+
* `Deno.env.get("X")`, `c.env.UPPER_SNAKE` (also `ctx`/`context`), or `env.X`, where `c` and `env` must
|
|
290
|
+
* be parameters of an enclosing function, never values the file builds. Comparing two such reads
|
|
291
|
+
* involves no request value, so it is no timing oracle (a startup "keys must differ" check).
|
|
292
|
+
*/
|
|
293
|
+
function isConfigRead(ts: typeof TSApi, node: TSApi.Expression): boolean {
|
|
294
|
+
if (ts.isCallExpression(node)) {
|
|
295
|
+
const callee = node.expression
|
|
296
|
+
const [key] = node.arguments
|
|
297
|
+
return (
|
|
298
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
299
|
+
callee.name.text === "get" &&
|
|
300
|
+
ts.isPropertyAccessExpression(callee.expression) &&
|
|
301
|
+
callee.expression.name.text === "env" &&
|
|
302
|
+
ts.isIdentifier(callee.expression.expression) &&
|
|
303
|
+
callee.expression.expression.text === "Deno" &&
|
|
304
|
+
node.arguments.length === 1 &&
|
|
305
|
+
key !== undefined &&
|
|
306
|
+
ts.isStringLiteralLike(key)
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
let key: string
|
|
310
|
+
if (ts.isPropertyAccessExpression(node)) key = node.name.text
|
|
311
|
+
else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression))
|
|
312
|
+
key = node.argumentExpression.text
|
|
313
|
+
else return false
|
|
314
|
+
const object = node.expression
|
|
315
|
+
if (ts.isIdentifier(object)) return object.text === "env" && isParameterRef(ts, object)
|
|
316
|
+
if (!ts.isPropertyAccessExpression(object) || object.name.text !== "env") return false
|
|
317
|
+
const owner = object.expression
|
|
318
|
+
if (ts.isMetaProperty(owner)) return owner.name.text === "meta"
|
|
319
|
+
if (!ts.isIdentifier(owner)) return false
|
|
320
|
+
if (owner.text === "process" || owner.text === "Bun") return true
|
|
321
|
+
return CONTEXT_NAMES.has(owner.text) && CONFIG_KEY.test(key) && isParameterRef(ts, owner)
|
|
322
|
+
}
|
|
323
|
+
|
|
152
324
|
function secretComparisons(
|
|
153
325
|
ts: typeof TSApi,
|
|
154
|
-
file: string,
|
|
155
326
|
tree: TSApi.SourceFile,
|
|
327
|
+
browser: boolean,
|
|
156
328
|
): readonly SecretComparison[] {
|
|
157
329
|
const operators = [
|
|
158
330
|
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
@@ -170,9 +342,9 @@ function secretComparisons(
|
|
|
170
342
|
const left = secretName(ts, node.left)
|
|
171
343
|
const right = secretName(ts, node.right)
|
|
172
344
|
if (
|
|
173
|
-
((left
|
|
174
|
-
|
|
175
|
-
!
|
|
345
|
+
(isSecretName(left) || isSecretName(right)) &&
|
|
346
|
+
!isClientConfirmationPair(browser, left, right) &&
|
|
347
|
+
!(isConfigRead(ts, node.left) && isConfigRead(ts, node.right))
|
|
176
348
|
) {
|
|
177
349
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
178
350
|
found.push({ node, left, right, line })
|
|
@@ -203,11 +375,19 @@ function timingSafeHelper(file: string): string {
|
|
|
203
375
|
* Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
|
|
204
376
|
* add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
|
|
205
377
|
* them, so a line number a check run reported no longer names the same statement after the first fix.
|
|
378
|
+
* `browser` is {@link provenBrowserFile} for the file, so the fix rewrites exactly what check reported.
|
|
206
379
|
*/
|
|
207
|
-
export function rewriteSecretComparisons(
|
|
380
|
+
export function rewriteSecretComparisons(
|
|
381
|
+
ts: typeof TSApi,
|
|
382
|
+
file: string,
|
|
383
|
+
source: string,
|
|
384
|
+
browser: boolean,
|
|
385
|
+
): string {
|
|
208
386
|
const tree = parse(ts, file, source)
|
|
209
387
|
const lines = tree.text.split("\n")
|
|
210
|
-
const sites = secretComparisons(ts,
|
|
388
|
+
const sites = secretComparisons(ts, tree, browser).filter(
|
|
389
|
+
(site) => expectedReason(lines, site.line, "NF-S002") === undefined,
|
|
390
|
+
)
|
|
211
391
|
// A comparison nested inside another (`(token === a) === flag`) is left for the next run, so every
|
|
212
392
|
// edit below covers a disjoint range and applying them from the end keeps earlier offsets valid.
|
|
213
393
|
const outer = sites.filter(
|
|
@@ -246,22 +426,32 @@ export const secretComparisonRule: CheckRule = {
|
|
|
246
426
|
const ts = loaded.compiler
|
|
247
427
|
if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
|
|
248
428
|
const findings: Diagnostic[] = []
|
|
429
|
+
const webDirs = createWebAppProbe(ctx.root)
|
|
249
430
|
for (const file of ctx.project.source.files) {
|
|
250
431
|
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
251
432
|
if (parsed === undefined) continue
|
|
252
433
|
const { tree, lines } = parsed
|
|
253
|
-
|
|
254
|
-
|
|
434
|
+
const browser = provenBrowserFile(ctx.root, file, webDirs)
|
|
435
|
+
for (const { left, right, line } of secretComparisons(ts, tree, browser)) {
|
|
436
|
+
const reviewed = expectedReason(lines, line, "NF-S002") !== undefined
|
|
255
437
|
findings.push(
|
|
256
438
|
diagnostic({
|
|
257
439
|
code: "NF-S002",
|
|
258
|
-
severity: reviewed ? "info" :
|
|
440
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
259
441
|
file,
|
|
260
442
|
line,
|
|
261
443
|
message: reviewed
|
|
262
|
-
? "secret comparison is
|
|
263
|
-
:
|
|
264
|
-
|
|
444
|
+
? "secret comparison is expected here"
|
|
445
|
+
: unexpectedMessage(
|
|
446
|
+
"secret-like values must use a length check and timing-safe comparison",
|
|
447
|
+
lines,
|
|
448
|
+
line,
|
|
449
|
+
"NF-S002",
|
|
450
|
+
),
|
|
451
|
+
evidence: [
|
|
452
|
+
left ?? right ?? "secret comparison",
|
|
453
|
+
...expectationEvidence(lines, line, "NF-S002"),
|
|
454
|
+
],
|
|
265
455
|
...(reviewed
|
|
266
456
|
? {}
|
|
267
457
|
: {
|
|
@@ -303,7 +493,7 @@ export const piiLogRule: CheckRule = {
|
|
|
303
493
|
const name = nameOf(ts, arg)
|
|
304
494
|
if (name !== undefined && PII.test(name)) {
|
|
305
495
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
306
|
-
const reviewed =
|
|
496
|
+
const reviewed = expectedReason(lines, line, "NF-S003") !== undefined
|
|
307
497
|
findings.push(
|
|
308
498
|
diagnostic({
|
|
309
499
|
code: "NF-S003",
|
|
@@ -311,9 +501,14 @@ export const piiLogRule: CheckRule = {
|
|
|
311
501
|
file,
|
|
312
502
|
line,
|
|
313
503
|
message: reviewed
|
|
314
|
-
? "sensitive value in log call is
|
|
315
|
-
:
|
|
316
|
-
|
|
504
|
+
? "sensitive value in log call is expected here"
|
|
505
|
+
: unexpectedMessage(
|
|
506
|
+
"PII-shaped values must not be passed directly to log calls",
|
|
507
|
+
lines,
|
|
508
|
+
line,
|
|
509
|
+
"NF-S003",
|
|
510
|
+
),
|
|
511
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S003")],
|
|
317
512
|
verify: "nifra check --lints-only",
|
|
318
513
|
}),
|
|
319
514
|
)
|
|
@@ -353,6 +548,104 @@ function functionName(ts: typeof TSApi, fn: TSApi.Node): string | undefined {
|
|
|
353
548
|
return undefined
|
|
354
549
|
}
|
|
355
550
|
|
|
551
|
+
/**
|
|
552
|
+
* A gate's name. `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
|
|
553
|
+
* case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them authorization
|
|
554
|
+
* decisions. `ensure`/`verify`/`check`/`validate` name one only before what a gate decides on
|
|
555
|
+
* (`verifyToken`, `ensureLoggedIn`, `checkPermission`), so `ensureDir` and `checkHealth` are not gates.
|
|
556
|
+
*/
|
|
557
|
+
const GATE_NAMES = [
|
|
558
|
+
/^(?:require|assert|authori[sz]e|authenticate)/i,
|
|
559
|
+
/^can[A-Z]/,
|
|
560
|
+
/.Guard$/,
|
|
561
|
+
/^(?:ensure|verify|check|validate)(?:Auth\w*|LoggedIn|Login|Session|Token|Jwt|JWT|Signatures?|Webhook|Hmac|HMAC|Password|Credentials?|Permissions?|Access|Roles?|Admin(?:istrator)?|Owner(?:ship)?|Scopes?|Csrf|CSRF|ApiKey|Origin|Tenant|User|Member(?:ship)?)(?![a-z])/,
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
/** An authenticator returns who is calling, so its "nobody" result (`null`, `undefined`) is a denial. */
|
|
565
|
+
const AUTHENTICATOR = /^authenticate/i
|
|
566
|
+
|
|
567
|
+
/** Result-object fields whose `false` is the denial (`{ ok: false, error }`). */
|
|
568
|
+
const DECISION_FIELDS = new Set([
|
|
569
|
+
"ok",
|
|
570
|
+
"allowed",
|
|
571
|
+
"authorized",
|
|
572
|
+
"authenticated",
|
|
573
|
+
"valid",
|
|
574
|
+
"success",
|
|
575
|
+
"granted",
|
|
576
|
+
])
|
|
577
|
+
|
|
578
|
+
/** Helpers whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). */
|
|
579
|
+
const FAILING_HELPERS = new Set(["fail", "deny", "reject", "forbid", "unauthorized", "abort"])
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Whether a gate's catch block fails closed: it throws, returns `false`, a result object whose decision
|
|
583
|
+
* field is `false`, a `new Response(…)` or a `deny…(…)` call (an authenticator also `null` or
|
|
584
|
+
* nothing), or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
|
|
585
|
+
* conformance/invariant asserts that funnel every error through a `never`-returning `fail()` are not
|
|
586
|
+
* fail-open - the scanner reads no types, so it trusts the helper by name, the same trade the SQL and
|
|
587
|
+
* gate-name heuristics already make. Read from the syntax, so a "throw" in a comment or a string counts
|
|
588
|
+
* for nothing, and a nested function's statements are not the catch's own.
|
|
589
|
+
*/
|
|
590
|
+
function failsClosed(ts: typeof TSApi, block: TSApi.Block, gate: string): boolean {
|
|
591
|
+
const authenticator = AUTHENTICATOR.test(gate)
|
|
592
|
+
let closed = false
|
|
593
|
+
const visit = (node: TSApi.Node): void => {
|
|
594
|
+
if (closed || ts.isFunctionLike(node)) return
|
|
595
|
+
if (
|
|
596
|
+
ts.isThrowStatement(node) ||
|
|
597
|
+
(ts.isReturnStatement(node) &&
|
|
598
|
+
(node.expression === undefined
|
|
599
|
+
? authenticator
|
|
600
|
+
: isDenial(ts, node.expression, authenticator))) ||
|
|
601
|
+
(ts.isCallExpression(node) &&
|
|
602
|
+
ts.isIdentifier(node.expression) &&
|
|
603
|
+
FAILING_HELPERS.has(node.expression.text))
|
|
604
|
+
) {
|
|
605
|
+
closed = true
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
ts.forEachChild(node, visit)
|
|
609
|
+
}
|
|
610
|
+
visit(block)
|
|
611
|
+
return closed
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function isDenial(ts: typeof TSApi, expression: TSApi.Expression, authenticator: boolean): boolean {
|
|
615
|
+
let value = expression
|
|
616
|
+
while (
|
|
617
|
+
ts.isParenthesizedExpression(value) ||
|
|
618
|
+
ts.isAwaitExpression(value) ||
|
|
619
|
+
ts.isAsExpression(value) ||
|
|
620
|
+
ts.isSatisfiesExpression(value)
|
|
621
|
+
)
|
|
622
|
+
value = value.expression
|
|
623
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) return true
|
|
624
|
+
if (
|
|
625
|
+
authenticator &&
|
|
626
|
+
(value.kind === ts.SyntaxKind.NullKeyword ||
|
|
627
|
+
ts.isVoidExpression(value) ||
|
|
628
|
+
(ts.isIdentifier(value) && value.text === "undefined"))
|
|
629
|
+
)
|
|
630
|
+
return true
|
|
631
|
+
if (ts.isObjectLiteralExpression(value)) {
|
|
632
|
+
return value.properties.some(
|
|
633
|
+
(property) =>
|
|
634
|
+
ts.isPropertyAssignment(property) &&
|
|
635
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
636
|
+
DECISION_FIELDS.has(property.name.text) &&
|
|
637
|
+
property.initializer.kind === ts.SyntaxKind.FalseKeyword,
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
if (ts.isNewExpression(value))
|
|
641
|
+
return ts.isIdentifier(value.expression) && value.expression.text === "Response"
|
|
642
|
+
return (
|
|
643
|
+
ts.isCallExpression(value) &&
|
|
644
|
+
ts.isIdentifier(value.expression) &&
|
|
645
|
+
value.expression.text.startsWith("deny")
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
356
649
|
export const failOpenGateRule: CheckRule = {
|
|
357
650
|
code: "NF-S001",
|
|
358
651
|
title: "Fail-open gate",
|
|
@@ -362,47 +655,41 @@ export const failOpenGateRule: CheckRule = {
|
|
|
362
655
|
const ts = loaded.compiler
|
|
363
656
|
if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
|
|
364
657
|
const findings: Diagnostic[] = []
|
|
658
|
+
const webDirs = createWebAppProbe(ctx.root)
|
|
365
659
|
for (const file of ctx.project.source.files) {
|
|
366
660
|
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
367
661
|
if (parsed === undefined) continue
|
|
368
662
|
const { tree, lines } = parsed
|
|
663
|
+
let browser: boolean | undefined
|
|
369
664
|
const visit = (node: TSApi.Node): void => {
|
|
370
665
|
if (ts.isCatchClause(node)) {
|
|
371
666
|
let parent: TSApi.Node | undefined = node.parent
|
|
372
667
|
while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
|
|
373
668
|
const name = parent === undefined ? undefined : functionName(ts, parent)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// authorization decisions. The other roots stay case-insensitive.
|
|
377
|
-
const gateName =
|
|
378
|
-
name !== undefined &&
|
|
379
|
-
(/^(?:require|assert|authorize)/i.test(name) || /^can[A-Z]/.test(name))
|
|
380
|
-
if (name !== undefined && gateName) {
|
|
381
|
-
const text = node.block.getText(tree)
|
|
382
|
-
// A catch fails closed when it rethrows, returns an explicit denial, or hands the failure
|
|
383
|
-
// to a helper whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). The last
|
|
384
|
-
// form is why conformance/invariant asserts that funnel every error through a
|
|
385
|
-
// `never`-returning `fail()` are not fail-open - the scanner reads no types, so it trusts
|
|
386
|
-
// the helper by name, the same trade the SQL and gate-name heuristics already make.
|
|
387
|
-
if (
|
|
388
|
-
!/\b(?:throw|return\s+(?:false|new\s+Response|deny))/s.test(text) &&
|
|
389
|
-
!/(?:^|[^.\w])(?:fail|deny|reject|forbid|unauthorized|abort)\s*\(/s.test(text)
|
|
390
|
-
) {
|
|
669
|
+
if (name !== undefined && GATE_NAMES.some((gate) => gate.test(name))) {
|
|
670
|
+
if (!failsClosed(ts, node.block, name)) {
|
|
391
671
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
392
|
-
const reviewed =
|
|
672
|
+
const reviewed = expectedReason(lines, line, "NF-S001") !== undefined
|
|
673
|
+
browser ??= provenBrowserFile(ctx.root, file, webDirs)
|
|
393
674
|
findings.push(
|
|
394
675
|
diagnostic({
|
|
395
676
|
code: "NF-S001",
|
|
396
|
-
severity: reviewed ? "info" : "error",
|
|
677
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
397
678
|
file,
|
|
398
679
|
line,
|
|
399
680
|
message: reviewed
|
|
400
|
-
? "gate catch block is
|
|
401
|
-
:
|
|
681
|
+
? "gate catch block is expected here"
|
|
682
|
+
: unexpectedMessage(
|
|
683
|
+
"gate catch blocks must rethrow or return an explicit denial",
|
|
684
|
+
lines,
|
|
685
|
+
line,
|
|
686
|
+
"NF-S001",
|
|
687
|
+
),
|
|
402
688
|
evidence: [
|
|
403
689
|
name,
|
|
404
690
|
"catch block has no denial or rethrow",
|
|
405
|
-
...
|
|
691
|
+
...(browser ? ["browser code: the server's own gate decides"] : []),
|
|
692
|
+
...expectationEvidence(lines, line, "NF-S001"),
|
|
406
693
|
],
|
|
407
694
|
verify: "nifra check --lints-only",
|
|
408
695
|
}),
|
|
@@ -469,7 +756,7 @@ export const corsOriginPredicateRule: CheckRule = {
|
|
|
469
756
|
: false
|
|
470
757
|
if (ignoresOrigin) {
|
|
471
758
|
const line = tree.getLineAndCharacterOfPosition(prop.getStart(tree)).line + 1
|
|
472
|
-
const reviewed =
|
|
759
|
+
const reviewed = expectedReason(lines, line, "NF-S004") !== undefined
|
|
473
760
|
findings.push(
|
|
474
761
|
diagnostic({
|
|
475
762
|
code: "NF-S004",
|
|
@@ -477,9 +764,17 @@ export const corsOriginPredicateRule: CheckRule = {
|
|
|
477
764
|
file,
|
|
478
765
|
line,
|
|
479
766
|
message: reviewed
|
|
480
|
-
? "constant CORS origin predicate is
|
|
481
|
-
:
|
|
482
|
-
|
|
767
|
+
? "constant CORS origin predicate is expected here"
|
|
768
|
+
: unexpectedMessage(
|
|
769
|
+
"CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument",
|
|
770
|
+
lines,
|
|
771
|
+
line,
|
|
772
|
+
"NF-S004",
|
|
773
|
+
),
|
|
774
|
+
evidence: [
|
|
775
|
+
"origin predicate",
|
|
776
|
+
...expectationEvidence(lines, line, "NF-S004"),
|
|
777
|
+
],
|
|
483
778
|
verify: "nifra check --lints-only",
|
|
484
779
|
}),
|
|
485
780
|
)
|
|
@@ -520,7 +815,7 @@ export const externalRedirectRule: CheckRule = {
|
|
|
520
815
|
prop.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
521
816
|
) {
|
|
522
817
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
523
|
-
const reviewed =
|
|
818
|
+
const reviewed = expectedReason(lines, line, "NF-S005") !== undefined
|
|
524
819
|
findings.push(
|
|
525
820
|
diagnostic({
|
|
526
821
|
code: "NF-S005",
|
|
@@ -528,9 +823,14 @@ export const externalRedirectRule: CheckRule = {
|
|
|
528
823
|
file,
|
|
529
824
|
line,
|
|
530
825
|
message: reviewed
|
|
531
|
-
? "external redirect is
|
|
532
|
-
:
|
|
533
|
-
|
|
826
|
+
? "external redirect is expected here"
|
|
827
|
+
: unexpectedMessage(
|
|
828
|
+
"redirect opts out of the same-origin default - verify the target can never be derived from request input (open-redirect risk)",
|
|
829
|
+
lines,
|
|
830
|
+
line,
|
|
831
|
+
"NF-S005",
|
|
832
|
+
),
|
|
833
|
+
evidence: ["external: true", ...expectationEvidence(lines, line, "NF-S005")],
|
|
534
834
|
verify: "nifra check --lints-only",
|
|
535
835
|
}),
|
|
536
836
|
)
|
|
@@ -574,7 +874,7 @@ export const assuranceEscapeHatchRule: CheckRule = {
|
|
|
574
874
|
const consequence = name !== undefined ? ESCAPE_HATCHES[name] : undefined
|
|
575
875
|
if (name !== undefined && consequence !== undefined) {
|
|
576
876
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
577
|
-
const reviewed =
|
|
877
|
+
const reviewed = expectedReason(lines, line, "NF-S006") !== undefined
|
|
578
878
|
findings.push(
|
|
579
879
|
diagnostic({
|
|
580
880
|
code: "NF-S006",
|
|
@@ -582,9 +882,9 @@ export const assuranceEscapeHatchRule: CheckRule = {
|
|
|
582
882
|
file,
|
|
583
883
|
line,
|
|
584
884
|
message: reviewed
|
|
585
|
-
? `${name} escape hatch is
|
|
586
|
-
: `${name}: ${consequence}`,
|
|
587
|
-
evidence: [name, ...
|
|
885
|
+
? `${name} escape hatch is expected here`
|
|
886
|
+
: unexpectedMessage(`${name}: ${consequence}`, lines, line, "NF-S006"),
|
|
887
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S006")],
|
|
588
888
|
verify: "nifra check --lints-only",
|
|
589
889
|
}),
|
|
590
890
|
)
|
|
@@ -658,6 +958,24 @@ export const unprefixedSecureCookieRule: CheckRule = {
|
|
|
658
958
|
},
|
|
659
959
|
}
|
|
660
960
|
|
|
961
|
+
/** Reads the backend the contract-lock check loaded; this rule runs no project code of its own. */
|
|
962
|
+
export const inputBeforeAuthenticationRule: CheckRule = {
|
|
963
|
+
code: "NF-S008",
|
|
964
|
+
title: "Authenticated route validates input before authentication",
|
|
965
|
+
async scan(ctx) {
|
|
966
|
+
return (ctx.project.check.contracts.inputBeforeAuth ?? []).map((route) =>
|
|
967
|
+
diagnostic({
|
|
968
|
+
code: "NF-S008",
|
|
969
|
+
severity: "warn",
|
|
970
|
+
file: BACKEND_APP_FILE,
|
|
971
|
+
message: `${route} authenticates in a hook that runs after its declared input is validated, so an unauthenticated caller reaches the body parser and reads the validation issues. Authenticate with \`app.authenticate()\` (the first-party auth middleware already does), or set \`validationOrder: "auth-before-validation"\` on the route so its hooks run first`,
|
|
972
|
+
evidence: [route],
|
|
973
|
+
verify: "nifra check --lints-only",
|
|
974
|
+
}),
|
|
975
|
+
)
|
|
976
|
+
},
|
|
977
|
+
}
|
|
978
|
+
|
|
661
979
|
export const securityRules = Object.freeze([
|
|
662
980
|
failOpenGateRule,
|
|
663
981
|
secretComparisonRule,
|
|
@@ -666,4 +984,5 @@ export const securityRules = Object.freeze([
|
|
|
666
984
|
externalRedirectRule,
|
|
667
985
|
assuranceEscapeHatchRule,
|
|
668
986
|
unprefixedSecureCookieRule,
|
|
987
|
+
inputBeforeAuthenticationRule,
|
|
669
988
|
])
|