nsauditor-ai 0.1.68 → 0.1.70
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 +8 -4
- package/data/license-revocations.json +7 -0
- package/package.json +2 -1
- package/utils/license.mjs +385 -1
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
|
-
- **CE 0.1.
|
|
20
|
+
- **CE 0.1.69** (current) — paired with **EE 0.9.0** (May 2026). **HIPAA FRAMEWORK CYCLE — first 0.9.x release**: HIPAA Security Rule §164.312 Technical Safeguards ships as the second supported compliance framework alongside SOC 2 (closes the long-standing "planned" gap in EE's roadmap for the highest-demand next framework). **Dual-framework workflow**: use `--compliance hipaa` or `--compliance soc2,hipaa` for HIPAA-only or SOC 2 + HIPAA evidence packs from a single scan. **HIPAA coverage matrix introduced at 7 covered + 3 partial + 45 OOS** within §164.312 + entire §164.308 Administrative Safeguards + entire §164.310 Physical Safeguards (architecturally OOS for any infrastructure scanner — pair with HIPAA-focused GRC platforms like Drata HIPAA / Vanta HIPAA / Compliancy Group for §164.308 + §164.310). **HHS Required vs Addressable discipline** surfaced per control. **§164.312(c)(1) Integrity ransomware-defense substrate**: EE's `aws-backup-auditor` Logically Air-Gapped Backup Vault cross-verification produces HHS-OCR-2024-enforcement-relevant evidence on ePHI backup integrity preservation against full source-account compromise. **Per-framework SLA-citation map** in EE renderer — HIPAA reports cite §164.312(b) audit-controls cadence + §164.312(d) Person/Entity Authentication (not SOC 2 CC IDs); closes the auditor-detectable cross-framework citation leak class. **Zero BAA required** — Zero Data Exfiltration architecture means ePHI never leaves customer infrastructure. **Zero engine/CLI changes**: CE's `--compliance` flag already accepts CSV (wired since EE 0.3.0). **6 same-session reviewer folds** (2 R-HIGH defensive-coding + 2 R-MEDIUM scope-broadening + 1 R-LOW comment fix + 1 docstring strengthening; 0 R-CRITICAL). **+85 new tests across 3 new suites**: load-bearing anchor-drift defense via inheritance contract (32) + engine-end-to-end fixture tests across all 10 §164.312 controls (36) + per-framework renderer citation correctness (17). **EE regression: 5890/5890 across 928 suites; 69-session 100% green streak preserved.** **AWS-dogfood verified** against operator's test account (207 findings analyzed, per-framework citation map confirmed firing, ransomware-substrate surfaces correctly). **Plugin count UNCHANGED at 24**; SOC 2 coverage matrix UNCHANGED at 10/4/33 (additive-only cycle; no SOC 2 mappings changed). **No breaking changes** — additive only.
|
|
21
|
+
|
|
22
|
+
- **CE 0.1.68** — paired with **EE 0.8.0** (May 2026). **24 enterprise plugins** across AWS / Azure / GCP, mapped to 10 fully-covered + 4 partial AICPA TSC controls. **MINOR VERSION MILESTONE — EE-RT.23 Move B**: EE plugin 1022 Azure scanner refactored to per-dim source attribution. Each of the 4 helpers now emits findings with its own source: `azure-nsg-auditor` / `azure-rbac-auditor` / `azure-storage-auditor` / `azure-keyvault-auditor`. Closes long-standing blocker (EE 0.6.9 R1-MEDIUM-1) for routing Azure storage findings into Appendix A "Cloud Bucket Exposure Attestation" without commingling NSG / RBAC / Key Vault. **Engine `details.category` projection contract** added (`normalizeFindings` + violation surface; additive, backward-compat). **Key Vault soc2.json gap closure — 13 new mappings** (CC6.1 + CC6.3 + C1.1 + A1.2): pre-0.8.0 Key Vault dim emitted 10 distinct `details.category` values but had ZERO soc2 routing — latent silent false-clean class closed. **Appendix A multi-cloud expansion**: 3 sources (AWS S3 + GCS + Azure Storage); `computeBucketStats` dedup key now provider-qualified (closes cross-cloud bucket-name collision). **7 same-session reviewer folds** (2 R-HIGH + 3 R-MEDIUM + 2 R-LOW; 0 R-CRITICAL). **+23 new tests / +6 new suites**. **EE regression: 5805/5805 across 907 suites; 68-session 100% green streak preserved.** **Plugin count UNCHANGED at 24**; coverage matrix UNCHANGED at 10/4/33 (pure substrate-evidence depth uplift on already-covered controls — but KV gap closure was a silent false-clean class). ⚠️ **Customer migration**: suppressions targeting `match.source: 'azure-cloud-scanner'` silently no-op post-0.8.0 — must be split into per-dim entries (see EE CHANGELOG for migration snippet).
|
|
21
23
|
|
|
22
24
|
- **CE 0.1.67** — paired with **EE 0.7.3** (May 2026). **Critical hotfix** closing 2 production bugs surfaced by EE 0.7.2 dogfood scan: cross-version `google-auth-library` fragmentation broke SA impersonation chains (R-CRITICAL — 100% false-clean impact on free-trial/gmail GCP customers + business GCP customers with no-long-lived-SA-keys policy); `GOOGLE_CLOUD_PROJECT_ID` env-var alias silently skipped (R-MEDIUM). +14 new tests across 2 new suites. EE regression 5782/5782 across 900 suites; 67-session 100% green streak preserved.
|
|
23
25
|
|
|
@@ -33,7 +35,7 @@ For prior releases, see [CHANGELOG.md](./CHANGELOG.md).
|
|
|
33
35
|
|
|
34
36
|
→ **[See a sample scan walk-through](https://www.nsauditor.com/ai/docs/sample-scan/)** — full EE 0.6.7 output against a fictional Acme Corp AWS account + home-office router. Real engine output, synthetic data, no signup required.
|
|
35
37
|
|
|
36
|
-
→ **[NSAuditor AI Enterprise Edition](https://www.nsauditor.com/ai/enterprise/)** —
|
|
38
|
+
→ **[NSAuditor AI Enterprise Edition](https://www.nsauditor.com/ai/enterprise/)** — 24 cloud plugins across AWS / Azure / GCP, signed SOC 2 + HIPAA §164.312 evidence with RFC 3161 timestamps, native Vanta connector (Drata / Secureframe planned), runs entirely inside your infrastructure (zero data exfiltration by architecture; no BAA required for HIPAA — ePHI never leaves customer infrastructure). Pricing from **$2k/yr (5 seats)** to **$10k+/yr (unlimited + custom SLA)**.
|
|
37
39
|
|
|
38
40
|
---
|
|
39
41
|
|
|
@@ -72,7 +74,8 @@ NSAuditor AI is available in three editions:
|
|
|
72
74
|
| Advanced CTEM + trend analysis | — | ✅ | ✅ |
|
|
73
75
|
| Cloud scanners (AWS/GCP/Azure) | — | — | ✅ |
|
|
74
76
|
| Zero Trust assessment | — | — | ✅ |
|
|
75
|
-
| SOC 2 compliance (10 covered + 4 partial controls
|
|
77
|
+
| SOC 2 compliance (10 covered + 4 partial controls; AWS + Azure + GCP evidence streams; PI1.5 stored-items partial via DynamoDB audit-the-auditor) | — | — | ✅ |
|
|
78
|
+
| **HIPAA §164.312 Technical Safeguards** (NEW EE 0.9.0 — 7 covered + 3 partial + 45 OOS; §164.308 admin + §164.310 physical architecturally OOS; per-control HHS Required/Addressable discipline; **Zero BAA required**) | — | — | ✅ |
|
|
76
79
|
| SLA/MTTR tracking + compensating controls | — | — | ✅ |
|
|
77
80
|
| Recurring-scan attestation (Type II evidence) | — | — | ✅ |
|
|
78
81
|
| GRC platform connector (Vanta) | — | — | ✅ |
|
|
@@ -231,6 +234,7 @@ Results land in `./out/<host>_<timestamp>/`:
|
|
|
231
234
|
| 1190 | AWS SES Email Integrity | Enterprise | 6 dimensions: DKIM enablement + CNAME DNS resolution + key-fingerprint pin, DMARC TXT parsing + alignment classifier, custom MailFrom alignment, config-set TLS enforcement, sending-auth policy wildcards, dedicated IP pool, suppression list (count-only — ZDE invariant: never reads addresses). **CC6.1 / CC6.6 / C1.1 / CC7.1 / Privacy** |
|
|
232
235
|
| 1200 | AWS Inspector2 / GuardDuty Enablement | Enterprise | 4 dimensions across all opted-in regions (17+ incl. GovCloud / ISO): GuardDuty Detector + protection features (S3 / EKS / EBS-malware / RDS-login / Lambda / RuntimeMonitoring), Inspector2 enablement, scan-target coverage. Plus alerting-destination dim (EventBridge or SecurityHub) and per-target liveness probes for Lambda / SNS / SQS / IAM / API destination / CloudWatch Logs. **CC7.1 / CC7.2** |
|
|
233
236
|
| — | SOC 2 Compliance Engine | Enterprise | AICPA TSC 2017 mapping (10 covered + 4 partial controls), chain-of-custody, RFC 3161 timestamps, suppression workflow with Ed25519 signing. |
|
|
237
|
+
| — | **HIPAA Compliance Engine (NEW EE 0.9.0)** | Enterprise | HIPAA Security Rule §164.312 Technical Safeguards mapping (7 covered + 3 partial + 45 OOS within §164.312 + entire §164.308 + entire §164.310). HHS Required/Addressable discipline per control. Same institutional-grade evidence infrastructure as SOC 2 (chain-of-custody, RFC 3161 timestamps, Ed25519 suppression signing). Use `--compliance hipaa` or `--compliance soc2,hipaa` for dual-framework reports from a single scan. **Zero BAA required** — Zero Data Exfiltration architecture means ePHI never leaves customer infrastructure. |
|
|
234
238
|
| — | SLA & MTTR Tracking | Enterprise | Per-severity SLA targets, compensating-control flow, finding lifecycle, Type II rolling-quarter cadence. |
|
|
235
239
|
| — | Recurring-Scan Attestation | Enterprise | Multi-scan chronological matrix, cadence gap detection, scope-drift surface (CC8.1). |
|
|
236
240
|
| — | GRC Platform Connector | Enterprise | Native API push to Vanta / Drata / Secureframe with retry/backoff, idempotency, rate-limit handling, per-tenant token rotation. |
|
|
@@ -543,7 +547,7 @@ nsauditor-ai --version (or -v, or `version`)
|
|
|
543
547
|
| `--interval <min>` | Rescan interval in minutes (requires `--watch`) | `60` |
|
|
544
548
|
| `--webhook-url <url>` | Webhook URL for delta alerts | — |
|
|
545
549
|
| `--alert-severity <sev>` | Minimum severity for webhook alerts | `high` |
|
|
546
|
-
| `--compliance <fw>` | Compliance framework to map findings into (e.g. `soc2`). **Enterprise license required.** See `@nsasoft/nsauditor-ai-ee` README for
|
|
550
|
+
| `--compliance <fw>` | Compliance framework to map findings into. Accepts CSV for multi-framework runs (e.g. `soc2`, `hipaa`, `soc2,hipaa`). **Enterprise license required.** Supported frameworks as of EE 0.9.0: `soc2` (AICPA TSC 2017) + `hipaa` (HIPAA Security Rule §164.312 Technical Safeguards, 2013 Final Rule). See `@nsasoft/nsauditor-ai-ee` README for per-framework coverage details. | — |
|
|
547
551
|
| `--compliance-scope <path>` | Optional JSON file describing the assessment scope (passed to the compliance engine for cover-page attestation) | — |
|
|
548
552
|
| `--help`, `-h` | Print usage block (subcommands, flags, env vars, examples) and exit 0 | — |
|
|
549
553
|
| `--version`, `-v` | Print `nsauditor-ai <version>` and exit 0 | — |
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"issued_at": "2026-05-22T00:00:00.000Z",
|
|
4
|
+
"revoked": [],
|
|
5
|
+
"signature": "",
|
|
6
|
+
"_comment": "Initial CE 0.1.70 empty blocklist. The license-manager service signs this envelope with the production ES256 private key (same key as JWT signing) and ships updates via CE patch releases. An empty/unsigned blocklist is a valid initial state — the verifier returns [] on signature failure (fail-open at the blocklist layer; tampering provides no escalation since the file lives inside the npm package and supply-chain attackers already have full privilege)."
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nsauditor-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"description": "Modular AI-assisted network security audit platform — Community Edition",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"bin/",
|
|
18
18
|
"config/",
|
|
19
|
+
"data/",
|
|
19
20
|
"docs/EULA-nsauditor-ai.md",
|
|
20
21
|
"plugins/",
|
|
21
22
|
"utils/",
|
package/utils/license.mjs
CHANGED
|
@@ -5,11 +5,32 @@
|
|
|
5
5
|
// KEY ROTATION: If the private key is compromised, generate a new EC P-256 key
|
|
6
6
|
// pair, update PUBLIC_KEY_PEM below, and ship a CE update. All existing JWTs
|
|
7
7
|
// become invalid. See license-manager docs/architecture.md for full procedure.
|
|
8
|
+
//
|
|
9
|
+
// CE 0.1.70 (EE 0.9.1 paired) — air-gap operational hardening per external
|
|
10
|
+
// audit 2026-05-22:
|
|
11
|
+
// • D-HIGH-1: per-host licenseId replay defense (persisted on first
|
|
12
|
+
// activation; subsequent loads with a different licenseId fail-closed
|
|
13
|
+
// to CE). Closes the seat-cloning class — a `seats:N` token cannot be
|
|
14
|
+
// installed on N+1 machines without operator-noticeable rejection.
|
|
15
|
+
// • D-HIGH-2: signed revocation blocklist baked into the package
|
|
16
|
+
// (`data/license-revocations.json`). The license-manager service
|
|
17
|
+
// signs the envelope with the production ES256 key; this verifier
|
|
18
|
+
// validates the signature before honoring the revocation list.
|
|
19
|
+
// Vendors can revoke individual licenses via CE patch bump without
|
|
20
|
+
// PUBLIC_KEY_PEM rotation (which would invalidate ALL licenses).
|
|
21
|
+
// • D-HIGH-3: monotonic-clock anchor — persisted `last_seen_unix_ts`
|
|
22
|
+
// is checked on each load; a wall-clock rewind beyond CLOCK_ROLLBACK
|
|
23
|
+
// _TOLERANCE_S fails-closed to CE. Defeats `faketime`-style attacks
|
|
24
|
+
// against the JWT `exp` claim in air-gap deployments where NTP cannot
|
|
25
|
+
// be consulted at verification time.
|
|
8
26
|
|
|
9
27
|
import { jwtVerify, importSPKI } from 'jose';
|
|
10
28
|
import { promises as fsp } from 'node:fs';
|
|
29
|
+
import * as fsSync from 'node:fs';
|
|
11
30
|
import { homedir, platform } from 'node:os';
|
|
12
31
|
import { dirname, join } from 'node:path';
|
|
32
|
+
import { createPublicKey, createVerify } from 'node:crypto';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
13
34
|
import dotenv from 'dotenv';
|
|
14
35
|
import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
|
|
15
36
|
|
|
@@ -25,6 +46,69 @@ u8cSY2dN6mfevYnOydP0SXLHCfWHr+SlpZpA2BiU6GKEk+QdIlWXOgGZsA==
|
|
|
25
46
|
// CE (Community Edition) is free and always works without a license.
|
|
26
47
|
let _verifiedTier = null;
|
|
27
48
|
|
|
49
|
+
// ── CE 0.1.70 — Air-gap hardening constants ──────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// Wall-clock rewind tolerance: a 5-minute slack covers NTP step adjustments,
|
|
52
|
+
// DST transitions on misconfigured systems, and brief clock skew between
|
|
53
|
+
// suspend/resume cycles. Beyond this, treat as clock-rollback attempt.
|
|
54
|
+
// Tunable via NSAUDITOR_LICENSE_CLOCK_TOLERANCE_S for support-only use.
|
|
55
|
+
const CLOCK_ROLLBACK_TOLERANCE_S = 5 * 60;
|
|
56
|
+
|
|
57
|
+
// Persisted state file basename. Lives at:
|
|
58
|
+
// • Linux: $XDG_STATE_HOME/nsauditor/license-state.json
|
|
59
|
+
// (falls back to ~/.local/state/nsauditor/license-state.json,
|
|
60
|
+
// then ~/.nsauditor/license-state.json)
|
|
61
|
+
// • Windows: %LOCALAPPDATA%\nsauditor\license-state.json
|
|
62
|
+
// • macOS: ~/.nsauditor/license-state.json (licenseId ALSO persisted in
|
|
63
|
+
// Keychain via service=nsauditor-ai account=NSAUDITOR_LICENSE_ID;
|
|
64
|
+
// Keychain wins on read when both exist).
|
|
65
|
+
const LICENSE_STATE_BASENAME = 'license-state.json';
|
|
66
|
+
const LICENSE_ID_KEYCHAIN_ACCOUNT = 'NSAUDITOR_LICENSE_ID';
|
|
67
|
+
|
|
68
|
+
// Shipped revocation blocklist. ES256-signed envelope at this path; updated
|
|
69
|
+
// by the license-manager service and shipped in each CE patch via the
|
|
70
|
+
// package.json `files` array. Verifier reads, validates signature against
|
|
71
|
+
// the same PUBLIC_KEY_PEM above, and returns the `revoked` array.
|
|
72
|
+
const REVOCATION_BLOCKLIST_PATH = fileURLToPath(
|
|
73
|
+
new URL('../data/license-revocations.json', import.meta.url),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Escape-hatch env vars — DOCUMENTED AS SUPPORT-ONLY. Operators with edge-
|
|
77
|
+
// case deployment requirements (hardware migration without vendor support,
|
|
78
|
+
// emergency clock rollback for a stuck system, etc.) can disable individual
|
|
79
|
+
// defenses. Accepted disable values per `_envDisabled` are case-insensitive:
|
|
80
|
+
// `0` / `false` / `no` / `off` / `disabled`. Default ENABLED for all three.
|
|
81
|
+
//
|
|
82
|
+
// Persistent audit trail (operator-visible record of which defenses were
|
|
83
|
+
// disabled across loadLicense calls) is deferred to CE 0.1.71 — see
|
|
84
|
+
// `tasks/external-audit-findings-2026-05-22.md` next-cycle item. For 0.9.1
|
|
85
|
+
// the disables are runtime-only; operators concerned about defense hygiene
|
|
86
|
+
// should grep their env for these names at deployment time.
|
|
87
|
+
const ENV_REPLAY_DEFENSE = 'NSAUDITOR_LICENSE_ID_REPLAY_DEFENSE';
|
|
88
|
+
const ENV_REVOCATION_CHECK = 'NSAUDITOR_LICENSE_REVOCATION_CHECK';
|
|
89
|
+
const ENV_CLOCK_ANCHOR = 'NSAUDITOR_LICENSE_CLOCK_ANCHOR';
|
|
90
|
+
|
|
91
|
+
// P1.D R2-HIGH fold: accept all common falsy notations + normalize case
|
|
92
|
+
// per [[aws_string_case_normalization]] discipline. Operators who set
|
|
93
|
+
// `=False` (capital F) or `=off` or `=no` previously left the defense
|
|
94
|
+
// enabled — silently broke the support-only docstring promise.
|
|
95
|
+
const _ENV_DISABLE_VALUES = new Set(['0', 'false', 'no', 'off', 'disabled']);
|
|
96
|
+
function _envDisabled(name) {
|
|
97
|
+
const raw = process.env[name];
|
|
98
|
+
if (raw === undefined || raw === null) return false;
|
|
99
|
+
return _ENV_DISABLE_VALUES.has(String(raw).trim().toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _resolveClockToleranceS() {
|
|
103
|
+
const v = parseInt(process.env.NSAUDITOR_LICENSE_CLOCK_TOLERANCE_S ?? '', 10);
|
|
104
|
+
if (!Number.isFinite(v) || v < 0) return CLOCK_ROLLBACK_TOLERANCE_S;
|
|
105
|
+
// P1.D R2-MED fold: cap at 24h. Anything beyond a day is not a "clock
|
|
106
|
+
// skew" — it's a backdoor disable of D-HIGH-3 without going through the
|
|
107
|
+
// documented NSAUDITOR_LICENSE_CLOCK_ANCHOR=0 escape hatch. Cap surfaces
|
|
108
|
+
// the abuse via the more-visible explicit env var.
|
|
109
|
+
return Math.min(v, 24 * 60 * 60);
|
|
110
|
+
}
|
|
111
|
+
|
|
28
112
|
/**
|
|
29
113
|
* Synchronous tier detection.
|
|
30
114
|
* Returns 'pro' | 'enterprise' | 'ce'.
|
|
@@ -269,6 +353,228 @@ export function mergeLicenseIntoEnvFile(existingContent, key) {
|
|
|
269
353
|
return `${existingContent}${sep}${newLine}\n`;
|
|
270
354
|
}
|
|
271
355
|
|
|
356
|
+
// ── CE 0.1.70 — Air-gap hardening helpers (D-HIGH-1, D-HIGH-2, D-HIGH-3) ─────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the platform-appropriate path to the persisted license-state file.
|
|
360
|
+
* Tracks `licenseId` (replay defense) and `lastSeenUnixTs` (clock anchor).
|
|
361
|
+
*
|
|
362
|
+
* The state file is mode 0600 (POSIX) or profile-ACL-restricted (Windows).
|
|
363
|
+
* macOS additionally persists `licenseId` to the Keychain via the
|
|
364
|
+
* keychainGet/keychainSet helpers; the state file is the source of truth
|
|
365
|
+
* for `lastSeenUnixTs` on every platform.
|
|
366
|
+
*
|
|
367
|
+
* Exported for test inspection.
|
|
368
|
+
* @internal
|
|
369
|
+
*/
|
|
370
|
+
export function _getLicenseStateFilePath(opts = {}) {
|
|
371
|
+
if (opts._stateFile) return opts._stateFile;
|
|
372
|
+
// Env-var override (test infra + advanced operators who want a custom path).
|
|
373
|
+
// Honors the same NSAUDITOR_LICENSE_STATE_FILE shape used by existing
|
|
374
|
+
// tests to redirect to a temp directory during test runs.
|
|
375
|
+
if (process.env.NSAUDITOR_LICENSE_STATE_FILE) {
|
|
376
|
+
return process.env.NSAUDITOR_LICENSE_STATE_FILE;
|
|
377
|
+
}
|
|
378
|
+
const plat = opts._platform ?? platform();
|
|
379
|
+
if (plat === 'win32') {
|
|
380
|
+
const base = process.env.LOCALAPPDATA
|
|
381
|
+
?? join(homedir(), 'AppData', 'Local');
|
|
382
|
+
return join(base, 'nsauditor', LICENSE_STATE_BASENAME);
|
|
383
|
+
}
|
|
384
|
+
// Linux: prefer XDG_STATE_HOME; fall back to ~/.local/state; finally
|
|
385
|
+
// co-locate with the existing ~/.nsauditor/.env for operators who
|
|
386
|
+
// already know that directory.
|
|
387
|
+
const xdgState = process.env.XDG_STATE_HOME;
|
|
388
|
+
if (xdgState) {
|
|
389
|
+
return join(xdgState, 'nsauditor', LICENSE_STATE_BASENAME);
|
|
390
|
+
}
|
|
391
|
+
return join(homedir(), '.nsauditor', LICENSE_STATE_BASENAME);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Read the persisted license-state file. Returns `{ licenseId, lastSeenUnixTs }`
|
|
396
|
+
* or `null` when the file does not exist or cannot be parsed.
|
|
397
|
+
*
|
|
398
|
+
* On macOS we additionally consult the Keychain for `licenseId`; when both
|
|
399
|
+
* exist and disagree, Keychain wins (it's the more tamper-resistant store).
|
|
400
|
+
* @internal
|
|
401
|
+
*/
|
|
402
|
+
export async function _readLicenseState(opts = {}) {
|
|
403
|
+
const statePath = _getLicenseStateFilePath(opts);
|
|
404
|
+
let state = null;
|
|
405
|
+
try {
|
|
406
|
+
const text = await fsp.readFile(statePath, 'utf8');
|
|
407
|
+
const parsed = JSON.parse(text);
|
|
408
|
+
if (parsed && typeof parsed === 'object') {
|
|
409
|
+
state = {
|
|
410
|
+
licenseId: typeof parsed.licenseId === 'string' ? parsed.licenseId : null,
|
|
411
|
+
lastSeenUnixTs: Number.isFinite(parsed.lastSeenUnixTs)
|
|
412
|
+
? parsed.lastSeenUnixTs
|
|
413
|
+
: null,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
} catch { /* missing or malformed — treat as no state */ }
|
|
417
|
+
|
|
418
|
+
const plat = opts._platform ?? platform();
|
|
419
|
+
// Skip Keychain when the state-file env override is set: that's the
|
|
420
|
+
// test-mode signal (no test should write to the operator's real
|
|
421
|
+
// Keychain, and no test should read from it either — bleeds state
|
|
422
|
+
// across test cases via a side channel).
|
|
423
|
+
if (plat === 'darwin' && !process.env.NSAUDITOR_LICENSE_STATE_FILE) {
|
|
424
|
+
const kget = opts._keychainGet ?? keychainGet;
|
|
425
|
+
try {
|
|
426
|
+
const kcId = await kget(LICENSE_ID_KEYCHAIN_ACCOUNT);
|
|
427
|
+
if (kcId) {
|
|
428
|
+
state = state ?? { licenseId: null, lastSeenUnixTs: null };
|
|
429
|
+
state.licenseId = kcId; // Keychain wins
|
|
430
|
+
}
|
|
431
|
+
} catch { /* keychain unavailable — keep state-file value */ }
|
|
432
|
+
}
|
|
433
|
+
return state;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Persist license state (licenseId + lastSeenUnixTs) atomically.
|
|
438
|
+
* Writes to `${path}.tmp` then renames; survives partial-crash without
|
|
439
|
+
* leaving an in-flight half-written state file.
|
|
440
|
+
*
|
|
441
|
+
* On macOS the licenseId is ALSO written to the Keychain (best-effort;
|
|
442
|
+
* a Keychain failure does not abort the file write since the file is the
|
|
443
|
+
* cross-platform fallback path).
|
|
444
|
+
*
|
|
445
|
+
* @internal
|
|
446
|
+
*/
|
|
447
|
+
export async function _writeLicenseState(state, opts = {}) {
|
|
448
|
+
const statePath = _getLicenseStateFilePath(opts);
|
|
449
|
+
const dir = dirname(statePath);
|
|
450
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
451
|
+
const body = JSON.stringify({
|
|
452
|
+
schema_version: 1,
|
|
453
|
+
licenseId: state.licenseId ?? null,
|
|
454
|
+
lastSeenUnixTs: Number.isFinite(state.lastSeenUnixTs) ? state.lastSeenUnixTs : null,
|
|
455
|
+
}, null, 2);
|
|
456
|
+
const tmp = `${statePath}.tmp`;
|
|
457
|
+
await fsp.writeFile(tmp, body, { mode: 0o600 });
|
|
458
|
+
await fsp.rename(tmp, statePath);
|
|
459
|
+
// Re-chmod on overwrite (writeFile honors mode only on CREATE).
|
|
460
|
+
const plat = opts._platform ?? platform();
|
|
461
|
+
if (plat !== 'win32') {
|
|
462
|
+
try { await fsp.chmod(statePath, 0o600); } catch { /* not fatal */ }
|
|
463
|
+
}
|
|
464
|
+
// Skip Keychain when the state-file env override is set: matches the
|
|
465
|
+
// read-side test-mode-skip in _readLicenseState so the test suite's
|
|
466
|
+
// per-case file-path isolation isn't bypassed by the macOS Keychain
|
|
467
|
+
// side channel.
|
|
468
|
+
if (plat === 'darwin' && state.licenseId && !process.env.NSAUDITOR_LICENSE_STATE_FILE) {
|
|
469
|
+
const kset = opts._keychainSet ?? keychainSet;
|
|
470
|
+
try { await kset(LICENSE_ID_KEYCHAIN_ACCOUNT, state.licenseId); } catch { /* not fatal */ }
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Load and verify the shipped revocation blocklist. Returns the list of
|
|
476
|
+
* revoked licenseId strings on success, or `[]` on ANY failure (file
|
|
477
|
+
* missing, malformed JSON, invalid signature, unsupported schema version).
|
|
478
|
+
*
|
|
479
|
+
* Fail-open posture on tampering is deliberate: the blocklist lives in the
|
|
480
|
+
* package's `data/` directory, which is only writable by parties who
|
|
481
|
+
* already have the privilege to swap binaries (i.e., supply-chain
|
|
482
|
+
* attackers); for those, tampering provides no additional escalation. A
|
|
483
|
+
* fail-closed posture (refuse ALL licenses when the blocklist is corrupt)
|
|
484
|
+
* would brick legitimate customers via a single bad CE patch.
|
|
485
|
+
*
|
|
486
|
+
* Test seam: `opts._blocklistData` short-circuits the file load and
|
|
487
|
+
* returns a pre-loaded array. `opts._publicKeyPem` overrides the embedded
|
|
488
|
+
* production key for signature-verification unit tests.
|
|
489
|
+
*
|
|
490
|
+
* @internal
|
|
491
|
+
*/
|
|
492
|
+
export async function _loadAndVerifyBlocklist(opts = {}) {
|
|
493
|
+
if (Array.isArray(opts._blocklistData)) {
|
|
494
|
+
return opts._blocklistData;
|
|
495
|
+
}
|
|
496
|
+
// Env-var override mirrors the state-file env override pattern; lets
|
|
497
|
+
// existing tests redirect to a temp file without touching loadLicense
|
|
498
|
+
// call sites.
|
|
499
|
+
const path = opts._blocklistPath
|
|
500
|
+
?? process.env.NSAUDITOR_LICENSE_REVOCATIONS_FILE
|
|
501
|
+
?? REVOCATION_BLOCKLIST_PATH;
|
|
502
|
+
let raw;
|
|
503
|
+
try {
|
|
504
|
+
raw = await fsp.readFile(path, 'utf8');
|
|
505
|
+
} catch { return []; }
|
|
506
|
+
|
|
507
|
+
let envelope;
|
|
508
|
+
try {
|
|
509
|
+
envelope = JSON.parse(raw);
|
|
510
|
+
} catch { return []; }
|
|
511
|
+
|
|
512
|
+
if (!envelope || typeof envelope !== 'object') return [];
|
|
513
|
+
if (envelope.schema_version !== 1) return [];
|
|
514
|
+
if (!Array.isArray(envelope.revoked)) return [];
|
|
515
|
+
|
|
516
|
+
const publicKeyPem = opts._publicKeyPem ?? PUBLIC_KEY_PEM;
|
|
517
|
+
if (!_verifyBlocklistSignature(envelope, publicKeyPem)) return [];
|
|
518
|
+
|
|
519
|
+
return envelope.revoked.filter((s) => typeof s === 'string' && s.length > 0);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Verify the ES256 detached signature over the blocklist envelope. The
|
|
524
|
+
* signature covers the canonical JSON of the envelope MINUS the
|
|
525
|
+
* `signature` field itself: `JSON.stringify({schema_version, issued_at, revoked})`
|
|
526
|
+
* with stable key order.
|
|
527
|
+
*
|
|
528
|
+
* Signature is base64-encoded over the SHA-256-of-canonical-JSON,
|
|
529
|
+
* verified against the ES256 (P-256) public key.
|
|
530
|
+
*
|
|
531
|
+
* Returns `true` only on cryptographic match; any error returns `false`
|
|
532
|
+
* (fail-open at the blocklist layer is documented in _loadAndVerifyBlocklist).
|
|
533
|
+
*
|
|
534
|
+
* @internal
|
|
535
|
+
*/
|
|
536
|
+
export function _verifyBlocklistSignature(envelope, publicKeyPem) {
|
|
537
|
+
if (!envelope || typeof envelope.signature !== 'string') return false;
|
|
538
|
+
if (envelope.signature.length === 0) return false;
|
|
539
|
+
const canonical = _canonicalizeBlocklistForSigning(envelope);
|
|
540
|
+
if (!canonical) return false;
|
|
541
|
+
try {
|
|
542
|
+
const sigBuf = Buffer.from(envelope.signature, 'base64');
|
|
543
|
+
const pub = createPublicKey({ key: publicKeyPem, format: 'pem' });
|
|
544
|
+
// P1.D R2-HIGH fold: pin to ES256 (EC P-256) algorithm explicitly.
|
|
545
|
+
// Without this assertion, a future PUBLIC_KEY_PEM rotation to an RSA
|
|
546
|
+
// key would silently enable RS256 forgery of the blocklist (the
|
|
547
|
+
// verify code path is generic over algorithm). Mirror of the audit's
|
|
548
|
+
// own canonical-JWT-verifier hardening pattern.
|
|
549
|
+
if (pub.asymmetricKeyType !== 'ec') return false;
|
|
550
|
+
const details = pub.asymmetricKeyDetails;
|
|
551
|
+
if (!details || details.namedCurve !== 'prime256v1') return false;
|
|
552
|
+
const verifier = createVerify('SHA256');
|
|
553
|
+
verifier.update(canonical, 'utf8');
|
|
554
|
+
verifier.end();
|
|
555
|
+
return verifier.verify({ key: pub, dsaEncoding: 'der' }, sigBuf);
|
|
556
|
+
} catch {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Canonical signing input: stable key order, no signature field.
|
|
563
|
+
* @internal
|
|
564
|
+
*/
|
|
565
|
+
export function _canonicalizeBlocklistForSigning(envelope) {
|
|
566
|
+
if (!envelope || typeof envelope !== 'object') return null;
|
|
567
|
+
const canonical = {
|
|
568
|
+
schema_version: envelope.schema_version,
|
|
569
|
+
issued_at: envelope.issued_at,
|
|
570
|
+
revoked: Array.isArray(envelope.revoked) ? [...envelope.revoked] : [],
|
|
571
|
+
};
|
|
572
|
+
// Deterministic stringification: sort revoked alphabetically to remove
|
|
573
|
+
// ordering ambiguity from the signing input.
|
|
574
|
+
canonical.revoked.sort();
|
|
575
|
+
return JSON.stringify(canonical);
|
|
576
|
+
}
|
|
577
|
+
|
|
272
578
|
/**
|
|
273
579
|
* Full async JWT verification. Call once at startup.
|
|
274
580
|
* On success, caches verified tier so subsequent getTierFromEnv() calls
|
|
@@ -279,10 +585,12 @@ export function mergeLicenseIntoEnvFile(existingContent, key) {
|
|
|
279
585
|
* @param {string} [keyStr] - License key; if omitted, runs the multi-source
|
|
280
586
|
* resolution chain (env var → Keychain → ~/.nsauditor/.env). See
|
|
281
587
|
* resolveLicenseKey() above.
|
|
588
|
+
* @param {object} [opts] - Test seams (see internal helpers). All options
|
|
589
|
+
* prefixed with `_` are documented as test-only.
|
|
282
590
|
* @returns {Promise<{valid: boolean, tier: string, org?: string, seats?: number,
|
|
283
591
|
* licenseId?: string, capabilities?: string[], expiresAt?: string, reason?: string}>}
|
|
284
592
|
*/
|
|
285
|
-
export async function loadLicense(keyStr) {
|
|
593
|
+
export async function loadLicense(keyStr, opts = {}) {
|
|
286
594
|
// Explicit keyStr argument wins (preserves the existing behavior for
|
|
287
595
|
// callers like the `license --status` subcommand which passes the env
|
|
288
596
|
// var directly). When omitted, run the multi-source resolution chain.
|
|
@@ -326,6 +634,61 @@ export async function loadLicense(keyStr) {
|
|
|
326
634
|
return { valid: false, tier: 'ce', reason: 'tier mismatch' };
|
|
327
635
|
}
|
|
328
636
|
|
|
637
|
+
// ── CE 0.1.70 air-gap hardening: 3 fail-closed checks ────────────────
|
|
638
|
+
// Order matters. (1) Revocation runs first — a revoked license should
|
|
639
|
+
// be rejected even before the per-host bind. (2) Replay defense second
|
|
640
|
+
// — a non-revoked but mismatched-host install is the next-most-likely
|
|
641
|
+
// abuse path. (3) Clock anchor third — only relevant if the prior two
|
|
642
|
+
// pass; a clock-rollback attack against a legitimately installed
|
|
643
|
+
// license is the least-common case. Each check has an env-var escape
|
|
644
|
+
// hatch documented as support-only.
|
|
645
|
+
|
|
646
|
+
const now = (opts._now ?? Date.now)();
|
|
647
|
+
|
|
648
|
+
// (1) D-HIGH-2: signed revocation blocklist.
|
|
649
|
+
if (!_envDisabled(ENV_REVOCATION_CHECK)) {
|
|
650
|
+
try {
|
|
651
|
+
const revoked = await _loadAndVerifyBlocklist(opts);
|
|
652
|
+
if (payload.licenseId && revoked.includes(payload.licenseId)) {
|
|
653
|
+
_verifiedTier = 'ce';
|
|
654
|
+
return { valid: false, tier: 'ce', reason: 'license_revoked' };
|
|
655
|
+
}
|
|
656
|
+
} catch { /* fail-open per _loadAndVerifyBlocklist contract */ }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Read prior persisted state once for checks (2) + (3) + first-activation write.
|
|
660
|
+
let persistedState = null;
|
|
661
|
+
try { persistedState = await _readLicenseState(opts); } catch { /* missing fine */ }
|
|
662
|
+
|
|
663
|
+
// (2) D-HIGH-1: per-host licenseId replay defense.
|
|
664
|
+
if (!_envDisabled(ENV_REPLAY_DEFENSE)) {
|
|
665
|
+
if (persistedState && persistedState.licenseId
|
|
666
|
+
&& payload.licenseId
|
|
667
|
+
&& persistedState.licenseId !== payload.licenseId) {
|
|
668
|
+
_verifiedTier = 'ce';
|
|
669
|
+
return { valid: false, tier: 'ce', reason: 'license_id_mismatch' };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// (3) D-HIGH-3: monotonic-clock anchor.
|
|
674
|
+
if (!_envDisabled(ENV_CLOCK_ANCHOR)) {
|
|
675
|
+
if (persistedState && Number.isFinite(persistedState.lastSeenUnixTs)) {
|
|
676
|
+
const tolerance = _resolveClockToleranceS() * 1000;
|
|
677
|
+
if (now < persistedState.lastSeenUnixTs - tolerance) {
|
|
678
|
+
_verifiedTier = 'ce';
|
|
679
|
+
return { valid: false, tier: 'ce', reason: 'clock_rollback_detected' };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// All checks passed — update state (first-activation persist + heartbeat).
|
|
685
|
+
try {
|
|
686
|
+
await _writeLicenseState({
|
|
687
|
+
licenseId: payload.licenseId ?? persistedState?.licenseId ?? null,
|
|
688
|
+
lastSeenUnixTs: now,
|
|
689
|
+
}, opts);
|
|
690
|
+
} catch { /* state write failure must not block a verified license */ }
|
|
691
|
+
|
|
329
692
|
// Cache verified tier for synchronous access
|
|
330
693
|
_verifiedTier = payload.tier;
|
|
331
694
|
|
|
@@ -366,6 +729,11 @@ export async function loadLicense(keyStr) {
|
|
|
366
729
|
/**
|
|
367
730
|
* @internal Test-only. Reset cached verified tier between tests.
|
|
368
731
|
* Disabled in production to prevent accidental tier cache clearing.
|
|
732
|
+
*
|
|
733
|
+
* Clears in-memory state only (`_verifiedTier` + permissive-warned-paths).
|
|
734
|
+
* Does NOT touch the persisted license-state file — tests that need that
|
|
735
|
+
* cleared should either rotate the state-file path via beforeEach OR call
|
|
736
|
+
* `_clearLicenseStateFileForTests()` explicitly.
|
|
369
737
|
*/
|
|
370
738
|
export function _resetCache() {
|
|
371
739
|
if (process.env.NODE_ENV === 'production') {
|
|
@@ -374,3 +742,19 @@ export function _resetCache() {
|
|
|
374
742
|
_verifiedTier = null;
|
|
375
743
|
_permissiveWarnedPaths.clear();
|
|
376
744
|
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* @internal Test-only. Remove the persisted license-state file at the path
|
|
748
|
+
* resolved by the env var or platform default. Refuses to touch the real
|
|
749
|
+
* platform-default path (only honors NSAUDITOR_LICENSE_STATE_FILE override)
|
|
750
|
+
* so a production process that accidentally imports this helper cannot
|
|
751
|
+
* wipe operator state.
|
|
752
|
+
*/
|
|
753
|
+
export function _clearLicenseStateFileForTests() {
|
|
754
|
+
if (process.env.NODE_ENV === 'production') {
|
|
755
|
+
throw new Error('_clearLicenseStateFileForTests is test-only');
|
|
756
|
+
}
|
|
757
|
+
const override = process.env.NSAUDITOR_LICENSE_STATE_FILE;
|
|
758
|
+
if (!override) return; // refuse to touch platform default
|
|
759
|
+
try { fsSync.unlinkSync(override); } catch { /* not fatal — file may not exist */ }
|
|
760
|
+
}
|