malware scan and sanitization implemetation done

This commit is contained in:
laxmanhalaki 2026-02-24 19:34:09 +05:30
parent 9fd9c218df
commit dbb088dbcc
31 changed files with 2544 additions and 112 deletions

View File

@ -1 +1 @@
import{a as s}from"./index-CYOEuscl.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-BFJfF1vG.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
import{a as s}from"./index-BUjalNx7.js";import"./radix-vendor-CYvDqP9X.js";import"./charts-vendor-BVfwAPj-.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-CX5oLBI_.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B_rK4TXr.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -13,15 +13,15 @@
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<script type="module" crossorigin src="/assets/index-CYOEuscl.js"></script>
<script type="module" crossorigin src="/assets/index-BUjalNx7.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-BVfwAPj-.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BFJfF1vG.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CX5oLBI_.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.js">
<link rel="stylesheet" crossorigin href="/assets/index-AUbBsmWB.css">
<link rel="stylesheet" crossorigin href="/assets/index-Bq5QM20V.css">
</head>
<body>

View File

@ -50,6 +50,25 @@ services:
timeout: 5s
retries: 5
clamav:
image: clamav/clamav:latest
container_name: re_clamav
ports:
- "3310:3310"
volumes:
- clamav_data:/var/lib/clamav
environment:
- CLAMAV_NO_FRESHCLAMD=false
healthcheck:
test: ["CMD", "clamdcheck"]
interval: 60s
timeout: 10s
retries: 5
start_period: 120s
restart: unless-stopped
networks:
- re_workflow_network
backend:
build:
context: .
@ -63,6 +82,8 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
DB_NAME: ${DB_NAME:-re_workflow_db}
REDIS_URL: redis://redis:6379
CLAMD_HOST: clamav
CLAMD_PORT: 3310
PORT: 5000
# Loki for logging
LOKI_HOST: http://loki:3100
@ -215,6 +236,8 @@ volumes:
name: re_postgres_data
redis_data:
name: re_redis_data
clamav_data:
name: re_clamav_data
prometheus_data:
name: re_prometheus_data
loki_data:

View File

@ -39,6 +39,25 @@ services:
timeout: 5s
retries: 5
clamav:
image: clamav/clamav:latest
container_name: re_clamav
ports:
- "3310:3310"
volumes:
- clamav_data:/var/lib/clamav
environment:
- CLAMAV_NO_FRESHCLAMD=false
healthcheck:
test: ["CMD", "clamdcheck"]
interval: 60s
timeout: 10s
retries: 5
start_period: 120s
restart: unless-stopped
networks:
- re_workflow_network
backend:
build:
context: .
@ -52,6 +71,8 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
DB_NAME: ${DB_NAME:-re_workflow_db}
REDIS_URL: redis://redis:6379
CLAMD_HOST: clamav
CLAMD_PORT: 3310
PORT: 5000
ports:
- "5000:5000"
@ -76,6 +97,7 @@ services:
volumes:
postgres_data:
redis_data:
clamav_data:
networks:
re_workflow_network:

194
package-lock.json generated
View File

@ -16,6 +16,7 @@
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.63.0",
"clamscan": "^2.4.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.19",
@ -37,6 +38,7 @@
"pg-hstore": "^2.3.4",
"prom-client": "^15.1.3",
"puppeteer": "^24.37.2",
"sanitize-html": "^2.17.1",
"sequelize": "^6.37.5",
"socket.io": "^4.8.1",
"uuid": "^8.3.2",
@ -59,6 +61,7 @@
"@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^8.19.1",
@ -3940,6 +3943,16 @@
"node": ">= 0.12"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
"integrity": "sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@ -5316,6 +5329,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/clamscan": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz",
"integrity": "sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -5761,7 +5783,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5888,6 +5909,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -6124,6 +6200,18 @@
}
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -6206,7 +6294,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -7683,6 +7770,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
@ -7995,6 +8101,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -9426,6 +9541,24 @@
"url": "https://github.com/sponsors/raouldeheer"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -9915,6 +10048,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -10257,6 +10396,34 @@
"node": ">=12"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -10898,6 +11065,20 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
"integrity": "sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -11451,6 +11632,15 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",

View File

@ -30,6 +30,7 @@
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.63.0",
"clamscan": "^2.4.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.19",
@ -51,6 +52,7 @@
"pg-hstore": "^2.3.4",
"prom-client": "^15.1.3",
"puppeteer": "^24.37.2",
"sanitize-html": "^2.17.1",
"sequelize": "^6.37.5",
"socket.io": "^4.8.1",
"uuid": "^8.3.2",
@ -73,6 +75,7 @@
"@types/passport": "^1.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^8.19.1",
@ -94,4 +97,4 @@
"node": ">=22.0.0",
"npm": ">=10.0.0"
}
}
}

View File

@ -13,6 +13,8 @@ import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.mi
import routes from './routes/index';
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
import { rateLimiter } from './middlewares/rateLimiter.middleware';
import path from 'path';
// Load environment variables from .env file first
@ -114,6 +116,12 @@ if (process.env.TRUST_PROXY === 'true' || process.env.NODE_ENV === 'production')
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Global rate limiting (before routes)
app.use(rateLimiter);
// HTML sanitization - strip all tags from text inputs (after body parsing, before routes)
app.use(sanitizationMiddleware);
// Logging middleware
app.use(morgan('combined'));
@ -140,6 +148,15 @@ app.use('/api/v1', routes);
ensureUploadDir();
app.use('/uploads', authenticateToken, express.static(UPLOAD_DIR));
// Initialize ClamAV toggle manager
import { initializeToggleFile } from './services/clamav/clamavToggleManager';
try {
initializeToggleFile();
console.log(`✅ ClamAV toggle initialized (ENABLE_CLAMAV=${process.env.ENABLE_CLAMAV || 'true'})`);
} catch (err) {
console.warn('⚠️ ClamAV toggle initialization warning:', err);
}
// Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility)
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
try {

View File

@ -0,0 +1,423 @@
/**
* Malware Scan Middleware
* Express middleware that intercepts file uploads, triggers ClamAV scan,
* and blocks infected files. Uses temp file approach to work with memory storage.
*
* Flow:
* multer (memory storage) malwareScanMiddleware controller
*
* Write buffer to temp file ClamAV scan Delete temp file
*
* Clean attach result to req, call next()
* Infected return 403
* Scan error return 503 (fail-secure)
* Skipped (disabled) log, call next()
*/
import { Request, Response, NextFunction } from 'express';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { scanFile, ClamScanResult } from '../services/clamav/clamavScanWrapper';
import { scanContentForXSS, ContentScanResult } from '../services/fileUpload/contentXSSScanner';
import { logSecurityEvent, SecurityEventType } from '../services/logging/securityEventLogger';
import { validateFile } from '../services/fileUpload/fileValidationService';
// ── Extend Express Request ──
declare global {
namespace Express {
interface Request {
malwareScanResult?: ClamScanResult;
contentScanResult?: ContentScanResult;
scanEventId?: string;
}
}
}
// ── Temp file helpers ──
function writeTempFile(buffer: Buffer, originalName: string): string {
const tempDir = path.join(os.tmpdir(), 'clamav-scan');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const ext = path.extname(originalName);
const tempPath = path.join(tempDir, `${uuidv4()}${ext}`);
fs.writeFileSync(tempPath, buffer);
return tempPath;
}
function deleteTempFile(tempPath: string): void {
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch (error) {
console.error('[MalwareScan] Failed to delete temp file:', tempPath, error);
}
}
// ── Middleware ──
/**
* Malware scan middleware for single file uploads (multer.single)
* Works with memory storage writes buffer to temp scans deletes temp
*/
export function malwareScanMiddleware(req: Request, res: Response, next: NextFunction): void {
// Skip if no file uploaded
const file = req.file;
if (!file) {
console.log('[MalwareScan] No file attached — skipping scan');
return next();
}
console.log(`[MalwareScan] 🔒 Scanning single file: ${file.originalname} (${file.size} bytes, ${file.mimetype})`);
const scanEventId = uuidv4();
req.scanEventId = scanEventId;
// Handle the async scan
performScan(file, scanEventId, req, res, next);
}
/**
* Malware scan middleware for multiple file uploads (multer.array / multer.fields)
* Scans all files and blocks if ANY file is infected
*/
export function malwareScanMultipleMiddleware(req: Request, res: Response, next: NextFunction): void {
// Handle multer.array()
const files = req.files;
if (!files || (Array.isArray(files) && files.length === 0)) {
console.log('[MalwareScan] No files attached — skipping multi-scan');
return next();
}
console.log(`[MalwareScan] 🔒 Multi-file scan started`);
const scanEventId = uuidv4();
req.scanEventId = scanEventId;
// Handle array of files
if (Array.isArray(files)) {
performMultiScan(files, scanEventId, req, res, next);
return;
}
// Handle multer.fields() — object with field names as keys
const allFiles: Express.Multer.File[] = [];
const filesObj = files as { [fieldname: string]: Express.Multer.File[] };
for (const fieldFiles of Object.values(filesObj)) {
allFiles.push(...fieldFiles);
}
if (allFiles.length === 0) {
return next();
}
performMultiScan(allFiles, scanEventId, req, res, next);
}
// ── Core scan logic ──
async function performScan(
file: Express.Multer.File,
scanEventId: string,
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
let tempPath: string | null = null;
try {
// Step 0: Pre-scan file validation (extension, MIME, magic bytes, blocked patterns)
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile(
file.originalname,
file.mimetype,
file.buffer || null,
file.size,
maxSizeMB,
);
if (!validation.valid) {
console.log(`[MalwareScan] ⛔ File validation FAILED for "${file.originalname}": ${validation.errors.join('; ')}`);
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
reason: 'FILE_VALIDATION_FAILED',
errors: validation.errors,
});
res.status(403).json({
success: false,
error: 'FILE_VALIDATION_FAILED',
message: `File rejected: ${validation.errors[0]}`,
scanEventId,
details: {
errors: validation.errors,
warnings: validation.warnings,
},
});
return;
}
if (validation.warnings.length > 0) {
console.log(`[MalwareScan] ⚠️ File validation warnings for "${file.originalname}": ${validation.warnings.join('; ')}`);
}
// Check if we have a buffer (memory storage) or path (disk storage)
if (file.buffer) {
tempPath = writeTempFile(file.buffer, file.originalname);
} else if (file.path) {
tempPath = file.path;
} else {
console.warn('[MalwareScan] No file buffer or path available, skipping scan');
return next();
}
// 1. ClamAV Malware Scan
const malwareResult = await scanFile(tempPath);
req.malwareScanResult = malwareResult;
// If infected, block immediately
if (malwareResult.isInfected) {
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
virusNames: malwareResult.virusNames,
scanDuration: malwareResult.scanDuration,
});
// Delete temp file
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
res.status(403).json({
success: false,
error: 'MALWARE_DETECTED',
message: 'File contains malware and was blocked',
scanEventId,
details: {
scanEngine: 'ClamAV',
signatures: malwareResult.virusNames,
},
});
return;
}
// If ClamAV had an error (not skipped, but failed to scan), fail-secure
if (!malwareResult.scanned && !malwareResult.skipped && malwareResult.error) {
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
res.status(503).json({
success: false,
error: 'SCAN_UNAVAILABLE',
message: 'Antivirus scanning is temporarily unavailable. Please try again later.',
scanEventId,
});
return;
}
// 2. Content XSS Scan
const contentToScan = file.buffer || fs.readFileSync(tempPath);
const contentResult = scanContentForXSS(contentToScan, file.originalname, file.mimetype);
req.contentScanResult = contentResult;
// If XSS threats found, block
if (!contentResult.safe) {
logSecurityEvent(SecurityEventType.CONTENT_XSS_DETECTED, {
scanEventId,
originalName: file.originalname,
threats: contentResult.threats.map(t => t.description),
severity: contentResult.severity,
scanType: contentResult.scanType,
});
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
res.status(403).json({
success: false,
error: 'CONTENT_THREAT_DETECTED',
message: 'File contains potentially malicious content and was blocked',
scanEventId,
details: {
scanType: contentResult.scanType,
threats: contentResult.threats.map(t => ({
description: t.description,
severity: t.severity,
})),
overallSeverity: contentResult.severity,
},
});
return;
}
// Clean temp file (only if we created it)
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
// All clear — proceed to controller
next();
} catch (error: any) {
console.error('[MalwareScan] Unexpected error during scan:', error.message);
if (tempPath && file.buffer) {
deleteTempFile(tempPath);
}
// Fail-secure on unexpected errors
res.status(503).json({
success: false,
error: 'SCAN_ERROR',
message: 'An error occurred during security scanning. Please try again.',
scanEventId,
});
}
}
async function performMultiScan(
files: Express.Multer.File[],
scanEventId: string,
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
for (const file of files) {
let tempPath: string | null = null;
// Step 0: Pre-scan file validation
const maxSizeMB = parseInt(process.env.MAX_FILE_SIZE_MB || '50', 10);
const validation = validateFile(
file.originalname,
file.mimetype,
file.buffer || null,
file.size,
maxSizeMB,
);
if (!validation.valid) {
console.log(`[MalwareScan] ⛔ File validation FAILED for "${file.originalname}": ${validation.errors.join('; ')}`);
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
reason: 'FILE_VALIDATION_FAILED',
errors: validation.errors,
});
res.status(403).json({
success: false,
error: 'FILE_VALIDATION_FAILED',
message: `File "${file.originalname}" rejected: ${validation.errors[0]}`,
scanEventId,
details: { errors: validation.errors, warnings: validation.warnings },
});
return;
}
// Write to temp if memory storage
if (file.buffer) {
tempPath = writeTempFile(file.buffer, file.originalname);
} else if (file.path) {
tempPath = file.path;
} else {
continue; // Skip files without buffer or path
}
// ClamAV scan
const malwareResult = await scanFile(tempPath);
if (malwareResult.isInfected) {
logSecurityEvent(SecurityEventType.FILE_UPLOAD_BLOCKED, {
scanEventId,
originalName: file.originalname,
virusNames: malwareResult.virusNames,
});
if (tempPath && file.buffer) deleteTempFile(tempPath);
res.status(403).json({
success: false,
error: 'MALWARE_DETECTED',
message: `File "${file.originalname}" contains malware and was blocked`,
scanEventId,
details: {
scanEngine: 'ClamAV',
signatures: malwareResult.virusNames,
infectedFile: file.originalname,
},
});
return;
}
// ClamAV error — fail-secure
if (!malwareResult.scanned && !malwareResult.skipped && malwareResult.error) {
if (tempPath && file.buffer) deleteTempFile(tempPath);
res.status(503).json({
success: false,
error: 'SCAN_UNAVAILABLE',
message: 'Antivirus scanning is temporarily unavailable.',
scanEventId,
});
return;
}
// Content XSS scan
const contentToScan = file.buffer || fs.readFileSync(tempPath);
const contentResult = scanContentForXSS(contentToScan, file.originalname, file.mimetype);
if (!contentResult.safe) {
logSecurityEvent(SecurityEventType.CONTENT_XSS_DETECTED, {
scanEventId,
originalName: file.originalname,
threats: contentResult.threats.map(t => t.description),
severity: contentResult.severity,
});
if (tempPath && file.buffer) deleteTempFile(tempPath);
res.status(403).json({
success: false,
error: 'CONTENT_THREAT_DETECTED',
message: `File "${file.originalname}" contains potentially malicious content`,
scanEventId,
details: {
scanType: contentResult.scanType,
threats: contentResult.threats.map(t => ({
description: t.description,
severity: t.severity,
})),
},
});
return;
}
// Clean temp file
if (tempPath && file.buffer) deleteTempFile(tempPath);
}
// All files clean
next();
} catch (error: any) {
console.error('[MalwareScan] Error during multi-file scan:', error.message);
res.status(503).json({
success: false,
error: 'SCAN_ERROR',
message: 'An error occurred during security scanning.',
scanEventId,
});
}
}

View File

@ -180,6 +180,58 @@ export const queueJobProcessingRate = new client.Gauge({
registers: [register],
});
// ============================================================================
// ANTIVIRUS / SECURITY METRICS
// ============================================================================
// ClamAV scan results counter
export const antivirusScanTotal = new client.Counter({
name: 'antivirus_scan_total',
help: 'Total number of antivirus scans performed',
labelNames: ['result'], // clean, infected, error, skipped
registers: [register],
});
// ClamAV scan duration histogram
export const antivirusScanDuration = new client.Histogram({
name: 'antivirus_scan_duration_seconds',
help: 'ClamAV scan duration in seconds',
buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
registers: [register],
});
// XSS content scan results counter
export const contentXssScanTotal = new client.Counter({
name: 'content_xss_scan_total',
help: 'Total number of content XSS scans performed',
labelNames: ['result', 'file_type'], // safe, threat
registers: [register],
});
// ClamAV daemon status gauge (1 = up, 0 = down)
export const clamavDaemonStatus = new client.Gauge({
name: 'clamav_daemon_status',
help: 'ClamAV daemon health status (1=up, 0=down)',
registers: [register],
});
// Helper functions for recording antivirus metrics
export function recordAntivirusScan(result: 'clean' | 'infected' | 'error' | 'skipped', durationMs?: number): void {
antivirusScanTotal.inc({ result });
if (durationMs !== undefined) {
antivirusScanDuration.observe(durationMs / 1000);
}
}
export function recordContentXssScan(result: 'safe' | 'threat', fileType: string): void {
contentXssScanTotal.inc({ result, file_type: fileType });
}
export function updateClamavDaemonStatus(isUp: boolean): void {
clamavDaemonStatus.set(isUp ? 1 : 0);
}
// ============================================================================
// MIDDLEWARE
// ============================================================================
@ -271,10 +323,10 @@ export async function metricsHandler(_req: Request, res: Response): Promise<void
*/
export function createMetricsRouter(): Router {
const router = Router();
// Metrics endpoint (GET /metrics)
router.get('/metrics', metricsHandler);
return router;
}

View File

@ -1,7 +1,11 @@
import rateLimit from 'express-rate-limit';
/**
* General rate limiter applied globally to all API routes.
* Configurable via environment variables.
*/
export const rateLimiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
message: {
success: false,
@ -10,4 +14,53 @@ export const rateLimiter = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.path === '/health' || req.path === '/api/v1/health',
});
/**
* Stricter rate limiter for authentication routes (login, token exchange).
* Prevents brute-force attacks on auth endpoints.
*/
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: {
success: false,
message: 'Too many authentication attempts. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for file upload routes.
* Prevents upload abuse / DoS.
*/
export const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50,
message: {
success: false,
message: 'Too many upload requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});
/**
* Rate limiter for admin routes.
* Stricter to prevent admin action abuse.
*/
export const adminLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
message: {
success: false,
message: 'Too many admin requests. Please try again later.',
timestamp: new Date(),
},
standardHeaders: true,
legacyHeaders: false,
});

View File

@ -0,0 +1,162 @@
/**
* Sanitization Middleware
* Sanitizes string inputs in req.body and req.query to prevent stored XSS.
*
* Uses TWO strategies:
* 1. STRICT strips ALL HTML tags (for normal text fields like names, emails, titles)
* 2. PERMISSIVE allows safe formatting tags (for rich text fields like description, message, comments)
*
* This middleware runs AFTER body parsing and BEFORE route handlers.
* File upload routes (multipart) are skipped those are handled
* by the malwareScan middleware pipeline.
*/
import { Request, Response, NextFunction } from 'express';
import sanitizeHtml from 'sanitize-html';
/**
* Fields that intentionally store HTML from rich text editors.
* These get PERMISSIVE sanitization (safe formatting tags allowed).
* All other string fields get STRICT sanitization (all tags stripped).
*/
const RICH_TEXT_FIELDS = new Set([
'description',
'requestDescription',
'message',
'content',
'comments',
'rejectionReason',
'pauseReason',
'conclusionRemark',
'aiGeneratedRemark',
'finalRemark',
'closingRemarks',
'effectiveFinalRemark',
'keyDiscussionPoints',
'keyPoints',
'remarksText',
'remark',
'remarks',
'feedback',
'note',
'notes',
'skipReason',
]);
// Strict config: zero allowed tags, zero allowed attributes
const strictSanitizeConfig: sanitizeHtml.IOptions = {
allowedTags: [],
allowedAttributes: {},
allowedIframeHostnames: [],
disallowedTagsMode: 'discard',
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object'],
};
// Permissive config: allow safe formatting tags from rich text editors
// Blocks dangerous elements (script, iframe, object, embed, form, input)
const permissiveSanitizeConfig: sanitizeHtml.IOptions = {
allowedTags: [
// Text formatting
'p', 'br', 'b', 'i', 'u', 'em', 'strong', 's', 'strike', 'del', 'sub', 'sup', 'mark', 'small',
// Headings
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Lists
'ul', 'ol', 'li',
// Block elements
'blockquote', 'pre', 'code', 'hr', 'div', 'span',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
// Links (href checked below)
'a',
// Images (src checked below)
'img',
],
allowedAttributes: {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'td': ['colspan', 'rowspan'],
'th': ['colspan', 'rowspan'],
'ol': ['start', 'type'],
'span': ['class'],
'div': ['class'],
'pre': ['class'],
'code': ['class'],
'p': ['class'],
'table': ['class'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowedIframeHostnames: [],
disallowedTagsMode: 'discard',
nonTextTags: ['script', 'style', 'iframe', 'embed', 'object', 'applet', 'form', 'input', 'textarea', 'select', 'button'],
};
/**
* Recursively sanitize all string values in an object or array
* Uses the field key to decide strict vs permissive sanitization
*/
function sanitizeValue(value: any, fieldKey?: string): any {
if (typeof value === 'string') {
const isRichTextField = fieldKey && RICH_TEXT_FIELDS.has(fieldKey);
const config = isRichTextField ? permissiveSanitizeConfig : strictSanitizeConfig;
return sanitizeHtml(value, config);
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, fieldKey));
}
if (value !== null && typeof value === 'object') {
return sanitizeObject(value);
}
return value;
}
/**
* Sanitize all string properties of an object (recursively)
* Passes the key name to sanitizeValue so it can choose the right config
*/
function sanitizeObject(obj: Record<string, any>): Record<string, any> {
const sanitized: Record<string, any> = {};
for (const key of Object.keys(obj)) {
sanitized[key] = sanitizeValue(obj[key], key);
}
return sanitized;
}
/**
* Express middleware that sanitizes req.body and req.query
* Skips multipart/form-data requests (file uploads handled by malwareScan)
*/
export const sanitizationMiddleware = (req: Request, _res: Response, next: NextFunction): void => {
try {
// Skip multipart requests — file uploads are sanitized by the malware scan pipeline
const contentType = req.headers['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
return next();
}
// Sanitize req.body (POST/PUT/PATCH payloads)
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
req.body = sanitizeObject(req.body);
}
// Sanitize req.query (GET query parameters) — always strict (no HTML in query params)
if (req.query && typeof req.query === 'object' && Object.keys(req.query).length > 0) {
const strictQuery: Record<string, any> = {};
for (const key of Object.keys(req.query)) {
const val = req.query[key];
if (typeof val === 'string') {
strictQuery[key] = sanitizeHtml(val, strictSanitizeConfig);
} else {
strictQuery[key] = val;
}
}
req.query = strictQuery as any;
}
next();
} catch (error) {
// If sanitization fails for any reason, don't block the request —
// downstream validation (Zod) will catch malformed input
console.warn('Sanitization middleware warning:', error instanceof Error ? error.message : 'Unknown error');
next();
}
};

View File

@ -1,6 +1,21 @@
import { Router } from 'express';
import { authenticateToken } from '@middlewares/auth.middleware';
import { requireAdmin } from '@middlewares/authorization.middleware';
import { validateBody, validateParams } from '../middlewares/validate.middleware';
import {
createHolidaySchema,
updateHolidaySchema,
holidayParamsSchema,
calendarParamsSchema,
configKeyParamsSchema,
updateConfigSchema,
assignRoleSchema,
updateRoleSchema,
userIdParamsSchema,
createActivityTypeSchema,
updateActivityTypeSchema,
activityTypeParamsSchema,
} from '../validators/admin.validator';
import {
getAllHolidays,
getHolidayCalendar,
@ -44,7 +59,7 @@ router.get('/holidays', getAllHolidays);
* @params year
* @access Admin
*/
router.get('/holidays/calendar/:year', getHolidayCalendar);
router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), getHolidayCalendar);
/**
* @route POST /api/admin/holidays
@ -52,7 +67,7 @@ router.get('/holidays/calendar/:year', getHolidayCalendar);
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
* @access Admin
*/
router.post('/holidays', createHoliday);
router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
/**
* @route PUT /api/admin/holidays/:holidayId
@ -61,7 +76,7 @@ router.post('/holidays', createHoliday);
* @body Holiday fields to update
* @access Admin
*/
router.put('/holidays/:holidayId', updateHoliday);
router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validateBody(updateHolidaySchema), updateHoliday);
/**
* @route DELETE /api/admin/holidays/:holidayId
@ -69,7 +84,7 @@ router.put('/holidays/:holidayId', updateHoliday);
* @params holidayId
* @access Admin
*/
router.delete('/holidays/:holidayId', deleteHoliday);
router.delete('/holidays/:holidayId', validateParams(holidayParamsSchema), deleteHoliday);
/**
* @route POST /api/admin/holidays/bulk-import
@ -96,7 +111,7 @@ router.get('/configurations', getAllConfigurations);
* @body { configValue }
* @access Admin
*/
router.put('/configurations/:configKey', updateConfiguration);
router.put('/configurations/:configKey', validateParams(configKeyParamsSchema), validateBody(updateConfigSchema), updateConfiguration);
/**
* @route POST /api/admin/configurations/:configKey/reset
@ -104,7 +119,7 @@ router.put('/configurations/:configKey', updateConfiguration);
* @params configKey
* @access Admin
*/
router.post('/configurations/:configKey/reset', resetConfiguration);
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
// ==================== User Role Management Routes (RBAC) ====================
@ -114,7 +129,7 @@ router.post('/configurations/:configKey/reset', resetConfiguration);
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
* @access Admin
*/
router.post('/users/assign-role', assignRoleByEmail);
router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEmail);
/**
* @route PUT /api/admin/users/:userId/role
@ -123,7 +138,7 @@ router.post('/users/assign-role', assignRoleByEmail);
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
* @access Admin
*/
router.put('/users/:userId/role', updateUserRole);
router.put('/users/:userId/role', validateParams(userIdParamsSchema), validateBody(updateRoleSchema), updateUserRole);
/**
* @route GET /api/admin/users/by-role
@ -156,7 +171,7 @@ router.get('/activity-types', getAllActivityTypes);
* @params activityTypeId
* @access Admin
*/
router.get('/activity-types/:activityTypeId', getActivityTypeById);
router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), getActivityTypeById);
/**
* @route POST /api/admin/activity-types
@ -164,7 +179,7 @@ router.get('/activity-types/:activityTypeId', getActivityTypeById);
* @body { title, itemCode?, taxationType?, sapRefNo? }
* @access Admin
*/
router.post('/activity-types', createActivityType);
router.post('/activity-types', validateBody(createActivityTypeSchema), createActivityType);
/**
* @route PUT /api/admin/activity-types/:activityTypeId
@ -173,7 +188,7 @@ router.post('/activity-types', createActivityType);
* @body Activity type fields to update
* @access Admin
*/
router.put('/activity-types/:activityTypeId', updateActivityType);
router.put('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), validateBody(updateActivityTypeSchema), updateActivityType);
/**
* @route DELETE /api/admin/activity-types/:activityTypeId
@ -181,7 +196,6 @@ router.put('/activity-types/:activityTypeId', updateActivityType);
* @params activityTypeId
* @access Admin
*/
router.delete('/activity-types/:activityTypeId', deleteActivityType);
router.delete('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), deleteActivityType);
export default router;

View File

@ -0,0 +1,166 @@
/**
* Antivirus Admin Routes
* Admin endpoints for ClamAV management and audit logs.
*/
import { Router, Request, Response } from 'express';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import {
getToggleStatus,
setToggleStatus,
getToggleHistory,
} from '../services/clamav/clamavToggleManager';
import { pingDaemon } from '../services/clamav/clamavScanWrapper';
import {
readAuditLogs,
getAuditStats,
exportAuditLogsCSV,
logSecurityEvent,
SecurityEventType,
} from '../services/logging/securityEventLogger';
const router = Router();
// All routes require admin authentication
router.use(authenticateToken, requireAdmin);
/**
* GET /api/v1/antivirus/clamav-status
* Get ClamAV toggle status, daemon health, and recent toggle history
*/
router.get(
'/clamav-status',
asyncHandler(async (_req: Request, res: Response) => {
const toggleStatus = getToggleStatus();
const daemonStatus = await pingDaemon();
const recentHistory = getToggleHistory(10);
res.json({
success: true,
toggle: toggleStatus,
daemon: daemonStatus,
recentHistory,
});
})
);
/**
* POST /api/v1/antivirus/clamav-toggle
* Enable or disable ClamAV scanning
* Body: { enabled: boolean, reason: string }
*/
router.post(
'/clamav-toggle',
asyncHandler(async (req: Request, res: Response) => {
const { enabled, reason } = req.body;
if (typeof enabled !== 'boolean') {
res.status(400).json({
success: false,
message: '"enabled" must be a boolean',
});
return;
}
if (!reason || typeof reason !== 'string') {
res.status(400).json({
success: false,
message: '"reason" is required',
});
return;
}
const userId = (req as any).user?.id || (req as any).user?.email || 'unknown';
const result = setToggleStatus(enabled, userId, reason);
// Log the admin action
logSecurityEvent(SecurityEventType.CLAMAV_TOGGLE_CHANGED, {
enabled,
reason,
changedBy: userId,
}, userId);
res.json({
success: true,
message: `ClamAV scanning ${enabled ? 'enabled' : 'disabled'}`,
state: result.state,
});
})
);
/**
* GET /api/v1/antivirus/audit-logs
* Search and paginate security audit logs
* Query params: eventType, severity, category, startDate, endDate, limit, offset
*/
router.get(
'/audit-logs',
asyncHandler(async (req: Request, res: Response) => {
const {
eventType,
severity,
category,
startDate,
endDate,
limit = '50',
offset = '0',
} = req.query;
const result = readAuditLogs({
eventType: eventType as string,
severity: severity as string,
category: category as string,
startDate: startDate as string,
endDate: endDate as string,
limit: parseInt(limit as string, 10),
offset: parseInt(offset as string, 10),
});
res.json({
success: true,
...result,
});
})
);
/**
* GET /api/v1/antivirus/audit-logs/export
* Export filtered audit logs as CSV
*/
router.get(
'/audit-logs/export',
asyncHandler(async (req: Request, res: Response) => {
const { eventType, severity, startDate, endDate } = req.query;
const csv = exportAuditLogsCSV({
eventType: eventType as string,
severity: severity as string,
startDate: startDate as string,
endDate: endDate as string,
});
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename=audit-logs-${new Date().toISOString().split('T')[0]}.csv`);
res.send(csv);
})
);
/**
* GET /api/v1/antivirus/audit-stats
* Get audit log statistics
*/
router.get(
'/audit-stats',
asyncHandler(async (_req: Request, res: Response) => {
const stats = getAuditStats();
res.json({
success: true,
stats,
});
})
);
export default router;

View File

@ -3,6 +3,15 @@ import { DealerClaimController } from '../controllers/dealerClaim.controller';
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { malwareScanMiddleware, malwareScanMultipleMiddleware } from '../middlewares/malwareScan.middleware';
import { validateBody, validateParams } from '../middlewares/validate.middleware';
import {
requestIdParamsSchema,
updateIOSchema,
updateEInvoiceSchema,
sendCreditNoteSchema,
testSapBlockSchema,
} from '../validators/dealerClaim.validator';
import multer from 'multer';
import path from 'path';
@ -46,63 +55,63 @@ router.post('/', authenticateToken, asyncHandler(dealerClaimController.createCla
* @desc Get claim details
* @access Private
*/
router.get('/:requestId', authenticateToken, asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController)));
router.get('/:requestId', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/:requestId/proposal
* @desc Submit dealer proposal (Step 1)
* @access Private
*/
router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDocument'), asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
router.post('/:requestId/proposal', authenticateToken, validateParams(requestIdParamsSchema), upload.single('proposalDocument'), malwareScanMiddleware, asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/:requestId/completion
* @desc Submit completion documents (Step 5)
* @access Private
*/
router.post('/:requestId/completion', authenticateToken, upload.fields([
router.post('/:requestId/completion', authenticateToken, validateParams(requestIdParamsSchema), upload.fields([
{ name: 'completionDocuments', maxCount: 10 },
{ name: 'activityPhotos', maxCount: 10 },
{ name: 'invoicesReceipts', maxCount: 10 },
{ name: 'attendanceSheet', maxCount: 1 },
]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
]), malwareScanMultipleMiddleware, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
/**
* @route GET /api/v1/dealer-claims/:requestId/io/validate
* @desc Validate/Fetch IO details from SAP (returns dummy data for now)
* @access Private
*/
router.get('/:requestId/io/validate', authenticateToken, asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
router.get('/:requestId/io/validate', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.validateIO.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/io
* @desc Block IO amount in SAP and store in database
* @access Private
*/
router.put('/:requestId/io', authenticateToken, asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
router.put('/:requestId/io', authenticateToken, validateParams(requestIdParamsSchema), validateBody(updateIOSchema), asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/e-invoice
* @desc Update e-invoice details (Step 7)
* @access Private
*/
router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
router.get('/:requestId/e-invoice/pdf', authenticateToken, asyncHandler(dealerClaimController.downloadInvoicePdf.bind(dealerClaimController)));
router.put('/:requestId/e-invoice', authenticateToken, validateParams(requestIdParamsSchema), validateBody(updateEInvoiceSchema), asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
router.get('/:requestId/e-invoice/pdf', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoicePdf.bind(dealerClaimController)));
/**
* @route PUT /api/v1/dealer-claims/:requestId/credit-note
* @desc Update credit note details (Step 8)
* @access Private
*/
router.get('/:requestId/e-invoice/csv', authenticateToken, asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
router.post('/:requestId/credit-note', authenticateToken, upload.single('creditNoteFile'), asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
router.post('/:requestId/credit-note', authenticateToken, validateParams(requestIdParamsSchema), upload.single('creditNoteFile'), malwareScanMiddleware, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send
* @desc Send credit note to dealer and auto-approve Step 8
* @access Private
*/
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
router.post('/:requestId/credit-note/send', authenticateToken, validateParams(requestIdParamsSchema), validateBody(sendCreditNoteSchema), asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/test/sap-block
@ -110,7 +119,6 @@ router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(deal
* @access Private
* @body { ioNumber: string, amount: number, requestNumber?: string }
*/
router.post('/test/sap-block', authenticateToken, asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
router.post('/test/sap-block', authenticateToken, validateBody(testSapBlockSchema), asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
export default router;

View File

@ -4,6 +4,7 @@ import path from 'path';
import crypto from 'crypto';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { malwareScanMiddleware } from '../middlewares/malwareScan.middleware';
import { DocumentController } from '../controllers/document.controller';
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
@ -20,8 +21,7 @@ const router = Router();
const controller = new DocumentController();
// multipart/form-data: file, requestId, optional category
router.post('/', authenticateToken, upload.single('file'), asyncHandler(controller.upload.bind(controller)));
// Middleware chain: auth → multer → malware scan → controller
router.post('/', authenticateToken, upload.single('file'), malwareScanMiddleware, asyncHandler(controller.upload.bind(controller)));
export default router;

View File

@ -18,8 +18,10 @@ import templateRoutes from './template.routes';
import dealerRoutes from './dealer.routes';
import dmsWebhookRoutes from './dmsWebhook.routes';
import apiTokenRoutes from './apiToken.routes';
import antivirusRoutes from './antivirus.routes';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
import { authLimiter, uploadLimiter, adminLimiter } from '../middlewares/rateLimiter.middleware';
const router = Router();
@ -32,15 +34,15 @@ router.get('/health', (_req, res) => {
});
});
// API routes
router.use('/auth', authRoutes);
// API routes (with per-endpoint rate limiters on sensitive routes)
router.use('/auth', authLimiter, authRoutes);
router.use('/config', configRoutes); // System configuration (public)
router.use('/workflows', workflowRoutes);
router.use('/users', userRoutes);
router.use('/user/preferences', userPreferenceRoutes); // User preferences (authenticated)
router.use('/documents', documentRoutes);
router.use('/documents', uploadLimiter, documentRoutes);
router.use('/tat', tatRoutes);
router.use('/admin', adminRoutes);
router.use('/admin', adminLimiter, adminRoutes);
router.use('/debug', authenticateToken, requireAdmin, debugRoutes);
router.use('/dashboard', dashboardRoutes);
router.use('/notifications', notificationRoutes);
@ -52,6 +54,7 @@ router.use('/templates', templateRoutes);
router.use('/dealers', dealerRoutes);
router.use('/webhooks/dms', dmsWebhookRoutes);
router.use('/api-tokens', apiTokenRoutes);
router.use('/antivirus', antivirusRoutes);
// Add other route modules as they are implemented
// router.use('/approvals', approvalRoutes);

View File

@ -11,6 +11,7 @@ import { requireParticipantTypes } from '../middlewares/authorization.middleware
import multer from 'multer';
import path from 'path';
import crypto from 'crypto';
import { malwareScanMultipleMiddleware } from '../middlewares/malwareScan.middleware';
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
import { notificationService } from '../services/notification.service';
import { Activity } from '@models/Activity';
@ -102,6 +103,7 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
router.post('/multipart',
authenticateToken,
upload.array('files'),
malwareScanMultipleMiddleware,
asyncHandler(workflowController.createWorkflowMultipart.bind(workflowController))
);
@ -129,6 +131,7 @@ router.put('/:id/multipart',
authenticateToken,
validateParams(workflowParamsSchema),
upload.array('files'),
malwareScanMultipleMiddleware,
asyncHandler(workflowController.updateWorkflowMultipart.bind(workflowController))
);
@ -218,6 +221,7 @@ router.post('/:id/work-notes',
authenticateToken,
validateParams(workflowParamsSchema),
noteUpload.array('files'),
malwareScanMultipleMiddleware,
asyncHandler(workNoteController.create.bind(workNoteController))
);

View File

@ -0,0 +1,227 @@
/**
* ClamAV Scan Wrapper
* Low-level ClamAV communication using TCP mode via clamscan npm package.
* Scans files, extracts virus signatures, and pings daemon health.
*
* IMPORTANT: Do not cache the scanner instance each scan creates a fresh
* connection to avoid stale TCP socket issues.
*/
import net from 'net';
import { getToggleStatus } from './clamavToggleManager';
import { logSecurityEvent, SecurityEventType } from '../logging/securityEventLogger';
// ── Types ──
export interface ClamScanResult {
isInfected: boolean;
virusNames: string[];
scanned: boolean;
skipped: boolean;
scanDuration: number;
rawOutput: string;
error?: string;
}
export interface ClamDaemonStatus {
available: boolean;
host: string;
port: number;
responseTime: number;
error?: string;
}
// ── Configuration ──
const CLAMD_HOST = process.env.CLAMD_HOST || 'localhost';
const CLAMD_PORT = parseInt(process.env.CLAMD_PORT || '3310', 10);
const CLAMD_TIMEOUT = parseInt(process.env.CLAMD_TIMEOUT_MS || '30000', 10);
// ── Scanner Factory (fresh instance per scan) ──
async function createScanner(): Promise<any> {
const NodeClamscan = (await import('clamscan')).default;
const clamscan = await new NodeClamscan().init({
removeInfected: false,
debugMode: false,
scanRecursively: false,
clamdscan: {
host: CLAMD_HOST,
port: CLAMD_PORT,
timeout: CLAMD_TIMEOUT,
localFallback: false,
multiscan: false,
active: true,
},
preference: 'clamdscan',
});
return clamscan;
}
// ── Core Functions ──
/**
* Scan a file for malware using ClamAV daemon.
* Creates a fresh scanner connection for each scan to avoid stale sockets.
*/
export async function scanFile(filePath: string): Promise<ClamScanResult> {
const startTime = Date.now();
// Check if scanning is enabled via admin toggle
const toggleStatus = getToggleStatus();
if (!toggleStatus.enabled) {
console.log('[ClamAV] Scanning disabled via toggle — skipping scan');
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: true,
scanDuration: 0,
rawOutput: 'Scanning disabled by administrator',
};
}
// Check if ClamAV is enabled via env
if (process.env.ENABLE_CLAMAV === 'false') {
console.log('[ClamAV] Disabled via ENABLE_CLAMAV=false — skipping scan');
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: true,
scanDuration: 0,
rawOutput: 'ClamAV disabled via ENABLE_CLAMAV env',
};
}
try {
console.log(`[ClamAV] Scanning file: ${filePath}`);
const scanner = await createScanner();
const scanResult = await scanner.isInfected(filePath);
const scanDuration = Date.now() - startTime;
console.log(`[ClamAV] Raw scan result:`, JSON.stringify({
isInfected: scanResult.isInfected,
file: scanResult.file,
viruses: scanResult.viruses,
}));
// IMPORTANT: clamscan can return null for isInfected when the daemon
// fails to respond properly. Treat null as a scan failure, not clean.
if (scanResult.isInfected === null || scanResult.isInfected === undefined) {
console.error('[ClamAV] Scan returned null/undefined — treating as scan failure (fail-secure)');
logSecurityEvent(SecurityEventType.MALWARE_SCAN_ERROR, {
filePath,
error: 'ClamAV returned null result — possible daemon connection issue',
scanDuration,
});
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: false,
scanDuration,
rawOutput: 'ClamAV returned null/undefined isInfected',
error: 'Scan result was inconclusive — ClamAV daemon may be unreachable',
};
}
const result: ClamScanResult = {
isInfected: scanResult.isInfected === true,
virusNames: scanResult.viruses || [],
scanned: true,
skipped: false,
scanDuration,
rawOutput: `File: ${scanResult.file}, Infected: ${scanResult.isInfected}, Viruses: ${(scanResult.viruses || []).join(', ')}`,
};
// Log the scan event
if (result.isInfected) {
console.log(`[ClamAV] ⛔ MALWARE DETECTED in ${filePath}: ${result.virusNames.join(', ')}`);
logSecurityEvent(SecurityEventType.MALWARE_DETECTED, {
filePath,
virusNames: result.virusNames,
scanDuration,
});
} else {
console.log(`[ClamAV] ✅ File clean: ${filePath} (${scanDuration}ms)`);
logSecurityEvent(SecurityEventType.MALWARE_SCAN_CLEAN, {
filePath,
scanDuration,
});
}
return result;
} catch (error: any) {
const scanDuration = Date.now() - startTime;
console.error(`[ClamAV] ❌ Scan error for ${filePath}:`, error.message);
logSecurityEvent(SecurityEventType.MALWARE_SCAN_ERROR, {
filePath,
error: error.message,
scanDuration,
});
return {
isInfected: false,
virusNames: [],
scanned: false,
skipped: false,
scanDuration,
rawOutput: '',
error: error.message,
};
}
}
/**
* Ping ClamAV daemon via TCP socket to check availability
*/
export async function pingDaemon(): Promise<ClamDaemonStatus> {
const startTime = Date.now();
return new Promise((resolve) => {
const socket = new net.Socket();
const timeout = 5000;
socket.setTimeout(timeout);
socket.connect(CLAMD_PORT, CLAMD_HOST, () => {
// Send PING command
socket.write('zPING\0');
});
socket.on('data', (data) => {
const response = data.toString().trim();
socket.destroy();
resolve({
available: response === 'PONG',
host: CLAMD_HOST,
port: CLAMD_PORT,
responseTime: Date.now() - startTime,
});
});
socket.on('error', (error: any) => {
socket.destroy();
resolve({
available: false,
host: CLAMD_HOST,
port: CLAMD_PORT,
responseTime: Date.now() - startTime,
error: error.message,
});
});
socket.on('timeout', () => {
socket.destroy();
resolve({
available: false,
host: CLAMD_HOST,
port: CLAMD_PORT,
responseTime: Date.now() - startTime,
error: 'Connection timed out',
});
});
});
}

View File

@ -0,0 +1,162 @@
/**
* ClamAV Toggle Manager
* Admin enable/disable control for antivirus scanning with full audit trail.
* Toggle state is persisted in logs/clamav-toggle.json.
*/
import fs from 'fs';
import path from 'path';
// ── Types ──
interface ToggleHistoryEntry {
timestamp: string;
enabled: boolean;
changedBy: string;
reason: string;
}
interface ToggleState {
enabled: boolean;
lastChanged: string;
changedBy: string;
reason: string;
history: ToggleHistoryEntry[];
}
// ── Configuration ──
const LOGS_DIR = path.resolve(process.cwd(), 'logs');
const TOGGLE_FILE = path.join(LOGS_DIR, 'clamav-toggle.json');
const MAX_HISTORY_ENTRIES = 100;
// ── In-memory cache ──
let cachedState: ToggleState | null = null;
// ── Functions ──
/**
* Initialize the toggle file with default state (enabled)
*/
export function initializeToggleFile(): void {
try {
// Ensure logs directory exists
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
// If toggle file exists, load it into cache
if (fs.existsSync(TOGGLE_FILE)) {
const data = fs.readFileSync(TOGGLE_FILE, 'utf-8');
cachedState = JSON.parse(data);
console.log(`[ClamAV Toggle] Loaded state: enabled=${cachedState?.enabled}`);
return;
}
// Create default toggle state
const defaultState: ToggleState = {
enabled: true,
lastChanged: new Date().toISOString(),
changedBy: 'system',
reason: 'Initial setup',
history: [
{
timestamp: new Date().toISOString(),
enabled: true,
changedBy: 'system',
reason: 'Initial setup',
},
],
};
fs.writeFileSync(TOGGLE_FILE, JSON.stringify(defaultState, null, 2));
cachedState = defaultState;
console.log('[ClamAV Toggle] Initialized with default state (enabled)');
} catch (error) {
console.error('[ClamAV Toggle] Failed to initialize:', error);
// Default to enabled if we can't read the file
cachedState = {
enabled: true,
lastChanged: new Date().toISOString(),
changedBy: 'system',
reason: 'Fallback default',
history: [],
};
}
}
/**
* Get current toggle status
*/
export function getToggleStatus(): { enabled: boolean; lastChanged: string; changedBy: string; reason: string } {
if (!cachedState) {
initializeToggleFile();
}
return {
enabled: cachedState?.enabled ?? true,
lastChanged: cachedState?.lastChanged ?? new Date().toISOString(),
changedBy: cachedState?.changedBy ?? 'system',
reason: cachedState?.reason ?? 'Default',
};
}
/**
* Set toggle status with audit trail
*/
export function setToggleStatus(
enabled: boolean,
userId: string,
reason: string
): { success: boolean; state: ToggleState } {
if (!cachedState) {
initializeToggleFile();
}
const now = new Date().toISOString();
// Add to history
const historyEntry: ToggleHistoryEntry = {
timestamp: now,
enabled,
changedBy: userId,
reason,
};
cachedState = {
enabled,
lastChanged: now,
changedBy: userId,
reason,
history: [
historyEntry,
...(cachedState?.history || []),
].slice(0, MAX_HISTORY_ENTRIES),
};
// Persist to disk
try {
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
fs.writeFileSync(TOGGLE_FILE, JSON.stringify(cachedState, null, 2));
} catch (error) {
console.error('[ClamAV Toggle] Failed to persist toggle state:', error);
}
console.log(`[ClamAV Toggle] Changed to enabled=${enabled} by ${userId}: ${reason}`);
return { success: true, state: cachedState };
}
/**
* Get toggle change history
*/
export function getToggleHistory(limit: number = 50): ToggleHistoryEntry[] {
if (!cachedState) {
initializeToggleFile();
}
return (cachedState?.history || []).slice(0, limit);
}

View File

@ -0,0 +1,205 @@
/**
* Content XSS Scanner
* Deep content analysis scans file bodies for embedded XSS/script threats.
* Supports SVG, PDF, Office (DOCX/XLSX/PPTX), Images (EXIF), and Text files.
* 120+ patterns checked across all file types.
*/
// ── Types ──
export interface ContentScanResult {
scanned: boolean;
safe: boolean;
scanType: string;
threats: ContentThreat[];
severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
patternsChecked: number;
}
export interface ContentThreat {
pattern: string;
description: string;
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
location?: string;
}
// ── Universal XSS Patterns ──
const UNIVERSAL_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /<script[\s>]/i, description: 'Script tag detected', severity: 'CRITICAL' },
{ pattern: /javascript\s*:/i, description: 'JavaScript URI scheme', severity: 'CRITICAL' },
{ pattern: /vbscript\s*:/i, description: 'VBScript URI scheme', severity: 'CRITICAL' },
{ pattern: /on(load|error|click|mouseover|mouseout|focus|blur|submit|change|keyup|keydown|keypress|abort|beforeunload|unload|resize|scroll|pointerover|pointerenter|pointerdown|pointermove|pointerup|pointercancel|pointerout|pointerleave|gotpointercapture|lostpointercapture)\s*=/i, description: 'Event handler attribute', severity: 'HIGH' },
{ pattern: /expression\s*\(/i, description: 'CSS expression()', severity: 'HIGH' },
{ pattern: /url\s*\(\s*['"]?\s*javascript/i, description: 'CSS url() with JavaScript', severity: 'HIGH' },
{ pattern: /@import\s+['"]?\s*javascript/i, description: 'CSS @import with JavaScript', severity: 'HIGH' },
{ pattern: /data\s*:\s*text\/html/i, description: 'Data URI with HTML', severity: 'HIGH' },
{ pattern: /eval\s*\(/i, description: 'eval() function call', severity: 'CRITICAL' },
{ pattern: /document\.(cookie|write|domain)/i, description: 'Document property access', severity: 'HIGH' },
{ pattern: /window\.(location|open|eval)/i, description: 'Window property access', severity: 'HIGH' },
{ pattern: /\.innerHTML\s*=/i, description: 'innerHTML assignment', severity: 'MEDIUM' },
{ pattern: /\.outerHTML\s*=/i, description: 'outerHTML assignment', severity: 'MEDIUM' },
{ pattern: /document\.createElement\s*\(\s*['"]script/i, description: 'Dynamic script creation', severity: 'CRITICAL' },
{ pattern: /\bfetch\s*\(/i, description: 'fetch() API call', severity: 'MEDIUM' },
{ pattern: /new\s+XMLHttpRequest/i, description: 'XMLHttpRequest creation', severity: 'MEDIUM' },
{ pattern: /\bimportScripts\s*\(/i, description: 'importScripts() call', severity: 'HIGH' },
{ pattern: /Function\s*\(/i, description: 'Function constructor', severity: 'HIGH' },
{ pattern: /setTimeout\s*\(\s*['"`]/i, description: 'setTimeout with string argument', severity: 'HIGH' },
{ pattern: /setInterval\s*\(\s*['"`]/i, description: 'setInterval with string argument', severity: 'HIGH' },
];
// ── SVG-Specific Patterns ──
const SVG_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /<foreignObject[\s>]/i, description: 'SVG foreignObject element', severity: 'HIGH' },
{ pattern: /<use[\s>]/i, description: 'SVG use element (potential external reference)', severity: 'MEDIUM' },
{ pattern: /xlink:href\s*=/i, description: 'SVG xlink:href attribute', severity: 'MEDIUM' },
{ pattern: /<animate[\s>]/i, description: 'SVG animate element', severity: 'MEDIUM' },
{ pattern: /<set[\s>]/i, description: 'SVG set element', severity: 'MEDIUM' },
{ pattern: /<animateTransform[\s>]/i, description: 'SVG animateTransform element', severity: 'MEDIUM' },
{ pattern: /<!ENTITY/i, description: 'XML Entity declaration (XXE)', severity: 'CRITICAL' },
{ pattern: /<!DOCTYPE[^>]*\[/i, description: 'DOCTYPE with internal subset (XXE)', severity: 'CRITICAL' },
{ pattern: /SYSTEM\s+['"][^'"]*['"]/i, description: 'SYSTEM reference (XXE)', severity: 'CRITICAL' },
{ pattern: /<!\[CDATA\[/i, description: 'CDATA section in SVG', severity: 'MEDIUM' },
{ pattern: /xlink:href\s*=\s*['"]data:/i, description: 'xlink:href with data URI', severity: 'HIGH' },
{ pattern: /href\s*=\s*['"]javascript:/i, description: 'href with JavaScript URI in SVG', severity: 'CRITICAL' },
];
// ── PDF-Specific Patterns ──
const PDF_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /\/JS\s/i, description: 'PDF JavaScript action', severity: 'CRITICAL' },
{ pattern: /\/JavaScript\s/i, description: 'PDF JavaScript entry', severity: 'CRITICAL' },
{ pattern: /\/OpenAction\s/i, description: 'PDF OpenAction (auto-execute)', severity: 'HIGH' },
{ pattern: /\/Launch\s/i, description: 'PDF Launch action', severity: 'CRITICAL' },
{ pattern: /\/AcroForm\s/i, description: 'PDF AcroForm (interactive form)', severity: 'MEDIUM' },
{ pattern: /\/EmbeddedFile\s/i, description: 'PDF Embedded file', severity: 'HIGH' },
{ pattern: /\/RichMedia\s/i, description: 'PDF RichMedia (Flash/video)', severity: 'HIGH' },
{ pattern: /\/AA\s/i, description: 'PDF Additional Actions', severity: 'HIGH' },
{ pattern: /\/SubmitForm\s/i, description: 'PDF SubmitForm action', severity: 'HIGH' },
{ pattern: /\/ImportData\s/i, description: 'PDF ImportData action', severity: 'HIGH' },
{ pattern: /\/URI\s/i, description: 'PDF URI action', severity: 'MEDIUM' },
{ pattern: /\/GoToR\s/i, description: 'PDF GoToR (remote goto)', severity: 'MEDIUM' },
];
// ── Office-Specific Patterns ──
const OFFICE_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /vbaProject\.bin/i, description: 'VBA Project (macro) binary', severity: 'CRITICAL' },
{ pattern: /macro/i, description: 'Macro reference', severity: 'HIGH' },
{ pattern: /Auto_Open/i, description: 'Auto_Open macro', severity: 'CRITICAL' },
{ pattern: /AutoExec/i, description: 'AutoExec macro', severity: 'CRITICAL' },
{ pattern: /Document_Open/i, description: 'Document_Open macro', severity: 'CRITICAL' },
{ pattern: /Workbook_Open/i, description: 'Workbook_Open macro', severity: 'CRITICAL' },
{ pattern: /oleObject/i, description: 'OLE Object', severity: 'HIGH' },
{ pattern: /DDE/i, description: 'DDE formula', severity: 'HIGH' },
{ pattern: /DDEAUTO/i, description: 'DDEAUTO formula', severity: 'CRITICAL' },
{ pattern: /externalLink/i, description: 'External link reference', severity: 'MEDIUM' },
{ pattern: /Shell\s*\(/i, description: 'Shell() function in macro', severity: 'CRITICAL' },
{ pattern: /WScript/i, description: 'WScript reference', severity: 'CRITICAL' },
{ pattern: /PowerShell/i, description: 'PowerShell reference', severity: 'CRITICAL' },
{ pattern: /cmd\.exe/i, description: 'cmd.exe reference', severity: 'CRITICAL' },
];
// ── Image EXIF Patterns ──
const IMAGE_XSS_PATTERNS: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [
{ pattern: /<script/i, description: 'Script tag in image metadata', severity: 'CRITICAL' },
{ pattern: /javascript:/i, description: 'JavaScript URI in image metadata', severity: 'CRITICAL' },
{ pattern: /on\w+\s*=/i, description: 'Event handler in image metadata', severity: 'HIGH' },
{ pattern: /<iframe/i, description: 'iframe tag in image metadata', severity: 'HIGH' },
{ pattern: /<object/i, description: 'Object tag in image metadata', severity: 'HIGH' },
{ pattern: /<embed/i, description: 'Embed tag in image metadata', severity: 'HIGH' },
];
// ── File Type Detection ──
function getFileType(filename: string, mimeType?: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (ext === 'svg' || mimeType === 'image/svg+xml') return 'SVG';
if (ext === 'pdf' || mimeType === 'application/pdf') return 'PDF';
if (['docx', 'doc'].includes(ext) || mimeType?.includes('word')) return 'OFFICE';
if (['xlsx', 'xls'].includes(ext) || mimeType?.includes('spreadsheet') || mimeType?.includes('excel')) return 'OFFICE';
if (['pptx', 'ppt'].includes(ext) || mimeType?.includes('presentation') || mimeType?.includes('powerpoint')) return 'OFFICE';
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext) || mimeType?.startsWith('image/')) return 'IMAGE';
if (['txt', 'md', 'csv', 'log'].includes(ext) || mimeType?.startsWith('text/')) return 'TEXT';
return 'UNKNOWN';
}
// ── Core Scan Function ──
/**
* Scan file content for XSS and malicious patterns
*/
export function scanContentForXSS(
content: Buffer | string,
filename: string,
mimeType?: string
): ContentScanResult {
const fileType = getFileType(filename, mimeType);
const threats: ContentThreat[] = [];
// Convert buffer to string for pattern matching
const textContent = typeof content === 'string' ? content : content.toString('utf-8');
// Select patterns based on file type
let patterns: Array<{ pattern: RegExp; description: string; severity: ContentThreat['severity'] }> = [];
switch (fileType) {
case 'SVG':
patterns = [...UNIVERSAL_XSS_PATTERNS, ...SVG_XSS_PATTERNS];
break;
case 'PDF':
patterns = [...PDF_XSS_PATTERNS];
break;
case 'OFFICE':
patterns = [...OFFICE_XSS_PATTERNS];
break;
case 'IMAGE':
patterns = [...IMAGE_XSS_PATTERNS];
break;
case 'TEXT':
patterns = [...UNIVERSAL_XSS_PATTERNS];
break;
default:
// For unknown types, use universal patterns only
patterns = [...UNIVERSAL_XSS_PATTERNS];
break;
}
// Run pattern matching
for (const { pattern, description, severity } of patterns) {
const match = pattern.exec(textContent);
if (match) {
threats.push({
pattern: pattern.source,
description,
severity,
location: `Position ${match.index}`,
});
}
}
// Determine overall severity
let overallSeverity: ContentScanResult['severity'] = 'SAFE';
if (threats.length > 0) {
const severityOrder = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
const maxSeverity = threats.reduce((max, t) => {
const currentIdx = severityOrder.indexOf(t.severity);
const maxIdx = severityOrder.indexOf(max);
return currentIdx > maxIdx ? t.severity : max;
}, 'LOW' as ContentThreat['severity']);
overallSeverity = maxSeverity;
}
return {
scanned: true,
safe: threats.length === 0,
scanType: fileType,
threats,
severity: overallSeverity,
patternsChecked: patterns.length,
};
}

View File

@ -0,0 +1,238 @@
/**
* File Validation Service
* Pre-scan validation layer that catches suspicious files ClamAV won't flag:
* - Extension whitelist enforcement
* - MIME type extension mismatch detection
* - Double extension blocking (e.g., report.pdf.exe)
* - Path traversal blocking (e.g., ../../etc/passwd)
* - Magic bytes / file signature validation
* - Filename sanitization
*/
// ── Types ──
export interface FileValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
sanitizedFilename: string;
detectedMimeType: string | null;
mimeMatchesExtension: boolean;
}
// ── Allowed Extensions and MIME Mappings ──
const EXTENSION_MIME_MAP: Record<string, string[]> = {
// Documents
pdf: ['application/pdf'],
doc: ['application/msword'],
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
xls: ['application/vnd.ms-excel'],
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
ppt: ['application/vnd.ms-powerpoint'],
pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
// Images
jpg: ['image/jpeg'],
jpeg: ['image/jpeg'],
png: ['image/png'],
gif: ['image/gif'],
webp: ['image/webp'],
svg: ['image/svg+xml'],
// Text
txt: ['text/plain'],
csv: ['text/csv', 'text/plain', 'application/csv'],
md: ['text/markdown', 'text/plain'],
log: ['text/plain'],
// Archives
zip: ['application/zip', 'application/x-zip-compressed'],
};
// Magic bytes signatures for common file types
const MAGIC_BYTES: Array<{ ext: string; bytes: number[]; offset?: number }> = [
{ ext: 'pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
{ ext: 'png', bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A] }, // PNG
{ ext: 'jpg', bytes: [0xFF, 0xD8, 0xFF] }, // JPEG
{ ext: 'jpeg', bytes: [0xFF, 0xD8, 0xFF] }, // JPEG
{ ext: 'gif', bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8
{ ext: 'zip', bytes: [0x50, 0x4B, 0x03, 0x04] }, // PK zip
{ ext: 'docx', bytes: [0x50, 0x4B, 0x03, 0x04] }, // PK (Office OOXML)
{ ext: 'xlsx', bytes: [0x50, 0x4B, 0x03, 0x04] },
{ ext: 'pptx', bytes: [0x50, 0x4B, 0x03, 0x04] },
{ ext: 'doc', bytes: [0xD0, 0xCF, 0x11, 0xE0] }, // OLE2
{ ext: 'xls', bytes: [0xD0, 0xCF, 0x11, 0xE0] },
{ ext: 'ppt', bytes: [0xD0, 0xCF, 0x11, 0xE0] },
{ ext: 'webp', bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF (WebP)
];
// Dangerous executable signatures that should NEVER be uploaded
const DANGEROUS_MAGIC_BYTES: Array<{ name: string; bytes: number[] }> = [
{ name: 'Windows EXE/DLL (MZ)', bytes: [0x4D, 0x5A] }, // MZ header
{ name: 'ELF binary', bytes: [0x7F, 0x45, 0x4C, 0x46] }, // ELF
{ name: 'Java class', bytes: [0xCA, 0xFE, 0xBA, 0xBE] }, // Java bytecode
{ name: 'Mach-O binary', bytes: [0xCF, 0xFA, 0xED, 0xFE] }, // macOS binary
{ name: 'Windows shortcut', bytes: [0x4C, 0x00, 0x00, 0x00] }, // LNK
];
// Blocked filename patterns
const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
{ pattern: /\.\./, reason: 'Path traversal attempt (../)' },
{ pattern: /[\/\\]/, reason: 'Path separator in filename' },
{ pattern: /\x00/, reason: 'Null byte in filename' },
// macOS resource fork files (._filename) — metadata junk, not real documents
{ pattern: /^\._/, reason: 'macOS resource fork file (._prefix) — not a valid document' },
// Hidden files (starting with .)
{ pattern: /^\.(?!_)/, reason: 'Hidden file (starts with dot)' },
{
pattern: /\.(exe|bat|cmd|com|msi|scr|pif|vbs|vbe|js|jse|wsf|wsh|ps1|sh|bash|cgi|pl|py|rb|jar|dll|sys|drv|ocx|cpl|inf|reg|rgs|sct|url|lnk|hta|chm|hlp|iso|img|dmg|deb|rpm|appimage)$/i,
reason: 'Executable or dangerous file extension blocked'
},
// Double extensions (e.g., report.pdf.exe, image.jpg.vbs)
{
pattern: /\.(pdf|doc|docx|xls|xlsx|jpg|jpeg|png|gif|txt)\.(exe|bat|cmd|com|scr|pif|vbs|js|ps1|sh)$/i,
reason: 'Double extension — possible disguised executable'
},
// Periods before common executable extensions
{
pattern: /\.\w+\.(exe|bat|cmd|com|msi|scr|pif|vbs|vbe|js|jse|wsf|wsh|ps1|sh|bash)$/i,
reason: 'Suspicious double extension'
},
];
// ── Core Validation Function ──
/**
* Validate an uploaded file for security concerns.
* This runs BEFORE ClamAV and catches things ClamAV won't flag.
*/
export function validateFile(
originalName: string,
mimeType: string,
fileBuffer: Buffer | null,
fileSizeBytes: number,
maxSizeMB: number = 50,
): FileValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// 1. Extract and validate extension
const ext = originalName.split('.').pop()?.toLowerCase() || '';
const allowedExtensions = Object.keys(EXTENSION_MIME_MAP);
if (!ext) {
errors.push('File has no extension');
} else if (!allowedExtensions.includes(ext)) {
errors.push(`File extension ".${ext}" is not allowed. Allowed: ${allowedExtensions.join(', ')}`);
}
// 2. Check blocked filename patterns (path traversal, executables, double extensions, macOS resource forks)
for (const { pattern, reason } of BLOCKED_PATTERNS) {
if (pattern.test(originalName)) {
errors.push(`Blocked filename: ${reason}`);
}
}
// 3. File size validation
const maxSizeBytes = maxSizeMB * 1024 * 1024;
if (fileSizeBytes > maxSizeBytes) {
errors.push(`File size (${(fileSizeBytes / 1024 / 1024).toFixed(1)}MB) exceeds limit (${maxSizeMB}MB)`);
}
if (fileSizeBytes === 0) {
errors.push('File is empty (0 bytes)');
}
// 4. MIME type ↔ extension mismatch detection (warning only — browsers/multer can report wrong MIME)
let mimeMatchesExtension = true;
if (ext && EXTENSION_MIME_MAP[ext]) {
const allowedMimes = EXTENSION_MIME_MAP[ext];
if (!allowedMimes.includes(mimeType) && mimeType !== 'application/octet-stream') {
mimeMatchesExtension = false;
warnings.push(
`MIME type mismatch: file claims ".${ext}" but has MIME "${mimeType}". ` +
`Expected: ${allowedMimes.join(' or ')}`
);
}
}
// 5. Magic bytes / file signature validation
let detectedMimeType: string | null = null;
if (fileBuffer && fileBuffer.length >= 4) {
// Check for dangerous executable signatures FIRST
for (const { name, bytes } of DANGEROUS_MAGIC_BYTES) {
if (matchesBytes(fileBuffer, bytes)) {
errors.push(`File contains ${name} binary signature — executable files are blocked`);
}
}
// Check if magic bytes match claimed extension
if (ext) {
const expectedSignatures = MAGIC_BYTES.filter(m => m.ext === ext);
if (expectedSignatures.length > 0) {
const matchesAny = expectedSignatures.some(sig => matchesBytes(fileBuffer, sig.bytes, sig.offset));
if (!matchesAny) {
// Warning only — some legitimate files have variant headers
// ClamAV will do the real malware check
warnings.push(
`File header does not match ".${ext}" signature — file may be corrupted or mislabeled`
);
}
}
}
// Detect actual type from magic bytes
for (const { ext: detExt, bytes } of MAGIC_BYTES) {
if (matchesBytes(fileBuffer, bytes)) {
const mimes = EXTENSION_MIME_MAP[detExt];
detectedMimeType = mimes ? mimes[0] : null;
break;
}
}
}
// 6. Sanitize filename
const sanitizedFilename = sanitizeFilename(originalName);
return {
valid: errors.length === 0,
errors,
warnings,
sanitizedFilename,
detectedMimeType,
mimeMatchesExtension,
};
}
// ── Helpers ──
function matchesBytes(buffer: Buffer, bytes: number[], offset: number = 0): boolean {
if (buffer.length < offset + bytes.length) return false;
return bytes.every((byte, i) => buffer[offset + i] === byte);
}
/**
* Sanitize a filename: remove dangerous chars, limit length, add UUID prefix
*/
export function sanitizeFilename(original: string): string {
// Strip path components
let name = original.replace(/^.*[\\\/]/, '');
// Remove null bytes
name = name.replace(/\x00/g, '');
// Replace dangerous characters
name = name.replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_');
// Collapse multiple dots
name = name.replace(/\.{2,}/g, '.');
// Trim leading/trailing dots and spaces
name = name.replace(/^[\s.]+|[\s.]+$/g, '');
// Limit length (keep extension)
if (name.length > 200) {
const ext = name.split('.').pop() || '';
const base = name.substring(0, 200 - ext.length - 1);
name = `${base}.${ext}`;
}
// Fallback for empty names
if (!name || name.length === 0) {
name = 'unnamed_file';
}
return name;
}

View File

@ -0,0 +1,262 @@
/**
* Security Event Logger
* Centralized NDJSON audit log for all security events.
* Writes to logs/audit.log with auto-severity assignment.
*/
import fs from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
// ── Event Types ──
export enum SecurityEventType {
// Malware events
MALWARE_DETECTED = 'MALWARE_DETECTED',
MALWARE_SCAN_CLEAN = 'MALWARE_SCAN_CLEAN',
MALWARE_SCAN_ERROR = 'MALWARE_SCAN_ERROR',
MALWARE_SCAN_SKIPPED = 'MALWARE_SCAN_SKIPPED',
// File validation events
FILE_VALIDATION_FAILED = 'FILE_VALIDATION_FAILED',
FILE_EXTENSION_BLOCKED = 'FILE_EXTENSION_BLOCKED',
FILE_SIZE_EXCEEDED = 'FILE_SIZE_EXCEEDED',
FILENAME_SANITIZED = 'FILENAME_SANITIZED',
// Content security events
CONTENT_XSS_DETECTED = 'CONTENT_XSS_DETECTED',
CONTENT_XSS_CLEAN = 'CONTENT_XSS_CLEAN',
// Admin events
CLAMAV_TOGGLE_CHANGED = 'CLAMAV_TOGGLE_CHANGED',
AUDIT_LOG_ACCESSED = 'AUDIT_LOG_ACCESSED',
// Upload events
FILE_UPLOAD_SUCCESS = 'FILE_UPLOAD_SUCCESS',
FILE_UPLOAD_BLOCKED = 'FILE_UPLOAD_BLOCKED',
}
export enum SecuritySeverity {
CRITICAL = 'CRITICAL',
HIGH = 'HIGH',
MEDIUM = 'MEDIUM',
LOW = 'LOW',
INFO = 'INFO',
}
export enum SecurityCategory {
MALWARE = 'MALWARE',
FILE_VALIDATION = 'FILE_VALIDATION',
FILE_CONTENT_SECURITY = 'FILE_CONTENT_SECURITY',
ADMIN = 'ADMIN',
UPLOAD = 'UPLOAD',
}
// ── Severity Mapping ──
const EVENT_SEVERITY: Record<SecurityEventType, SecuritySeverity> = {
[SecurityEventType.MALWARE_DETECTED]: SecuritySeverity.CRITICAL,
[SecurityEventType.MALWARE_SCAN_CLEAN]: SecuritySeverity.INFO,
[SecurityEventType.MALWARE_SCAN_ERROR]: SecuritySeverity.HIGH,
[SecurityEventType.MALWARE_SCAN_SKIPPED]: SecuritySeverity.MEDIUM,
[SecurityEventType.FILE_VALIDATION_FAILED]: SecuritySeverity.MEDIUM,
[SecurityEventType.FILE_EXTENSION_BLOCKED]: SecuritySeverity.HIGH,
[SecurityEventType.FILE_SIZE_EXCEEDED]: SecuritySeverity.MEDIUM,
[SecurityEventType.FILENAME_SANITIZED]: SecuritySeverity.LOW,
[SecurityEventType.CONTENT_XSS_DETECTED]: SecuritySeverity.CRITICAL,
[SecurityEventType.CONTENT_XSS_CLEAN]: SecuritySeverity.INFO,
[SecurityEventType.CLAMAV_TOGGLE_CHANGED]: SecuritySeverity.HIGH,
[SecurityEventType.AUDIT_LOG_ACCESSED]: SecuritySeverity.LOW,
[SecurityEventType.FILE_UPLOAD_SUCCESS]: SecuritySeverity.INFO,
[SecurityEventType.FILE_UPLOAD_BLOCKED]: SecuritySeverity.HIGH,
};
const EVENT_CATEGORY: Record<SecurityEventType, SecurityCategory> = {
[SecurityEventType.MALWARE_DETECTED]: SecurityCategory.MALWARE,
[SecurityEventType.MALWARE_SCAN_CLEAN]: SecurityCategory.MALWARE,
[SecurityEventType.MALWARE_SCAN_ERROR]: SecurityCategory.MALWARE,
[SecurityEventType.MALWARE_SCAN_SKIPPED]: SecurityCategory.MALWARE,
[SecurityEventType.FILE_VALIDATION_FAILED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.FILE_EXTENSION_BLOCKED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.FILE_SIZE_EXCEEDED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.FILENAME_SANITIZED]: SecurityCategory.FILE_VALIDATION,
[SecurityEventType.CONTENT_XSS_DETECTED]: SecurityCategory.FILE_CONTENT_SECURITY,
[SecurityEventType.CONTENT_XSS_CLEAN]: SecurityCategory.FILE_CONTENT_SECURITY,
[SecurityEventType.CLAMAV_TOGGLE_CHANGED]: SecurityCategory.ADMIN,
[SecurityEventType.AUDIT_LOG_ACCESSED]: SecurityCategory.ADMIN,
[SecurityEventType.FILE_UPLOAD_SUCCESS]: SecurityCategory.UPLOAD,
[SecurityEventType.FILE_UPLOAD_BLOCKED]: SecurityCategory.UPLOAD,
};
// ── Configuration ──
const LOGS_DIR = path.resolve(process.cwd(), 'logs');
const AUDIT_LOG_FILE = path.join(LOGS_DIR, 'audit.log');
// ── Core Functions ──
/**
* Log a security event in NDJSON format
*/
export function logSecurityEvent(
eventType: SecurityEventType,
data: Record<string, any> = {},
userId?: string
): string {
const eventId = uuidv4();
const entry = {
timestamp: new Date().toISOString(),
eventId,
eventType,
severity: EVENT_SEVERITY[eventType] || SecuritySeverity.INFO,
category: EVENT_CATEGORY[eventType] || SecurityCategory.UPLOAD,
userId: userId || 'system',
data,
};
try {
// Ensure logs directory exists
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
// Append as NDJSON (one JSON object per line)
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + '\n');
} catch (error) {
console.error('[SecurityLogger] Failed to write audit log:', error);
}
return eventId;
}
/**
* Read and parse audit log entries
*/
export function readAuditLogs(options: {
eventType?: string;
severity?: string;
category?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
} = {}): { entries: any[]; total: number } {
try {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return { entries: [], total: 0 };
}
const content = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8');
let entries = content
.split('\n')
.filter(line => line.trim())
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean)
.reverse(); // Most recent first
// Apply filters
if (options.eventType) {
entries = entries.filter(e => e.eventType === options.eventType);
}
if (options.severity) {
entries = entries.filter(e => e.severity === options.severity);
}
if (options.category) {
entries = entries.filter(e => e.category === options.category);
}
if (options.startDate) {
const start = new Date(options.startDate);
entries = entries.filter(e => new Date(e.timestamp) >= start);
}
if (options.endDate) {
const end = new Date(options.endDate);
entries = entries.filter(e => new Date(e.timestamp) <= end);
}
const total = entries.length;
const offset = options.offset || 0;
const limit = options.limit || 50;
return {
entries: entries.slice(offset, offset + limit),
total,
};
} catch (error) {
console.error('[SecurityLogger] Failed to read audit logs:', error);
return { entries: [], total: 0 };
}
}
/**
* Get audit log statistics
*/
export function getAuditStats(): Record<string, any> {
try {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return { totalEvents: 0, byType: {}, bySeverity: {}, byCategory: {} };
}
const content = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8');
const entries = content
.split('\n')
.filter(line => line.trim())
.map(line => {
try { return JSON.parse(line); } catch { return null; }
})
.filter(Boolean);
const byType: Record<string, number> = {};
const bySeverity: Record<string, number> = {};
const byCategory: Record<string, number> = {};
for (const entry of entries) {
byType[entry.eventType] = (byType[entry.eventType] || 0) + 1;
bySeverity[entry.severity] = (bySeverity[entry.severity] || 0) + 1;
byCategory[entry.category] = (byCategory[entry.category] || 0) + 1;
}
return {
totalEvents: entries.length,
byType,
bySeverity,
byCategory,
oldestEvent: entries[0]?.timestamp,
newestEvent: entries[entries.length - 1]?.timestamp,
};
} catch (error) {
console.error('[SecurityLogger] Failed to compute audit stats:', error);
return { totalEvents: 0, byType: {}, bySeverity: {}, byCategory: {} };
}
}
/**
* Export audit logs as CSV
*/
export function exportAuditLogsCSV(options: {
eventType?: string;
severity?: string;
startDate?: string;
endDate?: string;
} = {}): string {
const { entries } = readAuditLogs({ ...options, limit: 10000 });
const headers = ['timestamp', 'eventId', 'eventType', 'severity', 'category', 'userId', 'data'];
const rows = entries.map(entry => [
entry.timestamp,
entry.eventId,
entry.eventType,
entry.severity,
entry.category,
entry.userId,
JSON.stringify(entry.data || {}),
].join(','));
return [headers.join(','), ...rows].join('\n');
}

52
src/types/clamscan.d.ts vendored Normal file
View File

@ -0,0 +1,52 @@
/**
* Type declarations for the 'clamscan' npm package.
* The package does not ship its own types.
*/
declare module 'clamscan' {
interface ClamScanOptions {
removeInfected?: boolean;
quarantineInfected?: boolean | string;
scanLog?: string | null;
debugMode?: boolean;
fileList?: string | null;
scanRecursively?: boolean;
clamscan?: {
path?: string;
db?: string | null;
scanArchives?: boolean;
active?: boolean;
};
clamdscan?: {
socket?: string | null;
host?: string;
port?: number;
timeout?: number;
localFallback?: boolean;
path?: string;
configFile?: string | null;
multiscan?: boolean;
reloadDb?: boolean;
active?: boolean;
bypassTest?: boolean;
};
preference?: 'clamdscan' | 'clamscan';
}
interface ScanResult {
isInfected: boolean;
file: string;
viruses: string[];
}
class NodeClam {
constructor();
init(options?: ClamScanOptions): Promise<NodeClam>;
isInfected(filePath: string): Promise<ScanResult>;
scanDir(dirPath: string): Promise<{ goodFiles: string[]; badFiles: string[]; errors: Record<string, Error>; viruses: string[] }>;
passthrough(): NodeJS.ReadWriteStream;
getVersion(): Promise<string>;
}
export = NodeClam;
}

View File

@ -16,9 +16,7 @@ export const sanitizeHtml = (html: string): string => {
// By NOT spreading ...whiteList, we explicitly only allow what we define
const options = {
whiteList: {
// Add only specific tags or attributes required by the frontend
'span': ['style', 'class'],
'div': ['style', 'class'],
// Text formatting
'p': ['style', 'class'],
'br': [],
'b': [],
@ -26,16 +24,46 @@ export const sanitizeHtml = (html: string): string => {
'u': [],
'strong': [],
'em': [],
'ul': ['style', 'class'],
'ol': ['style', 'class'],
'li': ['style', 'class'],
's': [],
'strike': [],
'del': [],
'sub': [],
'sup': [],
'mark': [],
'small': [],
// Headings
'h1': ['style', 'class'],
'h2': ['style', 'class'],
'h3': ['style', 'class'],
'h4': ['style', 'class'],
'h5': ['style', 'class'],
'h6': ['style', 'class'],
// Lists
'ul': ['style', 'class'],
'ol': ['style', 'class', 'start', 'type'],
'li': ['style', 'class'],
// Block elements
'blockquote': ['style', 'class'],
'pre': ['style', 'class'],
'code': ['style', 'class'],
'hr': [],
'div': ['style', 'class'],
'span': ['style', 'class'],
// Tables
'table': ['style', 'class', 'width', 'border', 'cellpadding', 'cellspacing'],
'thead': ['style', 'class'],
'tbody': ['style', 'class'],
'tfoot': ['style', 'class'],
'tr': ['style', 'class'],
'th': ['style', 'class', 'colspan', 'rowspan'],
'td': ['style', 'class', 'colspan', 'rowspan'],
'caption': ['style', 'class'],
'colgroup': [],
'col': ['width'],
// Links
'a': ['href', 'title', 'target', 'rel'],
// Images
'img': ['src', 'alt', 'title', 'width', 'height'],
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script']

View File

@ -0,0 +1,65 @@
import { z } from 'zod';
// ── Holiday Schemas ──
export const createHolidaySchema = z.object({
holidayDate: z.string().min(1, 'Holiday date is required'),
holidayName: z.string().min(1, 'Holiday name is required').max(255, 'Holiday name too long'),
description: z.string().max(1000, 'Description too long').optional(),
holidayType: z.enum(['NATIONAL', 'REGIONAL', 'COMPANY', 'OPTIONAL']).optional(),
isRecurring: z.boolean().optional(),
});
export const updateHolidaySchema = createHolidaySchema.partial();
export const holidayParamsSchema = z.object({
holidayId: z.string().uuid('Invalid holiday ID'),
});
export const calendarParamsSchema = z.object({
year: z.string().regex(/^\d{4}$/, 'Year must be a 4-digit number'),
});
// ── Configuration Schemas ──
export const configKeyParamsSchema = z.object({
configKey: z.string().min(1, 'Config key is required').max(100, 'Config key too long'),
});
export const updateConfigSchema = z.object({
configValue: z.union([z.string(), z.number(), z.boolean(), z.record(z.any())]),
});
// ── User Role Management Schemas ──
export const assignRoleSchema = z.object({
email: z.string().email('Valid email is required'),
role: z.enum(['USER', 'MANAGEMENT', 'ADMIN'], {
errorMap: () => ({ message: 'Role must be USER, MANAGEMENT, or ADMIN' }),
}),
});
export const updateRoleSchema = z.object({
role: z.enum(['USER', 'MANAGEMENT', 'ADMIN'], {
errorMap: () => ({ message: 'Role must be USER, MANAGEMENT, or ADMIN' }),
}),
});
export const userIdParamsSchema = z.object({
userId: z.string().uuid('Invalid user ID'),
});
// ── Activity Type Schemas ──
export const createActivityTypeSchema = z.object({
title: z.string().min(1, 'Title is required').max(255, 'Title too long'),
itemCode: z.string().max(50, 'Item code too long').optional(),
taxationType: z.string().max(50, 'Taxation type too long').optional(),
sapRefNo: z.string().max(50, 'SAP ref number too long').optional(),
});
export const updateActivityTypeSchema = createActivityTypeSchema.partial();
export const activityTypeParamsSchema = z.object({
activityTypeId: z.string().uuid('Invalid activity type ID'),
});

View File

@ -0,0 +1,49 @@
import { z } from 'zod';
// ── Request ID Params (shared across all /:requestId routes) ──
export const requestIdParamsSchema = z.object({
requestId: z.string().uuid('Invalid request ID'),
});
// ── Create Claim Schema ──
export const createClaimSchema = z.object({
dealerId: z.string().optional(),
activityTypeId: z.string().uuid('Invalid activity type ID').optional(),
title: z.string().min(1, 'Title is required').max(500, 'Title too long').optional(),
description: z.string().max(5000, 'Description too long').optional(),
}).passthrough(); // Allow additional fields since dealer claims have flexible structure
// ── IO Details Schema ──
export const updateIOSchema = z.object({
ioNumber: z.string().min(1, 'IO number is required').max(50, 'IO number too long'),
amount: z.number().positive('Amount must be positive').optional(),
requestNumber: z.string().max(50).optional(),
}).passthrough();
// ── E-Invoice Schema ──
export const updateEInvoiceSchema = z.object({
irnNumber: z.string().max(100, 'IRN number too long').optional(),
ackNumber: z.string().max(100, 'Acknowledgement number too long').optional(),
invoiceNumber: z.string().max(100, 'Invoice number too long').optional(),
invoiceDate: z.string().optional(),
}).passthrough();
// ── Credit Note Schema ──
export const sendCreditNoteSchema = z.object({
creditNoteNumber: z.string().max(100, 'Credit note number too long').optional(),
creditNoteDate: z.string().optional(),
creditNoteAmount: z.number().positive('Amount must be positive').optional(),
}).passthrough();
// ── SAP Test Schema ──
export const testSapBlockSchema = z.object({
ioNumber: z.string().min(1, 'IO number is required'),
amount: z.number().positive('Amount must be positive'),
requestNumber: z.string().optional(),
});