nsauditor-ai 0.2.38 → 0.2.39
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 -19
- package/package.json +1 -1
- package/plugins/port_scanner.mjs +67 -17
- package/utils/nvd_cache.mjs +48 -3
package/README.md
CHANGED
|
@@ -17,25 +17,11 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
17
17
|
|
|
18
18
|
## What's New
|
|
19
19
|
|
|
20
|
-
**
|
|
21
|
-
|
|
22
|
-
**Three flags, one new command, and a startup veto that stops swallowing itself (CE 0.2.37 / Enterprise 0.2.37 + 0.33.0).** Paired with Enterprise 0.33.0, and **this CE is required by it** — `compliance attest`, `--sla-policy <file>` and `--compliance-history <dir>` live here, so the Enterprise peer floor is raised to 0.2.37. `compliance attest` produces a Type II recurring-scan attestation over a directory of prior scans; the two flags reach the SLA/MTTR engine, which had been wired inside the compliance phase behind options nothing set. A startup posture preflight now refuses a configuration contradiction — `NSAUDITOR_OFFLINE_ONLY=1` together with a configured outbound path — with exit 2, at every tier and every command. And the bare `catch {}` around the Enterprise stage is split: "Enterprise is not installed" stays silent, but "Enterprise ran and threw" now names the lost stage on stderr instead of exiting 0 with no compliance report and no output. Also corrected on this frozen page: the MCP tool tables listed seven tools that are not registered and omitted two that are; WORM evidence storage was sold with a ✅ though its writer has no caller; and `--help` told operators to select plugin 1023 with `--plugins`, which cannot work — it needs a discovery plugin to confirm the host is up first.
|
|
23
|
-
|
|
24
|
-
**The scan summary stops hiding its own scope limits, and a new tool ends matrix guesswork (CE 0.2.36 / Enterprise 0.32.11).** `scan_cloud`'s markdown rendered `CRITICAL · HIGH · MEDIUM · LOW · PASS` — **INFO was not a column** — so on a real AWS scan 55 of 62 INFO records appeared in no summary anywhere, several of them declarations of what was **not** assessed. INFO is now a column and rolls up by category, and scope boundaries get their own `[🔎 SCOPE NOT ASSESSED]` channel: surface nobody assessed must not read like surface assessed and found clean. **New MCP tool `compliance_matrix`** returns the shipped coverage matrix for any of the seven frameworks, derived from the framework maps at call time and failing closed rather than returning an empty one. `pdfExport` is withdrawn (registered and minted, implemented nowhere), and **every capability now ships a written description** — a bare flag name is a claim with no text behind it. `--watch` is described honestly, including the part the obvious correction got wrong: each tick writes that tick's artifacts; what is missing is any relation between them.
|
|
25
|
-
|
|
26
|
-
**The dependency gate starts measuring what you install (CE 0.2.35 / Enterprise 0.32.10).** Paired with Enterprise 0.32.10 — **no CE behaviour change**. On the Enterprise side the advisory gate had been auditing the maintainer's working tree while calling it the production closure; it now packs the tarball, installs it the way you would, and audits **that**, and it will not report a clean result until it has proved an advisory database actually answered. The two trees shared 5 of 26 advisory packages and **zero** high-severity ones. Also: the SOC 2 matrix is enumerated in full (**10 / 4 / 37 = 51** — completeness, no control moved), and the Type II docs now say which mechanisms are reachable versus roadmap.
|
|
27
|
-
|
|
28
|
-
**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).**
|
|
20
|
+
**Exploit intelligence lands in Pro, and a risk-score claim gets corrected here (CE 0.2.39 / Enterprise 0.34.0).** Paired with Enterprise 0.34.0 — **no CE behaviour change.** Enterprise findings that carry CVEs are now joined by CVE-ID against a local **CISA KEV** catalog and a local **FIRST EPSS** scores file, banded and ordered exploit-first, so a KEV-listed MEDIUM outranks an unexploited CRITICAL. Both stores are **operator-populated — no feed data ships** — and fail closed when stale, so an out-of-date catalog never reports "not exploited". The CE-side change is a correction this README owed you: *"a composite risk score (severity × exploitability × impact × exposure)"* described inputs the code never had. It computes **CVSS weighted by verification status, with an uplift for initial-access techniques**, and that is what it now says. `prioritize()` in the documented FindingQueue sorts by **severity rank alone** — the exploit-aware ordering is Enterprise-side. Community Edition scanning, plugins and output are unchanged.
|
|
29
21
|
|
|
30
|
-
**
|
|
31
|
-
|
|
32
|
-
**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.
|
|
33
|
-
|
|
34
|
-
**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.
|
|
35
|
-
|
|
36
|
-
**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.
|
|
22
|
+
**The opt-in RFC 3161 register stops understating itself (CE 0.2.38 / Enterprise 0.33.1).** Paired with Enterprise 0.33.1 — **no CE behaviour change; wording only, and it is the wording npm freezes.** Trusted timestamping is opt-in, became operator-configurable in 0.33.0, and has since been proven end to end against a real Time-Stamp Authority twice over: on the npm path, and separately from inside the published Marketplace container image. Six places in this README still told you the container leg was unverified. They now state what is true — **RFC 3161 timestamping is opt-in via `NSAUDITOR_TSA_URL`, with no default ever and an outbound call to the authority you name** — and keep the one caveat that survives: retained images at `:0.32.11` and earlier carry no `openssl` at all, so nothing here speaks for them. **Ed25519 suppression signing is unchanged and still not reachable** — the workflow ships, the signature does not, and no shipped entry point signs a suppression.
|
|
37
23
|
|
|
38
|
-
|
|
24
|
+
See **[CHANGELOG.md](./CHANGELOG.md)** for the full per-release history.
|
|
39
25
|
|
|
40
26
|
→ See a sample EE scan output: **[walk-through with synthetic Acme Corp AWS account](https://www.nsauditor.com/ai/docs/sample-scan/)** (no signup required)
|
|
41
27
|
|
|
@@ -52,7 +38,7 @@ Scan → Analyze → Prioritize → Track → Act
|
|
|
52
38
|
- **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
|
|
53
39
|
- **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
|
|
54
40
|
- **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
|
|
55
|
-
- **Risk-scored prioritization (Pro)** — findings carry a composite risk score (
|
|
41
|
+
- **Risk-scored prioritization (Pro)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) 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
|
|
56
42
|
- **Continuous monitoring (CTEM)** — watch mode rescans on a schedule, diffs against previous results, and fires webhook alerts on changes
|
|
57
43
|
- **MCP integration** — expose scanning tools to AI assistants like Claude Code via Model Context Protocol
|
|
58
44
|
- **CI/CD ready** — SARIF output with `--fail-on` severity gating for pipeline integration
|
|
@@ -1100,7 +1086,7 @@ NSAuditor AI is built on a **Zero Data Exfiltration (ZDE)** architecture:
|
|
|
1100
1086
|
- **No data processing.** Nsasoft US LLC never sees, stores, or processes your scan results.
|
|
1101
1087
|
- **AI is opt-in.** External AI calls use your own API keys. Redaction runs locally first.
|
|
1102
1088
|
- **License validation is offline.** JWT signature verified locally with an embedded public key.
|
|
1103
|
-
- **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. The other outbound paths — AI enrichment, the GRC push, the continuous-monitoring webhook, and the timestamping and signing paths on the roadmap — are opt-in and off by default; the paths that are *not* optional are the scan target itself, your own cloud provider's APIs during a cloud scan, and DNS resolution of the target. All of them are enumerated with their trigger and default state in the egress register (EE `docs/architecture.md` §14.1.1), which is generated from code and guarded in both directions — deliberately, so this sentence never again has to carry a completeness claim that prose alone cannot keep true. Populating the local NVD store is the operator's; no feed data is delivered with the product.
|
|
1089
|
+
- **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. The other outbound paths — AI enrichment, the GRC push, the continuous-monitoring webhook, and the timestamping and signing paths on the roadmap — are opt-in and off by default; the paths that are *not* optional are the scan target itself, your own cloud provider's APIs during a cloud scan, and DNS resolution of the target. All of them are enumerated with their trigger and default state in the egress register (EE `docs/architecture.md` §14.1.1), which is generated from code and guarded in both directions — deliberately, so this sentence never again has to carry a completeness claim that prose alone cannot keep true. Populating the local NVD store is the operator's; no feed data is delivered with the product. The prior absolute form of this bullet — *"Fully air-gappable. Every feature works without internet access (Enterprise includes offline NVD feeds)"* — is **WITHDRAWN** (CE 0.2.33): quoted here so the withdrawal record stays on this page now that release history lives in the [CHANGELOG](./CHANGELOG.md).
|
|
1104
1090
|
|
|
1105
1091
|
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.
|
|
1106
1092
|
|
package/package.json
CHANGED
package/plugins/port_scanner.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import net from "node:net";
|
|
|
6
6
|
import dgram from "node:dgram";
|
|
7
7
|
import fsp from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { mapLimit } from "../utils/concurrency.mjs";
|
|
9
11
|
|
|
10
12
|
/* ------------------------------ helpers ------------------------------ */
|
|
11
13
|
|
|
@@ -57,16 +59,29 @@ export function parsePortsSpec(spec) {
|
|
|
57
59
|
return out;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* ⚠️ THE PACKAGE'S OWN DATA IS FOUND RELATIVE TO THE PACKAGE, NOT TO THE CALLER.
|
|
64
|
+
*
|
|
65
|
+
* This used to default to `process.cwd()` alone, with the `catch` below swallowing the miss.
|
|
66
|
+
* A globally installed CLI runs from wherever the OPERATOR happens to be, so unless that
|
|
67
|
+
* directory contained a `config/services.json`, the default port list came back EMPTY, the
|
|
68
|
+
* early return in `run()` fired, and the scan reported `up:false` with every bucket empty —
|
|
69
|
+
* in about a millisecond, having probed nothing. Measured 2026-08-10 on the installed build:
|
|
70
|
+
* cwd=/private/tmp gave 0 rows, cwd=<package> gave 50 rows and tcpOpen [80,21,53,443,…].
|
|
71
|
+
*
|
|
72
|
+
* That is a FALSE CLEAN shipped to every npm customer: an empty sweep is indistinguishable
|
|
73
|
+
* from a host with nothing listening, and downstream every plugin gated on `tcp_open` is then
|
|
74
|
+
* skipped silently. cwd is still tried FIRST so an operator can override the port set by
|
|
75
|
+
* placing their own `config/services.json` beside them — the documented behaviour — but the
|
|
76
|
+
* package copy is the floor, and a scan can no longer fall through to nothing.
|
|
77
|
+
*/
|
|
78
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
79
|
+
|
|
80
|
+
/** Parse ONE services.json into a port set. Never throws; an unusable file yields nothing. */
|
|
81
|
+
async function readPortSet(fp) {
|
|
64
82
|
const out = { tcp: [], udp: [] };
|
|
65
83
|
try {
|
|
66
|
-
const
|
|
67
|
-
const raw = await fsp.readFile(fp, "utf8");
|
|
68
|
-
const cfg = JSON.parse(raw);
|
|
69
|
-
|
|
84
|
+
const cfg = JSON.parse(await fsp.readFile(fp, "utf8"));
|
|
70
85
|
if (Array.isArray(cfg?.services)) {
|
|
71
86
|
for (const s of cfg.services) {
|
|
72
87
|
const p = Number(s?.port);
|
|
@@ -79,14 +94,33 @@ async function loadConfigPortsFromServicesJson(cwd = process.cwd()) {
|
|
|
79
94
|
if (Array.isArray(cfg?.tcp)) out.tcp.push(...cfg.tcp);
|
|
80
95
|
if (Array.isArray(cfg?.udp)) out.udp.push(...cfg.udp);
|
|
81
96
|
}
|
|
82
|
-
} catch {
|
|
83
|
-
// no config, ignore
|
|
84
|
-
}
|
|
97
|
+
} catch { /* unreadable or unparseable — yields nothing, and the caller falls through */ }
|
|
85
98
|
out.tcp = uniqInts(out.tcp);
|
|
86
99
|
out.udp = uniqInts(out.udp);
|
|
87
100
|
return out;
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The default port set: the caller's override if it YIELDS PORTS, otherwise the package's own.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ THE RULE IS *YIELD*, NOT *READ*, AND A REVIEW CAUGHT THE DIFFERENCE. An earlier fix tried
|
|
107
|
+
* cwd then the package and committed to the first file that READ successfully. But
|
|
108
|
+
* `config/services.json` is a common filename: a FOREIGN one in the caller's directory —
|
|
109
|
+
* another application's, or an empty/typo'd nsauditor one (`{}`, `{"services":[]}`) — parses
|
|
110
|
+
* fine, populates nothing, and the package floor was then never consulted. The scan probed
|
|
111
|
+
* ZERO ports and reported `up:false`: the same false clean as before, re-gated on a filename
|
|
112
|
+
* collision instead of a missing file. Committing on read also made that fix's own comment
|
|
113
|
+
* ("a scan can no longer fall through to nothing") untrue.
|
|
114
|
+
*
|
|
115
|
+
* A non-empty override REPLACES rather than merges — an operator narrowing the sweep on
|
|
116
|
+
* purpose must not silently get the full set back.
|
|
117
|
+
*/
|
|
118
|
+
async function loadConfigPortsFromServicesJson(cwd = process.cwd()) {
|
|
119
|
+
const override = await readPortSet(path.join(cwd, "config", "services.json"));
|
|
120
|
+
if (override.tcp.length || override.udp.length) return override;
|
|
121
|
+
return readPortSet(path.join(PKG_ROOT, "config", "services.json"));
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
function classifyTcpError(err) {
|
|
91
125
|
const code = err?.code || "";
|
|
92
126
|
const msg = err?.message || String(err) || "";
|
|
@@ -261,15 +295,31 @@ export default {
|
|
|
261
295
|
|
|
262
296
|
const data = [];
|
|
263
297
|
|
|
298
|
+
// ⚠️ BOUNDED CONCURRENCY, NOT A SEQUENTIAL LOOP — and the reason is a measured false clean.
|
|
299
|
+
// These two loops used to `await` one port at a time. A filtered or silent port costs the
|
|
300
|
+
// FULL per-port timeout, so the shipped 50-port sweep (43 TCP + 7 UDP) took 40,977 ms
|
|
301
|
+
// against a real router on 2026-08-10 — past the 30,000 ms `PLUGIN_TIMEOUT_MS` the plugin
|
|
302
|
+
// manager races every run against. The orchestrator therefore DISCARDED a correct result
|
|
303
|
+
// (`up:true`, `tcpOpen:[80,21,53,443,...]`) that arrived eleven seconds later, and
|
|
304
|
+
// substituted an empty one.
|
|
305
|
+
//
|
|
306
|
+
// That is not slowness, it is a FALSE CLEAN: with `ctx.tcpOpen` empty, every plugin gated
|
|
307
|
+
// on `tcp_open` is skipped by `plugin_manager.mjs` with a bare `return false` — no finding,
|
|
308
|
+
// no evidence gap — so `HTTP Probe` and `Webapp Detector` never ran, and the compliance
|
|
309
|
+
// layer reported PASS controls off a scan that never probed HTTP.
|
|
310
|
+
//
|
|
311
|
+
// `mapLimit` returns results IN INPUT ORDER, so the evidence rows stay deterministic; the
|
|
312
|
+
// cap is deliberately modest because a port sweep is latency-bound, not CPU-bound, and a
|
|
313
|
+
// scanner that floods a small target is its own kind of defect.
|
|
314
|
+
const SWEEP_CONCURRENCY = toInt(process.env.PORT_SCAN_CONCURRENCY, 12);
|
|
315
|
+
|
|
264
316
|
// TCP scans
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
317
|
+
data.push(...await mapLimit(tcpPorts, SWEEP_CONCURRENCY, (p) =>
|
|
318
|
+
scanTcpPort(host, p, { timeoutMs, bannerTimeoutMs, maxBannerBytes })));
|
|
268
319
|
|
|
269
320
|
// UDP scans
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
321
|
+
data.push(...await mapLimit(udpPorts, SWEEP_CONCURRENCY, (p) =>
|
|
322
|
+
scanUdpPort(host, p, { timeoutMs, udpPayload })));
|
|
273
323
|
|
|
274
324
|
// Buckets
|
|
275
325
|
const tcpOpen = data.filter(d => d.probe_protocol === "tcp" && d.status === "open").map(d => d.probe_port);
|
package/utils/nvd_cache.mjs
CHANGED
|
@@ -3,12 +3,42 @@
|
|
|
3
3
|
|
|
4
4
|
import fsp from 'node:fs/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TTL_DAYS = 7;
|
|
8
9
|
const MAX_ENTRIES = 10000;
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Where the cache lives when the caller does not say.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ NOT `'.nvd_cache'`, AND THE REASON IS A MEASURED OUTAGE. That default was CWD-RELATIVE
|
|
15
|
+
* (`path.resolve` below), and `mcp_server.mjs` constructs its client with no `cacheDir` — so
|
|
16
|
+
* under the Claude Desktop MCP server, which runs from `/`, the write target was
|
|
17
|
+
* `/.nvd_cache` and `mkdir` returned EROFS. `get_vulnerabilities` was DEAD for every Desktop
|
|
18
|
+
* user (Gate 3, 2026-08-10). For CLI users it was quieter and still wrong: the cache lived
|
|
19
|
+
* wherever the operator happened to stand, so it never persisted between directories and
|
|
20
|
+
* every run re-fetched what it already had.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ SAME CLASS AS THE PORT-SCANNER FIX IN THIS SAME RELEASE (CE 0.2.39), one module over, on
|
|
23
|
+
* a WRITE path: package data and package state resolved against the CALLER's cwd under a
|
|
24
|
+
* globally installed binary. That fix addressed `config/services.json` and did not touch this
|
|
25
|
+
* — the named instance, not the class. [[guard_the_hazard_class_not_the_named_instance]]
|
|
26
|
+
*
|
|
27
|
+
* Per-user, not per-directory, and overridable by the env var an operator can point at a
|
|
28
|
+
* writable volume in a container.
|
|
29
|
+
*/
|
|
30
|
+
export function defaultCacheDir() {
|
|
31
|
+
const fromEnv = process.env.NSAUDITOR_NVD_CACHE_DIR;
|
|
32
|
+
if (fromEnv && String(fromEnv).trim()) return String(fromEnv).trim();
|
|
33
|
+
try {
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
if (home) return path.join(home, '.nsauditor', 'nvd_cache');
|
|
36
|
+
} catch { /* no home in this environment — fall through */ }
|
|
37
|
+
return path.join(os.tmpdir(), 'nsauditor-nvd-cache');
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
export class NvdCache {
|
|
11
|
-
constructor(cacheDir =
|
|
41
|
+
constructor(cacheDir = defaultCacheDir()) {
|
|
12
42
|
this.cacheFile = path.resolve(cacheDir, 'nvd_cache.json');
|
|
13
43
|
this.ttlMs = (Number(process.env.NVD_CACHE_TTL_DAYS) || DEFAULT_TTL_DAYS) * 86400000;
|
|
14
44
|
this._data = null;
|
|
@@ -45,8 +75,23 @@ export class NvdCache {
|
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
async _save() {
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
// ⚠️ A CACHE IS AN OPTIMISATION: AN UNWRITABLE LOCATION COSTS CACHING, NEVER THE TOOL.
|
|
79
|
+
// The Desktop outage was not that the cache was unavailable — it was that an unavailable
|
|
80
|
+
// cache took `get_vulnerabilities` down with it. A read-only or hostile environment now
|
|
81
|
+
// loses persistence and keeps the feature; the in-memory `_data` still serves the run.
|
|
82
|
+
try {
|
|
83
|
+
await fsp.mkdir(path.dirname(this.cacheFile), { recursive: true });
|
|
84
|
+
await fsp.writeFile(this.cacheFile, JSON.stringify(this._data, null, 2), 'utf8');
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!this._warnedUnwritable) {
|
|
87
|
+
this._warnedUnwritable = true;
|
|
88
|
+
// Said ONCE, and said out loud: a silently non-persisting cache is the quieter
|
|
89
|
+
// half of this very defect.
|
|
90
|
+
console.error(`[nsauditor] NVD cache not persisted (${err.code || err.message}) at ` +
|
|
91
|
+
`${path.dirname(this.cacheFile)} — continuing without caching. Set ` +
|
|
92
|
+
'NSAUDITOR_NVD_CACHE_DIR to a writable path to restore it.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
50
95
|
}
|
|
51
96
|
|
|
52
97
|
async get(key) {
|