nsauditor-ai 0.2.22 → 0.2.23
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/cli.mjs +91 -20
- package/mcp_server.mjs +38 -1
- package/package.json +1 -1
- package/plugin_manager.mjs +91 -4
- package/utils/ai_stage.mjs +129 -0
- package/utils/scan_history.mjs +4 -0
- package/utils/sentinel_scope.mjs +41 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ 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.2.
|
|
20
|
+
**Latest: CE 0.2.23 + Enterprise 0.31.9** (July 2026) — **operator bug-fix cycle (real CE engine/CLI/MCP change).** Fixes three operator-reported bugs from a live test: **(BUG2b) cloud-scan scope integrity** — cloud auditor plugins ran during a `--host 192.168.1.1` **network** scan; a cloud auditor now runs **only** on its own cloud sentinel (`aws`/`gcp`/`azure`), never on a network host, never via credentials-in-env, and never via an explicit `--plugins`/MCP `probe_service` selection (`--host` is the sole cloud-intent signal); **(BUG1) AI-conclusion robustness** — the AI conclusion no longer aborts on cloud-scale payloads (payload-scaled timeout, 120s→600s, `NSA_AI_TIMEOUT_MS`-overridable) and writes a **visible** `scan_response_ai.txt` stub on failure instead of failing quiet; **(BUG2a)** the AI bail message names the unresolved API key instead of misreporting "AI_ENABLED=false". Externally reviewed across multiple rounds → 🟢 PASS. Paired **Enterprise 0.31.9** (GAP-1 #3 RoC positive-substrate render-site parity). No new framework, no new plugins (still 28), all seven coverage matrices unchanged. Requires CE 0.2.8+. Full detail in **[CHANGELOG.md](./CHANGELOG.md)**.
|
|
21
21
|
|
|
22
22
|
→ See a sample EE scan output: **[walk-through with synthetic Acme Corp AWS account](https://www.nsauditor.com/ai/docs/sample-scan/)** (no signup required)
|
|
23
23
|
|
|
@@ -66,6 +66,16 @@ If you're heading into a **SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS C
|
|
|
66
66
|
→ **[See sample EE scan output](https://www.nsauditor.com/ai/docs/sample-scan/)** — full evidence pack against synthetic Acme Corp AWS account (no signup required)
|
|
67
67
|
→ **[Buy NSAuditor AI Enterprise Edition](https://www.nsauditor.com/ai/pricing/)** — $2k / $5k / $10k+ per year for 5 / 25 / unlimited seats + custom SLA. Onboarding call included.
|
|
68
68
|
|
|
69
|
+
### Prefer to buy through AWS Marketplace?
|
|
70
|
+
|
|
71
|
+
Enterprise Edition is also available as an **[AWS Marketplace container listing](https://aws.amazon.com/marketplace/pp?sku=etar8knc8dx7bshizrrnnjbzi)** — same product, same local ES256 license key, billed through your AWS account (consolidated billing / EDP drawdown, procurement-friendly; custom Enterprise terms via AWS Private Offers). *Launching now in limited availability — if the listing shows "not available," it hasn't gone fully public yet: [contact us](https://www.nsauditor.com/support.html) for immediate access via a Private Offer.*
|
|
72
|
+
|
|
73
|
+
How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS dependency at scan time):
|
|
74
|
+
|
|
75
|
+
1. **Subscribe** on the listing (tiers `base` / `growth` / `scale` mirror the 5 / 25 / unlimited-seat plans).
|
|
76
|
+
2. **Register** your email + AWS account ID at the URL shown in the listing's usage instructions — your **ES256 license key arrives by email**.
|
|
77
|
+
3. **Pull and run** the Docker image from the Marketplace registry (commands in the listing's usage instructions and your license email) with `NSAUDITOR_LICENSE_KEY=<your key>`. One tier-agnostic image — upgrades are just a new license key, never a new image. The container runs fully offline after that; billing lives in AWS, enforcement is your local key.
|
|
78
|
+
|
|
69
79
|
### Feature comparison
|
|
70
80
|
|
|
71
81
|
| | Community (Free) | Pro ($49/mo) | Enterprise ($2k+/yr) |
|
package/cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { buildSarifLog } from './utils/sarif.mjs';
|
|
|
18
18
|
import { buildCsv } from './utils/export_csv.mjs';
|
|
19
19
|
import { buildMarkdownReport } from './utils/report_md.mjs';
|
|
20
20
|
import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
|
|
21
|
+
import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
|
|
21
22
|
import { getTierFromEnv, loadLicense } from './utils/license.mjs';
|
|
22
23
|
import { resolveCapabilities, hasCapability, inferRequiredTier } from './utils/capabilities.mjs';
|
|
23
24
|
import { createScheduler } from './utils/scheduler.mjs';
|
|
@@ -185,7 +186,8 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
185
186
|
console.warn('[OpenAI] No conclusion.summary available; skipping.');
|
|
186
187
|
return {
|
|
187
188
|
file_paths: { folder: outDir, plain: null, ai_json: null, raw_json: null, html: null, admin_html: null },
|
|
188
|
-
ai_conclusion: null
|
|
189
|
+
ai_conclusion: null,
|
|
190
|
+
ai_status: 'no_summary',
|
|
189
191
|
};
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -306,10 +308,13 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
306
308
|
// --- Bail out early if sending disabled -----------------------------------
|
|
307
309
|
const providerLabel = aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
|
|
308
310
|
if (!sendEnabled || !key) {
|
|
309
|
-
|
|
311
|
+
// BUG2(a): distinguish "AI disabled" from "AI enabled but key unresolved" —
|
|
312
|
+
// the old single message misdiagnosed a keychain/env key miss as AI_ENABLED=false.
|
|
313
|
+
console.log(aiBailMessage({ sendEnabled, key, aiProvider, model }));
|
|
310
314
|
return {
|
|
311
315
|
file_paths: { folder: outDir, plain: null, ai_json: null, raw_json: adminRawPath, html: null, admin_html: adminHtmlPath },
|
|
312
|
-
ai_conclusion: null
|
|
316
|
+
ai_conclusion: null,
|
|
317
|
+
ai_status: sendEnabled ? 'key_unresolved' : 'skipped',
|
|
313
318
|
};
|
|
314
319
|
}
|
|
315
320
|
|
|
@@ -335,14 +340,32 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
335
340
|
|
|
336
341
|
// --- Send to AI provider ---------------------------------------------------
|
|
337
342
|
let aiConclusionText = null;
|
|
343
|
+
// review fold 8: contain the serialization — hoisting userContent above the
|
|
344
|
+
// try (so the catch can name the timeout) must not let a JSON.stringify throw
|
|
345
|
+
// (e.g. a circular payload) escape maybeSendToOpenAI uncaught. (payloadForAI is
|
|
346
|
+
// already serialized once at the aiPayloadPath write above, so this is
|
|
347
|
+
// defensive; the fallback keeps the timeout math + stub path alive.)
|
|
348
|
+
let userContent;
|
|
349
|
+
try {
|
|
350
|
+
userContent = `Scan payload:\n${JSON.stringify(payloadForAI, null, 2)}`;
|
|
351
|
+
} catch (e) {
|
|
352
|
+
console.warn(`[${providerLabel}] Failed to serialize AI payload:`, e?.message || e);
|
|
353
|
+
userContent = `Scan payload:\n${String(payloadForAI?.summary ?? '(unserializable payload)')}`;
|
|
354
|
+
}
|
|
355
|
+
// AbortController timeout — prevents the pipeline hanging on a stalled AI provider.
|
|
356
|
+
// BUG1: scale with payload size (cloud-scale 28-40KB payloads exceeded the old
|
|
357
|
+
// flat 120s default and aborted); an explicit NSA_AI_TIMEOUT_MS still wins.
|
|
358
|
+
// Hoisted above the try so the catch's fail-visible stub can name the timeout.
|
|
359
|
+
const AI_TIMEOUT_MS = computeAiTimeoutMs({
|
|
360
|
+
payloadBytes: Buffer.byteLength(userContent, 'utf8'), // optional fold: UTF-8 bytes, not UTF-16 units
|
|
361
|
+
host,
|
|
362
|
+
envOverride: process.env.NSA_AI_TIMEOUT_MS,
|
|
363
|
+
});
|
|
338
364
|
try {
|
|
339
365
|
console.log(`[${providerLabel}] Sending summary, model:`, model);
|
|
340
366
|
|
|
341
367
|
let resp;
|
|
342
|
-
const userContent = `Scan payload:\n${JSON.stringify(payloadForAI, null, 2)}`;
|
|
343
368
|
|
|
344
|
-
// AbortController timeout — prevents the pipeline hanging on a stalled AI provider.
|
|
345
|
-
const AI_TIMEOUT_MS = Number(process.env.NSA_AI_TIMEOUT_MS) || 120_000; // 2 min default
|
|
346
369
|
const ac = new AbortController();
|
|
347
370
|
const aiTimer = setTimeout(() => ac.abort(), AI_TIMEOUT_MS);
|
|
348
371
|
|
|
@@ -350,7 +373,9 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
350
373
|
if (aiProvider === 'claude') {
|
|
351
374
|
// --- Claude (Anthropic) ---
|
|
352
375
|
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
353
|
-
|
|
376
|
+
// R-3: maxRetries:0 so the outer AbortController/timeout can't race the
|
|
377
|
+
// SDK's own internal retry loop (which would multiply the wall-clock).
|
|
378
|
+
const client = new Anthropic({ apiKey: key, maxRetries: 0 });
|
|
354
379
|
|
|
355
380
|
resp = await client.messages.create({
|
|
356
381
|
model,
|
|
@@ -359,7 +384,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
359
384
|
messages: [
|
|
360
385
|
{ role: 'user', content: userContent }
|
|
361
386
|
]
|
|
362
|
-
}, { signal: ac.signal });
|
|
387
|
+
}, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
|
|
363
388
|
|
|
364
389
|
console.log(`[${providerLabel}] Response id:`, resp?.id ?? '(unknown)');
|
|
365
390
|
|
|
@@ -373,7 +398,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
373
398
|
// --- Ollama (OpenAI-compatible API) ---
|
|
374
399
|
const { default: OpenAI } = await import('openai');
|
|
375
400
|
const ollamaBase = process.env.OLLAMA_BASE_URL || 'http://localhost:11434/v1';
|
|
376
|
-
const client = new OpenAI({ baseURL: ollamaBase, apiKey: key });
|
|
401
|
+
const client = new OpenAI({ baseURL: ollamaBase, apiKey: key, maxRetries: 0 }); // R-3
|
|
377
402
|
|
|
378
403
|
resp = await client.chat.completions.create({
|
|
379
404
|
model,
|
|
@@ -381,7 +406,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
381
406
|
{ role: 'system', content: promptText },
|
|
382
407
|
{ role: 'user', content: userContent }
|
|
383
408
|
]
|
|
384
|
-
}, { signal: ac.signal });
|
|
409
|
+
}, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
|
|
385
410
|
|
|
386
411
|
console.log(`[${providerLabel}] Response id:`, resp?.id ?? '(unknown)');
|
|
387
412
|
|
|
@@ -389,7 +414,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
389
414
|
} else {
|
|
390
415
|
// --- OpenAI ---
|
|
391
416
|
const { default: OpenAI } = await import('openai');
|
|
392
|
-
const client = new OpenAI({ apiKey: key });
|
|
417
|
+
const client = new OpenAI({ apiKey: key, maxRetries: 0 }); // R-3
|
|
393
418
|
|
|
394
419
|
if (client.responses?.create) {
|
|
395
420
|
resp = await client.responses.create({
|
|
@@ -398,7 +423,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
398
423
|
{ role: 'system', content: promptText },
|
|
399
424
|
{ role: 'user', content: userContent }
|
|
400
425
|
]
|
|
401
|
-
}, { signal: ac.signal });
|
|
426
|
+
}, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
|
|
402
427
|
} else if (client.chat?.completions?.create) {
|
|
403
428
|
resp = await client.chat.completions.create({
|
|
404
429
|
model,
|
|
@@ -406,7 +431,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
406
431
|
{ role: 'system', content: promptText },
|
|
407
432
|
{ role: 'user', content: userContent }
|
|
408
433
|
]
|
|
409
|
-
}, { signal: ac.signal });
|
|
434
|
+
}, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
|
|
410
435
|
} else {
|
|
411
436
|
throw new Error('OpenAI SDK: neither responses.create nor chat.completions.create is available.');
|
|
412
437
|
}
|
|
@@ -491,13 +516,17 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
491
516
|
html: aiHtmlPath,
|
|
492
517
|
admin_html: adminHtmlPath
|
|
493
518
|
},
|
|
494
|
-
ai_conclusion: aiConclusionText
|
|
519
|
+
ai_conclusion: aiConclusionText,
|
|
520
|
+
// review fold D: a 200 with no extractable text is NOT "ok" — the
|
|
521
|
+
// end-of-scan summary must not read "OK" when there is no conclusion.
|
|
522
|
+
ai_status: (typeof aiConclusionText === 'string' && aiConclusionText.trim()) ? 'ok' : 'empty',
|
|
495
523
|
};
|
|
496
524
|
} catch (err) {
|
|
497
|
-
|
|
525
|
+
const errMsg = String(err?.message || err);
|
|
526
|
+
console.error(`[${providerLabel}] Send failed:`, err?.stack || errMsg);
|
|
498
527
|
try {
|
|
499
528
|
await fsp.writeFile(aiErrPath, JSON.stringify({
|
|
500
|
-
error:
|
|
529
|
+
error: errMsg,
|
|
501
530
|
stack: err?.stack || null,
|
|
502
531
|
provider: aiProvider,
|
|
503
532
|
model
|
|
@@ -507,16 +536,38 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
507
536
|
console.warn(`[${providerLabel}] Also failed to write error file:`, e?.message || e);
|
|
508
537
|
}
|
|
509
538
|
|
|
539
|
+
// BUG1 (fail-VISIBLE): also write a human-readable scan_response_ai.txt stub
|
|
540
|
+
// so the operator sees WHY there is no AI conclusion (the JSON error file +
|
|
541
|
+
// the console.error above both scroll away in a 3-cloud run). Names the error
|
|
542
|
+
// and, on a timeout/abort, the NSA_AI_TIMEOUT_MS remedy.
|
|
543
|
+
let aiFailureStubPath = null;
|
|
544
|
+
try {
|
|
545
|
+
await fsp.writeFile(aiTxtPath, aiFailureStubText({
|
|
546
|
+
host, model, providerLabel, errorMessage: errMsg,
|
|
547
|
+
timeoutMs: AI_TIMEOUT_MS, whenIso: new Date().toISOString(),
|
|
548
|
+
}), 'utf8');
|
|
549
|
+
aiFailureStubPath = aiTxtPath;
|
|
550
|
+
console.log(`[${providerLabel}] Wrote AI failure stub:`, aiTxtPath);
|
|
551
|
+
} catch (e) {
|
|
552
|
+
console.warn(`[${providerLabel}] Also failed to write AI failure stub:`, e?.message || e);
|
|
553
|
+
}
|
|
554
|
+
|
|
510
555
|
return {
|
|
511
556
|
file_paths: {
|
|
512
557
|
folder: outDir,
|
|
558
|
+
// review fold 16: keep `plain` NULL on failure — a downstream consumer
|
|
559
|
+
// uses `plain != null` as an AI-success proxy. The visible stub is
|
|
560
|
+
// surfaced on its own `ai_failure_stub` key + via ai_status/ai_error.
|
|
513
561
|
plain: null,
|
|
514
562
|
ai_json: null,
|
|
563
|
+
ai_failure_stub: aiFailureStubPath,
|
|
515
564
|
raw_json: adminRawPath,
|
|
516
565
|
html: null,
|
|
517
566
|
admin_html: adminHtmlPath
|
|
518
567
|
},
|
|
519
|
-
ai_conclusion: null
|
|
568
|
+
ai_conclusion: null,
|
|
569
|
+
ai_status: 'failed',
|
|
570
|
+
ai_error: errMsg,
|
|
520
571
|
};
|
|
521
572
|
}
|
|
522
573
|
}
|
|
@@ -705,14 +756,17 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
705
756
|
}
|
|
706
757
|
} catch { /* EE not installed — CE proceeds unchanged */ }
|
|
707
758
|
|
|
708
|
-
const { file_paths: ai_file_paths, ai_conclusion } = await maybeSendToOpenAI({ host, results, conclusion, promptMode, outDir });
|
|
759
|
+
const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error } = await maybeSendToOpenAI({ host, results, conclusion, promptMode, outDir });
|
|
760
|
+
// BUG1 (fail-VISIBLE): a one-line end-of-scan AI status so a failed/aborted AI
|
|
761
|
+
// conclusion is not silently swallowed under the per-stage logs.
|
|
762
|
+
console.log(aiSummaryLine({ host, ai_status, ai_error }));
|
|
709
763
|
|
|
710
764
|
// --- Scan history: record & compare ---
|
|
711
765
|
let scanDiff = null;
|
|
712
766
|
try {
|
|
713
767
|
const outRoot = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out').replace(/\.[^/.]+$/, '') || 'out';
|
|
714
768
|
const services = conclusion?.result?.services ?? [];
|
|
715
|
-
const
|
|
769
|
+
const serviceFindingsCount = services.reduce((n, svc) => {
|
|
716
770
|
if (svc.anonymousLogin === true) n++;
|
|
717
771
|
if (svc.axfrAllowed === true) n++;
|
|
718
772
|
if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
|
|
@@ -721,6 +775,15 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
721
775
|
if (Array.isArray(cves)) n += cves.length;
|
|
722
776
|
return n;
|
|
723
777
|
}, 0);
|
|
778
|
+
// review fold R-1: cloud plugins emit findings on `results[].result.findings`,
|
|
779
|
+
// NOT as service-level attrs — so a cloud (--host aws) scan recorded
|
|
780
|
+
// findingsCount:0 in scan_history over a 201-finding scan (a false-clean
|
|
781
|
+
// history channel). Roll cloud findings into findingsCount + surface the split.
|
|
782
|
+
const cloudFindingsCount = (results || []).reduce((n, r) => {
|
|
783
|
+
const f = r?.result?.findings;
|
|
784
|
+
return n + (Array.isArray(f) ? f.length : 0);
|
|
785
|
+
}, 0);
|
|
786
|
+
const findingsCount = serviceFindingsCount + cloudFindingsCount;
|
|
724
787
|
|
|
725
788
|
const scanSummary = {
|
|
726
789
|
timestamp: new Date().toISOString(),
|
|
@@ -729,6 +792,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
729
792
|
openPorts: services.filter((s) => s.status === 'open').map((s) => s.port),
|
|
730
793
|
os: conclusion?.result?.host?.os ?? null,
|
|
731
794
|
findingsCount,
|
|
795
|
+
cloudFindingsCount,
|
|
732
796
|
services: services.map((s) => ({
|
|
733
797
|
port: s.port, protocol: s.protocol ?? 'tcp',
|
|
734
798
|
service: s.service ?? null, version: s.version ?? null,
|
|
@@ -755,7 +819,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
755
819
|
console.warn('[ScanHistory] Failed to record/compare scan:', err?.message || err);
|
|
756
820
|
}
|
|
757
821
|
|
|
758
|
-
return { host, results, conclusion, ai_file_paths, ai_conclusion, scanDiff };
|
|
822
|
+
return { host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff };
|
|
759
823
|
}
|
|
760
824
|
|
|
761
825
|
/* -------------------- CI/CD severity threshold helpers ------------------- */
|
|
@@ -930,6 +994,13 @@ Cloud-scan hosts:
|
|
|
930
994
|
single cloud and is therefore skipped under this
|
|
931
995
|
auto-scope; to run it, select it explicitly
|
|
932
996
|
(--plugins 023,...) or scan a network host/CIDR.
|
|
997
|
+
INVERSE (cloud scope integrity): a cloud auditor runs
|
|
998
|
+
ONLY on its own sentinel host. On a NETWORK host (IP /
|
|
999
|
+
CIDR / hostname) the cloud-scanner plugins NEVER run —
|
|
1000
|
+
not via --plugins all, not via an explicit --plugins
|
|
1001
|
+
1020, and not because cloud credentials are present in
|
|
1002
|
+
the environment. '--host' is the sole cloud-scan
|
|
1003
|
+
trigger; credentials are a capability, not intent.
|
|
933
1004
|
|
|
934
1005
|
Examples:
|
|
935
1006
|
nsauditor-ai scan --host 10.0.0.1 --plugins all
|
package/mcp_server.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
ListToolsRequestSchema,
|
|
24
24
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
25
25
|
import { buildRegionIntent } from './utils/region_intent.mjs';
|
|
26
|
+
import { excludeMismatchedCloudPlugins, isCloudSentinelHost } from './utils/sentinel_scope.mjs';
|
|
26
27
|
import { getTierFromEnv, loadLicense } from './utils/license.mjs';
|
|
27
28
|
import { resolveCapabilities } from './utils/capabilities.mjs';
|
|
28
29
|
import { buildMarkdownReport } from './utils/report_md.mjs';
|
|
@@ -343,6 +344,13 @@ export async function validateHost(host) {
|
|
|
343
344
|
throw new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
// Cloud-sentinel hosts (aws/gcp/azure) are scoping tokens, NOT DNS names — they
|
|
348
|
+
// route to the cloud-scanner plugins (probe_service enforces the cloud-intent
|
|
349
|
+
// gate downstream; the CLI whitelists them the same way in scanSingleHost).
|
|
350
|
+
// Skip the SSRF DNS resolution for them so the sentinel path is reachable
|
|
351
|
+
// (otherwise dns.lookup('aws') ENOTFOUND is misread as a blocked address).
|
|
352
|
+
if (isCloudSentinelHost(h)) return h;
|
|
353
|
+
|
|
346
354
|
// DNS resolution check — catches rebinding, decimal/octal IPs, IPv6-mapped addrs.
|
|
347
355
|
// NSA_ALLOW_ALL_HOSTS=1 bypasses RFC 1918 checks for local network auditing.
|
|
348
356
|
if (!process.env.NSA_ALLOW_ALL_HOSTS) {
|
|
@@ -564,7 +572,36 @@ export async function handleProbeService(args) {
|
|
|
564
572
|
throw new Error(`Unknown plugin: ${args.pluginName}`);
|
|
565
573
|
}
|
|
566
574
|
|
|
567
|
-
|
|
575
|
+
// M-1 (BUG2b): probe_service is the LIVE MCP single-plugin route (dispatches
|
|
576
|
+
// via _runOne). It must honor the same cloud-intent contract as run()/runByName
|
|
577
|
+
// — a cloud auditor runs IFF the host is ITS sentinel; a network host (or the
|
|
578
|
+
// wrong cloud) must NEVER trigger it, even with cloud creds in the env.
|
|
579
|
+
const gate = excludeMismatchedCloudPlugins([plugin], host);
|
|
580
|
+
if (gate.skipped.length) {
|
|
581
|
+
const need = plugin.cloudProvider;
|
|
582
|
+
throw new Error(
|
|
583
|
+
`Plugin ${plugin.id} (${plugin.name}) is a ${need} cloud auditor — it does not run against ` +
|
|
584
|
+
`${gate.sentinel ? `the '${gate.sentinel}' cloud` : `network host '${host}'`}. ` +
|
|
585
|
+
`For a cloud audit use the scan_cloud tool (providers: ["${need}"]), or call probe_service ` +
|
|
586
|
+
`with host: "${need}". Cloud auditors run only on their own cloud; credentials in the ` +
|
|
587
|
+
`environment are a capability, not an intent signal.`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
// M1-SSRF (fold): validateHost whitelists the cloud-sentinel literals past the
|
|
591
|
+
// SSRF DNS check. That is safe ONLY because a sentinel host must reach a CLOUD
|
|
592
|
+
// plugin (which ignores `host`). A NETWORK plugin (no cloudProvider) routed a
|
|
593
|
+
// sentinel host would socket.connect(port, 'aws') without SSRF re-validation —
|
|
594
|
+
// a bypass if the literal resolves internally. Reject it, restoring the
|
|
595
|
+
// invariant that scopeSelectionForHost enforces on the pm.run() path.
|
|
596
|
+
if (isCloudSentinelHost(host) && !plugin.cloudProvider) {
|
|
597
|
+
const s = String(host).trim().toLowerCase();
|
|
598
|
+
throw new Error(
|
|
599
|
+
`Plugin ${plugin.id} (${plugin.name}) is a network plugin — it does not run against the ` +
|
|
600
|
+
`'${s}' cloud sentinel. Use a network host/IP/CIDR, or a cloud auditor with host: "${s}".`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
|
|
604
|
+
const result = await pm._runOne(plugin, host, args.port, { hostKind });
|
|
568
605
|
return result;
|
|
569
606
|
}
|
|
570
607
|
|
package/package.json
CHANGED
package/plugin_manager.mjs
CHANGED
|
@@ -20,7 +20,7 @@ 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, scopeSelectionForProviders } from './utils/sentinel_scope.mjs';
|
|
23
|
+
import { scopeSelectionForHost, scopeSelectionForProviders, excludeMismatchedCloudPlugins, isCloudSentinelHost } from './utils/sentinel_scope.mjs';
|
|
24
24
|
import { mapLimit } from './utils/concurrency.mjs';
|
|
25
25
|
|
|
26
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -588,7 +588,25 @@ export class PluginManager {
|
|
|
588
588
|
return await this.runConcluder(resultsArg);
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
-
|
|
591
|
+
// BUG2(b): runByName (a public PluginManager entry) honors the same
|
|
592
|
+
// cloud-intent contract as run() — a cloud auditor runs ONLY on its own
|
|
593
|
+
// sentinel host — and threads opts.hostKind. Defense-in-depth for direct
|
|
594
|
+
// library consumers; the LIVE MCP single-plugin route (probe_service →
|
|
595
|
+
// _runOne) is guarded at its own call site in mcp_server.handleProbeService.
|
|
596
|
+
const rb = excludeMismatchedCloudPlugins([plugin], host);
|
|
597
|
+
if (rb.skipped.length) {
|
|
598
|
+
const need = plugin.cloudProvider;
|
|
599
|
+
console.error(
|
|
600
|
+
`Plugin ${plugin.id} (${plugin.name}) is a ${need} cloud auditor — it runs only on --host ${need} ` +
|
|
601
|
+
`(host '${host}' is ${rb.sentinel ? `the '${rb.sentinel}' cloud` : 'a network target'}). Skipping.`,
|
|
602
|
+
);
|
|
603
|
+
return { id: plugin.id, name: plugin.name, result: { up: false, skipped: true, data: [] } };
|
|
604
|
+
}
|
|
605
|
+
const rbOpts = {
|
|
606
|
+
...opts,
|
|
607
|
+
hostKind: isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network',
|
|
608
|
+
};
|
|
609
|
+
const arr = await this._runAcrossPorts(plugin, host, rbOpts);
|
|
592
610
|
const filtered = arr.filter(Boolean);
|
|
593
611
|
if (filtered.length === 0) return { id: plugin.id, name: plugin.name, result: { up: false, data: [] } };
|
|
594
612
|
if (filtered.length === 1) return filtered[0];
|
|
@@ -769,6 +787,17 @@ export class PluginManager {
|
|
|
769
787
|
opts = maybeOpts || {};
|
|
770
788
|
}
|
|
771
789
|
|
|
790
|
+
// BUG2(b): tag the host kind so plugins + the EE cloud gate can honor the
|
|
791
|
+
// "--host is the sole cloud-intent signal" contract as defense-in-depth
|
|
792
|
+
// (alongside the network-host exclusion below). Cloned so the caller's opts
|
|
793
|
+
// object — shared across concurrent hosts in the CLI multi-host path — is
|
|
794
|
+
// never mutated; hostKind stays a scalar string so the shallow opts spread
|
|
795
|
+
// in callPlugin (:152) / _runOne (:517) forwards it verbatim to every plugin.
|
|
796
|
+
opts = {
|
|
797
|
+
...opts,
|
|
798
|
+
hostKind: isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network',
|
|
799
|
+
};
|
|
800
|
+
|
|
772
801
|
// Sentinel-host scoping: on --host aws|gcp|azure with the implicit `all`,
|
|
773
802
|
// run only that cloud's plugins; skip other clouds + non-cloud plugins.
|
|
774
803
|
const scope = scopeSelectionForHost(selection, host, rawSpec);
|
|
@@ -790,16 +819,70 @@ export class PluginManager {
|
|
|
790
819
|
}
|
|
791
820
|
}
|
|
792
821
|
|
|
822
|
+
// BUG2(b): a cloud auditor (any plugin with a `cloudProvider` tag) runs IFF
|
|
823
|
+
// the host is ITS sentinel — the symmetric inverse of the sentinel scoping
|
|
824
|
+
// above. Fires for the implicit `all` AND an explicit `--plugins 1020`
|
|
825
|
+
// selection: a network scan must NEVER audit a cloud estate (even with cloud
|
|
826
|
+
// creds in the env), and a `--host gcp` scan must not run an explicitly-listed
|
|
827
|
+
// AWS auditor. '--host' is the sole cloud-intent signal — no escape hatch.
|
|
828
|
+
const netScope = excludeMismatchedCloudPlugins(selection, host);
|
|
829
|
+
// review fold R-5: excluded cloud plugins are removed from `selection` before
|
|
830
|
+
// dispatch, so without a manifest entry they leave no machine-readable trace
|
|
831
|
+
// in pm.run()'s result. (The requirements/capability skip classes DO push a
|
|
832
|
+
// manifest entry; the sentinel-scope skip at :812 does not — a separate
|
|
833
|
+
// pre-existing gap, not addressed here.) A `skipped` entry surfaces the
|
|
834
|
+
// exclusion to MCP scan_host responses + pm.run() library consumers (the CLI
|
|
835
|
+
// does not currently render the manifest). One entry per excluded plugin.
|
|
836
|
+
const cloudSkipManifest = netScope.skipped.map((p) => ({
|
|
837
|
+
id: String(p?.id || ''),
|
|
838
|
+
name: p?.name || 'Plugin',
|
|
839
|
+
status: 'skipped',
|
|
840
|
+
reason: `${p?.cloudProvider || 'cloud'} cloud auditor requires --host ${p?.cloudProvider || 'aws|gcp|azure'} ` +
|
|
841
|
+
`(host '${host}' is ${netScope.sentinel ? `the '${netScope.sentinel}' cloud` : 'a network target'})`,
|
|
842
|
+
duration_ms: 0,
|
|
843
|
+
}));
|
|
844
|
+
if (netScope.excludedCloud && netScope.skipped.length) {
|
|
845
|
+
selection = netScope.selected;
|
|
846
|
+
const ids = netScope.skipped.map((p) => p?.id ?? '?').join(', ');
|
|
847
|
+
if (netScope.sentinel) {
|
|
848
|
+
// foreign-cloud plugin explicitly selected on a mismatched sentinel host
|
|
849
|
+
const foreign = [...new Set(netScope.skipped.map((p) => p?.cloudProvider).filter(Boolean))].join('/');
|
|
850
|
+
console.error(
|
|
851
|
+
`Host '${host}' is the '${netScope.sentinel}' cloud → skipping ${netScope.skipped.length} ` +
|
|
852
|
+
`non-${netScope.sentinel} cloud auditor(s) [${ids}] (${foreign} auditors require --host ${foreign}). ` +
|
|
853
|
+
`A cloud auditor runs only on its own sentinel host — '--host' is the sole cloud-scan trigger.`,
|
|
854
|
+
);
|
|
855
|
+
} else {
|
|
856
|
+
console.error(
|
|
857
|
+
`Host '${host}' is a network target → skipping ${netScope.skipped.length} cloud auditor(s) ` +
|
|
858
|
+
`[${ids}]: cloud plugins require a cloud sentinel host (--host aws|gcp|azure). ` +
|
|
859
|
+
`Credentials in the environment are a capability, not an intent signal — '--host' is the ` +
|
|
860
|
+
`sole cloud-scan trigger. (To audit a cloud, run e.g. \`--host aws\`.)`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
// review fold 4: if the exclusion emptied the selection, say so loudly — an
|
|
864
|
+
// empty result is NOT a clean pass (mirrors the sentinel zero-match warning).
|
|
865
|
+
if (selection.length === 0) {
|
|
866
|
+
console.error(
|
|
867
|
+
`WARNING: after excluding cloud auditors for host '${host}', ZERO plugins remain — ` +
|
|
868
|
+
`NOTHING was audited (an empty result is NOT a clean pass). To audit a cloud, use --host aws|gcp|azure.`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
793
873
|
// Decide execution mode
|
|
794
874
|
const anyOrchestratedSignals = selection.some((p) => p?.priority != null || p?.requirements != null);
|
|
795
875
|
const orchestrate = opts.orchestrate !== false && anyOrchestratedSignals;
|
|
796
876
|
|
|
797
877
|
let results = [];
|
|
798
878
|
let manifest = [];
|
|
879
|
+
// R-5: surface excluded cloud auditors as skipped manifest entries (before
|
|
880
|
+
// the dispatched plugins), so the exclusion is machine-visible, not stderr-only.
|
|
881
|
+
if (cloudSkipManifest.length) manifest.push(...cloudSkipManifest);
|
|
799
882
|
if (orchestrate) {
|
|
800
883
|
const orch = await this._runOrchestrated(host, selection, opts);
|
|
801
884
|
results = orch.results;
|
|
802
|
-
manifest = orch.manifest;
|
|
885
|
+
manifest = manifest.concat(orch.manifest);
|
|
803
886
|
} else {
|
|
804
887
|
// Legacy path: simple run across ports for each plugin (except concluder)
|
|
805
888
|
const toRun = selection.filter((p) => !isConcluder(p));
|
|
@@ -884,7 +967,11 @@ export class PluginManager {
|
|
|
884
967
|
return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'skipped', reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`, duration_ms: 0 }, outputs: [] };
|
|
885
968
|
}
|
|
886
969
|
const startMs = Date.now();
|
|
887
|
-
|
|
970
|
+
// BUG2(b) fold B: thread hostKind on the MCP/cloud-parallel path too, so
|
|
971
|
+
// the defense-in-depth signal is present on EVERY cloud dispatch route
|
|
972
|
+
// (selection here is already scoped to each plugin's cloudProvider).
|
|
973
|
+
const cloudHostKind = mod.cloudProvider ? `cloud:${mod.cloudProvider}` : opts.hostKind;
|
|
974
|
+
const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs, hostKind: cloudHostKind });
|
|
888
975
|
const duration_ms = Date.now() - startMs;
|
|
889
976
|
let status = 'ran';
|
|
890
977
|
let reason = null;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// utils/ai_stage.mjs
|
|
2
|
+
//
|
|
3
|
+
// Pure, testable helpers for the CLI AI-conclusion stage (cli.mjs
|
|
4
|
+
// maybeSendToOpenAI). Extracted so the BUG1 (payload-scaled timeout +
|
|
5
|
+
// fail-visible failure) and BUG2(a) (disabled-vs-key bail message) logic can be
|
|
6
|
+
// unit-tested without importing the CLI entrypoint. No I/O, no side effects.
|
|
7
|
+
|
|
8
|
+
/** Display label for an AI provider id. */
|
|
9
|
+
export function aiProviderLabel(aiProvider) {
|
|
10
|
+
return aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The env var an operator sets for a provider's key (null for keyless ollama). */
|
|
14
|
+
export function aiKeyEnvVar(aiProvider) {
|
|
15
|
+
if (aiProvider === 'claude') return 'ANTHROPIC_API_KEY';
|
|
16
|
+
if (aiProvider === 'ollama') return null;
|
|
17
|
+
return 'OPENAI_API_KEY';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* BUG2(a): the AI-stage bail message. The old code printed a single
|
|
22
|
+
* "AI_ENABLED=false; not sending" for BOTH `!sendEnabled` and `!key`, so an
|
|
23
|
+
* operator who set AI_ENABLED=true but whose key failed to resolve (unset env or
|
|
24
|
+
* a missing `keychain:<label>` entry) was told AI was disabled — a misdiagnosis.
|
|
25
|
+
* This distinguishes the two, and on a key miss names the provider-specific env
|
|
26
|
+
* var + the keychain-reference possibility.
|
|
27
|
+
*
|
|
28
|
+
* @returns {string} the log line to print at the bail site.
|
|
29
|
+
*/
|
|
30
|
+
export function aiBailMessage({ sendEnabled, key, aiProvider, model }) {
|
|
31
|
+
const label = aiProviderLabel(aiProvider);
|
|
32
|
+
if (!sendEnabled) {
|
|
33
|
+
return `[${label}] AI_ENABLED is not set to true — skipping AI conclusion. Model=${model}`;
|
|
34
|
+
}
|
|
35
|
+
// sendEnabled === true but the key did not resolve.
|
|
36
|
+
const envVar = aiKeyEnvVar(aiProvider);
|
|
37
|
+
const ref = envVar
|
|
38
|
+
? `\`${envVar}\` is unset/empty, or its \`keychain:<label>\` reference did not resolve`
|
|
39
|
+
: 'the provider key did not resolve';
|
|
40
|
+
return `[${label}] AI_ENABLED=true but the API key could not be resolved — ${ref}. Skipping AI conclusion. Model=${model}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* BUG1: scale the AI-call timeout with the payload size. The fixed 120s
|
|
45
|
+
* AbortController aborted cloud-scale scans (AWS 28KB / Azure 40KB payloads with
|
|
46
|
+
* 200+ findings) whose 16k-token conclusions take longer than 2 min to generate,
|
|
47
|
+
* while a small 4-5KB network-host payload finished well inside 120s. An explicit
|
|
48
|
+
* NSA_AI_TIMEOUT_MS (envOverride) always wins. Otherwise: floor 120s (preserves
|
|
49
|
+
* network-host behavior), +15s per KB of payload, ceiling 600s (10 min).
|
|
50
|
+
*
|
|
51
|
+
* @param {{payloadBytes?: number, host?: string, envOverride?: string|number}} o
|
|
52
|
+
* @returns {number} timeout in ms.
|
|
53
|
+
*/
|
|
54
|
+
export function computeAiTimeoutMs({ payloadBytes = 0, host = '', envOverride } = {}) {
|
|
55
|
+
const explicit = Number(envOverride);
|
|
56
|
+
// optional fold: an explicit override wins, truncated to an integer ms (a
|
|
57
|
+
// fractional value would otherwise be passed verbatim to setTimeout/the SDK).
|
|
58
|
+
if (Number.isFinite(explicit) && explicit > 0) return Math.trunc(explicit);
|
|
59
|
+
const FLOOR_MS = 120_000; // 2 min — unchanged for small network-host payloads
|
|
60
|
+
const CEIL_MS = 600_000; // 10 min hard cap
|
|
61
|
+
const MS_PER_KB = 15_000;
|
|
62
|
+
const kb = Math.max(0, Number(payloadBytes) || 0) / 1024;
|
|
63
|
+
const scaled = FLOOR_MS + Math.round(kb * MS_PER_KB);
|
|
64
|
+
return Math.min(CEIL_MS, Math.max(FLOOR_MS, scaled));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* BUG1 (fail-VISIBLE): on an AI failure the pipeline used to write only a JSON
|
|
69
|
+
* error file + a console.error that scrolls away in a 3-cloud run, so the
|
|
70
|
+
* operator just saw "no AI conclusion". This produces a human-readable
|
|
71
|
+
* scan_response_ai.txt body that names the error and — when it looks like a
|
|
72
|
+
* timeout/abort — the NSA_AI_TIMEOUT_MS remedy.
|
|
73
|
+
*
|
|
74
|
+
* @returns {string} the scan_response_ai.txt body for the failure case.
|
|
75
|
+
*/
|
|
76
|
+
export function aiFailureStubText({ host, model, providerLabel, errorMessage, timeoutMs, whenIso }) {
|
|
77
|
+
// review fold 9 + R-3: match the local AbortController abort ("Request was
|
|
78
|
+
// aborted.") AND the SDK request-timeout ("Request timed out." — the
|
|
79
|
+
// APIConnectionTimeoutError the SDK `timeout` option raises, racing the
|
|
80
|
+
// AbortController per fold-10) — both cases NSA_AI_TIMEOUT_MS addresses. We do
|
|
81
|
+
// NOT match the raw undici "Connect Timeout Error" / socket ETIMEDOUT strings
|
|
82
|
+
// (raising the local timeout is the wrong remedy for a TCP-connect failure).
|
|
83
|
+
// Caveat: the Anthropic SDK reclassifies a connect-timeout as "Request timed
|
|
84
|
+
// out." — so on the Claude path a connect failure may still surface the remedy;
|
|
85
|
+
// that is acceptable (a wrapped SDK timeout is plausibly the local window).
|
|
86
|
+
const aborted = /\babort|request timed out/i.test(String(errorMessage || ''));
|
|
87
|
+
const lines = [
|
|
88
|
+
`Model: ${model}`,
|
|
89
|
+
`Provider: ${providerLabel}`,
|
|
90
|
+
`When: ${whenIso}`,
|
|
91
|
+
`Host: ${host}`,
|
|
92
|
+
``,
|
|
93
|
+
`==== ${providerLabel} Conclusion: FAILED ====`,
|
|
94
|
+
`The AI conclusion could NOT be generated for this scan.`,
|
|
95
|
+
`Error: ${errorMessage}`,
|
|
96
|
+
];
|
|
97
|
+
if (aborted) {
|
|
98
|
+
const secs = Math.round((Number(timeoutMs) || 0) / 1000);
|
|
99
|
+
lines.push(
|
|
100
|
+
``,
|
|
101
|
+
`This looks like a TIMEOUT — the request was aborted${secs ? ` after ~${secs}s` : ''}. Large`,
|
|
102
|
+
`cloud-scan payloads can exceed the AI timeout. Re-run with a longer timeout, e.g.:`,
|
|
103
|
+
` NSA_AI_TIMEOUT_MS=600000 nsauditor-ai --host <target> ...`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One-line end-of-scan summary so an AI failure/skip is visible without scrolling
|
|
111
|
+
* the per-stage logs. `ai_status` covers EVERY value maybeSendToOpenAI returns:
|
|
112
|
+
* 'ok' — a conclusion was generated
|
|
113
|
+
* 'empty' — provider returned 200 but no extractable text (NOT ok)
|
|
114
|
+
* 'skipped' — AI disabled (AI_ENABLED not true)
|
|
115
|
+
* 'key_unresolved'— AI enabled but the API key did not resolve
|
|
116
|
+
* 'no_summary' — no scan summary to send
|
|
117
|
+
* 'failed' — the provider call threw (carries ai_error as the cause)
|
|
118
|
+
* Any unknown status falls to the FAILED branch (fail-visible default).
|
|
119
|
+
*/
|
|
120
|
+
export function aiSummaryLine({ host, ai_status, ai_error }) {
|
|
121
|
+
switch (ai_status) {
|
|
122
|
+
case 'ok': return `AI conclusion: OK (${host})`;
|
|
123
|
+
case 'empty': return `AI conclusion: EMPTY — provider returned no text (${host})`;
|
|
124
|
+
case 'skipped': return `AI conclusion: SKIPPED — AI_ENABLED not set (${host})`;
|
|
125
|
+
case 'key_unresolved': return `AI conclusion: SKIPPED — API key could not be resolved (${host})`;
|
|
126
|
+
case 'no_summary': return `AI conclusion: SKIPPED — no scan summary to send (${host})`;
|
|
127
|
+
default: return `AI conclusion: FAILED (${host})${ai_error ? ` — ${ai_error}` : ''}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/utils/scan_history.mjs
CHANGED
|
@@ -31,6 +31,10 @@ export async function recordScan(outputDir, summary) {
|
|
|
31
31
|
openPorts: Array.isArray(summary.openPorts) ? summary.openPorts : [],
|
|
32
32
|
os: summary.os ?? null,
|
|
33
33
|
findingsCount: summary.findingsCount ?? 0,
|
|
34
|
+
// review re-fold R-1: persist the cloud/service split so a cloud (--host aws)
|
|
35
|
+
// scan's findings are machine-visible in history (findingsCount already
|
|
36
|
+
// includes them; this surfaces how many came from cloud auditors).
|
|
37
|
+
cloudFindingsCount: summary.cloudFindingsCount ?? 0,
|
|
34
38
|
services: Array.isArray(summary.services) ? summary.services.map((s) => ({
|
|
35
39
|
port: s.port ?? null,
|
|
36
40
|
protocol: s.protocol ?? 'tcp',
|
package/utils/sentinel_scope.mjs
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
// (`aws`/`gcp`/`azure`) and the plugin selection is the IMPLICIT `all`, run only
|
|
5
5
|
// the plugins tagged with that provider (`plugin.cloudProvider`), skipping the
|
|
6
6
|
// other clouds + the non-cloud network plugins (which would otherwise probe the
|
|
7
|
-
// literal string "aws" and emit unreachable-host noise).
|
|
8
|
-
//
|
|
7
|
+
// literal string "aws" and emit unreachable-host noise).
|
|
8
|
+
//
|
|
9
|
+
// scopeSelectionForHost honors an EXPLICIT plugin selection as-is (it only scopes
|
|
10
|
+
// the implicit `all`). The CLOUD-INTENT contract itself, however, is NOT waivable
|
|
11
|
+
// by explicit selection: excludeMismatchedCloudPlugins (BUG2b) strips any cloud
|
|
12
|
+
// auditor whose cloudProvider does not match the host's sentinel — including an
|
|
13
|
+
// explicitly-listed foreign-cloud plugin and every cloud plugin on a network host.
|
|
14
|
+
// '--host' is the sole cloud-scan trigger; credentials/selection are not intent.
|
|
9
15
|
|
|
10
16
|
export const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
|
|
11
17
|
|
|
@@ -41,6 +47,39 @@ export function scopeSelectionForHost(selection, host, spec) {
|
|
|
41
47
|
return { selected, skipped, scoped: true, provider };
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/**
|
|
51
|
+
* BUG2(b) (operator-confirmed contract 2026-07-03): the INVERSE of
|
|
52
|
+
* scopeSelectionForHost — a cloud auditor plugin runs IF AND ONLY IF the host is
|
|
53
|
+
* ITS OWN cloud sentinel. `--host` is the sole cloud-intent signal; credentials
|
|
54
|
+
* in the environment are a capability, never intent; there is NO escape hatch
|
|
55
|
+
* (not the implicit `all`, not an explicit `--plugins 1020` selection).
|
|
56
|
+
*
|
|
57
|
+
* Strips every cloud-tagged plugin whose `cloudProvider` does NOT match the
|
|
58
|
+
* host's sentinel:
|
|
59
|
+
* - NETWORK host (IP / CIDR / hostname → sentinel === null): ALL cloud plugins
|
|
60
|
+
* are foreign → all stripped (the reported router-scan bug).
|
|
61
|
+
* - SENTINEL host P (aws/gcp/azure): a foreign-cloud plugin (e.g. an AWS
|
|
62
|
+
* auditor explicitly selected on `--host gcp`) is stripped; P's own plugins
|
|
63
|
+
* and non-cloud plugins are kept. (Complements scopeSelectionForHost, which
|
|
64
|
+
* only scopes the implicit `all` — this also covers explicit selections.)
|
|
65
|
+
*
|
|
66
|
+
* @param {Array<object>} selection resolved plugin objects (each may have .cloudProvider)
|
|
67
|
+
* @param {string} host
|
|
68
|
+
* @returns {{selected: object[], skipped: object[], sentinel: string|null, excludedCloud: boolean}}
|
|
69
|
+
*/
|
|
70
|
+
export function excludeMismatchedCloudPlugins(selection, host) {
|
|
71
|
+
const sentinel = isCloudSentinelHost(host) ? String(host).trim().toLowerCase() : null;
|
|
72
|
+
const selected = [];
|
|
73
|
+
const skipped = [];
|
|
74
|
+
for (const p of selection) {
|
|
75
|
+
// non-cloud plugins (no cloudProvider) always survive; a cloud plugin
|
|
76
|
+
// survives only when the host IS its sentinel.
|
|
77
|
+
if (p && p.cloudProvider && p.cloudProvider !== sentinel) skipped.push(p);
|
|
78
|
+
else selected.push(p);
|
|
79
|
+
}
|
|
80
|
+
return { selected, skipped, sentinel, excludedCloud: skipped.length > 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
/**
|
|
45
84
|
* Multi-cloud generalization of scopeSelectionForHost: scope a resolved plugin
|
|
46
85
|
* selection to the union of the given providers (by each plugin's cloudProvider
|