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