progmune-runtime 3.7.13 → 3.7.15

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.
@@ -56,6 +56,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
56
56
  exports.analyzeHapiApp = analyzeHapiApp;
57
57
  exports.analyzeHapiFile = analyzeHapiFile;
58
58
  const fs = __importStar(require("fs"));
59
+ const route_window_1 = require("./route-window");
59
60
  const MUTATION_METHODS = new Set(["post", "put", "patch", "delete"]);
60
61
  const AUTH_ENTRY_WORDS = [
61
62
  "login", "signin", "sign_in", "regist", "signup", "sign_up",
@@ -66,11 +67,56 @@ function isAuthEntryPath(pathName) {
66
67
  return AUTH_ENTRY_WORDS.some((w) => lower.includes(w));
67
68
  }
68
69
  // ── Analysis(代码串级) ──
70
+ /** 声明式 hapi 路由模块(V6 遗留缺口):真实 hapi 应用(glue/pal/插件)
71
+ * 以数组声明路由——module.exports = (server) => [ { method, path,
72
+ * config: { auth: 'jwt', ... }, handler }, ... ],由框架注册;
73
+ * 文件本身无 require('hapi')、无 server.route 调用 */
74
+ function isDeclarativeHapiModule(code) {
75
+ const hasRouteObject = /method\s*:\s*['"](GET|POST|PUT|PATCH|DELETE|get|post|put|patch|delete)['"]/.test(code) &&
76
+ /path\s*:\s*['"]/.test(code) &&
77
+ /\b(?:config|options)\s*:/.test(code);
78
+ if (!hasRouteObject)
79
+ return false;
80
+ return (/module\.exports\s*=\s*\(?\s*server\b/.test(code) ||
81
+ /module\.exports\s*=\s*function\s*\(\s*server\b/.test(code) ||
82
+ /return\s*\[/.test(code));
83
+ }
84
+ /** 自 routeObjStart('{') 取平衡块文本(含嵌套 config 对象;字符串感知) */
85
+ function hapiBalancedBlock(code, openIdx) {
86
+ let depth = 1;
87
+ let end = openIdx + 1;
88
+ let quote = null;
89
+ while (end < code.length && depth > 0) {
90
+ const ch = code[end];
91
+ if (quote) {
92
+ if (ch === quote && code[end - 1] !== "\\")
93
+ quote = null;
94
+ }
95
+ else if (ch === '"' || ch === "'" || ch === "`") {
96
+ quote = ch;
97
+ }
98
+ else if (ch === "{") {
99
+ depth++;
100
+ }
101
+ else if (ch === "}") {
102
+ depth--;
103
+ }
104
+ end++;
105
+ }
106
+ return code.slice(openIdx + 1, Math.max(openIdx + 1, end - 1));
107
+ }
108
+ function hapiAuthOption(block) {
109
+ const authM = block.match(/\bauth\s*:\s*(?:['"]([^'"]+)['"]|\{\s*strategy\s*:\s*['"]([^'"]+)['"]|\s*(false|true))/);
110
+ if (!authM)
111
+ return null;
112
+ return authM[3] === "false" ? "false" : authM[1] || authM[2] || authM[3] || null;
113
+ }
69
114
  function analyzeHapiApp(code) {
70
115
  const issues = [];
71
116
  const routes = [];
72
117
  const strategies = [];
73
- const hasHapi = /@hapi\/hapi|\bHapi\.server\b|\bhapi\.server\b/.test(code);
118
+ // @hapi-scoped(v17+)、v16 require('hapi'),或声明式数组模块(V6 修复)
119
+ const hasHapi = /@hapi\/hapi|\bHapi\.server\b|\bhapi\.server\b|require\(\s*['"]hapi['"]\s*\)|from\s+['"]hapi['"]/.test(code) || isDeclarativeHapiModule(code);
74
120
  if (!hasHapi) {
75
121
  return { hasHapi: false, routes, strategies, issues };
76
122
  }
@@ -80,43 +126,69 @@ function analyzeHapiApp(code) {
80
126
  while ((m = strategyRe.exec(code)) !== null) {
81
127
  strategies.push(m[1]);
82
128
  }
83
- // 路由块:server.route({ ... }) —— 从 route( 向后截 500 字符窗口
84
- const routeRe = /\.route\s*\(\s*\{/g;
85
- while ((m = routeRe.exec(code)) !== null) {
86
- const window = code.slice(m.index, m.index + 500);
87
- const methodM = window.match(/method\s*:\s*['"]([^'"]+)['"]/);
88
- const pathM = window.match(/path\s*:\s*['"]([^'"]+)['"]/);
89
- const authM = window.match(/auth\s*:\s*(?:['"]([^'"]+)['"]|\{\s*strategy\s*:\s*['"]([^'"]+)['"]|\s*(false|true))/);
90
- if (!methodM || !pathM)
129
+ const declarative = isDeclarativeHapiModule(code);
130
+ const lineAt = (idx) => code.slice(0, idx).split("\n").length;
131
+ if (!declarative) {
132
+ // 直连形态:server.route({ ... }) —— 500 字符窗口
133
+ const routeRe = /\.route\s*\(\s*\{/g;
134
+ while ((m = routeRe.exec(code)) !== null) {
135
+ const window = code.slice(m.index, m.index + 500);
136
+ const methodM = window.match(/method\s*:\s*['"]([^'"]+)['"]/);
137
+ const pathM = window.match(/path\s*:\s*['"]([^'"]+)['"]/);
138
+ if (!methodM || !pathM)
139
+ continue;
140
+ routes.push({
141
+ method: methodM[1].toLowerCase(),
142
+ path: pathM[1],
143
+ authOption: hapiAuthOption(window),
144
+ line: lineAt(m.index),
145
+ });
146
+ }
147
+ }
148
+ else {
149
+ // 声明式数组:每个 { method, path, config:{auth} } 路由对象
150
+ const verbRe = /method\s*:\s*['"](GET|POST|PUT|PATCH|DELETE|get|post|put|patch|delete)['"]/g;
151
+ while ((m = verbRe.exec(code)) !== null) {
152
+ const verb = m[1].toLowerCase();
153
+ const verbLine = lineAt(m.index);
154
+ const objStart = code.lastIndexOf("{", m.index);
155
+ if (objStart < 0)
156
+ continue;
157
+ const block = hapiBalancedBlock(code, objStart);
158
+ const pathM = block.match(/path\s*:\s*['"]([^'"]+)['"]/);
159
+ if (!pathM)
160
+ continue;
161
+ // 重复对象去重
162
+ if (routes.some((r) => r.line === verbLine && r.method === verb))
163
+ continue;
164
+ routes.push({
165
+ method: verb,
166
+ path: pathM[1],
167
+ authOption: hapiAuthOption(block),
168
+ line: verbLine,
169
+ });
170
+ }
171
+ }
172
+ // register 集合豁免(语义层,同其他框架):users/login 姊妹 → POST users 公开
173
+ const registerRoots = (0, route_window_1.collectRegisterRoots)(routes.map((r) => r.path));
174
+ for (const r of routes) {
175
+ if (!MUTATION_METHODS.has(r.method))
91
176
  continue;
92
- const method = methodM[1].toLowerCase();
93
- const pathName = pathM[1];
94
- // auth: false → 显式公开;无 auth 字段 → authM null
95
- const authOption = authM
96
- ? (authM[3] === "false" ? "false" : authM[1] || authM[2] || authM[3] || null)
97
- : null;
98
- routes.push({
99
- method,
100
- path: pathName,
101
- authOption,
102
- line: code.slice(0, m.index).split("\n").length,
103
- });
104
- if (!MUTATION_METHODS.has(method))
177
+ if (isAuthEntryPath(r.path))
105
178
  continue;
106
- if (isAuthEntryPath(pathName))
179
+ if (r.method === "post" && (0, route_window_1.isRegisterRoot)(r.path, registerRoots))
107
180
  continue;
108
- // auth 字段(authOption null 且非显式 false 已涵盖)或显式 false → 报
109
- if (authOption === null || authOption === "false") {
181
+ if (r.authOption === null || r.authOption === "false") {
110
182
  issues.push({
111
183
  severity: "medium",
112
184
  rule: "HAPI_ROUTE_NO_AUTH",
113
- message: authOption === "false"
114
- ? `Mutation route ${method.toUpperCase()} ${pathName} is explicitly ` +
185
+ message: r.authOption === "false"
186
+ ? `Mutation route ${r.method.toUpperCase()} ${r.path} is explicitly ` +
115
187
  `public (auth: false) — any caller can reach it.`
116
- : `Mutation route ${method.toUpperCase()} ${pathName} has no auth ` +
188
+ : `Mutation route ${r.method.toUpperCase()} ${r.path} has no auth ` +
117
189
  `option in its route config — any caller can reach it.`,
118
- route: `${method.toUpperCase()} ${pathName}`,
119
- line: code.slice(0, m.index).split("\n").length,
190
+ route: `${r.method.toUpperCase()} ${r.path}`,
191
+ line: r.line,
120
192
  });
121
193
  }
122
194
  }
@@ -126,7 +198,9 @@ function analyzeHapiFile(filePath) {
126
198
  if (!fs.existsSync(filePath))
127
199
  return null;
128
200
  const code = fs.readFileSync(filePath, "utf-8");
129
- if (!/@hapi\/hapi|@hapi\/hawk|\bHapi\.server\b/.test(code))
201
+ // gate:@hapi-scoped / v16 require('hapi') / 声明式数组模块
202
+ const marker = /@hapi\/hapi|@hapi\/hawk|\bHapi\.server\b|require\(\s*['"]hapi['"]\s*\)|from\s+['"]hapi['"]/.test(code) || isDeclarativeHapiModule(code);
203
+ if (!marker)
130
204
  return null;
131
205
  return analyzeHapiApp(code);
132
206
  }
@@ -58,3 +58,94 @@ server.route({ method: "POST", path: "/login", handler: () => "token" });
58
58
  (0, vitest_1.expect)(issues).toHaveLength(0);
59
59
  });
60
60
  });
61
+ // ── V6 修复轮回归:v16 时代 require('hapi') gate 兼容 ──
62
+ (0, vitest_1.describe)("hapi-detector V6 gate 修复回归", () => {
63
+ (0, vitest_1.it)("v16 形态 require('hapi') + server.route 可被分析(旧 gate 只认 @hapi-scoped)", () => {
64
+ const code = `
65
+ const Hapi = require("hapi");
66
+ const server = new Hapi.Server();
67
+ server.connection({ port: 3000 });
68
+ server.auth.strategy("jwt", "jwt", { key: "s" });
69
+ server.route({ method: "POST", path: "/articles", config: { auth: "jwt" }, handler: (r, reply) => reply({}) });
70
+ server.route({ method: "POST", path: "/payments", handler: (r, reply) => reply({}) });
71
+ `;
72
+ const { hasHapi, strategies, routes, issues } = (0, hapi_detector_1.analyzeHapiApp)(code);
73
+ (0, vitest_1.expect)(hasHapi).toBe(true);
74
+ (0, vitest_1.expect)(strategies).toContain("jwt");
75
+ const articles = routes.find((r) => r.path === "/articles");
76
+ const payments = routes.find((r) => r.path === "/payments");
77
+ (0, vitest_1.expect)(articles).toBeDefined();
78
+ (0, vitest_1.expect)(articles.authOption).toBe("jwt"); // config.auth 嵌套亦被窗口文本捕获
79
+ (0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("HAPI_ROUTE_NO_AUTH");
80
+ (0, vitest_1.expect)(issues.map((i) => i.route)).not.toContain("POST /articles");
81
+ });
82
+ (0, vitest_1.it)("gate 不误收 hapi-auth-jwt2(require('hapi') 需闭合引号紧随)", () => {
83
+ const { hasHapi } = (0, hapi_detector_1.analyzeHapiApp)(`
84
+ const hapiAuth = require("hapi-auth-jwt2");
85
+ module.exports = (server) => { return []; };
86
+ `);
87
+ (0, vitest_1.expect)(hasHapi).toBe(false);
88
+ });
89
+ });
90
+ // ── V6 遗留缺口:声明式数组路由 + config.auth 嵌套 ──
91
+ const DECLARATIVE = `
92
+ module.exports = (server) => {
93
+ const handlers = require('./handlers')(server)
94
+ return [
95
+ // GET 公开
96
+ {
97
+ method: 'GET',
98
+ path: '/articles',
99
+ config: { description: 'list' },
100
+ handler: handlers.list
101
+ },
102
+ // mutation 受保护(config.auth 嵌套)
103
+ {
104
+ method: 'POST',
105
+ path: '/articles',
106
+ config: { auth: 'jwt', response: {} },
107
+ handler: handlers.create
108
+ },
109
+ // 无认证 mutation
110
+ {
111
+ method: 'POST',
112
+ path: '/payments',
113
+ config: {},
114
+ handler: handlers.pay
115
+ }
116
+ ]
117
+ }
118
+ `;
119
+ const DECLARATIVE_USERS = `
120
+ module.exports = (server) => {
121
+ return [
122
+ { method: 'POST', path: '/users/login', config: {}, handler: h },
123
+ { method: 'POST', path: '/users', config: {}, handler: h },
124
+ { method: 'PUT', path: '/user', config: { auth: 'jwt' }, handler: h }
125
+ ]
126
+ }
127
+ `;
128
+ (0, vitest_1.describe)("hapi 声明式数组路由(V6 修复回归)", () => {
129
+ (0, vitest_1.it)("module.exports=(server)+数组路由对象被识别,config.auth 保护生效", () => {
130
+ const { hasHapi, routes, issues } = (0, hapi_detector_1.analyzeHapiApp)(DECLARATIVE);
131
+ (0, vitest_1.expect)(hasHapi).toBe(true);
132
+ (0, vitest_1.expect)(routes.length).toBe(3);
133
+ const create = routes.find((r) => r.method === "post" && r.path === "/articles");
134
+ (0, vitest_1.expect)(create.authOption).toBe("jwt");
135
+ const missing = issues.filter((i) => i.rule === "HAPI_ROUTE_NO_AUTH").map((i) => i.route);
136
+ (0, vitest_1.expect)(missing).toContain("POST /payments");
137
+ (0, vitest_1.expect)(missing).not.toContain("POST /articles");
138
+ });
139
+ (0, vitest_1.it)("摘 config.auth → mutation 报(敏感性)", () => {
140
+ const stripped = DECLARATIVE.replace("config: { auth: 'jwt', response: {} }", "config: { response: {} }");
141
+ const { issues } = (0, hapi_detector_1.analyzeHapiApp)(stripped);
142
+ (0, vitest_1.expect)(issues.some((i) => i.rule === "HAPI_ROUTE_NO_AUTH" && i.route === "POST /articles")).toBe(true);
143
+ });
144
+ (0, vitest_1.it)("register/login 公开:users/login + users(姊妹佐证)不报", () => {
145
+ const { issues } = (0, hapi_detector_1.analyzeHapiApp)(DECLARATIVE_USERS);
146
+ const missing = issues.filter((i) => i.rule === "HAPI_ROUTE_NO_AUTH").map((i) => i.route);
147
+ (0, vitest_1.expect)(missing).not.toContain("POST /users/login");
148
+ (0, vitest_1.expect)(missing).not.toContain("POST /users");
149
+ (0, vitest_1.expect)(missing).not.toContain("PUT /user");
150
+ });
151
+ });
@@ -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