nsauditor-ai 0.1.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.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/bin/nsauditor-ai-mcp.mjs +2 -0
  5. package/bin/nsauditor-ai.mjs +2 -0
  6. package/cli.mjs +939 -0
  7. package/config/services.json +304 -0
  8. package/docs/EULA-nsauditor-ai.md +324 -0
  9. package/index.mjs +15 -0
  10. package/mcp_server.mjs +382 -0
  11. package/package.json +44 -0
  12. package/plugin_manager.mjs +829 -0
  13. package/plugins/arp_scanner.mjs +162 -0
  14. package/plugins/db_scanner.mjs +248 -0
  15. package/plugins/dns_scanner.mjs +369 -0
  16. package/plugins/dnssd-scanner.mjs +245 -0
  17. package/plugins/ftp_banner_check.mjs +247 -0
  18. package/plugins/host_up_check.mjs +337 -0
  19. package/plugins/http_probe.mjs +290 -0
  20. package/plugins/llmnr_scanner.mjs +130 -0
  21. package/plugins/mdns_scanner.mjs +522 -0
  22. package/plugins/netbios_scanner.mjs +737 -0
  23. package/plugins/opensearch_scanner.mjs +276 -0
  24. package/plugins/os_detector.mjs +436 -0
  25. package/plugins/ping_checker.mjs +271 -0
  26. package/plugins/port_scanner.mjs +250 -0
  27. package/plugins/result_concluder.mjs +274 -0
  28. package/plugins/snmp_scanner.mjs +278 -0
  29. package/plugins/ssh_scanner.mjs +421 -0
  30. package/plugins/sunrpc_scanner.mjs +339 -0
  31. package/plugins/syn_scanner.mjs +314 -0
  32. package/plugins/tls_scanner.mjs +225 -0
  33. package/plugins/upnp_scanner.mjs +441 -0
  34. package/plugins/webapp_detector.mjs +246 -0
  35. package/plugins/wsd_scanner.mjs +290 -0
  36. package/utils/attack_map.mjs +180 -0
  37. package/utils/capabilities.mjs +53 -0
  38. package/utils/conclusion_utils.mjs +70 -0
  39. package/utils/cpe.mjs +74 -0
  40. package/utils/cve_validator.mjs +64 -0
  41. package/utils/cvss.mjs +129 -0
  42. package/utils/delta_reporter.mjs +110 -0
  43. package/utils/export_csv.mjs +82 -0
  44. package/utils/finding_queue.mjs +64 -0
  45. package/utils/finding_schema.mjs +36 -0
  46. package/utils/host_iterator.mjs +166 -0
  47. package/utils/license.mjs +29 -0
  48. package/utils/net_validation.mjs +66 -0
  49. package/utils/nvd_cache.mjs +77 -0
  50. package/utils/nvd_client.mjs +130 -0
  51. package/utils/oui.mjs +107 -0
  52. package/utils/plugin_discovery.mjs +89 -0
  53. package/utils/prompts.mjs +143 -0
  54. package/utils/raw_report_html.mjs +170 -0
  55. package/utils/redact.mjs +79 -0
  56. package/utils/report_html.mjs +236 -0
  57. package/utils/sarif.mjs +225 -0
  58. package/utils/scan_history.mjs +248 -0
  59. package/utils/scheduler.mjs +157 -0
  60. package/utils/webhook.mjs +177 -0
@@ -0,0 +1,829 @@
1
+ // plugin_manager.mjs
2
+ // Backward-compatible PluginManager with:
3
+ // - static create(dir) for CLI compatibility
4
+ // - runStrategy "single" (run once)
5
+ // - robust Result Concluder invocation (supports both signatures)
6
+ // - duplicate result coalescing by plugin id
7
+ // - optional verbose logs via NSA_VERBOSE=1|true|yes
8
+ // - Orchestrated execution with priority + requirements gating,
9
+ // shared context (hostUp, tcpOpen, udpOpen), per-port runs,
10
+ // and support for requirements.host === 'up' | 'down' | omitted.
11
+ // - Injects shared OUI helpers (lookupVendor, probableOsFromVendor) from utils/oui.mjs
12
+ // into every plugin's opts.context so plugins can use vendor/OS heuristics without
13
+ // importing the OUI DB themselves.
14
+ // - **FIX**: OS Detector (id "013") is invoked with prior plugin `outputs` via opts.results.
15
+
16
+ import fs from "fs";
17
+ import fsp from "fs/promises";
18
+ import path from "path";
19
+ import { pathToFileURL, fileURLToPath } from "url";
20
+ import { discoverPlugins } from './utils/plugin_discovery.mjs';
21
+ import { getTierFromEnv } from './utils/license.mjs';
22
+ import { resolveCapabilities } from './utils/capabilities.mjs';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+
26
+ const VERBOSE = /^(1|true|yes|on)$/i.test(String(process.env.NSA_VERBOSE || ''));
27
+ const PLUGIN_TIMEOUT_MS = Number(process.env.PLUGIN_TIMEOUT_MS || 30000);
28
+ const PREFIX = '[nsauditor]';
29
+ const vlog = VERBOSE ? (...a) => console.log(PREFIX, ...a) : () => {};
30
+ const vwarn = VERBOSE ? (...a) => console.warn(PREFIX, ...a) : () => {};
31
+ const verror = (...a) => console.error(PREFIX, ...a); // never silenced
32
+
33
+ vlog(`PluginManager module loaded, __filename: ${__filename}`);
34
+
35
+ // ---- OUI helpers (shared to all plugins via context) ----
36
+ let BASE_CTX = {};
37
+ try {
38
+ const oui = await import("./utils/oui.mjs");
39
+ await oui.initOui(); // Explicitly initialize
40
+ const lookupVendor = typeof oui.lookupVendor === "function" ? oui.lookupVendor : null;
41
+ const probableOsFromVendor = typeof oui.probableOsFromVendor === "function" ? oui.probableOsFromVendor : null;
42
+ BASE_CTX = {
43
+ ...(lookupVendor ? { lookupVendor } : {}),
44
+ ...(probableOsFromVendor ? { probableOsFromVendor } : {}),
45
+ };
46
+ const size = Object.keys(BASE_CTX).length;
47
+ if (size) {
48
+ vlog("OUI helpers available in plugin context:", Object.keys(BASE_CTX));
49
+ } else {
50
+ vlog("OUI helpers not available (utils/oui.mjs missing or partial).");
51
+ }
52
+ } catch (e) {
53
+ vlog("Could not load utils/oui.mjs:", e?.message || e);
54
+ }
55
+
56
+ function isConcluder(p) {
57
+ return p?.id === "008" || /result\s*concluder/i.test(p?.name || "");
58
+ }
59
+ function jclone(x) { return JSON.parse(JSON.stringify(x ?? {})); }
60
+
61
+ // Merge multiple wrapped results (same plugin) into one
62
+ function mergeResultObjects(plugin, arr) {
63
+ const merged = {
64
+ id: plugin.id,
65
+ name: plugin.name,
66
+ result: {
67
+ up: arr.some((r) => r?.result?.up === true),
68
+ program: arr.find((r) => r?.result?.program)?.result?.program || "Unknown",
69
+ version: arr.find((r) => r?.result?.version)?.result?.version || "Unknown",
70
+ os: arr.find((r) => r?.result?.os)?.result?.os || null,
71
+ type: arr.find((r) => r?.result?.type)?.result?.type || null,
72
+ data: [],
73
+ },
74
+ };
75
+ for (const r of arr) {
76
+ if (Array.isArray(r?.result?.data)) merged.result.data.push(...r.result.data);
77
+ }
78
+ return merged;
79
+ }
80
+
81
+ /* ----------------------------- helpers ----------------------------- */
82
+
83
+ function getPriority(p) {
84
+ const n = Number(p?.priority);
85
+ return Number.isFinite(n) ? n : 100;
86
+ }
87
+ function safeLower(x) { return String(x || "").toLowerCase(); }
88
+ function arrayify(x) { return Array.isArray(x) ? x : x != null ? [x] : []; }
89
+
90
+ // requirements gating
91
+ function shouldRunPlugin(mod, ctx) {
92
+ const req = mod?.requirements || {};
93
+
94
+ // host requirement
95
+ if (req.host === "up" && !ctx.hostUp) return false;
96
+ if (req.host === "down" && ctx.hostUp === true) return false;
97
+
98
+ // tcp_open gating
99
+ if (Array.isArray(req.tcp_open) && req.tcp_open.length) {
100
+ const any = req.tcp_open.some((p) => ctx.tcpOpen.has(p));
101
+ if (!any) return false;
102
+ }
103
+
104
+ // udp_open gating
105
+ if (Array.isArray(req.udp_open) && req.udp_open.length) {
106
+ const any = req.udp_open.some((p) => ctx.udpOpen.has(p));
107
+ if (!any) return false;
108
+ }
109
+
110
+ // Optional: only_if_os_unknown gating (e.g., ARP Scanner)
111
+ if (req.only_if_os_unknown) {
112
+ const known = !!ctx.os || !!ctx.guessedOs || !!ctx.pingOs || !!ctx.arpOs;
113
+ if (known) return false;
114
+ }
115
+
116
+ return true;
117
+ }
118
+
119
+ // Merge the manager's orchestration context with the shared OUI helpers
120
+ function withBaseContext(ctxLike) {
121
+ const base = BASE_CTX;
122
+ const live = ctxLike || {};
123
+ return { ...base, ...live };
124
+ }
125
+
126
+ async function callPlugin(mod, host, ctx, priorOutputs = null) {
127
+ // Decide if we run once per matching open port, or once total.
128
+ const req = mod?.requirements || {};
129
+ const runs = [];
130
+
131
+ const perTcp = Array.isArray(req.tcp_open) && req.tcp_open.length
132
+ ? req.tcp_open.filter((p) => ctx.tcpOpen.has(p))
133
+ : [];
134
+
135
+ const perUdp = Array.isArray(req.udp_open) && req.udp_open.length
136
+ ? req.udp_open.filter((p) => ctx.udpOpen.has(p))
137
+ : [];
138
+
139
+ // Special-case OS Detector: pass prior plugin outputs so it can reason over them
140
+ const isOsDetector = (mod?.id === "013") || /os\s*detector/i.test(String(mod?.name || ""));
141
+
142
+ const runWithCtx = (port) => {
143
+ const extra = isOsDetector && Array.isArray(priorOutputs) ? { results: priorOutputs } : {};
144
+ const pluginPromise = mod.run(host, port, { context: withBaseContext(ctx), ...extra });
145
+
146
+ const timeoutMs = PLUGIN_TIMEOUT_MS;
147
+ let timer;
148
+ const timeoutPromise = new Promise((_, reject) => {
149
+ timer = setTimeout(() => reject(new Error(`Plugin "${mod.name}" timed out after ${timeoutMs}ms`)), timeoutMs);
150
+ });
151
+
152
+ return Promise.race([pluginPromise, timeoutPromise]).finally(() => clearTimeout(timer));
153
+ };
154
+
155
+ // If plugin explicitly asked to run once per required port, do so
156
+ for (const port of perTcp) runs.push(runWithCtx(port));
157
+ for (const port of perUdp) runs.push(runWithCtx(port));
158
+
159
+ // Otherwise, use legacy semantics: run across plugin.ports unless "single"
160
+ if (!runs.length) {
161
+ const ports =
162
+ mod.runStrategy === "single"
163
+ ? [0]
164
+ : mod.ports?.length
165
+ ? mod.ports
166
+ : [0];
167
+
168
+ for (const port of ports) runs.push(runWithCtx(port));
169
+ }
170
+
171
+ const arr = await Promise.allSettled(runs);
172
+ const results = arr.map((pr) => pr.status === "fulfilled"
173
+ ? { ok: true, value: pr.value }
174
+ : { ok: false, error: pr.reason });
175
+
176
+ return results.map((r) => {
177
+ if (!r.ok) {
178
+ const isTimeout = r.error?.message?.includes('timed out') || false;
179
+ if (isTimeout) {
180
+ vlog(`Plugin "${mod.name}" timed out after ${PLUGIN_TIMEOUT_MS}ms — skipping`);
181
+ }
182
+ return {
183
+ id: String(mod.id || ""),
184
+ name: mod.name || "Plugin",
185
+ result: { up: false, error: String(r.error?.message || r.error), data: [], timedOut: isTimeout },
186
+ };
187
+ }
188
+ const raw = r.value;
189
+ // Normalize to wrapped envelope if needed
190
+ if (raw && raw.id && raw.result) return raw;
191
+ return { id: String(mod.id || ""), name: mod.name || "Plugin", result: jclone(raw) || { up: false, data: [] } };
192
+ });
193
+ }
194
+
195
+ // Heuristics to update context from any plugin's result
196
+ function updateContextFromResult(mod, result, ctx) {
197
+ try {
198
+ const id = String(mod?.id || "");
199
+ const name = safeLower(mod?.name);
200
+
201
+ // If plugin itself says up => trust
202
+ if (result?.up === true) ctx.hostUp = true;
203
+
204
+ // Capture OS hints for gating (so ARP can skip)
205
+ if (result?.os) {
206
+ const label = String(result.os || "").trim();
207
+ if (label && label.toLowerCase() !== "unknown") {
208
+ ctx.os = ctx.os || label;
209
+ if (/ping/i.test(name)) ctx.pingOs = label;
210
+ if (/arp/i.test(name)) ctx.arpOs = label;
211
+ ctx.guessedOs = ctx.guessedOs || label;
212
+ }
213
+ }
214
+
215
+ // Scan data rows for signals
216
+ const rows = Array.isArray(result?.data) ? result.data : [];
217
+ for (const d of rows) {
218
+ const info = safeLower(d?.probe_info);
219
+ const proto = (d?.probe_protocol || "").toLowerCase();
220
+ const port = Number.isFinite(d?.probe_port) ? Number(d.probe_port) : null;
221
+
222
+ if (/host .*up|ping .*success|success/.test(info)) {
223
+ ctx.hostUp = true;
224
+ }
225
+
226
+ // --- NEW: extract target MAC from ARP (or any row that exposes a MAC) ---
227
+ try {
228
+ // prefer explicit d.mac, otherwise parse any MAC from response_banner or probe_info
229
+ const macCandidate =
230
+ (typeof d.mac === "string" && d.mac) ||
231
+ (typeof d.response_banner === "string" && d.response_banner.match(/([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}/)?.[0]) ||
232
+ (typeof d.probe_info === "string" && d.probe_info.match(/([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}/)?.[0]) ||
233
+ null;
234
+
235
+ if (macCandidate && !ctx.arpMac) {
236
+ // normalize to AA:BB:CC:DD:EE:FF
237
+ const flat = macCandidate.replace(/[^0-9A-Fa-f]/g, "").toUpperCase();
238
+ if (flat.length === 12) {
239
+ ctx.arpMac = flat.match(/.{2}/g).join(":");
240
+ // optional: log when verbose
241
+ vlog("Captured target MAC from results:", ctx.arpMac);
242
+ }
243
+ }
244
+ } catch {}
245
+
246
+ // TCP open/closed hints
247
+ if (proto === "tcp" && Number.isFinite(port)) {
248
+ if (/connect success|connection successful|banner received|http\/1|ssh-2\.0|^220 /.test(safeLower(d?.probe_info || "") + " " + safeLower(d?.response_banner || ""))) {
249
+ ctx.tcpOpen.add(port);
250
+ } else if (/refused/.test(info)) {
251
+ // explicitly closed; don't add to open set
252
+ } else if (/timeout|filtered|unreachable/.test(info)) {
253
+ // filtered/unknown; do nothing
254
+ }
255
+ }
256
+
257
+ // UDP hints (rare)
258
+ if (proto === "udp" && Number.isFinite(port)) {
259
+ if (/udp response|snmp response|sysdescr|pdu/.test(info)) {
260
+ ctx.udpOpen.add(port);
261
+ }
262
+ }
263
+ }
264
+
265
+ // Port Scanner result has explicit fields
266
+ if (id === "003" || name.includes("port scanner")) {
267
+ for (const p of arrayify(result?.tcpOpen)) ctx.tcpOpen.add(Number(p));
268
+ for (const p of arrayify(result?.udpOpen)) ctx.udpOpen.add(Number(p));
269
+ }
270
+
271
+ // Service-specific implicit opens
272
+ if (id === "006" || name.includes("http probe")) {
273
+ const first = rows[0];
274
+ const proto = first?.probe_protocol;
275
+ const port = first?.probe_port;
276
+ if ((proto === "http" || proto === "https") && result?.up === true && Number.isFinite(port)) {
277
+ ctx.tcpOpen.add(Number(port));
278
+ }
279
+ }
280
+ if (id === "004" || name.includes("ftp")) {
281
+ const first = rows[0];
282
+ if (result?.up === true && Number.isFinite(first?.probe_port)) {
283
+ if (first?.response_banner) ctx.tcpOpen.add(Number(first.probe_port));
284
+ }
285
+ }
286
+ if (id === "002" || name.includes("ssh")) {
287
+ const first = rows[0];
288
+ if (first?.response_banner && Number.isFinite(first?.probe_port)) {
289
+ ctx.tcpOpen.add(Number(first.probe_port));
290
+ }
291
+ }
292
+ if (id === "007" || name.includes("snmp")) {
293
+ const first = rows[0];
294
+ if (/snmp response/.test(safeLower(first?.probe_info || "")) && Number.isFinite(first?.probe_port)) {
295
+ ctx.udpOpen.add(Number(first.probe_port));
296
+ }
297
+ }
298
+ } catch (e) {
299
+ vlog("Context update failed:", e?.message || e);
300
+ }
301
+ }
302
+
303
+ /* ----------------------------- PLUGIN MANAGER ----------------------------- */
304
+
305
+ /**
306
+ * Describe why a plugin was skipped given its requirements and the current context.
307
+ * Exported for direct testing.
308
+ */
309
+ export function describeSkipReason(mod, ctx) {
310
+ const req = mod?.requirements || {};
311
+ if (req.host === 'up' && !ctx.hostUp) return 'host not up';
312
+ if (req.host === 'down' && ctx.hostUp) return 'host is up (requires down)';
313
+ if (Array.isArray(req.tcp_open) && req.tcp_open.length) {
314
+ const missing = req.tcp_open.filter(p => !ctx.tcpOpen.has(p));
315
+ if (missing.length) return `tcp ports not open: ${missing.join(',')}`;
316
+ }
317
+ if (Array.isArray(req.udp_open) && req.udp_open.length) {
318
+ const missing = req.udp_open.filter(p => !ctx.udpOpen.has(p));
319
+ if (missing.length) return `udp ports not open: ${missing.join(',')}`;
320
+ }
321
+ if (req.only_if_os_unknown) {
322
+ const known = !!ctx?.os || !!ctx?.guessedOs || !!ctx?.pingOs || !!ctx?.arpOs;
323
+ if (known) return 'OS already determined';
324
+ }
325
+ return 'unknown';
326
+ }
327
+
328
+ export class PluginManager {
329
+ constructor(directory = "./plugins") {
330
+ this.directory = directory;
331
+ this.plugins = [];
332
+ this._resolvedCapabilities = resolveCapabilities(getTierFromEnv());
333
+ }
334
+
335
+ // ---- Backward-compatible factory ----
336
+ // Accepts:
337
+ // - a directory string (legacy CLI path)
338
+ // - an options object { plugins: [...] } ← test injection (Task 2.3)
339
+ // - an options object { baseDir: '...' } ← explicit base dir for discovery
340
+ // - no args → uses process.cwd() for discovery
341
+ static async create(dirOrOpts = {}) {
342
+ // Test injection path: pass plugins array directly, skip discovery
343
+ if (dirOrOpts && typeof dirOrOpts === 'object' && !Array.isArray(dirOrOpts) && dirOrOpts.plugins) {
344
+ const mgr = new PluginManager('/nonexistent');
345
+ mgr.plugins = dirOrOpts.plugins;
346
+ // Allow callers to explicitly set tier for isolation; default to env tier.
347
+ const tier = dirOrOpts.tier ?? getTierFromEnv();
348
+ mgr._resolvedCapabilities = resolveCapabilities(tier);
349
+ vlog("PluginManager initialized with injected plugins");
350
+ return mgr;
351
+ }
352
+
353
+ // Resolve baseDir from string arg or options object
354
+ const baseDir = typeof dirOrOpts === 'string'
355
+ ? path.resolve(dirOrOpts, '..') // legacy: dir was './plugins', baseDir is parent
356
+ : (dirOrOpts?.baseDir ?? process.cwd());
357
+
358
+ vlog(`Initializing PluginManager via discoverPlugins, baseDir: ${baseDir}`);
359
+ const rawPlugins = await discoverPlugins(baseDir);
360
+
361
+ // Normalize plugin fields (same as loadPlugins() did inline)
362
+ for (const plugin of rawPlugins) {
363
+ plugin.protocols = Array.isArray(plugin.protocols) ? plugin.protocols : [];
364
+ plugin.ports = Array.isArray(plugin.ports) ? plugin.ports : [];
365
+ if (plugin.runStrategy && String(plugin.runStrategy).toLowerCase() !== 'single') {
366
+ plugin.runStrategy = undefined;
367
+ }
368
+ if (String(plugin.runStrategy).toLowerCase() === 'single') {
369
+ plugin.runStrategy = 'single';
370
+ }
371
+ if (!Array.isArray(plugin.dependencies)) {
372
+ plugin.dependencies = plugin.dependencies != null
373
+ ? [plugin.dependencies].filter(Boolean)
374
+ : [];
375
+ }
376
+ }
377
+
378
+ const mgr = new PluginManager(baseDir);
379
+ mgr.plugins = rawPlugins;
380
+ // Resolve capabilities from env tier at load time — prevents permissive fallback.
381
+ // TODO (Phase 2): replace getTierFromEnv() with loadLicense() result here.
382
+ mgr._resolvedCapabilities = resolveCapabilities(getTierFromEnv());
383
+ vlog("PluginManager initialized successfully");
384
+ return mgr;
385
+ }
386
+
387
+ // Optional new-style init() if you prefer ctor + init
388
+ async init() {
389
+ vlog(`Initializing PluginManager with directory: ${this.directory}`);
390
+ await this.loadPlugins();
391
+ vlog("PluginManager initialized successfully");
392
+ }
393
+
394
+ async loadPlugins() {
395
+ vlog(`Loading plugins from directory: ${this.directory}`);
396
+ const resolvedDir = this.directory;
397
+
398
+ // Access check
399
+ try {
400
+ await fsp.access(resolvedDir, fs.constants.R_OK | fs.constants.W_OK);
401
+ vlog(`Plugin directory is accessible (read/write): ${resolvedDir}`);
402
+ } catch (e) {
403
+ throw new Error(`Plugin directory not accessible: ${resolvedDir} -> ${e.message}`);
404
+ }
405
+
406
+ vlog(`Checking directory contents for ${resolvedDir}`);
407
+ const entries = await fsp.readdir(resolvedDir);
408
+ const files = entries.filter((f) => f.endsWith(".mjs"));
409
+ vlog(`Found files in plugin directory: ${files.join(", ")}`);
410
+
411
+ const loaded = [];
412
+ for (const file of files) {
413
+ const full = path.join(resolvedDir, file);
414
+ vlog(`Processing file: ${full}`);
415
+ try {
416
+ await fsp.access(full, fs.constants.R_OK);
417
+ const preview = (await fsp.readFile(full, "utf8")).slice(0, 50).replace(/\r?\n/g, " ");
418
+ vlog(`File is accessible: ${full}\nFile content preview (first 50 chars): ${preview}`);
419
+ } catch (e) {
420
+ console.warn(`Cannot read file ${full}: ${e.message}`);
421
+ continue;
422
+ }
423
+
424
+ try {
425
+ const url = pathToFileURL(full).href;
426
+ vlog(`Attempting to load plugin: ${full} (${url})`);
427
+ const mod = await import(url);
428
+ const plugin = mod.default || mod;
429
+ const keys = Object.keys(plugin || {});
430
+ vlog(`Plugin module loaded: ${file}, keys: ${keys.join(", ")}`);
431
+
432
+ if (!plugin || typeof plugin.run !== "function" || !plugin.id || !plugin.name) {
433
+ console.warn(`Skipping ${file}: missing id/name/run`);
434
+ continue;
435
+ }
436
+
437
+ // normalize optional fields
438
+ plugin.protocols = Array.isArray(plugin.protocols) ? plugin.protocols : [];
439
+ plugin.ports = Array.isArray(plugin.ports) ? plugin.ports : [];
440
+ if (plugin.runStrategy && String(plugin.runStrategy).toLowerCase() !== "single") {
441
+ plugin.runStrategy = undefined;
442
+ }
443
+ if (String(plugin.runStrategy).toLowerCase() === "single") {
444
+ plugin.runStrategy = "single";
445
+ }
446
+ if (!Array.isArray(plugin.dependencies)) {
447
+ if (plugin.dependencies != null) {
448
+ plugin.dependencies = [plugin.dependencies].filter(Boolean);
449
+ } else {
450
+ plugin.dependencies = [];
451
+ }
452
+ }
453
+ loaded.push(plugin);
454
+ vlog(`Loaded plugin: ${plugin.name} (${plugin.id})`);
455
+ } catch (e) {
456
+ console.error(`Failed to load ${file}: ${e.stack || e}`);
457
+ }
458
+ }
459
+
460
+ this.plugins = loaded;
461
+ const meta = this.describePlugins(false);
462
+ vlog("All Plugins Metadata:", JSON.stringify(meta, null, 2));
463
+ }
464
+
465
+ // legacy name used by CLI/output; keep it
466
+ describePlugins(logOut = true) {
467
+ const meta = this.plugins.map((p) => {
468
+ const out = {
469
+ id: p.id,
470
+ name: p.name,
471
+ description: p.description,
472
+ protocols: p.protocols || [],
473
+ ports: p.ports || [],
474
+ };
475
+ if (p.runStrategy) out.runStrategy = p.runStrategy;
476
+ if (p.dependencies?.length) out.dependencies = p.dependencies;
477
+ if (p.priority != null) out.priority = p.priority;
478
+ if (p.requirements != null) out.requirements = p.requirements;
479
+ return out;
480
+ });
481
+ if (logOut) vlog("All Plugins Metadata:", JSON.stringify(meta, null, 2));
482
+ return meta;
483
+ }
484
+
485
+ getAllPluginsMetadata() { return this.describePlugins(false); }
486
+
487
+ findPlugin(nameOrId) {
488
+ if (!nameOrId) return null;
489
+ const needle = String(nameOrId).toLowerCase();
490
+ return (
491
+ this.plugins.find((p) => String(p.id).toLowerCase() === needle) ||
492
+ this.plugins.find((p) => String(p.name).toLowerCase() === needle) ||
493
+ null
494
+ );
495
+ }
496
+
497
+ async _runOne(plugin, host, port, opts = {}) {
498
+ const timeoutMs = parseInt(process.env.PLUGIN_TIMEOUT_MS, 10) || PLUGIN_TIMEOUT_MS;
499
+ let timer;
500
+ const timeoutPromise = new Promise((_, reject) => {
501
+ timer = setTimeout(() => reject(new Error(`Plugin ${plugin.name || plugin.id} timed out after ${timeoutMs}ms`)), timeoutMs);
502
+ });
503
+ try {
504
+ vlog(`Running ${plugin.name} on ${host}:${port}`);
505
+ // Ensure every run gets the BASE_CTX helpers merged into opts.context
506
+ const mergedOpts = { ...opts, context: withBaseContext(opts?.context || {}) };
507
+ const raw = await Promise.race([
508
+ plugin.run(host, port, mergedOpts),
509
+ timeoutPromise,
510
+ ]);
511
+ clearTimeout(timer);
512
+
513
+ // If plugin returned wrapped shape already, keep it
514
+ if (raw && raw.id && raw.result) {
515
+ vlog(`${plugin.name} Result:`, JSON.stringify(raw, null, 2));
516
+ return raw;
517
+ }
518
+
519
+ // Otherwise wrap to a normalized envelope
520
+ const wrapped = { id: plugin.id, name: plugin.name, result: jclone(raw) };
521
+ if (!wrapped.result) wrapped.result = {};
522
+ if (!Array.isArray(wrapped.result.data)) wrapped.result.data = [];
523
+ vlog(`${plugin.name} Result:`, JSON.stringify(wrapped, null, 2));
524
+ return wrapped;
525
+ } catch (err) {
526
+ clearTimeout(timer);
527
+ const isTimeout = err?.message?.includes('timed out') || false;
528
+ if (isTimeout) {
529
+ vlog(`Plugin "${plugin.name}" timed out after ${timeoutMs}ms — skipping`);
530
+ }
531
+ verror(`Error running ${plugin.name} on ${host}:${port}`, err?.message || err);
532
+ return {
533
+ id: plugin.id,
534
+ name: plugin.name,
535
+ result: { up: false, error: String(err?.message || err), data: [], timedOut: isTimeout },
536
+ };
537
+ }
538
+ }
539
+
540
+ async _runAcrossPorts(plugin, host, opts = {}) {
541
+ const ports =
542
+ plugin.runStrategy === "single"
543
+ ? [0]
544
+ : plugin.ports?.length
545
+ ? plugin.ports
546
+ : [0];
547
+
548
+ const out = [];
549
+ for (const port of ports) {
550
+ const r = await this._runOne(plugin, host, port, opts);
551
+ out.push(r);
552
+ }
553
+ return out;
554
+ }
555
+
556
+ async runByName(nameOrId, host, opts = {}) {
557
+ vlog(`Running plugin by name: ${nameOrId}`);
558
+ const plugin = this.findPlugin(nameOrId);
559
+ if (!plugin) {
560
+ const msg = `Plugin not found: ${nameOrId}`;
561
+ console.error(msg);
562
+ return { error: msg };
563
+ }
564
+
565
+ if (isConcluder(plugin)) {
566
+ // Accept results from multiple places to avoid "undefined" issues
567
+ const resultsArg =
568
+ (Array.isArray(opts?.results) && opts.results) ||
569
+ (Array.isArray(host?.results) && host.results) ||
570
+ (Array.isArray(host) && host) ||
571
+ null;
572
+
573
+ if (!resultsArg) {
574
+ console.warn("Result Concluder called without a results array; ignoring.");
575
+ return { id: plugin.id, name: plugin.name, error: "Result Concluder requires plugin results array" };
576
+ }
577
+ return await this.runConcluder(resultsArg);
578
+ }
579
+
580
+ const arr = await this._runAcrossPorts(plugin, host, opts);
581
+ const filtered = arr.filter(Boolean);
582
+ if (filtered.length === 0) return { id: plugin.id, name: plugin.name, result: { up: false, data: [] } };
583
+ if (filtered.length === 1) return filtered[0];
584
+ return mergeResultObjects(plugin, filtered);
585
+ }
586
+
587
+ async runConcluder(resultsArray) {
588
+ const concluder =
589
+ this.plugins.find((p) => p.id === "008") ||
590
+ this.plugins.find((p) => /result\s*concluder/i.test(p.name || ""));
591
+
592
+ if (!concluder) return null;
593
+ if (!Array.isArray(resultsArray)) {
594
+ console.warn("runConcluder called without an array; returning error object.");
595
+ return { id: concluder.id, name: concluder.name, error: "Expected an array of plugin results" };
596
+ }
597
+
598
+ try {
599
+ // Support both signatures:
600
+ // 1) run(pluginResults)
601
+ // 2) run(host, port, { results })
602
+ let conclusion;
603
+ if (concluder.run.length >= 3) {
604
+ vlog("Running Result Concluder with plugin results (opts.results signature):", JSON.stringify(resultsArray, null, 2));
605
+ conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}) });
606
+ } else {
607
+ vlog("Running Result Concluder with plugin results (single-arg signature):", JSON.stringify(resultsArray, null, 2));
608
+ conclusion = await concluder.run(resultsArray);
609
+ }
610
+
611
+ vlog("Result Concluder raw output:", JSON.stringify(conclusion, null, 2));
612
+
613
+ // Wrap conclusion if plugin returned a bare result object
614
+ let wrapped;
615
+ if (conclusion && conclusion.id && conclusion.result) {
616
+ wrapped = { id: concluder.id, name: concluder.name, ...conclusion };
617
+ } else {
618
+ wrapped = { id: concluder.id, name: concluder.name, result: conclusion };
619
+ }
620
+
621
+ vlog("Result Concluder Result:", JSON.stringify(wrapped, null, 2));
622
+ return wrapped;
623
+ } catch (err) {
624
+ console.error("Error running Result Concluder:", err?.stack || err);
625
+ return { id: concluder.id, name: concluder.name, error: String(err?.message || err) };
626
+ }
627
+ }
628
+
629
+ _resolveSelection(spec) {
630
+ if (!spec || spec === "all") return this.plugins.slice();
631
+ if (Array.isArray(spec)) {
632
+ const out = [];
633
+ for (const x of spec) {
634
+ const p = this.findPlugin(x);
635
+ if (p) out.push(p);
636
+ }
637
+ return out;
638
+ }
639
+ const parts = String(spec).split(",").map((s) => s.trim()).filter(Boolean);
640
+ return this._resolveSelection(parts);
641
+ }
642
+
643
+ _hasCapabilities(plugin, capabilities) {
644
+ if (!plugin.requiredCapabilities?.length) return true;
645
+ // Fall back to capabilities resolved at load time — never "allow all".
646
+ // TODO (Phase 2): _resolvedCapabilities will reflect JWT-verified tier.
647
+ const caps = capabilities ?? this._resolvedCapabilities ?? {};
648
+ return plugin.requiredCapabilities.every(cap => Boolean(caps[cap]));
649
+ }
650
+
651
+ /* -------------------- Orchestrated execution path -------------------- */
652
+ async _runOrchestrated(host, selection, opts = {}) {
653
+ // Shared context flows through all plugins (+ OUI helpers injected)
654
+ const ctx = withBaseContext({
655
+ host,
656
+ hostUp: false,
657
+ tcpOpen: new Set(),
658
+ udpOpen: new Set(),
659
+ // guessedOs / pingOs / arpOs will be filled as plugins run
660
+ });
661
+
662
+ // Sort by priority (stable)
663
+ const toRun = selection
664
+ .filter((p) => !isConcluder(p))
665
+ .sort((a, b) => getPriority(a) - getPriority(b));
666
+
667
+ const outputs = [];
668
+ const manifest = [];
669
+
670
+ for (const mod of toRun) {
671
+ if (!shouldRunPlugin(mod, ctx)) {
672
+ vlog(`Skipping ${mod.name} (priority ${getPriority(mod)}) due to unmet requirements.`);
673
+ manifest.push({
674
+ id: String(mod.id || ''),
675
+ name: mod.name || 'Plugin',
676
+ status: 'skipped',
677
+ reason: describeSkipReason(mod, ctx),
678
+ duration_ms: 0,
679
+ });
680
+ continue;
681
+ }
682
+
683
+ if (!this._hasCapabilities(mod, opts?.capabilities)) {
684
+ vlog(`Skipping ${mod.name} (priority ${getPriority(mod)}) due to missing capabilities: ${mod.requiredCapabilities?.join(',')}`);
685
+ manifest.push({
686
+ id: String(mod.id || ''),
687
+ name: mod.name || 'Plugin',
688
+ status: 'skipped',
689
+ reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`,
690
+ duration_ms: 0,
691
+ });
692
+ continue;
693
+ }
694
+
695
+ vlog(`Running ${mod.name} (priority ${getPriority(mod)}) on ${host}`);
696
+ // **FIX**: pass prior outputs into OS Detector via callPlugin(..., priorOutputs)
697
+ const startMs = Date.now();
698
+ const wrappedRuns = await callPlugin(mod, host, ctx, outputs);
699
+ const duration_ms = Date.now() - startMs;
700
+
701
+ // Determine manifest status from the plugin results
702
+ let status = 'ran';
703
+ let reason = null;
704
+ for (const wrapped of wrappedRuns) {
705
+ if (wrapped.result?.timedOut) {
706
+ status = 'timeout';
707
+ reason = wrapped.result.error || `timed out after ${PLUGIN_TIMEOUT_MS}ms`;
708
+ } else if (wrapped.result?.error && status !== 'timeout') {
709
+ status = 'error';
710
+ reason = wrapped.result.error;
711
+ }
712
+ }
713
+
714
+ manifest.push({
715
+ id: String(mod.id || ''),
716
+ name: mod.name || 'Plugin',
717
+ status,
718
+ reason,
719
+ duration_ms,
720
+ });
721
+
722
+ for (const wrapped of wrappedRuns) {
723
+ vlog(`${mod.name} Result:`, JSON.stringify(wrapped, null, 2));
724
+ outputs.push(wrapped);
725
+ try {
726
+ updateContextFromResult(mod, wrapped.result, ctx);
727
+ } catch (e) {
728
+ vlog(`Context update failed for ${mod.name}:`, e?.message || e);
729
+ }
730
+ }
731
+ }
732
+
733
+ return { ctx, results: outputs, manifest };
734
+ }
735
+
736
+ /**
737
+ * Backward-compat run signatures:
738
+ * A) run(host, spec='all', opts={})
739
+ * B) run(host, { plugins:'all', orchestrate?, ...opts })
740
+ *
741
+ * Default: if any selected plugin exports priority/requirements,
742
+ * we use the orchestrated path unless opts.orchestrate === false.
743
+ */
744
+ async run(host, specOrOptions = "all", maybeOpts = {}) {
745
+ let selection;
746
+ let opts;
747
+
748
+ if (specOrOptions && typeof specOrOptions === "object" && !Array.isArray(specOrOptions)) {
749
+ const { plugins = "all", ...rest } = specOrOptions;
750
+ selection = this._resolveSelection(plugins);
751
+ opts = rest;
752
+ } else {
753
+ selection = this._resolveSelection(specOrOptions);
754
+ opts = maybeOpts || {};
755
+ }
756
+
757
+ // Decide execution mode
758
+ const anyOrchestratedSignals = selection.some((p) => p?.priority != null || p?.requirements != null);
759
+ const orchestrate = opts.orchestrate !== false && anyOrchestratedSignals;
760
+
761
+ let results = [];
762
+ let manifest = [];
763
+ if (orchestrate) {
764
+ const orch = await this._runOrchestrated(host, selection, opts);
765
+ results = orch.results;
766
+ manifest = orch.manifest;
767
+ } else {
768
+ // Legacy path: simple run across ports for each plugin (except concluder)
769
+ const toRun = selection.filter((p) => !isConcluder(p));
770
+ for (const plugin of toRun) {
771
+ const startMs = Date.now();
772
+ // Ensure legacy path also gets OUI helpers
773
+ const arr = await this._runAcrossPorts(plugin, host, { ...opts, context: withBaseContext(opts?.context || {}) });
774
+ const duration_ms = Date.now() - startMs;
775
+ let status = 'ran';
776
+ let reason = null;
777
+ for (const r of arr) {
778
+ results.push(r);
779
+ if (r.result?.timedOut) {
780
+ status = 'timeout';
781
+ reason = r.result.error || 'timed out';
782
+ } else if (r.result?.error && status !== 'timeout') {
783
+ status = 'error';
784
+ reason = r.result.error;
785
+ }
786
+ }
787
+ manifest.push({
788
+ id: String(plugin.id || ''),
789
+ name: plugin.name || 'Plugin',
790
+ status,
791
+ reason,
792
+ duration_ms,
793
+ });
794
+ }
795
+ }
796
+
797
+ const coalesceSamePlugin = false;
798
+ let resultsForConcluder = results;
799
+
800
+ if (coalesceSamePlugin) {
801
+ const byId = new Map();
802
+ for (const r of results) {
803
+ const key = r.id;
804
+ if (!byId.has(key)) byId.set(key, []);
805
+ byId.get(key).push(r);
806
+ }
807
+ resultsForConcluder = [...byId.values()].map((arr) => {
808
+ return arr.length === 1
809
+ ? arr[0]
810
+ : mergeResultObjects({ id: arr[0].id, name: arr[0].name }, arr);
811
+ });
812
+ }
813
+
814
+ const conclusion = await this.runConcluder(resultsForConcluder);
815
+
816
+ return {
817
+ host,
818
+ results,
819
+ conclusion,
820
+ manifest,
821
+ ai: null,
822
+ ai_meta: null,
823
+ ai_error: null,
824
+ ai_out_path: null,
825
+ };
826
+ }
827
+ }
828
+
829
+ export default PluginManager;