nsauditor-ai 0.2.32 → 0.2.34
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 +5 -1
- package/package.json +1 -1
- package/plugins/port_scanner.mjs +72 -0
- package/plugins/tls_scanner.mjs +107 -5
- package/utils/cloud_finding_summary.mjs +13 -5
- package/utils/conclusion_utils.mjs +44 -2
- package/utils/license.mjs +7 -1
- package/utils/output_dir.mjs +17 -3
- package/utils/validate.mjs +24 -2
package/README.md
CHANGED
|
@@ -17,6 +17,10 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
17
17
|
|
|
18
18
|
## What's New
|
|
19
19
|
|
|
20
|
+
**Internal-provenance strip + a false-clean closure (CE 0.2.34 / Enterprise 0.32.9).** Paired with Enterprise 0.32.9. The evidence pack a customer hands their auditor no longer carries internal engineering markers — **686 unexplained occurrences → 0** on a rebuilt three-cloud pack, with a positive control so the zero is a measurement rather than an absence of looking. Two behavioural fixes ride with it: a cloud plugin that returns `up:false` (optional SDK absent, credentials unusable) now fail-closes an evidence gap on every in-scope control of that cloud instead of leaving them PASS, and an archived scan conclusion re-mapped through the new build warns instead of failing clean. CE's side: the MCP `scan_cloud` summary strips **both** spellings of the evidence-gap routing prefix, so an operator pairing this CE with an older Enterprise still gets a clean one-line summary. **⚠️ If you suppress by `titlePattern` on the old violation text, re-point it — see the [Enterprise CHANGELOG](https://github.com/nsasoft/nsauditor-ai-ee/blob/main/CHANGELOG.md).**
|
|
21
|
+
|
|
22
|
+
**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.
|
|
23
|
+
|
|
20
24
|
**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
25
|
|
|
22
26
|
**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 +1012,7 @@ NSAuditor AI is built on a **Zero Data Exfiltration (ZDE)** architecture:
|
|
|
1008
1012
|
- **No data processing.** Nsasoft US LLC never sees, stores, or processes your scan results.
|
|
1009
1013
|
- **AI is opt-in.** External AI calls use your own API keys. Redaction runs locally first.
|
|
1010
1014
|
- **License validation is offline.** JWT signature verified locally with an embedded public key.
|
|
1011
|
-
- **
|
|
1015
|
+
- **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
1016
|
|
|
1013
1017
|
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
1018
|
|
package/package.json
CHANGED
package/plugins/port_scanner.mjs
CHANGED
|
@@ -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
|
+
}
|
package/plugins/tls_scanner.mjs
CHANGED
|
@@ -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;
|
|
@@ -26,11 +26,19 @@ export const GAP_CLAUSE_RE =
|
|
|
26
26
|
// Substrate-evidence PASS prose (1220/1222 push these into the same issues[] as
|
|
27
27
|
// violations) — informational; never a lead clause while any alternative exists.
|
|
28
28
|
const PASS_CLAUSE_RE = /substrate evidence:/i;
|
|
29
|
-
// EE compliance-routing tag
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
29
|
+
// EE compliance-routing tag leading many gap emissions — anchor plumbing for the EE
|
|
30
|
+
// framework JSONs, not operator prose. Stripped at THIS presentation layer only (routing
|
|
31
|
+
// matches the RAW issue strings EE-side) so the 160-char slice spends its budget on
|
|
32
|
+
// substance; the [⚠ EVIDENCE GAP] badge already says it is a gap.
|
|
33
|
+
//
|
|
34
|
+
// TWO SPELLINGS, and the old one must stay. EE renamed the prefix in its A1 cycle ③ —
|
|
35
|
+
// `EE-RT.1.2 multi-region-enumeration-incomplete: ` became
|
|
36
|
+
// `Evidence gap (multi-region enumeration incomplete): ` — because it renders as the
|
|
37
|
+
// violation TITLE in the customer's evidence pack and an internal roadmap id has no
|
|
38
|
+
// business there. CE ships independently of EE and an operator can pair ANY supported EE
|
|
39
|
+
// with this CE, so dropping the old alternative would leave the old prefix unstripped on
|
|
40
|
+
// every paired-with-older-EE run. Recognise both.
|
|
41
|
+
const ROUTING_PREFIX_RE = /^(?:EE-RT\.[\dx.]+ [a-z][a-z-]*|Evidence gap \((?:scan time budget exceeded|multi-region enumeration incomplete)\)): /;
|
|
34
42
|
|
|
35
43
|
function classifyClause(s) {
|
|
36
44
|
if (PASS_CLAUSE_RE.test(s)) return 'pass';
|
|
@@ -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] = {
|
|
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
|
-
|
|
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
|
package/utils/output_dir.mjs
CHANGED
|
@@ -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
|
|
41
|
-
//
|
|
42
|
-
|
|
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.)
|
package/utils/validate.mjs
CHANGED
|
@@ -57,11 +57,33 @@ export async function checkPlugins({ discover, pkgRoot = PKG_ROOT } = {}) {
|
|
|
57
57
|
const fn = discover || (await import('./plugin_discovery.mjs')).discoverPlugins;
|
|
58
58
|
const result = await fn(pkgRoot);
|
|
59
59
|
const plugins = Array.isArray(result) ? result : (result?.plugins ?? []);
|
|
60
|
+
// `discoverPlugins` reads THREE sources: CE plugins under `pkgRoot`, the EE package
|
|
61
|
+
// resolved GLOBALLY (it is a separate npm package, so `pkgRoot` cannot and should not
|
|
62
|
+
// reach it), and NSAUDITOR_PLUGIN_PATH. Reporting the aggregate count beside a single
|
|
63
|
+
// `basePath` produced "28 plugins loaded / basePath: <dir>" for a directory containing
|
|
64
|
+
// NONE of them — a false report from the command an operator runs to check their install.
|
|
65
|
+
// Each plugin already carries `_source`; use it, so `basePath` describes what it counts.
|
|
66
|
+
const bySource = {};
|
|
67
|
+
for (const p of plugins) bySource[p?._source ?? 'unknown'] = (bySource[p?._source ?? 'unknown'] ?? 0) + 1;
|
|
68
|
+
const ceCount = bySource.ce ?? 0;
|
|
69
|
+
const others = Object.entries(bySource).filter(([k]) => k !== 'ce');
|
|
70
|
+
// Only claim attribution we actually have. An injected `discover` (tests, embedders) may
|
|
71
|
+
// return plugins without `_source`; asserting "0 at basePath" there would be a different
|
|
72
|
+
// false report from the one this fix removes.
|
|
73
|
+
const attributable = Object.keys(bySource).some((k) => k !== 'unknown');
|
|
74
|
+
const suffix = others.length ? ` (+ ${others.map(([k, n]) => `${n} ${k}`).join(', ')})` : '';
|
|
60
75
|
return {
|
|
61
76
|
name: 'plugins',
|
|
62
77
|
status: STATUSES.OK,
|
|
63
|
-
message:
|
|
64
|
-
|
|
78
|
+
message: attributable
|
|
79
|
+
? `${ceCount} plugin${ceCount === 1 ? '' : 's'} at basePath${suffix}`
|
|
80
|
+
: `${plugins.length} plugin${plugins.length === 1 ? '' : 's'} loaded`,
|
|
81
|
+
details: {
|
|
82
|
+
count: plugins.length,
|
|
83
|
+
basePathCount: attributable ? ceCount : plugins.length,
|
|
84
|
+
bySource,
|
|
85
|
+
basePath: pkgRoot,
|
|
86
|
+
},
|
|
65
87
|
};
|
|
66
88
|
} catch (err) {
|
|
67
89
|
return {
|