@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.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
@@ -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;AAKxC,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,YAAY,CAAA;AAoMxD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CA+B/F;AAED,eAAO,MAAM,oBAAoB,EAAE,SAwClC,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,SAgDxB,CAAA;AA0BD,eAAO,MAAM,gBAAgB,EAAE,SA+D9B,CAAA;AAiBD,eAAO,MAAM,uBAAuB,EAAE,SA6DrC,CAAA;AAED,eAAO,MAAM,oBAAoB,EAAE,SAgDlC,CAAA;AAWD,eAAO,MAAM,wBAAwB,EAAE,SAyCtC,CAAA;AAID,eAAO,MAAM,0BAA0B,EAAE,SAwDxC,CAAA;AAED,eAAO,MAAM,aAAa,sBAQxB,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"}
@@ -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
- const REVIEWED = "@nifra-gate-reviewed";
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
- // The marker counts anywhere in the contiguous comment block above the finding (or trailing on its
27
- // line) - a human writing the multi-line justification the hatch asks for must not un-suppress the
28
- // finding by growing the comment past two lines.
29
- function hasReview(lines, line) {
30
- return commentBlockHasMarker(lines, line, REVIEWED);
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
- function reviewedEvidence(lines, line) {
33
- return hasReview(lines, line) ? [REVIEWED] : [];
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 severity by file role. Server-side code compares real secret material - a timing oracle
37
- * there is exploitable, so it fails the gate. Browser code (a route's frontend half, `frontend/`)
38
- * compares values the client already holds, so the same shape is advisory rather than a gate
39
- * failure. `shared/` also runs on the server, and a file that cannot be classified is treated as
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 secretComparisonSeverity(file) {
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 "error";
61
+ return false;
46
62
  if (/\.shared\.[^/]+$/.test(path) || /(?:^|\/)shared\//.test(path))
47
- return "error";
48
- return "warn";
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(file, left, right) {
53
- if (secretComparisonSeverity(file) !== "warn")
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, never a secret: secrets are strings or bytes, so a number on
89
- * either side proves this is not the compare a timing oracle applies to.
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
- function secretComparisons(ts, file, tree) {
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 !== undefined && SECRET.test(left)) ||
145
- (right !== undefined && SECRET.test(right))) &&
146
- !isClientConfirmationPair(file, left, right)) {
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, file, tree).filter((site) => !hasReview(lines, site.line));
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
- for (const { left, right, line } of secretComparisons(ts, file, tree)) {
222
- const reviewed = hasReview(lines, line);
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" : secretComparisonSeverity(file),
371
+ severity: reviewed ? "info" : browser ? "warn" : "error",
226
372
  file,
227
373
  line,
228
374
  message: reviewed
229
- ? "secret comparison is explicitly marked as reviewed"
230
- : "secret-like values must use a length check and timing-safe comparison",
231
- evidence: [left ?? right ?? "secret comparison", ...reviewedEvidence(lines, line)],
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 = hasReview(lines, line);
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 explicitly marked as reviewed"
282
- : "PII-shaped values must not be passed directly to log calls",
283
- evidence: [name, ...reviewedEvidence(lines, line)],
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
- // `can` only names a gate in camelCase (`canEdit`, `canAccess`) - matching it
339
- // case-insensitively swept in `canonicalize…`, `cancel…`, `candidate…`, none of them
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 = hasReview(lines, line);
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 explicitly marked as reviewed"
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
- ...reviewedEvidence(lines, line),
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 = hasReview(lines, line);
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 explicitly marked as reviewed"
438
- : "CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument",
439
- evidence: ["origin predicate", ...reviewedEvidence(lines, line)],
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 = hasReview(lines, line);
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 explicitly marked as reviewed"
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", ...reviewedEvidence(lines, line)],
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 = hasReview(lines, line);
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 explicitly marked as reviewed`
538
- : `${name}: ${consequence}`,
539
- evidence: [name, ...reviewedEvidence(lines, line)],
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