agentshield-sdk 13.5.0 → 14.0.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 +97 -0
- package/README.md +12 -1
- package/package.json +2 -2
- package/src/detector-core.js +135 -51
- package/src/enterprise.js +127 -12
- package/src/integrations-frameworks.js +373 -0
- package/src/integrations.js +207 -0
- package/src/main.js +10 -14
- package/src/middleware.js +107 -2
- package/src/native-scanner.js +104 -0
- package/src/plugin-system.js +422 -6
- 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
|
+
};
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Agent Shield — Federated Threat Intelligence Node (v12)
|
|
5
|
-
*
|
|
6
|
-
* A local threat intelligence node that can share and receive
|
|
7
|
-
* anonymized attack patterns with differential privacy.
|
|
8
|
-
*
|
|
9
|
-
* All processing runs locally — no data ever leaves your environment
|
|
10
|
-
* unless explicitly exported via exportPatterns().
|
|
11
|
-
*
|
|
12
|
-
* @module persistent-learning
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const crypto = require('crypto');
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Local threat intelligence node.
|
|
19
|
-
*/
|
|
20
|
-
class ThreatIntelNode {
|
|
21
|
-
/**
|
|
22
|
-
* @param {object} [options]
|
|
23
|
-
* @param {string} [options.nodeId] - Unique node identifier.
|
|
24
|
-
* @param {number} [options.noiseLevel=0.1] - Differential privacy noise level (0-1).
|
|
25
|
-
*/
|
|
26
|
-
constructor(options = {}) {
|
|
27
|
-
this.nodeId = options.nodeId || crypto.randomBytes(4).toString('hex');
|
|
28
|
-
this.noiseLevel = options.noiseLevel || 0.1;
|
|
29
|
-
|
|
30
|
-
/** @type {Map<string, { pattern: string, hash: string, count: number, confidence: number, firstSeen: number, lastSeen: number, category: string }>} */
|
|
31
|
-
this.patterns = new Map();
|
|
32
|
-
this.stats = { reported: 0, imported: 0, exported: 0 };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Report a locally observed attack pattern.
|
|
37
|
-
* @param {object} attack
|
|
38
|
-
* @param {string} attack.text - Attack text.
|
|
39
|
-
* @param {string} attack.category - Attack category.
|
|
40
|
-
* @param {string} [attack.severity] - Severity level.
|
|
41
|
-
* @returns {{ hash: string, isNew: boolean }}
|
|
42
|
-
*/
|
|
43
|
-
reportAttack(attack) {
|
|
44
|
-
const hash = crypto.createHash('sha256').update(attack.text || '').digest('hex').substring(0, 16);
|
|
45
|
-
const existing = this.patterns.get(hash);
|
|
46
|
-
|
|
47
|
-
if (existing) {
|
|
48
|
-
existing.count++;
|
|
49
|
-
existing.lastSeen = Date.now();
|
|
50
|
-
existing.confidence = Math.min(1, existing.confidence + 0.1);
|
|
51
|
-
this.stats.reported++;
|
|
52
|
-
return { hash, isNew: false };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
this.patterns.set(hash, {
|
|
56
|
-
pattern: (attack.text || '').substring(0, 200),
|
|
57
|
-
hash,
|
|
58
|
-
count: 1,
|
|
59
|
-
confidence: 0.5,
|
|
60
|
-
firstSeen: Date.now(),
|
|
61
|
-
lastSeen: Date.now(),
|
|
62
|
-
category: attack.category || 'unknown',
|
|
63
|
-
severity: attack.severity || 'medium'
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
this.stats.reported++;
|
|
67
|
-
return { hash, isNew: true };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Export anonymized patterns with differential privacy.
|
|
72
|
-
* @returns {Array<object>} Anonymized patterns.
|
|
73
|
-
*/
|
|
74
|
-
exportPatterns() {
|
|
75
|
-
const exported = [];
|
|
76
|
-
for (const [, p] of this.patterns) {
|
|
77
|
-
// Differential privacy: add noise to counts, truncate patterns
|
|
78
|
-
const noisyCount = Math.max(1, Math.round(p.count + (Math.random() - 0.5) * p.count * this.noiseLevel));
|
|
79
|
-
const noisyConfidence = Math.min(1, Math.max(0, p.confidence + (Math.random() - 0.5) * this.noiseLevel));
|
|
80
|
-
|
|
81
|
-
exported.push({
|
|
82
|
-
hash: p.hash,
|
|
83
|
-
category: p.category,
|
|
84
|
-
severity: p.severity,
|
|
85
|
-
count: noisyCount,
|
|
86
|
-
confidence: Math.round(noisyConfidence * 100) / 100,
|
|
87
|
-
// Do NOT export the actual pattern text — only hash + metadata
|
|
88
|
-
sourceNode: this.nodeId
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
this.stats.exported += exported.length;
|
|
92
|
-
return exported;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Import patterns from another node.
|
|
97
|
-
* @param {Array<object>} patterns - Patterns from exportPatterns().
|
|
98
|
-
* @returns {{ imported: number, merged: number, new: number }}
|
|
99
|
-
*/
|
|
100
|
-
importPatterns(patterns) {
|
|
101
|
-
let merged = 0;
|
|
102
|
-
let newPatterns = 0;
|
|
103
|
-
|
|
104
|
-
for (const p of (patterns || [])) {
|
|
105
|
-
if (!p.hash) continue;
|
|
106
|
-
const existing = this.patterns.get(p.hash);
|
|
107
|
-
|
|
108
|
-
if (existing) {
|
|
109
|
-
// Merge: average confidence, sum counts
|
|
110
|
-
existing.confidence = (existing.confidence + (p.confidence || 0.5)) / 2;
|
|
111
|
-
existing.count += p.count || 1;
|
|
112
|
-
existing.lastSeen = Date.now();
|
|
113
|
-
merged++;
|
|
114
|
-
} else {
|
|
115
|
-
this.patterns.set(p.hash, {
|
|
116
|
-
pattern: '[imported]',
|
|
117
|
-
hash: p.hash,
|
|
118
|
-
count: p.count || 1,
|
|
119
|
-
confidence: p.confidence || 0.5,
|
|
120
|
-
firstSeen: Date.now(),
|
|
121
|
-
lastSeen: Date.now(),
|
|
122
|
-
category: p.category || 'unknown',
|
|
123
|
-
severity: p.severity || 'medium'
|
|
124
|
-
});
|
|
125
|
-
newPatterns++;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
this.stats.imported += merged + newPatterns;
|
|
130
|
-
return { imported: merged + newPatterns, merged, new: newPatterns };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Get all known patterns.
|
|
135
|
-
* @returns {Array<object>}
|
|
136
|
-
*/
|
|
137
|
-
getKnownPatterns() {
|
|
138
|
-
return [...this.patterns.values()].sort((a, b) => b.confidence - a.confidence);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Check if a text matches any known pattern.
|
|
143
|
-
* @param {string} text
|
|
144
|
-
* @returns {{ matches: boolean, pattern: object|null }}
|
|
145
|
-
*/
|
|
146
|
-
checkAgainstKnown(text) {
|
|
147
|
-
const hash = crypto.createHash('sha256').update(text || '').digest('hex').substring(0, 16);
|
|
148
|
-
const match = this.patterns.get(hash);
|
|
149
|
-
return { matches: !!match, pattern: match || null };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Get stats.
|
|
154
|
-
* @returns {object}
|
|
155
|
-
*/
|
|
156
|
-
getStats() {
|
|
157
|
-
return { ...this.stats, totalPatterns: this.patterns.size, nodeId: this.nodeId };
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
module.exports = { ThreatIntelNode };
|