@kenhkl/agent-moss 0.17.7 → 0.17.8

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/dist/cli.js CHANGED
@@ -1258,6 +1258,20 @@ function redactChain(chain, currentSessionId) {
1258
1258
  return step;
1259
1259
  });
1260
1260
  }
1261
+ function redactGraph(graph, currentSessionId) {
1262
+ if (!graph) return null;
1263
+ if (chainDetailMode() === "full") return graph;
1264
+ const nodes = (graph.nodes || []).map((n) => {
1265
+ const m = { ...n };
1266
+ if (String(m.session_id ?? "") !== String(currentSessionId)) {
1267
+ m.session_id = "<other-session>";
1268
+ m.action_detail = "<\u5DF2\u9690\u85CF>";
1269
+ m.resources = [];
1270
+ }
1271
+ return m;
1272
+ });
1273
+ return { nodes, edges: graph.edges || [] };
1274
+ }
1261
1275
  function sessionSweepThreshold() {
1262
1276
  return envInt("AGENT_MOSS_SESSION_SWEEP_THRESHOLD", 50);
1263
1277
  }
@@ -1439,7 +1453,42 @@ var init_models = __esm({
1439
1453
  confidence: z.number().int().min(0).max(100).default(100),
1440
1454
  analysis_duration_ms: z.number().default(0),
1441
1455
  // 组合攻击链反例路径:每步 [session_id, seq, stage, timestamp],含真实发生时间
1442
- violation_chain: z.array(z.array(z.union([z.string(), z.number()]).nullable())).default([])
1456
+ violation_chain: z.array(z.array(z.union([z.string(), z.number()]).nullable())).default([]),
1457
+ // 结构化攻击链图(前端可视化):节点 + 边(next 时序 / data 数据流)
1458
+ violation_graph: z.object({
1459
+ // 性质元信息(前端展示"违反了哪条性质")
1460
+ property_id: z.string().optional(),
1461
+ property_short: z.string().optional(),
1462
+ property_name: z.string().optional(),
1463
+ // 性质表达式 token → 节点位置(前端展示"表达式对应哪个节点")
1464
+ property_expression: z.array(
1465
+ z.object({
1466
+ token: z.string(),
1467
+ node_index: z.union([z.number(), z.array(z.number())])
1468
+ })
1469
+ ).optional(),
1470
+ nodes: z.array(
1471
+ z.object({
1472
+ session_id: z.string().default(""),
1473
+ seq: z.number().default(0),
1474
+ stage: z.string().default(""),
1475
+ action_detail: z.string().default(""),
1476
+ resources: z.array(z.string()).default([]),
1477
+ // 节点在性质中的角色:source/carrier/sink(P2)或阶段名(P1)
1478
+ role: z.string().optional(),
1479
+ // 是否为违例见证节点(当前动作补全链 / 触发外传)
1480
+ witness: z.boolean().optional()
1481
+ })
1482
+ ),
1483
+ edges: z.array(
1484
+ z.object({
1485
+ from: z.number(),
1486
+ to: z.number(),
1487
+ type: z.enum(["next", "data"]).default("next"),
1488
+ label: z.string().optional()
1489
+ })
1490
+ )
1491
+ }).nullable().default(null)
1443
1492
  });
1444
1493
  HealthResponseSchema = z.object({
1445
1494
  status: z.string().default("healthy"),
@@ -9511,6 +9560,33 @@ var init_builder = __esm({
9511
9560
  });
9512
9561
 
9513
9562
  // src/engine/formal-engine/properties.ts
9563
+ function graphFromPath(nodes, path6, edgeType = "next", propertyMeta, roles, witnessLast = true, propertyExpression) {
9564
+ const byKey = /* @__PURE__ */ new Map();
9565
+ for (const n of nodes) byKey.set(`${n.sessionId}#${n.seq}`, n);
9566
+ const gNodes = [];
9567
+ const gEdges = [];
9568
+ for (const item of path6) {
9569
+ const n = byKey.get(`${item.sessionId}#${item.seq}`);
9570
+ gNodes.push({
9571
+ session_id: item.sessionId,
9572
+ seq: item.seq,
9573
+ stage: n ? n.stage : "",
9574
+ action_detail: n ? n.actionDetail : item.detail,
9575
+ resources: n ? [...n.resources].sort() : []
9576
+ });
9577
+ if (gNodes.length > 1) {
9578
+ gEdges.push({ from: gNodes.length - 2, to: gNodes.length - 1, type: edgeType });
9579
+ }
9580
+ }
9581
+ (roles || []).forEach((role, i) => {
9582
+ if (i < gNodes.length && role) gNodes[i].role = role;
9583
+ });
9584
+ if (witnessLast && gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
9585
+ const out = { nodes: gNodes, edges: gEdges };
9586
+ if (propertyMeta) Object.assign(out, propertyMeta);
9587
+ if (propertyExpression) out.property_expression = propertyExpression;
9588
+ return out;
9589
+ }
9514
9590
  function parseTs(ts) {
9515
9591
  if (!ts) return null;
9516
9592
  const t = Date.parse(String(ts).trim());
@@ -9568,7 +9644,12 @@ function checkCrossSessionChain(graph) {
9568
9644
  propertyId: "cross-" + chain.id,
9569
9645
  reason: chain.reason,
9570
9646
  riskLevel: chain.riskLevel,
9571
- path: path6
9647
+ path: path6,
9648
+ graph: graphFromPath(nodes, path6, "next", {
9649
+ property_id: "cross-" + chain.id,
9650
+ property_short: "P1",
9651
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE"
9652
+ }, stages, true, stages.map((s, i) => ({ token: s, node_index: i })))
9572
9653
  });
9573
9654
  return violations;
9574
9655
  } else {
@@ -9588,7 +9669,12 @@ function checkCrossSessionChain(graph) {
9588
9669
  propertyId: "cross-" + chain.id,
9589
9670
  reason: chain.reason,
9590
9671
  riskLevel: chain.riskLevel,
9591
- path: path6
9672
+ path: path6,
9673
+ graph: graphFromPath(nodes, path6, "next", {
9674
+ property_id: "cross-" + chain.id,
9675
+ property_short: "P1",
9676
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE"
9677
+ }, stages, true, stages.map((s, i) => ({ token: s, node_index: i })))
9592
9678
  });
9593
9679
  }
9594
9680
  }
@@ -9603,10 +9689,15 @@ function checkSensitiveDataFlow(graph) {
9603
9689
  for (let i = 0; i < nodes.length; i++) {
9604
9690
  const n = nodes[i];
9605
9691
  if (n.stage === "CRED" && [...n.resources].some((r) => CRED_FILE_RE.test(r))) {
9606
- tainted.set(i, `${n.sessionId}#${n.seq} \u8BBF\u95EE ${[...n.resources][0]}`);
9692
+ tainted.set(i, {
9693
+ source: `${n.sessionId}#${n.seq} \u8BBF\u95EE ${[...n.resources][0]}`,
9694
+ parent: null
9695
+ });
9607
9696
  }
9608
9697
  }
9609
9698
  if (tainted.size === 0) return violations;
9699
+ const taintEdges = /* @__PURE__ */ new Map();
9700
+ const edgeKey = (p, c) => `${p}:${c}`;
9610
9701
  const edges = allEdges(graph);
9611
9702
  let changed = true;
9612
9703
  while (changed) {
@@ -9617,7 +9708,8 @@ function checkSensitiveDataFlow(graph) {
9617
9708
  const dstRes = nodes[e.dst].resources;
9618
9709
  for (const r of srcRes) {
9619
9710
  if (dstRes.has(r)) {
9620
- tainted.set(e.dst, tainted.get(e.src));
9711
+ tainted.set(e.dst, { source: tainted.get(e.src).source, parent: e.src });
9712
+ taintEdges.set(edgeKey(e.src, e.dst), r);
9621
9713
  changed = true;
9622
9714
  break;
9623
9715
  }
@@ -9625,16 +9717,75 @@ function checkSensitiveDataFlow(graph) {
9625
9717
  }
9626
9718
  }
9627
9719
  }
9720
+ const tracePath = (idx) => {
9721
+ const chain = [idx];
9722
+ const seen = /* @__PURE__ */ new Set([idx]);
9723
+ while (true) {
9724
+ const parent = tainted.get(chain[chain.length - 1])?.parent;
9725
+ if (parent === void 0 || parent === null || seen.has(parent)) break;
9726
+ chain.push(parent);
9727
+ seen.add(parent);
9728
+ }
9729
+ return chain.reverse();
9730
+ };
9731
+ const taintGraph = (chain) => {
9732
+ const gNodes = [];
9733
+ const gEdges = [];
9734
+ chain.forEach((ni, k) => {
9735
+ const n = nodes[ni];
9736
+ const role = k === 0 ? "source" : k === chain.length - 1 ? "sink" : "carrier";
9737
+ gNodes.push({
9738
+ session_id: n.sessionId,
9739
+ seq: n.seq,
9740
+ stage: n.stage,
9741
+ action_detail: n.actionDetail,
9742
+ resources: [...n.resources].sort(),
9743
+ role
9744
+ });
9745
+ if (k > 0) {
9746
+ const prev = chain[k - 1];
9747
+ gEdges.push({
9748
+ from: k - 1,
9749
+ to: k,
9750
+ type: "data",
9751
+ label: taintEdges.get(edgeKey(prev, ni)) || ""
9752
+ });
9753
+ }
9754
+ });
9755
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
9756
+ const expr = [{ token: "CRED", node_index: 0 }];
9757
+ if (chain.length > 2) {
9758
+ expr.push({
9759
+ token: "\u5171\u4EAB\u8F7D\u4F53",
9760
+ node_index: Array.from({ length: chain.length - 2 }, (_, i) => i + 1)
9761
+ });
9762
+ }
9763
+ expr.push({ token: "EXFIL", node_index: chain.length - 1 });
9764
+ return {
9765
+ property_id: "sensitive-data-flow",
9766
+ property_short: "P2",
9767
+ property_name: "\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20",
9768
+ property_expression: expr,
9769
+ nodes: gNodes,
9770
+ edges: gEdges
9771
+ };
9772
+ };
9628
9773
  const witness = graph.witnessIdx;
9629
9774
  const candidates = witness !== void 0 ? [witness] : nodes.map((_, i) => i);
9630
9775
  for (const i of candidates) {
9631
9776
  const n = nodes[i];
9632
9777
  if (tainted.has(i) && EXFIL_RE.test(n.actionDetail)) {
9778
+ const chain = tracePath(i);
9633
9779
  violations.push({
9634
9780
  propertyId: "sensitive-data-flow",
9635
- reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${tainted.get(i)} \u2192 ${n.sessionId}#${n.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
9781
+ reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${tainted.get(i).source} \u2192 ${n.sessionId}#${n.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
9636
9782
  riskLevel: "critical",
9637
- path: [{ sessionId: n.sessionId, seq: n.seq, detail: n.actionDetail.slice(0, 60) }]
9783
+ path: chain.map((j) => ({
9784
+ sessionId: nodes[j].sessionId,
9785
+ seq: nodes[j].seq,
9786
+ detail: nodes[j].actionDetail.slice(0, 60)
9787
+ })),
9788
+ graph: taintGraph(chain)
9638
9789
  });
9639
9790
  if (witness !== void 0) return violations;
9640
9791
  }
@@ -10176,6 +10327,12 @@ var init_stateful = __esm({
10176
10327
  nodes_ = [];
10177
10328
  ptr = {};
10178
10329
  tainted = /* @__PURE__ */ new Set();
10330
+ /** taint 传播路径:nodeIdx -> 使该节点染毒的前驱 nodeIdx(CRED 源为 null) */
10331
+ taintParent = /* @__PURE__ */ new Map();
10332
+ /** "parent:child" -> 触发传播的共享资源(图上的 data 边标签) */
10333
+ taintEdgeLabel = /* @__PURE__ */ new Map();
10334
+ /** 资源 -> 首次染毒该资源的节点 seq */
10335
+ taintOrigin = /* @__PURE__ */ new Map();
10179
10336
  /** taint 反索引:资源路径 → 已染毒(O(1) 命中)。 */
10180
10337
  taintedResources = /* @__PURE__ */ new Set();
10181
10338
  aliveResources = {};
@@ -10386,7 +10543,8 @@ var init_stateful = __esm({
10386
10543
  stages,
10387
10544
  riskLevel: String(chain.riskLevel || chain.risk_level || "high"),
10388
10545
  reason: String(chain.reason || "\u68C0\u6D4B\u5230\u7EC4\u5408\u653B\u51FB\u94FE"),
10389
- path: this.chainPath(stages)
10546
+ path: this.chainPath(stages),
10547
+ graph: this.buildChainGraph(stages, cid)
10390
10548
  };
10391
10549
  }
10392
10550
  }
@@ -10433,21 +10591,69 @@ var init_stateful = __esm({
10433
10591
  }
10434
10592
  return path6;
10435
10593
  }
10594
+ /** 把链路径转成前端可画的结构化图(相邻节点 next 边)。 */
10595
+ buildChainGraph(stages, chainId) {
10596
+ const path6 = this.chainPath(stages);
10597
+ const gNodes = path6.map((p, i) => {
10598
+ const n = this.nodes_.find((x) => x.sessionId === p.sessionId && x.seq === p.seq);
10599
+ return {
10600
+ session_id: p.sessionId,
10601
+ seq: p.seq,
10602
+ stage: p.stage,
10603
+ action_detail: n ? n.actionDetail : "",
10604
+ resources: n ? [...n.resources].sort() : [],
10605
+ role: stages[i] || ""
10606
+ };
10607
+ });
10608
+ const gEdges = [];
10609
+ for (let i = 1; i < gNodes.length; i++) {
10610
+ gEdges.push({ from: i - 1, to: i, type: "next" });
10611
+ }
10612
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
10613
+ return {
10614
+ property_id: "cross-" + chainId,
10615
+ property_short: "P1",
10616
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE",
10617
+ property_expression: stages.map((s, i) => ({ token: s, node_index: i })),
10618
+ nodes: gNodes,
10619
+ edges: gEdges
10620
+ };
10621
+ }
10436
10622
  // ---------------- 敏感数据 taint(P2) ----------------
10437
10623
  advanceTaint(node) {
10438
10624
  if (node.lifecycle === "dead") return;
10439
10625
  const idx = node.seq;
10440
10626
  const taintedRes = /* @__PURE__ */ new Set();
10627
+ let parent = null;
10628
+ let label = "";
10629
+ let foundParent = false;
10441
10630
  for (const r of node.resources) {
10442
- if (this.resourceTainted(r)) taintedRes.add(r);
10631
+ if (this.resourceTainted(r)) {
10632
+ taintedRes.add(r);
10633
+ if (!foundParent) {
10634
+ const origin = this.taintOrigin.get(r);
10635
+ if (origin !== void 0) {
10636
+ parent = origin;
10637
+ label = r;
10638
+ foundParent = true;
10639
+ }
10640
+ }
10641
+ }
10443
10642
  }
10444
10643
  if (node.stage === "CRED" && node.resources.some((r) => CRED_FILE_RE2.test(r))) {
10445
10644
  this.tainted.add(idx);
10645
+ parent = null;
10646
+ label = "";
10446
10647
  for (const r of node.resources) taintedRes.add(r);
10447
10648
  }
10448
10649
  if (taintedRes.size > 0) {
10449
10650
  this.tainted.add(idx);
10450
- for (const r of node.resources) this.taintedResources.add(r);
10651
+ this.taintParent.set(idx, parent);
10652
+ if (parent !== null) this.taintEdgeLabel.set(`${parent}:${idx}`, label);
10653
+ for (const r of node.resources) {
10654
+ this.taintedResources.add(r);
10655
+ if (!this.taintOrigin.has(r)) this.taintOrigin.set(r, idx);
10656
+ }
10451
10657
  }
10452
10658
  }
10453
10659
  resourceTainted(resource) {
@@ -10457,16 +10663,84 @@ var init_stateful = __esm({
10457
10663
  checkExfilAfterTaint(node) {
10458
10664
  if (!this.tainted.has(node.seq)) return [];
10459
10665
  if (EXFIL_RE2.test(node.actionDetail)) {
10666
+ const chain = this.traceTaintChain(node.seq);
10460
10667
  return [{
10461
10668
  property_id: "sensitive-data-flow",
10462
- reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${this.taintSource(node)} \u2192 ${node.sessionId}#${node.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
10669
+ reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${this.taintSource(node, chain)} \u2192 ${node.sessionId}#${node.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
10463
10670
  risk_level: "critical",
10464
- path: [[node.sessionId, node.seq, node.actionDetail.slice(0, 60)]]
10671
+ path: chain.map((j) => {
10672
+ const n = this.nodes_[j];
10673
+ return [n.sessionId, n.seq, n.actionDetail.slice(0, 60), n.timestamp];
10674
+ }),
10675
+ graph: this.taintGraph(chain)
10465
10676
  }];
10466
10677
  }
10467
10678
  return [];
10468
10679
  }
10469
- taintSource(node) {
10680
+ /** 沿 taint parent 回溯出 CRED 源 → 当前节点的完整路径。 */
10681
+ traceTaintChain(idx) {
10682
+ const chain = [idx];
10683
+ const seen = /* @__PURE__ */ new Set([idx]);
10684
+ while (true) {
10685
+ const parent = this.taintParent.get(chain[chain.length - 1]);
10686
+ if (parent === void 0 || parent === null || seen.has(parent)) break;
10687
+ chain.push(parent);
10688
+ seen.add(parent);
10689
+ }
10690
+ return chain.reverse();
10691
+ }
10692
+ /** 把 taint 路径转成前端可画的结构化图(data 边带共享资源标签)。 */
10693
+ taintGraph(chain) {
10694
+ const gNodes = [];
10695
+ const gEdges = [];
10696
+ chain.forEach((ni, k) => {
10697
+ const n = this.nodes_[ni];
10698
+ const role = k === 0 ? "source" : k === chain.length - 1 ? "sink" : "carrier";
10699
+ gNodes.push({
10700
+ session_id: n.sessionId,
10701
+ seq: n.seq,
10702
+ stage: n.stage,
10703
+ action_detail: n.actionDetail,
10704
+ resources: [...n.resources].sort(),
10705
+ role
10706
+ });
10707
+ if (k > 0) {
10708
+ const prev = chain[k - 1];
10709
+ gEdges.push({
10710
+ from: k - 1,
10711
+ to: k,
10712
+ type: "data",
10713
+ label: this.taintEdgeLabel.get(`${prev}:${ni}`) || ""
10714
+ });
10715
+ }
10716
+ });
10717
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
10718
+ const expr = [{ token: "CRED", node_index: 0 }];
10719
+ if (chain.length > 2) {
10720
+ expr.push({
10721
+ token: "\u5171\u4EAB\u8F7D\u4F53",
10722
+ node_index: Array.from({ length: chain.length - 2 }, (_, i) => i + 1)
10723
+ });
10724
+ }
10725
+ expr.push({ token: "EXFIL", node_index: chain.length - 1 });
10726
+ return {
10727
+ property_id: "sensitive-data-flow",
10728
+ property_short: "P2",
10729
+ property_name: "\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20",
10730
+ property_expression: expr,
10731
+ nodes: gNodes,
10732
+ edges: gEdges
10733
+ };
10734
+ }
10735
+ taintSource(node, chain = []) {
10736
+ if (chain.length > 0) {
10737
+ const src = this.nodes_[chain[0]];
10738
+ if (src) {
10739
+ for (const r of node.resources) {
10740
+ if (src.resources.includes(r)) return `${src.sessionId}#${src.seq} \u8BBF\u95EE ${r}`;
10741
+ }
10742
+ }
10743
+ }
10470
10744
  const sorted = [...this.tainted].sort((a, b) => a - b);
10471
10745
  for (const i of sorted) {
10472
10746
  if (i === node.seq) continue;
@@ -10485,6 +10759,9 @@ var init_stateful = __esm({
10485
10759
  nodes: this.nodes_.map((n) => ({ ...n, resources: [...n.resources] })),
10486
10760
  ptrs: { ...this.ptr },
10487
10761
  tainted: [...this.tainted].sort((a, b) => a - b),
10762
+ // taint 传播路径(前端攻击链图);边标签不入快照,重启后可从资源交集重建
10763
+ taint_parent: Object.fromEntries([...this.taintParent.entries()].map(([k, v]) => [String(k), v])),
10764
+ taint_origin: Object.fromEntries([...this.taintOrigin.entries()]),
10488
10765
  alive: { ...this.aliveResources },
10489
10766
  seq_next: this.seqNext
10490
10767
  };
@@ -10512,15 +10789,53 @@ var init_stateful = __esm({
10512
10789
  this.seqNext = Number(snapshot.seq_next || this.nodes_.length);
10513
10790
  this.ptr = Object.fromEntries(Object.entries(snapshot.ptrs || {}).map(([k, v]) => [k, Number(v)]));
10514
10791
  this.tainted = new Set((snapshot.tainted || []).map(Number));
10792
+ this.taintParent = new Map(
10793
+ Object.entries(snapshot.taint_parent || {}).map(
10794
+ ([k, v]) => [Number(k), v === null || v === void 0 ? null : Number(v)]
10795
+ )
10796
+ );
10797
+ this.taintOrigin = new Map(
10798
+ Object.entries(snapshot.taint_origin || {}).map(
10799
+ ([k, v]) => [String(k), Number(v)]
10800
+ )
10801
+ );
10802
+ this.taintEdgeLabel = /* @__PURE__ */ new Map();
10515
10803
  this.taintedResources = /* @__PURE__ */ new Set();
10516
10804
  for (const i of this.tainted) {
10517
10805
  const n = this.nodes_[i];
10518
10806
  if (n) for (const r of n.resources) this.taintedResources.add(r);
10519
10807
  }
10808
+ if (this.tainted.size > 0 && this.taintParent.size === 0) this.rebuildTaintParent();
10809
+ if (this.taintOrigin.size === 0) {
10810
+ for (const i of [...this.tainted].sort((a, b) => a - b)) {
10811
+ const n = this.nodes_[i];
10812
+ if (!n) continue;
10813
+ for (const r of n.resources) {
10814
+ if (!this.taintOrigin.has(r)) this.taintOrigin.set(r, i);
10815
+ }
10816
+ }
10817
+ }
10520
10818
  this.aliveResources = Object.fromEntries(
10521
10819
  Object.entries(snapshot.alive || {}).map(([k, v]) => [k, Number(v)])
10522
10820
  );
10523
10821
  }
10822
+ /** 旧快照无 parent 信息时,按 seq 顺序找最近的资源相交染毒前驱。 */
10823
+ rebuildTaintParent() {
10824
+ for (const i of [...this.tainted].sort((a, b) => a - b)) {
10825
+ const n = this.nodes_[i];
10826
+ if (!n) continue;
10827
+ let parent = null;
10828
+ for (let j = i - 1; j >= 0; j--) {
10829
+ if (!this.tainted.has(j)) continue;
10830
+ const m = this.nodes_[j];
10831
+ if (m && m.resources.some((r) => n.resources.includes(r))) {
10832
+ parent = j;
10833
+ break;
10834
+ }
10835
+ }
10836
+ this.taintParent.set(i, parent);
10837
+ }
10838
+ }
10524
10839
  };
10525
10840
  }
10526
10841
  });
@@ -10944,7 +11259,9 @@ async function runAnalysis(input) {
10944
11259
  riskType: "formal_verification",
10945
11260
  confidence: 90,
10946
11261
  source: "formal_engine",
10947
- violatedLayers: ["L2.2"]
11262
+ violatedLayers: ["L2.2"],
11263
+ violationGraph: v?.graph ?? null,
11264
+ violationChain: (v?.path ?? []).map((p) => [p.sessionId, p.seq, p.detail])
10948
11265
  };
10949
11266
  }
10950
11267
  }
@@ -11489,7 +11806,9 @@ async function analyzeInner(input, mossLog) {
11489
11806
  violated_layers: judgment.violatedLayers || [],
11490
11807
  policy: "",
11491
11808
  violated_policy: `[${judgment.riskType}] ${judgment.reason}`,
11492
- confidence: judgment.confidence
11809
+ confidence: judgment.confidence,
11810
+ violation_graph: judgment.violationGraph ?? null,
11811
+ violation_chain: judgment.violationChain ?? []
11493
11812
  };
11494
11813
  mossLog.logResult("Deny", denied.reason);
11495
11814
  return denied;
@@ -12110,7 +12429,8 @@ async function analyzeRoute(c) {
12110
12429
  confidence: 90,
12111
12430
  analysis_duration_ms: Math.round((performance.now() - t0) * 10) / 10,
12112
12431
  // 跨会话节点脱敏(时间线按 agent_id 共享,原样返回等于泄露别人的命令原文)
12113
- violation_chain: redactChain(judgment.violation_chain, req.session_id)
12432
+ violation_chain: redactChain(judgment.violation_chain, req.session_id),
12433
+ violation_graph: redactGraph(judgment.violation_graph, req.session_id)
12114
12434
  })
12115
12435
  );
12116
12436
  }
@@ -12130,7 +12450,9 @@ async function analyzeRoute(c) {
12130
12450
  policy: result.policy,
12131
12451
  violated_policy: result.violated_policy,
12132
12452
  confidence: result.confidence,
12133
- analysis_duration_ms: Math.round(durationMs * 10) / 10
12453
+ analysis_duration_ms: Math.round(durationMs * 10) / 10,
12454
+ violation_chain: result.violation_chain ?? [],
12455
+ violation_graph: redactGraph(result.violation_graph, req.session_id)
12134
12456
  })
12135
12457
  );
12136
12458
  } catch (err) {
@@ -12160,7 +12482,8 @@ function incMatchedToDeny(inc) {
12160
12482
  property_id: "cross-" + inc.matched.chainId,
12161
12483
  reason: inc.matched.reason,
12162
12484
  risk_level: inc.matched.riskLevel,
12163
- violation_chain: chain
12485
+ violation_chain: chain,
12486
+ violation_graph: inc.matched.graph ?? null
12164
12487
  };
12165
12488
  }
12166
12489
  if (inc.exfil && inc.exfil.length > 0) {
@@ -12168,7 +12491,9 @@ function incMatchedToDeny(inc) {
12168
12491
  property_id: "sensitive-data-flow",
12169
12492
  reason: inc.exfil[0].reason,
12170
12493
  risk_level: inc.exfil[0].risk_level,
12171
- violation_chain: []
12494
+ // P2 的 path 现在包含 CRED 源 → 中间载体 → 外传点的完整链
12495
+ violation_chain: inc.exfil[0].path ?? [],
12496
+ violation_graph: inc.exfil[0].graph ?? null
12172
12497
  };
12173
12498
  }
12174
12499
  return null;
@@ -1,3 +1,37 @@
1
+ interface AttackChainNode {
2
+ session_id: string;
3
+ seq: number;
4
+ stage: string;
5
+ action_detail: string;
6
+ resources: string[];
7
+ /** 节点在性质中的角色:source/carrier/sink(P2),或阶段名(P1) */
8
+ role?: string;
9
+ /** 是否为违例见证节点(当前动作补全链 / 触发外传) */
10
+ witness?: boolean;
11
+ }
12
+ interface AttackChainEdge {
13
+ from: number;
14
+ to: number;
15
+ type: 'next' | 'data';
16
+ label?: string;
17
+ }
18
+ /** 性质表达式 token:token 文本 + 对应节点下标(可为多个)。 */
19
+ interface AttackChainExprToken {
20
+ token: string;
21
+ node_index: number | number[];
22
+ }
23
+ /** 结构化攻击链图(前端 SVG 渲染的数据契约,与 Python 端逐字段对齐)。 */
24
+ interface AttackChainGraph {
25
+ /** 性质元信息(前端展示"违反了哪条性质") */
26
+ property_id?: string;
27
+ property_short?: string;
28
+ property_name?: string;
29
+ /** 性质表达式与节点位置的对应关系 */
30
+ property_expression?: AttackChainExprToken[];
31
+ nodes: AttackChainNode[];
32
+ edges: AttackChainEdge[];
33
+ }
34
+
1
35
  /** Security judgment result from a single analysis layer */
2
36
  interface SecurityJudgment {
3
37
  allowed: boolean;
@@ -8,6 +42,10 @@ interface SecurityJudgment {
8
42
  source: string;
9
43
  violatedLayers: string[];
10
44
  isCredential?: boolean;
45
+ /** 组合式拦截的结构化攻击链(前端可视化用) */
46
+ violationGraph?: AttackChainGraph | null;
47
+ /** 兼容旧调用方:反例路径数组 */
48
+ violationChain?: unknown[];
11
49
  }
12
50
 
13
51
  /** 把命令按 shell 的连接符切成若干段,去掉空白段。
@@ -8483,6 +8483,33 @@ function allNodesFlat(mg) {
8483
8483
  // src/engine/formal-engine/properties.ts
8484
8484
  init_esm_shims();
8485
8485
  init_rules_loader();
8486
+ function graphFromPath(nodes, path5, edgeType = "next", propertyMeta, roles, witnessLast = true, propertyExpression) {
8487
+ const byKey = /* @__PURE__ */ new Map();
8488
+ for (const n of nodes) byKey.set(`${n.sessionId}#${n.seq}`, n);
8489
+ const gNodes = [];
8490
+ const gEdges = [];
8491
+ for (const item of path5) {
8492
+ const n = byKey.get(`${item.sessionId}#${item.seq}`);
8493
+ gNodes.push({
8494
+ session_id: item.sessionId,
8495
+ seq: item.seq,
8496
+ stage: n ? n.stage : "",
8497
+ action_detail: n ? n.actionDetail : item.detail,
8498
+ resources: n ? [...n.resources].sort() : []
8499
+ });
8500
+ if (gNodes.length > 1) {
8501
+ gEdges.push({ from: gNodes.length - 2, to: gNodes.length - 1, type: edgeType });
8502
+ }
8503
+ }
8504
+ (roles || []).forEach((role, i) => {
8505
+ if (i < gNodes.length && role) gNodes[i].role = role;
8506
+ });
8507
+ if (witnessLast && gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
8508
+ const out = { nodes: gNodes, edges: gEdges };
8509
+ if (propertyMeta) Object.assign(out, propertyMeta);
8510
+ if (propertyExpression) out.property_expression = propertyExpression;
8511
+ return out;
8512
+ }
8486
8513
  function parseTs(ts) {
8487
8514
  if (!ts) return null;
8488
8515
  const t = Date.parse(String(ts).trim());
@@ -8543,7 +8570,12 @@ function checkCrossSessionChain(graph) {
8543
8570
  propertyId: "cross-" + chain.id,
8544
8571
  reason: chain.reason,
8545
8572
  riskLevel: chain.riskLevel,
8546
- path: path5
8573
+ path: path5,
8574
+ graph: graphFromPath(nodes, path5, "next", {
8575
+ property_id: "cross-" + chain.id,
8576
+ property_short: "P1",
8577
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE"
8578
+ }, stages, true, stages.map((s, i) => ({ token: s, node_index: i })))
8547
8579
  });
8548
8580
  return violations;
8549
8581
  } else {
@@ -8563,7 +8595,12 @@ function checkCrossSessionChain(graph) {
8563
8595
  propertyId: "cross-" + chain.id,
8564
8596
  reason: chain.reason,
8565
8597
  riskLevel: chain.riskLevel,
8566
- path: path5
8598
+ path: path5,
8599
+ graph: graphFromPath(nodes, path5, "next", {
8600
+ property_id: "cross-" + chain.id,
8601
+ property_short: "P1",
8602
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE"
8603
+ }, stages, true, stages.map((s, i) => ({ token: s, node_index: i })))
8567
8604
  });
8568
8605
  }
8569
8606
  }
@@ -8578,10 +8615,15 @@ function checkSensitiveDataFlow(graph) {
8578
8615
  for (let i = 0; i < nodes.length; i++) {
8579
8616
  const n = nodes[i];
8580
8617
  if (n.stage === "CRED" && [...n.resources].some((r) => CRED_FILE_RE.test(r))) {
8581
- tainted.set(i, `${n.sessionId}#${n.seq} \u8BBF\u95EE ${[...n.resources][0]}`);
8618
+ tainted.set(i, {
8619
+ source: `${n.sessionId}#${n.seq} \u8BBF\u95EE ${[...n.resources][0]}`,
8620
+ parent: null
8621
+ });
8582
8622
  }
8583
8623
  }
8584
8624
  if (tainted.size === 0) return violations;
8625
+ const taintEdges = /* @__PURE__ */ new Map();
8626
+ const edgeKey = (p, c) => `${p}:${c}`;
8585
8627
  const edges = allEdges(graph);
8586
8628
  let changed = true;
8587
8629
  while (changed) {
@@ -8592,7 +8634,8 @@ function checkSensitiveDataFlow(graph) {
8592
8634
  const dstRes = nodes[e.dst].resources;
8593
8635
  for (const r of srcRes) {
8594
8636
  if (dstRes.has(r)) {
8595
- tainted.set(e.dst, tainted.get(e.src));
8637
+ tainted.set(e.dst, { source: tainted.get(e.src).source, parent: e.src });
8638
+ taintEdges.set(edgeKey(e.src, e.dst), r);
8596
8639
  changed = true;
8597
8640
  break;
8598
8641
  }
@@ -8600,16 +8643,75 @@ function checkSensitiveDataFlow(graph) {
8600
8643
  }
8601
8644
  }
8602
8645
  }
8646
+ const tracePath = (idx) => {
8647
+ const chain = [idx];
8648
+ const seen = /* @__PURE__ */ new Set([idx]);
8649
+ while (true) {
8650
+ const parent = tainted.get(chain[chain.length - 1])?.parent;
8651
+ if (parent === void 0 || parent === null || seen.has(parent)) break;
8652
+ chain.push(parent);
8653
+ seen.add(parent);
8654
+ }
8655
+ return chain.reverse();
8656
+ };
8657
+ const taintGraph = (chain) => {
8658
+ const gNodes = [];
8659
+ const gEdges = [];
8660
+ chain.forEach((ni, k) => {
8661
+ const n = nodes[ni];
8662
+ const role = k === 0 ? "source" : k === chain.length - 1 ? "sink" : "carrier";
8663
+ gNodes.push({
8664
+ session_id: n.sessionId,
8665
+ seq: n.seq,
8666
+ stage: n.stage,
8667
+ action_detail: n.actionDetail,
8668
+ resources: [...n.resources].sort(),
8669
+ role
8670
+ });
8671
+ if (k > 0) {
8672
+ const prev = chain[k - 1];
8673
+ gEdges.push({
8674
+ from: k - 1,
8675
+ to: k,
8676
+ type: "data",
8677
+ label: taintEdges.get(edgeKey(prev, ni)) || ""
8678
+ });
8679
+ }
8680
+ });
8681
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
8682
+ const expr = [{ token: "CRED", node_index: 0 }];
8683
+ if (chain.length > 2) {
8684
+ expr.push({
8685
+ token: "\u5171\u4EAB\u8F7D\u4F53",
8686
+ node_index: Array.from({ length: chain.length - 2 }, (_, i) => i + 1)
8687
+ });
8688
+ }
8689
+ expr.push({ token: "EXFIL", node_index: chain.length - 1 });
8690
+ return {
8691
+ property_id: "sensitive-data-flow",
8692
+ property_short: "P2",
8693
+ property_name: "\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20",
8694
+ property_expression: expr,
8695
+ nodes: gNodes,
8696
+ edges: gEdges
8697
+ };
8698
+ };
8603
8699
  const witness = graph.witnessIdx;
8604
8700
  const candidates = witness !== void 0 ? [witness] : nodes.map((_, i) => i);
8605
8701
  for (const i of candidates) {
8606
8702
  const n = nodes[i];
8607
8703
  if (tainted.has(i) && EXFIL_RE.test(n.actionDetail)) {
8704
+ const chain = tracePath(i);
8608
8705
  violations.push({
8609
8706
  propertyId: "sensitive-data-flow",
8610
- reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${tainted.get(i)} \u2192 ${n.sessionId}#${n.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
8707
+ reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${tainted.get(i).source} \u2192 ${n.sessionId}#${n.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
8611
8708
  riskLevel: "critical",
8612
- path: [{ sessionId: n.sessionId, seq: n.seq, detail: n.actionDetail.slice(0, 60) }]
8709
+ path: chain.map((j) => ({
8710
+ sessionId: nodes[j].sessionId,
8711
+ seq: nodes[j].seq,
8712
+ detail: nodes[j].actionDetail.slice(0, 60)
8713
+ })),
8714
+ graph: taintGraph(chain)
8613
8715
  });
8614
8716
  if (witness !== void 0) return violations;
8615
8717
  }
@@ -9353,7 +9455,9 @@ async function runAnalysis(input) {
9353
9455
  riskType: "formal_verification",
9354
9456
  confidence: 90,
9355
9457
  source: "formal_engine",
9356
- violatedLayers: ["L2.2"]
9458
+ violatedLayers: ["L2.2"],
9459
+ violationGraph: v?.graph ?? null,
9460
+ violationChain: (v?.path ?? []).map((p) => [p.sessionId, p.seq, p.detail])
9357
9461
  };
9358
9462
  }
9359
9463
  }
package/dist/server.js CHANGED
@@ -1104,6 +1104,20 @@ function redactChain(chain, currentSessionId) {
1104
1104
  return step;
1105
1105
  });
1106
1106
  }
1107
+ function redactGraph(graph, currentSessionId) {
1108
+ if (!graph) return null;
1109
+ if (chainDetailMode() === "full") return graph;
1110
+ const nodes = (graph.nodes || []).map((n) => {
1111
+ const m = { ...n };
1112
+ if (String(m.session_id ?? "") !== String(currentSessionId)) {
1113
+ m.session_id = "<other-session>";
1114
+ m.action_detail = "<\u5DF2\u9690\u85CF>";
1115
+ m.resources = [];
1116
+ }
1117
+ return m;
1118
+ });
1119
+ return { nodes, edges: graph.edges || [] };
1120
+ }
1107
1121
  function sessionSweepThreshold() {
1108
1122
  return envInt("AGENT_MOSS_SESSION_SWEEP_THRESHOLD", 50);
1109
1123
  }
@@ -8994,6 +9008,33 @@ var init_builder = __esm({
8994
9008
  });
8995
9009
 
8996
9010
  // src/engine/formal-engine/properties.ts
9011
+ function graphFromPath(nodes, path6, edgeType = "next", propertyMeta, roles, witnessLast = true, propertyExpression) {
9012
+ const byKey = /* @__PURE__ */ new Map();
9013
+ for (const n of nodes) byKey.set(`${n.sessionId}#${n.seq}`, n);
9014
+ const gNodes = [];
9015
+ const gEdges = [];
9016
+ for (const item of path6) {
9017
+ const n = byKey.get(`${item.sessionId}#${item.seq}`);
9018
+ gNodes.push({
9019
+ session_id: item.sessionId,
9020
+ seq: item.seq,
9021
+ stage: n ? n.stage : "",
9022
+ action_detail: n ? n.actionDetail : item.detail,
9023
+ resources: n ? [...n.resources].sort() : []
9024
+ });
9025
+ if (gNodes.length > 1) {
9026
+ gEdges.push({ from: gNodes.length - 2, to: gNodes.length - 1, type: edgeType });
9027
+ }
9028
+ }
9029
+ (roles || []).forEach((role, i) => {
9030
+ if (i < gNodes.length && role) gNodes[i].role = role;
9031
+ });
9032
+ if (witnessLast && gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
9033
+ const out = { nodes: gNodes, edges: gEdges };
9034
+ if (propertyMeta) Object.assign(out, propertyMeta);
9035
+ if (propertyExpression) out.property_expression = propertyExpression;
9036
+ return out;
9037
+ }
8997
9038
  function parseTs(ts) {
8998
9039
  if (!ts) return null;
8999
9040
  const t = Date.parse(String(ts).trim());
@@ -9051,7 +9092,12 @@ function checkCrossSessionChain(graph) {
9051
9092
  propertyId: "cross-" + chain.id,
9052
9093
  reason: chain.reason,
9053
9094
  riskLevel: chain.riskLevel,
9054
- path: path6
9095
+ path: path6,
9096
+ graph: graphFromPath(nodes, path6, "next", {
9097
+ property_id: "cross-" + chain.id,
9098
+ property_short: "P1",
9099
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE"
9100
+ }, stages, true, stages.map((s, i) => ({ token: s, node_index: i })))
9055
9101
  });
9056
9102
  return violations;
9057
9103
  } else {
@@ -9071,7 +9117,12 @@ function checkCrossSessionChain(graph) {
9071
9117
  propertyId: "cross-" + chain.id,
9072
9118
  reason: chain.reason,
9073
9119
  riskLevel: chain.riskLevel,
9074
- path: path6
9120
+ path: path6,
9121
+ graph: graphFromPath(nodes, path6, "next", {
9122
+ property_id: "cross-" + chain.id,
9123
+ property_short: "P1",
9124
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE"
9125
+ }, stages, true, stages.map((s, i) => ({ token: s, node_index: i })))
9075
9126
  });
9076
9127
  }
9077
9128
  }
@@ -9086,10 +9137,15 @@ function checkSensitiveDataFlow(graph) {
9086
9137
  for (let i = 0; i < nodes.length; i++) {
9087
9138
  const n = nodes[i];
9088
9139
  if (n.stage === "CRED" && [...n.resources].some((r) => CRED_FILE_RE.test(r))) {
9089
- tainted.set(i, `${n.sessionId}#${n.seq} \u8BBF\u95EE ${[...n.resources][0]}`);
9140
+ tainted.set(i, {
9141
+ source: `${n.sessionId}#${n.seq} \u8BBF\u95EE ${[...n.resources][0]}`,
9142
+ parent: null
9143
+ });
9090
9144
  }
9091
9145
  }
9092
9146
  if (tainted.size === 0) return violations;
9147
+ const taintEdges = /* @__PURE__ */ new Map();
9148
+ const edgeKey = (p, c) => `${p}:${c}`;
9093
9149
  const edges = allEdges(graph);
9094
9150
  let changed = true;
9095
9151
  while (changed) {
@@ -9100,7 +9156,8 @@ function checkSensitiveDataFlow(graph) {
9100
9156
  const dstRes = nodes[e.dst].resources;
9101
9157
  for (const r of srcRes) {
9102
9158
  if (dstRes.has(r)) {
9103
- tainted.set(e.dst, tainted.get(e.src));
9159
+ tainted.set(e.dst, { source: tainted.get(e.src).source, parent: e.src });
9160
+ taintEdges.set(edgeKey(e.src, e.dst), r);
9104
9161
  changed = true;
9105
9162
  break;
9106
9163
  }
@@ -9108,16 +9165,75 @@ function checkSensitiveDataFlow(graph) {
9108
9165
  }
9109
9166
  }
9110
9167
  }
9168
+ const tracePath = (idx) => {
9169
+ const chain = [idx];
9170
+ const seen = /* @__PURE__ */ new Set([idx]);
9171
+ while (true) {
9172
+ const parent = tainted.get(chain[chain.length - 1])?.parent;
9173
+ if (parent === void 0 || parent === null || seen.has(parent)) break;
9174
+ chain.push(parent);
9175
+ seen.add(parent);
9176
+ }
9177
+ return chain.reverse();
9178
+ };
9179
+ const taintGraph = (chain) => {
9180
+ const gNodes = [];
9181
+ const gEdges = [];
9182
+ chain.forEach((ni, k) => {
9183
+ const n = nodes[ni];
9184
+ const role = k === 0 ? "source" : k === chain.length - 1 ? "sink" : "carrier";
9185
+ gNodes.push({
9186
+ session_id: n.sessionId,
9187
+ seq: n.seq,
9188
+ stage: n.stage,
9189
+ action_detail: n.actionDetail,
9190
+ resources: [...n.resources].sort(),
9191
+ role
9192
+ });
9193
+ if (k > 0) {
9194
+ const prev = chain[k - 1];
9195
+ gEdges.push({
9196
+ from: k - 1,
9197
+ to: k,
9198
+ type: "data",
9199
+ label: taintEdges.get(edgeKey(prev, ni)) || ""
9200
+ });
9201
+ }
9202
+ });
9203
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
9204
+ const expr = [{ token: "CRED", node_index: 0 }];
9205
+ if (chain.length > 2) {
9206
+ expr.push({
9207
+ token: "\u5171\u4EAB\u8F7D\u4F53",
9208
+ node_index: Array.from({ length: chain.length - 2 }, (_, i) => i + 1)
9209
+ });
9210
+ }
9211
+ expr.push({ token: "EXFIL", node_index: chain.length - 1 });
9212
+ return {
9213
+ property_id: "sensitive-data-flow",
9214
+ property_short: "P2",
9215
+ property_name: "\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20",
9216
+ property_expression: expr,
9217
+ nodes: gNodes,
9218
+ edges: gEdges
9219
+ };
9220
+ };
9111
9221
  const witness = graph.witnessIdx;
9112
9222
  const candidates = witness !== void 0 ? [witness] : nodes.map((_, i) => i);
9113
9223
  for (const i of candidates) {
9114
9224
  const n = nodes[i];
9115
9225
  if (tainted.has(i) && EXFIL_RE.test(n.actionDetail)) {
9226
+ const chain = tracePath(i);
9116
9227
  violations.push({
9117
9228
  propertyId: "sensitive-data-flow",
9118
- reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${tainted.get(i)} \u2192 ${n.sessionId}#${n.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
9229
+ reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${tainted.get(i).source} \u2192 ${n.sessionId}#${n.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
9119
9230
  riskLevel: "critical",
9120
- path: [{ sessionId: n.sessionId, seq: n.seq, detail: n.actionDetail.slice(0, 60) }]
9231
+ path: chain.map((j) => ({
9232
+ sessionId: nodes[j].sessionId,
9233
+ seq: nodes[j].seq,
9234
+ detail: nodes[j].actionDetail.slice(0, 60)
9235
+ })),
9236
+ graph: taintGraph(chain)
9121
9237
  });
9122
9238
  if (witness !== void 0) return violations;
9123
9239
  }
@@ -9659,6 +9775,12 @@ var init_stateful = __esm({
9659
9775
  nodes_ = [];
9660
9776
  ptr = {};
9661
9777
  tainted = /* @__PURE__ */ new Set();
9778
+ /** taint 传播路径:nodeIdx -> 使该节点染毒的前驱 nodeIdx(CRED 源为 null) */
9779
+ taintParent = /* @__PURE__ */ new Map();
9780
+ /** "parent:child" -> 触发传播的共享资源(图上的 data 边标签) */
9781
+ taintEdgeLabel = /* @__PURE__ */ new Map();
9782
+ /** 资源 -> 首次染毒该资源的节点 seq */
9783
+ taintOrigin = /* @__PURE__ */ new Map();
9662
9784
  /** taint 反索引:资源路径 → 已染毒(O(1) 命中)。 */
9663
9785
  taintedResources = /* @__PURE__ */ new Set();
9664
9786
  aliveResources = {};
@@ -9869,7 +9991,8 @@ var init_stateful = __esm({
9869
9991
  stages,
9870
9992
  riskLevel: String(chain.riskLevel || chain.risk_level || "high"),
9871
9993
  reason: String(chain.reason || "\u68C0\u6D4B\u5230\u7EC4\u5408\u653B\u51FB\u94FE"),
9872
- path: this.chainPath(stages)
9994
+ path: this.chainPath(stages),
9995
+ graph: this.buildChainGraph(stages, cid)
9873
9996
  };
9874
9997
  }
9875
9998
  }
@@ -9916,21 +10039,69 @@ var init_stateful = __esm({
9916
10039
  }
9917
10040
  return path6;
9918
10041
  }
10042
+ /** 把链路径转成前端可画的结构化图(相邻节点 next 边)。 */
10043
+ buildChainGraph(stages, chainId) {
10044
+ const path6 = this.chainPath(stages);
10045
+ const gNodes = path6.map((p, i) => {
10046
+ const n = this.nodes_.find((x) => x.sessionId === p.sessionId && x.seq === p.seq);
10047
+ return {
10048
+ session_id: p.sessionId,
10049
+ seq: p.seq,
10050
+ stage: p.stage,
10051
+ action_detail: n ? n.actionDetail : "",
10052
+ resources: n ? [...n.resources].sort() : [],
10053
+ role: stages[i] || ""
10054
+ };
10055
+ });
10056
+ const gEdges = [];
10057
+ for (let i = 1; i < gNodes.length; i++) {
10058
+ gEdges.push({ from: i - 1, to: i, type: "next" });
10059
+ }
10060
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
10061
+ return {
10062
+ property_id: "cross-" + chainId,
10063
+ property_short: "P1",
10064
+ property_name: "\u8DE8\u4F1A\u8BDD\u7EC4\u5408\u94FE",
10065
+ property_expression: stages.map((s, i) => ({ token: s, node_index: i })),
10066
+ nodes: gNodes,
10067
+ edges: gEdges
10068
+ };
10069
+ }
9919
10070
  // ---------------- 敏感数据 taint(P2) ----------------
9920
10071
  advanceTaint(node) {
9921
10072
  if (node.lifecycle === "dead") return;
9922
10073
  const idx = node.seq;
9923
10074
  const taintedRes = /* @__PURE__ */ new Set();
10075
+ let parent = null;
10076
+ let label = "";
10077
+ let foundParent = false;
9924
10078
  for (const r of node.resources) {
9925
- if (this.resourceTainted(r)) taintedRes.add(r);
10079
+ if (this.resourceTainted(r)) {
10080
+ taintedRes.add(r);
10081
+ if (!foundParent) {
10082
+ const origin = this.taintOrigin.get(r);
10083
+ if (origin !== void 0) {
10084
+ parent = origin;
10085
+ label = r;
10086
+ foundParent = true;
10087
+ }
10088
+ }
10089
+ }
9926
10090
  }
9927
10091
  if (node.stage === "CRED" && node.resources.some((r) => CRED_FILE_RE2.test(r))) {
9928
10092
  this.tainted.add(idx);
10093
+ parent = null;
10094
+ label = "";
9929
10095
  for (const r of node.resources) taintedRes.add(r);
9930
10096
  }
9931
10097
  if (taintedRes.size > 0) {
9932
10098
  this.tainted.add(idx);
9933
- for (const r of node.resources) this.taintedResources.add(r);
10099
+ this.taintParent.set(idx, parent);
10100
+ if (parent !== null) this.taintEdgeLabel.set(`${parent}:${idx}`, label);
10101
+ for (const r of node.resources) {
10102
+ this.taintedResources.add(r);
10103
+ if (!this.taintOrigin.has(r)) this.taintOrigin.set(r, idx);
10104
+ }
9934
10105
  }
9935
10106
  }
9936
10107
  resourceTainted(resource) {
@@ -9940,16 +10111,84 @@ var init_stateful = __esm({
9940
10111
  checkExfilAfterTaint(node) {
9941
10112
  if (!this.tainted.has(node.seq)) return [];
9942
10113
  if (EXFIL_RE2.test(node.actionDetail)) {
10114
+ const chain = this.traceTaintChain(node.seq);
9943
10115
  return [{
9944
10116
  property_id: "sensitive-data-flow",
9945
- reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${this.taintSource(node)} \u2192 ${node.sessionId}#${node.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
10117
+ reason: `\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20\uFF1A${this.taintSource(node, chain)} \u2192 ${node.sessionId}#${node.seq} \u7ECF\u5171\u4EAB\u8F7D\u4F53\u5916\u53D1`,
9946
10118
  risk_level: "critical",
9947
- path: [[node.sessionId, node.seq, node.actionDetail.slice(0, 60)]]
10119
+ path: chain.map((j) => {
10120
+ const n = this.nodes_[j];
10121
+ return [n.sessionId, n.seq, n.actionDetail.slice(0, 60), n.timestamp];
10122
+ }),
10123
+ graph: this.taintGraph(chain)
9948
10124
  }];
9949
10125
  }
9950
10126
  return [];
9951
10127
  }
9952
- taintSource(node) {
10128
+ /** 沿 taint parent 回溯出 CRED 源 → 当前节点的完整路径。 */
10129
+ traceTaintChain(idx) {
10130
+ const chain = [idx];
10131
+ const seen = /* @__PURE__ */ new Set([idx]);
10132
+ while (true) {
10133
+ const parent = this.taintParent.get(chain[chain.length - 1]);
10134
+ if (parent === void 0 || parent === null || seen.has(parent)) break;
10135
+ chain.push(parent);
10136
+ seen.add(parent);
10137
+ }
10138
+ return chain.reverse();
10139
+ }
10140
+ /** 把 taint 路径转成前端可画的结构化图(data 边带共享资源标签)。 */
10141
+ taintGraph(chain) {
10142
+ const gNodes = [];
10143
+ const gEdges = [];
10144
+ chain.forEach((ni, k) => {
10145
+ const n = this.nodes_[ni];
10146
+ const role = k === 0 ? "source" : k === chain.length - 1 ? "sink" : "carrier";
10147
+ gNodes.push({
10148
+ session_id: n.sessionId,
10149
+ seq: n.seq,
10150
+ stage: n.stage,
10151
+ action_detail: n.actionDetail,
10152
+ resources: [...n.resources].sort(),
10153
+ role
10154
+ });
10155
+ if (k > 0) {
10156
+ const prev = chain[k - 1];
10157
+ gEdges.push({
10158
+ from: k - 1,
10159
+ to: k,
10160
+ type: "data",
10161
+ label: this.taintEdgeLabel.get(`${prev}:${ni}`) || ""
10162
+ });
10163
+ }
10164
+ });
10165
+ if (gNodes.length > 0) gNodes[gNodes.length - 1].witness = true;
10166
+ const expr = [{ token: "CRED", node_index: 0 }];
10167
+ if (chain.length > 2) {
10168
+ expr.push({
10169
+ token: "\u5171\u4EAB\u8F7D\u4F53",
10170
+ node_index: Array.from({ length: chain.length - 2 }, (_, i) => i + 1)
10171
+ });
10172
+ }
10173
+ expr.push({ token: "EXFIL", node_index: chain.length - 1 });
10174
+ return {
10175
+ property_id: "sensitive-data-flow",
10176
+ property_short: "P2",
10177
+ property_name: "\u654F\u611F\u6570\u636E\u4F20\u64AD\u5916\u4F20",
10178
+ property_expression: expr,
10179
+ nodes: gNodes,
10180
+ edges: gEdges
10181
+ };
10182
+ }
10183
+ taintSource(node, chain = []) {
10184
+ if (chain.length > 0) {
10185
+ const src = this.nodes_[chain[0]];
10186
+ if (src) {
10187
+ for (const r of node.resources) {
10188
+ if (src.resources.includes(r)) return `${src.sessionId}#${src.seq} \u8BBF\u95EE ${r}`;
10189
+ }
10190
+ }
10191
+ }
9953
10192
  const sorted = [...this.tainted].sort((a, b) => a - b);
9954
10193
  for (const i of sorted) {
9955
10194
  if (i === node.seq) continue;
@@ -9968,6 +10207,9 @@ var init_stateful = __esm({
9968
10207
  nodes: this.nodes_.map((n) => ({ ...n, resources: [...n.resources] })),
9969
10208
  ptrs: { ...this.ptr },
9970
10209
  tainted: [...this.tainted].sort((a, b) => a - b),
10210
+ // taint 传播路径(前端攻击链图);边标签不入快照,重启后可从资源交集重建
10211
+ taint_parent: Object.fromEntries([...this.taintParent.entries()].map(([k, v]) => [String(k), v])),
10212
+ taint_origin: Object.fromEntries([...this.taintOrigin.entries()]),
9971
10213
  alive: { ...this.aliveResources },
9972
10214
  seq_next: this.seqNext
9973
10215
  };
@@ -9995,15 +10237,53 @@ var init_stateful = __esm({
9995
10237
  this.seqNext = Number(snapshot.seq_next || this.nodes_.length);
9996
10238
  this.ptr = Object.fromEntries(Object.entries(snapshot.ptrs || {}).map(([k, v]) => [k, Number(v)]));
9997
10239
  this.tainted = new Set((snapshot.tainted || []).map(Number));
10240
+ this.taintParent = new Map(
10241
+ Object.entries(snapshot.taint_parent || {}).map(
10242
+ ([k, v]) => [Number(k), v === null || v === void 0 ? null : Number(v)]
10243
+ )
10244
+ );
10245
+ this.taintOrigin = new Map(
10246
+ Object.entries(snapshot.taint_origin || {}).map(
10247
+ ([k, v]) => [String(k), Number(v)]
10248
+ )
10249
+ );
10250
+ this.taintEdgeLabel = /* @__PURE__ */ new Map();
9998
10251
  this.taintedResources = /* @__PURE__ */ new Set();
9999
10252
  for (const i of this.tainted) {
10000
10253
  const n = this.nodes_[i];
10001
10254
  if (n) for (const r of n.resources) this.taintedResources.add(r);
10002
10255
  }
10256
+ if (this.tainted.size > 0 && this.taintParent.size === 0) this.rebuildTaintParent();
10257
+ if (this.taintOrigin.size === 0) {
10258
+ for (const i of [...this.tainted].sort((a, b) => a - b)) {
10259
+ const n = this.nodes_[i];
10260
+ if (!n) continue;
10261
+ for (const r of n.resources) {
10262
+ if (!this.taintOrigin.has(r)) this.taintOrigin.set(r, i);
10263
+ }
10264
+ }
10265
+ }
10003
10266
  this.aliveResources = Object.fromEntries(
10004
10267
  Object.entries(snapshot.alive || {}).map(([k, v]) => [k, Number(v)])
10005
10268
  );
10006
10269
  }
10270
+ /** 旧快照无 parent 信息时,按 seq 顺序找最近的资源相交染毒前驱。 */
10271
+ rebuildTaintParent() {
10272
+ for (const i of [...this.tainted].sort((a, b) => a - b)) {
10273
+ const n = this.nodes_[i];
10274
+ if (!n) continue;
10275
+ let parent = null;
10276
+ for (let j = i - 1; j >= 0; j--) {
10277
+ if (!this.tainted.has(j)) continue;
10278
+ const m = this.nodes_[j];
10279
+ if (m && m.resources.some((r) => n.resources.includes(r))) {
10280
+ parent = j;
10281
+ break;
10282
+ }
10283
+ }
10284
+ this.taintParent.set(i, parent);
10285
+ }
10286
+ }
10007
10287
  };
10008
10288
  }
10009
10289
  });
@@ -10427,7 +10707,9 @@ async function runAnalysis(input) {
10427
10707
  riskType: "formal_verification",
10428
10708
  confidence: 90,
10429
10709
  source: "formal_engine",
10430
- violatedLayers: ["L2.2"]
10710
+ violatedLayers: ["L2.2"],
10711
+ violationGraph: v?.graph ?? null,
10712
+ violationChain: (v?.path ?? []).map((p) => [p.sessionId, p.seq, p.detail])
10431
10713
  };
10432
10714
  }
10433
10715
  }
@@ -10800,7 +11082,42 @@ var AnalyzeResponseSchema = z.object({
10800
11082
  confidence: z.number().int().min(0).max(100).default(100),
10801
11083
  analysis_duration_ms: z.number().default(0),
10802
11084
  // 组合攻击链反例路径:每步 [session_id, seq, stage, timestamp],含真实发生时间
10803
- violation_chain: z.array(z.array(z.union([z.string(), z.number()]).nullable())).default([])
11085
+ violation_chain: z.array(z.array(z.union([z.string(), z.number()]).nullable())).default([]),
11086
+ // 结构化攻击链图(前端可视化):节点 + 边(next 时序 / data 数据流)
11087
+ violation_graph: z.object({
11088
+ // 性质元信息(前端展示"违反了哪条性质")
11089
+ property_id: z.string().optional(),
11090
+ property_short: z.string().optional(),
11091
+ property_name: z.string().optional(),
11092
+ // 性质表达式 token → 节点位置(前端展示"表达式对应哪个节点")
11093
+ property_expression: z.array(
11094
+ z.object({
11095
+ token: z.string(),
11096
+ node_index: z.union([z.number(), z.array(z.number())])
11097
+ })
11098
+ ).optional(),
11099
+ nodes: z.array(
11100
+ z.object({
11101
+ session_id: z.string().default(""),
11102
+ seq: z.number().default(0),
11103
+ stage: z.string().default(""),
11104
+ action_detail: z.string().default(""),
11105
+ resources: z.array(z.string()).default([]),
11106
+ // 节点在性质中的角色:source/carrier/sink(P2)或阶段名(P1)
11107
+ role: z.string().optional(),
11108
+ // 是否为违例见证节点(当前动作补全链 / 触发外传)
11109
+ witness: z.boolean().optional()
11110
+ })
11111
+ ),
11112
+ edges: z.array(
11113
+ z.object({
11114
+ from: z.number(),
11115
+ to: z.number(),
11116
+ type: z.enum(["next", "data"]).default("next"),
11117
+ label: z.string().optional()
11118
+ })
11119
+ )
11120
+ }).nullable().default(null)
10804
11121
  });
10805
11122
  var HealthResponseSchema = z.object({
10806
11123
  status: z.string().default("healthy"),
@@ -11224,7 +11541,9 @@ async function analyzeInner(input, mossLog) {
11224
11541
  violated_layers: judgment.violatedLayers || [],
11225
11542
  policy: "",
11226
11543
  violated_policy: `[${judgment.riskType}] ${judgment.reason}`,
11227
- confidence: judgment.confidence
11544
+ confidence: judgment.confidence,
11545
+ violation_graph: judgment.violationGraph ?? null,
11546
+ violation_chain: judgment.violationChain ?? []
11228
11547
  };
11229
11548
  mossLog.logResult("Deny", denied.reason);
11230
11549
  return denied;
@@ -11781,7 +12100,8 @@ async function analyzeRoute(c) {
11781
12100
  confidence: 90,
11782
12101
  analysis_duration_ms: Math.round((performance.now() - t0) * 10) / 10,
11783
12102
  // 跨会话节点脱敏(时间线按 agent_id 共享,原样返回等于泄露别人的命令原文)
11784
- violation_chain: redactChain(judgment.violation_chain, req.session_id)
12103
+ violation_chain: redactChain(judgment.violation_chain, req.session_id),
12104
+ violation_graph: redactGraph(judgment.violation_graph, req.session_id)
11785
12105
  })
11786
12106
  );
11787
12107
  }
@@ -11801,7 +12121,9 @@ async function analyzeRoute(c) {
11801
12121
  policy: result.policy,
11802
12122
  violated_policy: result.violated_policy,
11803
12123
  confidence: result.confidence,
11804
- analysis_duration_ms: Math.round(durationMs * 10) / 10
12124
+ analysis_duration_ms: Math.round(durationMs * 10) / 10,
12125
+ violation_chain: result.violation_chain ?? [],
12126
+ violation_graph: redactGraph(result.violation_graph, req.session_id)
11805
12127
  })
11806
12128
  );
11807
12129
  } catch (err) {
@@ -11832,7 +12154,8 @@ function incMatchedToDeny(inc) {
11832
12154
  property_id: "cross-" + inc.matched.chainId,
11833
12155
  reason: inc.matched.reason,
11834
12156
  risk_level: inc.matched.riskLevel,
11835
- violation_chain: chain
12157
+ violation_chain: chain,
12158
+ violation_graph: inc.matched.graph ?? null
11836
12159
  };
11837
12160
  }
11838
12161
  if (inc.exfil && inc.exfil.length > 0) {
@@ -11840,7 +12163,9 @@ function incMatchedToDeny(inc) {
11840
12163
  property_id: "sensitive-data-flow",
11841
12164
  reason: inc.exfil[0].reason,
11842
12165
  risk_level: inc.exfil[0].risk_level,
11843
- violation_chain: []
12166
+ // P2 的 path 现在包含 CRED 源 → 中间载体 → 外传点的完整链
12167
+ violation_chain: inc.exfil[0].path ?? [],
12168
+ violation_graph: inc.exfil[0].graph ?? null
11844
12169
  };
11845
12170
  }
11846
12171
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kenhkl/agent-moss",
3
- "version": "0.17.7",
3
+ "version": "0.17.8",
4
4
  "description": "Multi-layer security analysis engine for AI agents (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",