nsauditor-ai 0.1.29 → 0.1.30

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
@@ -15,6 +15,17 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
15
15
 
16
16
  **Zero Data Exfiltration by design.** NSAuditor AI works fully offline. AI analysis, CVE correlation, and continuous monitoring all happen locally. External calls (to AI APIs, NVD, etc.) are opt-in and use your own API keys. We never see your scan data.
17
17
 
18
+ ## What's New (0.1.30)
19
+
20
+ The 0.1.30 line is a paired release with `@nsasoft/nsauditor-ai-ee@0.3.2` that closes the customer-onboarding gap and a critical false-clean SOC 2 reporting bug:
21
+
22
+ - **`nsauditor-ai license install <KEY>`** — verifies the JWT *before* persisting; writes to macOS Keychain or `~/.nsauditor/.env` (mode 0600) depending on platform. Three-line install flow: `npm install -g` → `license install` → `license --status`. No more shell-rc edits.
23
+ - **Multi-source license loader** — `loadLicense()` resolves keys from env var → platform Keychain → `~/.nsauditor/.env`, in that order. CI/CD env-var override still wins.
24
+ - **`nsauditor-ai license --plugins`** — real enumeration of discovered plugins, grouped by source (CE / EE / custom), with active-or-required-tier status.
25
+ - **`nsauditor-ai --version` / `-v`** — prints version and exits 0 (parallel to `--help`'s discovery-flag UX).
26
+ - **Cloud-sentinel SSRF bypass** — `--host aws|gcp|azure` no longer requires `NSA_ALLOW_ALL_HOSTS=1`. The sentinel literals route to EE cloud-scanner plugins via the provider's API; the SSRF guard's RFC 1918 / loopback protection is preserved for real network targets.
27
+ - **EE-0.3.2.1 hard dep** — CE forwards the per-plugin `results` array to `enrichScan()`. Without this, EE 0.3.2's cloud-finding harvester sees nothing and produces false-clean SOC 2 reports against AWS accounts. EE emits a runtime version-skew warning when this opt is missing.
28
+
18
29
  ## What It Does
19
30
 
20
31
  ```
@@ -49,7 +60,7 @@ NSAuditor AI is available in three editions:
49
60
  | Advanced CTEM + trend analysis | — | ✅ | ✅ |
50
61
  | Cloud scanners (AWS/GCP/Azure) | — | — | ✅ |
51
62
  | Zero Trust assessment | — | — | ✅ |
52
- | SOC 2 compliance (7 covered + 5 partial controls) | — | — | ✅ |
63
+ | SOC 2 compliance (8 covered + 5 partial controls) | — | — | ✅ |
53
64
  | SLA/MTTR tracking + compensating controls | — | — | ✅ |
54
65
  | Recurring-scan attestation (Type II evidence) | — | — | ✅ |
55
66
  | GRC platform connector (Vanta) | — | — | ✅ |
@@ -70,6 +81,9 @@ NSAuditor AI is available in three editions:
70
81
  # Install globally
71
82
  npm install -g nsauditor-ai
72
83
 
84
+ # See all flags, subcommands, and worked examples
85
+ nsauditor-ai --help
86
+
73
87
  # Configure (optional — scans work fully offline without AI)
74
88
  cat > .env << 'EOF'
75
89
  AI_ENABLED=true
@@ -357,8 +371,16 @@ The `keychain:` prefix works anywhere an API key is read — CLI, MCP server, or
357
371
 
358
372
  ```
359
373
  nsauditor-ai scan [options]
374
+ nsauditor-ai license install <KEY>
375
+ nsauditor-ai license <--status | --capabilities | --plugins>
376
+ nsauditor-ai security <set|delete|list|get> <KEY>
377
+ nsauditor-ai validate
378
+ nsauditor-ai --help (or -h, or `help`)
379
+ nsauditor-ai --version (or -v, or `version`)
360
380
  ```
361
381
 
382
+ > Run `nsauditor-ai --help` (or `-h`, or just `nsauditor-ai help`) for a quick reference of subcommands, flags, env vars, and worked examples — works without a license key configured. `--version` / `-v` (CE 0.1.30+) prints `nsauditor-ai <version>` and exits 0.
383
+
362
384
  | Flag | Description | Default |
363
385
  |---|---|---|
364
386
  | `--host <target>` | Target: IP, hostname, CIDR, dash range. Aliases: `--ip`, `--target` | *required*\* |
@@ -374,6 +396,10 @@ nsauditor-ai scan [options]
374
396
  | `--interval <min>` | Rescan interval in minutes (requires `--watch`) | `60` |
375
397
  | `--webhook-url <url>` | Webhook URL for delta alerts | — |
376
398
  | `--alert-severity <sev>` | Minimum severity for webhook alerts | `high` |
399
+ | `--compliance <fw>` | Compliance framework to map findings into (e.g. `soc2`). **Enterprise license required.** See `@nsasoft/nsauditor-ai-ee` README for supported frameworks | — |
400
+ | `--compliance-scope <path>` | Optional JSON file describing the assessment scope (passed to the compliance engine for cover-page attestation) | — |
401
+ | `--help`, `-h` | Print usage block (subcommands, flags, env vars, examples) and exit 0 | — |
402
+ | `--version`, `-v` | Print `nsauditor-ai <version>` and exit 0 (CE 0.1.30+) | — |
377
403
 
378
404
  \* Either `--host` or `--host-file` is required.
379
405
 
package/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { buildCsv } from './utils/export_csv.mjs';
15
15
  import { buildMarkdownReport } from './utils/report_md.mjs';
16
16
  import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
17
17
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
18
- import { resolveCapabilities, hasCapability } from './utils/capabilities.mjs';
18
+ import { resolveCapabilities, hasCapability, inferRequiredTier } from './utils/capabilities.mjs';
19
19
  import { createScheduler } from './utils/scheduler.mjs';
20
20
  import { buildDeltaReport, formatDeltaSummary, hasSignificantChanges } from './utils/delta_reporter.mjs';
21
21
  import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
@@ -525,11 +525,27 @@ async function parseArgs(argv) {
525
525
  // Help: bare `--help`/`-h`/`help` or completely empty invocation.
526
526
  // Recognized before the scan-default so it doesn't crash with
527
527
  // "--host or --host-file is required" on a help request.
528
+ //
529
+ // CE-0.1.30.1 reviewer M1: short flag `-h` only matches at `a[0]`. The
530
+ // long-flag `--help` matches anywhere in argv (typing `nsauditor-ai
531
+ // scan --help` should still print help). Pre-fix the parser had
532
+ // `a.includes('-h')` which would match `-h` as a value of another
533
+ // flag (e.g., `--alert-severity -h` would silently fire help instead
534
+ // of failing argv validation). Same fix mirrored in the version branch
535
+ // below.
528
536
  if (a.length === 0 || a[0] === '--help' || a[0] === '-h' || a[0] === 'help' ||
529
- a.includes('--help') || a.includes('-h')) {
537
+ a.includes('--help')) {
530
538
  args.cmd = 'help';
531
539
  return args;
532
540
  }
541
+ // Version: bare `--version`/`-v`/`version`. CE-0.1.30.1 — same pre-license
542
+ // dispatch as help so a discovery flag never errors with
543
+ // "--host or --host-file is required" or with a missing-key fatal.
544
+ if (a[0] === '--version' || a[0] === '-v' || a[0] === 'version' ||
545
+ a.includes('--version')) {
546
+ args.cmd = 'version';
547
+ return args;
548
+ }
533
549
  if (a.length && !a[0].startsWith('--')) args.cmd = a[0];
534
550
 
535
551
  const get = (name) => {
@@ -588,10 +604,28 @@ async function parseArgs(argv) {
588
604
  return args;
589
605
  }
590
606
 
607
+ // EE-0.3.2.5 (CE side): cloud-provider sentinel hosts ('aws' / 'gcp' /
608
+ // 'azure', case-insensitive) are NOT real DNS names — they're scoping
609
+ // tokens that EE cloud-scanner plugins (020/021/022/023/030) interpret
610
+ // as "this run targets the cloud provider via its API, not a network
611
+ // address." resolveAndValidate() returns ENOTFOUND for these, so pre-
612
+ // 0.3.2.5 customers had to set NSA_ALLOW_ALL_HOSTS=1 to bypass — which
613
+ // is undocumented AND disables the guard for legitimate RFC 1918 / IP
614
+ // targets in the same scan. Whitelist the sentinels in scanSingleHost
615
+ // so cloud scans Just Work and the env-var bypass remains scoped to
616
+ // its documented use case (local-network auditing).
617
+ //
618
+ // Hoisted to module scope so the Set is built once at module load
619
+ // rather than per scanSingleHost() call (reviewer L1 fold).
620
+ const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
621
+
591
622
  async function scanSingleHost(pm, host, plugins, opts, promptMode) {
592
623
  // SSRF guard — block loopback, private ranges, cloud metadata endpoints.
593
624
  // Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
594
- if (!process.env.NSA_ALLOW_ALL_HOSTS) {
625
+ // Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
626
+ const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
627
+
628
+ if (!process.env.NSA_ALLOW_ALL_HOSTS && !isCloudSentinel) {
595
629
  if (isBlockedIp(host)) {
596
630
  throw new Error(`Scanning blocked address range is not allowed: ${host}`);
597
631
  }
@@ -626,6 +660,17 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
626
660
  // EE enrichment hook — no-op if @nsasoft/nsauditor-ai-ee is not installed
627
661
  // or the license tier doesn't grant intelligenceEngine. Compliance + outDir
628
662
  // are forwarded so EE can write scan_finding_queue.json and SOC 2 artifacts.
663
+ //
664
+ // CE-0.1.30.5: forward the per-plugin `results` array. This is the hard
665
+ // dependency that makes EE-0.3.2.1's cloud-finding harvester actually
666
+ // work in production — pre-CE-0.1.30, EE only saw the concluder object
667
+ // (single plugin output) and could not harvest findings from cloud
668
+ // plugins (020/030/etc.) that live in pm.run().results[]. Without this
669
+ // line, EE 0.3.2 emits a runtime warning ("CE 0.1.30+ … running with
670
+ // older CE") AND continues to produce false-clean SOC 2 reports against
671
+ // AWS accounts. EE 0.3.2 + CE 0.1.30 ship as a paired release; mixing
672
+ // versions is supported but the EE-side cloud-harvest behavior requires
673
+ // CE-0.1.30+ to take effect.
629
674
  try {
630
675
  const { enrichScan } = await import('@nsasoft/nsauditor-ai-ee');
631
676
  const eeEnrichment = await enrichScan(conclusion, {
@@ -633,6 +678,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
633
678
  outDir,
634
679
  compliance: opts.compliance ?? process.env.COMPLIANCE_FRAMEWORKS ?? null,
635
680
  complianceScope: opts.complianceScope ?? null,
681
+ results,
636
682
  onWarn: (msg) => console.warn(`[EE] ${msg}`),
637
683
  });
638
684
  if (eeEnrichment?.enrichedPrompt) {
@@ -758,6 +804,16 @@ function maxSeverityInConclusion(conclusion) {
758
804
  async function main() {
759
805
  const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope } = await parseArgs(process.argv);
760
806
 
807
+ // Version: handled before license verification so it works without a key.
808
+ // CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
809
+ // `nsauditor-ai --version` errored with "Fatal: --host or --host-file
810
+ // is required". Output format mirrors GNU `--version` convention:
811
+ // "<tool> <version>" on a single line, then exit 0.
812
+ if (cmd === 'version') {
813
+ console.log(`nsauditor-ai ${TOOL_VERSION}`);
814
+ process.exit(0);
815
+ }
816
+
761
817
  // Help: handled before license verification so it works without a key.
762
818
  if (cmd === 'help') {
763
819
  console.log(`nsauditor-ai — Modular AI-assisted network security audit platform
@@ -768,7 +824,8 @@ Usage:
768
824
  nsauditor-ai license <subcommand>
769
825
  nsauditor-ai security <subcommand>
770
826
  nsauditor-ai validate
771
- nsauditor-ai help
827
+ nsauditor-ai version (or --version / -v)
828
+ nsauditor-ai help (or --help / -h)
772
829
 
773
830
  Scan options:
774
831
  --host, --ip, --target <h> Target host, IP, or CIDR
@@ -788,8 +845,13 @@ Scan options:
788
845
  --compliance-scope <path> JSON file describing the assessment scope
789
846
 
790
847
  License subcommands:
848
+ nsauditor-ai license install <KEY> Verify and persist a license key (Keychain
849
+ on macOS, ~/.nsauditor/.env on Linux/Windows
850
+ with mode 0600). Rejects invalid/expired keys.
791
851
  nsauditor-ai license --status Show active tier, org, seats, expiry
792
852
  nsauditor-ai license --capabilities List active capabilities for current tier
853
+ nsauditor-ai license --plugins List discovered plugins grouped by source
854
+ (CE / EE / custom) with active-or-required-tier
793
855
 
794
856
  Security subcommands (macOS Keychain):
795
857
  nsauditor-ai security set <KEY> Store a secret (read from stdin)
@@ -800,15 +862,24 @@ Security subcommands (macOS Keychain):
800
862
  Environment:
801
863
  NSAUDITOR_LICENSE_KEY Pro/Enterprise license JWT (env var; takes precedence)
802
864
  NSA_ALLOW_ALL_HOSTS=1 Permit RFC1918 / loopback (local-network auditing)
803
- CLOUD_PROVIDER=aws|gcp|azure Required for cloud scanner plugins (020/021/022)
865
+ CLOUD_PROVIDER=aws|gcp|azure Required for cloud scanner plugins (020/021/022/023/030)
804
866
  AI_PROVIDER=openai|claude|ollama AI provider for report generation
805
867
  COMPLIANCE_TSA_URL RFC 3161 timestamp authority for SOC 2 attestation
806
868
 
869
+ Cloud-scan hosts:
870
+ --host aws | gcp | azure Sentinel literals (case-insensitive). These are not
871
+ DNS-resolved; they route the scan to the matching
872
+ cloud-scanner plugin via the provider's control-plane
873
+ API. Pair with --plugins 020,030 (or similar) — using
874
+ --plugins all on a sentinel host produces noisy
875
+ unreachable-host findings from non-cloud plugins.
876
+
807
877
  Examples:
808
878
  nsauditor-ai scan --host 10.0.0.1 --plugins all
809
879
  CLOUD_PROVIDER=aws AWS_PROFILE=default \\
810
- nsauditor-ai scan --host aws --plugins 020
880
+ nsauditor-ai scan --host aws --plugins 020 --compliance soc2
811
881
  nsauditor-ai scan --host 10.0.0.0/24 --plugins all --compliance soc2
882
+ nsauditor-ai license install enterprise_eyJhbGciOiJFUzI1NiIs...
812
883
  nsauditor-ai license --status
813
884
 
814
885
  Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/pricing/`);
@@ -848,8 +919,134 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
848
919
  for (const [name, enabled] of Object.entries(caps)) {
849
920
  console.log(` ${enabled ? '✓' : '✗'} ${name}`);
850
921
  }
922
+ } else if (rawArgs.includes('--plugins')) {
923
+ // CE-0.1.30.3 — real enumeration of discovered plugins, grouped by
924
+ // source (CE / EE / custom NSAUDITOR_PLUGIN_PATH). Pre-fix this
925
+ // branch crashed with `TypeError: p.toLowerCase is not a function`
926
+ // (since hotfixed in 0.1.28 to a Usage fallback). Now: discover
927
+ // plugins, group by `_source`, format with active/required-tier
928
+ // status. Output format matches the EE README "Quick Start" example.
929
+ const tier = getTierFromEnv();
930
+ const caps = resolveCapabilities(tier);
931
+ const pm = await PluginManager.create(`${__dirname}/plugins`);
932
+
933
+ const groups = { ce: [], ee: [], custom: [] };
934
+ for (const plugin of pm.plugins) {
935
+ const source = plugin._source ?? 'ce';
936
+ if (!groups[source]) groups[source] = [];
937
+ groups[source].push(plugin);
938
+ }
939
+
940
+ const sourceLabels = {
941
+ ce: 'CE plugins (from nsauditor-ai)',
942
+ ee: 'EE plugins (from @nsasoft/nsauditor-ai-ee)',
943
+ custom: 'Custom plugins (from NSAUDITOR_PLUGIN_PATH)',
944
+ };
945
+
946
+ // Render in fixed order: ce → ee → custom (then any unknown sources alphabetical).
947
+ const renderOrder = ['ce', 'ee', 'custom', ...Object.keys(groups).filter((k) => !['ce','ee','custom'].includes(k)).sort()];
948
+ let totalRendered = 0;
949
+ for (const source of renderOrder) {
950
+ const plugins = groups[source];
951
+ if (!plugins || plugins.length === 0) continue;
952
+ if (totalRendered > 0) console.log('');
953
+ console.log(`${sourceLabels[source] ?? `${source} plugins`}:`);
954
+
955
+ // Sort by id (string-compare keeps zero-padded ids in numeric order).
956
+ const sorted = [...plugins].sort((a, b) =>
957
+ String(a.id ?? '').localeCompare(String(b.id ?? ''))
958
+ );
959
+ for (const plugin of sorted) {
960
+ const required = Array.isArray(plugin.requiredCapabilities) ? plugin.requiredCapabilities : [];
961
+ const allMet = required.length === 0 || required.every((c) => Boolean(caps[c]));
962
+ // Reviewer M2 fold: derive the required tier from the unmet
963
+ // capability set so the "requires: …" label is accurate even
964
+ // when the plugin doesn't declare a `tier` field. EE plugins
965
+ // 021/022/023 (no `tier` declaration) require `cloudScanners`
966
+ // which is enterprise-gated — pre-fold they showed
967
+ // "requires: pro" misleadingly. Now they show "requires:
968
+ // enterprise" via inferRequiredTier(). plugin.tier is the
969
+ // operator-declared override; fall back to inference.
970
+ const inferredTier = inferRequiredTier(required);
971
+ const requiresLabel = plugin.tier ?? inferredTier ?? 'pro';
972
+ const status = allMet ? '✓ active' : `✗ requires: ${requiresLabel}`;
973
+ // Layout matches the EE README example:
974
+ // " 003 SSH Scanner ✓ active"
975
+ const idStr = String(plugin.id ?? '?').padEnd(3);
976
+ const nameStr = String(plugin.name ?? '<unnamed>').padEnd(28);
977
+ console.log(` ${idStr} ${nameStr} ${status}`);
978
+ }
979
+ totalRendered += sorted.length;
980
+ }
981
+
982
+ if (totalRendered === 0) {
983
+ console.log('No plugins discovered. Re-install nsauditor-ai or check NSAUDITOR_PLUGIN_PATH.');
984
+ } else {
985
+ console.log('');
986
+ console.log(` ${totalRendered} plugin${totalRendered === 1 ? '' : 's'} total · current tier: ${tier}`);
987
+ }
988
+ } else if (rawArgs.includes('install')) {
989
+ // CE-0.1.30.4 — install command. Verify the JWT FIRST, then persist
990
+ // to a platform-appropriate location (macOS Keychain / file).
991
+ // Closes the customer-onboarding gap where new customers had no
992
+ // friendly way to register a license without manually editing
993
+ // shell-rc / .env files.
994
+ const installIdx = rawArgs.indexOf('install');
995
+ const rawKey = rawArgs[installIdx + 1];
996
+ const installKey = typeof rawKey === 'string' ? rawKey.trim() : '';
997
+
998
+ if (!installKey || installKey.startsWith('-')) {
999
+ console.error('Usage: nsauditor-ai license install <KEY>');
1000
+ console.error(' KEY is the JWT from your purchase confirmation (starts with `pro_` or `enterprise_`).');
1001
+ console.error(' Verifies the signature before persisting; invalid keys are rejected.');
1002
+ console.error('');
1003
+ console.error('Storage locations (chosen by platform):');
1004
+ console.error(' macOS: Keychain — service "nsauditor-ai", account "NSAUDITOR_LICENSE_KEY"');
1005
+ console.error(' Linux: $XDG_CONFIG_HOME/nsauditor/.env (or ~/.nsauditor/.env), mode 0600');
1006
+ console.error(' Windows: %USERPROFILE%\\.nsauditor\\.env (DPAPI integration on roadmap)');
1007
+ process.exit(2);
1008
+ }
1009
+
1010
+ // 1. Verify the key BEFORE persisting. Bypasses the resolver chain
1011
+ // by passing the key explicitly to loadLicense().
1012
+ const verified = await loadLicense(installKey);
1013
+ if (!verified.valid) {
1014
+ console.error(`✗ License key rejected: ${verified.reason}`);
1015
+ console.error(' No changes made. Confirm the key matches your purchase email exactly.');
1016
+ process.exit(1);
1017
+ }
1018
+
1019
+ // 2. Persist (Keychain on macOS / file elsewhere). Lazy import to
1020
+ // avoid loading the persistor unless install is actually invoked.
1021
+ const { persistLicenseKey } = await import('./utils/license.mjs');
1022
+ const persisted = await persistLicenseKey(installKey);
1023
+ if (!persisted.ok) {
1024
+ console.error(`✗ Verification succeeded but storage failed: ${persisted.error}`);
1025
+ console.error(' Fall-back: set NSAUDITOR_LICENSE_KEY env var manually:');
1026
+ console.error(' export NSAUDITOR_LICENSE_KEY="<your-key>"');
1027
+ process.exit(1);
1028
+ }
1029
+
1030
+ // 3. Confirm. Same shape as `license --status` so customers see
1031
+ // the persisted key reflected back. NEVER print the key value
1032
+ // itself — it's a secret. Reviewer M1 fold: surface the
1033
+ // Keychain-fallback warning if the persistor returned one
1034
+ // (silent fallback would leave macOS users believing they
1035
+ // have Keychain protection when they don't).
1036
+ const tierLabel = { ce: 'Community Edition (CE)', pro: 'Pro', enterprise: 'Enterprise' };
1037
+ if (persisted.warning) {
1038
+ console.warn(`⚠ ${persisted.warning}`);
1039
+ }
1040
+ console.log(`✓ ${tierLabel[verified.tier]} license installed`);
1041
+ console.log(` Stored at: ${persisted.location}`);
1042
+ console.log(` Org: ${verified.org}`);
1043
+ console.log(` Seats: ${verified.seats}`);
1044
+ console.log(` License ID: ${verified.licenseId}`);
1045
+ console.log(` Expires: ${verified.expiresAt}`);
1046
+ console.log('');
1047
+ console.log(' Verify with: nsauditor-ai license --status');
851
1048
  } else {
852
- console.log('Usage: nsauditor-ai license --status | --capabilities');
1049
+ console.log('Usage: nsauditor-ai license --status | --capabilities | --plugins | install <KEY>');
853
1050
  }
854
1051
  process.exit(0);
855
1052
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -51,3 +51,29 @@ export function resolveCapabilities(tier = 'ce') {
51
51
  export function hasCapability(capabilities, cap) {
52
52
  return Boolean(capabilities?.[cap]);
53
53
  }
54
+
55
+ // CE-0.1.30.3 reviewer M2 fold: derive the highest tier among a plugin's
56
+ // required capabilities. Used by `license --plugins` to render
57
+ // "✗ requires: <tier>" accurately when a plugin doesn't declare a `tier`
58
+ // field. Pre-fold the CLI fell back to `'pro'` for plugins like 021/022/023
59
+ // (no `tier` field, but require `cloudScanners` which is enterprise-gated)
60
+ // — which misled customers about what license they needed.
61
+ //
62
+ // Returns 'ce' / 'pro' / 'enterprise' (the highest tier among all required
63
+ // caps), or null when the plugin has no requiredCapabilities.
64
+ const _TIER_RANK = { ce: 0, pro: 1, enterprise: 2 };
65
+ const _RANK_TO_TIER = ['ce', 'pro', 'enterprise'];
66
+
67
+ export function inferRequiredTier(requiredCapabilities) {
68
+ if (!Array.isArray(requiredCapabilities) || requiredCapabilities.length === 0) {
69
+ return null;
70
+ }
71
+ let maxRank = 0;
72
+ for (const cap of requiredCapabilities) {
73
+ const def = CAPABILITIES[cap];
74
+ if (!def) continue;
75
+ const rank = _TIER_RANK[def.tier] ?? 0;
76
+ if (rank > maxRank) maxRank = rank;
77
+ }
78
+ return _RANK_TO_TIER[maxRank];
79
+ }
package/utils/license.mjs CHANGED
@@ -7,6 +7,11 @@
7
7
  // become invalid. See license-manager docs/architecture.md for full procedure.
8
8
 
9
9
  import { jwtVerify, importSPKI } from 'jose';
10
+ import { promises as fsp } from 'node:fs';
11
+ import { homedir, platform } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import dotenv from 'dotenv';
14
+ import { keychainGet, keychainSet } from './keychain.mjs';
10
15
 
11
16
  // ES256 public key — embedded directly so it works in npm package (no file read).
12
17
  // Corresponding private key is in the license-manager service (NEVER shipped here).
@@ -41,6 +46,229 @@ export function getTierFromEnv() {
41
46
  return 'ce';
42
47
  }
43
48
 
49
+ /**
50
+ * CE-0.1.30.2 — multi-source license-key resolution chain.
51
+ *
52
+ * Resolution order (first non-empty wins):
53
+ * 1. process.env.NSAUDITOR_LICENSE_KEY — CI/CD takes precedence
54
+ * 2. macOS Keychain (service=nsauditor-ai, account=NSAUDITOR_LICENSE_KEY)
55
+ * — set by `nsauditor-ai license install <KEY>` on macOS
56
+ * 3. $XDG_CONFIG_HOME/nsauditor/.env (or ~/.nsauditor/.env) — universal
57
+ * file fallback; set by `license install` on Linux/Windows OR
58
+ * manually edited by the operator. Mode 0600 expected; permissive
59
+ * mode triggers a warning (still loaded — operator's choice).
60
+ *
61
+ * @param {object} [opts]
62
+ * @param {string} [opts._homeFileOverride] — test seam. Path to a .env-format
63
+ * file to read instead of ~/.nsauditor/.env. Bypasses XDG resolution.
64
+ * @param {Function} [opts._keychainGet] — test seam. Replaces the macOS
65
+ * Keychain reader.
66
+ * @returns {Promise<string|null>} The license key string, or null if no
67
+ * source had one.
68
+ */
69
+ export async function resolveLicenseKey(opts = {}) {
70
+ // 1. env var
71
+ if (process.env.NSAUDITOR_LICENSE_KEY) return process.env.NSAUDITOR_LICENSE_KEY;
72
+
73
+ // 2. platform secret store (macOS Keychain today; Linux/Windows skip)
74
+ const kget = opts._keychainGet ?? keychainGet;
75
+ try {
76
+ const fromKeychain = await kget('NSAUDITOR_LICENSE_KEY');
77
+ if (fromKeychain) return fromKeychain;
78
+ } catch { /* keychain unavailable — fall through */ }
79
+
80
+ // 3. ~/.nsauditor/.env (or $XDG_CONFIG_HOME/nsauditor/.env)
81
+ const filePath = opts._homeFileOverride ?? defaultLicenseFilePath();
82
+ try {
83
+ const stat = await fsp.stat(filePath);
84
+ // Warn (non-fatal) if mode is more permissive than 0600 on POSIX.
85
+ // Windows has no concept of POSIX file mode; skip the check there.
86
+ // Reviewer M10 fold: only warn once per path per process to avoid
87
+ // spamming the console under repeated CLI invocations.
88
+ if (platform() !== 'win32' && stat.isFile() && (stat.mode & 0o077) !== 0) {
89
+ if (!_permissiveWarnedPaths.has(filePath)) {
90
+ const modeStr = (stat.mode & 0o777).toString(8).padStart(3, '0');
91
+ console.warn(`⚠ ${filePath} has permissive mode ${modeStr} — recommend chmod 0600`);
92
+ _permissiveWarnedPaths.add(filePath);
93
+ }
94
+ }
95
+ const buf = await fsp.readFile(filePath, 'utf8');
96
+ const parsed = dotenv.parse(buf);
97
+ if (parsed.NSAUDITOR_LICENSE_KEY) return parsed.NSAUDITOR_LICENSE_KEY;
98
+ } catch { /* file missing / unreadable — fall through */ }
99
+
100
+ return null;
101
+ }
102
+
103
+ // CE-0.1.30.2 reviewer M10: one-shot permissive-mode warning per path per
104
+ // process. Without this, every `loadLicense()` call against a 0644 file
105
+ // emits a console.warn — and CE re-resolves on every CLI invocation
106
+ // (plus the `cmd === 'license'` branch double-calls today, see reviewer
107
+ // M7 follow-up). Module-scoped Set keeps memory bounded (~1 path per
108
+ // install) and silences the noise without hiding the message from
109
+ // operators who haven't seen it yet.
110
+ const _permissiveWarnedPaths = new Set();
111
+
112
+ function defaultLicenseFilePath() {
113
+ // Honor $XDG_CONFIG_HOME (Linux convention; some macOS users set it).
114
+ // Falls back to ~/.nsauditor/.env which is what the existing README
115
+ // method-2 docs already promise.
116
+ if (process.env.XDG_CONFIG_HOME) {
117
+ return join(process.env.XDG_CONFIG_HOME, 'nsauditor', '.env');
118
+ }
119
+ return join(homedir(), '.nsauditor', '.env');
120
+ }
121
+
122
+ /**
123
+ * CE-0.1.30.4 — persist a verified license key to a platform-appropriate
124
+ * location so subsequent `loadLicense()` calls find it via the resolver
125
+ * chain (CE-0.1.30.2).
126
+ *
127
+ * Platform routing:
128
+ * - macOS: keychainSet('NSAUDITOR_LICENSE_KEY', key) via the existing
129
+ * utils/keychain.mjs helper. If Keychain is unavailable
130
+ * (e.g., headless mac without `security` daemon, user
131
+ * denied the prompt), falls back to the file path so
132
+ * install does not silently fail.
133
+ * - Linux: write $XDG_CONFIG_HOME/nsauditor/.env (or ~/.nsauditor/.env)
134
+ * with mode 0600 (parent dir 0700). Preserves any OTHER
135
+ * env-vars the operator has in the file — only the
136
+ * NSAUDITOR_LICENSE_KEY line is replaced/added.
137
+ * - Windows: same file path (%USERPROFILE%\.nsauditor\.env). No DPAPI
138
+ * yet — defer to a future release; the file ACL inherits
139
+ * from the user profile which is typically restrictive
140
+ * enough on Windows 10+.
141
+ *
142
+ * The caller MUST verify the key via loadLicense() BEFORE calling
143
+ * persistLicenseKey() — this function does not validate. Persisting an
144
+ * invalid or expired key would store garbage and the next loadLicense()
145
+ * call would just reject it again, but doing so silently undermines the
146
+ * `install` command's contract ("we only persist verified keys").
147
+ *
148
+ * @param {string} key - The full prefixed key (`pro_eyJ...` or `enterprise_eyJ...`).
149
+ * @param {object} [opts]
150
+ * @param {string} [opts._platform] - Test seam (override Node's platform()).
151
+ * @param {Function} [opts._keychainSet] - Test seam (replace Keychain writer).
152
+ * @param {string} [opts._homeFileOverride] - Test seam (override the file path).
153
+ * @returns {Promise<{ok: true, location: string} | {ok: false, error: string}>}
154
+ * On success, `location` is a human-readable string ("macOS Keychain
155
+ * (service=nsauditor-ai)" or the filesystem path). The `install`
156
+ * command surfaces this so the operator knows where the key landed.
157
+ */
158
+ export async function persistLicenseKey(key, opts = {}) {
159
+ if (typeof key !== 'string' || key.length === 0) {
160
+ return { ok: false, error: 'persistLicenseKey: key must be a non-empty string' };
161
+ }
162
+
163
+ const plat = opts._platform ?? platform();
164
+ const kset = opts._keychainSet ?? keychainSet;
165
+
166
+ // 1. macOS: try Keychain first.
167
+ // Reviewer M1 fold: track Keychain-fallback reason so the caller can
168
+ // surface it to the operator. Pre-fix, the fallback was silent — the
169
+ // operator only learned about it implicitly via the `location` line
170
+ // showing a filesystem path instead of "macOS Keychain (...)".
171
+ let keychainFallbackReason = null;
172
+ if (plat === 'darwin') {
173
+ try {
174
+ await kset('NSAUDITOR_LICENSE_KEY', key);
175
+ return { ok: true, location: 'macOS Keychain (service=nsauditor-ai)' };
176
+ } catch (err) {
177
+ // Fall through to file-based storage. Examples: `security` daemon
178
+ // unavailable on headless mac; user denied the Keychain prompt;
179
+ // SIP-restricted environment.
180
+ keychainFallbackReason = err && err.message ? err.message : String(err);
181
+ }
182
+ }
183
+
184
+ // 2. File-based storage (Linux, Windows, macOS Keychain fallback).
185
+ try {
186
+ const filePath = opts._homeFileOverride ?? defaultLicenseFilePath();
187
+ const dir = dirname(filePath);
188
+ // Create dir with 0700 if missing (recursive=true is a no-op if it
189
+ // already exists; mode is only applied to NEW dirs along the path).
190
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
191
+
192
+ // Preserve other env-vars in an existing file. Read-modify-write
193
+ // pattern: parse current contents, replace/add the
194
+ // NSAUDITOR_LICENSE_KEY line, write back.
195
+ let existingContent = '';
196
+ try {
197
+ existingContent = await fsp.readFile(filePath, 'utf8');
198
+ } catch { /* missing file — fine, we'll create one */ }
199
+
200
+ const newContent = mergeLicenseIntoEnvFile(existingContent, key);
201
+ await fsp.writeFile(filePath, newContent, { mode: 0o600 });
202
+
203
+ // Re-chmod in case the file pre-existed with a more permissive mode
204
+ // (writeFile only sets mode on CREATE, not overwrite).
205
+ if (plat !== 'win32') {
206
+ await fsp.chmod(filePath, 0o600);
207
+ }
208
+
209
+ const result = { ok: true, location: filePath };
210
+ if (keychainFallbackReason !== null) {
211
+ result.warning =
212
+ `macOS Keychain unavailable (${keychainFallbackReason}); fell back to file storage. ` +
213
+ `Re-run after granting Keychain access for stronger protection.`;
214
+ }
215
+ return result;
216
+ } catch (err) {
217
+ return { ok: false, error: err.message };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Merge a license key into the existing dotenv-format file content,
223
+ * preserving every OTHER line. If a NSAUDITOR_LICENSE_KEY line already
224
+ * exists, replace it; otherwise append. If the file was empty/new,
225
+ * write a header comment.
226
+ *
227
+ * Reviewer M2 / M2b folds:
228
+ * - **Multi-occurrence safety**: a corrupted file with TWO+ existing
229
+ * `NSAUDITOR_LICENSE_KEY=` lines previously had only the first
230
+ * replaced. dotenv parses last-wins, so `--status` would show the
231
+ * OLD value while install reported success. Now we replace the first
232
+ * occurrence and remove the rest, collapsing blank lines.
233
+ * - **CRLF preservation**: the regex anchors on `[ \t]*` (not `\s*`,
234
+ * which matches `\r`) and the value-tail uses `[^\r\n]*` so Windows-
235
+ * style line endings are not mangled. The replacement line itself
236
+ * uses `\n` regardless — Notepad and dotenv both accept mixed
237
+ * endings, but this avoids producing them from `\r`-stripped tails.
238
+ *
239
+ * Exported for test coverage of the merge semantics specifically.
240
+ * @internal
241
+ */
242
+ export function mergeLicenseIntoEnvFile(existingContent, key) {
243
+ const KEY_LINE_RE = /^[ \t]*NSAUDITOR_LICENSE_KEY[ \t]*=[^\r\n]*$/gm;
244
+ const newLine = `NSAUDITOR_LICENSE_KEY=${key}`;
245
+
246
+ // Count matches (regex needs the global flag for matchAll-equivalence).
247
+ const matches = existingContent.match(KEY_LINE_RE);
248
+ if (matches && matches.length > 0) {
249
+ // Replace the first occurrence in place; remove any duplicates
250
+ // (corrupted-file defense). Collapse the blank lines that result.
251
+ let firstReplaced = false;
252
+ let merged = existingContent.replace(KEY_LINE_RE, () => {
253
+ if (firstReplaced) return '__NSAUDITOR_PURGE__'; // sentinel for removal
254
+ firstReplaced = true;
255
+ return newLine;
256
+ });
257
+ // Drop sentinel lines + their trailing newline.
258
+ merged = merged.replace(/__NSAUDITOR_PURGE__\r?\n?/g, '');
259
+ return merged;
260
+ }
261
+
262
+ if (existingContent.trim().length === 0) {
263
+ // Empty/new file — write a header.
264
+ return `# NSAuditor AI license — set via \`nsauditor-ai license install\`\n${newLine}\n`;
265
+ }
266
+
267
+ // Append to existing content (with a separating newline if needed).
268
+ const sep = existingContent.endsWith('\n') ? '' : '\n';
269
+ return `${existingContent}${sep}${newLine}\n`;
270
+ }
271
+
44
272
  /**
45
273
  * Full async JWT verification. Call once at startup.
46
274
  * On success, caches verified tier so subsequent getTierFromEnv() calls
@@ -48,12 +276,17 @@ export function getTierFromEnv() {
48
276
  *
49
277
  * Never throws — degrades to 'ce' on any failure.
50
278
  *
51
- * @param {string} [keyStr] - License key; defaults to NSAUDITOR_LICENSE_KEY env var.
279
+ * @param {string} [keyStr] - License key; if omitted, runs the multi-source
280
+ * resolution chain (env var → Keychain → ~/.nsauditor/.env). See
281
+ * resolveLicenseKey() above.
52
282
  * @returns {Promise<{valid: boolean, tier: string, org?: string, seats?: number,
53
283
  * licenseId?: string, capabilities?: string[], expiresAt?: string, reason?: string}>}
54
284
  */
55
285
  export async function loadLicense(keyStr) {
56
- const raw = keyStr ?? process.env.NSAUDITOR_LICENSE_KEY;
286
+ // Explicit keyStr argument wins (preserves the existing behavior for
287
+ // callers like the `license --status` subcommand which passes the env
288
+ // var directly). When omitted, run the multi-source resolution chain.
289
+ const raw = keyStr ?? (await resolveLicenseKey());
57
290
  if (!raw) return { valid: false, tier: 'ce', reason: 'no key provided' };
58
291
 
59
292
  // Strip tier prefix
@@ -124,4 +357,5 @@ export function _resetCache() {
124
357
  throw new Error('_resetCache is test-only and disabled in production');
125
358
  }
126
359
  _verifiedTier = null;
360
+ _permissiveWarnedPaths.clear();
127
361
  }
package/utils/oui.mjs CHANGED
@@ -6,11 +6,19 @@ import { fileURLToPath } from "url";
6
6
 
7
7
  let OUI_DB = null;
8
8
 
9
+ // CE-0.1.30.3 reviewer M1 fold: gate startup chatter behind NSA_VERBOSE so
10
+ // operator-facing flags like `nsauditor-ai license --plugins` and
11
+ // `nsauditor-ai --version` do not pollute stdout with debug banners.
12
+ // Errors remain unconditional (console.error, stderr) — the operator
13
+ // should still see real failure modes.
14
+ const VERBOSE = /^(1|true|yes|on)$/i.test(String(process.env.NSA_VERBOSE || ''));
15
+ const vlog = VERBOSE ? (...a) => console.log(...a) : () => {};
16
+
9
17
  // Try to import oui-data module, fall back to local file if it fails
10
18
  async function loadOuiData() {
11
19
  try {
12
20
  const ouiData = await import("oui-data", { with: { type: "json" } }).then(module => module.default);
13
- console.log("[oui.mjs] Successfully loaded oui-data module with", Object.keys(ouiData).length, "entries");
21
+ vlog("[oui.mjs] Successfully loaded oui-data module with", Object.keys(ouiData).length, "entries");
14
22
  return ouiData;
15
23
  } catch (e) {
16
24
  console.error("[oui.mjs] Failed to load oui-data module:", e.message);
@@ -19,7 +27,7 @@ async function loadOuiData() {
19
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
28
  const localPath = path.join(__dirname, "oui-data.json");
21
29
  const data = JSON.parse(await fs.readFile(localPath, "utf8"));
22
- console.log("[oui.mjs] Loaded fallback oui-data.json from", localPath, "with", Object.keys(data).length, "entries");
30
+ vlog("[oui.mjs] Loaded fallback oui-data.json from", localPath, "with", Object.keys(data).length, "entries");
23
31
  return data;
24
32
  } catch (fallbackError) {
25
33
  console.error("[oui.mjs] Failed to load fallback oui-data.json:", fallbackError.message);
@@ -65,7 +73,7 @@ export function lookupVendor(mac) {
65
73
  const oui = macToOUI(mac);
66
74
  const entry = OUI_DB[oui];
67
75
  const vendor = pickOrgName(entry);
68
- console.log(`[oui.mjs] Lookup for MAC ${mac} (OUI: ${oui}) -> Vendor: ${vendor || 'Not found'}`);
76
+ vlog(`[oui.mjs] Lookup for MAC ${mac} (OUI: ${oui}) -> Vendor: ${vendor || 'Not found'}`);
69
77
  return vendor;
70
78
  } catch (e) {
71
79
  console.error("[oui.mjs] Lookup error:", e.message);