From 11ab5cde8f354b260a2847f91e61b90a9ed9c75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:28:34 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 66 +++++++++++++++++++++++++++++++++---- web/static/i18n/en-US.json | 15 ++++++--- web/static/i18n/zh-CN.json | 15 ++++++--- web/static/js/fact-graph.js | 55 ++++++++++++++++++++++++++++++- web/static/js/projects.js | 44 +++++++++++++++---------- web/templates/index.html | 26 ++++++++------- 6 files changed, 175 insertions(+), 46 deletions(-) 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: <可选> - 依赖事实: -- 结构化出边(自动同步): + - 结构化关系边(自动同步;links 文本格式 type: source_fact_key): - discovered_on: target/primary_domain ## 备注与不确定性 @@ -798,13 +798,14 @@ async function handleGraphConnectNodePick(factKey) { loadProjectFacts(); } -function formatOutgoingLinksForModal(links) { +function formatIncomingLinksForModal(links) { if (!links || !links.length) return ''; return links - .map((e) => `${e.edge_type || e.type}: ${e.target_fact_key || e.to}`) + .map((e) => `${e.edge_type || e.type}: ${e.source_fact_key || e.from}`) .join('\n'); } + async function loadProjectFactGraph() { const container = document.getElementById('project-fact-graph-container'); const statsEl = document.getElementById('project-fact-graph-stats'); @@ -887,9 +888,9 @@ function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) { return edges .map((e) => { const isOut = e.source === factKey; - const dirLabel = isOut ? tp('projects.graphEdgeOutgoing') : tp('projects.graphEdgeIncoming'); - const other = isOut ? e.target : e.source; - const arrow = isOut ? '→' : '←'; + const dirLabel = isOut ? tp('projects.graphEdgeFromSelf') : tp('projects.graphEdgeToSelf'); + const src = e.source || ''; + const tgt = e.target || ''; const selected = e.id === selectedEdgeId ? ' is-selected' : ''; const synthetic = isSyntheticGraphEdge(e); const deleteBtn = synthetic @@ -898,8 +899,7 @@ function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) { return `
${escapeHtml(dirLabel)} ${escapeHtml(e.type || '')} - ${arrow} - ${escapeHtml(other)} + ${escapeHtml(src)} → ${escapeHtml(tgt)} ${deleteBtn}
`; }) @@ -935,17 +935,25 @@ function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) { if (!sidebar || !titleEl || !metaEl) return; titleEl.textContent = factKey; if (categoryEl) { - const cat = node?.category || node?.type || ''; - categoryEl.textContent = cat; - categoryEl.hidden = !cat; - categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + (cat || 'note'); + const visualType = + typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.resolveGraphNodeType + ? ProjectFactGraph.resolveGraphNodeType(node) + : node?.type || node?.category || 'note'; + const theme = + typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.nodeTheme + ? ProjectFactGraph.nodeTheme(visualType) + : { typeEn: String(visualType).toUpperCase(), typeLabel: visualType }; + categoryEl.textContent = theme.typeEn || String(visualType).toUpperCase(); + categoryEl.hidden = false; + categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + visualType; + categoryEl.title = theme.typeLabel || visualType; } const conf = node?.confidence || ''; - const label = node?.label || ''; - if (label || conf) { + const summary = (node?.summary || node?.label || '').trim(); + if (summary || conf) { const parts = []; - if (label) { - parts.push(`${escapeHtml(label)}`); + if (summary) { + parts.push(`${escapeHtml(summary)}`); } if (conf) { parts.push(formatConfidenceBadge(conf)); @@ -1806,6 +1814,8 @@ function resetFactModalForm() { if (rel) rel.value = ''; const linksEl = document.getElementById('fact-modal-links'); if (linksEl) linksEl.value = ''; + const incomingWrap = document.getElementById('fact-modal-incoming-links-wrap'); + if (incomingWrap) incomingWrap.hidden = true; updateFactFormHints(); } @@ -1838,7 +1848,7 @@ function fillFactModalForm(f) { const rel = document.getElementById('fact-modal-related-vuln'); if (rel) rel.value = f.related_vulnerability_id || ''; const linksEl = document.getElementById('fact-modal-links'); - if (linksEl) linksEl.value = formatOutgoingLinksForModal(f.outgoing_links); + if (linksEl) linksEl.value = formatIncomingLinksForModal(f.incoming_links); const pinEl = document.getElementById('fact-modal-pinned'); if (pinEl) pinEl.checked = !!f.pinned; updateFactFormHints(); diff --git a/web/templates/index.html b/web/templates/index.html index 2f2d1c40..796a256c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1607,7 +1607,7 @@ - 攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。 + 攻击路径图箭头与事实存储方向一致(source → target);节点按 target→infra→finding→exploit 分层排布。虚线边为待确认。

-
@@ -1659,7 +1653,7 @@

@@ -1668,7 +1662,15 @@
-
+