Add files via upload

This commit is contained in:
公明
2026-06-21 01:52:14 +08:00
committed by GitHub
parent ecf594a25b
commit e19d8e39bd
6 changed files with 236 additions and 49 deletions
+63 -9
View File
@@ -24536,17 +24536,30 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
flex-direction: column;
overflow: hidden;
min-height: 0;
padding-bottom: 16px;
padding-bottom: 0;
}
#project-panel-graph .projects-graph-toolbar {
flex: 0 0 auto;
}
#project-panel-graph .project-fact-graph-layout {
flex: 1 1 auto;
flex: 1 1 0;
min-height: 0;
max-height: 100%;
overflow: hidden;
}
#project-panel-graph .project-fact-graph-container {
min-height: 0;
height: 100%;
}
#project-panel-graph .project-fact-graph-footer {
flex: 0 0 auto;
flex-shrink: 0;
position: relative;
z-index: 20;
margin: 0;
padding: 10px 0 12px;
background: #fff;
border-top: 1px solid #eef2f7;
}
.projects-graph-toolbar-row {
align-items: flex-end;
@@ -24607,7 +24620,27 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px 12px;
gap: 8px 14px;
}
.projects-graph-legend-group {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}
.projects-graph-legend-heading {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #94a3b8;
}
.projects-graph-legend-divider {
display: inline-block;
width: 1px;
height: 18px;
background: #e2e8f0;
flex: 0 0 auto;
}
.projects-graph-legend-item {
display: inline-flex;
@@ -24616,27 +24649,40 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
font-size: 0.75rem;
color: #64748b;
}
.projects-graph-legend-item i {
.projects-graph-legend-item--edge i {
display: inline-block;
width: 22px;
height: 0;
border-top: 2.5px solid var(--legend-color, #cbd5e1);
border-radius: 2px;
}
.projects-graph-legend-item--dashed i {
.projects-graph-legend-item--edge.projects-graph-legend-item--dashed i {
border-top-style: dashed;
opacity: 0.7;
}
.projects-graph-legend-item--node i {
display: inline-block;
width: 14px;
height: 14px;
border: 1.5px solid var(--legend-color, #cbd5e1);
border-radius: 4px;
background: linear-gradient(135deg, #ffffff 0%, var(--legend-bg, #f8fafc) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.projects-graph-legend-item--node-dashed i {
border-style: dashed;
opacity: 0.85;
}
.project-fact-graph-layout {
position: relative;
display: flex;
min-height: 480px;
min-height: 0;
align-items: stretch;
}
.project-fact-graph-container {
flex: 1 1 auto;
width: 100%;
min-height: 480px;
min-height: 240px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 14px;
background-color: #f8fafc;
@@ -24781,8 +24827,8 @@ 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,
.project-fact-graph-node-category--vulnerability { color: #be123c; background: #fff1f2; border-color: #fecdd3; }
.project-fact-graph-node-category--finding { color: #be123c; background: #fff1f2; border-color: #fecdd3; }
.project-fact-graph-node-category--vulnerability { color: #7e22ce; background: #f5f3ff; border-color: #ddd6fe; }
.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; }
@@ -24831,6 +24877,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
min-width: 0;
color: #475569;
}
.project-fact-graph-node-vuln-hint {
display: block;
width: 100%;
font-size: 0.75rem;
line-height: 1.45;
color: #64748b;
}
.project-fact-graph-edges-wrap {
flex: 1 1 auto;
min-height: 0;
@@ -24991,6 +25044,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
gap: 8px 12px;
margin: 10px 0 0;
flex: 0 0 auto;
flex-shrink: 0;
}
.project-fact-graph-stats {
display: flex;
+10
View File
@@ -280,6 +280,14 @@
"graphStats": "Nodes: {{nodes}} | Edges: {{edges}}",
"graphStatsNodes": "Nodes",
"graphStatsEdges": "Edges",
"graphLegendNodes": "Nodes",
"graphLegendEdges": "Edges",
"graphLegendNodeTarget": "TARGET",
"graphLegendNodeInfra": "INFRA",
"graphLegendNodeFinding": "FINDING",
"graphLegendNodeVuln": "VULN",
"graphLegendNodeExploit": "EXPLOIT",
"graphLegendNodeMissing": "MISSING",
"graphLegendDiscovered": "discovered_on",
"graphLegendLeads": "leads_to",
"graphLegendExploits": "exploits",
@@ -310,6 +318,8 @@
"graphEdgeDeleteFailed": "Failed to delete edge",
"graphEdgeDeleteSuccess": "Edge deleted",
"graphDeleteEdge": "Delete",
"viewVulnerability": "View vulnerability",
"graphVulnSidebarHint": "Linked vulnerability node. Use the button below to open it in Vulnerability Management.",
"promoteAttackChain": "Promote chain",
"promoteAttackChainTitle": "Promote conversation attack chain to project facts",
"confirmPromoteAttackChain": "Promote this conversation's attack chain into the project? Facts and edges will be created or updated.",
+10
View File
@@ -268,6 +268,14 @@
"graphStats": "节点: {{nodes}} | 边: {{edges}}",
"graphStatsNodes": "节点",
"graphStatsEdges": "边",
"graphLegendNodes": "节点",
"graphLegendEdges": "连线",
"graphLegendNodeTarget": "TARGET · 目标",
"graphLegendNodeInfra": "INFRA · 基础设施",
"graphLegendNodeFinding": "FINDING · 发现",
"graphLegendNodeVuln": "VULN · 漏洞",
"graphLegendNodeExploit": "EXPLOIT · 利用",
"graphLegendNodeMissing": "MISSING · 缺失",
"graphLegendDiscovered": "discovered_on",
"graphLegendLeads": "leads_to",
"graphLegendExploits": "exploits",
@@ -298,6 +306,8 @@
"graphEdgeDeleteFailed": "删除边失败",
"graphEdgeDeleteSuccess": "边已删除",
"graphDeleteEdge": "删边",
"viewVulnerability": "查看漏洞",
"graphVulnSidebarHint": "关联漏洞节点,点击下方按钮在漏洞管理中查看详情。",
"promoteAttackChain": "沉淀攻击链",
"promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边",
"confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。",
+103 -31
View File
@@ -9,6 +9,7 @@
let _graphData = null;
let _onNodeSelect = null;
let _onEdgeSelect = null;
let _resizeObs = null;
const EDGE_COLORS = {
discovered_on: '#4F46E5',
@@ -30,25 +31,29 @@
const CARD_MIN_W = 300;
const CARD_TARGET_W = 360;
const CARD_MIN_H = 88;
const CARD_MAX_H = 152;
const CARD_MAX_H = 176;
const CARD_HEADER_FS = 11;
const CARD_HEADER_LH = 16;
const CARD_KEY_FS = 10;
const CARD_KEY_LH = 14;
const CARD_SUMMARY_FS = 13;
const CARD_SUMMARY_LH = 18;
const CARD_SECTION_GAP = 6;
const CARD_FONT =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif';
const CARD_KEY_FONT =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace';
function nodeTheme(type) {
switch (type) {
case 'target':
return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' };
case 'finding':
return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'vulnerability' };
return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'finding', cardStyle: 'default' };
case 'exploit':
return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability' };
return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability', cardStyle: 'default' };
case 'vulnerability':
return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#BE123C', bgEnd: '#FFF1F2', icon: 'vulnerability' };
return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#9333EA', bgEnd: '#F5F3FF', icon: 'vuln', cardStyle: 'default' };
case 'auth':
return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' };
case 'infra':
@@ -148,19 +153,24 @@
return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT;
}
function computeNodeLayout(type, summary, statusBadge, theme) {
function computeNodeLayout(type, summary, statusBadge, theme, factKey) {
const width = type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
const textW = cardTextWidth(width);
const t = theme || nodeTheme(type);
const headerLines = wrapTextLines(buildHeaderText(t, statusBadge), textW, CARD_HEADER_FS, 2, true);
const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, 4, true);
const keyText = String(factKey || '').trim();
const keyLines = keyText ? wrapTextLines(keyText, textW, CARD_KEY_FS, 2, false) : [];
const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, keyLines.length ? 3 : 4, true);
const keyBlockHeight = keyLines.length
? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP
: CARD_SECTION_GAP;
const height = Math.min(
CARD_MAX_H,
Math.max(
CARD_MIN_H,
CARD_PAD +
headerLines.length * CARD_HEADER_LH +
CARD_SECTION_GAP +
keyBlockHeight +
summaryLines.length * CARD_SUMMARY_LH +
CARD_PAD,
),
@@ -169,8 +179,11 @@
width,
height,
headerLines,
keyLines,
summaryLines,
searchLabel: headerLines.join(' ') + '\n' + summaryLines.join(' '),
searchLabel: [headerLines.join(' '), keyLines.join(' '), summaryLines.join(' ')]
.filter(Boolean)
.join('\n'),
};
}
@@ -183,6 +196,21 @@
`<circle cx="12" cy="12" r="2.5" fill="${color}"/></g>`
);
}
if (kind === 'finding') {
return (
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
`<circle cx="10" cy="10" r="6" fill="none" stroke="${color}" stroke-width="2"/>` +
`<line x1="14.5" y1="14.5" x2="19" y2="19" stroke="${color}" stroke-width="2" stroke-linecap="round"/></g>`
);
}
if (kind === 'vuln') {
return (
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
`<path d="M12 2.5l7.5 3v6.2c0 4.6-3.1 8.1-7.5 9.3-4.4-1.2-7.5-4.7-7.5-9.3V5.5z" fill="${color}" fill-opacity="0.12" stroke="${color}" stroke-width="2"/>` +
`<line x1="12" y1="8.5" x2="12" y2="12.5" stroke="${color}" stroke-width="2" stroke-linecap="round"/>` +
`<circle cx="12" cy="15.5" r="1.1" fill="${color}"/></g>`
);
}
if (kind === 'vulnerability') {
return (
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
@@ -198,7 +226,7 @@
}
function buildNodeCardSvgUrl(theme, layout, confidence) {
const { width, height, headerLines, summaryLines } = layout;
const { width, height, headerLines, keyLines, summaryLines } = layout;
const accent = theme.accent;
const bgEnd = theme.bgEnd;
const conf = (confidence || '').toLowerCase();
@@ -207,7 +235,14 @@
const iconX = CARD_PAD;
const iconY = (height - CARD_ICON) / 2;
const headerY = CARD_PAD + CARD_HEADER_FS;
const summaryY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_SUMMARY_FS;
const keyY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_KEY_FS;
const summaryY =
CARD_PAD +
headerLines.length * CARD_HEADER_LH +
(keyLines.length
? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP
: CARD_SECTION_GAP) +
CARD_SUMMARY_FS;
const stroke = isTentative
? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"`
@@ -220,6 +255,13 @@
)
.join('');
const keySvg = keyLines
.map(
(line, i) =>
`<text x="${CARD_TEXT_X}" y="${keyY + i * CARD_KEY_LH}" font-size="${CARD_KEY_FS}" font-weight="500" fill="#64748b" font-family='${CARD_KEY_FONT}'>${escapeXml(line)}</text>`,
)
.join('');
const summarySvg = summaryLines
.map(
(line, i) =>
@@ -238,7 +280,7 @@
`<g${isDeprecated ? ' opacity="0.55"' : ''}>` +
`<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="12" fill="url(#bg)" ${stroke}/>` +
svgIconGroup(theme.icon, accent, iconX, iconY) +
`<g clip-path="url(#textClip)">${headerSvg}${summarySvg}</g>` +
`<g clip-path="url(#textClip)">${headerSvg}${keySvg}${summarySvg}</g>` +
`</g></svg>`;
try {
@@ -249,6 +291,10 @@
}
function destroy() {
if (_resizeObs) {
_resizeObs.disconnect();
_resizeObs = null;
}
if (_cy) {
_cy.destroy();
_cy = null;
@@ -256,9 +302,28 @@
_graphData = null;
}
function observeContainerResize(container) {
if (_resizeObs) {
_resizeObs.disconnect();
_resizeObs = null;
}
if (!container || typeof ResizeObserver === 'undefined') return;
_resizeObs = new ResizeObserver(() => {
if (_cy) {
try {
_cy.resize();
} catch (e) {
console.warn('graph resize', e);
}
}
});
_resizeObs.observe(container);
}
function centerGraph() {
if (!_cy) return;
try {
_cy.resize();
_cy.fit(undefined, 56);
if (_cy.zoom() < 0.65) {
_cy.zoom(0.65);
@@ -273,17 +338,13 @@
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 === 'infra' || t === 'auth' || t === 'business') return '1';
if (t === 'exploit' || t === 'poc') return '3';
if (t === 'chain' || t === 'finding' || t === 'vulnerability') return '2';
if (t === 'vulnerability' || t === 'vuln') return '3';
if (t === 'chain' || t === 'finding') return '2';
if (t === 'note') return '2';
return '2';
}
@@ -408,16 +469,19 @@
nodes.forEach((node) => {
nodeIds.add(node.id);
const theme = nodeTheme(node.type || node.category || 'note');
const label = node.label || node.fact_key || node.id;
const visualType = resolveGraphNodeType(node);
const theme = nodeTheme(visualType);
const factKey = node.fact_key || node.id;
const summary = (node.summary || node.label || '').trim() || '—';
const statusBadge = buildStatusBadge(node.confidence);
const layout = computeNodeLayout(node.type || node.category || 'note', label, statusBadge, theme);
const layout = computeNodeLayout(visualType, summary, statusBadge, theme, factKey);
elements.push({
data: {
id: node.id,
label: layout.searchLabel,
factKey: node.fact_key || node.id,
type: node.type || 'note',
category: node.category || '',
type: visualType,
typeLabel: theme.typeLabel,
typeEn: theme.typeEn,
accentColor: theme.accent,
@@ -529,6 +593,7 @@
});
applyElkLayout(validEdges, isComplex);
observeContainerResize(container);
return _cy;
}
@@ -577,21 +642,28 @@
}
}
/** 与后端 GraphNodeType 一致:优先 fact_key 前缀,再 category/type。 */
/** 与后端 GraphNodeType 一致:优先 categoryvuln: 合成节点例外;无 category 时回退 type/key。 */
function resolveGraphNodeType(node) {
if (!node) return 'note';
const key = String(node.fact_key || node.id || '').toLowerCase();
if (key.startsWith('vuln:')) return 'vulnerability';
const cat = String(node.category || '').toLowerCase();
if (cat) {
if (cat === 'vuln') return 'vulnerability';
if (cat === 'missing') return 'missing';
return cat;
}
const t = String(node.type || '').toLowerCase();
if (t === 'vuln') return 'vulnerability';
if (t) return t;
if (key.startsWith('target/')) return 'target';
if (key.startsWith('exploit/') || key.startsWith('poc/') || key.startsWith('evidence/')) return 'exploit';
if (key.startsWith('exploit/') || key.startsWith('evidence/')) return 'exploit';
if (key.startsWith('poc/')) return 'poc';
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';
if (key.startsWith('infra/') || key.startsWith('business/')) return 'infra';
return 'note';
}
global.ProjectFactGraph = {
+32 -4
View File
@@ -920,20 +920,31 @@ function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) {
if (!edges.length) wrap.hidden = false;
}
function graphVulnIdFromKey(factKey) {
const key = String(factKey || '');
if (!key.startsWith('vuln:')) return null;
return key.slice(5);
}
function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
if (!factKey || String(factKey).startsWith('vuln:')) {
if (!factKey) {
closeProjectFactGraphSidebar();
return;
}
_selectedGraphFactKey = factKey;
_selectedGraphEdgeId = selectedEdgeId || null;
const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey);
const vulnId = graphVulnIdFromKey(factKey);
const isVulnNode = !!vulnId;
const sidebar = document.getElementById('project-fact-graph-sidebar');
const titleEl = document.getElementById('project-fact-graph-node-title');
const metaEl = document.getElementById('project-fact-graph-node-meta');
const categoryEl = document.getElementById('project-fact-graph-node-category');
const detailBtn = document.getElementById('project-fact-graph-detail-btn');
const editBtn = document.getElementById('project-fact-graph-edit-btn');
if (!sidebar || !titleEl || !metaEl) return;
titleEl.textContent = factKey;
titleEl.textContent = isVulnNode ? vulnId : factKey;
titleEl.title = isVulnNode ? vulnId : factKey;
if (categoryEl) {
const visualType =
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.resolveGraphNodeType
@@ -950,11 +961,16 @@ function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
}
const conf = node?.confidence || '';
const summary = (node?.summary || node?.label || '').trim();
if (summary || conf) {
if (summary || conf || isVulnNode) {
const parts = [];
if (summary) {
parts.push(`<span class="project-fact-graph-node-summary">${escapeHtml(summary)}</span>`);
}
if (isVulnNode) {
parts.push(
`<span class="project-fact-graph-node-vuln-hint">${escapeHtml(tp('projects.graphVulnSidebarHint'))}</span>`,
);
}
if (conf) {
parts.push(formatConfidenceBadge(conf));
}
@@ -962,6 +978,12 @@ function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
} else {
metaEl.textContent = '';
}
if (detailBtn) {
detailBtn.textContent = isVulnNode ? tp('projects.viewVulnerability') : tp('projects.details');
}
if (editBtn) {
editBtn.hidden = isVulnNode;
}
renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId);
if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.selectEdge(_selectedGraphEdgeId);
@@ -1003,7 +1025,13 @@ async function deleteProjectFactEdge(edgeId) {
}
function openSelectedGraphFactDetail() {
if (_selectedGraphFactKey) viewProjectFactBody(_selectedGraphFactKey);
if (!_selectedGraphFactKey) return;
const vulnId = graphVulnIdFromKey(_selectedGraphFactKey);
if (vulnId) {
openVulnerabilityDetail(vulnId);
return;
}
viewProjectFactBody(_selectedGraphFactKey);
}
function editSelectedGraphFact() {
+18 -5
View File
@@ -1664,11 +1664,24 @@
</div>
<div class="project-fact-graph-footer">
<div id="project-fact-graph-stats" class="project-fact-graph-stats"></div>
<div class="projects-graph-legend" aria-hidden="true">
<span class="projects-graph-legend-item"><i style="--legend-color:#4F46E5"></i><span data-i18n="projects.graphLegendDiscovered">discovered_on</span></span>
<span class="projects-graph-legend-item"><i style="--legend-color:#64748B"></i><span data-i18n="projects.graphLegendLeads">leads_to</span></span>
<span class="projects-graph-legend-item"><i style="--legend-color:#DC2626"></i><span data-i18n="projects.graphLegendExploits">exploits</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--dashed"><i style="--legend-color:#94A3B8"></i><span data-i18n="projects.graphLegendTentative">待确认</span></span>
<div class="projects-graph-legend" role="group" aria-label="Graph legend">
<div class="projects-graph-legend-group">
<span class="projects-graph-legend-heading" data-i18n="projects.graphLegendNodes">节点</span>
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#4F46E5;--legend-bg:#F5F3FF"></i><span data-i18n="projects.graphLegendNodeTarget">TARGET · 目标</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#64748B;--legend-bg:#F8FAFC"></i><span data-i18n="projects.graphLegendNodeInfra">INFRA · 基础设施</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#E11D48;--legend-bg:#FFF1F2"></i><span data-i18n="projects.graphLegendNodeFinding">FINDING · 发现</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#9333EA;--legend-bg:#F5F3FF"></i><span data-i18n="projects.graphLegendNodeVuln">VULN · 漏洞</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--node"><i style="--legend-color:#B45309;--legend-bg:#FFFBEB"></i><span data-i18n="projects.graphLegendNodeExploit">EXPLOIT · 利用</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--node projects-graph-legend-item--node-dashed"><i style="--legend-color:#CBD5E1;--legend-bg:#F1F5F9"></i><span data-i18n="projects.graphLegendNodeMissing">MISSING · 缺失</span></span>
</div>
<span class="projects-graph-legend-divider" aria-hidden="true"></span>
<div class="projects-graph-legend-group">
<span class="projects-graph-legend-heading" data-i18n="projects.graphLegendEdges">连线</span>
<span class="projects-graph-legend-item projects-graph-legend-item--edge"><i style="--legend-color:#4F46E5"></i><span data-i18n="projects.graphLegendDiscovered">discovered_on</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--edge"><i style="--legend-color:#64748B"></i><span data-i18n="projects.graphLegendLeads">leads_to</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--edge"><i style="--legend-color:#DC2626"></i><span data-i18n="projects.graphLegendExploits">exploits</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--edge projects-graph-legend-item--dashed"><i style="--legend-color:#94A3B8"></i><span data-i18n="projects.graphLegendTentative">待确认(虚线)</span></span>
</div>
</div>
</div>
</div>