@nifrajs/cli 4.0.0 → 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 +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- 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 +28 -3
- 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 +958 -71
- 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/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.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/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.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 +181 -47
- 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 +34 -3
- 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 +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- 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/sync-manifest.ts +48 -18
- 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/dist/rules/security.d.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type * as TSApi from "typescript";
|
|
2
2
|
import type { CheckRule } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether NF-S002 may treat `file` as browser code, which compares values the client already holds,
|
|
5
|
+
* so a finding there is advisory. It must be browser code by path (a route's frontend half,
|
|
6
|
+
* `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
|
|
7
|
+
* without a web dependency runs on the server. `shared/` also runs on the server, and a file that
|
|
8
|
+
* cannot be classified is treated as server - fail closed.
|
|
9
|
+
*/
|
|
10
|
+
export declare function provenBrowserFile(root: string, file: string, inWebApp?: (dir: string) => boolean): boolean;
|
|
3
11
|
/**
|
|
4
12
|
* Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
|
|
5
13
|
* add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
|
|
6
14
|
* them, so a line number a check run reported no longer names the same statement after the first fix.
|
|
15
|
+
* `browser` is {@link provenBrowserFile} for the file, so the fix rewrites exactly what check reported.
|
|
7
16
|
*/
|
|
8
|
-
export declare function rewriteSecretComparisons(ts: typeof TSApi, file: string, source: string): string;
|
|
17
|
+
export declare function rewriteSecretComparisons(ts: typeof TSApi, file: string, source: string, browser: boolean): string;
|
|
9
18
|
export declare const secretComparisonRule: CheckRule;
|
|
10
19
|
export declare const piiLogRule: CheckRule;
|
|
11
20
|
export declare const failOpenGateRule: CheckRule;
|
|
@@ -13,5 +22,7 @@ export declare const corsOriginPredicateRule: CheckRule;
|
|
|
13
22
|
export declare const externalRedirectRule: CheckRule;
|
|
14
23
|
export declare const assuranceEscapeHatchRule: CheckRule;
|
|
15
24
|
export declare const unprefixedSecureCookieRule: CheckRule;
|
|
25
|
+
/** Reads the backend the contract-lock check loaded; this rule runs no project code of its own. */
|
|
26
|
+
export declare const inputBeforeAuthenticationRule: CheckRule;
|
|
16
27
|
export declare const securityRules: readonly CheckRule[];
|
|
17
28
|
//# sourceMappingURL=security.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../src/rules/security.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../src/rules/security.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAWxC,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,YAAY,CAAA;AAgExD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAiC,GAC3D,OAAO,CAKT;AAyRD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,EAAE,EAAE,OAAO,KAAK,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,GACf,MAAM,CAiCR;AAED,eAAO,MAAM,oBAAoB,EAAE,SAkDlC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,SAqDxB,CAAA;AA2FD,eAAO,MAAM,gBAAgB,EAAE,SAqD9B,CAAA;AAiBD,eAAO,MAAM,uBAAuB,EAAE,SAqErC,CAAA;AAED,eAAO,MAAM,oBAAoB,EAAE,SAqDlC,CAAA;AAWD,eAAO,MAAM,wBAAwB,EAAE,SAyCtC,CAAA;AAID,eAAO,MAAM,0BAA0B,EAAE,SAwDxC,CAAA;AAED,mGAAmG;AACnG,eAAO,MAAM,6BAA6B,EAAE,SAe3C,CAAA;AAED,eAAO,MAAM,aAAa,sBASxB,CAAA"}
|
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,50 @@ 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
|
-
* there is
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* server - fail closed.
|
|
52
|
+
* Whether NF-S002 may treat `file` as browser code, which compares values the client already holds,
|
|
53
|
+
* so a finding there is advisory. It must be browser code by path (a route's frontend half,
|
|
54
|
+
* `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
|
|
55
|
+
* without a web dependency runs on the server. `shared/` also runs on the server, and a file that
|
|
56
|
+
* cannot be classified is treated as server - fail closed.
|
|
41
57
|
*/
|
|
42
|
-
function
|
|
58
|
+
export function provenBrowserFile(root, file, inWebApp = createWebAppProbe(root)) {
|
|
43
59
|
const path = file.replaceAll("\\", "/");
|
|
44
60
|
if (!isBrowserSource(path) || /\.server\.[cm]?[tj]sx?$/.test(path))
|
|
45
|
-
return
|
|
61
|
+
return false;
|
|
46
62
|
if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path))
|
|
47
|
-
return
|
|
48
|
-
return
|
|
63
|
+
return false;
|
|
64
|
+
return inWebApp(parentOf(path));
|
|
49
65
|
}
|
|
50
66
|
const CONFIRMATION_NAME = /^(?:confirm|confirmation|confirmPassword|passwordConfirmation|passwordConfirm|passwordRepeat|repeatPassword)$/i;
|
|
51
67
|
/** Client form confirmation compares two values already held by the browser, not secret material. */
|
|
52
|
-
function isClientConfirmationPair(
|
|
53
|
-
if (
|
|
68
|
+
function isClientConfirmationPair(browser, left, right) {
|
|
69
|
+
if (!browser)
|
|
54
70
|
return false;
|
|
55
71
|
const names = [left, right];
|
|
56
72
|
return (names.some((name) => name?.toLowerCase() === "password") &&
|
|
@@ -85,8 +101,9 @@ function secretName(ts, node) {
|
|
|
85
101
|
* Presence checks (`token === undefined`, `secret == null`, `apiKey !== ""`) and `typeof` guards are
|
|
86
102
|
* not equality over secret material - rewriting them to a timing-safe comparison would break the code.
|
|
87
103
|
* A comparison against a numeric literal (`signature !== 1`, `signatureLength !== 64`) is a
|
|
88
|
-
* length/version/discriminant check,
|
|
89
|
-
*
|
|
104
|
+
* length/version/discriminant check, and one against `true`/`false` (`persistRefreshToken === true`)
|
|
105
|
+
* is a flag check: secrets are strings or bytes, so a number or a boolean on either side proves this
|
|
106
|
+
* is not the compare a timing oracle applies to.
|
|
90
107
|
*/
|
|
91
108
|
function isPresenceComparison(ts, node) {
|
|
92
109
|
for (const side of [node.left, node.right]) {
|
|
@@ -94,6 +111,8 @@ function isPresenceComparison(ts, node) {
|
|
|
94
111
|
return true;
|
|
95
112
|
if (side.kind === ts.SyntaxKind.NullKeyword)
|
|
96
113
|
return true;
|
|
114
|
+
if (side.kind === ts.SyntaxKind.TrueKeyword || side.kind === ts.SyntaxKind.FalseKeyword)
|
|
115
|
+
return true;
|
|
97
116
|
if (ts.isStringLiteralLike(side) && side.text === "")
|
|
98
117
|
return true;
|
|
99
118
|
if (ts.isTypeOfExpression(side))
|
|
@@ -127,7 +146,131 @@ function parsedFile(ts, sources, file) {
|
|
|
127
146
|
files.set(file, parsed);
|
|
128
147
|
return parsed;
|
|
129
148
|
}
|
|
130
|
-
|
|
149
|
+
// The last word of a name that states a fact about a secret rather than holding it: `tokenType`,
|
|
150
|
+
// `SECRET_CAP_SQLSTATE`, `apiKeyIds`, `apiKeyHeaderName`. Exact words only, so the secret word anywhere
|
|
151
|
+
// else still matches (`tokenValue`, `passwordHash`, `apiKeyHeader`). `code`, `url`, `path` and `prefix`
|
|
152
|
+
// stay out: an OTP, a capability URL and a prefix compare all leak secret material.
|
|
153
|
+
const NON_MATERIAL_WORD = new Set([
|
|
154
|
+
"type",
|
|
155
|
+
"kind",
|
|
156
|
+
"name",
|
|
157
|
+
"id",
|
|
158
|
+
"ids",
|
|
159
|
+
"length",
|
|
160
|
+
"len",
|
|
161
|
+
"count",
|
|
162
|
+
"size",
|
|
163
|
+
"mode",
|
|
164
|
+
"policy",
|
|
165
|
+
"version",
|
|
166
|
+
"sqlstate",
|
|
167
|
+
"status",
|
|
168
|
+
"enabled",
|
|
169
|
+
"required",
|
|
170
|
+
"ttl",
|
|
171
|
+
"expiry",
|
|
172
|
+
"expires",
|
|
173
|
+
]);
|
|
174
|
+
function lastWord(name) {
|
|
175
|
+
const words = name
|
|
176
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
177
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
178
|
+
.split(/[\s_$-]+/);
|
|
179
|
+
return (words.findLast((word) => word !== "") ?? "").toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
function isSecretName(name) {
|
|
182
|
+
return name !== undefined && SECRET.test(name) && !NON_MATERIAL_WORD.has(lastWord(name));
|
|
183
|
+
}
|
|
184
|
+
/** Whether `name` binds a declaration in `statements` itself (not in a nested scope). */
|
|
185
|
+
function declaresIn(ts, statements, name) {
|
|
186
|
+
return statements.some((statement) => {
|
|
187
|
+
if (ts.isVariableStatement(statement))
|
|
188
|
+
return statement.declarationList.declarations.some((d) => bindsName(ts, d.name, name));
|
|
189
|
+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement))
|
|
190
|
+
return statement.name?.text === name;
|
|
191
|
+
return false;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function bindsName(ts, binding, name) {
|
|
195
|
+
if (ts.isIdentifier(binding))
|
|
196
|
+
return binding.text === name;
|
|
197
|
+
return binding.elements.some((element) => !ts.isOmittedExpression(element) && bindsName(ts, element.name, name));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether `id` names a plain parameter of an enclosing function, not a value the file builds: the
|
|
201
|
+
* nearest declaration of the name, walking out, must be that parameter. A loop or catch variable, a
|
|
202
|
+
* local or a destructured parameter in between disproves it.
|
|
203
|
+
*/
|
|
204
|
+
function isParameterRef(ts, id) {
|
|
205
|
+
for (let node = id.parent; node !== undefined; node = node.parent) {
|
|
206
|
+
if (ts.isFunctionLike(node)) {
|
|
207
|
+
for (const parameter of node.parameters) {
|
|
208
|
+
if (ts.isIdentifier(parameter.name)) {
|
|
209
|
+
if (parameter.name.text === id.text)
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
else if (bindsName(ts, parameter.name, id.text))
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if ((ts.isBlock(node) || ts.isSourceFile(node)) && declaresIn(ts, node.statements, id.text))
|
|
217
|
+
return false;
|
|
218
|
+
if ((ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) &&
|
|
219
|
+
node.initializer !== undefined &&
|
|
220
|
+
ts.isVariableDeclarationList(node.initializer) &&
|
|
221
|
+
node.initializer.declarations.some((d) => bindsName(ts, d.name, id.text)))
|
|
222
|
+
return false;
|
|
223
|
+
if (ts.isCatchClause(node) &&
|
|
224
|
+
node.variableDeclaration !== undefined &&
|
|
225
|
+
bindsName(ts, node.variableDeclaration.name, id.text))
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const CONFIG_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
231
|
+
const CONTEXT_NAMES = new Set(["c", "ctx", "context"]);
|
|
232
|
+
/**
|
|
233
|
+
* Whether `node` reads deployment configuration: `process.env.X`, `Bun.env.X`, `import.meta.env.X`,
|
|
234
|
+
* `Deno.env.get("X")`, `c.env.UPPER_SNAKE` (also `ctx`/`context`), or `env.X`, where `c` and `env` must
|
|
235
|
+
* be parameters of an enclosing function, never values the file builds. Comparing two such reads
|
|
236
|
+
* involves no request value, so it is no timing oracle (a startup "keys must differ" check).
|
|
237
|
+
*/
|
|
238
|
+
function isConfigRead(ts, node) {
|
|
239
|
+
if (ts.isCallExpression(node)) {
|
|
240
|
+
const callee = node.expression;
|
|
241
|
+
const [key] = node.arguments;
|
|
242
|
+
return (ts.isPropertyAccessExpression(callee) &&
|
|
243
|
+
callee.name.text === "get" &&
|
|
244
|
+
ts.isPropertyAccessExpression(callee.expression) &&
|
|
245
|
+
callee.expression.name.text === "env" &&
|
|
246
|
+
ts.isIdentifier(callee.expression.expression) &&
|
|
247
|
+
callee.expression.expression.text === "Deno" &&
|
|
248
|
+
node.arguments.length === 1 &&
|
|
249
|
+
key !== undefined &&
|
|
250
|
+
ts.isStringLiteralLike(key));
|
|
251
|
+
}
|
|
252
|
+
let key;
|
|
253
|
+
if (ts.isPropertyAccessExpression(node))
|
|
254
|
+
key = node.name.text;
|
|
255
|
+
else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression))
|
|
256
|
+
key = node.argumentExpression.text;
|
|
257
|
+
else
|
|
258
|
+
return false;
|
|
259
|
+
const object = node.expression;
|
|
260
|
+
if (ts.isIdentifier(object))
|
|
261
|
+
return object.text === "env" && isParameterRef(ts, object);
|
|
262
|
+
if (!ts.isPropertyAccessExpression(object) || object.name.text !== "env")
|
|
263
|
+
return false;
|
|
264
|
+
const owner = object.expression;
|
|
265
|
+
if (ts.isMetaProperty(owner))
|
|
266
|
+
return owner.name.text === "meta";
|
|
267
|
+
if (!ts.isIdentifier(owner))
|
|
268
|
+
return false;
|
|
269
|
+
if (owner.text === "process" || owner.text === "Bun")
|
|
270
|
+
return true;
|
|
271
|
+
return CONTEXT_NAMES.has(owner.text) && CONFIG_KEY.test(key) && isParameterRef(ts, owner);
|
|
272
|
+
}
|
|
273
|
+
function secretComparisons(ts, tree, browser) {
|
|
131
274
|
const operators = [
|
|
132
275
|
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
133
276
|
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
@@ -141,9 +284,9 @@ function secretComparisons(ts, file, tree) {
|
|
|
141
284
|
!isPresenceComparison(ts, node)) {
|
|
142
285
|
const left = secretName(ts, node.left);
|
|
143
286
|
const right = secretName(ts, node.right);
|
|
144
|
-
if (((left
|
|
145
|
-
(
|
|
146
|
-
!
|
|
287
|
+
if ((isSecretName(left) || isSecretName(right)) &&
|
|
288
|
+
!isClientConfirmationPair(browser, left, right) &&
|
|
289
|
+
!(isConfigRead(ts, node.left) && isConfigRead(ts, node.right))) {
|
|
147
290
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
148
291
|
found.push({ node, left, right, line });
|
|
149
292
|
}
|
|
@@ -171,11 +314,12 @@ function timingSafeHelper(file) {
|
|
|
171
314
|
* Rewrite every unreviewed NF-S002 comparison in one file to `nifraTimingSafeEqual(left, right)` and
|
|
172
315
|
* add the helper once. Sites come from the syntax tree, not a reported line: the helper lands above
|
|
173
316
|
* them, so a line number a check run reported no longer names the same statement after the first fix.
|
|
317
|
+
* `browser` is {@link provenBrowserFile} for the file, so the fix rewrites exactly what check reported.
|
|
174
318
|
*/
|
|
175
|
-
export function rewriteSecretComparisons(ts, file, source) {
|
|
319
|
+
export function rewriteSecretComparisons(ts, file, source, browser) {
|
|
176
320
|
const tree = parse(ts, file, source);
|
|
177
321
|
const lines = tree.text.split("\n");
|
|
178
|
-
const sites = secretComparisons(ts,
|
|
322
|
+
const sites = secretComparisons(ts, tree, browser).filter((site) => expectedReason(lines, site.line, "NF-S002") === undefined);
|
|
179
323
|
// A comparison nested inside another (`(token === a) === flag`) is left for the next run, so every
|
|
180
324
|
// edit below covers a disjoint range and applying them from the end keeps earlier offsets valid.
|
|
181
325
|
const outer = sites.filter(({ node }) => !sites.some((other) => other.node !== node && other.node.pos >= node.pos && other.node.end <= node.end));
|
|
@@ -213,22 +357,27 @@ export const secretComparisonRule = {
|
|
|
213
357
|
if (ts === undefined)
|
|
214
358
|
return didNotRun("NF-S002", "Non-constant-time secret comparison scan");
|
|
215
359
|
const findings = [];
|
|
360
|
+
const webDirs = createWebAppProbe(ctx.root);
|
|
216
361
|
for (const file of ctx.project.source.files) {
|
|
217
362
|
const parsed = parsedFile(ts, ctx.project.source, file);
|
|
218
363
|
if (parsed === undefined)
|
|
219
364
|
continue;
|
|
220
365
|
const { tree, lines } = parsed;
|
|
221
|
-
|
|
222
|
-
|
|
366
|
+
const browser = provenBrowserFile(ctx.root, file, webDirs);
|
|
367
|
+
for (const { left, right, line } of secretComparisons(ts, tree, browser)) {
|
|
368
|
+
const reviewed = expectedReason(lines, line, "NF-S002") !== undefined;
|
|
223
369
|
findings.push(diagnostic({
|
|
224
370
|
code: "NF-S002",
|
|
225
|
-
severity: reviewed ? "info" :
|
|
371
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
226
372
|
file,
|
|
227
373
|
line,
|
|
228
374
|
message: reviewed
|
|
229
|
-
? "secret comparison is
|
|
230
|
-
: "secret-like values must use a length check and timing-safe comparison",
|
|
231
|
-
evidence: [
|
|
375
|
+
? "secret comparison is expected here"
|
|
376
|
+
: unexpectedMessage("secret-like values must use a length check and timing-safe comparison", lines, line, "NF-S002"),
|
|
377
|
+
evidence: [
|
|
378
|
+
left ?? right ?? "secret comparison",
|
|
379
|
+
...expectationEvidence(lines, line, "NF-S002"),
|
|
380
|
+
],
|
|
232
381
|
...(reviewed
|
|
233
382
|
? {}
|
|
234
383
|
: {
|
|
@@ -271,16 +420,16 @@ export const piiLogRule = {
|
|
|
271
420
|
const name = nameOf(ts, arg);
|
|
272
421
|
if (name !== undefined && PII.test(name)) {
|
|
273
422
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
274
|
-
const reviewed =
|
|
423
|
+
const reviewed = expectedReason(lines, line, "NF-S003") !== undefined;
|
|
275
424
|
findings.push(diagnostic({
|
|
276
425
|
code: "NF-S003",
|
|
277
426
|
severity: reviewed ? "info" : "warn",
|
|
278
427
|
file,
|
|
279
428
|
line,
|
|
280
429
|
message: reviewed
|
|
281
|
-
? "sensitive value in log call is
|
|
282
|
-
: "PII-shaped values must not be passed directly to log calls",
|
|
283
|
-
evidence: [name, ...
|
|
430
|
+
? "sensitive value in log call is expected here"
|
|
431
|
+
: unexpectedMessage("PII-shaped values must not be passed directly to log calls", lines, line, "NF-S003"),
|
|
432
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S003")],
|
|
284
433
|
verify: "nifra check --lints-only",
|
|
285
434
|
}));
|
|
286
435
|
}
|
|
@@ -313,6 +462,63 @@ function functionName(ts, fn) {
|
|
|
313
462
|
return binding.name.text;
|
|
314
463
|
return undefined;
|
|
315
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* A gate's name. `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
|
|
467
|
+
* case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them authorization
|
|
468
|
+
* decisions. `ensure`/`verify`/`check`/`validate` name one only before what a gate decides on
|
|
469
|
+
* (`verifyToken`, `ensureLoggedIn`, `checkPermission`), so `ensureDir` and `checkHealth` are not gates.
|
|
470
|
+
*/
|
|
471
|
+
const GATE_NAMES = [
|
|
472
|
+
/^(?:require|assert|authori[sz]e|authenticate)/i,
|
|
473
|
+
/^can[A-Z]/,
|
|
474
|
+
/.Guard$/,
|
|
475
|
+
/^(?: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)/,
|
|
476
|
+
];
|
|
477
|
+
/** Helpers whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). */
|
|
478
|
+
const FAILING_HELPERS = new Set(["fail", "deny", "reject", "forbid", "unauthorized", "abort"]);
|
|
479
|
+
/**
|
|
480
|
+
* Whether a gate's catch block fails closed: it throws, returns `false`, a `new Response(…)` or a
|
|
481
|
+
* `deny…(…)` call, or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
|
|
482
|
+
* conformance/invariant asserts that funnel every error through a `never`-returning `fail()` are not
|
|
483
|
+
* fail-open - the scanner reads no types, so it trusts the helper by name, the same trade the SQL and
|
|
484
|
+
* gate-name heuristics already make. Read from the syntax, so a "throw" in a comment or a string counts
|
|
485
|
+
* for nothing, and a nested function's statements are not the catch's own.
|
|
486
|
+
*/
|
|
487
|
+
function failsClosed(ts, block) {
|
|
488
|
+
let closed = false;
|
|
489
|
+
const visit = (node) => {
|
|
490
|
+
if (closed || ts.isFunctionLike(node))
|
|
491
|
+
return;
|
|
492
|
+
if (ts.isThrowStatement(node) ||
|
|
493
|
+
(ts.isReturnStatement(node) &&
|
|
494
|
+
node.expression !== undefined &&
|
|
495
|
+
isDenial(ts, node.expression)) ||
|
|
496
|
+
(ts.isCallExpression(node) &&
|
|
497
|
+
ts.isIdentifier(node.expression) &&
|
|
498
|
+
FAILING_HELPERS.has(node.expression.text))) {
|
|
499
|
+
closed = true;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
ts.forEachChild(node, visit);
|
|
503
|
+
};
|
|
504
|
+
visit(block);
|
|
505
|
+
return closed;
|
|
506
|
+
}
|
|
507
|
+
function isDenial(ts, expression) {
|
|
508
|
+
let value = expression;
|
|
509
|
+
while (ts.isParenthesizedExpression(value) ||
|
|
510
|
+
ts.isAwaitExpression(value) ||
|
|
511
|
+
ts.isAsExpression(value) ||
|
|
512
|
+
ts.isSatisfiesExpression(value))
|
|
513
|
+
value = value.expression;
|
|
514
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword)
|
|
515
|
+
return true;
|
|
516
|
+
if (ts.isNewExpression(value))
|
|
517
|
+
return ts.isIdentifier(value.expression) && value.expression.text === "Response";
|
|
518
|
+
return (ts.isCallExpression(value) &&
|
|
519
|
+
ts.isIdentifier(value.expression) &&
|
|
520
|
+
value.expression.text.startsWith("deny"));
|
|
521
|
+
}
|
|
316
522
|
export const failOpenGateRule = {
|
|
317
523
|
code: "NF-S001",
|
|
318
524
|
title: "Fail-open gate",
|
|
@@ -335,34 +541,22 @@ export const failOpenGateRule = {
|
|
|
335
541
|
while (parent !== undefined && !ts.isFunctionLike(parent))
|
|
336
542
|
parent = parent.parent;
|
|
337
543
|
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)) {
|
|
544
|
+
if (name !== undefined && GATE_NAMES.some((gate) => gate.test(name))) {
|
|
545
|
+
if (!failsClosed(ts, node.block)) {
|
|
352
546
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
353
|
-
const reviewed =
|
|
547
|
+
const reviewed = expectedReason(lines, line, "NF-S001") !== undefined;
|
|
354
548
|
findings.push(diagnostic({
|
|
355
549
|
code: "NF-S001",
|
|
356
550
|
severity: reviewed ? "info" : "error",
|
|
357
551
|
file,
|
|
358
552
|
line,
|
|
359
553
|
message: reviewed
|
|
360
|
-
? "gate catch block is
|
|
361
|
-
: "gate catch blocks must rethrow or return an explicit denial",
|
|
554
|
+
? "gate catch block is expected here"
|
|
555
|
+
: unexpectedMessage("gate catch blocks must rethrow or return an explicit denial", lines, line, "NF-S001"),
|
|
362
556
|
evidence: [
|
|
363
557
|
name,
|
|
364
558
|
"catch block has no denial or rethrow",
|
|
365
|
-
...
|
|
559
|
+
...expectationEvidence(lines, line, "NF-S001"),
|
|
366
560
|
],
|
|
367
561
|
verify: "nifra check --lints-only",
|
|
368
562
|
}));
|
|
@@ -427,16 +621,19 @@ export const corsOriginPredicateRule = {
|
|
|
427
621
|
: false;
|
|
428
622
|
if (ignoresOrigin) {
|
|
429
623
|
const line = tree.getLineAndCharacterOfPosition(prop.getStart(tree)).line + 1;
|
|
430
|
-
const reviewed =
|
|
624
|
+
const reviewed = expectedReason(lines, line, "NF-S004") !== undefined;
|
|
431
625
|
findings.push(diagnostic({
|
|
432
626
|
code: "NF-S004",
|
|
433
627
|
severity: reviewed ? "info" : "warn",
|
|
434
628
|
file,
|
|
435
629
|
line,
|
|
436
630
|
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: [
|
|
631
|
+
? "constant CORS origin predicate is expected here"
|
|
632
|
+
: unexpectedMessage("CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument", lines, line, "NF-S004"),
|
|
633
|
+
evidence: [
|
|
634
|
+
"origin predicate",
|
|
635
|
+
...expectationEvidence(lines, line, "NF-S004"),
|
|
636
|
+
],
|
|
440
637
|
verify: "nifra check --lints-only",
|
|
441
638
|
}));
|
|
442
639
|
}
|
|
@@ -477,16 +674,16 @@ export const externalRedirectRule = {
|
|
|
477
674
|
nameOf(ts, prop.name) === "external" &&
|
|
478
675
|
prop.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
479
676
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
480
|
-
const reviewed =
|
|
677
|
+
const reviewed = expectedReason(lines, line, "NF-S005") !== undefined;
|
|
481
678
|
findings.push(diagnostic({
|
|
482
679
|
code: "NF-S005",
|
|
483
680
|
severity: reviewed ? "info" : "warn",
|
|
484
681
|
file,
|
|
485
682
|
line,
|
|
486
683
|
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", ...
|
|
684
|
+
? "external redirect is expected here"
|
|
685
|
+
: 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"),
|
|
686
|
+
evidence: ["external: true", ...expectationEvidence(lines, line, "NF-S005")],
|
|
490
687
|
verify: "nifra check --lints-only",
|
|
491
688
|
}));
|
|
492
689
|
}
|
|
@@ -527,16 +724,16 @@ export const assuranceEscapeHatchRule = {
|
|
|
527
724
|
const consequence = name !== undefined ? ESCAPE_HATCHES[name] : undefined;
|
|
528
725
|
if (name !== undefined && consequence !== undefined) {
|
|
529
726
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
530
|
-
const reviewed =
|
|
727
|
+
const reviewed = expectedReason(lines, line, "NF-S006") !== undefined;
|
|
531
728
|
findings.push(diagnostic({
|
|
532
729
|
code: "NF-S006",
|
|
533
730
|
severity: reviewed ? "info" : "warn",
|
|
534
731
|
file,
|
|
535
732
|
line,
|
|
536
733
|
message: reviewed
|
|
537
|
-
? `${name} escape hatch is
|
|
538
|
-
: `${name}: ${consequence}`,
|
|
539
|
-
evidence: [name, ...
|
|
734
|
+
? `${name} escape hatch is expected here`
|
|
735
|
+
: unexpectedMessage(`${name}: ${consequence}`, lines, line, "NF-S006"),
|
|
736
|
+
evidence: [name, ...expectationEvidence(lines, line, "NF-S006")],
|
|
540
737
|
verify: "nifra check --lints-only",
|
|
541
738
|
}));
|
|
542
739
|
}
|
|
@@ -599,6 +796,21 @@ export const unprefixedSecureCookieRule = {
|
|
|
599
796
|
return findings;
|
|
600
797
|
},
|
|
601
798
|
};
|
|
799
|
+
/** Reads the backend the contract-lock check loaded; this rule runs no project code of its own. */
|
|
800
|
+
export const inputBeforeAuthenticationRule = {
|
|
801
|
+
code: "NF-S008",
|
|
802
|
+
title: "Authenticated route validates input before authentication",
|
|
803
|
+
async scan(ctx) {
|
|
804
|
+
return (ctx.project.check.contracts.inputBeforeAuth ?? []).map((route) => diagnostic({
|
|
805
|
+
code: "NF-S008",
|
|
806
|
+
severity: "warn",
|
|
807
|
+
file: BACKEND_APP_FILE,
|
|
808
|
+
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`,
|
|
809
|
+
evidence: [route],
|
|
810
|
+
verify: "nifra check --lints-only",
|
|
811
|
+
}));
|
|
812
|
+
},
|
|
813
|
+
};
|
|
602
814
|
export const securityRules = Object.freeze([
|
|
603
815
|
failOpenGateRule,
|
|
604
816
|
secretComparisonRule,
|
|
@@ -607,5 +819,6 @@ export const securityRules = Object.freeze([
|
|
|
607
819
|
externalRedirectRule,
|
|
608
820
|
assuranceEscapeHatchRule,
|
|
609
821
|
unprefixedSecureCookieRule,
|
|
822
|
+
inputBeforeAuthenticationRule,
|
|
610
823
|
]);
|
|
611
824
|
//# sourceMappingURL=security.js.map
|