progmune-runtime 3.7.8 → 3.7.10
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 +20 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/frameworks/fastify-detector.js +137 -0
- package/dist/frameworks/fastify-detector.test.js +62 -0
- package/dist/frameworks/flask-detector.js +61 -0
- package/dist/frameworks/flask-detector.test.js +71 -0
- package/dist/frameworks/index.js +15 -3
- package/dist/frameworks/nextjs-detector.js +172 -0
- package/dist/frameworks/nextjs-detector.test.js +113 -0
- package/dist/sdk.js +1 -1
- package/dist/trust/engine.js +159 -0
- package/package.json +7 -3
- package/tools/extract_framework_django.py +269 -0
- package/tools/extract_framework_flask.py +169 -0
- package/tools/extract_framework_py.py +237 -0
- package/tools/extract_ir.py +1109 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
/**
|
|
37
|
+
* nextjs-detector.test.ts — Next.js App Router 适配器规则回归(文件系统 I/O,
|
|
38
|
+
* 使用临时目录夹具——与 express-detector.test.ts 同款风格)
|
|
39
|
+
*/
|
|
40
|
+
const vitest_1 = require("vitest");
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const nextjs_detector_1 = require("./nextjs-detector");
|
|
45
|
+
let dir;
|
|
46
|
+
(0, vitest_1.beforeEach)(() => {
|
|
47
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "nextjs-det-"));
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.afterEach)(() => {
|
|
50
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
function writeRoute(rel, code) {
|
|
53
|
+
const full = path.join(dir, rel);
|
|
54
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
55
|
+
fs.writeFileSync(full, code);
|
|
56
|
+
}
|
|
57
|
+
const MUTATION_ROUTE = `export async function POST(req: Request) {
|
|
58
|
+
return Response.json({ ok: true });
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
const AUTHED_ROUTE = `import { getServerSession } from "next-auth";
|
|
62
|
+
export async function POST(req: Request) {
|
|
63
|
+
const session = await getServerSession();
|
|
64
|
+
return Response.json({ ok: true });
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
const AUTH_MIDDLEWARE = `import { withAuth } from "next-auth/middleware";
|
|
68
|
+
export default withAuth(function middleware(req) {});
|
|
69
|
+
`;
|
|
70
|
+
(0, vitest_1.describe)("nextjs-detector", () => {
|
|
71
|
+
(0, vitest_1.it)("R1:无认证 mutation 路由文件 → NEXT_ROUTE_NO_AUTH", () => {
|
|
72
|
+
writeRoute("app/api/transfer/route.ts", MUTATION_ROUTE);
|
|
73
|
+
const { hasNext, issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
|
|
74
|
+
(0, vitest_1.expect)(hasNext).toBe(true);
|
|
75
|
+
(0, vitest_1.expect)(issues.map((i) => i.rule)).toContain("NEXT_ROUTE_NO_AUTH");
|
|
76
|
+
});
|
|
77
|
+
(0, vitest_1.it)("R1:路由内 getServerSession 认证调用保护不报", () => {
|
|
78
|
+
writeRoute("app/api/transfer/route.ts", AUTHED_ROUTE);
|
|
79
|
+
const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
|
|
80
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
(0, vitest_1.it)("R1:认证 middleware 全局保护不报", () => {
|
|
83
|
+
writeRoute("app/api/transfer/route.ts", MUTATION_ROUTE);
|
|
84
|
+
writeRoute("middleware.ts", AUTH_MIDDLEWARE);
|
|
85
|
+
const mw = (0, nextjs_detector_1.readNextMiddleware)(dir);
|
|
86
|
+
const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir, mw);
|
|
87
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
(0, vitest_1.it)("R1:GET 导出不报(公开读)", () => {
|
|
90
|
+
writeRoute("app/api/articles/route.ts", `export async function GET() { return Response.json([]); }`);
|
|
91
|
+
const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
|
|
92
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
(0, vitest_1.it)("R1 豁免:login/auth 认证入口路径不报", () => {
|
|
95
|
+
writeRoute("app/api/auth/login/route.ts", MUTATION_ROUTE);
|
|
96
|
+
const { issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
|
|
97
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
(0, vitest_1.it)("pages/api 旧式路由同样覆盖", () => {
|
|
100
|
+
writeRoute("pages/api/transfer.ts", `export default function handler(req, res) { res.json({ok:true}); }`);
|
|
101
|
+
// 无 export function POST 的旧式 handler 不识别方法 → 无 flag(口径如实)
|
|
102
|
+
writeRoute("pages/api/transfer2.ts", `export default async function POST(req: Request) { return Response.json({}); }`);
|
|
103
|
+
const { hasNext, issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
|
|
104
|
+
(0, vitest_1.expect)(hasNext).toBe(true);
|
|
105
|
+
// transfer2 无 POST 导出匹配(default 导出非具名)——旧式页路由方法不可静态区分,如实
|
|
106
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
(0, vitest_1.it)("无 Next.js 结构的目录不产生问题", () => {
|
|
109
|
+
const { hasNext, issues } = (0, nextjs_detector_1.analyzeNextApp)(dir);
|
|
110
|
+
(0, vitest_1.expect)(hasNext).toBe(false);
|
|
111
|
+
(0, vitest_1.expect)(issues).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/dist/sdk.js
CHANGED
|
@@ -20,7 +20,7 @@ const risk_model_1 = require("./risk-model");
|
|
|
20
20
|
const protocol_knowledge_1 = require("./protocol-knowledge");
|
|
21
21
|
const evidence_repository_1 = require("./evidence-repository");
|
|
22
22
|
/** Runtime version — stable public identifier. Internal layers evolve underneath. */
|
|
23
|
-
exports.RUNTIME_VERSION = "3.7.
|
|
23
|
+
exports.RUNTIME_VERSION = "3.7.10";
|
|
24
24
|
function verify(filePath) {
|
|
25
25
|
const cert = (0, certify_1.certify)(filePath);
|
|
26
26
|
const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
|
package/dist/trust/engine.js
CHANGED
|
@@ -64,6 +64,9 @@ const annotation_suggest_1 = require("../annotation-suggest");
|
|
|
64
64
|
const call_graph_propagator_1 = require("./call-graph-propagator");
|
|
65
65
|
const fastapi_detector_1 = require("../frameworks/fastapi-detector");
|
|
66
66
|
const django_detector_1 = require("../frameworks/django-detector");
|
|
67
|
+
const flask_detector_1 = require("../frameworks/flask-detector");
|
|
68
|
+
const fastify_detector_1 = require("../frameworks/fastify-detector");
|
|
69
|
+
const nextjs_detector_1 = require("../frameworks/nextjs-detector");
|
|
67
70
|
const ssg_bridge_1 = require("./ssg-bridge");
|
|
68
71
|
const call_sequence_1 = require("../call-sequence");
|
|
69
72
|
const extract_ir_1 = require("../extract-ir");
|
|
@@ -96,6 +99,9 @@ async function evaluateTrust(ctx) {
|
|
|
96
99
|
const trpcResult = collectTRPCViolations(ctx);
|
|
97
100
|
const fastapiResult = collectFastapiViolations(ctx);
|
|
98
101
|
const djangoResult = collectDjangoViolations(ctx);
|
|
102
|
+
const flaskResult = collectFlaskViolations(ctx);
|
|
103
|
+
const fastifyResult = collectFastifyViolations(ctx);
|
|
104
|
+
const nextjsResult = collectNextjsViolations(ctx);
|
|
99
105
|
const coverageData = collectVerificationCoverage(ctx);
|
|
100
106
|
const governanceDefects = collectGovernanceDefects(ctx);
|
|
101
107
|
// ═══════════════════════════════════════
|
|
@@ -115,6 +121,9 @@ async function evaluateTrust(ctx) {
|
|
|
115
121
|
...trpcResult.violations,
|
|
116
122
|
...fastapiResult.violations,
|
|
117
123
|
...djangoResult.violations,
|
|
124
|
+
...flaskResult.violations,
|
|
125
|
+
...fastifyResult.violations,
|
|
126
|
+
...nextjsResult.violations,
|
|
118
127
|
];
|
|
119
128
|
// ═══════════════════════════════════════
|
|
120
129
|
// PHASE 3: SCORE
|
|
@@ -252,6 +261,33 @@ async function evaluateTrust(ctx) {
|
|
|
252
261
|
issuesFound: djangoResult.violations.length,
|
|
253
262
|
}
|
|
254
263
|
: undefined,
|
|
264
|
+
/** Flask framework adapter coverage — route/auth-guard analysis */
|
|
265
|
+
flaskCoverage: flaskResult.coverage.routes > 0
|
|
266
|
+
? {
|
|
267
|
+
appsDetected: flaskResult.coverage.apps,
|
|
268
|
+
totalRoutes: flaskResult.coverage.routes,
|
|
269
|
+
filesScanned: flaskResult.coverage.filesScanned,
|
|
270
|
+
issuesFound: flaskResult.violations.length,
|
|
271
|
+
}
|
|
272
|
+
: undefined,
|
|
273
|
+
/** Fastify framework adapter coverage — route/auth-hook analysis */
|
|
274
|
+
fastifyCoverage: fastifyResult.coverage.routes > 0
|
|
275
|
+
? {
|
|
276
|
+
appsDetected: fastifyResult.coverage.apps,
|
|
277
|
+
totalRoutes: fastifyResult.coverage.routes,
|
|
278
|
+
filesScanned: fastifyResult.coverage.filesScanned,
|
|
279
|
+
issuesFound: fastifyResult.violations.length,
|
|
280
|
+
}
|
|
281
|
+
: undefined,
|
|
282
|
+
/** Next.js framework adapter coverage — App Router route handler analysis */
|
|
283
|
+
nextjsCoverage: nextjsResult.coverage.routes > 0
|
|
284
|
+
? {
|
|
285
|
+
appsDetected: nextjsResult.coverage.apps,
|
|
286
|
+
totalRoutes: nextjsResult.coverage.routes,
|
|
287
|
+
filesScanned: nextjsResult.coverage.filesScanned,
|
|
288
|
+
issuesFound: nextjsResult.violations.length,
|
|
289
|
+
}
|
|
290
|
+
: undefined,
|
|
255
291
|
},
|
|
256
292
|
dimensions: {
|
|
257
293
|
policyCompliance: {
|
|
@@ -390,6 +426,129 @@ function mapPolicyViolation(rv, filePath, _enterprisePolicy) {
|
|
|
390
426
|
* Collect Express-specific security violations from the framework detector.
|
|
391
427
|
* Maps ExpressSecurityIssue[] → TrustViolation[].
|
|
392
428
|
*/
|
|
429
|
+
function collectNextjsViolations(ctx) {
|
|
430
|
+
const violations = [];
|
|
431
|
+
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
|
432
|
+
try {
|
|
433
|
+
const middlewareCode = (0, nextjs_detector_1.readNextMiddleware)(ctx.projectPath);
|
|
434
|
+
const analysis = (0, nextjs_detector_1.analyzeNextApp)(ctx.projectPath, middlewareCode);
|
|
435
|
+
if (!analysis.hasNext)
|
|
436
|
+
return { violations, coverage };
|
|
437
|
+
coverage.apps = 1;
|
|
438
|
+
coverage.routes = analysis.routeFiles.length;
|
|
439
|
+
coverage.filesScanned = analysis.routeFiles.length;
|
|
440
|
+
for (const issue of analysis.issues) {
|
|
441
|
+
violations.push({
|
|
442
|
+
severity: issue.severity === "low" ? "low" : issue.severity,
|
|
443
|
+
rule_id: issue.rule,
|
|
444
|
+
file: issue.file || "",
|
|
445
|
+
function: "unknown",
|
|
446
|
+
message: issue.message,
|
|
447
|
+
evidence: issue.route || "",
|
|
448
|
+
why: `Framework structural analysis: ${issue.message}`,
|
|
449
|
+
fix: `Add an auth check inside the route handler (e.g. getServerSession) or protect the app with auth middleware.`,
|
|
450
|
+
policy_ref: "framework-safety.nextjs",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch { /* best-effort */ }
|
|
455
|
+
return { violations, coverage };
|
|
456
|
+
}
|
|
457
|
+
function collectFastifyViolations(ctx) {
|
|
458
|
+
const violations = [];
|
|
459
|
+
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
|
460
|
+
try {
|
|
461
|
+
const fs = require("fs");
|
|
462
|
+
const candidateDirs = ["src", "server", "app", "api", "routes", "lib"];
|
|
463
|
+
const extensions = languageToExtensions(ctx.language);
|
|
464
|
+
for (const dir of candidateDirs) {
|
|
465
|
+
const dirPath = path.join(ctx.projectPath, dir);
|
|
466
|
+
if (!fs.existsSync(dirPath))
|
|
467
|
+
continue;
|
|
468
|
+
let files;
|
|
469
|
+
try {
|
|
470
|
+
files = walkDir(dirPath, extensions, 100);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
for (const file of files) {
|
|
476
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(file))
|
|
477
|
+
continue;
|
|
478
|
+
coverage.filesScanned++;
|
|
479
|
+
try {
|
|
480
|
+
const analysis = (0, fastify_detector_1.analyzeFastifyFile)(file);
|
|
481
|
+
if (!analysis || !analysis.hasFastify)
|
|
482
|
+
continue;
|
|
483
|
+
coverage.apps++;
|
|
484
|
+
coverage.routes += analysis.routes.length;
|
|
485
|
+
for (const issue of analysis.issues) {
|
|
486
|
+
violations.push({
|
|
487
|
+
severity: issue.severity === "low" ? "low" : issue.severity,
|
|
488
|
+
rule_id: issue.rule,
|
|
489
|
+
file: path.relative(ctx.projectPath, file),
|
|
490
|
+
function: "unknown",
|
|
491
|
+
message: issue.message,
|
|
492
|
+
evidence: issue.route || "",
|
|
493
|
+
why: `Framework structural analysis: ${issue.message}`,
|
|
494
|
+
fix: `Add preHandler/preValidation auth to the route options, or register an auth addHook.`,
|
|
495
|
+
policy_ref: "framework-safety.fastify",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch { /* skip unreadable files */ }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch { /* best-effort */ }
|
|
504
|
+
return { violations, coverage };
|
|
505
|
+
}
|
|
506
|
+
function collectFlaskViolations(ctx) {
|
|
507
|
+
const violations = [];
|
|
508
|
+
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
|
509
|
+
// 仅 Python 项目跑框架结构扫描
|
|
510
|
+
const lang = ctx.language || "typescript";
|
|
511
|
+
if (lang !== "python")
|
|
512
|
+
return { violations, coverage };
|
|
513
|
+
try {
|
|
514
|
+
const { execSync } = require("child_process");
|
|
515
|
+
const fs = require("fs");
|
|
516
|
+
const os = require("os");
|
|
517
|
+
// engine.js 位于 dist/trust/ → 仓库根 tools/ 需要两级向上
|
|
518
|
+
const scriptPath = path.resolve(__dirname, "..", "..", "tools", "extract_framework_flask.py");
|
|
519
|
+
const outPath = path.join(os.tmpdir(), `progmune-fwfl-${process.pid}-${Date.now()}.json`);
|
|
520
|
+
execSync(`python3 "${scriptPath}" "${ctx.projectPath}" "${outPath}"`, {
|
|
521
|
+
encoding: "utf-8",
|
|
522
|
+
stdio: "pipe",
|
|
523
|
+
timeout: 60000,
|
|
524
|
+
});
|
|
525
|
+
if (!fs.existsSync(outPath))
|
|
526
|
+
return { violations, coverage };
|
|
527
|
+
const structure = JSON.parse(fs.readFileSync(outPath, "utf-8"));
|
|
528
|
+
fs.unlinkSync(outPath);
|
|
529
|
+
const analysis = (0, flask_detector_1.analyzeFlaskStructure)(structure);
|
|
530
|
+
if (!analysis.hasFlask)
|
|
531
|
+
return { violations, coverage };
|
|
532
|
+
coverage.apps = (structure.apps || []).length + (structure.blueprints || []).length;
|
|
533
|
+
coverage.routes = (structure.routes || []).length;
|
|
534
|
+
coverage.filesScanned = structure.filesScanned || 0;
|
|
535
|
+
for (const issue of analysis.issues) {
|
|
536
|
+
violations.push({
|
|
537
|
+
severity: issue.severity === "low" ? "low" : issue.severity,
|
|
538
|
+
rule_id: issue.rule,
|
|
539
|
+
file: issue.file || "",
|
|
540
|
+
function: issue.handler || "unknown",
|
|
541
|
+
message: issue.message,
|
|
542
|
+
evidence: issue.route || "",
|
|
543
|
+
why: `Framework structural analysis: ${issue.message}`,
|
|
544
|
+
fix: `Add an auth decorator (@login_required or custom) to the route handler, or register an auth before_request guard.`,
|
|
545
|
+
policy_ref: "framework-safety.flask",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch { /* best-effort — framework analysis must never break evaluation */ }
|
|
550
|
+
return { violations, coverage };
|
|
551
|
+
}
|
|
393
552
|
function collectDjangoViolations(ctx) {
|
|
394
553
|
const violations = [];
|
|
395
554
|
const coverage = { apps: 0, routes: 0, filesScanned: 0 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "progmune-runtime",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.10",
|
|
4
4
|
"description": "Progmune — AI Trust Decision Engine. Verify AI-generated code before it reaches production. Outputs APPROVED / NEEDS_REVIEW / BLOCKED with evidence.",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist/",
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
"c-aliases.json",
|
|
9
9
|
"CHANGELOG.md",
|
|
10
10
|
"docs/Progmune_项目全解.html",
|
|
11
|
-
"docs/Progmune_投资人白皮书_v2.0.html"
|
|
11
|
+
"docs/Progmune_投资人白皮书_v2.0.html",
|
|
12
|
+
"tools/extract_ir.py",
|
|
13
|
+
"tools/extract_framework_py.py",
|
|
14
|
+
"tools/extract_framework_django.py",
|
|
15
|
+
"tools/extract_framework_flask.py"
|
|
12
16
|
],
|
|
13
17
|
"main": "dist/mcp-server.mjs",
|
|
14
18
|
"bin": {
|
|
@@ -110,4 +114,4 @@
|
|
|
110
114
|
"tsx": "^4.22.4",
|
|
111
115
|
"vitest": "^3.2.6"
|
|
112
116
|
}
|
|
113
|
-
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Django Framework Structure Extractor — 框架结构扫描(M2)
|
|
4
|
+
|
|
5
|
+
从 Python AST 提取 Django 结构:
|
|
6
|
+
1) urlpatterns 解析:url()/path()/re_path() → 视图引用(FBV views.foo /
|
|
7
|
+
CBV Foo.as_view() / include('module.urls') 递归)
|
|
8
|
+
2) 视图注册表:
|
|
9
|
+
- FBV:函数装饰器(login_required/permission_required/staff_member_required/
|
|
10
|
+
user_passes_test/authentication_decorator 等 *auth* 名装饰器)
|
|
11
|
+
- CBV:基类(APIView/View/generics.*/LoginRequiredMixin/
|
|
12
|
+
PermissionRequiredMixin)、方法(get/post/put/patch/delete)、
|
|
13
|
+
@method_decorator、DRF permission_classes(AllowAny/IsAuthenticated/
|
|
14
|
+
IsAdminUser/其他类名)
|
|
15
|
+
- @api_view 装饰器:methods + permission_classes kwarg
|
|
16
|
+
3) 每个 urlpattern → 视图解析(按短名匹配注册表)
|
|
17
|
+
|
|
18
|
+
输出 JSON:{hasDjango, routes:[{pattern,urlname,view,kind,file}],
|
|
19
|
+
views:{name:{file,decorators,methods,permissionClasses,isDrf,isProtected}},
|
|
20
|
+
filesScanned}
|
|
21
|
+
|
|
22
|
+
用法:python3 extract_framework_django.py <projectRoot> <outJson>
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
SKIP_DIRS = {"tests", "test", "deps", "venv", "env", "node_modules", "vendor",
|
|
32
|
+
".git", "migrations", "__pycache__", "scripts", "docs",
|
|
33
|
+
"staticfiles", "static"}
|
|
34
|
+
|
|
35
|
+
AUTH_DECORATORS = {
|
|
36
|
+
"login_required", "permission_required", "staff_member_required",
|
|
37
|
+
"user_passes_test", "authentication_decorator", "login_required_decorator",
|
|
38
|
+
"require_http_methods",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
AUTH_MIXINS = {"LoginRequiredMixin", "PermissionRequiredMixin",
|
|
42
|
+
"UserPassesTestMixin", "StaffMemberRequiredMixin"}
|
|
43
|
+
|
|
44
|
+
DRF_BASES = {"APIView", "GenericAPIView", "RetrieveAPIView", "ListAPIView",
|
|
45
|
+
"CreateAPIView", "UpdateAPIView", "DestroyAPIView",
|
|
46
|
+
"RetrieveUpdateAPIView", "ListCreateAPIView",
|
|
47
|
+
"RetrieveUpdateDestroyAPIView", "ViewSet", "ModelViewSet",
|
|
48
|
+
"GenericViewSet", "ReadOnlyModelViewSet"}
|
|
49
|
+
|
|
50
|
+
MUTATION_METHODS = {"post", "put", "patch", "delete"}
|
|
51
|
+
|
|
52
|
+
VIEW_BASES = DRF_BASES | {"View", "TemplateView", "FormView", "CreateView",
|
|
53
|
+
"UpdateView", "DeleteView", "ListView", "DetailView"}
|
|
54
|
+
|
|
55
|
+
OPEN_PERMISSIONS = {"AllowAny"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def name_of(node):
|
|
59
|
+
if isinstance(node, ast.Name):
|
|
60
|
+
return node.id
|
|
61
|
+
if isinstance(node, ast.Attribute):
|
|
62
|
+
return node.attr
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def short_view_name(ref):
|
|
67
|
+
"""views.auth_lab → auth_lab;Foo.as_view() → Foo"""
|
|
68
|
+
if isinstance(ref, ast.Attribute):
|
|
69
|
+
return ref.attr
|
|
70
|
+
if isinstance(ref, ast.Call) and isinstance(ref.func, ast.Attribute):
|
|
71
|
+
return ref.func.value.attr if isinstance(ref.func.value, ast.Attribute) \
|
|
72
|
+
else name_of(ref.func.value)
|
|
73
|
+
if isinstance(ref, ast.Name):
|
|
74
|
+
return ref.id
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ViewCollector(ast.NodeVisitor):
|
|
79
|
+
"""收集全部函数/类的视图特征(与 urlpatterns 解耦)"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, filepath):
|
|
82
|
+
self.file = filepath
|
|
83
|
+
self.fbvs = {} # name -> {decorators, apiView: [methods]}
|
|
84
|
+
self.classes = {} # name -> {bases, methods, decorators, permissionClasses, isDrf, protected}
|
|
85
|
+
|
|
86
|
+
def visit_FunctionDef(self, node):
|
|
87
|
+
self._scan_function(node, async_=False)
|
|
88
|
+
self.generic_visit(node)
|
|
89
|
+
|
|
90
|
+
def visit_AsyncFunctionDef(self, node):
|
|
91
|
+
self._scan_function(node, async_=True)
|
|
92
|
+
self.generic_visit(node)
|
|
93
|
+
|
|
94
|
+
def _scan_function(self, node, async_):
|
|
95
|
+
decorators = []
|
|
96
|
+
api_view_methods = None
|
|
97
|
+
permission_classes = []
|
|
98
|
+
for dec in node.decorator_list:
|
|
99
|
+
if isinstance(dec, ast.Name):
|
|
100
|
+
decorators.append(dec.id)
|
|
101
|
+
elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
|
|
102
|
+
decorators.append(dec.func.id)
|
|
103
|
+
if dec.func.id == "api_view":
|
|
104
|
+
if dec.args and isinstance(dec.args[0], (ast.List, ast.Tuple)):
|
|
105
|
+
api_view_methods = [
|
|
106
|
+
e.value.lower() for e in dec.args[0].elts
|
|
107
|
+
if isinstance(e, ast.Constant)
|
|
108
|
+
]
|
|
109
|
+
for kw in dec.keywords:
|
|
110
|
+
if kw.arg == "permission_classes" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
111
|
+
permission_classes = [name_of(e) for e in kw.value.elts if name_of(e)]
|
|
112
|
+
elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
113
|
+
decorators.append(dec.func.attr) # django.views.decorators.login_required
|
|
114
|
+
self.fbvs[node.name] = {
|
|
115
|
+
"file": self.file,
|
|
116
|
+
"decorators": decorators,
|
|
117
|
+
"apiViewMethods": api_view_methods,
|
|
118
|
+
"permissionClasses": permission_classes,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def visit_ClassDef(self, node):
|
|
122
|
+
bases = [name_of(b) for b in node.bases if name_of(b)]
|
|
123
|
+
methods = []
|
|
124
|
+
permission_classes = []
|
|
125
|
+
decorators = []
|
|
126
|
+
for item in node.body:
|
|
127
|
+
if isinstance(item, ast.FunctionDef) and item.name in (
|
|
128
|
+
"get", "post", "put", "patch", "delete", "create",
|
|
129
|
+
"update", "destroy", "list", "retrieve"):
|
|
130
|
+
methods.append(item.name)
|
|
131
|
+
if isinstance(item, ast.Assign):
|
|
132
|
+
for t in item.targets:
|
|
133
|
+
if isinstance(t, ast.Name) and t.id == "permission_classes":
|
|
134
|
+
if isinstance(item.value, (ast.List, ast.Tuple)):
|
|
135
|
+
permission_classes = [name_of(e) for e in item.value.elts if name_of(e)]
|
|
136
|
+
elif isinstance(item.value, ast.Name):
|
|
137
|
+
permission_classes = [item.value.id]
|
|
138
|
+
for dec in node.decorator_list:
|
|
139
|
+
if isinstance(dec, ast.Name):
|
|
140
|
+
decorators.append(dec.id)
|
|
141
|
+
elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
|
|
142
|
+
decorators.append(dec.func.id)
|
|
143
|
+
elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
144
|
+
decorators.append(dec.func.attr)
|
|
145
|
+
is_drf = any(b in DRF_BASES for b in bases)
|
|
146
|
+
has_mixin = any(b in AUTH_MIXINS for b in bases)
|
|
147
|
+
self.classes[node.name] = {
|
|
148
|
+
"file": self.file,
|
|
149
|
+
"bases": bases,
|
|
150
|
+
"methods": methods,
|
|
151
|
+
"decorators": decorators,
|
|
152
|
+
"permissionClasses": permission_classes,
|
|
153
|
+
"isDrf": is_drf,
|
|
154
|
+
"protectedByMixin": has_mixin,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class UrlCollector(ast.NodeVisitor):
|
|
159
|
+
"""收集 urlpatterns 列表里的视图引用"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, filepath):
|
|
162
|
+
self.file = filepath
|
|
163
|
+
self.routes = []
|
|
164
|
+
|
|
165
|
+
def visit_Assign(self, node):
|
|
166
|
+
for t in node.targets:
|
|
167
|
+
if isinstance(t, ast.Name) and t.id == "urlpatterns" and isinstance(node.value, (ast.List, ast.Tuple)):
|
|
168
|
+
for elt in node.value.elts:
|
|
169
|
+
self._scan_entry(elt)
|
|
170
|
+
self.generic_visit(node)
|
|
171
|
+
|
|
172
|
+
def _scan_entry(self, elt):
|
|
173
|
+
if not isinstance(elt, ast.Call):
|
|
174
|
+
return
|
|
175
|
+
fn = name_of(elt.func) # url / path / re_path
|
|
176
|
+
if fn not in ("url", "path", "re_path"):
|
|
177
|
+
return
|
|
178
|
+
pattern = ""
|
|
179
|
+
if elt.args and isinstance(elt.args[0], ast.Constant):
|
|
180
|
+
pattern = str(elt.args[0].value)
|
|
181
|
+
view_ref = elt.args[1] if len(elt.args) > 1 else None
|
|
182
|
+
if view_ref is None:
|
|
183
|
+
return
|
|
184
|
+
kind = "other"
|
|
185
|
+
view_name = None
|
|
186
|
+
if isinstance(view_ref, ast.Call) and isinstance(view_ref.func, ast.Attribute):
|
|
187
|
+
if view_ref.func.attr == "as_view":
|
|
188
|
+
kind = "cbv"
|
|
189
|
+
view_name = short_view_name(view_ref)
|
|
190
|
+
elif view_ref.func.id == "include" if isinstance(view_ref.func, ast.Name) else False:
|
|
191
|
+
kind = "include"
|
|
192
|
+
if view_name is None:
|
|
193
|
+
if isinstance(view_ref, ast.Name):
|
|
194
|
+
kind = "include" if False else "fbv"
|
|
195
|
+
view_name = view_ref.id
|
|
196
|
+
elif isinstance(view_ref, ast.Attribute):
|
|
197
|
+
kind = "fbv"
|
|
198
|
+
view_name = view_ref.attr
|
|
199
|
+
elif isinstance(view_ref, ast.Call) and isinstance(view_ref.func, ast.Name):
|
|
200
|
+
if view_ref.func.id == "include":
|
|
201
|
+
kind = "include"
|
|
202
|
+
else:
|
|
203
|
+
kind = "fbv"
|
|
204
|
+
view_name = view_ref.func.id
|
|
205
|
+
urlname = ""
|
|
206
|
+
for kw in elt.keywords:
|
|
207
|
+
if kw.arg == "name" and isinstance(kw.value, ast.Constant):
|
|
208
|
+
urlname = str(kw.value)
|
|
209
|
+
self.routes.append({
|
|
210
|
+
"pattern": pattern,
|
|
211
|
+
"urlname": urlname,
|
|
212
|
+
"view": view_name,
|
|
213
|
+
"kind": kind,
|
|
214
|
+
"file": self.file,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def walk_py_files(root):
|
|
219
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
220
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
221
|
+
for fn in filenames:
|
|
222
|
+
if fn.endswith(".py") and not fn.startswith("test_"):
|
|
223
|
+
yield os.path.join(dirpath, fn)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main():
|
|
227
|
+
root, out = sys.argv[1], sys.argv[2]
|
|
228
|
+
views, routes, files_scanned = {}, [], 0
|
|
229
|
+
for fp in walk_py_files(root):
|
|
230
|
+
files_scanned += 1
|
|
231
|
+
try:
|
|
232
|
+
with open(fp, "r", encoding="utf-8", errors="replace") as f:
|
|
233
|
+
tree = ast.parse(f.read(), filename=fp)
|
|
234
|
+
except (SyntaxError, UnicodeDecodeError, OSError):
|
|
235
|
+
continue
|
|
236
|
+
vc, uc = ViewCollector(fp), UrlCollector(fp)
|
|
237
|
+
vc.visit(tree)
|
|
238
|
+
uc.visit(tree)
|
|
239
|
+
for name, info in vc.fbvs.items():
|
|
240
|
+
info["kind"] = "fbv"
|
|
241
|
+
views[name] = info
|
|
242
|
+
for name, info in vc.classes.items():
|
|
243
|
+
info["kind"] = "cbv"
|
|
244
|
+
views[name] = info
|
|
245
|
+
routes.extend(uc.routes)
|
|
246
|
+
|
|
247
|
+
# 保护判定(扫描器只算事实,规则在 TS 检测器侧)
|
|
248
|
+
for name, info in views.items():
|
|
249
|
+
auth_dec = [d for d in info.get("decorators", [])
|
|
250
|
+
if d in AUTH_DECORATORS or "auth" in d.lower() or "login" in d.lower()]
|
|
251
|
+
info["authDecorators"] = auth_dec
|
|
252
|
+
if info["kind"] == "cbv":
|
|
253
|
+
pc = info.get("permissionClasses") or []
|
|
254
|
+
info["openPermission"] = (len(pc) == 0) or all(
|
|
255
|
+
p in OPEN_PERMISSIONS for p in pc
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
result = {
|
|
259
|
+
"hasDjango": bool(routes),
|
|
260
|
+
"routes": routes,
|
|
261
|
+
"views": views,
|
|
262
|
+
"filesScanned": files_scanned,
|
|
263
|
+
}
|
|
264
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
265
|
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
main()
|