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.
- package/CHANGELOG.md +79 -0
- package/README.md +260 -1187
- package/package.json +2 -2
- package/src/audit-immutable.js +59 -1
- package/src/audit.js +1 -1
- package/src/cross-turn.js +25 -1
- package/src/detector-core.js +198 -0
- package/src/document-scanner.js +20 -0
- package/src/main.js +22 -0
- package/src/memory-guard.js +60 -0
- package/src/render-differential.js +608 -0
- package/src/side-channel-monitor.js +560 -0
- package/src/supply-chain-scanner.js +112 -2
- package/src/sybil-detector.js +526 -0
|
@@ -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 —
|
|
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:
|
|
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 };
|