nsauditor-ai 0.2.32 → 0.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,8 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
17
17
 
18
18
  ## What's New
19
19
 
20
+ **Capability-claim honesty pass, part 2 — the air-gapped-delivery class (CE 0.2.33 / Enterprise 0.32.8).** Paired with Enterprise 0.32.8, a **documentation-and-prose release** — no detection, routing, or compliance behaviour changes. 0.32.7 withdrew six phantom capability *flags*; an audit of the air-gapped-delivery claims found the same class one layer down, in prose, across **27 sites** (distinct lines) in the three published packages. **CE fix this cycle — `--out` with a version-named directory.** `--out .../ee-0.32.8` silently wrote artifacts to the *parent* directory: the file-vs-directory check keyed on "has an extension", and `path.parse('ee-0.32.8').ext` is `'.8'`. The same shape broke `v1.2.3` and `release-2026.07`. An extension now counts as file-like only if it starts with a letter, so version-named output directories work and `--out report.json` still resolves to its parent. Found by the pre-publish smoke gate running the documented command end-to-end. **CE docs change this cycle:** the ZDE section's *"Fully air-gappable. Every feature works without internet access"* is corrected to **"Air-gappable, once configured for it"** — scanning, analysis, license verification and evidence-pack generation all run with no outbound network access, and Enterprise adds offline CVE matching under `NSAUDITOR_OFFLINE_ONLY=1` (which emits an explicit coverage gap rather than a silent clean when the local store cannot answer), but a **default** scan still attempts NVD egress unless that variable is set, and the only other outbound paths — AI enrichment and the GRC push — are opt-in and off by default. The claim that Enterprise "includes offline NVD feeds" is **WITHDRAWN**: no feed data is delivered with the product, and populating the local store is the operator's. Held by a new mutation-proven guard (`tests/capability_claim_honesty.test.mjs`) that fails the build both if the absolute claim returns **and** if the honest qualifier is deleted. On the Enterprise side the same sweep found **five** rendered runtime strings — prose that reaches the assessor-facing evidence pack — making claims the code does not support. Plugin count UNCHANGED at 28; all seven coverage matrices UNCHANGED. See **[CHANGELOG.md](./CHANGELOG.md)** for the full per-release history.
21
+
20
22
  **Cross-framework routing + capability-claim honesty pass (CE 0.2.32 / Enterprise 0.32.7).** Paired with Enterprise 0.32.7, a **matrix-neutral** release. The network-scan analysis agents' findings now route in **all seven compliance frameworks** — a host serving cleartext or exposing SMB previously failed the SOC 2 report and read clean in HIPAA / NIST CSF / ISO 27001 / CIS v8 / PCI / GDPR off the *same scan*; that gap is closed (48 mapping rules across the seven framework files, a mutation-proven drift guard, all coverage matrices byte-identical). And a **capability-claim honesty pass** withdraws several Pro/Enterprise capabilities that were advertised without a shipping implementation. **CE code change this cycle:** the tier-capability map (`utils/capabilities.mjs`) drops six flags — `verificationEngine`, `brandedReports`, `usageMetering`, `dockerIsolation` (no implementation) and `zdePolicyEngine`, `enterpriseCTEM` (real cores ship + are claimed in prose; no distinct engine behind the name) — in lockstep with Enterprise and both licensing repos, so `nsauditor-ai license --capabilities` and every minted license now name only capabilities that ship. `resolveCapabilities(tier)` derives from tier and ignores the JWT capability array, so **issued licenses are unaffected**. Plugin count UNCHANGED at 28; all seven coverage matrices UNCHANGED. See **[CHANGELOG.md](./CHANGELOG.md)** for the full per-release history.
21
23
 
22
24
  **Network-scan false-negative closures on the analysis agents (CE 0.2.31 / Enterprise 0.32.6).** No CE code change this cycle — a paired bump for Enterprise 0.32.6, a **matrix-neutral** false-negative-hardening release on the EE analysis-agent (network-scan) path: **cleartext transport** is now flagged where before only weak-TLS-where-TLS-exists was — a service that should be encrypted but offers no TLS at all used to read clean (→ **SOC 2 CC6.7**); **SMB-alone** exposure is now its own **HIGH** finding rather than being silenced by an `SMB && RDP` conjunction — SMB without RDP is the higher-risk case (→ **CC6.6**); and **WinRM 5985/5986 · Elasticsearch 9300 · MSRPC 135 · a new aggregate open-port-count rule** are new exposure signals (→ **CC6.6**). Routed SOC 2-first with drift-detector coverage; **SOC 2 routing only this cycle — cross-framework mappings (HIPAA §164.312(e)(1) · CIS · NIST) deferred.** Plugin count UNCHANGED at 28; all seven coverage matrices UNCHANGED. See **[CHANGELOG.md](./CHANGELOG.md)** for the full per-release history.
@@ -1008,7 +1010,7 @@ NSAuditor AI is built on a **Zero Data Exfiltration (ZDE)** architecture:
1008
1010
  - **No data processing.** Nsasoft US LLC never sees, stores, or processes your scan results.
1009
1011
  - **AI is opt-in.** External AI calls use your own API keys. Redaction runs locally first.
1010
1012
  - **License validation is offline.** JWT signature verified locally with an embedded public key.
1011
- - **Fully air-gappable.** Every feature works without internet access (Enterprise includes offline NVD feeds).
1013
+ - **Air-gappable, once configured for it.** Scanning, analysis, license verification and evidence-pack generation all run with no outbound network access; Enterprise adds offline CVE matching from a local NVD store under `NSAUDITOR_OFFLINE_ONLY=1`, which emits an explicit coverage gap rather than a silent clean when the store cannot answer. Stated precisely because it matters operationally: a **default** scan still attempts NVD egress unless that variable is set, and the only other outbound paths — AI enrichment and the GRC push — are opt-in and off by default. Populating the local NVD store is the operator's; no feed data is delivered with the product.
1012
1014
 
1013
1015
  Nsasoft US LLC is not a data processor, data controller, or business associate under any data protection regulation. You own and control all data produced by NSAuditor AI.
1014
1016
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -300,3 +300,75 @@ export default {
300
300
  };
301
301
  },
302
302
  };
303
+
304
+ /* --------------------------- conclude adapter --------------------------- */
305
+
306
+ /**
307
+ * services[] enumerates services FOUND, so only an OPEN port becomes a record.
308
+ * A refusal (RST) or a timeout is the absence of a service, and neither is lost:
309
+ * both stay in the conclusion's evidence[] stream and in this scan's own
310
+ * tcpClosed / tcpFiltered / udpNoResponse buckets.
311
+ *
312
+ * Measured, on a default sweep of localhost (50 ports from config/services.json):
313
+ * 41 refusals, 1 listener. Materializing refusals would put 42 rows into
314
+ * services[] to carry one bit of signal — and every consumer renders services
315
+ * unfiltered (report_md's table, export_csv's row-per-service, SARIF results,
316
+ * attack_map, and the scan_history baseline that deltas compare against).
317
+ */
318
+ const MATERIALIZED_STATUSES = new Set(["open"]);
319
+
320
+ /**
321
+ * Without this adapter, result_concluder falls through to fallbackRecord(),
322
+ * which keeps only `rows.find(Boolean)` — ONE record for the whole sweep. Since
323
+ * this plugin emits one row PER scanned port, that collapsed N open ports into
324
+ * the first-SCANNED port and dropped the rest before any consumer (CE reports,
325
+ * EE analysis agents) ever saw them. It also derived that survivor's status from
326
+ * the host-level `result.up`, so a filtered port was reported `open` whenever any
327
+ * other port on the host was open.
328
+ *
329
+ * `service` is deliberately the not-fingerprinted label, NOT a well-known-port
330
+ * name: a TCP connect proves a port answers, not which protocol speaks on it.
331
+ * Consumers that infer a protocol from the port number must do so themselves, at
332
+ * their own (lower) confidence tier — the EE crypto_agent, for one, rates a
333
+ * label-derived cleartext finding HIGH with certainty prose and a port-inferred
334
+ * one MEDIUM with an explicit "UNCONFIRMED" caveat. Emitting `'ftp'` for port 21
335
+ * here would silently promote every unfingerprinted listener into the confident
336
+ * tier. Identification is the job of the protocol probes, which are authoritative
337
+ * for their ports and overwrite these records on merge.
338
+ *
339
+ * @param {{host?: string, result?: object}} args
340
+ * @returns {Promise<object[]>} one ServiceRecord per affirmatively-observed port
341
+ */
342
+ export async function conclude({ result } = {}) {
343
+ const rows = Array.isArray(result?.data) ? result.data : [];
344
+ const items = [];
345
+
346
+ for (const row of rows) {
347
+ const port = Number(row?.probe_port);
348
+ if (!Number.isInteger(port) || port <= 0) continue;
349
+
350
+ const status = String(row?.status || "");
351
+ if (!MATERIALIZED_STATUSES.has(status)) continue;
352
+
353
+ items.push({
354
+ port,
355
+ protocol: String(row?.probe_protocol || "tcp").toLowerCase(),
356
+ service: "unknown",
357
+ program: result?.program || "Unknown",
358
+ version: result?.version || "Unknown",
359
+ // From the ROW, never from the host-level result.up.
360
+ status,
361
+ info: row?.probe_info || null,
362
+ banner: row?.response_banner || null,
363
+ source: "port_scanner",
364
+ // The record's OWN probe row. The full sweep is already pushed to the
365
+ // conclusion's top-level evidence[]; repeating it on every record would be
366
+ // O(n^2) duplication of the same rows.
367
+ evidence: [row],
368
+ // A connect scan never outranks a protocol probe for the same port.
369
+ authoritative: false,
370
+ });
371
+ }
372
+
373
+ return items;
374
+ }
@@ -46,6 +46,45 @@ const DEFAULT_PORTS = {
46
46
  995: 'pop3s'
47
47
  };
48
48
 
49
+ // ── crypto_agent producer contract ──────────────────────────────────────────
50
+ // The EE crypto_agent (agents/crypto_agent.mjs) grades TLS quality off structured
51
+ // fields on each service record. Before this producer leg no CE plugin set them,
52
+ // so a real scan read clean over deprecated protocols / weak ciphers / expired /
53
+ // self-signed certs. We emit only public handshake facts (versions, cipher names,
54
+ // cert validity dates) — ZDE-safe, and only where a handshake was observed.
55
+ const WEAK_TLS_PROTOCOLS = new Set(['TLSv1', 'TLSv1.1', 'SSLv3', 'SSLv2']);
56
+
57
+ // Weak cipher name fragments — mirrors plugins/040_tls_cert_auditor.mjs.
58
+ const WEAK_CIPHER_FRAGMENTS = ['RC4', '3DES', 'DES', 'NULL', 'EXPORT', 'ADH', 'AECDH', 'ANON', 'SEED', 'IDEA', 'CAMELLIA128'];
59
+
60
+ // Cipher policy for the PROBE socket only (rationale at its use site in check()).
61
+ // `eNULL` (no-encryption suites) is NOT part of OpenSSL's `ALL` by convention, so it must
62
+ // be named explicitly or a server accepting NULL-SHA is undetectable. `aNULL` (anonymous,
63
+ // ADH/AECDH) IS inside `ALL` at SECLEVEL=0 — we deliberately allow it so the suite can be
64
+ // GRADED, then re-probe without it to recover certificate evidence, because an anonymous
65
+ // handshake presents no certificate.
66
+ const PROBE_CIPHERS = 'ALL:eNULL:@SECLEVEL=0';
67
+ const PROBE_CIPHERS_AUTHENTICATED = 'ALL:eNULL:!aNULL:@SECLEVEL=0';
68
+
69
+ function isWeakCipherName(name) {
70
+ const u = String(name || '').toUpperCase();
71
+ if (!u || u === 'UNKNOWN') return false;
72
+ return WEAK_CIPHER_FRAGMENTS.some((f) => u.includes(f));
73
+ }
74
+
75
+ // Self-signed iff the issuer DN equals the subject DN. A real self-signed leaf also
76
+ // self-loops its issuerCertificate under getPeerCertificate(true); the DN-equality
77
+ // check is the robust, stub-friendly signal (040 remains the deep cert auditor).
78
+ // Keyed on CN|O|OU (Node omits absent DN fields) so an O-only / OU-only self-signed
79
+ // cert — appliances, IoT, internal CAs, `openssl req -subj "/O=..."` — is caught,
80
+ // not only CN-bearing DNs. Requires a non-empty DN so two empty DNs don't match.
81
+ function isSelfSignedCert(cert) {
82
+ if (!cert || !cert.subject || !cert.issuer) return false;
83
+ const dnKey = (x) => `${x.CN || ''}|${x.O || ''}|${x.OU || ''}`;
84
+ const subjectDn = dnKey(cert.subject);
85
+ return subjectDn !== '||' && subjectDn === dnKey(cert.issuer);
86
+ }
87
+
49
88
  function parseCsvEnv(name, fallback) {
50
89
  const v = process.env[name];
51
90
  if (!v) return fallback;
@@ -103,7 +142,7 @@ export default {
103
142
  hostname: hostname || null
104
143
  };
105
144
 
106
- const check = (version) => new Promise((resolve) => {
145
+ const check = (version, cipherPolicy = PROBE_CIPHERS) => new Promise((resolve) => {
107
146
  let settled = false;
108
147
  const options = {
109
148
  host,
@@ -111,15 +150,27 @@ export default {
111
150
  servername: hostname,
112
151
  rejectUnauthorized: false,
113
152
  minVersion: version,
114
- maxVersion: version
153
+ maxVersion: version,
154
+ // A scanner must be able to PROPOSE legacy suites, or it cannot discover them.
155
+ // Node 20+/OpenSSL 3 refuse to offer TLSv1/TLSv1.1 at the default security level
156
+ // and fail client-side before any packet: ERR_SSL_NO_PROTOCOLS_AVAILABLE
157
+ // (error:0A0000BF SSL routines:tls_setup_handshake). Without this, weakProtocols
158
+ // could NEVER populate against a real legacy server — the branch was dead in
159
+ // production while a stubbed unit test stayed green. @SECLEVEL=0 lowers only this
160
+ // probe's client policy (standard scanner practice — sslscan / testssl.sh do the
161
+ // same); it sends no data and does not weaken anything the operator runs.
162
+ // Guarded by tests/tls_scanner_real_tls_integration.test.mjs against a real server.
163
+ ciphers: cipherPolicy
115
164
  };
116
165
  const socket = tlsApi.connect(options, () => {
117
166
  if (settled) return;
118
167
  settled = true;
119
168
  const protocol = socket.getProtocol?.();
120
169
  const cipher = socket.getCipher?.();
170
+ let cert = null;
171
+ try { cert = socket.getPeerCertificate?.(true) || null; } catch { cert = null; }
121
172
  try { socket.end?.(); } catch {}
122
- resolve({ success: true, protocol, cipher: cipher ? cipher.name : 'Unknown' });
173
+ resolve({ success: true, protocol, cipher: cipher ? cipher.name : 'Unknown', cert });
123
174
  });
124
175
  socket.setTimeout?.(timeoutMs);
125
176
  socket.on?.('timeout', () => {
@@ -135,12 +186,15 @@ export default {
135
186
  });
136
187
  });
137
188
 
189
+ let leafCert = null;
138
190
  for (const v of versions) {
139
191
  const res = await check(v);
140
192
  if (res.success) {
141
193
  const proto = res.protocol || v;
142
194
  result.supportedVersions.push(proto);
143
195
  result.ciphers[proto] = res.cipher;
196
+ // Keep the first observed leaf cert (all versions serve the same cert).
197
+ if (!leafCert && res.cert && res.cert.subject) leafCert = res.cert;
144
198
  }
145
199
  if (debug) {
146
200
  result.errors.push({ version: v, success: !!res.success, error: res.success ? 'none' : res.error });
@@ -150,6 +204,20 @@ export default {
150
204
  result.isTLSService = result.supportedVersions.length > 0;
151
205
  result.supportsOld = result.supportedVersions.some(v => v === 'TLSv1' || v === 'TLSv1.1');
152
206
 
207
+ // An anonymous (aNULL) suite presents NO certificate, so if the probe negotiated one
208
+ // the cert axes would go silent on exactly the worst-configured hosts — a false-clean
209
+ // on the two axes this producer exists to close. Re-probe ONCE excluding aNULL, purely
210
+ // to recover cert evidence; the weak-cipher grading from the first pass is retained.
211
+ // Costs an extra handshake only on the rare anonymous-capable server.
212
+ if (result.isTLSService && !leafCert) {
213
+ const known = result.supportedVersions[result.supportedVersions.length - 1];
214
+ const again = await check(known, PROBE_CIPHERS_AUTHENTICATED);
215
+ if (again.success && again.cert && again.cert.subject) leafCert = again.cert;
216
+ }
217
+ // Public cert facts only — the full cert stays in-process (ZDE).
218
+ result.certExpiry = leafCert?.valid_to || null;
219
+ result.certSelfSigned = isSelfSignedCert(leafCert);
220
+
153
221
  return result;
154
222
  }
155
223
 
@@ -177,7 +245,15 @@ export default {
177
245
  probe_port: r.port,
178
246
  probe_service: r.service,
179
247
  probe_info,
180
- response_banner: JSON.stringify(bannerObj)
248
+ response_banner: JSON.stringify(bannerObj),
249
+ // Structured evidence for the crypto_agent producer contract (see conclude()).
250
+ tlsEvidence: {
251
+ tls: r.isTLSService,
252
+ supportedVersions: r.supportedVersions,
253
+ ciphers: r.ciphers,
254
+ certExpiry: r.certExpiry || null,
255
+ certSelfSigned: r.certSelfSigned === true
256
+ }
181
257
  };
182
258
  });
183
259
 
@@ -205,6 +281,31 @@ export async function conclude({ host, result }) {
205
281
  const info = r?.probe_info || null;
206
282
  const banner = r?.response_banner || null;
207
283
  const status = /TLS: /.test(String(info||'')) ? 'open' : 'closed';
284
+
285
+ // crypto_agent producer contract — attach the TLS-quality fields ONLY where a
286
+ // handshake was observed (status === 'open'), so the consumer's "encrypted
287
+ // wherever this fires" premise stays code-true.
288
+ const ev = r?.tlsEvidence || {};
289
+ let contract = {};
290
+ if (status === 'open') {
291
+ const supportedVersions = Array.isArray(ev.supportedVersions) ? ev.supportedVersions : [];
292
+ const weakProtocols = supportedVersions.filter((v) => WEAK_TLS_PROTOCOLS.has(v));
293
+ // weakCiphers reports the NEGOTIATED cipher per version (run() connects with
294
+ // node's default, strong client cipher list), so a non-empty value is a true
295
+ // positive but an empty value is NOT proof "no weak cipher is accepted" — the
296
+ // server may accept RC4/3DES that a default client never proposes. A forced
297
+ // per-cipher enumeration (like the per-version handshake) is the follow-up; the
298
+ // consumer only raises a finding on a non-empty value, so it never over-reads [].
299
+ const weakCiphers = [...new Set(Object.values(ev.ciphers || {}).filter(isWeakCipherName))];
300
+ contract = {
301
+ tls: true, // status === 'open' ⇒ a handshake was observed
302
+ weakProtocols,
303
+ weakCiphers,
304
+ certSelfSigned: ev.certSelfSigned === true,
305
+ ...(ev.certExpiry ? { certExpiry: ev.certExpiry } : {})
306
+ };
307
+ }
308
+
208
309
  items.push({
209
310
  port,
210
311
  protocol: 'tcp',
@@ -216,7 +317,8 @@ export async function conclude({ host, result }) {
216
317
  banner,
217
318
  source: 'tls-scanner',
218
319
  evidence: [r],
219
- authoritative: true
320
+ authoritative: true,
321
+ ...contract
220
322
  });
221
323
  }
222
324
  return items;
@@ -48,8 +48,39 @@ export function normalizeService(svc) {
48
48
  };
49
49
  }
50
50
 
51
+ /**
52
+ * Liveness resolution for a merge. Authority governs IDENTITY (what the service
53
+ * IS — service/program/version/banner/contract fields); it must never govern
54
+ * LIVENESS (whether the port is open).
55
+ *
56
+ * A successful TCP connect is a direct observation: something is listening. A
57
+ * protocol probe that then fails to speak that protocol has learned about the
58
+ * SERVICE, not about the port — so its `closed`/`unknown` must not erase the
59
+ * observation. Measured before this guard existed: a real listener on 6379 that
60
+ * does not speak Redis was found `open` by port_scanner, overwritten to
61
+ * `unknown` by db_scanner's failed probe (authoritative:true), and the EE
62
+ * exposure_agent's HIGH "Database port 6379 open" finding went 1 -> 0. The same
63
+ * shape was confirmed on tls_scanner ('closed'), ssh_scanner (whose
64
+ * 'Connection closed before banner' matches statusFrom's /closed/ regex) and
65
+ * opensearch_scanner — a live, reachable port rendering as closed with zero
66
+ * findings, which in a compliance-evidence product is a false clean.
67
+ *
68
+ * Only DOWNGRADES are blocked: an affirmative `open` from either record wins,
69
+ * and otherwise the caller's own default (the `base` record) is preserved, so
70
+ * this changes nothing else about the existing precedence.
71
+ *
72
+ * @param {object} base - the record whose status would have won before
73
+ * @param {object} other - the record it is merging with
74
+ * @returns {string} the resolved status
75
+ */
76
+ function resolveLiveness(base, other) {
77
+ if (base?.status === 'open' || other?.status === 'open') return 'open';
78
+ return base?.status || 'unknown';
79
+ }
80
+
51
81
  // Merge by protocol:port with basic authority precedence.
52
- // If 'authoritative' flag is true on a record, it wins over non-authoritative.
82
+ // If 'authoritative' flag is true on a record, it wins over non-authoritative —
83
+ // for identity. See resolveLiveness above for why status is handled separately.
53
84
  export function upsertService(services, next, { authoritative = false } = {}) {
54
85
  const key = keyOf(next);
55
86
  const i = services.findIndex(s => keyOf(s) === key);
@@ -60,7 +91,16 @@ export function upsertService(services, next, { authoritative = false } = {}) {
60
91
  const cur = services[i];
61
92
  // Authority precedence
62
93
  if (authoritative && !cur.__authoritative) {
63
- services[i] = { ...cur, ...next, __authoritative: true };
94
+ services[i] = {
95
+ ...cur,
96
+ ...next,
97
+ status: resolveLiveness(next, cur),
98
+ // This branch alone used to drop the prior record's evidence, so the very
99
+ // probe that established the port was open left no trace on the merged
100
+ // record. The other two branches always concatenated.
101
+ evidence: (cur.evidence || []).concat(next.evidence || []),
102
+ __authoritative: true,
103
+ };
64
104
  return;
65
105
  }
66
106
  if (!authoritative && cur.__authoritative) {
@@ -71,6 +111,7 @@ export function upsertService(services, next, { authoritative = false } = {}) {
71
111
  version: cur.version || next.version || null,
72
112
  info: cur.info || next.info || null,
73
113
  banner: cur.banner || next.banner || null,
114
+ status: resolveLiveness(cur, next),
74
115
  evidence: (cur.evidence || []).concat(next.evidence || [])
75
116
  };
76
117
  return;
@@ -79,6 +120,7 @@ export function upsertService(services, next, { authoritative = false } = {}) {
79
120
  services[i] = {
80
121
  ...cur,
81
122
  ...next,
123
+ status: resolveLiveness(next, cur),
82
124
  evidence: (cur.evidence || []).concat(next.evidence || []),
83
125
  __authoritative: cur.__authoritative || authoritative
84
126
  };
package/utils/license.mjs CHANGED
@@ -594,7 +594,13 @@ export async function loadLicense(keyStr, opts = {}) {
594
594
  // Explicit keyStr argument wins (preserves the existing behavior for
595
595
  // callers like the `license --status` subcommand which passes the env
596
596
  // var directly). When omitted, run the multi-source resolution chain.
597
- let raw = keyStr ?? (await resolveLicenseKey());
597
+ // `opts` carries the same `_`-prefixed test seams resolveLicenseKey documents
598
+ // (_homeFileOverride, _keychainGet). They were reachable only by calling that
599
+ // function directly, so a test exercising the no-key path THROUGH loadLicense
600
+ // read the operator's real ~/.nsauditor/.env and Keychain — machine-dependent,
601
+ // and it made the "no key" case pass or fail depending on whose box ran it.
602
+ // No production caller passes a second argument, so threading is inert there.
603
+ let raw = keyStr ?? (await resolveLicenseKey(opts));
598
604
  if (!raw) return { valid: false, tier: 'ce', reason: 'no key provided' };
599
605
 
600
606
  // Thread K (CE 0.1.32): support `keychain:LABEL` indirection on the
@@ -37,9 +37,23 @@ export function resolveBaseOutDir() {
37
37
  process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out'
38
38
  );
39
39
  const parsed = path.parse(raw);
40
- // If env var pointed at a file (has an extension), use its parent dir.
41
- // Otherwise treat the whole value as a directory.
42
- return parsed.ext ? (parsed.dir || 'out') : (raw || 'out');
40
+ // If env var pointed at a FILE, use its parent dir; otherwise treat the whole
41
+ // value as a directory.
42
+ //
43
+ // ⚠️ "Has a dot" is NOT the same as "is a file", and reading it that way was a
44
+ // silent evidence-misplacement bug (found by the EE 0.32.8 pre-publish smoke
45
+ // gate). `path.parse('ee-0.32.8').ext` is `'.8'`, so `--out .../ee-0.32.8`
46
+ // resolved to `.../` and every artifact landed in the PARENT folder — exit 0,
47
+ // no warning, evidence scattered into a directory shared with other runs. It
48
+ // hit the naming convention this project uses for its own evidence archives,
49
+ // and the same shape breaks `v1.2.3` and `release-2026.07`.
50
+ //
51
+ // A file extension starts with a LETTER (`.json`, `.html`, `.csv`, `.sarif`).
52
+ // A trailing `.8` / `.07` / `.28` is a version or date component. Keying on the
53
+ // first character distinguishes them without guessing at a list of known
54
+ // extensions — and keeps the documented `--out report.json` affordance working.
55
+ const isFileExtension = /^\.[A-Za-z]/.test(parsed.ext);
56
+ return isFileExtension ? (parsed.dir || 'out') : (raw || 'out');
43
57
  }
44
58
 
45
59
  // (toCleanPath moved to utils/path_helpers.mjs in v0.1.20 — no _internals export needed.)