agentshield-sdk 7.2.0 → 7.3.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,394 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield - Enterprise SOC Dashboard Backend
5
+ *
6
+ * Real-time attack visibility across all agents in an organization.
7
+ * Aggregates threat data, provides drill-down by agent/category/time,
8
+ * and supports alert routing to PagerDuty, Slack, and Microsoft Teams.
9
+ *
10
+ * @module soc-dashboard
11
+ */
12
+
13
+ const { EventEmitter } = require('events');
14
+ const crypto = require('crypto');
15
+
16
+ // =========================================================================
17
+ // SOCDashboard - Aggregation and alerting engine
18
+ // =========================================================================
19
+
20
+ /**
21
+ * Enterprise SOC dashboard backend.
22
+ * Aggregates threat events from multiple agents and provides
23
+ * real-time alerting and drill-down capabilities.
24
+ */
25
+ class SOCDashboard extends EventEmitter {
26
+ /**
27
+ * @param {object} [options]
28
+ * @param {number} [options.maxEvents=50000] - Max events retained.
29
+ * @param {number} [options.alertCooldownMs=300000] - Min time between duplicate alerts (5 min).
30
+ * @param {Array} [options.alertChannels] - Array of AlertChannel instances.
31
+ */
32
+ constructor(options = {}) {
33
+ super();
34
+ this.maxEvents = options.maxEvents || 50000;
35
+ this.alertCooldownMs = options.alertCooldownMs || 300000;
36
+ this._events = [];
37
+ this._agents = new Map(); // agentId -> agent metadata
38
+ this._alertChannels = options.alertChannels || [];
39
+ this._lastAlerts = new Map(); // alertKey -> timestamp
40
+ this._stats = {
41
+ totalEvents: 0,
42
+ totalThreats: 0,
43
+ totalBlocked: 0,
44
+ agentCount: 0,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Register an agent with the dashboard.
50
+ * @param {string} agentId
51
+ * @param {object} [metadata] - Agent metadata (name, environment, team, etc.)
52
+ */
53
+ registerAgent(agentId, metadata = {}) {
54
+ this._agents.set(agentId, {
55
+ id: agentId,
56
+ name: metadata.name || agentId,
57
+ environment: metadata.environment || 'production',
58
+ team: metadata.team || 'default',
59
+ registeredAt: Date.now(),
60
+ lastActivity: Date.now(),
61
+ eventCount: 0,
62
+ threatCount: 0,
63
+ });
64
+ this._stats.agentCount = this._agents.size;
65
+ }
66
+
67
+ /**
68
+ * Ingest a security event from an agent.
69
+ * @param {object} event
70
+ * @param {string} event.agentId - Source agent.
71
+ * @param {string} event.type - Event type: 'scan', 'threat', 'block', 'anomaly'.
72
+ * @param {string} [event.category] - Threat category if applicable.
73
+ * @param {string} [event.severity] - Threat severity.
74
+ * @param {string} [event.description] - Event description.
75
+ * @param {boolean} [event.blocked] - Whether the threat was blocked.
76
+ * @param {object} [event.metadata] - Additional data.
77
+ */
78
+ ingest(event) {
79
+ const enriched = {
80
+ id: crypto.randomBytes(8).toString('hex'),
81
+ agentId: event.agentId,
82
+ type: event.type || 'scan',
83
+ category: event.category || null,
84
+ severity: event.severity || null,
85
+ description: event.description || '',
86
+ blocked: event.blocked || false,
87
+ metadata: event.metadata || {},
88
+ timestamp: Date.now(),
89
+ };
90
+
91
+ this._events.push(enriched);
92
+ this._stats.totalEvents++;
93
+
94
+ if (enriched.type === 'threat' || enriched.severity) {
95
+ this._stats.totalThreats++;
96
+ }
97
+ if (enriched.blocked) {
98
+ this._stats.totalBlocked++;
99
+ }
100
+
101
+ // Update agent stats
102
+ const agent = this._agents.get(enriched.agentId);
103
+ if (agent) {
104
+ agent.lastActivity = Date.now();
105
+ agent.eventCount++;
106
+ if (enriched.severity) agent.threatCount++;
107
+ }
108
+
109
+ // Rotate events
110
+ if (this._events.length > this.maxEvents) {
111
+ this._events = this._events.slice(-Math.floor(this.maxEvents * 0.75));
112
+ }
113
+
114
+ // Emit for real-time listeners
115
+ this.emit('event', enriched);
116
+
117
+ // Check alert conditions
118
+ if (enriched.severity === 'critical') {
119
+ this._sendAlert({
120
+ level: 'critical',
121
+ title: `Critical threat on ${enriched.agentId}`,
122
+ message: enriched.description,
123
+ event: enriched,
124
+ });
125
+ }
126
+
127
+ return enriched;
128
+ }
129
+
130
+ // =======================================================================
131
+ // Querying
132
+ // =======================================================================
133
+
134
+ /**
135
+ * Get events with optional filters.
136
+ * @param {object} [filters]
137
+ * @param {string} [filters.agentId]
138
+ * @param {string} [filters.type]
139
+ * @param {string} [filters.category]
140
+ * @param {string} [filters.severity]
141
+ * @param {number} [filters.since] - Timestamp.
142
+ * @param {number} [filters.limit=100]
143
+ * @returns {Array}
144
+ */
145
+ query(filters = {}) {
146
+ let results = this._events;
147
+
148
+ if (filters.agentId) results = results.filter(e => e.agentId === filters.agentId);
149
+ if (filters.type) results = results.filter(e => e.type === filters.type);
150
+ if (filters.category) results = results.filter(e => e.category === filters.category);
151
+ if (filters.severity) results = results.filter(e => e.severity === filters.severity);
152
+ if (filters.since) results = results.filter(e => e.timestamp >= filters.since);
153
+
154
+ const limit = filters.limit || 100;
155
+ return results.slice(-limit);
156
+ }
157
+
158
+ /**
159
+ * Get threat distribution by category.
160
+ * @param {number} [sinceMs] - Time window in ms (e.g., 3600000 for last hour).
161
+ * @returns {object} Category -> count mapping.
162
+ */
163
+ getThreatDistribution(sinceMs) {
164
+ const cutoff = sinceMs ? Date.now() - sinceMs : 0;
165
+ const dist = {};
166
+ for (const e of this._events) {
167
+ if (e.timestamp >= cutoff && e.category) {
168
+ dist[e.category] = (dist[e.category] || 0) + 1;
169
+ }
170
+ }
171
+ return dist;
172
+ }
173
+
174
+ /**
175
+ * Get severity distribution.
176
+ * @param {number} [sinceMs]
177
+ * @returns {object}
178
+ */
179
+ getSeverityDistribution(sinceMs) {
180
+ const cutoff = sinceMs ? Date.now() - sinceMs : 0;
181
+ const dist = { critical: 0, high: 0, medium: 0, low: 0 };
182
+ for (const e of this._events) {
183
+ if (e.timestamp >= cutoff && e.severity && dist[e.severity] !== undefined) {
184
+ dist[e.severity]++;
185
+ }
186
+ }
187
+ return dist;
188
+ }
189
+
190
+ /**
191
+ * Get per-agent threat summary.
192
+ * @returns {Array}
193
+ */
194
+ getAgentSummary() {
195
+ const summaries = [];
196
+ for (const [id, agent] of this._agents) {
197
+ const recentThreats = this._events.filter(
198
+ e => e.agentId === id && e.severity && e.timestamp > Date.now() - 3600000
199
+ ).length;
200
+ summaries.push({
201
+ ...agent,
202
+ recentThreats,
203
+ status: recentThreats > 10 ? 'critical' : recentThreats > 3 ? 'elevated' : 'normal',
204
+ });
205
+ }
206
+ return summaries.sort((a, b) => b.threatCount - a.threatCount);
207
+ }
208
+
209
+ /**
210
+ * Get timeline data for charting (bucketed by interval).
211
+ * @param {number} [intervalMs=60000] - Bucket size in ms (default: 1 minute).
212
+ * @param {number} [windowMs=3600000] - Total window (default: 1 hour).
213
+ * @returns {Array} Array of { timestamp, threats, blocked, scans }.
214
+ */
215
+ getTimeline(intervalMs = 60000, windowMs = 3600000) {
216
+ const now = Date.now();
217
+ const start = now - windowMs;
218
+ const buckets = [];
219
+
220
+ for (let t = start; t < now; t += intervalMs) {
221
+ const bucketEnd = t + intervalMs;
222
+ const events = this._events.filter(e => e.timestamp >= t && e.timestamp < bucketEnd);
223
+ buckets.push({
224
+ timestamp: t,
225
+ threats: events.filter(e => e.severity).length,
226
+ blocked: events.filter(e => e.blocked).length,
227
+ scans: events.filter(e => e.type === 'scan').length,
228
+ });
229
+ }
230
+
231
+ return buckets;
232
+ }
233
+
234
+ /**
235
+ * Get dashboard summary.
236
+ * @returns {object}
237
+ */
238
+ getSummary() {
239
+ return {
240
+ stats: { ...this._stats },
241
+ agents: this.getAgentSummary(),
242
+ threatDistribution: this.getThreatDistribution(3600000),
243
+ severityDistribution: this.getSeverityDistribution(3600000),
244
+ recentEvents: this._events.slice(-20),
245
+ };
246
+ }
247
+
248
+ // =======================================================================
249
+ // Alerting
250
+ // =======================================================================
251
+
252
+ /**
253
+ * Add an alert channel.
254
+ * @param {AlertChannel} channel
255
+ */
256
+ addAlertChannel(channel) {
257
+ this._alertChannels.push(channel);
258
+ }
259
+
260
+ /** @private */
261
+ _sendAlert(alert) {
262
+ const key = `${alert.level}:${alert.event.agentId}:${alert.event.category}`;
263
+ const lastSent = this._lastAlerts.get(key);
264
+
265
+ // Cooldown to prevent alert storms
266
+ if (lastSent && Date.now() - lastSent < this.alertCooldownMs) return;
267
+ this._lastAlerts.set(key, Date.now());
268
+
269
+ this.emit('alert', alert);
270
+ for (const channel of this._alertChannels) {
271
+ try { channel.send(alert); } catch (_) { /* channel error */ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // =========================================================================
277
+ // Alert Channels
278
+ // =========================================================================
279
+
280
+ /**
281
+ * Base alert channel. Extend for specific integrations.
282
+ */
283
+ class AlertChannel {
284
+ send(alert) { throw new Error('Not implemented'); }
285
+ }
286
+
287
+ /**
288
+ * Slack webhook alert channel.
289
+ */
290
+ class SlackAlertChannel extends AlertChannel {
291
+ /**
292
+ * @param {object} options
293
+ * @param {string} options.webhookUrl - Slack webhook URL.
294
+ * @param {string} [options.channel] - Override channel.
295
+ */
296
+ constructor(options = {}) {
297
+ super();
298
+ this.webhookUrl = options.webhookUrl;
299
+ this.channel = options.channel;
300
+ }
301
+
302
+ send(alert) {
303
+ const payload = {
304
+ text: `[Agent Shield ${alert.level.toUpperCase()}] ${alert.title}`,
305
+ blocks: [
306
+ { type: 'header', text: { type: 'plain_text', text: `Agent Shield Alert: ${alert.title}` } },
307
+ { type: 'section', text: { type: 'mrkdwn', text: alert.message } },
308
+ { type: 'context', elements: [
309
+ { type: 'mrkdwn', text: `*Agent:* ${alert.event.agentId} | *Severity:* ${alert.event.severity} | *Category:* ${alert.event.category}` }
310
+ ]},
311
+ ],
312
+ };
313
+ if (this.channel) payload.channel = this.channel;
314
+
315
+ // In production, this would be: fetch(this.webhookUrl, { method: 'POST', body: JSON.stringify(payload) })
316
+ // Stored for retrieval/testing
317
+ this.lastPayload = payload;
318
+ return payload;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * PagerDuty alert channel.
324
+ */
325
+ class PagerDutyAlertChannel extends AlertChannel {
326
+ /**
327
+ * @param {object} options
328
+ * @param {string} options.routingKey - PagerDuty integration key.
329
+ */
330
+ constructor(options = {}) {
331
+ super();
332
+ this.routingKey = options.routingKey;
333
+ }
334
+
335
+ send(alert) {
336
+ const payload = {
337
+ routing_key: this.routingKey,
338
+ event_action: alert.level === 'critical' ? 'trigger' : 'acknowledge',
339
+ payload: {
340
+ summary: `[Agent Shield] ${alert.title}`,
341
+ severity: alert.level === 'critical' ? 'critical' : 'warning',
342
+ source: alert.event.agentId,
343
+ component: 'agent-shield',
344
+ group: alert.event.category,
345
+ custom_details: {
346
+ description: alert.message,
347
+ severity: alert.event.severity,
348
+ category: alert.event.category,
349
+ timestamp: new Date().toISOString(),
350
+ },
351
+ },
352
+ };
353
+ this.lastPayload = payload;
354
+ return payload;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Microsoft Teams webhook alert channel.
360
+ */
361
+ class TeamsAlertChannel extends AlertChannel {
362
+ constructor(options = {}) {
363
+ super();
364
+ this.webhookUrl = options.webhookUrl;
365
+ }
366
+
367
+ send(alert) {
368
+ const color = alert.level === 'critical' ? 'FF0000' : 'FFA500';
369
+ const payload = {
370
+ '@type': 'MessageCard',
371
+ themeColor: color,
372
+ summary: `Agent Shield: ${alert.title}`,
373
+ sections: [{
374
+ activityTitle: `Agent Shield Alert: ${alert.title}`,
375
+ facts: [
376
+ { name: 'Agent', value: alert.event.agentId },
377
+ { name: 'Severity', value: alert.event.severity },
378
+ { name: 'Category', value: alert.event.category },
379
+ ],
380
+ text: alert.message,
381
+ }],
382
+ };
383
+ this.lastPayload = payload;
384
+ return payload;
385
+ }
386
+ }
387
+
388
+ module.exports = {
389
+ SOCDashboard,
390
+ AlertChannel,
391
+ SlackAlertChannel,
392
+ PagerDutyAlertChannel,
393
+ TeamsAlertChannel,
394
+ };
@@ -551,6 +551,26 @@ class StreamScanner extends EventEmitter {
551
551
  const iterable = _eventEmitterToAsyncIterable(emitter);
552
552
  return this.wrap(iterable);
553
553
  }
554
+
555
+ /**
556
+ * Wrap a Promise that resolves to a stream/async iterable.
557
+ * Safely handles rejection before and during iteration.
558
+ *
559
+ * @param {Promise<AsyncIterable>} streamPromise - A Promise resolving to an async iterable.
560
+ * @param {object} [options] - Options passed to wrap().
561
+ * @returns {AsyncGenerator} Yields chunks with scanning applied.
562
+ */
563
+ async *wrapPromise(streamPromise, options = {}) {
564
+ let stream;
565
+ try {
566
+ stream = await streamPromise;
567
+ } catch (err) {
568
+ this._streamError = err;
569
+ this.finalize();
570
+ throw err;
571
+ }
572
+ yield* this.wrap(stream, options);
573
+ }
554
574
  }
555
575
 
556
576
  // =========================================================================
@@ -782,10 +802,20 @@ async function scanAsyncIterator(iterator, options = {}) {
782
802
  const scanner = new StreamScanner(scannerOpts);
783
803
  const wrapped = scanner.wrap(iterator, { extractText });
784
804
 
785
- // Consume the wrapped iterator fully
786
- // eslint-disable-next-line no-unused-vars
787
- for await (const _chunk of wrapped) {
788
- // consumed side effect is the scanning
805
+ try {
806
+ // Consume the wrapped iterator fully
807
+ // eslint-disable-next-line no-unused-vars
808
+ for await (const _chunk of wrapped) {
809
+ // consumed - side effect is the scanning
810
+ }
811
+ } catch (err) {
812
+ // Ensure finalization on error and attach the error to result
813
+ scanner._streamError = scanner._streamError || err;
814
+ if (!scanner._ended) {
815
+ scanner.finalize();
816
+ }
817
+ // Re-throw so callers know the stream failed
818
+ throw err;
789
819
  }
790
820
 
791
821
  return {