diff --git a/web/static/css/style.css b/web/static/css/style.css
index 58e319ac..b3edd1ed 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -24545,6 +24545,9 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
flex: 1 1 auto;
min-height: 0;
}
+#project-panel-graph .project-fact-graph-footer {
+ flex: 0 0 auto;
+}
.projects-graph-toolbar-row {
align-items: flex-end;
}
@@ -24603,8 +24606,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
display: flex;
flex-wrap: wrap;
align-items: center;
- gap: 8px 14px;
- padding-top: 2px;
+ justify-content: flex-end;
+ gap: 6px 12px;
}
.projects-graph-legend-item {
display: inline-flex;
@@ -24778,10 +24781,16 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
border: 1px solid #e2e8f0;
}
.project-fact-graph-node-category--target { color: #4338ca; background: #eef2ff; border-color: #c7d2fe; }
-.project-fact-graph-node-category--finding { color: #be123c; background: #fff1f2; border-color: #fecdd3; }
+.project-fact-graph-node-category--finding,
+.project-fact-graph-node-category--vulnerability { color: #be123c; background: #fff1f2; border-color: #fecdd3; }
.project-fact-graph-node-category--exploit,
.project-fact-graph-node-category--poc { color: #c2410c; background: #ffedd5; border-color: #fdba74; }
+.project-fact-graph-node-category--chain { color: #6d28d9; background: #f5f3ff; border-color: #ddd6fe; }
.project-fact-graph-node-category--auth { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
+.project-fact-graph-node-category--infra { color: #475569; background: #f1f5f9; border-color: #cbd5e1; }
+.project-fact-graph-node-category--business { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
+.project-fact-graph-node-category--note { color: #64748b; background: #f8fafc; border-color: #e2e8f0; }
+.project-fact-graph-node-category--missing { color: #94a3b8; background: #f1f5f9; border-color: #e2e8f0; font-style: italic; }
.project-fact-graph-sidebar-close {
flex-shrink: 0;
display: inline-flex;
@@ -24861,7 +24870,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
}
.project-fact-graph-edge-item {
display: grid;
- grid-template-columns: auto auto auto 1fr auto;
+ grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 4px 6px;
padding: 6px 8px;
@@ -24931,6 +24940,42 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
color: #cbd5e1;
flex-shrink: 0;
}
+.projects-incoming-links-readonly {
+ margin-top: 4px;
+}
+.projects-incoming-links-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.projects-incoming-links-item {
+ padding: 8px 10px;
+ font-size: 0.8125rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #f8fafc;
+ color: #334155;
+ word-break: break-all;
+}
+.projects-incoming-links-item code {
+ font-size: 0.75rem;
+}
+.projects-incoming-links-empty {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: #94a3b8;
+}
+.projects-edge-type {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.75rem;
+ color: #4338ca;
+}
+.projects-edge-arrow {
+ color: #94a3b8;
+}
.project-fact-graph-sidebar-actions {
display: flex;
gap: 8px;
@@ -24938,13 +24983,22 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
padding-top: 4px;
border-top: 1px solid #f1f5f9;
}
+.project-fact-graph-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px 12px;
+ margin: 10px 0 0;
+ flex: 0 0 auto;
+}
.project-fact-graph-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
- margin: 12px 0 0;
- flex: 0 0 auto;
+ margin: 0;
+ flex: 0 1 auto;
}
.projects-graph-stat-badge {
display: inline-flex;
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index 2ff498d7..bb94a4c6 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -263,7 +263,7 @@
"tabVulns": "Related vulnerabilities",
"tabSettings": "Settings",
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
- "graphToolbarHint": "Attack path graph shows target → finding → exploit causality. Dashed edges are tentative. Click a node for details.",
+ "graphToolbarHint": "Graph arrows match stored fact links (source → target). Nodes are layered target→infra→finding→exploit. Dashed edges are tentative.",
"graphView": "View",
"graphViewPath": "Attack path",
"graphViewFull": "Full graph",
@@ -284,9 +284,14 @@
"graphLegendLeads": "leads_to",
"graphLegendExploits": "exploits",
"graphLegendTentative": "Tentative (dashed)",
- "factLinksLabel": "Outgoing links",
- "factLinksPlaceholder": "discovered_on: target/primary_domain\nleads_to: finding/swagger",
- "factLinksHint": "One per line: type: fact_key. Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all outgoing links.",
+ "factLinksLabel": "Links (from → this fact)",
+ "factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce",
+ "factLinksHint": "One per line: type: source_fact_key (source → this fact). Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all links.",
+ "factIncomingLinksLabel": "Incoming links (read-only)",
+ "factIncomingLinksHint": "Derived from outgoing links on source facts. e.g. finding discovered_on → target/* appears as incoming on the target; edit the source fact's outgoing links.",
+ "factIncomingLinksEmpty": "No incoming links",
+ "graphEdgeFromSelf": "From this node",
+ "graphEdgeToSelf": "To this node",
"linksColumn": "Links",
"linkCountsTitle": "Outgoing / incoming edge counts",
"graphConnect": "Connect",
@@ -296,7 +301,7 @@
"graphConnectFailed": "Failed to create edge",
"graphConnectSuccess": "Edge created",
"graphEdgesTitle": "Links",
- "graphEdgesHint": "Click an edge in the graph to focus it. Delete mistaken links here.",
+ "graphEdgesHint": "Arrow direction matches the database and edit modal (source → target). Click an edge to focus it.",
"graphEdgesEmpty": "No links yet",
"graphEdgeOutgoing": "Outgoing",
"graphEdgeIncoming": "Incoming",
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index ab2dfcde..f697869f 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -251,7 +251,7 @@
"tabVulns": "关联漏洞",
"tabSettings": "设置",
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现",
- "graphToolbarHint": "攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。",
+ "graphToolbarHint": "攻击路径图箭头与事实存储方向一致(source → target);节点按 target→infra→finding→exploit 分层排布。虚线边为待确认。",
"graphView": "视图",
"graphViewPath": "攻击路径",
"graphViewFull": "完整关系",
@@ -272,9 +272,14 @@
"graphLegendLeads": "leads_to",
"graphLegendExploits": "exploits",
"graphLegendTentative": "待确认(虚线)",
- "factLinksLabel": "关系边(出边)",
- "factLinksPlaceholder": "discovered_on: target/primary_domain\nleads_to: finding/swagger",
- "factLinksHint": "每行一条:type: fact_key。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。",
+ "factLinksLabel": "关系边(from → 本事实)",
+ "factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce",
+ "factLinksHint": "每行一条:type: source_fact_key(来源 → 当前事实)。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。",
+ "factIncomingLinksLabel": "入边(只读)",
+ "factIncomingLinksHint": "由来源事实的出边产生。例如 finding 的 discovered_on → target/*,在目标上会显示为入边;请编辑来源事实的出边。",
+ "factIncomingLinksEmpty": "暂无入边",
+ "graphEdgeFromSelf": "本节点指出",
+ "graphEdgeToSelf": "指向本节点",
"linksColumn": "关系",
"linkCountsTitle": "出边数 / 入边数",
"graphConnect": "连边",
@@ -284,7 +289,7 @@
"graphConnectFailed": "创建边失败",
"graphConnectSuccess": "边已创建",
"graphEdgesTitle": "关系边",
- "graphEdgesHint": "点击图中连线可定位;误连可在此删除。",
+ "graphEdgesHint": "箭头方向与数据库/编辑弹窗一致(source → target);点击连线可定位。",
"graphEdgesEmpty": "暂无关系边",
"graphEdgeOutgoing": "出边",
"graphEdgeIncoming": "入边",
diff --git a/web/static/js/fact-graph.js b/web/static/js/fact-graph.js
index e604e23d..200b2639 100644
--- a/web/static/js/fact-graph.js
+++ b/web/static/js/fact-graph.js
@@ -45,12 +45,20 @@
return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' };
case 'finding':
return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'vulnerability' };
+ case 'exploit':
+ return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability' };
case 'vulnerability':
return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#BE123C', bgEnd: '#FFF1F2', icon: 'vulnerability' };
case 'auth':
return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' };
case 'infra':
return { typeLabel: '基础设施', typeEn: 'INFRA', accent: '#64748B', bgEnd: '#F8FAFC', icon: 'default' };
+ case 'chain':
+ return { typeLabel: '攻击链', typeEn: 'CHAIN', accent: '#7C3AED', bgEnd: '#F5F3FF', icon: 'vulnerability' };
+ case 'poc':
+ return { typeLabel: 'POC', typeEn: 'POC', accent: '#C2410C', bgEnd: '#FFEDD5', icon: 'vulnerability' };
+ case 'business':
+ return { typeLabel: '业务', typeEn: 'BUSINESS', accent: '#0369A1', bgEnd: '#F0F9FF', icon: 'default' };
case 'missing':
return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' };
default:
@@ -261,6 +269,24 @@
}
}
+ // ELK 分层(仅影响节点纵向位置,不修改边的 source/target)
+ function pathGraphNodeLayer(type, factKey) {
+ const key = (factKey || '').toLowerCase();
+ if (key.startsWith('vuln:')) return '4';
+ if (key.startsWith('target/')) return '0';
+ if (key.startsWith('infra/') || key.startsWith('auth/') || key.startsWith('business/')) return '1';
+ if (key.startsWith('exploit/') || key.startsWith('evidence/')) return '3';
+ if (key.startsWith('poc/')) return '3';
+ if (key.startsWith('chain/')) return '2';
+ if (key.startsWith('finding/')) return '2';
+ const t = (type || '').toLowerCase();
+ if (t === 'target') return '0';
+ if (t === 'infra' || t === 'auth') return '1';
+ if (t === 'exploit' || t === 'poc') return '3';
+ if (t === 'chain' || t === 'finding' || t === 'vulnerability') return '2';
+ return '2';
+ }
+
function applyElkLayout(validEdges, isComplex) {
const layoutOptions = {
name: 'breadthfirst',
@@ -290,7 +316,15 @@
const n = _cy ? _cy.getElementById(node.id) : null;
const w = n.length ? n.data('nodeWidth') : node.type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
const h = n.length ? n.data('nodeHeight') : CARD_MIN_H;
- return { id: node.id, width: w, height: h };
+ const nodeKey = node.fact_key || node.id;
+ return {
+ id: node.id,
+ width: w,
+ height: h,
+ layoutOptions: {
+ 'org.eclipse.elk.layered.layering.layerId': pathGraphNodeLayer(node.type, nodeKey),
+ },
+ };
}),
edges: validEdges.map((edge) => ({
id: edge.id,
@@ -543,6 +577,23 @@
}
}
+ /** 与后端 GraphNodeType 一致:优先 fact_key 前缀,再 category/type。 */
+ function resolveGraphNodeType(node) {
+ if (!node) return 'note';
+ const key = String(node.fact_key || node.id || '').toLowerCase();
+ if (key.startsWith('target/')) return 'target';
+ if (key.startsWith('exploit/') || key.startsWith('poc/') || key.startsWith('evidence/')) return 'exploit';
+ if (key.startsWith('chain/')) return 'chain';
+ if (key.startsWith('finding/')) return 'finding';
+ if (key.startsWith('auth/')) return 'auth';
+ if (key.startsWith('infra/')) return 'infra';
+ if (key.startsWith('business/')) return 'business';
+ if (key.startsWith('vuln:')) return 'vulnerability';
+ const t = String(node.type || node.category || 'note').toLowerCase();
+ if (t === 'vuln') return 'vulnerability';
+ return t || 'note';
+ }
+
global.ProjectFactGraph = {
render,
destroy,
@@ -551,5 +602,7 @@
setConnectMode,
selectEdge,
clearEdgeSelection,
+ nodeTheme,
+ resolveGraphNodeType,
};
})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/web/static/js/projects.js b/web/static/js/projects.js
index 20cb554d..2e1e4fff 100644
--- a/web/static/js/projects.js
+++ b/web/static/js/projects.js
@@ -64,7 +64,7 @@ Host: ...
## 关联
- related_vulnerability_id: <可选>
- 依赖事实:
点击图中连线可定位;误连可在此删除。
+箭头方向与数据库/编辑弹窗一致(source → target);点击连线可定位。
每行一条:type: fact_key。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。
+ + +每行一条:type: source_fact_key(来源 → 当前事实)。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。