progmune-runtime 3.7.29 → 3.7.31

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.7.31] — 2026-09-18
4
+
5
+ ### 提取器 SSRF 标记正则死循环修复(P0,真实工程可用性故障)
6
+
7
+ - **现象**:IR 提取阶段对含 `fetch(` 的 TS 文件 100% CPU 挂死——一个 55 行的切片 >480s 未完成,而 108 KB / 5 文件的语料只需 35s。实测 **nuxt-modules/og-image、tinacms/tinacms、zereight/gitlab-mcp 三个独立仓库全部触发**
8
+ - **定位**:对挂住进程做 CPU 采样(`sample`),**2193/2193 采样全部落在 `Builtins_RegExpPrototypeExec`**——确定为正则死循环,而非此前怀疑的 OOM / ts-morph 依赖解析
9
+ - **根因**(`src/extract-ir.ts` SSRF 分支,3.7.28 引入):`TS_HTTP_FETCH_SINK` 是**无 `g` 标志**的正则,却用于 `while ((m = TS_HTTP_FETCH_SINK.exec(text)) !== null)` 迭代。非全局正则的 `exec()` 忽略 `lastIndex` 并恒返回首个匹配;当首个 `fetch(` 后的 300 字符窗口不含污点时(真实代码里最常见的「良性 fetch」形态),循环无出口。同文件另 7 处 `while-exec` 循环均带 `g`,仅此一处遗漏
10
+ - **修复**:新增 `tsHttpFetchSinkIter()`(同模式 `"g"` 版)专供迭代;布尔探测仍用无标志版本(带 `g` 的 `.test()` 会污染 `lastIndex`)。模式至少匹配 5 字符,不可能零宽匹配
11
+ - **影响面**:任何含 `fetch(` 且函数体内无 SSRF 守卫词汇(`localhost|ssrf|hostname|…`)的 TS 工程都会挂死提取阶段——这是面向真实代码的普遍故障,不是基准内部的边缘情况
12
+ - **验收**:og-image 480s+ → **48s**;tinacms 300s+ → **57s**;gitlab-mcp 切片 480s+ → **80s**;最小复现 70s+ → **18s**
13
+
14
+ ### 修复回归语料扩样至 17 条(fr-012 ~ fr-017)
15
+
16
+ - **样本结构**:open-webui 占比 75% → **41%**,独立仓库 3 → **10**(gitlab-mcp ×2、mockoon、og-image、redocly-cli、tinacms)
17
+ - **结果**:6 条新语料全部 MISS(pre/post 均 0 违规),全库 **DETECTED 4/17、MISS 13/17、PENDING 0**
18
+ - **正向对照**:为排除「0 违规是哑值」,scan `demo-project/` 报出 1 条真实违规(`SSG_AUTH_STATE_VIOLATION`,`token_lifecycle_flow.ts::main`)——引擎是活的
19
+ - **注册数据修正**:fr-013 的 parent SHA 末 4 位转置(codeload 404)且原 fix_commit 是纯 CHANGELOG 文档提交,经 PR #624 正文 link 定位到真修复 PR #571;fr-012 锚点错位(其「文件读取」半边由更早的 PR #482 修复)
20
+ - **新根因类别**:fr-012(MCP 工具实参 `args.file_path`)、fr-015(`decodeURIComponent` 解码后的对象字段 `font.path`)暴露**污点源词汇表缺口**——与 skyvern/open-webui 已暴露的认证词表缺口同族,共同病灶是「标记管线靠名字识源,不靠数据流语义」
21
+ - 详见 `blind-benchmark/REALWORLD_FIX_REGRESSION_V2.md`
22
+
23
+ ### 回归
24
+
25
+ - 新增 `src/extract-ir-ssrf-loop.test.ts` 2 green ✓(①良性 fetch 必须正常返回——修复前永不返回;②URL 形参流入 fetch 仍必须注入 `__progmune_ssrf_user_url__`)/ build ✓
26
+
27
+ ## [3.7.30] — 2026-09-17
28
+
29
+ ### 提取器性能重构(3.7.28 引入的回归修复)
30
+
31
+ - **问题**:3.7.28 的标记增强(路径穿越/SSRF)采用独立遍历——对每个函数类节点做 ts-morph getStart/getEnd/getText + 正则扫描,check 自扫(仓库 375 个源文件)从 ~30s 退化到 10 分钟+(SSRF 增强单测 4 分钟 CPU)
32
+ - **修复**(`src/extract-ir.ts`):①标记计算**内联进主循环**——复用主循环已获取的函数文本与形参名(computeMarkerCalls),删除两个独立遍历增强函数;②文件级预过滤(路径穿越只看含 request 模式的文件,SSRF 只看含 fetch sink 的文件);③主循环跳过 node_modules(lib.d.ts 等声明文件只贡献噪声);④微型节点(<80 字符)跳过正则扫描
33
+ - **行为保持**:fr-007 openhop 标记(flowRoutes + FlowStore.save/get/delete/updateFlow)与 fr-011 mcp-from-openapi 标记(fromURL)重构后逐项一致;**TS 795 零漂移(3086 flags LOST 0 / ADDED 0)**
34
+ - **诊断教训**:期间本机 8GB 内存被后台进程耗尽(一个 9 小时 CPU 的幽灵 extractor 进程 + 多个未杀净的扫描进程),计时测量被环境噪声污染——定位靠逐步计时(PT 增强 267ms / SSRF 增强 4min+)与进程审计,最终重构从架构上消除该开销
35
+
36
+ ### 回归
37
+
38
+ - TS 795 零漂移 ✓ / 标记双语料复验一致 ✓ / 引擎测试 73 green ✓ / build ✓
39
+
3
40
  ## [3.7.29] — 2026-09-14
4
41
 
5
42
  ### 失败语料沉淀接通(知识网络入口修复)
package/README.md CHANGED
@@ -109,15 +109,18 @@ Progmune is honest about what it can and cannot verify.
109
109
  | **Python** | ✅ Production | Blind benchmark: **recall 100% / precision 100%** (729 gold findings, 90 projects); real-world validation: PyGoat (OWASP vulnerable-by-design Django app) **67 TP / 0 FP, 100% labeled precision**; three well-written apps (django/fastapi realworld, django-unicorn) with 0 false-positive true findings |
110
110
  | **C** | ✅ Annotation-driven (Beta) | IR extraction via the registry + SSG state machine; **annotate ~2-3 protocol primitives per protocol to get trusted verification** (gold 5/5 on real modules: redis ACL / libssh client / libssh server / libssh callback-dispatch / uftpd transfer-auth — all 0 FP with precise violation localization; app-level gold v2: **P=91.7% / R=100% / F1=95.7%**). Unannotated auto-detection stays out of scope (0 TP on real corpus — positioning decision recorded in the C Language Status doc); TLS-level coverage still absent. See [C Language Status](https://github.com/shenlian19831109/progmune-runtime/blob/main/docs/c-language-status.md). |
111
111
  | **Go** | ✅ Annotation-driven (Beta) | IR extraction via registry + SSG state machine; synthetic gold v1: **P=100% / R=100%** (clean×3 + violations×3); zero external toolchain (pure-TS lexical extractor — works from npm installs) |
112
- | **Java** | ❌ None | Planned |
112
+ | **Java** | 🔶 Annotation-driven (Beta) | Two layers, verified on real corpora. **Framework layer ✅**: Spring adapter (`spring-detector.ts` — security rule ordering + ant patterns + controller annotation routes) on gothinkster/spring-boot-realworld (1581★): 19 routes / 12 mutations fully parsed, 0 issues, catch-all flip counter-proof ×10; Spring Boot 3 modern dialect re-verified (SecurityFilterChain bean + String[] allowlist expansion): 15 routes, 0 issues. **Core layer**: pure-TS lexical extractor (zero toolchain deps) at **100%/100% recovery vs a tree-sitter AST baseline (1216/1216)**; SSG annotation-driven protocol rows (token / auth-register / resource) engine-locked with a real-corpus closed loop (**1 real TP / 0 FP**). Unannotated auto-detection stays out of scope (same decision as C/Go). Production label pending enterprise PoC. See [Java Language Status](https://github.com/shenlian19831109/progmune-runtime/blob/main/docs/java-language-status.md). |
113
113
 
114
114
  **Multi-language IR (registry-based).** TypeScript (ts-morph) and Python (AST) extractors are registered entries in one registry (`src/extract-project-ir.ts`); `extractProjectIR` merges every detected language into a single function IR shared by the agent loop, `execute()`, and the MCP server — the agent composes function-protocol chains across both languages. Adding a language (Go, Java, …) = register one extractor entry; callers don't change.
115
115
 
116
- **Framework adapters: 12 dedicated detectors (4 structural + 8 heuristic).** Express ✅, tRPC ✅, FastAPI ✅, Django ✅ (structural route-level auth analysis — every mutation route checked for auth), Flask ✅ (route/before_request auth guard analysis), Fastify ✅ (route preHandler/hook auth analysis) and Next.js ✅ (App Router route-handler auth analysis), NestJS ✅ (decorator route parsing + global APP_GUARD recognition + @Public exemption), Koa ✅, Hapi ✅ (route-config auth strategy analysis), Gin ✅ and Fiber ✅ (Go route/auth-middleware analysis) have dedicated detectors. Next.js version-aware governance also exists. Spring Boot/Go frameworks and a few TS frameworks remain — framework adaptation is the #1 product gap.
116
+ **Framework adapters: 13 dedicated detectors — 4 structural (AST-based) + 8 heuristic (code-string) + Spring Boot (security rule-ordering model); 13/13 now validated on real corpora.** Express ✅, tRPC ✅, FastAPI ✅, Django ✅ (structural route-level auth analysis — every mutation route checked for auth), Flask ✅ (route/before_request auth guard analysis), Fastify ✅ (route preHandler/hook auth analysis) and Next.js ✅ (App Router route-handler auth analysis), NestJS ✅ (decorator route parsing + global APP_GUARD recognition + @Public exemption), Koa ✅, Hapi ✅ (route-config auth strategy analysis), Gin ✅ and Fiber ✅ (Go route/auth-middleware analysis) and Spring Boot ✅ (Spring Security rule ordering; Boot 2 + Boot 3 dialects) have dedicated detectors. Next.js version-aware governance also exists.
117
+
118
+ **Evidence tiers** (adopted 2026-09-02): 🟡 synthetic → ✅ **real-corpus validated** (≥1 real production corpus at 0 protocol-level FP + guard-removal counter-proof) → 🏭 production (enterprise PoC/adoption — none yet). **All 13/13 are ✅.** Documented boundaries: Express `main.ts` retains a NO_HELMET hardening gap (not protocol-level); the NestJS corpus yields 1 real TP (`DELETE /users/:slug`); jiotv_go's 2 flags are capability-token shapes; gofiber recipes flags are demo-context noise (no auth intent). Remaining gap: a few TS frameworks and Go frameworks beyond Gin/Fiber.
117
119
 
118
120
  ### What Progmune does NOT cover (honest boundaries)
119
121
 
120
- - **TS-side taint-based injection flaws** — the source-level SQLi/XSS/SSRF detections ship for Python; the TypeScript extractor is name/call-based, so TS injection classes remain uncovered (documented, not hidden).
122
+ - **TS-side taint-based injection flaws — partial.** Python ships source-level SQLi/XSS/SSRF. On the TypeScript side, **path traversal (3.7.26)** and **SSRF (3.7.28)** are covered: the extractor emits markers (request/param taint → fs or HTTP sink, one hop + one cross-function hop) and the engine consumes them at the IR layer — validated against real security fixes at their ground-truth sites (fr-007 openhop, fr-011 mcp-from-openapi). **SQLi and XSS remain uncovered**: the TS extractor is name/call-based, not a dataflow taint engine, so those classes stay out of scope (documented, not hidden).
123
+ - **Missing-check (absence) vulnerabilities** — the dominant real-world fix shape. A protocol state machine proves "these calls happened in the wrong order"; it does not prove "a required check was never called." Authorisation-policy bypasses, allowlist-path omissions, and ownership checks missing on one route are the largest uncovered class — measured, not guessed: see the [real-fix regression corpus](https://github.com/shenlian19831109/progmune-runtime/blob/main/blind-benchmark/fix-regression-corpus.json).
121
124
  - **SCA / dependency vulnerabilities** — hallucinated package names, supply-chain issues. Separate tooling exists for this.
122
125
  - **Runtime behavior** — Progmune is static analysis only; no DAST/sandbox execution.
123
126
  - **Framework internals** — well-known framework dispatch/cache machinery (e.g. django-unicorn internals) can produce a small number of boundary false positives; they are documented per-corpus in the benchmark gold files.
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * SSRF 标记提取的死循环回归(2026-09-18,P0)。
4
+ *
5
+ * 根因:computeMarkerCalls 的 SSRF 分支对一个**无 `g` 标志**的正则
6
+ * TS_HTTP_FETCH_SINK 做 while-exec 迭代。非全局正则的 exec() 忽略
7
+ * lastIndex 并恒返回首个匹配,因此只要首个 fetch 调用后的 300 字符窗口
8
+ * 内不含污点——真实代码里最常见的「良性 fetch」形态——循环就没有出口,
9
+ * 提取阶段 100% CPU 死循环。CPU 采样佐证:2193/2193 采样全部落在
10
+ * Builtins_RegExpPrototypeExec。
11
+ *
12
+ * 影响面:任何含 `fetch(` 且函数体内无 SSRF 守卫词汇的 TS 工程都会挂死
13
+ * 提取阶段(实测 nuxt og-image、tinacms、gitlab-mcp 三个独立仓库均触发)。
14
+ *
15
+ * 本文件锁定两端:
16
+ * 1. 良性 fetch 必须正常返回(修复前永不返回);
17
+ * 2. 真正的 SSRF 污点仍必须注入 __progmune_ssrf_user_url__(修复不得削弱检出)。
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ const vitest_1 = require("vitest");
54
+ const fs = __importStar(require("fs"));
55
+ const os = __importStar(require("os"));
56
+ const path = __importStar(require("path"));
57
+ const extract_ir_1 = require("./extract-ir");
58
+ const TSCONFIG = JSON.stringify({
59
+ compilerOptions: {
60
+ target: "ES2020",
61
+ module: "commonjs",
62
+ moduleResolution: "node",
63
+ strict: false,
64
+ skipLibCheck: true,
65
+ noEmit: true,
66
+ },
67
+ include: ["**/*.ts"],
68
+ });
69
+ function makeProject(files) {
70
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pm-ssrf-loop-"));
71
+ fs.writeFileSync(path.join(dir, "tsconfig.json"), TSCONFIG);
72
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "fixture", version: "0.0.0", private: true }));
73
+ for (const [rel, body] of Object.entries(files)) {
74
+ const abs = path.join(dir, rel);
75
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
76
+ fs.writeFileSync(abs, body);
77
+ }
78
+ return dir;
79
+ }
80
+ (0, vitest_1.describe)("SSRF 标记提取:良性 fetch 不得使提取器死循环", () => {
81
+ (0, vitest_1.it)("无守卫词汇、无污点的 fetch 调用应正常完成提取", () => {
82
+ const dir = makeProject({
83
+ "api.ts": `
84
+ export async function ping(): Promise<any> {
85
+ const response = await fetch("https://x.example/uploads", { method: "POST" });
86
+ return response.json();
87
+ }
88
+
89
+ export async function pong(): Promise<any> {
90
+ return fetch("https://x.example/other");
91
+ }
92
+ `,
93
+ });
94
+ try {
95
+ const fns = (0, extract_ir_1.extractIR)(dir);
96
+ const names = fns.map((f) => f.name);
97
+ (0, vitest_1.expect)(names).toContain("ping");
98
+ (0, vitest_1.expect)(names).toContain("pong");
99
+ }
100
+ finally {
101
+ fs.rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ }, 150000);
104
+ (0, vitest_1.it)("URL 形参直接流入 fetch 仍应注入 __progmune_ssrf_user_url__", () => {
105
+ const dir = makeProject({
106
+ "proxy.ts": `
107
+ export async function proxy(target: string): Promise<any> {
108
+ const response = await fetch(target);
109
+ return response;
110
+ }
111
+ `,
112
+ });
113
+ try {
114
+ const fns = (0, extract_ir_1.extractIR)(dir);
115
+ const proxy = fns.find((f) => f.name === "proxy");
116
+ (0, vitest_1.expect)(proxy).toBeDefined();
117
+ (0, vitest_1.expect)(proxy.calls ?? []).toContain("__progmune_ssrf_user_url__");
118
+ }
119
+ finally {
120
+ fs.rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ }, 150000);
123
+ });
@@ -338,7 +338,7 @@ function manualResolveModule(moduleSpecifier, sourceFilePath, projectRoot, origi
338
338
  }
339
339
  return null;
340
340
  }
341
- function extractDirectCalls(func) {
341
+ function extractDirectCalls(func, preText) {
342
342
  const body = func.getBody();
343
343
  if (!body)
344
344
  return [];
@@ -360,7 +360,7 @@ function extractDirectCalls(func) {
360
360
  // - inline ownership comparison: ownerId/authorId compared with ==/!== —
361
361
  // the Ownership Check rules' satisfier consumes it (the call-name
362
362
  // interface cannot see inline comparisons).
363
- const text = func.getText();
363
+ const text = preText !== undefined ? preText : func.getText();
364
364
  if (/set_cookie\(|setCookie\(|\btoken\s*[:=]|\bsession_token\s*[:=]/.test(text)) {
365
365
  calls.push("__progmune_token_issued__");
366
366
  }
@@ -508,96 +508,6 @@ function methodSinkParamMap(project, absRoot) {
508
508
  }
509
509
  return map;
510
510
  }
511
- /**
512
- * 注入路径穿越标记:
513
- * 1) 本函数体内 sink 实参直接污点;
514
- * 2) 跨函数一跳——调用项目方法且污点实参落在该方法的 sink 形参位置。
515
- * 标记只进 funcs 中该函数的 calls(方法条目不单独标——违规归因到入口)。
516
- */
517
- function augmentPathTraversalMarkers(project, funcs, absRoot) {
518
- const sinkParams = methodSinkParamMap(project, absRoot);
519
- const methodCallRe = new RegExp(`\\.([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
520
- const mark = (name, file) => {
521
- const entry = funcs.find((x) => x.name === name && x.file === file);
522
- if (!entry)
523
- return;
524
- entry.calls = entry.calls || [];
525
- if (!entry.calls.includes(PATH_TRAVERSAL_MARKER)) {
526
- entry.calls.push(PATH_TRAVERSAL_MARKER);
527
- }
528
- };
529
- // 对每个函数式节点(函数声明/方法/箭头回调——fastify 路由回调是内联
530
- // 箭头函数,不在 funcs 中)独立做污点分析。节点文本预过滤:
531
- // 不含 request 访问也不含 sink 方法调用则跳过。
532
- const sinkMethodNames = [...sinkParams.keys()];
533
- const prefilter = new RegExp(`\\b(?:req|request)\\.(?:params|query|body|headers|cookies)\\b|` +
534
- (sinkMethodNames.length > 0 ? `\\.(?:${sinkMethodNames.join("|")})\\s*\\(` : "a^"));
535
- for (const sf of project.getSourceFiles()) {
536
- if (sf.getFilePath().includes("node_modules"))
537
- continue;
538
- const relPath = path.relative(absRoot, sf.getFilePath());
539
- sf.forEachDescendant((node) => {
540
- if (!ts_morph_1.Node.isFunctionDeclaration(node) &&
541
- !ts_morph_1.Node.isArrowFunction(node) &&
542
- !ts_morph_1.Node.isMethodDeclaration(node)) {
543
- return;
544
- }
545
- const text = node.getText();
546
- if (!prefilter.test(text))
547
- return;
548
- const tainted = collectTaintedNames(text);
549
- if (tainted.size === 0)
550
- return;
551
- const taint = taintPattern(tainted);
552
- if (!taint)
553
- return;
554
- // 1) 节点体内 sink 实参直接污点 → 标记该节点归属的 funcs 条目
555
- if (hasTaintedSinkCall(text, tainted)) {
556
- const nodeName = ts_morph_1.Node.isArrowFunction(node) ? undefined : node.getName();
557
- if (ts_morph_1.Node.isFunctionDeclaration(node) && nodeName) {
558
- mark(nodeName, relPath);
559
- }
560
- else if (ts_morph_1.Node.isMethodDeclaration(node) && nodeName) {
561
- const cls = node.getParent();
562
- if (ts_morph_1.Node.isClassDeclaration(cls) && cls.getName()) {
563
- mark(`${cls.getName()}.${nodeName}`, relPath);
564
- }
565
- }
566
- return;
567
- }
568
- // 2) 跨函数一跳:污点实参传入项目方法体的文件 sink。
569
- // 标记调用方(若可归属)+ 被调方法条目——路由回调不可归属时,
570
- // 方法条目是唯一可归因位置(openhop 形态)。
571
- methodCallRe.lastIndex = 0;
572
- let m;
573
- while ((m = methodCallRe.exec(text)) !== null) {
574
- const callee = m[1];
575
- const rec = sinkParams.get(callee);
576
- if (!rec || rec.idxs.size === 0)
577
- continue;
578
- const win = m[2] || "";
579
- const args = splitArgWindow(win);
580
- const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
581
- if (!hit)
582
- continue;
583
- const nodeName = ts_morph_1.Node.isArrowFunction(node) ? undefined : node.getName();
584
- if (ts_morph_1.Node.isFunctionDeclaration(node) && nodeName) {
585
- mark(nodeName, relPath);
586
- }
587
- else if (ts_morph_1.Node.isMethodDeclaration(node) && nodeName) {
588
- const cls = node.getParent();
589
- if (ts_morph_1.Node.isClassDeclaration(cls) && cls.getName()) {
590
- mark(`${cls.getName()}.${nodeName}`, relPath);
591
- }
592
- }
593
- for (const me of rec.entries) {
594
- mark(me.name, me.file);
595
- }
596
- return;
597
- }
598
- });
599
- }
600
- }
601
511
  // ═══════════════════════════════════════════════════════════════
602
512
  // SSRF marker(2026-09-14,REALWORLD_FIX_REGRESSION fr-011/fr-010)
603
513
  // URL 形参/request 污点 → HTTP fetch sink,函数内无 SSRF 守卫证据
@@ -606,87 +516,83 @@ function augmentPathTraversalMarkers(project, funcs, absRoot) {
606
516
  // Web 形态(req.params)共用同一标记;引擎 IR 层消费。
607
517
  // ═══════════════════════════════════════════════════════════════
608
518
  const TS_HTTP_FETCH_SINK = /\b(fetch|axios\.(?:get|post|put|delete|head|patch)|http\.request|https\.request|ky\.(?:get|post|put|delete|head|patch)|undici\.request|nodeFetch|got)\s*\(/;
519
+ /**
520
+ * 同模式的**全局**版本,仅用于 while-exec 迭代。
521
+ *
522
+ * 2026-09-18 P0 修复:此前 while 循环直接对无 `g` 标志的 TS_HTTP_FETCH_SINK
523
+ * 反复调用 exec()。非全局正则的 exec() 会忽略 lastIndex 并恒返回首个匹配,
524
+ * 一旦首个匹配的 300 字符窗口内不含污点(真实代码里最常见的良性 fetch
525
+ * 形态),循环无出口 → 提取器 100% CPU 死循环。CPU 采样证实:
526
+ * 2193/2193 采样全部落在 Builtins_RegExpPrototypeExec。
527
+ * 该缺陷会使任何含 `fetch(` 且无 SSRF 守卫词汇的 TS 工程挂死提取阶段。
528
+ */
529
+ function tsHttpFetchSinkIter() {
530
+ return new RegExp(TS_HTTP_FETCH_SINK.source, "g");
531
+ }
609
532
  const SSRF_GUARD_EVIDENCE = /127\.0\.0\.1|0\.0\.0\.0|169\.254\.|::1|localhost|isPrivate|isLoopback|hostname|denylist|blocklist|ssrf|validateUrl|isSafeUrl|getAddresses|ipaddress|forbidden_host|private_ip/i;
610
533
  const URL_PARAM_NAME = /^(url|target|endpoint|link|href|webUrl|web_url|sourceUrl|source_url|remoteUrl|remote_url|fetchUrl|fetch_url|specUrl|spec_url|apiUrl|api_url|origin|baseUrl|base_url)$/i;
611
534
  function collectUrlParamNames(params) {
612
535
  return params.map((p) => p.name).filter((n) => URL_PARAM_NAME.test(n));
613
536
  }
614
- function augmentSsfrMarkers(project, funcs, absRoot) {
615
- const sinkRe = TS_HTTP_FETCH_SINK;
616
- for (const sf of project.getSourceFiles()) {
617
- if (sf.getFilePath().includes("node_modules"))
618
- continue;
619
- const relPath = path.relative(absRoot, sf.getFilePath());
620
- sf.forEachDescendant((node) => {
621
- if (!ts_morph_1.Node.isFunctionDeclaration(node) &&
622
- !ts_morph_1.Node.isArrowFunction(node) &&
623
- !ts_morph_1.Node.isMethodDeclaration(node)) {
624
- return;
625
- }
626
- const text = node.getText();
627
- if (text.length > 12000)
628
- return; // 巨型函数跳过(性能 + 噪声)
629
- // 预过滤:必须有 fetch sink
630
- sinkRe.lastIndex = 0;
631
- if (!sinkRe.test(text))
632
- return;
633
- // 守卫证据:函数内有 SSRF 防护词汇 → 抑制
634
- if (SSRF_GUARD_EVIDENCE.test(text))
635
- return;
636
- // URL 形参污点
637
- const urlParams = ts_morph_1.Node.isArrowFunction(node)
638
- ? []
639
- : collectUrlParamNames(node.getParameters().map((p) => ({ name: p.getName() })));
640
- // request 污点(Web 形态)
641
- const reqTainted = collectTaintedNames(text);
642
- const taintParts = [];
643
- if (urlParams.length > 0)
644
- taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
645
- if (reqTainted.size > 0)
646
- taintParts.push(`\\b(?:${[...reqTainted].join("|")})\\b`);
647
- taintParts.push(/\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.source);
648
- const taint = new RegExp(taintParts.join("|"));
649
- if (taintParts.length <= 1) {
650
- // 只有 request 根模式而无实际污点名——仍需检查 sink 实参
651
- }
652
- // sink 实参窗口含污点 → 标记
653
- let flagged = false;
654
- sinkRe.lastIndex = 0;
655
- let m;
656
- while ((m = sinkRe.exec(text)) !== null) {
657
- // 取调用后 300 字符窗口(URL 是首个实参)
658
- const after = text.slice(m.index + m[0].length, m.index + m[0].length + 300);
659
- if (taint.test(after)) {
660
- flagged = true;
661
- break;
662
- }
663
- }
664
- if (!flagged)
665
- return;
666
- const nodeName = ts_morph_1.Node.isArrowFunction(node) ? undefined : node.getName();
667
- if (ts_morph_1.Node.isFunctionDeclaration(node) && nodeName) {
668
- const entry = funcs.find((x) => x.name === nodeName && x.file === relPath);
669
- if (entry) {
670
- entry.calls = entry.calls || [];
671
- if (!entry.calls.includes("__progmune_ssrf_user_url__")) {
672
- entry.calls.push("__progmune_ssrf_user_url__");
673
- }
674
- }
537
+ /**
538
+ * 标记增强的内联实现(2026-09-15 性能重构):消费主循环已获取的
539
+ * 函数文本与形参名,输出该函数应附加的合成标记。
540
+ * - 路径穿越:request 污点 → 文件 sink(本函数内);或污点实参传入
541
+ * 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
542
+ * - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
543
+ */
544
+ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
545
+ const markers = [];
546
+ // ── 路径穿越 ──
547
+ if (hasRequestRootedExpr(text)) {
548
+ const tainted = collectTaintedNames(text);
549
+ if (tainted.size > 0) {
550
+ if (hasTaintedSinkCall(text, tainted)) {
551
+ markers.push("__progmune_path_traversal__");
675
552
  }
676
- else if (ts_morph_1.Node.isMethodDeclaration(node) && nodeName) {
677
- const cls = node.getParent();
678
- if (ts_morph_1.Node.isClassDeclaration(cls) && cls.getName()) {
679
- const entry = funcs.find((x) => x.name === `${cls.getName()}.${nodeName}` && x.file === relPath);
680
- if (entry) {
681
- entry.calls = entry.calls || [];
682
- if (!entry.calls.includes("__progmune_ssrf_user_url__")) {
683
- entry.calls.push("__progmune_ssrf_user_url__");
553
+ else {
554
+ const methodCallRe = new RegExp(`\\.([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
555
+ const taint = taintPattern(tainted);
556
+ if (taint) {
557
+ let m;
558
+ while ((m = methodCallRe.exec(text)) !== null) {
559
+ const rec = sinkParams.get(m[1]);
560
+ if (!rec || rec.idxs.size === 0)
561
+ continue;
562
+ const args = splitArgWindow(m[2] || "");
563
+ const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
564
+ if (hit) {
565
+ markers.push("__progmune_path_traversal__");
566
+ onMethodHit(rec);
684
567
  }
685
568
  }
686
569
  }
687
570
  }
688
- });
571
+ }
689
572
  }
573
+ // ── SSRF ──
574
+ TS_HTTP_FETCH_SINK.lastIndex = 0;
575
+ if (TS_HTTP_FETCH_SINK.test(text) && !SSRF_GUARD_EVIDENCE.test(text)) {
576
+ const urlParams = paramNames.filter((n) => URL_PARAM_NAME.test(n));
577
+ const reqTainted = collectTaintedNames(text);
578
+ const taintParts = [];
579
+ if (urlParams.length > 0)
580
+ taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
581
+ if (reqTainted.size > 0)
582
+ taintParts.push(`\\b(?:${[...reqTainted].join("|")})\\b`);
583
+ taintParts.push(/\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.source);
584
+ const taint = new RegExp(taintParts.join("|"));
585
+ const fetchSinkIter = tsHttpFetchSinkIter();
586
+ let m;
587
+ while ((m = fetchSinkIter.exec(text)) !== null) {
588
+ const after = text.slice(m.index + m[0].length, m.index + m[0].length + 300);
589
+ if (taint.test(after)) {
590
+ markers.push("__progmune_ssrf_user_url__");
591
+ break;
592
+ }
593
+ }
594
+ }
595
+ return markers;
690
596
  }
691
597
  /**
692
598
  * 从 TypeScript 项目提取 IR(函数签名、参数、返回值、协议注解)。
@@ -766,15 +672,31 @@ function _extractSingleProject(absRoot, tsconfigPath) {
766
672
  project.addSourceFilesAtPaths(path.join(absRoot, "**/*.ts"));
767
673
  }
768
674
  const funcs = [];
675
+ // 标记增强的共享状态(2026-09-15 内联重构):
676
+ // sinkParams = 项目方法名 → 方法体内流入文件 sink 的形参下标 + 方法条目;
677
+ // pendingMethodMarks = 跨函数命中待标记的方法条目(主循环后统一应用)
678
+ const sinkParams = methodSinkParamMap(project, absRoot);
679
+ const pendingMethodMarks = new Set();
680
+ const onMethodHit = (rec) => {
681
+ for (const me of rec.entries)
682
+ pendingMethodMarks.add(`${me.name}\u0000${me.file}`);
683
+ };
769
684
  for (const sf of project.getSourceFiles()) {
685
+ if (sf.getFilePath().includes("node_modules"))
686
+ continue; // lib.d.ts 等声明文件只贡献噪声(2026-09-17 性能)
770
687
  const relPath = path.relative(absRoot, sf.getFilePath());
688
+ const _ft0 = Date.now();
771
689
  for (const f of sf.getFunctions()) {
772
690
  const name = f.getName();
773
691
  if (!name)
774
692
  continue;
693
+ const fParams = f.getParameters();
694
+ const fText = f.getText();
695
+ const fCalls = extractDirectCalls(f, fText);
696
+ fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit));
775
697
  funcs.push({
776
698
  name,
777
- params: f.getParameters().map(p => ({
699
+ params: fParams.map(p => ({
778
700
  name: p.getName(),
779
701
  type: getParamType(p),
780
702
  typeDetail: getParamTypeDetail(p),
@@ -782,7 +704,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
782
704
  returnType: getReturnType(f),
783
705
  returnTypeDetail: getReturnTypeDetail(f),
784
706
  file: relPath,
785
- calls: extractDirectCalls(f),
707
+ calls: fCalls,
786
708
  exported: f.isExported(),
787
709
  inputs: deriveInputs(f.getParameters().map(p => ({ name: p.getName(), type: p.getTypeNode()?.getText() || "any" }))),
788
710
  outputs: deriveOutputs(f.getReturnTypeNode()?.getText() || "any"),
@@ -798,9 +720,13 @@ function _extractSingleProject(absRoot, tsconfigPath) {
798
720
  // 直接箭头函数: const fn = () => {}
799
721
  if (ts_morph_1.Node.isArrowFunction(init)) {
800
722
  const name = vd.getName();
723
+ const initParams = init.getParameters();
724
+ const initText = init.getText();
725
+ const initCalls = extractDirectCalls(init, initText);
726
+ initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit));
801
727
  funcs.push({
802
728
  name,
803
- params: init.getParameters().map(p => ({
729
+ params: initParams.map(p => ({
804
730
  name: p.getName(),
805
731
  type: getParamType(p),
806
732
  typeDetail: getParamTypeDetail(p),
@@ -809,7 +735,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
809
735
  returnTypeDetail: getReturnTypeDetail(init),
810
736
  file: relPath,
811
737
  exported: vd.isExported(),
812
- calls: extractDirectCalls(init),
738
+ calls: initCalls,
813
739
  protocol: parseProtocolFromJSDoc(vd),
814
740
  ...parseCapabilityFromJSDoc(vd),
815
741
  });
@@ -820,9 +746,13 @@ function _extractSingleProject(absRoot, tsconfigPath) {
820
746
  for (const arg of init.getArguments()) {
821
747
  if (ts_morph_1.Node.isArrowFunction(arg)) {
822
748
  const name = vd.getName();
749
+ const argParams = arg.getParameters();
750
+ const argText = arg.getText();
751
+ const argCalls = extractDirectCalls(arg, argText);
752
+ argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit));
823
753
  funcs.push({
824
754
  name,
825
- params: arg.getParameters().map(p => ({
755
+ params: argParams.map(p => ({
826
756
  name: p.getName(),
827
757
  type: getParamType(p),
828
758
  typeDetail: getParamTypeDetail(p),
@@ -831,7 +761,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
831
761
  returnTypeDetail: getReturnTypeDetail(arg),
832
762
  file: relPath,
833
763
  exported: vd.isExported(),
834
- calls: extractDirectCalls(arg),
764
+ calls: argCalls,
835
765
  protocol: parseProtocolFromJSDoc(vd),
836
766
  });
837
767
  break; // 只取第一个箭头函数参数
@@ -851,30 +781,41 @@ function _extractSingleProject(absRoot, tsconfigPath) {
851
781
  const mn = m.getName();
852
782
  if (!mn)
853
783
  continue;
784
+ const mParams = m.getParameters();
785
+ const mText = m.getText();
786
+ const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit);
854
787
  funcs.push({
855
788
  name: `${cn}.${mn}`,
856
- params: m.getParameters().map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),
789
+ params: mParams.map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),
857
790
  returnType: m.getReturnTypeNode?.()?.getText?.() || "any",
858
791
  returnTypeDetail: m.getReturnTypeNode?.()?.getText?.() || "any",
859
792
  file: relPath,
860
793
  exported: cls.isExported(),
861
- calls: [],
794
+ calls: mCalls,
862
795
  ...parseCapabilityFromJSDoc(m),
863
796
  protocol: parseProtocolFromJSDoc(m),
864
797
  });
865
798
  }
866
799
  }
800
+ const _ftd = Date.now() - _ft0;
801
+ if (_ftd > 3000)
802
+ console.error(`[perf-file] ${relPath}: ${_ftd}ms`);
803
+ }
804
+ // ── 标记增强(2026-09-11 fr-007 / 2026-09-14 fr-011):在下方
805
+ // 主循环内联计算(复用已获取的函数文本,无独立遍历——
806
+ // 2026-09-15 性能重构);跨函数命中在循环结束后统一应用。
807
+ for (const key of pendingMethodMarks) {
808
+ const idx = key.indexOf("\u0000");
809
+ const mname = key.slice(0, idx);
810
+ const mfile = key.slice(idx + 1);
811
+ const entry = funcs.find((x) => x.name === mname && x.file === mfile);
812
+ if (entry) {
813
+ entry.calls = entry.calls || [];
814
+ if (!entry.calls.includes("__progmune_path_traversal__")) {
815
+ entry.calls.push("__progmune_path_traversal__");
816
+ }
817
+ }
867
818
  }
868
- // ═══════════════════════════════════════════════════════════════
869
- // Phase 4.5: Path traversal marker(2026-09-11,fr-007 openhop)
870
- // 镜像 Python 提取器的 request 污点 → 文件 sink 单跳追踪,并补
871
- // 跨函数一跳:调用点把污点参数传给项目方法,该方法体内该参数
872
- // 流入文件 sink → 调用方函数注入 __progmune_path_traversal__。
873
- // 消费方:protocol-domain-validator PATH_TRAVERSAL 检查(引擎)
874
- // 与 protocol-detector 同名规则(source-level benchmark 路径)。
875
- // ═══════════════════════════════════════════════════════════════
876
- augmentPathTraversalMarkers(project, funcs, absRoot);
877
- augmentSsfrMarkers(project, funcs, absRoot);
878
819
  // ═══════════════════════════════════════════════════════════════
879
820
  // Phase 5: Dynamic external function resolution
880
821
  // Replaces hardcoded knownExternals
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.29";
23
+ exports.RUNTIME_VERSION = "3.7.30";
24
24
  function verify(filePath) {
25
25
  const cert = (0, certify_1.certify)(filePath);
26
26
  const kb = (0, protocol_knowledge_1.buildKnowledgeBase)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "progmune-runtime",
3
- "version": "3.7.29",
3
+ "version": "3.7.31",
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/",