pompelmi 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/bin/pompelmi.js +66 -3
- package/docker/Dockerfile +18 -0
- package/docker/README.md +62 -0
- package/docker/entrypoint.sh +148 -0
- package/package.json +1 -1
- package/packages/vscode/README.md +61 -0
- package/packages/vscode/package.json +70 -0
- package/packages/vscode/src/extension.js +109 -0
- package/src/ClamAVScanner.js +40 -0
- package/src/MultiEngine.js +190 -0
- package/src/Policy.js +154 -0
- package/src/ScanCache.js +136 -0
- package/src/Scorecard.js +85 -0
- package/src/Watcher.js +58 -23
- package/src/index.js +5 -1
- package/src/index.mjs +4 -0
- package/types/index.d.ts +254 -2
package/src/ClamAVScanner.js
CHANGED
|
@@ -137,4 +137,44 @@ async function scanDirectory(dirPath, options = {}) {
|
|
|
137
137
|
return { clean, malicious, errors };
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
async function* streamDirectory(dirPath, options = {}) {
|
|
141
|
+
if (typeof dirPath !== 'string') {
|
|
142
|
+
throw new Error('dirPath must be a string');
|
|
143
|
+
}
|
|
144
|
+
if (!fs.existsSync(dirPath)) {
|
|
145
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const entries = fs.readdirSync(dirPath, { recursive: true });
|
|
149
|
+
const files = entries
|
|
150
|
+
.map(entry => path.join(dirPath, entry))
|
|
151
|
+
.filter(fullPath => fs.statSync(fullPath).isFile());
|
|
152
|
+
|
|
153
|
+
const total = files.length;
|
|
154
|
+
let scanned = 0;
|
|
155
|
+
const summary = { clean: [], malicious: [], errors: [] };
|
|
156
|
+
|
|
157
|
+
for (const filePath of files) {
|
|
158
|
+
let verdict;
|
|
159
|
+
try {
|
|
160
|
+
verdict = await scan(filePath, options);
|
|
161
|
+
} catch {
|
|
162
|
+
verdict = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
scanned++;
|
|
166
|
+
|
|
167
|
+
if (verdict === Verdict.Clean) summary.clean.push(filePath);
|
|
168
|
+
else if (verdict === Verdict.Malicious) summary.malicious.push(filePath);
|
|
169
|
+
else summary.errors.push(filePath);
|
|
170
|
+
|
|
171
|
+
yield { type: 'progress', scanned, total, file: filePath };
|
|
172
|
+
yield { type: 'result', file: filePath, verdict };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
yield { type: 'complete', summary };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
scanDirectory.stream = streamDirectory;
|
|
179
|
+
|
|
140
180
|
module.exports = { scan, scanBuffer, scanStream, scanDirectory };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { scan, scanBuffer } = require('./ClamAVScanner.js');
|
|
8
|
+
const { Verdict } = require('./verdicts.js');
|
|
9
|
+
|
|
10
|
+
// ── VirusTotal helpers ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function vtRequest(method, urlStr, headers, body) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const url = new URL(urlStr);
|
|
15
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
16
|
+
const opts = {
|
|
17
|
+
method,
|
|
18
|
+
hostname: url.hostname,
|
|
19
|
+
path: url.pathname + url.search,
|
|
20
|
+
headers,
|
|
21
|
+
};
|
|
22
|
+
const req = mod.request(opts, (res) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
res.on('data', c => chunks.push(c));
|
|
25
|
+
res.on('end', () => {
|
|
26
|
+
try {
|
|
27
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
28
|
+
} catch {
|
|
29
|
+
resolve({});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
req.on('error', reject);
|
|
34
|
+
if (body) req.write(body);
|
|
35
|
+
req.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Multipart form-data upload for VirusTotal /files endpoint
|
|
40
|
+
function vtUpload(apiKey, buffer) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const boundary = crypto.randomBytes(16).toString('hex');
|
|
43
|
+
const CRLF = '\r\n';
|
|
44
|
+
const pre = Buffer.from(
|
|
45
|
+
`--${boundary}${CRLF}` +
|
|
46
|
+
`Content-Disposition: form-data; name="file"; filename="upload"${CRLF}` +
|
|
47
|
+
`Content-Type: application/octet-stream${CRLF}${CRLF}`
|
|
48
|
+
);
|
|
49
|
+
const post = Buffer.from(`${CRLF}--${boundary}--${CRLF}`);
|
|
50
|
+
const body = Buffer.concat([pre, buffer, post]);
|
|
51
|
+
|
|
52
|
+
const opts = {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
hostname: 'www.virustotal.com',
|
|
55
|
+
path: '/api/v3/files',
|
|
56
|
+
headers: {
|
|
57
|
+
'x-apikey': apiKey,
|
|
58
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
59
|
+
'Content-Length': body.length,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const req = https.request(opts, (res) => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
res.on('data', c => chunks.push(c));
|
|
66
|
+
res.on('end', () => {
|
|
67
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
68
|
+
catch { resolve({}); }
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
req.on('error', reject);
|
|
72
|
+
req.write(body);
|
|
73
|
+
req.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function vtScanBuffer(apiKey, buffer, threshold = 1) {
|
|
78
|
+
try {
|
|
79
|
+
// Upload file
|
|
80
|
+
const upload = await vtUpload(apiKey, buffer);
|
|
81
|
+
const id = upload && upload.data && upload.data.id;
|
|
82
|
+
if (!id) return { name: 'virustotal', verdict: Verdict.ScanError, detections: 0, error: 'no analysis id' };
|
|
83
|
+
|
|
84
|
+
// Poll for result (up to 60s)
|
|
85
|
+
for (let i = 0; i < 12; i++) {
|
|
86
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
87
|
+
const report = await vtRequest('GET',
|
|
88
|
+
`https://www.virustotal.com/api/v3/analyses/${id}`,
|
|
89
|
+
{ 'x-apikey': apiKey }, null
|
|
90
|
+
);
|
|
91
|
+
const status = report && report.data && report.data.attributes && report.data.attributes.status;
|
|
92
|
+
if (status === 'completed') {
|
|
93
|
+
const stats = report.data.attributes.stats || {};
|
|
94
|
+
const detections = (stats.malicious || 0) + (stats.suspicious || 0);
|
|
95
|
+
const verdict = detections >= threshold ? Verdict.Malicious : Verdict.Clean;
|
|
96
|
+
return { name: 'virustotal', verdict, detections };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { name: 'virustotal', verdict: Verdict.ScanError, detections: 0, error: 'timeout' };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { name: 'virustotal', verdict: Verdict.ScanError, detections: 0, error: err.message };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── ClamAV engine wrapper ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async function clamavScanFile(config, filePath) {
|
|
108
|
+
try {
|
|
109
|
+
const verdict = await scan(filePath, config);
|
|
110
|
+
return { name: 'clamav', verdict };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { name: 'clamav', verdict: Verdict.ScanError, error: err.message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function clamavScanBuffer(config, buffer) {
|
|
117
|
+
try {
|
|
118
|
+
const verdict = await scanBuffer(buffer, config);
|
|
119
|
+
return { name: 'clamav', verdict };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { name: 'clamav', verdict: Verdict.ScanError, error: err.message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Consensus logic ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function applyConsensus(results, consensus) {
|
|
128
|
+
const verdicts = results.map(r => r.verdict);
|
|
129
|
+
const malCount = verdicts.filter(v => v === Verdict.Malicious).length;
|
|
130
|
+
const total = verdicts.length;
|
|
131
|
+
|
|
132
|
+
let final;
|
|
133
|
+
if (consensus === 'all') {
|
|
134
|
+
final = malCount === total ? Verdict.Malicious : Verdict.Clean;
|
|
135
|
+
} else if (consensus === 'majority') {
|
|
136
|
+
final = malCount > total / 2 ? Verdict.Malicious : Verdict.Clean;
|
|
137
|
+
} else {
|
|
138
|
+
// 'any' (default)
|
|
139
|
+
final = malCount > 0 ? Verdict.Malicious : Verdict.Clean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// If no conclusive result but errors exist, propagate ScanError
|
|
143
|
+
if (final === Verdict.Clean && verdicts.every(v => v === Verdict.ScanError)) {
|
|
144
|
+
final = Verdict.ScanError;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return final;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Public factory ──────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function createMultiEngine(config = {}) {
|
|
153
|
+
const { engines = [], consensus = 'any' } = config;
|
|
154
|
+
|
|
155
|
+
async function _runEnginesOnBuffer(buffer) {
|
|
156
|
+
return Promise.all(engines.map(async (engine) => {
|
|
157
|
+
if (engine.type === 'virustotal') {
|
|
158
|
+
if (!engine.apiKey) return { name: 'virustotal', verdict: Verdict.ScanError, error: 'no apiKey' };
|
|
159
|
+
return vtScanBuffer(engine.apiKey, buffer, engine.threshold ?? 1);
|
|
160
|
+
}
|
|
161
|
+
// clamav
|
|
162
|
+
return clamavScanBuffer(engine, buffer);
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function scanMultiBuffer(buffer) {
|
|
167
|
+
if (!Buffer.isBuffer(buffer)) throw new Error('buffer must be a Buffer');
|
|
168
|
+
const engineResults = await _runEnginesOnBuffer(buffer);
|
|
169
|
+
const verdict = applyConsensus(engineResults, consensus);
|
|
170
|
+
return { verdict, consensus, engines: engineResults };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function scanMultiFile(filePath) {
|
|
174
|
+
if (typeof filePath !== 'string') throw new Error('filePath must be a string');
|
|
175
|
+
const buffer = fs.readFileSync(filePath);
|
|
176
|
+
const engineResults = await Promise.all(engines.map(async (engine) => {
|
|
177
|
+
if (engine.type === 'virustotal') {
|
|
178
|
+
if (!engine.apiKey) return { name: 'virustotal', verdict: Verdict.ScanError, error: 'no apiKey' };
|
|
179
|
+
return vtScanBuffer(engine.apiKey, buffer, engine.threshold ?? 1);
|
|
180
|
+
}
|
|
181
|
+
return clamavScanFile(engine, filePath);
|
|
182
|
+
}));
|
|
183
|
+
const verdict = applyConsensus(engineResults, consensus);
|
|
184
|
+
return { verdict, consensus, engines: engineResults };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { scan: scanMultiFile, scanBuffer: scanMultiBuffer };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { createMultiEngine };
|
package/src/Policy.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { scanBuffer } = require('./ClamAVScanner.js');
|
|
5
|
+
const { Verdict } = require('./verdicts.js');
|
|
6
|
+
|
|
7
|
+
const REASONS = {
|
|
8
|
+
FILE_TOO_LARGE: 'file_too_large',
|
|
9
|
+
MIME_NOT_ALLOWED: 'mime_not_allowed',
|
|
10
|
+
EXT_NOT_ALLOWED: 'extension_not_allowed',
|
|
11
|
+
ENCRYPTED: 'encrypted_archive',
|
|
12
|
+
VIRUS_DETECTED: 'virus_detected',
|
|
13
|
+
SCANNER_UNAVAILABLE: 'scanner_unavailable',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Minimal magic-byte check for encrypted ZIP (central directory encrypted flag)
|
|
17
|
+
function looksEncrypted(buffer) {
|
|
18
|
+
// ZIP local file header signature: 50 4B 03 04
|
|
19
|
+
// General-purpose bit flag word at offset 6; bit 0 = encryption
|
|
20
|
+
if (buffer.length < 8) return false;
|
|
21
|
+
if (buffer[0] === 0x50 && buffer[1] === 0x4B &&
|
|
22
|
+
buffer[2] === 0x03 && buffer[3] === 0x04) {
|
|
23
|
+
const flags = buffer.readUInt16LE(6);
|
|
24
|
+
return (flags & 0x01) !== 0;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createPolicy(rules = {}) {
|
|
30
|
+
const {
|
|
31
|
+
scan: scanOpts = undefined,
|
|
32
|
+
maxSize = null,
|
|
33
|
+
allowedMimeTypes = null,
|
|
34
|
+
allowedExtensions = null,
|
|
35
|
+
rejectEncrypted = false,
|
|
36
|
+
onScannerUnavailable = 'reject',
|
|
37
|
+
} = rules;
|
|
38
|
+
|
|
39
|
+
async function check(buffer, meta = {}) {
|
|
40
|
+
const { filename = null, mimeType = null, size = buffer.length } = meta;
|
|
41
|
+
const details = { size, mimeType, extension: null, virusName: null };
|
|
42
|
+
|
|
43
|
+
if (filename) {
|
|
44
|
+
details.extension = path.extname(filename).toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 1. Size check
|
|
48
|
+
if (maxSize !== null && size > maxSize) {
|
|
49
|
+
return { allowed: false, reason: REASONS.FILE_TOO_LARGE, verdict: Verdict.ScanError, details };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. MIME type check
|
|
53
|
+
if (allowedMimeTypes && mimeType) {
|
|
54
|
+
if (!allowedMimeTypes.includes(mimeType)) {
|
|
55
|
+
return { allowed: false, reason: REASONS.MIME_NOT_ALLOWED, verdict: Verdict.ScanError, details };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 3. Extension check
|
|
60
|
+
if (allowedExtensions && details.extension) {
|
|
61
|
+
if (!allowedExtensions.includes(details.extension)) {
|
|
62
|
+
return { allowed: false, reason: REASONS.EXT_NOT_ALLOWED, verdict: Verdict.ScanError, details };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. Encrypted archive check
|
|
67
|
+
if (rejectEncrypted && looksEncrypted(buffer)) {
|
|
68
|
+
return { allowed: false, reason: REASONS.ENCRYPTED, verdict: Verdict.ScanError, details };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 5. Virus scan
|
|
72
|
+
if (scanOpts !== undefined) {
|
|
73
|
+
let verdict;
|
|
74
|
+
try {
|
|
75
|
+
verdict = await scanBuffer(buffer, scanOpts);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (onScannerUnavailable === 'allow') {
|
|
78
|
+
return { allowed: true, reason: null, verdict: Verdict.ScanError, details };
|
|
79
|
+
}
|
|
80
|
+
if (onScannerUnavailable === 'throw') {
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
// 'reject'
|
|
84
|
+
return { allowed: false, reason: REASONS.SCANNER_UNAVAILABLE, verdict: Verdict.ScanError, details };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (verdict === Verdict.Malicious) {
|
|
88
|
+
return { allowed: false, reason: REASONS.VIRUS_DETECTED, verdict: Verdict.Malicious, details };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { allowed: true, reason: null, verdict, details };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { allowed: true, reason: null, verdict: Verdict.Clean, details };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function middleware() {
|
|
98
|
+
return async function policyMiddleware(req, res, next) {
|
|
99
|
+
let file = null;
|
|
100
|
+
if (req.file) {
|
|
101
|
+
file = req.file;
|
|
102
|
+
} else if (req.files) {
|
|
103
|
+
const files = Array.isArray(req.files)
|
|
104
|
+
? req.files
|
|
105
|
+
: Object.values(req.files).flat();
|
|
106
|
+
file = files[0] || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!file) return next();
|
|
110
|
+
|
|
111
|
+
const buffer = file.buffer;
|
|
112
|
+
const filename = file.originalname || null;
|
|
113
|
+
const mimeType = file.mimetype || null;
|
|
114
|
+
const size = buffer ? buffer.length : (file.size || 0);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const result = await check(buffer || Buffer.alloc(0), { filename, mimeType, size });
|
|
118
|
+
if (!result.allowed) {
|
|
119
|
+
return res.status(403).json({ error: result.reason });
|
|
120
|
+
}
|
|
121
|
+
next();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
next(err);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nestGuard() {
|
|
129
|
+
return {
|
|
130
|
+
canActivate: async (context) => {
|
|
131
|
+
const req = context.switchToHttp().getRequest();
|
|
132
|
+
const res = context.switchToHttp().getResponse();
|
|
133
|
+
const file = req.file || (req.files && (Array.isArray(req.files) ? req.files[0] : Object.values(req.files).flat()[0]));
|
|
134
|
+
if (!file) return true;
|
|
135
|
+
|
|
136
|
+
const buffer = file.buffer;
|
|
137
|
+
const filename = file.originalname || null;
|
|
138
|
+
const mimeType = file.mimetype || null;
|
|
139
|
+
const size = buffer ? buffer.length : (file.size || 0);
|
|
140
|
+
|
|
141
|
+
const result = await check(buffer || Buffer.alloc(0), { filename, mimeType, size });
|
|
142
|
+
if (!result.allowed) {
|
|
143
|
+
res.status(403).json({ error: result.reason });
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { check, middleware, nestGuard };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { createPolicy };
|
package/src/ScanCache.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { scan, scanBuffer } = require('./ClamAVScanner.js');
|
|
6
|
+
|
|
7
|
+
function sha256ofBuffer(buf) {
|
|
8
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sha256ofFile(filePath) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const hash = crypto.createHash('sha256');
|
|
14
|
+
const stream = fs.createReadStream(filePath);
|
|
15
|
+
stream.on('error', reject);
|
|
16
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
17
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createCache(options = {}) {
|
|
22
|
+
const ttl = options.ttl ?? 3600000;
|
|
23
|
+
const maxSize = options.maxSize ?? 1000;
|
|
24
|
+
const storage = options.storage || 'memory';
|
|
25
|
+
const filePath = options.filePath || './.pompelmi-cache.json';
|
|
26
|
+
|
|
27
|
+
// LRU map: key = sha256, value = { verdict, expiresAt }
|
|
28
|
+
// Insertion order tracks LRU; on hit we re-insert to move to back.
|
|
29
|
+
let store = new Map();
|
|
30
|
+
let hits = 0;
|
|
31
|
+
let misses = 0;
|
|
32
|
+
let writeTimer = null;
|
|
33
|
+
|
|
34
|
+
// ── file storage bootstrap ──────────────────────────────────────────────────
|
|
35
|
+
if (storage === 'file') {
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
40
|
+
if (v.expiresAt > now) store.set(k, v);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// file absent or invalid — start with empty cache
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _persist() {
|
|
48
|
+
if (storage !== 'file') return;
|
|
49
|
+
if (writeTimer) return;
|
|
50
|
+
writeTimer = setTimeout(() => {
|
|
51
|
+
writeTimer = null;
|
|
52
|
+
const obj = {};
|
|
53
|
+
for (const [k, v] of store) obj[k] = v;
|
|
54
|
+
const tmp = filePath + '.tmp';
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
57
|
+
fs.renameSync(tmp, filePath);
|
|
58
|
+
} catch { /* non-fatal */ }
|
|
59
|
+
}, 500);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _get(sha) {
|
|
63
|
+
const entry = store.get(sha);
|
|
64
|
+
if (!entry) return null;
|
|
65
|
+
if (Date.now() > entry.expiresAt) {
|
|
66
|
+
store.delete(sha);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Move to end (LRU: most-recently-used = last)
|
|
70
|
+
store.delete(sha);
|
|
71
|
+
store.set(sha, entry);
|
|
72
|
+
return entry.verdict;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _set(sha, verdict) {
|
|
76
|
+
if (store.has(sha)) store.delete(sha); // re-insert at end
|
|
77
|
+
if (store.size >= maxSize) {
|
|
78
|
+
// evict oldest (first) entry
|
|
79
|
+
store.delete(store.keys().next().value);
|
|
80
|
+
}
|
|
81
|
+
store.set(sha, { verdict, expiresAt: Date.now() + ttl });
|
|
82
|
+
_persist();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function scanWithCache(filePath, opts = {}) {
|
|
86
|
+
const sha = await sha256ofFile(filePath);
|
|
87
|
+
const cached = _get(sha);
|
|
88
|
+
if (cached !== null) { hits++; return cached; }
|
|
89
|
+
misses++;
|
|
90
|
+
const verdict = await scan(filePath, opts);
|
|
91
|
+
_set(sha, verdict);
|
|
92
|
+
return verdict;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function scanBufferWithCache(buffer, opts = {}) {
|
|
96
|
+
const sha = sha256ofBuffer(buffer);
|
|
97
|
+
const cached = _get(sha);
|
|
98
|
+
if (cached !== null) { hits++; return cached; }
|
|
99
|
+
misses++;
|
|
100
|
+
const verdict = await scanBuffer(buffer, opts);
|
|
101
|
+
_set(sha, verdict);
|
|
102
|
+
return verdict;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stats() {
|
|
106
|
+
const total = hits + misses;
|
|
107
|
+
return {
|
|
108
|
+
hits,
|
|
109
|
+
misses,
|
|
110
|
+
size: store.size,
|
|
111
|
+
hitRate: total === 0 ? 0 : hits / total,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clear() {
|
|
116
|
+
store = new Map();
|
|
117
|
+
hits = 0;
|
|
118
|
+
misses = 0;
|
|
119
|
+
_persist();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function del(sha) {
|
|
123
|
+
store.delete(sha);
|
|
124
|
+
_persist();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
scan: scanWithCache,
|
|
129
|
+
scanBuffer: scanBufferWithCache,
|
|
130
|
+
stats,
|
|
131
|
+
clear,
|
|
132
|
+
delete: del,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { createCache };
|
package/src/Scorecard.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const CHECKS = [
|
|
4
|
+
{
|
|
5
|
+
key: 'scanEnabled',
|
|
6
|
+
check: 'Virus scanning enabled',
|
|
7
|
+
weight: 30,
|
|
8
|
+
pass: c => c.scanEnabled === true,
|
|
9
|
+
recommendation: 'Enable virus scanning by integrating pompelmi into your upload handler.',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
key: 'mimeTypeAllowlist',
|
|
13
|
+
check: 'MIME type allowlist',
|
|
14
|
+
weight: 20,
|
|
15
|
+
pass: c => Array.isArray(c.mimeTypeAllowlist) && c.mimeTypeAllowlist.length > 0,
|
|
16
|
+
recommendation: 'Define an explicit MIME type allowlist to reject unexpected file types.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
key: 'fileSizeLimit',
|
|
20
|
+
check: 'File size limit',
|
|
21
|
+
weight: 10,
|
|
22
|
+
pass: c => typeof c.fileSizeLimit === 'number' && c.fileSizeLimit > 0,
|
|
23
|
+
recommendation: 'Set a maximum file size limit to prevent resource exhaustion.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'diskWriteBeforeScan',
|
|
27
|
+
check: 'No disk write before scan',
|
|
28
|
+
weight: 15,
|
|
29
|
+
pass: c => c.diskWriteBeforeScan === false,
|
|
30
|
+
recommendation: 'Scan files in-memory before writing to disk to avoid storing malware even temporarily.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'scanErrorBehavior',
|
|
34
|
+
check: 'Scan error behavior is reject',
|
|
35
|
+
weight: 10,
|
|
36
|
+
pass: c => c.scanErrorBehavior === 'reject',
|
|
37
|
+
recommendation: 'Set scanErrorBehavior to "reject" — failing open on scan errors is a security risk.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'clamdUnavailableBehavior',
|
|
41
|
+
check: 'clamd unavailable behavior is reject',
|
|
42
|
+
weight: 10,
|
|
43
|
+
pass: c => c.clamdUnavailableBehavior === 'reject',
|
|
44
|
+
recommendation: 'Set clamdUnavailableBehavior to "reject" — if clamd is down, deny uploads rather than allowing them through.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'tlsEnabled',
|
|
48
|
+
check: 'TLS enabled on upload endpoint',
|
|
49
|
+
weight: 5,
|
|
50
|
+
pass: c => c.tlsEnabled === true,
|
|
51
|
+
recommendation: 'Enable TLS (HTTPS) on your upload endpoint to protect files in transit.',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function scoreToGrade(score) {
|
|
56
|
+
if (score >= 90) return 'A';
|
|
57
|
+
if (score >= 75) return 'B';
|
|
58
|
+
if (score >= 60) return 'C';
|
|
59
|
+
if (score >= 45) return 'D';
|
|
60
|
+
return 'F';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function generateScorecard(config = {}) {
|
|
64
|
+
const maxScore = CHECKS.reduce((s, c) => s + c.weight, 0);
|
|
65
|
+
let earned = 0;
|
|
66
|
+
const findings = [];
|
|
67
|
+
const recommendations = [];
|
|
68
|
+
|
|
69
|
+
for (const def of CHECKS) {
|
|
70
|
+
const passed = def.pass(config);
|
|
71
|
+
findings.push({ check: def.check, status: passed ? 'pass' : 'fail', weight: def.weight });
|
|
72
|
+
if (passed) {
|
|
73
|
+
earned += def.weight;
|
|
74
|
+
} else {
|
|
75
|
+
recommendations.push(def.recommendation);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const score = Math.round((earned / maxScore) * 100);
|
|
80
|
+
const grade = scoreToGrade(score);
|
|
81
|
+
|
|
82
|
+
return { grade, score, findings, recommendations };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { generateScorecard };
|