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/README.md +209 -0
- package/admin/enterprise.png +0 -0
- package/admin/index.html +5297 -0
- package/admin/install.html +511 -0
- package/bin/cli.js +247 -0
- package/compliance-checks.cjs +221 -0
- package/db.cjs +999 -0
- package/kms-providers.cjs +329 -0
- package/package.json +59 -0
- package/server.cjs +4214 -0
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
|
+
};
|