@nifrajs/cli 2.11.0 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/assure-hydration.d.ts +14 -0
- package/dist/assure-hydration.d.ts.map +1 -0
- package/dist/assure-hydration.js +483 -0
- package/dist/assure-hydration.js.map +1 -0
- package/dist/assure.d.ts +35 -0
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +222 -1
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +13 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +64 -1
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +125 -10
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +706 -145
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +194 -10
- package/dist/cli.js.map +1 -1
- package/dist/contract-proof.d.ts +49 -0
- package/dist/contract-proof.d.ts.map +1 -0
- package/dist/contract-proof.js +128 -0
- package/dist/contract-proof.js.map +1 -0
- package/dist/contracts.d.ts +22 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +134 -0
- package/dist/contracts.js.map +1 -0
- package/dist/diagnostics.d.ts +23 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +9 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/diff-tool.d.ts +2 -0
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +1 -1
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts +8 -3
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +36 -15
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +28 -2
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +173 -7
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +1 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +37 -16
- package/dist/examples.js.map +1 -1
- package/dist/fix-recipes.d.ts +12 -0
- package/dist/fix-recipes.d.ts.map +1 -0
- package/dist/fix-recipes.js +106 -0
- package/dist/fix-recipes.js.map +1 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +60 -4
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/source-facts.d.ts +15 -0
- package/dist/internal/source-facts.d.ts.map +1 -0
- package/dist/internal/source-facts.js +82 -0
- package/dist/internal/source-facts.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +9 -0
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +38 -0
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +53 -4
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +6 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +14 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +3 -0
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-root.d.ts +83 -0
- package/dist/mcp-root.d.ts.map +1 -0
- package/dist/mcp-root.js +221 -0
- package/dist/mcp-root.js.map +1 -0
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +18 -4
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +27 -4
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +358 -25
- package/dist/mcp.js.map +1 -1
- package/dist/project-path.d.ts +3 -0
- package/dist/project-path.d.ts.map +1 -0
- package/dist/project-path.js +36 -0
- package/dist/project-path.js.map +1 -0
- package/dist/replay.d.ts +12 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/dist/rules/codes.d.ts +42 -0
- package/dist/rules/codes.d.ts.map +1 -0
- package/dist/rules/codes.js +52 -0
- package/dist/rules/codes.js.map +1 -0
- package/dist/rules/comment-markers.d.ts +10 -0
- package/dist/rules/comment-markers.d.ts.map +1 -0
- package/dist/rules/comment-markers.js +24 -0
- package/dist/rules/comment-markers.js.map +1 -0
- package/dist/rules/index.d.ts +31 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +133 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/legacy.d.ts +7 -0
- package/dist/rules/legacy.d.ts.map +1 -0
- package/dist/rules/legacy.js +85 -0
- package/dist/rules/legacy.js.map +1 -0
- package/dist/rules/routes.d.ts +12 -0
- package/dist/rules/routes.d.ts.map +1 -0
- package/dist/rules/routes.js +142 -0
- package/dist/rules/routes.js.map +1 -0
- package/dist/rules/security.d.ts +10 -0
- package/dist/rules/security.d.ts.map +1 -0
- package/dist/rules/security.js +471 -0
- package/dist/rules/security.js.map +1 -0
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +5 -1
- package/dist/search-terms.js.map +1 -1
- package/dist/types-search.d.ts +1 -2
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +33 -16
- package/dist/types-search.js.map +1 -1
- package/dist/verification.d.ts +9 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +17 -1
- package/dist/verification.js.map +1 -1
- package/dist/work-graph.d.ts +123 -0
- package/dist/work-graph.d.ts.map +1 -0
- package/dist/work-graph.js +465 -0
- package/dist/work-graph.js.map +1 -0
- package/docs/examples.json +32 -2
- package/docs/llms-full.txt +906 -15
- package/docs/types.json +1682 -130
- package/package.json +34 -9
- package/src/assure-hydration.ts +606 -0
- package/src/assure.ts +316 -2
- package/src/capabilities-tool.ts +80 -1
- package/src/check.ts +873 -168
- package/src/cli.ts +198 -11
- package/src/contract-proof.ts +194 -0
- package/src/contracts.ts +157 -0
- package/src/diagnostics.ts +32 -0
- package/src/diff-tool.ts +1 -1
- package/src/docs-search.ts +45 -15
- package/src/doctor.ts +259 -8
- package/src/examples.ts +43 -15
- package/src/fix-recipes.ts +126 -0
- package/src/init-agents.ts +61 -4
- package/src/internal/source-facts.ts +133 -0
- package/src/internal/typescript-import.ts +29 -0
- package/src/introspect.ts +75 -6
- package/src/load.ts +6 -0
- package/src/manifest-tool.ts +16 -10
- package/src/mcp-docs-tools.ts +3 -0
- package/src/mcp-http.ts +1 -1
- package/src/mcp-root.ts +271 -0
- package/src/mcp-run.ts +19 -4
- package/src/mcp.ts +438 -32
- package/src/project-path.ts +36 -0
- package/src/replay.ts +44 -0
- package/src/rules/codes.ts +57 -0
- package/src/rules/comment-markers.ts +26 -0
- package/src/rules/index.ts +175 -0
- package/src/rules/legacy.ts +93 -0
- package/src/rules/routes.ts +157 -0
- package/src/rules/security.ts +505 -0
- package/src/search-terms.ts +9 -4
- package/src/types-search.ts +39 -15
- package/src/verification.ts +18 -1
- 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
|
+
])
|
package/src/search-terms.ts
CHANGED
|
@@ -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))]
|
|
55
|
-
|
|
56
|
-
|
|
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 {
|
package/src/types-search.ts
CHANGED
|
@@ -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
|
|
45
|
-
const
|
|
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(
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/src/verification.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|