Add files via upload

This commit is contained in:
公明
2026-05-26 17:49:46 +08:00
committed by GitHub
parent 52d03dc849
commit 983fe650c1
3 changed files with 392 additions and 24 deletions
+118
View File
@@ -21755,6 +21755,55 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
background: #f1f5f9;
color: #64748b;
}
.projects-category {
display: inline-block;
font-size: 0.6875rem;
font-weight: 600;
padding: 3px 8px;
border-radius: 6px;
text-transform: lowercase;
white-space: nowrap;
}
.projects-category--target {
background: #dbeafe;
color: #1e40af;
}
.projects-category--auth {
background: #ede9fe;
color: #5b21b6;
}
.projects-category--infra {
background: #e2e8f0;
color: #334155;
}
.projects-category--business {
background: #ccfbf1;
color: #0f766e;
}
.projects-category--finding {
background: #ffedd5;
color: #c2410c;
}
.projects-category--chain {
background: #fed7aa;
color: #9a3412;
}
.projects-category--exploit {
background: #fee2e2;
color: #991b1b;
}
.projects-category--poc {
background: #ffe4e6;
color: #be123c;
}
.projects-category--note {
background: #f1f5f9;
color: #64748b;
}
.projects-category--custom {
background: #e0e7ff;
color: #4338ca;
}
.projects-severity {
display: inline-block;
font-size: 0.6875rem;
@@ -21928,6 +21977,75 @@ body.projects-modal-open {
border: 1px solid #e2e8f0;
color: #334155;
}
.projects-field-hint {
margin: 6px 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: #64748b;
}
.projects-field-hint--warn {
color: #b45309;
}
.projects-form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.projects-form-label-row > label {
margin-bottom: 0;
}
.projects-form-label-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.projects-form-label-actions .btn-link {
font-size: 0.75rem;
padding: 0;
border: none;
background: none;
color: #2563eb;
cursor: pointer;
text-decoration: underline;
}
.fact-modal-body-input {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.5;
}
.projects-fact-sparse-warn {
margin: 0 0 10px;
padding: 10px 12px;
font-size: 0.8125rem;
line-height: 1.45;
color: #92400e;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
}
.projects-fact-badge {
display: inline-block;
font-size: 0.6875rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 4px;
white-space: nowrap;
}
.projects-fact-badge--ok {
color: #166534;
background: #dcfce7;
}
.projects-fact-badge--warn {
color: #92400e;
background: #fef3c7;
}
.projects-fact-badge--na {
color: #64748b;
background: #f1f5f9;
}
.vulnerability-filter-field--project select {
min-width: 120px;
max-width: 160px;
+246 -15
View File
@@ -11,6 +11,129 @@ let _projectsFetchPromise = null;
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
/** 与后端 internal/project/fact_template.go 对齐 */
const FACT_ATTACK_CHAIN_BODY_TEMPLATE = `## 结论(可验证,一句话)
<勿仅写「存在漏洞」;写明类型 + 位置 + 触发条件>
## 目标与入口
- 目标: <URL / IP:Port / 主机名>
- 入口: <路径 / 接口 / 参数>
- 前置条件: <匿名 / 角色 / Cookie / 其他依赖>
## 攻击链(逐步可复现)
1. <侦察/发现>
2. <利用/触发>
3. <影响证明(读文件、RCE 回显、越权数据等)>
## Exploit / POC
### 请求
\`\`\`http
<METHOD> <path> HTTP/1.1
Host: ...
...
<body>
\`\`\`
### 响应 / 现象
<关键响应片段、状态码、差异点>
### 命令 / 脚本(如有)
\`\`\`bash
<command>
\`\`\`
## 关键证据
- <工具输出摘要 / 截图路径 / 会话或消息 ID>
## 关联
- related_vulnerability_id: <可选>
- 依赖事实: <fact_key,如 auth/session_cookie>
## 备注与不确定性
<待验证假设、环境差异、绕过尝试记录>`;
const FACT_ENV_BODY_TEMPLATE = `## 摘要
<该事实的核心认知>
## 细节
<端口/版本/路径/凭据特征/业务规则等>
## 来源与证据
<命令输出、响应片段、发现时间>
## 关联
- 相关 fact_key: <可选>`;
const FACT_ATTACK_CHAIN_PREFIXES = ['finding/', 'chain/', 'exploit/', 'poc/'];
const FACT_ATTACK_CHAIN_CATEGORIES = new Set(['finding', 'chain', 'exploit', 'poc', 'vuln']);
function requiresAttackChainFact(category, factKey) {
const c = (category || '').trim().toLowerCase();
if (FACT_ATTACK_CHAIN_CATEGORIES.has(c)) return true;
const key = (factKey || '').trim().toLowerCase();
return FACT_ATTACK_CHAIN_PREFIXES.some((p) => key.startsWith(p));
}
function isSparseFactBody(category, factKey, body) {
if (!requiresAttackChainFact(category, factKey)) return false;
const text = (body || '').trim();
if (!text) return true;
const lower = text.toLowerCase();
const hasSteps =
lower.includes('攻击链') ||
lower.includes('## 攻击') ||
lower.includes('## exploit') ||
lower.includes('## poc');
const hasHTTP =
lower.includes('```http') ||
lower.includes('```bash') ||
lower.includes('curl ') ||
lower.includes('get ') ||
lower.includes('post ');
const hasReq = lower.includes('请求') || lower.includes('响应') || lower.includes('payload');
return !(hasSteps || hasHTTP || hasReq);
}
function formatFactBodyBadge(f) {
if (!requiresAttackChainFact(f.category, f.fact_key)) {
const hasBody = !!(f.body || '').trim();
return `<span class="projects-fact-badge projects-fact-badge--na" title="环境类事实">${hasBody ? '有详情' : '—'}</span>`;
}
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
return '<span class="projects-fact-badge projects-fact-badge--warn" title="缺少攻击链/POC 结构">待补全</span>';
}
return '<span class="projects-fact-badge projects-fact-badge--ok" title="含可复现结构">可复现</span>';
}
function updateFactFormHints() {
const cat = document.getElementById('fact-modal-category')?.value || '';
const key = document.getElementById('fact-modal-key')?.value || '';
const body = document.getElementById('fact-modal-body')?.value || '';
const hint = document.getElementById('fact-modal-body-hint');
if (!hint) return;
if (requiresAttackChainFact(cat, key)) {
const sparse = isSparseFactBody(cat, key, body);
hint.textContent = sparse
? '⚠ 攻击链类事实:请填写完整 body(步骤、HTTP/命令、响应现象),勿仅写结论。可点「插入攻击链模板」。'
: '攻击链类:body 将用于审计复现,请保留原始请求/响应与逐步步骤。';
hint.classList.toggle('projects-field-hint--warn', sparse);
} else {
hint.textContent = '环境认知类:body 建议记录来源证据;发现/利用请改用 finding|chain|exploit|poc 分类。';
hint.classList.remove('projects-field-hint--warn');
}
}
function insertFactBodyTemplate(kind) {
const ta = document.getElementById('fact-modal-body');
if (!ta) return;
const tpl = kind === 'env' ? FACT_ENV_BODY_TEMPLATE : FACT_ATTACK_CHAIN_BODY_TEMPLATE;
if (ta.value.trim() && !confirm('将覆盖当前 body 内容为模板,是否继续?')) return;
ta.value = tpl;
updateFactFormHints();
ta.focus();
}
function getActiveProjectId() {
try {
return localStorage.getItem(PROJECT_ACTIVE_KEY) || '';
@@ -126,6 +249,27 @@ function updateProjectsListCount() {
if (el) el.textContent = String(projectsCache.length);
}
/** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */
const FACT_CATEGORY_BADGE = {
target: 'projects-category--target',
auth: 'projects-category--auth',
infra: 'projects-category--infra',
business: 'projects-category--business',
finding: 'projects-category--finding',
chain: 'projects-category--chain',
exploit: 'projects-category--exploit',
poc: 'projects-category--poc',
note: 'projects-category--note',
vuln: 'projects-category--exploit',
};
function formatCategoryBadge(category) {
const raw = (category || '').trim();
const c = raw.toLowerCase() || 'note';
const cls = FACT_CATEGORY_BADGE[c] || 'projects-category--custom';
return `<span class="projects-category ${cls}">${escapeHtml(raw || '—')}</span>`;
}
function formatConfidenceBadge(confidence) {
const c = (confidence || '').toLowerCase();
let cls = 'projects-confidence--tentative';
@@ -268,15 +412,15 @@ function switchProjectTab(tab) {
async function loadProjectFacts() {
const tbody = document.getElementById('project-facts-tbody');
if (!tbody || !currentProjectId) return;
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="6">加载中…</td></tr>';
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">加载中…</td></tr>';
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?limit=200`);
if (!res.ok) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="6">加载失败</td></tr>';
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">加载失败</td></tr>';
return;
}
const facts = await res.json();
if (!facts.length) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="6">暂无事实,点击「添加事实」或由 Agent 自动写入</td></tr>';
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">暂无事实,点击「添加事实」或由 Agent 自动写入</td></tr>';
refreshProjectHeaderStats();
return;
}
@@ -285,8 +429,9 @@ async function loadProjectFacts() {
const idEsc = escapeHtml(f.id);
return `<tr>
<td><code>${keyEsc}</code></td>
<td>${escapeHtml(f.category)}</td>
<td>${formatCategoryBadge(f.category)}</td>
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td>${formatFactBodyBadge(f)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td>
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
<td class="col-actions">${renderProjectFactActions(keyEsc, idEsc)}</td>
@@ -327,10 +472,26 @@ async function viewProjectFactBody(factKey) {
const f = await res.json();
_factDetailKey = f.fact_key;
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
document.getElementById('fact-detail-meta').textContent =
`分类: ${f.category} · 置信度: ${f.confidence} · 更新: ${formatProjectTime(f.updated_at, f.created_at)}` +
(f.related_vulnerability_id ? ` · 关联漏洞: ${f.related_vulnerability_id}` : '');
const metaParts = [
`分类: ${f.category}`,
`置信度: ${f.confidence}`,
`更新: ${formatProjectTime(f.updated_at, f.created_at)}`,
];
if (f.related_vulnerability_id) metaParts.push(`关联漏洞: ${f.related_vulnerability_id}`);
if (f.source_conversation_id) metaParts.push(`来源对话: ${f.source_conversation_id}`);
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || '(无 body)';
const warnEl = document.getElementById('fact-detail-sparse-warn');
if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
warnEl.hidden = false;
warnEl.textContent =
'⚠ 该事实属于攻击链/利用类,但 body 缺少可复现结构(攻击链步骤、HTTP/命令、请求响应等)。建议编辑后补全以便审计复现。';
} else {
warnEl.hidden = true;
warnEl.textContent = '';
}
}
openProjectsOverlay('fact-detail-modal');
}
@@ -562,6 +723,7 @@ function resetFactModalForm() {
document.getElementById('fact-modal-confidence').value = 'tentative';
const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = '';
updateFactFormHints();
}
function fillFactModalForm(f) {
@@ -569,7 +731,19 @@ function fillFactModalForm(f) {
document.getElementById('fact-modal-title').textContent = '编辑事实';
document.getElementById('fact-modal-submit-btn').textContent = '保存修改';
document.getElementById('fact-modal-key').value = f.fact_key || '';
document.getElementById('fact-modal-category').value = f.category || 'note';
const catEl = document.getElementById('fact-modal-category');
const cat = (f.category || 'note').trim().toLowerCase();
if (catEl) {
const known = Array.from(catEl.options).some((o) => o.value === cat);
if (known) catEl.value = cat;
else {
const opt = document.createElement('option');
opt.value = f.category;
opt.textContent = `${f.category}(自定义)`;
catEl.appendChild(opt);
catEl.value = f.category;
}
}
document.getElementById('fact-modal-summary').value = f.summary || '';
document.getElementById('fact-modal-body').value = f.body || '';
const conf = (f.confidence || 'tentative').toLowerCase();
@@ -580,6 +754,7 @@ function fillFactModalForm(f) {
}
const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = f.related_vulnerability_id || '';
updateFactFormHints();
}
function showAddFactModal() {
@@ -608,12 +783,20 @@ function closeFactModal() {
async function saveFactModal() {
const fact_key = document.getElementById('fact-modal-key').value.trim();
const summary = document.getElementById('fact-modal-summary').value.trim();
const category = document.getElementById('fact-modal-category').value.trim() || 'note';
const body = document.getElementById('fact-modal-body').value;
if (!fact_key || !summary) return alert('fact_key 与 summary 必填');
if (isSparseFactBody(category, fact_key, body)) {
const ok = confirm(
'该事实属于攻击链/利用类,但 body 尚未包含可复现结构(步骤、HTTP/命令、请求响应等)。\n仍要保存吗?建议先插入攻击链模板并填写 POC。',
);
if (!ok) return;
}
const payload = {
fact_key,
category: document.getElementById('fact-modal-category').value.trim() || 'note',
category,
summary,
body: document.getElementById('fact-modal-body').value,
body,
confidence: document.getElementById('fact-modal-confidence').value,
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
};
@@ -708,17 +891,63 @@ function getChatProjectSelection() {
return getActiveProjectId();
}
function isActiveChatProjectId(id) {
if (!id) return false;
return projectsCache.some((p) => p.id === id && p.status !== 'archived');
}
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
function resolveChatProjectSelection() {
const raw = getChatProjectSelection();
if (!raw) return '';
if (!_projectsListReady) return raw;
return isActiveChatProjectId(raw) ? raw : '';
}
let _normalizingStaleProject = false;
/** 项目列表加载后,清除 localStorage 或对话上残留的失效项目 ID */
async function normalizeStaleChatProjectSelection() {
if (!_projectsListReady || _normalizingStaleProject) return;
const raw = getChatProjectSelection();
if (!raw || isActiveChatProjectId(raw)) return;
_normalizingStaleProject = true;
try {
if (window.currentConversationId) {
window._loadedConversationProjectId = '';
try {
const res = await apiFetch(
`/api/conversations/${encodeURIComponent(window.currentConversationId)}/project`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: '' }),
}
);
if (!res.ok) console.warn('清除失效的项目绑定失败');
} catch (e) {
console.warn(e);
}
} else {
setActiveProjectId('');
}
} finally {
_normalizingStaleProject = false;
}
}
function updateChatProjectButtonLabel() {
const textEl = document.getElementById('chat-project-text');
if (!textEl) return;
const id = getChatProjectSelection();
textEl.textContent = id ? getProjectName(id) || id : '无项目';
const id = resolveChatProjectSelection();
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : '无项目';
}
function renderChatProjectPanelList() {
const list = document.getElementById('chat-project-list');
if (!list) return;
const selected = getChatProjectSelection();
const selected = resolveChatProjectSelection();
const activeProjects = projectsCache.filter((p) => p.status !== 'archived');
const items = [{ id: '', name: '无项目', description: '不绑定项目黑板' }, ...activeProjects];
if (!items.length) {
@@ -839,6 +1068,7 @@ async function refreshChatProjectSelector() {
if (!document.getElementById('chat-project-btn')) return;
try {
await ensureProjectsLoaded();
await normalizeStaleChatProjectSelection();
} catch (e) {
console.warn(e);
}
@@ -857,8 +1087,7 @@ async function onChatProjectChange() {
function initChatProjectSelector() {
if (window._chatProjectSelectorInited) return;
window._chatProjectSelectorInited = true;
prefetchProjectsForChat();
updateChatProjectButtonLabel();
refreshChatProjectSelector().catch(() => {});
document.addEventListener('click', (e) => {
const panel = document.getElementById('chat-project-panel');
const wrapper = document.querySelector('.project-selector-wrapper');
@@ -899,6 +1128,8 @@ window.prefetchProjectsForChat = prefetchProjectsForChat;
window.getActiveProjectId = getActiveProjectId;
window.getProjectName = getProjectName;
window.viewProjectFactBody = viewProjectFactBody;
window.insertFactBodyTemplate = insertFactBodyTemplate;
window.updateFactFormHints = updateFactFormHints;
window.deprecateProjectFactByKey = deprecateProjectFactByKey;
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
window.openVulnerabilityDetail = openVulnerabilityDetail;
+28 -9
View File
@@ -1467,12 +1467,12 @@
</nav>
<div id="project-panel-facts" class="projects-panel" role="tabpanel">
<div class="projects-panel-toolbar">
<span class="projects-panel-hint">Agent 每轮可见 key + 摘要;完整内容通过 get_project_fact 获取</span>
<span class="projects-panel-hint">索引仅含 key + 摘要(须含「什么+在哪+如何验证」);攻击链/POC 写在 bodyAgent 通过 get_project_fact 复现</span>
<button class="btn-primary btn-small" type="button" onclick="showAddFactModal()">+ 添加事实</button>
</div>
<div class="projects-table-wrap">
<table class="data-table data-table--projects">
<thead><tr><th>Key</th><th>分类</th><th>摘要</th><th>置信度</th><th>更新</th><th class="col-actions">操作</th></tr></thead>
<thead><tr><th>Key</th><th>分类</th><th>摘要</th><th>Body</th><th>置信度</th><th>更新</th><th class="col-actions">操作</th></tr></thead>
<tbody id="project-facts-tbody"></tbody>
</table>
</div>
@@ -3972,7 +3972,7 @@
<div class="projects-modal-header-text">
<div>
<h3 id="fact-modal-title">添加事实</h3>
<p class="projects-modal-subtitle">摘要注入 Agent;完整内容通过 get_project_fact 获取</p>
<p class="projects-modal-subtitle">摘要注入黑板索引;body 沉淀攻击链与 POC,供审计复现(与漏洞记录分工)</p>
</div>
</div>
<button type="button" class="projects-modal-close" onclick="closeFactModal()" aria-label="关闭">&times;</button>
@@ -3980,12 +3980,23 @@
<div class="projects-modal-body">
<div class="projects-form-field">
<label for="fact-modal-key">fact_key</label>
<input type="text" id="fact-modal-key" class="form-input" placeholder="target/primary_domain">
<input type="text" id="fact-modal-key" class="form-input" placeholder="target/primary_domain 或 finding/sqli-login" oninput="updateFactFormHints()">
<p class="projects-field-hint">环境类:target/、auth/、infra/、business/;发现/利用:finding/、chain/、exploit/、poc/</p>
</div>
<div class="projects-form-row">
<div class="projects-form-field">
<label for="fact-modal-category">分类</label>
<input type="text" id="fact-modal-category" class="form-input" value="note">
<select id="fact-modal-category" class="form-input" onchange="updateFactFormHints()">
<option value="target">target(目标)</option>
<option value="auth">auth(认证)</option>
<option value="infra">infra(基础设施)</option>
<option value="business">business(业务)</option>
<option value="finding">finding(发现)</option>
<option value="chain">chain(攻击链)</option>
<option value="exploit">exploit(利用)</option>
<option value="poc">pocPOC</option>
<option value="note" selected>note(备注)</option>
</select>
</div>
<div class="projects-form-field">
<label for="fact-modal-confidence">置信度</label>
@@ -3997,12 +4008,19 @@
</div>
</div>
<div class="projects-form-field">
<label for="fact-modal-summary">摘要</label>
<input type="text" id="fact-modal-summary" class="form-input" placeholder="一行概述,会注入到 Agent 上下文">
<label for="fact-modal-summary">摘要(索引一行)</label>
<input type="text" id="fact-modal-summary" class="form-input" maxlength="400" placeholder="什么 + 在哪 + 如何验证(勿仅写「存在 XSS」)" oninput="updateFactFormHints()">
</div>
<div class="projects-form-field">
<label for="fact-modal-body">body(完整详情)</label>
<textarea id="fact-modal-body" class="form-input" rows="5" placeholder="POC、长文本、原始输出等"></textarea>
<div class="projects-form-label-row">
<label for="fact-modal-body">body(可复现详情)</label>
<div class="projects-form-label-actions">
<button type="button" class="btn-link btn-small" onclick="insertFactBodyTemplate('attack')">插入攻击链模板</button>
<button type="button" class="btn-link btn-small" onclick="insertFactBodyTemplate('env')">插入环境模板</button>
</div>
</div>
<textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea>
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
</div>
<div class="projects-form-field">
<label for="fact-modal-related-vuln">关联漏洞 ID</label>
@@ -4027,6 +4045,7 @@
<button type="button" class="projects-modal-close" onclick="closeFactDetailModal()" aria-label="关闭">&times;</button>
</div>
<div class="projects-modal-body">
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
<pre id="fact-detail-body" class="fact-detail-body"></pre>
</div>
<div class="projects-modal-footer">