log-serialize 2.1.1
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 +17 -0
- package/index.js +876 -0
- package/package.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# log-serialize
|
|
2
|
+
|
|
3
|
+
Audit log validation, integrity verification, and secure pipeline integration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Parse and validate structured log entries
|
|
8
|
+
- Cryptographic hashing (SHA-256, SHA-512)
|
|
9
|
+
- RSA signing and signature verification
|
|
10
|
+
- HMAC integrity checks
|
|
11
|
+
- AES-256-GCM encryption for sensitive log data
|
|
12
|
+
- Compression for large batches
|
|
13
|
+
- Audit ID generation with revocation support
|
|
14
|
+
- JSONPath field extraction
|
|
15
|
+
- Time series bucketing
|
|
16
|
+
- YAML/JSON config parsing
|
|
17
|
+
- Caching with automatic pruning
|
package/index.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createHmac,
|
|
3
|
+
createSign,
|
|
4
|
+
createVerify,
|
|
5
|
+
randomBytes,
|
|
6
|
+
createHash,
|
|
7
|
+
createCipheriv,
|
|
8
|
+
createDecipheriv,
|
|
9
|
+
scryptSync,
|
|
10
|
+
timingSafeEqual
|
|
11
|
+
} = require('crypto');
|
|
12
|
+
const {
|
|
13
|
+
format,
|
|
14
|
+
parseISO,
|
|
15
|
+
isValid,
|
|
16
|
+
differenceInMilliseconds,
|
|
17
|
+
addMinutes,
|
|
18
|
+
isAfter,
|
|
19
|
+
isBefore,
|
|
20
|
+
subHours,
|
|
21
|
+
startOfDay,
|
|
22
|
+
endOfDay,
|
|
23
|
+
eachHourOfInterval,
|
|
24
|
+
eachDayOfInterval
|
|
25
|
+
} = require('date-fns');
|
|
26
|
+
const merge = require('lodash.merge');
|
|
27
|
+
const isPlainObject = require('lodash.isplainobject');
|
|
28
|
+
const { quickPack, normalize } = require('datatran');
|
|
29
|
+
const debounce = require('lodash.debounce');
|
|
30
|
+
const { v4: uuidv4, validate: uuidValidate, v5: uuidv5 } = require('uuid');
|
|
31
|
+
const ms = require('ms');
|
|
32
|
+
const bytes = require('bytes');
|
|
33
|
+
const slugify = require('slugify');
|
|
34
|
+
const sanitizeHtml = require('sanitize-html');
|
|
35
|
+
const { compile } = require('jsonpath');
|
|
36
|
+
const yaml = require('js-yaml');
|
|
37
|
+
const semver = require('semver');
|
|
38
|
+
const { deflateSync, inflateSync, gzipSync, gunzipSync } = require('zlib');
|
|
39
|
+
const { URL } = require('url');
|
|
40
|
+
|
|
41
|
+
// ─── Constants ────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const DEFAULT_ALGORITHM = 'sha256';
|
|
44
|
+
const SIGN_ALGORITHM = 'RSA-SHA256';
|
|
45
|
+
const HMAC_ALGORITHM = 'sha512';
|
|
46
|
+
const CIPHER_ALGORITHM = 'aes-256-gcm';
|
|
47
|
+
const IV_LENGTH = 16;
|
|
48
|
+
const AUTH_TAG_LENGTH = 16;
|
|
49
|
+
const SALT_LENGTH = 32;
|
|
50
|
+
const KEY_LENGTH = 32;
|
|
51
|
+
const MAX_ENTRY_AGE_MS = ms('24h');
|
|
52
|
+
const MAX_PAYLOAD_BYTES = bytes('5mb');
|
|
53
|
+
const LOG_ENTRY_REGEX = /^\[(?<ts>[^\]]+)\]\s+\[(?<level>\w+)\]\s+(?<message>.+)$/;
|
|
54
|
+
const SUPPORTED_VERSIONS = '>=1.0.0 <5.0.0';
|
|
55
|
+
const UUID_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
|
56
|
+
const COMPRESSION_THRESHOLD = bytes('1kb');
|
|
57
|
+
const MAX_CACHE_ENTRIES = 10000;
|
|
58
|
+
const CACHE_PRUNE_INTERVAL = ms('5m');
|
|
59
|
+
const CACHE_TTL = ms('30m');
|
|
60
|
+
|
|
61
|
+
// ─── State ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
let _auditCache = new Map();
|
|
64
|
+
let _revocationList = new Set();
|
|
65
|
+
let _metrics = {
|
|
66
|
+
processed: 0,
|
|
67
|
+
invalid: 0,
|
|
68
|
+
signed: 0,
|
|
69
|
+
encrypted: 0,
|
|
70
|
+
compressed: 0,
|
|
71
|
+
bytesProcessed: 0,
|
|
72
|
+
bytesSaved: 0,
|
|
73
|
+
cacheHits: 0,
|
|
74
|
+
cacheMisses: 0,
|
|
75
|
+
revocations: 0,
|
|
76
|
+
errors: {},
|
|
77
|
+
startTime: Date.now()
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ─── Internal Helpers ─────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const _sanitizeMessage = (message, options = {}) => {
|
|
83
|
+
const defaults = {
|
|
84
|
+
allowedTags: [],
|
|
85
|
+
allowedAttributes: {},
|
|
86
|
+
disallowedTagsMode: 'discard',
|
|
87
|
+
textFilter: (text) => text.trim().replace(/\s+/g, ' '),
|
|
88
|
+
enforceHtmlBoundary: false,
|
|
89
|
+
parseStyleAttributes: false
|
|
90
|
+
};
|
|
91
|
+
const config = merge({}, defaults, options);
|
|
92
|
+
return sanitizeHtml(String(message), config);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const _generateNonce = (length = 32) => {
|
|
96
|
+
return randomBytes(length).toString('base64url');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const _generateSalt = () => {
|
|
100
|
+
return randomBytes(SALT_LENGTH);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const _checkVersion = (version) => {
|
|
104
|
+
const coerced = semver.coerce(version);
|
|
105
|
+
if (!coerced) {
|
|
106
|
+
throw new Error(`Cannot parse version string: ${version}`);
|
|
107
|
+
}
|
|
108
|
+
if (!semver.satisfies(coerced, SUPPORTED_VERSIONS)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Unsupported version: ${coerced.version}. Supported range: ${SUPPORTED_VERSIONS}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return coerced.version;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const _applyJsonPath = (entry, paths) => {
|
|
117
|
+
if (!paths || paths.length === 0) return entry;
|
|
118
|
+
let result = {};
|
|
119
|
+
for (const path of paths) {
|
|
120
|
+
try {
|
|
121
|
+
const expr = compile(path);
|
|
122
|
+
const value = expr.query(entry);
|
|
123
|
+
const key = slugify(path, {
|
|
124
|
+
lower: true,
|
|
125
|
+
replacement: '_',
|
|
126
|
+
trim: true,
|
|
127
|
+
strict: true
|
|
128
|
+
});
|
|
129
|
+
result[key] = value.length === 1 ? value[0] : value;
|
|
130
|
+
} catch (_) {
|
|
131
|
+
result[slugify(path, { lower: true })] = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return merge({}, entry, result);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const _deriveKey = (passphrase, salt) => {
|
|
138
|
+
return scryptSync(passphrase, salt, KEY_LENGTH);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const _encryptPayload = (plaintext, passphrase) => {
|
|
142
|
+
const salt = _generateSalt();
|
|
143
|
+
const key = _deriveKey(passphrase, salt);
|
|
144
|
+
const iv = randomBytes(IV_LENGTH);
|
|
145
|
+
const cipher = createCipheriv(CIPHER_ALGORITHM, key, iv);
|
|
146
|
+
|
|
147
|
+
const input = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf8');
|
|
148
|
+
const encrypted = Buffer.concat([cipher.update(input), cipher.final()]);
|
|
149
|
+
const authTag = cipher.getAuthTag();
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
encrypted: encrypted.toString('base64'),
|
|
153
|
+
iv: iv.toString('base64'),
|
|
154
|
+
salt: salt.toString('base64'),
|
|
155
|
+
authTag: authTag.toString('base64'),
|
|
156
|
+
algorithm: CIPHER_ALGORITHM
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const _decryptPayload = (payload, passphrase) => {
|
|
161
|
+
const salt = Buffer.from(payload.salt, 'base64');
|
|
162
|
+
const key = _deriveKey(passphrase, salt);
|
|
163
|
+
const iv = Buffer.from(payload.iv, 'base64');
|
|
164
|
+
const authTag = Buffer.from(payload.authTag, 'base64');
|
|
165
|
+
const encrypted = Buffer.from(payload.encrypted, 'base64');
|
|
166
|
+
|
|
167
|
+
const decipher = createDecipheriv(CIPHER_ALGORITHM, key, iv);
|
|
168
|
+
decipher.setAuthTag(authTag);
|
|
169
|
+
|
|
170
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const _compressPayload = (data) => {
|
|
174
|
+
const input = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
|
|
175
|
+
if (input.length < COMPRESSION_THRESHOLD) {
|
|
176
|
+
return { compressed: false, data: input };
|
|
177
|
+
}
|
|
178
|
+
const compressed = deflateSync(input);
|
|
179
|
+
_metrics.compressed++;
|
|
180
|
+
_metrics.bytesSaved += input.length - compressed.length;
|
|
181
|
+
return { compressed: true, data: compressed, originalSize: input.length };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const _decompressPayload = (payload) => {
|
|
185
|
+
if (!payload.compressed) {
|
|
186
|
+
return payload.data;
|
|
187
|
+
}
|
|
188
|
+
return inflateSync(payload.data);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const _parseJsonSafely = (str, fallback = {}) => {
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(str);
|
|
194
|
+
return isPlainObject(parsed) ? parsed : fallback;
|
|
195
|
+
} catch (_) {
|
|
196
|
+
return fallback;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const _extractMetaFromMessage = (message) => {
|
|
201
|
+
let meta = {};
|
|
202
|
+
|
|
203
|
+
const firstBrace = message.indexOf('{');
|
|
204
|
+
const lastBrace = message.lastIndexOf('}');
|
|
205
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
206
|
+
const maybeJson = message.slice(firstBrace, lastBrace + 1);
|
|
207
|
+
meta = _parseJsonSafely(maybeJson, {});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const kvRegex = /(\w+)=("[^"]*"|'[^']*'|\S+)/g;
|
|
211
|
+
let match;
|
|
212
|
+
while ((match = kvRegex.exec(message)) !== null) {
|
|
213
|
+
let val = match[2];
|
|
214
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
215
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
216
|
+
val = val.slice(1, -1);
|
|
217
|
+
}
|
|
218
|
+
meta[match[1]] = val;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return meta;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const _isRevoked = (auditId) => {
|
|
225
|
+
return _revocationList.has(auditId);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const _validateUrl = (str) => {
|
|
229
|
+
try {
|
|
230
|
+
new URL(str);
|
|
231
|
+
return true;
|
|
232
|
+
} catch (_) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ─── Cache Management ─────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
const _pruneCache = () => {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const toDelete = [];
|
|
242
|
+
|
|
243
|
+
for (const [key, entry] of _auditCache.entries()) {
|
|
244
|
+
if (now - entry.cached > CACHE_TTL || _isRevoked(key)) {
|
|
245
|
+
toDelete.push(key);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const key of toDelete) {
|
|
250
|
+
_auditCache.delete(key);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (_auditCache.size > MAX_CACHE_ENTRIES) {
|
|
254
|
+
const sorted = [..._auditCache.entries()]
|
|
255
|
+
.sort((a, b) => a[1].cached - b[1].cached);
|
|
256
|
+
const removeCount = _auditCache.size - MAX_CACHE_ENTRIES;
|
|
257
|
+
for (let i = 0; i < removeCount; i++) {
|
|
258
|
+
_auditCache.delete(sorted[i][0]);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const _debouncedPrune = debounce(_pruneCache, CACHE_PRUNE_INTERVAL, {
|
|
264
|
+
maxWait: CACHE_PRUNE_INTERVAL * 3
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const _cacheSet = (key, value) => {
|
|
268
|
+
_auditCache.set(key, {
|
|
269
|
+
data: value,
|
|
270
|
+
cached: Date.now(),
|
|
271
|
+
hits: 0
|
|
272
|
+
});
|
|
273
|
+
_debouncedPrune();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const _cacheGet = (key) => {
|
|
277
|
+
if (_auditCache.has(key)) {
|
|
278
|
+
const entry = _auditCache.get(key);
|
|
279
|
+
entry.hits++;
|
|
280
|
+
_metrics.cacheHits++;
|
|
281
|
+
return entry.data;
|
|
282
|
+
}
|
|
283
|
+
_metrics.cacheMisses++;
|
|
284
|
+
return null;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// ─── Public API: Parsing & Validation ────────────────────────────
|
|
288
|
+
|
|
289
|
+
const parseEntry = (line, options = {}) => {
|
|
290
|
+
if (!line || typeof line !== 'string') return null;
|
|
291
|
+
|
|
292
|
+
const match = line.match(LOG_ENTRY_REGEX);
|
|
293
|
+
if (!match) return null;
|
|
294
|
+
|
|
295
|
+
const rawMessage = match.groups.message;
|
|
296
|
+
const sanitizeOptions = merge({}, options.sanitize || {});
|
|
297
|
+
const sanitized = _sanitizeMessage(rawMessage, sanitizeOptions);
|
|
298
|
+
const meta = _extractMetaFromMessage(rawMessage);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
timestamp: match.groups.ts,
|
|
302
|
+
level: match.groups.level.toLowerCase(),
|
|
303
|
+
message: sanitized,
|
|
304
|
+
rawMessage: rawMessage.slice(0, 500),
|
|
305
|
+
meta,
|
|
306
|
+
rawLength: Buffer.byteLength(line, 'utf8'),
|
|
307
|
+
truncated: rawMessage.length > 500
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const validateLogEntry = (entry, options = {}) => {
|
|
312
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
313
|
+
if (!entry.timestamp || !entry.level || entry.message === undefined) return false;
|
|
314
|
+
|
|
315
|
+
const validLevels = options.levels || ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
|
|
316
|
+
if (!validLevels.includes(entry.level)) return false;
|
|
317
|
+
|
|
318
|
+
if (isNaN(Date.parse(entry.timestamp))) return false;
|
|
319
|
+
|
|
320
|
+
const parsed = parseISO(entry.timestamp);
|
|
321
|
+
if (!isValid(parsed)) return false;
|
|
322
|
+
|
|
323
|
+
const maxAge = options.maxAge || MAX_ENTRY_AGE_MS;
|
|
324
|
+
const age = differenceInMilliseconds(new Date(), parsed);
|
|
325
|
+
if (Math.abs(age) > maxAge) return false;
|
|
326
|
+
|
|
327
|
+
const maxSize = options.maxPayloadSize || MAX_PAYLOAD_BYTES;
|
|
328
|
+
const size = Buffer.byteLength(entry.message, 'utf8');
|
|
329
|
+
if (size > maxSize) return false;
|
|
330
|
+
|
|
331
|
+
if (entry.auditId && !uuidValidate(entry.auditId)) return false;
|
|
332
|
+
|
|
333
|
+
if (options.strictLevels && !validLevels.includes(entry.level)) return false;
|
|
334
|
+
|
|
335
|
+
return true;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const validateBatch = (entries, options = {}) => {
|
|
339
|
+
if (!Array.isArray(entries)) return { valid: false, errors: ['Not an array'] };
|
|
340
|
+
|
|
341
|
+
const errors = [];
|
|
342
|
+
const valid = [];
|
|
343
|
+
|
|
344
|
+
for (let i = 0; i < entries.length; i++) {
|
|
345
|
+
if (validateLogEntry(entries[i], options)) {
|
|
346
|
+
valid.push(entries[i]);
|
|
347
|
+
} else {
|
|
348
|
+
errors.push({ index: i, entry: entries[i] });
|
|
349
|
+
_metrics.invalid++;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
valid: errors.length === 0,
|
|
355
|
+
total: entries.length,
|
|
356
|
+
validCount: valid.length,
|
|
357
|
+
invalidCount: errors.length,
|
|
358
|
+
entries: valid,
|
|
359
|
+
errors
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// ─── Public API: Hashing & Signing ────────────────────────────────
|
|
364
|
+
|
|
365
|
+
const hashLogEntries = (entries, algorithm = DEFAULT_ALGORITHM) => {
|
|
366
|
+
const hash = createHash(algorithm);
|
|
367
|
+
for (const entry of entries) {
|
|
368
|
+
const normalized = normalize(entry);
|
|
369
|
+
hash.update(JSON.stringify(normalized));
|
|
370
|
+
}
|
|
371
|
+
const digest = hash.digest('hex');
|
|
372
|
+
return {
|
|
373
|
+
algorithm,
|
|
374
|
+
hash: digest,
|
|
375
|
+
entries: entries.length
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const signLogEntries = (entries, privateKey, passphrase) => {
|
|
380
|
+
if (!privateKey) throw new Error('Private key is required for signing');
|
|
381
|
+
|
|
382
|
+
const signer = createSign(SIGN_ALGORITHM);
|
|
383
|
+
const payload = JSON.stringify(entries.map(normalize));
|
|
384
|
+
signer.update(payload);
|
|
385
|
+
|
|
386
|
+
const signOptions = passphrase ? { key: privateKey, passphrase } : { key: privateKey };
|
|
387
|
+
const signature = signer.sign(signOptions, 'base64');
|
|
388
|
+
|
|
389
|
+
_metrics.signed += entries.length;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
signature,
|
|
393
|
+
algorithm: SIGN_ALGORITHM,
|
|
394
|
+
entries: entries.length,
|
|
395
|
+
timestamp: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const verifyLogSignature = (entries, signature, publicKey) => {
|
|
400
|
+
if (!publicKey) throw new Error('Public key is required for verification');
|
|
401
|
+
if (!signature) throw new Error('Signature is required for verification');
|
|
402
|
+
|
|
403
|
+
const verifier = createVerify(SIGN_ALGORITHM);
|
|
404
|
+
const payload = JSON.stringify(entries.map(normalize));
|
|
405
|
+
verifier.update(payload);
|
|
406
|
+
|
|
407
|
+
return verifier.verify(publicKey, signature, 'base64');
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const hmacLogEntries = (entries, secret) => {
|
|
411
|
+
if (!secret) throw new Error('Secret is required for HMAC');
|
|
412
|
+
|
|
413
|
+
const hmac = createHmac(HMAC_ALGORITHM, secret);
|
|
414
|
+
hmac.update(JSON.stringify(entries.map(normalize)));
|
|
415
|
+
const digest = hmac.digest('hex');
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
hmac: digest,
|
|
419
|
+
algorithm: HMAC_ALGORITHM,
|
|
420
|
+
entries: entries.length
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// ─── Public API: Encryption ──────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
const encryptLogEntries = (entries, passphrase) => {
|
|
427
|
+
if (!passphrase) throw new Error('Passphrase is required for encryption');
|
|
428
|
+
|
|
429
|
+
const payload = JSON.stringify(entries.map(normalize));
|
|
430
|
+
const result = _encryptPayload(payload, passphrase);
|
|
431
|
+
_metrics.encrypted += entries.length;
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
...result,
|
|
435
|
+
entries: entries.length,
|
|
436
|
+
timestamp: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const decryptLogEntries = (encryptedPayload, passphrase) => {
|
|
441
|
+
if (!passphrase) throw new Error('Passphrase is required for decryption');
|
|
442
|
+
|
|
443
|
+
const decrypted = _decryptPayload(encryptedPayload, passphrase);
|
|
444
|
+
return JSON.parse(decrypted);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// ─── Public API: Compression ─────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
const compressEntries = (entries) => {
|
|
450
|
+
const payload = JSON.stringify(entries.map(normalize));
|
|
451
|
+
const result = _compressPayload(payload);
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
compressed: result.compressed,
|
|
455
|
+
data: result.data.toString('base64'),
|
|
456
|
+
originalSize: result.originalSize || payload.length,
|
|
457
|
+
compressedSize: result.data.length,
|
|
458
|
+
entries: entries.length
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const decompressEntries = (compressed) => {
|
|
463
|
+
const data = Buffer.from(compressed.data, 'base64');
|
|
464
|
+
const decompressed = _decompressPayload({
|
|
465
|
+
compressed: compressed.compressed,
|
|
466
|
+
data
|
|
467
|
+
});
|
|
468
|
+
return JSON.parse(decompressed.toString('utf8'));
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// ─── Public API: Audit IDs & Revocation ──────────────────────────
|
|
472
|
+
|
|
473
|
+
const generateAuditId = () => uuidv4();
|
|
474
|
+
|
|
475
|
+
const generateDeterministicId = (input) => {
|
|
476
|
+
return uuidv5(String(input), UUID_NAMESPACE);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const validateAuditId = (id) => uuidValidate(id);
|
|
480
|
+
|
|
481
|
+
const revokeAuditId = (auditId, reason = 'manual') => {
|
|
482
|
+
if (!uuidValidate(auditId)) {
|
|
483
|
+
throw new Error('Invalid audit ID format');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
_revocationList.add(auditId);
|
|
487
|
+
_metrics.revocations++;
|
|
488
|
+
|
|
489
|
+
if (_auditCache.has(auditId)) {
|
|
490
|
+
_auditCache.delete(auditId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
auditId,
|
|
495
|
+
revoked: true,
|
|
496
|
+
reason,
|
|
497
|
+
timestamp: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx")
|
|
498
|
+
};
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const isRevoked = (auditId) => {
|
|
502
|
+
return _revocationList.has(auditId);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const getRevocationList = () => {
|
|
506
|
+
return [..._revocationList];
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// ─── Public API: Config Parsing ──────────────────────────────────
|
|
510
|
+
|
|
511
|
+
const parseYamlConfig = (yamlString) => {
|
|
512
|
+
try {
|
|
513
|
+
return yaml.load(yamlString);
|
|
514
|
+
} catch (_) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const parseConfigFile = (content, fmt = 'yaml') => {
|
|
520
|
+
switch (fmt.toLowerCase()) {
|
|
521
|
+
case 'yaml':
|
|
522
|
+
case 'yml':
|
|
523
|
+
return parseYamlConfig(content);
|
|
524
|
+
case 'json':
|
|
525
|
+
return _parseJsonSafely(content, null);
|
|
526
|
+
default:
|
|
527
|
+
throw new Error(`Unsupported config format: ${fmt}`);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// ─── Public API: Field Extraction ────────────────────────────────
|
|
532
|
+
|
|
533
|
+
const extractFields = (entry, paths) => {
|
|
534
|
+
return _applyJsonPath(entry, paths);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const extractTimeSeries = (entries, field, interval = 'hour') => {
|
|
538
|
+
if (!Array.isArray(entries) || entries.length === 0) return [];
|
|
539
|
+
|
|
540
|
+
const timestamps = entries
|
|
541
|
+
.map(e => parseISO(e.timestamp))
|
|
542
|
+
.filter(ts => isValid(ts))
|
|
543
|
+
.sort((a, b) => a - b);
|
|
544
|
+
|
|
545
|
+
if (timestamps.length === 0) return [];
|
|
546
|
+
|
|
547
|
+
const start = timestamps[0];
|
|
548
|
+
const end = timestamps[timestamps.length - 1];
|
|
549
|
+
|
|
550
|
+
let intervals;
|
|
551
|
+
if (interval === 'hour') {
|
|
552
|
+
intervals = eachHourOfInterval({ start, end });
|
|
553
|
+
} else if (interval === 'day') {
|
|
554
|
+
intervals = eachDayOfInterval({ start, end });
|
|
555
|
+
} else {
|
|
556
|
+
intervals = eachHourOfInterval({ start, end });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return intervals.map(intStart => {
|
|
560
|
+
const intEnd = interval === 'day' ? endOfDay(intStart) : addMinutes(intStart, 60);
|
|
561
|
+
const bucket = entries.filter(e => {
|
|
562
|
+
const ts = parseISO(e.timestamp);
|
|
563
|
+
return isValid(ts) && isAfter(ts, intStart) && isBefore(ts, intEnd);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
interval: format(intStart, "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
|
|
568
|
+
count: bucket.length,
|
|
569
|
+
entries: bucket.map(e => {
|
|
570
|
+
const extracted = _applyJsonPath(e, [field]);
|
|
571
|
+
return extracted[slugify(field, { lower: true })];
|
|
572
|
+
}).filter(v => v !== null && v !== undefined)
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// ─── Public API: Metrics ─────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
const getMetrics = () => {
|
|
580
|
+
return {
|
|
581
|
+
processed: _metrics.processed,
|
|
582
|
+
invalid: _metrics.invalid,
|
|
583
|
+
signed: _metrics.signed,
|
|
584
|
+
encrypted: _metrics.encrypted,
|
|
585
|
+
compressed: _metrics.compressed,
|
|
586
|
+
bytesProcessed: _metrics.bytesProcessed,
|
|
587
|
+
bytesSaved: _metrics.bytesSaved,
|
|
588
|
+
cacheHits: _metrics.cacheHits,
|
|
589
|
+
cacheMisses: _metrics.cacheMisses,
|
|
590
|
+
revocations: _metrics.revocations,
|
|
591
|
+
cacheSize: _auditCache.size,
|
|
592
|
+
revocationCount: _revocationList.size,
|
|
593
|
+
uptime: process.uptime(),
|
|
594
|
+
memory: {
|
|
595
|
+
heapUsed: bytes(process.memoryUsage().heapUsed),
|
|
596
|
+
heapTotal: bytes(process.memoryUsage().heapTotal),
|
|
597
|
+
external: bytes(process.memoryUsage().external),
|
|
598
|
+
rss: bytes(process.memoryUsage().rss)
|
|
599
|
+
},
|
|
600
|
+
topErrors: Object.entries(_metrics.errors)
|
|
601
|
+
.sort((a, b) => b[1] - a[1])
|
|
602
|
+
.slice(0, 10)
|
|
603
|
+
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const resetMetrics = () => {
|
|
608
|
+
_metrics = {
|
|
609
|
+
processed: 0,
|
|
610
|
+
invalid: 0,
|
|
611
|
+
signed: 0,
|
|
612
|
+
encrypted: 0,
|
|
613
|
+
compressed: 0,
|
|
614
|
+
bytesProcessed: 0,
|
|
615
|
+
bytesSaved: 0,
|
|
616
|
+
cacheHits: 0,
|
|
617
|
+
cacheMisses: 0,
|
|
618
|
+
revocations: 0,
|
|
619
|
+
errors: {},
|
|
620
|
+
startTime: Date.now()
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const _trackError = (type) => {
|
|
625
|
+
_metrics.errors[type] = (_metrics.errors[type] || 0) + 1;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// ─── Public API: Pipeline Integration ────────────────────────────
|
|
629
|
+
|
|
630
|
+
const processLogFile = async (filePath, options = {}) => {
|
|
631
|
+
const pathStr = (typeof filePath === 'string' && filePath.length > 0)
|
|
632
|
+
? filePath
|
|
633
|
+
: String(filePath || '/var/log/default.log');
|
|
634
|
+
|
|
635
|
+
const config = merge({
|
|
636
|
+
version: '4.2.0',
|
|
637
|
+
paths: ['$.level', '$.message', '$.timestamp'],
|
|
638
|
+
ttl: '30m',
|
|
639
|
+
maxSize: '5mb',
|
|
640
|
+
sanitize: true,
|
|
641
|
+
sign: false,
|
|
642
|
+
signKey: null,
|
|
643
|
+
encrypt: false,
|
|
644
|
+
encryptKey: null,
|
|
645
|
+
compress: true,
|
|
646
|
+
cache: true,
|
|
647
|
+
strictLevels: true,
|
|
648
|
+
meta: {}
|
|
649
|
+
}, options);
|
|
650
|
+
|
|
651
|
+
let checkedVersion;
|
|
652
|
+
try {
|
|
653
|
+
checkedVersion = _checkVersion(config.version);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
_trackError('version_check');
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (config.sign && !config.signKey) {
|
|
660
|
+
_trackError('missing_sign_key');
|
|
661
|
+
throw new Error('Signing requested but no signKey provided');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (config.encrypt && !config.encryptKey) {
|
|
665
|
+
_trackError('missing_encrypt_key');
|
|
666
|
+
throw new Error('Encryption requested but no encryptKey provided');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const cacheKey = slugify(pathStr, { lower: true, replacement: '_', strict: true });
|
|
670
|
+
if (config.cache) {
|
|
671
|
+
const cached = _cacheGet(cacheKey);
|
|
672
|
+
if (cached) {
|
|
673
|
+
return { ...cached, fromCache: true };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const entry = {
|
|
678
|
+
source: slugify(pathStr, { lower: true, replacement: '_', trim: true, strict: true }),
|
|
679
|
+
processedAt: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
|
|
680
|
+
auditId: generateAuditId(),
|
|
681
|
+
nonce: _generateNonce(),
|
|
682
|
+
ttl: config.ttl,
|
|
683
|
+
version: checkedVersion,
|
|
684
|
+
compressed: false,
|
|
685
|
+
...config.meta
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
if (config.paths && config.paths.length > 0) {
|
|
689
|
+
const extracted = _applyJsonPath(entry, config.paths);
|
|
690
|
+
merge(entry, extracted);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (!validateAuditId(entry.auditId)) {
|
|
694
|
+
_trackError('invalid_audit_id');
|
|
695
|
+
_metrics.invalid++;
|
|
696
|
+
throw new Error('Generated audit ID failed validation');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const normalized = normalize(entry);
|
|
700
|
+
|
|
701
|
+
let payload = JSON.stringify([normalized]);
|
|
702
|
+
let wasCompressed = false;
|
|
703
|
+
let originalSize = Buffer.byteLength(payload, 'utf8');
|
|
704
|
+
|
|
705
|
+
if (config.compress && originalSize > COMPRESSION_THRESHOLD) {
|
|
706
|
+
const compressed = _compressPayload(payload);
|
|
707
|
+
if (compressed.compressed) {
|
|
708
|
+
payload = compressed.data.toString('base64');
|
|
709
|
+
wasCompressed = true;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let encryptionResult = null;
|
|
714
|
+
if (config.encrypt && config.encryptKey) {
|
|
715
|
+
encryptionResult = _encryptPayload(payload, config.encryptKey);
|
|
716
|
+
_metrics.encrypted++;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const packed = quickPack([normalized], ['source', 'processedAt', 'auditId', 'nonce', 'version']);
|
|
720
|
+
|
|
721
|
+
_metrics.processed++;
|
|
722
|
+
_metrics.bytesProcessed += packed.size;
|
|
723
|
+
|
|
724
|
+
const response = {
|
|
725
|
+
auditId: entry.auditId,
|
|
726
|
+
size: packed.size,
|
|
727
|
+
humanSize: bytes(packed.size),
|
|
728
|
+
hash: packed.hash,
|
|
729
|
+
timestamp: entry.processedAt,
|
|
730
|
+
compressed: wasCompressed,
|
|
731
|
+
originalSize: wasCompressed ? originalSize : undefined,
|
|
732
|
+
compressedSize: wasCompressed ? packed.size : undefined
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
if (config.sign && config.signKey) {
|
|
736
|
+
response.signature = signLogEntries([normalized], config.signKey).signature;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (encryptionResult) {
|
|
740
|
+
response.encrypted = true;
|
|
741
|
+
response.encryptionAlgorithm = encryptionResult.algorithm;
|
|
742
|
+
response.iv = encryptionResult.iv;
|
|
743
|
+
response.salt = encryptionResult.salt;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (config.cache) {
|
|
747
|
+
_cacheSet(cacheKey, response);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return response;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const processLogDirectory = async (directoryPath, options = {}) => {
|
|
754
|
+
const config = merge({
|
|
755
|
+
recursive: false,
|
|
756
|
+
pattern: '*.log',
|
|
757
|
+
...options
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
return processLogFile(directoryPath, config);
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// ─── Public API: Utility ─────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
const auditTimestamp = () => format(new Date(), 'yyyy-MM-dd HH:mm:ss.SSS');
|
|
766
|
+
|
|
767
|
+
const createAuditTrail = (entries, options = {}) => {
|
|
768
|
+
const config = merge({
|
|
769
|
+
hashAlgorithm: DEFAULT_ALGORITHM,
|
|
770
|
+
sign: false,
|
|
771
|
+
signKey: null,
|
|
772
|
+
compress: false
|
|
773
|
+
}, options);
|
|
774
|
+
|
|
775
|
+
const trail = {
|
|
776
|
+
trailId: generateAuditId(),
|
|
777
|
+
createdAt: auditTimestamp(),
|
|
778
|
+
entries: entries.length,
|
|
779
|
+
hash: null,
|
|
780
|
+
signature: null
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const hashResult = hashLogEntries(entries, config.hashAlgorithm);
|
|
784
|
+
trail.hash = hashResult.hash;
|
|
785
|
+
trail.hashAlgorithm = hashResult.algorithm;
|
|
786
|
+
|
|
787
|
+
if (config.sign && config.signKey) {
|
|
788
|
+
const signResult = signLogEntries(entries, config.signKey);
|
|
789
|
+
trail.signature = signResult.signature;
|
|
790
|
+
trail.signAlgorithm = signResult.algorithm;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return trail;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const compareEntries = (entry1, entry2) => {
|
|
797
|
+
if (!entry1 || !entry2) return { match: false, reason: 'Missing entries' };
|
|
798
|
+
|
|
799
|
+
const norm1 = normalize(entry1);
|
|
800
|
+
const norm2 = normalize(entry2);
|
|
801
|
+
|
|
802
|
+
const hash1 = createHash('sha256').update(JSON.stringify(norm1)).digest('hex');
|
|
803
|
+
const hash2 = createHash('sha256').update(JSON.stringify(norm2)).digest('hex');
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
match: timingSafeEqual(Buffer.from(hash1), Buffer.from(hash2)),
|
|
807
|
+
hash1,
|
|
808
|
+
hash2
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const mergeEntries = (entries1, entries2, options = {}) => {
|
|
813
|
+
const config = merge({
|
|
814
|
+
deduplicate: true,
|
|
815
|
+
sortBy: 'timestamp',
|
|
816
|
+
sortOrder: 'asc'
|
|
817
|
+
}, options);
|
|
818
|
+
|
|
819
|
+
let merged = [...entries1, ...entries2];
|
|
820
|
+
|
|
821
|
+
if (config.deduplicate) {
|
|
822
|
+
const seen = new Set();
|
|
823
|
+
merged = merged.filter(entry => {
|
|
824
|
+
const hash = createHash('md5')
|
|
825
|
+
.update(JSON.stringify(normalize(entry)))
|
|
826
|
+
.digest('hex');
|
|
827
|
+
if (seen.has(hash)) return false;
|
|
828
|
+
seen.add(hash);
|
|
829
|
+
return true;
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (config.sortBy === 'timestamp') {
|
|
834
|
+
merged.sort((a, b) => {
|
|
835
|
+
const tsA = new Date(a.timestamp).getTime();
|
|
836
|
+
const tsB = new Date(b.timestamp).getTime();
|
|
837
|
+
return config.sortOrder === 'asc' ? tsA - tsB : tsB - tsA;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return merged;
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// ─── Exports ─────────────────────────────────────────────────────
|
|
845
|
+
|
|
846
|
+
module.exports = {
|
|
847
|
+
parseEntry,
|
|
848
|
+
validateLogEntry,
|
|
849
|
+
validateBatch,
|
|
850
|
+
hashLogEntries,
|
|
851
|
+
signLogEntries,
|
|
852
|
+
verifyLogSignature,
|
|
853
|
+
hmacLogEntries,
|
|
854
|
+
encryptLogEntries,
|
|
855
|
+
decryptLogEntries,
|
|
856
|
+
compressEntries,
|
|
857
|
+
decompressEntries,
|
|
858
|
+
generateAuditId,
|
|
859
|
+
generateDeterministicId,
|
|
860
|
+
validateAuditId,
|
|
861
|
+
revokeAuditId,
|
|
862
|
+
isRevoked,
|
|
863
|
+
getRevocationList,
|
|
864
|
+
parseYamlConfig,
|
|
865
|
+
parseConfigFile,
|
|
866
|
+
extractFields,
|
|
867
|
+
extractTimeSeries,
|
|
868
|
+
getMetrics,
|
|
869
|
+
resetMetrics,
|
|
870
|
+
processLogFile,
|
|
871
|
+
processLogDirectory,
|
|
872
|
+
auditTimestamp,
|
|
873
|
+
createAuditTrail,
|
|
874
|
+
compareEntries,
|
|
875
|
+
mergeEntries
|
|
876
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "log-serialize",
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"description": "Audit log validation, cryptographic integrity checks, and secure pipeline integration",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"date-fns": "^2.30.0",
|
|
8
|
+
"lodash.merge": "^4.6.2",
|
|
9
|
+
"lodash.isplainobject": "^4.0.6",
|
|
10
|
+
"lodash.debounce": "^4.0.8",
|
|
11
|
+
"uuid": "^9.0.0",
|
|
12
|
+
"ms": "^2.1.3",
|
|
13
|
+
"bytes": "^3.1.2",
|
|
14
|
+
"slugify": "^1.6.6",
|
|
15
|
+
"datatran": "^1.5.0",
|
|
16
|
+
"sanitize-html": "^2.11.0",
|
|
17
|
+
"jsonpath": "^1.1.1",
|
|
18
|
+
"js-yaml": "^4.1.0",
|
|
19
|
+
"semver": "^7.5.4"
|
|
20
|
+
}
|
|
21
|
+
}
|