nsauditor-ai 0.2.23 → 0.2.25

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 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.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)**.
20
+ **Enterprise GRC push activation + AWS positive-substrate evidence uplift.** The Enterprise **Vanta** push connector is now wired for scan-time activation, a **Drata** connector library ships alongside it (Secureframe on the roadmap), and AWS PASS-tier findings now surface as positive substrate evidence in the compliance RoC. See the **[GRC Connectors](#grc-connectors-vanta--drata)** section below, and **[CHANGELOG.md](./CHANGELOG.md)** for the full per-release history.
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
 
@@ -58,7 +58,7 @@ If you're heading into a **SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS C
58
58
  - **GDPR Article 32 (Security of Processing)** (Regulation (EU) 2016/679) — **4 covered + 5 partial + 2 OOS across 11 Art. 32 sub-measure units**; **GDPR Article 32 infrastructure substrate only — NOT GDPR compliance** (GDPR is a 99-article legal regime; Art. 32 security-of-processing is the only article an infrastructure scanner can substrate-evidence; the rest is operator-side, out of scope by design). Four-factor proportionality (substrate *for* your "appropriate to the risk" determination, never an absolute pass/fail); personal-data-scope attestation (pair with your Art. 30 records of processing); **Art. 83(4) lower fine tier** (€10M/2%, not the €20M/4% headline tier); Art. 32(3)/Art. 42 cloud-provider certification-inheritance
59
59
  - 🔐 **Cryptographically signed evidence** — SHA-256 chain-of-custody + RFC 3161 trusted timestamps + Ed25519 suppression signing. Non-repudiation, not just integrity. Auditors can verify offline.
60
60
  - 🏛️ **Zero Data Exfiltration architecture** — your scan data never leaves your infrastructure. Air-gapped deployment supported. AI analysis happens locally (Ollama) or via your own API keys. Important for PCI DSS CDE-isolation threat models.
61
- - 🔗 **Native GRC platform integration** — push evidence directly to **Vanta** (live; Drata + Secureframe planned). Idempotent retries, per-tenant token rotation, rate-limit handling, signed-envelope round-trip integrity.
61
+ - 🔗 **GRC connectors — Vanta + Drata (Enterprise)** — map compliance findings to your GRC platform's evidence/test records and push them at **scan time** (opt-in). Suppression-aware outcome mapping, idempotent retries, rate-limit handling, token redaction, and Zero-Data-Exfiltration egress redaction. Secureframe on the roadmap; live validation against production tenants is in progress. See **[GRC Connectors](#grc-connectors-vanta--drata)** below.
62
62
  - 🗄️ **WORM evidence storage** — S3 Object Lock COMPLIANCE-mode for SEC Rule 17a-4(f) / FINRA 4511 retention compliance
63
63
  - 📊 **SLA / MTTR tracking + recurring-scan attestation** — the **Type II operating-effectiveness evidence** auditors actually demand (not just point-in-time snapshots)
64
64
  - 🎯 **11 adversarial-audit Claude Code skills** authored per the Per-Framework Adversarial-Audit Skill Pairing institutional pattern — Phase-4 Compliance/GRC chain 8-of-8 COMPLETE for all shipped frameworks (SOC 2 + HIPAA + NIST CSF + PCI DSS + ISO 27001 + CIS Controls v8 + GDPR Article 32 + GRC connector)
@@ -110,7 +110,7 @@ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS
110
110
  | Recurring-scan attestation (Type II operating-effectiveness) | — | — | ✅ |
111
111
  | WORM evidence storage (S3 Object Lock — SEC 17a-4 / FINRA 4511) | — | — | ✅ |
112
112
  | **Enterprise — integration + deployment** | | | |
113
- | GRC platform connector (Vanta live; Drata + Secureframe planned) | — | — | ✅ |
113
+ | GRC connectors Vanta + Drata push (scan-time, opt-in); Secureframe planned | — | — | ✅ |
114
114
  | Tabletop simulation + SIEM correlation | — | — | ✅ |
115
115
  | Docker per-scan isolation | — | — | ✅ |
116
116
  | Air-gapped deployment | — | — | ✅ |
@@ -121,6 +121,35 @@ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS
121
121
 
122
122
  ---
123
123
 
124
+ ## GRC Connectors (Vanta & Drata)
125
+
126
+ *Enterprise feature. Requires `@nsasoft/nsauditor-ai-ee`.*
127
+
128
+ Every compliance scan already produces a GRC-ready JSON evidence artifact. The **GRC connectors** take the next step: they map each NSAuditor compliance finding to your GRC platform's own evidence/test records and **push them at scan time** — so your Vanta or Drata workspace reflects the latest cloud posture without a manual export/import round-trip.
129
+
130
+ **Opt-in, and Zero-Data-Exfiltration by default.** The push is off unless you set the environment variables below. When it runs, egress is redaction-gated: resource identifiers can be hashed or removed, the persisted audit log stores a body **fingerprint** (never the raw payload), and your API token is never written to any artifact. Nothing leaves your infrastructure that you didn't opt into.
131
+
132
+ ```bash
133
+ # Enable the scan-time push (Enterprise)
134
+ COMPLIANCE_GRC_PROVIDER=vanta # or: drata
135
+ COMPLIANCE_GRC_TOKEN=<your API key> # never serialized to artifacts
136
+ # Optional:
137
+ # COMPLIANCE_GRC_REDACTION=hash # off | hash | remove (egress identifier redaction)
138
+ # COMPLIANCE_GRC_CONTROL_MAP=/path/to/config.json # provider config: Vanta control→test map, or Drata connection ({connectionId, resourceId, schemaMap})
139
+ ```
140
+
141
+ | Platform | Status | Model |
142
+ |---|---|---|
143
+ | **Vanta** | Connector + scan-time activation shipped | Maps findings to Vanta test results; suppression-aware outcome mapping (pass / fail / passed-with-compensating-control), framework-dimensioned idempotency keys, retry with rate-limit backoff, circuit breaker |
144
+ | **Drata** | Connector library shipped | Pushes structured records via Drata **Custom Connections**; your Drata **Test Builder** rules (Advanced/Enterprise plans) evaluate them — the connector delivers evidence, your rules do the evaluation |
145
+ | **Secureframe** | On the roadmap | — |
146
+
147
+ **Reliability + audit-integrity built in:** idempotent retries (a network-timed-out push won't create duplicate records), per-attempt + total-duration timeout caps, a consecutive-failure circuit breaker, token redaction across every log and error path, and a durable per-control push audit log written next to your scan artifacts.
148
+
149
+ > **Honest status.** The Vanta and Drata connectors are shipped, opt-in, and covered by an extensive test suite. **Live validation against production Vanta / Drata tenants is in progress** as partner onboarding proceeds — until it completes, treat production use as early-access and validate against your own tenant first. This is a single-workspace, operator-configured connector; it is not a multi-tenant managed sync.
150
+
151
+ ---
152
+
124
153
  ## Quick Start
125
154
 
126
155
  ```bash
@@ -277,7 +306,7 @@ Results land in `./out/<host>_<timestamp>/`:
277
306
  | — | **GDPR Article 32 Compliance Engine (NEW EE 0.20.0)** | Enterprise | **GDPR Article 32 (Security of Processing)** infrastructure substrate (Regulation (EU) 2016/679) — **4 covered + 5 partial + 2 OOS across 11 Art. 32 sub-measure units** (the 7th framework). **This is GDPR Article 32 infrastructure substrate ONLY — NOT GDPR compliance.** GDPR is a 99-article legal regime; Art. 32 security-of-processing is the only article whose evidence is technical infrastructure state, so the rest of GDPR (lawful basis, consent, DSARs, records of processing, DPIAs, transfers) is operator-side and out of scope by design. **Four-factor proportionality** — Art. 32 measures are "appropriate to the risk" taking into account state-of-the-art / cost / nature-scope-context-purposes / risk; nothing is an absolute pass/fail, the engine produces substrate *for* the operator's determination. **Personal-data-scope attestation** — the scanner reads configuration, not data classification; a finding is an Art. 32 concern only if the resource processes personal data (pair with your Art. 30 records of processing). Controller-vs-processor role applicability + Art. 28 processor agreements. **Art. 83(4) lower fine tier** — Art. 32 infringements cap at €10M or 2% of turnover, NOT the €20M / 4% Art. 83(5) headline tier (which is for the basic principles + data-subject rights). Art. 32(3)/Art. 42 cloud-provider certification-inheritance (ISO 27001 / SOC 2 / C5 / EU Cloud CoC adherence as an element of demonstrable compliance, not a substitute). Use `--compliance gdpr` or any combination for multi-framework reports from a single scan. |
278
307
  | — | SLA & MTTR Tracking | Enterprise | Per-severity SLA targets, compensating-control flow, finding lifecycle, Type II rolling-quarter cadence. |
279
308
  | — | Recurring-Scan Attestation | Enterprise | Multi-scan chronological matrix, cadence gap detection, scope-drift surface (CC8.1). |
280
- | — | GRC Platform Connector | Enterprise | Native API push to Vanta / Drata / Secureframe with retry/backoff, idempotency, rate-limit handling, per-tenant token rotation. |
309
+ | — | GRC Platform Connector | Enterprise | Vanta + Drata connectors (Secureframe planned) scan-time push (opt-in), retry/backoff, deterministic idempotency, rate-limit handling, circuit breaker, foreign-token detection, ZDE egress redaction. See [GRC Connectors](#grc-connectors-vanta--drata). |
281
310
  | — | WORM Evidence Storage | Enterprise | S3 Object Lock COMPLIANCE-mode + resource redaction + SHA-256 manifest. SEC 17a-4 / FINRA 4511 retention-compatible. |
282
311
  | — | Tabletop Simulation | Enterprise | Probe-event manifest + SIEM detection correlation, configurable coverage bands (Type II / High-Assurance presets). |
283
312
 
package/cli.mjs CHANGED
@@ -912,7 +912,8 @@ Usage:
912
912
 
913
913
  Scan options:
914
914
  --host, --ip, --target <h> Target host, IP, or CIDR
915
- --host-file <path> File with one host per line
915
+ --host-file <path> File with one host per line (cloud sentinels in the
916
+ file reconcile CLOUD_PROVIDER the same as --host)
916
917
  --env <path> Load a dotenv (KEY=value) file for this scan (per-account
917
918
  credentials). Override-on; missing file = hard error.
918
919
  --aws-profile <name> Use a named profile from the OS-default ~/.aws/credentials.
@@ -984,7 +985,11 @@ Cloud-scan hosts:
984
985
  characters — your shell treats | as a pipe. Sentinels are
985
986
  not DNS-resolved; they route the scan to the matching
986
987
  cloud-scanner plugins via the provider's control-plane
987
- API, and imply CLOUD_PROVIDER=<host> when unset. With
988
+ API, and imply CLOUD_PROVIDER to the cloud leg(s) when
989
+ unset ('aws,gcp,azure' → CLOUD_PROVIDER=aws,gcp,azure).
990
+ A CLOUD_PROVIDER already pinned to a cloud that does NOT
991
+ cover every requested leg is a hard error (fail-fast),
992
+ not a silent skip of the uncovered legs. With
988
993
  --plugins all the scan AUTO-SCOPES to only that cloud's
989
994
  plugins (plugins not applicable to this host are skipped +
990
995
  logged — other-cloud plugins run on their OWN --host pass,
@@ -1028,6 +1033,16 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1028
1033
  console.error('Error: --aws-profile requires a profile name (e.g. --aws-profile prod)');
1029
1034
  process.exit(2);
1030
1035
  }
1036
+ // Resolve the scan host list up-front (scan command only) so the CLOUD_PROVIDER
1037
+ // reconcile below (resolveScanEnv) sees --host-file cloud-sentinel legs too. With
1038
+ // --host-file the `host` arg is undefined, so without this a host-file of cloud
1039
+ // sentinels would bypass the fail-fast/imply reconcile entirely. Reused as the
1040
+ // scan target below (no double-parse); a parse failure is left to the scan-time
1041
+ // resolution so the existing error path + exit code are unchanged.
1042
+ let resolvedHosts = null;
1043
+ if (cmd === 'scan' && hostFile) {
1044
+ try { resolvedHosts = await parseHostFile(hostFile); } catch { /* defer to scan-time resolution */ }
1045
+ }
1031
1046
  try {
1032
1047
  const { resolveScanEnv } = await import('./utils/env_loader.mjs');
1033
1048
  const fsm = await import('node:fs');
@@ -1035,6 +1050,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1035
1050
  envPath: typeof args.env === 'string' ? args.env : undefined,
1036
1051
  awsProfile: typeof args.awsProfile === 'string' ? args.awsProfile : undefined,
1037
1052
  host,
1053
+ hosts: resolvedHosts ?? undefined,
1038
1054
  env: process.env,
1039
1055
  fileExists: (p) => fsm.existsSync(p),
1040
1056
  readFile: (p) => fsm.readFileSync(p, 'utf8'),
@@ -1912,7 +1928,10 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1912
1928
  // Resolve host list
1913
1929
  let hosts;
1914
1930
  if (hostFile) {
1915
- hosts = await parseHostFile(hostFile);
1931
+ // Reuse the up-front resolution done for the CLOUD_PROVIDER reconcile (avoids a
1932
+ // second parse + duplicate suspicious-line warnings); re-parse only if that was
1933
+ // skipped/failed so a genuine host-file error still surfaces here as before.
1934
+ hosts = resolvedHosts ?? await parseHostFile(hostFile);
1916
1935
  } else if (host) {
1917
1936
  hosts = await parseHostArg(host);
1918
1937
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -728,9 +728,15 @@ export class PluginManager {
728
728
  const wrappedRuns = await callPlugin(mod, host, ctx, outputs, opts);
729
729
  const duration_ms = Date.now() - startMs;
730
730
 
731
- // Determine manifest status from the plugin results
731
+ // Determine manifest status from the plugin results. A gate-skip envelope
732
+ // ({ up:false, skipped:true, ... }) carries neither `timedOut` nor `error`,
733
+ // so classify it as 'skipped' (not 'ran') iff nothing actually ran and there
734
+ // was no timeout/error — otherwise a self-skipped plugin would be miscounted
735
+ // as audited. Timeout/error still win over a skip; a skip+real-run mix is 'ran'.
732
736
  let status = 'ran';
733
737
  let reason = null;
738
+ let sawRealRun = false;
739
+ let skipReason = null;
734
740
  for (const wrapped of wrappedRuns) {
735
741
  if (wrapped.result?.timedOut) {
736
742
  status = 'timeout';
@@ -738,8 +744,16 @@ export class PluginManager {
738
744
  } else if (wrapped.result?.error && status !== 'timeout') {
739
745
  status = 'error';
740
746
  reason = wrapped.result.error;
747
+ } else if (wrapped.result?.skipped === true) {
748
+ skipReason = wrapped.result.reason || wrapped.result.skipReason || 'skipped by plugin gate';
749
+ } else {
750
+ sawRealRun = true;
741
751
  }
742
752
  }
753
+ if (status === 'ran' && skipReason != null && !sawRealRun) {
754
+ status = 'skipped';
755
+ reason = skipReason;
756
+ }
743
757
 
744
758
  manifest.push({
745
759
  id: String(mod.id || ''),
@@ -973,12 +987,25 @@ export class PluginManager {
973
987
  const cloudHostKind = mod.cloudProvider ? `cloud:${mod.cloudProvider}` : opts.hostKind;
974
988
  const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs, hostKind: cloudHostKind });
975
989
  const duration_ms = Date.now() - startMs;
990
+ // Same classifier as _runOrchestrated: a gate-skip envelope
991
+ // ({ up:false, skipped:true, ... }) → 'skipped' (not 'ran') iff nothing ran
992
+ // and there was no timeout/error, so a self-skipped cloud is not counted as
993
+ // audited (auditedProviders derives from status==='ran' at :1034).
994
+ // NB: the `!sawRealRun` term is defensive-only on this path — cloud plugins
995
+ // are runStrategy:'single' (one wrappedRun), so a skip+real-run MIX cannot
996
+ // arise here (unlike the multi-port network path in _runOrchestrated, which
997
+ // has the mutation-pinned mix test). Kept for uniformity with that classifier.
976
998
  let status = 'ran';
977
999
  let reason = null;
1000
+ let sawRealRun = false;
1001
+ let skipReason = null;
978
1002
  for (const w of wrappedRuns) {
979
1003
  if (w.result?.timedOut) { status = 'timeout'; reason = w.result.error || `timed out after ${timeoutMs}ms`; }
980
1004
  else if (w.result?.error && status !== 'timeout') { status = 'error'; reason = w.result.error; }
1005
+ else if (w.result?.skipped === true) { skipReason = w.result.reason || w.result.skipReason || 'skipped by plugin gate'; }
1006
+ else { sawRealRun = true; }
981
1007
  }
1008
+ if (status === 'ran' && skipReason != null && !sawRealRun) { status = 'skipped'; reason = skipReason; }
982
1009
  return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status, reason, duration_ms }, outputs: wrappedRuns };
983
1010
  } catch (err) {
984
1011
  return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'error', reason: String(err?.message || err), duration_ms: 0 }, outputs: [] };
@@ -48,17 +48,43 @@ function providerMatchesHost(effective, hostProvider) {
48
48
  return tokens.includes(hostProvider);
49
49
  }
50
50
 
51
+ // The distinct cloud-sentinel legs implied by the scan target, in first-appearance
52
+ // order, lowercased + deduped. Mirrors the CLI's host-file-XOR-host dispatch
53
+ // precedence: a pre-resolved `hosts` array (the --host-file path) is the
54
+ // authoritative source when present; otherwise the `--host` string (single 'aws'
55
+ // or a CSV 'aws,gcp,azure') is used. The two are NEVER unioned — the scan
56
+ // dispatches exactly one source, so reconciling both would over-widen
57
+ // CLOUD_PROVIDER and could false-throw on a leg that is never dispatched.
58
+ // Non-sentinel tokens (IPs / CIDRs / hostnames) are ignored; [] means no cloud
59
+ // leg (a pure network scan needs no CLOUD_PROVIDER).
60
+ function sentinelLegs(host, hosts) {
61
+ const tokens = Array.isArray(hosts)
62
+ ? hosts.slice()
63
+ : (typeof host === 'string' ? host.split(',') : []);
64
+ const legs = [];
65
+ for (const raw of tokens) {
66
+ const t = String(raw).trim().toLowerCase();
67
+ if (CLOUD_SENTINELS.has(t) && !legs.includes(t)) legs.push(t);
68
+ }
69
+ return legs;
70
+ }
71
+
51
72
  /**
52
73
  * @param {object} args
53
74
  * @param {string} [args.envPath] value of --env
54
75
  * @param {string} [args.awsProfile] value of --aws-profile
55
- * @param {string} [args.host] --host value (for sentinel CLOUD_PROVIDER implication)
76
+ * @param {string} [args.host] --host value: a single sentinel ('aws'), a
77
+ * CSV of sentinels ('aws,gcp,azure'), or a
78
+ * network host — for CLOUD_PROVIDER implication
79
+ * @param {string[]} [args.hosts] pre-resolved host list (the --host-file path,
80
+ * where `host` is undefined); reconciled the
81
+ * same way as a CSV `host`
56
82
  * @param {object} args.env snapshot of current process.env (read-only)
57
83
  * @param {(p:string)=>boolean} args.fileExists
58
84
  * @param {(p:string)=>string} args.readFile
59
85
  * @returns {{set: Record<string,string>, unset: string[]}}
60
86
  */
61
- export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists, readFile }) {
87
+ export function resolveScanEnv({ envPath, awsProfile, host, hosts, env = {}, fileExists, readFile }) {
62
88
  const set = {};
63
89
  const unset = [];
64
90
 
@@ -79,6 +105,16 @@ export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists
79
105
  Object.assign(set, dotenv.parse(content));
80
106
  }
81
107
 
108
+ // Capture whether the OPERATOR (not the tool) pinned CLOUD_PROVIDER — from the
109
+ // --env file (step 1) or the shell env — BEFORE the --aws-profile implication
110
+ // in step 2. This is what decides throw-vs-imply for sentinel legs (step 3):
111
+ // • operator-pinned provider that misses a host leg → conflict → fail-fast;
112
+ // • unpinned (or only tool/profile-implied) → imply the UNION of the legs.
113
+ // Without this snapshot, --aws-profile's implied bare 'aws' (step 2) would look
114
+ // like an operator pin and wrongly THROW on a `--host aws,gcp,azure` run.
115
+ const operatorPinned = providerAlreadySet(set, env);
116
+ const operatorProvider = operatorPinned ? effectiveProvider(set, env) : '';
117
+
82
118
  // 2. --aws-profile: AWS_PROFILE wins; clear explicit keys; load ~/.aws/config; imply aws.
83
119
  if (typeof awsProfile === 'string' && awsProfile.length) {
84
120
  set.AWS_PROFILE = awsProfile;
@@ -90,23 +126,34 @@ export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists
90
126
  if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = 'aws';
91
127
  }
92
128
 
93
- // 3. Sentinel host implies its provider when CLOUD_PROVIDER is still unset.
94
- // Fail-fast on a host vs CLOUD_PROVIDER contradiction: if the effective
95
- // provider (--env file + shell env) is set to a cloud that does NOT match
96
- // the host, scoping would select host-plugins that then all self-skip on
97
- // the awsScanSkipReason gate ZERO plugins run a silent "clean" report.
98
- if (typeof host === 'string' && CLOUD_SENTINELS.has(host.trim().toLowerCase())) {
99
- const hostProvider = host.trim().toLowerCase();
100
- const effective = effectiveProvider(set, env);
101
- if (effective && !providerMatchesHost(effective, hostProvider)) {
102
- throw new Error(
103
- `--host '${hostProvider}' conflicts with CLOUD_PROVIDER='${effective}': the host and the ` +
104
- `effective cloud provider do not match, which would silently skip every plugin (an empty ` +
105
- `"clean" report). Resolve by dropping the conflicting CLOUD_PROVIDER, or scan the matching host ` +
106
- `(--host ${effective.split(',')[0].trim()}).`,
107
- );
129
+ // 3. Sentinel host leg(s) imply their provider(s) when the operator hasn't
130
+ // pinned CLOUD_PROVIDER; fail-fast when a pinned provider does NOT cover
131
+ // every leg. Handles a single sentinel ('aws'), a CSV ('aws,gcp,azure'),
132
+ // AND a --host-file resolved list (`hosts`) uniformly each is a set of
133
+ // legs. A leg that the effective provider does not cover would silently
134
+ // self-skip on the awsScanSkipReason/gcpScanSkipReason gate ZERO plugins
135
+ // run on that leg → a silent "clean" report for a whole cloud.
136
+ const legs = sentinelLegs(host, hosts);
137
+ if (legs.length) {
138
+ if (operatorPinned) {
139
+ const uncovered = legs.filter((leg) => !providerMatchesHost(operatorProvider, leg));
140
+ if (uncovered.length) {
141
+ throw new Error(
142
+ `--host '${legs.join(',')}' conflicts with CLOUD_PROVIDER='${operatorProvider}': the host ` +
143
+ `leg(s) [${uncovered.join(', ')}] are not covered by the effective cloud provider, so every ` +
144
+ `plugin on ${uncovered.length > 1 ? 'those legs' : 'that leg'} would silently self-skip (an ` +
145
+ `empty "clean" report). Resolve by dropping the conflicting CLOUD_PROVIDER, or set ` +
146
+ `CLOUD_PROVIDER to include ${uncovered.join(',')} (e.g. CLOUD_PROVIDER=${legs.join(',')}).`,
147
+ );
148
+ }
149
+ // pinned provider covers every leg → leave the operator's value untouched
150
+ } else {
151
+ // Unpinned (or only tool/profile-implied): imply the UNION of the legs so
152
+ // each cloud leg runs. This overrides a bare 'aws' that step 2's
153
+ // --aws-profile implication may have written — 'aws' ⊆ the union, and the
154
+ // aws leg still resolves the profile via AWS_PROFILE.
155
+ set.CLOUD_PROVIDER = legs.join(',');
108
156
  }
109
- if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = hostProvider;
110
157
  }
111
158
 
112
159
  return { set, unset };