progmune-runtime 3.7.31 → 3.7.35
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 +105 -0
- package/dist/extract-ir-taint-guard.test.js +372 -0
- package/dist/extract-ir-taint-structural.test.js +289 -0
- package/dist/extract-ir.js +263 -47
- package/dist/ir-staleness.js +233 -0
- package/dist/ir-staleness.test.js +228 -0
- package/dist/sdk.js +1 -1
- package/dist/trust/engine.js +28 -2
- package/package.json +1 -1
package/dist/extract-ir.js
CHANGED
|
@@ -378,6 +378,156 @@ function extractDirectCalls(func, preText) {
|
|
|
378
378
|
// 与 protocol-detector 的同名规则(source-level benchmark 路径)。
|
|
379
379
|
// ═══════════════════════════════════════════════════════════════
|
|
380
380
|
const PATH_TRAVERSAL_MARKER = "__progmune_path_traversal__";
|
|
381
|
+
const PATH_GUARD_RULES = [
|
|
382
|
+
{
|
|
383
|
+
id: "G-A",
|
|
384
|
+
// resolve(p).startsWith(resolve(root)+sep) / x.startsWith(baseDir)
|
|
385
|
+
re: /(?:resolve|realpath|normalize)\s*\([^()]*\)\s*\.\s*startsWith\s*\(/,
|
|
386
|
+
why: "目录包含性校验(canonical 形态):规范化后判断是否落在基目录内",
|
|
387
|
+
source: "设计形态,canonical;fr-016 Redocly 修复的同族(见 G-C)",
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "G-A2",
|
|
391
|
+
re: /\.\s*startsWith\s*\(\s*[A-Za-z_$][\w$]*(?:[Bb]ase|[Rr]oot|[Dd]ir|Dir|Root|Base)[\w$]*\s*\)/,
|
|
392
|
+
why: "目录包含性校验(基目录名形态):startsWith(baseDir/rootDir/...)",
|
|
393
|
+
source: "设计形态,canonical",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
id: "G-B1",
|
|
397
|
+
re: /\b(?:path\.)?isAbsolute\s*\(/,
|
|
398
|
+
why: "绝对路径拒绝:绝对路径不受 join(root, p) 约束",
|
|
399
|
+
source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(pre/post 同一段,@ index.ts:7968)",
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
id: "G-B2",
|
|
403
|
+
// startsWith(".." + sep) / includes(sep + ".." + sep) / === ".."
|
|
404
|
+
re: /\.\s*(?:startsWith|includes)\s*\(\s*(?:[^()]*["'`]\.\.)/,
|
|
405
|
+
why: "上跳拒绝:显式检测 .. 段",
|
|
406
|
+
source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(index.ts:7968-7977)",
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
id: "G-B3",
|
|
410
|
+
re: /(?:===?|!==)\s*["'`]\.\.["'`]/,
|
|
411
|
+
why: "上跳拒绝:与 .. 字面量直接比较",
|
|
412
|
+
source: "fr-012 gitlab-mcp 下载侧 localPath 既有守卫块(normalizedLocalPath === \"..\")",
|
|
413
|
+
},
|
|
414
|
+
// 注:G-C 不放在本数组里——它是「按函数名判定」,需要配反例名单
|
|
415
|
+
// (见 PATH_GUARD_FN_CALL_RE / PATH_GUARD_FN_DENY 与下方注释)。
|
|
416
|
+
{
|
|
417
|
+
id: "G-D",
|
|
418
|
+
// /^[A-Za-z0-9_-]+$/ —— 锚定的字符集白名单,天然排除 / \ .
|
|
419
|
+
re: /\/\^\[[^\]]*\]\s*[+*?]\$\//,
|
|
420
|
+
why: "锚定字符集白名单:^…$ 且字符类不含 ./\\ 时无法构造上跳",
|
|
421
|
+
source: "fr-007 openhop `FLOW_ID_PATTERN = /^[A-Za-z0-9_-]+$/`(flow-id.ts)",
|
|
422
|
+
},
|
|
423
|
+
];
|
|
424
|
+
/**
|
|
425
|
+
* 明确**不算**守卫的形态(反例清单)。
|
|
426
|
+
*
|
|
427
|
+
* N-A `path.basename` 是这张表里最重要的一行:它只去目录、不去 `..`,
|
|
428
|
+
* 却极常被当成「已经 sanitize 过」。fr-012 pre 的实测反例:漏洞态代码里
|
|
429
|
+
* 就有 `path.basename`,漏洞依然成立。一旦把 basename 算作守卫,
|
|
430
|
+
* fr-012 的真值会被自己压掉 —— 见 src/extract-ir-taint-guard.test.ts。
|
|
431
|
+
*/
|
|
432
|
+
const PATH_NON_GUARD_NOTE = "N-A path.basename / N-B 单独出现的 join·resolve / N-C 长度或字符数检查 / N-D if (!p) throw —— 均不计入守卫";
|
|
433
|
+
/**
|
|
434
|
+
* G-C:独立校验函数被调用(项目自有命名)。
|
|
435
|
+
*
|
|
436
|
+
* 后缀刻意**不收** `Dir` / `Name` / 裸的 `Valid` —— 实证代价:
|
|
437
|
+
* - `ensureDir()`(fr-007 openhop store.ts:46)与 `s.isDirectory()` 都会被
|
|
438
|
+
* `…Dir(` 命中,而它们是**建目录 / 类型判断**,与校验无关;第一版词表
|
|
439
|
+
* 因此把 fr-007 的 pre 侧 5 条全部误压成 0(召回归零)。
|
|
440
|
+
* - `isValid(x)` / `isSub(a,b)` 同理,会把判别力稀释成猜函数名。
|
|
441
|
+
* 保留 `Id`(fr-007 `assertValidFlowId`)、`Within`(fr-016 `assertWithinDir`)、
|
|
442
|
+
* `Safe|Path|Inside|InDir|Contained|Traversal|Root|Base`。
|
|
443
|
+
*/
|
|
444
|
+
const PATH_GUARD_FN_CALL_RE = /\b(?:assert|ensure|check|validate|verify|sanitize|is)[A-Za-z$]*(?:Safe|Path|Paths|Within|Inside|InDir|Contained|Traversal|Id|Root|Base)[A-Za-z0-9_$]*(?=\s*\()/g;
|
|
445
|
+
/** 形态上会命中 G-C 但**确定不是**路径校验的名字(实证反例,逐个加) */
|
|
446
|
+
const PATH_GUARD_FN_DENY = /^(?:ensureDir|isDirectory|isDir|assertDir|makeDir|mkdir|checkExists|isValid|isRoot|ensureId|getId)$/i;
|
|
447
|
+
function callsPathGuardFn(text) {
|
|
448
|
+
PATH_GUARD_FN_CALL_RE.lastIndex = 0;
|
|
449
|
+
let m;
|
|
450
|
+
while ((m = PATH_GUARD_FN_CALL_RE.exec(text)) !== null) {
|
|
451
|
+
if (!PATH_GUARD_FN_DENY.test(m[0]))
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
/** 返回命中的守卫规则 id;无校验证据返回 null。 */
|
|
457
|
+
function hasPathGuardEvidence(text) {
|
|
458
|
+
for (const r of PATH_GUARD_RULES) {
|
|
459
|
+
if (r.re.test(text))
|
|
460
|
+
return r.id;
|
|
461
|
+
}
|
|
462
|
+
if (callsPathGuardFn(text))
|
|
463
|
+
return "G-C";
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
function escapeRe(s) {
|
|
467
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* 项目内「自身含校验证据」的函数名集合,并向调用方传播一跳(有界)。
|
|
471
|
+
*
|
|
472
|
+
* 为什么必须传播:fr-007 的修复是把 `assertValidFlowId(id)` 放进 `filePath()`,
|
|
473
|
+
* 真正含 sink 的 `FlowStore.get/save/...` 与外层 `flowRoutes` 里**一个校验词汇都没有**——
|
|
474
|
+
* 只看函数体等于没做。传播上限 3 轮(防止公共函数名导致的无限扩散)。
|
|
475
|
+
*/
|
|
476
|
+
function pathGuardFunctionNames(project) {
|
|
477
|
+
const guarded = new Set();
|
|
478
|
+
const bodies = [];
|
|
479
|
+
const note = (names, text) => {
|
|
480
|
+
if (!text)
|
|
481
|
+
return;
|
|
482
|
+
bodies.push({ names, text });
|
|
483
|
+
if (hasPathGuardEvidence(text))
|
|
484
|
+
for (const n of names)
|
|
485
|
+
if (n)
|
|
486
|
+
guarded.add(n);
|
|
487
|
+
};
|
|
488
|
+
for (const sf of project.getSourceFiles()) {
|
|
489
|
+
for (const cls of sf.getClasses()) {
|
|
490
|
+
const cn = cls.getName();
|
|
491
|
+
if (!cn)
|
|
492
|
+
continue;
|
|
493
|
+
for (const m of cls.getMethods()) {
|
|
494
|
+
const mn = m.getName();
|
|
495
|
+
if (!mn)
|
|
496
|
+
continue;
|
|
497
|
+
note([mn, `${cn}.${mn}`], m.getText());
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const fn of sf.getFunctions()) {
|
|
501
|
+
const fnName = fn.getName();
|
|
502
|
+
if (!fnName)
|
|
503
|
+
continue;
|
|
504
|
+
note([fnName], fn.getText());
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// 向调用方传播:调用了一个已校验函数 ⇒ 这条流已被拦截(有界 3 轮)
|
|
508
|
+
for (let round = 0; round < 3; round++) {
|
|
509
|
+
const names = [...guarded].filter((n) => !n.includes("."));
|
|
510
|
+
if (names.length === 0)
|
|
511
|
+
break;
|
|
512
|
+
const re = new RegExp(`\\b(?:${names.map(escapeRe).join("|")})\\s*\\(`, "g");
|
|
513
|
+
let added = false;
|
|
514
|
+
for (const b of bodies) {
|
|
515
|
+
if (b.names.every((n) => guarded.has(n)))
|
|
516
|
+
continue;
|
|
517
|
+
re.lastIndex = 0;
|
|
518
|
+
if (re.test(b.text)) {
|
|
519
|
+
for (const n of b.names)
|
|
520
|
+
if (n && !guarded.has(n)) {
|
|
521
|
+
guarded.add(n);
|
|
522
|
+
added = true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!added)
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
return guarded;
|
|
530
|
+
}
|
|
381
531
|
const TS_FILE_SINK_NAMES = [
|
|
382
532
|
"readFile", "readFileSync", "writeFile", "writeFileSync",
|
|
383
533
|
"appendFile", "appendFileSync", "unlink", "unlinkSync", "rm", "rmSync",
|
|
@@ -385,21 +535,49 @@ const TS_FILE_SINK_NAMES = [
|
|
|
385
535
|
"createWriteStream", "existsSync", "readdir", "readdirSync",
|
|
386
536
|
"copyFile", "rename", "stat", "lstat", "rmdir", "truncate",
|
|
387
537
|
];
|
|
538
|
+
// ═══════════════════════════════════════════════════════════════
|
|
539
|
+
// 不可信入口根表 —— 污点数据流试点(2026-09-18)
|
|
540
|
+
//
|
|
541
|
+
// 此前整条污点链路只锚定 Express 形态的
|
|
542
|
+
// `(?:req|request)\.(?:params|query|body|headers|cookies)`,
|
|
543
|
+
// 即**根集合只有一种来源**。后果是 MCP 工具实参、CLI 参数、URL 解码产物
|
|
544
|
+
// 等来源天然不可见:fr-012 的 `args.file_path`(← params.arguments)与
|
|
545
|
+
// fr-015 的 `font.path`(← URL 解码)都因此 MISS。往 URL_PARAM_NAME 里加
|
|
546
|
+
// 名字解决不了——那只是在已经认错了根的前提下扩充变量名清单(打地鼠)。
|
|
547
|
+
//
|
|
548
|
+
// 本表改为按**传输面**声明根:判定依据是值的来源性质,而不是变量叫什么。
|
|
549
|
+
// 每条根必须窄且可解释,新增需附 `why`(closes #taint-pilot)。
|
|
550
|
+
// ═══════════════════════════════════════════════════════════════
|
|
551
|
+
const UNTRUSTED_ROOTS = [
|
|
552
|
+
{
|
|
553
|
+
id: "http_request",
|
|
554
|
+
expr: String.raw `\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?`,
|
|
555
|
+
why: "HTTP 请求对象字段,Express/Koa/Fastify/Nest 等通用形态",
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
id: "mcp_tool_args",
|
|
559
|
+
expr: String.raw `\bparams\.arguments\b[.[]?`,
|
|
560
|
+
why: "MCP CallToolRequest.params.arguments —— 工具调用实参,调用方可控(fr-012)",
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
/** 所有根的来源片段,供 **无标志** 的布尔探测正则使用 */
|
|
564
|
+
const UNTRUSTED_ROOT_SRC = UNTRUSTED_ROOTS.map((r) => `(?:${r.expr})`).join("|");
|
|
565
|
+
/** 既有的 request 根语义,改由根表统一提供 */
|
|
566
|
+
function hasRequestRootedExpr(text) {
|
|
567
|
+
return new RegExp(UNTRUSTED_ROOT_SRC).test(text);
|
|
568
|
+
}
|
|
388
569
|
function tsSinkCallRegex() {
|
|
389
570
|
return new RegExp(`(?:^|[^\\w.$])(?:await\\s+)?(?:[\\w$]+\\.)?(${TS_FILE_SINK_NAMES.join("|")})\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
|
|
390
571
|
}
|
|
391
|
-
/**
|
|
392
|
-
function hasRequestRootedExpr(text) {
|
|
393
|
-
return /\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.test(text);
|
|
394
|
-
}
|
|
395
|
-
/** 函数体内被 request 污染的局部名(含解构与单跳赋值,深度 ≤2) */
|
|
572
|
+
/** 函数体内被不可信根污染的局部名(含解构与单跳赋值,深度 ≤2) */
|
|
396
573
|
function collectTaintedNames(text) {
|
|
397
574
|
const tainted = new Set();
|
|
398
575
|
let m;
|
|
399
|
-
const
|
|
576
|
+
const root = UNTRUSTED_ROOT_SRC;
|
|
577
|
+
const direct = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
|
|
400
578
|
while ((m = direct.exec(text)) !== null)
|
|
401
579
|
tainted.add(m[1]);
|
|
402
|
-
const destr =
|
|
580
|
+
const destr = new RegExp(`(?:const|let|var)\\s*\\{\\s*([^}=]*?)\\s*\\}\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
|
|
403
581
|
while ((m = destr.exec(text)) !== null) {
|
|
404
582
|
for (const part of m[1].split(",")) {
|
|
405
583
|
const name = part.trim().split(":")[0].trim();
|
|
@@ -407,7 +585,7 @@ function collectTaintedNames(text) {
|
|
|
407
585
|
tainted.add(name);
|
|
408
586
|
}
|
|
409
587
|
}
|
|
410
|
-
const bareAssign =
|
|
588
|
+
const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
|
|
411
589
|
while ((m = bareAssign.exec(text)) !== null)
|
|
412
590
|
tainted.add(m[1]);
|
|
413
591
|
// 单跳传播(深度 ≤2)
|
|
@@ -429,9 +607,7 @@ function collectTaintedNames(text) {
|
|
|
429
607
|
return tainted;
|
|
430
608
|
}
|
|
431
609
|
function taintPattern(tainted) {
|
|
432
|
-
const parts = [
|
|
433
|
-
/\b(?:req|request)\.(?:params|query|body|headers|cookies)\b[.[]?/.source,
|
|
434
|
-
];
|
|
610
|
+
const parts = [UNTRUSTED_ROOT_SRC];
|
|
435
611
|
if (tainted.size > 0) {
|
|
436
612
|
parts.push(`\\b(?:${[...tainted].join("|")})\\b`);
|
|
437
613
|
}
|
|
@@ -474,6 +650,27 @@ function splitArgWindow(win) {
|
|
|
474
650
|
/** 项目方法名 → 方法体内流入文件 sink 的形参下标集合 */
|
|
475
651
|
function methodSinkParamMap(project, absRoot) {
|
|
476
652
|
const map = new Map();
|
|
653
|
+
const register = (keyName, fullName, text, relPath, params) => {
|
|
654
|
+
const sinkRe = tsSinkCallRegex();
|
|
655
|
+
let sm;
|
|
656
|
+
while ((sm = sinkRe.exec(text)) !== null) {
|
|
657
|
+
const win = sm[2] || "";
|
|
658
|
+
params.forEach((pname, idx) => {
|
|
659
|
+
if (!pname)
|
|
660
|
+
return;
|
|
661
|
+
if (!new RegExp(`\\b${pname}\\b`).test(win))
|
|
662
|
+
return;
|
|
663
|
+
if (!map.has(keyName)) {
|
|
664
|
+
map.set(keyName, { idxs: new Set(), entries: [] });
|
|
665
|
+
}
|
|
666
|
+
const rec = map.get(keyName);
|
|
667
|
+
rec.idxs.add(idx);
|
|
668
|
+
if (!rec.entries.some((e) => e.name === fullName)) {
|
|
669
|
+
rec.entries.push({ name: fullName, file: relPath });
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
};
|
|
477
674
|
for (const sf of project.getSourceFiles()) {
|
|
478
675
|
const relPath = path.relative(absRoot, sf.getFilePath());
|
|
479
676
|
for (const cls of sf.getClasses()) {
|
|
@@ -484,27 +681,23 @@ function methodSinkParamMap(project, absRoot) {
|
|
|
484
681
|
const params = m.getParameters().map((p) => p.getName());
|
|
485
682
|
if (params.length === 0)
|
|
486
683
|
continue;
|
|
487
|
-
|
|
488
|
-
const sinkRe = tsSinkCallRegex();
|
|
489
|
-
let sm;
|
|
490
|
-
while ((sm = sinkRe.exec(text)) !== null) {
|
|
491
|
-
const win = sm[2] || "";
|
|
492
|
-
params.forEach((pname, idx) => {
|
|
493
|
-
if (new RegExp(`\\b${pname}\\b`).test(win)) {
|
|
494
|
-
if (!map.has(m.getName())) {
|
|
495
|
-
map.set(m.getName(), { idxs: new Set(), entries: [] });
|
|
496
|
-
}
|
|
497
|
-
const rec = map.get(m.getName());
|
|
498
|
-
rec.idxs.add(idx);
|
|
499
|
-
const fullName = `${cn}.${m.getName()}`;
|
|
500
|
-
if (!rec.entries.some((e) => e.name === fullName)) {
|
|
501
|
-
rec.entries.push({ name: fullName, file: relPath });
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
});
|
|
505
|
-
}
|
|
684
|
+
register(m.getName(), `${cn}.${m.getName()}`, m.getText(), relPath, params);
|
|
506
685
|
}
|
|
507
686
|
}
|
|
687
|
+
// ── 污点数据流试点(2026-09-18)──
|
|
688
|
+
// 此前只登记**类方法**。真实 TS 工程大量使用顶层函数声明(MCP handler、
|
|
689
|
+
// Nuxt runtime binding、工具函数),跨函数一跳对它们完全不生效——fr-012 的
|
|
690
|
+
// `markdownUpload` 正是顶层函数,因此从未进入本表,即使上游 taint 已识别,
|
|
691
|
+
// 也无法传导到 sink。这里按同样的「形参 → 文件 sink」规则纳入函数声明。
|
|
692
|
+
for (const fn of sf.getFunctions()) {
|
|
693
|
+
const fnName = fn.getName();
|
|
694
|
+
if (!fnName)
|
|
695
|
+
continue;
|
|
696
|
+
const params = fn.getParameters().map((p) => p.getName());
|
|
697
|
+
if (params.length === 0)
|
|
698
|
+
continue;
|
|
699
|
+
register(fnName, fnName, fn.getText(), relPath, params);
|
|
700
|
+
}
|
|
508
701
|
}
|
|
509
702
|
return map;
|
|
510
703
|
}
|
|
@@ -541,29 +734,50 @@ function collectUrlParamNames(params) {
|
|
|
541
734
|
* 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
|
|
542
735
|
* - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
|
|
543
736
|
*/
|
|
544
|
-
function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
|
|
737
|
+
function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns) {
|
|
545
738
|
const markers = [];
|
|
546
739
|
// ── 路径穿越 ──
|
|
740
|
+
// G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
|
|
741
|
+
// 与 SSRF 侧「无 SSRF_GUARD_EVIDENCE 才标记」对齐。
|
|
742
|
+
// 未传 guardFns(旧调用点/单测)时按「不做校验识别」的旧语义处理。
|
|
547
743
|
if (hasRequestRootedExpr(text)) {
|
|
548
744
|
const tainted = collectTaintedNames(text);
|
|
549
|
-
|
|
745
|
+
const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
|
|
746
|
+
// C5(2026-09-19):不可信根可以直接写在 sink 实参里,不需要中间变量。
|
|
747
|
+
// 此前外层要求 collectTaintedNames 非空,于是
|
|
748
|
+
// `fs.readFileSync("/data/" + req.params.name)` —— 真实工程里最常见的形态
|
|
749
|
+
// 不标记;只有写成 `const n = req.params.name; readFileSync("/data/" + n)` 才标。
|
|
750
|
+
// SSRF 侧从来不是这样(它把 UNTRUSTED_ROOT_SRC 并进 taint 模式去匹配实参窗口),
|
|
751
|
+
// 这是同一条数据流上的又一处口径不一致。
|
|
752
|
+
if (!selfGuarded) {
|
|
550
753
|
if (hasTaintedSinkCall(text, tainted)) {
|
|
551
754
|
markers.push("__progmune_path_traversal__");
|
|
552
755
|
}
|
|
553
756
|
else {
|
|
554
757
|
const methodCallRe = new RegExp(`\\.([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
|
|
758
|
+
// ── 污点数据流试点(2026-09-18)──
|
|
759
|
+
// 此前只识别成员调用(`obj.sink(...)`)。顶层/模块内函数是真实 TS 工程
|
|
760
|
+
// 的主要形态(fr-012 的 `markdownUpload(...)` 就是裸调用),缺这一则
|
|
761
|
+
// 即使该函数的形参已登记为 sink 形参,taint 也无法传导过去。
|
|
762
|
+
const directCallRe = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
|
|
555
763
|
const taint = taintPattern(tainted);
|
|
556
764
|
if (taint) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
765
|
+
for (const callRe of [methodCallRe, directCallRe]) {
|
|
766
|
+
callRe.lastIndex = 0;
|
|
767
|
+
let m;
|
|
768
|
+
while ((m = callRe.exec(text)) !== null) {
|
|
769
|
+
const rec = sinkParams.get(m[1]);
|
|
770
|
+
if (!rec || rec.idxs.size === 0)
|
|
771
|
+
continue;
|
|
772
|
+
// G1:被调用方自身已校验 ⇒ 这条跨函数流已被拦截,不标记
|
|
773
|
+
if (guardFns && guardFns.has(m[1]))
|
|
774
|
+
continue;
|
|
775
|
+
const args = splitArgWindow(m[2] || "");
|
|
776
|
+
const hit = args.some((arg, i) => rec.idxs.has(i) && taint.test(arg));
|
|
777
|
+
if (hit) {
|
|
778
|
+
markers.push("__progmune_path_traversal__");
|
|
779
|
+
onMethodHit(rec);
|
|
780
|
+
}
|
|
567
781
|
}
|
|
568
782
|
}
|
|
569
783
|
}
|
|
@@ -580,7 +794,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit) {
|
|
|
580
794
|
taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
|
|
581
795
|
if (reqTainted.size > 0)
|
|
582
796
|
taintParts.push(`\\b(?:${[...reqTainted].join("|")})\\b`);
|
|
583
|
-
taintParts.push(
|
|
797
|
+
taintParts.push(UNTRUSTED_ROOT_SRC);
|
|
584
798
|
const taint = new RegExp(taintParts.join("|"));
|
|
585
799
|
const fetchSinkIter = tsHttpFetchSinkIter();
|
|
586
800
|
let m;
|
|
@@ -676,6 +890,8 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
676
890
|
// sinkParams = 项目方法名 → 方法体内流入文件 sink 的形参下标 + 方法条目;
|
|
677
891
|
// pendingMethodMarks = 跨函数命中待标记的方法条目(主循环后统一应用)
|
|
678
892
|
const sinkParams = methodSinkParamMap(project, absRoot);
|
|
893
|
+
// G1(2026-09-19):路径校验识别。自身含校验证据的函数名 + 向调用方传播一跳。
|
|
894
|
+
const guardFns = pathGuardFunctionNames(project);
|
|
679
895
|
const pendingMethodMarks = new Set();
|
|
680
896
|
const onMethodHit = (rec) => {
|
|
681
897
|
for (const me of rec.entries)
|
|
@@ -693,7 +909,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
693
909
|
const fParams = f.getParameters();
|
|
694
910
|
const fText = f.getText();
|
|
695
911
|
const fCalls = extractDirectCalls(f, fText);
|
|
696
|
-
fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit));
|
|
912
|
+
fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
|
|
697
913
|
funcs.push({
|
|
698
914
|
name,
|
|
699
915
|
params: fParams.map(p => ({
|
|
@@ -723,7 +939,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
723
939
|
const initParams = init.getParameters();
|
|
724
940
|
const initText = init.getText();
|
|
725
941
|
const initCalls = extractDirectCalls(init, initText);
|
|
726
|
-
initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit));
|
|
942
|
+
initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
|
|
727
943
|
funcs.push({
|
|
728
944
|
name,
|
|
729
945
|
params: initParams.map(p => ({
|
|
@@ -749,7 +965,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
749
965
|
const argParams = arg.getParameters();
|
|
750
966
|
const argText = arg.getText();
|
|
751
967
|
const argCalls = extractDirectCalls(arg, argText);
|
|
752
|
-
argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit));
|
|
968
|
+
argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns));
|
|
753
969
|
funcs.push({
|
|
754
970
|
name,
|
|
755
971
|
params: argParams.map(p => ({
|
|
@@ -783,7 +999,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
783
999
|
continue;
|
|
784
1000
|
const mParams = m.getParameters();
|
|
785
1001
|
const mText = m.getText();
|
|
786
|
-
const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit);
|
|
1002
|
+
const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns);
|
|
787
1003
|
funcs.push({
|
|
788
1004
|
name: `${cn}.${mn}`,
|
|
789
1005
|
params: mParams.map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
exports.inspectIrFreshness = inspectIrFreshness;
|
|
37
|
+
exports.reextractMode = reextractMode;
|
|
38
|
+
exports.reextractEnabled = reextractEnabled;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
/**
|
|
42
|
+
* ir.json 陈旧性判定(2026-09-19,独立于污点试点立项)。
|
|
43
|
+
*
|
|
44
|
+
* 背景(真实事故,两次会话级误判):
|
|
45
|
+
* - trust 引擎的自动提取是「缺才提取」:项目已有 ir.json 就以文件为准、不重生成。
|
|
46
|
+
* - 该语义在【源码变了但 ir.json 没变】的场景下静默过期——
|
|
47
|
+
* ① 分析器读的是旧调用序列(fix-regression 复测结论可能是哑值);
|
|
48
|
+
* ② P4.5 注解合并读同一个 ir.json,旧文件让新加的 @protocol 注解不生效。
|
|
49
|
+
* - fr-005 首扫报 0(`rm ir.json` 后才报出)、污点试点 V1 的「标记注入但 trust 报 0」
|
|
50
|
+
* 根因都是同一处。
|
|
51
|
+
*
|
|
52
|
+
* 政策:mtime 比对,不做「每次全量重提」。
|
|
53
|
+
* - 全量重提对 skyvern 级项目是 5–10 分钟量级成本,不能默认付;
|
|
54
|
+
* - 只在【存在源文件 mtime > ir.json mtime】时才重提,常量级开销(一次 stat 遍历)。
|
|
55
|
+
* 政策:mtime 比对,不做「每次全量重提」。
|
|
56
|
+
* - 全量重提对 skyvern 级项目是 5–10 分钟量级成本,不能默认付;对本仓库
|
|
57
|
+
* (22k 源文件)足以触发单测 30s 超时——实测教训,见 tests/trust/engine.test.ts。
|
|
58
|
+
* - 只在【存在源文件 mtime > ir.json mtime】时才重提(一次 stat 遍历,恒定成本)。
|
|
59
|
+
* - 超大仓库(遍历被 MAX_STATS 截断)默认【只警告不重提】:此时 freshness 证据
|
|
60
|
+
* 不完整,自动重提会把 CLI 拖到分钟级。确需重提时显式 PROGMUNE_IR_REEXTRACT=always。
|
|
61
|
+
*
|
|
62
|
+
* 策略阀 `PROGMUNE_IR_REEXTRACT`:
|
|
63
|
+
* - 未设(auto):遍历完整且陈旧 → 重提;遍历被截断 → 只警告
|
|
64
|
+
* - always :只要陈旧就重提(自担成本,用于批量语料复测)
|
|
65
|
+
* - never :回到旧语义(永不重提),但会打「IR 可能陈旧」警告——不允许静默。
|
|
66
|
+
*/
|
|
67
|
+
/** 会被提取器读取的源码后缀(跨语言;不在表内的文件不影响 IR 内容)。 */
|
|
68
|
+
const SOURCE_EXT = new Set([
|
|
69
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
70
|
+
".py", ".c", ".h", ".cc", ".cpp", ".hpp",
|
|
71
|
+
".go", ".java", ".rs", ".rb", ".php", ".cs", ".swift", ".kt", ".scala",
|
|
72
|
+
]);
|
|
73
|
+
/** 不可能是业务源码、且体量足以毁掉遍历成本的目录。 */
|
|
74
|
+
const SKIP_DIRS = new Set([
|
|
75
|
+
"node_modules", ".git", "dist", "build", "out", "coverage", ".next",
|
|
76
|
+
".nuxt", ".turbo", ".cache", "__pycache__", ".venv", "venv", "vendor",
|
|
77
|
+
"target", "bin", "obj", ".gradle", ".idea", ".vscode", ".workbuddy",
|
|
78
|
+
]);
|
|
79
|
+
/** 防御性上限:超大仓库不做无界遍历(宁可漏判也不卡死)。 */
|
|
80
|
+
const MAX_STATS = 20000;
|
|
81
|
+
const MAX_DEPTH = 12;
|
|
82
|
+
/**
|
|
83
|
+
* auto 模式下愿意为「一次 IR 重提」买单的项目规模上限(源码文件数)。
|
|
84
|
+
* 超过则只警告不自动重提——重提成本随文件数超线性增长,本仓库(万级源文件)
|
|
85
|
+
* 实测足以把单测推过 30s 超时。
|
|
86
|
+
*/
|
|
87
|
+
const AUTO_REEXTRACT_FILE_BUDGET = 5000;
|
|
88
|
+
/**
|
|
89
|
+
* 扫描项目目录,找出最新修改的源码文件。
|
|
90
|
+
*
|
|
91
|
+
* 必须【完整遍历】(不做「找到第一个更新的就早退」):早退会在超大仓库上
|
|
92
|
+
* 把 truncated 信号抹掉——本仓库(22k 源文件)就是活例:早退后 evidenceComplete
|
|
93
|
+
* 误为真 → 自动全量重提 → 单测 30s 超时。stat 扫一遍的成本远低于一次 IR 提取,
|
|
94
|
+
* 换来的是可靠的 fresh 判定与可靠的大仓信号。
|
|
95
|
+
*
|
|
96
|
+
* @requires PROJECT_PATH @produces NEWEST_SOURCE
|
|
97
|
+
* @tags ir, staleness, mtime
|
|
98
|
+
*/
|
|
99
|
+
function newestSourceUnder(root, opts) {
|
|
100
|
+
const maxStats = opts.maxStats ?? MAX_STATS;
|
|
101
|
+
const maxDepth = opts.maxDepth ?? MAX_DEPTH;
|
|
102
|
+
let newestMs = null;
|
|
103
|
+
let newestFile = null;
|
|
104
|
+
let scanned = 0;
|
|
105
|
+
let truncated = false;
|
|
106
|
+
const walk = (dir, depth) => {
|
|
107
|
+
if (depth > maxDepth || truncated)
|
|
108
|
+
return;
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return; // 权限 / 竞态删除:跳过
|
|
115
|
+
}
|
|
116
|
+
for (const ent of entries) {
|
|
117
|
+
if (truncated)
|
|
118
|
+
return;
|
|
119
|
+
if (ent.isDirectory()) {
|
|
120
|
+
if (SKIP_DIRS.has(ent.name))
|
|
121
|
+
continue;
|
|
122
|
+
if (ent.name.startsWith("."))
|
|
123
|
+
continue; // 隐藏目录多为工具产物
|
|
124
|
+
walk(path.join(dir, ent.name), depth + 1);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!ent.isFile())
|
|
128
|
+
continue;
|
|
129
|
+
if (!SOURCE_EXT.has(path.extname(ent.name).toLowerCase()))
|
|
130
|
+
continue;
|
|
131
|
+
if (scanned >= maxStats) {
|
|
132
|
+
truncated = true;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const full = path.join(dir, ent.name);
|
|
136
|
+
let st;
|
|
137
|
+
try {
|
|
138
|
+
st = fs.statSync(full, { throwIfNoEntry: false }) ?? undefined;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
st = undefined;
|
|
142
|
+
}
|
|
143
|
+
if (!st)
|
|
144
|
+
continue;
|
|
145
|
+
scanned++;
|
|
146
|
+
if (newestMs === null || st.mtimeMs > newestMs) {
|
|
147
|
+
newestMs = st.mtimeMs;
|
|
148
|
+
newestFile = full;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
walk(root, 0);
|
|
153
|
+
return { mtimeMs: newestMs, file: newestFile, scannedFiles: scanned, truncated };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 判定 ir.json 是否需要重新生成。
|
|
157
|
+
*
|
|
158
|
+
* - ir.json 不存在 → missing(需提取)
|
|
159
|
+
* - 存在源码 mtime > ir.json mtime → source-newer(需重提)
|
|
160
|
+
* - 其余 → fresh / no-sources(沿用磁盘文件)
|
|
161
|
+
*
|
|
162
|
+
* @requires PROJECT_PATH @produces IR_FRESHNESS
|
|
163
|
+
* @tags ir, staleness, mtime
|
|
164
|
+
* @useWhen evaluateTrust 决定是否自动提取 IR
|
|
165
|
+
*/
|
|
166
|
+
function inspectIrFreshness(projectPath, irPathOverride, opts = {}) {
|
|
167
|
+
const irPath = irPathOverride || path.join(projectPath, "ir.json");
|
|
168
|
+
let irStat;
|
|
169
|
+
try {
|
|
170
|
+
irStat = fs.statSync(irPath, { throwIfNoEntry: false }) ?? undefined;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
irStat = undefined;
|
|
174
|
+
}
|
|
175
|
+
const irMtimeMs = irStat ? irStat.mtimeMs : null;
|
|
176
|
+
const src = newestSourceUnder(projectPath, opts);
|
|
177
|
+
const exceedsAutoBudget = src.scannedFiles > (opts.autoBudget ?? AUTO_REEXTRACT_FILE_BUDGET);
|
|
178
|
+
if (!irStat) {
|
|
179
|
+
return {
|
|
180
|
+
exists: false,
|
|
181
|
+
stale: true,
|
|
182
|
+
reason: "missing",
|
|
183
|
+
irMtimeMs: null,
|
|
184
|
+
newestSourceMtimeMs: src.mtimeMs,
|
|
185
|
+
newestSourcePath: src.file,
|
|
186
|
+
scannedFiles: src.scannedFiles,
|
|
187
|
+
truncated: src.truncated,
|
|
188
|
+
evidenceComplete: false, // 是否有证据都不影响:缺就必须提
|
|
189
|
+
exceedsAutoBudget,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (src.mtimeMs === null) {
|
|
193
|
+
// 扫不到源码(目标目录不是真源码根,或全是跳过后缀):无证据 → 保守沿用。
|
|
194
|
+
return {
|
|
195
|
+
exists: true,
|
|
196
|
+
stale: false,
|
|
197
|
+
reason: "no-sources",
|
|
198
|
+
irMtimeMs,
|
|
199
|
+
newestSourceMtimeMs: null,
|
|
200
|
+
newestSourcePath: null,
|
|
201
|
+
scannedFiles: 0,
|
|
202
|
+
truncated: src.truncated,
|
|
203
|
+
evidenceComplete: !src.truncated,
|
|
204
|
+
exceedsAutoBudget,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// 严格大于:ir.json 与源码同刻写出(写源码→随后写 ir.json 的常见顺序)
|
|
208
|
+
// 判为 fresh,避免把正常管线推入全量重提。
|
|
209
|
+
const stale = irMtimeMs !== null && src.mtimeMs > irMtimeMs;
|
|
210
|
+
return {
|
|
211
|
+
exists: true,
|
|
212
|
+
stale,
|
|
213
|
+
reason: stale ? "source-newer" : "fresh",
|
|
214
|
+
irMtimeMs,
|
|
215
|
+
newestSourceMtimeMs: src.mtimeMs,
|
|
216
|
+
newestSourcePath: src.file,
|
|
217
|
+
scannedFiles: src.scannedFiles,
|
|
218
|
+
truncated: src.truncated,
|
|
219
|
+
evidenceComplete: !src.truncated,
|
|
220
|
+
exceedsAutoBudget,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function reextractMode() {
|
|
224
|
+
const v = (process.env.PROGMUNE_IR_REEXTRACT || "").toLowerCase();
|
|
225
|
+
if (v === "always")
|
|
226
|
+
return "always";
|
|
227
|
+
if (v === "never" || v === "off" || v === "0" || v === "false")
|
|
228
|
+
return "never";
|
|
229
|
+
return "auto";
|
|
230
|
+
}
|
|
231
|
+
function reextractEnabled() {
|
|
232
|
+
return reextractMode() !== "never";
|
|
233
|
+
}
|