security-no-xlsx-dependency.test.mjs
sha256:41d741fb345c4abdb640838aa3d847de02ccffd7a39fce04894e743e683b50d0
fix(security): pin patched transitive deps to clear Dependa…
Human
minor
⚠ breaking
8 hours ago
| 1 | /** |
| 2 | * Security regression guard: the vulnerable SheetJS `xlsx` package must never |
| 3 | * be (re)introduced into any npm lockfile, and the Excel import path must keep |
| 4 | * using the maintained `exceljs` parser. |
| 5 | * |
| 6 | * Background |
| 7 | * ---------- |
| 8 | * Dependabot alerts #29/#30 flagged `xlsx` (SheetJS community build) for |
| 9 | * Prototype Pollution and ReDoS with *no upstream patch available*. Knowtation |
| 10 | * resolved this structurally by migrating `lib/importers/excel-xlsx.mjs` to |
| 11 | * `exceljs` and removing the `xlsx` dependency entirely. These tests prove that |
| 12 | * remediation stays in force, so a future transitive bump cannot silently pull |
| 13 | * the unpatched parser back in and expose the Excel import surface (which |
| 14 | * accepts user-supplied .xlsx files) to those CVEs. |
| 15 | * |
| 16 | * This is the "Option C" guarantee from the security audit: instead of pinning |
| 17 | * an unpatched package, we assert the unsafe code path cannot exist. |
| 18 | */ |
| 19 | |
| 20 | import assert from 'assert'; |
| 21 | import { test } from 'node:test'; |
| 22 | import fs from 'fs'; |
| 23 | import path from 'path'; |
| 24 | import { fileURLToPath } from 'url'; |
| 25 | |
| 26 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 27 | const repoRoot = path.resolve(__dirname, '..'); |
| 28 | |
| 29 | const LOCKFILES = [ |
| 30 | 'package-lock.json', |
| 31 | 'hub/package-lock.json', |
| 32 | 'hub/bridge/package-lock.json', |
| 33 | 'hub/gateway/package-lock.json', |
| 34 | ]; |
| 35 | |
| 36 | /** |
| 37 | * Collect every package name referenced by an npm v3 lockfile, drawn from both |
| 38 | * the `packages` map (keyed by `node_modules/...` install path) and the legacy |
| 39 | * `dependencies` tree, so a match cannot slip through a single representation. |
| 40 | * |
| 41 | * @param {string} lockPath absolute path to a package-lock.json |
| 42 | * @returns {Set<string>} the set of dependency names present in the lockfile |
| 43 | */ |
| 44 | function collectLockfilePackageNames(lockPath) { |
| 45 | const raw = fs.readFileSync(lockPath, 'utf8'); |
| 46 | /** @type {{ packages?: Record<string, unknown>, dependencies?: Record<string, unknown> }} */ |
| 47 | const lock = JSON.parse(raw); |
| 48 | const names = new Set(); |
| 49 | for (const key of Object.keys(lock.packages ?? {})) { |
| 50 | if (key === '') continue; |
| 51 | const idx = key.lastIndexOf('node_modules/'); |
| 52 | const name = idx === -1 ? key : key.slice(idx + 'node_modules/'.length); |
| 53 | if (name) names.add(name); |
| 54 | } |
| 55 | for (const name of Object.keys(lock.dependencies ?? {})) { |
| 56 | names.add(name); |
| 57 | } |
| 58 | return names; |
| 59 | } |
| 60 | |
| 61 | test('no npm lockfile contains the vulnerable SheetJS `xlsx` package', () => { |
| 62 | for (const rel of LOCKFILES) { |
| 63 | const lockPath = path.join(repoRoot, rel); |
| 64 | assert.ok(fs.existsSync(lockPath), `expected lockfile to exist: ${rel}`); |
| 65 | const names = collectLockfilePackageNames(lockPath); |
| 66 | assert.ok( |
| 67 | !names.has('xlsx'), |
| 68 | `${rel} must not contain the unpatched 'xlsx' (SheetJS) package — use exceljs instead`, |
| 69 | ); |
| 70 | // Defend against a maliciously aliased reintroduction (npm:xlsx@...). |
| 71 | const rawText = fs.readFileSync(lockPath, 'utf8'); |
| 72 | assert.ok( |
| 73 | !/"resolved":\s*"[^"]*\/xlsx\/-\//.test(rawText), |
| 74 | `${rel} resolves a tarball for 'xlsx' — the vulnerable parser must not be installed`, |
| 75 | ); |
| 76 | } |
| 77 | }); |
| 78 | |
| 79 | test('Excel importer uses exceljs and never imports the `xlsx` package', () => { |
| 80 | const importerPath = path.join(repoRoot, 'lib/importers/excel-xlsx.mjs'); |
| 81 | assert.ok(fs.existsSync(importerPath), 'lib/importers/excel-xlsx.mjs must exist'); |
| 82 | const src = fs.readFileSync(importerPath, 'utf8'); |
| 83 | |
| 84 | assert.match( |
| 85 | src, |
| 86 | /import\s+ExcelJS\s+from\s+['"]exceljs['"]/, |
| 87 | 'excel importer must parse via exceljs', |
| 88 | ); |
| 89 | |
| 90 | // A bare `from 'xlsx'` / `require('xlsx')` would reintroduce the CVE surface. |
| 91 | assert.doesNotMatch( |
| 92 | src, |
| 93 | /from\s+['"]xlsx['"]|require\(\s*['"]xlsx['"]\s*\)/, |
| 94 | 'excel importer must not import the vulnerable xlsx package', |
| 95 | ); |
| 96 | }); |
| 97 | |
| 98 | test('root package.json declares no direct dependency on `xlsx`', () => { |
| 99 | const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); |
| 100 | const buckets = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; |
| 101 | for (const bucket of buckets) { |
| 102 | const deps = pkg[bucket] ?? {}; |
| 103 | assert.ok( |
| 104 | !Object.prototype.hasOwnProperty.call(deps, 'xlsx'), |
| 105 | `package.json#${bucket} must not declare 'xlsx'`, |
| 106 | ); |
| 107 | } |
| 108 | }); |
File History
1 commit
sha256:41d741fb345c4abdb640838aa3d847de02ccffd7a39fce04894e743e683b50d0
fix(security): pin patched transitive deps to clear Dependa…
Human
minor
⚠
8 hours ago