loki-mode 7.49.0 → 7.51.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.
@@ -0,0 +1,424 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SIEM event export for audit/security events.
5
+ *
6
+ * Two well-specified, vendor-agnostic export formats:
7
+ * 1. CEF (Common Event Format) - ArcSight/QRadar/most SIEMs accept it.
8
+ * 2. Splunk HEC (HTTP Event Collector) JSON - Splunk's native ingest API.
9
+ *
10
+ * Design mirrors src/observability/otel.js:
11
+ * - Zero egress unless an endpoint env var is configured (auto-detect, no
12
+ * required flags).
13
+ * - SSRF-safe endpoint validation (only http:/https:, same guard the
14
+ * OTLPExporter uses).
15
+ * - Network failures are logged, never thrown: observability must never
16
+ * break the application.
17
+ *
18
+ * Auto-detected configuration (no flags required):
19
+ * LOKI_SPLUNK_HEC_URL - Splunk HEC collector URL. Presence enables HEC.
20
+ * LOKI_SPLUNK_HEC_TOKEN - Splunk HEC auth token (sent as "Splunk <token>").
21
+ * LOKI_SPLUNK_HEC_INDEX - optional Splunk index name.
22
+ * LOKI_SPLUNK_HEC_SOURCETYPE - optional sourcetype (default loki:audit).
23
+ * LOKI_CEF_VENDOR / LOKI_CEF_PRODUCT - override CEF vendor/product fields.
24
+ *
25
+ * GitHub Enterprise SAML SSO event ingestion is intentionally NOT implemented
26
+ * here. It is a docs-only follow-up (see docs/siem-integration.md): it requires
27
+ * an outbound API client with org-scoped admin tokens and lives outside the
28
+ * local audit path, so no code is warranted yet.
29
+ */
30
+
31
+ const path = require('path');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Version (matches the OTEL scope-version pattern)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ let _version = '0.0.0';
38
+ try {
39
+ const pkg = require(path.join(__dirname, '..', '..', 'package.json'));
40
+ _version = pkg.version || '0.0.0';
41
+ } catch (_) {
42
+ // Fallback if package.json is not found
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // CEF severity mapping
47
+ // ---------------------------------------------------------------------------
48
+
49
+ // CEF severity is 0-10. Map common audit levels into that range.
50
+ const CEF_SEVERITY = {
51
+ debug: 1,
52
+ info: 3,
53
+ notice: 4,
54
+ warning: 6,
55
+ warn: 6,
56
+ error: 8,
57
+ critical: 9,
58
+ alert: 10,
59
+ emergency: 10,
60
+ };
61
+
62
+ function cefSeverityFor(entry) {
63
+ // Failed events are elevated regardless of declared level.
64
+ if (entry && entry.success === false) {
65
+ return 8;
66
+ }
67
+ const level = String((entry && (entry.level || entry.severity)) || 'info').toLowerCase();
68
+ return Object.prototype.hasOwnProperty.call(CEF_SEVERITY, level)
69
+ ? CEF_SEVERITY[level]
70
+ : 3;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // CEF (Common Event Format) formatter
75
+ // ---------------------------------------------------------------------------
76
+
77
+ // CEF header pipes must be escaped with a backslash. Backslash itself too.
78
+ function escapeCefHeader(value) {
79
+ return String(value == null ? '' : value)
80
+ .replace(/\\/g, '\\\\')
81
+ .replace(/\|/g, '\\|')
82
+ // Newlines would break the single-line record; collapse to spaces.
83
+ .replace(/[\r\n]+/g, ' ');
84
+ }
85
+
86
+ // CEF extension values escape backslash, equals, and newlines (not pipes).
87
+ function escapeCefExtension(value) {
88
+ return String(value == null ? '' : value)
89
+ .replace(/\\/g, '\\\\')
90
+ .replace(/=/g, '\\=')
91
+ .replace(/\r/g, '\\r')
92
+ .replace(/\n/g, '\\n');
93
+ }
94
+
95
+ /**
96
+ * Flatten the audit "details" object into dotted keys for CEF extension space.
97
+ * Bounded depth to avoid pathological nesting.
98
+ */
99
+ function flattenDetails(obj, prefix, out, depth) {
100
+ if (depth > 4 || obj == null || typeof obj !== 'object') return out;
101
+ for (const [k, v] of Object.entries(obj)) {
102
+ const key = prefix ? prefix + '.' + k : k;
103
+ if (v != null && typeof v === 'object' && !Array.isArray(v)) {
104
+ flattenDetails(v, key, out, depth + 1);
105
+ } else {
106
+ out[key] = Array.isArray(v) ? v.join(',') : v;
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Convert a Loki audit entry into a single-line CEF record.
114
+ *
115
+ * Format:
116
+ * CEF:0|Vendor|Product|Version|SignatureID|Name|Severity|Extension
117
+ *
118
+ * @param {object} entry - audit entry (audit.py log_event shape or generic).
119
+ * @param {object} [opts] - { vendor, product, version }
120
+ * @returns {string} a single-line CEF record (no trailing newline).
121
+ */
122
+ function toCEF(entry, opts) {
123
+ const o = opts || {};
124
+ const vendor = o.vendor || process.env.LOKI_CEF_VENDOR || 'Autonomi';
125
+ const product = o.product || process.env.LOKI_CEF_PRODUCT || 'Loki Mode';
126
+ const version = o.version || _version;
127
+
128
+ const e = entry || {};
129
+ // Signature id and name come from the action; fall back to "event".
130
+ const signatureId = e.action || e.event || 'event';
131
+ const name = e.action || e.event || 'Loki audit event';
132
+ const severity = cefSeverityFor(e);
133
+
134
+ const header =
135
+ 'CEF:0|' +
136
+ escapeCefHeader(vendor) + '|' +
137
+ escapeCefHeader(product) + '|' +
138
+ escapeCefHeader(version) + '|' +
139
+ escapeCefHeader(signatureId) + '|' +
140
+ escapeCefHeader(name) + '|' +
141
+ String(severity);
142
+
143
+ // Build the extension key=value space. Use CEF standard keys where possible.
144
+ const ext = {};
145
+ if (e.timestamp) ext.rt = e.timestamp;
146
+ if (e.user_id) ext.suser = e.user_id;
147
+ if (e.ip_address) ext.src = e.ip_address;
148
+ if (e.resource_type) ext.cs1 = e.resource_type;
149
+ if (e.resource_type) ext.cs1Label = 'resourceType';
150
+ if (e.resource_id) ext.cs2 = e.resource_id;
151
+ if (e.resource_id) ext.cs2Label = 'resourceId';
152
+ if (e.token_id) ext.cs3 = e.token_id;
153
+ if (e.token_id) ext.cs3Label = 'tokenId';
154
+ if (typeof e.success === 'boolean') ext.outcome = e.success ? 'success' : 'failure';
155
+ if (e.error) ext.msg = e.error;
156
+
157
+ // Flatten details under a "loki." namespace so they survive round-trips.
158
+ if (e.details && typeof e.details === 'object') {
159
+ flattenDetails(e.details, 'loki', ext, 0);
160
+ }
161
+
162
+ const extStr = Object.entries(ext)
163
+ .map(([k, v]) => escapeCefExtension(k) + '=' + escapeCefExtension(v))
164
+ .join(' ');
165
+
166
+ return extStr ? header + '|' + extStr : header + '|';
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Splunk HEC JSON formatter
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Convert a Loki audit entry into a Splunk HEC event envelope.
175
+ *
176
+ * HEC expects: { time, host, source, sourcetype, index, event }
177
+ * where "time" is epoch SECONDS (float allowed).
178
+ *
179
+ * @param {object} entry - audit entry.
180
+ * @param {object} [opts] - { sourcetype, index, host, source }
181
+ * @returns {object} the HEC envelope object (JSON-serializable).
182
+ */
183
+ function toHEC(entry, opts) {
184
+ const o = opts || {};
185
+ const e = entry || {};
186
+
187
+ let epochSeconds = Date.now() / 1000;
188
+ if (e.timestamp) {
189
+ const parsed = Date.parse(e.timestamp);
190
+ if (!Number.isNaN(parsed)) {
191
+ epochSeconds = parsed / 1000;
192
+ }
193
+ }
194
+
195
+ const envelope = {
196
+ time: epochSeconds,
197
+ source: o.source || 'loki-mode',
198
+ sourcetype: o.sourcetype || process.env.LOKI_SPLUNK_HEC_SOURCETYPE || 'loki:audit',
199
+ event: e,
200
+ };
201
+
202
+ const host = o.host || process.env.LOKI_SERVICE_NAME;
203
+ if (host) envelope.host = host;
204
+
205
+ const index = o.index || process.env.LOKI_SPLUNK_HEC_INDEX;
206
+ if (index) envelope.index = index;
207
+
208
+ return envelope;
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // SSRF-safe endpoint validation (mirrors OTLPExporter constructor guard)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Validate that an endpoint URL is safe to POST to.
217
+ * Only http: and https: schemes are permitted, matching the OTEL guard.
218
+ *
219
+ * @param {string} endpoint
220
+ * @returns {URL} the parsed URL
221
+ * @throws {Error} if the scheme is not http:/https: or the URL is malformed.
222
+ */
223
+ function validateEndpoint(endpoint) {
224
+ // Throws TypeError on malformed input; let it propagate to the caller.
225
+ const parsed = new URL(endpoint);
226
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
227
+ throw new Error(
228
+ `Invalid SIEM endpoint scheme "${parsed.protocol}". Only http: and https: are allowed.`
229
+ );
230
+ }
231
+ return parsed;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Splunk HEC sender
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function _defaultErrorHandler(err) {
239
+ process.stderr.write(`[loki-siem] HEC export error: ${err && (err.message || err.code) || String(err)}\n`);
240
+ }
241
+
242
+ /**
243
+ * Splunk HEC sender. Constructed only when an endpoint is configured.
244
+ * No constructor side effects beyond validating the endpoint.
245
+ */
246
+ class HECSender {
247
+ constructor(endpoint, token, opts) {
248
+ // SSRF guard up front. Mirrors OTLPExporter.
249
+ this._url = validateEndpoint(endpoint);
250
+ this._token = token || '';
251
+ const o = opts || {};
252
+ this._sourcetype = o.sourcetype;
253
+ this._index = o.index;
254
+ this._host = o.host;
255
+ this._source = o.source;
256
+ this._errorHandler = o.errorHandler || _defaultErrorHandler;
257
+ }
258
+
259
+ /**
260
+ * Send a single audit entry to Splunk HEC. Fire-and-forget.
261
+ * Returns the serialized body (for testing/inspection).
262
+ */
263
+ send(entry) {
264
+ const envelope = toHEC(entry, {
265
+ sourcetype: this._sourcetype,
266
+ index: this._index,
267
+ host: this._host,
268
+ source: this._source,
269
+ });
270
+ const body = JSON.stringify(envelope);
271
+ this._post(body);
272
+ return body;
273
+ }
274
+
275
+ _post(body) {
276
+ const isHttps = this._url.protocol === 'https:';
277
+ const httpModule = isHttps ? require('https') : require('http');
278
+
279
+ const headers = {
280
+ 'Content-Type': 'application/json',
281
+ 'Content-Length': Buffer.byteLength(body),
282
+ };
283
+ if (this._token) {
284
+ headers.Authorization = `Splunk ${this._token}`;
285
+ }
286
+
287
+ const options = {
288
+ hostname: this._url.hostname,
289
+ port: this._url.port || (isHttps ? 443 : 80),
290
+ path: this._url.pathname + (this._url.search || ''),
291
+ method: 'POST',
292
+ headers,
293
+ };
294
+
295
+ let req;
296
+ try {
297
+ req = httpModule.request(options, (res) => {
298
+ res.resume(); // drain to free the socket
299
+ });
300
+ } catch (err) {
301
+ // Synchronous construction errors must never escape.
302
+ try { this._errorHandler(err); } catch (_) { /* swallow */ }
303
+ return;
304
+ }
305
+
306
+ req.on('error', (err) => {
307
+ // Observability must never break the application.
308
+ try { this._errorHandler(err); } catch (_) { /* swallow */ }
309
+ });
310
+
311
+ req.write(body);
312
+ req.end();
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Auto-detection: build a sender only when an endpoint env var is present.
318
+ // No env var configured => null => zero egress.
319
+ // ---------------------------------------------------------------------------
320
+
321
+ /**
322
+ * Build a Splunk HEC sender from the environment, or return null when no HEC
323
+ * endpoint is configured. This is the "no egress unless configured" gate.
324
+ *
325
+ * @param {object} [env] - environment override (defaults to process.env).
326
+ * @returns {HECSender|null}
327
+ */
328
+ function createHECSenderFromEnv(env) {
329
+ const e = env || process.env;
330
+ const url = (e.LOKI_SPLUNK_HEC_URL || '').trim();
331
+ if (!url) return null; // Not configured: never send.
332
+ return new HECSender(url, (e.LOKI_SPLUNK_HEC_TOKEN || '').trim(), {
333
+ sourcetype: e.LOKI_SPLUNK_HEC_SOURCETYPE,
334
+ index: e.LOKI_SPLUNK_HEC_INDEX,
335
+ host: e.LOKI_SERVICE_NAME,
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Whether any SIEM exporter is configured via the environment.
341
+ * @param {object} [env]
342
+ * @returns {boolean}
343
+ */
344
+ function isConfigured(env) {
345
+ const e = env || process.env;
346
+ return Boolean((e.LOKI_SPLUNK_HEC_URL || '').trim());
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Ready-to-use OTEL collector templates for popular vendors.
351
+ //
352
+ // These are NOT egress: they are env-var recipes a user copies. Each returns
353
+ // the set of env vars to export so the existing OTEL bridge (otel.js) ships to
354
+ // that vendor's OTLP/HTTP endpoint. Region/site and API key are injected by
355
+ // the caller. Documented in docs/siem-integration.md.
356
+ // ---------------------------------------------------------------------------
357
+
358
+ const OTEL_TEMPLATES = {
359
+ /**
360
+ * Datadog OTLP intake. Datadog accepts OTLP/HTTP at its agent or, with the
361
+ * OTel collector contrib exporter, directly. The common path is the Datadog
362
+ * Agent's OTLP receiver on :4318. For Agentless intake use the collector.
363
+ * site examples: datadoghq.com, datadoghq.eu, us5.datadoghq.com
364
+ */
365
+ datadog(opts) {
366
+ const o = opts || {};
367
+ const endpoint = o.endpoint || 'http://localhost:4318';
368
+ const vars = {
369
+ LOKI_OTEL_ENDPOINT: endpoint,
370
+ LOKI_SERVICE_NAME: o.serviceName || 'loki-mode',
371
+ };
372
+ // Header is honored by the @opentelemetry exporter when the real SDK path
373
+ // is active. The built-in JSON exporter posts to a local agent that holds
374
+ // the API key, so the header is optional there.
375
+ if (o.apiKey) {
376
+ vars.OTEL_EXPORTER_OTLP_HEADERS = `dd-api-key=${o.apiKey}`;
377
+ }
378
+ if (o.site) {
379
+ vars.OTEL_RESOURCE_ATTRIBUTES =
380
+ `deployment.environment=${o.environment || 'production'},dd.site=${o.site}`;
381
+ }
382
+ return vars;
383
+ },
384
+
385
+ /**
386
+ * Honeycomb OTLP/HTTP. Honeycomb ingests OTLP directly at
387
+ * https://api.honeycomb.io (or api.eu1.honeycomb.io). Auth via the
388
+ * x-honeycomb-team header; dataset via x-honeycomb-dataset for metrics.
389
+ */
390
+ honeycomb(opts) {
391
+ const o = opts || {};
392
+ const endpoint = o.endpoint || 'https://api.honeycomb.io';
393
+ const headers = [];
394
+ if (o.apiKey) headers.push(`x-honeycomb-team=${o.apiKey}`);
395
+ if (o.dataset) headers.push(`x-honeycomb-dataset=${o.dataset}`);
396
+ const vars = {
397
+ LOKI_OTEL_ENDPOINT: endpoint,
398
+ LOKI_SERVICE_NAME: o.serviceName || 'loki-mode',
399
+ };
400
+ if (headers.length) {
401
+ vars.OTEL_EXPORTER_OTLP_HEADERS = headers.join(',');
402
+ }
403
+ return vars;
404
+ },
405
+ };
406
+
407
+ module.exports = {
408
+ // Formatters
409
+ toCEF,
410
+ toHEC,
411
+ cefSeverityFor,
412
+ escapeCefHeader,
413
+ escapeCefExtension,
414
+ // Endpoint safety
415
+ validateEndpoint,
416
+ // HEC sender
417
+ HECSender,
418
+ createHECSenderFromEnv,
419
+ isConfigured,
420
+ // OTEL vendor templates
421
+ OTEL_TEMPLATES,
422
+ // Version (for testing)
423
+ _version,
424
+ };