chore: replace vulnerable xlsx parser dependency

This commit is contained in:
Your Name 2026-05-27 12:51:04 +08:00
parent 7859fa08d4
commit 574e4e4c70
7 changed files with 164 additions and 105 deletions

View File

@ -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<ReturnType<typeof getProductPlanningToken>>) {
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) {

View File

@ -2205,7 +2205,7 @@ export function ProductPlanningPage({
<CardHeader>
<CardTitle>关键词拆词分析</CardTitle>
<CardDescription>
支持 txt、xls、xlsx 关键词文件,也支持直接导入手动分词结果。
支持 txt、xlsx、csv 关键词文件,也支持直接导入手动分词结果。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
@ -2233,8 +2233,8 @@ export function ProductPlanningPage({
<Input
accept={
manualSegmentationMode
? ".xlsx,.xls,.csv"
: ".txt,.xlsx,.xls,.csv"
? ".xlsx,.csv"
: ".txt,.xlsx,.csv"
}
ref={
manualSegmentationMode ? manualFileRef : keywordFileRef
@ -2243,8 +2243,8 @@ export function ProductPlanningPage({
/>
<p className="text-muted-foreground text-xs">
{manualSegmentationMode
? "手动分词模式支持 xls / xlsx / csv,至少包含“词语”和“标签分类”列。"
: "普通模式支持 txt / xls / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"}
? "手动分词模式支持 xlsx / csv,至少包含“词语”和“标签分类”列。"
: "普通模式支持 txt / xlsx / csv,并自动识别关键词、搜索人气、支付转化率等列。"}
</p>
</div>
@ -2371,7 +2371,7 @@ export function ProductPlanningPage({
上传模板文件
</label>
<Input
accept=".txt,.csv,.xls,.xlsx"
accept=".txt,.csv,.xlsx"
ref={templateUploadRef}
type="file"
/>
@ -2384,8 +2384,7 @@ export function ProductPlanningPage({
{templateUploading ? "上传中..." : "上传模板"}
</Button>
<p className="text-muted-foreground text-xs">
支持 txt / csv / xls /
xlsx,作为关键词源数据模板共享给其他登录用户。
支持 txt / csv / xlsx,作为关键词源数据模板共享给其他登录用户。
</p>
</div>
</div>
@ -2586,11 +2585,11 @@ export function ProductPlanningPage({
<label className="flex items-center justify-between font-medium text-xs">
<span>导入评价表</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-normal text-[10px] text-muted-foreground">
csv/xls
csv/xlsx
</span>
</label>
<Input
accept=".csv,.xls,.xlsx"
accept=".csv,.xlsx"
className="h-9 cursor-pointer text-sm file:text-sm"
onChange={(event) => {
const file = event.target.files?.[0];
@ -2608,11 +2607,11 @@ export function ProductPlanningPage({
<label className="flex items-center justify-between font-medium text-xs">
<span>导入问大家表</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-normal text-[10px] text-muted-foreground">
csv/xls
csv/xlsx
</span>
</label>
<Input
accept=".csv,.xls,.xlsx"
accept=".csv,.xlsx"
className="h-9 cursor-pointer text-sm file:text-sm"
onChange={(event) => {
const file = event.target.files?.[0];

View File

@ -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.

View File

@ -12,7 +12,7 @@ import {
type SpreadsheetRecord = Record<string, unknown>;
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<SpreadsheetRecord>(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);

View File

@ -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<string, unknown>;
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<SpreadsheetRecord>(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);

View File

@ -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"
},

65
pnpm-lock.yaml generated
View File

@ -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: {}