progmune-runtime 3.7.37 → 3.7.41
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 +249 -0
- package/dist/extract-ir-taint-guard.test.js +53 -0
- package/dist/extract-ir-taint-structural.test.js +874 -3
- package/dist/extract-ir.js +441 -27
- package/dist/sdk.js +1 -1
- package/package.json +1 -1
package/dist/extract-ir.js
CHANGED
|
@@ -622,6 +622,237 @@ function collectSanitizedExprs(text, directGuardFns) {
|
|
|
622
622
|
// 但 `args.name` 能匹配 `path.join(DIR, args.name)` 里的 args.name。
|
|
623
623
|
return new RegExp([...exprs].map((e) => `(?:^|[^\\w$.])${escapeRe(e)}\\b`).join("|"));
|
|
624
624
|
}
|
|
625
|
+
// ═══════════════════════════════════════════════════════════════
|
|
626
|
+
// C4b(2026-09-20):项目自有的「纯塑形」helper —— 补齐 C4 的最后一跳
|
|
627
|
+
//
|
|
628
|
+
// 缺口形态(fr-016 真实语料,iterateAsyncApiComponents / iterateComponents):
|
|
629
|
+
// const filename = getFileNamePath(componentDirPath, componentName, ext);
|
|
630
|
+
// writeToFileByExtension(componentData, filename); // sink 侧已继承(fr-016)
|
|
631
|
+
// // getFileNamePath(a, b, c) { return path.join(a, b) + `.${c}`; }
|
|
632
|
+
// `componentName` 已被 Object.keys 污染,但 `getFileNamePath` 不在
|
|
633
|
+
// TS_PATH_SHAPER_RE 里(那是 node:path 家族 + String 原型方法的固定清单),
|
|
634
|
+
// 于是 `filename` 收不到污点 —— 整条流断在最后一跳。
|
|
635
|
+
//
|
|
636
|
+
// 判据(三条同时成立才认,宁可漏、不可错):
|
|
637
|
+
// ① 有形参,且有带表达式的 return(void / 只写文件的函数不算)
|
|
638
|
+
// ② 函数体内没有文件 sink —— 含 sink 说明它不只是塑形
|
|
639
|
+
// ③ 每个 return 表达式里:所有调用都是塑形调用(node:path 家族 / 保值字符串
|
|
640
|
+
// 方法 / 已认定的纯塑形 helper),且所有自由标识符都是自己的形参
|
|
641
|
+
// ③ 的后半句是精度核心:return 里只要出现任何**不是形参**的自由标识符
|
|
642
|
+
// (模块常量、闭包变量、别的调用结果),就不认。
|
|
643
|
+
//
|
|
644
|
+
// 这与 C4 是同一条原则(白名单优于黑名单):只对**能证明**的形态传播。
|
|
645
|
+
// 认不出来的 helper 不传播 —— 那是可见的漏报,比不可见的误报便宜(R9)。
|
|
646
|
+
// 有界:两轮不动点,允许 helper 调用已认定的 helper,但不追环。
|
|
647
|
+
// ═══════════════════════════════════════════════════════════════
|
|
648
|
+
/** node:path 家族里只做塑形、不做校验的方法名 */
|
|
649
|
+
const PATH_SHAPER_METHODS = new Set([
|
|
650
|
+
"join", "resolve", "normalize", "basename", "dirname",
|
|
651
|
+
"extname", "relative", "format", "toNamespacedPath",
|
|
652
|
+
]);
|
|
653
|
+
/**
|
|
654
|
+
* 字符过滤方法 —— **故意不进** C4b 的证据集。
|
|
655
|
+
*
|
|
656
|
+
* C4 的内联规则里 `.replace/.trim/...` 是算塑形的(TS_PATH_SHAPER_RE 第二组),
|
|
657
|
+
* 但那是「参数窗口里看得见整个表达式」时的取舍。helper 形式看不见实参窗口,
|
|
658
|
+
* 判据就该更严:`.replace(/[^a-z0-9]/gi, "")` 恰恰是净化函数最常见的写法,
|
|
659
|
+
* 把字符过滤当成「确定不净化」的证据,正是 C4 当初用白名单避开的那类错误
|
|
660
|
+
* (`readSanitized` 用例锁的就是它)。
|
|
661
|
+
* 代价:`toSlug(p) { return p.trim().toLowerCase(); }` 仍不传播(已知缺口)。
|
|
662
|
+
*/
|
|
663
|
+
const VALUE_STRING_METHODS = new Set([
|
|
664
|
+
"trim", "trimStart", "trimEnd", "replace", "replaceAll",
|
|
665
|
+
"toLowerCase", "toUpperCase", "toString", "slice", "substring",
|
|
666
|
+
"substr", "padStart", "padEnd", "concat", "normalize",
|
|
667
|
+
]);
|
|
668
|
+
/** return 表达式里允许出现的非形参自由标识符(语言/模块级常量) */
|
|
669
|
+
const SHAPER_ALLOWED_FREE_IDENTS = new Set([
|
|
670
|
+
"path", "sep", "undefined", "null", "true", "false", "String", "Number",
|
|
671
|
+
]);
|
|
672
|
+
/** 允许出现在调用位的值转换函数(不改变路径语义) */
|
|
673
|
+
const VALUE_CONVERTER_CALLS = new Set(["String", "Number", "Boolean"]);
|
|
674
|
+
/**
|
|
675
|
+
* 判断 return 表达式是不是「纯塑形」:
|
|
676
|
+
* 所有调用都在塑形白名单内,且所有自由标识符都是形参(或模块级字面量常量)。
|
|
677
|
+
*
|
|
678
|
+
* 实现上刻意做**单趟标识符扫描**(看每个标识符的紧邻字符),而不是
|
|
679
|
+
* `NAME(?!\s*\()` 这种前瞻 —— 前瞻会被正则回溯绕过:
|
|
680
|
+
* 对 `withExt(...)`,`([A-Za-z_$][\w$]*)(?!\s*\()` 可以退化成匹配 `withEx`
|
|
681
|
+
* 让前瞻通过,于是把调用名误判成自由标识符(2026-09-20 实测踩到)。
|
|
682
|
+
*/
|
|
683
|
+
function isPureShaperExpr(expr, params, shapers, extraIdents) {
|
|
684
|
+
// 先抹掉字符串与模板字面量:里面的 `${...}` 可能带花括号,会干扰后续扫描;
|
|
685
|
+
// 抹掉后如果表达式里还有别的自由标识符,仍会被下面的检查拦住。
|
|
686
|
+
const s = expr
|
|
687
|
+
.replace(/`(?:\\.|\$\{[^}]*\}|[^`\\])*`/g, '""')
|
|
688
|
+
.replace(/'(?:\\.|[^'\\])*'/g, '""')
|
|
689
|
+
.replace(/"(?:\\.|[^"\\])*"/g, '""');
|
|
690
|
+
const idRe = /[A-Za-z_$][\w$]*/g;
|
|
691
|
+
let m;
|
|
692
|
+
while ((m = idRe.exec(s)) !== null) {
|
|
693
|
+
const n = m[0];
|
|
694
|
+
// 紧前一个非空字符 / 紧后一个非空字符
|
|
695
|
+
let i = m.index - 1;
|
|
696
|
+
while (i >= 0 && /\s/.test(s[i]))
|
|
697
|
+
i--;
|
|
698
|
+
const before = i >= 0 ? s[i] : "";
|
|
699
|
+
let j = m.index + n.length;
|
|
700
|
+
while (j < s.length && /\s/.test(s[j]))
|
|
701
|
+
j++;
|
|
702
|
+
const after = j < s.length ? s[j] : "";
|
|
703
|
+
if (before === ".")
|
|
704
|
+
continue; // 成员名(`.join` / `.name`),不参与判定
|
|
705
|
+
if (after === "(") {
|
|
706
|
+
// 调用位:必须落在塑形白名单。字符过滤方法(`VALUE_STRING_METHODS`)
|
|
707
|
+
// **不算**证据 —— 见其定义处注释。
|
|
708
|
+
if (!PATH_SHAPER_METHODS.has(n) && !VALUE_CONVERTER_CALLS.has(n) && !shapers.has(n)) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
// 自由标识符:必须是自己的形参、语言级常量白名单,或**已确证的字面量常量 / path 别名**
|
|
714
|
+
if (params.has(n) || SHAPER_ALLOWED_FREE_IDENTS.has(n))
|
|
715
|
+
continue;
|
|
716
|
+
if (extraIdents && extraIdents.has(n))
|
|
717
|
+
continue;
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
/** 收集模块级(top-level)**字面量**常量名 —— helper 的 return 引用它们仍是纯塑形 */
|
|
723
|
+
function moduleLiteralConstNames(project) {
|
|
724
|
+
const out = new Set();
|
|
725
|
+
for (const sf of project.getSourceFiles()) {
|
|
726
|
+
for (const vd of sf.getVariableDeclarations()) {
|
|
727
|
+
// 只要模块作用域:VariableStatement 的父节点必须是 SourceFile
|
|
728
|
+
const stmt = vd.getVariableStatement();
|
|
729
|
+
if (!stmt || !ts_morph_1.Node.isSourceFile(stmt.getParent()))
|
|
730
|
+
continue;
|
|
731
|
+
const init = vd.getInitializer();
|
|
732
|
+
if (!init)
|
|
733
|
+
continue;
|
|
734
|
+
// 只收「字面量」—— 一旦允许任意表达式,helper 就能把别处的污点藏在常量后面
|
|
735
|
+
// (C4e 政策:看不见的一律不传播)
|
|
736
|
+
const isLiteral = ts_morph_1.Node.isStringLiteral(init) ||
|
|
737
|
+
ts_morph_1.Node.isNoSubstitutionTemplateLiteral(init) ||
|
|
738
|
+
ts_morph_1.Node.isNumericLiteral(init) ||
|
|
739
|
+
init.getKind() === ts.SyntaxKind.TrueKeyword ||
|
|
740
|
+
init.getKind() === ts.SyntaxKind.FalseKeyword;
|
|
741
|
+
if (isLiteral)
|
|
742
|
+
out.add(vd.getName());
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return out;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* C4e:`import * as p from "path"` / `import nodePath from "node:path"` /
|
|
749
|
+
* `import { join } from "path"` —— 这些局部名随项目各异,但**出处可确证**是
|
|
750
|
+
* path 模块,helper 的 return 引用它们仍是纯塑形。
|
|
751
|
+
* 只放行 path 模块本身(含 node: 前缀),不放行 fs/os —— 后者会被 helper 用来把
|
|
752
|
+
* IO 藏进 return(判据②只扫 sink 调用,覆盖不到「返回内嵌读写」这一类)。
|
|
753
|
+
*/
|
|
754
|
+
function pathModuleAliases(project) {
|
|
755
|
+
const out = new Set(["path"]);
|
|
756
|
+
for (const sf of project.getSourceFiles()) {
|
|
757
|
+
for (const id of sf.getImportDeclarations()) {
|
|
758
|
+
const spec = id.getModuleSpecifierValue();
|
|
759
|
+
if (spec !== "path" && spec !== "node:path")
|
|
760
|
+
continue;
|
|
761
|
+
// 实测(ts-morph 本仓库版本):NamespaceImport 上既无 getName() 也无
|
|
762
|
+
// getNameNode(),只有 getText() —— 返回的就是别名本身。
|
|
763
|
+
const ns = id.getNamespaceImport(); // `import * as X`
|
|
764
|
+
if (ns)
|
|
765
|
+
out.add(ns.getText().replace(/[^\w$]/g, ""));
|
|
766
|
+
const def = id.getDefaultImport();
|
|
767
|
+
if (def)
|
|
768
|
+
out.add(def.getText());
|
|
769
|
+
for (const nb of id.getNamedImports())
|
|
770
|
+
out.add(nb.getName());
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return out;
|
|
774
|
+
}
|
|
775
|
+
/** return 表达式列表:块体取 return 语句,箭头简洁体把整个体当作返回值 */
|
|
776
|
+
function returnExprsOf(body) {
|
|
777
|
+
if (!ts_morph_1.Node.isBlock(body))
|
|
778
|
+
return [body.getText()]; // 箭头简洁体
|
|
779
|
+
const rets = [];
|
|
780
|
+
for (const rs of body.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement)) {
|
|
781
|
+
const e = rs.getExpression();
|
|
782
|
+
if (e)
|
|
783
|
+
rets.push(e.getText());
|
|
784
|
+
}
|
|
785
|
+
return rets;
|
|
786
|
+
}
|
|
787
|
+
/** 扫描全项目,收集「纯塑形」helper 的名字(有界两轮不动点) */
|
|
788
|
+
function pureShaperFunctionNames(project) {
|
|
789
|
+
const sinkRe = tsSinkCallRegex();
|
|
790
|
+
const cands = [];
|
|
791
|
+
/** 登记一个候选:三条判据 —— 有形参 / 有返回值 / 体内无 sink */
|
|
792
|
+
const consider = (name, paramNodes, declText, bodyNode) => {
|
|
793
|
+
const params = new Set(paramNodes.filter(Boolean));
|
|
794
|
+
if (params.size === 0)
|
|
795
|
+
return;
|
|
796
|
+
sinkRe.lastIndex = 0;
|
|
797
|
+
if (sinkRe.test(declText))
|
|
798
|
+
return; // ②
|
|
799
|
+
// 用 AST 取 return 表达式,而不是正则 —— 模板字面量里的 `${}` 会
|
|
800
|
+
// 让「遇到花括号就停」的朴素正则在 `path.join(a,b) + \`.${c}\`` 上截断。
|
|
801
|
+
const rets = returnExprsOf(bodyNode);
|
|
802
|
+
if (rets.length === 0)
|
|
803
|
+
return; // ①
|
|
804
|
+
cands.push({ name, params, rets });
|
|
805
|
+
};
|
|
806
|
+
for (const sf of project.getSourceFiles()) {
|
|
807
|
+
// (a) 函数声明
|
|
808
|
+
for (const fn of sf.getFunctions()) {
|
|
809
|
+
const name = fn.getName();
|
|
810
|
+
if (!name)
|
|
811
|
+
continue;
|
|
812
|
+
const body = fn.getBody();
|
|
813
|
+
if (!body)
|
|
814
|
+
continue; // 重载签名 / declare 只有签名
|
|
815
|
+
consider(name, fn.getParameters().map((p) => p.getName()), fn.getText(), body);
|
|
816
|
+
}
|
|
817
|
+
// (b) 变量承载的箭头 / 函数表达式 —— 现代 TS 里 helper 的主力写法
|
|
818
|
+
// `export const toPath = (n: string) => n + ".md";`
|
|
819
|
+
// sf.getFunctions() 收不到这些,2026-09-20 实测整片漏。
|
|
820
|
+
for (const vd of sf.getVariableDeclarations()) {
|
|
821
|
+
const init = vd.getInitializer();
|
|
822
|
+
if (!init)
|
|
823
|
+
continue;
|
|
824
|
+
const isArrow = ts_morph_1.Node.isArrowFunction(init);
|
|
825
|
+
const isFnExpr = ts_morph_1.Node.isFunctionExpression(init);
|
|
826
|
+
if (!isArrow && !isFnExpr)
|
|
827
|
+
continue;
|
|
828
|
+
const fnLike = init;
|
|
829
|
+
const body = fnLike.getBody();
|
|
830
|
+
if (!body)
|
|
831
|
+
continue;
|
|
832
|
+
consider(vd.getName(), fnLike.getParameters().map((p) => p.getName()), init.getText(), body);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// 形参之外允许出现的自由标识符 = 模块级字面量常量 ∪ 确证的 path 模块别名
|
|
836
|
+
const extraIdents = moduleLiteralConstNames(project);
|
|
837
|
+
for (const a of pathModuleAliases(project))
|
|
838
|
+
extraIdents.add(a);
|
|
839
|
+
const shapers = new Set();
|
|
840
|
+
for (let round = 0; round < 2; round++) {
|
|
841
|
+
let added = false;
|
|
842
|
+
for (const c of cands) {
|
|
843
|
+
if (shapers.has(c.name))
|
|
844
|
+
continue;
|
|
845
|
+
const ok = c.rets.every((e) => isPureShaperExpr(e, c.params, shapers, extraIdents));
|
|
846
|
+
if (!ok)
|
|
847
|
+
continue;
|
|
848
|
+
shapers.add(c.name);
|
|
849
|
+
added = true;
|
|
850
|
+
}
|
|
851
|
+
if (!added)
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
return shapers;
|
|
855
|
+
}
|
|
625
856
|
const TS_FILE_SINK_NAMES = [
|
|
626
857
|
"readFile", "readFileSync", "writeFile", "writeFileSync",
|
|
627
858
|
"appendFile", "appendFileSync", "unlink", "unlinkSync", "rm", "rmSync",
|
|
@@ -653,6 +884,29 @@ const UNTRUSTED_ROOTS = [
|
|
|
653
884
|
expr: String.raw `\bparams\.arguments\b[.[]?`,
|
|
654
885
|
why: "MCP CallToolRequest.params.arguments —— 工具调用实参,调用方可控(fr-012)",
|
|
655
886
|
},
|
|
887
|
+
{
|
|
888
|
+
id: "document_parse",
|
|
889
|
+
expr: String.raw `\b(?:JSON|JSON5)\s*\.\s*parse\b|\b(?:YAML|yaml)\s*\.\s*(?:parse|load)\b|\b(?:parse|load|read)(?:Yaml|YAML|Json|JSON|Toml|TOML|Xml|XML|Csv|CSV)\s*\(`,
|
|
890
|
+
why: "外部文档的解析产物:值的**内容**由被解析的输入决定,而非由代码写死(fr-016 的 OpenAPI/AsyncAPI 描述文件经 parseYaml 进入后再 split 落盘)",
|
|
891
|
+
},
|
|
892
|
+
// ── 一类性质不同的根(务必读懂再改)──
|
|
893
|
+
// 上面两条是**传输面**:值跨进程边界进来,来源性质无歧义。这一条不是。
|
|
894
|
+
// `Object.keys/values/entries` 是语言运算,声明的是一件更弱的事:
|
|
895
|
+
// 「这些名字/值不是字面量,而是运行时数据结构的产物」。
|
|
896
|
+
// 之所以仍然列为根,理由与取舍:
|
|
897
|
+
// - 根的职责是【如实描述值的来源】,不是做是否有害的价值判断。
|
|
898
|
+
// 键名能否落到 fs sink、以及有没有被校验,那是前缀修剪与守卫证据的事
|
|
899
|
+
// (G1/G2)。压掉来源的代价是不可见的漏报,而留下来的误报是可见的。
|
|
900
|
+
// - 它是 fr-016 唯一本地可观测的入口:文档本体已由**上游**解析好作为形参
|
|
901
|
+
// 传入(`channels: Record<string, any>`),函数体内没有任何解析调用,
|
|
902
|
+
// 看得见的只有这次枚举。
|
|
903
|
+
// 代价:键名来自内部固定字典(如 `for (const l of Object.keys(locales))`)
|
|
904
|
+
// 也会被标记。若工具体现为噪声过大,应优先收窄**这里**,不要在守卫侧放宽。
|
|
905
|
+
{
|
|
906
|
+
id: "runtime_key_enum",
|
|
907
|
+
expr: String.raw `\bObject\s*\.\s*(?:keys|values|entries)\s*\(`,
|
|
908
|
+
why: "运行时枚举产物:键名/键值由数据结构内容决定,不是字面量(fr-016 的 Object.keys(channels) → channelName)",
|
|
909
|
+
},
|
|
656
910
|
];
|
|
657
911
|
/** 所有根的来源片段,供 **无标志** 的布尔探测正则使用 */
|
|
658
912
|
const UNTRUSTED_ROOT_SRC = UNTRUSTED_ROOTS.map((r) => `(?:${r.expr})`).join("|");
|
|
@@ -686,7 +940,7 @@ const TS_PATH_SHAPER_RE = /(?:path\s*\.\s*(?:posix|win32)\s*\.\s*|path\s*\.\s*)?
|
|
|
686
940
|
/** 拼接形态:`+` 或反引号(模板字面量)—— 同样只是塑形 */
|
|
687
941
|
const TS_CONCAT_RE = /\+|`/;
|
|
688
942
|
/** 函数体内被不可信根污染的局部名(含解构、单跳赋值与 C4 塑形传播,深度 ≤3) */
|
|
689
|
-
function collectTaintedNames(text) {
|
|
943
|
+
function collectTaintedNames(text, shapers) {
|
|
690
944
|
const tainted = new Set();
|
|
691
945
|
let m;
|
|
692
946
|
const root = UNTRUSTED_ROOT_SRC;
|
|
@@ -704,21 +958,108 @@ function collectTaintedNames(text) {
|
|
|
704
958
|
const bareAssign = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*=\\s*(?:await\\s+)?(?:${root})`, "g");
|
|
705
959
|
while ((m = bareAssign.exec(text)) !== null)
|
|
706
960
|
tainted.add(m[1]);
|
|
707
|
-
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
let
|
|
715
|
-
while ((
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
961
|
+
// 枚举绑定:`for (const k of Object.keys(doc))` / `for (const [k, v] of Object.entries(doc))`
|
|
962
|
+
// fr-016 的污点就是这样进来的 —— 文档本体已由上游解析好作为形参传入,
|
|
963
|
+
// 函数体内没有赋值语句,只有这次枚举。少了这条,iterate-* 系列函数的
|
|
964
|
+
// 局部变量一个都收不到(此前 MISS 的一半原因在此)。
|
|
965
|
+
const bindFromEnum = (pattern) => {
|
|
966
|
+
const enumBind = new RegExp(`for\\s*\\(\\s*(?:const|let|var)\\s+([\\w$\\[\\],\\s]+?)\\s+(?:of|in)\\s+(?:await\\s+)?(?:${pattern})`, "g");
|
|
967
|
+
let hit = false;
|
|
968
|
+
let em;
|
|
969
|
+
while ((em = enumBind.exec(text)) !== null) {
|
|
970
|
+
const binding = em[1].replace(/[[\]]/g, " ");
|
|
971
|
+
for (const part of binding.split(",")) {
|
|
972
|
+
const name = part.trim().split(":")[0].trim();
|
|
973
|
+
if (/^[\w$]+$/.test(name) && !tainted.has(name)) {
|
|
974
|
+
tainted.add(name);
|
|
975
|
+
hit = true;
|
|
976
|
+
}
|
|
719
977
|
}
|
|
720
978
|
}
|
|
721
|
-
|
|
979
|
+
return hit;
|
|
980
|
+
};
|
|
981
|
+
bindFromEnum(root);
|
|
982
|
+
// ── C4d(2026-09-20):高阶枚举方法的回调形参 ──
|
|
983
|
+
// `Object.keys(doc).forEach(k => …)` / `ks.map(k => …)` 与 for-of 语义等价
|
|
984
|
+
// (形参 = 被枚举到的元素),但此前只绑 for-of/for-in,这类整片漏。
|
|
985
|
+
// 实测(2026-09-20 探针):Object.keys/entries 后接 forEach/map 的 4 种
|
|
986
|
+
// 写法全部 MISS,而 for-in 其实一直是通的 —— 缺口在回调,不在 for-in。
|
|
987
|
+
//
|
|
988
|
+
// 方法表的取舍(务必读懂再扩):只收【枚举全部元素、且形参 = 元素本身】的
|
|
989
|
+
// forEach/map/flatMap/filter 四个。不收:
|
|
990
|
+
// - find/some/every —— 谓词,形参虽是元素但语义是「判定」,且常配合
|
|
991
|
+
// 白名单做守卫,盲目绑进去反而给守卫侧喂误报;
|
|
992
|
+
// - reduce —— 第一个形参是累加器不是元素,语义错位。
|
|
993
|
+
// 认不出就不传播,代价是漏报,不是误报。
|
|
994
|
+
//
|
|
995
|
+
// ── C4d-b(2026-09-20,同日第二次修正)──
|
|
996
|
+
// 初版正则以 `[,)]` 收尾,带来两件事:
|
|
997
|
+
// ① `forEach(handleOne)` —— 回调是【函数引用】而非内联函数,`handleOne` 被
|
|
998
|
+
// 当成形参绑进污点集合。探针 `Object.keys(doc).forEach(handle); … = handle`
|
|
999
|
+
// 实测误标:这是把回调名当元素,语义错误;
|
|
1000
|
+
// ② 只认 `(k) =>` 带括号的箭头,`k =>` / `function (k) {}` 两种常见写法漏。
|
|
1001
|
+
// 改法:分两条 —— 箭头分支形参后必须见 `=>`(括号可有可无),ES5 分支必须见
|
|
1002
|
+
// `function` 关键字。两者都要求【回调体是内联的】,函数引用形态自然被排除。
|
|
1003
|
+
const CALLBACK_ENUM_METHODS = "forEach|map|flatMap|filter";
|
|
1004
|
+
const bindFromCallback = (receiver) => {
|
|
1005
|
+
const callbackRe = (paramsTail) => new RegExp(`${receiver}\\s*\\.\\s*(?:${CALLBACK_ENUM_METHODS})\\s*\\(\\s*(?:async\\s+)?${paramsTail}`, "g");
|
|
1006
|
+
// 形参字符集要容得下 TS 的**类型标注**(`(s: any) =>` 是 TS 工程里的常态,
|
|
1007
|
+
// 漏了 `:` 会让整环不匹配 —— 2026-09-20 实测踩到),因此放行 `:.|<>`
|
|
1008
|
+
// (联合类型与泛型形参);`=>` 与 `{` 仍不在集合内,函数体不会被吃进来。
|
|
1009
|
+
const params = String.raw `\(?\s*([\w$\[\],.:\s<>|]+?)\s*\)?`;
|
|
1010
|
+
// 箭头回调:`(k) =>` / `k =>` / `async (k) =>` / `([k, v]) =>`
|
|
1011
|
+
const arrow = callbackRe(`${params}\\s*=>`);
|
|
1012
|
+
// ES5 回调:`function (k) { … }` —— 不是箭头,但形参同样是元素
|
|
1013
|
+
const fnCb = callbackRe(String.raw `function\s*\(?\s*([\w$\[\],.:\s<>|]+?)\s*\)`);
|
|
1014
|
+
let hit = false;
|
|
1015
|
+
for (const re of [arrow, fnCb]) {
|
|
1016
|
+
let cm;
|
|
1017
|
+
while ((cm = re.exec(text)) !== null) {
|
|
1018
|
+
const raw = cm[1].trim();
|
|
1019
|
+
// `[k, v]` 解构:两个都是元素,都绑;`k, i` 只取第一个(第二个是索引)
|
|
1020
|
+
const parts = raw.startsWith("[")
|
|
1021
|
+
? raw.replace(/[[\]]/g, " ").split(",")
|
|
1022
|
+
: [raw.split(",")[0]];
|
|
1023
|
+
for (const part of parts) {
|
|
1024
|
+
const name = part.trim().split(":")[0].trim();
|
|
1025
|
+
if (/^[\w$]+$/.test(name) && !tainted.has(name)) {
|
|
1026
|
+
tainted.add(name);
|
|
1027
|
+
hit = true;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return hit;
|
|
1033
|
+
};
|
|
1034
|
+
// 根形态:`Object.keys(doc).forEach(...)` —— root 正则只吃到开括号,
|
|
1035
|
+
// 这里补 `[^()]*\)` 吃掉实参列表与闭括号(`Object.keys` 的实参无嵌套)。
|
|
1036
|
+
bindFromCallback(`(?:${root})[^()]*\\)`);
|
|
1037
|
+
// ── 有界不动点(≤3 轮):塑形传播 / 聚合迭代 / 单跳赋值 ──
|
|
1038
|
+
// C4c:三轮里的第二轮才是关键 —— `const ks = Object.keys(o); for (const k of ks)`
|
|
1039
|
+
// 的被迭代对象是**已被污染的变量**,不再是根表达式。此前 enumBind 只按 root
|
|
1040
|
+
// 匹配,这种「先收集再迭代」的写法一根都收不到(fr-016 的 gather* 系列如此)。
|
|
1041
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
1042
|
+
let grew = taintedViaShaper(text, tainted, shapers);
|
|
1043
|
+
if (tainted.size > 0) {
|
|
1044
|
+
const names = [...tainted].map(escapeRe).join("|");
|
|
1045
|
+
if (bindFromEnum(`\\b(?:${names})\\b`))
|
|
1046
|
+
grew = true;
|
|
1047
|
+
// C4d 变量形态:`const ks = Object.keys(doc); ks.forEach(k => …)`
|
|
1048
|
+
// C4d-b:接收者允许中间隔着成员 —— `doc.sections.forEach((s) => …)`。
|
|
1049
|
+
// doc/ks 已污时,它的成员同样是污点数据的组成部分,枚举出来的元素也是。
|
|
1050
|
+
// 末尾仍必须是四个枚举方法之一,`doc.length` 之类不会触发。
|
|
1051
|
+
if (bindFromCallback(`(?:^|[^\\w$.])(?:${names})(?:\\s*\\.\\s*[\\w$]+)*`))
|
|
1052
|
+
grew = true;
|
|
1053
|
+
const hop = new RegExp(`(?:const|let|var)\\s+([\\w$]+)\\s*=\\s*\\b(?:${names})\\b`, "g");
|
|
1054
|
+
let hm;
|
|
1055
|
+
while ((hm = hop.exec(text)) !== null) {
|
|
1056
|
+
if (!tainted.has(hm[1])) {
|
|
1057
|
+
tainted.add(hm[1]);
|
|
1058
|
+
grew = true;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (!grew)
|
|
722
1063
|
break;
|
|
723
1064
|
}
|
|
724
1065
|
return tainted;
|
|
@@ -731,7 +1072,13 @@ function collectTaintedNames(text) {
|
|
|
731
1072
|
* `normalized = path.normalize(p)` → `savePath = path.join(normalized, f)`
|
|
732
1073
|
* 这类链式构造能接上(fr-012 下载侧守卫块就是这个形状)。
|
|
733
1074
|
*/
|
|
734
|
-
function taintedViaShaper(text, tainted) {
|
|
1075
|
+
function taintedViaShaper(text, tainted, shapers) {
|
|
1076
|
+
// C4b:项目自有「纯塑形」helper 的调用也算子(如 getFileNamePath(dir, name, ext))。
|
|
1077
|
+
// 见 pureShaperFunctionNames —— 只认三条判据全部成立的函数,认不出就不传播。
|
|
1078
|
+
const localShaperRe = shapers && shapers.size > 0
|
|
1079
|
+
? new RegExp(`(?:^|[^\\w$.])(?:${[...shapers].map(escapeRe).join("|")})\\s*\\(`)
|
|
1080
|
+
: null;
|
|
1081
|
+
let grewAny = false;
|
|
735
1082
|
for (let depth = 0; depth < 3; depth++) {
|
|
736
1083
|
// 注意:这里**不能**以 `tainted.size === 0` 提前退出 —— 种子是根模式
|
|
737
1084
|
// (taintPattern 恒定并入 UNTRUSTED_ROOT_SRC)。本轮这 4 条 known-gap
|
|
@@ -739,7 +1086,7 @@ function taintedViaShaper(text, tainted) {
|
|
|
739
1086
|
// 加这道闸门等于整条 C4 不生效(2026-09-19 实测踩到)。
|
|
740
1087
|
const taintRe = taintPattern(tainted);
|
|
741
1088
|
if (!taintRe)
|
|
742
|
-
return;
|
|
1089
|
+
return grewAny;
|
|
743
1090
|
let added = false;
|
|
744
1091
|
for (const line of text.split("\n")) {
|
|
745
1092
|
const m = /^\s*(?:const|let|var)?\s*([\w$]+)\s*=\s*(?:await\s+)?(.+)$/.exec(line);
|
|
@@ -749,14 +1096,19 @@ function taintedViaShaper(text, tainted) {
|
|
|
749
1096
|
const rhs = m[2];
|
|
750
1097
|
if (tainted.has(name))
|
|
751
1098
|
continue;
|
|
752
|
-
|
|
1099
|
+
const shaped = TS_PATH_SHAPER_RE.test(rhs) ||
|
|
1100
|
+
TS_CONCAT_RE.test(rhs) ||
|
|
1101
|
+
(localShaperRe !== null && localShaperRe.test(rhs));
|
|
1102
|
+
if (taintRe.test(rhs) && shaped) {
|
|
753
1103
|
tainted.add(name);
|
|
754
1104
|
added = true;
|
|
755
1105
|
}
|
|
756
1106
|
}
|
|
757
1107
|
if (!added)
|
|
758
|
-
return;
|
|
1108
|
+
return grewAny;
|
|
1109
|
+
grewAny = true;
|
|
759
1110
|
}
|
|
1111
|
+
return grewAny;
|
|
760
1112
|
}
|
|
761
1113
|
function taintPattern(tainted) {
|
|
762
1114
|
const parts = [UNTRUSTED_ROOT_SRC];
|
|
@@ -819,6 +1171,7 @@ function splitArgWindow(win) {
|
|
|
819
1171
|
/** 项目方法名 → 方法体内流入文件 sink 的形参下标集合 */
|
|
820
1172
|
function methodSinkParamMap(project, absRoot) {
|
|
821
1173
|
const map = new Map();
|
|
1174
|
+
const bodies = [];
|
|
822
1175
|
const register = (keyName, fullName, text, relPath, params) => {
|
|
823
1176
|
const sinkRe = tsSinkCallRegex();
|
|
824
1177
|
let sm;
|
|
@@ -850,7 +1203,9 @@ function methodSinkParamMap(project, absRoot) {
|
|
|
850
1203
|
const params = m.getParameters().map((p) => p.getName());
|
|
851
1204
|
if (params.length === 0)
|
|
852
1205
|
continue;
|
|
853
|
-
|
|
1206
|
+
const body = { keyName: m.getName(), fullName: `${cn}.${m.getName()}`, relPath, params, text: m.getText() };
|
|
1207
|
+
bodies.push(body);
|
|
1208
|
+
register(body.keyName, body.fullName, body.text, relPath, params);
|
|
854
1209
|
}
|
|
855
1210
|
}
|
|
856
1211
|
// ── 污点数据流试点(2026-09-18)──
|
|
@@ -865,8 +1220,63 @@ function methodSinkParamMap(project, absRoot) {
|
|
|
865
1220
|
const params = fn.getParameters().map((p) => p.getName());
|
|
866
1221
|
if (params.length === 0)
|
|
867
1222
|
continue;
|
|
868
|
-
|
|
1223
|
+
const body = { keyName: fnName, fullName: fnName, relPath, params, text: fn.getText() };
|
|
1224
|
+
bodies.push(body);
|
|
1225
|
+
register(body.keyName, body.fullName, body.text, relPath, params);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// ── sink 形参继承(2026-09-19,fr-016)──
|
|
1229
|
+
// 真实工程落盘几乎必过一层自有封装,只做「形参 → 本函数体内 fs sink」的
|
|
1230
|
+
// 一跳登记,等于把整层 wrapper 排除在外:
|
|
1231
|
+
// writeToFileByExtension(data, filePath) → writeJson/writeYaml(d, filePath) → fs.writeFileSync(filePath)
|
|
1232
|
+
// 于是即使上游 taint 已识别,`writeToFileByExtension(data, taintedPath)`
|
|
1233
|
+
// 也查不到任何记录 —— fr-016 的流量断了 sink 侧这一环,与来源侧的根缺口
|
|
1234
|
+
// 是**两个独立缺陷**,必须两个都补才能让真实语料动起来。
|
|
1235
|
+
//
|
|
1236
|
+
// 规矩与既有 register 完全一致、只是再推一层:自己的形参被传进已登记函数的
|
|
1237
|
+
// sink 位置 ⇒ 继承该 sink 位置。有界迭代(≤3 轮)保证收敛。
|
|
1238
|
+
//
|
|
1239
|
+
// 注意这不是 C4b。C4b 是**值侧**( helper 会不会改变污点性质);
|
|
1240
|
+
// 这里是** sink 侧**( wrapper 会不会真的写到文件)。两侧对称,缺一不可。
|
|
1241
|
+
// 注意用 new RegExp(String) 而非正则字面量:本文件同类表达式(directCallRe 等)
|
|
1242
|
+
// 都按这套写法,写成字面量时 `\\w` 会被当成「反斜杠 + w」,一个调用都匹配不到。
|
|
1243
|
+
const wrapperCallRe = new RegExp(`(?:^|[^\\w$.])([\\w$]+)\\s*\\(([\\s\\S]{0,250}?)\\)`, "g");
|
|
1244
|
+
for (let round = 0; round < 3; round++) {
|
|
1245
|
+
let grew = false;
|
|
1246
|
+
for (const b of bodies) {
|
|
1247
|
+
wrapperCallRe.lastIndex = 0;
|
|
1248
|
+
let cm;
|
|
1249
|
+
while ((cm = wrapperCallRe.exec(b.text)) !== null) {
|
|
1250
|
+
const callee = map.get(cm[1]);
|
|
1251
|
+
if (!callee || callee.idxs.size === 0)
|
|
1252
|
+
continue;
|
|
1253
|
+
const args = splitArgWindow(cm[2] || "");
|
|
1254
|
+
if (args.length === 0)
|
|
1255
|
+
continue;
|
|
1256
|
+
b.params.forEach((pname, idx) => {
|
|
1257
|
+
if (!pname)
|
|
1258
|
+
return;
|
|
1259
|
+
const pre = new RegExp(`\\b${escapeRe(pname)}\\b`);
|
|
1260
|
+
// 形参出现在被调用方的【sink 形参位】上,才算继承了 sink
|
|
1261
|
+
const inherited = [...callee.idxs].some((i) => i < args.length && pre.test(args[i]));
|
|
1262
|
+
if (!inherited)
|
|
1263
|
+
return;
|
|
1264
|
+
if (!map.has(b.keyName)) {
|
|
1265
|
+
map.set(b.keyName, { idxs: new Set(), entries: [] });
|
|
1266
|
+
}
|
|
1267
|
+
const rec = map.get(b.keyName);
|
|
1268
|
+
if (rec.idxs.has(idx))
|
|
1269
|
+
return;
|
|
1270
|
+
rec.idxs.add(idx);
|
|
1271
|
+
grew = true;
|
|
1272
|
+
if (!rec.entries.some((e) => e.name === b.fullName)) {
|
|
1273
|
+
rec.entries.push({ name: b.fullName, file: b.relPath });
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
869
1277
|
}
|
|
1278
|
+
if (!grew)
|
|
1279
|
+
break;
|
|
870
1280
|
}
|
|
871
1281
|
return map;
|
|
872
1282
|
}
|
|
@@ -903,14 +1313,14 @@ function collectUrlParamNames(params) {
|
|
|
903
1313
|
* 项目方法体内的文件 sink(跨函数一跳,onMethodHit 回调登记方法条目)
|
|
904
1314
|
* - SSRF:URL 形参/request 污点 → HTTP fetch sink,无守卫词汇
|
|
905
1315
|
*/
|
|
906
|
-
function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns, directGuardFns) {
|
|
1316
|
+
function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns, directGuardFns, shapers) {
|
|
907
1317
|
const markers = [];
|
|
908
1318
|
// ── 路径穿越 ──
|
|
909
1319
|
// G1(2026-09-19):有流还不够,必须【没有校验证据】才标记——
|
|
910
1320
|
// 与 SSRF 侧「无 SSRF_GUARD_EVIDENCE 才标记」对齐。
|
|
911
1321
|
// 未传 guardFns(旧调用点/单测)时按「不做校验识别」的旧语义处理。
|
|
912
1322
|
if (hasRequestRootedExpr(text)) {
|
|
913
|
-
const tainted = collectTaintedNames(text);
|
|
1323
|
+
const tainted = collectTaintedNames(text, shapers);
|
|
914
1324
|
const selfGuarded = guardFns ? hasPathGuardEvidence(text) !== null : false;
|
|
915
1325
|
// G2:调用点抑制 —— 被传进「自身含校验证据的函数」的表达式视为已净化。
|
|
916
1326
|
// 与 selfGuarded(函数级、按词形)互补:这条按【被调用方的实际证据】定案,
|
|
@@ -961,7 +1371,7 @@ function computeMarkerCalls(text, paramNames, sinkParams, onMethodHit, guardFns,
|
|
|
961
1371
|
TS_HTTP_FETCH_SINK.lastIndex = 0;
|
|
962
1372
|
if (TS_HTTP_FETCH_SINK.test(text) && !SSRF_GUARD_EVIDENCE.test(text)) {
|
|
963
1373
|
const urlParams = paramNames.filter((n) => URL_PARAM_NAME.test(n));
|
|
964
|
-
const reqTainted = collectTaintedNames(text);
|
|
1374
|
+
const reqTainted = collectTaintedNames(text, shapers);
|
|
965
1375
|
const taintParts = [];
|
|
966
1376
|
if (urlParams.length > 0)
|
|
967
1377
|
taintParts.push(`\\b(?:${urlParams.join("|")})\\b`);
|
|
@@ -1068,6 +1478,10 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
1068
1478
|
// G2 只用 direct —— 传播得到的名字是「被推断为守卫」,拿它做抑制会把
|
|
1069
1479
|
// 推断误差直接放大成误报消除。
|
|
1070
1480
|
const { all: guardFns, direct: directGuardFns } = pathGuardFunctionNames(project);
|
|
1481
|
+
// C4b:项目自有「纯塑形」helper —— 让污点能穿过 getFileNamePath(...) 这类
|
|
1482
|
+
// 自建封装。与 guardFns 方向相反:那边是压掉流,这边是接通流,
|
|
1483
|
+
// 所以它的判据更严(三条全中才认),认不出就留着漏报。
|
|
1484
|
+
const shapers = pureShaperFunctionNames(project);
|
|
1071
1485
|
const pendingMethodMarks = new Set();
|
|
1072
1486
|
const onMethodHit = (rec) => {
|
|
1073
1487
|
for (const me of rec.entries)
|
|
@@ -1085,7 +1499,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
1085
1499
|
const fParams = f.getParameters();
|
|
1086
1500
|
const fText = f.getText();
|
|
1087
1501
|
const fCalls = extractDirectCalls(f, fText);
|
|
1088
|
-
fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
|
|
1502
|
+
fCalls.push(...computeMarkerCalls(fText, fParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
|
|
1089
1503
|
funcs.push({
|
|
1090
1504
|
name,
|
|
1091
1505
|
params: fParams.map(p => ({
|
|
@@ -1115,7 +1529,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
1115
1529
|
const initParams = init.getParameters();
|
|
1116
1530
|
const initText = init.getText();
|
|
1117
1531
|
const initCalls = extractDirectCalls(init, initText);
|
|
1118
|
-
initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
|
|
1532
|
+
initCalls.push(...computeMarkerCalls(initText, initParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
|
|
1119
1533
|
funcs.push({
|
|
1120
1534
|
name,
|
|
1121
1535
|
params: initParams.map(p => ({
|
|
@@ -1141,7 +1555,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
1141
1555
|
const argParams = arg.getParameters();
|
|
1142
1556
|
const argText = arg.getText();
|
|
1143
1557
|
const argCalls = extractDirectCalls(arg, argText);
|
|
1144
|
-
argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns));
|
|
1558
|
+
argCalls.push(...computeMarkerCalls(argText, argParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers));
|
|
1145
1559
|
funcs.push({
|
|
1146
1560
|
name,
|
|
1147
1561
|
params: argParams.map(p => ({
|
|
@@ -1175,7 +1589,7 @@ function _extractSingleProject(absRoot, tsconfigPath) {
|
|
|
1175
1589
|
continue;
|
|
1176
1590
|
const mParams = m.getParameters();
|
|
1177
1591
|
const mText = m.getText();
|
|
1178
|
-
const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns);
|
|
1592
|
+
const mCalls = computeMarkerCalls(mText, mParams.map((p) => p.getName()), sinkParams, onMethodHit, guardFns, directGuardFns, shapers);
|
|
1179
1593
|
funcs.push({
|
|
1180
1594
|
name: `${cn}.${mn}`,
|
|
1181
1595
|
params: mParams.map((p) => ({ name: p.getName(), type: getParamType(p), typeDetail: getParamTypeDetail(p) })),
|
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.41";
|
|
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.
|
|
3
|
+
"version": "3.7.41",
|
|
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/",
|