@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/dist/rules/security.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { BACKEND_APP_FILE } from "../app-files.js";
|
|
1
2
|
import { isBrowserSource } from "../check-scan.js";
|
|
2
3
|
import { diagnostic } from "../diagnostics.js";
|
|
4
|
+
import { createWebAppProbe } from "../internal/nifra-project.js";
|
|
3
5
|
import { scriptKindOf } from "../internal/script-kind.js";
|
|
4
|
-
import { commentBlockHasMarker } from "./comment-markers.js";
|
|
6
|
+
import { commentBlockExpectation, commentBlockHasMarker, MISSING_EXPECTATION_REASON, } from "./comment-markers.js";
|
|
5
7
|
import { loadRuleTypeScript } from "./typescript.js";
|
|
6
8
|
/**
|
|
7
9
|
* A security rule that cannot run says so as an advisory finding instead of silently returning no
|
|
@@ -21,36 +23,51 @@ function didNotRun(code, title) {
|
|
|
21
23
|
}
|
|
22
24
|
const SECRET = /(?:token|secret|apiKey|api_key|signature|hmac|password)/i;
|
|
23
25
|
const PII = /(?:email|phone|ssn|password|token|authorization)/i;
|
|
24
|
-
|
|
26
|
+
// Retired in 5.0.0: it silenced any security finding without a reason. It now silences nothing and
|
|
27
|
+
// only makes the finding name the form that replaced it.
|
|
28
|
+
const RETIRED_REVIEW_MARKER = "@nifra-gate-reviewed";
|
|
25
29
|
const parseCache = new WeakMap();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The reason a `nifra-expect <code>: <reason>` comment on or above `line` gives for a security
|
|
32
|
+
* finding. Every security hatch needs the reason: a comment without one silences nothing.
|
|
33
|
+
*/
|
|
34
|
+
function expectedReason(lines, line, code) {
|
|
35
|
+
return commentBlockExpectation(lines, line, [code])?.reason;
|
|
36
|
+
}
|
|
37
|
+
/** The evidence a silenced finding carries: the comment that silenced it, reason included. */
|
|
38
|
+
function expectationEvidence(lines, line, code) {
|
|
39
|
+
const reason = expectedReason(lines, line, code);
|
|
40
|
+
return reason === undefined ? [] : [`nifra-expect ${code}: ${reason}`];
|
|
31
41
|
}
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
/** An unsilenced finding's message, plus why a hatch comment written for it does not count. */
|
|
43
|
+
function unexpectedMessage(message, lines, line, code) {
|
|
44
|
+
if (commentBlockExpectation(lines, line, [code]) !== undefined)
|
|
45
|
+
return `${message}; ${MISSING_EXPECTATION_REASON}`;
|
|
46
|
+
if (commentBlockHasMarker(lines, line, RETIRED_REVIEW_MARKER))
|
|
47
|
+
return `${message}; ${RETIRED_REVIEW_MARKER} no longer silences a finding - after review, write // nifra-expect ${code}: <reason>`;
|
|
48
|
+
return message;
|
|
34
49
|
}
|
|
50
|
+
const parentOf = (dir) => dir.slice(0, Math.max(dir.lastIndexOf("/"), 0));
|
|
35
51
|
/**
|
|
36
|
-
* NF-S002
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* server
|
|
52
|
+
* Whether NF-S001 and NF-S002 may treat `file` as browser code, where a finding is advisory: a
|
|
53
|
+
* browser compares values the client already holds, and a gate there that fails open grants nothing
|
|
54
|
+
* the server does not check again. It must be browser code by path (a route's frontend half,
|
|
55
|
+
* `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
|
|
56
|
+
* without a web dependency runs on the server. `shared/` also runs on the server, and a file that
|
|
57
|
+
* cannot be classified is treated as server - fail closed.
|
|
41
58
|
*/
|
|
42
|
-
function
|
|
59
|
+
export function provenBrowserFile(root, file, inWebApp = createWebAppProbe(root)) {
|
|
43
60
|
const path = file.replaceAll("\\", "/");
|
|
44
61
|
if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path))
|
|
45
|
-
return
|
|
62
|
+
return false;
|
|
46
63
|
if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path))
|
|
47
|
-
return
|
|
48
|
-
return
|
|
64
|
+
return false;
|
|
65
|
+
return inWebApp(parentOf(path));
|
|
49
66
|
}
|
|
50
67
|
const CONFIRMATION_NAME = /^(?:confirm|confirmation|confirmPassword|passwordConfirmation|passwordConfirm|passwordRepeat|repeatPassword)$/i;
|
|
51
68
|
/** Client form confirmation compares two values already held by the browser, not secret material. */
|
|
52
|
-
function isClientConfirmationPair(
|
|
53
|
-
if (
|
|
69
|
+
function isClientConfirmationPair(browser, left, right) {
|
|
70
|
+
if (!browser)
|
|
54
71
|
return false;
|
|
55
72
|
const names = [left, right];
|
|
56
73
|
return (names.some((name) => name?.toLowerCase() === "password") &&
|
|
@@ -85,8 +102,9 @@ function secretName(ts, node) {
|
|
|
85
102
|
* Presence checks (`token === undefined`, `secret == null`, `apiKey !== ""`) and `typeof` guards are
|
|
86
103
|
* not equality over secret material - rewriting them to a timing-safe comparison would break the code.
|
|
87
104
|
* A comparison against a numeric literal (`signature !== 1`, `signatureLength !== 64`) is a
|
|
88
|
-
* length/version/discriminant check,
|
|
89
|
-
*
|
|
105
|
+
* length/version/discriminant check, and one against `true`/`false` (`persistRefreshToken === true`)
|
|
106
|
+
* is a flag check: secrets are strings or bytes, so a number or a boolean on either side proves this
|
|
107
|
+
* is not the compare a timing oracle applies to.
|
|
90
108
|
*/
|
|
91
109
|
function isPresenceComparison(ts, node) {
|
|
92
110
|
for (const side of [node.left, node.right]) {
|
|
@@ -94,6 +112,8 @@ function isPresenceComparison(ts, node) {
|
|
|
94
112
|
return true;
|
|
95
113
|
if (side.kind === ts.SyntaxKind.NullKeyword)
|
|
96
114
|
return true;
|
|
115
|
+
if (side.kind === ts.SyntaxKind.TrueKeyword || side.kind === ts.SyntaxKind.FalseKeyword)
|
|
116
|
+
return true;
|
|
97
117
|
if (ts.isStringLiteralLike(side) && side.text === "")
|
|
98
118
|
return true;
|
|
99
119
|
if (ts.isTypeOfExpression(side))
|
|
@@ -127,7 +147,131 @@ function parsedFile(ts, sources, file) {
|
|
|
127
147
|
files.set(file, parsed);
|
|
128
148
|
return parsed;
|
|
129
149
|
}
|
|
130
|
-
|
|
150
|
+
// The last word of a name that states a fact about a secret rather than holding it: `tokenType`,
|
|
151
|
+
// `SECRET_CAP_SQLSTATE`, `apiKeyIds`, `apiKeyHeaderName`. Exact words only, so the secret word anywhere
|
|
152
|
+
// else still matches (`tokenValue`, `passwordHash`, `apiKeyHeader`). `code`, `url`, `path` and `prefix`
|
|
153
|
+
// stay out: an OTP, a capability URL and a prefix compare all leak secret material.
|
|
154
|
+
const NON_MATERIAL_WORD = new Set([
|
|
155
|
+
"type",
|
|
156
|
+
"kind",
|
|
157
|
+
"name",
|
|
158
|
+
"id",
|
|
159
|
+
"ids",
|
|
160
|
+
"length",
|
|
161
|
+
"len",
|
|
162
|
+
"count",
|
|
163
|
+
"size",
|
|
164
|
+
"mode",
|
|
165
|
+
"policy",
|
|
166
|
+
"version",
|
|
167
|
+
"sqlstate",
|
|
168
|
+
"status",
|
|
169
|
+
"enabled",
|
|
170
|
+
"required",
|
|
171
|
+
"ttl",
|
|
172
|
+
"expiry",
|
|
173
|
+
"expires",
|
|
174
|
+
]);
|
|
175
|
+
function lastWord(name) {
|
|
176
|
+
const words = name
|
|
177
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
178
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
179
|
+
.split(/[\s_$-]+/);
|
|
180
|
+
return (words.findLast((word) => word !== "") ?? "").toLowerCase();
|
|
181
|
+
}
|
|
182
|
+
function isSecretName(name) {
|
|
183
|
+
return name !== undefined && SECRET.test(name) && !NON_MATERIAL_WORD.has(lastWord(name));
|
|
184
|
+
}
|
|
185
|
+
/** Whether `name` binds a declaration in `statements` itself (not in a nested scope). */
|
|
186
|
+
function declaresIn(ts, statements, name) {
|
|
187
|
+
return statements.some((statement) => {
|
|
188
|
+
if (ts.isVariableStatement(statement))
|
|
189
|
+
return statement.declarationList.declarations.some((d) => bindsName(ts, d.name, name));
|
|
190
|
+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement))
|
|
191
|
+
return statement.name?.text === name;
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function bindsName(ts, binding, name) {
|
|
196
|
+
if (ts.isIdentifier(binding))
|
|
197
|
+
return binding.text === name;
|
|
198
|
+
return binding.elements.some((element) => !ts.isOmittedExpression(element) && bindsName(ts, element.name, name));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether `id` names a plain parameter of an enclosing function, not a value the file builds: the
|
|
202
|
+
* nearest declaration of the name, walking out, must be that parameter. A loop or catch variable, a
|
|
203
|
+
* local or a destructured parameter in between disproves it.
|
|
204
|
+
*/
|
|
205
|
+
function isParameterRef(ts, id) {
|
|
206
|
+
for (let node = id.parent; node !== undefined; node = node.parent) {
|
|
207
|
+
if (ts.isFunctionLike(node)) {
|
|
208
|
+
for (const parameter of node.parameters) {
|
|
209
|
+
if (ts.isIdentifier(parameter.name)) {
|
|
210
|
+
if (parameter.name.text === id.text)
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
else if (bindsName(ts, parameter.name, id.text))
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if ((ts.isBlock(node) || ts.isSourceFile(node)) && declaresIn(ts, node.statements, id.text))
|
|
218
|
+
return false;
|
|
219
|
+
if ((ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) &&
|
|
220
|
+
node.initializer !== undefined &&
|
|
221
|
+
ts.isVariableDeclarationList(node.initializer) &&
|
|
222
|
+
node.initializer.declarations.some((d) => bindsName(ts, d.name, id.text)))
|
|
223
|
+
return false;
|
|
224
|
+
if (ts.isCatchClause(node) &&
|
|
225
|
+
node.variableDeclaration !== undefined &&
|
|
226
|
+
bindsName(ts, node.variableDeclaration.name, id.text))
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const CONFIG_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
232
|
+
const CONTEXT_NAMES = new Set(["c", "ctx", "context"]);
|
|
233
|
+
/**
|
|
234
|
+
* Whether `node` reads deployment configuration: `process.env.X`, `Bun.env.X`, `import.meta.env.X`,
|
|
235
|
+
* `Deno.env.get("X")`, `c.env.UPPER_SNAKE` (also `ctx`/`context`), or `env.X`, where `c` and `env` must
|
|
236
|
+
* be parameters of an enclosing function, never values the file builds. Comparing two such reads
|
|
237
|
+
* involves no request value, so it is no timing oracle (a startup "keys must differ" check).
|
|
238
|
+
*/
|
|
239
|
+
function isConfigRead(ts, node) {
|
|
240
|
+
if (ts.isCallExpression(node)) {
|
|
241
|
+
const callee = node.expression;
|
|
242
|
+
const [key] = node.arguments;
|
|
243
|
+
return (ts.isPropertyAccessExpression(callee) &&
|
|
244
|
+
callee.name.text === "get" &&
|
|
245
|
+
ts.isPropertyAccessExpression(callee.expression) &&
|
|
246
|
+
callee.expression.name.text === "env" &&
|
|
247
|
+
ts.isIdentifier(callee.expression.expression) &&
|
|
248
|
+
callee.expression.expression.text === "Deno" &&
|
|
249
|
+
node.arguments.length === 1 &&
|
|
250
|
+
key !== undefined &&
|
|
251
|
+
ts.isStringLiteralLike(key));
|
|
252
|
+
}
|
|
253
|
+
let key;
|
|
254
|
+
if (ts.isPropertyAccessExpression(node))
|
|
255
|
+
key = node.name.text;
|
|
256
|
+
else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression))
|
|
257
|
+
key = node.argumentExpression.text;
|
|
258
|
+
else
|
|
259
|
+
return false;
|
|
260
|
+
const object = node.expression;
|
|
261
|
+
if (ts.isIdentifier(object))
|
|
262
|
+
return object.text === "env" && isParameterRef(ts, object);
|
|
263
|
+
if (!ts.isPropertyAccessExpression(object) || object.name.text !== "env")
|
|
264
|
+
return false;
|
|
265
|
+
const owner = object.expression;
|
|
266
|
+
if (ts.isMetaProperty(owner))
|
|
267
|
+
return owner.name.text === "meta";
|
|
268
|
+
if (!ts.isIdentifier(owner))
|
|
269
|
+
return false;
|
|
270
|
+
if (owner.text === "process" || owner.text === "Bun")
|
|
271
|
+
return true;
|
|
272
|
+
return CONTEXT_NAMES.has(owner.text) && CONFIG_KEY.test(key) && isParameterRef(ts, owner);
|
|
273
|
+
}
|
|
274
|
+
function secretComparisons(ts, tree, browser) {
|
|
131
275
|
const operators = [
|
|
132
276
|
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
133
277
|
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
@@ -141,9 +285,9 @@ function secretComparisons(ts, file, tree) {
|
|
|
141
285
|
!isPresenceComparison(ts, node)) {
|
|
142
286
|
const left = secretName(ts, node.left);
|
|
143
287
|
const right = secretName(ts, node.right);
|
|
144
|
-
if (((left
|
|
145
|
-
(
|
|
146
|
-
!
|
|
288
|
+
if ((isSecretName(left) || isSecretName(right)) &&
|
|
289
|
+
!isClientConfirmationPair(browser, left, right) &&
|
|
290
|
+
!(isConfigRead(ts, node.left) && isConfigRead(ts, node.right))) {
|
|
147
291
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
148
292
|
found.push({ node, left, right, line });
|
|
149
293
|
}
|
|
@@ -171,11 +315,12 @@ function timingSafeHelper(file) {
|
|
|
171
315
|
* Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
|
|
172
316
|
* add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
|
|
173
317
|
* them, so a line number a check run reported no longer names the same statement after the first fix.
|
|
318
|
+
* `browser` is {@link provenBrowserFile} for the file, so the fix rewrites exactly what check reported.
|
|
174
319
|
*/
|
|
175
|
-
export function rewriteSecretComparisons(ts, file, source) {
|
|
320
|
+
export function rewriteSecretComparisons(ts, file, source, browser) {
|
|
176
321
|
const tree = parse(ts, file, source);
|
|
177
322
|
const lines = tree.text.split("\n");
|
|
178
|
-
const sites = secretComparisons(ts,
|
|
323
|
+
const sites = secretComparisons(ts, tree, browser).filter((site) => expectedReason(lines, site.line, "NF-S002") === undefined);
|
|
179
324
|
// A comparison nested inside another (`(token === a) === flag`) is left for the next run, so every
|
|
180
325
|
// edit below covers a disjoint range and applying them from the end keeps earlier offsets valid.
|
|
181
326
|
const outer = sites.filter(({ node }) => !sites.some((other) => other.node !== node && other.node.pos >= node.pos && other.node.end <= node.end));
|
|
@@ -213,22 +358,27 @@ export const secretComparisonRule = {
|
|
|
213
358
|
if (ts === undefined)
|
|
214
359
|
return didNotRun("NF-S002", "Non-constant-time secret comparison scan");
|
|
215
360
|
const findings = [];
|
|
361
|
+
const webDirs = createWebAppProbe(ctx.root);
|
|
216
362
|
for (const file of ctx.project.source.files) {
|
|
217
363
|
const parsed = parsedFile(ts, ctx.project.source, file);
|
|
218
364
|
if (parsed === undefined)
|
|
219
365
|
continue;
|
|
220
366
|
const { tree, lines } = parsed;
|
|
221
|
-
|
|
222
|
-
|
|
367
|
+
const browser = provenBrowserFile(ctx.root, file, webDirs);
|
|
368
|
+
for (const { left, right, line } of secretComparisons(ts, tree, browser)) {
|
|
369
|
+
const reviewed = expectedReason(lines, line, "NF-S002") !== undefined;
|
|
223
370
|
findings.push(diagnostic({
|
|
224
371
|
code: "NF-S002",
|
|
225
|
-
severity: reviewed ? "info" :
|
|
372
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
226
373
|
file,
|
|
227
374
|
line,
|
|
228
375
|
message: reviewed
|
|
229
|
-
? "secret comparison is
|
|
230
|
-
: "secret-like values must use a length check and timing-safe comparison",
|
|
231
|
-
evidence: [
|
|
376
|
+
? "secret comparison is expected here"
|
|
377
|
+
: unexpectedMessage("secret-like values must use a length check and timing-safe comparison", lines, line, "NF-S002"),
|
|
378
|
+
evidence: [
|
|
379
|
+
left ?? right ?? "secret comparison",
|
|
380
|
+
...expectationEvidence(lines, line, "NF-S002"),
|
|
381
|
+
],
|
|
232
382
|
...(reviewed
|
|
233
383
|
? {}
|
|
234
384
|
: {
|
|
@@ -271,16 +421,16 @@ export const piiLogRule = {
|
|
|
271
421
|
const name = nameOf(ts, arg);
|
|
272
422
|
if (name !== undefined && PII.test(name)) {
|
|
273
423
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
274
|
-
const reviewed =
|
|
424
|
+
const reviewed = expectedReason(lines, line, "NF-S003") !== undefined;
|
|
275
425
|
findings.push(diagnostic({
|
|
276
426
|
code: "NF-S003",
|
|
277
427
|
severity: reviewed ? "info" : "warn",
|
|
278
428
|
file,
|
|
279
429
|
line,
|
|
280
430
|
message: reviewed
|
|
281
|
-
? "sensitive value in log call is
|
|
282
|
-
: "PII-shaped values must not be passed directly to log calls",
|
|
283
|
-
evidence: [name, ...
|
|
431
|
+
? "sensitive value in log call is expected here"
|
|
432
|
+
: unexpectedMessage("PII-shaped values must not be passed directly to log calls", lines, line, "NF-S003"),
|
|
433
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S003")],
|
|
284
434
|
verify: "nifra check --lints-only",
|
|
285
435
|
}));
|
|
286
436
|
}
|
|
@@ -313,6 +463,89 @@ function functionName(ts, fn) {
|
|
|
313
463
|
return binding.name.text;
|
|
314
464
|
return undefined;
|
|
315
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* A gate's name. `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
|
|
468
|
+
* case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them authorization
|
|
469
|
+
* decisions. `ensure`/`verify`/`check`/`validate` name one only before what a gate decides on
|
|
470
|
+
* (`verifyToken`, `ensureLoggedIn`, `checkPermission`), so `ensureDir` and `checkHealth` are not gates.
|
|
471
|
+
*/
|
|
472
|
+
const GATE_NAMES = [
|
|
473
|
+
/^(?:require|assert|authori[sz]e|authenticate)/i,
|
|
474
|
+
/^can[A-Z]/,
|
|
475
|
+
/.Guard$/,
|
|
476
|
+
/^(?: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])/,
|
|
477
|
+
];
|
|
478
|
+
/** An authenticator returns who is calling, so its "nobody" result (`null`, `undefined`) is a denial. */
|
|
479
|
+
const AUTHENTICATOR = /^authenticate/i;
|
|
480
|
+
/** Result-object fields whose `false` is the denial (`{ ok: false, error }`). */
|
|
481
|
+
const DECISION_FIELDS = new Set([
|
|
482
|
+
"ok",
|
|
483
|
+
"allowed",
|
|
484
|
+
"authorized",
|
|
485
|
+
"authenticated",
|
|
486
|
+
"valid",
|
|
487
|
+
"success",
|
|
488
|
+
"granted",
|
|
489
|
+
]);
|
|
490
|
+
/** Helpers whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). */
|
|
491
|
+
const FAILING_HELPERS = new Set(["fail", "deny", "reject", "forbid", "unauthorized", "abort"]);
|
|
492
|
+
/**
|
|
493
|
+
* Whether a gate's catch block fails closed: it throws, returns `false`, a result object whose decision
|
|
494
|
+
* field is `false`, a `new Response(…)` or a `deny…(…)` call (an authenticator also `null` or
|
|
495
|
+
* nothing), or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
|
|
496
|
+
* conformance/invariant asserts that funnel every error through a `never`-returning `fail()` are not
|
|
497
|
+
* fail-open - the scanner reads no types, so it trusts the helper by name, the same trade the SQL and
|
|
498
|
+
* gate-name heuristics already make. Read from the syntax, so a "throw" in a comment or a string counts
|
|
499
|
+
* for nothing, and a nested function's statements are not the catch's own.
|
|
500
|
+
*/
|
|
501
|
+
function failsClosed(ts, block, gate) {
|
|
502
|
+
const authenticator = AUTHENTICATOR.test(gate);
|
|
503
|
+
let closed = false;
|
|
504
|
+
const visit = (node) => {
|
|
505
|
+
if (closed || ts.isFunctionLike(node))
|
|
506
|
+
return;
|
|
507
|
+
if (ts.isThrowStatement(node) ||
|
|
508
|
+
(ts.isReturnStatement(node) &&
|
|
509
|
+
(node.expression === undefined
|
|
510
|
+
? authenticator
|
|
511
|
+
: isDenial(ts, node.expression, authenticator))) ||
|
|
512
|
+
(ts.isCallExpression(node) &&
|
|
513
|
+
ts.isIdentifier(node.expression) &&
|
|
514
|
+
FAILING_HELPERS.has(node.expression.text))) {
|
|
515
|
+
closed = true;
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
ts.forEachChild(node, visit);
|
|
519
|
+
};
|
|
520
|
+
visit(block);
|
|
521
|
+
return closed;
|
|
522
|
+
}
|
|
523
|
+
function isDenial(ts, expression, authenticator) {
|
|
524
|
+
let value = expression;
|
|
525
|
+
while (ts.isParenthesizedExpression(value) ||
|
|
526
|
+
ts.isAwaitExpression(value) ||
|
|
527
|
+
ts.isAsExpression(value) ||
|
|
528
|
+
ts.isSatisfiesExpression(value))
|
|
529
|
+
value = value.expression;
|
|
530
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword)
|
|
531
|
+
return true;
|
|
532
|
+
if (authenticator &&
|
|
533
|
+
(value.kind === ts.SyntaxKind.NullKeyword ||
|
|
534
|
+
ts.isVoidExpression(value) ||
|
|
535
|
+
(ts.isIdentifier(value) && value.text === "undefined")))
|
|
536
|
+
return true;
|
|
537
|
+
if (ts.isObjectLiteralExpression(value)) {
|
|
538
|
+
return value.properties.some((property) => ts.isPropertyAssignment(property) &&
|
|
539
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
540
|
+
DECISION_FIELDS.has(property.name.text) &&
|
|
541
|
+
property.initializer.kind === ts.SyntaxKind.FalseKeyword);
|
|
542
|
+
}
|
|
543
|
+
if (ts.isNewExpression(value))
|
|
544
|
+
return ts.isIdentifier(value.expression) && value.expression.text === "Response";
|
|
545
|
+
return (ts.isCallExpression(value) &&
|
|
546
|
+
ts.isIdentifier(value.expression) &&
|
|
547
|
+
value.expression.text.startsWith("deny"));
|
|
548
|
+
}
|
|
316
549
|
export const failOpenGateRule = {
|
|
317
550
|
code: "NF-S001",
|
|
318
551
|
title: "Fail-open gate",
|
|
@@ -324,45 +557,37 @@ export const failOpenGateRule = {
|
|
|
324
557
|
if (ts === undefined)
|
|
325
558
|
return didNotRun("NF-S001", "Fail-open gate scan");
|
|
326
559
|
const findings = [];
|
|
560
|
+
const webDirs = createWebAppProbe(ctx.root);
|
|
327
561
|
for (const file of ctx.project.source.files) {
|
|
328
562
|
const parsed = parsedFile(ts, ctx.project.source, file);
|
|
329
563
|
if (parsed === undefined)
|
|
330
564
|
continue;
|
|
331
565
|
const { tree, lines } = parsed;
|
|
566
|
+
let browser;
|
|
332
567
|
const visit = (node) => {
|
|
333
568
|
if (ts.isCatchClause(node)) {
|
|
334
569
|
let parent = node.parent;
|
|
335
570
|
while (parent !== undefined && !ts.isFunctionLike(parent))
|
|
336
571
|
parent = parent.parent;
|
|
337
572
|
const name = parent === undefined ? undefined : functionName(ts, parent);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// authorization decisions. The other roots stay case-insensitive.
|
|
341
|
-
const gateName = name !== undefined &&
|
|
342
|
-
(/^(?:require|assert|authorize)/i.test(name) || /^can[A-Z]/.test(name));
|
|
343
|
-
if (name !== undefined && gateName) {
|
|
344
|
-
const text = node.block.getText(tree);
|
|
345
|
-
// A catch fails closed when it rethrows, returns an explicit denial, or hands the failure
|
|
346
|
-
// to a helper whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). The last
|
|
347
|
-
// form is why conformance/invariant asserts that funnel every error through a
|
|
348
|
-
// `never`-returning `fail()` are not fail-open - the scanner reads no types, so it trusts
|
|
349
|
-
// the helper by name, the same trade the SQL and gate-name heuristics already make.
|
|
350
|
-
if (!/\b(?:throw|return\s+(?:false|new\s+Response|deny))/s.test(text) &&
|
|
351
|
-
!/(?:^|[^.\w])(?:fail|deny|reject|forbid|unauthorized|abort)\s*\(/s.test(text)) {
|
|
573
|
+
if (name !== undefined && GATE_NAMES.some((gate) => gate.test(name))) {
|
|
574
|
+
if (!failsClosed(ts, node.block, name)) {
|
|
352
575
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
353
|
-
const reviewed =
|
|
576
|
+
const reviewed = expectedReason(lines, line, "NF-S001") !== undefined;
|
|
577
|
+
browser ??= provenBrowserFile(ctx.root, file, webDirs);
|
|
354
578
|
findings.push(diagnostic({
|
|
355
579
|
code: "NF-S001",
|
|
356
|
-
severity: reviewed ? "info" : "error",
|
|
580
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
357
581
|
file,
|
|
358
582
|
line,
|
|
359
583
|
message: reviewed
|
|
360
|
-
? "gate catch block is
|
|
361
|
-
: "gate catch blocks must rethrow or return an explicit denial",
|
|
584
|
+
? "gate catch block is expected here"
|
|
585
|
+
: unexpectedMessage("gate catch blocks must rethrow or return an explicit denial", lines, line, "NF-S001"),
|
|
362
586
|
evidence: [
|
|
363
587
|
name,
|
|
364
588
|
"catch block has no denial or rethrow",
|
|
365
|
-
...
|
|
589
|
+
...(browser ? ["browser code: the server's own gate decides"] : []),
|
|
590
|
+
...expectationEvidence(lines, line, "NF-S001"),
|
|
366
591
|
],
|
|
367
592
|
verify: "nifra check --lints-only",
|
|
368
593
|
}));
|
|
@@ -427,16 +652,19 @@ export const corsOriginPredicateRule = {
|
|
|
427
652
|
: false;
|
|
428
653
|
if (ignoresOrigin) {
|
|
429
654
|
const line = tree.getLineAndCharacterOfPosition(prop.getStart(tree)).line + 1;
|
|
430
|
-
const reviewed =
|
|
655
|
+
const reviewed = expectedReason(lines, line, "NF-S004") !== undefined;
|
|
431
656
|
findings.push(diagnostic({
|
|
432
657
|
code: "NF-S004",
|
|
433
658
|
severity: reviewed ? "info" : "warn",
|
|
434
659
|
file,
|
|
435
660
|
line,
|
|
436
661
|
message: reviewed
|
|
437
|
-
? "constant CORS origin predicate is
|
|
438
|
-
: "CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument",
|
|
439
|
-
evidence: [
|
|
662
|
+
? "constant CORS origin predicate is expected here"
|
|
663
|
+
: unexpectedMessage("CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument", lines, line, "NF-S004"),
|
|
664
|
+
evidence: [
|
|
665
|
+
"origin predicate",
|
|
666
|
+
...expectationEvidence(lines, line, "NF-S004"),
|
|
667
|
+
],
|
|
440
668
|
verify: "nifra check --lints-only",
|
|
441
669
|
}));
|
|
442
670
|
}
|
|
@@ -477,16 +705,16 @@ export const externalRedirectRule = {
|
|
|
477
705
|
nameOf(ts, prop.name) === "external" &&
|
|
478
706
|
prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
479
707
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
480
|
-
const reviewed =
|
|
708
|
+
const reviewed = expectedReason(lines, line, "NF-S005") !== undefined;
|
|
481
709
|
findings.push(diagnostic({
|
|
482
710
|
code: "NF-S005",
|
|
483
711
|
severity: reviewed ? "info" : "warn",
|
|
484
712
|
file,
|
|
485
713
|
line,
|
|
486
714
|
message: reviewed
|
|
487
|
-
? "external redirect is
|
|
488
|
-
: "redirect opts out of the same-origin default - verify the target can never be derived from request input (open-redirect risk)",
|
|
489
|
-
evidence: ["external: true", ...
|
|
715
|
+
? "external redirect is expected here"
|
|
716
|
+
: unexpectedMessage("redirect opts out of the same-origin default - verify the target can never be derived from request input (open-redirect risk)", lines, line, "NF-S005"),
|
|
717
|
+
evidence: ["external: true", ...expectationEvidence(lines, line, "NF-S005")],
|
|
490
718
|
verify: "nifra check --lints-only",
|
|
491
719
|
}));
|
|
492
720
|
}
|
|
@@ -527,16 +755,16 @@ export const assuranceEscapeHatchRule = {
|
|
|
527
755
|
const consequence = name !== undefined ? ESCAPE_HATCHES[name] : undefined;
|
|
528
756
|
if (name !== undefined && consequence !== undefined) {
|
|
529
757
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
530
|
-
const reviewed =
|
|
758
|
+
const reviewed = expectedReason(lines, line, "NF-S006") !== undefined;
|
|
531
759
|
findings.push(diagnostic({
|
|
532
760
|
code: "NF-S006",
|
|
533
761
|
severity: reviewed ? "info" : "warn",
|
|
534
762
|
file,
|
|
535
763
|
line,
|
|
536
764
|
message: reviewed
|
|
537
|
-
? `${name} escape hatch is
|
|
538
|
-
: `${name}: ${consequence}`,
|
|
539
|
-
evidence: [name, ...
|
|
765
|
+
? `${name} escape hatch is expected here`
|
|
766
|
+
: unexpectedMessage(`${name}: ${consequence}`, lines, line, "NF-S006"),
|
|
767
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S006")],
|
|
540
768
|
verify: "nifra check --lints-only",
|
|
541
769
|
}));
|
|
542
770
|
}
|
|
@@ -599,6 +827,21 @@ export const unprefixedSecureCookieRule = {
|
|
|
599
827
|
return findings;
|
|
600
828
|
},
|
|
601
829
|
};
|
|
830
|
+
/** Reads the backend the contract-lock check loaded; this rule runs no project code of its own. */
|
|
831
|
+
export const inputBeforeAuthenticationRule = {
|
|
832
|
+
code: "NF-S008",
|
|
833
|
+
title: "Authenticated route validates input before authentication",
|
|
834
|
+
async scan(ctx) {
|
|
835
|
+
return (ctx.project.check.contracts.inputBeforeAuth ?? []).map((route) => diagnostic({
|
|
836
|
+
code: "NF-S008",
|
|
837
|
+
severity: "warn",
|
|
838
|
+
file: BACKEND_APP_FILE,
|
|
839
|
+
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`,
|
|
840
|
+
evidence: [route],
|
|
841
|
+
verify: "nifra check --lints-only",
|
|
842
|
+
}));
|
|
843
|
+
},
|
|
844
|
+
};
|
|
602
845
|
export const securityRules = Object.freeze([
|
|
603
846
|
failOpenGateRule,
|
|
604
847
|
secretComparisonRule,
|
|
@@ -607,5 +850,6 @@ export const securityRules = Object.freeze([
|
|
|
607
850
|
externalRedirectRule,
|
|
608
851
|
assuranceEscapeHatchRule,
|
|
609
852
|
unprefixedSecureCookieRule,
|
|
853
|
+
inputBeforeAuthenticationRule,
|
|
610
854
|
]);
|
|
611
855
|
//# sourceMappingURL=security.js.map
|