filepilot-enterprise-vault 1.0.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/server.cjs ADDED
@@ -0,0 +1,4214 @@
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { WebSocketServer } = require('ws');
7
+ const urlParser = require('url');
8
+
9
+ const {
10
+ sequelize,
11
+ User,
12
+ VaultGroup,
13
+ ConnectionProfile,
14
+ AccessKey,
15
+ TransferLog,
16
+ FileVersion,
17
+ SystemConfig,
18
+ PendingReversion,
19
+ AdminSession,
20
+ MfaChallenge,
21
+ KeyVersion,
22
+ initDb,
23
+ updateDbConnection
24
+ } = require('./db.cjs');
25
+
26
+ const kmsProviders = require('./kms-providers.cjs');
27
+
28
+ // Base32 & TOTP helpers
29
+ const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
30
+
31
+ function base32Decode(str) {
32
+ str = str.toUpperCase().replace(/=+$/, '');
33
+ let bits = '';
34
+ for (let i = 0; i < str.length; i++) {
35
+ const val = BASE32_CHARS.indexOf(str[i]);
36
+ if (val === -1) throw new Error('Invalid Base32 character');
37
+ bits += val.toString(2).padStart(5, '0');
38
+ }
39
+ const bytes = [];
40
+ for (let i = 0; i + 8 <= bits.length; i += 8) {
41
+ bytes.push(parseInt(bits.substring(i, i + 8), 2));
42
+ }
43
+ return Buffer.from(bytes);
44
+ }
45
+
46
+ function base32Encode(buffer) {
47
+ let bits = '';
48
+ for (let i = 0; i < buffer.length; i++) {
49
+ bits += buffer[i].toString(2).padStart(8, '0');
50
+ }
51
+ let str = '';
52
+ for (let i = 0; i < bits.length; i += 5) {
53
+ const chunk = bits.substring(i, i + 5).padEnd(5, '0');
54
+ str += BASE32_CHARS[parseInt(chunk, 2)];
55
+ }
56
+ return str;
57
+ }
58
+
59
+ function generateTotp(secretBase32, timeStepOffset = 0) {
60
+ const secretBytes = base32Decode(secretBase32);
61
+ const timeStep = Math.floor(Date.now() / 1000 / 30) + timeStepOffset;
62
+
63
+ const buffer = Buffer.alloc(8);
64
+ buffer.writeUInt32BE(0, 0); // High 32 bits
65
+ buffer.writeUInt32BE(timeStep, 4); // Low 32 bits
66
+
67
+ const hmac = crypto.createHmac('sha1', secretBytes);
68
+ hmac.update(buffer);
69
+ const hash = hmac.digest();
70
+
71
+ const offset = hash[hash.length - 1] & 0x0f;
72
+ const binary = ((hash[offset] & 0x7f) << 24) |
73
+ ((hash[offset + 1] & 0xff) << 16) |
74
+ ((hash[offset + 2] & 0xff) << 8) |
75
+ (hash[offset + 3] & 0xff);
76
+
77
+ const code = binary % 1000000;
78
+ return code.toString().padStart(6, '0');
79
+ }
80
+
81
+ function verifyTotp(secretBase32, code, window = 1) {
82
+ for (let i = -window; i <= window; i++) {
83
+ if (generateTotp(secretBase32, i) === code) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ const app = express();
91
+ const PORT = process.env.PORT || 8200;
92
+
93
+ // Cookie parser helper
94
+ function parseCookies(cookieHeader) {
95
+ const list = {};
96
+ if (!cookieHeader) return list;
97
+ cookieHeader.split(';').forEach(cookie => {
98
+ const parts = cookie.split('=');
99
+ list[parts.shift().trim()] = decodeURI(parts.join('='));
100
+ });
101
+ return list;
102
+ }
103
+
104
+ // Periodic database session cleanup
105
+ setInterval(async () => {
106
+ try {
107
+ const { Op } = require('sequelize');
108
+ const deletedCount = await AdminSession.destroy({
109
+ where: {
110
+ expiresAt: {
111
+ [Op.lt]: new Date()
112
+ }
113
+ }
114
+ });
115
+ if (deletedCount > 0) {
116
+ console.log(`[Session Cleanup] Pruned ${deletedCount} expired admin sessions.`);
117
+ }
118
+ } catch (err) {
119
+ console.error("[Session Cleanup Error] Failed to prune expired sessions:", err);
120
+ }
121
+ }, 10 * 60 * 1000);
122
+
123
+ // Login attempts map for lockout policy
124
+ const loginAttempts = new Map();
125
+
126
+ app.use(cors());
127
+ app.use(express.json());
128
+
129
+ const DATA_DIR = process.env.VAULT_DATA_DIR || process.cwd();
130
+ const KEY_FILE = path.join(DATA_DIR, '.vault_key');
131
+ const configPath = process.env.VAULT_CONFIG_PATH || path.join(DATA_DIR, 'config.json');
132
+ let encryptionKey;
133
+ let activeHttpServer;
134
+ let activeWss;
135
+
136
+ // WebSockets connected clients mapping: token string -> WebSocket connection
137
+ const clients = new Map();
138
+
139
+ // Cryptographic Key Initialization
140
+ function loadOrCreateKey() {
141
+ try {
142
+ const envKey = process.env.VAULT_MASTER_KEY;
143
+ if (envKey) {
144
+ console.log("[VAULT KEY] Using master key from environment variable (VAULT_MASTER_KEY).");
145
+ if (envKey.length === 64 && /^[0-9a-fA-F]+$/.test(envKey)) {
146
+ encryptionKey = Buffer.from(envKey, 'hex');
147
+ } else if (envKey.length === 44 && /^[0-9a-zA-Z+/=]+$/.test(envKey)) {
148
+ encryptionKey = Buffer.from(envKey, 'base64');
149
+ } else {
150
+ encryptionKey = Buffer.from(envKey, 'utf8');
151
+ }
152
+ return;
153
+ }
154
+
155
+ console.warn("WARNING: Using file-based master key. Set VAULT_MASTER_KEY env variable for production use.");
156
+
157
+ if (fs.existsSync(KEY_FILE)) {
158
+ const fileKey = fs.readFileSync(KEY_FILE).toString('utf8').trim();
159
+ if (fileKey.length === 64 && /^[0-9a-fA-F]+$/.test(fileKey)) {
160
+ encryptionKey = Buffer.from(fileKey, 'hex');
161
+ } else if (fileKey.length === 44 && /^[0-9a-zA-Z+/=]+$/.test(fileKey)) {
162
+ encryptionKey = Buffer.from(fileKey, 'base64');
163
+ } else {
164
+ encryptionKey = fs.readFileSync(KEY_FILE);
165
+ }
166
+ } else {
167
+ encryptionKey = crypto.randomBytes(32);
168
+ fs.writeFileSync(KEY_FILE, encryptionKey);
169
+ }
170
+ } catch (err) {
171
+ console.error("Failed to load or generate vault key:", err);
172
+ encryptionKey = crypto.scryptSync("fallback-vault-salt", "salt", 32);
173
+ }
174
+ }
175
+ loadOrCreateKey();
176
+
177
+ // Cryptographic Helper Functions (AES-256-GCM)
178
+ function wrapDek(dek, kek) {
179
+ const iv = crypto.randomBytes(12);
180
+ const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv);
181
+ let encrypted = cipher.update(dek);
182
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
183
+ const tag = cipher.getAuthTag();
184
+ const combined = Buffer.concat([iv, tag, encrypted]);
185
+ return combined.toString('base64');
186
+ }
187
+
188
+ function unwrapDek(wrappedDek, kek) {
189
+ const combined = Buffer.from(wrappedDek, 'base64');
190
+ const iv = combined.subarray(0, 12);
191
+ const tag = combined.subarray(12, 28);
192
+ const encrypted = combined.subarray(28);
193
+
194
+ const decipher = crypto.createDecipheriv('aes-256-gcm', kek, iv);
195
+ decipher.setAuthTag(tag);
196
+ let decrypted = decipher.update(encrypted);
197
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
198
+ return decrypted; // raw DEK Buffer
199
+ }
200
+
201
+ const kekCache = new Map();
202
+
203
+ // Get KEK by version
204
+ async function getKekByVersion(version) {
205
+ if (kekCache.has(version)) {
206
+ return kekCache.get(version);
207
+ }
208
+ const activeVersion = await KeyVersion.max('version') || 1;
209
+ if (version === activeVersion) {
210
+ kekCache.set(version, encryptionKey);
211
+ return encryptionKey; // current/active KEK
212
+ } else if (version === activeVersion - 1 && process.env.VAULT_MASTER_KEY_PREVIOUS) {
213
+ const prevKey = process.env.VAULT_MASTER_KEY_PREVIOUS;
214
+ let prevBuffer;
215
+ if (prevKey.length === 64 && /^[0-9a-fA-F]+$/.test(prevKey)) {
216
+ prevBuffer = Buffer.from(prevKey, 'hex');
217
+ } else if (prevKey.length === 44 && /^[0-9a-zA-Z+/=]+$/.test(prevKey)) {
218
+ prevBuffer = Buffer.from(prevKey, 'base64');
219
+ } else {
220
+ prevBuffer = Buffer.from(prevKey, 'utf8');
221
+ }
222
+ kekCache.set(version, prevBuffer);
223
+ return prevBuffer;
224
+ }
225
+ // Fallback to active KEK
226
+ return encryptionKey;
227
+ }
228
+
229
+ function decryptKmsCredentials(encryptedStr) {
230
+ if (!encryptedStr) return {};
231
+ const decrypted = decrypt(encryptedStr, encryptionKey);
232
+ if (decrypted === "[Decryption Error]") {
233
+ return {};
234
+ }
235
+ try {
236
+ return JSON.parse(decrypted);
237
+ } catch (err) {
238
+ console.error("Failed to parse decrypted KMS credentials:", err);
239
+ return {};
240
+ }
241
+ }
242
+
243
+ function encryptKmsCredentials(credsObj) {
244
+ if (!credsObj) return null;
245
+ return encrypt(JSON.stringify(credsObj), encryptionKey);
246
+ }
247
+
248
+ // Unwrap group DEK
249
+ async function getGroupDek(vaultGroup) {
250
+ if (!vaultGroup.wrapped_dek) {
251
+ return encryptionKey;
252
+ }
253
+
254
+ const providerType = vaultGroup.kms_provider || 'local';
255
+ const provider = kmsProviders.providers[providerType];
256
+ if (!provider) {
257
+ throw new Error(`Unsupported KMS provider: ${providerType}`);
258
+ }
259
+
260
+ let providerConfig = {};
261
+ if (providerType === 'local') {
262
+ const version = vaultGroup.dek_version || 1;
263
+ const kek = await getKekByVersion(version);
264
+ providerConfig = { kek };
265
+ } else {
266
+ if (vaultGroup.kms_config) {
267
+ try {
268
+ providerConfig = JSON.parse(vaultGroup.kms_config);
269
+ } catch (err) {
270
+ console.error("Failed to parse kms_config:", err);
271
+ }
272
+ }
273
+ if (vaultGroup.kms_credentials_encrypted) {
274
+ const decryptedCreds = decryptKmsCredentials(vaultGroup.kms_credentials_encrypted);
275
+ providerConfig = { ...providerConfig, ...decryptedCreds };
276
+ }
277
+ }
278
+
279
+ try {
280
+ return await provider.unwrapDek(providerConfig, vaultGroup.wrapped_dek);
281
+ } catch (err) {
282
+ console.error(`[KMS Error] Failed to unwrap DEK for group ${vaultGroup.name} (${vaultGroup.id}) using provider ${providerType}:`, err);
283
+
284
+ // Log the failure to the audit log cleanly
285
+ await addAuditLog(
286
+ 'System',
287
+ JSON.stringify({
288
+ action: 'kms_error',
289
+ performedBy: 'System',
290
+ performedByUsername: 'System',
291
+ ipAddress: '127.0.0.1',
292
+ details: `KMS Unwrap Error for Group ${vaultGroup.name}: ${err.message}`,
293
+ status: 'failed',
294
+ groupId: vaultGroup.id
295
+ }),
296
+ 'failed'
297
+ );
298
+
299
+ throw err;
300
+ }
301
+ }
302
+
303
+ function encrypt(text, key) {
304
+ if (!text) return text;
305
+ if (text.startsWith('enc:')) return text; // Already encrypted
306
+ const encKey = key || encryptionKey;
307
+ try {
308
+ const iv = crypto.randomBytes(12);
309
+ const cipher = crypto.createCipheriv('aes-256-gcm', encKey, iv);
310
+ let encrypted = cipher.update(text, 'utf8', 'hex');
311
+ encrypted += cipher.final('hex');
312
+ const tag = cipher.getAuthTag().toString('hex');
313
+ return `enc:${iv.toString('hex')}:${tag}:${encrypted}`;
314
+ } catch (err) {
315
+ console.error("Encryption failed:", err);
316
+ return text;
317
+ }
318
+ }
319
+
320
+ // Decrypt text
321
+ function decrypt(text, key) {
322
+ if (!text || !text.startsWith('enc:')) return text;
323
+ const decKey = key || encryptionKey;
324
+ try {
325
+ const parts = text.split(':');
326
+ if (parts.length !== 4) return text;
327
+ const iv = Buffer.from(parts[1], 'hex');
328
+ const tag = Buffer.from(parts[2], 'hex');
329
+ const encryptedText = Buffer.from(parts[3], 'hex');
330
+ const decipher = crypto.createDecipheriv('aes-256-gcm', decKey, iv);
331
+ decipher.setAuthTag(tag);
332
+ let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
333
+ decrypted += decipher.final('utf8');
334
+ return decrypted;
335
+ } catch (err) {
336
+ console.error("Decryption failed:", err);
337
+ return "[Decryption Error]";
338
+ }
339
+ }
340
+
341
+ // IP Allowlist matching utility
342
+ function ipMatches(clientIp, allowlistStr) {
343
+ if (!allowlistStr || allowlistStr.trim() === '') return true;
344
+
345
+ let ip = clientIp.trim();
346
+ // Handle IPv6 mapped IPv4 addresses
347
+ if (ip.startsWith('::ffff:')) {
348
+ ip = ip.substring(7);
349
+ }
350
+ // Localhost aliases
351
+ if (ip === '::1') {
352
+ ip = '127.0.0.1';
353
+ }
354
+
355
+ const list = allowlistStr.split(',').map(s => s.trim()).filter(Boolean);
356
+ for (const allowed of list) {
357
+ if (allowed === ip) return true;
358
+
359
+ // Wildcard prefix match (e.g. 192.168.1.*)
360
+ if (allowed.endsWith('*')) {
361
+ const prefix = allowed.slice(0, -1);
362
+ if (ip.startsWith(prefix)) return true;
363
+ }
364
+
365
+ // CIDR subnet matching (e.g. 192.168.1.0/24)
366
+ if (allowed.includes('/')) {
367
+ const [subnet, mask] = allowed.split('/');
368
+ const maskInt = parseInt(mask, 10);
369
+ if (ip4ToInt(ip) && ip4ToInt(subnet)) {
370
+ const ipInt = ip4ToInt(ip);
371
+ const subInt = ip4ToInt(subnet);
372
+ const maskBits = ~((1 << (32 - maskInt)) - 1);
373
+ if ((ipInt & maskBits) === (subInt & maskBits)) return true;
374
+ }
375
+ }
376
+ }
377
+ return false;
378
+ }
379
+
380
+ function ip4ToInt(ip) {
381
+ const parts = ip.split('.').map(Number);
382
+ if (parts.length !== 4 || parts.some(isNaN)) return null;
383
+ return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
384
+ }
385
+
386
+ const dns = require('dns').promises;
387
+ const net = require('net');
388
+
389
+ function isPrivateIp(ip) {
390
+ if (ip.startsWith('::ffff:')) {
391
+ ip = ip.substring(7);
392
+ }
393
+
394
+ const ipv4Parts = ip.split('.');
395
+ if (ipv4Parts.length === 4) {
396
+ const a = parseInt(ipv4Parts[0], 10);
397
+ const b = parseInt(ipv4Parts[1], 10);
398
+ const c = parseInt(ipv4Parts[2], 10);
399
+ const d = parseInt(ipv4Parts[3], 10);
400
+
401
+ if (isNaN(a) || isNaN(b) || isNaN(c) || isNaN(d)) return false;
402
+
403
+ // 127.0.0.0/8 (Loopback)
404
+ if (a === 127) return true;
405
+ // 10.0.0.0/8 (Private)
406
+ if (a === 10) return true;
407
+ // 172.16.0.0/12 (Private)
408
+ if (a === 172 && b >= 16 && b <= 31) return true;
409
+ // 192.168.0.0/16 (Private)
410
+ if (a === 192 && b === 168) return true;
411
+ // 169.254.0.0/16 (Link-local)
412
+ if (a === 169 && b === 254) return true;
413
+
414
+ return false;
415
+ }
416
+
417
+ const cleanIp = ip.toLowerCase().trim();
418
+ if (cleanIp === '::1' || cleanIp === '0:0:0:0:0:0:0:1') {
419
+ return true;
420
+ }
421
+ // Link-local: starts with fe80, fe90, fea0, feb0 (fe80::/10)
422
+ if (cleanIp.startsWith('fe80:') || cleanIp.startsWith('fe90:') || cleanIp.startsWith('fea0:') || cleanIp.startsWith('feb0:') ||
423
+ /^[fF][eE][89abAB]/i.test(cleanIp)) {
424
+ return true;
425
+ }
426
+
427
+ return false;
428
+ }
429
+
430
+ async function isHostPrivate(host) {
431
+ try {
432
+ if (net.isIP(host)) {
433
+ return isPrivateIp(host);
434
+ }
435
+
436
+ const addresses = await dns.lookup(host, { all: true });
437
+ for (const addr of addresses) {
438
+ if (isPrivateIp(addr.address)) {
439
+ return true;
440
+ }
441
+ }
442
+ return false;
443
+ } catch (err) {
444
+ return false;
445
+ }
446
+ }
447
+
448
+ // Lightweight In-Memory Rate Limiter Middleware
449
+ const ipRequestCounts = new Map();
450
+
451
+ function createRateLimiter(maxRequests, windowMs, message) {
452
+ return (req, res, next) => {
453
+ if (process.env.NODE_ENV === 'test') {
454
+ return next();
455
+ }
456
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
457
+
458
+ // Bypass rate limits for local loopback development connections
459
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
460
+ return next();
461
+ }
462
+
463
+ const now = Date.now();
464
+
465
+ if (!ipRequestCounts.has(ip)) {
466
+ ipRequestCounts.set(ip, []);
467
+ }
468
+
469
+ const timestamps = ipRequestCounts.get(ip);
470
+ const activeTimestamps = timestamps.filter(t => now - t < windowMs);
471
+
472
+ if (activeTimestamps.length >= maxRequests) {
473
+ return res.status(429).json({ error: message || "Too many requests. Please try again later." });
474
+ }
475
+
476
+ activeTimestamps.push(now);
477
+ ipRequestCounts.set(ip, activeTimestamps);
478
+ next();
479
+ };
480
+ }
481
+
482
+ const authLimiter = createRateLimiter(5, 10 * 60 * 1000, "Too many login attempts. Please try again after 10 minutes.");
483
+ const syncLimiter = createRateLimiter(30, 60 * 1000, "Too many profile sync attempts. Please try again in a minute.");
484
+
485
+ // Server Audit log helper
486
+ async function addAuditLog(tokenDesc, action, status = "success") {
487
+ try {
488
+ const loggingEnabled = await SystemConfig.findOne({ where: { key: 'audit_logging_enabled' } });
489
+ if (loggingEnabled && loggingEnabled.value === 'false') return;
490
+
491
+ // Check if there is an AccessKey matching tokenDesc (using hash if it looks like a token)
492
+ let key = null;
493
+ if (tokenDesc && tokenDesc.length > 10 && !tokenDesc.includes(' ')) {
494
+ const hashed = crypto.createHash('sha256').update(tokenDesc).digest('hex');
495
+ key = await AccessKey.findOne({
496
+ where: { token_hash: hashed },
497
+ include: [User]
498
+ });
499
+ }
500
+
501
+ let username = key && key.User ? key.User.username : null;
502
+ let tokenVal = key ? key.token_hash : null;
503
+
504
+ if (!username && tokenDesc === 'admin-token') {
505
+ username = 'Master Token';
506
+ tokenVal = 'admin-token';
507
+ }
508
+
509
+ let errMsg = action;
510
+ let metadataVal = null;
511
+ if (action && action.startsWith('{') && action.endsWith('}')) {
512
+ try {
513
+ JSON.parse(action);
514
+ metadataVal = action;
515
+ errMsg = null;
516
+ } catch (e) {}
517
+ }
518
+
519
+ await TransferLog.create({
520
+ token: tokenVal,
521
+ username: username || tokenDesc,
522
+ connectionId: 'VaultServer',
523
+ filePath: 'SystemAudit',
524
+ action: 'audit',
525
+ status: status,
526
+ errorMessage: errMsg,
527
+ metadata: metadataVal
528
+ });
529
+ } catch (err) {
530
+ console.error("Failed to write system audit log:", err);
531
+ }
532
+
533
+ // Forward to SIEM Webhook
534
+ triggerSiemWebhook(tokenDesc, action, status);
535
+ }
536
+
537
+ // SIEM Webhook Integration
538
+ async function triggerSiemWebhook(scope, action, status) {
539
+ try {
540
+ const siemUrl = await SystemConfig.findOne({ where: { key: 'siem_webhook_url' } });
541
+ if (!siemUrl || !siemUrl.value || siemUrl.value.trim() === '') return;
542
+
543
+ const siemSecretConfig = await SystemConfig.findOne({ where: { key: 'siem_webhook_secret' } });
544
+ const secret = siemSecretConfig ? siemSecretConfig.value : '';
545
+
546
+ const payload = {
547
+ timestamp: new Date().toISOString(),
548
+ system: "FilePilot Enterprise Engine",
549
+ scope,
550
+ action,
551
+ status
552
+ };
553
+ const rawBody = JSON.stringify(payload);
554
+ const signature = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
555
+
556
+ const attemptPost = async (attempt = 0) => {
557
+ const controller = new AbortController();
558
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
559
+
560
+ try {
561
+ const res = await fetch(siemUrl.value, {
562
+ method: 'POST',
563
+ headers: {
564
+ 'Content-Type': 'application/json',
565
+ 'X-Vault-Signature-256': `sha256=${signature}`
566
+ },
567
+ body: rawBody,
568
+ signal: controller.signal
569
+ });
570
+ clearTimeout(timeoutId);
571
+
572
+ if (!res.ok) {
573
+ throw new Error(`HTTP error status ${res.status}`);
574
+ }
575
+ } catch (err) {
576
+ clearTimeout(timeoutId);
577
+ console.error(`[SIEM Webhook Error] Attempt ${attempt} failed: ${err.message}`);
578
+
579
+ if (attempt < 3) {
580
+ const backoffs = [1000, 4000, 16000];
581
+ const delay = backoffs[attempt];
582
+ await new Promise(resolve => setTimeout(resolve, delay));
583
+ return attemptPost(attempt + 1);
584
+ } else {
585
+ console.error(`[SIEM Webhook Error] All attempts exhausted. Logging permanent failure.`);
586
+ try {
587
+ await TransferLog.create({
588
+ token: null,
589
+ username: 'System',
590
+ connectionId: 'VaultServer',
591
+ filePath: 'SystemAudit',
592
+ action: 'siem_failure',
593
+ status: 'failed',
594
+ errorMessage: `SIEM Webhook delivery permanently failed for action "${action}"`
595
+ });
596
+ } catch (logErr) {
597
+ console.error("Failed to write SIEM failure log:", logErr);
598
+ }
599
+ }
600
+ }
601
+ };
602
+
603
+ attemptPost();
604
+ } catch (e) {
605
+ console.error("SIEM Webhook error:", e);
606
+ }
607
+ }
608
+
609
+ // Admin Authentication Middleware
610
+ async function authMiddleware(req, res, next) {
611
+ const cookies = parseCookies(req.headers.cookie);
612
+ const sessionId = cookies['vault_session'];
613
+
614
+ if (!sessionId) {
615
+ return res.status(401).json({ error: "Unauthorized access: Session missing" });
616
+ }
617
+
618
+ const hashedSession = crypto.createHash('sha256').update(sessionId).digest('hex');
619
+ const session = await AdminSession.findOne({
620
+ where: { session_hash: hashedSession },
621
+ include: [User]
622
+ });
623
+
624
+ if (!session || new Date() > new Date(session.expiresAt)) {
625
+ if (session) await session.destroy();
626
+ return res.status(401).json({ error: "Unauthorized access: Session expired or invalid" });
627
+ }
628
+
629
+ // Check CSRF token for state-changing requests (POST, PUT, DELETE)
630
+ if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
631
+ const csrfHeader = req.headers['x-csrf-token'];
632
+ if (!csrfHeader || csrfHeader !== session.csrfToken) {
633
+ console.warn(`[CSRF] Blocked ${req.method} request to ${req.path} - CSRF mismatch or missing`);
634
+ return res.status(403).json({ error: "Forbidden: CSRF token invalid or missing" });
635
+ }
636
+ }
637
+
638
+ const user = session.User;
639
+ if (!user) {
640
+ return res.status(401).json({ error: "Unauthorized access: User not found" });
641
+ }
642
+
643
+ // Throttle lastActivityAt update to at most once per minute
644
+ const now = new Date();
645
+ if (!session.lastActivityAt || (now - new Date(session.lastActivityAt)) > 60000) {
646
+ session.lastActivityAt = now;
647
+ await session.save();
648
+ }
649
+
650
+ req.adminUser = user;
651
+ req.session = session;
652
+ req.sessionId = session.id;
653
+ req.rawSessionToken = sessionId;
654
+ next();
655
+ }
656
+
657
+ // Granular RBAC Permission Matrix
658
+ const ROLE_PERMISSIONS = {
659
+ admin: ['*'], // Special wildcard for all permissions
660
+ manager: [
661
+ 'vault.*',
662
+ 'profile.*',
663
+ 'token.*',
664
+ 'backup.*',
665
+ 'audit.view',
666
+ 'system.view_dashboard',
667
+ 'legal_hold.manage',
668
+ 'user.view'
669
+ ],
670
+ operator: [
671
+ 'vault.view_groups',
672
+ 'profile.view',
673
+ 'profile.create',
674
+ 'profile.edit',
675
+ 'profile.test_connection',
676
+ 'token.view',
677
+ 'token.issue',
678
+ 'backup.view',
679
+ 'system.view_dashboard'
680
+ ],
681
+ auditor: [
682
+ 'vault.view_groups',
683
+ 'profile.view',
684
+ 'token.view',
685
+ 'audit.view',
686
+ 'audit.export',
687
+ 'backup.view',
688
+ 'system.view_dashboard'
689
+ ]
690
+ };
691
+
692
+ // Helper to check permission
693
+ function hasPermission(user, requiredPermission) {
694
+ if (!user || !user.role) return false;
695
+ const role = user.role.toLowerCase();
696
+ const permissions = ROLE_PERMISSIONS[role] || [];
697
+
698
+ if (permissions.includes('*')) return true;
699
+ if (permissions.includes(requiredPermission)) return true;
700
+
701
+ // Handle wildcard matching (e.g. "profile.*" matches "profile.view")
702
+ const requiredParts = requiredPermission.split('.');
703
+ if (requiredParts.length === 2) {
704
+ const wildcardPattern = `${requiredParts[0]}.*`;
705
+ if (permissions.includes(wildcardPattern)) return true;
706
+ }
707
+
708
+ return false;
709
+ }
710
+
711
+ // Middleware to check permission
712
+ function requirePermission(requiredPermission) {
713
+ return (req, res, next) => {
714
+ if (!req.adminUser) {
715
+ return res.status(401).json({ error: "Unauthorized access: Session missing or invalid" });
716
+ }
717
+ if (hasPermission(req.adminUser, requiredPermission)) {
718
+ return next();
719
+ }
720
+ return res.status(403).json({ error: `Forbidden: Missing required permission '${requiredPermission}'` });
721
+ };
722
+ }
723
+
724
+ // Health Check Endpoint
725
+ app.get('/healthz', async (req, res) => {
726
+ let dbStatus = "error";
727
+ let httpStatus = 503;
728
+ try {
729
+ await sequelize.query('SELECT 1');
730
+ dbStatus = "connected";
731
+ httpStatus = 200;
732
+ } catch (err) {
733
+ console.error("[Healthcheck] Database check failed:", err.message);
734
+ }
735
+
736
+ res.status(httpStatus).json({
737
+ status: httpStatus === 200 ? "ok" : "error",
738
+ db: dbStatus,
739
+ uptime: Math.floor(process.uptime())
740
+ });
741
+ });
742
+
743
+ // Check if installation is complete helper (async to verify admin user exists)
744
+ const checkIsInstalled = async () => {
745
+ const hasKey = !!process.env.VAULT_MASTER_KEY || fs.existsSync(KEY_FILE);
746
+ const hasConfig = !!process.env.DB_DIALECT || fs.existsSync(configPath);
747
+ if (!hasKey || !hasConfig) return false;
748
+ try {
749
+ const adminCount = await User.count({ where: { role: 'admin' } });
750
+ return adminCount > 0;
751
+ } catch (err) {
752
+ return false;
753
+ }
754
+ };
755
+
756
+ // Middleware to block API requests if setup is incomplete
757
+ app.use('/admin/api', async (req, res, next) => {
758
+ if (req.path.startsWith('/install/')) {
759
+ return next();
760
+ }
761
+ const installed = await checkIsInstalled();
762
+ if (!installed) {
763
+ return res.status(503).json({ error: "Vault Setup is incomplete. Please access the setup wizard at http://localhost:8200/admin" });
764
+ }
765
+ next();
766
+ });
767
+
768
+ // Installer Endpoint: Test Database Connection
769
+ app.post('/admin/api/install/test-db', async (req, res) => {
770
+ const { dialect, host, port, username, password, database, storage } = req.body;
771
+ try {
772
+ const { Sequelize } = require('sequelize');
773
+ let tempSequelize;
774
+ if (dialect === 'sqlite') {
775
+ tempSequelize = new Sequelize({
776
+ dialect: 'sqlite',
777
+ storage: storage || path.join(DATA_DIR, 'vault.db'),
778
+ logging: false
779
+ });
780
+ } else if (dialect === 'postgres') {
781
+ tempSequelize = new Sequelize({
782
+ dialect: 'postgres',
783
+ host: host || 'localhost',
784
+ port: parseInt(port) || 5432,
785
+ username,
786
+ password,
787
+ database,
788
+ logging: false,
789
+ dialectOptions: {
790
+ ssl: {
791
+ require: true,
792
+ rejectUnauthorized: false
793
+ }
794
+ }
795
+ });
796
+ } else { // mysql
797
+ tempSequelize = new Sequelize({
798
+ dialect: 'mysql',
799
+ host: host || 'localhost',
800
+ port: parseInt(port) || 3306,
801
+ username,
802
+ password,
803
+ database,
804
+ logging: false
805
+ });
806
+ }
807
+ await tempSequelize.authenticate();
808
+ await tempSequelize.close();
809
+ res.json({ success: true });
810
+ } catch (err) {
811
+ res.status(400).json({ error: err.message });
812
+ }
813
+ });
814
+
815
+ // Installer Endpoint: Complete Installation and Seeding
816
+ app.post('/admin/api/install/submit', async (req, res) => {
817
+ if (await checkIsInstalled()) {
818
+ return res.status(400).json({ error: "System is already configured and installed." });
819
+ }
820
+
821
+ const {
822
+ dbDialect,
823
+ dbHost,
824
+ dbPort,
825
+ dbUsername,
826
+ dbPassword,
827
+ dbName,
828
+ dbStorage,
829
+ adminEmail,
830
+ adminPassword,
831
+ keyMode,
832
+ customKey
833
+ } = req.body;
834
+
835
+ try {
836
+ // 1. Resolve master key string representation
837
+ let masterKeyStr;
838
+ if (keyMode === 'custom') {
839
+ masterKeyStr = customKey;
840
+ } else {
841
+ masterKeyStr = crypto.randomBytes(32).toString('hex');
842
+ }
843
+
844
+ // Parse key to set the in-memory encryptionKey variable
845
+ if (masterKeyStr.length === 64 && /^[0-9a-fA-F]+$/.test(masterKeyStr)) {
846
+ encryptionKey = Buffer.from(masterKeyStr, 'hex');
847
+ } else if (masterKeyStr.length === 44 && /^[0-9a-zA-Z+/=]+$/.test(masterKeyStr)) {
848
+ encryptionKey = Buffer.from(masterKeyStr, 'base64');
849
+ } else {
850
+ encryptionKey = Buffer.from(masterKeyStr, 'utf8');
851
+ }
852
+
853
+ // 2. Write all configuration parameters to .env file
854
+ const envContent = [
855
+ `# FilePilot Corporate Vault Environment Configuration`,
856
+ `VAULT_MASTER_KEY=${masterKeyStr}`,
857
+ `DB_DIALECT=${dbDialect}`,
858
+ `DB_HOST=${dbHost || 'localhost'}`,
859
+ `DB_PORT=${dbPort || ''}`,
860
+ `DB_USERNAME=${dbUsername || ''}`,
861
+ `DB_PASSWORD=${dbPassword || ''}`,
862
+ `DB_NAME=${dbName || ''}`,
863
+ `DB_STORAGE=${dbStorage || ''}`,
864
+ `DB_SSL=false`
865
+ ].join('\n');
866
+
867
+ const envFilePath = path.join(DATA_DIR, '.env');
868
+ fs.writeFileSync(envFilePath, envContent, 'utf8');
869
+
870
+ // Clean up any legacy configuration files if they exist to avoid confusion
871
+ if (fs.existsSync(configPath)) {
872
+ try { fs.unlinkSync(configPath); } catch (_) {}
873
+ }
874
+ if (fs.existsSync(KEY_FILE)) {
875
+ try { fs.unlinkSync(KEY_FILE); } catch (_) {}
876
+ }
877
+
878
+ // Set the loaded environment variables in the active process
879
+ process.env.VAULT_MASTER_KEY = masterKeyStr;
880
+ process.env.DB_DIALECT = dbDialect;
881
+ process.env.DB_HOST = dbHost || 'localhost';
882
+ if (dbPort) process.env.DB_PORT = String(dbPort);
883
+ if (dbUsername) process.env.DB_USERNAME = dbUsername;
884
+ if (dbPassword) process.env.DB_PASSWORD = dbPassword;
885
+ if (dbName) process.env.DB_NAME = dbName;
886
+ if (dbStorage) process.env.DB_STORAGE = dbStorage;
887
+ process.env.DB_SSL = 'false';
888
+
889
+ // 3. Dynamically re-connect and synchronize Sequelize models on the target database
890
+ console.log('[Setup] Loading fresh database module to apply configuration...');
891
+ try {
892
+ const dbPath = require.resolve('./db.cjs');
893
+ delete require.cache[dbPath];
894
+ } catch (_) {}
895
+ const db = require('./db.cjs');
896
+
897
+ db.updateDbConnection({
898
+ dialect: dbDialect,
899
+ host: dbHost,
900
+ port: dbPort,
901
+ username: dbUsername,
902
+ password: dbPassword,
903
+ database: dbName,
904
+ storage: dbStorage,
905
+ ssl: false
906
+ });
907
+
908
+ // Initialize database, run migrations and sync models
909
+ await db.initDb();
910
+
911
+ // 4. Seed Admin user
912
+ const adminHash = crypto.createHash('sha256').update(adminPassword).digest('hex');
913
+ let adminUser = await db.User.findOne({ where: { username: 'admin' } });
914
+ if (adminUser) {
915
+ adminUser.passwordHash = adminHash;
916
+ await adminUser.save();
917
+ } else {
918
+ await db.User.create({
919
+ username: 'admin',
920
+ passwordHash: adminHash,
921
+ role: 'admin'
922
+ });
923
+ }
924
+
925
+ // 5. Seed default configurations
926
+ await db.SystemConfig.findOrCreate({ where: { key: 'siem_webhook_url' }, defaults: { value: '' } });
927
+ await db.SystemConfig.findOrCreate({ where: { key: 'siem_webhook_secret' }, defaults: { value: crypto.randomBytes(32).toString('hex') } });
928
+ await db.SystemConfig.findOrCreate({ where: { key: 'audit_logging_enabled' }, defaults: { value: 'true' } });
929
+
930
+ res.json({ success: true });
931
+
932
+ console.log("[Setup] Installation successful. Restarting server to apply new configuration...");
933
+ setTimeout(() => {
934
+ if (activeWss) {
935
+ try { activeWss.close(); } catch (_) {}
936
+ }
937
+ if (activeHttpServer) {
938
+ try {
939
+ if (typeof activeHttpServer.closeAllConnections === 'function') {
940
+ activeHttpServer.closeAllConnections();
941
+ }
942
+ activeHttpServer.close(() => {
943
+ const isServerOrCli = process.argv[1] && (process.argv[1].endsWith('server.cjs') || process.argv[1].endsWith('cli.js'));
944
+ if (isServerOrCli) {
945
+ const { spawn } = require('child_process');
946
+ const child = spawn(process.argv[0], process.argv.slice(1), {
947
+ stdio: 'inherit'
948
+ });
949
+ child.on('close', (code) => {
950
+ process.exit(code || 0);
951
+ });
952
+ } else {
953
+ process.exit(0);
954
+ }
955
+ });
956
+ } catch (_) {
957
+ process.exit(0);
958
+ }
959
+ } else {
960
+ process.exit(0);
961
+ }
962
+ }, 1000);
963
+ } catch (err) {
964
+ console.error("Installation failed:", err);
965
+ res.status(500).json({ error: err.message });
966
+ }
967
+ });
968
+
969
+ // Admin Authentication Verification Probe
970
+ app.post('/admin/api/verify-auth', authLimiter, async (req, res) => {
971
+ const username = req.body.username || 'admin';
972
+ const password = req.body.password;
973
+
974
+ // Check if locked
975
+ const now = Date.now();
976
+ const attempt = loginAttempts.get(username);
977
+ if (attempt && attempt.lockUntil > now) {
978
+ const waitMins = Math.ceil((attempt.lockUntil - now) / 60000);
979
+ return res.status(429).json({ error: `Account locked. Try again in ${waitMins} minutes.` });
980
+ }
981
+
982
+ if (!password) {
983
+ return res.status(401).json({ error: "Password required" });
984
+ }
985
+
986
+ const hash = crypto.createHash('sha256').update(password).digest('hex');
987
+ const user = await User.findOne({ where: { username } });
988
+
989
+ if (user && user.passwordHash === hash) {
990
+ if (user.mfa_enabled) {
991
+ const challengeId = crypto.randomBytes(32).toString('hex');
992
+ await MfaChallenge.create({
993
+ id: challengeId,
994
+ userId: user.id,
995
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
996
+ consumed: false
997
+ });
998
+ return res.json({ mfaRequired: true, challengeId });
999
+ }
1000
+
1001
+ // Reset login attempts
1002
+ loginAttempts.delete(username);
1003
+
1004
+ // Generate Session ID
1005
+ const sessionId = crypto.randomBytes(32).toString('hex');
1006
+ const csrfToken = crypto.randomBytes(16).toString('hex');
1007
+ const expiresAt = Date.now() + 8 * 60 * 60 * 1000; // 8 hours
1008
+
1009
+ // Soft cap check
1010
+ const MAX_SESSIONS_PER_USER = 5;
1011
+ const activeSessions = await AdminSession.findAll({
1012
+ where: { userId: user.id },
1013
+ order: [['createdAt', 'ASC']]
1014
+ });
1015
+ if (activeSessions.length >= MAX_SESSIONS_PER_USER) {
1016
+ const deleteCount = activeSessions.length - MAX_SESSIONS_PER_USER + 1;
1017
+ for (let i = 0; i < deleteCount; i++) {
1018
+ await activeSessions[i].destroy();
1019
+ }
1020
+ }
1021
+
1022
+ const sessionHash = crypto.createHash('sha256').update(sessionId).digest('hex');
1023
+ const clientIp = req.ip || req.connection.remoteAddress || '127.0.0.1';
1024
+ const userAgent = req.headers['user-agent'] || 'Unknown';
1025
+
1026
+ await AdminSession.create({
1027
+ session_hash: sessionHash,
1028
+ userId: user.id,
1029
+ ipAddress: clientIp,
1030
+ userAgent: userAgent,
1031
+ csrfToken,
1032
+ expiresAt: new Date(expiresAt)
1033
+ });
1034
+
1035
+ // Set cookie
1036
+ res.setHeader('Set-Cookie', `vault_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800`);
1037
+
1038
+ // Add audit log
1039
+ await addAuditLog(username, "Administrator logged in successfully");
1040
+
1041
+ return res.json({ success: true, csrfToken });
1042
+ } else {
1043
+ // Increment failed login attempt
1044
+ let count = 1;
1045
+ let lockUntil = 0;
1046
+ if (attempt) {
1047
+ count = attempt.count + 1;
1048
+ if (count >= 5) {
1049
+ lockUntil = now + 15 * 60 * 1000; // 15 minutes lock
1050
+ console.log(`[AUTH] Locking user "${username}" for 15 minutes due to 5 consecutive failures.`);
1051
+ }
1052
+ }
1053
+ loginAttempts.set(username, { count, lockUntil });
1054
+
1055
+ await addAuditLog(username, `Failed login attempt (${count}/5)`, "error");
1056
+
1057
+ if (count >= 5) {
1058
+ return res.status(429).json({ error: "Account locked. Try again in 15 minutes." });
1059
+ }
1060
+ return res.status(401).json({ error: "Invalid password" });
1061
+ }
1062
+ });
1063
+
1064
+ // Admin Logout Endpoint
1065
+ app.post('/admin/api/logout', authMiddleware, async (req, res) => {
1066
+ if (req.sessionId) {
1067
+ await AdminSession.destroy({ where: { id: req.sessionId } });
1068
+ }
1069
+ // Clear cookie
1070
+ res.setHeader('Set-Cookie', 'vault_session=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0');
1071
+ res.json({ success: true });
1072
+ });
1073
+
1074
+ // Admin Password Update Endpoint
1075
+ app.post('/admin/api/change-password', authMiddleware, async (req, res) => {
1076
+ const { oldPassword, newPassword } = req.body;
1077
+ const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex');
1078
+
1079
+ if (req.adminUser.passwordHash !== oldHash) {
1080
+ return res.status(400).json({ error: "Current admin password does not match." });
1081
+ }
1082
+ if (!newPassword || newPassword.trim().length < 4) {
1083
+ return res.status(400).json({ error: "Password must be at least 4 characters long." });
1084
+ }
1085
+
1086
+ const newHash = crypto.createHash('sha256').update(newPassword.trim()).digest('hex');
1087
+ req.adminUser.passwordHash = newHash;
1088
+ await req.adminUser.save();
1089
+ await addAuditLog(
1090
+ req.adminUser.username,
1091
+ JSON.stringify({
1092
+ action: "password_changed",
1093
+ performedBy: req.adminUser.id,
1094
+ performedByUsername: req.adminUser.username
1095
+ })
1096
+ );
1097
+ res.json({ success: true });
1098
+ });
1099
+
1100
+ // ── Multi-Factor Authentication (MFA) APIs ───────────────────────────
1101
+ app.post('/admin/api/mfa/enroll', authMiddleware, async (req, res) => {
1102
+ try {
1103
+ const user = req.adminUser;
1104
+ const rawSecret = crypto.randomBytes(20);
1105
+ const secretBase32 = base32Encode(rawSecret);
1106
+
1107
+ // Encrypt the secret
1108
+ const secretEncrypted = encrypt(secretBase32);
1109
+ user.mfa_secret_encrypted = secretEncrypted;
1110
+ await user.save();
1111
+
1112
+ const QRCode = require('qrcode');
1113
+ const otpAuthUrl = `otpauth://totp/FilePilot:${user.username}?secret=${secretBase32}&issuer=FilePilot`;
1114
+ const qrCodeDataUri = await QRCode.toDataURL(otpAuthUrl);
1115
+
1116
+ res.json({
1117
+ success: true,
1118
+ qrCode: qrCodeDataUri,
1119
+ secret: secretBase32
1120
+ });
1121
+ } catch (err) {
1122
+ console.error("MFA enroll failed:", err);
1123
+ res.status(500).json({ error: err.message });
1124
+ }
1125
+ });
1126
+
1127
+ app.post('/admin/api/mfa/enroll/verify', authMiddleware, async (req, res) => {
1128
+ try {
1129
+ const { code } = req.body;
1130
+ if (!code) {
1131
+ return res.status(400).json({ error: "Verification code required" });
1132
+ }
1133
+ const user = req.adminUser;
1134
+ if (!user.mfa_secret_encrypted) {
1135
+ return res.status(400).json({ error: "MFA enrollment not initiated" });
1136
+ }
1137
+
1138
+ const secretBase32 = decrypt(user.mfa_secret_encrypted);
1139
+ const isValid = verifyTotp(secretBase32, code);
1140
+ if (!isValid) {
1141
+ return res.status(400).json({ error: "Invalid verification code. Please try again." });
1142
+ }
1143
+
1144
+ // Generate 10 single-use backup codes
1145
+ const rawBackupCodes = [];
1146
+ const hashedBackupCodes = [];
1147
+ for (let i = 0; i < 10; i++) {
1148
+ const rawCode = crypto.randomBytes(6).toString('hex');
1149
+ const hashed = crypto.createHash('sha256').update(rawCode).digest('hex');
1150
+ rawBackupCodes.push(rawCode);
1151
+ hashedBackupCodes.push(hashed);
1152
+ }
1153
+
1154
+ user.mfa_enabled = true;
1155
+ user.mfa_backup_codes_hash = JSON.stringify(hashedBackupCodes);
1156
+ await user.save();
1157
+
1158
+ await addAuditLog(
1159
+ user.username,
1160
+ JSON.stringify({
1161
+ action: "mfa_enabled",
1162
+ userId: user.id
1163
+ })
1164
+ );
1165
+
1166
+ res.json({
1167
+ success: true,
1168
+ backupCodes: rawBackupCodes
1169
+ });
1170
+ } catch (err) {
1171
+ console.error("MFA enroll verify failed:", err);
1172
+ res.status(500).json({ error: err.message });
1173
+ }
1174
+ });
1175
+
1176
+ app.post('/admin/api/mfa/disable', authMiddleware, async (req, res) => {
1177
+ try {
1178
+ const { password, code } = req.body;
1179
+ if (!password || !code) {
1180
+ return res.status(400).json({ error: "Password and code are required." });
1181
+ }
1182
+ const user = req.adminUser;
1183
+
1184
+ const passHash = crypto.createHash('sha256').update(password).digest('hex');
1185
+ if (user.passwordHash !== passHash) {
1186
+ return res.status(400).json({ error: "Invalid password." });
1187
+ }
1188
+
1189
+ let isValid = false;
1190
+ if (user.mfa_secret_encrypted) {
1191
+ const secretBase32 = decrypt(user.mfa_secret_encrypted);
1192
+ if (verifyTotp(secretBase32, code)) {
1193
+ isValid = true;
1194
+ }
1195
+ }
1196
+
1197
+ if (!isValid && user.mfa_backup_codes_hash) {
1198
+ const backupHashes = JSON.parse(user.mfa_backup_codes_hash);
1199
+ const inputHash = crypto.createHash('sha256').update(code).digest('hex');
1200
+ const idx = backupHashes.indexOf(inputHash);
1201
+ if (idx !== -1) {
1202
+ isValid = true;
1203
+ backupHashes.splice(idx, 1);
1204
+ user.mfa_backup_codes_hash = JSON.stringify(backupHashes);
1205
+ }
1206
+ }
1207
+
1208
+ if (!isValid) {
1209
+ return res.status(400).json({ error: "Invalid verification code or backup code." });
1210
+ }
1211
+
1212
+ user.mfa_enabled = false;
1213
+ user.mfa_secret_encrypted = null;
1214
+ user.mfa_backup_codes_hash = null;
1215
+ await user.save();
1216
+
1217
+ await addAuditLog(
1218
+ user.username,
1219
+ JSON.stringify({
1220
+ action: "mfa_disabled",
1221
+ userId: user.id
1222
+ })
1223
+ );
1224
+
1225
+ res.json({ success: true });
1226
+ } catch (err) {
1227
+ console.error("MFA disable failed:", err);
1228
+ res.status(500).json({ error: err.message });
1229
+ }
1230
+ });
1231
+
1232
+ app.post('/admin/api/mfa/regenerate-backup-codes', authMiddleware, async (req, res) => {
1233
+ try {
1234
+ const { password, code } = req.body;
1235
+ if (!password || !code) {
1236
+ return res.status(400).json({ error: "Password and code are required." });
1237
+ }
1238
+ const user = req.adminUser;
1239
+
1240
+ const passHash = crypto.createHash('sha256').update(password).digest('hex');
1241
+ if (user.passwordHash !== passHash) {
1242
+ return res.status(400).json({ error: "Invalid password." });
1243
+ }
1244
+
1245
+ let isValid = false;
1246
+ if (user.mfa_secret_encrypted) {
1247
+ const secretBase32 = decrypt(user.mfa_secret_encrypted);
1248
+ if (verifyTotp(secretBase32, code)) {
1249
+ isValid = true;
1250
+ }
1251
+ }
1252
+
1253
+ if (!isValid && user.mfa_backup_codes_hash) {
1254
+ const backupHashes = JSON.parse(user.mfa_backup_codes_hash);
1255
+ const inputHash = crypto.createHash('sha256').update(code).digest('hex');
1256
+ const idx = backupHashes.indexOf(inputHash);
1257
+ if (idx !== -1) {
1258
+ isValid = true;
1259
+ backupHashes.splice(idx, 1);
1260
+ user.mfa_backup_codes_hash = JSON.stringify(backupHashes);
1261
+ }
1262
+ }
1263
+
1264
+ if (!isValid) {
1265
+ return res.status(400).json({ error: "Invalid verification code or backup code." });
1266
+ }
1267
+
1268
+ const rawBackupCodes = [];
1269
+ const hashedBackupCodes = [];
1270
+ for (let i = 0; i < 10; i++) {
1271
+ const rawCode = crypto.randomBytes(6).toString('hex');
1272
+ const hashed = crypto.createHash('sha256').update(rawCode).digest('hex');
1273
+ rawBackupCodes.push(rawCode);
1274
+ hashedBackupCodes.push(hashed);
1275
+ }
1276
+
1277
+ user.mfa_backup_codes_hash = JSON.stringify(hashedBackupCodes);
1278
+ await user.save();
1279
+
1280
+ await addAuditLog(
1281
+ user.username,
1282
+ JSON.stringify({
1283
+ action: "mfa_backup_codes_regenerated",
1284
+ userId: user.id
1285
+ })
1286
+ );
1287
+
1288
+ res.json({
1289
+ success: true,
1290
+ backupCodes: rawBackupCodes
1291
+ });
1292
+ } catch (err) {
1293
+ console.error("MFA regenerate backup codes failed:", err);
1294
+ res.status(500).json({ error: err.message });
1295
+ }
1296
+ });
1297
+
1298
+ app.post('/admin/api/mfa/verify-login', authLimiter, async (req, res) => {
1299
+ try {
1300
+ const { challengeId, code } = req.body;
1301
+ if (!challengeId || !code) {
1302
+ return res.status(400).json({ error: "Challenge ID and code are required." });
1303
+ }
1304
+
1305
+ const challenge = await MfaChallenge.findByPk(challengeId);
1306
+ if (!challenge || challenge.consumed || new Date(challenge.expiresAt) < new Date()) {
1307
+ return res.status(400).json({ error: "Invalid or expired login challenge." });
1308
+ }
1309
+
1310
+ const user = await User.findByPk(challenge.userId);
1311
+ if (!user) {
1312
+ return res.status(404).json({ error: "User not found." });
1313
+ }
1314
+
1315
+ const username = user.username;
1316
+
1317
+ // Check lock
1318
+ const now = Date.now();
1319
+ const attempt = loginAttempts.get(username);
1320
+ if (attempt && attempt.lockUntil > now) {
1321
+ const waitMins = Math.ceil((attempt.lockUntil - now) / 60000);
1322
+ return res.status(429).json({ error: `Account locked. Try again in ${waitMins} minutes.` });
1323
+ }
1324
+
1325
+ let isValid = false;
1326
+ if (user.mfa_secret_encrypted) {
1327
+ const secretBase32 = decrypt(user.mfa_secret_encrypted);
1328
+ if (verifyTotp(secretBase32, code)) {
1329
+ isValid = true;
1330
+ }
1331
+ }
1332
+
1333
+ if (!isValid && user.mfa_backup_codes_hash) {
1334
+ const backupHashes = JSON.parse(user.mfa_backup_codes_hash);
1335
+ const inputHash = crypto.createHash('sha256').update(code).digest('hex');
1336
+ const idx = backupHashes.indexOf(inputHash);
1337
+ if (idx !== -1) {
1338
+ isValid = true;
1339
+ backupHashes.splice(idx, 1);
1340
+ user.mfa_backup_codes_hash = JSON.stringify(backupHashes);
1341
+ await user.save();
1342
+ }
1343
+ }
1344
+
1345
+ if (!isValid) {
1346
+ let count = 1;
1347
+ let lockUntil = 0;
1348
+ if (attempt) {
1349
+ count = attempt.count + 1;
1350
+ if (count >= 5) {
1351
+ lockUntil = now + 15 * 60 * 1000;
1352
+ }
1353
+ }
1354
+ loginAttempts.set(username, { count, lockUntil });
1355
+ await addAuditLog(username, `Failed MFA login attempt (${count}/5)`, "error");
1356
+
1357
+ if (count >= 5) {
1358
+ return res.status(429).json({ error: "Account locked. Try again in 15 minutes." });
1359
+ }
1360
+ return res.status(401).json({ error: "Invalid MFA code" });
1361
+ }
1362
+
1363
+ loginAttempts.delete(username);
1364
+ challenge.consumed = true;
1365
+ await challenge.save();
1366
+
1367
+ const sessionId = crypto.randomBytes(32).toString('hex');
1368
+ const csrfToken = crypto.randomBytes(16).toString('hex');
1369
+ const expiresAt = Date.now() + 8 * 60 * 60 * 1000;
1370
+
1371
+ const MAX_SESSIONS_PER_USER = 5;
1372
+ const activeSessions = await AdminSession.findAll({
1373
+ where: { userId: user.id },
1374
+ order: [['createdAt', 'ASC']]
1375
+ });
1376
+ if (activeSessions.length >= MAX_SESSIONS_PER_USER) {
1377
+ const deleteCount = activeSessions.length - MAX_SESSIONS_PER_USER + 1;
1378
+ for (let i = 0; i < deleteCount; i++) {
1379
+ await activeSessions[i].destroy();
1380
+ }
1381
+ }
1382
+
1383
+ const sessionHash = crypto.createHash('sha256').update(sessionId).digest('hex');
1384
+ const clientIp = req.ip || req.connection.remoteAddress || '127.0.0.1';
1385
+ const userAgent = req.headers['user-agent'] || 'Unknown';
1386
+
1387
+ await AdminSession.create({
1388
+ session_hash: sessionHash,
1389
+ userId: user.id,
1390
+ ipAddress: clientIp,
1391
+ userAgent,
1392
+ csrfToken,
1393
+ expiresAt: new Date(expiresAt)
1394
+ });
1395
+
1396
+ res.setHeader('Set-Cookie', `vault_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=28800`);
1397
+ await addAuditLog(username, "Administrator logged in successfully (MFA verified)");
1398
+
1399
+ return res.json({ success: true, csrfToken });
1400
+ } catch (err) {
1401
+ console.error("MFA verify login failed:", err);
1402
+ res.status(500).json({ error: err.message });
1403
+ }
1404
+ });
1405
+
1406
+ if (process.env.NODE_ENV === 'test') {
1407
+ app.post('/admin/api/test/reset-lockout', async (req, res) => {
1408
+ const { username } = req.body;
1409
+ loginAttempts.delete(username);
1410
+ res.json({ success: true });
1411
+ });
1412
+ }
1413
+
1414
+ // SIEM & SMTP Webhook Config Update Endpoint
1415
+ app.post('/admin/api/settings', authMiddleware, async (req, res) => {
1416
+ const {
1417
+ siem_webhook_url,
1418
+ siem_webhook_secret,
1419
+ audit_logging_enabled,
1420
+ smtp_host,
1421
+ smtp_port,
1422
+ smtp_username,
1423
+ smtp_password,
1424
+ smtp_sender,
1425
+ backup_retention_limit,
1426
+ backup_enabled
1427
+ } = req.body;
1428
+
1429
+ const hasSiemFields = siem_webhook_url !== undefined || siem_webhook_secret !== undefined;
1430
+ const hasSystemFields = audit_logging_enabled !== undefined ||
1431
+ smtp_host !== undefined || smtp_port !== undefined ||
1432
+ smtp_username !== undefined || smtp_password !== undefined ||
1433
+ smtp_sender !== undefined || backup_retention_limit !== undefined ||
1434
+ backup_enabled !== undefined;
1435
+
1436
+ if (hasSiemFields && !hasPermission(req.adminUser, 'siem.configure')) {
1437
+ return res.status(403).json({ error: "Forbidden: Missing required permission 'siem.configure'" });
1438
+ }
1439
+
1440
+ if (hasSystemFields && !hasPermission(req.adminUser, 'system.configure')) {
1441
+ return res.status(403).json({ error: "Forbidden: Missing required permission 'system.configure'" });
1442
+ }
1443
+
1444
+ if (siem_webhook_url !== undefined) {
1445
+ const [config] = await SystemConfig.findOrCreate({ where: { key: 'siem_webhook_url' } });
1446
+ config.value = siem_webhook_url || "";
1447
+ await config.save();
1448
+ }
1449
+
1450
+ if (siem_webhook_secret !== undefined) {
1451
+ const [config] = await SystemConfig.findOrCreate({ where: { key: 'siem_webhook_secret' } });
1452
+ config.value = siem_webhook_secret || "";
1453
+ await config.save();
1454
+ }
1455
+
1456
+ if (audit_logging_enabled !== undefined) {
1457
+ const [config] = await SystemConfig.findOrCreate({ where: { key: 'audit_logging_enabled' } });
1458
+ config.value = String(audit_logging_enabled);
1459
+ await config.save();
1460
+ }
1461
+
1462
+ if (backup_retention_limit !== undefined) {
1463
+ const [config] = await SystemConfig.findOrCreate({ where: { key: 'backup_retention_limit' } });
1464
+ config.value = String(backup_retention_limit);
1465
+ await config.save();
1466
+ }
1467
+
1468
+ if (backup_enabled !== undefined) {
1469
+ const [config] = await SystemConfig.findOrCreate({ where: { key: 'backup_enabled' } });
1470
+ config.value = String(backup_enabled);
1471
+ await config.save();
1472
+ }
1473
+
1474
+ const smtpFields = { smtp_host, smtp_port, smtp_username, smtp_password, smtp_sender };
1475
+ for (const [key, val] of Object.entries(smtpFields)) {
1476
+ if (val !== undefined) {
1477
+ const [config] = await SystemConfig.findOrCreate({ where: { key } });
1478
+ config.value = String(val) || "";
1479
+ await config.save();
1480
+ }
1481
+ }
1482
+
1483
+ await addAuditLog(
1484
+ req.adminUser.username,
1485
+ JSON.stringify({
1486
+ action: "settings_updated",
1487
+ performedBy: req.adminUser.id,
1488
+ performedByUsername: req.adminUser.username
1489
+ })
1490
+ );
1491
+ res.json({ success: true });
1492
+ });
1493
+
1494
+ // Maintenance API: Clear all stored file backups from disk and DB
1495
+ app.post('/admin/api/maintenance/clear-backups', authMiddleware, requirePermission('system.configure'), async (req, res) => {
1496
+ try {
1497
+ const activeHolds = await VaultGroup.findAll({ where: { legal_hold_active: true } });
1498
+ const heldGroupIds = activeHolds.map(g => g.id);
1499
+
1500
+ const list = await FileVersion.findAll();
1501
+ for (const f of list) {
1502
+ let isHeld = false;
1503
+ let profile = await ConnectionProfile.findByPk(f.connectionId);
1504
+ if (!profile) {
1505
+ profile = await ConnectionProfile.findOne({ where: { clientProfileId: f.connectionId } });
1506
+ }
1507
+ if (profile && heldGroupIds.includes(profile.groupId)) {
1508
+ isHeld = true;
1509
+ }
1510
+ if (isHeld) {
1511
+ continue;
1512
+ }
1513
+
1514
+ if (f.backupPath && fs.existsSync(f.backupPath)) {
1515
+ try {
1516
+ fs.unlinkSync(f.backupPath);
1517
+ } catch (_) {}
1518
+ }
1519
+ await f.destroy();
1520
+ }
1521
+
1522
+ // Clean up empty connection subdirectories
1523
+ const backupsDir = path.join(DATA_DIR, 'backups');
1524
+ if (fs.existsSync(backupsDir)) {
1525
+ try {
1526
+ const dirs = fs.readdirSync(backupsDir);
1527
+ for (const d of dirs) {
1528
+ const sub = path.join(backupsDir, d);
1529
+ if (fs.statSync(sub).isDirectory()) {
1530
+ fs.rmdirSync(sub);
1531
+ }
1532
+ }
1533
+ } catch (_) {}
1534
+ }
1535
+
1536
+ await addAuditLog(
1537
+ req.adminUser.username,
1538
+ JSON.stringify({
1539
+ action: "backups_purged",
1540
+ performedBy: req.adminUser.id,
1541
+ performedByUsername: req.adminUser.username
1542
+ })
1543
+ );
1544
+ res.json({ success: true, message: "All stored file version backups have been deleted." });
1545
+ } catch (err) {
1546
+ res.status(500).json({ error: err.message });
1547
+ }
1548
+ });
1549
+
1550
+ // Maintenance API: Archive security audit logs older than cutoff_date
1551
+ app.post('/admin/api/maintenance/clear-logs', authMiddleware, requirePermission('system.configure'), async (req, res) => {
1552
+ try {
1553
+ const { cutoff_date } = req.body;
1554
+ if (!cutoff_date) {
1555
+ return res.status(400).json({ error: "Missing cutoff_date parameter" });
1556
+ }
1557
+ const cutoff = new Date(cutoff_date);
1558
+ if (isNaN(cutoff.getTime())) {
1559
+ return res.status(400).json({ error: "Invalid cutoff_date format" });
1560
+ }
1561
+
1562
+ const { Op } = require('sequelize');
1563
+
1564
+ const activeHolds = await VaultGroup.findAll({ where: { legal_hold_active: true } });
1565
+ const heldGroupIds = activeHolds.map(g => g.id);
1566
+ const heldKeys = await AccessKey.findAll({ where: { groupId: { [Op.in]: heldGroupIds } } });
1567
+ const heldTokens = heldKeys.map(k => k.token_hash);
1568
+
1569
+ const heldProfiles = await ConnectionProfile.findAll({ where: { groupId: { [Op.in]: heldGroupIds } } });
1570
+ const heldProfileIds = heldProfiles.map(p => p.id);
1571
+
1572
+ const whereClause = {
1573
+ createdAt: { [Op.lt]: cutoff },
1574
+ archived: false,
1575
+ [Op.and]: [
1576
+ {
1577
+ metadata: {
1578
+ [Op.or]: [
1579
+ { [Op.eq]: null },
1580
+ { [Op.notLike]: '%legal_hold_%' }
1581
+ ]
1582
+ }
1583
+ }
1584
+ ]
1585
+ };
1586
+
1587
+ if (heldTokens.length > 0) {
1588
+ whereClause[Op.and].push({
1589
+ token: { [Op.or]: [ { [Op.eq]: null }, { [Op.notIn]: heldTokens } ] }
1590
+ });
1591
+ }
1592
+
1593
+ if (heldProfileIds.length > 0) {
1594
+ whereClause[Op.and].push({
1595
+ connectionId: { [Op.or]: [ { [Op.eq]: null }, { [Op.notIn]: heldProfileIds } ] }
1596
+ });
1597
+ }
1598
+
1599
+ // Count how many we are archiving
1600
+ const count = await TransferLog.count({
1601
+ where: whereClause
1602
+ });
1603
+
1604
+ // Update to archived
1605
+ await TransferLog.update(
1606
+ { archived: true },
1607
+ {
1608
+ where: whereClause
1609
+ }
1610
+ );
1611
+
1612
+ // Create a new audit log entry recording this archiving action
1613
+ await addAuditLog(
1614
+ req.adminUser.username,
1615
+ JSON.stringify({
1616
+ action: "logs_archived",
1617
+ performedBy: req.adminUser.id,
1618
+ performedByUsername: req.adminUser.username,
1619
+ cutoffDate: cutoff.toISOString(),
1620
+ entriesArchived: count
1621
+ })
1622
+ );
1623
+
1624
+ res.json({
1625
+ success: true,
1626
+ message: `Successfully archived ${count} logs older than ${cutoff.toLocaleDateString()}.`,
1627
+ archivedCount: count
1628
+ });
1629
+ } catch (err) {
1630
+ res.status(500).json({ error: err.message });
1631
+ }
1632
+ });
1633
+
1634
+
1635
+ // ── Audit Logs CSV/JSON Export Route ──────────────────────────────────
1636
+ app.get('/admin/api/audit/export', authMiddleware, requirePermission('audit.export'), async (req, res) => {
1637
+ const format = req.query.format || 'json';
1638
+ const includeArchived = req.query.include_archived === 'true';
1639
+ const whereClause = includeArchived ? {} : { archived: false };
1640
+
1641
+ const logs = await TransferLog.findAll({
1642
+ where: whereClause,
1643
+ order: [['createdAt', 'DESC']],
1644
+ limit: 500
1645
+ });
1646
+
1647
+ const formattedLogs = formatLogsForExport(logs);
1648
+
1649
+ if (format === 'csv') {
1650
+ let csv = 'Timestamp,Event Scope,Action Log,Result\n';
1651
+ formattedLogs.forEach(l => {
1652
+ const escapedAction = `"${l.action.replace(/"/g, '""')}"`;
1653
+ csv += `${l.timestamp},"${l.token_desc}",${escapedAction},${l.status}\n`;
1654
+ });
1655
+ res.setHeader('Content-Type', 'text/csv');
1656
+ res.setHeader('Content-Disposition', 'attachment; filename=filepilot_audit_logs.csv');
1657
+ return res.send(csv);
1658
+ }
1659
+
1660
+ res.json(formattedLogs);
1661
+ });
1662
+
1663
+ function computeLogHash(log, expectedPrevHash) {
1664
+ let logFields;
1665
+ if (!log.hash_version || log.hash_version === 1) {
1666
+ logFields = {
1667
+ token: log.token || null,
1668
+ username: log.username || null,
1669
+ connectionId: log.connectionId,
1670
+ filePath: log.filePath,
1671
+ fileSize: log.fileSize ? parseInt(log.fileSize) : 0,
1672
+ action: log.action,
1673
+ status: log.status || 'success',
1674
+ errorMessage: log.metadata || log.errorMessage || null,
1675
+ prev_hash: expectedPrevHash
1676
+ };
1677
+ } else {
1678
+ logFields = {
1679
+ token: log.token || null,
1680
+ username: log.username || null,
1681
+ connectionId: log.connectionId,
1682
+ filePath: log.filePath,
1683
+ fileSize: log.fileSize ? parseInt(log.fileSize) : 0,
1684
+ action: log.action,
1685
+ status: log.status || 'success',
1686
+ errorMessage: log.errorMessage || null,
1687
+ metadata: log.metadata || null,
1688
+ prev_hash: expectedPrevHash
1689
+ };
1690
+ }
1691
+ const hashInput = JSON.stringify(logFields);
1692
+ return crypto.createHash('sha256').update(hashInput).digest('hex');
1693
+ }
1694
+
1695
+ function formatLogsForExport(logs) {
1696
+ return logs.map(l => {
1697
+ let scopeDesc = l.username || 'System';
1698
+ if (l.token) {
1699
+ const displayToken = l.token.length > 8 ? `${l.token.substring(0, 8)}...` : l.token;
1700
+ if (l.username && l.username !== 'System' && l.username !== 'Anonymous Client') {
1701
+ scopeDesc = `${l.username} (${displayToken})`;
1702
+ } else {
1703
+ scopeDesc = displayToken;
1704
+ }
1705
+ }
1706
+ let actionDesc = l.errorMessage || '';
1707
+ const jsonStr = l.metadata || (l.errorMessage && l.errorMessage.startsWith('{') && l.errorMessage.endsWith('}') ? l.errorMessage : null);
1708
+ if (l.action === 'audit' && jsonStr) {
1709
+ try {
1710
+ const parsed = JSON.parse(jsonStr);
1711
+ if (parsed.action === 'logs_archived') {
1712
+ actionDesc = `Archived ${parsed.entriesArchived} logs older than ${new Date(parsed.cutoffDate).toLocaleDateString()} (Performed by admin: ${parsed.performedByUsername})`;
1713
+ } else if (parsed.action === 'kek_rotated') {
1714
+ actionDesc = `Rotated Vault KEK to version ${parsed.targetVersion} (Performed by admin: ${parsed.performedByUsername})`;
1715
+ } else if (parsed.action === 'user_created') {
1716
+ actionDesc = `Created user "${parsed.targetUser}" with role "${parsed.targetRole}" (Performed by admin: ${parsed.performedByUsername})`;
1717
+ } else if (parsed.action === 'user_updated') {
1718
+ actionDesc = `Updated user "${parsed.targetUser}" details (Performed by admin: ${parsed.performedByUsername})`;
1719
+ } else if (parsed.action === 'user_deleted') {
1720
+ actionDesc = `Deleted user "${parsed.targetUser}" (Performed by admin: ${parsed.performedByUsername})`;
1721
+ } else if (parsed.action === 'backups_purged') {
1722
+ actionDesc = `Purged all physical file backup versions and DB history (Performed by admin: ${parsed.performedByUsername})`;
1723
+ } else if (parsed.action === 'password_changed') {
1724
+ actionDesc = `Changed vault console password (Performed by admin: ${parsed.performedByUsername})`;
1725
+ } else if (parsed.action === 'settings_updated') {
1726
+ actionDesc = `Updated system settings (Performed by admin: ${parsed.performedByUsername})`;
1727
+ } else if (parsed.action === 'db_config_updated') {
1728
+ actionDesc = `Updated database configuration. Dialect set to ${parsed.dialect.toUpperCase()} (Performed by admin: ${parsed.performedByUsername})`;
1729
+ } else if (parsed.action === 'group_created') {
1730
+ actionDesc = `Created Vault Group "${parsed.groupName}" (Performed by admin: ${parsed.performedByUsername})`;
1731
+ } else if (parsed.action === 'group_updated') {
1732
+ actionDesc = `Updated Vault Group "${parsed.groupName}" details (Performed by admin: ${parsed.performedByUsername})`;
1733
+ } else if (parsed.action === 'group_deleted') {
1734
+ actionDesc = `Deleted Vault Group "${parsed.groupName}" (Performed by admin: ${parsed.performedByUsername})`;
1735
+ } else if (parsed.action === 'profile_created') {
1736
+ actionDesc = `Added profile "${parsed.profileName}" to group "${parsed.groupName}" (Performed by admin: ${parsed.performedByUsername})`;
1737
+ } else if (parsed.action === 'profile_updated') {
1738
+ actionDesc = `Updated profile "${parsed.profileName}" in group "${parsed.groupName}" (Performed by admin: ${parsed.performedByUsername})`;
1739
+ } else if (parsed.action === 'profile_deleted') {
1740
+ actionDesc = `Deleted profile "${parsed.profileName}" (Performed by admin: ${parsed.performedByUsername})`;
1741
+ } else if (parsed.action === 'token_issued') {
1742
+ actionDesc = `Issued Access Key for "${parsed.targetUser}" (Assigned: ${parsed.targetGroup}) (Performed by admin: ${parsed.performedByUsername})`;
1743
+ } else if (parsed.action === 'token_updated') {
1744
+ actionDesc = `Updated Access Key for "${parsed.targetUser}" (Performed by admin: ${parsed.performedByUsername})`;
1745
+ } else if (parsed.action === 'token_revoked') {
1746
+ actionDesc = `Revoked Access Key for "${parsed.tokenOwner}" (Performed by admin: ${parsed.performedByUsername})`;
1747
+ }
1748
+ } catch (e) {
1749
+ // fallback to raw errorMessage
1750
+ }
1751
+ } else if (l.action !== 'audit') {
1752
+ actionDesc = `${l.action.toUpperCase()}: ${l.filePath} (${l.status})${l.errorMessage ? ' - ' + l.errorMessage : ''}`;
1753
+ }
1754
+ return {
1755
+ timestamp: l.createdAt.toISOString(),
1756
+ token_desc: scopeDesc,
1757
+ action: actionDesc,
1758
+ status: l.status,
1759
+ archived: l.archived
1760
+ };
1761
+ });
1762
+ }
1763
+
1764
+ // ── Audit Logs Integrity Chain Verification ──────────────────────────
1765
+ app.get('/admin/api/audit/verify', authMiddleware, requirePermission('audit.export'), async (req, res) => {
1766
+ try {
1767
+ const logs = await TransferLog.findAll({ order: [['id', 'ASC']] });
1768
+
1769
+ let expectedPrevHash = '0';
1770
+ for (const log of logs) {
1771
+ if (log.prev_hash !== expectedPrevHash) {
1772
+ return res.json({
1773
+ intact: false,
1774
+ brokenAtId: log.id,
1775
+ reason: `prev_hash mismatch. Expected: ${expectedPrevHash}, Actual: ${log.prev_hash}`
1776
+ });
1777
+ }
1778
+
1779
+ const computedHash = computeLogHash(log, expectedPrevHash);
1780
+
1781
+ if (log.entry_hash !== computedHash) {
1782
+ return res.json({
1783
+ intact: false,
1784
+ brokenAtId: log.id,
1785
+ reason: `entry_hash mismatch. Expected: ${computedHash}, Actual: ${log.entry_hash}`
1786
+ });
1787
+ }
1788
+
1789
+ expectedPrevHash = computedHash;
1790
+ }
1791
+
1792
+ res.json({ intact: true });
1793
+ } catch (err) {
1794
+ console.error("Audit log verification error:", err);
1795
+ res.status(500).json({ error: err.message });
1796
+ }
1797
+ });
1798
+
1799
+ // ── Full Audit Logs Chain Verification (Archived + Active) ───────────
1800
+ app.get('/admin/api/audit/verify-full', authMiddleware, requirePermission('audit.export'), async (req, res) => {
1801
+ try {
1802
+ const logs = await TransferLog.findAll({ order: [['id', 'ASC']] });
1803
+
1804
+ let expectedPrevHash = '0';
1805
+ let activeCount = 0;
1806
+ let archivedCount = 0;
1807
+
1808
+ for (const log of logs) {
1809
+ if (log.prev_hash !== expectedPrevHash) {
1810
+ return res.json({
1811
+ intact: false,
1812
+ brokenAtId: log.id,
1813
+ reason: `prev_hash mismatch. Expected: ${expectedPrevHash}, Actual: ${log.prev_hash}`
1814
+ });
1815
+ }
1816
+
1817
+ const computedHash = computeLogHash(log, expectedPrevHash);
1818
+
1819
+ if (log.entry_hash !== computedHash) {
1820
+ return res.json({
1821
+ intact: false,
1822
+ brokenAtId: log.id,
1823
+ reason: `entry_hash mismatch. Computed: ${computedHash}, Actual: ${log.entry_hash}`
1824
+ });
1825
+ }
1826
+
1827
+ if (log.archived) {
1828
+ archivedCount++;
1829
+ } else {
1830
+ activeCount++;
1831
+ }
1832
+ expectedPrevHash = computedHash;
1833
+ }
1834
+
1835
+ res.json({
1836
+ intact: true,
1837
+ activeCount,
1838
+ archivedCount,
1839
+ totalCount: logs.length
1840
+ });
1841
+ } catch (err) {
1842
+ res.status(500).json({ error: err.message });
1843
+ }
1844
+ });
1845
+
1846
+ // ── User Management CRUD ──────────────────────────────────────────────
1847
+ app.get('/admin/api/users', authMiddleware, requirePermission('user.view'), async (req, res) => {
1848
+ const users = await User.findAll({
1849
+ attributes: ['id', 'username', 'role', 'mfa_enabled', 'createdAt']
1850
+ });
1851
+ res.json(users);
1852
+ });
1853
+
1854
+ app.post('/admin/api/users', authMiddleware, requirePermission('user.manage'), async (req, res) => {
1855
+ const { id, username, password, role } = req.body;
1856
+ if (!username) {
1857
+ return res.status(400).json({ error: "Username is required" });
1858
+ }
1859
+
1860
+ if (!id) {
1861
+ // Create new user
1862
+ if (!password) {
1863
+ return res.status(400).json({ error: "Password is required for new users" });
1864
+ }
1865
+ const hash = crypto.createHash('sha256').update(password).digest('hex');
1866
+ try {
1867
+ const newUser = await User.create({
1868
+ username,
1869
+ passwordHash: hash,
1870
+ role: role || 'operator'
1871
+ });
1872
+ await addAuditLog(
1873
+ req.adminUser.username,
1874
+ JSON.stringify({
1875
+ action: "user_created",
1876
+ performedBy: req.adminUser.id,
1877
+ performedByUsername: req.adminUser.username,
1878
+ targetUser: username,
1879
+ targetRole: role
1880
+ })
1881
+ );
1882
+ res.json({ success: true, user: { id: newUser.id, username: newUser.username, role: newUser.role } });
1883
+ } catch (e) {
1884
+ res.status(400).json({ error: "Username already exists" });
1885
+ }
1886
+ } else {
1887
+ // Edit existing user
1888
+ const user = await User.findByPk(id);
1889
+ if (!user) {
1890
+ return res.status(404).json({ error: "User not found" });
1891
+ }
1892
+ user.username = username;
1893
+ user.role = role || user.role;
1894
+ if (password) {
1895
+ user.passwordHash = crypto.createHash('sha256').update(password).digest('hex');
1896
+ }
1897
+ await user.save();
1898
+ await addAuditLog(
1899
+ req.adminUser.username,
1900
+ JSON.stringify({
1901
+ action: "user_updated",
1902
+ performedBy: req.adminUser.id,
1903
+ performedByUsername: req.adminUser.username,
1904
+ targetUser: username
1905
+ })
1906
+ );
1907
+ res.json({ success: true });
1908
+ }
1909
+ });
1910
+
1911
+ app.delete('/admin/api/users/:id', authMiddleware, requirePermission('user.manage'), async (req, res) => {
1912
+ const user = await User.findByPk(req.params.id);
1913
+ if (user) {
1914
+ if (user.username === 'admin') {
1915
+ return res.status(400).json({ error: "Cannot delete master administrator user" });
1916
+ }
1917
+ const username = user.username;
1918
+ await user.destroy();
1919
+ await addAuditLog(
1920
+ req.adminUser.username,
1921
+ JSON.stringify({
1922
+ action: "user_deleted",
1923
+ performedBy: req.adminUser.id,
1924
+ performedByUsername: req.adminUser.username,
1925
+ targetUser: username
1926
+ })
1927
+ );
1928
+ res.json({ success: true });
1929
+ } else {
1930
+ res.status(404).json({ error: "User not found" });
1931
+ }
1932
+ });
1933
+
1934
+ // User-Agent parser helper for sessions friendly labels
1935
+ function parseUserAgent(ua) {
1936
+ if (!ua) return 'Unknown Device';
1937
+
1938
+ let browser = 'Unknown Browser';
1939
+ let os = 'Unknown OS';
1940
+
1941
+ if (ua.includes('Windows')) os = 'Windows';
1942
+ else if (ua.includes('Macintosh') || ua.includes('Mac OS')) os = 'macOS';
1943
+ else if (ua.includes('Linux')) os = 'Linux';
1944
+ else if (ua.includes('Android')) os = 'Android';
1945
+ else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
1946
+
1947
+ if (ua.includes('Firefox')) browser = 'Firefox';
1948
+ else if (ua.includes('Edg')) browser = 'Microsoft Edge';
1949
+ else if (ua.includes('Chrome')) browser = 'Chrome';
1950
+ else if (ua.includes('Safari')) browser = 'Safari';
1951
+ else if (ua.includes('Postman')) browser = 'Postman';
1952
+
1953
+ return `${browser} on ${os}`;
1954
+ }
1955
+
1956
+ // ── Session & Device Management API Endpoints ────────────────────────
1957
+ app.get('/admin/api/sessions', authMiddleware, async (req, res) => {
1958
+ try {
1959
+ const sessionsList = await AdminSession.findAll({
1960
+ where: { userId: req.adminUser.id },
1961
+ order: [['createdAt', 'DESC']]
1962
+ });
1963
+
1964
+ const result = sessionsList.map(s => ({
1965
+ id: s.id,
1966
+ ipAddress: s.ipAddress,
1967
+ userAgent: parseUserAgent(s.userAgent),
1968
+ createdAt: s.createdAt,
1969
+ lastActivityAt: s.lastActivityAt,
1970
+ isCurrent: s.id === req.sessionId
1971
+ }));
1972
+ res.json(result);
1973
+ } catch (err) {
1974
+ res.status(500).json({ error: err.message });
1975
+ }
1976
+ });
1977
+
1978
+ app.get('/admin/api/sessions/all', authMiddleware, requirePermission('user.manage'), async (req, res) => {
1979
+ try {
1980
+ const sessionsList = await AdminSession.findAll({
1981
+ include: [User],
1982
+ order: [['createdAt', 'DESC']]
1983
+ });
1984
+
1985
+ const result = sessionsList.map(s => ({
1986
+ id: s.id,
1987
+ username: s.User ? s.User.username : 'Unknown',
1988
+ ipAddress: s.ipAddress,
1989
+ userAgent: parseUserAgent(s.userAgent),
1990
+ createdAt: s.createdAt,
1991
+ lastActivityAt: s.lastActivityAt,
1992
+ isCurrent: s.id === req.sessionId
1993
+ }));
1994
+ res.json(result);
1995
+ } catch (err) {
1996
+ res.status(500).json({ error: err.message });
1997
+ }
1998
+ });
1999
+
2000
+ app.delete('/admin/api/sessions/:id', authMiddleware, async (req, res) => {
2001
+ try {
2002
+ const session = await AdminSession.findByPk(req.params.id);
2003
+ if (!session) {
2004
+ return res.status(404).json({ error: "Session not found" });
2005
+ }
2006
+
2007
+ const isOwner = session.userId === req.adminUser.id;
2008
+ if (!isOwner && !hasPermission(req.adminUser, 'user.manage')) {
2009
+ return res.status(403).json({ error: "Forbidden: Missing user.manage permission to revoke other users' sessions" });
2010
+ }
2011
+
2012
+ await session.destroy();
2013
+ res.json({ success: true });
2014
+ } catch (err) {
2015
+ res.status(500).json({ error: err.message });
2016
+ }
2017
+ });
2018
+
2019
+ app.post('/admin/api/sessions/revoke-others', authMiddleware, async (req, res) => {
2020
+ try {
2021
+ const { Op } = require('sequelize');
2022
+ const deletedCount = await AdminSession.destroy({
2023
+ where: {
2024
+ userId: req.adminUser.id,
2025
+ id: {
2026
+ [Op.ne]: req.sessionId
2027
+ }
2028
+ }
2029
+ });
2030
+ res.json({ success: true, revokedCount: deletedCount });
2031
+ } catch (err) {
2032
+ res.status(500).json({ error: err.message });
2033
+ }
2034
+ });
2035
+
2036
+ // ── Enterprise Client Connection Session Management Endpoints ────────
2037
+ app.get('/admin/api/client-connections', authMiddleware, requirePermission('user.view'), async (req, res) => {
2038
+ try {
2039
+ const list = [];
2040
+ for (const [tokenHash, ws] of clients.entries()) {
2041
+ if (ws.clientConnection) {
2042
+ const key = await AccessKey.findByPk(tokenHash, { include: [User, VaultGroup] });
2043
+ list.push({
2044
+ tokenHash,
2045
+ username: key && key.User ? key.User.username : 'Unknown',
2046
+ groupName: key && key.VaultGroup ? key.VaultGroup.name : 'Unknown',
2047
+ connection: ws.clientConnection
2048
+ });
2049
+ }
2050
+ }
2051
+ res.json(list);
2052
+ } catch (err) {
2053
+ res.status(500).json({ error: err.message });
2054
+ }
2055
+ });
2056
+
2057
+ app.post('/admin/api/client-connections/disconnect', authMiddleware, requirePermission('user.manage'), async (req, res) => {
2058
+ try {
2059
+ const { tokenHash } = req.body;
2060
+ const ws = clients.get(tokenHash);
2061
+ if (ws) {
2062
+ console.log(`[WS] Terminating client session for token: ${tokenHash}`);
2063
+ ws.send(JSON.stringify({ type: "force_disconnect" }));
2064
+ res.json({ success: true });
2065
+ } else {
2066
+ res.status(404).json({ error: "Active client connection not found" });
2067
+ }
2068
+ } catch (err) {
2069
+ res.status(500).json({ error: err.message });
2070
+ }
2071
+ });
2072
+
2073
+
2074
+ // ── Master KEK Rotation ──────────────────────────────────────────────
2075
+ app.post('/admin/api/system/rotate-kek', authMiddleware, requirePermission('system.configure'), async (req, res) => {
2076
+ try {
2077
+ const { new_kek } = req.body;
2078
+ const { Op } = require('sequelize');
2079
+
2080
+ const activeVersion = await KeyVersion.max('version') || 1;
2081
+ const pendingGroups = await VaultGroup.findAll({
2082
+ where: {
2083
+ dek_version: { [Op.lt]: activeVersion }
2084
+ }
2085
+ });
2086
+
2087
+ let targetVersion;
2088
+ let targetKekBuffer;
2089
+ let resolvedNewKekHex = '';
2090
+
2091
+ if (pendingGroups.length > 0) {
2092
+ console.log(`[KEK Rotation] Resuming interrupted rotation for ${pendingGroups.length} VaultGroups to KEK version ${activeVersion}...`);
2093
+ targetVersion = activeVersion;
2094
+ targetKekBuffer = encryptionKey;
2095
+ } else {
2096
+ // Start a new rotation
2097
+ targetVersion = activeVersion + 1;
2098
+ const generatedKey = crypto.randomBytes(32).toString('hex');
2099
+ resolvedNewKekHex = new_kek || generatedKey;
2100
+
2101
+ if (resolvedNewKekHex.length === 64 && /^[0-9a-fA-F]+$/.test(resolvedNewKekHex)) {
2102
+ targetKekBuffer = Buffer.from(resolvedNewKekHex, 'hex');
2103
+ } else if (resolvedNewKekHex.length === 44 && /^[0-9a-zA-Z+/=]+$/.test(resolvedNewKekHex)) {
2104
+ targetKekBuffer = Buffer.from(resolvedNewKekHex, 'base64');
2105
+ } else {
2106
+ targetKekBuffer = Buffer.from(resolvedNewKekHex, 'utf8');
2107
+ }
2108
+
2109
+ // Idempotent insertion of KeyVersion
2110
+ await KeyVersion.findOrCreate({
2111
+ where: { version: targetVersion },
2112
+ defaults: { createdAt: new Date() }
2113
+ });
2114
+ }
2115
+
2116
+ const groups = await VaultGroup.findAll();
2117
+ console.log(`[KEK Rotation] Rotating VaultGroups to KEK version ${targetVersion}...`);
2118
+
2119
+ for (const group of groups) {
2120
+ if (group.dek_version === targetVersion) {
2121
+ continue; // Already processed
2122
+ }
2123
+
2124
+ // Unwrap DEK using the group's current KEK version
2125
+ const dek = await getGroupDek(group);
2126
+
2127
+ // Re-wrap DEK using the target KEK
2128
+ const newWrappedDek = wrapDek(dek, targetKekBuffer);
2129
+
2130
+ group.wrapped_dek = newWrappedDek;
2131
+ group.dek_version = targetVersion;
2132
+ await group.save();
2133
+ }
2134
+
2135
+ if (targetVersion > activeVersion) {
2136
+ // Cache the old key under activeVersion before overwriting encryptionKey
2137
+ const oldKek = await getKekByVersion(activeVersion);
2138
+ kekCache.set(activeVersion, oldKek);
2139
+
2140
+ // Retire the previous KEK version
2141
+ await KeyVersion.update(
2142
+ { retiredAt: new Date() },
2143
+ { where: { version: activeVersion } }
2144
+ );
2145
+
2146
+ // Update in-memory active key and cache
2147
+ encryptionKey = targetKekBuffer;
2148
+ kekCache.set(targetVersion, targetKekBuffer);
2149
+ }
2150
+
2151
+ await addAuditLog(
2152
+ req.adminUser.username,
2153
+ JSON.stringify({
2154
+ action: "kek_rotated",
2155
+ performedBy: req.adminUser.id,
2156
+ performedByUsername: req.adminUser.username,
2157
+ targetVersion: targetVersion
2158
+ })
2159
+ );
2160
+
2161
+ res.json({
2162
+ success: true,
2163
+ new_kek: resolvedNewKekHex,
2164
+ new_version: targetVersion,
2165
+ instructions: "Rotation successful. Please update your environment variables: set 'VAULT_MASTER_KEY' to the new key value, set 'VAULT_MASTER_KEY_PREVIOUS' to your old master key, and restart the server process. Once validated, you can safely remove 'VAULT_MASTER_KEY_PREVIOUS'."
2166
+ });
2167
+ } catch (err) {
2168
+ console.error("KEK Rotation failed:", err);
2169
+ res.status(500).json({ error: "KEK Rotation failed: " + err.message });
2170
+ }
2171
+ });
2172
+
2173
+ // ── Database Configuration Update ────────────────────────────────────
2174
+ app.post('/admin/api/config/db', authMiddleware, requirePermission('system.configure'), async (req, res) => {
2175
+ const { dialect, host, port, username, password, database, storage, ssl } = req.body;
2176
+ if (!dialect) {
2177
+ return res.status(400).json({ error: "Missing database dialect" });
2178
+ }
2179
+
2180
+ let defaultPort = 5432;
2181
+ let defaultUsername = '';
2182
+ let defaultDatabase = 'vault';
2183
+ if (dialect === 'mysql') {
2184
+ defaultPort = 3306;
2185
+ defaultUsername = 'root';
2186
+ defaultDatabase = 'filepilotenterprise';
2187
+ } else if (dialect === 'postgres') {
2188
+ defaultPort = 5432;
2189
+ defaultUsername = 'postgres';
2190
+ defaultDatabase = 'vault';
2191
+ }
2192
+
2193
+ const currentMasterKey = process.env.VAULT_MASTER_KEY || '';
2194
+
2195
+ const envContent = [
2196
+ `# FilePilot Corporate Vault Environment Configuration`,
2197
+ `VAULT_MASTER_KEY=${currentMasterKey}`,
2198
+ `DB_DIALECT=${dialect}`,
2199
+ `DB_HOST=${host || 'localhost'}`,
2200
+ `DB_PORT=${port ? parseInt(port) : defaultPort}`,
2201
+ `DB_USERNAME=${username || ''}`,
2202
+ `DB_PASSWORD=${password || ''}`,
2203
+ `DB_NAME=${database || defaultDatabase}`,
2204
+ `DB_STORAGE=${storage || path.join(DATA_DIR, 'vault.db')}`,
2205
+ `DB_SSL=${ssl ? 'true' : 'false'}`
2206
+ ].join('\n');
2207
+
2208
+ const envFilePath = path.join(DATA_DIR, '.env');
2209
+ fs.writeFileSync(envFilePath, envContent, 'utf8');
2210
+
2211
+ // Clean up legacy files to avoid confusion
2212
+ if (fs.existsSync(configPath)) {
2213
+ try { fs.unlinkSync(configPath); } catch (_) {}
2214
+ }
2215
+ await addAuditLog(
2216
+ req.adminUser.username,
2217
+ JSON.stringify({
2218
+ action: "db_config_updated",
2219
+ performedBy: req.adminUser.id,
2220
+ performedByUsername: req.adminUser.username,
2221
+ dialect: dialect
2222
+ })
2223
+ );
2224
+
2225
+ res.json({ success: true, message: "Database config saved. Please restart the vault server to apply settings." });
2226
+ });
2227
+
2228
+ // Get current user profile and permissions
2229
+ app.get('/admin/api/me', authMiddleware, (req, res) => {
2230
+ const role = req.adminUser.role.toLowerCase();
2231
+ const allPermissionsList = [
2232
+ 'vault.view_groups', 'vault.manage_groups',
2233
+ 'profile.view', 'profile.create', 'profile.edit', 'profile.delete', 'profile.decrypt_credentials', 'profile.test_connection',
2234
+ 'token.view', 'token.issue', 'token.revoke',
2235
+ 'user.view', 'user.manage',
2236
+ 'audit.view', 'audit.export',
2237
+ 'backup.view', 'backup.restore',
2238
+ 'siem.configure', 'system.configure', 'system.view_dashboard',
2239
+ 'legal_hold.manage'
2240
+ ];
2241
+
2242
+ res.json({
2243
+ username: req.adminUser.username,
2244
+ role: req.adminUser.role,
2245
+ mfa_enabled: req.adminUser.mfa_enabled,
2246
+ permissions: role === 'admin'
2247
+ ? allPermissionsList
2248
+ : ROLE_PERMISSIONS[role] || []
2249
+ });
2250
+ });
2251
+
2252
+ // ── Groups Administration API ──────────────────────────────────────
2253
+ app.get('/admin/api/state', authMiddleware, async (req, res) => {
2254
+ const siemConfig = await SystemConfig.findOne({ where: { key: 'siem_webhook_url' } });
2255
+ const auditConfig = await SystemConfig.findOne({ where: { key: 'audit_logging_enabled' } });
2256
+ const keyVersions = await KeyVersion.findAll({ order: [['version', 'ASC']] });
2257
+
2258
+ const groups = await VaultGroup.findAll({
2259
+ include: [ConnectionProfile]
2260
+ });
2261
+
2262
+ const hasDecryptPermission = hasPermission(req.adminUser, 'profile.decrypt_credentials');
2263
+
2264
+ const stateGroups = [];
2265
+ for (const g of groups) {
2266
+ const groupJson = g.toJSON();
2267
+ let dek = null;
2268
+ let dekError = false;
2269
+ try {
2270
+ dek = await getGroupDek(g);
2271
+ } catch (err) {
2272
+ console.error(`Failed to load DEK for group ${g.name}:`, err.message);
2273
+ dekError = true;
2274
+ }
2275
+ const profiles = [];
2276
+ for (const p of (groupJson.ConnectionProfiles || [])) {
2277
+ let decryptedPass = '';
2278
+ if (!dekError && p.passwordEncrypted) {
2279
+ decryptedPass = decrypt(p.passwordEncrypted, dek);
2280
+ } else if (dekError) {
2281
+ decryptedPass = '[Decryption Error]';
2282
+ }
2283
+ let decryptedKey = '';
2284
+ if (p.authMode === 'keypair') {
2285
+ decryptedKey = decryptedPass;
2286
+ decryptedPass = '';
2287
+ }
2288
+ profiles.push({
2289
+ id: p.id,
2290
+ name: p.name,
2291
+ protocol: p.protocol,
2292
+ host: p.host,
2293
+ port: p.port,
2294
+ username: p.username,
2295
+ password: hasDecryptPermission ? decryptedPass : '********',
2296
+ options: p.authMode === 'keypair' ? { private_key: hasDecryptPermission ? decryptedKey : '********' } : {}
2297
+ });
2298
+ }
2299
+ let maskedCredentials = {};
2300
+ if (g.kms_credentials_encrypted) {
2301
+ try {
2302
+ const decryptedCreds = decryptKmsCredentials(g.kms_credentials_encrypted);
2303
+ for (const keyName in decryptedCreds) {
2304
+ maskedCredentials[keyName] = '********';
2305
+ }
2306
+ } catch (err) {}
2307
+ }
2308
+ stateGroups.push({
2309
+ id: groupJson.id,
2310
+ name: groupJson.name,
2311
+ status: 'active',
2312
+ ip_allowlist: groupJson.ipAllowlist || '',
2313
+ wrapped_dek: groupJson.wrapped_dek,
2314
+ dek_version: groupJson.dek_version || 1,
2315
+ kms_provider: groupJson.kms_provider || 'local',
2316
+ kms_config: groupJson.kms_config || '{}',
2317
+ kms_credentials: maskedCredentials,
2318
+ legal_hold_active: groupJson.legal_hold_active,
2319
+ legal_hold_reason: groupJson.legal_hold_reason,
2320
+ legal_hold_placed_by: groupJson.legal_hold_placed_by,
2321
+ legal_hold_placed_at: groupJson.legal_hold_placed_at,
2322
+ legal_hold_freeze_writes: groupJson.legal_hold_freeze_writes,
2323
+ profiles
2324
+ });
2325
+ }
2326
+
2327
+ const tokens = await AccessKey.findAll({
2328
+ include: [User]
2329
+ });
2330
+ const stateTokens = tokens.map(t => ({
2331
+ token_hash: t.token_hash,
2332
+ token_display: t.token_hash.substring(0, 8) + '...',
2333
+ user: t.User ? t.User.username : 'Master Token',
2334
+ groupId: t.groupId,
2335
+ status: t.status,
2336
+ expires_at: t.expiresAt || ''
2337
+ }));
2338
+
2339
+ const hasUserView = hasPermission(req.adminUser, 'user.view');
2340
+ const users = await User.findAll({
2341
+ attributes: hasUserView
2342
+ ? ['id', 'username', 'role', 'mfa_enabled', 'createdAt']
2343
+ : ['id', 'username', 'role', 'createdAt']
2344
+ });
2345
+
2346
+ let configJson = {
2347
+ database: {
2348
+ dialect: process.env.DB_DIALECT || 'sqlite',
2349
+ host: process.env.DB_HOST || 'localhost',
2350
+ port: process.env.DB_PORT ? parseInt(process.env.DB_PORT) || '' : '',
2351
+ username: process.env.DB_USERNAME || '',
2352
+ database: process.env.DB_NAME || '',
2353
+ storage: process.env.DB_STORAGE || '',
2354
+ ssl: process.env.DB_SSL === 'true'
2355
+ }
2356
+ };
2357
+ if (fs.existsSync(configPath)) {
2358
+ try {
2359
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2360
+ if (parsed && parsed.database) {
2361
+ configJson.database = { ...configJson.database, ...parsed.database };
2362
+ }
2363
+ } catch {}
2364
+ }
2365
+
2366
+ const totalVersionsCount = await FileVersion.count();
2367
+ const totalVersionsSize = (await FileVersion.sum('size')) || 0;
2368
+ const recentLogs = await TransferLog.findAll({
2369
+ where: { archived: false },
2370
+ order: [['createdAt', 'DESC']],
2371
+ limit: 5
2372
+ });
2373
+
2374
+ const smtpHostConfig = await SystemConfig.findOne({ where: { key: 'smtp_host' } });
2375
+ const smtpPortConfig = await SystemConfig.findOne({ where: { key: 'smtp_port' } });
2376
+ const smtpUsernameConfig = await SystemConfig.findOne({ where: { key: 'smtp_username' } });
2377
+ const smtpPasswordConfig = await SystemConfig.findOne({ where: { key: 'smtp_password' } });
2378
+ const smtpSenderConfig = await SystemConfig.findOne({ where: { key: 'smtp_sender' } });
2379
+
2380
+ const backupLimitConfig = await SystemConfig.findOne({ where: { key: 'backup_retention_limit' } });
2381
+ const backupEnabledConfig = await SystemConfig.findOne({ where: { key: 'backup_enabled' } });
2382
+
2383
+ const allPermissionsList = [
2384
+ 'vault.view_groups', 'vault.manage_groups',
2385
+ 'profile.view', 'profile.create', 'profile.edit', 'profile.delete', 'profile.decrypt_credentials', 'profile.test_connection',
2386
+ 'token.view', 'token.issue', 'token.revoke',
2387
+ 'user.view', 'user.manage',
2388
+ 'audit.view', 'audit.export',
2389
+ 'backup.view', 'backup.restore',
2390
+ 'siem.configure', 'system.configure', 'system.view_dashboard',
2391
+ 'legal_hold.manage'
2392
+ ];
2393
+
2394
+ res.json({
2395
+ currentUser: {
2396
+ username: req.adminUser.username,
2397
+ role: req.adminUser.role,
2398
+ permissions: req.adminUser.role.toLowerCase() === 'admin'
2399
+ ? allPermissionsList
2400
+ : ROLE_PERMISSIONS[req.adminUser.role.toLowerCase()] || []
2401
+ },
2402
+ siem_webhook_url: siemConfig ? siemConfig.value : '',
2403
+ audit_logging_enabled: auditConfig ? auditConfig.value === 'true' : true,
2404
+ groups: stateGroups,
2405
+ tokens: stateTokens,
2406
+ users,
2407
+ database: configJson.database,
2408
+ activeSocketsCount: clients.size,
2409
+ databaseDialect: sequelize.options.dialect || 'sqlite',
2410
+ totalVersionsCount,
2411
+ totalVersionsSize,
2412
+ recentLogs,
2413
+ smtp_host: smtpHostConfig ? smtpHostConfig.value : '',
2414
+ smtp_port: smtpPortConfig ? parseInt(smtpPortConfig.value) || 25 : 25,
2415
+ smtp_username: smtpUsernameConfig ? smtpUsernameConfig.value : '',
2416
+ smtp_password: hasPermission(req.adminUser, 'system.configure') ? (smtpPasswordConfig ? smtpPasswordConfig.value : '') : '********',
2417
+ smtp_sender: smtpSenderConfig ? smtpSenderConfig.value : '',
2418
+ backup_retention_limit: backupLimitConfig ? parseInt(backupLimitConfig.value) || 10 : 10,
2419
+ backup_enabled: backupEnabledConfig ? backupEnabledConfig.value === 'true' : true,
2420
+ keyVersions
2421
+ });
2422
+ });
2423
+
2424
+ app.post('/admin/api/groups', authMiddleware, requirePermission('vault.manage_groups'), async (req, res) => {
2425
+ const group = req.body;
2426
+ if (!group.name) {
2427
+ return res.status(400).json({ error: "Missing group name" });
2428
+ }
2429
+
2430
+ if (!group.id) {
2431
+ const newId = 'g_' + Math.random().toString(36).substring(2, 9);
2432
+ const dek = crypto.randomBytes(32);
2433
+ const activeVersion = await KeyVersion.max('version') || 1;
2434
+ const activeKek = await getKekByVersion(activeVersion);
2435
+ const wrappedDek = wrapDek(dek, activeKek);
2436
+
2437
+ await VaultGroup.create({
2438
+ id: newId,
2439
+ name: group.name,
2440
+ ipAllowlist: group.ip_allowlist || '',
2441
+ wrapped_dek: wrappedDek,
2442
+ dek_version: activeVersion,
2443
+ migrated: true
2444
+ });
2445
+ await addAuditLog(
2446
+ req.adminUser.username,
2447
+ JSON.stringify({
2448
+ action: "group_created",
2449
+ performedBy: req.adminUser.id,
2450
+ performedByUsername: req.adminUser.username,
2451
+ groupName: group.name
2452
+ })
2453
+ );
2454
+ } else {
2455
+ const g = await VaultGroup.findByPk(group.id);
2456
+ if (g) {
2457
+ g.name = group.name;
2458
+ g.ipAllowlist = group.ip_allowlist || '';
2459
+ await g.save();
2460
+ await addAuditLog(
2461
+ req.adminUser.username,
2462
+ JSON.stringify({
2463
+ action: "group_updated",
2464
+ performedBy: req.adminUser.id,
2465
+ performedByUsername: req.adminUser.username,
2466
+ groupName: group.name
2467
+ })
2468
+ );
2469
+ } else {
2470
+ return res.status(404).json({ error: "Group not found" });
2471
+ }
2472
+ }
2473
+ res.json({ success: true });
2474
+ });
2475
+
2476
+ app.delete('/admin/api/groups/:id', authMiddleware, requirePermission('vault.manage_groups'), async (req, res) => {
2477
+ const id = req.params.id;
2478
+ const g = await VaultGroup.findByPk(id);
2479
+ if (g) {
2480
+ if (g.legal_hold_active) {
2481
+ return res.status(403).json({ error: "Cannot delete Group: Vault Group is under active Legal Hold" });
2482
+ }
2483
+ const groupName = g.name;
2484
+ await g.destroy();
2485
+ notifyGroupRevocation(id);
2486
+ await addAuditLog(
2487
+ req.adminUser.username,
2488
+ JSON.stringify({
2489
+ action: "group_deleted",
2490
+ performedBy: req.adminUser.id,
2491
+ performedByUsername: req.adminUser.username,
2492
+ groupName: groupName
2493
+ })
2494
+ );
2495
+ res.json({ success: true });
2496
+ } else {
2497
+ res.status(404).json({ error: "Group not found" });
2498
+ }
2499
+ });
2500
+
2501
+ app.post('/admin/api/groups/:id/legal-hold', authMiddleware, requirePermission('legal_hold.manage'), async (req, res) => {
2502
+ const { id } = req.params;
2503
+ const { reason, freeze_writes } = req.body;
2504
+ if (!reason || reason.trim() === '') {
2505
+ return res.status(400).json({ error: "Missing required reason parameter" });
2506
+ }
2507
+
2508
+ const group = await VaultGroup.findByPk(id);
2509
+ if (!group) {
2510
+ return res.status(404).json({ error: "Group not found" });
2511
+ }
2512
+
2513
+ group.legal_hold_active = true;
2514
+ group.legal_hold_reason = reason;
2515
+ group.legal_hold_placed_by = req.adminUser.username;
2516
+ group.legal_hold_placed_at = new Date();
2517
+ group.legal_hold_freeze_writes = !!freeze_writes;
2518
+ await group.save();
2519
+
2520
+ await addAuditLog(
2521
+ req.adminUser.username,
2522
+ JSON.stringify({
2523
+ action: "legal_hold_placed",
2524
+ performedBy: req.adminUser.id,
2525
+ performedByUsername: req.adminUser.username,
2526
+ groupId: id,
2527
+ groupName: group.name,
2528
+ reason: reason,
2529
+ freezeWrites: !!freeze_writes
2530
+ })
2531
+ );
2532
+
2533
+ // Disconnect active client connections for this group in real time!
2534
+ try {
2535
+ const keys = await AccessKey.findAll({ where: { groupId: id } });
2536
+ const tokenHashes = keys.map(k => k.token_hash);
2537
+ for (const tokenHash of tokenHashes) {
2538
+ const ws = clients.get(tokenHash);
2539
+ if (ws) {
2540
+ console.log(`[WS] Terminating client session for token ${tokenHash} due to Legal Hold on group ${id}`);
2541
+ try {
2542
+ ws.send(JSON.stringify({ type: "access_suspended" }));
2543
+ ws.close();
2544
+ } catch (_) {}
2545
+ }
2546
+ }
2547
+ } catch (err) {
2548
+ console.error("Error disconnecting client sessions on legal hold placement:", err);
2549
+ }
2550
+
2551
+ res.json({ success: true, message: "Legal Hold placed successfully." });
2552
+ });
2553
+
2554
+ app.delete('/admin/api/groups/:id/legal-hold', authMiddleware, requirePermission('legal_hold.manage'), async (req, res) => {
2555
+ const { id } = req.params;
2556
+ const { reason } = req.body;
2557
+ if (!reason || reason.trim() === '') {
2558
+ return res.status(400).json({ error: "Missing required reason parameter for lifting hold" });
2559
+ }
2560
+
2561
+ const group = await VaultGroup.findByPk(id);
2562
+ if (!group) {
2563
+ return res.status(404).json({ error: "Group not found" });
2564
+ }
2565
+
2566
+ group.legal_hold_active = false;
2567
+ group.legal_hold_reason = null;
2568
+ group.legal_hold_placed_by = null;
2569
+ group.legal_hold_placed_at = null;
2570
+ group.legal_hold_freeze_writes = false;
2571
+ await group.save();
2572
+
2573
+ await addAuditLog(
2574
+ req.adminUser.username,
2575
+ JSON.stringify({
2576
+ action: "legal_hold_lifted",
2577
+ performedBy: req.adminUser.id,
2578
+ performedByUsername: req.adminUser.username,
2579
+ groupId: id,
2580
+ groupName: group.name,
2581
+ reason: reason
2582
+ })
2583
+ );
2584
+
2585
+ // Notify active clients that hold is lifted and access is restored!
2586
+ try {
2587
+ const keys = await AccessKey.findAll({ where: { groupId: id } });
2588
+ const tokenHashes = keys.map(k => k.token_hash);
2589
+ for (const tokenHash of tokenHashes) {
2590
+ const ws = clients.get(tokenHash);
2591
+ if (ws) {
2592
+ try {
2593
+ ws.send(JSON.stringify({ type: "access_restored" }));
2594
+ } catch (_) {}
2595
+ }
2596
+ }
2597
+ } catch (err) {
2598
+ console.error("Error notifying clients on legal hold lifting:", err);
2599
+ }
2600
+
2601
+ res.json({ success: true, message: "Legal Hold lifted successfully." });
2602
+ });
2603
+
2604
+ app.post('/admin/api/groups/:id/kms/test', authMiddleware, requirePermission('system.configure'), async (req, res) => {
2605
+ const { id } = req.params;
2606
+ const { kms_provider, kms_config, kms_credentials } = req.body;
2607
+
2608
+ const group = await VaultGroup.findByPk(id);
2609
+ if (!group) {
2610
+ return res.status(404).json({ error: "Group not found" });
2611
+ }
2612
+
2613
+ const provider = kmsProviders.providers[kms_provider];
2614
+ if (!provider) {
2615
+ return res.status(400).json({ error: `Unsupported KMS provider: ${kms_provider}` });
2616
+ }
2617
+
2618
+ let currentCreds = {};
2619
+ if (group.kms_credentials_encrypted) {
2620
+ currentCreds = decryptKmsCredentials(group.kms_credentials_encrypted);
2621
+ }
2622
+ const mergedCreds = { ...currentCreds };
2623
+ if (kms_credentials && typeof kms_credentials === 'object') {
2624
+ for (const key in kms_credentials) {
2625
+ if (kms_credentials[key] !== '********') {
2626
+ mergedCreds[key] = kms_credentials[key];
2627
+ }
2628
+ }
2629
+ }
2630
+
2631
+ let testConfig = { ...(kms_config || {}), ...mergedCreds };
2632
+ if (kms_provider === 'local') {
2633
+ const version = group.dek_version || 1;
2634
+ const kek = await getKekByVersion(version);
2635
+ testConfig = { kek };
2636
+ }
2637
+
2638
+ try {
2639
+ await provider.testConnection(testConfig);
2640
+ res.json({ success: true, message: "KMS connection test succeeded!" });
2641
+ } catch (err) {
2642
+ res.status(400).json({ error: err.message });
2643
+ }
2644
+ });
2645
+
2646
+ app.post('/admin/api/groups/:id/kms', authMiddleware, requirePermission('system.configure'), async (req, res) => {
2647
+ const { id } = req.params;
2648
+ const { kms_provider, kms_config, kms_credentials } = req.body;
2649
+
2650
+ const group = await VaultGroup.findByPk(id);
2651
+ if (!group) {
2652
+ return res.status(404).json({ error: "Group not found" });
2653
+ }
2654
+
2655
+ const provider = kmsProviders.providers[kms_provider];
2656
+ if (!provider) {
2657
+ return res.status(400).json({ error: `Unsupported KMS provider: ${kms_provider}` });
2658
+ }
2659
+
2660
+ let currentCreds = {};
2661
+ if (group.kms_credentials_encrypted) {
2662
+ currentCreds = decryptKmsCredentials(group.kms_credentials_encrypted);
2663
+ }
2664
+ const mergedCreds = { ...currentCreds };
2665
+ if (kms_credentials && typeof kms_credentials === 'object') {
2666
+ for (const key in kms_credentials) {
2667
+ if (kms_credentials[key] !== '********') {
2668
+ mergedCreds[key] = kms_credentials[key];
2669
+ }
2670
+ }
2671
+ }
2672
+
2673
+ let testConfig = { ...(kms_config || {}), ...mergedCreds };
2674
+ if (kms_provider === 'local') {
2675
+ const version = group.dek_version || 1;
2676
+ const kek = await getKekByVersion(version);
2677
+ testConfig = { kek };
2678
+ }
2679
+
2680
+ try {
2681
+ await provider.testConnection(testConfig);
2682
+ const rawDek = await getGroupDek(group);
2683
+ const wrappedDek = await provider.wrapDek(testConfig, rawDek);
2684
+
2685
+ group.kms_provider = kms_provider;
2686
+ group.kms_config = JSON.stringify(kms_config || {});
2687
+ group.kms_credentials_encrypted = encryptKmsCredentials(mergedCreds);
2688
+ group.wrapped_dek = wrappedDek;
2689
+ await group.save();
2690
+
2691
+ await addAuditLog(
2692
+ req.adminUser.username,
2693
+ JSON.stringify({
2694
+ action: "kms_updated",
2695
+ performedBy: req.adminUser.id,
2696
+ performedByUsername: req.adminUser.username,
2697
+ groupId: group.id,
2698
+ groupName: group.name,
2699
+ kms_provider: kms_provider
2700
+ })
2701
+ );
2702
+
2703
+ res.json({ success: true });
2704
+ } catch (err) {
2705
+ res.status(400).json({ error: err.message });
2706
+ }
2707
+ });
2708
+
2709
+ app.post('/admin/api/groups/:groupId/profiles', authMiddleware, async (req, res) => {
2710
+ const groupId = req.params.groupId;
2711
+ const profile = req.body;
2712
+
2713
+ if (!profile.id) {
2714
+ if (!hasPermission(req.adminUser, 'profile.create')) {
2715
+ return res.status(403).json({ error: "Forbidden: Missing required permission 'profile.create'" });
2716
+ }
2717
+ } else {
2718
+ if (!hasPermission(req.adminUser, 'profile.edit')) {
2719
+ return res.status(403).json({ error: "Forbidden: Missing required permission 'profile.edit'" });
2720
+ }
2721
+ }
2722
+
2723
+ if (!profile.name || !profile.host || !profile.protocol) {
2724
+ return res.status(400).json({ error: "Missing required profile fields" });
2725
+ }
2726
+
2727
+ const g = await VaultGroup.findByPk(groupId);
2728
+ if (!g) {
2729
+ return res.status(404).json({ error: "Vault Group not found" });
2730
+ }
2731
+ if (g.legal_hold_active && g.legal_hold_freeze_writes) {
2732
+ return res.status(403).json({ error: "Cannot create/modify profiles: Vault Group writes are frozen under active Legal Hold" });
2733
+ }
2734
+
2735
+ try {
2736
+ const dek = await getGroupDek(g);
2737
+
2738
+ let keyVal = profile.password || '';
2739
+ if (profile.options && profile.options.private_key) {
2740
+ keyVal = profile.options.private_key;
2741
+ }
2742
+ const encryptedKey = (keyVal && keyVal !== '********') ? encrypt(keyVal, dek) : '';
2743
+
2744
+ const jumpHost = profile.options && profile.options.jump_host ? profile.options.jump_host : null;
2745
+ const jumpPort = profile.options && profile.options.jump_port ? parseInt(profile.options.jump_port) || 22 : null;
2746
+ const jumpUsername = profile.options && profile.options.jump_username ? profile.options.jump_username : null;
2747
+ const jumpAuthMode = profile.options && profile.options.jump_auth_mode ? profile.options.jump_auth_mode : 'password';
2748
+
2749
+ let jumpKeyVal = '';
2750
+ if (profile.options) {
2751
+ if (jumpAuthMode === 'keypair' && profile.options.jump_private_key) {
2752
+ jumpKeyVal = profile.options.jump_private_key;
2753
+ } else if (profile.options.jump_password) {
2754
+ jumpKeyVal = profile.options.jump_password;
2755
+ }
2756
+ }
2757
+ const encryptedJumpKey = (jumpKeyVal && jumpKeyVal !== '********') ? encrypt(jumpKeyVal, dek) : '';
2758
+
2759
+ if (!profile.id) {
2760
+ const newId = 'p_' + Math.random().toString(36).substring(2, 9);
2761
+ await ConnectionProfile.create({
2762
+ id: newId,
2763
+ groupId,
2764
+ name: profile.name,
2765
+ protocol: profile.protocol,
2766
+ host: profile.host,
2767
+ port: parseInt(profile.port) || 22,
2768
+ username: profile.username,
2769
+ passwordEncrypted: encryptedKey,
2770
+ authMode: profile.options && profile.options.private_key ? 'keypair' : 'password',
2771
+ jumpHost,
2772
+ jumpPort,
2773
+ jumpUsername,
2774
+ jumpPasswordEncrypted: encryptedJumpKey,
2775
+ jumpAuthMode
2776
+ });
2777
+ await addAuditLog(
2778
+ req.adminUser.username,
2779
+ JSON.stringify({
2780
+ action: "profile_created",
2781
+ performedBy: req.adminUser.id,
2782
+ performedByUsername: req.adminUser.username,
2783
+ profileName: profile.name,
2784
+ groupName: g.name
2785
+ })
2786
+ );
2787
+ } else {
2788
+ const p = await ConnectionProfile.findByPk(profile.id);
2789
+ if (p) {
2790
+ p.name = profile.name;
2791
+ p.protocol = profile.protocol;
2792
+ p.host = profile.host;
2793
+ p.port = parseInt(profile.port) || 22;
2794
+ p.username = profile.username;
2795
+ p.passwordEncrypted = encryptedKey || p.passwordEncrypted;
2796
+ p.authMode = profile.options && profile.options.private_key ? 'keypair' : 'password';
2797
+
2798
+ p.jumpHost = jumpHost;
2799
+ p.jumpPort = jumpPort;
2800
+ p.jumpUsername = jumpUsername;
2801
+ p.jumpAuthMode = jumpAuthMode;
2802
+ p.jumpPasswordEncrypted = encryptedJumpKey || p.jumpPasswordEncrypted;
2803
+
2804
+ await p.save();
2805
+ await addAuditLog(
2806
+ req.adminUser.username,
2807
+ JSON.stringify({
2808
+ action: "profile_updated",
2809
+ performedBy: req.adminUser.id,
2810
+ performedByUsername: req.adminUser.username,
2811
+ profileName: profile.name,
2812
+ groupName: g.name
2813
+ })
2814
+ );
2815
+ } else {
2816
+ await ConnectionProfile.create({
2817
+ id: profile.id,
2818
+ groupId,
2819
+ name: profile.name,
2820
+ protocol: profile.protocol,
2821
+ host: profile.host,
2822
+ port: parseInt(profile.port) || 22,
2823
+ username: profile.username,
2824
+ passwordEncrypted: encryptedKey,
2825
+ authMode: profile.options && profile.options.private_key ? 'keypair' : 'password',
2826
+ jumpHost,
2827
+ jumpPort,
2828
+ jumpUsername,
2829
+ jumpPasswordEncrypted: encryptedJumpKey,
2830
+ jumpAuthMode
2831
+ });
2832
+ await addAuditLog(
2833
+ req.adminUser.username,
2834
+ JSON.stringify({
2835
+ action: "profile_created",
2836
+ performedBy: req.adminUser.id,
2837
+ performedByUsername: req.adminUser.username,
2838
+ profileName: profile.name,
2839
+ groupName: g.name
2840
+ })
2841
+ );
2842
+ }
2843
+ }
2844
+ res.json({ success: true });
2845
+ } catch (err) {
2846
+ console.error("Profile save failed:", err);
2847
+ res.status(500).json({ error: `Failed to save profile: ${err.message}` });
2848
+ }
2849
+ });
2850
+
2851
+ // Connection Testing API Endpoint
2852
+ app.post('/admin/api/profiles/test-connection', authMiddleware, requirePermission('profile.test_connection'), async (req, res) => {
2853
+ const { profileId } = req.body;
2854
+
2855
+ if (!profileId) {
2856
+ return res.status(400).json({ error: "Missing target profile ID" });
2857
+ }
2858
+
2859
+ const profile = await ConnectionProfile.findByPk(profileId);
2860
+ if (!profile) {
2861
+ return res.status(404).json({ error: "Connection profile not found" });
2862
+ }
2863
+
2864
+ const host = profile.host;
2865
+ const port = profile.port || 22;
2866
+ const protocol = profile.protocol || 'sftp';
2867
+
2868
+ const logs = [];
2869
+ const log = (msg) => {
2870
+ const timestamp = new Date().toLocaleTimeString();
2871
+ logs.push(`[${timestamp}] ${msg}`);
2872
+ };
2873
+
2874
+ log(`[SYSTEM] Starting connectivity test for profile "${profile.name}" (${protocol.toUpperCase()})`);
2875
+ log(`[DNS] Resolving address for target host "${host}"...`);
2876
+
2877
+ // Check if private targets are allowed
2878
+ let allowPrivateTargets = false;
2879
+ if (process.env.ALLOW_PRIVATE_TARGETS === 'true') {
2880
+ allowPrivateTargets = true;
2881
+ }
2882
+ if (fs.existsSync(configPath)) {
2883
+ try {
2884
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2885
+ if (parsed.allowPrivateTargets === true) {
2886
+ allowPrivateTargets = true;
2887
+ }
2888
+ } catch {}
2889
+ }
2890
+ try {
2891
+ const dbConfigVal = await SystemConfig.findOne({ where: { key: 'allow_private_targets' } });
2892
+ if (dbConfigVal && dbConfigVal.value === 'true') {
2893
+ allowPrivateTargets = true;
2894
+ }
2895
+ } catch {}
2896
+
2897
+ if (!allowPrivateTargets) {
2898
+ const isPrivate = await isHostPrivate(host);
2899
+ if (isPrivate) {
2900
+ log(`[SECURITY ERROR] Connection to private/local target "${host}" was blocked to prevent SSRF.`);
2901
+ return res.status(403).json({
2902
+ success: false,
2903
+ error: "Forbidden: Connection to private/local targets is disabled.",
2904
+ logs
2905
+ });
2906
+ }
2907
+ }
2908
+
2909
+ const net = require('net');
2910
+ const targetPort = parseInt(port) || 22;
2911
+ const targetHost = host.trim();
2912
+
2913
+ log(`[TCP] Opening connection to ${targetHost}:${targetPort}...`);
2914
+
2915
+ const startTime = Date.now();
2916
+ const socket = new net.Socket();
2917
+ let completed = false;
2918
+
2919
+ const cleanup = () => {
2920
+ if (!socket.destroyed) {
2921
+ try {
2922
+ socket.destroy();
2923
+ } catch (_) {}
2924
+ }
2925
+ };
2926
+
2927
+ const promise = new Promise((resolve) => {
2928
+ socket.setTimeout(5000); // 5 seconds timeout
2929
+
2930
+ socket.connect(targetPort, targetHost, () => {
2931
+ const elapsed = Date.now() - startTime;
2932
+ log(`[TCP] Connection established successfully in ${elapsed}ms.`);
2933
+
2934
+ if (['sftp', 'ssh', 'ftp'].includes(protocol.toLowerCase())) {
2935
+ log(`[PROTOCOL] Connection open. Waiting for protocol greeting banner...`);
2936
+ } else {
2937
+ completed = true;
2938
+ log(`[SYSTEM] Connectivity check completed successfully.`);
2939
+ cleanup();
2940
+ resolve({ success: true });
2941
+ }
2942
+ });
2943
+
2944
+ socket.on('data', (data) => {
2945
+ if (completed) return;
2946
+ const banner = data.toString('utf8').trim().split('\n')[0];
2947
+ log(`[PROTOCOL] Received remote banner: "${banner}"`);
2948
+ completed = true;
2949
+ log(`[SYSTEM] Connection test completed successfully.`);
2950
+ cleanup();
2951
+ resolve({ success: true });
2952
+ });
2953
+
2954
+ socket.on('timeout', () => {
2955
+ if (completed) return;
2956
+ completed = true;
2957
+ log(`[TCP] Connection timed out after 5000ms.`);
2958
+ cleanup();
2959
+ resolve({ success: false, error: "Connection Timeout" });
2960
+ });
2961
+
2962
+ socket.on('error', (err) => {
2963
+ if (completed) return;
2964
+ completed = true;
2965
+ log(`[TCP] Socket error: ${err.message}`);
2966
+
2967
+ let hint = "Verify the target host is correct, the port is open, and vault server's IP is allowed by target firewalls.";
2968
+ if (err.code === 'ECONNREFUSED') {
2969
+ hint = "The target port is closed or connection was actively refused. Verify that the SSH/SFTP/FTP daemon is active.";
2970
+ } else if (err.code === 'ENOTFOUND') {
2971
+ hint = "Hostname resolution failed. Check DNS settings and hostname spellings.";
2972
+ }
2973
+ log(`[HINT] ${hint}`);
2974
+ cleanup();
2975
+ resolve({ success: false, error: err.message });
2976
+ });
2977
+ });
2978
+
2979
+ const result = await promise;
2980
+ res.json({
2981
+ success: result.success,
2982
+ error: result.error,
2983
+ logs
2984
+ });
2985
+ });
2986
+
2987
+
2988
+ app.delete('/admin/api/groups/:groupId/profiles/:profileId', authMiddleware, requirePermission('profile.delete'), async (req, res) => {
2989
+ const { groupId, profileId } = req.params;
2990
+ const g = await VaultGroup.findByPk(groupId);
2991
+ if (g && g.legal_hold_active) {
2992
+ return res.status(403).json({ error: "Cannot delete Connection Profile: Vault Group is under active Legal Hold" });
2993
+ }
2994
+ const p = await ConnectionProfile.findByPk(profileId);
2995
+ if (p) {
2996
+ const name = p.name;
2997
+ await p.destroy();
2998
+ await addAuditLog(
2999
+ req.adminUser.username,
3000
+ JSON.stringify({
3001
+ action: "profile_deleted",
3002
+ performedBy: req.adminUser.id,
3003
+ performedByUsername: req.adminUser.username,
3004
+ profileName: name
3005
+ })
3006
+ );
3007
+ res.json({ success: true });
3008
+ } else {
3009
+ res.status(404).json({ error: "Profile not found" });
3010
+ }
3011
+ });
3012
+
3013
+ // ── User Scoped Tokens Management API ──────────────────────────────
3014
+ app.post('/admin/api/tokens', authMiddleware, requirePermission('token.issue'), async (req, res) => {
3015
+ const { token_hash, user, groupId, status, expires_at } = req.body;
3016
+ if (!user || !groupId) {
3017
+ return res.status(400).json({ error: "Missing required token fields (user and groupId)" });
3018
+ }
3019
+
3020
+ const g = await VaultGroup.findByPk(groupId);
3021
+ if (!g) {
3022
+ return res.status(404).json({ error: "Assigned Vault Group not found" });
3023
+ }
3024
+ if (g.legal_hold_active && g.legal_hold_freeze_writes) {
3025
+ return res.status(403).json({ error: "Cannot issue/modify token: Vault Group writes are frozen under active Legal Hold" });
3026
+ }
3027
+
3028
+ // Find or create User mapping based on name
3029
+ const [usr] = await User.findOrCreate({
3030
+ where: { username: user },
3031
+ defaults: {
3032
+ passwordHash: crypto.createHash('sha256').update('temporary-user-password').digest('hex'),
3033
+ role: 'operator'
3034
+ }
3035
+ });
3036
+
3037
+ if (token_hash) {
3038
+ const key = await AccessKey.findByPk(token_hash);
3039
+ if (key) {
3040
+ key.groupId = groupId;
3041
+ key.expiresAt = expires_at || "";
3042
+ key.userId = usr.id;
3043
+ const oldStatus = key.status;
3044
+ key.status = status || key.status;
3045
+ await key.save();
3046
+
3047
+ await addAuditLog(
3048
+ req.adminUser.username,
3049
+ JSON.stringify({
3050
+ action: "token_updated",
3051
+ performedBy: req.adminUser.id,
3052
+ performedByUsername: req.adminUser.username,
3053
+ targetUser: user
3054
+ })
3055
+ );
3056
+ if (status === 'blocked' && oldStatus !== 'blocked') {
3057
+ notifyTokenSuspension(token_hash);
3058
+ } else if (status === 'active' && oldStatus === 'blocked') {
3059
+ notifyTokenRestoration(token_hash);
3060
+ }
3061
+ res.json({ success: true });
3062
+ } else {
3063
+ res.status(404).json({ error: "Access key not found" });
3064
+ }
3065
+ } else {
3066
+ // Generate new key on the server
3067
+ const rawToken = crypto.randomBytes(32).toString('hex');
3068
+ const hashed = crypto.createHash('sha256').update(rawToken).digest('hex');
3069
+
3070
+ await AccessKey.create({
3071
+ token_hash: hashed,
3072
+ userId: usr.id,
3073
+ groupId,
3074
+ status: status || "active",
3075
+ expiresAt: expires_at || ""
3076
+ });
3077
+
3078
+ await addAuditLog(
3079
+ req.adminUser.username,
3080
+ JSON.stringify({
3081
+ action: "token_issued",
3082
+ performedBy: req.adminUser.id,
3083
+ performedByUsername: req.adminUser.username,
3084
+ targetUser: user,
3085
+ targetGroup: groupId
3086
+ })
3087
+ );
3088
+
3089
+ // Return raw token ONE TIME to the admin/client
3090
+ res.json({ success: true, token: rawToken });
3091
+ }
3092
+ });
3093
+
3094
+ app.delete('/admin/api/tokens/:token', authMiddleware, requirePermission('token.revoke'), async (req, res) => {
3095
+ const tokenVal = req.params.token;
3096
+ const key = await AccessKey.findByPk(tokenVal, { include: [User] });
3097
+ if (key) {
3098
+ const group = await VaultGroup.findByPk(key.groupId);
3099
+ if (group && group.legal_hold_active) {
3100
+ return res.status(403).json({ error: "Cannot delete Access Key: Scoped Vault Group is under active Legal Hold" });
3101
+ }
3102
+ const ownerName = key.User ? key.User.username : 'Master Token';
3103
+ await key.destroy();
3104
+ await addAuditLog(
3105
+ req.adminUser.username,
3106
+ JSON.stringify({
3107
+ action: "token_revoked",
3108
+ performedBy: req.adminUser.id,
3109
+ performedByUsername: req.adminUser.username,
3110
+ tokenOwner: ownerName
3111
+ })
3112
+ );
3113
+ notifyTokenRevocation(tokenVal);
3114
+ res.json({ success: true });
3115
+ } else {
3116
+ res.status(404).json({ error: "Access key not found" });
3117
+ }
3118
+ });
3119
+
3120
+ // ── Client-side Transfer Audit Logging endpoint ──────────────────────
3121
+ app.post('/v1/audit/log', async (req, res) => {
3122
+ const { token, connectionId, filePath, fileSize, action, status, errorMessage } = req.body;
3123
+ if (!connectionId || !filePath || !action) {
3124
+ return res.status(400).json({ error: "Missing required logging parameters" });
3125
+ }
3126
+
3127
+ // Resolve token owner
3128
+ let username = 'Anonymous Client';
3129
+ let tokenHash = null;
3130
+ if (token) {
3131
+ tokenHash = crypto.createHash('sha256').update(token).digest('hex');
3132
+ const key = await AccessKey.findOne({ where: { token_hash: tokenHash }, include: [User] });
3133
+ if (key && key.User) {
3134
+ username = key.User.username;
3135
+ }
3136
+ }
3137
+
3138
+ await TransferLog.create({
3139
+ token: tokenHash || null,
3140
+ username,
3141
+ connectionId,
3142
+ filePath,
3143
+ fileSize: parseInt(fileSize) || 0,
3144
+ action,
3145
+ status: status || 'success',
3146
+ errorMessage: errorMessage || null
3147
+ });
3148
+
3149
+ res.json({ success: true });
3150
+ });
3151
+
3152
+ // ── File Versioning System & Backup management ────────────────────────
3153
+ app.post('/v1/files/version', async (req, res) => {
3154
+ const { token, connectionId, profileName, filePath, size, hash, content, modifiedBy } = req.body;
3155
+ if (!connectionId || !filePath || content === undefined) {
3156
+ return res.status(400).json({ error: "Missing version data parameters" });
3157
+ }
3158
+
3159
+ const backupEnabledConfig = await SystemConfig.findOne({ where: { key: 'backup_enabled' } });
3160
+ const isBackupEnabled = backupEnabledConfig ? backupEnabledConfig.value === 'true' : true;
3161
+ if (!isBackupEnabled) {
3162
+ return res.json({ success: true, version: 0, message: "Backups are globally disabled" });
3163
+ }
3164
+
3165
+ // Resolve user from token
3166
+ let username = 'System';
3167
+ if (token) {
3168
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
3169
+ const key = await AccessKey.findOne({ where: { token_hash: tokenHash }, include: [User] });
3170
+ if (key && key.User) {
3171
+ username = key.User.username;
3172
+ }
3173
+ if (key && profileName) {
3174
+ const profile = await ConnectionProfile.findOne({
3175
+ where: { groupId: key.groupId, name: profileName }
3176
+ });
3177
+ if (profile) {
3178
+ profile.clientProfileId = connectionId;
3179
+ await profile.save();
3180
+ console.log(`[Version Link] Linked client profile ID "${connectionId}" to server profile "${profile.name}" (${profile.id})`);
3181
+ }
3182
+ }
3183
+ }
3184
+
3185
+ // Get current max version
3186
+ const lastVersion = await FileVersion.findOne({
3187
+ where: { connectionId, filePath },
3188
+ order: [['version', 'DESC']]
3189
+ });
3190
+
3191
+ const nextVerNum = lastVersion ? lastVersion.version + 1 : 1;
3192
+
3193
+ // Save previous version content to backup path
3194
+ const backupDir = path.join(DATA_DIR, 'backups', connectionId);
3195
+ fs.mkdirSync(backupDir, { recursive: true });
3196
+
3197
+ const backupFileName = `${Buffer.from(filePath).toString('hex')}_v${nextVerNum}.bak`;
3198
+ const backupPath = path.join(backupDir, backupFileName);
3199
+ fs.writeFileSync(backupPath, content, 'utf8');
3200
+
3201
+ await FileVersion.create({
3202
+ connectionId,
3203
+ filePath,
3204
+ version: nextVerNum,
3205
+ size: parseInt(size) || content.length,
3206
+ hash: hash || crypto.createHash('md5').update(content).digest('hex'),
3207
+ backupPath,
3208
+ modifiedBy: modifiedBy || username
3209
+ });
3210
+
3211
+ // Apply backup retention policy dynamically
3212
+ try {
3213
+ const backupLimitConfig = await SystemConfig.findOne({ where: { key: 'backup_retention_limit' } });
3214
+ const retentionLimit = backupLimitConfig ? parseInt(backupLimitConfig.value) || 10 : 10;
3215
+
3216
+ const existingVersions = await FileVersion.findAll({
3217
+ where: { connectionId, filePath },
3218
+ order: [['version', 'ASC']]
3219
+ });
3220
+ if (existingVersions.length > retentionLimit) {
3221
+ const deleteLimit = existingVersions.length - retentionLimit;
3222
+ const oldVersions = existingVersions.slice(0, deleteLimit);
3223
+ for (const oldVer of oldVersions) {
3224
+ if (oldVer.backupPath && fs.existsSync(oldVer.backupPath)) {
3225
+ try {
3226
+ fs.unlinkSync(oldVer.backupPath);
3227
+ console.log(`[Retention] Deleted old physical backup file: ${oldVer.backupPath}`);
3228
+ } catch (unlinkErr) {
3229
+ console.error(`[Retention] Failed to delete backup file: ${oldVer.backupPath}`, unlinkErr);
3230
+ }
3231
+ }
3232
+ await oldVer.destroy();
3233
+ console.log(`[Retention] Removed version ${oldVer.version} entry for file ${filePath} from database`);
3234
+ }
3235
+ }
3236
+ } catch (retentionErr) {
3237
+ console.error('[Retention] Error enforcing backup retention policy:', retentionErr);
3238
+ }
3239
+
3240
+ res.json({ success: true, version: nextVerNum });
3241
+ });
3242
+
3243
+ app.get('/admin/api/versions', authMiddleware, requirePermission('backup.view'), async (req, res) => {
3244
+ const list = await FileVersion.findAll({ order: [['createdAt', 'DESC']], limit: 200 });
3245
+ res.json(list);
3246
+ });
3247
+
3248
+ app.get('/admin/api/versions/:id/content', authMiddleware, requirePermission('backup.view'), async (req, res) => {
3249
+ const ver = await FileVersion.findByPk(req.params.id);
3250
+ if (!ver) return res.status(404).json({ error: "Version not found" });
3251
+ if (!fs.existsSync(ver.backupPath)) {
3252
+ return res.status(404).json({ error: "Backup file not found on server disk" });
3253
+ }
3254
+ const content = fs.readFileSync(ver.backupPath, 'utf8');
3255
+ res.json({ content });
3256
+ });
3257
+
3258
+ app.post('/admin/api/versions/restore', authMiddleware, requirePermission('backup.restore'), async (req, res) => {
3259
+ const { versionId, clear } = req.body;
3260
+ const ver = await FileVersion.findByPk(versionId);
3261
+ if (!ver) {
3262
+ return res.status(404).json({ error: "File version record not found" });
3263
+ }
3264
+
3265
+ let content = "";
3266
+ if (!clear) {
3267
+ if (!fs.existsSync(ver.backupPath)) {
3268
+ return res.status(404).json({ error: "Physical backup file does not exist on server disk" });
3269
+ }
3270
+ content = fs.readFileSync(ver.backupPath, 'utf8');
3271
+ }
3272
+
3273
+ // Trigger WebSocket client write if connection ID is active
3274
+ try {
3275
+ let profile = await ConnectionProfile.findByPk(ver.connectionId);
3276
+ if (!profile) {
3277
+ profile = await ConnectionProfile.findOne({ where: { clientProfileId: ver.connectionId } });
3278
+ }
3279
+ if (profile) {
3280
+ const keys = await AccessKey.findAll({ where: { groupId: profile.groupId } });
3281
+ let sentCount = 0;
3282
+ for (const k of keys) {
3283
+ const clientWs = clients.get(k.token_hash);
3284
+ if (clientWs && clientWs.readyState === 1 && k.status !== 'blocked') { // OPEN and not blocked
3285
+ clientWs.send(JSON.stringify({
3286
+ type: 'write_file',
3287
+ connectionId: ver.connectionId,
3288
+ filePath: ver.filePath,
3289
+ content: content,
3290
+ isQueued: false,
3291
+ timestamp: Date.now()
3292
+ }));
3293
+ sentCount++;
3294
+ await PendingReversion.create({
3295
+ groupId: profile.groupId,
3296
+ connectionId: ver.connectionId,
3297
+ filePath: ver.filePath,
3298
+ content: content,
3299
+ status: 'applied',
3300
+ tokenHash: k.token_hash
3301
+ });
3302
+ } else {
3303
+ await PendingReversion.create({
3304
+ groupId: profile.groupId,
3305
+ connectionId: ver.connectionId,
3306
+ filePath: ver.filePath,
3307
+ content: content,
3308
+ status: 'pending',
3309
+ tokenHash: k.token_hash
3310
+ });
3311
+ }
3312
+ }
3313
+ console.log(`[Revert] Dispatched write_file command to ${sentCount} active clients for profile ${ver.connectionId}`);
3314
+ }
3315
+ } catch (err) {
3316
+ console.error('Failed to notify clients of file reversion:', err);
3317
+ }
3318
+
3319
+ res.json({ success: true, filePath: ver.filePath, connectionId: ver.connectionId, content });
3320
+ });
3321
+
3322
+ // ── Client Synchronization Endpoint (Multi-Token RBAC model) ─────────
3323
+ app.get(['/v1/secret/data/filepilot/profiles', '/v1/secret/data/filepilot/profiles/:group_id'], syncLimiter, async (req, res) => {
3324
+ if (!(await checkIsInstalled())) {
3325
+ return res.status(503).json({ errors: ["Vault Setup is incomplete. Please complete setup at http://localhost:8200/admin"] });
3326
+ }
3327
+
3328
+ const groupId = req.params.group_id;
3329
+ const vaultToken = req.headers['x-vault-token'];
3330
+ const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
3331
+
3332
+ if (!vaultToken) {
3333
+ await addAuditLog("Unauthorized Access", `Blocked sync request (Missing Token)`, "error");
3334
+ return res.status(401).json({
3335
+ errors: ['Permission denied, missing X-Vault-Token header']
3336
+ });
3337
+ }
3338
+
3339
+ const isMasterToken = (vaultToken === 'admin-token');
3340
+ let tokenObj = null;
3341
+ if (!isMasterToken) {
3342
+ const tokenHash = crypto.createHash('sha256').update(vaultToken).digest('hex');
3343
+ tokenObj = await AccessKey.findOne({
3344
+ where: { token_hash: tokenHash },
3345
+ include: [User]
3346
+ });
3347
+ }
3348
+
3349
+ let targetGroupId;
3350
+ let tokenDesc = "Master Token";
3351
+
3352
+ if (!isMasterToken) {
3353
+ if (!tokenObj) {
3354
+ await addAuditLog(`Token "${vaultToken.substring(0, 8)}..."`, `Blocked sync request (Invalid Token)`, "error");
3355
+ return res.status(403).json({
3356
+ errors: ['Permission denied, invalid X-Vault-Token']
3357
+ });
3358
+ }
3359
+
3360
+ if (tokenObj.status === 'blocked') {
3361
+ const owner = tokenObj.User ? tokenObj.User.username : 'Unknown';
3362
+ await addAuditLog(owner, `Blocked sync request for "${owner}" (Access Key Blocked)`, "error");
3363
+ return res.status(403).json({
3364
+ errors: ['Permission denied: Your Access Key has been blocked']
3365
+ });
3366
+ }
3367
+
3368
+ if (tokenObj.expiresAt) {
3369
+ const expiry = new Date(tokenObj.expiresAt);
3370
+ if (expiry < new Date()) {
3371
+ tokenObj.status = 'expired';
3372
+ await tokenObj.save();
3373
+ const owner = tokenObj.User ? tokenObj.User.username : 'Unknown';
3374
+ await addAuditLog(owner, `Blocked sync request for "${owner}" (Access Key Expired)`, "error");
3375
+ notifyTokenRevocation(tokenObj.token_hash);
3376
+ return res.status(403).json({
3377
+ errors: ['Permission denied: Your Access Key has expired']
3378
+ });
3379
+ }
3380
+ }
3381
+
3382
+ targetGroupId = tokenObj.groupId;
3383
+ tokenDesc = tokenObj.User ? tokenObj.User.username : 'Operator';
3384
+ } else {
3385
+ targetGroupId = groupId;
3386
+ }
3387
+
3388
+ if (!targetGroupId) {
3389
+ return res.status(400).json({
3390
+ errors: ['Missing Vault Group target ID']
3391
+ });
3392
+ }
3393
+
3394
+ const group = await VaultGroup.findByPk(targetGroupId, { include: [ConnectionProfile] });
3395
+ if (!group) {
3396
+ return res.status(404).json({
3397
+ errors: ['Vault Group not found']
3398
+ });
3399
+ }
3400
+
3401
+ if (group.ipAllowlist && !ipMatches(clientIp, group.ipAllowlist)) {
3402
+ await addAuditLog(tokenDesc, `Blocked sync request for group "${group.name}" (IP ${clientIp} not whitelisted)`, "error");
3403
+ return res.status(403).json({
3404
+ errors: [`Permission denied: Client IP ${clientIp} is not in the group's IP allowlist`]
3405
+ });
3406
+ }
3407
+
3408
+ try {
3409
+ const dek = await getGroupDek(group);
3410
+ const decryptedProfiles = (group.ConnectionProfiles || []).map(p => {
3411
+ const pJson = p.toJSON();
3412
+ let pass = pJson.passwordEncrypted ? decrypt(pJson.passwordEncrypted, dek) : '';
3413
+ let privKey = '';
3414
+ if (pJson.authMode === 'keypair') {
3415
+ privKey = pass;
3416
+ pass = '';
3417
+ }
3418
+
3419
+ const options = {};
3420
+ if (pJson.authMode === 'keypair') {
3421
+ options.private_key = privKey;
3422
+ }
3423
+
3424
+ if (pJson.jumpHost) {
3425
+ let jumpPass = pJson.jumpPasswordEncrypted ? decrypt(pJson.jumpPasswordEncrypted, dek) : '';
3426
+ let jumpPrivKey = '';
3427
+ if (pJson.jumpAuthMode === 'keypair') {
3428
+ jumpPrivKey = jumpPass;
3429
+ jumpPass = '';
3430
+ }
3431
+
3432
+ options.jump_host = pJson.jumpHost;
3433
+ options.jump_port = String(pJson.jumpPort || 22);
3434
+ options.jump_username = pJson.jumpUsername;
3435
+ options.jump_auth_mode = pJson.jumpAuthMode || 'password';
3436
+ if (pJson.jumpAuthMode === 'keypair') {
3437
+ options.jump_private_key = jumpPrivKey;
3438
+ } else {
3439
+ options.jump_password = jumpPass;
3440
+ }
3441
+ }
3442
+
3443
+ if (group.legal_hold_active) {
3444
+ options.legal_hold = 'true';
3445
+ if (group.legal_hold_freeze_writes) {
3446
+ options.legal_hold_freeze_writes = 'true';
3447
+ }
3448
+ }
3449
+
3450
+ return {
3451
+ id: pJson.id,
3452
+ name: pJson.name,
3453
+ protocol: pJson.protocol,
3454
+ host: pJson.host,
3455
+ port: pJson.port,
3456
+ username: pJson.username,
3457
+ password: pass,
3458
+ tags: ['enterprise'],
3459
+ options
3460
+ };
3461
+ });
3462
+
3463
+ await addAuditLog(tokenDesc, `Sync request for vault "${group.name}" (Returned ${decryptedProfiles.length} profiles)`);
3464
+
3465
+ res.json({
3466
+ vault_name: group.name,
3467
+ profiles: decryptedProfiles
3468
+ });
3469
+ } catch (err) {
3470
+ console.error("Profile sync failed:", err);
3471
+ res.status(500).json({
3472
+ errors: [`Decryption or KMS key unwrapping failed: ${err.message}`]
3473
+ });
3474
+ }
3475
+ });
3476
+
3477
+ // Serve Admin UI static files
3478
+ app.get('/admin', async (req, res) => {
3479
+ if (!(await checkIsInstalled())) {
3480
+ return res.sendFile(path.join(__dirname, 'admin', 'install.html'));
3481
+ }
3482
+ res.sendFile(path.join(__dirname, 'admin', 'index.html'));
3483
+ });
3484
+
3485
+ app.get('/admin/install.html', (req, res) => {
3486
+ res.sendFile(path.join(__dirname, 'admin', 'install.html'));
3487
+ });
3488
+
3489
+ // Fallback static files
3490
+ app.use('/admin', async (req, res, next) => {
3491
+ if (!(await checkIsInstalled())) {
3492
+ if (req.path === '/' || req.path === '/index.html') {
3493
+ return res.sendFile(path.join(__dirname, 'admin', 'install.html'));
3494
+ }
3495
+ }
3496
+ next();
3497
+ }, express.static(path.join(__dirname, 'admin')));
3498
+
3499
+ // Boot server after database initialization (if configured)
3500
+ if (require.main === module || process.env.RUN_VAULT_SERVER === 'true') {
3501
+ const bootServer = async () => {
3502
+ const hasKey = !!process.env.VAULT_MASTER_KEY || fs.existsSync(KEY_FILE);
3503
+ const hasConfig = !!process.env.DB_DIALECT || fs.existsSync(configPath);
3504
+
3505
+ if (hasKey && hasConfig) {
3506
+ console.log("Vault is configured. Initializing database...");
3507
+ await initDb();
3508
+ } else {
3509
+ console.log("Vault is not configured. Starting in setup wizard mode...");
3510
+ }
3511
+
3512
+ activeHttpServer = app.listen(PORT, () => {
3513
+ console.log(`FilePilot Corporate Vault Server running on port ${PORT}`);
3514
+ console.log(`Admin Portal: http://localhost:${PORT}/admin`);
3515
+ console.log(`Sync URL: http://localhost:${PORT}/v1/secret/data/filepilot/profiles`);
3516
+ });
3517
+
3518
+ // Setup WebSocket server
3519
+ activeWss = new WebSocketServer({ server: activeHttpServer });
3520
+ const wss = activeWss;
3521
+
3522
+ const keepaliveInterval = setInterval(() => {
3523
+ wss.clients.forEach(ws => {
3524
+ if (ws.isAlive === false) {
3525
+ console.log("[WS] Terminating unresponsive client connection.");
3526
+ return ws.terminate();
3527
+ }
3528
+ ws.isAlive = false;
3529
+ ws.ping();
3530
+ });
3531
+ }, 30000);
3532
+
3533
+ wss.on('close', () => {
3534
+ clearInterval(keepaliveInterval);
3535
+ });
3536
+
3537
+ wss.on('connection', async (ws, req) => {
3538
+ ws.isAlive = true;
3539
+ ws.on('pong', () => { ws.isAlive = true; });
3540
+
3541
+ const parameters = urlParser.parse(req.url, true).query;
3542
+ const isCollab = parameters.collaboration === 'true';
3543
+ const file = parameters.file;
3544
+ const user = parameters.user || 'Collaborator';
3545
+
3546
+ if (isCollab && file) {
3547
+ ws.isCollab = true;
3548
+ ws.collabFile = file;
3549
+ ws.collabUser = user;
3550
+
3551
+ console.log(`[WS Collab] User "${user}" joined collaboration for file: ${file}`);
3552
+
3553
+ ws.on('message', (message) => {
3554
+ try {
3555
+ const payload = JSON.parse(message.toString());
3556
+ if (payload.type === 'cursor' || payload.type === 'edit') {
3557
+ wss.clients.forEach((client) => {
3558
+ if (client !== ws && client.isCollab && client.readyState === 1 && client.collabFile === file) {
3559
+ client.send(JSON.stringify({
3560
+ ...payload,
3561
+ user: user,
3562
+ }));
3563
+ }
3564
+ });
3565
+ }
3566
+ } catch (err) {
3567
+ console.error("[WS Collab] Error processing collab message:", err);
3568
+ }
3569
+ });
3570
+
3571
+ ws.on('close', () => {
3572
+ console.log(`[WS Collab] User "${user}" left collaboration for file: ${file}`);
3573
+ wss.clients.forEach((client) => {
3574
+ if (client !== ws && client.isCollab && client.readyState === 1 && client.collabFile === file) {
3575
+ client.send(JSON.stringify({
3576
+ type: 'leave',
3577
+ user: user,
3578
+ }));
3579
+ }
3580
+ });
3581
+ });
3582
+
3583
+ return;
3584
+ }
3585
+
3586
+ // If the vault is not installed/configured, reject WebSocket token connections immediately to prevent database crashes
3587
+ if (!(await checkIsInstalled())) {
3588
+ console.log("[WS] Connection rejected: Vault is not configured/installed yet.");
3589
+ try {
3590
+ ws.send(JSON.stringify({ type: "revoked" }));
3591
+ ws.close();
3592
+ } catch (_) {}
3593
+ return;
3594
+ }
3595
+
3596
+ const token = parameters.token;
3597
+ const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
3598
+ console.log(`[WS] Connection attempt. Token (truncated): ${token ? token.substring(0, 8) + '...' : 'none'} from IP: ${clientIp}`);
3599
+
3600
+ if (token) {
3601
+ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
3602
+ const tokenObj = await AccessKey.findByPk(tokenHash, { include: [User] });
3603
+
3604
+ if (!tokenObj) {
3605
+ console.log(`[WS] Connection rejected: Token not found`);
3606
+ try {
3607
+ ws.send(JSON.stringify({ type: "access_revoked" }));
3608
+ ws.close();
3609
+ } catch (_) {}
3610
+ return;
3611
+ }
3612
+
3613
+ if (tokenObj.expiresAt) {
3614
+ const expiry = new Date(tokenObj.expiresAt);
3615
+ if (expiry < new Date()) {
3616
+ tokenObj.status = 'expired';
3617
+ await tokenObj.save();
3618
+ console.log(`[WS] Connection rejected: Token has expired`);
3619
+ try {
3620
+ ws.send(JSON.stringify({ type: "access_revoked" }));
3621
+ ws.close();
3622
+ } catch (_) {}
3623
+ return;
3624
+ }
3625
+ }
3626
+
3627
+ const group = await VaultGroup.findByPk(tokenObj.groupId);
3628
+ if (group && group.ipAllowlist && !ipMatches(clientIp, group.ipAllowlist)) {
3629
+ console.log(`[WS] Connection rejected: Client IP ${clientIp} is not whitelisted for group "${group.name}"`);
3630
+ try {
3631
+ ws.send(JSON.stringify({ type: "access_revoked" }));
3632
+ ws.close();
3633
+ } catch (_) {}
3634
+ return;
3635
+ }
3636
+
3637
+ clients.set(tokenHash, ws);
3638
+ console.log(`[WS] Client registered. Active clients count: ${clients.size}`);
3639
+
3640
+ ws.on('message', (message) => {
3641
+ try {
3642
+ const payload = JSON.parse(message.toString());
3643
+ if (payload.type === 'client_session') {
3644
+ ws.clientConnection = payload.connected ? {
3645
+ host: payload.host,
3646
+ protocol: payload.protocol,
3647
+ username: payload.username,
3648
+ profileId: payload.profileId,
3649
+ connectedAt: new Date().toISOString()
3650
+ } : null;
3651
+ console.log(`[WS] Token ${tokenHash.substring(0, 8)} updated connection status:`, ws.clientConnection);
3652
+ }
3653
+ } catch (err) {
3654
+ console.error("[WS] Error processing client message:", err);
3655
+ }
3656
+ });
3657
+
3658
+ if (tokenObj.status === 'blocked') {
3659
+ console.log(`[WS] Client connected but token is blocked (suspended)`);
3660
+ try {
3661
+ ws.send(JSON.stringify({ type: "access_suspended" }));
3662
+ } catch (_) {}
3663
+ } else {
3664
+ // Fetch and push any pending reversions for this specific token!
3665
+ try {
3666
+ const pending = await PendingReversion.findAll({
3667
+ where: { tokenHash: tokenHash, status: 'pending' }
3668
+ });
3669
+ if (pending.length > 0) {
3670
+ console.log(`[WS] Pushing ${pending.length} pending file reversions to client ${tokenObj.User?.username || 'Operator'}`);
3671
+ for (const p of pending) {
3672
+ ws.send(JSON.stringify({
3673
+ type: 'write_file',
3674
+ connectionId: p.connectionId,
3675
+ filePath: p.filePath,
3676
+ content: p.content,
3677
+ isQueued: true,
3678
+ timestamp: p.createdAt.getTime()
3679
+ }));
3680
+ p.status = 'applied';
3681
+ await p.save();
3682
+ }
3683
+ }
3684
+ } catch (pendingErr) {
3685
+ console.error('[WS] Failed to fetch and dispatch pending reversions:', pendingErr);
3686
+ }
3687
+ }
3688
+
3689
+ ws.on('error', (err) => {
3690
+ console.error(`[WS] Socket error for client:`, err);
3691
+ });
3692
+
3693
+ ws.on('close', () => {
3694
+ clients.delete(tokenHash);
3695
+ console.log(`[WS] Client disconnected. Active clients count: ${clients.size}`);
3696
+ });
3697
+ }
3698
+ });
3699
+ };
3700
+ bootServer().catch(err => {
3701
+ console.error("Server boot failed:", err);
3702
+ process.exit(1);
3703
+ });
3704
+ }
3705
+
3706
+ function notifyTokenSuspension(tokenVal) {
3707
+ console.log(`[WS] Attempting to suspend token: ${tokenVal}`);
3708
+ const ws = clients.get(tokenVal);
3709
+ if (ws) {
3710
+ try {
3711
+ console.log(`[WS] Sending 'access_suspended' message to client with token: ${tokenVal}`);
3712
+ ws.send(JSON.stringify({ type: "access_suspended" }));
3713
+ ws.close();
3714
+ } catch (e) {
3715
+ console.error("[WS] Failed to send WebSocket suspension message:", e);
3716
+ }
3717
+ clients.delete(tokenVal);
3718
+ }
3719
+ }
3720
+
3721
+ function notifyTokenRestoration(tokenVal) {
3722
+ console.log(`[WS] Attempting to restore token: ${tokenVal}`);
3723
+ const ws = clients.get(tokenVal);
3724
+ if (ws) {
3725
+ try {
3726
+ console.log(`[WS] Sending 'access_restored' message to client with token: ${tokenVal}`);
3727
+ ws.send(JSON.stringify({ type: "access_restored" }));
3728
+ } catch (e) {
3729
+ console.error("[WS] Failed to send WebSocket restoration message:", e);
3730
+ }
3731
+ }
3732
+ }
3733
+
3734
+ function notifyTokenRevocation(tokenVal) {
3735
+ console.log(`[WS] Attempting to revoke token: ${tokenVal}`);
3736
+ const ws = clients.get(tokenVal);
3737
+ if (ws) {
3738
+ try {
3739
+ console.log(`[WS] Sending 'access_revoked' message to client with token: ${tokenVal}`);
3740
+ ws.send(JSON.stringify({ type: "access_revoked" }));
3741
+ ws.close();
3742
+ } catch (e) {
3743
+ console.error("[WS] Failed to send WebSocket revocation message:", e);
3744
+ }
3745
+ clients.delete(tokenVal);
3746
+ } else {
3747
+ console.log(`[WS] No active socket connection found for token: ${tokenVal}`);
3748
+ }
3749
+ }
3750
+
3751
+ async function notifyGroupRevocation(groupId) {
3752
+ const tokens = await AccessKey.findAll({ where: { groupId } });
3753
+ tokens.forEach(t => {
3754
+ notifyTokenRevocation(t.token_hash);
3755
+ });
3756
+ }
3757
+
3758
+ // ── Compliance Export and Continuous Drift Detection ─────────────────
3759
+ const complianceChecks = require('./compliance-checks.cjs');
3760
+ const PDFDocument = require('pdfkit');
3761
+
3762
+ const FRAMEWORK_MAPPINGS = {
3763
+ soc2: {
3764
+ name: "SOC 2 Type II",
3765
+ disclaimer: "This report provides technical evidence to support a SOC 2 Type II compliance assessment. It does not constitute certification and should be reviewed by a qualified auditor.",
3766
+ controls: [
3767
+ {
3768
+ controlId: "CC6.1",
3769
+ title: "Logical access security measures (Encryption Posture)",
3770
+ postureSource: "encryption"
3771
+ },
3772
+ {
3773
+ controlId: "CC6.3",
3774
+ title: "Access registration and role assignment (Access Control Posture)",
3775
+ postureSource: "accessControl"
3776
+ },
3777
+ {
3778
+ controlId: "CC6.6",
3779
+ title: "Boundary protection and firewalls (Access Policy Posture)",
3780
+ postureSource: "accessPolicy"
3781
+ },
3782
+ {
3783
+ controlId: "CC7.2",
3784
+ title: "System monitoring and logging (Audit Integrity Posture)",
3785
+ postureSource: "auditIntegrity"
3786
+ },
3787
+ {
3788
+ controlId: "CC6.4",
3789
+ title: "Session security and terminal locks (Session Security Posture)",
3790
+ postureSource: "sessionSecurity"
3791
+ }
3792
+ ]
3793
+ },
3794
+ hipaa: {
3795
+ name: "HIPAA Security Rule",
3796
+ disclaimer: "This report provides technical evidence to support a HIPAA Security Rule compliance assessment. It does not constitute certification and should be reviewed by a qualified auditor.",
3797
+ controls: [
3798
+ {
3799
+ controlId: "§164.312(a)(2)(iv)",
3800
+ title: "Encryption and Decryption (Encryption Posture)",
3801
+ postureSource: "encryption"
3802
+ },
3803
+ {
3804
+ controlId: "§164.312(a)(1)",
3805
+ title: "Access Control (Access Control Posture)",
3806
+ postureSource: "accessControl"
3807
+ },
3808
+ {
3809
+ controlId: "§164.312(e)(1)",
3810
+ title: "Transmission Security (Access Policy Posture)",
3811
+ postureSource: "accessPolicy"
3812
+ },
3813
+ {
3814
+ controlId: "§164.312(b)",
3815
+ title: "Audit controls (Audit Integrity Posture)",
3816
+ postureSource: "auditIntegrity"
3817
+ },
3818
+ {
3819
+ controlId: "§164.312(a)(2)(iii)",
3820
+ title: "Automatic logoff (Session Security Posture)",
3821
+ postureSource: "sessionSecurity"
3822
+ }
3823
+ ]
3824
+ },
3825
+ iso27001: {
3826
+ name: "ISO/IEC 27001:2022 Annex A",
3827
+ disclaimer: "This report provides technical evidence to support an ISO/IEC 27001 compliance assessment. It does not constitute certification and should be reviewed by a qualified auditor.",
3828
+ controls: [
3829
+ {
3830
+ controlId: "A.8.24",
3831
+ title: "Use of cryptography (Encryption Posture)",
3832
+ postureSource: "encryption"
3833
+ },
3834
+ {
3835
+ controlId: "A.8.2",
3836
+ title: "Privileged access rights (Access Control Posture)",
3837
+ postureSource: "accessControl"
3838
+ },
3839
+ {
3840
+ controlId: "A.8.20",
3841
+ title: "Network security (Access Policy Posture)",
3842
+ postureSource: "accessPolicy"
3843
+ },
3844
+ {
3845
+ controlId: "A.8.15",
3846
+ title: "Logging (Audit Integrity Posture)",
3847
+ postureSource: "auditIntegrity"
3848
+ },
3849
+ {
3850
+ controlId: "A.5.15",
3851
+ title: "Access control (Session Security Posture)",
3852
+ postureSource: "sessionSecurity"
3853
+ }
3854
+ ]
3855
+ },
3856
+ generic: {
3857
+ name: "Generic Security Posture",
3858
+ disclaimer: "This report provides technical evidence to support a general compliance assessment. It does not constitute certification and should be reviewed by a qualified auditor.",
3859
+ controls: [
3860
+ {
3861
+ controlId: "GEN-CRYPTO",
3862
+ title: "Cryptographic Protection",
3863
+ postureSource: "encryption"
3864
+ },
3865
+ {
3866
+ controlId: "GEN-RBAC",
3867
+ title: "Role-Based Access Control",
3868
+ postureSource: "accessControl"
3869
+ },
3870
+ {
3871
+ controlId: "GEN-NET",
3872
+ title: "Network Policy & IP Control",
3873
+ postureSource: "accessPolicy"
3874
+ },
3875
+ {
3876
+ controlId: "GEN-AUDIT",
3877
+ title: "Audit Log Integrity",
3878
+ postureSource: "auditIntegrity"
3879
+ },
3880
+ {
3881
+ controlId: "GEN-SESS",
3882
+ title: "Session Control & Security limits",
3883
+ postureSource: "sessionSecurity"
3884
+ }
3885
+ ]
3886
+ }
3887
+ };
3888
+
3889
+ const DRIFT_RULES = [
3890
+ {
3891
+ id: "RULE_NO_IP_ALLOWLIST",
3892
+ description: "Vault Group has no IP allowlist configured",
3893
+ severity: "medium",
3894
+ check: async (postures) => {
3895
+ const findings = [];
3896
+ for (const group of postures.accessPolicy) {
3897
+ if (!group.hasIpAllowlist) {
3898
+ findings.push({
3899
+ ruleId: "RULE_NO_IP_ALLOWLIST",
3900
+ severity: "medium",
3901
+ description: `Vault Group "${group.groupName}" has no IP allowlist configured, allowing connections from any IP address.`,
3902
+ affectedEntity: `Vault Group: ${group.groupName} (${group.groupId})`
3903
+ });
3904
+ }
3905
+ }
3906
+ return findings;
3907
+ }
3908
+ },
3909
+ {
3910
+ id: "RULE_TOKEN_AGE_90",
3911
+ description: "Token has been active more than 90 days without rotation",
3912
+ severity: "medium",
3913
+ check: async (postures) => {
3914
+ const findings = [];
3915
+ for (const token of postures.accessControl.activeTokens) {
3916
+ if (token.ageDays > 90) {
3917
+ findings.push({
3918
+ ruleId: "RULE_TOKEN_AGE_90",
3919
+ severity: "medium",
3920
+ description: `Token issued to user "${token.user}" has been active for ${token.ageDays} days without rotation.`,
3921
+ affectedEntity: `Token: ${token.token_hash_truncated} (User: ${token.user})`
3922
+ });
3923
+ }
3924
+ }
3925
+ return findings;
3926
+ }
3927
+ },
3928
+ {
3929
+ id: "RULE_NO_MFA",
3930
+ description: "Admin user does not have MFA enabled",
3931
+ severity: "high",
3932
+ check: async (postures) => {
3933
+ const findings = [];
3934
+ if (postures.accessControl && postures.accessControl.users) {
3935
+ for (const u of postures.accessControl.users) {
3936
+ if (!u.mfa_enabled) {
3937
+ findings.push({
3938
+ ruleId: "RULE_NO_MFA",
3939
+ severity: "high",
3940
+ description: `Admin Portal user "${u.username}" does not have Multi-Factor Authentication (MFA) enabled.`,
3941
+ affectedEntity: `User: ${u.username} (Role: ${u.role})`
3942
+ });
3943
+ }
3944
+ }
3945
+ }
3946
+ return findings;
3947
+ }
3948
+ },
3949
+ {
3950
+ id: "RULE_LOCAL_KEK",
3951
+ description: "Vault Group is still using the app-managed local KEK instead of a dedicated BYOK provider",
3952
+ severity: "low",
3953
+ check: async (postures) => {
3954
+ const findings = [];
3955
+ for (const group of postures.encryption.groups) {
3956
+ if (group.kmsProvider === 'local') {
3957
+ findings.push({
3958
+ ruleId: "RULE_LOCAL_KEK",
3959
+ severity: "low",
3960
+ description: `Vault Group "${group.groupName}" is using the app-managed local KEK instead of an external BYOK KMS provider.`,
3961
+ affectedEntity: `Vault Group: ${group.groupName} (${group.groupId})`
3962
+ });
3963
+ }
3964
+ }
3965
+ return findings;
3966
+ }
3967
+ },
3968
+ {
3969
+ id: "RULE_KEK_ROTATION_180",
3970
+ description: "KEK/DEK has not been rotated in over 180 days",
3971
+ severity: "medium",
3972
+ check: async (postures) => {
3973
+ const findings = [];
3974
+ const oldestRotation = await KeyVersion.findOne({ order: [['version', 'DESC']] });
3975
+ const refDate = oldestRotation ? new Date(oldestRotation.createdAt) : new Date();
3976
+ const ageDays = (Date.now() - refDate.getTime()) / (1000 * 60 * 60 * 24);
3977
+ if (ageDays > 180) {
3978
+ findings.push({
3979
+ ruleId: "RULE_KEK_ROTATION_180",
3980
+ severity: "medium",
3981
+ description: `The master Key Encryption Key (KEK) has not been rotated in ${Math.round(ageDays)} days (threshold is 180 days).`,
3982
+ affectedEntity: "Global KEK"
3983
+ });
3984
+ }
3985
+ return findings;
3986
+ }
3987
+ },
3988
+ {
3989
+ id: "RULE_SESSION_TIMEOUT_24",
3990
+ description: "Session timeout is configured longer than 24 hours",
3991
+ severity: "low",
3992
+ check: async (postures) => {
3993
+ const findings = [];
3994
+ if (postures.sessionSecurity.sessionTimeoutMs > 24 * 60 * 60 * 1000) {
3995
+ findings.push({
3996
+ ruleId: "RULE_SESSION_TIMEOUT_24",
3997
+ severity: "low",
3998
+ description: `The session timeout limit is configured for ${Math.round(postures.sessionSecurity.sessionTimeoutMs / (1000 * 60 * 60))} hours, which exceeds the 24-hour limit.`,
3999
+ affectedEntity: "Session Configuration"
4000
+ });
4001
+ }
4002
+ return findings;
4003
+ }
4004
+ },
4005
+ {
4006
+ id: "RULE_NO_SYNC_7_DAYS",
4007
+ description: "A Vault Group has zero successful syncs in the last 7 days",
4008
+ severity: "low",
4009
+ check: async (postures) => {
4010
+ const findings = [];
4011
+ const { Op } = require('sequelize');
4012
+ for (const group of postures.accessPolicy) {
4013
+ const recentSync = await TransferLog.findOne({
4014
+ where: {
4015
+ action: {
4016
+ [Op.like]: `Sync request for vault "${group.groupName}"%`
4017
+ },
4018
+ status: {
4019
+ [Op.not]: 'failed'
4020
+ },
4021
+ createdAt: {
4022
+ [Op.gt]: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
4023
+ }
4024
+ }
4025
+ });
4026
+ if (!recentSync) {
4027
+ findings.push({
4028
+ ruleId: "RULE_NO_SYNC_7_DAYS",
4029
+ severity: "low",
4030
+ description: `Vault Group "${group.groupName}" has zero successful client profile synchronization logs in the last 7 days.`,
4031
+ affectedEntity: `Vault Group: ${group.groupName} (${group.groupId})`
4032
+ });
4033
+ }
4034
+ }
4035
+ return findings;
4036
+ }
4037
+ }
4038
+ ];
4039
+
4040
+ // Endpoint: One-Click Compliance Evidence Export
4041
+ app.get('/admin/api/compliance/export', authMiddleware, requirePermission('audit.export'), async (req, res) => {
4042
+ try {
4043
+ const framework = (req.query.framework || 'generic').toLowerCase();
4044
+ const format = (req.query.format || 'pdf').toLowerCase();
4045
+ const mapping = FRAMEWORK_MAPPINGS[framework];
4046
+
4047
+ if (!mapping) {
4048
+ return res.status(400).json({ error: `Unsupported compliance framework: ${framework}` });
4049
+ }
4050
+
4051
+ // Call all five collector functions
4052
+ const assembledPostures = {
4053
+ encryption: await complianceChecks.getEncryptionPosture(),
4054
+ accessControl: await complianceChecks.getAccessControlPosture(),
4055
+ auditIntegrity: await complianceChecks.getAuditIntegrityPosture(),
4056
+ accessPolicy: await complianceChecks.getAccessPolicyPosture(),
4057
+ sessionSecurity: await complianceChecks.getSessionSecurityPosture()
4058
+ };
4059
+
4060
+ // Log the export action to the audit log (with attribution)
4061
+ await addAuditLog(
4062
+ req.adminUser.username,
4063
+ JSON.stringify({
4064
+ action: "compliance_evidence_exported",
4065
+ performedBy: req.adminUser.id,
4066
+ performedByUsername: req.adminUser.username,
4067
+ framework
4068
+ })
4069
+ );
4070
+
4071
+ // Format output
4072
+ if (format === 'json') {
4073
+ const report = {
4074
+ generationTimestamp: new Date().toISOString(),
4075
+ vaultInstanceIdentifier: process.env.VAULT_INSTANCE_ID || 'filepilot-corporate-vault',
4076
+ framework: framework,
4077
+ disclaimer: mapping.disclaimer,
4078
+ evidence: mapping.controls.map(c => ({
4079
+ controlId: c.controlId,
4080
+ controlTitle: c.title,
4081
+ data: assembledPostures[c.postureSource]
4082
+ }))
4083
+ };
4084
+ return res.json(report);
4085
+ }
4086
+
4087
+ // PDF format
4088
+ const doc = new PDFDocument({ margin: 50 });
4089
+ res.setHeader('Content-Type', 'application/pdf');
4090
+ res.setHeader('Content-Disposition', `attachment; filename="compliance_report_${framework}.pdf"`);
4091
+ doc.pipe(res);
4092
+
4093
+ // Title
4094
+ doc.fontSize(20).text(`Compliance Evidence Report: ${mapping.name}`, { align: 'center' });
4095
+ doc.moveDown(0.5);
4096
+
4097
+ // Gen info
4098
+ doc.fontSize(10).text(`Generated On: ${new Date().toUTCString()}`);
4099
+ doc.text(`Vault Instance ID: ${process.env.VAULT_INSTANCE_ID || 'filepilot-corporate-vault'}`);
4100
+ doc.moveDown(1);
4101
+
4102
+ // Disclaimer
4103
+ doc.fontSize(10).fillColor('#64748b').text(mapping.disclaimer, { oblique: true });
4104
+ doc.fillColor('black'); // Reset color
4105
+ doc.moveDown(1.5);
4106
+
4107
+ // Iterate controls
4108
+ for (const c of mapping.controls) {
4109
+ doc.fontSize(14).text(`${c.controlId} - ${c.title}`, { underline: true });
4110
+ doc.moveDown(0.5);
4111
+
4112
+ const data = assembledPostures[c.postureSource];
4113
+ const jsonStr = JSON.stringify(data, null, 2);
4114
+
4115
+ doc.fontSize(9).font('Courier').text(jsonStr);
4116
+ doc.font('Helvetica'); // Restore font
4117
+ doc.moveDown(1.5);
4118
+ }
4119
+
4120
+ doc.end();
4121
+ } catch (err) {
4122
+ console.error("[Compliance Export Error] Failed to generate compliance report:", err);
4123
+ res.status(500).json({ error: err.message });
4124
+ }
4125
+ });
4126
+
4127
+ // Endpoint: Continuous Compliance Drift Findings
4128
+ app.get('/admin/api/compliance/drift', authMiddleware, requirePermission('audit.view'), async (req, res) => {
4129
+ try {
4130
+ const postures = {
4131
+ encryption: await complianceChecks.getEncryptionPosture(),
4132
+ accessControl: await complianceChecks.getAccessControlPosture(),
4133
+ auditIntegrity: await complianceChecks.getAuditIntegrityPosture(),
4134
+ accessPolicy: await complianceChecks.getAccessPolicyPosture(),
4135
+ sessionSecurity: await complianceChecks.getSessionSecurityPosture()
4136
+ };
4137
+
4138
+ const findings = [];
4139
+ for (const rule of DRIFT_RULES) {
4140
+ try {
4141
+ const ruleFindings = await rule.check(postures);
4142
+ findings.push(...ruleFindings);
4143
+ } catch (err) {
4144
+ console.error(`[Compliance Drift API] Error running rule ${rule.id}:`, err);
4145
+ }
4146
+ }
4147
+
4148
+ res.json({
4149
+ timestamp: new Date().toISOString(),
4150
+ findings
4151
+ });
4152
+ } catch (err) {
4153
+ console.error("[Compliance Drift Error] Failed to fetch drift findings:", err);
4154
+ res.status(500).json({ error: err.message });
4155
+ }
4156
+ });
4157
+
4158
+ // Scheduled Compliance Drift Checker (runs automatically once every 24 hours, or 5s in test)
4159
+ let lastHighFindingIds = new Set();
4160
+
4161
+ async function runComplianceDriftCheck() {
4162
+ if (!(await checkIsInstalled())) {
4163
+ return;
4164
+ }
4165
+ try {
4166
+ const postures = {
4167
+ encryption: await complianceChecks.getEncryptionPosture(),
4168
+ accessControl: await complianceChecks.getAccessControlPosture(),
4169
+ auditIntegrity: await complianceChecks.getAuditIntegrityPosture(),
4170
+ accessPolicy: await complianceChecks.getAccessPolicyPosture(),
4171
+ sessionSecurity: await complianceChecks.getSessionSecurityPosture()
4172
+ };
4173
+
4174
+ const findings = [];
4175
+ for (const rule of DRIFT_RULES) {
4176
+ try {
4177
+ const ruleFindings = await rule.check(postures);
4178
+ findings.push(...ruleFindings);
4179
+ } catch (err) {
4180
+ console.error(`[Drift Job] Error running rule ${rule.id}:`, err);
4181
+ }
4182
+ }
4183
+
4184
+ const currentHighIds = new Set();
4185
+ const currentHighs = findings.filter(f => f.severity === 'high');
4186
+
4187
+ for (const f of currentHighs) {
4188
+ const uniqueId = `${f.ruleId}:${f.affectedEntity}`;
4189
+ currentHighIds.add(uniqueId);
4190
+
4191
+ if (!lastHighFindingIds.has(uniqueId)) {
4192
+ console.log(`[Drift Job] NEW HIGH SEVERITY FINDING DETECTED: ${f.description}`);
4193
+ await triggerSiemWebhook('Compliance Drift', f.description, 'high');
4194
+ }
4195
+ }
4196
+
4197
+ lastHighFindingIds = currentHighIds;
4198
+ } catch (err) {
4199
+ console.error("[Drift Job] Failed to execute compliance check:", err);
4200
+ }
4201
+ }
4202
+
4203
+ const DRIFT_INTERVAL_MS = process.env.NODE_ENV === 'test' ? 5000 : 24 * 60 * 60 * 1000;
4204
+ setInterval(runComplianceDriftCheck, DRIFT_INTERVAL_MS);
4205
+ setTimeout(runComplianceDriftCheck, 1000);
4206
+
4207
+ module.exports = {
4208
+ computeLogHash,
4209
+ formatLogsForExport,
4210
+ getGroupDek,
4211
+ app,
4212
+ decryptKmsCredentials,
4213
+ encryptKmsCredentials
4214
+ };