From 574e4e4c70e9206f07b01d58bab497174f270dd4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2026 12:51:04 +0800 Subject: [PATCH] chore: replace vulnerable xlsx parser dependency --- app/api/product-planning/templates/route.ts | 4 +- .../product-planning-page.tsx | 23 +++-- docs/dependency-risk-register.md | 8 +- lib/agents/workflow-brand-import.ts | 84 ++++++++++++++++--- lib/product-planning/file-parsers.ts | 84 ++++++++++++++++--- package.json | 1 - pnpm-lock.yaml | 65 -------------- 7 files changed, 164 insertions(+), 105 deletions(-) diff --git a/app/api/product-planning/templates/route.ts b/app/api/product-planning/templates/route.ts index 77822f63b..393032055 100644 --- a/app/api/product-planning/templates/route.ts +++ b/app/api/product-planning/templates/route.ts @@ -22,7 +22,7 @@ import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; const MAX_TEMPLATE_FILE_SIZE = 10 * 1024 * 1024; -const ALLOWED_TEMPLATE_EXTENSIONS = new Set(["txt", "csv", "xls", "xlsx"]); +const ALLOWED_TEMPLATE_EXTENSIONS = new Set(["txt", "csv", "xlsx"]); function isAdminToken(token: Awaited>) { const type = String(token?.type || "").trim(); @@ -131,7 +131,7 @@ export async function POST(request: NextRequest) { const extension = getTemplateExtension(file.name); if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) { - return jsonMutationError(400, "模板文件仅支持 txt、csv、xls、xlsx"); + return jsonMutationError(400, "模板文件仅支持 txt、csv、xlsx"); } if (file.size <= 0) { diff --git a/components/product-planning/product-planning-page.tsx b/components/product-planning/product-planning-page.tsx index e121fc291..99c9d6cfa 100644 --- a/components/product-planning/product-planning-page.tsx +++ b/components/product-planning/product-planning-page.tsx @@ -2205,7 +2205,7 @@ export function ProductPlanningPage({ 关键词拆词分析 - 支持 txt、xls、xlsx 关键词文件,也支持直接导入手动分词结果。 + 支持 txt、xlsx、csv 关键词文件,也支持直接导入手动分词结果。 @@ -2233,8 +2233,8 @@ export function ProductPlanningPage({

{manualSegmentationMode - ? "手动分词模式支持 xls / xlsx / csv,至少包含“词语”和“标签分类”列。" - : "普通模式支持 txt / xls / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"} + ? "手动分词模式支持 xlsx / csv,至少包含“词语”和“标签分类”列。" + : "普通模式支持 txt / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"}

@@ -2371,7 +2371,7 @@ export function ProductPlanningPage({ 上传模板文件 @@ -2384,8 +2384,7 @@ export function ProductPlanningPage({ {templateUploading ? "上传中..." : "上传模板"}

- 支持 txt / csv / xls / - xlsx,作为关键词源数据模板共享给其他登录用户。 + 支持 txt / csv / xlsx,作为关键词源数据模板共享给其他登录用户。

@@ -2586,11 +2585,11 @@ export function ProductPlanningPage({ { const file = event.target.files?.[0]; @@ -2608,11 +2607,11 @@ export function ProductPlanningPage({ { const file = event.target.files?.[0]; diff --git a/docs/dependency-risk-register.md b/docs/dependency-risk-register.md index 2bd8dd908..8c8e7297b 100644 --- a/docs/dependency-risk-register.md +++ b/docs/dependency-risk-register.md @@ -22,15 +22,15 @@ Use `pnpm --silent audit:security:json` when another script needs machine-readab `pnpm --silent audit:security:json` currently reports: - Critical: 0 advisories. -- High: 2 advisories. +- High: 0 advisories. - Moderate: 44 advisories. - Low: 1 advisory. -The previous local baseline was Critical: 2 / High: 31. The first remediation batch upgraded direct dependencies and patched transitive packages via `pnpm.overrides`. +The previous local baseline was Critical: 2 / High: 31. The remediation batches upgraded direct dependencies, patched transitive packages via `pnpm.overrides`, and removed the vulnerable `xlsx` parser dependency. ## Priority Remediation Buckets -- `xlsx`: two high advisories remain, and npm audit reports no patched version. Plan a replacement or isolation strategy rather than pretending a patch exists. +- `xlsx`: remediated by replacing spreadsheet import parsing with the existing `exceljs` dependency and dropping legacy `.xls` upload support from affected import paths. - `next`: remediated by resolving Next.js to `16.2.6`; keep route smoke tests in the verification path. - `fast-xml-parser`: remediated through a transitive override resolving to `5.8.0`. - `protobufjs`: remediated through a transitive override resolving to `7.5.6`. @@ -40,7 +40,7 @@ The previous local baseline was Critical: 2 / High: 31. The first remediation ba ## Release Policy -- Do not add `pnpm audit:security` to `pnpm check:quality` until the remaining `xlsx` advisories are removed or explicitly accepted with compensating controls. +- Keep `pnpm audit:security` as a pre-release and PR review command rather than embedding it in `pnpm check:quality`; the registry-backed audit endpoint can fail for network or mirror reasons. - New direct dependencies should be checked with `pnpm audit:security` before merging. - Critical advisories in runtime dependencies should block production release unless they are proven unreachable and documented here. - High advisories should have either a remediation PR or an explicit risk acceptance note before commercial deployment. diff --git a/lib/agents/workflow-brand-import.ts b/lib/agents/workflow-brand-import.ts index b03c4a3d8..e330b3ab7 100644 --- a/lib/agents/workflow-brand-import.ts +++ b/lib/agents/workflow-brand-import.ts @@ -12,7 +12,7 @@ import { type SpreadsheetRecord = Record; -export const AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT = ".xlsx,.xls,.csv"; +export const AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT = ".xlsx,.csv"; const BRAND_SHEET_NAME = "品牌树模板"; const INSTRUCTION_SHEET_NAME = "填写说明"; @@ -107,6 +107,40 @@ function normalizeHeader(value: unknown) { .toLowerCase(); } +function normalizeSpreadsheetCellValue(value: unknown) { + if (value == null) { + return ""; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value !== "object") { + return value; + } + + const cellObject = value as { + hyperlink?: unknown; + result?: unknown; + richText?: Array<{ text?: unknown }>; + text?: unknown; + }; + + if (Array.isArray(cellObject.richText)) { + return cellObject.richText.map((part) => String(part.text ?? "")).join(""); + } + if (cellObject.result != null) { + return cellObject.result; + } + if (cellObject.text != null) { + return cellObject.text; + } + if (cellObject.hyperlink != null) { + return cellObject.hyperlink; + } + + return String(value); +} + function triggerBlobDownload(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -140,18 +174,48 @@ async function readSpreadsheetRecords(file: File) { return parsed.data; } - const { read, utils } = await import("xlsx"); + const { Workbook } = await import("exceljs"); + const workbook = new Workbook(); const buffer = await file.arrayBuffer(); - const workbook = read(buffer, { type: "array" }); - const sheetName = workbook.SheetNames[0]; - if (!sheetName) { + await workbook.xlsx.load(buffer); + + const worksheet = workbook.worksheets[0]; + if (!worksheet) { throw new Error("表格中没有可读取的工作表"); } - const worksheet = workbook.Sheets[sheetName]; - return utils.sheet_to_json(worksheet, { - defval: "", + const rawHeaderValues = worksheet.getRow(1).values; + const headerValues: unknown[] = Array.isArray(rawHeaderValues) ? rawHeaderValues.slice(1) : []; + const headers = headerValues.map((value) => + normalizeCell(normalizeSpreadsheetCellValue(value), 128) + ); + const records: SpreadsheetRecord[] = []; + + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (rowNumber === 1) { + return; + } + + const record: SpreadsheetRecord = {}; + let hasValue = false; + + headers.forEach((header, index) => { + if (!header) { + return; + } + const value = normalizeSpreadsheetCellValue(row.getCell(index + 1).value); + record[header] = value; + if (normalizeCell(value, 2048)) { + hasValue = true; + } + }); + + if (hasValue) { + records.push(record); + } }); + + return records; } function createBrandEntry(brandName: string, sortOrder: number): AgentWorkflowBrandStoreEntry { @@ -280,8 +344,8 @@ export async function parseAgentWorkflowBrandImportFile( const headerAliases = getHeaderAliases(labels); const resolvedLabels = normalizeAgentWorkflowCategoryLabels(labels); const extension = file.name.toLowerCase().split(".").pop() || ""; - if (!["xls", "xlsx", "csv"].includes(extension)) { - throw new Error("仅支持 xls、xlsx、csv 文件"); + if (!["xlsx", "csv"].includes(extension)) { + throw new Error("仅支持 xlsx、csv 文件"); } const records = await readSpreadsheetRecords(file); diff --git a/lib/product-planning/file-parsers.ts b/lib/product-planning/file-parsers.ts index 67b2233ae..a1399423b 100644 --- a/lib/product-planning/file-parsers.ts +++ b/lib/product-planning/file-parsers.ts @@ -1,4 +1,3 @@ -import * as XLSX from "xlsx"; import Papa from "papaparse"; import type { KeywordAnalysisRow, @@ -18,6 +17,40 @@ import { aggregateManualKeywordRows } from "./keyword-segmentation"; type SpreadsheetRecord = Record; +function normalizeSpreadsheetCellValue(value: unknown) { + if (value == null) { + return ""; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (typeof value !== "object") { + return value; + } + + const cellObject = value as { + hyperlink?: unknown; + result?: unknown; + richText?: Array<{ text?: unknown }>; + text?: unknown; + }; + + if (Array.isArray(cellObject.richText)) { + return cellObject.richText.map((part) => String(part.text ?? "")).join(""); + } + if (cellObject.result != null) { + return cellObject.result; + } + if (cellObject.text != null) { + return cellObject.text; + } + if (cellObject.hyperlink != null) { + return cellObject.hyperlink; + } + + return String(value); +} + const KEYWORD_ALIASES = { keyword: ["关键词", "搜索词", "搜索关键词", "keyword", "keywords", "query"], searchPopularity: ["搜索人气", "搜索量", "人气", "searchvolume", "popularity", "volume"], @@ -75,17 +108,46 @@ async function readSpreadsheetRecords(file: File) { return parsed.data; } + const { Workbook } = await import("exceljs"); + const workbook = new Workbook(); const buffer = await file.arrayBuffer(); - const workbook = XLSX.read(buffer, { type: "array" }); - const sheetName = workbook.SheetNames[0]; - if (!sheetName) { + await workbook.xlsx.load(buffer); + + const worksheet = workbook.worksheets[0]; + if (!worksheet) { throw new Error("表格中没有可读取的工作表"); } - const worksheet = workbook.Sheets[sheetName]; - return XLSX.utils.sheet_to_json(worksheet, { - defval: "", + const rawHeaderValues = worksheet.getRow(1).values; + const headerValues: unknown[] = Array.isArray(rawHeaderValues) ? rawHeaderValues.slice(1) : []; + const headers = headerValues.map((value) => normalizeText(normalizeSpreadsheetCellValue(value))); + const records: SpreadsheetRecord[] = []; + + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (rowNumber === 1) { + return; + } + + const record: SpreadsheetRecord = {}; + let hasValue = false; + + headers.forEach((header, index) => { + if (!header) { + return; + } + const value = normalizeSpreadsheetCellValue(row.getCell(index + 1).value); + record[header] = value; + if (normalizeText(value)) { + hasValue = true; + } + }); + + if (hasValue) { + records.push(record); + } }); + + return records; } export async function parseKeywordInputFile(file: File) { @@ -105,8 +167,8 @@ export async function parseKeywordInputFile(file: File) { return rows; } - if (!["xls", "xlsx", "csv"].includes(extension)) { - throw new Error("仅支持 txt、xls、xlsx、csv 文件"); + if (!["xlsx", "csv"].includes(extension)) { + throw new Error("仅支持 txt、xlsx、csv 文件"); } const records = await readSpreadsheetRecords(file); @@ -158,8 +220,8 @@ export async function parseKeywordInputFile(file: File) { export async function parseManualSegmentationFile(file: File) { const extension = file.name.toLowerCase().split(".").pop() || ""; - if (!["xls", "xlsx", "csv"].includes(extension)) { - throw new Error("手动分词文件仅支持 xls、xlsx、csv"); + if (!["xlsx", "csv"].includes(extension)) { + throw new Error("手动分词文件仅支持 xlsx、csv"); } const records = await readSpreadsheetRecords(file); diff --git a/package.json b/package.json index f7f49b813..57d5d99fa 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,6 @@ "use-stick-to-bottom": "^1.1.1", "usehooks-ts": "^3.1.0", "ws": "^8.18.3", - "xlsx": "^0.18.5", "zod": "^3.25.76", "zustand": "5.0.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63a912f7c..be44ba409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,9 +367,6 @@ importers: ws: specifier: ^8.18.3 version: 8.20.0(bufferutil@4.0.9) - xlsx: - specifier: ^0.18.5 - version: 0.18.5 zod: specifier: ^3.25.76 version: 3.25.76 @@ -3664,10 +3661,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} - engines: {node: '>=0.8'} - agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3839,10 +3832,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} - engines: {node: '>=0.8'} - chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -3913,10 +3902,6 @@ packages: codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} - engines: {node: '>=0.8'} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -4537,10 +4522,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} - engines: {node: '>=0.8'} - framer-motion@11.18.2: resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} peerDependencies: @@ -6144,10 +6125,6 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} - engines: {node: '>=0.8'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6615,14 +6592,6 @@ packages: engines: {node: '>=8'} hasBin: true - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} - engines: {node: '>=0.8'} - - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} - engines: {node: '>=0.8'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6642,11 +6611,6 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} - engines: {node: '>=0.8'} - hasBin: true - xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -10191,8 +10155,6 @@ snapshots: acorn@8.15.0: {} - adler-32@1.3.1: {} - agent-base@7.1.4: {} ahooks@3.9.7(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): @@ -10379,11 +10341,6 @@ snapshots: ccount@2.0.1: {} - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -10456,8 +10413,6 @@ snapshots: '@codemirror/state': 6.5.2 '@codemirror/view': 6.36.4 - codepage@1.15.0: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -11056,8 +11011,6 @@ snapshots: format@0.2.2: {} - frac@1.1.2: {} - framer-motion@11.18.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): dependencies: motion-dom: 11.18.1 @@ -13242,10 +13195,6 @@ snapshots: sql-escaper@1.3.3: {} - ssf@0.11.2: - dependencies: - frac: 1.1.2 - stackback@0.0.2: {} stats-gl@2.4.2(@types/three@0.182.0)(three@0.182.0): @@ -13751,10 +13700,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wmf@1.0.2: {} - - word@0.3.0: {} - wrappy@1.0.2: {} ws@8.20.0(bufferutil@4.0.9): @@ -13765,16 +13710,6 @@ snapshots: dependencies: is-wsl: 3.1.1 - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 - xml-naming@0.1.0: {} xmlchars@2.2.0: {}