diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index ee1ea032..7f2b6761 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -92,3 +92,8 @@ - Excel 第 52 行:截图中的 `bad host/abc` 原先会直接拼入 URL。单行生成与 Beacon 构建均在生成前校验 IP/DNS 主机名,拒绝协议、路径、端口、空格、非法数字 IP 与命令片段;不执行 DNS 查询,也不改变地址解析优先级。 - URL 以监听器协议生成,HTTPS 使用 https;IPv6 使用 net.JoinHostPort 补齐方括号,普通域名及 IPv4 保持原格式。 - 主机校验覆盖全部单行类型;真实 API 验证非法输入返回 400、合法域名与 IPv6 正常生成,测试仅使用 test-only-token。 + +## 追加:重复包提示与关联事实查看 + +- Excel 第 28 行:现有预检已经区分相同内容和 ID 冲突,后端跳过/保留不会覆盖本地数据;确认动作仍用于记录冲突处理。按钮明确改为“确认跳过导入”或“确认保留本地版本”,结果分别显示“已跳过导入”“已保留本地版本”。创建、覆盖确认及另存语义保留。14 项既有与新增包导入测试通过。 +- Excel 第 15 行:关联事实入口原先先切到 facts 并清空筛选,改为留在当前页面查询并直接打开详情;无关联只提示,多条取消也留在原页。4 项实际函数回归覆盖单条、空集合、多条选择/取消和 API 失败,不再发生列表导航或筛选重置。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 37f6060e..65ed8ea9 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -17,7 +17,7 @@ | 12 | 11 | 样式问题 | 待核对 | | 13 | 12 | 样式问题 | 待核对 | | 14 | 13 | 明文回显 API Key | 已修复并有针对性验证 | -| 15 | 14 | 点击查看事实后,弹窗无事实,然后就跳转到事实页面了,觉得留在本页面合适 | 待核对 | +| 15 | 14 | 点击查看事实后,弹窗无事实,然后就跳转到事实页面了,觉得留在本页面合适 | 已修复查看入口强制跳页;直接打开事实详情,空关联仅提示,4 项脚本回归通过 | | 16 | 15 | 不同的项目保存漏洞时能有去重逻辑吗,待确认。隔离好像也没啥问题 | 跨项目全局去重属行为变更建议;现有按项目授权隔离,不改动 | | 17 | 16 | 拦截规则已经停用了,但是还是被拦截了。可能刚停用还没生效,然后被拦截,(是因为停用之后 未保存只 | 表中已说明未保存开关;当前有未保存状态与保存入口,未发现运行时缺陷 | | 18 | 17 | 样式问题 | 待核对 | @@ -30,7 +30,7 @@ | 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 | | 26 | 24 | 工作流 id 由客户端随便填、零校验 | 已修复新 ID 格式/长度、重复创建及编辑改 ID 校验 | | 27 | 25 | 样式问题 | 待核对 | -| 28 | 26 | 导入相同的工作流id的包,还能点确认,并且按钮显示导入完成。拦截的交互可以优化 | 待核对 | +| 28 | 26 | 导入相同的工作流id的包,还能点确认,并且按钮显示导入完成。拦截的交互可以优化 | 已修复确认及结果按钮语义;跳过/保留明确显示,14 项包导入测试通过 | | 29 | 27 | Agent 节点 16 分 43 秒 / 118 次工具调用,全程零进度、零超时 | 待核对 | | 30 | 28 | 工具节点在会话容器内执行 nmap 默认参数必失败 | 当前仓库未实现,不扩展能力 | | 31 | 29 | 参数模板未解析被原样下发,流程「成功」但结论与事实相反 | 已修复 JSON 参数模板展开,嵌套/转义及实际工具运行回归通过 | diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 11ea2bab..a3e8dc42 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3975,6 +3975,8 @@ "continueOverwrite": "Continue to overwrite confirmation", "confirmRename": "Confirm save as new ID", "confirmCreate": "Confirm create", + "confirmSkip": "Confirm skip import", + "confirmKeep": "Confirm keep local version", "confirmComplete": "Confirm completion" }, "result": { @@ -3984,6 +3986,8 @@ "keptExisting": "The local workflow was kept; no changes were made.", "skippedIdentical": "The same local content already exists; import was skipped.", "complete": "Import completed.", + "skippedAction": "Import skipped", + "keptAction": "Local version kept", "completedAction": "Import completed" }, "exportSelectSaved": "Choose a saved workflow before exporting." diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 73f826b8..7b9d82a9 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3987,6 +3987,8 @@ "continueOverwrite": "К подтверждению перезаписи", "confirmRename": "Подтвердить сохранение с новым ID", "confirmCreate": "Подтвердить создание", + "confirmSkip": "Подтвердить пропуск импорта", + "confirmKeep": "Подтвердить сохранение локальной версии", "confirmComplete": "Подтвердить завершение" }, "result": { @@ -3996,6 +3998,8 @@ "keptExisting": "Локальный сценарий оставлен без изменений.", "skippedIdentical": "Такое локальное содержимое уже есть; импорт пропущен.", "complete": "Импорт завершён.", + "skippedAction": "Импорт пропущен", + "keptAction": "Локальная версия сохранена", "completedAction": "Импорт завершён" }, "exportSelectSaved": "Перед экспортом выберите сохранённый сценарий." diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index d00e0080..fc334b0e 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3963,6 +3963,8 @@ "continueOverwrite": "继续确认覆盖", "confirmRename": "确认另存", "confirmCreate": "确认创建", + "confirmSkip": "确认跳过导入", + "confirmKeep": "确认保留本地版本", "confirmComplete": "确认完成" }, "result": { @@ -3972,6 +3974,8 @@ "keptExisting": "已保留本地工作流,未写入任何更改。", "skippedIdentical": "本地已存在相同内容,已跳过导入。", "complete": "导入已完成。", + "skippedAction": "已跳过导入", + "keptAction": "已保留本地版本", "completedAction": "导入已完成" }, "exportSelectSaved": "请先选择已保存的工作流后再导出。" diff --git a/web/static/js/project-related-facts.test.cjs b/web/static/js/project-related-facts.test.cjs new file mode 100644 index 00000000..f3c1b167 --- /dev/null +++ b/web/static/js/project-related-facts.test.cjs @@ -0,0 +1,36 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('web/static/js/projects.js', 'utf8'); +function harness(facts, selection, ok = true) { + const opened = [], requests = [], messages = []; + const c = vm.createContext({ currentProjectId: 'project1', URLSearchParams, apiFetch: async url => { requests.push(url); return { ok, json: async () => facts }; }, alert: message => messages.push(message), tp: key => key, prompt: () => selection, viewProjectFactBody: async key => opened.push(key), switchProjectTab: () => { throw Error('unexpected navigation'); }, loadProjectFacts: () => { throw Error('unexpected list refresh'); }, document: { getElementById: () => { throw Error('unexpected filter reset'); } } }); + vm.runInContext(source.slice(source.indexOf('async function viewFactsForVulnerability('), source.indexOf('function openProjectsOverlay(')), c); + return { c, opened, requests, messages }; +} +test('single related fact opens its detail without changing the page or filters', async () => { + const h = harness([{ fact_key: 'evidence' }]); + await h.c.viewFactsForVulnerability('v1'); + assert.deepEqual(h.opened, ['evidence']); + assert.match(h.requests[0], /related_vulnerability_id=v1/); +}); +test('no related facts shows a message without opening an empty modal or navigating', async () => { + const h = harness([]); + await h.c.viewFactsForVulnerability('v1'); + assert.equal(h.opened.length, 0); + assert.deepEqual(h.messages, ['projects.noFactsForVulnerability']); +}); +test('multiple facts open the selected detail and cancellation leaves the page intact', async () => { + for (const selection of ['2', null, '', '9']) { + const h = harness([{ fact_key: 'one' }, { fact_key: 'two' }], selection); + await h.c.viewFactsForVulnerability('v1'); + assert.deepEqual(h.opened, selection === '2' ? ['two'] : []); + } +}); +test('failed related fact request reports the failure without opening a modal', async () => { + const h = harness([], null, false); + await h.c.viewFactsForVulnerability('v1'); + assert.deepEqual(h.messages, ['projects.loadRelatedFactsFailed']); + assert.equal(h.opened.length, 0); +}); diff --git a/web/static/js/projects.js b/web/static/js/projects.js index a75dfb3c..81be0739 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -1901,28 +1901,16 @@ function openVulnerabilityDetail(vulnId) { async function viewFactsForVulnerability(vulnId) { if (!currentProjectId) return; - switchProjectTab('facts'); - const searchEl = document.getElementById('project-facts-search'); - const catEl = document.getElementById('project-facts-filter-category'); - const confEl = document.getElementById('project-facts-filter-confidence'); - const sparseEl = document.getElementById('project-facts-filter-sparse'); - const hideDepEl = document.getElementById('project-facts-filter-hide-deprecated'); - if (searchEl) searchEl.value = ''; - if (catEl) catEl.value = ''; - if (confEl) confEl.value = ''; - if (sparseEl) sparseEl.checked = false; - if (hideDepEl) hideDepEl.checked = true; const params = new URLSearchParams({ limit: '50', related_vulnerability_id: vulnId }); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${params}`); if (!res.ok) return alert(tp('projects.loadRelatedFactsFailed')); const facts = await res.json(); if (!facts.length) { alert(tp('projects.noFactsForVulnerability')); - loadProjectFacts(); return; } if (facts.length === 1) { - viewProjectFactBody(facts[0].fact_key); + await viewProjectFactBody(facts[0].fact_key); return; } const pick = prompt( @@ -1933,12 +1921,10 @@ async function viewFactsForVulnerability(vulnId) { }) || `This vulnerability is linked to ${facts.length} facts. Enter index to view:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`, ); if (pick == null || pick === '') { - loadProjectFacts(); return; } const idx = parseInt(pick, 10) - 1; - if (facts[idx]) viewProjectFactBody(facts[idx].fact_key); - else loadProjectFacts(); + if (facts[idx]) await viewProjectFactBody(facts[idx].fact_key); } function openProjectsOverlay(id, opts) { diff --git a/web/static/js/workflow-package-ui.test.cjs b/web/static/js/workflow-package-ui.test.cjs index c06206af..3a6d89ee 100644 --- a/web/static/js/workflow-package-ui.test.cjs +++ b/web/static/js/workflow-package-ui.test.cjs @@ -80,3 +80,33 @@ test('每次打开导入弹窗都会开始新的导入会话', () => { assert.match(openHandler, /resetWorkflowPackageImport\(\);/); assert.doesNotMatch(openHandler, /restoreWorkflowPackageState\(\)/); }); + +test('未写入的导入结果明确显示跳过或保留,不显示导入完成', () => { + const vm = require('node:vm'); + const source = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + const start = source.indexOf(' function renderWorkflowPackageImportResult('); + const end = source.indexOf(' async function restoreWorkflowPackageState(', start); + for (const [result, expected] of [['skipped_identical', '已跳过导入'], ['kept_existing', '已保留本地版本'], ['created', '导入已完成'], ['overwritten', '导入已完成'], ['renamed', '导入已完成']]) { + const button = {}; + const c = vm.createContext({ workflowPackageText: (_, fallback) => fallback, renderWorkflowPackageResolution() {}, workflowPackageSubmitBtn: () => button }); + vm.runInContext(source.slice(start, end), c); + c.renderWorkflowPackageImportResult({ result }); + assert.equal(button.textContent, expected); + assert.equal(button.disabled, true); + } +}); + +test('重复包确认明确区分跳过和保留,创建及覆盖入口保持原语义', () => { + const vm = require('node:vm'); + const source = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + const start = source.indexOf(' function renderWorkflowPackageResolution('); + const end = source.indexOf(' function resetWorkflowPackageImport(', start); + for (const [state, action, expected] of [['identical', 'keep_existing', '确认跳过导入'], ['id_conflict', 'keep_existing', '确认保留本地版本'], ['none', 'create', '确认创建'], ['id_conflict', 'overwrite', '继续确认覆盖'], ['id_conflict', 'rename', '确认另存']]) { + const button = {}; + const c = vm.createContext({ workflowPackageState: { inspection: { conflict: { state } }, resolutionAction: action, riskChoicesVisible: true, newWorkflowId: 'copy' }, workflowPackageResolutionEl: () => ({}), workflowPackageSubmitBtn: () => button, workflowPackageClient: () => ({ allowedActions: () => [action] }), workflowPackageText: (_, fallback) => fallback, esc: value => value, workflowPackageResolutionCard: () => '', workflowPackageSetStep() {} }); + vm.runInContext(source.slice(start, end), c); + c.renderWorkflowPackageResolution(); + assert.equal(button.textContent, expected); + assert.equal(button.disabled, false); + } +}); diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index 32d9ecbf..c744022f 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -2719,7 +2719,10 @@ submit.disabled = !action || renameIncomplete; if (action === 'overwrite') submit.textContent = workflowPackageText('workflows.package.resolution.continueOverwrite', '继续确认覆盖'); else if (action === 'rename') submit.textContent = workflowPackageText('workflows.package.resolution.confirmRename', '确认另存'); - else submit.textContent = action === 'create' ? workflowPackageText('workflows.package.resolution.confirmCreate', '确认创建') : workflowPackageText('workflows.package.resolution.confirmComplete', '确认完成'); + else if (action === 'create') submit.textContent = workflowPackageText('workflows.package.resolution.confirmCreate', '确认创建'); + else submit.textContent = conflictState === 'identical' + ? workflowPackageText('workflows.package.resolution.confirmSkip', '确认跳过导入') + : workflowPackageText('workflows.package.resolution.confirmKeep', '确认保留本地版本'); } workflowPackageSetStep('import'); } @@ -2775,7 +2778,11 @@ const submit = workflowPackageSubmitBtn(); if (submit) { submit.disabled = true; - submit.textContent = workflowPackageText('workflows.package.result.completedAction', '导入已完成'); + submit.textContent = result === 'skipped_identical' + ? workflowPackageText('workflows.package.result.skippedAction', '已跳过导入') + : result === 'kept_existing' + ? workflowPackageText('workflows.package.result.keptAction', '已保留本地版本') + : workflowPackageText('workflows.package.result.completedAction', '导入已完成'); } }