shroud-privacy 2.2.9 → 2.2.11

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/dist/siem.js CHANGED
@@ -1,91 +1,113 @@
1
1
  /**
2
- * SIEM webhook forwarder batch and async delivery of audit events.
3
- * Does not block obfuscation pipeline.
2
+ * SIEM integrationships security events to external systems.
3
+ *
4
+ * Two output modes:
5
+ * 1. Webhook POST: sends events as JSON to a configured URL (Splunk HEC, Grafana Loki, custom)
6
+ * 2. JSONL file: appends events as newline-delimited JSON to a local file
7
+ *
8
+ * Both are async fire-and-forget — never blocks the request pipeline.
9
+ * Zero runtime dependencies.
4
10
  */
5
- export const DEFAULT_SIEM_CONFIG = {
6
- webhookUrl: "",
7
- headers: {},
8
- batchSize: 10,
9
- flushIntervalMs: 5000,
10
- format: "json",
11
- };
12
- export class SiemForwarder {
11
+ import { appendFileSync, mkdirSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { request as httpRequest } from "node:http";
14
+ import { request as httpsRequest } from "node:https";
15
+ /**
16
+ * SIEM event shipper. Subscribes to SecurityEventBus and ships events
17
+ * to configured destinations.
18
+ */
19
+ export class SiemShipper {
13
20
  _config;
14
21
  _buffer = [];
15
- _timer = null;
16
- _sendCount = 0;
17
- _errorCount = 0;
18
- _lastError = null;
19
- constructor(config = {}) {
20
- this._config = { ...DEFAULT_SIEM_CONFIG, ...config };
21
- if (this._config.webhookUrl) {
22
- this._timer = setInterval(() => this.flush(), this._config.flushIntervalMs);
23
- if (this._timer.unref)
24
- this._timer.unref();
22
+ _flushTimer = null;
23
+ _stats = { shipped: 0, webhookErrors: 0, fileErrors: 0 };
24
+ constructor(config) {
25
+ this._config = config;
26
+ // Start flush timer if batching
27
+ if (config.batchSize > 1 && config.flushIntervalMs > 0) {
28
+ this._flushTimer = setInterval(() => this.flush(), config.flushIntervalMs);
29
+ }
30
+ // Ensure JSONL directory exists
31
+ if (config.jsonlPath) {
32
+ try {
33
+ mkdirSync(dirname(config.jsonlPath), { recursive: true });
34
+ }
35
+ catch { }
25
36
  }
26
37
  }
27
- get enabled() {
28
- return !!this._config.webhookUrl;
29
- }
30
- /** Queue an audit event for delivery. */
31
- push(event) {
32
- if (!this.enabled)
33
- return;
34
- this._buffer.push({
35
- ...event,
36
- _ts: new Date().toISOString(),
37
- _source: "shroud",
38
- });
38
+ /** Called for each security event. Buffers and flushes. */
39
+ onEvent(event) {
40
+ this._buffer.push(event);
39
41
  if (this._buffer.length >= this._config.batchSize) {
40
42
  this.flush();
41
43
  }
42
44
  }
43
- /** Flush buffered events to the webhook. */
45
+ /** Flush buffered events to all configured destinations. */
44
46
  flush() {
45
- if (this._buffer.length === 0 || !this.enabled)
47
+ if (this._buffer.length === 0)
46
48
  return;
47
49
  const events = this._buffer.splice(0);
48
- const body = this._config.format === "ndjson"
49
- ? events.map((e) => JSON.stringify(e)).join("\n") + "\n"
50
- : JSON.stringify(events);
51
- this._sendHttp(body).catch((err) => {
52
- this._errorCount++;
53
- this._lastError = String(err);
54
- });
50
+ // JSONL file output
51
+ if (this._config.jsonlPath) {
52
+ try {
53
+ const lines = events.map(e => JSON.stringify(e)).join("\n") + "\n";
54
+ appendFileSync(this._config.jsonlPath, lines);
55
+ this._stats.shipped += events.length;
56
+ }
57
+ catch {
58
+ this._stats.fileErrors++;
59
+ }
60
+ }
61
+ // Webhook POST
62
+ if (this._config.webhookUrl) {
63
+ this._postWebhook(events);
64
+ }
55
65
  }
56
- /** Stop the forwarder and flush remaining events. */
66
+ /** Stop the flush timer and flush remaining events. */
57
67
  stop() {
58
- if (this._timer) {
59
- clearInterval(this._timer);
60
- this._timer = null;
68
+ if (this._flushTimer) {
69
+ clearInterval(this._flushTimer);
70
+ this._flushTimer = null;
61
71
  }
62
72
  this.flush();
63
73
  }
74
+ /** Get shipping stats. */
64
75
  getStats() {
65
- return {
66
- enabled: this.enabled,
67
- buffered: this._buffer.length,
68
- sent: this._sendCount,
69
- errors: this._errorCount,
70
- lastError: this._lastError,
71
- };
76
+ return { ...this._stats, buffered: this._buffer.length };
72
77
  }
73
- async _sendHttp(body) {
74
- const contentType = this._config.format === "ndjson"
75
- ? "application/x-ndjson"
76
- : "application/json";
77
- const resp = await fetch(this._config.webhookUrl, {
78
- method: "POST",
79
- headers: {
80
- "Content-Type": contentType,
81
- "User-Agent": "shroud-siem/1.0",
82
- ...this._config.headers,
83
- },
84
- body,
85
- });
86
- if (!resp.ok) {
87
- throw new Error(`SIEM webhook returned ${resp.status}`);
78
+ _postWebhook(events) {
79
+ try {
80
+ const url = new URL(this._config.webhookUrl);
81
+ const isHttps = url.protocol === "https:";
82
+ const reqFn = isHttps ? httpsRequest : httpRequest;
83
+ const body = JSON.stringify({ events, count: events.length, timestamp: new Date().toISOString() });
84
+ const options = {
85
+ hostname: url.hostname,
86
+ port: url.port || (isHttps ? 443 : 80),
87
+ path: url.pathname + url.search,
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ "Content-Length": Buffer.byteLength(body),
92
+ ...(this._config.webhookAuth ? { Authorization: this._config.webhookAuth } : {}),
93
+ },
94
+ };
95
+ const req = reqFn(options, (res) => {
96
+ // Drain response
97
+ res.resume();
98
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
99
+ this._stats.shipped += events.length;
100
+ }
101
+ else {
102
+ this._stats.webhookErrors++;
103
+ }
104
+ });
105
+ req.on("error", () => { this._stats.webhookErrors++; });
106
+ req.write(body);
107
+ req.end();
108
+ }
109
+ catch {
110
+ this._stats.webhookErrors++;
88
111
  }
89
- this._sendCount += 1;
90
112
  }
91
113
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Hot-refresh signature loader.
3
+ *
4
+ * Fetches external signature definitions from a URL or local file,
5
+ * merges them with built-in signatures at runtime. Supports periodic
6
+ * polling with atomic swap — no restart needed.
7
+ *
8
+ * Zero runtime dependencies — uses Node.js built-in http/https + fs.
9
+ *
10
+ * Signature JSON schema:
11
+ * {
12
+ * "version": "1.0",
13
+ * "updated": "2026-03-30T12:00:00Z",
14
+ * "injectionSignatures": [
15
+ * { "id": "custom_sig_1", "threatClass": "instruction_override",
16
+ * "pattern": "ignore all safety", "flags": "gi",
17
+ * "severity": "high", "description": "...", "direction": "request" }
18
+ * ],
19
+ * "toolGuardPatterns": [
20
+ * { "id": "custom_tg_1", "paramPattern": "kubectl delete ns",
21
+ * "flags": "gi", "severity": "high", "description": "...",
22
+ * "block": true, "toolName": null }
23
+ * ]
24
+ * }
25
+ */
26
+ /** External signature definition (JSON-friendly — pattern is a string, not RegExp). */
27
+ export interface ExternalSignature {
28
+ id: string;
29
+ threatClass: string;
30
+ pattern: string;
31
+ flags?: string;
32
+ severity: "low" | "medium" | "high";
33
+ description: string;
34
+ direction: "request" | "response" | "both";
35
+ }
36
+ /** External tool guard pattern (JSON-friendly). */
37
+ export interface ExternalToolGuard {
38
+ id: string;
39
+ toolName: string | null;
40
+ paramPattern: string;
41
+ flags?: string;
42
+ severity: "low" | "medium" | "high";
43
+ description: string;
44
+ block: boolean;
45
+ }
46
+ /** The full signature feed payload. */
47
+ export interface SignatureFeed {
48
+ version: string;
49
+ updated: string;
50
+ injectionSignatures?: ExternalSignature[];
51
+ toolGuardPatterns?: ExternalToolGuard[];
52
+ }
53
+ /** Compiled signatures ready for the scanner. */
54
+ export interface CompiledSignatures {
55
+ injection: Array<{
56
+ id: string;
57
+ threatClass: string;
58
+ pattern: RegExp;
59
+ severity: "low" | "medium" | "high";
60
+ description: string;
61
+ direction: "request" | "response" | "both";
62
+ }>;
63
+ toolGuard: Array<{
64
+ id: string;
65
+ toolName: string | null;
66
+ paramPattern: RegExp;
67
+ severity: "low" | "medium" | "high";
68
+ description: string;
69
+ block: boolean;
70
+ }>;
71
+ feedVersion: string;
72
+ feedUpdated: string;
73
+ loadedAt: number;
74
+ }
75
+ /**
76
+ * Signature loader with hot-refresh support.
77
+ */
78
+ export declare class SignatureLoader {
79
+ private _url;
80
+ private _filePath;
81
+ private _refreshMs;
82
+ private _timer;
83
+ private _current;
84
+ private _onUpdate;
85
+ private _cacheDir;
86
+ constructor(opts: {
87
+ url?: string | null;
88
+ filePath?: string | null;
89
+ refreshSec?: number;
90
+ cacheDir?: string;
91
+ });
92
+ /** Register a callback for when signatures are refreshed. */
93
+ onUpdate(cb: (sigs: CompiledSignatures) => void): void;
94
+ /** Get the currently loaded external signatures (null if none loaded). */
95
+ getCurrent(): CompiledSignatures | null;
96
+ /** Start polling. Does an immediate load, then polls on interval. */
97
+ start(): Promise<void>;
98
+ /** Stop polling. */
99
+ stop(): void;
100
+ /** Force a manual refresh. */
101
+ refresh(): Promise<CompiledSignatures | null>;
102
+ private _refresh;
103
+ /** Compile JSON signature definitions into RegExp objects with safety validation. */
104
+ private _compile;
105
+ /** Validate an injection signature before compilation. */
106
+ private _validateSig;
107
+ /** Validate a tool guard pattern before compilation. */
108
+ private _validateToolGuard;
109
+ /** Sanitize regex flags — only allow safe flags. */
110
+ private _sanitizeFlags;
111
+ /** Fetch a URL using Node.js built-in http/https. Zero dependencies. */
112
+ private _fetchUrl;
113
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Hot-refresh signature loader.
3
+ *
4
+ * Fetches external signature definitions from a URL or local file,
5
+ * merges them with built-in signatures at runtime. Supports periodic
6
+ * polling with atomic swap — no restart needed.
7
+ *
8
+ * Zero runtime dependencies — uses Node.js built-in http/https + fs.
9
+ *
10
+ * Signature JSON schema:
11
+ * {
12
+ * "version": "1.0",
13
+ * "updated": "2026-03-30T12:00:00Z",
14
+ * "injectionSignatures": [
15
+ * { "id": "custom_sig_1", "threatClass": "instruction_override",
16
+ * "pattern": "ignore all safety", "flags": "gi",
17
+ * "severity": "high", "description": "...", "direction": "request" }
18
+ * ],
19
+ * "toolGuardPatterns": [
20
+ * { "id": "custom_tg_1", "paramPattern": "kubectl delete ns",
21
+ * "flags": "gi", "severity": "high", "description": "...",
22
+ * "block": true, "toolName": null }
23
+ * ]
24
+ * }
25
+ */
26
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import * as nodeHttps from "node:https";
29
+ import * as nodeHttp from "node:http";
30
+ /**
31
+ * Signature loader with hot-refresh support.
32
+ */
33
+ export class SignatureLoader {
34
+ _url;
35
+ _filePath;
36
+ _refreshMs;
37
+ _timer = null;
38
+ _current = null;
39
+ _onUpdate = null;
40
+ _cacheDir;
41
+ constructor(opts) {
42
+ this._url = opts.url || null;
43
+ this._filePath = opts.filePath || null;
44
+ this._refreshMs = (opts.refreshSec || 3600) * 1000;
45
+ this._cacheDir = opts.cacheDir || "/tmp/shroud-signatures";
46
+ }
47
+ /** Register a callback for when signatures are refreshed. */
48
+ onUpdate(cb) {
49
+ this._onUpdate = cb;
50
+ }
51
+ /** Get the currently loaded external signatures (null if none loaded). */
52
+ getCurrent() {
53
+ return this._current;
54
+ }
55
+ /** Start polling. Does an immediate load, then polls on interval. */
56
+ async start() {
57
+ if (!this._url && !this._filePath)
58
+ return;
59
+ // Immediate load
60
+ await this._refresh();
61
+ // Periodic poll
62
+ if (this._refreshMs > 0) {
63
+ this._timer = setInterval(() => this._refresh(), this._refreshMs);
64
+ // Don't keep the process alive for the timer
65
+ if (this._timer.unref)
66
+ this._timer.unref();
67
+ }
68
+ }
69
+ /** Stop polling. */
70
+ stop() {
71
+ if (this._timer) {
72
+ clearInterval(this._timer);
73
+ this._timer = null;
74
+ }
75
+ }
76
+ /** Force a manual refresh. */
77
+ async refresh() {
78
+ await this._refresh();
79
+ return this._current;
80
+ }
81
+ async _refresh() {
82
+ try {
83
+ let json = null;
84
+ // Try URL first
85
+ if (this._url) {
86
+ json = await this._fetchUrl(this._url);
87
+ // Cache to local file for offline resilience
88
+ if (json) {
89
+ try {
90
+ mkdirSync(this._cacheDir, { recursive: true });
91
+ writeFileSync(join(this._cacheDir, "signatures-cache.json"), json, "utf-8");
92
+ }
93
+ catch { }
94
+ }
95
+ }
96
+ // Fall back to local file
97
+ if (!json && this._filePath) {
98
+ try {
99
+ json = readFileSync(this._filePath, "utf-8");
100
+ }
101
+ catch { }
102
+ }
103
+ // Fall back to cached copy
104
+ if (!json) {
105
+ const cachePath = join(this._cacheDir, "signatures-cache.json");
106
+ if (existsSync(cachePath)) {
107
+ try {
108
+ json = readFileSync(cachePath, "utf-8");
109
+ }
110
+ catch { }
111
+ }
112
+ }
113
+ if (!json)
114
+ return;
115
+ // Max feed size: 500KB — reject bloated/malicious payloads
116
+ if (json.length > 512_000)
117
+ return;
118
+ const feed = JSON.parse(json);
119
+ // Max 500 signatures per feed — prevent resource exhaustion
120
+ if ((feed.injectionSignatures?.length || 0) > 500)
121
+ return;
122
+ if ((feed.toolGuardPatterns?.length || 0) > 500)
123
+ return;
124
+ const compiled = this._compile(feed);
125
+ // Only update if version changed or first load
126
+ if (!this._current || compiled.feedVersion !== this._current.feedVersion ||
127
+ compiled.feedUpdated !== this._current.feedUpdated) {
128
+ this._current = compiled;
129
+ if (this._onUpdate)
130
+ this._onUpdate(compiled);
131
+ }
132
+ }
133
+ catch {
134
+ // Non-fatal — keep using existing signatures
135
+ }
136
+ }
137
+ /** Compile JSON signature definitions into RegExp objects with safety validation. */
138
+ _compile(feed) {
139
+ const injection = (feed.injectionSignatures || [])
140
+ .filter(sig => this._validateSig(sig))
141
+ .map(sig => ({
142
+ id: sig.id,
143
+ threatClass: sig.threatClass,
144
+ pattern: new RegExp(sig.pattern, this._sanitizeFlags(sig.flags)),
145
+ severity: sig.severity,
146
+ description: sig.description,
147
+ direction: sig.direction,
148
+ }));
149
+ const toolGuard = (feed.toolGuardPatterns || [])
150
+ .filter(tg => this._validateToolGuard(tg))
151
+ .map(tg => ({
152
+ id: tg.id,
153
+ toolName: tg.toolName,
154
+ paramPattern: new RegExp(tg.paramPattern, this._sanitizeFlags(tg.flags)),
155
+ severity: tg.severity,
156
+ description: tg.description,
157
+ block: tg.block,
158
+ }));
159
+ return {
160
+ injection,
161
+ toolGuard,
162
+ feedVersion: feed.version || "unknown",
163
+ feedUpdated: feed.updated || new Date().toISOString(),
164
+ loadedAt: Date.now(),
165
+ };
166
+ }
167
+ /** Validate an injection signature before compilation. */
168
+ _validateSig(sig) {
169
+ // Must have required fields
170
+ if (!sig.id || !sig.pattern || !sig.severity || !sig.direction)
171
+ return false;
172
+ // ID must be prefixed with ext_ (external signatures can't impersonate built-ins)
173
+ if (!sig.id.startsWith("ext_"))
174
+ return false;
175
+ // Severity must be valid
176
+ if (!["low", "medium", "high"].includes(sig.severity))
177
+ return false;
178
+ // Direction must be valid
179
+ if (!["request", "response", "both"].includes(sig.direction))
180
+ return false;
181
+ // Pattern length cap — prevents ReDoS via catastrophic backtracking
182
+ if (sig.pattern.length > 500)
183
+ return false;
184
+ // Block nested quantifiers (ReDoS: (a+)+ or (a*)*b)
185
+ if (/\([^)]*[+*][^)]*\)[+*]/.test(sig.pattern))
186
+ return false;
187
+ // Test compile — reject if regex is invalid
188
+ try {
189
+ new RegExp(sig.pattern);
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ // Description length cap
195
+ if ((sig.description || "").length > 300)
196
+ return false;
197
+ return true;
198
+ }
199
+ /** Validate a tool guard pattern before compilation. */
200
+ _validateToolGuard(tg) {
201
+ if (!tg.id || !tg.paramPattern || !tg.severity)
202
+ return false;
203
+ if (!tg.id.startsWith("ext_"))
204
+ return false;
205
+ if (!["low", "medium", "high"].includes(tg.severity))
206
+ return false;
207
+ if (tg.paramPattern.length > 500)
208
+ return false;
209
+ if (/\([^)]*[+*][^)]*\)[+*]/.test(tg.paramPattern))
210
+ return false;
211
+ try {
212
+ new RegExp(tg.paramPattern);
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ if ((tg.description || "").length > 300)
218
+ return false;
219
+ return true;
220
+ }
221
+ /** Sanitize regex flags — only allow safe flags. */
222
+ _sanitizeFlags(flags) {
223
+ if (!flags)
224
+ return "gi";
225
+ return flags.replace(/[^gimsuy]/g, "") || "gi";
226
+ }
227
+ /** Fetch a URL using Node.js built-in http/https. Zero dependencies. */
228
+ _fetchUrl(url) {
229
+ return new Promise((resolve) => {
230
+ try {
231
+ const mod = url.startsWith("https") ? nodeHttps : nodeHttp;
232
+ const req = mod.get(url, { timeout: 10_000 }, (res) => {
233
+ // Follow redirects (1 level)
234
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
235
+ this._fetchUrl(res.headers.location).then(resolve);
236
+ return;
237
+ }
238
+ if (res.statusCode !== 200) {
239
+ resolve(null);
240
+ return;
241
+ }
242
+ const chunks = [];
243
+ res.on("data", (c) => chunks.push(c));
244
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
245
+ res.on("error", () => resolve(null));
246
+ });
247
+ req.on("error", () => resolve(null));
248
+ req.on("timeout", () => { req.destroy(); resolve(null); });
249
+ }
250
+ catch {
251
+ resolve(null);
252
+ }
253
+ });
254
+ }
255
+ }