@nifrajs/cli 2.10.0 → 2.12.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 (193) hide show
  1. package/README.md +7 -1
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
@@ -0,0 +1,505 @@
1
+ import type * as TSApi from "typescript"
2
+ import { type Diagnostic, diagnostic } from "../diagnostics.ts"
3
+ import { importProjectTypeScript } from "../internal/typescript-import.ts"
4
+ import { commentBlockHasMarker } from "./comment-markers.ts"
5
+ import type { CheckRule, SourceIndex } from "./index.ts"
6
+
7
+ /**
8
+ * A security rule that cannot run says so as an advisory finding instead of silently returning no
9
+ * findings - a report that skipped a scanner reads as "scanned and safe", which is a lie. Same
10
+ * contract the interpolated-SQL rule already keeps.
11
+ */
12
+ function didNotRun(code: string, title: string): readonly Diagnostic[] {
13
+ return [
14
+ diagnostic({
15
+ code,
16
+ severity: "warn",
17
+ message: `${title} (${code}) did NOT run - TypeScript is not installed, so this report says nothing about it`,
18
+ fix: { recipe: "toolchain.install-typescript", command: "bun add -d typescript" },
19
+ verify: "nifra check --lints-only",
20
+ }),
21
+ ]
22
+ }
23
+
24
+ const SECRET = /(?:token|secret|apiKey|api_key|signature|hmac|password)/i
25
+ const PII = /(?:email|phone|ssn|password|token|authorization)/i
26
+ const REVIEWED = "@nifra-gate-reviewed"
27
+ const parseCache = new WeakMap<
28
+ SourceIndex,
29
+ Map<string, { readonly tree: TSApi.SourceFile; readonly lines: readonly string[] }>
30
+ >()
31
+
32
+ // The marker counts anywhere in the contiguous comment block above the finding (or trailing on its
33
+ // line) - a human writing the multi-line justification the hatch asks for must not un-suppress the
34
+ // finding by growing the comment past two lines.
35
+ function hasReview(lines: readonly string[], line: number): boolean {
36
+ return commentBlockHasMarker(lines, line, REVIEWED)
37
+ }
38
+
39
+ function reviewedEvidence(lines: readonly string[], line: number): readonly string[] {
40
+ return hasReview(lines, line) ? [REVIEWED] : []
41
+ }
42
+
43
+ /**
44
+ * NF-S002 severity by file role. Server-side code compares real secret material - a timing oracle
45
+ * there is exploitable, so it fails the gate. Client-bundled code (route modules, .tsx/.jsx)
46
+ * compares values the client already holds, so the same shape is advisory rather than a gate
47
+ * failure. A server marker beats a client marker (`routes/x.server.ts` is server), and a plain .ts
48
+ * that cannot be classified is treated as server - fail closed.
49
+ */
50
+ function secretComparisonSeverity(file: string): "error" | "warn" {
51
+ const path = file.replaceAll("\\", "/")
52
+ if (/\.server\.[cm]?[tj]sx?$/.test(path)) return "error"
53
+ if (/(?:^|\/)server\//.test(path) || /(?:^|\/)backend\.[cm]?[tj]s$/.test(path)) return "error"
54
+ if (/\.[tj]sx$/.test(path) || /(?:^|\/)routes\//.test(path)) return "warn"
55
+ return "error"
56
+ }
57
+
58
+ function nameOf(ts: typeof TSApi, node: TSApi.Node): string | undefined {
59
+ if (ts.isIdentifier(node)) return node.text
60
+ if (ts.isPropertyAccessExpression(node)) return node.name.text
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * Presence checks (`token === undefined`, `secret == null`, `apiKey !== ""`) and `typeof` guards are
66
+ * not equality over secret material - rewriting them to a timing-safe comparison would break the code.
67
+ */
68
+ function isPresenceComparison(ts: typeof TSApi, node: TSApi.BinaryExpression): boolean {
69
+ for (const side of [node.left, node.right]) {
70
+ if (ts.isIdentifier(side) && side.text === "undefined") return true
71
+ if (side.kind === ts.SyntaxKind.NullKeyword) return true
72
+ if (ts.isStringLiteralLike(side) && side.text === "") return true
73
+ if (ts.isTypeOfExpression(side)) return true
74
+ if (ts.isVoidExpression(side)) return true
75
+ }
76
+ return false
77
+ }
78
+
79
+ function parse(ts: typeof TSApi, file: string, source: string): TSApi.SourceFile {
80
+ const kind = /\.tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JS
81
+ return ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
82
+ }
83
+
84
+ function parsedFile(
85
+ ts: typeof TSApi,
86
+ sources: SourceIndex,
87
+ file: string,
88
+ ): { readonly tree: TSApi.SourceFile; readonly lines: readonly string[] } | undefined {
89
+ let files = parseCache.get(sources)
90
+ if (files === undefined) {
91
+ files = new Map()
92
+ parseCache.set(sources, files)
93
+ }
94
+ const cached = files.get(file)
95
+ if (cached !== undefined) return cached
96
+ const source = sources.read(file)
97
+ if (source === undefined) return undefined
98
+ const tree = parse(ts, file, source)
99
+ const parsed = { tree, lines: tree.text.split("\n") }
100
+ files.set(file, parsed)
101
+ return parsed
102
+ }
103
+
104
+ export const secretComparisonRule: CheckRule = {
105
+ code: "NF-S002",
106
+ title: "Non-constant-time secret comparison",
107
+ async scan(ctx) {
108
+ const ts = await importProjectTypeScript(ctx.root)
109
+ if (ts === undefined) return didNotRun("NF-S002", "Non-constant-time secret comparison scan")
110
+ const findings: Diagnostic[] = []
111
+ for (const file of ctx.sources.files) {
112
+ const parsed = parsedFile(ts, ctx.sources, file)
113
+ if (parsed === undefined) continue
114
+ const { tree, lines } = parsed
115
+ const visit = (node: TSApi.Node): void => {
116
+ if (
117
+ ts.isBinaryExpression(node) &&
118
+ [
119
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
120
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
121
+ ts.SyntaxKind.EqualsEqualsToken,
122
+ ts.SyntaxKind.ExclamationEqualsToken,
123
+ ].includes(node.operatorToken.kind) &&
124
+ !isPresenceComparison(ts, node)
125
+ ) {
126
+ const left = nameOf(ts, node.left)
127
+ const right = nameOf(ts, node.right)
128
+ if (
129
+ (left !== undefined && SECRET.test(left)) ||
130
+ (right !== undefined && SECRET.test(right))
131
+ ) {
132
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
133
+ const reviewed = hasReview(lines, line)
134
+ findings.push(
135
+ diagnostic({
136
+ code: "NF-S002",
137
+ severity: reviewed ? "info" : secretComparisonSeverity(file),
138
+ file,
139
+ line,
140
+ message: reviewed
141
+ ? "secret comparison is explicitly marked as reviewed"
142
+ : "secret-like values must use a length check and timing-safe comparison",
143
+ evidence: [left ?? right ?? "secret comparison", ...reviewedEvidence(lines, line)],
144
+ ...(reviewed
145
+ ? {}
146
+ : {
147
+ fix: {
148
+ recipe: "security.timing-safe-equal",
149
+ command: "nifra fix --code NF-S002",
150
+ },
151
+ }),
152
+ verify: "nifra check --lints-only",
153
+ }),
154
+ )
155
+ }
156
+ }
157
+ ts.forEachChild(node, visit)
158
+ }
159
+ visit(tree)
160
+ }
161
+ return findings
162
+ },
163
+ }
164
+
165
+ export const piiLogRule: CheckRule = {
166
+ code: "NF-S003",
167
+ title: "Sensitive value in log call",
168
+ async scan(ctx) {
169
+ const ts = await importProjectTypeScript(ctx.root)
170
+ if (ts === undefined) return didNotRun("NF-S003", "Sensitive-value-in-log scan")
171
+ const findings: Diagnostic[] = []
172
+ for (const file of ctx.sources.files) {
173
+ const parsed = parsedFile(ts, ctx.sources, file)
174
+ if (parsed === undefined) continue
175
+ const { tree, lines } = parsed
176
+ const visit = (node: TSApi.Node): void => {
177
+ if (ts.isCallExpression(node)) {
178
+ const expression = node.expression
179
+ const callee = ts.isPropertyAccessExpression(expression)
180
+ ? `${nameOf(ts, expression.expression) ?? ""}.${expression.name.text}`
181
+ : (nameOf(ts, expression) ?? "")
182
+ if (/^(?:console|logger)\./.test(callee)) {
183
+ for (const arg of node.arguments) {
184
+ const name = nameOf(ts, arg)
185
+ if (name !== undefined && PII.test(name)) {
186
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
187
+ const reviewed = hasReview(lines, line)
188
+ findings.push(
189
+ diagnostic({
190
+ code: "NF-S003",
191
+ severity: reviewed ? "info" : "warn",
192
+ file,
193
+ line,
194
+ message: reviewed
195
+ ? "sensitive value in log call is explicitly marked as reviewed"
196
+ : "PII-shaped values must not be passed directly to log calls",
197
+ evidence: [name, ...reviewedEvidence(lines, line)],
198
+ verify: "nifra check --lints-only",
199
+ }),
200
+ )
201
+ }
202
+ }
203
+ }
204
+ }
205
+ ts.forEachChild(node, visit)
206
+ }
207
+ visit(tree)
208
+ }
209
+ return findings
210
+ },
211
+ }
212
+
213
+ export const failOpenGateRule: CheckRule = {
214
+ code: "NF-S001",
215
+ title: "Fail-open gate",
216
+ async scan(ctx) {
217
+ const ts = await importProjectTypeScript(ctx.root)
218
+ if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
219
+ const findings: Diagnostic[] = []
220
+ for (const file of ctx.sources.files) {
221
+ const parsed = parsedFile(ts, ctx.sources, file)
222
+ if (parsed === undefined) continue
223
+ const { tree, lines } = parsed
224
+ const visit = (node: TSApi.Node): void => {
225
+ if (ts.isCatchClause(node)) {
226
+ let parent: TSApi.Node | undefined = node.parent
227
+ while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
228
+ const name =
229
+ ts.isFunctionLike(parent) && parent.name && ts.isIdentifier(parent.name)
230
+ ? parent.name.text
231
+ : undefined
232
+ if (name !== undefined && /^(?:require|assert|can|authorize)/i.test(name)) {
233
+ const text = node.block.getText(tree)
234
+ if (!/\b(?:throw|return\s+(?:false|new\s+Response|deny))/s.test(text)) {
235
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
236
+ const reviewed = hasReview(lines, line)
237
+ findings.push(
238
+ diagnostic({
239
+ code: "NF-S001",
240
+ severity: reviewed ? "info" : "error",
241
+ file,
242
+ line,
243
+ message: reviewed
244
+ ? "gate catch block is explicitly marked as reviewed"
245
+ : "gate catch blocks must rethrow or return an explicit denial",
246
+ evidence: [
247
+ name,
248
+ "catch block has no denial or rethrow",
249
+ ...reviewedEvidence(lines, line),
250
+ ],
251
+ verify: "nifra check --lints-only",
252
+ }),
253
+ )
254
+ }
255
+ }
256
+ }
257
+ ts.forEachChild(node, visit)
258
+ }
259
+ visit(tree)
260
+ }
261
+ return findings
262
+ },
263
+ }
264
+
265
+ /** True when `param` is referenced anywhere inside `body`. */
266
+ function usesParam(ts: typeof TSApi, body: TSApi.Node, param: string): boolean {
267
+ let used = false
268
+ const walk = (n: TSApi.Node): void => {
269
+ if (used) return
270
+ if (ts.isIdentifier(n) && n.text === param) {
271
+ used = true
272
+ return
273
+ }
274
+ ts.forEachChild(n, walk)
275
+ }
276
+ walk(body)
277
+ return used
278
+ }
279
+
280
+ export const corsOriginPredicateRule: CheckRule = {
281
+ code: "NF-S004",
282
+ title: "CORS origin predicate ignores the origin",
283
+ async scan(ctx) {
284
+ const ts = await importProjectTypeScript(ctx.root)
285
+ if (ts === undefined) return didNotRun("NF-S004", "CORS origin predicate scan")
286
+ const findings: Diagnostic[] = []
287
+ for (const file of ctx.sources.files) {
288
+ const parsed = parsedFile(ts, ctx.sources, file)
289
+ if (parsed === undefined) continue
290
+ const { tree, lines } = parsed
291
+ const visit = (node: TSApi.Node): void => {
292
+ if (ts.isCallExpression(node) && nameOf(ts, node.expression) === "cors") {
293
+ const arg = node.arguments[0]
294
+ if (arg !== undefined && ts.isObjectLiteralExpression(arg)) {
295
+ for (const prop of arg.properties) {
296
+ if (
297
+ ts.isPropertyAssignment(prop) &&
298
+ nameOf(ts, prop.name) === "origin" &&
299
+ (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))
300
+ ) {
301
+ const fn = prop.initializer
302
+ const param = fn.parameters[0]?.name
303
+ // A predicate with no parameter, or one that never reads it, allows every origin -
304
+ // the guardrail the predicate form exists for is bypassed. A destructured parameter
305
+ // is left alone (can't cheaply prove non-use).
306
+ const ignoresOrigin =
307
+ param === undefined
308
+ ? true
309
+ : ts.isIdentifier(param)
310
+ ? !usesParam(ts, fn.body, param.text)
311
+ : false
312
+ if (ignoresOrigin) {
313
+ const line = tree.getLineAndCharacterOfPosition(prop.getStart(tree)).line + 1
314
+ const reviewed = hasReview(lines, line)
315
+ findings.push(
316
+ diagnostic({
317
+ code: "NF-S004",
318
+ severity: reviewed ? "info" : "warn",
319
+ file,
320
+ line,
321
+ message: reviewed
322
+ ? "constant CORS origin predicate is explicitly marked as reviewed"
323
+ : "CORS origin predicate never inspects the origin - it allows every origin; list explicit origins or use the argument",
324
+ evidence: ["origin predicate", ...reviewedEvidence(lines, line)],
325
+ verify: "nifra check --lints-only",
326
+ }),
327
+ )
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ ts.forEachChild(node, visit)
334
+ }
335
+ visit(tree)
336
+ }
337
+ return findings
338
+ },
339
+ }
340
+
341
+ export const externalRedirectRule: CheckRule = {
342
+ code: "NF-S005",
343
+ title: "External redirect opt-out",
344
+ async scan(ctx) {
345
+ const ts = await importProjectTypeScript(ctx.root)
346
+ if (ts === undefined) return didNotRun("NF-S005", "External redirect opt-out scan")
347
+ const findings: Diagnostic[] = []
348
+ for (const file of ctx.sources.files) {
349
+ const parsed = parsedFile(ts, ctx.sources, file)
350
+ if (parsed === undefined) continue
351
+ const { tree, lines } = parsed
352
+ const visit = (node: TSApi.Node): void => {
353
+ if (ts.isCallExpression(node) && nameOf(ts, node.expression) === "redirect") {
354
+ for (const arg of node.arguments) {
355
+ if (!ts.isObjectLiteralExpression(arg)) continue
356
+ for (const prop of arg.properties) {
357
+ if (
358
+ ts.isPropertyAssignment(prop) &&
359
+ nameOf(ts, prop.name) === "external" &&
360
+ prop.initializer.kind === ts.SyntaxKind.TrueKeyword
361
+ ) {
362
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
363
+ const reviewed = hasReview(lines, line)
364
+ findings.push(
365
+ diagnostic({
366
+ code: "NF-S005",
367
+ severity: reviewed ? "info" : "warn",
368
+ file,
369
+ line,
370
+ message: reviewed
371
+ ? "external redirect is explicitly marked as reviewed"
372
+ : "redirect opts out of the same-origin default - verify the target can never be derived from request input (open-redirect risk)",
373
+ evidence: ["external: true", ...reviewedEvidence(lines, line)],
374
+ verify: "nifra check --lints-only",
375
+ }),
376
+ )
377
+ }
378
+ }
379
+ }
380
+ }
381
+ ts.forEachChild(node, visit)
382
+ }
383
+ visit(tree)
384
+ }
385
+ return findings
386
+ },
387
+ }
388
+
389
+ const ESCAPE_HATCHES: Readonly<Record<string, string>> = {
390
+ allowLengthless:
391
+ "body-limit stops publishing BODY_BOUNDED evidence - lengthless (chunked) bodies bypass the Content-Length gate",
392
+ allowGlobalKey:
393
+ "rate limiting collapses to one shared bucket - any client can exhaust the global allowance for everyone",
394
+ allowInProduction:
395
+ "in-memory rate-limit store in production - counts are per-instance and reset on restart",
396
+ }
397
+
398
+ export const assuranceEscapeHatchRule: CheckRule = {
399
+ code: "NF-S006",
400
+ title: "Security escape hatch enabled",
401
+ async scan(ctx) {
402
+ const ts = await importProjectTypeScript(ctx.root)
403
+ if (ts === undefined) return didNotRun("NF-S006", "Security escape hatch scan")
404
+ const findings: Diagnostic[] = []
405
+ for (const file of ctx.sources.files) {
406
+ const parsed = parsedFile(ts, ctx.sources, file)
407
+ if (parsed === undefined) continue
408
+ const { tree, lines } = parsed
409
+ const visit = (node: TSApi.Node): void => {
410
+ if (ts.isPropertyAssignment(node) && node.initializer.kind === ts.SyntaxKind.TrueKeyword) {
411
+ const name = nameOf(ts, node.name)
412
+ const consequence = name !== undefined ? ESCAPE_HATCHES[name] : undefined
413
+ if (name !== undefined && consequence !== undefined) {
414
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
415
+ const reviewed = hasReview(lines, line)
416
+ findings.push(
417
+ diagnostic({
418
+ code: "NF-S006",
419
+ severity: reviewed ? "info" : "warn",
420
+ file,
421
+ line,
422
+ message: reviewed
423
+ ? `${name} escape hatch is explicitly marked as reviewed`
424
+ : `${name}: ${consequence}`,
425
+ evidence: [name, ...reviewedEvidence(lines, line)],
426
+ verify: "nifra check --lints-only",
427
+ }),
428
+ )
429
+ }
430
+ }
431
+ ts.forEachChild(node, visit)
432
+ }
433
+ visit(tree)
434
+ }
435
+ return findings
436
+ },
437
+ }
438
+
439
+ const COOKIE_PREFIX = /^__(?:Host|Secure)-/
440
+
441
+ export const unprefixedSecureCookieRule: CheckRule = {
442
+ code: "NF-S007",
443
+ title: "Secure cookie without a __Host-/__Secure- prefix",
444
+ async scan(ctx) {
445
+ const ts = await importProjectTypeScript(ctx.root)
446
+ if (ts === undefined) return didNotRun("NF-S007", "Secure cookie prefix scan")
447
+ const findings: Diagnostic[] = []
448
+ for (const file of ctx.sources.files) {
449
+ const parsed = parsedFile(ts, ctx.sources, file)
450
+ if (parsed === undefined) continue
451
+ const { tree } = parsed
452
+ const visit = (node: TSApi.Node): void => {
453
+ if (ts.isCallExpression(node)) {
454
+ const callee = nameOf(ts, node.expression)
455
+ if (callee === "cookie" || callee === "serializeCookie") {
456
+ const nameArg = node.arguments[0]
457
+ const hasPrefix =
458
+ nameArg !== undefined &&
459
+ ts.isStringLiteralLike(nameArg) &&
460
+ COOKIE_PREFIX.test(nameArg.text)
461
+ const secure = node.arguments.some(
462
+ (arg) =>
463
+ ts.isObjectLiteralExpression(arg) &&
464
+ arg.properties.some(
465
+ (prop) =>
466
+ ts.isPropertyAssignment(prop) &&
467
+ nameOf(ts, prop.name) === "secure" &&
468
+ prop.initializer.kind === ts.SyntaxKind.TrueKeyword,
469
+ ),
470
+ )
471
+ // Only literal names are judged - a dynamic name can't be checked for a prefix.
472
+ if (secure && !hasPrefix && nameArg !== undefined && ts.isStringLiteralLike(nameArg)) {
473
+ const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
474
+ findings.push(
475
+ diagnostic({
476
+ code: "NF-S007",
477
+ severity: "info",
478
+ file,
479
+ line,
480
+ message:
481
+ "Secure cookie without a __Host-/__Secure- prefix - a prefix stops subdomains and insecure contexts from shadowing it",
482
+ evidence: [nameArg.text],
483
+ verify: "nifra check --lints-only",
484
+ }),
485
+ )
486
+ }
487
+ }
488
+ }
489
+ ts.forEachChild(node, visit)
490
+ }
491
+ visit(tree)
492
+ }
493
+ return findings
494
+ },
495
+ }
496
+
497
+ export const securityRules = Object.freeze([
498
+ failOpenGateRule,
499
+ secretComparisonRule,
500
+ piiLogRule,
501
+ corsOriginPredicateRule,
502
+ externalRedirectRule,
503
+ assuranceEscapeHatchRule,
504
+ unprefixedSecureCookieRule,
505
+ ])
@@ -44,6 +44,9 @@ const ALIASES: Readonly<Record<string, readonly string[]>> = {
44
44
  ws: ["websocket", "websockets"],
45
45
  }
46
46
 
47
+ export const MAX_QUERY_CHARS = 256
48
+ export const MAX_QUERY_TERMS = 12
49
+
47
50
  export const tokenize = (s: string): string[] =>
48
51
  s
49
52
  .toLowerCase()
@@ -51,10 +54,12 @@ export const tokenize = (s: string): string[] =>
51
54
  .filter((t) => t.length > 1)
52
55
 
53
56
  export function queryTermGroups(query: string): SearchTermGroup[] {
54
- return [...new Set(tokenize(query))].map((term) => ({
55
- term,
56
- variants: [term, ...(ALIASES[term] ?? [])],
57
- }))
57
+ return [...new Set(tokenize(query.slice(0, MAX_QUERY_CHARS)))]
58
+ .slice(0, MAX_QUERY_TERMS)
59
+ .map((term) => ({
60
+ term,
61
+ variants: [term, ...(ALIASES[term] ?? [])],
62
+ }))
58
63
  }
59
64
 
60
65
  function tokenMatches(term: string, token: string): boolean {
@@ -25,6 +25,25 @@ export interface ScoredType extends TypeEntry {
25
25
  readonly score: number
26
26
  }
27
27
 
28
+ interface PreparedType {
29
+ readonly type: TypeEntry
30
+ readonly nameTokens: ReadonlySet<string>
31
+ readonly bodyLower: string
32
+ }
33
+
34
+ const preparedTypesCache = new WeakMap<readonly TypeEntry[], readonly PreparedType[]>()
35
+ const prepareTypes = (types: readonly TypeEntry[]): readonly PreparedType[] => {
36
+ const cached = preparedTypesCache.get(types)
37
+ if (cached !== undefined) return cached
38
+ const prepared = types.map((type) => ({
39
+ type,
40
+ nameTokens: new Set(tokenize(type.name)),
41
+ bodyLower: `${type.signature} ${type.doc ?? ""}`.toLowerCase(),
42
+ }))
43
+ preparedTypesCache.set(types, prepared)
44
+ return prepared
45
+ }
46
+
28
47
  /** Exact (case-insensitive) name lookup - returns every declaration with that name (a name can exist in
29
48
  * more than one package). */
30
49
  export function lookupType(types: readonly TypeEntry[], name: string): TypeEntry[] {
@@ -41,13 +60,12 @@ export function searchTypes(
41
60
  const terms = queryTermGroups(query)
42
61
  if (terms.length === 0) return []
43
62
  const scored: ScoredType[] = []
44
- for (const t of types) {
45
- const nameTokens = new Set(tokenize(t.name))
46
- const body = `${t.signature} ${t.doc ?? ""}`.toLowerCase()
63
+ for (const prepared of prepareTypes(types)) {
64
+ const t = prepared.type
47
65
  let score = 0
48
66
  for (const term of terms) {
49
- if (tokenSetHas(nameTokens, term)) score += 8
50
- score += countBodyHits(body, term, 4)
67
+ if (tokenSetHas(prepared.nameTokens, term)) score += 8
68
+ score += countBodyHits(prepared.bodyLower, term, 4)
51
69
  }
52
70
  if (score > 0) scored.push({ ...t, score })
53
71
  }
@@ -149,14 +167,20 @@ export function renderTypesResult(
149
167
 
150
168
  /** Load the bundled type corpus (`docs/types.json`), resolved relative to this module so it works from
151
169
  * `src/` (dev) and `dist/` (published) alike. */
152
- export async function loadTypesCorpus(): Promise<TypeEntry[] | undefined> {
153
- const url = new URL("../docs/types.json", import.meta.url)
154
- try {
155
- const text = await Bun.file(url).text()
156
- if (text.length === 0) return undefined
157
- const parsed = JSON.parse(text) as TypeEntry[]
158
- return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
159
- } catch {
160
- return undefined
161
- }
170
+ let typesCorpusPromise: Promise<TypeEntry[] | undefined> | undefined
171
+
172
+ /** The published corpus is immutable, so keep one loaded array and its derived search fields. */
173
+ export function loadTypesCorpus(): Promise<TypeEntry[] | undefined> {
174
+ typesCorpusPromise ??= (async () => {
175
+ const url = new URL("../docs/types.json", import.meta.url)
176
+ try {
177
+ const text = await Bun.file(url).text()
178
+ if (text.length === 0) return undefined
179
+ const parsed = JSON.parse(text) as TypeEntry[]
180
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
181
+ } catch {
182
+ return undefined
183
+ }
184
+ })()
185
+ return typesCorpusPromise
162
186
  }
@@ -19,6 +19,7 @@ import {
19
19
  type AssuranceReport,
20
20
  evaluateRouteAssurance,
21
21
  } from "@nifrajs/core/assurance"
22
+ import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "@nifrajs/core/evidence"
22
23
  import {
23
24
  type CapabilityProjectReport,
24
25
  collectCapabilityProjectReport,
@@ -28,6 +29,8 @@ import { type CheckAssuranceContext, type CheckResult, collectCheckResult } from
28
29
  export interface CollectProjectVerificationOptions {
29
30
  /** Assurance config path, default `nifra.assurance.ts`. Honored by the assurance + levels views. */
30
31
  readonly config?: string
32
+ /** A config already loaded by the caller, avoiding a second cache-busted import. */
33
+ readonly preloaded?: { readonly config: AssuranceConfig } | { readonly error: unknown }
31
34
  /** Skip the `tsc` pass in the check view (the agent inner-loop mode). */
32
35
  readonly lintsOnly?: boolean
33
36
  /** Cancels the check view's typecheck. */
@@ -48,6 +51,8 @@ export interface ProjectVerification {
48
51
  readonly routeAssurance?: AssuranceReport
49
52
  /** Static capability provenance, when the config declares a capabilities policy. */
50
53
  readonly capability?: CapabilityProjectReport
54
+ /** Canonical token-only route/schema/assurance/capability facts shared by offline projections. */
55
+ readonly evidence?: ProjectEvidenceSnapshot
51
56
  /**
52
57
  * The typed-contract check (scanners + typecheck + the assurance-fed capability/manifest diagnostics).
53
58
  * Lazy and memoized: `nifra assure` never calls it, so that command pays nothing for the source walk.
@@ -72,10 +77,16 @@ export async function collectProjectVerification(
72
77
  let configError: unknown
73
78
  let routeAssurance: AssuranceReport | undefined
74
79
  let capability: CapabilityProjectReport | undefined
80
+ let evidence: ProjectEvidenceSnapshot | undefined
75
81
  // One try mirrors `check`'s single catch: any failure to load or reflect the config becomes the
76
82
  // `configError` each view then renders in its own idiom (a diagnostic, a throw, a failed rung).
77
83
  try {
78
- config = await loadAssuranceConfig(cwd, options.config)
84
+ if (options.preloaded !== undefined) {
85
+ if ("config" in options.preloaded) config = options.preloaded.config
86
+ else throw options.preloaded.error
87
+ } else {
88
+ config = await loadAssuranceConfig(cwd, options.config)
89
+ }
79
90
  routeAssurance = evaluateRouteAssurance(config.source, config.policy, {
80
91
  ...(config.capabilities !== undefined
81
92
  ? { definitions: config.capabilities.definitions }
@@ -84,6 +95,10 @@ export async function collectProjectVerification(
84
95
  if (config.capabilities !== undefined) {
85
96
  capability = await collectCapabilityProjectReport(cwd, config.source, config.capabilities)
86
97
  }
98
+ evidence = snapshotProjectEvidence(config.source, {
99
+ ...(routeAssurance !== undefined ? { assurance: routeAssurance } : {}),
100
+ ...(capability !== undefined ? { capabilities: capability.report } : {}),
101
+ })
87
102
  } catch (error) {
88
103
  configError = error
89
104
  config = undefined
@@ -100,6 +115,7 @@ export async function collectProjectVerification(
100
115
  ...(configError !== undefined ? { error: configError } : {}),
101
116
  ...(routeAssurance !== undefined ? { routeAssurance } : {}),
102
117
  ...(capability !== undefined ? { capability } : {}),
118
+ ...(evidence !== undefined ? { evidence } : {}),
103
119
  }
104
120
 
105
121
  let checkResult: Promise<CheckResult> | undefined
@@ -110,6 +126,7 @@ export async function collectProjectVerification(
110
126
  ...(configError !== undefined ? { configError } : {}),
111
127
  ...(routeAssurance !== undefined ? { routeAssurance } : {}),
112
128
  ...(capability !== undefined ? { capability } : {}),
129
+ ...(evidence !== undefined ? { evidence } : {}),
113
130
  check(): Promise<CheckResult> {
114
131
  checkResult ??= collectCheckResult(cwd, {
115
132
  lintsOnly: options.lintsOnly ?? false,