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 +2 -1
- package/bin/nsauditor-ai.mjs +2 -1
- package/cli.mjs +73 -11
- package/package.json +1 -1
- package/plugin_manager.mjs +24 -0
- package/utils/env_loader.mjs +113 -0
- package/utils/sentinel_scope.mjs +42 -0
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.
|
|
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.
|
package/bin/nsauditor-ai.mjs
CHANGED
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
|
|
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
|
|
894
|
-
API
|
|
895
|
-
--plugins all
|
|
896
|
-
|
|
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().
|
|
2008
|
-
|
|
2009
|
-
|
|
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
package/plugin_manager.mjs
CHANGED
|
@@ -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
|
+
}
|