agentshield-sdk 13.5.0 → 14.2.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 +192 -0
- package/README.md +12 -1
- package/package.json +2 -2
- package/src/detector-core.js +329 -51
- package/src/enterprise.js +127 -12
- package/src/integrations-frameworks.js +463 -0
- package/src/integrations.js +207 -0
- package/src/main.js +11 -14
- package/src/mcp-guard.js +52 -1
- package/src/middleware.js +107 -2
- package/src/native-scanner.js +104 -0
- package/src/plugin-system.js +422 -6
- package/src/supply-chain-scanner.js +164 -0
- package/src/persistent-learning.js +0 -161
- package/src/threat-intel-federation.js +0 -343
package/src/plugin-system.js
CHANGED
|
@@ -7,10 +7,17 @@
|
|
|
7
7
|
* Plugins are simple objects with a detect() method that returns an array
|
|
8
8
|
* of threat findings. All detection runs locally — no data ever leaves
|
|
9
9
|
* your environment.
|
|
10
|
+
*
|
|
11
|
+
* This module now includes IsolatedPluginSandbox which uses Node's built-in
|
|
12
|
+
* `vm` module to run untrusted plugin source code in a sanitized context
|
|
13
|
+
* with no access to process, fs, net, http, or child_process. It also
|
|
14
|
+
* enforces a preemptive timeout via vm.Script's `timeout` option.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
const path = require('path');
|
|
13
18
|
const fs = require('fs');
|
|
19
|
+
const vm = require('vm');
|
|
20
|
+
const crypto = require('crypto');
|
|
14
21
|
|
|
15
22
|
// =========================================================================
|
|
16
23
|
// HELPERS
|
|
@@ -27,13 +34,29 @@ const now = () => {
|
|
|
27
34
|
return Date.now();
|
|
28
35
|
};
|
|
29
36
|
|
|
37
|
+
// Try to raise the old-space memory cap a bit so tight infinite-loop plugins
|
|
38
|
+
// still get killed by the vm timeout (which is what provides the real bound).
|
|
39
|
+
try {
|
|
40
|
+
const v8 = require('v8');
|
|
41
|
+
if (typeof v8.setFlagsFromString === 'function') {
|
|
42
|
+
// Best-effort only; ignored silently if not permitted.
|
|
43
|
+
v8.setFlagsFromString('--max-old-space-size=4096');
|
|
44
|
+
}
|
|
45
|
+
} catch (_err) {
|
|
46
|
+
// v8 module missing or setFlagsFromString unavailable — silently skip.
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
// =========================================================================
|
|
31
|
-
// PLUGIN SANDBOX
|
|
50
|
+
// PLUGIN SANDBOX (legacy — kept for backward compatibility)
|
|
32
51
|
// =========================================================================
|
|
33
52
|
|
|
34
53
|
/**
|
|
35
54
|
* Runs plugins with timeout protection and error isolation.
|
|
36
55
|
* Prevents a misbehaving plugin from crashing the host agent.
|
|
56
|
+
*
|
|
57
|
+
* NOTE: This sandbox only times execution and catches errors. It does not
|
|
58
|
+
* isolate plugin code from the host process. Use IsolatedPluginSandbox for
|
|
59
|
+
* untrusted plugin source.
|
|
37
60
|
*/
|
|
38
61
|
class PluginSandbox {
|
|
39
62
|
/**
|
|
@@ -57,10 +80,6 @@ class PluginSandbox {
|
|
|
57
80
|
let error = null;
|
|
58
81
|
|
|
59
82
|
try {
|
|
60
|
-
// Run detection synchronously with a time check after completion.
|
|
61
|
-
// True preemptive timeout would require worker_threads, but for a
|
|
62
|
-
// lightweight zero-dependency SDK we keep it simple: run, measure,
|
|
63
|
-
// and flag if it exceeded the budget.
|
|
64
83
|
const output = plugin.detect(text, options);
|
|
65
84
|
const durationMs = now() - start;
|
|
66
85
|
|
|
@@ -82,6 +101,393 @@ class PluginSandbox {
|
|
|
82
101
|
}
|
|
83
102
|
}
|
|
84
103
|
|
|
104
|
+
// =========================================================================
|
|
105
|
+
// ISOLATED PLUGIN SANDBOX (vm-based, real isolation)
|
|
106
|
+
// =========================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whitelist of modules considered safe to expose to plugins via the
|
|
110
|
+
* restricted `require`. Anything else is blocked.
|
|
111
|
+
* @type {Set<string>}
|
|
112
|
+
*/
|
|
113
|
+
const DEFAULT_SAFE_MODULES = new Set([
|
|
114
|
+
'util'
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build a sanitized console that writes to a string buffer instead of stdout.
|
|
119
|
+
* @param {{buffer: string}} sink - Object whose `buffer` field gets appended to
|
|
120
|
+
* @returns {object} Console-like object
|
|
121
|
+
*/
|
|
122
|
+
function makeSafeConsole(sink) {
|
|
123
|
+
const write = (level) => (...args) => {
|
|
124
|
+
const line = args.map((a) => {
|
|
125
|
+
if (typeof a === 'string') return a;
|
|
126
|
+
try { return JSON.stringify(a); } catch (_) { return String(a); }
|
|
127
|
+
}).join(' ');
|
|
128
|
+
sink.buffer += `[${level}] ${line}\n`;
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
log: write('log'),
|
|
132
|
+
info: write('info'),
|
|
133
|
+
warn: write('warn'),
|
|
134
|
+
error: write('error'),
|
|
135
|
+
debug: write('debug')
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Real plugin sandbox using Node's `vm` module. Plugin source runs in a
|
|
141
|
+
* brand-new context with no access to the host globals or the require()
|
|
142
|
+
* function of the host process.
|
|
143
|
+
*/
|
|
144
|
+
class IsolatedPluginSandbox {
|
|
145
|
+
/**
|
|
146
|
+
* @param {object} [options]
|
|
147
|
+
* @param {number} [options.timeoutMs=100] - Hard execution budget in ms (preemptive)
|
|
148
|
+
* @param {string[]} [options.allowRequire=[]] - Whitelist of module IDs the plugin may require()
|
|
149
|
+
*/
|
|
150
|
+
constructor(options = {}) {
|
|
151
|
+
this.timeoutMs = options.timeoutMs || 100;
|
|
152
|
+
// Combine the default whitelist with any caller-supplied entries.
|
|
153
|
+
this.allowRequire = new Set([
|
|
154
|
+
...DEFAULT_SAFE_MODULES,
|
|
155
|
+
...(Array.isArray(options.allowRequire) ? options.allowRequire : [])
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a safe restricted `require` function for the plugin context.
|
|
161
|
+
* @returns {function}
|
|
162
|
+
* @private
|
|
163
|
+
*/
|
|
164
|
+
_makeSafeRequire() {
|
|
165
|
+
const allowed = this.allowRequire;
|
|
166
|
+
return function safeRequire(id) {
|
|
167
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
168
|
+
throw new Error('require(id) expects a non-empty string');
|
|
169
|
+
}
|
|
170
|
+
if (!allowed.has(id)) {
|
|
171
|
+
throw new Error(`[Agent Shield] require("${id}") blocked by sandbox. Allowed: ${[...allowed].join(', ') || '(none)'}`);
|
|
172
|
+
}
|
|
173
|
+
// Only absolute, unambiguous module names reach here.
|
|
174
|
+
// eslint-disable-next-line global-require
|
|
175
|
+
return require(id);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run untrusted plugin source. The source is expected to export a
|
|
181
|
+
* detect(text, options) function by assigning to `module.exports`
|
|
182
|
+
* or by assigning a top-level `detect` binding.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} source - Plugin source code as a string
|
|
185
|
+
* @param {string} text - Text to scan
|
|
186
|
+
* @param {object} [options] - Options passed to detect()
|
|
187
|
+
* @returns {{results: Array, error: string|null, durationMs: number, consoleOutput: string}}
|
|
188
|
+
*/
|
|
189
|
+
runSource(source, text, options = {}) {
|
|
190
|
+
if (typeof source !== 'string' || source.length === 0) {
|
|
191
|
+
return { results: [], error: 'Plugin source must be a non-empty string', durationMs: 0, consoleOutput: '' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sink = { buffer: '' };
|
|
195
|
+
const safeConsole = makeSafeConsole(sink);
|
|
196
|
+
const moduleShim = { exports: {} };
|
|
197
|
+
|
|
198
|
+
// Build a fresh object so the plugin cannot mutate the host's globals
|
|
199
|
+
// by poisoning the prototype chain from inside the context.
|
|
200
|
+
const sandboxGlobals = Object.create(null);
|
|
201
|
+
|
|
202
|
+
// Use vm.runInNewContext to realm-isolate the built-ins. Each call gets
|
|
203
|
+
// its own Object/Array/String/etc so that prototype pollution inside the
|
|
204
|
+
// plugin does NOT affect the host process.
|
|
205
|
+
const realm = vm.runInNewContext(`({
|
|
206
|
+
String, Number, Boolean, Array, Object, RegExp, Math, Date, JSON,
|
|
207
|
+
Map, Set, WeakMap, WeakSet, Error, TypeError, RangeError, SyntaxError,
|
|
208
|
+
Symbol, Promise
|
|
209
|
+
})`, Object.create(null), { timeout: this.timeoutMs });
|
|
210
|
+
|
|
211
|
+
sandboxGlobals.String = realm.String;
|
|
212
|
+
sandboxGlobals.Number = realm.Number;
|
|
213
|
+
sandboxGlobals.Boolean = realm.Boolean;
|
|
214
|
+
sandboxGlobals.Array = realm.Array;
|
|
215
|
+
sandboxGlobals.Object = realm.Object;
|
|
216
|
+
sandboxGlobals.RegExp = realm.RegExp;
|
|
217
|
+
sandboxGlobals.Math = realm.Math;
|
|
218
|
+
sandboxGlobals.Date = realm.Date;
|
|
219
|
+
sandboxGlobals.JSON = realm.JSON;
|
|
220
|
+
sandboxGlobals.Map = realm.Map;
|
|
221
|
+
sandboxGlobals.Set = realm.Set;
|
|
222
|
+
sandboxGlobals.WeakMap = realm.WeakMap;
|
|
223
|
+
sandboxGlobals.WeakSet = realm.WeakSet;
|
|
224
|
+
sandboxGlobals.Error = realm.Error;
|
|
225
|
+
sandboxGlobals.TypeError = realm.TypeError;
|
|
226
|
+
sandboxGlobals.RangeError = realm.RangeError;
|
|
227
|
+
sandboxGlobals.SyntaxError = realm.SyntaxError;
|
|
228
|
+
sandboxGlobals.Symbol = realm.Symbol;
|
|
229
|
+
sandboxGlobals.Promise = realm.Promise;
|
|
230
|
+
|
|
231
|
+
// Restricted surface for the plugin.
|
|
232
|
+
sandboxGlobals.console = safeConsole;
|
|
233
|
+
sandboxGlobals.require = this._makeSafeRequire();
|
|
234
|
+
sandboxGlobals.module = moduleShim;
|
|
235
|
+
sandboxGlobals.exports = moduleShim.exports;
|
|
236
|
+
|
|
237
|
+
// Plugin inputs are made available as globals for convenience.
|
|
238
|
+
sandboxGlobals.__text = text;
|
|
239
|
+
sandboxGlobals.__options = options;
|
|
240
|
+
|
|
241
|
+
// Create a sanitized `globalThis` / `global` that points at the same
|
|
242
|
+
// frozen-ish object so the plugin sees a self-consistent environment
|
|
243
|
+
// without being able to reach the host.
|
|
244
|
+
sandboxGlobals.globalThis = sandboxGlobals;
|
|
245
|
+
sandboxGlobals.global = sandboxGlobals;
|
|
246
|
+
|
|
247
|
+
// Create the context. Do NOT use codeGeneration: {strings: true} — we
|
|
248
|
+
// explicitly forbid eval/new Function inside the plugin.
|
|
249
|
+
const context = vm.createContext(sandboxGlobals, {
|
|
250
|
+
name: 'agent-shield-plugin-sandbox',
|
|
251
|
+
codeGeneration: { strings: false, wasm: false }
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Wrap source so that if the plugin assigns `detect = ...` without
|
|
255
|
+
// module.exports, we still find it.
|
|
256
|
+
const wrapped = `
|
|
257
|
+
(function() {
|
|
258
|
+
'use strict';
|
|
259
|
+
${source}
|
|
260
|
+
;if (typeof module.exports === 'function') { return module.exports; }
|
|
261
|
+
if (module.exports && typeof module.exports.detect === 'function') { return module.exports.detect; }
|
|
262
|
+
if (typeof detect === 'function') { return detect; }
|
|
263
|
+
return null;
|
|
264
|
+
})()
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
const start = now();
|
|
268
|
+
let script;
|
|
269
|
+
try {
|
|
270
|
+
script = new vm.Script(wrapped, { filename: 'plugin.js' });
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return {
|
|
273
|
+
results: [],
|
|
274
|
+
error: `Plugin compile error: ${err.message || String(err)}`,
|
|
275
|
+
durationMs: now() - start,
|
|
276
|
+
consoleOutput: sink.buffer
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let detectFn;
|
|
281
|
+
try {
|
|
282
|
+
detectFn = script.runInContext(context, { timeout: this.timeoutMs });
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const durationMs = now() - start;
|
|
285
|
+
const msg = err && err.message ? err.message : String(err);
|
|
286
|
+
console.log(`[Agent Shield] Isolated plugin load failed: ${msg}`);
|
|
287
|
+
return { results: [], error: msg, durationMs, consoleOutput: sink.buffer };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (typeof detectFn !== 'function') {
|
|
291
|
+
return {
|
|
292
|
+
results: [],
|
|
293
|
+
error: 'Plugin did not export a detect() function',
|
|
294
|
+
durationMs: now() - start,
|
|
295
|
+
consoleOutput: sink.buffer
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Invoke detect() inside the same context with its own timeout so that
|
|
300
|
+
// a tight infinite loop here is still killed.
|
|
301
|
+
const callScript = new vm.Script(
|
|
302
|
+
'__detect(__text, __options)',
|
|
303
|
+
{ filename: 'plugin-invoke.js' }
|
|
304
|
+
);
|
|
305
|
+
context.__detect = detectFn;
|
|
306
|
+
|
|
307
|
+
let output;
|
|
308
|
+
try {
|
|
309
|
+
output = callScript.runInContext(context, { timeout: this.timeoutMs });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const durationMs = now() - start;
|
|
312
|
+
const msg = err && err.message ? err.message : String(err);
|
|
313
|
+
console.log(`[Agent Shield] Isolated plugin threw: ${msg}`);
|
|
314
|
+
return { results: [], error: msg, durationMs, consoleOutput: sink.buffer };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const durationMs = now() - start;
|
|
318
|
+
const results = Array.isArray(output) ? output : [];
|
|
319
|
+
return { results, error: null, durationMs, consoleOutput: sink.buffer };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// =========================================================================
|
|
324
|
+
// PLUGIN VERIFIER / SIGNING
|
|
325
|
+
// =========================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Compute an HMAC-SHA256 signature for a plugin source string.
|
|
329
|
+
* @param {string} source - Plugin source code
|
|
330
|
+
* @param {string} key - HMAC secret
|
|
331
|
+
* @returns {string} Hex-encoded signature
|
|
332
|
+
*/
|
|
333
|
+
function signPlugin(source, key) {
|
|
334
|
+
if (typeof source !== 'string') {
|
|
335
|
+
throw new TypeError('signPlugin: source must be a string');
|
|
336
|
+
}
|
|
337
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
338
|
+
throw new TypeError('signPlugin: key must be a non-empty string');
|
|
339
|
+
}
|
|
340
|
+
return crypto.createHmac('sha256', key).update(source, 'utf8').digest('hex');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Verify a plugin signature using HMAC-SHA256 with a constant-time compare.
|
|
345
|
+
* @param {string} source - Plugin source code
|
|
346
|
+
* @param {string} signature - Hex-encoded signature to check
|
|
347
|
+
* @param {string} key - HMAC secret
|
|
348
|
+
* @returns {boolean} true if the signature is valid
|
|
349
|
+
*/
|
|
350
|
+
function verifyPluginSignature(source, signature, key) {
|
|
351
|
+
if (typeof source !== 'string' || typeof signature !== 'string' || typeof key !== 'string') {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
let expected;
|
|
355
|
+
try {
|
|
356
|
+
expected = signPlugin(source, key);
|
|
357
|
+
} catch (_err) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
if (expected.length !== signature.length) return false;
|
|
361
|
+
try {
|
|
362
|
+
return crypto.timingSafeEqual(
|
|
363
|
+
Buffer.from(expected, 'hex'),
|
|
364
|
+
Buffer.from(signature, 'hex')
|
|
365
|
+
);
|
|
366
|
+
} catch (_err) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Verifier for plugin signatures. If configured with a signing key, any
|
|
373
|
+
* unsigned or invalidly signed plugin is rejected.
|
|
374
|
+
*/
|
|
375
|
+
class PluginVerifier {
|
|
376
|
+
/**
|
|
377
|
+
* @param {object} [options]
|
|
378
|
+
* @param {string} [options.signingKey] - HMAC secret. If omitted, all plugins pass.
|
|
379
|
+
* @param {boolean} [options.requireSignature=true] - Reject unsigned plugins when key is set
|
|
380
|
+
*/
|
|
381
|
+
constructor(options = {}) {
|
|
382
|
+
this.signingKey = typeof options.signingKey === 'string' ? options.signingKey : null;
|
|
383
|
+
this.requireSignature = options.requireSignature !== false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Returns true when this verifier was configured with a signing key.
|
|
388
|
+
* @returns {boolean}
|
|
389
|
+
*/
|
|
390
|
+
isConfigured() {
|
|
391
|
+
return typeof this.signingKey === 'string' && this.signingKey.length > 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Verify a manifest + source bundle.
|
|
396
|
+
* @param {string} source - Plugin source code
|
|
397
|
+
* @param {PluginManifest|object} manifest - Plugin manifest (may contain .signature)
|
|
398
|
+
* @returns {{valid: boolean, reason: string|null}}
|
|
399
|
+
*/
|
|
400
|
+
verify(source, manifest) {
|
|
401
|
+
if (!this.isConfigured()) {
|
|
402
|
+
return { valid: true, reason: null };
|
|
403
|
+
}
|
|
404
|
+
const signature = manifest && typeof manifest.signature === 'string' ? manifest.signature : '';
|
|
405
|
+
if (!signature) {
|
|
406
|
+
if (this.requireSignature) {
|
|
407
|
+
return { valid: false, reason: 'Plugin is unsigned but verifier requires a signature' };
|
|
408
|
+
}
|
|
409
|
+
return { valid: true, reason: null };
|
|
410
|
+
}
|
|
411
|
+
const ok = verifyPluginSignature(source, signature, this.signingKey);
|
|
412
|
+
return ok
|
|
413
|
+
? { valid: true, reason: null }
|
|
414
|
+
: { valid: false, reason: 'Plugin signature does not match' };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// =========================================================================
|
|
419
|
+
// PLUGIN MANIFEST
|
|
420
|
+
// =========================================================================
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Capability strings a plugin may declare. A plugin that only uses regex
|
|
424
|
+
* matching should declare ['read_text', 'regex_only'] so the host can
|
|
425
|
+
* decide what to trust it with.
|
|
426
|
+
*/
|
|
427
|
+
const VALID_CAPABILITIES = new Set([
|
|
428
|
+
'read_text',
|
|
429
|
+
'regex_only',
|
|
430
|
+
'read_options',
|
|
431
|
+
'network', // explicit — almost always should NOT be granted
|
|
432
|
+
'filesystem', // explicit — almost always should NOT be granted
|
|
433
|
+
'require_modules'
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Manifest schema helper for plugins. A manifest describes what the plugin
|
|
438
|
+
* is and what it needs access to.
|
|
439
|
+
*/
|
|
440
|
+
class PluginManifest {
|
|
441
|
+
/**
|
|
442
|
+
* Validate a manifest object.
|
|
443
|
+
* @param {object} manifest
|
|
444
|
+
* @returns {{valid: boolean, errors: string[]}}
|
|
445
|
+
*/
|
|
446
|
+
static validate(manifest) {
|
|
447
|
+
const errors = [];
|
|
448
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
449
|
+
return { valid: false, errors: ['Manifest must be a non-null object'] };
|
|
450
|
+
}
|
|
451
|
+
if (typeof manifest.name !== 'string' || manifest.name.length === 0) {
|
|
452
|
+
errors.push('Manifest "name" must be a non-empty string');
|
|
453
|
+
}
|
|
454
|
+
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
|
455
|
+
errors.push('Manifest "version" must be a non-empty string');
|
|
456
|
+
}
|
|
457
|
+
if (typeof manifest.author !== 'string' || manifest.author.length === 0) {
|
|
458
|
+
errors.push('Manifest "author" must be a non-empty string');
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(manifest.capabilities)) {
|
|
461
|
+
errors.push('Manifest "capabilities" must be an array of strings');
|
|
462
|
+
} else {
|
|
463
|
+
for (const cap of manifest.capabilities) {
|
|
464
|
+
if (typeof cap !== 'string' || !VALID_CAPABILITIES.has(cap)) {
|
|
465
|
+
errors.push(`Unknown capability: ${String(cap)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (manifest.signature !== undefined && typeof manifest.signature !== 'string') {
|
|
470
|
+
errors.push('Manifest "signature" must be a string if provided');
|
|
471
|
+
}
|
|
472
|
+
return { valid: errors.length === 0, errors };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Create a signed manifest by attaching an HMAC signature over `source`.
|
|
477
|
+
* @param {object} manifest - Base manifest (name, version, author, capabilities)
|
|
478
|
+
* @param {string} source - Plugin source code
|
|
479
|
+
* @param {string} key - HMAC secret
|
|
480
|
+
* @returns {object} A new manifest object with a `signature` field
|
|
481
|
+
*/
|
|
482
|
+
static sign(manifest, source, key) {
|
|
483
|
+
const { valid, errors } = PluginManifest.validate({ ...manifest, capabilities: manifest.capabilities || [] });
|
|
484
|
+
if (!valid) {
|
|
485
|
+
throw new Error(`[Agent Shield] Cannot sign invalid manifest: ${errors.join('; ')}`);
|
|
486
|
+
}
|
|
487
|
+
return { ...manifest, signature: signPlugin(source, key) };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
85
491
|
// =========================================================================
|
|
86
492
|
// PLUGIN TEMPLATE
|
|
87
493
|
// =========================================================================
|
|
@@ -346,4 +752,14 @@ class PluginManager {
|
|
|
346
752
|
// EXPORTS
|
|
347
753
|
// =========================================================================
|
|
348
754
|
|
|
349
|
-
module.exports = {
|
|
755
|
+
module.exports = {
|
|
756
|
+
PluginManager,
|
|
757
|
+
PluginTemplate,
|
|
758
|
+
PluginSandbox,
|
|
759
|
+
IsolatedPluginSandbox,
|
|
760
|
+
PluginVerifier,
|
|
761
|
+
PluginManifest,
|
|
762
|
+
signPlugin,
|
|
763
|
+
verifyPluginSignature,
|
|
764
|
+
VALID_CAPABILITIES
|
|
765
|
+
};
|
|
@@ -51,6 +51,18 @@ const KNOWN_BAD_SERVERS = Object.freeze({
|
|
|
51
51
|
'flowise-unpatched': {
|
|
52
52
|
reason: 'CVSS 10.0 RCE actively exploited (CVE-2025-59528)',
|
|
53
53
|
severity: 'critical'
|
|
54
|
+
},
|
|
55
|
+
'lmdeploy-unpatched': {
|
|
56
|
+
reason: 'SSRF via vision-language image loader, exploited within 12 hours (CVE-2026-33626)',
|
|
57
|
+
severity: 'high'
|
|
58
|
+
},
|
|
59
|
+
'nginx-ui-mcp': {
|
|
60
|
+
reason: 'Auth bypass on MCP-integrated HTTP endpoints, actively exploited (CVE-2026-33032)',
|
|
61
|
+
severity: 'critical'
|
|
62
|
+
},
|
|
63
|
+
'splunk-mcp-server': {
|
|
64
|
+
reason: 'Auth tokens logged in cleartext (CVE-2026-20205)',
|
|
65
|
+
severity: 'high'
|
|
54
66
|
}
|
|
55
67
|
});
|
|
56
68
|
|
|
@@ -207,6 +219,18 @@ const CVE_REGISTRY = Object.freeze({
|
|
|
207
219
|
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
220
|
fix: 'Upgrade Flowise to >=3.0.6. Restrict access to MCP node.'
|
|
209
221
|
},
|
|
222
|
+
{
|
|
223
|
+
cve: 'CVE-2026-40933',
|
|
224
|
+
severity: 'critical',
|
|
225
|
+
description: 'Flowise MCP Adapters authenticated RCE (CVSS 9.9). Unsafe stdio command serialization in MCP adapter enables OS command execution via npx -c.',
|
|
226
|
+
fix: 'Upgrade Flowise to >=3.1.0.'
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
cve: 'CVE-2026-41264',
|
|
230
|
+
severity: 'high',
|
|
231
|
+
description: 'Flowise CSV Agent prompt injection to RCE. LLM-generated Python script executed without sandbox. No auth required.',
|
|
232
|
+
fix: 'Upgrade Flowise to >=3.1.0.'
|
|
233
|
+
},
|
|
210
234
|
{
|
|
211
235
|
cve: 'CVE-2025-8943',
|
|
212
236
|
severity: 'critical',
|
|
@@ -243,6 +267,122 @@ const CVE_REGISTRY = Object.freeze({
|
|
|
243
267
|
description: 'OS command injection RCE in codebase-mcp.',
|
|
244
268
|
fix: 'Upgrade codebase-mcp. Never pass unsanitized inputs to shell.'
|
|
245
269
|
}
|
|
270
|
+
],
|
|
271
|
+
'lmdeploy': [
|
|
272
|
+
{
|
|
273
|
+
cve: 'CVE-2026-33626',
|
|
274
|
+
severity: 'high',
|
|
275
|
+
description: 'LMDeploy SSRF via vision-language image loader (CVSS 7.5). load_image() fetches arbitrary URLs, enabling port scanning, IMDS access (169.254.169.254), and internal service probing. Exploited within 12 hours of disclosure.',
|
|
276
|
+
fix: 'Upgrade LMDeploy to >=0.12.3. Block private IP ranges and cloud metadata endpoints in image URLs.'
|
|
277
|
+
}
|
|
278
|
+
],
|
|
279
|
+
'nginx-ui': [
|
|
280
|
+
{
|
|
281
|
+
cve: 'CVE-2026-33032',
|
|
282
|
+
severity: 'critical',
|
|
283
|
+
description: 'nginx-ui auth bypass on MCP-integrated HTTP endpoints (CVSS 9.8). Actively exploited.',
|
|
284
|
+
fix: 'Apply nginx-ui patch. Enable authentication on all MCP endpoints.'
|
|
285
|
+
}
|
|
286
|
+
],
|
|
287
|
+
'splunk-mcp-server': [
|
|
288
|
+
{
|
|
289
|
+
cve: 'CVE-2026-20205',
|
|
290
|
+
severity: 'high',
|
|
291
|
+
description: 'Splunk MCP Server logs session/auth tokens in cleartext in _internal index (CVSS 7.2).',
|
|
292
|
+
fix: 'Upgrade Splunk MCP Server to >=1.0.3. Audit _internal index for leaked tokens.'
|
|
293
|
+
}
|
|
294
|
+
],
|
|
295
|
+
'mcp-ruby-sdk': [
|
|
296
|
+
{
|
|
297
|
+
cve: 'CVE-2026-33946',
|
|
298
|
+
severity: 'medium',
|
|
299
|
+
description: 'MCP Ruby SDK session fixation in SSE stream allows hijacking of MCP protocol communications.',
|
|
300
|
+
fix: 'Upgrade MCP Ruby SDK to >=0.9.2.'
|
|
301
|
+
}
|
|
302
|
+
],
|
|
303
|
+
'magento2-dev-mcp': [
|
|
304
|
+
{
|
|
305
|
+
cve: 'CVE-2026-5603',
|
|
306
|
+
severity: 'high',
|
|
307
|
+
description: 'Command injection in @elgentos/magento2-dev-mcp via child_process.execAsync with unsanitized input.',
|
|
308
|
+
fix: 'Upgrade magento2-dev-mcp. Sanitize all inputs before passing to child_process.'
|
|
309
|
+
}
|
|
310
|
+
],
|
|
311
|
+
'semantic-kernel': [
|
|
312
|
+
{
|
|
313
|
+
cve: 'CVE-2026-25592',
|
|
314
|
+
severity: 'high',
|
|
315
|
+
description: 'Microsoft Semantic Kernel .NET SDK <1.71.0: prompt injection invokes arbitrary kernel functions leading to RCE on host process. Disclosed by MSRC May 7, 2026.',
|
|
316
|
+
fix: 'Upgrade Semantic Kernel .NET SDK to >=1.71.0. Validate function names in kernel.invoke() calls.'
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
cve: 'CVE-2026-26030',
|
|
320
|
+
severity: 'high',
|
|
321
|
+
description: 'Microsoft Semantic Kernel Python <1.39.4: same RCE primitive as CVE-2026-25592 but in Python SDK.',
|
|
322
|
+
fix: 'Upgrade Semantic Kernel Python to >=1.39.4.'
|
|
323
|
+
}
|
|
324
|
+
],
|
|
325
|
+
'fastgpt': [
|
|
326
|
+
{
|
|
327
|
+
cve: 'CVE-2026-42302',
|
|
328
|
+
severity: 'critical',
|
|
329
|
+
description: 'FastGPT agent-sandbox unauthenticated RCE (CVSS 9.8). code-server launched with --auth none on 0.0.0.0:8080. Published May 8, 2026.',
|
|
330
|
+
fix: 'Upgrade FastGPT to >=4.14.13. Never expose agent-sandbox endpoints without authentication.'
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
cve: 'CVE-2026-44284',
|
|
334
|
+
severity: 'high',
|
|
335
|
+
description: 'FastGPT SSRF in MCP tool URL handling. Crafted URLs reach internal services and cloud metadata endpoints.',
|
|
336
|
+
fix: 'Upgrade FastGPT. Apply Agent Shield mcp-guard SSRF firewall.'
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
cve: 'CVE-2026-42344',
|
|
340
|
+
severity: 'high',
|
|
341
|
+
description: 'FastGPT DNS rebinding bypasses isInternalAddress() check. Attacker hostname resolves to internal IP after initial validation.',
|
|
342
|
+
fix: 'Upgrade FastGPT. Pin DNS resolution per request and re-validate on connect.'
|
|
343
|
+
}
|
|
344
|
+
],
|
|
345
|
+
'cline-kanban': [
|
|
346
|
+
{
|
|
347
|
+
cve: 'CVE-2026-44211',
|
|
348
|
+
severity: 'high',
|
|
349
|
+
description: 'Cline Kanban Server Cross-Origin WebSocket Hijacking. Missing origin validation lets attackers inject prompts into running agent terminals.',
|
|
350
|
+
fix: 'Upgrade Cline. Validate Origin header on WebSocket upgrade requests.'
|
|
351
|
+
}
|
|
352
|
+
],
|
|
353
|
+
'azure-sre-agent': [
|
|
354
|
+
{
|
|
355
|
+
cve: 'CVE-2026-32173',
|
|
356
|
+
severity: 'high',
|
|
357
|
+
description: 'Azure SRE Agent exposed live command streams over unauthenticated WebSocket to any Entra ID user (CVSS 8.6).',
|
|
358
|
+
fix: 'Apply Azure patch. Restrict WebSocket access to authorized principals only.'
|
|
359
|
+
}
|
|
360
|
+
],
|
|
361
|
+
'crewai': [
|
|
362
|
+
{
|
|
363
|
+
cve: 'CVE-2026-44400',
|
|
364
|
+
severity: 'high',
|
|
365
|
+
description: 'CrewAI Code Interpreter default enabled allows prompt injection → RCE chain.',
|
|
366
|
+
fix: 'Upgrade CrewAI to latest. Disable Code Interpreter by default. Apply Agent Shield shieldCrewAI wrapper.'
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
cve: 'CVE-2026-44401',
|
|
370
|
+
severity: 'high',
|
|
371
|
+
description: 'CrewAI Code Interpreter SSRF via prompt-injected URLs.',
|
|
372
|
+
fix: 'Upgrade CrewAI. Block private IP ranges in tool URLs.'
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
cve: 'CVE-2026-44402',
|
|
376
|
+
severity: 'high',
|
|
377
|
+
description: 'CrewAI Code Interpreter file-read primitive via prompt injection.',
|
|
378
|
+
fix: 'Upgrade CrewAI. Restrict filesystem access in Code Interpreter sandbox.'
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
cve: 'CVE-2026-44403',
|
|
382
|
+
severity: 'high',
|
|
383
|
+
description: 'CrewAI agent task chain prompt injection leads to unauthorized tool invocation.',
|
|
384
|
+
fix: 'Upgrade CrewAI. Use Agent Shield intent-firewall to validate cross-task transitions.'
|
|
385
|
+
}
|
|
246
386
|
]
|
|
247
387
|
});
|
|
248
388
|
|
|
@@ -378,6 +518,7 @@ class SupplyChainScanner {
|
|
|
378
518
|
this._scanSchema(tool, findings);
|
|
379
519
|
this._scanForSSRF(tool, findings);
|
|
380
520
|
this._scanForClawHavoc(tool, findings);
|
|
521
|
+
this._scanConsentPhishing(tool, findings);
|
|
381
522
|
}
|
|
382
523
|
|
|
383
524
|
// Analyze escalation chains
|
|
@@ -687,6 +828,29 @@ class SupplyChainScanner {
|
|
|
687
828
|
}
|
|
688
829
|
}
|
|
689
830
|
|
|
831
|
+
/** @private - Consent phishing: detect tools whose description misrepresents capabilities (OWASP ASI09) */
|
|
832
|
+
_scanConsentPhishing(tool, findings) {
|
|
833
|
+
if (!tool || !tool.description || !tool.inputSchema) return;
|
|
834
|
+
const desc = String(tool.description).toLowerCase();
|
|
835
|
+
const schemaStr = JSON.stringify(tool.inputSchema).toLowerCase();
|
|
836
|
+
|
|
837
|
+
const READ_WORDS = ['read', 'get', 'fetch', 'list', 'view', 'show', 'display', 'search', 'query', 'lookup'];
|
|
838
|
+
const WRITE_INDICATORS = ['"url"', '"endpoint"', '"host"', '"target"', '"destination"', '"webhook"', '"callback"', 'http', '"command"', '"exec"', '"shell"', '"script"'];
|
|
839
|
+
const BENIGN_WORDS = ['save', 'update', 'create', 'write', 'delete', 'send', 'post', 'execute', 'run', 'upload'];
|
|
840
|
+
|
|
841
|
+
const descLooksReadOnly = READ_WORDS.some(w => desc.includes(w)) && !BENIGN_WORDS.some(w => desc.includes(w));
|
|
842
|
+
const schemaHasWriteCapability = WRITE_INDICATORS.some(w => schemaStr.includes(w));
|
|
843
|
+
|
|
844
|
+
if (descLooksReadOnly && schemaHasWriteCapability) {
|
|
845
|
+
findings.push({
|
|
846
|
+
type: 'consent_phishing',
|
|
847
|
+
severity: 'high',
|
|
848
|
+
message: `Tool "${tool.name || 'unknown'}" description implies read-only ("${desc.substring(0, 60)}...") but schema contains write/network parameters. Users may approve dangerous actions unknowingly.`,
|
|
849
|
+
recommendation: 'Tool descriptions must accurately reflect capabilities. If the tool sends data to URLs or executes commands, the description must say so explicitly.'
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
690
854
|
/** @private */
|
|
691
855
|
_scanSchema(tool, findings) {
|
|
692
856
|
if (tool && tool.inputSchema && tool.inputSchema.additionalProperties === true) {
|