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