progmune-runtime 3.7.56 → 3.7.57
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/CHANGELOG.md +37 -0
- package/dist/frameworks/nestjs-detector.js +428 -161
- package/dist/frameworks/nestjs-detector.test.js +220 -0
- package/dist/protocol-detector.js +11 -11
- package/dist/sdk.js +1 -1
- package/dist/trust/alert-ranker.js +3 -1
- package/dist/trust/confidence-calculator.js +3 -1
- package/dist/trust/engine.js +20 -2
- package/dist/trust/score-calculator.js +88 -8
- package/dist/trust/score-calculator.test.js +149 -0
- package/dist/trust/types.js +17 -1
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
19
|
exports.analyzeNestJSProject = analyzeNestJSProject;
|
|
20
|
+
exports.collectAppLevelProviders = collectAppLevelProviders;
|
|
20
21
|
exports.analyzeNestJSFile = analyzeNestJSFile;
|
|
21
22
|
exports.formatNestJSReport = formatNestJSReport;
|
|
22
23
|
const ts_morph_1 = require("ts-morph");
|
|
@@ -38,51 +39,47 @@ function analyzeNestJSProject(projectRoot) {
|
|
|
38
39
|
project.addSourceFilesAtPaths(`${projectRoot}/**/*.ts`);
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
controllers: [], routes: [], issues: [], globalAuthGuards: [],
|
|
44
|
+
globalValidationPipes: [], registrations: [],
|
|
45
|
+
};
|
|
42
46
|
}
|
|
43
47
|
}
|
|
48
|
+
// 需要跳过 node_modules / 测试文件(它们在多个扫描里重复出现,抽出来一次)
|
|
49
|
+
const sourceFiles = project
|
|
50
|
+
.getSourceFiles()
|
|
51
|
+
.filter((f) => !f.getFilePath().includes("node_modules") &&
|
|
52
|
+
!/\.(test|spec)\.ts$/.test(f.getFilePath()));
|
|
53
|
+
// ── 注册事件(§52):一次性收集全部 app-level provider ──
|
|
54
|
+
const registrations = collectAppLevelProviders(sourceFiles);
|
|
44
55
|
const analysis = {
|
|
45
56
|
controllers: [],
|
|
46
57
|
routes: [],
|
|
47
58
|
issues: [],
|
|
48
59
|
globalAuthGuards: [],
|
|
60
|
+
globalValidationPipes: registrations
|
|
61
|
+
.filter((r) => r.token === "APP_PIPE")
|
|
62
|
+
.map((r) => r.impl)
|
|
63
|
+
.filter((n) => !!n),
|
|
64
|
+
registrations,
|
|
49
65
|
};
|
|
50
|
-
//
|
|
51
|
-
for (const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
continue;
|
|
56
|
-
for (const cls of file.getClasses()) {
|
|
57
|
-
const moduleDec = cls.getDecorator("Module");
|
|
58
|
-
if (!moduleDec)
|
|
59
|
-
continue;
|
|
60
|
-
const guardNames = extractAppGuardNames(moduleDec, cls);
|
|
61
|
-
for (const name of guardNames) {
|
|
62
|
-
if (isAuthGuardName(name) && !analysis.globalAuthGuards.includes(name)) {
|
|
63
|
-
analysis.globalAuthGuards.push(name);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
+
// 全局认证守卫 = APP_GUARD 注册事件里名字分类为「认证」的那些
|
|
67
|
+
for (const reg of registrations.filter((r) => r.token === "APP_GUARD")) {
|
|
68
|
+
const name = reg.impl;
|
|
69
|
+
if (name && isAuthGuardName(name) && !analysis.globalAuthGuards.includes(name)) {
|
|
70
|
+
analysis.globalAuthGuards.push(name);
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
const hasGlobalAuthGuard = analysis.globalAuthGuards.length > 0;
|
|
69
|
-
// ──
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (file.getFilePath().includes("node_modules"))
|
|
79
|
-
continue;
|
|
80
|
-
if (/\.(test|spec)\.ts$/.test(file.getFilePath()))
|
|
81
|
-
continue;
|
|
82
|
-
if (/\.useGlobalPipes\s*\(/.test(file.getText())) {
|
|
83
|
-
hasGlobalValidationPipe = true;
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
74
|
+
// ── 全局校验管道(两条官方注册途径:bootstrap useGlobalPipes / DI APP_PIPE)──
|
|
75
|
+
// 注:`useGlobalPipes()` 的参数不限管道类名——hedgedoc 的形态是
|
|
76
|
+
// useGlobalPipes(setupValidationPipe(logger)),管道经辅助函数创建,
|
|
77
|
+
// 字面 ValidationPipe 不在调用处。
|
|
78
|
+
const bootstrapPipeCall = sourceFiles.some((f) => /\.useGlobalPipes\s*\(/.test(f.getText()));
|
|
79
|
+
const hasGlobalValidationPipe = bootstrapPipeCall || analysis.globalValidationPipes.length > 0;
|
|
80
|
+
if (hasGlobalValidationPipe && bootstrapPipeCall) {
|
|
81
|
+
// bootstrap 途径拿不到实现名(参数可能是任意表达式),留占位便于报告展示
|
|
82
|
+
analysis.globalValidationPipes.push("useGlobalPipes()");
|
|
86
83
|
}
|
|
87
84
|
// ── 已校验 DTO 类名集合(E3 通道同款实现,import 落地代码而非重写)──
|
|
88
85
|
const validatedDtos = hasGlobalValidationPipe
|
|
@@ -169,13 +166,13 @@ function analyzeNestJSProject(projectRoot) {
|
|
|
169
166
|
const ctrlDec = cls.getDecorator("Controller");
|
|
170
167
|
if (!ctrlDec)
|
|
171
168
|
continue;
|
|
172
|
-
const
|
|
169
|
+
const basePaths = getRoutePaths(ctrlDec);
|
|
173
170
|
for (const method of cls.getMethods()) {
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
171
|
+
const httpDec = method.getDecorators().find((d) => isHttpDecorator(d));
|
|
172
|
+
if (!httpDec)
|
|
176
173
|
continue;
|
|
177
|
-
const
|
|
178
|
-
|
|
174
|
+
for (const p of joinRoutePaths(basePaths, getRoutePaths(httpDec)))
|
|
175
|
+
allRoutePaths.push(p);
|
|
179
176
|
}
|
|
180
177
|
}
|
|
181
178
|
}
|
|
@@ -192,7 +189,7 @@ function analyzeNestJSProject(projectRoot) {
|
|
|
192
189
|
continue;
|
|
193
190
|
const controllerName = cls.getName() || "UnknownController";
|
|
194
191
|
analysis.controllers.push(controllerName);
|
|
195
|
-
const
|
|
192
|
+
const basePaths = getRoutePaths(ctrlDec);
|
|
196
193
|
const classGuards = extractGuardNames(cls.getDecorator("UseGuards"));
|
|
197
194
|
const classPipes = extractGuardNames(cls.getDecorator("UsePipes"));
|
|
198
195
|
const classPublic = cls.getDecorator("Public") !== undefined
|
|
@@ -202,8 +199,9 @@ function analyzeNestJSProject(projectRoot) {
|
|
|
202
199
|
const httpMethod = getHttpMethod(method);
|
|
203
200
|
if (!httpMethod)
|
|
204
201
|
continue;
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
// 一条 handler 可以注册多个路径别名 ⇒ 每条路径各自出一条 route、各自判一次
|
|
203
|
+
// (§52:路径畸变会让依赖 path 的判据静默失准,见 getRoutePaths 注释)
|
|
204
|
+
const routePaths = joinRoutePaths(basePaths, getRoutePaths(method.getDecorators().find(d => isHttpDecorator(d))));
|
|
207
205
|
// Guards/Pipes: method-level overrides class-level
|
|
208
206
|
const methodGuards = extractGuardNames(method.getDecorator("UseGuards"));
|
|
209
207
|
const methodPipes = extractGuardNames(method.getDecorator("UsePipes"));
|
|
@@ -234,70 +232,73 @@ function analyzeNestJSProject(projectRoot) {
|
|
|
234
232
|
// auth/collab-token、auth/logout 等无 body 路由被误报 NO_VALIDATION)。
|
|
235
233
|
// 没有可校验的 DTO 输入,规则判定面不覆盖,不报。
|
|
236
234
|
const hasStructuredInput = method.getParameters().some((param) => param.getDecorators().some((d) => ["Body", "Query", "Param"].includes(d.getName())));
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
235
|
+
for (const fullPath of routePaths) {
|
|
236
|
+
const route = {
|
|
237
|
+
method: httpMethod,
|
|
238
|
+
path: fullPath,
|
|
239
|
+
controller: controllerName,
|
|
240
|
+
handler: method.getName() || "unknown",
|
|
241
|
+
hasAuthGuard: authGuards.length > 0,
|
|
242
|
+
hasValidationPipe: pipes.length > 0,
|
|
243
|
+
hasValidatedDto,
|
|
244
|
+
hasStructuredInput,
|
|
245
|
+
guards,
|
|
246
|
+
pipes,
|
|
247
|
+
isPublicDecorated,
|
|
248
|
+
};
|
|
249
|
+
analysis.routes.push(route);
|
|
250
|
+
// 路由级保护判定:类/方法认证守卫,或全局 APP_GUARD(除非 @Public 豁免),
|
|
251
|
+
// 或模块级中间件 forRoutes 覆盖(Nest 5 惯用法)
|
|
252
|
+
const protectedByGlobal = hasGlobalAuthGuard && !isPublicDecorated;
|
|
253
|
+
const protectedByMiddleware = middlewareCovers(ctrlMiddleware, controllerName, httpMethod, fullPath);
|
|
254
|
+
// ── Security Checks ──
|
|
255
|
+
// POST/PUT/DELETE without auth guard
|
|
256
|
+
// Skip intentionally public routes (login, register, health, etc.)
|
|
257
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(httpMethod) && !isPublicRoute(fullPath)) {
|
|
258
|
+
const isRegisterPost = httpMethod === "POST" && (0, route_window_1.isRegisterRoot)(fullPath, registerRoots);
|
|
259
|
+
if (!route.hasAuthGuard && !protectedByGlobal && !protectedByMiddleware && !isRegisterPost) {
|
|
260
|
+
analysis.issues.push({
|
|
261
|
+
type: "NESTJS_NO_AUTH",
|
|
262
|
+
severity: "critical",
|
|
263
|
+
route: `${httpMethod} ${fullPath}`,
|
|
264
|
+
controller: controllerName,
|
|
265
|
+
message: `Mutation route ${httpMethod} ${fullPath} has no auth guard and ` +
|
|
266
|
+
(hasGlobalAuthGuard
|
|
267
|
+
? `is explicitly marked public — it bypasses the global ${analysis.globalAuthGuards.join("/")}.`
|
|
268
|
+
: `no global APP_GUARD protects the app. Anyone can call it.`),
|
|
269
|
+
fix: `Add @UseGuards(AuthGuard) to the method or controller class, or remove the @Public marker.`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// 豁免:@UsePipes 装饰器,或「全局 ValidationPipe + 入参是已校验 DTO」
|
|
273
|
+
// (docmost 实测 123 条失明误报的修复——路由 DTO 在别的文件带
|
|
274
|
+
// class-validator 装饰器,只看 @UsePipes 永远看不到),
|
|
275
|
+
// 或路由无结构化输入(没有可校验的东西)
|
|
276
|
+
const validatedByGlobalPipe = hasGlobalValidationPipe && route.hasValidatedDto;
|
|
277
|
+
if (!route.hasValidationPipe && !validatedByGlobalPipe && hasStructuredInput) {
|
|
278
|
+
analysis.issues.push({
|
|
279
|
+
type: "NESTJS_NO_VALIDATION",
|
|
280
|
+
severity: "medium",
|
|
281
|
+
route: `${httpMethod} ${fullPath}`,
|
|
282
|
+
controller: controllerName,
|
|
283
|
+
message: `Mutation route ${httpMethod} ${fullPath} has no @UsePipes for input validation.`,
|
|
284
|
+
fix: `Add @UsePipes(ValidationPipe) or a DTO class to validate input.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
286
287
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
288
|
+
// Sensitive GET routes without auth
|
|
289
|
+
if (httpMethod === "GET") {
|
|
290
|
+
const sensitiveTerms = ["admin", "private", "secret", "manage"];
|
|
291
|
+
if (sensitiveTerms.some(t => fullPath.toLowerCase().includes(t))
|
|
292
|
+
&& !route.hasAuthGuard && !protectedByGlobal && !protectedByMiddleware) {
|
|
293
|
+
analysis.issues.push({
|
|
294
|
+
type: "NESTJS_SENSITIVE_PUBLIC",
|
|
295
|
+
severity: "high",
|
|
296
|
+
route: `${httpMethod} ${fullPath}`,
|
|
297
|
+
controller: controllerName,
|
|
298
|
+
message: `Sensitive GET route ${fullPath} is publicly accessible without auth protection.`,
|
|
299
|
+
fix: `Add @UseGuards(AuthGuard) to protect this route.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
}
|
|
@@ -316,10 +317,211 @@ function isAuthGuardName(name) {
|
|
|
316
317
|
return false;
|
|
317
318
|
return /auth|jwt|session|permission|role|access|apikey|api_key|token|login|passport/.test(lower);
|
|
318
319
|
}
|
|
320
|
+
// ═══════════════════════════════════════════════════════════════
|
|
321
|
+
// §52(2026-10-03):App-level provider 注册事件收集
|
|
322
|
+
// ═══════════════════════════════════════════════════════════════
|
|
323
|
+
//
|
|
324
|
+
// 三条发现途径,取并集,每条都留 (file, line) 供复核:
|
|
325
|
+
// A. `@Module({ providers: [...] })` 装饰器数组
|
|
326
|
+
// B. `providers = [...]` 类属性
|
|
327
|
+
// C. 任意数组常量 `const xxx = [...]`(含沿 spread 回溯)+ 逐行文本兜底
|
|
328
|
+
//
|
|
329
|
+
// C 存在的理由正是 immich:
|
|
330
|
+
// const commonMiddleware = [ { provide: APP_PIPE, useClass: ZodValidationPipe }, ... ];
|
|
331
|
+
// const apiMiddleware = [ FileUploadInterceptor, ...commonMiddleware, {...} ];
|
|
332
|
+
// @Module({ providers: [...common, ...apiMiddleware, ...] })
|
|
333
|
+
// `APP_PIPE` 离 `@Module` 隔了两跳 spread,只看装饰器那一层永远看不到。
|
|
334
|
+
//
|
|
335
|
+
// 「哪里表达了注册意图」与「它最终被挂在哪个模块上」是两件事 ——
|
|
336
|
+
// 判定「是否存在全局校验管道」只需要前者;把两者绑死(要求 spread 必须
|
|
337
|
+
// 追溯到某个 @Module)会让判定再次退化为形态枚举。见 §52 报告。
|
|
338
|
+
const APP_TOKEN_RE = /^APP_(PIPE|GUARD|INTERCEPTOR|FILTER)$/;
|
|
339
|
+
function appTokenOf(text) {
|
|
340
|
+
if (!text)
|
|
341
|
+
return null;
|
|
342
|
+
// 处理 import("@nestjs/core").APP_PIPE / 'APP_PIPE' 等形态
|
|
343
|
+
const cleaned = text.replace(/^['"]|['"]$/g, "").replace(/^.*\./, "").trim();
|
|
344
|
+
return APP_TOKEN_RE.test(cleaned) ? cleaned : null;
|
|
345
|
+
}
|
|
346
|
+
/** useClass/useExisting 的实现类名(useFactory/useValue 取不到 ⇒ null,保守不动) */
|
|
347
|
+
function implNameOf(obj) {
|
|
348
|
+
const pick = (key) => {
|
|
349
|
+
const prop = obj.getProperty?.(key);
|
|
350
|
+
const init = prop?.getInitializer?.();
|
|
351
|
+
const text = init?.getText?.() ?? "";
|
|
352
|
+
const m = /^[A-Za-z_$][\w$]*$/.exec(text.trim());
|
|
353
|
+
return m ? m[0] : null;
|
|
354
|
+
};
|
|
355
|
+
return pick("useClass") ?? pick("useExisting");
|
|
356
|
+
}
|
|
357
|
+
function providersArrayOf(obj) {
|
|
358
|
+
if (!obj)
|
|
359
|
+
return undefined;
|
|
360
|
+
const lit = obj.asKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
|
|
361
|
+
if (!lit)
|
|
362
|
+
return undefined;
|
|
363
|
+
const prop = lit.getProperty("providers");
|
|
364
|
+
if (!prop)
|
|
365
|
+
return undefined;
|
|
366
|
+
return prop.getInitializer?.()?.asKind?.(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
|
|
367
|
+
}
|
|
368
|
+
function collectAppLevelProviders(sourceFiles) {
|
|
369
|
+
const out = [];
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
const add = (r) => {
|
|
372
|
+
const key = `${r.token}|${r.impl}|${r.file}|${r.line}`;
|
|
373
|
+
if (seen.has(key))
|
|
374
|
+
return;
|
|
375
|
+
seen.add(key);
|
|
376
|
+
out.push(r);
|
|
377
|
+
};
|
|
378
|
+
// 文件级预筛:没出现 APP_* token 的文件直接跳过(绝大多数文件)
|
|
379
|
+
const candidates = sourceFiles.filter((f) => /APP_(PIPE|GUARD|INTERCEPTOR|FILTER)\b/.test(f.getFullText()) &&
|
|
380
|
+
// 测试脚手架里的 providers 是给「测试 app」搭的,不是生产注册证据。
|
|
381
|
+
// immich 实测:`test/utils.ts` 里有一整套 APP_PIPE/APP_GUARD 用于构造测试用的
|
|
382
|
+
// Nest app —— 采信它等于拿测试环境的配置豁免生产代码。
|
|
383
|
+
!/\.(test|spec)\.ts$/.test(f.getFilePath()) &&
|
|
384
|
+
!/\/(test|tests|__tests__|__mocks__|e2e)\//.test(f.getFilePath()));
|
|
385
|
+
if (candidates.length === 0)
|
|
386
|
+
return out;
|
|
387
|
+
// ── 顶层/模块级变量索引(回溯用,§52)──
|
|
388
|
+
// 数组变量:spread 引用链 `...middleware` 的目标
|
|
389
|
+
// 对象变量:`@Module(configVar)` 的装饰器参数(nocodb 形态普查:7 个 @Module 里
|
|
390
|
+
// **5 个(71%)是变量**而不是对象字面量 —— 主流写法,不是边角情况)
|
|
391
|
+
// ⚠ 索引**不做 APP_ 过滤**:中间变量往往不直接含 token
|
|
392
|
+
// (immich 的 `apiMiddleware` 只含 APP_GUARD,APP_PIPE 在再下一跳的 `commonMiddleware` 里)。
|
|
393
|
+
const arrayVars = new Map();
|
|
394
|
+
const objectVars = new Map();
|
|
395
|
+
for (const sf of candidates) {
|
|
396
|
+
for (const vs of sf.getVariableStatements()) {
|
|
397
|
+
for (const vd of vs.getDeclarations()) {
|
|
398
|
+
const init = vd.getInitializer();
|
|
399
|
+
if (!init)
|
|
400
|
+
continue;
|
|
401
|
+
const name = vd.getName();
|
|
402
|
+
const arr = init.asKind?.(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
|
|
403
|
+
if (arr) {
|
|
404
|
+
if (!arrayVars.has(name))
|
|
405
|
+
arrayVars.set(name, arr);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const obj = init.asKind?.(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
|
|
409
|
+
if (obj && !objectVars.has(name))
|
|
410
|
+
objectVars.set(name, obj);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const recordFromObj = (obj, source, via) => {
|
|
415
|
+
const provideProp = obj.getProperty("provide");
|
|
416
|
+
if (!provideProp)
|
|
417
|
+
return;
|
|
418
|
+
const init = provideProp.getInitializer?.();
|
|
419
|
+
const token = appTokenOf(init?.getText?.() ?? provideProp.getText());
|
|
420
|
+
if (!token)
|
|
421
|
+
return;
|
|
422
|
+
add({
|
|
423
|
+
token,
|
|
424
|
+
impl: implNameOf(obj),
|
|
425
|
+
source,
|
|
426
|
+
via,
|
|
427
|
+
file: obj.getSourceFile().getFilePath(),
|
|
428
|
+
line: obj.getStartLineNumber(),
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
const parseArray = (arr, source, via, depth) => {
|
|
432
|
+
if (depth > 5)
|
|
433
|
+
return;
|
|
434
|
+
for (const el of arr.getElements()) {
|
|
435
|
+
const obj = el.asKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression);
|
|
436
|
+
if (obj) {
|
|
437
|
+
recordFromObj(obj, source, via);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const spread = el.asKind(ts_morph_1.SyntaxKind.SpreadElement);
|
|
441
|
+
if (!spread)
|
|
442
|
+
continue;
|
|
443
|
+
const id = /^[A-Za-z_$][\w$]*$/.exec(spread.getExpression().getText().trim());
|
|
444
|
+
if (!id)
|
|
445
|
+
continue;
|
|
446
|
+
if (via.includes(id[0]))
|
|
447
|
+
continue; // 防止循环引用
|
|
448
|
+
const target = arrayVars.get(id[0]);
|
|
449
|
+
if (target)
|
|
450
|
+
parseArray(target, "array-literal", [...via, id[0]], depth + 1);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
for (const sf of candidates) {
|
|
454
|
+
// A + B:@Module 装饰器 / 类属性 providers
|
|
455
|
+
for (const cls of sf.getClasses()) {
|
|
456
|
+
const moduleDec = cls.getDecorator("Module");
|
|
457
|
+
if (!moduleDec)
|
|
458
|
+
continue;
|
|
459
|
+
const rawArg = moduleDec.getArguments()[0];
|
|
460
|
+
// `@Module(configVar)`:装饰器参数是变量 ⇒ 回溯到它的对象字面量定义
|
|
461
|
+
let decArg = rawArg;
|
|
462
|
+
if (rawArg?.getKind() === ts_morph_1.SyntaxKind.Identifier) {
|
|
463
|
+
decArg = objectVars.get(rawArg.getText().trim());
|
|
464
|
+
}
|
|
465
|
+
const decArr = providersArrayOf(decArg);
|
|
466
|
+
if (decArr)
|
|
467
|
+
parseArray(decArr, "module-decorator", [], 0);
|
|
468
|
+
for (const prop of cls.getProperties()) {
|
|
469
|
+
if (prop.getName() !== "providers")
|
|
470
|
+
continue;
|
|
471
|
+
const pArr = prop.getInitializer()?.asKind?.(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
|
|
472
|
+
if (pArr)
|
|
473
|
+
parseArray(pArr, "module-property", [], 0);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// C:数组常量(不要求被某个 @Module 引用)
|
|
477
|
+
for (const vs of sf.getVariableStatements()) {
|
|
478
|
+
for (const vd of vs.getDeclarations()) {
|
|
479
|
+
const arr = vd.getInitializer()?.asKind?.(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
|
|
480
|
+
if (!arr)
|
|
481
|
+
continue;
|
|
482
|
+
if (!/APP_(PIPE|GUARD|INTERCEPTOR|FILTER)\b/.test(arr.getText()))
|
|
483
|
+
continue;
|
|
484
|
+
parseArray(arr, "array-literal", [], 0);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// 逐行文本兜底:覆盖 AST 路径之外的写法(函数体内构造、动态拼装等)。
|
|
489
|
+
// 只补结构化解析没抓到的行,且跳过注释行。
|
|
490
|
+
const knownLines = new Set(out.map((r) => `${r.file}:${r.line}`));
|
|
491
|
+
for (const sf of candidates) {
|
|
492
|
+
const filePath = sf.getFilePath();
|
|
493
|
+
sf.getFullText()
|
|
494
|
+
.split("\n")
|
|
495
|
+
.forEach((line, i) => {
|
|
496
|
+
const trimmed = line.trim();
|
|
497
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*"))
|
|
498
|
+
return;
|
|
499
|
+
const m = /\{\s*provide\s*:\s*(?:[^A-Za-z]*?)APP_(PIPE|GUARD|INTERCEPTOR|FILTER)\b/.exec(line);
|
|
500
|
+
if (!m)
|
|
501
|
+
return;
|
|
502
|
+
const key = `${filePath}:${i + 1}`;
|
|
503
|
+
if (knownLines.has(key))
|
|
504
|
+
return;
|
|
505
|
+
const implM = /useClass\s*:\s*([A-Za-z_$][\w$]*)/.exec(line);
|
|
506
|
+
add({
|
|
507
|
+
token: `APP_${m[1]}`,
|
|
508
|
+
impl: implM ? implM[1] : null,
|
|
509
|
+
source: "text-fallback",
|
|
510
|
+
via: [],
|
|
511
|
+
file: filePath,
|
|
512
|
+
line: i + 1,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
319
518
|
/**
|
|
320
519
|
* 从 @Module 装饰器的 providers 提取 APP_GUARD 类名:
|
|
321
520
|
* providers: [{ provide: APP_GUARD, useClass: AuthGuard }]
|
|
322
521
|
* 支持装饰器参数对象字面量与类属性 providers 两种形态。
|
|
522
|
+
*
|
|
523
|
+
* ⚠ 保留仅为兼容(早期调用方):内含 spread 的形态(`...commonMiddleware`)
|
|
524
|
+
* 解析不了,真实注册事件请用 collectAppLevelProviders()(§52)。
|
|
323
525
|
*/
|
|
324
526
|
function extractAppGuardNames(moduleDec, cls) {
|
|
325
527
|
const names = [];
|
|
@@ -477,6 +679,50 @@ function getHttpMethod(method) {
|
|
|
477
679
|
function isHttpDecorator(dec) {
|
|
478
680
|
return ["Get", "Post", "Put", "Delete", "Patch", "Options", "Head", "All"].includes(dec.getName());
|
|
479
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* HTTP/Controller 装饰器的**全部**路径(Nest 允许一条 handler 注册多个路径别名)。
|
|
684
|
+
*
|
|
685
|
+
* §52 形态普查发现这不是边角写法:nocodb 348 个路由装饰器里 **250 个(72%)是数组形态**
|
|
686
|
+
* @Post(['/api/v1/db/meta/projects/:baseId/api-tokens',
|
|
687
|
+
* '/api/v2/meta/bases/:baseId/api-tokens'])
|
|
688
|
+
* 旧实现把整段数组文本当成一个路径 ⇒ path 变成形如 `/[\n '/api/v1...',\n ...]` 的畸形串,
|
|
689
|
+
* 于是依赖 path 的判据 —— isPublicRoute / middlewareCovers / registerRoots —— **全线静默失效**:
|
|
690
|
+
* 既匹配不到公开端点(该豁免的没豁免),也匹配不到中间件覆盖。
|
|
691
|
+
* ⇒ 这是「读不出来」而不是「判错」,属于接线缺陷,不是判据缺陷。
|
|
692
|
+
*
|
|
693
|
+
* 处理:字符串/数组逐项展开;模板字符串含 `${}` 无法静态求值 ⇒ 保留原文(不静默丢弃)。
|
|
694
|
+
*/
|
|
695
|
+
function getRoutePaths(decorator) {
|
|
696
|
+
if (!decorator)
|
|
697
|
+
return [""];
|
|
698
|
+
const arg = decorator.getArguments()[0];
|
|
699
|
+
const strip = (t) => t.replace(/^['"`]|['"`]$/g, "");
|
|
700
|
+
if (!arg)
|
|
701
|
+
return [""];
|
|
702
|
+
const arr = arg.asKind(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
|
|
703
|
+
if (arr) {
|
|
704
|
+
const out = [];
|
|
705
|
+
for (const el of arr.getElements()) {
|
|
706
|
+
const kind = el.getKind();
|
|
707
|
+
if (kind === ts_morph_1.SyntaxKind.StringLiteral || kind === ts_morph_1.SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
708
|
+
out.push(strip(el.getText()));
|
|
709
|
+
}
|
|
710
|
+
// 其它形态(模板表达式、标识符)不可静态求值 ⇒ 跳过(宁可不展开,不产生脏路径)
|
|
711
|
+
}
|
|
712
|
+
return out.length > 0 ? out : [""];
|
|
713
|
+
}
|
|
714
|
+
return [strip(arg.getText())];
|
|
715
|
+
}
|
|
716
|
+
/** 拼接 controller 基路径与方法路径(Nest 的 join 语义) */
|
|
717
|
+
function joinRoutePaths(basePaths, routePaths) {
|
|
718
|
+
const out = [];
|
|
719
|
+
for (const b of basePaths) {
|
|
720
|
+
for (const r of routePaths) {
|
|
721
|
+
out.push(b + (r.startsWith("/") || r === "" ? r : `/${r}`));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return out;
|
|
725
|
+
}
|
|
480
726
|
function getStringArg(decorator, index) {
|
|
481
727
|
if (!decorator)
|
|
482
728
|
return undefined;
|
|
@@ -486,17 +732,34 @@ function getStringArg(decorator, index) {
|
|
|
486
732
|
const text = args[index].getText();
|
|
487
733
|
return text.replace(/^['"]|['"]$/g, "");
|
|
488
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* @UseGuards / @UsePipes 参数里被注册的类名(取每个参数的**首个**标识符)。
|
|
737
|
+
*
|
|
738
|
+
* §52 修正:此前只收 `(?:Guard|Pipe|Interceptor)` 后缀的名字 ⇒
|
|
739
|
+
* `@UsePipes(ZodValidationPipe)` 好用、`@UsePipes(customBodyValidator)` 或
|
|
740
|
+
* `@UseGuards(Auth)` 全都看不见。但 Nest 强制 @UsePipes 的参数实现
|
|
741
|
+
* PipeTransform、@UseGuards 的参数实现 CanActivate ——
|
|
742
|
+
* **装饰器本身已经确定了它们是管道/守卫,不需要再从名字上确认一遍。**
|
|
743
|
+
*
|
|
744
|
+
* 「它是不是认证守卫」(ThrottlerGuard ≠ 认证)是另一件事,由 isAuthGuardName
|
|
745
|
+
* 单独判断。这两个关注点此前混在一起,导致「名字不像 ⇒ 连存在都看不见」。
|
|
746
|
+
*
|
|
747
|
+
* 只取首个标识符是为了避免把 `AuthGuard('JWT')` 里的 JWT 也当成守卫名
|
|
748
|
+
* (那会凭空多出一条认证证据,属反向误判)。
|
|
749
|
+
*/
|
|
489
750
|
function extractGuardNames(decorator) {
|
|
490
751
|
if (!decorator)
|
|
491
752
|
return [];
|
|
492
|
-
const args = decorator.getArguments();
|
|
493
753
|
const result = [];
|
|
494
|
-
for (const arg of
|
|
495
|
-
const text = arg
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
754
|
+
for (const arg of decorator.getArguments()) {
|
|
755
|
+
const text = arg
|
|
756
|
+
.getText()
|
|
757
|
+
.trim()
|
|
758
|
+
.replace(/^(?:new|await|void|typeof)\s+/, "")
|
|
759
|
+
.replace(/^this\./, "");
|
|
760
|
+
const m = /^[A-Za-z_$][\w$]*/.exec(text);
|
|
761
|
+
if (m)
|
|
762
|
+
result.push(m[0]);
|
|
500
763
|
}
|
|
501
764
|
return result;
|
|
502
765
|
}
|
|
@@ -525,6 +788,8 @@ function analyzeNestJSFile(filePath) {
|
|
|
525
788
|
routes: [],
|
|
526
789
|
issues: [],
|
|
527
790
|
globalAuthGuards: [], // 单文件分析无全局守卫上下文(项目级请用 analyzeNestJSProject)
|
|
791
|
+
globalValidationPipes: [],
|
|
792
|
+
registrations: [],
|
|
528
793
|
};
|
|
529
794
|
for (const file of project.getSourceFiles()) {
|
|
530
795
|
for (const cls of file.getClasses()) {
|
|
@@ -534,66 +799,68 @@ function analyzeNestJSFile(filePath) {
|
|
|
534
799
|
// ... (same parsing logic)
|
|
535
800
|
const controllerName = cls.getName() || "UnknownController";
|
|
536
801
|
analysis.controllers.push(controllerName);
|
|
537
|
-
const
|
|
802
|
+
const basePaths = getRoutePaths(ctrlDec);
|
|
538
803
|
const classGuards = extractGuardNames(cls.getDecorator("UseGuards"));
|
|
539
804
|
const classPipes = extractGuardNames(cls.getDecorator("UsePipes"));
|
|
540
805
|
for (const method of cls.getMethods()) {
|
|
541
806
|
const httpMethod = getHttpMethod(method);
|
|
542
807
|
if (!httpMethod)
|
|
543
808
|
continue;
|
|
544
|
-
const
|
|
545
|
-
const fullPath = basePath + (routePath.startsWith("/") ? routePath : `/${routePath}`);
|
|
809
|
+
const routePaths = joinRoutePaths(basePaths, getRoutePaths(method.getDecorators().find(d => isHttpDecorator(d))));
|
|
546
810
|
const methodGuards = extractGuardNames(method.getDecorator("UseGuards"));
|
|
547
811
|
const methodPipes = extractGuardNames(method.getDecorator("UsePipes"));
|
|
548
812
|
const guards = methodGuards.length > 0 ? methodGuards : classGuards;
|
|
549
813
|
const pipes = methodPipes.length > 0 ? methodPipes : classPipes;
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
814
|
+
for (const fullPath of routePaths) {
|
|
815
|
+
const route = {
|
|
816
|
+
method: httpMethod,
|
|
817
|
+
path: fullPath,
|
|
818
|
+
controller: controllerName,
|
|
819
|
+
handler: method.getName() || "unknown",
|
|
820
|
+
hasAuthGuard: guards.filter(isAuthGuardName).length > 0,
|
|
821
|
+
hasValidationPipe: pipes.length > 0,
|
|
822
|
+
hasValidatedDto: false, // 单文件分析无跨文件 DTO 上下文(项目级用 analyzeNestJSProject)
|
|
823
|
+
hasStructuredInput: true, // 单文件分析不区分(项目级解析真实入参)
|
|
824
|
+
guards,
|
|
825
|
+
pipes,
|
|
826
|
+
isPublicDecorated: false, // 单文件分析不解析 @Public(项目级用 analyzeNestJSProject)
|
|
827
|
+
};
|
|
828
|
+
analysis.routes.push(route);
|
|
829
|
+
// Security checks
|
|
830
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(httpMethod) && !isPublicRoute(fullPath)) {
|
|
831
|
+
if (!route.hasAuthGuard) {
|
|
832
|
+
analysis.issues.push({
|
|
833
|
+
type: "NESTJS_NO_AUTH",
|
|
834
|
+
severity: "critical",
|
|
835
|
+
route: `${httpMethod} ${fullPath}`,
|
|
836
|
+
controller: controllerName,
|
|
837
|
+
message: `Mutation route ${httpMethod} ${fullPath} has no @UseGuards. Anyone can call it.`,
|
|
838
|
+
fix: `Add @UseGuards(AuthGuard) to the method or controller class.`,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
if (!route.hasValidationPipe) {
|
|
842
|
+
analysis.issues.push({
|
|
843
|
+
type: "NESTJS_NO_VALIDATION",
|
|
844
|
+
severity: "medium",
|
|
845
|
+
route: `${httpMethod} ${fullPath}`,
|
|
846
|
+
controller: controllerName,
|
|
847
|
+
message: `Mutation route ${httpMethod} ${fullPath} has no @UsePipes for input validation.`,
|
|
848
|
+
fix: `Add @UsePipes(ValidationPipe) or a DTO class to validate input.`,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
584
851
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
852
|
+
if (httpMethod === "GET") {
|
|
853
|
+
const sensitiveTerms = ["admin", "private", "secret", "manage"];
|
|
854
|
+
if (sensitiveTerms.some(t => fullPath.toLowerCase().includes(t)) && !route.hasAuthGuard) {
|
|
855
|
+
analysis.issues.push({
|
|
856
|
+
type: "NESTJS_SENSITIVE_PUBLIC",
|
|
857
|
+
severity: "high",
|
|
858
|
+
route: `${httpMethod} ${fullPath}`,
|
|
859
|
+
controller: controllerName,
|
|
860
|
+
message: `Sensitive GET route ${fullPath} is publicly accessible without @UseGuards.`,
|
|
861
|
+
fix: `Add @UseGuards(AuthGuard) to protect this route.`,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
597
864
|
}
|
|
598
865
|
}
|
|
599
866
|
}
|