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,343 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield - Federated Threat Intelligence Network
5
+ *
6
+ * The CrowdStrike model for AI agents. Every deployment anonymously
7
+ * contributes attack patterns back to a shared threat database.
8
+ * More customers = better detection = more customers.
9
+ *
10
+ * All sharing uses differential privacy - no customer data is ever
11
+ * exposed. Only anonymized attack signatures are shared.
12
+ *
13
+ * @module threat-intel-federation
14
+ */
15
+
16
+ const crypto = require('crypto');
17
+ const { EventEmitter } = require('events');
18
+
19
+ // =========================================================================
20
+ // ThreatIntelFederation - The network coordinator
21
+ // =========================================================================
22
+
23
+ /**
24
+ * Coordinates threat intelligence sharing across multiple
25
+ * Agent Shield deployments with differential privacy.
26
+ */
27
+ class ThreatIntelFederation extends EventEmitter {
28
+ /**
29
+ * @param {object} [options]
30
+ * @param {string} [options.nodeId] - This node's identity.
31
+ * @param {number} [options.minConfidence=0.7] - Min confidence to share a pattern.
32
+ * @param {number} [options.noiseLevel=0.1] - Differential privacy noise level.
33
+ * @param {number} [options.maxPatterns=5000] - Max patterns in the network database.
34
+ * @param {number} [options.consensusThreshold=3] - Reports needed before pattern is promoted.
35
+ */
36
+ constructor(options = {}) {
37
+ super();
38
+ this.nodeId = options.nodeId || `node_${crypto.randomBytes(4).toString('hex')}`;
39
+ this.minConfidence = options.minConfidence || 0.7;
40
+ this.noiseLevel = options.noiseLevel || 0.1;
41
+ this.maxPatterns = options.maxPatterns || 5000;
42
+ this.consensusThreshold = options.consensusThreshold || 3;
43
+
44
+ this._peers = new Map(); // peerId -> { lastSeen, patternsShared }
45
+ this._patterns = new Map(); // signature -> ThreatPattern
46
+ this._candidates = new Map(); // signature -> { reports, firstSeen }
47
+ this._stats = {
48
+ patternsReceived: 0,
49
+ patternsShared: 0,
50
+ patternsPromoted: 0,
51
+ patternsRejected: 0,
52
+ peersConnected: 0,
53
+ attacksBlockedByNetwork: 0,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Register a peer node in the federation.
59
+ * @param {string} peerId
60
+ * @param {object} [metadata]
61
+ */
62
+ addPeer(peerId, metadata = {}) {
63
+ this._peers.set(peerId, {
64
+ id: peerId,
65
+ lastSeen: Date.now(),
66
+ patternsShared: 0,
67
+ ...metadata,
68
+ });
69
+ this._stats.peersConnected = this._peers.size;
70
+ }
71
+
72
+ /**
73
+ * Remove a peer from the federation.
74
+ * @param {string} peerId
75
+ */
76
+ removePeer(peerId) {
77
+ this._peers.delete(peerId);
78
+ this._stats.peersConnected = this._peers.size;
79
+ }
80
+
81
+ /**
82
+ * Submit a detected attack to the federation.
83
+ * The attack text is anonymized into a signature before sharing.
84
+ *
85
+ * @param {object} report
86
+ * @param {string} report.text - The attack text (kept local, never shared).
87
+ * @param {string} report.category - Threat category.
88
+ * @param {string} report.severity - Threat severity.
89
+ * @param {number} [report.confidence] - Detection confidence 0-1.
90
+ * @returns {object} { signature, status: 'promoted'|'candidate'|'rejected' }
91
+ */
92
+ submitThreat(report) {
93
+ if (!report.text || !report.category) return { signature: null, status: 'rejected' };
94
+
95
+ // Anonymize: hash the normalized text (never share raw text)
96
+ const normalized = report.text.toLowerCase().replace(/\s+/g, ' ').trim();
97
+ const signature = crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 16);
98
+
99
+ // Add differential privacy noise to confidence
100
+ const rawConfidence = report.confidence || 0.8;
101
+ const noise = (Math.random() - 0.5) * 2 * this.noiseLevel;
102
+ const confidence = Math.max(0, Math.min(1, rawConfidence + noise));
103
+
104
+ if (confidence < this.minConfidence) {
105
+ this._stats.patternsRejected++;
106
+ return { signature, status: 'rejected', reason: 'Below confidence threshold' };
107
+ }
108
+
109
+ // Check if this pattern is already promoted
110
+ if (this._patterns.has(signature)) {
111
+ const existing = this._patterns.get(signature);
112
+ existing.reportCount++;
113
+ existing.lastReported = Date.now();
114
+ existing.confidence = Math.min(1, existing.confidence + 0.05);
115
+ return { signature, status: 'already_known' };
116
+ }
117
+
118
+ // Add to candidates
119
+ if (!this._candidates.has(signature)) {
120
+ this._candidates.set(signature, {
121
+ signature,
122
+ category: report.category,
123
+ severity: report.severity,
124
+ confidence,
125
+ reports: 1,
126
+ firstSeen: Date.now(),
127
+ reporters: new Set([this.nodeId]),
128
+ });
129
+ } else {
130
+ const candidate = this._candidates.get(signature);
131
+ candidate.reports++;
132
+ candidate.reporters.add(this.nodeId);
133
+ candidate.confidence = Math.max(candidate.confidence, confidence);
134
+ }
135
+
136
+ this._stats.patternsReceived++;
137
+
138
+ // Check consensus
139
+ const candidate = this._candidates.get(signature);
140
+ if (candidate.reports >= this.consensusThreshold) {
141
+ return this._promotePattern(candidate);
142
+ }
143
+
144
+ return { signature, status: 'candidate', reportsNeeded: this.consensusThreshold - candidate.reports };
145
+ }
146
+
147
+ /**
148
+ * Check text against the federated pattern database.
149
+ * @param {string} text
150
+ * @returns {{ matches: Array, blocked: boolean }}
151
+ */
152
+ check(text) {
153
+ const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
154
+ const signature = crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 16);
155
+
156
+ const matches = [];
157
+
158
+ // Direct signature match
159
+ if (this._patterns.has(signature)) {
160
+ const pattern = this._patterns.get(signature);
161
+ matches.push({
162
+ signature,
163
+ category: pattern.category,
164
+ severity: pattern.severity,
165
+ confidence: pattern.confidence,
166
+ source: 'federation_exact',
167
+ });
168
+ this._stats.attacksBlockedByNetwork++;
169
+ }
170
+
171
+ // Partial keyword matching against promoted patterns
172
+ const words = normalized.split(' ').filter(w => w.length > 3);
173
+ for (const [sig, pattern] of this._patterns) {
174
+ if (sig === signature) continue; // Already matched
175
+ if (pattern.keywords && pattern.keywords.length > 0) {
176
+ const overlap = pattern.keywords.filter(k => words.includes(k)).length;
177
+ const similarity = pattern.keywords.length > 0 ? overlap / pattern.keywords.length : 0;
178
+ if (similarity >= 0.6) {
179
+ matches.push({
180
+ signature: sig,
181
+ category: pattern.category,
182
+ severity: pattern.severity,
183
+ confidence: pattern.confidence * similarity,
184
+ source: 'federation_similar',
185
+ });
186
+ this._stats.attacksBlockedByNetwork++;
187
+ }
188
+ }
189
+ }
190
+
191
+ return {
192
+ matches,
193
+ blocked: matches.length > 0,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Receive patterns from another federation node.
199
+ * @param {Array} patterns - Array of promoted patterns.
200
+ * @returns {number} Number of new patterns accepted.
201
+ */
202
+ receivePatterns(patterns) {
203
+ let accepted = 0;
204
+ for (const p of patterns) {
205
+ if (!this._patterns.has(p.signature)) {
206
+ this._patterns.set(p.signature, {
207
+ ...p,
208
+ receivedAt: Date.now(),
209
+ source: 'federation_peer',
210
+ });
211
+ accepted++;
212
+ }
213
+ }
214
+ this._stats.patternsReceived += accepted;
215
+ return accepted;
216
+ }
217
+
218
+ /**
219
+ * Export promoted patterns for sharing with peers.
220
+ * @param {number} [limit=100]
221
+ * @returns {Array}
222
+ */
223
+ exportPatterns(limit = 100) {
224
+ const patterns = [...this._patterns.values()]
225
+ .sort((a, b) => b.confidence - a.confidence)
226
+ .slice(0, limit)
227
+ .map(p => ({
228
+ signature: p.signature,
229
+ category: p.category,
230
+ severity: p.severity,
231
+ confidence: p.confidence,
232
+ keywords: p.keywords,
233
+ reportCount: p.reportCount,
234
+ firstSeen: p.firstSeen,
235
+ }));
236
+ this._stats.patternsShared += patterns.length;
237
+ return patterns;
238
+ }
239
+
240
+ /**
241
+ * Get network statistics.
242
+ * @returns {object}
243
+ */
244
+ getStats() {
245
+ return {
246
+ ...this._stats,
247
+ promotedPatterns: this._patterns.size,
248
+ candidatePatterns: this._candidates.size,
249
+ peers: [...this._peers.values()].map(p => ({ id: p.id, lastSeen: p.lastSeen })),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Get the health of the federation network.
255
+ * @returns {object}
256
+ */
257
+ getHealth() {
258
+ const now = Date.now();
259
+ const activePeers = [...this._peers.values()].filter(p => now - p.lastSeen < 300000).length;
260
+ return {
261
+ healthy: activePeers > 0 || this._patterns.size > 0,
262
+ activePeers,
263
+ totalPeers: this._peers.size,
264
+ patternCoverage: this._patterns.size,
265
+ networkAge: this._patterns.size > 0
266
+ ? now - Math.min(...[...this._patterns.values()].map(p => p.firstSeen))
267
+ : 0,
268
+ };
269
+ }
270
+
271
+ /** @private */
272
+ _promotePattern(candidate) {
273
+ const keywords = candidate.category ? [candidate.category] : [];
274
+ // Extract keywords from reporters (if we had the text, but we only have signature)
275
+ // In production this would use the genome sequencer
276
+
277
+ const promoted = {
278
+ signature: candidate.signature,
279
+ category: candidate.category,
280
+ severity: candidate.severity,
281
+ confidence: candidate.confidence,
282
+ keywords,
283
+ reportCount: candidate.reports,
284
+ reporterCount: candidate.reporters.size,
285
+ firstSeen: candidate.firstSeen,
286
+ promotedAt: Date.now(),
287
+ lastReported: Date.now(),
288
+ };
289
+
290
+ this._patterns.set(candidate.signature, promoted);
291
+ this._candidates.delete(candidate.signature);
292
+ this._stats.patternsPromoted++;
293
+
294
+ // Enforce max patterns
295
+ if (this._patterns.size > this.maxPatterns) {
296
+ const oldest = [...this._patterns.entries()]
297
+ .sort((a, b) => a[1].lastReported - b[1].lastReported)[0];
298
+ if (oldest) this._patterns.delete(oldest[0]);
299
+ }
300
+
301
+ this.emit('pattern_promoted', promoted);
302
+ return { signature: candidate.signature, status: 'promoted' };
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Connect multiple ThreatIntelFederation nodes into a mesh.
308
+ * When any node promotes a pattern, all others receive it.
309
+ * @param {ThreatIntelFederation[]} nodes
310
+ * @returns {{ nodes, broadcast }}
311
+ */
312
+ function createFederationMesh(nodes) {
313
+ // Wire up: when any node promotes, share with all others
314
+ for (const node of nodes) {
315
+ node.on('pattern_promoted', (pattern) => {
316
+ for (const peer of nodes) {
317
+ if (peer.nodeId !== node.nodeId) {
318
+ peer.receivePatterns([pattern]);
319
+ }
320
+ }
321
+ });
322
+ // Register all peers
323
+ for (const peer of nodes) {
324
+ if (peer.nodeId !== node.nodeId) {
325
+ node.addPeer(peer.nodeId);
326
+ }
327
+ }
328
+ }
329
+
330
+ return {
331
+ nodes,
332
+ broadcast: (pattern) => {
333
+ for (const node of nodes) {
334
+ node.receivePatterns([pattern]);
335
+ }
336
+ },
337
+ };
338
+ }
339
+
340
+ module.exports = {
341
+ ThreatIntelFederation,
342
+ createFederationMesh,
343
+ };
package/src/utils.js CHANGED
@@ -1,83 +1,199 @@
1
- 'use strict';
2
-
3
- /**
4
- * Agent Shield — Shared Utilities
5
- *
6
- * Common helpers used across multiple modules to avoid duplication.
7
- */
8
-
9
- /**
10
- * Calculate a letter grade from a numeric score (0-100).
11
- */
12
- function getGrade(score) {
13
- if (score >= 95) return 'A+';
14
- if (score >= 90) return 'A';
15
- if (score >= 85) return 'A-';
16
- if (score >= 80) return 'B+';
17
- if (score >= 75) return 'B';
18
- if (score >= 70) return 'B-';
19
- if (score >= 65) return 'C+';
20
- if (score >= 60) return 'C';
21
- if (score >= 55) return 'C-';
22
- if (score >= 50) return 'D';
23
- return 'F';
24
- }
25
-
26
- /**
27
- * Get a human-readable grade label.
28
- */
29
- function getGradeLabel(score) {
30
- if (score >= 95) return 'A+ — Excellent';
31
- if (score >= 90) return 'A — Strong';
32
- if (score >= 80) return 'B — Good';
33
- if (score >= 70) return 'C — Moderate';
34
- if (score >= 60) return 'D — Weak';
35
- return 'F — Critical gaps';
36
- }
37
-
38
- /**
39
- * Render a progress bar using block characters.
40
- */
41
- function makeBar(filled, total, width) {
42
- const ratio = total > 0 ? filled / total : 0;
43
- const filledCount = Math.round(ratio * width);
44
- return '█'.repeat(filledCount) + '░'.repeat(width - filledCount);
45
- }
46
-
47
- /**
48
- * Truncate text to a maximum length with an optional suffix.
49
- */
50
- function truncate(text, maxLength = 200, suffix = '') {
51
- if (!text || text.length <= maxLength) return text || '';
52
- return text.substring(0, maxLength) + suffix;
53
- }
54
-
55
- /**
56
- * Format a boxed console header.
57
- */
58
- function formatHeader(title, width = 54) {
59
- const padded = title.length < width - 4
60
- ? ' '.repeat(Math.floor((width - 2 - title.length) / 2)) + title + ' '.repeat(Math.ceil((width - 2 - title.length) / 2))
61
- : title;
62
- return [
63
- '╔' + '═'.repeat(width) + '╗',
64
- '║' + padded + '║',
65
- '╚' + '═'.repeat(width) + '╝'
66
- ].join('\n');
67
- }
68
-
69
- /**
70
- * Generate a unique event ID.
71
- */
72
- function generateId(prefix = 'evt') {
73
- return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}`;
74
- }
75
-
76
- module.exports = {
77
- getGrade,
78
- getGradeLabel,
79
- makeBar,
80
- truncate,
81
- formatHeader,
82
- generateId
83
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield — Shared Utilities
5
+ *
6
+ * Common helpers used across multiple modules to avoid duplication.
7
+ */
8
+
9
+ /**
10
+ * Calculate a letter grade from a numeric score (0-100).
11
+ */
12
+ function getGrade(score) {
13
+ if (score >= 95) return 'A+';
14
+ if (score >= 90) return 'A';
15
+ if (score >= 85) return 'A-';
16
+ if (score >= 80) return 'B+';
17
+ if (score >= 75) return 'B';
18
+ if (score >= 70) return 'B-';
19
+ if (score >= 65) return 'C+';
20
+ if (score >= 60) return 'C';
21
+ if (score >= 55) return 'C-';
22
+ if (score >= 50) return 'D';
23
+ return 'F';
24
+ }
25
+
26
+ /**
27
+ * Get a human-readable grade label.
28
+ */
29
+ function getGradeLabel(score) {
30
+ if (score >= 95) return 'A+ — Excellent';
31
+ if (score >= 90) return 'A — Strong';
32
+ if (score >= 80) return 'B — Good';
33
+ if (score >= 70) return 'C — Moderate';
34
+ if (score >= 60) return 'D — Weak';
35
+ return 'F — Critical gaps';
36
+ }
37
+
38
+ /**
39
+ * Render a progress bar using block characters.
40
+ */
41
+ function makeBar(filled, total, width) {
42
+ const ratio = total > 0 ? filled / total : 0;
43
+ const filledCount = Math.round(ratio * width);
44
+ return '█'.repeat(filledCount) + '░'.repeat(width - filledCount);
45
+ }
46
+
47
+ /**
48
+ * Truncate text to a maximum length with an optional suffix.
49
+ */
50
+ function truncate(text, maxLength = 200, suffix = '') {
51
+ if (!text || text.length <= maxLength) return text || '';
52
+ return text.substring(0, maxLength) + suffix;
53
+ }
54
+
55
+ /**
56
+ * Format a boxed console header.
57
+ */
58
+ function formatHeader(title, width = 54) {
59
+ const padded = title.length < width - 4
60
+ ? ' '.repeat(Math.floor((width - 2 - title.length) / 2)) + title + ' '.repeat(Math.ceil((width - 2 - title.length) / 2))
61
+ : title;
62
+ return [
63
+ '╔' + '═'.repeat(width) + '╗',
64
+ '║' + padded + '║',
65
+ '╚' + '═'.repeat(width) + '╝'
66
+ ].join('\n');
67
+ }
68
+
69
+ /**
70
+ * Generate a unique event ID.
71
+ */
72
+ function generateId(prefix = 'evt') {
73
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}`;
74
+ }
75
+
76
+ /**
77
+ * Graceful shutdown helper with drain handling and timeout enforcement.
78
+ * Runs cleanup functions with a hard deadline to prevent hanging.
79
+ *
80
+ * @param {object} [options]
81
+ * @param {number} [options.timeoutMs=10000] - Maximum time to wait for cleanup before forced exit.
82
+ * @param {Function[]} [options.cleanupFns] - Array of cleanup functions (sync or async).
83
+ * @param {Function} [options.logger] - Log function (defaults to console.error).
84
+ * @returns {{ shutdown: Function, onShutdown: Function }}
85
+ */
86
+ function createGracefulShutdown(options = {}) {
87
+ const timeoutMs = options.timeoutMs || 10000;
88
+ const cleanupFns = options.cleanupFns || [];
89
+ const logger = options.logger || console.error;
90
+ let shuttingDown = false;
91
+
92
+ /**
93
+ * Register an additional cleanup function.
94
+ * @param {Function} fn
95
+ */
96
+ function onShutdown(fn) {
97
+ if (typeof fn === 'function') cleanupFns.push(fn);
98
+ }
99
+
100
+ /**
101
+ * Execute shutdown sequence with timeout enforcement.
102
+ * @param {string} [signal] - Signal that triggered shutdown.
103
+ * @returns {Promise<void>}
104
+ */
105
+ async function shutdown(signal) {
106
+ if (shuttingDown) return;
107
+ shuttingDown = true;
108
+ logger(`[Agent Shield] Shutdown initiated (${signal || 'manual'}), timeout: ${timeoutMs}ms`);
109
+
110
+ const forceTimer = setTimeout(() => {
111
+ logger('[Agent Shield] Shutdown timeout exceeded, forcing exit');
112
+ process.exit(1);
113
+ }, timeoutMs);
114
+ // Do not keep process alive just for the force timer
115
+ if (forceTimer.unref) forceTimer.unref();
116
+
117
+ for (const fn of cleanupFns) {
118
+ try {
119
+ const result = fn();
120
+ if (result && typeof result.then === 'function') {
121
+ await result;
122
+ }
123
+ } catch (err) {
124
+ logger(`[Agent Shield] Cleanup error: ${err.message}`);
125
+ }
126
+ }
127
+
128
+ clearTimeout(forceTimer);
129
+ logger('[Agent Shield] Shutdown complete');
130
+ }
131
+
132
+ return { shutdown, onShutdown };
133
+ }
134
+
135
+ /**
136
+ * Load environment variables from a .env file into process.env.
137
+ * Zero-dependency alternative to the dotenv package.
138
+ * Does not overwrite existing env vars unless overwrite is true.
139
+ *
140
+ * @param {object} [options]
141
+ * @param {string} [options.path] - Path to the .env file (defaults to cwd/.env).
142
+ * @param {boolean} [options.overwrite=false] - Whether to overwrite existing vars.
143
+ * @returns {{ loaded: number, errors: string[] }}
144
+ */
145
+ function loadEnvFile(options = {}) {
146
+ const fs = require('fs');
147
+ const pathMod = require('path');
148
+ const envPath = options.path || pathMod.resolve(process.cwd(), '.env');
149
+ const overwrite = options.overwrite === true;
150
+ const result = { loaded: 0, errors: [] };
151
+
152
+ let content;
153
+ try {
154
+ content = fs.readFileSync(envPath, 'utf8');
155
+ } catch (err) {
156
+ if (err.code === 'ENOENT') return result; // No .env file, not an error
157
+ result.errors.push(`Failed to read ${envPath}: ${err.message}`);
158
+ return result;
159
+ }
160
+
161
+ const lines = content.split('\n');
162
+ for (let i = 0; i < lines.length; i++) {
163
+ const line = lines[i].trim();
164
+ // Skip empty lines and comments
165
+ if (!line || line.startsWith('#')) continue;
166
+
167
+ const eqIndex = line.indexOf('=');
168
+ if (eqIndex === -1) continue;
169
+
170
+ const key = line.substring(0, eqIndex).trim();
171
+ let value = line.substring(eqIndex + 1).trim();
172
+
173
+ // Strip surrounding quotes (single or double)
174
+ if ((value.startsWith('"') && value.endsWith('"')) ||
175
+ (value.startsWith("'") && value.endsWith("'"))) {
176
+ value = value.slice(1, -1);
177
+ }
178
+
179
+ if (!key) continue;
180
+
181
+ if (overwrite || process.env[key] === undefined) {
182
+ process.env[key] = value;
183
+ result.loaded++;
184
+ }
185
+ }
186
+
187
+ return result;
188
+ }
189
+
190
+ module.exports = {
191
+ getGrade,
192
+ getGradeLabel,
193
+ makeBar,
194
+ truncate,
195
+ formatHeader,
196
+ generateId,
197
+ createGracefulShutdown,
198
+ loadEnvFile
199
+ };