@nifrajs/cli 4.0.2 → 5.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/README.md +3 -0
- 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 +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -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 +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- 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 +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +16 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +133 -29
- 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/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.map +1 -1
- package/dist/fix-recipes.js +21 -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 +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- 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 +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -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 +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- 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 +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- 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 +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -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-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 +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +778 -64
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -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/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/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 +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -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 +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -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 +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- 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 +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- 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 +192 -40
- 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 +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -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 +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- 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 +42 -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 +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +166 -37
- 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/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 +25 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- 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 +8 -3
- package/src/migrate-layout.ts +885 -63
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -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,64 @@ 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
|
-
* there is
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* server - fail closed.
|
|
77
|
+
* Whether NF-S002 may treat `file` as browser code, which compares values the client already holds,
|
|
78
|
+
* so a finding there is advisory. It must be browser code by path (a route's frontend half,
|
|
79
|
+
* `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
|
|
80
|
+
* without a web dependency runs on the server. `shared/` also runs on the server, and a file that
|
|
81
|
+
* cannot be classified is treated as server - fail closed.
|
|
51
82
|
*/
|
|
52
|
-
function
|
|
83
|
+
export function provenBrowserFile(
|
|
84
|
+
root: string,
|
|
85
|
+
file: string,
|
|
86
|
+
inWebApp: (dir: string) => boolean = createWebAppProbe(root),
|
|
87
|
+
): boolean {
|
|
53
88
|
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
|
|
89
|
+
if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path)) return false
|
|
90
|
+
if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path)) return false
|
|
91
|
+
return inWebApp(parentOf(path))
|
|
57
92
|
}
|
|
58
93
|
|
|
59
94
|
const CONFIRMATION_NAME =
|
|
@@ -61,11 +96,11 @@ const CONFIRMATION_NAME =
|
|
|
61
96
|
|
|
62
97
|
/** Client form confirmation compares two values already held by the browser, not secret material. */
|
|
63
98
|
function isClientConfirmationPair(
|
|
64
|
-
|
|
99
|
+
browser: boolean,
|
|
65
100
|
left: string | undefined,
|
|
66
101
|
right: string | undefined,
|
|
67
102
|
): boolean {
|
|
68
|
-
if (
|
|
103
|
+
if (!browser) return false
|
|
69
104
|
const names = [left, right]
|
|
70
105
|
return (
|
|
71
106
|
names.some((name) => name?.toLowerCase() === "password") &&
|
|
@@ -102,13 +137,16 @@ function secretName(ts: typeof TSApi, node: TSApi.Node): string | undefined {
|
|
|
102
137
|
* Presence checks (`token === undefined`, `secret == null`, `apiKey !== ""`) and `typeof` guards are
|
|
103
138
|
* not equality over secret material - rewriting them to a timing-safe comparison would break the code.
|
|
104
139
|
* A comparison against a numeric literal (`signature !== 1`, `signatureLength !== 64`) is a
|
|
105
|
-
* length/version/discriminant check,
|
|
106
|
-
*
|
|
140
|
+
* length/version/discriminant check, and one against `true`/`false` (`persistRefreshToken === true`)
|
|
141
|
+
* is a flag check: secrets are strings or bytes, so a number or a boolean on either side proves this
|
|
142
|
+
* is not the compare a timing oracle applies to.
|
|
107
143
|
*/
|
|
108
144
|
function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): boolean {
|
|
109
145
|
for (const side of [node.left, node.right]) {
|
|
110
146
|
if (ts.isIdentifier(side) && side.text === "undefined") return true
|
|
111
147
|
if (side.kind === ts.SyntaxKind.NullKeyword) return true
|
|
148
|
+
if (side.kind === ts.SyntaxKind.TrueKeyword || side.kind === ts.SyntaxKind.FalseKeyword)
|
|
149
|
+
return true
|
|
112
150
|
if (ts.isStringLiteralLike(side) && side.text === "") return true
|
|
113
151
|
if (ts.isTypeOfExpression(side)) return true
|
|
114
152
|
if (ts.isVoidExpression(side)) return true
|
|
@@ -149,10 +187,143 @@ interface SecretComparison {
|
|
|
149
187
|
readonly line: number
|
|
150
188
|
}
|
|
151
189
|
|
|
190
|
+
// The last word of a name that states a fact about a secret rather than holding it: `tokenType`,
|
|
191
|
+
// `SECRET_CAP_SQLSTATE`, `apiKeyIds`, `apiKeyHeaderName`. Exact words only, so the secret word anywhere
|
|
192
|
+
// else still matches (`tokenValue`, `passwordHash`, `apiKeyHeader`). `code`, `url`, `path` and `prefix`
|
|
193
|
+
// stay out: an OTP, a capability URL and a prefix compare all leak secret material.
|
|
194
|
+
const NON_MATERIAL_WORD: ReadonlySet<string> = new Set([
|
|
195
|
+
"type",
|
|
196
|
+
"kind",
|
|
197
|
+
"name",
|
|
198
|
+
"id",
|
|
199
|
+
"ids",
|
|
200
|
+
"length",
|
|
201
|
+
"len",
|
|
202
|
+
"count",
|
|
203
|
+
"size",
|
|
204
|
+
"mode",
|
|
205
|
+
"policy",
|
|
206
|
+
"version",
|
|
207
|
+
"sqlstate",
|
|
208
|
+
"status",
|
|
209
|
+
"enabled",
|
|
210
|
+
"required",
|
|
211
|
+
"ttl",
|
|
212
|
+
"expiry",
|
|
213
|
+
"expires",
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
function lastWord(name: string): string {
|
|
217
|
+
const words = name
|
|
218
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
219
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
220
|
+
.split(/[\s_$-]+/)
|
|
221
|
+
return (words.findLast((word) => word !== "") ?? "").toLowerCase()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isSecretName(name: string | undefined): boolean {
|
|
225
|
+
return name !== undefined && SECRET.test(name) && !NON_MATERIAL_WORD.has(lastWord(name))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whether `name` binds a declaration in `statements` itself (not in a nested scope). */
|
|
229
|
+
function declaresIn(
|
|
230
|
+
ts: typeof TSApi,
|
|
231
|
+
statements: TSApi.NodeArray<TSApi.Statement>,
|
|
232
|
+
name: string,
|
|
233
|
+
): boolean {
|
|
234
|
+
return statements.some((statement) => {
|
|
235
|
+
if (ts.isVariableStatement(statement))
|
|
236
|
+
return statement.declarationList.declarations.some((d) => bindsName(ts, d.name, name))
|
|
237
|
+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement))
|
|
238
|
+
return statement.name?.text === name
|
|
239
|
+
return false
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function bindsName(ts: typeof TSApi, binding: TSApi.BindingName, name: string): boolean {
|
|
244
|
+
if (ts.isIdentifier(binding)) return binding.text === name
|
|
245
|
+
return binding.elements.some(
|
|
246
|
+
(element) => !ts.isOmittedExpression(element) && bindsName(ts, element.name, name),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whether `id` names a plain parameter of an enclosing function, not a value the file builds: the
|
|
252
|
+
* nearest declaration of the name, walking out, must be that parameter. A loop or catch variable, a
|
|
253
|
+
* local or a destructured parameter in between disproves it.
|
|
254
|
+
*/
|
|
255
|
+
function isParameterRef(ts: typeof TSApi, id: TSApi.Identifier): boolean {
|
|
256
|
+
for (let node: TSApi.Node | undefined = id.parent; node !== undefined; node = node.parent) {
|
|
257
|
+
if (ts.isFunctionLike(node)) {
|
|
258
|
+
for (const parameter of node.parameters) {
|
|
259
|
+
if (ts.isIdentifier(parameter.name)) {
|
|
260
|
+
if (parameter.name.text === id.text) return true
|
|
261
|
+
} else if (bindsName(ts, parameter.name, id.text)) return false
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if ((ts.isBlock(node) || ts.isSourceFile(node)) && declaresIn(ts, node.statements, id.text))
|
|
265
|
+
return false
|
|
266
|
+
if (
|
|
267
|
+
(ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) &&
|
|
268
|
+
node.initializer !== undefined &&
|
|
269
|
+
ts.isVariableDeclarationList(node.initializer) &&
|
|
270
|
+
node.initializer.declarations.some((d) => bindsName(ts, d.name, id.text))
|
|
271
|
+
)
|
|
272
|
+
return false
|
|
273
|
+
if (
|
|
274
|
+
ts.isCatchClause(node) &&
|
|
275
|
+
node.variableDeclaration !== undefined &&
|
|
276
|
+
bindsName(ts, node.variableDeclaration.name, id.text)
|
|
277
|
+
)
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
return false
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const CONFIG_KEY = /^[A-Z][A-Z0-9_]*$/
|
|
284
|
+
const CONTEXT_NAMES: ReadonlySet<string> = new Set(["c", "ctx", "context"])
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether `node` reads deployment configuration: `process.env.X`, `Bun.env.X`, `import.meta.env.X`,
|
|
288
|
+
* `Deno.env.get("X")`, `c.env.UPPER_SNAKE` (also `ctx`/`context`), or `env.X`, where `c` and `env` must
|
|
289
|
+
* be parameters of an enclosing function, never values the file builds. Comparing two such reads
|
|
290
|
+
* involves no request value, so it is no timing oracle (a startup "keys must differ" check).
|
|
291
|
+
*/
|
|
292
|
+
function isConfigRead(ts: typeof TSApi, node: TSApi.Expression): boolean {
|
|
293
|
+
if (ts.isCallExpression(node)) {
|
|
294
|
+
const callee = node.expression
|
|
295
|
+
const [key] = node.arguments
|
|
296
|
+
return (
|
|
297
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
298
|
+
callee.name.text === "get" &&
|
|
299
|
+
ts.isPropertyAccessExpression(callee.expression) &&
|
|
300
|
+
callee.expression.name.text === "env" &&
|
|
301
|
+
ts.isIdentifier(callee.expression.expression) &&
|
|
302
|
+
callee.expression.expression.text === "Deno" &&
|
|
303
|
+
node.arguments.length === 1 &&
|
|
304
|
+
key !== undefined &&
|
|
305
|
+
ts.isStringLiteralLike(key)
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
let key: string
|
|
309
|
+
if (ts.isPropertyAccessExpression(node)) key = node.name.text
|
|
310
|
+
else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression))
|
|
311
|
+
key = node.argumentExpression.text
|
|
312
|
+
else return false
|
|
313
|
+
const object = node.expression
|
|
314
|
+
if (ts.isIdentifier(object)) return object.text === "env" && isParameterRef(ts, object)
|
|
315
|
+
if (!ts.isPropertyAccessExpression(object) || object.name.text !== "env") return false
|
|
316
|
+
const owner = object.expression
|
|
317
|
+
if (ts.isMetaProperty(owner)) return owner.name.text === "meta"
|
|
318
|
+
if (!ts.isIdentifier(owner)) return false
|
|
319
|
+
if (owner.text === "process" || owner.text === "Bun") return true
|
|
320
|
+
return CONTEXT_NAMES.has(owner.text) && CONFIG_KEY.test(key) && isParameterRef(ts, owner)
|
|
321
|
+
}
|
|
322
|
+
|
|
152
323
|
function secretComparisons(
|
|
153
324
|
ts: typeof TSApi,
|
|
154
|
-
file: string,
|
|
155
325
|
tree: TSApi.SourceFile,
|
|
326
|
+
browser: boolean,
|
|
156
327
|
): readonly SecretComparison[] {
|
|
157
328
|
const operators = [
|
|
158
329
|
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
@@ -170,9 +341,9 @@ function secretComparisons(
|
|
|
170
341
|
const left = secretName(ts, node.left)
|
|
171
342
|
const right = secretName(ts, node.right)
|
|
172
343
|
if (
|
|
173
|
-
((left
|
|
174
|
-
|
|
175
|
-
!
|
|
344
|
+
(isSecretName(left) || isSecretName(right)) &&
|
|
345
|
+
!isClientConfirmationPair(browser, left, right) &&
|
|
346
|
+
!(isConfigRead(ts, node.left) && isConfigRead(ts, node.right))
|
|
176
347
|
) {
|
|
177
348
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
178
349
|
found.push({ node, left, right, line })
|
|
@@ -203,11 +374,19 @@ function timingSafeHelper(file: string): string {
|
|
|
203
374
|
* Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
|
|
204
375
|
* add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
|
|
205
376
|
* them, so a line number a check run reported no longer names the same statement after the first fix.
|
|
377
|
+
* `browser` is {@link provenBrowserFile} for the file, so the fix rewrites exactly what check reported.
|
|
206
378
|
*/
|
|
207
|
-
export function rewriteSecretComparisons(
|
|
379
|
+
export function rewriteSecretComparisons(
|
|
380
|
+
ts: typeof TSApi,
|
|
381
|
+
file: string,
|
|
382
|
+
source: string,
|
|
383
|
+
browser: boolean,
|
|
384
|
+
): string {
|
|
208
385
|
const tree = parse(ts, file, source)
|
|
209
386
|
const lines = tree.text.split("\n")
|
|
210
|
-
const sites = secretComparisons(ts,
|
|
387
|
+
const sites = secretComparisons(ts, tree, browser).filter(
|
|
388
|
+
(site) => expectedReason(lines, site.line, "NF-S002") === undefined,
|
|
389
|
+
)
|
|
211
390
|
// A comparison nested inside another (`(token === a) === flag`) is left for the next run, so every
|
|
212
391
|
// edit below covers a disjoint range and applying them from the end keeps earlier offsets valid.
|
|
213
392
|
const outer = sites.filter(
|
|
@@ -246,22 +425,32 @@ export const secretComparisonRule: CheckRule = {
|
|
|
246
425
|
const ts = loaded.compiler
|
|
247
426
|
if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
|
|
248
427
|
const findings: Diagnostic[] = []
|
|
428
|
+
const webDirs = createWebAppProbe(ctx.root)
|
|
249
429
|
for (const file of ctx.project.source.files) {
|
|
250
430
|
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
251
431
|
if (parsed === undefined) continue
|
|
252
432
|
const { tree, lines } = parsed
|
|
253
|
-
|
|
254
|
-
|
|
433
|
+
const browser = provenBrowserFile(ctx.root, file, webDirs)
|
|
434
|
+
for (const { left, right, line } of secretComparisons(ts, tree, browser)) {
|
|
435
|
+
const reviewed = expectedReason(lines, line, "NF-S002") !== undefined
|
|
255
436
|
findings.push(
|
|
256
437
|
diagnostic({
|
|
257
438
|
code: "NF-S002",
|
|
258
|
-
severity: reviewed ? "info" :
|
|
439
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
259
440
|
file,
|
|
260
441
|
line,
|
|
261
442
|
message: reviewed
|
|
262
|
-
? "secret comparison is
|
|
263
|
-
:
|
|
264
|
-
|
|
443
|
+
? "secret comparison is expected here"
|
|
444
|
+
: unexpectedMessage(
|
|
445
|
+
"secret-like values must use a length check and timing-safe comparison",
|
|
446
|
+
lines,
|
|
447
|
+
line,
|
|
448
|
+
"NF-S002",
|
|
449
|
+
),
|
|
450
|
+
evidence: [
|
|
451
|
+
left ?? right ?? "secret comparison",
|
|
452
|
+
...expectationEvidence(lines, line, "NF-S002"),
|
|
453
|
+
],
|
|
265
454
|
...(reviewed
|
|
266
455
|
? {}
|
|
267
456
|
: {
|
|
@@ -303,7 +492,7 @@ export const piiLogRule: CheckRule = {
|
|
|
303
492
|
const name = nameOf(ts, arg)
|
|
304
493
|
if (name !== undefined && PII.test(name)) {
|
|
305
494
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
306
|
-
const reviewed =
|
|
495
|
+
const reviewed = expectedReason(lines, line, "NF-S003") !== undefined
|
|
307
496
|
findings.push(
|
|
308
497
|
diagnostic({
|
|
309
498
|
code: "NF-S003",
|
|
@@ -311,9 +500,14 @@ export const piiLogRule: CheckRule = {
|
|
|
311
500
|
file,
|
|
312
501
|
line,
|
|
313
502
|
message: reviewed
|
|
314
|
-
? "sensitive value in log call is
|
|
315
|
-
:
|
|
316
|
-
|
|
503
|
+
? "sensitive value in log call is expected here"
|
|
504
|
+
: unexpectedMessage(
|
|
505
|
+
"PII-shaped values must not be passed directly to log calls",
|
|
506
|
+
lines,
|
|
507
|
+
line,
|
|
508
|
+
"NF-S003",
|
|
509
|
+
),
|
|
510
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S003")],
|
|
317
511
|
verify: "nifra check --lints-only",
|
|
318
512
|
}),
|
|
319
513
|
)
|
|
@@ -353,6 +547,71 @@ function functionName(ts: typeof TSApi, fn: TSApi.Node): string | undefined {
|
|
|
353
547
|
return undefined
|
|
354
548
|
}
|
|
355
549
|
|
|
550
|
+
/**
|
|
551
|
+
* A gate's name. `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
|
|
552
|
+
* case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them authorization
|
|
553
|
+
* decisions. `ensure`/`verify`/`check`/`validate` name one only before what a gate decides on
|
|
554
|
+
* (`verifyToken`, `ensureLoggedIn`, `checkPermission`), so `ensureDir` and `checkHealth` are not gates.
|
|
555
|
+
*/
|
|
556
|
+
const GATE_NAMES = [
|
|
557
|
+
/^(?:require|assert|authori[sz]e|authenticate)/i,
|
|
558
|
+
/^can[A-Z]/,
|
|
559
|
+
/.Guard$/,
|
|
560
|
+
/^(?:ensure|verify|check|validate)(?:Auth|LoggedIn|Login|Session|Token|Jwt|JWT|Signature|Webhook|Hmac|HMAC|Password|Credential|Permission|Access|Role|Admin|Owner|Scope|Csrf|CSRF|ApiKey|Origin|Tenant|User|Member)/,
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
/** Helpers whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). */
|
|
564
|
+
const FAILING_HELPERS = new Set(["fail", "deny", "reject", "forbid", "unauthorized", "abort"])
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Whether a gate's catch block fails closed: it throws, returns `false`, a `new Response(…)` or a
|
|
568
|
+
* `deny…(…)` call, or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
|
|
569
|
+
* conformance/invariant asserts that funnel every error through a `never`-returning `fail()` are not
|
|
570
|
+
* fail-open - the scanner reads no types, so it trusts the helper by name, the same trade the SQL and
|
|
571
|
+
* gate-name heuristics already make. Read from the syntax, so a "throw" in a comment or a string counts
|
|
572
|
+
* for nothing, and a nested function's statements are not the catch's own.
|
|
573
|
+
*/
|
|
574
|
+
function failsClosed(ts: typeof TSApi, block: TSApi.Block): boolean {
|
|
575
|
+
let closed = false
|
|
576
|
+
const visit = (node: TSApi.Node): void => {
|
|
577
|
+
if (closed || ts.isFunctionLike(node)) return
|
|
578
|
+
if (
|
|
579
|
+
ts.isThrowStatement(node) ||
|
|
580
|
+
(ts.isReturnStatement(node) &&
|
|
581
|
+
node.expression !== undefined &&
|
|
582
|
+
isDenial(ts, node.expression)) ||
|
|
583
|
+
(ts.isCallExpression(node) &&
|
|
584
|
+
ts.isIdentifier(node.expression) &&
|
|
585
|
+
FAILING_HELPERS.has(node.expression.text))
|
|
586
|
+
) {
|
|
587
|
+
closed = true
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
ts.forEachChild(node, visit)
|
|
591
|
+
}
|
|
592
|
+
visit(block)
|
|
593
|
+
return closed
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function isDenial(ts: typeof TSApi, expression: TSApi.Expression): boolean {
|
|
597
|
+
let value = expression
|
|
598
|
+
while (
|
|
599
|
+
ts.isParenthesizedExpression(value) ||
|
|
600
|
+
ts.isAwaitExpression(value) ||
|
|
601
|
+
ts.isAsExpression(value) ||
|
|
602
|
+
ts.isSatisfiesExpression(value)
|
|
603
|
+
)
|
|
604
|
+
value = value.expression
|
|
605
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) return true
|
|
606
|
+
if (ts.isNewExpression(value))
|
|
607
|
+
return ts.isIdentifier(value.expression) && value.expression.text === "Response"
|
|
608
|
+
return (
|
|
609
|
+
ts.isCallExpression(value) &&
|
|
610
|
+
ts.isIdentifier(value.expression) &&
|
|
611
|
+
value.expression.text.startsWith("deny")
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
356
615
|
export const failOpenGateRule: CheckRule = {
|
|
357
616
|
code: "NF-S001",
|
|
358
617
|
title: "Fail-open gate",
|
|
@@ -371,25 +630,10 @@ export const failOpenGateRule: CheckRule = {
|
|
|
371
630
|
let parent: TSApi.Node | undefined = node.parent
|
|
372
631
|
while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
|
|
373
632
|
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
|
-
) {
|
|
633
|
+
if (name !== undefined && GATE_NAMES.some((gate) => gate.test(name))) {
|
|
634
|
+
if (!failsClosed(ts, node.block)) {
|
|
391
635
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
392
|
-
const reviewed =
|
|
636
|
+
const reviewed = expectedReason(lines, line, "NF-S001") !== undefined
|
|
393
637
|
findings.push(
|
|
394
638
|
diagnostic({
|
|
395
639
|
code: "NF-S001",
|
|
@@ -397,12 +641,17 @@ export const failOpenGateRule: CheckRule = {
|
|
|
397
641
|
file,
|
|
398
642
|
line,
|
|
399
643
|
message: reviewed
|
|
400
|
-
? "gate catch block is
|
|
401
|
-
:
|
|
644
|
+
? "gate catch block is expected here"
|
|
645
|
+
: unexpectedMessage(
|
|
646
|
+
"gate catch blocks must rethrow or return an explicit denial",
|
|
647
|
+
lines,
|
|
648
|
+
line,
|
|
649
|
+
"NF-S001",
|
|
650
|
+
),
|
|
402
651
|
evidence: [
|
|
403
652
|
name,
|
|
404
653
|
"catch block has no denial or rethrow",
|
|
405
|
-
...
|
|
654
|
+
...expectationEvidence(lines, line, "NF-S001"),
|
|
406
655
|
],
|
|
407
656
|
verify: "nifra check --lints-only",
|
|
408
657
|
}),
|
|
@@ -469,7 +718,7 @@ export const corsOriginPredicateRule: CheckRule = {
|
|
|
469
718
|
: false
|
|
470
719
|
if (ignoresOrigin) {
|
|
471
720
|
const line = tree.getLineAndCharacterOfPosition(prop.getStart(tree)).line + 1
|
|
472
|
-
const reviewed =
|
|
721
|
+
const reviewed = expectedReason(lines, line, "NF-S004") !== undefined
|
|
473
722
|
findings.push(
|
|
474
723
|
diagnostic({
|
|
475
724
|
code: "NF-S004",
|
|
@@ -477,9 +726,17 @@ export const corsOriginPredicateRule: CheckRule = {
|
|
|
477
726
|
file,
|
|
478
727
|
line,
|
|
479
728
|
message: reviewed
|
|
480
|
-
? "constant CORS origin predicate is
|
|
481
|
-
:
|
|
482
|
-
|
|
729
|
+
? "constant CORS origin predicate is expected here"
|
|
730
|
+
: unexpectedMessage(
|
|
731
|
+
"CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument",
|
|
732
|
+
lines,
|
|
733
|
+
line,
|
|
734
|
+
"NF-S004",
|
|
735
|
+
),
|
|
736
|
+
evidence: [
|
|
737
|
+
"origin predicate",
|
|
738
|
+
...expectationEvidence(lines, line, "NF-S004"),
|
|
739
|
+
],
|
|
483
740
|
verify: "nifra check --lints-only",
|
|
484
741
|
}),
|
|
485
742
|
)
|
|
@@ -520,7 +777,7 @@ export const externalRedirectRule: CheckRule = {
|
|
|
520
777
|
prop.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
521
778
|
) {
|
|
522
779
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
523
|
-
const reviewed =
|
|
780
|
+
const reviewed = expectedReason(lines, line, "NF-S005") !== undefined
|
|
524
781
|
findings.push(
|
|
525
782
|
diagnostic({
|
|
526
783
|
code: "NF-S005",
|
|
@@ -528,9 +785,14 @@ export const externalRedirectRule: CheckRule = {
|
|
|
528
785
|
file,
|
|
529
786
|
line,
|
|
530
787
|
message: reviewed
|
|
531
|
-
? "external redirect is
|
|
532
|
-
:
|
|
533
|
-
|
|
788
|
+
? "external redirect is expected here"
|
|
789
|
+
: unexpectedMessage(
|
|
790
|
+
"redirect opts out of the same-origin default - verify the target can never be derived from request input (open-redirect risk)",
|
|
791
|
+
lines,
|
|
792
|
+
line,
|
|
793
|
+
"NF-S005",
|
|
794
|
+
),
|
|
795
|
+
evidence: ["external: true", ...expectationEvidence(lines, line, "NF-S005")],
|
|
534
796
|
verify: "nifra check --lints-only",
|
|
535
797
|
}),
|
|
536
798
|
)
|
|
@@ -574,7 +836,7 @@ export const assuranceEscapeHatchRule: CheckRule = {
|
|
|
574
836
|
const consequence = name !== undefined ? ESCAPE_HATCHES[name] : undefined
|
|
575
837
|
if (name !== undefined && consequence !== undefined) {
|
|
576
838
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
577
|
-
const reviewed =
|
|
839
|
+
const reviewed = expectedReason(lines, line, "NF-S006") !== undefined
|
|
578
840
|
findings.push(
|
|
579
841
|
diagnostic({
|
|
580
842
|
code: "NF-S006",
|
|
@@ -582,9 +844,9 @@ export const assuranceEscapeHatchRule: CheckRule = {
|
|
|
582
844
|
file,
|
|
583
845
|
line,
|
|
584
846
|
message: reviewed
|
|
585
|
-
? `${name} escape hatch is
|
|
586
|
-
: `${name}: ${consequence}`,
|
|
587
|
-
evidence: [name, ...
|
|
847
|
+
? `${name} escape hatch is expected here`
|
|
848
|
+
: unexpectedMessage(`${name}: ${consequence}`, lines, line, "NF-S006"),
|
|
849
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S006")],
|
|
588
850
|
verify: "nifra check --lints-only",
|
|
589
851
|
}),
|
|
590
852
|
)
|
|
@@ -658,6 +920,24 @@ export const unprefixedSecureCookieRule: CheckRule = {
|
|
|
658
920
|
},
|
|
659
921
|
}
|
|
660
922
|
|
|
923
|
+
/** Reads the backend the contract-lock check loaded; this rule runs no project code of its own. */
|
|
924
|
+
export const inputBeforeAuthenticationRule: CheckRule = {
|
|
925
|
+
code: "NF-S008",
|
|
926
|
+
title: "Authenticated route validates input before authentication",
|
|
927
|
+
async scan(ctx) {
|
|
928
|
+
return (ctx.project.check.contracts.inputBeforeAuth ?? []).map((route) =>
|
|
929
|
+
diagnostic({
|
|
930
|
+
code: "NF-S008",
|
|
931
|
+
severity: "warn",
|
|
932
|
+
file: BACKEND_APP_FILE,
|
|
933
|
+
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`,
|
|
934
|
+
evidence: [route],
|
|
935
|
+
verify: "nifra check --lints-only",
|
|
936
|
+
}),
|
|
937
|
+
)
|
|
938
|
+
},
|
|
939
|
+
}
|
|
940
|
+
|
|
661
941
|
export const securityRules = Object.freeze([
|
|
662
942
|
failOpenGateRule,
|
|
663
943
|
secretComparisonRule,
|
|
@@ -666,4 +946,5 @@ export const securityRules = Object.freeze([
|
|
|
666
946
|
externalRedirectRule,
|
|
667
947
|
assuranceEscapeHatchRule,
|
|
668
948
|
unprefixedSecureCookieRule,
|
|
949
|
+
inputBeforeAuthenticationRule,
|
|
669
950
|
])
|