progmune-runtime 3.7.14 → 3.7.16

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.
@@ -55,6 +55,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
55
55
  exports.analyzeKoaApp = analyzeKoaApp;
56
56
  exports.analyzeKoaFile = analyzeKoaFile;
57
57
  const fs = __importStar(require("fs"));
58
+ const route_window_1 = require("./route-window");
58
59
  const MUTATION_METHODS = new Set(["post", "put", "patch", "delete", "del"]);
59
60
  const AUTH_ENTRY_WORDS = [
60
61
  "login", "signin", "sign_in", "regist", "signup", "sign_up",
@@ -89,14 +90,18 @@ function analyzeKoaApp(code) {
89
90
  authGlobalMiddleware.push(m[1]);
90
91
  }
91
92
  // 路由注册:router.post('/x', mw1, mw2, handler) / .del()
92
- const routeRe = /\.(get|post|put|patch|delete|del)\s*\(\s*['"]([^'"]+)['"]/g;
93
+ // 接收者限定 router/*Router/app——config.get('secret') 之类不再被当路由
94
+ const routeRe = /\b(?:router|[A-Za-z_$][\w$]*[Rr]outer|app)\s*\.(get|post|put|patch|delete|del)\s*\(\s*['"]([^'"]+)['"]/g;
93
95
  while ((m = routeRe.exec(code)) !== null) {
94
96
  const method = m[1].toLowerCase() === "del" ? "delete" : m[1].toLowerCase();
95
97
  const pathName = m[2];
96
- // 从路径串向后截 300 字符窗口,收集中间件名(认证名判定)
97
- const windowStart = m.index + m[0].length;
98
- const window = code.slice(windowStart, windowStart + 300);
99
- const mwNames = window.match(/[A-Za-z_$][\w$]*/g) || [];
98
+ // 认证名收集窗口 = 本次路由调用(自路径串后至本调用闭合括号),
99
+ // 不跨路由边界——修复 300 字符前向窗口跨路由串扰(bleed)缺陷:
100
+ // 后面路由的 auth 名不再把前面的公开路由洗成 protected
101
+ const window = (0, route_window_1.routeCallWindow)(code, m.index + m[0].length);
102
+ // koa-router 语义:(path, ...middleware, handler)——末参 handler 由
103
+ // middlewareNamesFromWindow 排除,handler 名(如 ctrl.login)不再误判
104
+ const mwNames = (0, route_window_1.middlewareNamesFromWindow)(window);
100
105
  const hasAuthMw = mwNames.some((name) => isAuthFnName(name));
101
106
  routes.push({
102
107
  method,
@@ -104,15 +109,25 @@ function analyzeKoaApp(code) {
104
109
  protected: hasAuthMw,
105
110
  line: code.slice(0, m.index).split("\n").length,
106
111
  });
107
- if (MUTATION_METHODS.has(method) && !hasAuthMw
108
- && authGlobalMiddleware.length === 0 && !isAuthEntryPath(pathName)) {
112
+ }
113
+ // register 集合豁免(语义层):同文件存在 <path>/login|register|signup…
114
+ // 姊妹路由 ⇒ <path> 是账户集合,其无认证 POST = 公开注册(realworld
115
+ // 惯用 POST /users 而非 /register——路径豁免词表认不出,Koa 语料
116
+ // 1/1 register FP 的根因)。有 login 佐证才豁免:管理员建用户类
117
+ // 端点(无姊妹 login)仍会报
118
+ const registerRoots = (0, route_window_1.collectRegisterRoots)(routes.map((r) => r.path));
119
+ for (const r of routes) {
120
+ if (MUTATION_METHODS.has(r.method) && !r.protected
121
+ && authGlobalMiddleware.length === 0
122
+ && !isAuthEntryPath(r.path)
123
+ && !(r.method === "post" && (0, route_window_1.isRegisterRoot)(r.path, registerRoots))) {
109
124
  issues.push({
110
125
  severity: "medium",
111
126
  rule: "KOA_ROUTE_NO_AUTH",
112
- message: `Route ${method.toUpperCase()} ${pathName} has no auth middleware ` +
127
+ message: `Route ${r.method.toUpperCase()} ${r.path} has no auth middleware ` +
113
128
  `and the app registers no auth middleware — any caller can reach it.`,
114
- route: `${method.toUpperCase()} ${pathName}`,
115
- line: code.slice(0, m.index).split("\n").length,
129
+ route: `${r.method.toUpperCase()} ${r.path}`,
130
+ line: r.line,
116
131
  });
117
132
  }
118
133
  }
@@ -54,4 +54,42 @@ router.post("/login", async (ctx) => { ctx.body = "token"; });
54
54
  (0, vitest_1.expect)(hasKoa).toBe(false);
55
55
  (0, vitest_1.expect)(issues).toHaveLength(0);
56
56
  });
57
+ (0, vitest_1.it)("回归:窗口不跨路由串扰——公开路由后面的 auth 路由不再把它洗成 protected(修复 300 字符 bleed)", () => {
58
+ const { issues, routes } = (0, koa_detector_1.analyzeKoaApp)(app(`
59
+ router.post("/users", ctrl.post); // 公开 register —— 应报
60
+ router.post("/articles", auth, ctrl.create); // 受保护 —— 不报
61
+ `));
62
+ const register = routes.find((r) => r.path === "/users");
63
+ const article = routes.find((r) => r.path === "/articles");
64
+ (0, vitest_1.expect)(register.protected).toBe(false);
65
+ (0, vitest_1.expect)(article.protected).toBe(true);
66
+ (0, vitest_1.expect)(issues.map((i) => i.route)).toContain("POST /users");
67
+ (0, vitest_1.expect)(issues.map((i) => i.route)).not.toContain("POST /articles");
68
+ });
69
+ (0, vitest_1.it)("回归:config.get('secret') 不再是幻影路由(接收者限定 router/app)", () => {
70
+ const { routes } = (0, koa_detector_1.analyzeKoaApp)(app(`
71
+ const secret = config.get("secret");
72
+ router.post("/x", auth, h);
73
+ `));
74
+ (0, vitest_1.expect)(routes.map((r) => r.path)).not.toContain("secret");
75
+ (0, vitest_1.expect)(routes.map((r) => r.path)).toContain("/x");
76
+ });
77
+ });
78
+ (0, vitest_1.describe)("koa-detector register 集合豁免(语义层)", () => {
79
+ (0, vitest_1.it)("有 /users/login 姊妹佐证:POST /users(公开注册)不报", () => {
80
+ const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
81
+ router.post("/users/login", ctrl.login);
82
+ router.post("/users", ctrl.register);
83
+ router.post("/articles", auth, ctrl.create);
84
+ `));
85
+ (0, vitest_1.expect)(issues.map((i) => i.route)).not.toContain("POST /users");
86
+ (0, vitest_1.expect)(issues.map((i) => i.route)).not.toContain("POST /users/login");
87
+ (0, vitest_1.expect)(issues.map((i) => i.route)).not.toContain("POST /articles");
88
+ });
89
+ (0, vitest_1.it)("无姊妹佐证:POST /users 仍报(管理员建用户类端点不豁免)", () => {
90
+ const { issues } = (0, koa_detector_1.analyzeKoaApp)(app(`
91
+ router.post("/users", ctrl.createUser);
92
+ `));
93
+ (0, vitest_1.expect)(issues.map((i) => i.route)).toContain("POST /users");
94
+ });
57
95
  });
@@ -20,6 +20,7 @@ exports.analyzeNestJSProject = analyzeNestJSProject;
20
20
  exports.analyzeNestJSFile = analyzeNestJSFile;
21
21
  exports.formatNestJSReport = formatNestJSReport;
22
22
  const ts_morph_1 = require("ts-morph");
23
+ const route_window_1 = require("./route-window");
23
24
  // ── Core Analysis ──
24
25
  function analyzeNestJSProject(projectRoot) {
25
26
  let project;
@@ -64,6 +65,60 @@ function analyzeNestJSProject(projectRoot) {
64
65
  }
65
66
  }
66
67
  const hasGlobalAuthGuard = analysis.globalAuthGuards.length > 0;
68
+ // ── 第二遍:模块级中间件保护(Nest 5 时代惯用法)──
69
+ // class XxxModule implements NestModule { configure(consumer) {
70
+ // consumer.apply(AuthMiddleware).forRoutes({path, method}, ...) } }
71
+ // 覆盖关系:controller → [{path, methods}](REALWORLD_STRUCTURAL_V1:
72
+ // guard 单一模型漏掉 configure/forRoutes 中间件保护 → 23 issues 全 FP)
73
+ const ctrlMiddleware = new Map();
74
+ for (const file of project.getSourceFiles()) {
75
+ if (file.getFilePath().includes("node_modules"))
76
+ continue;
77
+ for (const cls of file.getClasses()) {
78
+ const moduleDec = cls.getDecorator("Module");
79
+ if (!moduleDec)
80
+ continue;
81
+ const configure = cls.getMethods().find((mm) => mm.getName() === "configure");
82
+ if (!configure)
83
+ continue;
84
+ const coverage = extractMiddlewareForRoutes(configure.getText());
85
+ if (coverage.length === 0)
86
+ continue;
87
+ // 该模块声明管哪些 controller
88
+ const ctrlNames = extractModuleControllerNames(moduleDec);
89
+ for (const c of ctrlNames) {
90
+ const merged = ctrlMiddleware.get(c) || [];
91
+ merged.push(...coverage);
92
+ ctrlMiddleware.set(c, merged);
93
+ }
94
+ }
95
+ }
96
+ // ── register 集合豁免预扫(语义层)──
97
+ // 项目级收集账户入口路由(*\/login|register|signup…)→ 集合根
98
+ // (/users/login → /users);该集合的 POST = 公开注册(realworld 惯用
99
+ // POST /users——register FP 跨框架根因)。POST-only:同集合的 PUT 等
100
+ // 不豁免;无 login 姊妹佐证的写集合(管理员建用户)仍查
101
+ const allRoutePaths = [];
102
+ for (const file of project.getSourceFiles()) {
103
+ if (file.getFilePath().includes("node_modules"))
104
+ continue;
105
+ if (/\.(test|spec)\.ts$/.test(file.getFilePath()))
106
+ continue;
107
+ for (const cls of file.getClasses()) {
108
+ const ctrlDec = cls.getDecorator("Controller");
109
+ if (!ctrlDec)
110
+ continue;
111
+ const basePath = getStringArg(ctrlDec, 0) || "";
112
+ for (const method of cls.getMethods()) {
113
+ const http = getHttpMethod(method);
114
+ if (!http)
115
+ continue;
116
+ const routePath = getStringArg(method.getDecorators().find((d) => isHttpDecorator(d)), 0) || "";
117
+ allRoutePaths.push(basePath + (routePath.startsWith("/") ? routePath : `/${routePath}`));
118
+ }
119
+ }
120
+ }
121
+ const registerRoots = (0, route_window_1.collectRegisterRoots)(allRoutePaths);
67
122
  for (const file of project.getSourceFiles()) {
68
123
  // Skip node_modules and test files
69
124
  if (file.getFilePath().includes("node_modules"))
@@ -111,13 +166,16 @@ function analyzeNestJSProject(projectRoot) {
111
166
  isPublicDecorated,
112
167
  };
113
168
  analysis.routes.push(route);
114
- // 路由级保护判定:类/方法认证守卫,或全局 APP_GUARD(除非 @Public 豁免)
169
+ // 路由级保护判定:类/方法认证守卫,或全局 APP_GUARD(除非 @Public 豁免),
170
+ // 或模块级中间件 forRoutes 覆盖(Nest 5 惯用法)
115
171
  const protectedByGlobal = hasGlobalAuthGuard && !isPublicDecorated;
172
+ const protectedByMiddleware = middlewareCovers(ctrlMiddleware, controllerName, httpMethod, fullPath);
116
173
  // ── Security Checks ──
117
174
  // POST/PUT/DELETE without auth guard
118
175
  // Skip intentionally public routes (login, register, health, etc.)
119
176
  if (["POST", "PUT", "DELETE", "PATCH"].includes(httpMethod) && !isPublicRoute(fullPath)) {
120
- if (!route.hasAuthGuard && !protectedByGlobal) {
177
+ const isRegisterPost = httpMethod === "POST" && (0, route_window_1.isRegisterRoot)(fullPath, registerRoots);
178
+ if (!route.hasAuthGuard && !protectedByGlobal && !protectedByMiddleware && !isRegisterPost) {
121
179
  analysis.issues.push({
122
180
  type: "NESTJS_NO_AUTH",
123
181
  severity: "critical",
@@ -145,7 +203,7 @@ function analyzeNestJSProject(projectRoot) {
145
203
  if (httpMethod === "GET") {
146
204
  const sensitiveTerms = ["admin", "private", "secret", "manage"];
147
205
  if (sensitiveTerms.some(t => fullPath.toLowerCase().includes(t))
148
- && !route.hasAuthGuard && !protectedByGlobal) {
206
+ && !route.hasAuthGuard && !protectedByGlobal && !protectedByMiddleware) {
149
207
  analysis.issues.push({
150
208
  type: "NESTJS_SENSITIVE_PUBLIC",
151
209
  severity: "high",
@@ -216,6 +274,90 @@ function extractAppGuardNames(moduleDec, cls) {
216
274
  }
217
275
  return names;
218
276
  }
277
+ // ── 模块级中间件覆盖(Nest 5 configure/forRoutes 惯用法)──
278
+ /** 从 configure(consumer) 方法文本提取 forRoutes 覆盖:{path, methods[]} */
279
+ function extractMiddlewareForRoutes(configureText) {
280
+ const out = [];
281
+ // consumer.apply(X).forRoutes({...}, {...}) / .forRoutes('path', ...)
282
+ const frIndexes = [];
283
+ let idx = 0;
284
+ while ((idx = configureText.indexOf(".forRoutes(", idx)) !== -1) {
285
+ frIndexes.push(idx + ".forRoutes(".length);
286
+ idx += ".forRoutes(".length;
287
+ }
288
+ for (const start of frIndexes) {
289
+ // 括号平衡取 forRoutes 参数块
290
+ let depth = 1;
291
+ let end = start;
292
+ while (end < configureText.length && depth > 0) {
293
+ if (configureText[end] === "(")
294
+ depth++;
295
+ else if (configureText[end] === ")")
296
+ depth--;
297
+ end++;
298
+ }
299
+ const argsText = configureText.slice(start, end - 1);
300
+ // 逐顶层参数(逗号切分,深度感知)
301
+ const args = [];
302
+ let cur = "";
303
+ let d = 0;
304
+ for (const ch of argsText) {
305
+ if (ch === "(" || ch === "[" || ch === "{")
306
+ d++;
307
+ else if (ch === ")" || ch === "]" || ch === "}")
308
+ d--;
309
+ if (ch === "," && d === 0) {
310
+ args.push(cur);
311
+ cur = "";
312
+ }
313
+ else
314
+ cur += ch;
315
+ }
316
+ if (cur.trim())
317
+ args.push(cur);
318
+ for (const arg of args) {
319
+ const trimmed = arg.trim();
320
+ const pathM = trimmed.match(/path\s*:\s*['"]([^'"]+)['"]/);
321
+ const methodM = trimmed.match(/method\s*:\s*RequestMethod\.(\w+)/);
322
+ const plain = trimmed.match(/^['"]([^'"]+)['"]$/);
323
+ const path = pathM ? pathM[1] : plain ? plain[1] : null;
324
+ if (!path)
325
+ continue;
326
+ const method = methodM ? methodM[1] : "*";
327
+ out.push({ path, methods: [method] });
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+ /** @Module({ controllers: [A, B] }) 里的控制器类名 */
333
+ function extractModuleControllerNames(moduleDec) {
334
+ const out = [];
335
+ const text = moduleDec.getText();
336
+ const m = text.match(/controllers\s*:\s*\[([^\]]*)\]/);
337
+ if (!m)
338
+ return out;
339
+ for (const name of m[1].split(",")) {
340
+ const n = name.trim().replace(/\s+as\s+\w+$/, "").split(".").pop();
341
+ if (n && /^[A-Za-z_$]/.test(n))
342
+ out.push(n);
343
+ }
344
+ return out;
345
+ }
346
+ /** route(method, fullPath)是否被 controller 的模块中间件覆盖 */
347
+ function middlewareCovers(ctrlMiddleware, controllerName, httpMethod, fullPath) {
348
+ const entries = ctrlMiddleware.get(controllerName);
349
+ if (!entries || entries.length === 0)
350
+ return false;
351
+ const norm = (p) => p.replace(/^\/+|\/+$/g, "");
352
+ const routePath = norm(fullPath);
353
+ return entries.some((e) => {
354
+ const em = e.methods[0] || "*";
355
+ // RequestMethod.ALL 与通配 * 匹配任意方法
356
+ if (em !== "*" && em !== "ALL" && em !== httpMethod)
357
+ return false;
358
+ return norm(e.path) === routePath;
359
+ });
360
+ }
219
361
  /** Check if a route is intentionally public (login, register, health, etc.). */
220
362
  function isPublicRoute(path) {
221
363
  // Normalize: ensure leading slash for consistent matching
@@ -138,3 +138,106 @@ export class AppModule {}
138
138
  (0, vitest_1.expect)(issues.map((i) => i.type)).not.toContain("NESTJS_NO_AUTH");
139
139
  });
140
140
  });
141
+ (0, vitest_1.describe)("nestjs-detector 模块中间件覆盖(V1 修复回归)", () => {
142
+ (0, vitest_1.it)("NestModule configure/forRoutes 保护的 mutation 不报(Nest 5 惯用法)", () => {
143
+ write("tsconfig.json", TSCONFIG);
144
+ write("src/app.module.ts", `
145
+ import { Module, NestModule, MiddlewareConsumer, RequestMethod } from "@nestjs/common";
146
+ import { AuthMiddleware } from "./auth.middleware";
147
+ import { ApiController } from "./api.controller";
148
+
149
+ @Module({ controllers: [ApiController] })
150
+ export class AppModule implements NestModule {
151
+ configure(consumer: MiddlewareConsumer) {
152
+ consumer
153
+ .apply(AuthMiddleware)
154
+ .forRoutes(
155
+ { path: "api/transfer", method: RequestMethod.POST },
156
+ { path: "api/items/:id", method: RequestMethod.ALL },
157
+ );
158
+ }
159
+ }
160
+ `);
161
+ write("src/api.controller.ts", `
162
+ import { Controller, Post, Delete, Get } from "@nestjs/common";
163
+ @Controller("api")
164
+ export class ApiController {
165
+ @Post("transfer") transfer() { return {}; }
166
+ @Delete("items/:id") deleteItem() { return {}; }
167
+ @Post("public") publicCreate() { return {}; }
168
+ }
169
+ `);
170
+ write("src/auth.middleware.ts", `
171
+ import { NestMiddleware } from "@nestjs/common";
172
+ export class AuthMiddleware implements NestMiddleware { use() {} }
173
+ `);
174
+ const a = (0, nestjs_detector_1.analyzeNestJSProject)(dir);
175
+ const noAuth = a.issues.filter((i) => i.type === "NESTJS_NO_AUTH");
176
+ // transfer 与 items/:id 受 forRoutes 中间件保护 → 不报
177
+ (0, vitest_1.expect)(noAuth.map((i) => i.route)).not.toContain("POST api/transfer");
178
+ (0, vitest_1.expect)(noAuth.map((i) => i.route)).not.toContain("DELETE api/items/:id");
179
+ // 未覆盖的 public 写路由仍报(保留敏感性)
180
+ (0, vitest_1.expect)(noAuth.map((i) => i.route)).toContain("POST api/public");
181
+ });
182
+ (0, vitest_1.it)("摘除 forRoutes 中间件覆盖后 mutation 重新被报(无感修复回归)", () => {
183
+ write("tsconfig.json", TSCONFIG);
184
+ write("src/app.module.ts", `
185
+ import { Module, NestModule, MiddlewareConsumer, RequestMethod } from "@nestjs/common";
186
+ import { ApiController } from "./api.controller";
187
+ @Module({ controllers: [ApiController] })
188
+ export class AppModule implements NestModule {
189
+ configure(consumer: MiddlewareConsumer) {
190
+ consumer.apply(AuthMiddleware).forRoutes(); // 覆盖被摘空
191
+ }
192
+ }
193
+ `);
194
+ write("src/api.controller.ts", `
195
+ import { Controller, Post } from "@nestjs/common";
196
+ @Controller("api")
197
+ export class ApiController {
198
+ @Post("transfer") transfer() { return {}; }
199
+ }
200
+ `);
201
+ write("src/auth.middleware.ts", `
202
+ import { NestMiddleware } from "@nestjs/common";
203
+ export class AuthMiddleware implements NestMiddleware { use() {} }
204
+ `);
205
+ const a = (0, nestjs_detector_1.analyzeNestJSProject)(dir);
206
+ (0, vitest_1.expect)(a.issues.some((i) => i.type === "NESTJS_NO_AUTH" && i.route === "POST api/transfer")).toBe(true);
207
+ });
208
+ });
209
+ (0, vitest_1.describe)("nestjs-detector register 集合豁免(语义层)", () => {
210
+ function writeUserApp() {
211
+ write("tsconfig.json", TSCONFIG);
212
+ write("src/user.controller.ts", `
213
+ import { Controller, Post, Get, Delete } from "@nestjs/common";
214
+ @Controller()
215
+ export class UserController {
216
+ @Post("users/login") login() { return {}; }
217
+ @Post("users") register() { return {}; } // 公开注册
218
+ @Delete("users/:slug") deleteUser() { return {}; } // 真实无保护
219
+ }
220
+ `);
221
+ }
222
+ (0, vitest_1.it)("有 users/login 姊妹佐证:POST users(公开注册)不报", () => {
223
+ writeUserApp();
224
+ const a = (0, nestjs_detector_1.analyzeNestJSProject)(dir);
225
+ const noAuth = a.issues.filter((i) => i.type === "NESTJS_NO_AUTH");
226
+ (0, vitest_1.expect)(noAuth.map((i) => i.route)).not.toContain("POST /users");
227
+ (0, vitest_1.expect)(noAuth.map((i) => i.route)).not.toContain("POST /users/login");
228
+ // 真实无保护的 DELETE 仍报(豁免不误伤)
229
+ (0, vitest_1.expect)(noAuth.map((i) => i.route)).toContain("DELETE /users/:slug");
230
+ });
231
+ (0, vitest_1.it)("无姊妹佐证:POST users 仍报(管理员建用户不豁免)", () => {
232
+ write("tsconfig.json", TSCONFIG);
233
+ write("src/admin.controller.ts", `
234
+ import { Controller, Post } from "@nestjs/common";
235
+ @Controller()
236
+ export class AdminController {
237
+ @Post("users") adminCreateUser() { return {}; }
238
+ }
239
+ `);
240
+ const a = (0, nestjs_detector_1.analyzeNestJSProject)(dir);
241
+ (0, vitest_1.expect)(a.issues.some((i) => i.type === "NESTJS_NO_AUTH" && i.route === "POST /users")).toBe(true);
242
+ });
243
+ });
@@ -66,7 +66,10 @@ const AUTH_ENTRY_WORDS = [
66
66
  "login", "signin", "sign_in", "regist", "signup", "sign_up",
67
67
  "token", "auth", "health",
68
68
  ];
69
- const AUTH_CALL_RE = /\b(getServerSession|requireAuth|requireUser|verifyToken|verifyAuth|isAuthenticated|checkAuth|getToken|withAuth|authSession|authenticate)\s*\(/;
69
+ // 路由级认证信号:会话式(getServerSession/next-auth v5 auth()/clerk
70
+ // currentUser)+ webhook 载荷签名校验(Stripe constructEvent 等——
71
+ // V5 真实语料证明 webhook 端点的标准保护是签名校验而非会话)
72
+ const AUTH_CALL_RE = /\b(getServerSession|requireAuth|requireUser|verifyToken|verifyAuth|isAuthenticated|checkAuth|getToken|withAuth|authSession|authenticate|auth|currentUser)\s*\(|\b(constructEvent|verifyWebhook|verifySignature|verifyWebhookSignature|validateWebhook|validateSignature)\s*\(/;
70
73
  const AUTH_MIDDLEWARE_RE = /\b(getServerSession|requireAuth|verifyToken|getToken|withAuth|next-auth|authorization|authenticate)\b/;
71
74
  function isAuthEntryFile(relFile) {
72
75
  const lower = relFile.toLowerCase();
@@ -110,4 +110,39 @@ export default withAuth(function middleware(req) {});
110
110
  (0, vitest_1.expect)(hasNext).toBe(false);
111
111
  (0, vitest_1.expect)(issues).toHaveLength(0);
112
112
  });
113
+ (0, vitest_1.it)("V5 修复回归:Stripe webhook 签名校验(constructEvent)视为端点认证——不报", () => {
114
+ writeRoute("app/api/webhooks/stripe/route.ts", `
115
+ import { stripe } from "@/lib/stripe";
116
+ export async function POST(req: Request) {
117
+ const body = await req.text();
118
+ const signature = req.headers.get("stripe-signature") as string;
119
+ const event = stripe.webhooks.constructEvent(body, signature, process.env.SECRET!);
120
+ return Response.json({ received: true });
121
+ }
122
+ `);
123
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
124
+ (0, vitest_1.expect)(issues).toHaveLength(0);
125
+ });
126
+ (0, vitest_1.it)("V5 修复回归:webhook 无签名校验仍报(保留对真缺失认证的敏感性)", () => {
127
+ writeRoute("app/api/webhooks/stripe/route.ts", `
128
+ export async function POST(req: Request) {
129
+ const body = await req.text();
130
+ return Response.json({ received: true });
131
+ }
132
+ `);
133
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
134
+ (0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("NEXT_ROUTE_NO_AUTH");
135
+ });
136
+ (0, vitest_1.it)("V5 修复回归:next-auth v5 / clerk 裸 auth() 调用视为认证——不报", () => {
137
+ writeRoute("app/api/transfer/route.ts", `
138
+ import { auth } from "@/auth";
139
+ export async function POST(req: Request) {
140
+ const session = await auth();
141
+ if (!session?.user) return new Response(null, { status: 403 });
142
+ return Response.json({ ok: true });
143
+ }
144
+ `);
145
+ const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
146
+ (0, vitest_1.expect)(issues).toHaveLength(0);
147
+ });
113
148
  });
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ /**
3
+ * route-window.ts — 路由调用窗口共享工具(Koa/Gin/Fiber 代码串检测器共用)
4
+ *
5
+ * 背景:初代实现用「路径串后固定 300 字符窗口」收集认证中间件名 →
6
+ * 跨路由串扰(bleed):后面路由的 auth 名污染前面公开路由的保护判定
7
+ * (V3 Koa / V7 Gin / V8 Fiber 同款缺陷)。
8
+ *
9
+ * 修复思路:
10
+ * 1. routeCallWindow —— 窗口 = 本次路由调用边界内(括号深度感知到闭合
11
+ * `)`),不跨路由、容忍内联 handler 的多层括号
12
+ * 2. middlewareNamesFromWindow —— koa-router/gin/fiber 语义一致:
13
+ * `(path, ...middleware, handler)`,最后一个纯函数引用参数是
14
+ * handler(如 ctrl.login / UsersLogin),排除——handler 名含 auth
15
+ * 词不再被误判为认证中间件
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.routeCallWindow = routeCallWindow;
19
+ exports.middlewareNamesFromWindow = middlewareNamesFromWindow;
20
+ exports.normPath = normPath;
21
+ exports.collectRegisterRoots = collectRegisterRoots;
22
+ exports.isRegisterRoot = isRegisterRoot;
23
+ /**
24
+ * 自路由调用起点(路径串刚结束处)扫描到本次调用的闭合括号。
25
+ * @param code 源码
26
+ * @param from 路径串结束位置(调用括号深度已为 1——方法名的 `(` 已消费)
27
+ * @returns 窗口内容(含闭合 `)`)
28
+ */
29
+ function routeCallWindow(code, from) {
30
+ let depth = 1;
31
+ let end = from;
32
+ let quote = null;
33
+ const MAX = 4000;
34
+ while (end < code.length && end - from < MAX) {
35
+ const ch = code[end];
36
+ if (quote) {
37
+ if (ch === quote && code[end - 1] !== "\\")
38
+ quote = null;
39
+ }
40
+ else if (ch === '"' || ch === "'" || ch === "`") {
41
+ quote = ch;
42
+ }
43
+ else if (ch === "(") {
44
+ depth++;
45
+ }
46
+ else if (ch === ")") {
47
+ depth--;
48
+ if (depth === 0)
49
+ break;
50
+ }
51
+ end++;
52
+ }
53
+ return code.slice(from, end + 1);
54
+ }
55
+ /** 纯函数引用:auth / ctrl.login / users.AuthMiddleware */
56
+ function isPlainFnRef(s) {
57
+ return /^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(s.trim());
58
+ }
59
+ /**
60
+ * 从路由调用参数窗口提取中间件候选名。
61
+ * 顶层逗号切分(深度感知)→ 末参若为纯函数引用则视为 handler 排除 →
62
+ * 其余参数内的标识符即中间件候选。
63
+ */
64
+ function middlewareNamesFromWindow(window) {
65
+ const names = [];
66
+ const body = window.replace(/\)\s*$/, "");
67
+ const parts = [];
68
+ let cur = "";
69
+ let d = 0;
70
+ for (const ch of body) {
71
+ if (ch === "(" || ch === "[" || ch === "{")
72
+ d++;
73
+ else if (ch === ")" || ch === "]" || ch === "}")
74
+ d--;
75
+ if (ch === "," && d === 0) {
76
+ parts.push(cur);
77
+ cur = "";
78
+ }
79
+ else {
80
+ cur += ch;
81
+ }
82
+ }
83
+ if (cur.trim().length > 0)
84
+ parts.push(cur);
85
+ const mwParts = isPlainFnRef(parts[parts.length - 1] ?? "")
86
+ ? parts.slice(0, -1)
87
+ : parts;
88
+ for (const p of mwParts) {
89
+ const found = p.match(/[A-Za-z_$][\w$]*/g) || [];
90
+ names.push(...found);
91
+ }
92
+ return names;
93
+ }
94
+ // ── register 集合豁免(语义层,Koa/Gin/Fiber/NestJS 共用)──
95
+ /** 账户集合根判定词:路径以这些后缀结尾的路由是账户入口(login 等) */
96
+ const ACCOUNT_ENTRY_SUFFIXES = [
97
+ "/login", "/signin", "/sign_in", "/register", "/signup", "/sign_up",
98
+ ];
99
+ /** 规范化路径:去首尾斜杠 */
100
+ function normPath(p) {
101
+ return p.replace(/^\/+|\/+$/g, "");
102
+ }
103
+ /**
104
+ * 收集「账户集合根」:对每条路径,若以账户入口后缀结尾(如 /users/login),
105
+ * 剥掉后缀得集合根(/users)。该集合的无认证 POST = 公开注册(豁免)。
106
+ * 佐证式豁免——无 login/register 姊妹的写集合不豁免(管理员建用户等仍查)。
107
+ */
108
+ function collectRegisterRoots(paths) {
109
+ const roots = new Set();
110
+ for (const p of paths) {
111
+ const lower = p.toLowerCase();
112
+ for (const suffix of ACCOUNT_ENTRY_SUFFIXES) {
113
+ if (lower.endsWith(suffix)) {
114
+ roots.add(normPath(p.slice(0, -suffix.length)));
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ return roots;
120
+ }
121
+ /** routePath 是否命中账户集合根(规范化比较) */
122
+ function isRegisterRoot(routePath, roots) {
123
+ return roots.has(normPath(routePath));
124
+ }