nsauditor-ai 0.1.92 → 0.1.94
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/README.md +11 -1
- package/mcp_server.mjs +121 -0
- package/package.json +1 -1
- package/plugin_manager.mjs +104 -4
- package/utils/concurrency.mjs +21 -0
- package/utils/sentinel_scope.mjs +21 -0
package/README.md
CHANGED
|
@@ -17,8 +17,10 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
17
17
|
|
|
18
18
|
## What's New
|
|
19
19
|
|
|
20
|
-
**Latest: CE 0.1.
|
|
20
|
+
**Latest: CE 0.1.94 + Enterprise 0.16.3** (May 2026)
|
|
21
21
|
|
|
22
|
+
- ⚡ **`scan_cloud` runs cloud plugins in parallel** (CE 0.1.94 — paired EE 0.16.3) — the cloud-audit engine now runs the requested clouds' plugins **concurrently** (default up to 20 at once, 25s per-plugin timeout) so a full ~20-plugin AWS/GCP/Azure audit finishes within Claude Desktop's ~60s tool-call limit — previously the heaviest, most security-critical plugins (IAM, S3, EC2, Inspector/GuardDuty, decrypt-path) timed out at the 5s the budget forced under sequential execution. Tunable via `CLOUD_SCAN_CONCURRENCY` / `CLOUD_PLUGIN_TIMEOUT_MS`; the network `scan_host` path is unchanged; the anti-false-clean reporting is preserved. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
|
|
23
|
+
- ☁️ **MCP `scan_cloud` tool: audit cloud accounts directly** (CE 0.1.93 — paired EE 0.16.2) — *(Enterprise)* a dedicated MCP tool so you can say *"Audit my AWS account"* or *"Audit my AWS and Azure accounts"* in Claude Desktop / Claude Code, with **no network host** — the server scopes the run to only the requested clouds' EE plugins. The MCP analog of the CLI `--host aws --plugins all` scoping. Enterprise-gated (`enterpriseMCP`) with plugin-layer defense-in-depth, and **anti-false-clean reporting** — a cloud is reported "audited" only if a plugin actually ran; a missing-credential / skipped cloud is surfaced as an explicit note + `audited:false` (R-HIGH caught + folded by the `audit-cloud-plugin-false-negatives` review). Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
|
|
22
24
|
- 🔌 **MCP `NSA_ENV_FILE`: per-environment `.env` for the MCP server** (CE 0.1.92 — paired EE 0.16.1) — the MCP analog of 0.16.0's CLI `--env`. Point the MCP server at a specific account/cloud by setting **`NSA_ENV_FILE`** to a dotenv path in your Claude Desktop / Claude Code config instead of inlining every credential; the server loads that environment's cloud credentials + `CLOUD_PROVIDER` at startup. Loaded **after** the MCP auth gate + license (scan-target vars only; the auth key + license are ignored if present in the file), **fail-fast** on a missing / INI / set-but-empty path, and treated as the **authoritative scan target** — leftover ambient provider credentials the file doesn't set are cleared, closing a false-clean (a partial file could otherwise silently scan a leftover account) caught + folded by the `audit-cloud-plugin-false-negatives` review. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
|
|
23
25
|
- 🎯 **Per-account scanning: `--env` / `--aws-profile` + sentinel-host plugin scoping** (CE 0.1.91 — paired EE 0.16.0) — audit many cloud accounts one at a time without shell-export juggling. **`--env <path>`** loads a per-scan dotenv credentials file (override-on; fail-fast on a missing file; an INI/`~/.aws/credentials` file is detected and redirected to `--aws-profile`). **`--aws-profile <name>`** uses a named profile from the OS-default `~/.aws/credentials` (clears stale explicit keys, sets `AWS_SDK_LOAD_CONFIG=1`, implies `CLOUD_PROVIDER=aws`). On a cloud-sentinel host, **`--host aws|gcp|azure` + `--plugins all` auto-scopes** to only that cloud's plugins (other clouds + non-cloud plugins are skipped and logged); explicit `--plugins` lists are unaffected. A host↔`CLOUD_PROVIDER` conflict fails fast (no silent empty "clean" report), and a zero-match sentinel scope warns loudly. EE 0.16.0 adds a declarative `cloudProvider` field to all 27 cloud plugins. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
|
|
24
26
|
- 🛡️ **Cross-cloud scope hotfix** (CE 0.1.90 — paired EE 0.15.9) — EE 0.15.9 moves the AWS-plugin `CLOUD_PROVIDER` gate from `preflight()` to `run()`: the 0.15.8 gate was placed where the scan orchestrator never calls it, so the cross-cloud bleed persisted; it's now on the load-bearing `run()` path (a `CLOUD_PROVIDER=gcp|azure` scan with AWS creds present no longer bleeds AWS resources into the GCP/Azure packs), validated via a run()-path test + a real-creds local proof. CE is a paired no-op bump. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
|
|
@@ -413,11 +415,17 @@ npx nsauditor-ai-mcp
|
|
|
413
415
|
|
|
414
416
|
| Tool | Purpose |
|
|
415
417
|
|---|---|
|
|
418
|
+
| `scan_cloud` | Audit one or more cloud accounts (AWS / GCP / Azure) using server-configured credentials; no network host. "Audit my AWS account" / "Audit my AWS and Azure accounts". |
|
|
416
419
|
| `start_assessment` | Multi-host orchestrated assessment workflow |
|
|
417
420
|
| `prioritize_risks` | Cross-host risk prioritization |
|
|
418
421
|
| `compliance_check` | Compliance mapping with gap analysis |
|
|
419
422
|
| `export_report` | Generate formatted compliance report |
|
|
420
423
|
|
|
424
|
+
> `scan_cloud` runs the requested clouds' plugins **concurrently** (default up to 20 at once, 25s per-plugin
|
|
425
|
+
> timeout) so a full multi-service cloud audit completes within Claude Desktop's ~60s tool-call limit. Tune with
|
|
426
|
+
> `CLOUD_SCAN_CONCURRENCY` (default 20) and `CLOUD_PLUGIN_TIMEOUT_MS` (default 25000) in the server env. The
|
|
427
|
+
> network `PLUGIN_TIMEOUT_MS` still governs `scan_host` / network scans.
|
|
428
|
+
|
|
421
429
|
Security: SSRF protection on all host inputs (blocks RFC 1918, loopback, fc00::/7, cloud metadata), port validation (1–65535), CPE format enforcement, dependency injection for test isolation. **Server-startup authentication is required** — see next section.
|
|
422
430
|
|
|
423
431
|
### Authentication (required)
|
|
@@ -501,6 +509,8 @@ The exact `NSA_MCP_AUTH_KEY` value to paste is printed by `nsauditor-ai mcp inst
|
|
|
501
509
|
- `NSA_MCP_AUTH_KEY` — **required** (see Authentication section above)
|
|
502
510
|
- `NSA_ALLOW_ALL_HOSTS=1` — required to scan private/RFC 1918 addresses (e.g., `192.168.x.x`)
|
|
503
511
|
- `PLUGIN_TIMEOUT_MS=5000` — reduces per-plugin timeout to 5s so the full scan completes within Claude Desktop's 60s MCP limit
|
|
512
|
+
- `CLOUD_SCAN_CONCURRENCY` — max cloud plugins run at once by `scan_cloud` (default 20).
|
|
513
|
+
- `CLOUD_PLUGIN_TIMEOUT_MS` — per-plugin timeout for `scan_cloud` (default 25000; independent of the network `PLUGIN_TIMEOUT_MS`).
|
|
504
514
|
- `AI_PROVIDER` and API key — optional, enables AI-powered analysis of scan results
|
|
505
515
|
|
|
506
516
|
#### `NSA_ENV_FILE` — point the MCP server at an environment file
|
package/mcp_server.mjs
CHANGED
|
@@ -54,6 +54,12 @@ export function _requireProCapability(toolName) {
|
|
|
54
54
|
return requireProCapability(toolName);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** @internal Test-only. Returns the Enterprise-gate denial object (or null if allowed). */
|
|
58
|
+
export function _requireEnterpriseCapability(toolName) {
|
|
59
|
+
if (process.env.NODE_ENV === 'production') throw new Error('_requireEnterpriseCapability is test-only and disabled in production');
|
|
60
|
+
return requireEnterpriseCapability(toolName);
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
function requireProCapability(toolName) {
|
|
58
64
|
if (_capabilities.proMCP) return null; // Pro/Enterprise: allow
|
|
59
65
|
return {
|
|
@@ -65,6 +71,17 @@ function requireProCapability(toolName) {
|
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
function requireEnterpriseCapability(toolName) {
|
|
75
|
+
if (_capabilities.enterpriseMCP) return null; // Enterprise: allow
|
|
76
|
+
return {
|
|
77
|
+
content: [{
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: `🔒 **${toolName}** (cloud account auditing) requires an Enterprise license.\n\nView Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins`,
|
|
80
|
+
}],
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
// ---------------------------------------------------------------------------
|
|
69
86
|
// Lazy singletons — initialised on first use, overridable for tests
|
|
70
87
|
// ---------------------------------------------------------------------------
|
|
@@ -176,6 +193,22 @@ const TOOLS = [
|
|
|
176
193
|
required: ['host'],
|
|
177
194
|
},
|
|
178
195
|
},
|
|
196
|
+
{
|
|
197
|
+
name: 'scan_cloud',
|
|
198
|
+
description:
|
|
199
|
+
'Audit one or more cloud accounts (AWS / GCP / Azure) for security & compliance posture using the credentials configured in the server environment. No network host required. Requires an Enterprise license. Use for "audit my AWS account" / "audit my AWS and Azure accounts".',
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
providers: {
|
|
204
|
+
type: 'array',
|
|
205
|
+
items: { type: 'string', enum: ['aws', 'gcp', 'azure'] },
|
|
206
|
+
description: 'Which cloud(s) to audit. Omit to audit all clouds the server is configured for.',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
required: [],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
179
212
|
{
|
|
180
213
|
name: 'probe_service',
|
|
181
214
|
description:
|
|
@@ -313,6 +346,80 @@ export async function handleScanHost(args) {
|
|
|
313
346
|
};
|
|
314
347
|
}
|
|
315
348
|
|
|
349
|
+
const VALID_PROVIDERS = ['aws', 'gcp', 'azure'];
|
|
350
|
+
|
|
351
|
+
export async function handleScanCloud(args) {
|
|
352
|
+
// Validate providers; default → all three.
|
|
353
|
+
let providers = VALID_PROVIDERS.slice();
|
|
354
|
+
if (args && args.providers != null) {
|
|
355
|
+
if (!Array.isArray(args.providers)) {
|
|
356
|
+
throw new Error('providers must be an array of "aws" | "gcp" | "azure"');
|
|
357
|
+
}
|
|
358
|
+
const requested = args.providers.map((p) => String(p).trim().toLowerCase());
|
|
359
|
+
for (const p of requested) {
|
|
360
|
+
if (!VALID_PROVIDERS.includes(p)) {
|
|
361
|
+
throw new Error(`unknown provider '${p}' (valid: ${VALID_PROVIDERS.join(', ')})`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (requested.length > 0) providers = requested;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const pm = await getPluginManager();
|
|
368
|
+
|
|
369
|
+
// Save/set/restore CLOUD_PROVIDER so the per-plugin .includes() gates pass and
|
|
370
|
+
// the cross-cloud bleed gate is satisfied — restored in finally so the tool
|
|
371
|
+
// never leaves the long-lived server's env mutated for the next call.
|
|
372
|
+
const savedProvider = process.env.CLOUD_PROVIDER;
|
|
373
|
+
let output;
|
|
374
|
+
try {
|
|
375
|
+
process.env.CLOUD_PROVIDER = providers.join(',');
|
|
376
|
+
output = await pm.runCloud(providers);
|
|
377
|
+
} finally {
|
|
378
|
+
if (savedProvider === undefined) delete process.env.CLOUD_PROVIDER;
|
|
379
|
+
else process.env.CLOUD_PROVIDER = savedProvider;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Render a Markdown summary (best-effort, same as scan_host).
|
|
383
|
+
let markdown = null;
|
|
384
|
+
try {
|
|
385
|
+
if (output.conclusion) {
|
|
386
|
+
markdown = buildMarkdownReport({ host: output.host, conclusion: output.conclusion, toolVersion: TOOL_VERSION });
|
|
387
|
+
}
|
|
388
|
+
} catch { /* swallow — markdown is best-effort */ }
|
|
389
|
+
|
|
390
|
+
// Anti-false-clean: a requested cloud is "audited" ONLY if >=1 of its plugins
|
|
391
|
+
// actually completed (manifest status 'ran'). Surface every NOT-effectively-
|
|
392
|
+
// audited provider with a specific reason — never a silent empty "clean".
|
|
393
|
+
const providerStatus = output.providerStatus || {};
|
|
394
|
+
const auditedProviders = output.auditedProviders || [];
|
|
395
|
+
const notes = [];
|
|
396
|
+
for (const p of providers) {
|
|
397
|
+
const s = providerStatus[p] || { available: 0, ran: 0, skipped: 0, errored: 0 };
|
|
398
|
+
if (s.ran > 0) continue; // effectively audited (clean or not)
|
|
399
|
+
if (s.available === 0) {
|
|
400
|
+
notes.push(`${p}: no cloud plugins available — Enterprise EE plugins not installed (an empty result is NOT a clean pass)`);
|
|
401
|
+
} else if (s.errored > 0) {
|
|
402
|
+
notes.push(`${p}: ${s.errored} plugin(s) ran but errored (e.g. credentials not configured) — NOT audited (an empty result is NOT a clean pass)`);
|
|
403
|
+
} else {
|
|
404
|
+
notes.push(`${p}: ${s.skipped} plugin(s) present but skipped (requirements/capability not met) — NOT audited`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Honest count: completed audits, NOT error/skip envelopes.
|
|
409
|
+
const pluginsRan = (output.manifest || []).filter((m) => m.status === 'ran').length;
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
providers,
|
|
413
|
+
audited: auditedProviders.length > 0,
|
|
414
|
+
auditedProviders,
|
|
415
|
+
conclusion: output.conclusion ?? null,
|
|
416
|
+
manifest: output.manifest ?? [],
|
|
417
|
+
pluginsRan,
|
|
418
|
+
markdown,
|
|
419
|
+
...(notes.length ? { notes } : {}),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
316
423
|
export async function handleProbeService(args) {
|
|
317
424
|
if (!args?.host || typeof args.host !== 'string') {
|
|
318
425
|
throw new Error('Missing required parameter: host');
|
|
@@ -371,6 +478,7 @@ export async function handleListPlugins() {
|
|
|
371
478
|
/** Map tool name to handler. Exported for testing. */
|
|
372
479
|
export const toolHandlers = {
|
|
373
480
|
scan_host: handleScanHost,
|
|
481
|
+
scan_cloud: handleScanCloud,
|
|
374
482
|
probe_service: handleProbeService,
|
|
375
483
|
get_vulnerabilities: handleGetVulnerabilities,
|
|
376
484
|
list_plugins: handleListPlugins,
|
|
@@ -429,6 +537,19 @@ export function createServer() {
|
|
|
429
537
|
}
|
|
430
538
|
}
|
|
431
539
|
|
|
540
|
+
// Gate the Enterprise cloud-audit tool at the MCP dispatch layer.
|
|
541
|
+
if (name === 'scan_cloud') {
|
|
542
|
+
const denied = requireEnterpriseCapability(name);
|
|
543
|
+
if (denied) {
|
|
544
|
+
return {
|
|
545
|
+
...denied,
|
|
546
|
+
content: denied.content.map((c) =>
|
|
547
|
+
c.type === 'text' ? { ...c, text: appendCallSentinel(c.text, callId) } : c,
|
|
548
|
+
),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
432
553
|
try {
|
|
433
554
|
const result = await handler(args ?? {});
|
|
434
555
|
|
package/package.json
CHANGED
package/plugin_manager.mjs
CHANGED
|
@@ -20,7 +20,8 @@ import { pathToFileURL, fileURLToPath } from "url";
|
|
|
20
20
|
import { discoverPlugins } from './utils/plugin_discovery.mjs';
|
|
21
21
|
import { getTierFromEnv } from './utils/license.mjs';
|
|
22
22
|
import { resolveCapabilities } from './utils/capabilities.mjs';
|
|
23
|
-
import { scopeSelectionForHost } from './utils/sentinel_scope.mjs';
|
|
23
|
+
import { scopeSelectionForHost, scopeSelectionForProviders } from './utils/sentinel_scope.mjs';
|
|
24
|
+
import { mapLimit } from './utils/concurrency.mjs';
|
|
24
25
|
|
|
25
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
27
|
|
|
@@ -145,9 +146,15 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
|
|
|
145
146
|
// Forward CLI-derived opts (ports, etc.) so plugins can honor flags like --ports.
|
|
146
147
|
// CLI opts come last so they don't override critical orchestration fields like
|
|
147
148
|
// `context` if the CLI ever accidentally collides on those names.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
// Promise.resolve().then(...) so a plugin that throws SYNCHRONOUSLY (before its
|
|
150
|
+
// first await) becomes a rejected promise the race below catches, instead of
|
|
151
|
+
// propagating out of callPlugin and aborting the whole (sequential or parallel) batch.
|
|
152
|
+
const pluginPromise = Promise.resolve().then(() => mod.run(host, port, { ...cliOpts, context: withBaseContext(ctx), ...extra }));
|
|
153
|
+
|
|
154
|
+
// Honor a per-run timeout (cloud path) but clamp to a positive number; an
|
|
155
|
+
// undefined (network path) or non-positive value falls back to PLUGIN_TIMEOUT_MS.
|
|
156
|
+
const reqTimeout = Number(cliOpts && cliOpts.timeoutMs);
|
|
157
|
+
const timeoutMs = Number.isFinite(reqTimeout) && reqTimeout > 0 ? reqTimeout : PLUGIN_TIMEOUT_MS;
|
|
151
158
|
let timer;
|
|
152
159
|
const timeoutPromise = new Promise((_, reject) => {
|
|
153
160
|
timer = setTimeout(() => reject(new Error(`Plugin "${mod.name}" timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
@@ -852,6 +859,99 @@ export class PluginManager {
|
|
|
852
859
|
ai_out_path: null,
|
|
853
860
|
};
|
|
854
861
|
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Concurrent executor for cloud plugins. Mirrors _runOrchestrated's per-plugin
|
|
865
|
+
* logic (requirements + capability skip checks, callPlugin, manifest status)
|
|
866
|
+
* but runs with bounded parallelism and a per-run timeout — cloud plugins are
|
|
867
|
+
* independent (no sequential context chain), so each gets its OWN base ctx and
|
|
868
|
+
* there is no shared-mutation race. Returns { results, manifest } in plugin order.
|
|
869
|
+
*/
|
|
870
|
+
async _runCloudPluginsParallel(selected, host, opts = {}) {
|
|
871
|
+
const concurrency = Number(process.env.CLOUD_SCAN_CONCURRENCY) || 20;
|
|
872
|
+
const rawTimeout = Number(process.env.CLOUD_PLUGIN_TIMEOUT_MS);
|
|
873
|
+
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 25000;
|
|
874
|
+
const toRun = selected.filter((p) => !isConcluder(p));
|
|
875
|
+
|
|
876
|
+
const entries = await mapLimit(toRun, concurrency, async (mod) => {
|
|
877
|
+
try {
|
|
878
|
+
const ctx = withBaseContext({ host, hostUp: false, tcpOpen: new Set(), udpOpen: new Set() });
|
|
879
|
+
if (!shouldRunPlugin(mod, ctx)) {
|
|
880
|
+
return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'skipped', reason: describeSkipReason(mod, ctx), duration_ms: 0 }, outputs: [] };
|
|
881
|
+
}
|
|
882
|
+
if (!this._hasCapabilities(mod, opts?.capabilities)) {
|
|
883
|
+
return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'skipped', reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`, duration_ms: 0 }, outputs: [] };
|
|
884
|
+
}
|
|
885
|
+
const startMs = Date.now();
|
|
886
|
+
const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs });
|
|
887
|
+
const duration_ms = Date.now() - startMs;
|
|
888
|
+
let status = 'ran';
|
|
889
|
+
let reason = null;
|
|
890
|
+
for (const w of wrappedRuns) {
|
|
891
|
+
if (w.result?.timedOut) { status = 'timeout'; reason = w.result.error || `timed out after ${timeoutMs}ms`; }
|
|
892
|
+
else if (w.result?.error && status !== 'timeout') { status = 'error'; reason = w.result.error; }
|
|
893
|
+
}
|
|
894
|
+
return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status, reason, duration_ms }, outputs: wrappedRuns };
|
|
895
|
+
} catch (err) {
|
|
896
|
+
return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'error', reason: String(err?.message || err), duration_ms: 0 }, outputs: [] };
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
return { results: entries.flatMap((e) => e.outputs), manifest: entries.map((e) => e.manifest) };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Cloud-account audit: scope the plugin run to the union of the given cloud
|
|
905
|
+
* providers (by each plugin's `cloudProvider` field), with no network host.
|
|
906
|
+
* Sibling of run() that scopes by provider-set instead of by sentinel host,
|
|
907
|
+
* then reuses the same orchestrate + conclude path. Returns the run()-shaped
|
|
908
|
+
* output. The cloud plugins ignore `host`; the synthetic label just identifies
|
|
909
|
+
* the report. An empty selection returns an empty result (surfaced as a note
|
|
910
|
+
* by the caller) rather than a silent clean.
|
|
911
|
+
*
|
|
912
|
+
* @param {string[]} providers e.g. ['aws'] or ['aws','azure']
|
|
913
|
+
* @param {object} [opts]
|
|
914
|
+
*/
|
|
915
|
+
async runCloud(providers, opts = {}) {
|
|
916
|
+
const reqProviders = (providers || []).map((p) => String(p).trim().toLowerCase());
|
|
917
|
+
const host = `cloud:${reqProviders.join('+') || 'none'}`;
|
|
918
|
+
const all = this.plugins.slice();
|
|
919
|
+
const { selected } = scopeSelectionForProviders(all, reqProviders);
|
|
920
|
+
|
|
921
|
+
// Seed per-provider accounting for EVERY requested provider, so a provider
|
|
922
|
+
// with zero plugins is visible (available:0), not just silently absent.
|
|
923
|
+
const providerStatus = {};
|
|
924
|
+
for (const p of reqProviders) providerStatus[p] = { available: 0, ran: 0, skipped: 0, errored: 0 };
|
|
925
|
+
|
|
926
|
+
if (selected.length === 0) {
|
|
927
|
+
// Parity with the sentinel-host path in run(): an empty scope is NOT a clean pass.
|
|
928
|
+
console.error(
|
|
929
|
+
`WARNING: scan_cloud scope [${reqProviders.join(', ') || '(none)'}] matched 0 cloud plugins — ` +
|
|
930
|
+
`NOTHING was audited (an empty result is NOT a clean pass).`,
|
|
931
|
+
);
|
|
932
|
+
return { host, results: [], conclusion: null, manifest: [], providerStatus, auditedProviders: [],
|
|
933
|
+
ai: null, ai_meta: null, ai_error: null, ai_out_path: null };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const orch = await this._runCloudPluginsParallel(selected, host, opts);
|
|
937
|
+
const conclusion = await this.runConcluder(orch.results);
|
|
938
|
+
|
|
939
|
+
// Per-provider effectiveness from the manifest (ids match the selected set).
|
|
940
|
+
const manifestById = new Map((orch.manifest || []).map((m) => [String(m.id), m]));
|
|
941
|
+
for (const p of selected) {
|
|
942
|
+
const prov = p.cloudProvider;
|
|
943
|
+
if (!providerStatus[prov]) providerStatus[prov] = { available: 0, ran: 0, skipped: 0, errored: 0 };
|
|
944
|
+
providerStatus[prov].available++;
|
|
945
|
+
const m = manifestById.get(String(p.id));
|
|
946
|
+
if (m && m.status === 'ran') providerStatus[prov].ran++;
|
|
947
|
+
else if (m && m.status === 'skipped') providerStatus[prov].skipped++;
|
|
948
|
+
else providerStatus[prov].errored++;
|
|
949
|
+
}
|
|
950
|
+
const auditedProviders = Object.keys(providerStatus).filter((p) => providerStatus[p].ran > 0);
|
|
951
|
+
|
|
952
|
+
return { host, results: orch.results, conclusion, manifest: orch.manifest, providerStatus, auditedProviders,
|
|
953
|
+
ai: null, ai_meta: null, ai_error: null, ai_out_path: null };
|
|
954
|
+
}
|
|
855
955
|
}
|
|
856
956
|
|
|
857
957
|
export default PluginManager;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// utils/concurrency.mjs
|
|
2
|
+
//
|
|
3
|
+
// Minimal bounded-concurrency runner: run fn(item, index) over items with at
|
|
4
|
+
// most `limit` in flight, returning results in input order. A thrown fn rejects
|
|
5
|
+
// the whole call — callers that need per-item isolation should catch inside fn.
|
|
6
|
+
|
|
7
|
+
export async function mapLimit(items, limit, fn) {
|
|
8
|
+
const list = Array.isArray(items) ? items : [];
|
|
9
|
+
const n = list.length;
|
|
10
|
+
const out = new Array(n);
|
|
11
|
+
const cap = Math.max(1, Math.min((limit | 0) || 1, n || 1));
|
|
12
|
+
let next = 0;
|
|
13
|
+
async function worker() {
|
|
14
|
+
while (next < n) {
|
|
15
|
+
const i = next++;
|
|
16
|
+
out[i] = await fn(list[i], i);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
await Promise.all(Array.from({ length: Math.min(cap, n) }, () => worker()));
|
|
20
|
+
return out;
|
|
21
|
+
}
|
package/utils/sentinel_scope.mjs
CHANGED
|
@@ -40,3 +40,24 @@ export function scopeSelectionForHost(selection, host, spec) {
|
|
|
40
40
|
}
|
|
41
41
|
return { selected, skipped, scoped: true, provider };
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Multi-cloud generalization of scopeSelectionForHost: scope a resolved plugin
|
|
46
|
+
* selection to the union of the given providers (by each plugin's cloudProvider
|
|
47
|
+
* field). Used by pluginManager.runCloud() / the MCP scan_cloud tool. Network
|
|
48
|
+
* plugins (no cloudProvider) and non-requested clouds land in `skipped`.
|
|
49
|
+
*
|
|
50
|
+
* @param {Array<object>} selection resolved plugin objects (each may have .cloudProvider)
|
|
51
|
+
* @param {string[]} providers e.g. ['aws'] or ['aws','azure']
|
|
52
|
+
* @returns {{selected: object[], skipped: object[], providers: string[]}}
|
|
53
|
+
*/
|
|
54
|
+
export function scopeSelectionForProviders(selection, providers) {
|
|
55
|
+
const set = new Set((providers || []).map((p) => String(p).trim().toLowerCase()));
|
|
56
|
+
const selected = [];
|
|
57
|
+
const skipped = [];
|
|
58
|
+
for (const p of selection) {
|
|
59
|
+
if (p && p.cloudProvider && set.has(p.cloudProvider)) selected.push(p);
|
|
60
|
+
else skipped.push(p);
|
|
61
|
+
}
|
|
62
|
+
return { selected, skipped, providers: [...set] };
|
|
63
|
+
}
|