选择或创建项目
项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。
@@ -1527,6 +1534,7 @@| Key | 分类 | 摘要 | Body | 置信度 | 更新 | 操作 | |
|---|---|---|---|---|---|---|---|
| Key | 分类 | 摘要 | 关系 | Body | 置信度 | 更新 | 操作 |
每行一条:type: fact_key。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。
+From 46a7d338a407c5f4a11f50050bbfbd7e38849a54 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 17:25:44 +0800
Subject: [PATCH] Add files via upload
---
web/static/css/style.css | 539 ++++++++++++++++++++++++++++++++--
web/static/i18n/en-US.json | 48 ++++
web/static/i18n/zh-CN.json | 48 ++++
web/static/js/fact-graph.js | 555 ++++++++++++++++++++++++++++++++++++
web/static/js/projects.js | 322 ++++++++++++++++++++-
web/templates/index.html | 86 +++++-
6 files changed, 1571 insertions(+), 27 deletions(-)
create mode 100644 web/static/js/fact-graph.js
diff --git a/web/static/css/style.css b/web/static/css/style.css
index a8138eef..58e319ac 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -23860,9 +23860,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
min-height: 420px;
}
.projects-placeholder-icon {
- font-size: 3rem;
- margin-bottom: 16px;
- opacity: 0.85;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 88px;
+ height: 88px;
+ margin-bottom: 20px;
+ color: #3b82f6;
+ background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
+ border: 1px solid #bfdbfe;
+ border-radius: 22px;
+ box-shadow: 0 8px 24px rgba(59, 130, 246, 0.12);
}
.projects-detail-placeholder h3 {
margin: 0 0 8px;
@@ -23883,7 +23891,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
background: #ffffff;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 14px;
- box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.04);
overflow: hidden;
min-height: 0;
align-self: stretch;
@@ -24066,6 +24074,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
color: #0066ff;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
+ font-weight: 600;
}
.projects-panel {
flex: 1;
@@ -24309,11 +24318,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
#project-panel-vulns .projects-table-wrap {
flex: 1 1 auto;
min-height: 0;
- overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
+#project-panel-conversations .projects-table-wrap,
+#project-panel-vulns .projects-table-wrap {
+ overflow-x: hidden;
+}
+#project-panel-facts .projects-table-wrap {
+ overflow-x: auto;
+}
#project-panel-facts .projects-table-wrap .data-table--projects thead th,
#project-panel-conversations .projects-table-wrap .data-table--projects thead th,
#project-panel-vulns .projects-table-wrap .data-table--projects thead th {
@@ -24332,12 +24347,6 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
.projects-panel-toolbar--hint .projects-fact-toolbar-hint {
margin: 0;
}
-#project-panel-facts .data-table--projects th:nth-child(1) { width: 20%; }
-#project-panel-facts .data-table--projects th:nth-child(2) { width: 9%; }
-#project-panel-facts .data-table--projects th:nth-child(3) { width: 30%; }
-#project-panel-facts .data-table--projects th:nth-child(4) { width: 9%; }
-#project-panel-facts .data-table--projects th:nth-child(5) { width: 10%; }
-#project-panel-facts .data-table--projects th:nth-child(6) { width: 10%; }
#project-panel-facts .data-table--projects .cell-fact-key {
overflow: hidden;
max-width: 0;
@@ -24345,6 +24354,16 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
#project-panel-facts .data-table--projects .cell-fact-category {
white-space: nowrap;
}
+#project-panel-facts .data-table--projects .cell-summary {
+ max-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+#project-panel-facts .data-table--projects .cell-fact-links {
+ text-align: center;
+ white-space: nowrap;
+}
#project-panel-facts .projects-fact-key-chip {
display: inline-block;
max-width: 100%;
@@ -24463,23 +24482,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
}
#project-panel-facts .data-table--projects th:nth-child(1),
#project-panel-facts .data-table--projects td:nth-child(1) {
- width: 19%;
+ width: 13%;
}
#project-panel-facts .data-table--projects th:nth-child(2),
#project-panel-facts .data-table--projects td:nth-child(2) {
- width: 9%;
+ width: 7%;
}
#project-panel-facts .data-table--projects th:nth-child(3),
#project-panel-facts .data-table--projects td:nth-child(3) {
- width: 28%;
+ width: 22%;
}
#project-panel-facts .data-table--projects th:nth-child(4),
#project-panel-facts .data-table--projects td:nth-child(4) {
- width: 8%;
+ width: 5%;
}
#project-panel-facts .data-table--projects th:nth-child(5),
#project-panel-facts .data-table--projects td:nth-child(5) {
- width: 9%;
+ width: 8%;
}
#project-panel-facts .data-table--projects th:nth-child(6),
#project-panel-facts .data-table--projects td:nth-child(6) {
@@ -24487,8 +24506,485 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
}
#project-panel-facts .data-table--projects th:nth-child(7),
#project-panel-facts .data-table--projects td:nth-child(7) {
- width: 19%;
+ width: 9%;
}
+#project-panel-facts .data-table--projects th.col-actions,
+#project-panel-facts .data-table--projects td.col-actions {
+ width: 28%;
+ min-width: 196px;
+ max-width: 240px;
+ position: sticky;
+ right: 0;
+ z-index: 3;
+ background: #fff;
+ box-shadow: -6px 0 10px rgba(15, 23, 42, 0.05);
+}
+#project-panel-facts .data-table--projects thead th.col-actions {
+ z-index: 6;
+ background: #f8fafc;
+}
+#project-panel-facts .data-table--projects tbody tr:hover td.col-actions {
+ background: #f8fafc;
+}
+#project-panel-facts .data-table--projects .col-actions .projects-table-actions {
+ flex-wrap: nowrap;
+}
+
+/* 项目事实攻击路径图 */
+#project-panel-graph.projects-panel--graph {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+ padding-bottom: 16px;
+}
+#project-panel-graph .projects-graph-toolbar {
+ flex: 0 0 auto;
+}
+#project-panel-graph .project-fact-graph-layout {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+.projects-graph-toolbar-row {
+ align-items: flex-end;
+}
+.projects-graph-search-field {
+ flex: 1 1 180px;
+ max-width: 280px;
+}
+.projects-graph-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+ padding: 3px;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+}
+.projects-graph-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 11px;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: #475569;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
+}
+.projects-graph-action-btn svg {
+ flex-shrink: 0;
+ opacity: 0.75;
+}
+.projects-graph-action-btn:hover {
+ color: #0f172a;
+ background: #fff;
+ border-color: #e2e8f0;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
+}
+.projects-graph-action-btn--connect {
+ color: #4338ca;
+ background: #eef2ff;
+ border-color: #c7d2fe;
+}
+.projects-graph-action-btn--connect:hover,
+.projects-graph-action-btn--connect-active {
+ color: #fff;
+ background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
+ border-color: transparent;
+ box-shadow: 0 2px 8px rgba(79, 70, 229, 0.35);
+}
+.projects-graph-legend {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 14px;
+ padding-top: 2px;
+}
+.projects-graph-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: #64748b;
+}
+.projects-graph-legend-item 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 {
+ border-top-style: dashed;
+ opacity: 0.7;
+}
+.project-fact-graph-layout {
+ position: relative;
+ display: flex;
+ min-height: 480px;
+ align-items: stretch;
+}
+.project-fact-graph-container {
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 480px;
+ border: 1px solid var(--border-color, #e2e8f0);
+ border-radius: 14px;
+ background-color: #f8fafc;
+ background-image:
+ radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.35) 1px, transparent 0);
+ background-size: 20px 20px;
+ position: relative;
+ overflow: hidden;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 1px 3px rgba(15, 23, 42, 0.04);
+}
+.project-fact-graph-container::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: radial-gradient(ellipse at center, transparent 55%, rgba(241, 245, 249, 0.65) 100%);
+ z-index: 1;
+}
+.project-fact-graph-container .loading-spinner,
+.project-fact-graph-container .project-fact-graph-empty,
+.project-fact-graph-container .error-message {
+ position: relative;
+ z-index: 2;
+}
+.project-fact-graph-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ height: 100%;
+ min-height: 420px;
+ padding: 40px 32px;
+}
+.project-fact-graph-empty-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 80px;
+ height: 80px;
+ margin-bottom: 18px;
+ background: rgba(255, 255, 255, 0.85);
+ border: 1px solid #e2e8f0;
+ border-radius: 20px;
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
+}
+.project-fact-graph-empty-title {
+ margin: 0 0 8px;
+ font-size: 1.0625rem;
+ font-weight: 600;
+ color: #0f172a;
+ letter-spacing: -0.01em;
+}
+.project-fact-graph-empty-hint {
+ margin: 0 0 16px;
+ max-width: 420px;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ color: #64748b;
+}
+.project-fact-graph-empty-steps {
+ margin: 0 0 20px;
+ padding-left: 1.2rem;
+ max-width: 400px;
+ text-align: left;
+ font-size: 0.8125rem;
+ line-height: 1.65;
+ color: #475569;
+}
+.project-fact-graph-empty-steps li {
+ margin-bottom: 4px;
+}
+.project-fact-graph-empty-steps li::marker {
+ color: #6366f1;
+ font-weight: 600;
+}
+.project-fact-graph-empty-cta {
+ margin-top: 4px;
+}
+.project-fact-graph-sidebar {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ bottom: 12px;
+ width: min(300px, calc(100% - 24px));
+ z-index: 12;
+ border: 1px solid rgba(226, 232, 240, 0.95);
+ border-radius: 14px;
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.96);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);
+ animation: projectGraphSidebarIn 0.2s ease;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+.project-fact-graph-sidebar[hidden] {
+ display: none !important;
+}
+@keyframes projectGraphSidebarIn {
+ from {
+ opacity: 0;
+ transform: translateX(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+.project-fact-graph-sidebar-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+.project-fact-graph-sidebar-title-wrap {
+ min-width: 0;
+ flex: 1;
+}
+.project-fact-graph-sidebar-header h4 {
+ margin: 4px 0 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ word-break: break-all;
+ color: #0f172a;
+ line-height: 1.35;
+}
+.project-fact-graph-node-category {
+ display: inline-block;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #f1f5f9;
+ color: #64748b;
+ 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--exploit,
+.project-fact-graph-node-category--poc { color: #c2410c; background: #ffedd5; border-color: #fdba74; }
+.project-fact-graph-node-category--auth { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
+.project-fact-graph-sidebar-close {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #fff;
+ color: #64748b;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+.project-fact-graph-sidebar-close:hover {
+ color: #0f172a;
+ border-color: #cbd5e1;
+ background: #f8fafc;
+}
+.project-fact-graph-node-meta {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: #64748b;
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 6px;
+ min-width: 0;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+.project-fact-graph-node-summary {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ color: #475569;
+}
+.project-fact-graph-edges-wrap {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-top: 4px;
+ border-top: 1px solid #f1f5f9;
+}
+.project-fact-graph-edges-title {
+ margin: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #475569;
+ letter-spacing: 0.02em;
+}
+.project-fact-graph-edges-hint {
+ margin: 0;
+ font-size: 0.72rem;
+ line-height: 1.45;
+ color: #94a3b8;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+.project-fact-graph-edges-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.project-fact-graph-edges-empty {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: #94a3b8;
+}
+.project-fact-graph-edge-item {
+ display: grid;
+ grid-template-columns: auto auto auto 1fr auto;
+ align-items: center;
+ gap: 4px 6px;
+ padding: 6px 8px;
+ font-size: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #f8fafc;
+ cursor: pointer;
+ transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
+}
+.project-fact-graph-edge-item:hover {
+ border-color: #cbd5e1;
+ background: #fff;
+}
+.project-fact-graph-edge-item.is-selected {
+ border-color: #818cf8;
+ background: #eef2ff;
+ box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.25);
+}
+.project-fact-graph-edge-dir {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: #64748b;
+ white-space: nowrap;
+}
+.project-fact-graph-edge-type {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.6875rem;
+ color: #4338ca;
+ white-space: nowrap;
+}
+.project-fact-graph-edge-arrow {
+ color: #94a3b8;
+}
+.project-fact-graph-edge-peer {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #334155;
+ min-width: 0;
+}
+.project-fact-graph-edge-delete {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: 1px solid #fecaca;
+ border-radius: 6px;
+ background: #fff;
+ color: #dc2626;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.project-fact-graph-edge-delete:hover {
+ background: #fef2f2;
+ border-color: #f87171;
+}
+.project-fact-graph-edge-synthetic {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ color: #cbd5e1;
+ flex-shrink: 0;
+}
+.project-fact-graph-sidebar-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding-top: 4px;
+ border-top: 1px solid #f1f5f9;
+}
+.project-fact-graph-stats {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin: 12px 0 0;
+ flex: 0 0 auto;
+}
+.projects-graph-stat-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.8125rem;
+ color: #64748b;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ padding: 4px 12px;
+ border-radius: 999px;
+}
+.projects-graph-stat-badge strong {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: #0f172a;
+ font-variant-numeric: tabular-nums;
+}
+#project-panel-graph .projects-fact-toolbar-filters {
+ flex-wrap: wrap;
+}
+.projects-fact-link-badge {
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-secondary, #64748b);
+}
+.projects-fact-link-badge--empty {
+ opacity: 0.45;
+}
+@media (max-width: 1100px) {
+ .project-fact-graph-sidebar {
+ width: min(280px, calc(100% - 24px));
+ }
+ .projects-graph-actions {
+ margin-left: 0;
+ width: 100%;
+ justify-content: flex-end;
+ }
+}
+
@media (max-width: 1400px) {
.projects-detail-header {
padding: 16px 18px 14px;
@@ -24513,11 +25009,12 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
}
#project-panel-facts .data-table--projects th:nth-child(3),
#project-panel-facts .data-table--projects td:nth-child(3) {
- width: 24%;
+ width: 22%;
}
- #project-panel-facts .data-table--projects th:nth-child(7),
- #project-panel-facts .data-table--projects td:nth-child(7) {
- width: 23%;
+ #project-panel-facts .data-table--projects th.col-actions,
+ #project-panel-facts .data-table--projects td.col-actions {
+ min-width: 188px;
+ max-width: 220px;
}
}
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index 927d68e8..2ff498d7 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -258,10 +258,58 @@
"vulnerabilityManagement": "Vulnerability management",
"addFactCta": "+ Add fact",
"tabFacts": "Fact board",
+ "tabGraph": "Attack path",
"tabConversations": "Bound conversations",
"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.",
+ "graphView": "View",
+ "graphViewPath": "Attack path",
+ "graphViewFull": "Full graph",
+ "graphSearchSr": "Search nodes",
+ "graphSearchPlaceholder": "Search nodes…",
+ "graphRefresh": "Refresh",
+ "graphCenter": "Center",
+ "graphEmpty": "No graph data yet. Add links on finding/exploit facts (discovered_on → target/*) to build the path.",
+ "graphEmptyTitle": "Build your attack path",
+ "graphEmptyStep1": "Add target facts (domains, endpoints, scope)",
+ "graphEmptyStep2": "Record findings/exploits with links between facts",
+ "graphEmptyStep3": "Use Connect mode or edit facts to add relationships",
+ "graphEmptyCta": "Add first fact",
+ "graphStats": "Nodes: {{nodes}} | Edges: {{edges}}",
+ "graphStatsNodes": "Nodes",
+ "graphStatsEdges": "Edges",
+ "graphLegendDiscovered": "discovered_on",
+ "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.",
+ "linksColumn": "Links",
+ "linkCountsTitle": "Outgoing / incoming edge counts",
+ "graphConnect": "Connect",
+ "graphConnectActive": "Connecting…",
+ "graphConnectPickTarget": "Source {{source}} selected — click target node",
+ "graphEdgeTypePrompt": "Edge type (discovered_on / leads_to / depends_on / enables / exploits)",
+ "graphConnectFailed": "Failed to create edge",
+ "graphConnectSuccess": "Edge created",
+ "graphEdgesTitle": "Links",
+ "graphEdgesHint": "Click an edge in the graph to focus it. Delete mistaken links here.",
+ "graphEdgesEmpty": "No links yet",
+ "graphEdgeOutgoing": "Outgoing",
+ "graphEdgeIncoming": "Incoming",
+ "graphEdgeSynthetic": "Auto-generated from fact link; edit the fact to remove",
+ "confirmDeleteGraphEdge": "Delete this link?",
+ "graphEdgeDeleteFailed": "Failed to delete edge",
+ "graphEdgeDeleteSuccess": "Edge deleted",
+ "graphDeleteEdge": "Delete",
+ "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.",
+ "promoteAttackChainFailed": "Promote failed",
+ "promoteAttackChainSuccess": "Promoted: {{facts_created}} new / {{facts_updated}} updated / {{edges_created}} edges",
"searchFactsSr": "Search facts",
"searchFactsPlaceholder": "Search key, summary, body…",
"category": "Category",
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index 9290be23..ab2dfcde 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -246,10 +246,58 @@
"vulnerabilityManagement": "漏洞管理",
"addFactCta": "+ 添加事实",
"tabFacts": "事实黑板",
+ "tabGraph": "攻击路径",
"tabConversations": "关联对话",
"tabVulns": "关联漏洞",
"tabSettings": "设置",
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现",
+ "graphToolbarHint": "攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。",
+ "graphView": "视图",
+ "graphViewPath": "攻击路径",
+ "graphViewFull": "完整关系",
+ "graphSearchSr": "搜索节点",
+ "graphSearchPlaceholder": "搜索节点…",
+ "graphRefresh": "刷新",
+ "graphCenter": "居中",
+ "graphEmpty": "暂无路径图数据。为 finding/exploit 类事实添加关系边(discovered_on → target/*)后将在此展示。",
+ "graphEmptyTitle": "构建攻击路径图",
+ "graphEmptyStep1": "添加 target 类事实(目标、域名、入口)",
+ "graphEmptyStep2": "记录 finding / exploit 并在 links 中连边",
+ "graphEmptyStep3": "使用「连边」模式或编辑事实手动补关系",
+ "graphEmptyCta": "添加第一条事实",
+ "graphStats": "节点: {{nodes}} | 边: {{edges}}",
+ "graphStatsNodes": "节点",
+ "graphStatsEdges": "边",
+ "graphLegendDiscovered": "discovered_on",
+ "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。保存时替换全部出边。",
+ "linksColumn": "关系",
+ "linkCountsTitle": "出边数 / 入边数",
+ "graphConnect": "连边",
+ "graphConnectActive": "连边中…",
+ "graphConnectPickTarget": "已选 {{source}},请点击目标节点",
+ "graphEdgeTypePrompt": "边类型(discovered_on / leads_to / depends_on / enables / exploits)",
+ "graphConnectFailed": "创建边失败",
+ "graphConnectSuccess": "边已创建",
+ "graphEdgesTitle": "关系边",
+ "graphEdgesHint": "点击图中连线可定位;误连可在此删除。",
+ "graphEdgesEmpty": "暂无关系边",
+ "graphEdgeOutgoing": "出边",
+ "graphEdgeIncoming": "入边",
+ "graphEdgeSynthetic": "由事实关联自动生成,请编辑事实解除",
+ "confirmDeleteGraphEdge": "确定删除此关系边?",
+ "graphEdgeDeleteFailed": "删除边失败",
+ "graphEdgeDeleteSuccess": "边已删除",
+ "graphDeleteEdge": "删边",
+ "promoteAttackChain": "沉淀攻击链",
+ "promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边",
+ "confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。",
+ "promoteAttackChainFailed": "沉淀失败",
+ "promoteAttackChainSuccess": "已沉淀:新建 {{facts_created}} / 更新 {{facts_updated}} / 边 {{edges_created}}",
"searchFactsSr": "搜索事实",
"searchFactsPlaceholder": "搜索 key、摘要、body…",
"category": "分类",
diff --git a/web/static/js/fact-graph.js b/web/static/js/fact-graph.js
new file mode 100644
index 00000000..e604e23d
--- /dev/null
+++ b/web/static/js/fact-graph.js
@@ -0,0 +1,555 @@
+/**
+ * 项目事实图渲染(Cytoscape + ELK),供项目管理页使用。
+ * 节点采用 SVG 卡片背景(图标 + 多行文字),避免 Cytoscape 原生 label 定位问题。
+ */
+(function (global) {
+ 'use strict';
+
+ let _cy = null;
+ let _graphData = null;
+ let _onNodeSelect = null;
+ let _onEdgeSelect = null;
+
+ const EDGE_COLORS = {
+ discovered_on: '#4F46E5',
+ leads_to: '#64748B',
+ enables: '#E11D48',
+ exploits: '#DC2626',
+ depends_on: '#0D9488',
+ contains: '#6366F1',
+ part_of: '#6366F1',
+ supports: '#94A3B8',
+ links_vuln: '#BE123C',
+ };
+
+ const CARD_PAD = 14;
+ const CARD_TEXT_PAD_RIGHT = 12;
+ const CARD_ICON = 36;
+ const CARD_ICON_GAP = 12;
+ const CARD_TEXT_X = CARD_PAD + CARD_ICON + CARD_ICON_GAP;
+ const CARD_MIN_W = 300;
+ const CARD_TARGET_W = 360;
+ const CARD_MIN_H = 88;
+ const CARD_MAX_H = 152;
+ const CARD_HEADER_FS = 11;
+ const CARD_HEADER_LH = 16;
+ 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';
+
+ 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' };
+ 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 'missing':
+ return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' };
+ default:
+ return { typeLabel: '备注', typeEn: 'NOTE', accent: '#94A3B8', bgEnd: '#F8FAFC', icon: 'default' };
+ }
+ }
+
+ function escapeXml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function escapeHtml(str) {
+ return escapeXml(str);
+ }
+
+ function buildStatusBadge(confidence) {
+ const conf = (confidence || '').toLowerCase();
+ if (conf === 'tentative') return '待确认';
+ if (conf === 'deprecated') return '已废弃';
+ return '';
+ }
+
+ function buildHeaderText(theme, statusBadge) {
+ const line = (theme.typeEn || '') + ' · ' + (theme.typeLabel || '');
+ return statusBadge ? line + ' · ' + statusBadge : line;
+ }
+
+ function isWideChar(ch) {
+ const code = ch.codePointAt(0) || 0;
+ if (code >= 0x4e00 && code <= 0x9fff) return true;
+ if (code >= 0x3400 && code <= 0x4dbf) return true;
+ if (code >= 0xf900 && code <= 0xfaff) return true;
+ if (code >= 0xff00 && code <= 0xffef) return true;
+ return /[·:,。;!?【】()《》、「」]/.test(ch);
+ }
+
+ function charWidth(ch, fontSize, bold) {
+ const scale = bold ? 1.05 : 1;
+ if (ch === ' ') return fontSize * 0.3 * scale;
+ if (isWideChar(ch)) return fontSize * scale;
+ return fontSize * 0.58 * scale;
+ }
+
+ function lineWidth(text, fontSize, bold) {
+ let width = 0;
+ for (const ch of text) width += charWidth(ch, fontSize, bold);
+ return width;
+ }
+
+ function wrapTextLines(text, maxWidth, fontSize, maxLines, bold) {
+ const raw = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!raw) return ['—'];
+ const safeWidth = Math.max(40, maxWidth - 4);
+ const chars = [...raw];
+ const lines = [];
+ let index = 0;
+ while (index < chars.length && lines.length < maxLines) {
+ let line = '';
+ let width = 0;
+ while (index < chars.length) {
+ const ch = chars[index];
+ const nextWidth = charWidth(ch, fontSize, bold);
+ if (line && width + nextWidth > safeWidth) break;
+ line += ch;
+ width += nextWidth;
+ index += 1;
+ if (width >= safeWidth) break;
+ }
+ if (line) lines.push(line);
+ }
+ if (index < chars.length && lines.length) {
+ let last = lines[lines.length - 1];
+ while (last.length > 1 && lineWidth(last + '…', fontSize, bold) > safeWidth) {
+ last = last.slice(0, -1);
+ }
+ lines[lines.length - 1] = last + '…';
+ }
+ return lines.length ? lines : ['—'];
+ }
+
+ function cardTextWidth(nodeWidth) {
+ return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT;
+ }
+
+ function computeNodeLayout(type, summary, statusBadge, theme) {
+ 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 height = Math.min(
+ CARD_MAX_H,
+ Math.max(
+ CARD_MIN_H,
+ CARD_PAD +
+ headerLines.length * CARD_HEADER_LH +
+ CARD_SECTION_GAP +
+ summaryLines.length * CARD_SUMMARY_LH +
+ CARD_PAD,
+ ),
+ );
+ return {
+ width,
+ height,
+ headerLines,
+ summaryLines,
+ searchLabel: headerLines.join(' ') + '\n' + summaryLines.join(' '),
+ };
+ }
+
+ function svgIconGroup(kind, color, x, y) {
+ const scale = (CARD_ICON / 24).toFixed(3);
+ if (kind === 'target') {
+ return (
+ `
' + escapeHtml(hint) + '
' + + stepsHtml + + actionHtml + + '${escapeHtml(tp('projects.graphEdgesEmpty'))}
`; + } + 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 selected = e.id === selectedEdgeId ? ' is-selected' : ''; + const synthetic = isSyntheticGraphEdge(e); + const deleteBtn = synthetic + ? `—` + : ``; + return `${keyEsc}${pinBadge}${vulnLink}项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。
@@ -1527,6 +1534,7 @@| Key | 分类 | 摘要 | Body | 置信度 | 更新 | 操作 | |
|---|---|---|---|---|---|---|---|
| Key | 分类 | 摘要 | 关系 | Body | 置信度 | 更新 | 操作 |
每行一条:type: fact_key。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。
+