malware scan and sanitization implemetation done
This commit is contained in:
parent
9fd9c218df
commit
dbb088dbcc
@ -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
64
build/assets/index-BUjalNx7.js
Normal file
64
build/assets/index-BUjalNx7.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-Bq5QM20V.css
Normal file
1
build/assets/index-Bq5QM20V.css
Normal file
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
@ -13,15 +13,15 @@
|
|||||||
<!-- Preload essential fonts and icons -->
|
<!-- Preload essential fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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/charts-vendor-BVfwAPj-.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CYvDqP9X.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/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/socket-vendor-TjCxX7sJ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B_rK4TXr.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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -50,6 +50,25 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@ -63,6 +82,8 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
||||||
DB_NAME: ${DB_NAME:-re_workflow_db}
|
DB_NAME: ${DB_NAME:-re_workflow_db}
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
|
CLAMD_HOST: clamav
|
||||||
|
CLAMD_PORT: 3310
|
||||||
PORT: 5000
|
PORT: 5000
|
||||||
# Loki for logging
|
# Loki for logging
|
||||||
LOKI_HOST: http://loki:3100
|
LOKI_HOST: http://loki:3100
|
||||||
@ -215,6 +236,8 @@ volumes:
|
|||||||
name: re_postgres_data
|
name: re_postgres_data
|
||||||
redis_data:
|
redis_data:
|
||||||
name: re_redis_data
|
name: re_redis_data
|
||||||
|
clamav_data:
|
||||||
|
name: re_clamav_data
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
name: re_prometheus_data
|
name: re_prometheus_data
|
||||||
loki_data:
|
loki_data:
|
||||||
|
|||||||
@ -39,6 +39,25 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@ -52,6 +71,8 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
DB_PASSWORD: ${DB_PASSWORD:-Admin@123}
|
||||||
DB_NAME: ${DB_NAME:-re_workflow_db}
|
DB_NAME: ${DB_NAME:-re_workflow_db}
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
|
CLAMD_HOST: clamav
|
||||||
|
CLAMD_PORT: 3310
|
||||||
PORT: 5000
|
PORT: 5000
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
@ -76,6 +97,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
clamav_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
re_workflow_network:
|
re_workflow_network:
|
||||||
|
|||||||
194
package-lock.json
generated
194
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bullmq": "^5.63.0",
|
"bullmq": "^5.63.0",
|
||||||
|
"clamscan": "^2.4.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"puppeteer": "^24.37.2",
|
"puppeteer": "^24.37.2",
|
||||||
|
"sanitize-html": "^2.17.1",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
@ -59,6 +61,7 @@
|
|||||||
"@types/passport": "^1.0.16",
|
"@types/passport": "^1.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||||
@ -3940,6 +3943,16 @@
|
|||||||
"node": ">= 0.12"
|
"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": {
|
"node_modules/@types/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
@ -5316,6 +5329,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@ -5761,7 +5783,6 @@
|
|||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -5888,6 +5909,61 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"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": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@ -6206,7 +6294,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@ -7683,6 +7770,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/http_ece": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
@ -7995,6 +8101,15 @@
|
|||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@ -9426,6 +9541,24 @@
|
|||||||
"url": "https://github.com/sponsors/raouldeheer"
|
"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": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@ -9915,6 +10048,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@ -10257,6 +10396,34 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@ -10898,6 +11065,20 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
@ -11451,6 +11632,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/source-map-support": {
|
||||||
"version": "0.5.13",
|
"version": "0.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bullmq": "^5.63.0",
|
"bullmq": "^5.63.0",
|
||||||
|
"clamscan": "^2.4.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
@ -51,6 +52,7 @@
|
|||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"puppeteer": "^24.37.2",
|
"puppeteer": "^24.37.2",
|
||||||
|
"sanitize-html": "^2.17.1",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
@ -73,6 +75,7 @@
|
|||||||
"@types/passport": "^1.0.16",
|
"@types/passport": "^1.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
||||||
@ -94,4 +97,4 @@
|
|||||||
"node": ">=22.0.0",
|
"node": ">=22.0.0",
|
||||||
"npm": ">=10.0.0"
|
"npm": ">=10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/app.ts
17
src/app.ts
@ -13,6 +13,8 @@ import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.mi
|
|||||||
import routes from './routes/index';
|
import routes from './routes/index';
|
||||||
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
||||||
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
||||||
|
import { sanitizationMiddleware } from './middlewares/sanitization.middleware';
|
||||||
|
import { rateLimiter } from './middlewares/rateLimiter.middleware';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Load environment variables from .env file first
|
// 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.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, 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
|
// Logging middleware
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
|
|
||||||
@ -140,6 +148,15 @@ app.use('/api/v1', routes);
|
|||||||
ensureUploadDir();
|
ensureUploadDir();
|
||||||
app.use('/uploads', authenticateToken, express.static(UPLOAD_DIR));
|
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)
|
// 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> => {
|
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
423
src/middlewares/malwareScan.middleware.ts
Normal file
423
src/middlewares/malwareScan.middleware.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -180,6 +180,58 @@ export const queueJobProcessingRate = new client.Gauge({
|
|||||||
registers: [register],
|
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
|
// MIDDLEWARE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -271,10 +323,10 @@ export async function metricsHandler(_req: Request, res: Response): Promise<void
|
|||||||
*/
|
*/
|
||||||
export function createMetricsRouter(): Router {
|
export function createMetricsRouter(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Metrics endpoint (GET /metrics)
|
// Metrics endpoint (GET /metrics)
|
||||||
router.get('/metrics', metricsHandler);
|
router.get('/metrics', metricsHandler);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General rate limiter — applied globally to all API routes.
|
||||||
|
* Configurable via environment variables.
|
||||||
|
*/
|
||||||
export const rateLimiter = rateLimit({
|
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),
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||||
message: {
|
message: {
|
||||||
success: false,
|
success: false,
|
||||||
@ -10,4 +14,53 @@ export const rateLimiter = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
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,
|
||||||
});
|
});
|
||||||
162
src/middlewares/sanitization.middleware.ts
Normal file
162
src/middlewares/sanitization.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,6 +1,21 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticateToken } from '@middlewares/auth.middleware';
|
import { authenticateToken } from '@middlewares/auth.middleware';
|
||||||
import { requireAdmin } from '@middlewares/authorization.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 {
|
import {
|
||||||
getAllHolidays,
|
getAllHolidays,
|
||||||
getHolidayCalendar,
|
getHolidayCalendar,
|
||||||
@ -44,7 +59,7 @@ router.get('/holidays', getAllHolidays);
|
|||||||
* @params year
|
* @params year
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.get('/holidays/calendar/:year', getHolidayCalendar);
|
router.get('/holidays/calendar/:year', validateParams(calendarParamsSchema), getHolidayCalendar);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/holidays
|
* @route POST /api/admin/holidays
|
||||||
@ -52,7 +67,7 @@ router.get('/holidays/calendar/:year', getHolidayCalendar);
|
|||||||
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
|
* @body { holidayDate, holidayName, description, holidayType, isRecurring, ... }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/holidays', createHoliday);
|
router.post('/holidays', validateBody(createHolidaySchema), createHoliday);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/admin/holidays/:holidayId
|
* @route PUT /api/admin/holidays/:holidayId
|
||||||
@ -61,7 +76,7 @@ router.post('/holidays', createHoliday);
|
|||||||
* @body Holiday fields to update
|
* @body Holiday fields to update
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.put('/holidays/:holidayId', updateHoliday);
|
router.put('/holidays/:holidayId', validateParams(holidayParamsSchema), validateBody(updateHolidaySchema), updateHoliday);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route DELETE /api/admin/holidays/:holidayId
|
* @route DELETE /api/admin/holidays/:holidayId
|
||||||
@ -69,7 +84,7 @@ router.put('/holidays/:holidayId', updateHoliday);
|
|||||||
* @params holidayId
|
* @params holidayId
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.delete('/holidays/:holidayId', deleteHoliday);
|
router.delete('/holidays/:holidayId', validateParams(holidayParamsSchema), deleteHoliday);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/holidays/bulk-import
|
* @route POST /api/admin/holidays/bulk-import
|
||||||
@ -96,7 +111,7 @@ router.get('/configurations', getAllConfigurations);
|
|||||||
* @body { configValue }
|
* @body { configValue }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.put('/configurations/:configKey', updateConfiguration);
|
router.put('/configurations/:configKey', validateParams(configKeyParamsSchema), validateBody(updateConfigSchema), updateConfiguration);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/configurations/:configKey/reset
|
* @route POST /api/admin/configurations/:configKey/reset
|
||||||
@ -104,7 +119,7 @@ router.put('/configurations/:configKey', updateConfiguration);
|
|||||||
* @params configKey
|
* @params configKey
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/configurations/:configKey/reset', resetConfiguration);
|
router.post('/configurations/:configKey/reset', validateParams(configKeyParamsSchema), resetConfiguration);
|
||||||
|
|
||||||
// ==================== User Role Management Routes (RBAC) ====================
|
// ==================== User Role Management Routes (RBAC) ====================
|
||||||
|
|
||||||
@ -114,7 +129,7 @@ router.post('/configurations/:configKey/reset', resetConfiguration);
|
|||||||
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
* @body { email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/users/assign-role', assignRoleByEmail);
|
router.post('/users/assign-role', validateBody(assignRoleSchema), assignRoleByEmail);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/admin/users/:userId/role
|
* @route PUT /api/admin/users/:userId/role
|
||||||
@ -123,7 +138,7 @@ router.post('/users/assign-role', assignRoleByEmail);
|
|||||||
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
* @body { role: 'USER' | 'MANAGEMENT' | 'ADMIN' }
|
||||||
* @access 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
|
* @route GET /api/admin/users/by-role
|
||||||
@ -156,7 +171,7 @@ router.get('/activity-types', getAllActivityTypes);
|
|||||||
* @params activityTypeId
|
* @params activityTypeId
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.get('/activity-types/:activityTypeId', getActivityTypeById);
|
router.get('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), getActivityTypeById);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/admin/activity-types
|
* @route POST /api/admin/activity-types
|
||||||
@ -164,7 +179,7 @@ router.get('/activity-types/:activityTypeId', getActivityTypeById);
|
|||||||
* @body { title, itemCode?, taxationType?, sapRefNo? }
|
* @body { title, itemCode?, taxationType?, sapRefNo? }
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.post('/activity-types', createActivityType);
|
router.post('/activity-types', validateBody(createActivityTypeSchema), createActivityType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route PUT /api/admin/activity-types/:activityTypeId
|
* @route PUT /api/admin/activity-types/:activityTypeId
|
||||||
@ -173,7 +188,7 @@ router.post('/activity-types', createActivityType);
|
|||||||
* @body Activity type fields to update
|
* @body Activity type fields to update
|
||||||
* @access Admin
|
* @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
|
* @route DELETE /api/admin/activity-types/:activityTypeId
|
||||||
@ -181,7 +196,6 @@ router.put('/activity-types/:activityTypeId', updateActivityType);
|
|||||||
* @params activityTypeId
|
* @params activityTypeId
|
||||||
* @access Admin
|
* @access Admin
|
||||||
*/
|
*/
|
||||||
router.delete('/activity-types/:activityTypeId', deleteActivityType);
|
router.delete('/activity-types/:activityTypeId', validateParams(activityTypeParamsSchema), deleteActivityType);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
166
src/routes/antivirus.routes.ts
Normal file
166
src/routes/antivirus.routes.ts
Normal 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;
|
||||||
@ -3,6 +3,15 @@ import { DealerClaimController } from '../controllers/dealerClaim.controller';
|
|||||||
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
|
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
|
||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { asyncHandler } from '../middlewares/errorHandler.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 multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@ -46,63 +55,63 @@ router.post('/', authenticateToken, asyncHandler(dealerClaimController.createCla
|
|||||||
* @desc Get claim details
|
* @desc Get claim details
|
||||||
* @access Private
|
* @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
|
* @route POST /api/v1/dealer-claims/:requestId/proposal
|
||||||
* @desc Submit dealer proposal (Step 1)
|
* @desc Submit dealer proposal (Step 1)
|
||||||
* @access Private
|
* @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
|
* @route POST /api/v1/dealer-claims/:requestId/completion
|
||||||
* @desc Submit completion documents (Step 5)
|
* @desc Submit completion documents (Step 5)
|
||||||
* @access Private
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.post('/:requestId/completion', authenticateToken, upload.fields([
|
router.post('/:requestId/completion', authenticateToken, validateParams(requestIdParamsSchema), upload.fields([
|
||||||
{ name: 'completionDocuments', maxCount: 10 },
|
{ name: 'completionDocuments', maxCount: 10 },
|
||||||
{ name: 'activityPhotos', maxCount: 10 },
|
{ name: 'activityPhotos', maxCount: 10 },
|
||||||
{ name: 'invoicesReceipts', maxCount: 10 },
|
{ name: 'invoicesReceipts', maxCount: 10 },
|
||||||
{ name: 'attendanceSheet', maxCount: 1 },
|
{ name: 'attendanceSheet', maxCount: 1 },
|
||||||
]), asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
|
]), malwareScanMultipleMiddleware, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController)));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/v1/dealer-claims/:requestId/io/validate
|
* @route GET /api/v1/dealer-claims/:requestId/io/validate
|
||||||
* @desc Validate/Fetch IO details from SAP (returns dummy data for now)
|
* @desc Validate/Fetch IO details from SAP (returns dummy data for now)
|
||||||
* @access Private
|
* @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
|
* @route PUT /api/v1/dealer-claims/:requestId/io
|
||||||
* @desc Block IO amount in SAP and store in database
|
* @desc Block IO amount in SAP and store in database
|
||||||
* @access Private
|
* @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
|
* @route PUT /api/v1/dealer-claims/:requestId/e-invoice
|
||||||
* @desc Update e-invoice details (Step 7)
|
* @desc Update e-invoice details (Step 7)
|
||||||
* @access Private
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
|
router.put('/:requestId/e-invoice', authenticateToken, validateParams(requestIdParamsSchema), validateBody(updateEInvoiceSchema), asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController)));
|
||||||
router.get('/:requestId/e-invoice/pdf', authenticateToken, asyncHandler(dealerClaimController.downloadInvoicePdf.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
|
* @route PUT /api/v1/dealer-claims/:requestId/credit-note
|
||||||
* @desc Update credit note details (Step 8)
|
* @desc Update credit note details (Step 8)
|
||||||
* @access Private
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.get('/:requestId/e-invoice/csv', authenticateToken, asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
|
router.get('/:requestId/e-invoice/csv', authenticateToken, validateParams(requestIdParamsSchema), asyncHandler(dealerClaimController.downloadInvoiceCsv.bind(dealerClaimController)));
|
||||||
router.post('/:requestId/credit-note', authenticateToken, upload.single('creditNoteFile'), asyncHandler(dealerClaimController.updateCreditNote.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
|
* @route POST /api/v1/dealer-claims/:requestId/credit-note/send
|
||||||
* @desc Send credit note to dealer and auto-approve Step 8
|
* @desc Send credit note to dealer and auto-approve Step 8
|
||||||
* @access Private
|
* @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
|
* @route POST /api/v1/dealer-claims/test/sap-block
|
||||||
@ -110,7 +119,6 @@ router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(deal
|
|||||||
* @access Private
|
* @access Private
|
||||||
* @body { ioNumber: string, amount: number, requestNumber?: string }
|
* @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;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
|
import { malwareScanMiddleware } from '../middlewares/malwareScan.middleware';
|
||||||
import { DocumentController } from '../controllers/document.controller';
|
import { DocumentController } from '../controllers/document.controller';
|
||||||
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
|
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
|
||||||
|
|
||||||
@ -20,8 +21,7 @@ const router = Router();
|
|||||||
const controller = new DocumentController();
|
const controller = new DocumentController();
|
||||||
|
|
||||||
// multipart/form-data: file, requestId, optional category
|
// 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;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,10 @@ import templateRoutes from './template.routes';
|
|||||||
import dealerRoutes from './dealer.routes';
|
import dealerRoutes from './dealer.routes';
|
||||||
import dmsWebhookRoutes from './dmsWebhook.routes';
|
import dmsWebhookRoutes from './dmsWebhook.routes';
|
||||||
import apiTokenRoutes from './apiToken.routes';
|
import apiTokenRoutes from './apiToken.routes';
|
||||||
|
import antivirusRoutes from './antivirus.routes';
|
||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { requireAdmin } from '../middlewares/authorization.middleware';
|
import { requireAdmin } from '../middlewares/authorization.middleware';
|
||||||
|
import { authLimiter, uploadLimiter, adminLimiter } from '../middlewares/rateLimiter.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -32,15 +34,15 @@ router.get('/health', (_req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// API routes
|
// API routes (with per-endpoint rate limiters on sensitive routes)
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authLimiter, authRoutes);
|
||||||
router.use('/config', configRoutes); // System configuration (public)
|
router.use('/config', configRoutes); // System configuration (public)
|
||||||
router.use('/workflows', workflowRoutes);
|
router.use('/workflows', workflowRoutes);
|
||||||
router.use('/users', userRoutes);
|
router.use('/users', userRoutes);
|
||||||
router.use('/user/preferences', userPreferenceRoutes); // User preferences (authenticated)
|
router.use('/user/preferences', userPreferenceRoutes); // User preferences (authenticated)
|
||||||
router.use('/documents', documentRoutes);
|
router.use('/documents', uploadLimiter, documentRoutes);
|
||||||
router.use('/tat', tatRoutes);
|
router.use('/tat', tatRoutes);
|
||||||
router.use('/admin', adminRoutes);
|
router.use('/admin', adminLimiter, adminRoutes);
|
||||||
router.use('/debug', authenticateToken, requireAdmin, debugRoutes);
|
router.use('/debug', authenticateToken, requireAdmin, debugRoutes);
|
||||||
router.use('/dashboard', dashboardRoutes);
|
router.use('/dashboard', dashboardRoutes);
|
||||||
router.use('/notifications', notificationRoutes);
|
router.use('/notifications', notificationRoutes);
|
||||||
@ -52,6 +54,7 @@ router.use('/templates', templateRoutes);
|
|||||||
router.use('/dealers', dealerRoutes);
|
router.use('/dealers', dealerRoutes);
|
||||||
router.use('/webhooks/dms', dmsWebhookRoutes);
|
router.use('/webhooks/dms', dmsWebhookRoutes);
|
||||||
router.use('/api-tokens', apiTokenRoutes);
|
router.use('/api-tokens', apiTokenRoutes);
|
||||||
|
router.use('/antivirus', antivirusRoutes);
|
||||||
|
|
||||||
// Add other route modules as they are implemented
|
// Add other route modules as they are implemented
|
||||||
// router.use('/approvals', approvalRoutes);
|
// router.use('/approvals', approvalRoutes);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { requireParticipantTypes } from '../middlewares/authorization.middleware
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { malwareScanMultipleMiddleware } from '../middlewares/malwareScan.middleware';
|
||||||
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
|
import { ensureUploadDir, UPLOAD_DIR } from '../config/storage';
|
||||||
import { notificationService } from '../services/notification.service';
|
import { notificationService } from '../services/notification.service';
|
||||||
import { Activity } from '@models/Activity';
|
import { Activity } from '@models/Activity';
|
||||||
@ -102,6 +103,7 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
|
|||||||
router.post('/multipart',
|
router.post('/multipart',
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
upload.array('files'),
|
upload.array('files'),
|
||||||
|
malwareScanMultipleMiddleware,
|
||||||
asyncHandler(workflowController.createWorkflowMultipart.bind(workflowController))
|
asyncHandler(workflowController.createWorkflowMultipart.bind(workflowController))
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -129,6 +131,7 @@ router.put('/:id/multipart',
|
|||||||
authenticateToken,
|
authenticateToken,
|
||||||
validateParams(workflowParamsSchema),
|
validateParams(workflowParamsSchema),
|
||||||
upload.array('files'),
|
upload.array('files'),
|
||||||
|
malwareScanMultipleMiddleware,
|
||||||
asyncHandler(workflowController.updateWorkflowMultipart.bind(workflowController))
|
asyncHandler(workflowController.updateWorkflowMultipart.bind(workflowController))
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -218,6 +221,7 @@ router.post('/:id/work-notes',
|
|||||||
authenticateToken,
|
authenticateToken,
|
||||||
validateParams(workflowParamsSchema),
|
validateParams(workflowParamsSchema),
|
||||||
noteUpload.array('files'),
|
noteUpload.array('files'),
|
||||||
|
malwareScanMultipleMiddleware,
|
||||||
asyncHandler(workNoteController.create.bind(workNoteController))
|
asyncHandler(workNoteController.create.bind(workNoteController))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
227
src/services/clamav/clamavScanWrapper.ts
Normal file
227
src/services/clamav/clamavScanWrapper.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
162
src/services/clamav/clamavToggleManager.ts
Normal file
162
src/services/clamav/clamavToggleManager.ts
Normal 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);
|
||||||
|
}
|
||||||
205
src/services/fileUpload/contentXSSScanner.ts
Normal file
205
src/services/fileUpload/contentXSSScanner.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
238
src/services/fileUpload/fileValidationService.ts
Normal file
238
src/services/fileUpload/fileValidationService.ts
Normal 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;
|
||||||
|
}
|
||||||
262
src/services/logging/securityEventLogger.ts
Normal file
262
src/services/logging/securityEventLogger.ts
Normal 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
52
src/types/clamscan.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@ -16,9 +16,7 @@ export const sanitizeHtml = (html: string): string => {
|
|||||||
// By NOT spreading ...whiteList, we explicitly only allow what we define
|
// By NOT spreading ...whiteList, we explicitly only allow what we define
|
||||||
const options = {
|
const options = {
|
||||||
whiteList: {
|
whiteList: {
|
||||||
// Add only specific tags or attributes required by the frontend
|
// Text formatting
|
||||||
'span': ['style', 'class'],
|
|
||||||
'div': ['style', 'class'],
|
|
||||||
'p': ['style', 'class'],
|
'p': ['style', 'class'],
|
||||||
'br': [],
|
'br': [],
|
||||||
'b': [],
|
'b': [],
|
||||||
@ -26,16 +24,46 @@ export const sanitizeHtml = (html: string): string => {
|
|||||||
'u': [],
|
'u': [],
|
||||||
'strong': [],
|
'strong': [],
|
||||||
'em': [],
|
'em': [],
|
||||||
'ul': ['style', 'class'],
|
's': [],
|
||||||
'ol': ['style', 'class'],
|
'strike': [],
|
||||||
'li': ['style', 'class'],
|
'del': [],
|
||||||
|
'sub': [],
|
||||||
|
'sup': [],
|
||||||
|
'mark': [],
|
||||||
|
'small': [],
|
||||||
|
// Headings
|
||||||
'h1': ['style', 'class'],
|
'h1': ['style', 'class'],
|
||||||
'h2': ['style', 'class'],
|
'h2': ['style', 'class'],
|
||||||
'h3': ['style', 'class'],
|
'h3': ['style', 'class'],
|
||||||
'h4': ['style', 'class'],
|
'h4': ['style', 'class'],
|
||||||
'h5': ['style', 'class'],
|
'h5': ['style', 'class'],
|
||||||
'h6': ['style', 'class'],
|
'h6': ['style', 'class'],
|
||||||
|
// Lists
|
||||||
|
'ul': ['style', 'class'],
|
||||||
|
'ol': ['style', 'class', 'start', 'type'],
|
||||||
|
'li': ['style', 'class'],
|
||||||
|
// Block elements
|
||||||
'blockquote': ['style', 'class'],
|
'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,
|
stripIgnoreTag: true,
|
||||||
stripIgnoreTagBody: ['script']
|
stripIgnoreTagBody: ['script']
|
||||||
|
|||||||
65
src/validators/admin.validator.ts
Normal file
65
src/validators/admin.validator.ts
Normal 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'),
|
||||||
|
});
|
||||||
49
src/validators/dealerClaim.validator.ts
Normal file
49
src/validators/dealerClaim.validator.ts
Normal 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(),
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user