nsauditor-ai 0.2.23 → 0.2.24

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,9 @@ 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
+ **Latest: CE 0.2.24 + Enterprise 0.31.10** (July 2026) — **multi-cloud scope-integrity fix (correctness).** Closes a false-clean in the multi-cloud one-liner: `--host aws,gcp,azure` (and a `--host-file` of cloud sentinels) under a stale / tool-implied `CLOUD_PROVIDER` silently skipped the un-covered cloud legs and reported them **"audited-clean" over zero API calls**. The CLI now reconciles `CLOUD_PROVIDER` against the requested cloud legs **fail-fast** on a mismatch (a pinned provider that misses a leg is a hard error, not a silent skip), else **imply the union** of the legs (so `--aws-profile prod --host aws,gcp,azure` works) and classifies a gate-skipped provider as `'skipped'` (dropped from `auditedProviders`, surfaced as `audited:false`, never a bare "audited, 0 findings"). TDD + externally reviewed across two rounds → 🟢 SHIP-CLEAR. Paired **Enterprise 0.31.10** (the EE-side GCP scope-integrity gate for 1024/1025 + the T4 GCP positive-substrate curation). 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
+
22
+ **Prior: 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
23
 
22
24
  → 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
25
 
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.24",
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 };