diff --git a/web/static/css/style.css b/web/static/css/style.css index cb74897a..e85c9b6f 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -19039,6 +19039,14 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .rbac-workspace-view[hidden] { display: none !important; } #rbac-role-save-btn[hidden] { display: none !important; } + +/* display:flex 等会覆盖 [hidden] 默认 display:none,权限控件须强制隐藏 */ +[data-require-permission][hidden], +[data-require-permission-any][hidden], +[data-require-permission].rbac-permission-denied, +[data-require-permission-any].rbac-permission-denied { + display: none !important; +} .rbac-user-row.is-active { box-shadow: inset 3px 0 0 var(--primary-color, #2563eb); } .rbac-role-card.is-selected { border-color: rgba(37, 99, 235, 0.4); diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index cf35bafd..faf6421d 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -458,6 +458,7 @@ "noFactsForVulnerability": "This vulnerability has no related facts yet. Link vulnerability or generate vulnerability draft from fact detail.", "promptChooseFactByIndex": "This vulnerability is linked to {{count}} facts. Enter index to view:\n{{lines}}", "enterProjectName": "Please enter project name", + "writePermissionDenied": "This account is read-only and cannot create or edit projects", "saveFailed": "Save failed", "invalidJson": "Invalid JSON format", "scopeNoteAuthorizedWebOnly": "Authorized for Web application layer testing only", @@ -1417,6 +1418,8 @@ "auth": { "sessionExpired": "Session expired, please sign in again", "unauthorized": "Unauthorized", + "forbidden": "Insufficient permissions", + "requestFailed": "Request failed", "enterPassword": "Please enter password", "loginFailedCheck": "Sign-in failed, please check the username or password", "loginFailedRetry": "Sign-in failed, please try again later", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 2d3ca777..b972f54f 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -446,6 +446,7 @@ "noFactsForVulnerability": "该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接", "promptChooseFactByIndex": "该漏洞关联 {{count}} 条事实,输入序号查看:\n{{lines}}", "enterProjectName": "请输入项目名称", + "writePermissionDenied": "当前账号仅有只读权限,无法创建或修改项目", "saveFailed": "保存失败", "invalidJson": "JSON 格式无效", "scopeNoteAuthorizedWebOnly": "仅授权 Web 应用层测试", @@ -1405,6 +1406,8 @@ "auth": { "sessionExpired": "认证已过期,请重新登录", "unauthorized": "未授权访问", + "forbidden": "权限不足", + "requestFailed": "请求失败", "enterPassword": "请输入密码", "loginFailedCheck": "登录失败,请检查用户名或密码", "loginFailedRetry": "登录失败,请稍后重试", diff --git a/web/static/js/agents.js b/web/static/js/agents.js index 27fbf1f9..0759d91f 100644 --- a/web/static/js/agents.js +++ b/web/static/js/agents.js @@ -86,6 +86,7 @@ async function loadMarkdownAgents() { } function showAddMarkdownAgentModal() { + if (typeof requirePermission === 'function' && !requirePermission('agents:write')) return; markdownAgentsEditingFilename = null; markdownAgentsEditingIsOrchestrator = false; const modal = document.getElementById('agent-md-modal'); @@ -157,6 +158,7 @@ function parseToolsInput(s) { } async function saveMarkdownAgent() { + if (typeof requirePermission === 'function' && !requirePermission('agents:write')) return; const name = document.getElementById('agent-md-name').value.trim(); if (!name) { showNotification(_agentsT('agentsPage.nameRequired'), 'error'); diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 121c1aea..6d85ccc3 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -187,13 +187,7 @@ async function apiFetch(url, options = {}) { : '未授权访问'; throw new Error(msg); } - if (response.status === 403) { - const result = await response.clone().json().catch(() => ({})); - const msg = result.error || (typeof window !== 'undefined' && typeof window.t === 'function' - ? window.t('auth.forbidden') - : '权限不足'); - throw new Error(msg); - } + // 403 属于可预期的 RBAC 拒绝,返回 Response 供调用方通过 res.ok / ensureApiOk 处理。 return response; } @@ -351,6 +345,9 @@ async function bootstrapApp() { isAppInitialized = true; } applyRBACToUI(); + if (typeof installWriteHandlerGuards === 'function') { + installWriteHandlerGuards(); + } await refreshAppData(); } @@ -393,7 +390,103 @@ function hasPermission(permission) { return !permission || authPermissions.has(permission); } -function applyRBACToUI() { +function hasAnyPermission(permissions) { + if (!Array.isArray(permissions) || !permissions.length) return true; + return permissions.some((permission) => hasPermission(permission)); +} + +async function readApiError(response, fallback) { + if (!response) { + return fallback || authT('auth.requestFailed', '请求失败'); + } + try { + const body = await response.clone().json(); + return body.error || body.message || fallback || authT('auth.requestFailed', '请求失败'); + } catch (error) { + return fallback || authT('auth.requestFailed', '请求失败'); + } +} + +function notifyApiError(message, type = 'error') { + const text = (message || '').trim() || authT('auth.requestFailed', '请求失败'); + if (typeof showNotification === 'function') { + showNotification(text, type); + return; + } + if (typeof showToast === 'function') { + showToast(text, type); + return; + } + alert(text); +} + +async function notifyApiResponseError(response, fallback) { + notifyApiError(await readApiError(response, fallback)); +} + +async function ensureApiOk(response, fallback) { + if (response && response.ok) return true; + await notifyApiResponseError(response, fallback); + return false; +} + +function requirePermission(permission, customMessage) { + const allowed = Array.isArray(permission) + ? hasAnyPermission(permission) + : hasPermission(permission); + if (allowed) return true; + notifyApiError(customMessage || authT('auth.forbidden', '权限不足')); + return false; +} + +function permissionAllowedForElement(el) { + if (!el) return true; + const anyOf = el.getAttribute('data-require-permission-any'); + const permission = el.getAttribute('data-require-permission'); + if (anyOf) { + return hasAnyPermission(anyOf.split(/[\s,|]+/).map((item) => item.trim()).filter(Boolean)); + } + if (permission) { + return hasPermission(permission); + } + return true; +} + +function applyPermissionElement(el) { + const anyOf = el.getAttribute('data-require-permission-any'); + const permission = el.getAttribute('data-require-permission'); + if (!anyOf && !permission) return; + const allowed = permissionAllowedForElement(el); + el.hidden = !allowed; + el.classList.toggle('rbac-permission-denied', !allowed); + if ('disabled' in el) { + el.disabled = !allowed; + } + el.setAttribute('aria-hidden', allowed ? 'false' : 'true'); + el.setAttribute('aria-disabled', allowed ? 'false' : 'true'); +} + +let permissionClickGuardInstalled = false; + +function installPermissionClickGuard() { + if (permissionClickGuardInstalled) return; + permissionClickGuardInstalled = true; + document.addEventListener('click', (event) => { + const target = event.target instanceof Element + ? event.target.closest('[data-require-permission], [data-require-permission-any]') + : null; + if (!target || permissionAllowedForElement(target)) return; + event.preventDefault(); + event.stopPropagation(); + if (typeof event.stopImmediatePropagation === 'function') { + event.stopImmediatePropagation(); + } + notifyApiError(authT('auth.forbidden', '权限不足')); + }, true); +} + +function applyRBACToUI(root) { + installPermissionClickGuard(); document.querySelectorAll('[data-page]').forEach((el) => { const page = el.getAttribute('data-page'); const permission = PAGE_PERMISSION_MAP[page]; @@ -402,6 +495,11 @@ function applyRBACToUI() { el.hidden = !allowed; el.setAttribute('aria-hidden', allowed ? 'false' : 'true'); }); + const permissionRoot = root instanceof Element ? root : document; + permissionRoot.querySelectorAll('[data-require-permission], [data-require-permission-any]').forEach(applyPermissionElement); + if (permissionRoot instanceof Element && permissionRoot.matches('[data-require-permission], [data-require-permission-any]')) { + applyPermissionElement(permissionRoot); + } const userAvatar = document.querySelector('.user-avatar-btn'); if (userAvatar && authUser && authUser.username) { const displayName = getAuthDisplayName(); @@ -537,6 +635,7 @@ function formatMarkdown(text, options) { } function setupLoginUI() { + installPermissionClickGuard(); const loginForm = document.getElementById('login-form'); if (loginForm) { loginForm.addEventListener('submit', submitLogin); @@ -630,6 +729,19 @@ async function logout() { window.toggleUserMenu = toggleUserMenu; window.logout = logout; window.hasPermission = hasPermission; +window.hasAnyPermission = hasAnyPermission; +window.readApiError = readApiError; +window.notifyApiError = notifyApiError; +window.notifyApiResponseError = notifyApiResponseError; +window.ensureApiOk = ensureApiOk; +window.requirePermission = requirePermission; +window.permissionAllowedForElement = permissionAllowedForElement; window.applyRBACToUI = applyRBACToUI; +function rbacAfterDynamicRender(root) { + applyRBACToUI(root); +} + +window.rbacAfterDynamicRender = rbacAfterDynamicRender; + document.addEventListener('DOMContentLoaded', initializeApp); diff --git a/web/static/js/c2.js b/web/static/js/c2.js index 3b4a85fe..303b18d9 100644 --- a/web/static/js/c2.js +++ b/web/static/js/c2.js @@ -647,7 +647,7 @@

${escapeHtml(c2t('c2.listeners.emptyTitle'))}

${escapeHtml(c2t('c2.listeners.emptyHint'))}

- `; @@ -700,14 +700,15 @@
${l.status === 'stopped' - ? `` - : `` + ? `` + : `` } - - + +
`; }).join(''); + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container); }; C2.getListenerCallbackHost = function(l) { @@ -1212,7 +1213,7 @@ @@ -1225,6 +1226,7 @@ C2.selectSession(C2.sessions[0].id); } C2.syncSessionsToolbar(); + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(list); }; C2.selectSession = function(id) { @@ -1280,8 +1282,8 @@ ${escapeHtml(heartbeatRel)}
- - + +
@@ -1307,7 +1309,7 @@
- + /
@@ -1351,6 +1353,7 @@ }); requestAnimationFrame(function () { C2.fitTerminal(); }); }, 0); + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container); }; C2.switchTab = function(tab) { @@ -3513,13 +3516,14 @@ ${formatTime(e.createdAt)} · ${escapeHtml(e.category || '')}${e.sessionId ? ' · ' + escapeHtml(String(e.sessionId).substring(0, 8)) : ''} - + `; }).join(''); C2.syncEventsToolbar(); if (typeof applyTranslations === 'function') applyTranslations(container); + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container); }; C2.connectEventStream = function() { @@ -3624,7 +3628,7 @@

${escapeHtml(p.name)}

- +
UA: ${escapeHtml(p.userAgent || defVal)}
@@ -3633,6 +3637,7 @@
`).join(''); + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container); }; C2.showCreateProfileModal = function() { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 3a989b98..62f55778 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -9045,6 +9045,7 @@ function navigateToVulnerabilitiesForContextConversation() { // 从上下文菜单删除对话 function deleteConversationFromContext() { + if (typeof requirePermission === 'function' && !requirePermission('chat:delete')) return; const convId = contextMenuConversationId; if (!convId) return; @@ -9596,6 +9597,7 @@ async function finishBatchGroupChangeAfterRemove() { // 删除选中的对话 async function deleteSelectedConversations() { + if (typeof requirePermission === 'function' && !requirePermission('chat:delete')) return; const checkboxes = document.querySelectorAll('.batch-conversation-checkbox:checked'); if (checkboxes.length === 0) { alert(typeof window.t === 'function' ? window.t('batchManageModal.confirmDeleteNone') : '请先选择要删除的对话'); @@ -9923,6 +9925,7 @@ document.addEventListener('click', function(event) { // 创建分组 async function createGroup(event) { + if (typeof requirePermission === 'function' && !requirePermission('group:write')) return; // 阻止事件冒泡 if (event) { event.preventDefault(); @@ -10356,6 +10359,7 @@ async function editGroup() { // 删除分组 async function deleteGroup() { + if (typeof requirePermission === 'function' && !requirePermission('group:delete')) return; if (!currentGroupId) return; const deleteConfirmMsg = typeof window.t === 'function' ? window.t('chat.deleteGroupConfirm') : '确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。'; @@ -10514,6 +10518,7 @@ async function pinGroupFromContext() { // 从上下文菜单删除分组 async function deleteGroupFromContext() { + if (typeof requirePermission === 'function' && !requirePermission('group:delete')) return; const groupId = contextMenuGroupId; if (!groupId) return; diff --git a/web/static/js/knowledge.js b/web/static/js/knowledge.js index 33a51450..e32bb7c2 100644 --- a/web/static/js/knowledge.js +++ b/web/static/js/knowledge.js @@ -210,6 +210,7 @@ function renderKnowledgeItemsByCategories(categoriesWithItems) { html += ''; container.innerHTML = html; + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container); } // 渲染知识项列表(向后兼容,用于按项分页的旧代码) @@ -260,6 +261,7 @@ function renderKnowledgeItems(items) { html += ''; container.innerHTML = html; + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container); } // 渲染分页控件(按分类分页) @@ -350,13 +352,13 @@ function renderKnowledgeItemCard(item) {

${escapeHtml(item.title)}

- - ` + ? `` : ''; const jsExecId = rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const isSelected = monitorState.selectedExecutions.has(rawExecId); @@ -6112,7 +6112,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
${terminateBtn} - +
@@ -6167,6 +6167,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { // 更新批量操作状态 updateBatchActionsState(); + if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(tableContainer); } // 渲染监控面板分页控件 diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 0c09826d..f72f4d13 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -26,10 +26,29 @@ function tpFmt(key, fallback, opts) { return text; } -function tpFmt(key, fallback, opts) { - const text = tp(key, opts); - if (!text || text === key) return fallback; - return text; +function requireProjectWrite() { + if (typeof requirePermission !== 'function') return true; + return requirePermission( + 'project:write', + tpFmt('projects.writePermissionDenied', '当前账号仅有只读权限,无法创建或修改项目'), + ); +} + +function requireProjectDelete() { + if (typeof requirePermission !== 'function') return true; + return requirePermission( + 'project:delete', + tpFmt('projects.writePermissionDenied', '当前账号仅有只读权限,无法删除项目'), + ); +} + +async function notifyProjectApiFailure(response, fallbackKey, fallbackText) { + if (typeof ensureApiOk === 'function') { + return ensureApiOk(response, tpFmt(fallbackKey, fallbackText)); + } + if (!response || response.ok) return true; + alert(tpFmt(fallbackKey, fallbackText)); + return false; } const PROJECTS_FILTER_SELECT_HANDLERS = { @@ -838,8 +857,10 @@ function renderProjectsSidebar() { updateProjectsListCount(); const list = projectsCache; if (!projectsCache.length) { - el.innerHTML = - `
${escapeHtml(tp('projects.noProjects'))}
`; + const createBtn = (typeof hasPermission === 'function' && hasPermission('project:write')) + ? `` + : ''; + el.innerHTML = `
${escapeHtml(tp('projects.noProjects'))}${createBtn ? `
${createBtn}` : ''}
`; updateProjectsDetailVisibility(); renderProjectsPagination(); return; @@ -866,6 +887,7 @@ function renderProjectsSidebar() {
`; }).join(''); updateProjectsDetailVisibility(); + if (typeof applyRBACToUI === 'function') applyRBACToUI(el); } function clampProjectDescription(text) { @@ -1029,6 +1051,7 @@ function toggleProjectFactGraphConnectMode() { async function handleGraphConnectNodePick(factKey) { if (!factKey || String(factKey).startsWith('vuln:')) return; + if (!requireProjectWrite()) return; if (!_graphConnectSource) { _graphConnectSource = factKey; if (typeof showNotification === 'function') { @@ -1052,10 +1075,7 @@ async function handleGraphConnectNodePick(factKey) { }), }); _graphConnectSource = null; - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return alert(err.error || tp('projects.graphConnectFailed')); - } + if (!(await notifyProjectApiFailure(res, 'projects.graphConnectFailed', '创建边失败'))) return; if (typeof showNotification === 'function') showNotification(tp('projects.graphConnectSuccess'), 'success'); loadProjectFactGraph(); loadProjectFacts(); @@ -1270,16 +1290,14 @@ function focusProjectFactGraphEdge(edgeId) { async function deleteProjectFactEdge(edgeId) { if (!edgeId || !currentProjectId) return; + if (!requireProjectWrite()) return; const edge = (_currentGraphData?.edges || []).find((e) => e.id === edgeId); if (isSyntheticGraphEdge(edge)) return; if (!confirm(tp('projects.confirmDeleteGraphEdge'))) return; const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges/${encodeURIComponent(edgeId)}`, { method: 'DELETE', }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return alert(err.error || tp('projects.graphEdgeDeleteFailed')); - } + if (!(await notifyProjectApiFailure(res, 'projects.graphEdgeDeleteFailed', '删除边失败'))) return; if (typeof showNotification === 'function') showNotification(tp('projects.graphEdgeDeleteSuccess'), 'success'); const keepKey = _selectedGraphFactKey; await loadProjectFactGraph(); @@ -1439,15 +1457,13 @@ function openProjectConversation(conversationId) { async function promoteConversationAttackChain(conversationId) { if (!currentProjectId || !conversationId) return; + if (!requireProjectWrite()) return; if (!confirm(tp('projects.confirmPromoteAttackChain'))) return; const res = await apiFetch( `/api/projects/${currentProjectId}/promote-attack-chain/${encodeURIComponent(conversationId)}`, { method: 'POST' }, ); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return alert(err.error || tp('projects.promoteAttackChainFailed')); - } + if (!(await notifyProjectApiFailure(res, 'projects.promoteAttackChainFailed', '沉淀失败'))) return; const data = await res.json(); if (typeof showNotification === 'function') { showNotification( @@ -1581,6 +1597,7 @@ async function linkFactToExistingVulnerability() { async function createVulnerabilityFromCurrentFact() { const f = _factDetailFact; if (!f || !currentProjectId) return; + if (typeof requirePermission === 'function' && !requirePermission('vulnerability:write')) return; let convId = (f.source_conversation_id || '').trim() || (typeof window.currentConversationId === 'string' ? window.currentConversationId.trim() : ''); @@ -1611,12 +1628,9 @@ async function createVulnerabilityFromCurrentFact() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return alert(err.error || tp('projects.createVulnerabilityFailed')); - } + if (!(await notifyProjectApiFailure(res, 'projects.createVulnerabilityFailed', '创建漏洞失败'))) return; const vuln = await res.json(); - await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, { + const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1628,6 +1642,7 @@ async function createVulnerabilityFromCurrentFact() { related_vulnerability_id: vuln.id, }), }); + if (!(await notifyProjectApiFailure(upd, 'projects.linkFailed', '关联失败'))) return; const createdVulnLabel = vuln.title || vuln.id; const successMsg = tp('projects.createVulnerabilityAndLinkSuccess', { value: createdVulnLabel, @@ -1648,6 +1663,7 @@ function inferSeverityFromFact(f) { } async function deprecateProjectFactByKey(factKey) { + if (!requireProjectWrite()) return; if (!confirm( tp('projects.confirmDeprecateFact', { factKey, @@ -1659,11 +1675,12 @@ async function deprecateProjectFactByKey(factKey) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fact_key: factKey }), }); - if (!res.ok) return alert(tp('projects.operationFailed')); + if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return; loadProjectFacts(); } async function restoreProjectFactByKey(factKey) { + if (!requireProjectWrite()) return; if (!confirm( tp('projects.confirmRestoreFact', { factKey, @@ -1675,10 +1692,7 @@ async function restoreProjectFactByKey(factKey) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fact_key: factKey, confidence: 'tentative' }), }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return alert(err.error || tp('projects.operationFailed')); - } + if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return; loadProjectFacts(); } @@ -1793,6 +1807,7 @@ function closeProjectsOverlay(id) { } function showNewProjectModal() { + if (!requireProjectWrite()) return; document.getElementById('project-modal-title').textContent = tp('projects.modalNewTitle'); const sub = document.getElementById('project-modal-subtitle'); if (sub) sub.textContent = tp('projects.modalNewSubtitle'); @@ -1806,6 +1821,7 @@ function showNewProjectModal() { async function showEditProjectModal(projectId) { if (!projectId) return; + if (!requireProjectWrite()) return; window._projectModalFromChat = false; window._projectModalEditId = projectId; document.getElementById('project-modal-title').textContent = tp('projects.modalEditTitle'); @@ -1848,6 +1864,7 @@ function showNewProjectModalFromChat() { } async function saveProjectModal() { + if (!requireProjectWrite()) return; const name = document.getElementById('project-modal-name').value.trim().slice(0, PROJECT_NAME_MAX_LENGTH); if (!name) return alert(tp('projects.enterProjectName')); const body = { @@ -1855,31 +1872,39 @@ async function saveProjectModal() { description: clampProjectDescription(document.getElementById('project-modal-description').value), }; const editId = window._projectModalEditId; - const res = editId - ? await apiFetch(`/api/projects/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) - : await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || tp('projects.saveFailed')); - return; - } - const fromChat = !!window._projectModalFromChat; - const fromWebshellConnId = window._projectModalFromWebshellConnId || ''; - window._projectModalFromChat = false; - window._projectModalFromWebshellConnId = ''; - closeProjectModal(); - const saved = await res.json(); - await loadProjectsList(); - if (saved.id) { - if (fromWebshellConnId && !editId) { - if (typeof applyWebshellAiProjectSelection === 'function') { - await applyWebshellAiProjectSelection(saved.id); + const submitBtn = document.getElementById('project-modal-submit-btn'); + if (submitBtn) submitBtn.disabled = true; + try { + const res = editId + ? await apiFetch(`/api/projects/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + : await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!(await notifyProjectApiFailure(res, 'projects.saveFailed', '保存失败'))) return; + const fromChat = !!window._projectModalFromChat; + const fromWebshellConnId = window._projectModalFromWebshellConnId || ''; + window._projectModalFromChat = false; + window._projectModalFromWebshellConnId = ''; + closeProjectModal(); + const saved = await res.json(); + await loadProjectsList(); + if (saved.id) { + if (fromWebshellConnId && !editId) { + if (typeof applyWebshellAiProjectSelection === 'function') { + await applyWebshellAiProjectSelection(saved.id); + } + } else if (fromChat && !editId) { + await applyChatProjectSelection(saved.id); + } else { + await selectProject(saved.id); } - } else if (fromChat && !editId) { - await applyChatProjectSelection(saved.id); - } else { - await selectProject(saved.id); } + } catch (error) { + if (typeof notifyApiError === 'function') { + notifyApiError(error?.message || tpFmt('projects.saveFailed', '保存失败')); + } else { + alert(error?.message || tpFmt('projects.saveFailed', '保存失败')); + } + } finally { + if (submitBtn) submitBtn.disabled = false; } } @@ -1914,7 +1939,7 @@ function insertProjectScopeExample() { } async function saveProjectSettings() { - if (!currentProjectId) return; + if (!currentProjectId || !requireProjectWrite()) return; const scopeRaw = document.getElementById('project-edit-scope').value.trim(); if (scopeRaw) { try { @@ -1936,10 +1961,14 @@ async function saveProjectSettings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - if (!res.ok) return alert(tp('projects.saveFailed')); + if (!(await notifyProjectApiFailure(res, 'projects.saveFailed', '保存失败'))) return; await loadProjectsList(); await selectProject(currentProjectId); - alert(tp('projects.saved')); + if (typeof notifyApiError === 'function') { + notifyApiError(tp('projects.saved'), 'success'); + } else { + alert(tp('projects.saved')); + } } function findProjectById(projectId) { @@ -2002,6 +2031,7 @@ function showProjectListActionMenu(event, projectId) { } if (deleteText) deleteText.textContent = tp('projects.deleteProject'); positionProjectListActionMenu(event); + if (typeof applyRBACToUI === 'function') applyRBACToUI(menu); } function initProjectListActionMenu() { @@ -2020,6 +2050,7 @@ function initProjectListActionMenu() { } async function toggleProjectArchiveById(projectId) { + if (!requireProjectWrite()) return; const p = findProjectById(projectId); if (!p) return; const cur = p.status || 'active'; @@ -2030,7 +2061,7 @@ async function toggleProjectArchiveById(projectId) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: next }), }); - if (!res.ok) return alert(tp('projects.operationFailed')); + if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return; await loadProjectsList(); if (currentProjectId === projectId && projectsCache.some((item) => item.id === projectId)) { await selectProject(projectId); @@ -2042,10 +2073,11 @@ async function toggleProjectArchiveById(projectId) { } async function deleteProjectById(projectId) { + if (!requireProjectDelete()) return; if (!projectId || !confirm(tp('projects.confirmDeleteProject'))) return; const deletedIndex = projectsCache.findIndex((p) => p.id === projectId); const res = await apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' }); - if (!res.ok) return alert(tp('projects.deleteFailed')); + if (!(await notifyProjectApiFailure(res, 'projects.deleteFailed', '删除失败'))) return; if (getActiveProjectId() === projectId) setActiveProjectId(''); if (currentProjectId === projectId) currentProjectId = null; await loadProjectsList(); @@ -2147,12 +2179,14 @@ function fillFactModalForm(f) { function showAddFactModal() { if (!currentProjectId) return alert(tp('projects.selectProjectFirst')); + if (!requireProjectWrite()) return; resetFactModalForm(); openProjectsOverlay('fact-modal'); } async function showEditFactModal(factKey) { if (!currentProjectId) return alert(tp('projects.selectProjectFirst')); + if (!requireProjectWrite()) return; resetFactModalForm(); openProjectsOverlay('fact-modal', { focus: false }); const res = await apiFetch( @@ -2175,6 +2209,7 @@ function closeFactModal() { } async function saveFactModal() { + if (!requireProjectWrite()) return; 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'; @@ -2208,18 +2243,17 @@ async function saveFactModal() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - return alert(err.error || tp('projects.saveFailed')); - } + if (!(await notifyProjectApiFailure(res, 'projects.saveFailed', '保存失败'))) return; closeFactModal(); loadProjectFacts(); if (currentProjectTab === 'graph') loadProjectFactGraph(); } async function deleteProjectFact(id) { + if (!requireProjectWrite()) return; if (!confirm(tp('projects.confirmDeleteFact'))) return; - await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); + const res = await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); + if (!(await notifyProjectApiFailure(res, 'projects.operationFailed', '操作失败'))) return; loadProjectFacts(); if (currentProjectTab === 'graph') loadProjectFactGraph(); } @@ -2511,6 +2545,8 @@ async function renderChatProjectPanel() { }); clearProjectPickerPanelSearch('chat', 'chat-project-search'); await loadChatProjectPanelList(); + const panel = document.getElementById('chat-project-panel'); + if (panel && typeof applyRBACToUI === 'function') applyRBACToUI(panel); requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus()); } diff --git a/web/static/js/rbac-guards.js b/web/static/js/rbac-guards.js new file mode 100644 index 00000000..c6cf98b0 --- /dev/null +++ b/web/static/js/rbac-guards.js @@ -0,0 +1,278 @@ +/** + * 全局写操作权限守卫:为各页面 onclick 绑定的 window/C2 方法统一包一层 requirePermission。 + * 在全部业务脚本加载完成后执行(见 index.html 引用顺序)。 + */ +(function () { + 'use strict'; + + const GLOBAL_WRITE_HANDLER_PERMISSIONS = { + // 对话 / 分组 + sendMessage: 'chat:write', + startNewConversation: 'chat:write', + deleteConversation: 'chat:delete', + deleteConversationTurnFromUI: 'chat:delete', + deleteConversationFromContext: 'chat:delete', + showBatchManageModal: 'chat:delete', + deleteSelectedConversations: 'chat:delete', + renameConversation: 'chat:write', + pinConversation: 'chat:write', + showCreateGroupModal: 'group:write', + createGroup: 'group:write', + editGroup: 'group:write', + deleteGroup: 'group:delete', + deleteGroupFromContext: 'group:delete', + pinGroupFromContext: 'group:write', + renameGroupFromContext: 'group:write', + applyBatchGroupChange: 'group:write', + applyCustomIcon: 'group:write', + + // 人机协同 + applyHitlSidebarConfig: 'hitl:write', + saveHitlPageWhitelist: 'hitl:write', + saveHitlAuditStrategy: 'hitl:write', + saveHitlConversationConfig: 'hitl:write', + submitHitlDecision: 'hitl:write', + submitHitlDecisionWithPayload: 'hitl:write', + submitWorkflowHitlDecisionFromPage: 'hitl:write', + submitWorkflowHitlDecision: 'hitl:write', + dismissHitlItem: 'hitl:write', + batchDeleteHitlLogs: 'hitl:write', + clearHitlLogs: 'hitl:write', + + // 项目 + showNewProjectModal: 'project:write', + showNewProjectModalFromChat: 'project:write', + showNewProjectModalFromWebshellAi: 'project:write', + showEditProjectModal: 'project:write', + saveProjectModal: 'project:write', + saveProjectSettings: 'project:write', + archiveCurrentProject: 'project:write', + deleteCurrentProject: 'project:delete', + deleteProjectFromListMenu: 'project:delete', + toggleProjectFactGraphConnectMode: 'project:write', + editProjectFromListMenu: 'project:write', + toggleProjectArchiveFromListMenu: 'project:write', + showAddFactModal: 'project:write', + showEditFactModal: 'project:write', + editSelectedGraphFact: 'project:write', + editFactFromDetail: 'project:write', + saveFactModal: 'project:write', + deleteProjectFactEdge: 'project:delete', + deprecateProjectFactByKey: 'project:write', + restoreProjectFactByKey: 'project:write', + promoteConversationAttackChain: 'attackchain:write', + linkFactToExistingVulnerability: 'project:write', + createVulnerabilityFromCurrentFact: 'vulnerability:write', + unbindConversationFromProject: 'project:write', + + // 漏洞 + showAddVulnerabilityModal: 'vulnerability:write', + saveVulnerability: 'vulnerability:write', + deleteVulnerability: 'vulnerability:delete', + batchDeleteVulnerabilityReports: 'vulnerability:delete', + exportVulnerabilityReports: 'vulnerability:read', + changeVulnerabilityStatus: 'vulnerability:write', + bindVulnerabilityProject: 'vulnerability:write', + + // 角色 / Skills / Agents + showAddRoleModal: 'roles:write', + saveRole: 'roles:write', + deleteRole: 'roles:delete', + showAddSkillModal: 'skills:write', + saveSkill: 'skills:write', + deleteSkill: 'skills:delete', + showAddMarkdownAgentModal: 'agents:write', + saveMarkdownAgent: 'agents:write', + deleteMarkdownAgent: 'agents:delete', + + // 知识库 + buildKnowledgeIndex: 'knowledge:write', + rebuildKnowledgeIndexFull: 'knowledge:write', + showAddKnowledgeItemModal: 'knowledge:write', + saveKnowledgeItem: 'knowledge:write', + editKnowledgeItem: 'knowledge:write', + deleteKnowledgeItem: 'knowledge:delete', + deleteRetrievalLog: 'knowledge:delete', + + // 设置 / MCP + applySettings: 'config:write', + saveToolsConfig: 'config:write', + saveExternalMCP: 'mcp:write', + showAddExternalMCPModal: 'mcp:write', + deleteExternalMCP: 'mcp:write', + toggleExternalMCP: 'mcp:write', + changePassword: 'auth:self', + testOpenAIConnection: 'config:write', + testVisionConnection: 'config:write', + testHitlAuditModelConnection: 'config:write', + submitMcpToolAbortModal: 'monitor:write', + cancelMCPToolExecution: 'monitor:write', + + // FOFA / 信息收集 + submitFofaSearch: 'fofa:execute', + scanFofaRow: 'fofa:execute', + batchScanSelectedFofaRows: 'fofa:execute', + exportFofaResults: 'fofa:execute', + + // 任务队列 + showBatchImportModal: 'tasks:write', + deleteBatchQueue: 'tasks:delete', + deleteBatchQueueFromList: 'tasks:delete', + createBatchQueue: 'tasks:write', + saveAddBatchTask: 'tasks:write', + saveInlineTask: 'tasks:write', + deleteBatchTask: 'tasks:delete', + deleteBatchTaskFromElement: 'tasks:delete', + saveInlineTitle: 'tasks:write', + saveInlineRole: 'tasks:write', + saveInlineAgentMode: 'tasks:write', + saveInlineConcurrency: 'tasks:write', + saveInlineSchedule: 'tasks:write', + startBatchQueue: 'tasks:write', + pauseBatchQueue: 'tasks:write', + rerunBatchQueue: 'tasks:write', + runSingleBatchTask: 'tasks:write', + editBatchTaskFromElement: 'tasks:write', + batchCancelTasks: 'tasks:write', + cancelTask: 'tasks:write', + cancelActiveTask: 'tasks:write', + cancelProgressTask: 'tasks:write', + + // 工作流 + saveWorkflowDraft: 'workflow:write', + applyWorkflowMetaModal: 'workflow:write', + deleteCurrentWorkflow: 'workflow:delete', + deleteWorkflowSelection: 'workflow:delete', + dryRunWorkflowDraft: 'workflow:execute', + toggleWorkflowEnabled: 'workflow:write', + addWorkflowNodeFromPalette: 'workflow:write', + toggleWorkflowConnectMode: 'workflow:write', + addWorkflowCustomField: 'workflow:write', + + // 文件管理 + saveChatFilesEdit: 'files:write', + deleteChatFile: 'files:delete', + deleteChatFileIdx: 'files:delete', + deleteChatFolderFromBrowse: 'files:delete', + submitChatFilesRename: 'files:write', + submitChatFilesMkdir: 'files:write', + chatFilesOpenUploadPicker: 'files:write', + chatFilesUploadFiles: 'files:write', + onChatFilesUploadPick: 'files:write', + chatFilesUploadToFolderClick: 'files:write', + chatFilesDeleteFolderFromBtn: 'files:delete', + + // 监控 + deleteExecution: 'monitor:delete', + batchDeleteExecutions: 'monitor:delete', + + // 攻击链 + regenerateAttackChain: 'attackchain:write', + exportAttackChain: 'attackchain:read', + + // 通知 + markAllNotificationsSeen: 'notification:write', + + // RBAC + saveRbacUser: 'rbac:write', + deleteSelectedRbacUser: 'rbac:write', + saveRbacRole: 'rbac:write', + deleteRbacRole: 'rbac:write', + createRbacAssignment: 'rbac:write', + deleteRbacAssignment: 'rbac:write', + saveSelectedUserRoles: 'rbac:write', + + // WebShell + showAddWebshellModal: 'webshell:write', + showEditWebshellModal: 'webshell:write', + saveWebshellConnection: 'webshell:write', + deleteWebshell: 'webshell:delete', + testWebshellConnection: 'webshell:write', + + // 机器人 + openRobotCreateModal: 'robot:write', + openRobotEditor: 'robot:write', + startWechatRobotBind: 'robot:write', + submitWechatVerifyCode: 'robot:write', + + // 审计 + exportAuditLogs: 'audit:read', + exportAuditLogsCsv: 'audit:read', + runAuditExport: 'audit:read', + + // Agent 中断 + submitUserInterruptContinue: 'agent:execute', + submitUserInterruptHardCancel: 'agent:execute', + + // 终端(多会话) + addTerminalTab: 'terminal:execute', + removeTerminalTab: 'terminal:execute', + }; + + const NAMESPACE_WRITE_HANDLER_PERMISSIONS = { + C2: { + showCreateListenerModal: 'c2:write', + createListener: 'c2:write', + saveListener: 'c2:write', + editListener: 'c2:write', + startListener: 'c2:write', + stopListener: 'c2:write', + deleteListener: 'c2:delete', + deleteSessionRecord: 'c2:delete', + deleteSelectedSessions: 'c2:delete', + deleteFilteredSessions: 'c2:delete', + killSession: 'c2:write', + setSessionSleep: 'c2:write', + submitSessionSleep: 'c2:write', + uploadFileToImplant: 'c2:write', + onC2FileUploadPick: 'c2:write', + openFileUploadPicker: 'c2:write', + deleteTaskById: 'c2:delete', + deleteSelectedTasks: 'c2:delete', + cancelTask: 'c2:write', + buildBeacon: 'c2:write', + generateOneliner: 'c2:write', + createProfile: 'c2:write', + showCreateProfileModal: 'c2:write', + deleteProfile: 'c2:delete', + deleteEventById: 'c2:delete', + deleteSelectedEvents: 'c2:delete', + runTerminalCommand: 'terminal:execute', + executeInTerminal: 'terminal:execute', + }, + }; + + function wrapHandlerWithPermission(fn, permission) { + if (typeof fn !== 'function') return fn; + if (fn.__rbacGuarded) return fn; + const wrapped = function rbacGuardedHandler(...args) { + if (typeof requirePermission === 'function' && !requirePermission(permission)) { + return undefined; + } + return fn.apply(this, args); + }; + wrapped.__rbacGuarded = true; + wrapped.__rbacOriginal = fn; + return wrapped; + } + + function installWriteHandlerGuards() { + Object.entries(GLOBAL_WRITE_HANDLER_PERMISSIONS).forEach(([name, permission]) => { + if (typeof window[name] === 'function') { + window[name] = wrapHandlerWithPermission(window[name], permission); + } + }); + Object.entries(NAMESPACE_WRITE_HANDLER_PERMISSIONS).forEach(([ns, methods]) => { + const root = window[ns]; + if (!root || typeof root !== 'object') return; + Object.entries(methods).forEach(([method, permission]) => { + if (typeof root[method] === 'function') { + root[method] = wrapHandlerWithPermission(root[method], permission); + } + }); + }); + } + + window.installWriteHandlerGuards = installWriteHandlerGuards; + installWriteHandlerGuards(); +})(); diff --git a/web/static/js/rbac.js b/web/static/js/rbac.js index 6336dff7..cefe0931 100644 --- a/web/static/js/rbac.js +++ b/web/static/js/rbac.js @@ -209,6 +209,10 @@ function renderRbacPendingSelection() { } function rbacNotify(message, type = 'info') { + if (typeof notifyApiError === 'function') { + notifyApiError(message, type); + return; + } if (typeof showNotification === 'function') showNotification(message, type); else if (type === 'error') alert(message); } diff --git a/web/static/js/roles.js b/web/static/js/roles.js index ccfa075c..79f7af16 100644 --- a/web/static/js/roles.js +++ b/web/static/js/roles.js @@ -1279,6 +1279,7 @@ function setSelectedRoleTools(selectedToolKeys) { // 显示添加角色模态框 async function showAddRoleModal() { + if (typeof requirePermission === 'function' && !requirePermission('roles:write')) return; const modal = document.getElementById('role-modal'); if (!modal) return; @@ -1627,6 +1628,7 @@ async function loadAllToolsToStateMap() { // 保存角色 async function saveRole() { + if (typeof requirePermission === 'function' && !requirePermission('roles:write')) return; const name = document.getElementById('role-name').value.trim(); if (!name) { showNotification(_t('roleModal.roleNameRequired'), 'error'); diff --git a/web/static/js/router.js b/web/static/js/router.js index fa9248b4..4b77dbcd 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -120,6 +120,10 @@ function switchPage(pageId) { // 页面特定的初始化 initPage(pageId); + + if (typeof applyRBACToUI === 'function') { + applyRBACToUI(targetPage); + } } } window.switchPage = switchPage; @@ -414,9 +418,11 @@ async function initPage(pageId) { startExternalMcpPoll(); } }; - // 先拉取配置(含 tool_search 常驻列表),再加载工具与外部 MCP - if (typeof loadConfig === 'function') { - loadConfig(false) + // 先拉取配置(含 tool_search 常驻列表),再加载工具与外部 MCP。 + // 仅有 mcp:read 的用户无需拉全量 /api/config(需 config:read)。 + const canLoadFullConfig = typeof hasPermission !== 'function' || hasPermission('config:read'); + if (typeof loadConfig === 'function' && canLoadFullConfig) { + loadConfig(false, { silent: true }) .catch(err => { console.warn('加载配置失败(将继续加载 MCP 列表):', err); }) diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 7b2b8e0d..3e74d922 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -584,10 +584,14 @@ window.onclick = function(event) { } // 加载配置 -async function loadConfig(loadTools = true) { +async function loadConfig(loadTools = true, options = {}) { + const silent = options && options.silent === true; try { const response = await apiFetch('/api/config'); if (!response.ok) { + if (typeof readApiError === 'function') { + throw new Error(await readApiError(response, '获取配置失败')); + } throw new Error('获取配置失败'); } @@ -993,10 +997,17 @@ async function loadConfig(loadTools = true) { } } catch (error) { console.error('加载配置失败:', error); - const baseMsg = (typeof window !== 'undefined' && typeof window.t === 'function') - ? window.t('settings.apply.loadFailed') - : '加载配置失败'; - alert(baseMsg + ': ' + error.message); + if (!silent) { + const baseMsg = (typeof window !== 'undefined' && typeof window.t === 'function') + ? window.t('settings.apply.loadFailed') + : '加载配置失败'; + if (typeof notifyApiError === 'function') { + notifyApiError(baseMsg + ': ' + error.message); + } else { + alert(baseMsg + ': ' + error.message); + } + } + throw error; } } @@ -1050,6 +1061,9 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) { clearTimeout(timeoutId); if (!response.ok) { + if (typeof readApiError === 'function') { + throw new Error(await readApiError(response, '获取工具列表失败')); + } throw new Error('获取工具列表失败'); } @@ -2790,6 +2804,7 @@ async function testOpenAIConnection() { // 保存工具配置(独立函数,用于MCP管理页面) async function saveToolsConfig() { + if (typeof requirePermission === 'function' && !requirePermission('config:write')) return; try { // 先保存当前页的状态到全局映射 saveCurrentPageToolStates(); @@ -3004,7 +3019,12 @@ let currentEditingMCPName = null; // 拉取外部MCP列表数据(供轮询使用,返回 { servers, stats }) async function fetchExternalMCPs() { const response = await apiFetch('/api/external-mcp'); - if (!response.ok) throw new Error('获取外部MCP列表失败'); + if (!response.ok) { + if (typeof readApiError === 'function') { + throw new Error(await readApiError(response, '获取外部MCP列表失败')); + } + throw new Error('获取外部MCP列表失败'); + } return response.json(); } @@ -3207,6 +3227,7 @@ function renderExternalMCPStats(stats) { // 显示添加外部MCP模态框 function showAddExternalMCPModal() { + if (typeof requirePermission === 'function' && !requirePermission('mcp:write')) return; currentEditingMCPName = null; document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.addExternalMCP') : '添加外部MCP'); document.getElementById('external-mcp-json').value = ''; @@ -3316,6 +3337,7 @@ function loadExternalMCPExample() { // 保存外部MCP async function saveExternalMCP() { + if (typeof requirePermission === 'function' && !requirePermission('mcp:write')) return; const jsonTextarea = document.getElementById('external-mcp-json'); const jsonStr = jsonTextarea.value.trim(); const errorDiv = document.getElementById('external-mcp-json-error'); diff --git a/web/static/js/skills.js b/web/static/js/skills.js index ef35b615..e49048c8 100644 --- a/web/static/js/skills.js +++ b/web/static/js/skills.js @@ -472,6 +472,7 @@ function wireSkillModalOnce() { } function showAddSkillModal() { + if (typeof requirePermission === 'function' && !requirePermission('skills:write')) return; wireSkillModalOnce(); const modal = document.getElementById('skill-modal'); if (!modal) return; @@ -739,6 +740,7 @@ function closeSkillModal() { // 保存skill async function saveSkill() { + if (typeof requirePermission === 'function' && !requirePermission('skills:write')) return; if (isSavingSkill) return; const name = document.getElementById('skill-name').value.trim(); diff --git a/web/static/js/tasks.js b/web/static/js/tasks.js index 57b4ea89..5bf480cd 100644 --- a/web/static/js/tasks.js +++ b/web/static/js/tasks.js @@ -988,6 +988,7 @@ document.addEventListener('DOMContentLoaded', function() { // 创建批量任务队列 async function createBatchQueue() { + if (typeof requirePermission === 'function' && !requirePermission('tasks:write')) return; const input = document.getElementById('batch-tasks-input'); const titleInput = document.getElementById('batch-queue-title'); const roleSelect = document.getElementById('batch-queue-role'); @@ -2250,6 +2251,7 @@ async function saveInlineTask(queueId, taskId) { // 显示添加批量任务模态框 function showAddBatchTaskModal() { + if (typeof requirePermission === 'function' && !requirePermission('tasks:write')) return; const queueId = batchQueuesState.currentQueueId; if (!queueId) { alert(_t('tasks.queueInfoMissing')); diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js index 3b74d3b4..ca90dec2 100644 --- a/web/static/js/vulnerability.js +++ b/web/static/js/vulnerability.js @@ -1422,7 +1422,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) { -
' + '
' + '' + + ((typeof hasPermission === 'function' && hasPermission('project:write')) + ? ('') + : '') + + '' + '
' + ' +
暂无新事件
@@ -867,7 +867,7 @@
- @@ -1086,13 +1086,13 @@ - - @@ -1260,7 +1260,7 @@ - - +
@@ -1372,7 +1372,7 @@ 审计策略
- +
@@ -1391,7 +1391,7 @@
免审批工具白名单 - +

每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。

@@ -1499,7 +1499,7 @@
- +
@@ -1524,7 +1524,7 @@

MCP 工具配置

- +
@@ -1549,7 +1549,7 @@

外部 MCP 配置

- +
@@ -1568,9 +1568,9 @@

知识管理

- - - + + +
@@ -1673,7 +1673,7 @@

信息收集

- +
@@ -1737,10 +1737,10 @@
已选择 0 条
- - - - + + + +
@@ -1774,7 +1774,7 @@
- +
@@ -1800,7 +1800,7 @@

选择或创建项目

项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。

- +
@@ -1954,7 +1954,7 @@
- +
@@ -2135,14 +2135,14 @@
- - + +
修改后请点击保存以同步到服务器 -
@@ -2156,10 +2156,10 @@
@@ -2322,7 +2322,7 @@
@@ -2359,7 +2359,7 @@
@@ -2571,7 +2571,7 @@ @@ -2592,7 +2592,7 @@
@@ -2631,13 +2631,13 @@

节点库

- - - - - - - + + + + + + +
@@ -2650,12 +2650,12 @@
- - + + - - - + + +
@@ -2666,7 +2666,7 @@