nsauditor-ai 0.2.31 → 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 +9 -7
- package/package.json +1 -1
- package/plugins/port_scanner.mjs +72 -0
- package/plugins/tls_scanner.mjs +107 -5
- package/utils/capabilities.mjs +4 -6
- package/utils/conclusion_utils.mjs +44 -2
- package/utils/license.mjs +7 -1
- package/utils/output_dir.mjs +17 -3
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
|
+
**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
|
+
|
|
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.
|
|
23
|
+
|
|
20
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.
|
|
21
25
|
|
|
22
26
|
**Marketplace registration in CLI help + report-quality / routing-integrity release (CE 0.2.30 / Enterprise 0.32.5).** CE now surfaces **AWS Marketplace license registration** in `--help` and in `nsauditor-ai license --status`, so a Marketplace buyer can find the registration step without leaving the CLI. Paired with Enterprise 0.32.5, a **matrix-neutral** report-quality release: nine API Gateway (plugin 1050) mapping rules matched zero findings — **eight were re-sourced, and the ninth (a HIPAA §164.312(c)(1) Integrity mapping) was removed on doctrine rather than repaired**, because a WAF integration that fails closed alters no ePHI. **Routing was never broken and no finding was ever missed**: correctly-sourced catch-all rules already failed the same controls, verified by ablation. The real defect was report **prose** — first-match-wins surfaced a **stale** auditor-facing rationale while the accurate per-class rationale sat unreachable. Also: six auditor-facing rationales in that API-Gateway cohort were rewritten (a **cohort fix, not a class fix** — other rationales across the framework files still carry internal-development-history wording), the JSON report artifact no longer serializes raw routing regexes or rule sources, **a genuine CI/CD false-clean was closed** (a `codebuild:ListProjects` AccessDenied was warning-only, and warnings route to zero controls, so CI/CD controls could read clean over an un-enumerated inventory — now fail-closed into the existing evidence gap), and three compliance guards were hardened (one had been suppressed by a factually false comment; another had silently become a tautology; a third adds an expected-unmapped equality table). Plugin count UNCHANGED at 28; all seven coverage matrices UNCHANGED.
|
|
@@ -31,14 +35,14 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
31
35
|
## What It Does
|
|
32
36
|
|
|
33
37
|
```
|
|
34
|
-
Scan →
|
|
38
|
+
Scan → Analyze → Prioritize → Track → Act
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
- **27 scanner plugins** probe networks across ICMP, TCP, UDP, HTTP, TLS, SNMP, DNS, SMB, RPC, mDNS, UPnP, WS-Discovery, MCP (Model Context Protocol), and more
|
|
38
42
|
- **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
|
|
39
43
|
- **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
|
|
40
44
|
- **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
|
|
41
|
-
- **
|
|
45
|
+
- **Risk-scored prioritization (Pro)** — findings carry a composite risk score (severity × exploitability × impact × exposure) and a status field, and an operator suppression workflow (accepted-risk / false-positive with expiry) keeps triaged findings out of the report until they expire
|
|
42
46
|
- **Continuous monitoring (CTEM)** — watch mode rescans on a schedule, diffs against previous results, and fires webhook alerts on changes
|
|
43
47
|
- **MCP integration** — expose scanning tools to AI assistants like Claude Code via Model Context Protocol
|
|
44
48
|
- **CI/CD ready** — SARIF output with `--fail-on` severity gating for pipeline integration
|
|
@@ -91,7 +95,6 @@ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS
|
|
|
91
95
|
| CTEM watch mode | ✅ basic | ✅ advanced | ✅ advanced |
|
|
92
96
|
| **Pro features (vulnerability assessment)** | | | |
|
|
93
97
|
| CVE matching + MITRE ATT&CK mapping | — | ✅ | ✅ |
|
|
94
|
-
| Verified vulnerabilities (safe non-destructive probes) | — | ✅ | ✅ |
|
|
95
98
|
| Risk scoring + prioritization | — | ✅ | ✅ |
|
|
96
99
|
| Parallel analysis agents | — | ✅ | ✅ |
|
|
97
100
|
| **Enterprise — cloud scanning** | | | |
|
|
@@ -116,7 +119,6 @@ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS
|
|
|
116
119
|
| **Enterprise — integration + deployment** | | | |
|
|
117
120
|
| GRC connectors — Vanta + Drata + Secureframe push (scan-time, opt-in) | — | — | ✅ |
|
|
118
121
|
| Tabletop simulation + SIEM correlation | — | — | ✅ |
|
|
119
|
-
| Docker per-scan isolation | — | — | ✅ |
|
|
120
122
|
| Air-gapped deployment | — | — | ✅ |
|
|
121
123
|
|
|
122
124
|
**This repository is the Community Edition** — fully functional, MIT-licensed, no restrictions, no telemetry. Pro and Enterprise features ship via the [`@nsasoft/nsauditor-ai-ee`](https://www.nsauditor.com/ai/pricing) package and install alongside the CE binary once licensed.
|
|
@@ -376,7 +378,7 @@ NSAuditor AI supports three AI providers for vulnerability analysis. **All provi
|
|
|
376
378
|
**What changes by tier is the prompt content, not the provider:**
|
|
377
379
|
|
|
378
380
|
- **CE** — basic scan-summary prompts (services, ports, versions detected). Local MITRE ATT&CK mapping via `utils/attack_map.mjs`: service-context-aware CVE→technique mapping (`mapCveToAttack`, `mapServiceToAttack`), plus a CWE→technique fallback (`cweToMitre`, `cwesToMitre`) covering ~30 common CWEs (auth, crypto, injection, memory safety, info disclosure, privilege escalation, web). The CWE fallback fires only when CVE-derived mapping returns no techniques — useful for findings annotated with `evidence.cwe[]` (per FindingSchema v0.1.13+) but no CVE context, such as agent-detected misconfigurations and compliance-flagged weaknesses
|
|
379
|
-
- **Pro** — intelligence-enriched prompts (CVE matches, MITRE
|
|
381
|
+
- **Pro** — intelligence-enriched prompts (CVE matches, MITRE ATT&CK technique annotations, composite risk scores injected into the prompt). Same API call, better-grounded output
|
|
380
382
|
- **Enterprise** — Pro prompts + compliance context
|
|
381
383
|
|
|
382
384
|
**Redaction:** Before any data reaches an AI API, the redaction pipeline masks IP addresses, MAC addresses, serial numbers, and configurable confidential keywords. Admin RAW reports retain full detail for internal review.
|
|
@@ -996,7 +998,7 @@ For the full technical architecture, see [ARCHITECTURE.md](docs/architecture.md)
|
|
|
996
998
|
|
|
997
999
|
**Tech stack:** Node.js 20+ · ES Modules (.mjs) · OpenAI + Anthropic SDKs · Node.js built-in test runner · MCP stdio transport
|
|
998
1000
|
|
|
999
|
-
**Design patterns:** Factory (PluginManager.create) · Strategy (orchestrated/legacy execution) · Context (shared state) · Adapter (plugin conclude()) · Guard Clause (requirement gating) · Capability gating (CE/Pro/EE) · Semaphore (concurrency control) · Delta (scan history diff) · Boundary Guard (SSRF/injection protection) · Finding Queue (structured intermediate format) · Parallel Agents (concurrent specialized analysis)
|
|
1001
|
+
**Design patterns:** Factory (PluginManager.create) · Strategy (orchestrated/legacy execution) · Context (shared state) · Adapter (plugin conclude()) · Guard Clause (requirement gating) · Capability gating (CE/Pro/EE) · Semaphore (concurrency control) · Delta (scan history diff) · Boundary Guard (SSRF/injection protection) · Finding Queue (structured intermediate format) · Parallel Agents (concurrent specialized analysis)
|
|
1000
1002
|
|
|
1001
1003
|
---
|
|
1002
1004
|
|
|
@@ -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
|
-
- **
|
|
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
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;
|
package/utils/capabilities.mjs
CHANGED
|
@@ -14,23 +14,21 @@ export const CAPABILITIES = {
|
|
|
14
14
|
riskScoring: { tier: 'pro' },
|
|
15
15
|
proAI: { tier: 'pro' },
|
|
16
16
|
analysisAgents: { tier: 'pro' },
|
|
17
|
-
verificationEngine: { tier: 'pro' },
|
|
18
17
|
advancedCTEM: { tier: 'pro' },
|
|
19
18
|
enhancedRedaction: { tier: 'pro' },
|
|
20
19
|
proMCP: { tier: 'pro' },
|
|
21
20
|
pdfExport: { tier: 'pro' },
|
|
22
|
-
brandedReports: { tier: 'pro' },
|
|
23
21
|
|
|
24
22
|
// Enterprise
|
|
25
23
|
cloudScanners: { tier: 'enterprise' },
|
|
26
24
|
zeroTrust: { tier: 'enterprise' },
|
|
27
25
|
complianceEngine: { tier: 'enterprise' },
|
|
28
|
-
zdePolicyEngine: { tier: 'enterprise' },
|
|
29
|
-
enterpriseCTEM: { tier: 'enterprise' },
|
|
30
26
|
enterpriseMCP: { tier: 'enterprise' },
|
|
31
|
-
usageMetering: { tier: 'enterprise' },
|
|
32
27
|
airGapped: { tier: 'enterprise' },
|
|
33
|
-
|
|
28
|
+
// Removed 2026-07-21 (capability claim audit): verificationEngine / brandedReports /
|
|
29
|
+
// usageMetering / dockerIsolation (no implementation) + zdePolicyEngine / enterpriseCTEM
|
|
30
|
+
// (real cores ship + are claimed in prose; no distinct engine/datastore behind the flag).
|
|
31
|
+
// Keep in lockstep with EE index.mjs EE_CAPABILITIES + license-manager keygen.mjs.
|
|
34
32
|
};
|
|
35
33
|
|
|
36
34
|
const TIER_CAPS = {
|
|
@@ -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.)
|