Migrate from xlsx to exceljs to fix security vulnerabilities

- Replace xlsx package (v0.18.5) with exceljs (v4.4.0)
- Remove @types/xlsx dependency (exceljs has built-in TypeScript types)
- Update biaMatchingService.ts to use ExcelJS API:
  - Replace XLSX.read() with workbook.xlsx.load()
  - Replace XLSX.utils.sheet_to_json() with eachRow() iteration
  - Handle 1-based column indexing correctly
- Make loadBIAData() and findBIAMatch() async functions
- Update all callers in applications.ts and claude.ts to use await
- Fix npm audit: 0 vulnerabilities (was 1 high severity)

This migration eliminates the Prototype Pollution and ReDoS vulnerabilities
in the xlsx package while maintaining full functionality.
This commit is contained in:
2026-01-15 09:59:43 +01:00
parent c60fbe8821
commit e276e77fbc
5 changed files with 852 additions and 145 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "zira-backend", "name": "cmdb-insight-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "ZiRA Classificatie Tool Backend", "description": "CMDB Insight Backend",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
@@ -29,7 +29,7 @@
"openai": "^6.15.0", "openai": "^6.15.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"winston": "^3.17.0", "winston": "^3.17.0",
"xlsx": "^0.18.5" "exceljs": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
@@ -38,7 +38,6 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/xlsx": "^0.0.35",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }

View File

@@ -103,7 +103,7 @@ router.get('/bia-test', async (req: Request, res: Response) => {
if (getQueryString(req, 'clear') === 'true') { if (getQueryString(req, 'clear') === 'true') {
clearBIACache(); clearBIACache();
} }
const biaData = loadBIAData(); const biaData = await loadBIAData();
res.json({ res.json({
recordCount: biaData.length, recordCount: biaData.length,
records: biaData.slice(0, 20), // First 20 records records: biaData.slice(0, 20), // First 20 records
@@ -119,7 +119,7 @@ router.get('/bia-test', async (req: Request, res: Response) => {
router.get('/bia-debug', async (req: Request, res: Response) => { router.get('/bia-debug', async (req: Request, res: Response) => {
try { try {
clearBIACache(); clearBIACache();
const biaData = loadBIAData(); const biaData = await loadBIAData();
// Get a few sample applications // Get a few sample applications
const searchResult = await dataService.searchApplications({}, 1, 50); const searchResult = await dataService.searchApplications({}, 1, 50);
@@ -138,7 +138,7 @@ router.get('/bia-debug', async (req: Request, res: Response) => {
// Test each sample app // Test each sample app
for (const app of [...sampleApps, ...testApps]) { for (const app of [...sampleApps, ...testApps]) {
const matchResult = findBIAMatch(app.name, app.searchReference ?? null); const matchResult = await findBIAMatch(app.name, app.searchReference ?? null);
// Find all potential matches in Excel data for detailed analysis // Find all potential matches in Excel data for detailed analysis
const normalizedAppName = app.name.toLowerCase().trim(); const normalizedAppName = app.name.toLowerCase().trim();
@@ -207,7 +207,7 @@ router.get('/bia-comparison', async (req: Request, res: Response) => {
clearBIACache(); clearBIACache();
// Load fresh data // Load fresh data
const testBIAData = loadBIAData(); const testBIAData = await loadBIAData();
logger.info(`BIA comparison: Loaded ${testBIAData.length} records from Excel file`); logger.info(`BIA comparison: Loaded ${testBIAData.length} records from Excel file`);
if (testBIAData.length === 0) { if (testBIAData.length === 0) {
logger.error('BIA comparison: No Excel data loaded - check if BIA.xlsx exists and is readable'); logger.error('BIA comparison: No Excel data loaded - check if BIA.xlsx exists and is readable');
@@ -251,7 +251,7 @@ router.get('/bia-comparison', async (req: Request, res: Response) => {
for (const app of applications) { for (const app of applications) {
// Find BIA match in Excel // Find BIA match in Excel
const matchResult = findBIAMatch(app.name, app.searchReference ?? null); const matchResult = await findBIAMatch(app.name, app.searchReference ?? null);
// Log first few matches for debugging // Log first few matches for debugging
if (comparisonItems.length < 5) { if (comparisonItems.length < 5) {

View File

@@ -10,7 +10,7 @@ import { readFileSync, existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { dirname } from 'path'; import { dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import * as XLSX from 'xlsx'; import ExcelJS from 'exceljs';
import { logger } from './logger.js'; import { logger } from './logger.js';
// Get __dirname equivalent for ES modules // Get __dirname equivalent for ES modules
@@ -52,13 +52,13 @@ export function clearBIACache(): void {
/** /**
* Load BIA data from Excel file * Load BIA data from Excel file
*/ */
export function loadBIAData(): BIARecord[] { export async function loadBIAData(): Promise<BIARecord[]> {
const now = Date.now(); const now = Date.now();
// Return cached data if still valid AND has records // Return cached data if still valid AND has records
// Don't use cache if it's empty (indicates previous load failure) // Don't use cache if it's empty (indicates previous load failure)
if (biaDataCache && biaDataCache.length > 0 && (now - biaDataCacheTimestamp) < BIA_CACHE_TTL) { if (biaDataCache && biaDataCache.length > 0 && (now - biaDataCacheTimestamp) < BIA_CACHE_TTL) {
logger.debug(`Using cached BIA data (${biaDataCache.length} records, cached ${Math.round((now - biaDataCacheTimestamp) / 1000)}s ago)`); logger.debug(`Using cached BIA data (${biaDataCache.length} records, cached ${Math.round((now - biaDataCacheTimestamp) / 1000)}s ago)`);
return biaDataCache; return Promise.resolve(biaDataCache);
} }
// Clear cache if it's empty or expired // Clear cache if it's empty or expired
@@ -96,19 +96,45 @@ export function loadBIAData(): BIARecord[] {
logger.error(`__dirname: ${__dirname}`); logger.error(`__dirname: ${__dirname}`);
biaDataCache = []; biaDataCache = [];
biaDataCacheTimestamp = now; biaDataCacheTimestamp = now;
return []; return Promise.resolve([]);
} }
logger.info(`Loading BIA data from: ${biaFilePath}`); logger.info(`Loading BIA data from: ${biaFilePath}`);
try { try {
// Read file using readFileSync and then parse with XLSX.read // Read file using readFileSync and then parse with ExcelJS
// This works better in ES modules than XLSX.readFile
const fileBuffer = readFileSync(biaFilePath); const fileBuffer = readFileSync(biaFilePath);
const workbook = XLSX.read(fileBuffer, { type: 'buffer' }); const workbook = new ExcelJS.Workbook();
const sheetName = workbook.SheetNames[0]; // ExcelJS accepts Buffer, but TypeScript types may be strict - use type assertion
const worksheet = workbook.Sheets[sheetName]; await workbook.xlsx.load(fileBuffer as Buffer);
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; const worksheet = workbook.worksheets[0]; // First sheet
// Converteer naar 2D array formaat (zoals xlsx.utils.sheet_to_json met header: 1)
// We need at least column K (index 10), so ensure we read up to column 11 (1-based)
const data: any[][] = [];
const maxColumnNeeded = 11; // Column K is index 10 (0-based), so we need column 11 (1-based)
worksheet.eachRow((row, rowNumber) => {
const rowData: any[] = [];
// Ensure we have at least maxColumnNeeded columns, but also check actual cells
const actualMaxCol = Math.max(maxColumnNeeded, row.actualCellCount || 0);
for (let colNumber = 1; colNumber <= actualMaxCol; colNumber++) {
const cell = row.getCell(colNumber);
// ExcelJS uses 1-based indexing, convert to 0-based for array
// Handle different cell value types: convert to string for consistency
let cellValue: any = cell.value;
if (cellValue === null || cellValue === undefined) {
cellValue = '';
} else if (cellValue instanceof Date) {
cellValue = cellValue.toISOString();
} else if (typeof cellValue === 'object' && 'richText' in cellValue) {
// Handle RichText objects
cellValue = cell.value?.toString() || '';
}
rowData[colNumber - 1] = cellValue;
}
data.push(rowData);
});
logger.info(`Loaded Excel file: ${data.length} rows, first row has ${data[0]?.length || 0} columns`); logger.info(`Loaded Excel file: ${data.length} rows, first row has ${data[0]?.length || 0} columns`);
if (data.length > 0 && data[0]) { if (data.length > 0 && data[0]) {
@@ -236,12 +262,12 @@ export function loadBIAData(): BIARecord[] {
} }
biaDataCache = records; biaDataCache = records;
biaDataCacheTimestamp = now; biaDataCacheTimestamp = now;
return records; return Promise.resolve(records);
} catch (error) { } catch (error) {
logger.error('Failed to load BIA data from Excel', error); logger.error('Failed to load BIA data from Excel', error);
biaDataCache = []; biaDataCache = [];
biaDataCacheTimestamp = now; biaDataCacheTimestamp = now;
return []; return Promise.resolve([]);
} }
} }
@@ -330,11 +356,11 @@ function wordBasedSimilarity(str1: string, str2: string): number {
* - Confidence/similarity score * - Confidence/similarity score
* - Length similarity (prefer matches with similar length) * - Length similarity (prefer matches with similar length)
*/ */
export function findBIAMatch( export async function findBIAMatch(
applicationName: string, applicationName: string,
searchReference: string | null searchReference: string | null
): BIAMatchResult { ): Promise<BIAMatchResult> {
const biaData = loadBIAData(); const biaData = await loadBIAData();
if (biaData.length === 0) { if (biaData.length === 0) {
logger.warn(`No BIA data available for lookup of "${applicationName}" (biaData.length = 0)`); logger.warn(`No BIA data available for lookup of "${applicationName}" (biaData.length = 0)`);
return { return {

View File

@@ -52,7 +52,7 @@ try {
async function findBIAValue(applicationName: string, searchReference?: string | null): Promise<string | null> { async function findBIAValue(applicationName: string, searchReference?: string | null): Promise<string | null> {
// Use the unified matching service (imported at top of file) // Use the unified matching service (imported at top of file)
const { findBIAMatch } = await import('./biaMatchingService.js'); const { findBIAMatch } = await import('./biaMatchingService.js');
const matchResult = findBIAMatch(applicationName, searchReference || null); const matchResult = await findBIAMatch(applicationName, searchReference || null);
return matchResult.biaValue || null; return matchResult.biaValue || null;
} }

922
package-lock.json generated

File diff suppressed because it is too large Load Diff