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 +3 -1
- package/cli.mjs +22 -3
- package/package.json +1 -1
- package/plugin_manager.mjs +28 -1
- package/utils/env_loader.mjs +65 -18
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.
|
|
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
|
|
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
|
-
|
|
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
package/plugin_manager.mjs
CHANGED
|
@@ -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: [] };
|
package/utils/env_loader.mjs
CHANGED
|
@@ -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
|
|
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
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 };
|