nsauditor-ai 0.1.90 β†’ 0.1.91

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,8 +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.1.90 + Enterprise 0.15.9** (May 2026)
20
+ **Latest: CE 0.1.91 + Enterprise 0.16.0** (May 2026)
21
21
 
22
+ - 🎯 **Per-account scanning: `--env` / `--aws-profile` + sentinel-host plugin scoping** (CE 0.1.91 β€” paired EE 0.16.0) β€” audit many cloud accounts one at a time without shell-export juggling. **`--env <path>`** loads a per-scan dotenv credentials file (override-on; fail-fast on a missing file; an INI/`~/.aws/credentials` file is detected and redirected to `--aws-profile`). **`--aws-profile <name>`** uses a named profile from the OS-default `~/.aws/credentials` (clears stale explicit keys, sets `AWS_SDK_LOAD_CONFIG=1`, implies `CLOUD_PROVIDER=aws`). On a cloud-sentinel host, **`--host aws|gcp|azure` + `--plugins all` auto-scopes** to only that cloud's plugins (other clouds + non-cloud plugins are skipped and logged); explicit `--plugins` lists are unaffected. A host↔`CLOUD_PROVIDER` conflict fails fast (no silent empty "clean" report), and a zero-match sentinel scope warns loudly. EE 0.16.0 adds a declarative `cloudProvider` field to all 27 cloud plugins. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
22
23
  - πŸ›‘οΈ **Cross-cloud scope hotfix** (CE 0.1.90 β€” paired EE 0.15.9) β€” EE 0.15.9 moves the AWS-plugin `CLOUD_PROVIDER` gate from `preflight()` to `run()`: the 0.15.8 gate was placed where the scan orchestrator never calls it, so the cross-cloud bleed persisted; it's now on the load-bearing `run()` path (a `CLOUD_PROVIDER=gcp|azure` scan with AWS creds present no longer bleeds AWS resources into the GCP/Azure packs), validated via a run()-path test + a real-creds local proof. CE is a paired no-op bump. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
23
24
  - πŸ›‘οΈ **Cloud-plugin scoping fixes** (CE 0.1.89 β€” paired EE 0.15.8) β€” EE 0.15.8 closes two issues surfaced by the 0.15.7 full multi-cloud smoke: the AWS plugins now gate on `CLOUD_PROVIDER` (a `CLOUD_PROVIDER=gcp|azure` scan with AWS creds present no longer bleeds AWS resources into the GCP/Azure attestation packs), and the GCP IAM/storage auditor now surfaces an evidence-gap instead of a false-clean PASS when a policy can't be read. CE is a paired no-op bump (no CE code change). Plugin count UNCHANGED at 28; all six coverage matrices UNCHANGED.
24
25
  - ☁️ **GCP SDK refresh** (CE 0.1.88 β€” paired EE 0.15.7) β€” EE 0.15.7 re-applies the GCP SDK major bump (`@google-cloud/compute` ^6 / `@google-cloud/iam` ^2 / `googleapis` ^173) on the pure-ADC credential path, validated live against a test-infra GCP project (first live GCP audit: 3 CRITICAL firewall findings on compute@6). Compute-client SA-impersonation is explicitly unsupported on compute@6 (documented in-code + gated to plan-later); pure-ADC and key-file paths are fully supported. Also folds the plugin-1021 project-resolution fix. CE is a paired no-op bump (no CE code change). Plugin count UNCHANGED at 28; all six coverage matrices UNCHANGED.
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import '../cli.mjs';
2
+ import { main } from '../cli.mjs';
3
+ main().catch((err) => { console.error(err?.stack || err); process.exit(1); });
package/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import { buildHtmlReport } from './utils/report_html.mjs';
5
5
  import fsp from 'node:fs/promises';
6
6
  import { dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { realpathSync } from 'node:fs';
8
9
  import { createRequire } from 'node:module';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -522,7 +523,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
522
523
 
523
524
  /* ------------------------------- CLI ----------------------------------- */
524
525
 
525
- async function parseArgs(argv) {
526
+ export async function parseArgs(argv) {
526
527
  const args = { cmd: 'scan', host: undefined, plugins: 'all', insecureHttps: false };
527
528
  const a = argv.slice(2);
528
529
  // Help: bare `--help`/`-h`/`help` or completely empty invocation.
@@ -604,6 +605,15 @@ async function parseArgs(argv) {
604
605
  const complianceScopeVal = get('compliance-scope') || get('compliance_scope');
605
606
  args.complianceScope = (complianceScopeVal && complianceScopeVal !== true) ? complianceScopeVal : null;
606
607
 
608
+ // Per-scan account selection (EE 0.16.x). `--env <path>` loads a dotenv
609
+ // (override-on) for this scan; `--aws-profile <name>` selects a named profile
610
+ // from ~/.aws/credentials. Both are wired into main() via resolveScanEnv().
611
+ // get() returns undefined (absent), true (value-less flag), or the string value.
612
+ const envVal = get('env');
613
+ args.env = envVal === undefined ? undefined : envVal; // string path, or true if value-less
614
+ const awsProfileVal = get('aws-profile');
615
+ args.awsProfile = awsProfileVal === undefined ? undefined : awsProfileVal;
616
+
607
617
  return args;
608
618
  }
609
619
 
@@ -804,8 +814,9 @@ function maxSeverityInConclusion(conclusion) {
804
814
  return max;
805
815
  }
806
816
 
807
- async function main() {
808
- const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope } = await parseArgs(process.argv);
817
+ export async function main() {
818
+ const args = await parseArgs(process.argv);
819
+ const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope } = args;
809
820
 
810
821
  // Version: handled before license verification so it works without a key.
811
822
  // CE-0.1.30.1 β€” closes the discovery-flag UX gap where pre-fix
@@ -833,6 +844,10 @@ Usage:
833
844
  Scan options:
834
845
  --host, --ip, --target <h> Target host, IP, or CIDR
835
846
  --host-file <path> File with one host per line
847
+ --env <path> Load a dotenv (KEY=value) file for this scan (per-account
848
+ credentials). Override-on; missing file = hard error.
849
+ --aws-profile <name> Use a named profile from the OS-default ~/.aws/credentials.
850
+ Implies CLOUD_PROVIDER=aws; overrides explicit AWS_* keys.
836
851
  --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
837
852
  --ports <range> Override port list (e.g. 22,80,443 or 1-1000)
838
853
  --out <dir> Output directory for scan artifacts
@@ -890,10 +905,15 @@ Environment:
890
905
  Cloud-scan hosts:
891
906
  --host aws | gcp | azure Sentinel literals (case-insensitive). These are not
892
907
  DNS-resolved; they route the scan to the matching
893
- cloud-scanner plugin via the provider's control-plane
894
- API. Pair with --plugins 020,030 (or similar) β€” using
895
- --plugins all on a sentinel host produces noisy
896
- unreachable-host findings from non-cloud plugins.
908
+ cloud-scanner plugins via the provider's control-plane
909
+ API, and imply CLOUD_PROVIDER=<host> when unset. With
910
+ --plugins all the scan AUTO-SCOPES to only that cloud's
911
+ plugins (other clouds + non-cloud plugins are skipped and
912
+ the skip is logged) β€” so --plugins all is safe here.
913
+ Note: the composite zero-trust checker (1023) has no
914
+ single cloud and is therefore skipped under this
915
+ auto-scope; to run it, select it explicitly
916
+ (--plugins 023,...) or scan a network host/CIDR.
897
917
 
898
918
  Examples:
899
919
  nsauditor-ai scan --host 10.0.0.1 --plugins all
@@ -907,6 +927,37 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
907
927
  process.exit(0);
908
928
  }
909
929
 
930
+ // Per-scan environment selection (--env / --aws-profile) + sentinel-host
931
+ // implied CLOUD_PROVIDER. The eager `import 'dotenv/config'` at the top of
932
+ // this file already loaded the default cwd `.env` (override-off) for
933
+ // import-time consumers; this layers the explicitly-selected env ON TOP
934
+ // (override-on) before license + dispatch.
935
+ if (args.env === true) {
936
+ console.error('Error: --env requires a file path (e.g. --env ~/envs/prod.env)');
937
+ process.exit(2);
938
+ }
939
+ if (args.awsProfile === true) {
940
+ console.error('Error: --aws-profile requires a profile name (e.g. --aws-profile prod)');
941
+ process.exit(2);
942
+ }
943
+ try {
944
+ const { resolveScanEnv } = await import('./utils/env_loader.mjs');
945
+ const fsm = await import('node:fs');
946
+ const patch = resolveScanEnv({
947
+ envPath: typeof args.env === 'string' ? args.env : undefined,
948
+ awsProfile: typeof args.awsProfile === 'string' ? args.awsProfile : undefined,
949
+ host,
950
+ env: process.env,
951
+ fileExists: (p) => fsm.existsSync(p),
952
+ readFile: (p) => fsm.readFileSync(p, 'utf8'),
953
+ });
954
+ Object.assign(process.env, patch.set); // override-on
955
+ for (const k of patch.unset) delete process.env[k];
956
+ } catch (err) {
957
+ console.error(`Error: ${err.message}`);
958
+ process.exit(1);
959
+ }
960
+
910
961
  // Verify license JWT at startup (~5ms for ES256). Populates _verifiedTier
911
962
  // so all subsequent getTierFromEnv() calls return the cryptographically
912
963
  // validated tier instead of relying on prefix detection alone.
@@ -2004,7 +2055,18 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2004
2055
  }
2005
2056
  }
2006
2057
 
2007
- main().catch((err) => {
2008
- console.error(err?.stack || err);
2009
- process.exit(1);
2010
- });
2058
+ // Only auto-run main() when this file is the process entrypoint. Importing
2059
+ // cli.mjs (e.g. unit tests pulling in `parseArgs`) must NOT trigger a scan.
2060
+ const _isEntrypoint = (() => {
2061
+ try {
2062
+ return !!process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
2063
+ } catch {
2064
+ return false;
2065
+ }
2066
+ })();
2067
+ if (_isEntrypoint) {
2068
+ main().catch((err) => {
2069
+ console.error(err?.stack || err);
2070
+ process.exit(1);
2071
+ });
2072
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "description": "Modular AI-assisted network security audit platform β€” Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -20,6 +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 } from './utils/sentinel_scope.mjs';
23
24
 
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
 
@@ -749,15 +750,38 @@ export class PluginManager {
749
750
  let selection;
750
751
  let opts;
751
752
 
753
+ let rawSpec;
752
754
  if (specOrOptions && typeof specOrOptions === "object" && !Array.isArray(specOrOptions)) {
753
755
  const { plugins = "all", ...rest } = specOrOptions;
756
+ rawSpec = plugins;
754
757
  selection = this._resolveSelection(plugins);
755
758
  opts = rest;
756
759
  } else {
760
+ rawSpec = specOrOptions;
757
761
  selection = this._resolveSelection(specOrOptions);
758
762
  opts = maybeOpts || {};
759
763
  }
760
764
 
765
+ // Sentinel-host scoping: on --host aws|gcp|azure with the implicit `all`,
766
+ // run only that cloud's plugins; skip other clouds + non-cloud plugins.
767
+ const scope = scopeSelectionForHost(selection, host, rawSpec);
768
+ if (scope.scoped) {
769
+ selection = scope.selected;
770
+ if (scope.selected.length === 0) {
771
+ console.error(
772
+ `WARNING: --host '${scope.provider}' matched 0 ${scope.provider.toUpperCase()} plugins β€” ` +
773
+ `NOTHING was audited. Ensure the ${scope.provider.toUpperCase()} plugins are installed and ` +
774
+ `licensed (an empty result is NOT a clean pass).`,
775
+ );
776
+ } else if (scope.skipped.length) {
777
+ console.error(
778
+ `Cloud host '${scope.provider}' β†’ running ${scope.selected.length} ` +
779
+ `${scope.provider.toUpperCase()} plugin(s); skipping ${scope.skipped.length} ` +
780
+ `non-${scope.provider} plugin(s) (other clouds + non-cloud).`,
781
+ );
782
+ }
783
+ }
784
+
761
785
  // Decide execution mode
762
786
  const anyOrchestratedSignals = selection.some((p) => p?.priority != null || p?.requirements != null);
763
787
  const orchestrate = opts.orchestrate !== false && anyOrchestratedSignals;
@@ -0,0 +1,113 @@
1
+ // utils/env_loader.mjs
2
+ //
3
+ // Pure resolver for the per-scan environment selection flags (--env / --aws-profile)
4
+ // and the sentinel-host implied CLOUD_PROVIDER. Returns an env "patch"
5
+ // ({ set, unset }) that the CLI applies to process.env. Fail-fast: throws on a
6
+ // missing --env file or an INI/credentials file mistakenly passed to --env.
7
+ //
8
+ // fs is injected (readFile/fileExists) so the precedence + error rules unit-test
9
+ // without a real filesystem.
10
+
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import dotenv from 'dotenv';
14
+
15
+ const CLOUD_SENTINELS = new Set(['aws', 'gcp', 'azure']);
16
+ const AWS_EXPLICIT_KEYS = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'];
17
+
18
+ function expandTilde(p) {
19
+ if (typeof p !== 'string') return p;
20
+ if (p === '~') return os.homedir();
21
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
22
+ return p;
23
+ }
24
+
25
+ // INI signature: a [section] header line, OR no KEY=value line at all.
26
+ function looksLikeIni(content) {
27
+ const hasSection = /^\s*\[[^\]]+\]\s*$/m.test(content);
28
+ const hasKeyValue = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/m.test(content);
29
+ return hasSection || !hasKeyValue;
30
+ }
31
+
32
+ function providerAlreadySet(set, env) {
33
+ const v = set.CLOUD_PROVIDER ?? env.CLOUD_PROVIDER;
34
+ return v != null && String(v).trim() !== '';
35
+ }
36
+
37
+ // The effective CLOUD_PROVIDER after merging the --env file (set) + shell env.
38
+ function effectiveProvider(set, env) {
39
+ const v = set.CLOUD_PROVIDER ?? env.CLOUD_PROVIDER;
40
+ return v == null ? '' : String(v).trim().toLowerCase();
41
+ }
42
+
43
+ // Lenient match (mirrors awsScanSkipReason CSV semantics): the effective provider
44
+ // matches the host if it equals the host OR is a CSV that includes the host.
45
+ function providerMatchesHost(effective, hostProvider) {
46
+ if (!effective) return true; // unset β†’ no contradiction
47
+ const tokens = effective.split(',').map((t) => t.trim()).filter(Boolean);
48
+ return tokens.includes(hostProvider);
49
+ }
50
+
51
+ /**
52
+ * @param {object} args
53
+ * @param {string} [args.envPath] value of --env
54
+ * @param {string} [args.awsProfile] value of --aws-profile
55
+ * @param {string} [args.host] --host value (for sentinel CLOUD_PROVIDER implication)
56
+ * @param {object} args.env snapshot of current process.env (read-only)
57
+ * @param {(p:string)=>boolean} args.fileExists
58
+ * @param {(p:string)=>string} args.readFile
59
+ * @returns {{set: Record<string,string>, unset: string[]}}
60
+ */
61
+ export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists, readFile }) {
62
+ const set = {};
63
+ const unset = [];
64
+
65
+ // 1. --env dotenv file (override-on applied by caller via Object.assign).
66
+ if (typeof envPath === 'string' && envPath.length) {
67
+ const resolved = path.resolve(expandTilde(envPath));
68
+ if (!fileExists(resolved)) {
69
+ throw new Error(`--env file not found: ${resolved} (fail-fast: refusing to fall back to ambient credentials)`);
70
+ }
71
+ const content = readFile(resolved);
72
+ if (looksLikeIni(content)) {
73
+ throw new Error(
74
+ `${resolved} looks like an AWS credentials / INI file (has [section] headers / no KEY=value lines), ` +
75
+ `not a dotenv file. Use --aws-profile <name> instead (with AWS_SHARED_CREDENTIALS_FILE in an --env file ` +
76
+ `if the path is non-default).`,
77
+ );
78
+ }
79
+ Object.assign(set, dotenv.parse(content));
80
+ }
81
+
82
+ // 2. --aws-profile: AWS_PROFILE wins; clear explicit keys; load ~/.aws/config; imply aws.
83
+ if (typeof awsProfile === 'string' && awsProfile.length) {
84
+ set.AWS_PROFILE = awsProfile;
85
+ set.AWS_SDK_LOAD_CONFIG = '1';
86
+ for (const k of AWS_EXPLICIT_KEYS) {
87
+ delete set[k]; // a key from the --env file must not survive the profile
88
+ unset.push(k);
89
+ }
90
+ if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = 'aws';
91
+ }
92
+
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
+ );
108
+ }
109
+ if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = hostProvider;
110
+ }
111
+
112
+ return { set, unset };
113
+ }
@@ -0,0 +1,42 @@
1
+ // utils/sentinel_scope.mjs
2
+ //
3
+ // Sentinel-host plugin scoping. When the scan target is a cloud-sentinel host
4
+ // (`aws`/`gcp`/`azure`) and the plugin selection is the IMPLICIT `all`, run only
5
+ // the plugins tagged with that provider (`plugin.cloudProvider`), skipping the
6
+ // other clouds + the non-cloud network plugins (which would otherwise probe the
7
+ // literal string "aws" and emit unreachable-host noise). Explicit plugin
8
+ // selections are always honored as-is.
9
+
10
+ export const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
11
+
12
+ export function isCloudSentinelHost(host) {
13
+ return typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.trim().toLowerCase());
14
+ }
15
+
16
+ // spec is the ORIGINAL plugin spec ('all' | undefined | null β†’ implicit;
17
+ // array or CSV string β†’ explicit). Only the implicit case is scoped.
18
+ function isImplicitAll(spec) {
19
+ if (spec == null) return true;
20
+ if (typeof spec === 'string') return spec.trim().toLowerCase() === 'all';
21
+ return false; // arrays are always explicit
22
+ }
23
+
24
+ /**
25
+ * @param {Array<object>} selection resolved plugin objects (each may have .cloudProvider)
26
+ * @param {string} host
27
+ * @param {string|string[]} spec original selection spec
28
+ * @returns {{selected: object[], skipped: object[], scoped: boolean, provider: string|null}}
29
+ */
30
+ export function scopeSelectionForHost(selection, host, spec) {
31
+ if (!isCloudSentinelHost(host) || !isImplicitAll(spec)) {
32
+ return { selected: selection, skipped: [], scoped: false, provider: null };
33
+ }
34
+ const provider = host.trim().toLowerCase();
35
+ const selected = [];
36
+ const skipped = [];
37
+ for (const p of selection) {
38
+ if (p && p.cloudProvider === provider) selected.push(p);
39
+ else skipped.push(p);
40
+ }
41
+ return { selected, skipped, scoped: true, provider };
42
+ }