diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index bd4e0556..86bff03b 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -74,3 +74,9 @@ - Excel 第 3、6 行:登录空密码通过自定义校验展示原因并聚焦字段,编辑后清除旧错误;显示/隐藏密码按钮在关闭或重新打开登录时恢复隐藏。修改密码显示当前密码必填、新密码长度与确认不一致的具体原因,服务端失败也保留在表单中。 - 四项真实脚本回归通过;隔离预览使用实际模板片段与样式,在 375px 登录布局和桌面密码表单中验证提示、焦点和按钮。预览未使用真实账号凭据。 - Excel 第 4 行:HTTP 下请求体可被截获,属于部署传输配置风险。当前服务已有 TLS、HTTP 到 HTTPS 重定向和同端口协议分流;现有 TLS/重定向测试通过。应在部署中启用 HTTPS,未加入额外客户端加密协议或改动凭据配置。 + +## 追加:资产语言与导入兼容 + +- Excel 第 8、9 行:截图与模板对照证明灰色 CN/Beijing 是 placeholder,省市是手工输入,不是时区识别结果。示例增加中、英、俄翻译;不修改用户保存的地理字段,也不新增地区识别或联动能力。 +- Excel 第 10 行:CSV 与 XLSX 表头采用当前语言,环境、重要性、状态与已知风险等级显示本地化标签。原始地址、地区、项目、标签、未知枚举值保持原样。导入兼容旧英文列名与当前语言的导出列名、枚举值。 +- 验证:7 项测试通过,包含使用仓库内实际 SheetJS 生成和读取 CSV/XLSX,再调用真实导入解析;中、英、俄三种语言均验证地址、项目归属、带引号/换行标题、标签和枚举值不丢失。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 440eacff..261d71eb 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -10,9 +10,9 @@ | 5 | 4 | 登录页用户名字段未做必填校验,提交时 username 字段值为空字符串,应增加非空验证 | 已修复并有针对性验证 | | 6 | 5 | 修改密码,只有红框提示,没有具体错误原因 | 已修复;空值反馈、字段原因及浏览器布局验证通过 | | 7 | 6 | 通知,点击查看后跳转到页面,通知的计数还在,只有点了已读在不在。 | 已修复并有针对性验证 | -| 8 | 7 | 新增资产,自动识别出来的不是中文呢,好像是时区的问题。 | 待核对 | -| 9 | 8 | 接上一个,改成中国后,省份还是拼音 | 待核对 | -| 10 | 9 | 资产导出的 语言问题,也是英文 | 待核对 | +| 8 | 7 | 新增资产,自动识别出来的不是中文呢,好像是时区的问题。 | 截图中的 CN/Beijing 是占位示例;已改为随界面语言显示 | +| 9 | 8 | 接上一个,改成中国后,省份还是拼音 | 省市为手工输入字段,当前无自动地区联动;原描述误认示例或输入值 | +| 10 | 9 | 资产导出的 语言问题,也是英文 | 已修复导出表头及枚举语言;三种语言的真实 CSV/XLSX 往返测试通过 | | 11 | 10 | 项目管理,添加事实提示词优化,表单里是没有summary | 当前表单已有摘要(summary)且保存必填,原描述未复现 | | 12 | 11 | 样式问题 | 待核对 | | 13 | 12 | 样式问题 | 待核对 | diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 528a783f..df545fc0 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -1271,6 +1271,11 @@ "parseModalApplyRun": "Fill and query" }, "assets": { + "countryExample": "China", + "provinceExample": "Beijing", + "cityExample": "Beijing", + "exportIP": "IP address", + "exportVulnerabilityCount": "Vulnerability count", "overviewTitle": "Asset Overview", "libraryTitle": "Asset Library", "viewLibrary": "View asset library", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index e3139af5..9104482f 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -1283,6 +1283,11 @@ "parseModalApplyRun": "Подставить и выполнить" }, "assets": { + "countryExample": "Китай", + "provinceExample": "Пекин", + "cityExample": "Пекин", + "exportIP": "IP-адрес", + "exportVulnerabilityCount": "Количество уязвимостей", "overviewTitle": "Обзор активов", "libraryTitle": "Библиотека активов", "viewLibrary": "Открыть библиотеку активов", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index af096259..93e0dec9 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1259,6 +1259,11 @@ "parseModalApplyRun": "填入并查询" }, "assets": { + "countryExample": "中国", + "provinceExample": "北京", + "cityExample": "北京", + "exportIP": "IP 地址", + "exportVulnerabilityCount": "漏洞数量", "overviewTitle": "资产概览", "libraryTitle": "资产库", "viewLibrary": "查看资产库", diff --git a/web/static/js/asset-export-language.test.cjs b/web/static/js/asset-export-language.test.cjs new file mode 100644 index 00000000..90bd9366 --- /dev/null +++ b/web/static/js/asset-export-language.test.cjs @@ -0,0 +1,74 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, 'assets.js'), 'utf8'); +function context(locale) { + const translations = JSON.parse(fs.readFileSync(path.join(__dirname, '../i18n', locale + '.json'))).assets; + const c = vm.createContext({ window: { i18next: { t: key => translations[key.slice(7)] || key } } }); + vm.runInContext(source.slice(source.indexOf('function assetT('), source.indexOf('function downloadAssetTemplate(')), c); + vm.runInContext(source.slice(source.indexOf('function assetRiskPresentation('), source.indexOf('function assetOwnershipMarkup(')), c); + return c; +} +for (const locale of ['zh-CN', 'en-US', 'ru-RU']) { + test(locale + ' export headers and enum values can be imported again', () => { + const c = context(locale); + const columns = vm.runInContext('ASSET_IMPORT_COLUMNS.slice()', c); + for (const column of columns) { + const header = c.assetExportHeader(column); + assert.equal(c.assetImportColumnForHeader(header), column); + } + for (const [key, codes] of Object.entries({ environment: ['production', 'staging', 'testing', 'development', 'other'], criticality: ['critical', 'high', 'medium', 'low'], status: ['active', 'inactive'] })) { + for (const code of codes) { + const display = c.assetExportValue(key, code); + assert.equal(c.assetLocalizedEnumMap({ [code]: code }, key)[display.toLowerCase()], code); + } + } + assert.equal(c.assetExportValue('country', 'CN'), 'CN'); + assert.equal(c.assetExportValue('environment', 'custom-value'), 'custom-value'); + assert.equal(c.assetExportValue('vulnerability_count', 2), 2); + assert.equal(c.assetImportColumnForHeader('responsible_person'), 'responsible_person'); + }); +} +test('CSV and XLSX both use localized headers and values', () => { + const exportFunction = source.slice(source.indexOf('function exportSelectedAssets('), source.indexOf('async function deleteSelectedAssets(')); + assert.match(exportFunction, /headers\.map\(key => assetCsvCell\(assetExportHeader\(key\)\)\)/); + assert.match(exportFunction, /XLSX\.utils\.aoa_to_sheet/); + assert.match(exportFunction, /assetExportValue\(key, row\[key\]\)/); +}); +for (const locale of ['zh-CN', 'en-US', 'ru-RU']) { + test(locale + ' actual CSV and XLSX exports round trip through the import parser', async () => { + const c = context(locale); + Object.assign(c, { URL, Blob, document: { getElementById: () => null }, alert: message => { throw new Error(message); } }); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../vendor/xlsx.full.min.js'), 'utf8'), c); + c.window.XLSX = c.XLSX; + c.assetPageState = { projects: [{ id: 'p1', name: '项目一' }], selected: new Map() }; + const asset = { host: 'https://example.com:443', domain: 'example.com', port: 443, protocol: 'https', project_name: '项目一', title: 'title,"quoted"\nnext', status: 'inactive', environment: 'production', criticality: 'high', country: 'CN', province: 'Beijing', tags: ['one', 'two'] }; + c.assetPageState.selected.set('a1', asset); + vm.runInContext(source.slice(source.indexOf('function assetTargetLabel('), source.indexOf('function assetRiskPresentation(')), c); + vm.runInContext(source.slice(source.indexOf('function assetExportRows('), source.indexOf('function downloadAssetBlob(')), c); + vm.runInContext(source.slice(source.indexOf('function exportSelectedAssets('), source.indexOf('async function deleteSelectedAssets(')), c); + vm.runInContext(source.slice(source.indexOf('function assetEditorDefaultPort('), source.indexOf('async function assetEditorResponseError(')), c); + vm.runInContext(source.slice(source.indexOf('function parseAssetImportMatrix('), source.indexOf('function renderAssetImportPreview(')), c); + c.renderAssetImportPreview = () => {}; + let csv, workbook; + c.downloadAssetBlob = blob => { csv = blob; }; + c.XLSX.writeFile = book => { workbook = book; }; + c.exportSelectedAssets('csv'); + const csvBook = c.XLSX.read(await csv.text(), { type: 'string' }); + c.exportSelectedAssets('xlsx'); + const bytes = c.XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); + const xlsxBook = c.XLSX.read(bytes, { type: 'array' }); + for (const book of [csvBook, xlsxBook]) { + const matrix = c.XLSX.utils.sheet_to_json(book.Sheets[book.SheetNames[0]], { header: 1, defval: '', raw: false }); + assert.equal(matrix[0][0], c.assetExportHeader('target')); + c.parseAssetImportMatrix(matrix, 'export'); + const imported = c.assetPageState.importRows[0]; + assert.equal(imported.error, ''); + for (const key of ['host', 'domain', 'port', 'protocol', 'title', 'status', 'environment', 'criticality', 'country', 'province']) assert.equal(imported.asset[key], asset[key], key); + assert.equal(imported.asset.project_id, 'p1'); + assert.deepEqual(Array.from(imported.asset.tags), asset.tags); + } + }); +} diff --git a/web/static/js/assets.js b/web/static/js/assets.js index dc5fb0e8..b24faf19 100644 --- a/web/static/js/assets.js +++ b/web/static/js/assets.js @@ -100,13 +100,45 @@ const ASSET_IMPORT_HEADER_ALIASES = { status: ['status', '状态'] }; +const ASSET_EXPORT_LABELS = { + target: 'target', host: 'hostUrl', ip: 'exportIP', domain: 'domain', port: 'port', protocol: 'protocol', + title: 'pageTitle', server: 'server', project: 'project', responsible_person: 'responsiblePerson', + department: 'department', business_system: 'businessSystem', environment: 'environment', criticality: 'criticality', + country: 'country', province: 'province', city: 'city', source: 'source', status: 'status', tags: 'tagsLabel', + risk_level: 'riskLevel', vulnerability_count: 'exportVulnerabilityCount', first_seen_at: 'firstSeen', + last_seen_at: 'lastSeen', last_scan_at: 'lastScan' +}; + +function assetExportHeader(key) { + return assetT('assets.' + ASSET_EXPORT_LABELS[key], key); +} + +function assetLocalizedEnumMap(values, prefix) { + const result = { ...values }; + for (const code of new Set(Object.values(values))) { + if (!code) continue; + const key = prefix + code[0].toUpperCase() + code.slice(1); + result[assetT('assets.' + key, code).toLowerCase()] = code; + } + return result; +} + +function assetExportValue(key, value) { + if (['environment', 'criticality', 'status'].includes(key) && value) { + const codes = { environment: ['production', 'staging', 'testing', 'development', 'other'], criticality: ['critical', 'high', 'medium', 'low'], status: ['active', 'inactive'] }; + if (codes[key].includes(value)) return assetT('assets.' + key + value[0].toUpperCase() + value.slice(1), value); + } + if (key === 'risk_level' && ['critical', 'high', 'medium', 'low', 'info', 'normal', 'unassessed'].includes(value)) return assetRiskPresentation(value).label; + return value; +} + function normalizeAssetImportHeader(value) { return String(value == null ? '' : value).replace(/^\uFEFF/, '').trim().toLowerCase().replace(/[\s_\-/.()()]+/g, ''); } function assetImportColumnForHeader(value) { const normalized = normalizeAssetImportHeader(value); - return ASSET_IMPORT_COLUMNS.find(column => ASSET_IMPORT_HEADER_ALIASES[column].includes(normalized)) || ''; + return ASSET_IMPORT_COLUMNS.find(column => (ASSET_IMPORT_HEADER_ALIASES[column].includes(normalized) || normalizeAssetImportHeader(assetExportHeader(column)) === normalized)) || ''; } function assetImportTemplateHeaders() { @@ -301,13 +333,13 @@ function assetImportRecord(values, rowNumber) { if (rawDomain && !domain) return fail(assetT('assets.domainInvalid', '域名格式无效')); const protocol = (values.protocol || parsed.protocol || '').toLowerCase(); if (protocol && !/^[a-z][a-z0-9+.-]{0,31}$/.test(protocol)) return fail(assetT('assets.protocolInvalid', '协议格式无效')); - const statuses = { active: 'active', inactive: 'inactive', '活跃': 'active', '停用': 'inactive' }; + const statuses = assetLocalizedEnumMap({ active: 'active', inactive: 'inactive', '活跃': 'active', '停用': 'inactive' }, 'status'); const status = statuses[String(values.status || 'active').toLowerCase()]; if (!status) return fail(assetT('assets.importStatusInvalid', '状态仅支持 active 或 inactive')); - const environments = { '': '', production: 'production', staging: 'staging', testing: 'testing', development: 'development', other: 'other', '生产': 'production', '预发布': 'staging', '测试': 'testing', '开发': 'development', '其他': 'other' }; + const environments = assetLocalizedEnumMap({ '': '', production: 'production', staging: 'staging', testing: 'testing', development: 'development', other: 'other', '生产': 'production', '预发布': 'staging', '测试': 'testing', '开发': 'development', '其他': 'other' }, 'environment'); const environment = environments[String(values.environment || '').toLowerCase()]; if (environment == null) return fail('环境值无效'); - const criticalities = { '': '', critical: 'critical', high: 'high', medium: 'medium', low: 'low', '核心': 'critical', '重要': 'high', '一般': 'medium', '低': 'low' }; + const criticalities = assetLocalizedEnumMap({ '': '', critical: 'critical', high: 'high', medium: 'medium', low: 'low', '核心': 'critical', '重要': 'high', '一般': 'medium', '低': 'low' }, 'criticality'); const criticality = criticalities[String(values.criticality || '').toLowerCase()]; if (criticality == null) return fail('重要性值无效'); let projectId = ''; @@ -1017,13 +1049,13 @@ function exportSelectedAssets(format) { const stamp = new Date().toISOString().slice(0, 10); if (format === 'csv') { const headers = Object.keys(rows[0]); - const csv = '\uFEFF' + [headers.join(','), ...rows.map(row => headers.map(key => assetCsvCell(row[key])).join(','))].join('\r\n'); + const csv = '\uFEFF' + [headers.map(key => assetCsvCell(assetExportHeader(key))).join(','), ...rows.map(row => headers.map(key => assetCsvCell(assetExportValue(key, row[key]))).join(','))].join('\r\n'); downloadAssetBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `assets-${stamp}.csv`); return; } if (!window.XLSX) return alert('表格组件加载失败,请刷新后重试'); const workbook = XLSX.utils.book_new(); - const sheet = XLSX.utils.json_to_sheet(rows); + const sheet = XLSX.utils.aoa_to_sheet([Object.keys(rows[0]).map(assetExportHeader), ...rows.map(row => Object.keys(row).map(key => assetExportValue(key, row[key])))]); sheet['!autofilter'] = { ref: sheet['!ref'] }; sheet['!cols'] = Object.keys(rows[0]).map(key => ({ wch: ['target', 'host', 'title'].includes(key) ? 30 : 16 })); XLSX.utils.book_append_sheet(workbook, sheet, 'Assets'); diff --git a/web/templates/index.html b/web/templates/index.html index 3381de7a..b8acf944 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2143,9 +2143,9 @@

地理与状态

记录资产归属地区和当前启用状态

- - - + + +