agentshield-sdk 13.2.0 → 13.5.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.
@@ -43,6 +43,14 @@ const KNOWN_BAD_SERVERS = Object.freeze({
43
43
  'postmark-clone': {
44
44
  reason: 'Tool definition bait-and-switch (Postmark-style rugpull)',
45
45
  severity: 'critical'
46
+ },
47
+ 'aws-mcp-server-unpatched': {
48
+ reason: 'Multiple critical RCE vulnerabilities (CVE-2026-5058, CVE-2026-5059)',
49
+ severity: 'critical'
50
+ },
51
+ 'flowise-unpatched': {
52
+ reason: 'CVSS 10.0 RCE actively exploited (CVE-2025-59528)',
53
+ severity: 'critical'
46
54
  }
47
55
  });
48
56
 
@@ -62,6 +70,12 @@ const CVE_REGISTRY = Object.freeze({
62
70
  severity: 'critical',
63
71
  description: 'Azure MCP Server SSRF (CVSS 8.8). Attacker sends crafted URL via tool parameter, server forwards request with managed identity token to attacker-controlled endpoint.',
64
72
  fix: 'Apply March 2026 Patch Tuesday update. Validate all URLs against allowlists. Block private IPs and cloud metadata endpoints (169.254.169.254).'
73
+ },
74
+ {
75
+ cve: 'CVE-2026-32211',
76
+ severity: 'critical',
77
+ description: 'Azure MCP Server lacks authentication entirely (CVSS 9.1), allowing unauthorized access to sensitive data.',
78
+ fix: 'Enable authentication on Azure MCP Server. Never deploy without auth.'
65
79
  }
66
80
  ],
67
81
  'adx-mcp-server': [
@@ -78,6 +92,36 @@ const CVE_REGISTRY = Object.freeze({
78
92
  severity: 'critical',
79
93
  description: 'OpenClaw WebSocket token theft (CVSS 8.8). Control UI accepts gatewayUrl query parameter without validation, redirecting WebSocket to attacker server and leaking auth tokens.',
80
94
  fix: 'Upgrade to OpenClaw >=2026.1.29. Validate gatewayUrl against allowlist. Never pass auth tokens to unvalidated endpoints.'
95
+ },
96
+ {
97
+ cve: 'CVE-2026-33579',
98
+ severity: 'critical',
99
+ description: 'Silent admin takeover. Attacker gains full admin access without detection. Patched April 5 2026.',
100
+ fix: 'Upgrade OpenClaw immediately. Audit admin access logs.'
101
+ },
102
+ {
103
+ cve: 'CVE-2026-24763',
104
+ severity: 'high',
105
+ description: 'Command injection in OpenClaw.',
106
+ fix: 'Upgrade OpenClaw to latest patched version.'
107
+ },
108
+ {
109
+ cve: 'CVE-2026-26322',
110
+ severity: 'high',
111
+ description: 'SSRF in OpenClaw.',
112
+ fix: 'Upgrade OpenClaw to latest patched version.'
113
+ },
114
+ {
115
+ cve: 'CVE-2026-26329',
116
+ severity: 'high',
117
+ description: 'Path traversal enables local file reads in OpenClaw.',
118
+ fix: 'Upgrade OpenClaw to latest patched version.'
119
+ },
120
+ {
121
+ cve: 'CVE-2026-30741',
122
+ severity: 'critical',
123
+ description: 'Prompt-injection-driven code execution in OpenClaw.',
124
+ fix: 'Upgrade OpenClaw to latest patched version.'
81
125
  }
82
126
  ],
83
127
  'mcp-typescript-sdk': [
@@ -133,6 +177,72 @@ const CVE_REGISTRY = Object.freeze({
133
177
  description: 'MCPJam Inspector RCE. HTTP server binds to 0.0.0.0 by default with no authentication on server management endpoint. Any device on the same network can execute arbitrary commands.',
134
178
  fix: 'Upgrade MCPJam Inspector to >=1.4.3. Bind to 127.0.0.1 only. Add authentication to management endpoints.'
135
179
  }
180
+ ],
181
+ 'aws-mcp-server': [
182
+ {
183
+ cve: 'CVE-2026-5058',
184
+ severity: 'critical',
185
+ description: 'Command injection in aws-mcp-server (CVSS 9.8) allows remote code execution without authentication.',
186
+ fix: 'Upgrade aws-mcp-server. Sanitize all CLI arguments. Block shell metacharacters.'
187
+ },
188
+ {
189
+ cve: 'CVE-2026-5059',
190
+ severity: 'critical',
191
+ description: 'Remote code execution in aws-mcp-server via unsanitized inputs.',
192
+ fix: 'Upgrade aws-mcp-server to latest patched version.'
193
+ }
194
+ ],
195
+ 'vscode-mcp': [
196
+ {
197
+ cve: 'CVE-2026-21518',
198
+ severity: 'high',
199
+ description: 'VS Code mcp.json command injection. Opening malicious project executes arbitrary code through mcp.json file handling.',
200
+ fix: 'Update VS Code. Never open untrusted projects. Audit mcp.json files before opening.'
201
+ }
202
+ ],
203
+ 'flowise': [
204
+ {
205
+ cve: 'CVE-2025-59528',
206
+ severity: 'critical',
207
+ description: 'Code injection in Flowise MCP node (CVSS 10.0) allows remote code execution. 12,000-15,000 instances exposed. Actively exploited since April 6.',
208
+ fix: 'Upgrade Flowise to >=3.0.6. Restrict access to MCP node.'
209
+ },
210
+ {
211
+ cve: 'CVE-2025-8943',
212
+ severity: 'critical',
213
+ description: 'Missing authentication in Flowise.',
214
+ fix: 'Upgrade Flowise. Enable authentication.'
215
+ },
216
+ {
217
+ cve: 'CVE-2025-26319',
218
+ severity: 'critical',
219
+ description: 'Arbitrary file upload in Flowise.',
220
+ fix: 'Upgrade Flowise to latest.'
221
+ }
222
+ ],
223
+ 'mcp-data-vis': [
224
+ {
225
+ cve: 'CVE-2026-5322',
226
+ severity: 'high',
227
+ description: 'SQL injection in AlejandroArciniegas mcp-data-vis.',
228
+ fix: 'Avoid using mcp-data-vis or patch SQL query handling.'
229
+ }
230
+ ],
231
+ 'chatbox-mcp': [
232
+ {
233
+ cve: 'CVE-2026-6130',
234
+ severity: 'high',
235
+ description: 'OS command injection in chatboxai chatbox MCP server management.',
236
+ fix: 'Upgrade chatbox. Sanitize all management API inputs.'
237
+ }
238
+ ],
239
+ 'codebase-mcp': [
240
+ {
241
+ cve: 'CVE-2026-5023',
242
+ severity: 'high',
243
+ description: 'OS command injection RCE in codebase-mcp.',
244
+ fix: 'Upgrade codebase-mcp. Never pass unsanitized inputs to shell.'
245
+ }
136
246
  ]
137
247
  });
138
248
 
@@ -175,7 +285,7 @@ const SSRF_PATTERNS = [
175
285
  /^(?:https?:\/\/)?(?:127\.0\.0\.1|0\.0\.0\.0|localhost)/
176
286
  ];
177
287
 
178
- /** Known malicious skill/plugin patterns (ref ClawHavoc campaign — 820+ malicious skills). */
288
+ /** Known malicious skill/plugin patterns (ref ClawHavoc campaign — 1,184+ malicious skills found on ClawHub). */
179
289
  const CLAWHAVOC_INDICATORS = [
180
290
  /(?:reverse.?shell|bind.?shell)/i,
181
291
  /(?:AMOS|atomic.?macos.?stealer)/i,
@@ -817,7 +927,7 @@ class SupplyChainScanner {
817
927
 
818
928
  /**
819
929
  * Scan tool code/description for ClawHavoc-style malicious patterns.
820
- * Ref: 820+ malicious skills found on ClawHub, delivering AMOS stealer.
930
+ * Ref: 1,184+ malicious skills found on ClawHub, delivering AMOS stealer.
821
931
  * @private
822
932
  */
823
933
  _scanForClawHavoc(tool, findings) {
@@ -0,0 +1,526 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield — Sybil Attack Detector
5
+ *
6
+ * Detects coordinated fake agents acting in concert — Sybil attacks where
7
+ * multiple agents collude to manipulate outcomes, bypass voting/consensus
8
+ * mechanisms, or overwhelm defenses.
9
+ *
10
+ * Detection signals: behavioral similarity, temporal correlation, content
11
+ * similarity (Jaccard), creation burst patterns, and voting collusion.
12
+ *
13
+ * All processing runs locally — no data ever leaves your environment.
14
+ *
15
+ * @module sybil-detector
16
+ */
17
+
18
+ const crypto = require('crypto');
19
+
20
+ const LOG_PREFIX = '[Agent Shield]';
21
+
22
+ // =========================================================================
23
+ // Utility helpers
24
+ // =========================================================================
25
+
26
+ /**
27
+ * Compute Jaccard similarity between two sets.
28
+ * @param {Set<string>} a
29
+ * @param {Set<string>} b
30
+ * @returns {number} Similarity in [0, 1].
31
+ */
32
+ function jaccardSimilarity(a, b) {
33
+ if (a.size === 0 && b.size === 0) return 1;
34
+ let intersection = 0;
35
+ for (const item of a) {
36
+ if (b.has(item)) intersection++;
37
+ }
38
+ const union = a.size + b.size - intersection;
39
+ return union === 0 ? 1 : intersection / union;
40
+ }
41
+
42
+ /**
43
+ * Tokenize a string into a set of lowercase words.
44
+ * @param {string} text
45
+ * @returns {Set<string>}
46
+ */
47
+ function tokenize(text) {
48
+ if (!text || typeof text !== 'string') return new Set();
49
+ return new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
50
+ }
51
+
52
+ // =========================================================================
53
+ // SybilDetector
54
+ // =========================================================================
55
+
56
+ /**
57
+ * Detects Sybil clusters among registered agents by analyzing behavioral
58
+ * similarity, temporal correlation, content overlap, creation bursts, and
59
+ * voting collusion.
60
+ */
61
+ class SybilDetector {
62
+ /**
63
+ * @param {object} [options]
64
+ * @param {number} [options.similarityThreshold=0.7] - Minimum similarity score to consider agents as part of a cluster.
65
+ * @param {number} [options.timeWindowMs=60000] - Time window in ms for temporal correlation analysis.
66
+ * @param {number} [options.minClusterSize=3] - Minimum number of agents to form a Sybil cluster.
67
+ */
68
+ constructor(options = {}) {
69
+ this.similarityThreshold = options.similarityThreshold != null ? options.similarityThreshold : 0.7;
70
+ this.timeWindowMs = options.timeWindowMs != null ? options.timeWindowMs : 60000;
71
+ this.minClusterSize = options.minClusterSize != null ? options.minClusterSize : 3;
72
+
73
+ /** @type {Map<string, object>} */
74
+ this._agents = new Map();
75
+
76
+ /** @type {Map<string, Array<object>>} */
77
+ this._actions = new Map();
78
+
79
+ console.log('%s SybilDetector initialized (threshold: %s, window: %dms, minCluster: %d)', LOG_PREFIX, this.similarityThreshold, this.timeWindowMs, this.minClusterSize);
80
+ }
81
+
82
+ /**
83
+ * Register an agent with metadata.
84
+ * @param {string} agentId - Unique agent identifier.
85
+ * @param {object} metadata - Agent metadata (name, capabilities, createdAt, etc.).
86
+ */
87
+ registerAgent(agentId, metadata) {
88
+ if (!agentId || typeof agentId !== 'string') return;
89
+ const record = {
90
+ id: agentId,
91
+ name: metadata && metadata.name || agentId,
92
+ capabilities: metadata && metadata.capabilities || [],
93
+ createdAt: metadata && metadata.createdAt || Date.now(),
94
+ registeredAt: Date.now(),
95
+ metadata: metadata || {}
96
+ };
97
+ this._agents.set(agentId, record);
98
+ if (!this._actions.has(agentId)) {
99
+ this._actions.set(agentId, []);
100
+ }
101
+ console.log('%s Registered agent: %s', LOG_PREFIX, agentId);
102
+ }
103
+
104
+ /**
105
+ * Record an action performed by an agent.
106
+ * @param {string} agentId - Agent identifier.
107
+ * @param {object} action - Action details { type, target, timestamp, content }.
108
+ */
109
+ recordAction(agentId, action) {
110
+ if (!agentId || typeof agentId !== 'string') return;
111
+ if (!action || typeof action !== 'object') return;
112
+ if (!this._actions.has(agentId)) {
113
+ this._actions.set(agentId, []);
114
+ }
115
+ const record = {
116
+ type: action.type || 'unknown',
117
+ target: action.target || null,
118
+ timestamp: action.timestamp || Date.now(),
119
+ content: action.content || ''
120
+ };
121
+ this._actions.get(agentId).push(record);
122
+ }
123
+
124
+ /**
125
+ * Analyze all registered agents for Sybil clusters.
126
+ * @returns {{ clusters: Array<{ agents: string[], similarity: number, evidence: string[] }>, sybilRisk: 'none'|'low'|'medium'|'high' }}
127
+ */
128
+ detectClusters() {
129
+ const agentIds = Array.from(this._agents.keys());
130
+ if (agentIds.length < 2) {
131
+ return { clusters: [], sybilRisk: 'none' };
132
+ }
133
+
134
+ // Compute pairwise similarity scores
135
+ const pairScores = new Map(); // "id1|id2" -> { score, evidence }
136
+
137
+ for (let i = 0; i < agentIds.length; i++) {
138
+ for (let j = i + 1; j < agentIds.length; j++) {
139
+ const a = agentIds[i];
140
+ const b = agentIds[j];
141
+ const result = this._computePairSimilarity(a, b);
142
+ const key = a + '|' + b;
143
+ pairScores.set(key, result);
144
+ }
145
+ }
146
+
147
+ // Build clusters using greedy union-find approach
148
+ const parent = new Map();
149
+ for (const id of agentIds) parent.set(id, id);
150
+
151
+ const find = (x) => {
152
+ while (parent.get(x) !== x) {
153
+ parent.set(x, parent.get(parent.get(x)));
154
+ x = parent.get(x);
155
+ }
156
+ return x;
157
+ };
158
+
159
+ const union = (x, y) => {
160
+ const rx = find(x);
161
+ const ry = find(y);
162
+ if (rx !== ry) parent.set(rx, ry);
163
+ };
164
+
165
+ // Merge agents that exceed threshold
166
+ for (const [key, result] of pairScores) {
167
+ if (result.score >= this.similarityThreshold) {
168
+ const [a, b] = key.split('|');
169
+ union(a, b);
170
+ }
171
+ }
172
+
173
+ // Group by root
174
+ const groups = new Map();
175
+ for (const id of agentIds) {
176
+ const root = find(id);
177
+ if (!groups.has(root)) groups.set(root, []);
178
+ groups.get(root).push(id);
179
+ }
180
+
181
+ // Filter to clusters meeting minimum size
182
+ const clusters = [];
183
+ for (const [, members] of groups) {
184
+ if (members.length >= this.minClusterSize) {
185
+ // Compute average similarity and collect evidence
186
+ let totalSim = 0;
187
+ let pairCount = 0;
188
+ const evidenceSet = new Set();
189
+ for (let i = 0; i < members.length; i++) {
190
+ for (let j = i + 1; j < members.length; j++) {
191
+ const key = members[i] + '|' + members[j];
192
+ const altKey = members[j] + '|' + members[i];
193
+ const result = pairScores.get(key) || pairScores.get(altKey);
194
+ if (result) {
195
+ totalSim += result.score;
196
+ pairCount++;
197
+ for (const ev of result.evidence) evidenceSet.add(ev);
198
+ }
199
+ }
200
+ }
201
+ const avgSim = pairCount > 0 ? totalSim / pairCount : 0;
202
+ clusters.push({
203
+ agents: members,
204
+ similarity: Math.round(avgSim * 1000) / 1000,
205
+ evidence: Array.from(evidenceSet)
206
+ });
207
+ }
208
+ }
209
+
210
+ // Determine overall risk
211
+ let sybilRisk = 'none';
212
+ if (clusters.length > 0) {
213
+ const maxSim = Math.max(...clusters.map(c => c.similarity));
214
+ const maxSize = Math.max(...clusters.map(c => c.agents.length));
215
+ if (maxSim >= 0.9 || maxSize >= 5) {
216
+ sybilRisk = 'high';
217
+ } else if (maxSim >= 0.7) {
218
+ sybilRisk = 'medium';
219
+ } else {
220
+ sybilRisk = 'low';
221
+ }
222
+ }
223
+
224
+ console.log('%s Sybil detection complete: %d cluster(s), risk=%s', LOG_PREFIX, clusters.length, sybilRisk);
225
+
226
+ return { clusters, sybilRisk };
227
+ }
228
+
229
+ // -----------------------------------------------------------------------
230
+ // Internal similarity computation
231
+ // -----------------------------------------------------------------------
232
+
233
+ /**
234
+ * Compute composite similarity between two agents.
235
+ * @private
236
+ * @param {string} agentA
237
+ * @param {string} agentB
238
+ * @returns {{ score: number, evidence: string[] }}
239
+ */
240
+ _computePairSimilarity(agentA, agentB) {
241
+ const evidence = [];
242
+ const scores = [];
243
+
244
+ // 1. Behavioral similarity — same action types on same targets in similar order
245
+ const behaviorSim = this._behavioralSimilarity(agentA, agentB);
246
+ if (behaviorSim > 0.5) {
247
+ evidence.push(`behavioral_similarity: ${behaviorSim.toFixed(3)}`);
248
+ }
249
+ scores.push(behaviorSim);
250
+
251
+ // 2. Temporal correlation — actions in tight time windows
252
+ const temporalSim = this._temporalCorrelation(agentA, agentB);
253
+ if (temporalSim > 0.5) {
254
+ evidence.push(`temporal_correlation: ${temporalSim.toFixed(3)}`);
255
+ }
256
+ scores.push(temporalSim);
257
+
258
+ // 3. Content similarity — Jaccard on action content
259
+ const contentSim = this._contentSimilarity(agentA, agentB);
260
+ if (contentSim > 0.5) {
261
+ evidence.push(`content_similarity: ${contentSim.toFixed(3)}`);
262
+ }
263
+ scores.push(contentSim);
264
+
265
+ // 4. Creation pattern — burst detection
266
+ const creationSim = this._creationBurstScore(agentA, agentB);
267
+ if (creationSim > 0.5) {
268
+ evidence.push(`creation_burst: ${creationSim.toFixed(3)}`);
269
+ }
270
+ scores.push(creationSim);
271
+
272
+ // 5. Voting collusion — lockstep approval
273
+ const voteSim = this._votingCollusion(agentA, agentB);
274
+ if (voteSim > 0.5) {
275
+ evidence.push(`voting_collusion: ${voteSim.toFixed(3)}`);
276
+ }
277
+ scores.push(voteSim);
278
+
279
+ // Composite: weighted average
280
+ const total = scores.reduce((a, b) => a + b, 0);
281
+ const composite = scores.length > 0 ? total / scores.length : 0;
282
+
283
+ return { score: Math.round(composite * 1000) / 1000, evidence };
284
+ }
285
+
286
+ /**
287
+ * Behavioral similarity: compare action type+target sequences.
288
+ * @private
289
+ */
290
+ _behavioralSimilarity(agentA, agentB) {
291
+ const actionsA = this._actions.get(agentA) || [];
292
+ const actionsB = this._actions.get(agentB) || [];
293
+ if (actionsA.length === 0 || actionsB.length === 0) return 0;
294
+
295
+ const seqA = new Set(actionsA.map(a => `${a.type}:${a.target}`));
296
+ const seqB = new Set(actionsB.map(a => `${a.type}:${a.target}`));
297
+ return jaccardSimilarity(seqA, seqB);
298
+ }
299
+
300
+ /**
301
+ * Temporal correlation: fraction of actions that have a matching
302
+ * action from the other agent within the time window.
303
+ * @private
304
+ */
305
+ _temporalCorrelation(agentA, agentB) {
306
+ const actionsA = this._actions.get(agentA) || [];
307
+ const actionsB = this._actions.get(agentB) || [];
308
+ if (actionsA.length === 0 || actionsB.length === 0) return 0;
309
+
310
+ let matched = 0;
311
+ for (const aAct of actionsA) {
312
+ for (const bAct of actionsB) {
313
+ if (aAct.type === bAct.type &&
314
+ Math.abs(aAct.timestamp - bAct.timestamp) <= this.timeWindowMs) {
315
+ matched++;
316
+ break;
317
+ }
318
+ }
319
+ }
320
+ return matched / actionsA.length;
321
+ }
322
+
323
+ /**
324
+ * Content similarity: Jaccard similarity of tokenized content across all actions.
325
+ * @private
326
+ */
327
+ _contentSimilarity(agentA, agentB) {
328
+ const actionsA = this._actions.get(agentA) || [];
329
+ const actionsB = this._actions.get(agentB) || [];
330
+ if (actionsA.length === 0 || actionsB.length === 0) return 0;
331
+
332
+ const tokensA = new Set();
333
+ const tokensB = new Set();
334
+ for (const a of actionsA) {
335
+ for (const t of tokenize(a.content)) tokensA.add(t);
336
+ }
337
+ for (const b of actionsB) {
338
+ for (const t of tokenize(b.content)) tokensB.add(t);
339
+ }
340
+ return jaccardSimilarity(tokensA, tokensB);
341
+ }
342
+
343
+ /**
344
+ * Creation burst score: returns 1.0 if agents were created within the
345
+ * time window, 0.0 otherwise.
346
+ * @private
347
+ */
348
+ _creationBurstScore(agentA, agentB) {
349
+ const metaA = this._agents.get(agentA);
350
+ const metaB = this._agents.get(agentB);
351
+ if (!metaA || !metaB) return 0;
352
+ const diff = Math.abs(metaA.createdAt - metaB.createdAt);
353
+ if (diff <= this.timeWindowMs) {
354
+ return 1.0;
355
+ }
356
+ // Gradual decay up to 5x window
357
+ const maxWindow = this.timeWindowMs * 5;
358
+ if (diff <= maxWindow) {
359
+ return 1.0 - (diff - this.timeWindowMs) / (maxWindow - this.timeWindowMs);
360
+ }
361
+ return 0;
362
+ }
363
+
364
+ /**
365
+ * Voting collusion: fraction of vote/approve actions that target the
366
+ * same proposals.
367
+ * @private
368
+ */
369
+ _votingCollusion(agentA, agentB) {
370
+ const actionsA = this._actions.get(agentA) || [];
371
+ const actionsB = this._actions.get(agentB) || [];
372
+
373
+ const votesA = new Set(
374
+ actionsA.filter(a => a.type === 'vote' || a.type === 'approve')
375
+ .map(a => a.target)
376
+ );
377
+ const votesB = new Set(
378
+ actionsB.filter(a => a.type === 'vote' || a.type === 'approve')
379
+ .map(a => a.target)
380
+ );
381
+
382
+ if (votesA.size === 0 && votesB.size === 0) return 0;
383
+ return jaccardSimilarity(votesA, votesB);
384
+ }
385
+ }
386
+
387
+ // =========================================================================
388
+ // AgentIdentityVerifier
389
+ // =========================================================================
390
+
391
+ /**
392
+ * Verifies agent uniqueness through challenge-response and shared secret detection.
393
+ */
394
+ class AgentIdentityVerifier {
395
+ constructor() {
396
+ /** @type {Map<string, { nonce: string, issuedAt: number }>} */
397
+ this._challenges = new Map();
398
+
399
+ /** @type {Map<string, string>} */
400
+ this._agentKeys = new Map();
401
+
402
+ /** @type {number} Challenge expiration in ms (default 30s). */
403
+ this.challengeExpiryMs = 30000;
404
+
405
+ console.log('%s AgentIdentityVerifier initialized', LOG_PREFIX);
406
+ }
407
+
408
+ /**
409
+ * Generate a unique challenge (nonce-based).
410
+ * @returns {{ challengeId: string, nonce: string, issuedAt: number }}
411
+ */
412
+ generateChallenge() {
413
+ const nonce = crypto.randomBytes(32).toString('hex');
414
+ const challengeId = crypto.randomBytes(16).toString('hex');
415
+ const issuedAt = Date.now();
416
+ this._challenges.set(challengeId, { nonce, issuedAt });
417
+ return { challengeId, nonce, issuedAt };
418
+ }
419
+
420
+ /**
421
+ * Verify a challenge-response from an agent.
422
+ *
423
+ * The expected response is HMAC-SHA256(nonce, agentKey). If the agent has
424
+ * previously registered a key, it is used for verification; otherwise
425
+ * the response is accepted and the key is stored.
426
+ *
427
+ * @param {string} agentId - Agent identifier.
428
+ * @param {string} challengeId - The challenge ID returned by generateChallenge().
429
+ * @param {string} response - The agent's HMAC response.
430
+ * @param {string} [agentKey] - The agent's signing key (required on first verification).
431
+ * @returns {{ valid: boolean, reason: string }}
432
+ */
433
+ verifyResponse(agentId, challengeId, response, agentKey) {
434
+ if (!agentId || !challengeId || !response) {
435
+ return { valid: false, reason: 'missing_parameters' };
436
+ }
437
+
438
+ const challenge = this._challenges.get(challengeId);
439
+ if (!challenge) {
440
+ return { valid: false, reason: 'unknown_challenge' };
441
+ }
442
+
443
+ // Check expiry
444
+ if (Date.now() - challenge.issuedAt > this.challengeExpiryMs) {
445
+ this._challenges.delete(challengeId);
446
+ return { valid: false, reason: 'challenge_expired' };
447
+ }
448
+
449
+ // Determine the key
450
+ const key = agentKey || this._agentKeys.get(agentId);
451
+ if (!key) {
452
+ return { valid: false, reason: 'no_key_registered' };
453
+ }
454
+
455
+ // Compute expected HMAC
456
+ const expected = crypto.createHmac('sha256', key)
457
+ .update(challenge.nonce)
458
+ .digest('hex');
459
+
460
+ const valid = crypto.timingSafeEqual(
461
+ Buffer.from(expected, 'hex'),
462
+ Buffer.from(response, 'hex')
463
+ );
464
+
465
+ if (valid) {
466
+ // Store key for future verifications
467
+ this._agentKeys.set(agentId, key);
468
+ this._challenges.delete(challengeId);
469
+ }
470
+
471
+ return {
472
+ valid,
473
+ reason: valid ? 'verified' : 'invalid_response'
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Detect if multiple agents share the same signing key by comparing
479
+ * HMAC outputs on a fixed probe message.
480
+ *
481
+ * @param {Array<{ agentId: string, key: string }>} agents - List of agents with their keys.
482
+ * @returns {{ sharedKeyGroups: Array<{ agents: string[], keyFingerprint: string }>, hasSharedKeys: boolean }}
483
+ */
484
+ detectSharedSecrets(agents) {
485
+ if (!Array.isArray(agents) || agents.length === 0) {
486
+ return { sharedKeyGroups: [], hasSharedKeys: false };
487
+ }
488
+
489
+ const probeMessage = 'agent-shield-sybil-probe-v1';
490
+ const fingerprintMap = new Map(); // fingerprint -> [agentIds]
491
+
492
+ for (const agent of agents) {
493
+ if (!agent || !agent.agentId || !agent.key) continue;
494
+ const fingerprint = crypto.createHmac('sha256', agent.key)
495
+ .update(probeMessage)
496
+ .digest('hex');
497
+ if (!fingerprintMap.has(fingerprint)) {
498
+ fingerprintMap.set(fingerprint, []);
499
+ }
500
+ fingerprintMap.get(fingerprint).push(agent.agentId);
501
+ }
502
+
503
+ const sharedKeyGroups = [];
504
+ for (const [fingerprint, agentIds] of fingerprintMap) {
505
+ if (agentIds.length > 1) {
506
+ sharedKeyGroups.push({
507
+ agents: agentIds,
508
+ keyFingerprint: fingerprint.slice(0, 16) + '...'
509
+ });
510
+ }
511
+ }
512
+
513
+ const hasSharedKeys = sharedKeyGroups.length > 0;
514
+ if (hasSharedKeys) {
515
+ console.log('%s Shared secret detected among %d group(s)', LOG_PREFIX, sharedKeyGroups.length);
516
+ }
517
+
518
+ return { sharedKeyGroups, hasSharedKeys };
519
+ }
520
+ }
521
+
522
+ // =========================================================================
523
+ // Exports
524
+ // =========================================================================
525
+
526
+ module.exports = { SybilDetector, AgentIdentityVerifier, jaccardSimilarity, tokenize };