nsauditor-ai 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ A modular, AI-assisted network security audit platform that scans, understands,
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/nsauditor-ai.svg)](https://www.npmjs.com/package/nsauditor-ai)
8
8
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
- [![Node.js 24+](https://img.shields.io/badge/node-24%2B-green.svg)](https://nodejs.org)
9
+ [![Node.js 24.22.1+](https://img.shields.io/badge/node-24.22.1%2B-green.svg)](https://nodejs.org)
10
10
  [![Tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)](#tests)
11
11
 
12
12
  ---
@@ -17,22 +17,26 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
17
17
 
18
18
  ## What's New
19
19
 
20
- **Latest: CE 1.0.0 + Enterprise 2.0.0 — *Node.js 24; a scan connects only to the addresses its host check resolved; the AI payload carries no AWS account ID; on TCP an authoritative probe's row must identify its service to be one*.**
21
- A scan now connects only to the addresses its host check resolved — on the CLI and through the MCP server, for the target
22
- and each redirect hop — so a name that answers one address to the check and another at connect (DNS rebinding) cannot
23
- redirect it; two limits are stated in the [CHANGELOG](./CHANGELOG.md) (an HTTP proxy set with `NODE_USE_ENV_PROXY`, and an
24
- idle pooled connection). The payload sent to AI no longer carries an AWS account ID, and the system prompt sent with it is
25
- written beside it. A refused or unidentified TCP port is no longer listed as a service. Plugin 040 tells a weak certificate
26
- from an untrusted chain. ⚠️ **Security:** `--watch` printed the full `--webhook-url` to stdout; it now prints the host — if
27
- you ran `--watch` with a Slack or Teams URL where stdout is logged, rotate that URL. The supported runtime is **Node.js 24**.
20
+ **Latest: CE 1.1.1 + Enterprise 2.1.1 — *the OpenAI client on openai 7 and one zod; an AI assessment of part of the scan says so; a tier denial names the tools you can use; `license` exits 2 on an unknown subcommand*.**
21
+ The OpenAI client moves from `openai` 4 to `openai` 7, so a project that depends on this package holds one `zod` at
22
+ 4.6.6 or later (GHSA-mrhx-c7xc-m3w5), with no `overrides`, and no `node-domexception`. After every AI response the CLI
23
+ compares the input tokens the provider reports with its own estimate of the input (characters ÷ 4); when the provider
24
+ reports reading less than half of it, `scan_response_ai.txt` and `.html` open with one line saying the assessment covers
25
+ part of the scan, and `scan_response_ai.json` and the run record carry both numbers. Measured with a local Ollama model
26
+ whose settings were left at their defaults, which read about a tenth of a host scan
27
+ ([docs/openai.md](./docs/openai.md#when-the-provider-reads-part-of-the-input)). A tool refused for its licence tier
28
+ names the tools the caller can use now, and `compliance_matrix` without the Enterprise package names the licence it
29
+ needs instead of an install command that fails. `nsauditor-ai license` with an unknown subcommand, or none, exits 2. The
30
+ supported runtime is Node.js 24.22.1 or newer (`engines.node >=24.22.1`, since 1.1.0); npm only warns on an older
31
+ runtime, so check `node --version`.
28
32
  Two limits from Enterprise 1.3.0 still hold in the Pro delta and Enterprise MTTR: a scan made before Enterprise 1.3.0
29
33
  could not record a plugin left out of it, so in a comparison with one an agent's row that scan lacks is not refused;
30
34
  and a scan that discovered ports with the Nmap plugin (024) alone records no port oracle, so an analysis agent's or the
31
35
  CVE mapper's row on a port it did not measure can read RESOLVED and count as closed in MTTR, and the control it failed
32
36
  can read PASS — include the port scanner (003).
33
- Plugin count UNCHANGED at 27 Community + 29 Enterprise; every coverage matrix UNCHANGED; **Enterprise 2.0.0 requires this
34
- release** (`nsauditor-ai >= 1.0.0`). The free last-vs-current webhook alerting delta is untouched and stays free (see
35
- Continuous Monitoring below).
37
+ Plugin count UNCHANGED at 27 Community + 29 Enterprise; every coverage matrix UNCHANGED; Enterprise 2.1.1 pairs with this
38
+ release, and its peer floor stays at `nsauditor-ai >= 1.0.0`. The free last-vs-current webhook alerting delta is
39
+ untouched and stays free (see Continuous Monitoring below).
36
40
 
37
41
  For the full per-release history — every prior cycle, in detail — see [CHANGELOG.md](./CHANGELOG.md). This README keeps only the current release headline.
38
42
 
@@ -45,8 +49,8 @@ Scan → Analyze → Prioritize → Track → Act
45
49
  - **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
46
50
  - **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
47
51
  - **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
48
- - **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
49
- - **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) and a status field. Enterprise adds an operator suppression workflow for compliance findings (accepted-risk / false-positive with expiry): the compliance report shows a suppressed finding as suppressed, naming its approver and rationale, and an expired suppression no longer applies
52
+ - **AI-powered analysis** — send redacted scan results to OpenAI, Claude or a local Ollama model (your keys, your choice) for vulnerability assessments and remediation guidance
53
+ - **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score: CVSS/10 × a status weight, plus a 15% uplift for findings carrying an initial-access MITRE technique, capped at 1.0. Every finding the scanner emits is `UNVERIFIED`, so the weight is 0.6 for all of them (CVSS 9.8 → 0.59; with T1190 → 0.68; no CVSS → 0.30). The `VERIFIED` (1.0) and `FALSE_POSITIVE` (0.0) weights are unreachable in the shipped product: those status values are reserved, no shipped code or registered MCP tool sets them, and the re-probe engine that would have is withdrawn and not planned. Enterprise adds an operator suppression workflow for compliance findings (accepted-risk / false-positive with expiry): the compliance report shows a suppressed finding as suppressed, naming its approver and rationale, and an expired suppression no longer applies
50
54
  - **Continuous monitoring (CTEM)** — watch mode rescans on a schedule, compares each host with the previous cycle and alerts a host whose scan changed and that carries a finding at or above `--alert-severity`
51
55
  - **MCP integration** — expose scanning tools to AI assistants like Claude Code via Model Context Protocol
52
56
  - **CI/CD ready** — SARIF output with `--fail-on` severity gating for pipeline integration
@@ -66,8 +70,9 @@ NSAuditor AI is available in three editions: Community (free, MIT-licensed, no r
66
70
  | AI analysis (OpenAI, Claude, Ollama — your keys) | ✅ basic | ✅ enriched | ✅ enriched |
67
71
  | Structured findings + SARIF + CSV export | ✅ | ✅ | ✅ |
68
72
  | CTEM watch mode | ✅ basic | ✅ advanced | ✅ advanced |
73
+ | MITRE ATT&CK technique mapping — every scan maps the detected services to technique ids, linked in the report | ✅ | ✅ | ✅ |
69
74
  | **Pro features** | | | |
70
- | CVE matching + MITRE ATT&CK mapping | — | ✅ | ✅ |
75
+ | CVE matching + per-finding MITRE ATT&CK annotations (`evidence.mitre`) | — | ✅ | ✅ |
71
76
  | Risk scoring + prioritization | — | ✅ | ✅ |
72
77
  | Exploit intelligence — **CISA KEV** + **FIRST EPSS** joined onto the CVE matches, exploit-first ordering (a KEV-listed MEDIUM outranks an unexploited CRITICAL); stores are operator-populated — no feed data ships | — | ✅ | ✅ |
73
78
  | Parallel analysis agents | — | ✅ | ✅ |
@@ -174,7 +179,7 @@ Results land in `./out/<host>_<timestamp>/` (two scans of one host whose plugin
174
179
  | `scan_results.csv` | CSV — only with `--output-format csv` |
175
180
  | `scan_report.md` | GitHub-flavored Markdown report — only with `--output-format md` (or `markdown`) |
176
181
 
177
- > Requires Node.js 24 or newer (tested on Node 24).
182
+ > Requires Node.js 24.22.1 or newer (tested on Node 24.22.1). npm only warns on an older runtime, so check `node --version` yourself.
178
183
 
179
184
  ---
180
185
 
@@ -200,7 +205,7 @@ found". A higher tier does not add tools; it unlocks the ones already in the lis
200
205
 
201
206
  | Tool | Purpose |
202
207
  |---|---|
203
- | `scan_host` | Run a full plugin scan against a host — service detection, OS fingerprint and the flags the service checks leave on each service record (weak SSH algorithms, SNMP community, weak TLS protocols / ciphers and a self-signed certificate, the MCP server checks; anonymous FTP login and zone transfer only when `FTP_CHECK_ANON` / `DNS_CHECK_AXFR` are set — both off by default). Its records also carry the HTTP probe's dangerous methods (006; `methodsTested` false means no Allow header was read — not tested, never "none"), SMB null sessions (014; only with `SMB_NULL_SESSION` set) and the TLS-certificate / debug-endpoint / DNS-security auditors' findings (040 / 050 / 060 — the DNS-security audit tries a zone transfer against the domain's nameservers only with `DNS_AUDIT_AXFR_CHECK` set, also off by default, and records only an open transfer; for a domain-name target the DNS-security audit lands on the 53/udp record when the scan found a 53/udp service, otherwise in the conclusion's evidence; for an IP-address target it declines, with its reason on the manifest and a NOT TESTED line in the markdown). Each of the three record-level opt-in checks records whether it ran beside its result — `axfrTested` / `anonymousLoginTested` / `nullSessionTested` is `true` when measured, else the reason (`opt-in-off`, `no-domain`, `no-answer`) — and a null result is not tested, never "refused". It does NOT look up CVEs or run the analysis agents; with the Enterprise package the zero-trust assessment (1023) reaches it only as one score line in the evidence. So zero findings is not a clean verdict (use `get_vulnerabilities` per service CPE and `probe_service` for those plugins — both Pro — or the CLI scan with the Enterprise package and a Pro or Enterprise licence) |
208
+ | `scan_host` | Run a full plugin scan against a host — service detection, OS fingerprint and the flags the service checks leave on each service record (weak SSH algorithms, SNMP community, weak TLS protocols / ciphers and a self-signed certificate, the MCP server checks; anonymous FTP login and zone transfer only when `FTP_CHECK_ANON` / `DNS_CHECK_AXFR` are set — both off by default). Its records also carry the HTTP probe's dangerous methods (006; `methodsTested` false means no Allow header was read — not tested, never "none"), SMB null sessions (014; only with `SMB_NULL_SESSION` set) and the TLS-certificate / debug-endpoint / DNS-security auditors' findings (040 / 050 / 060 — the DNS-security audit tries a zone transfer against the domain's nameservers only with `DNS_AUDIT_AXFR_CHECK` set, also off by default, and records what each nameserver answered; for a domain-name target the DNS-security audit lands on the 53/udp record when the scan found a 53/udp service, otherwise in the conclusion's evidence; for an IP-address target it declines, with its reason on the manifest and a NOT TESTED line in the markdown). Each of the three record-level opt-in checks records whether it ran beside its result — `axfrTested` / `anonymousLoginTested` / `nullSessionTested` is `true` when measured, else the reason (`opt-in-off`, `no-domain`, `no-answer`) — and a null result is not tested, never "refused". It does NOT look up CVEs or run the analysis agents; with the Enterprise package the zero-trust assessment (1023) reaches it only as one score line in the evidence. So zero findings is not a clean verdict (use `get_vulnerabilities` per service CPE and `probe_service` for those plugins — both Pro — or the CLI scan with the Enterprise package and a Pro or Enterprise licence) |
204
209
  | `list_plugins` | List available scanner plugins with their IDs, priorities and requirements |
205
210
  | `compliance_matrix` | Return the shipped coverage matrix for a framework — Covered / Partial / Out of scope, with the per-group out-of-scope reasons. **Needs the Enterprise pack installed**: the matrices are its data, so on a Community-only install this fails closed with an install instruction rather than returning an empty matrix (an empty matrix is what gets filled in with a guess) |
206
211
 
@@ -208,7 +213,7 @@ found". A higher tier does not add tools; it unlocks the ones already in the lis
208
213
 
209
214
  | Tool | Purpose |
210
215
  |---|---|
211
- | `probe_service` | Run one specific plugin against a single `host:port` |
216
+ | `probe_service` | Run one specific plugin against a single `host:port` — a plugin with its own port set probes that set instead (the TLS scanner, 011: 443, 465, 563, 993 and 995), and the response's `probe` names the ports actually probed |
212
217
  | `get_vulnerabilities` | Look up known CVEs for a CPE (Common Platform Enumeration) string via the NVD API |
213
218
 
214
219
  **Unlocked by Enterprise:**
@@ -234,7 +239,7 @@ found". A higher tier does not add tools; it unlocks the ones already in the lis
234
239
  > has no MCP client in the path — there you can raise `PLUGIN_TIMEOUT_MS` (e.g. `90000`) freely. On the CLI a
235
240
  > plugin that declares its own budget already gets it; `PLUGIN_TIMEOUT_CEILING_MS` caps those.
236
241
 
237
- Security: SSRF protection on every host the MCP server is handed — the address it resolves to is checked (every answer), loopback, link-local and cloud-metadata addresses are always refused, and RFC 1918, CGNAT and fc00::/7 are refused unless `NSA_ALLOW_ALL_HOSTS` is set; a host the scanned target names — a redirect hop, a UPnP device's description URL — is checked the same way by the plugin that would follow it. Since CE 1.0.0 a scan connects to the addresses its check resolved — the target's, and each redirect hop's — so a name that answers one address to the check and another when the scan connects (DNS rebinding) is not followed; the system tools a scan runs (`ping`, `nping`, `hping3`, `nmap`, `curl`) are handed the address. Two limits: with `NODE_USE_ENV_PROXY` and a proxy configured, an HTTP request goes through the proxy by name and the proxy resolves it; and a request can reuse an idle connection the same process opened earlier to the same name, for as long as that connection stays idle in the pool — a few seconds by default; a server's Keep-Alive hint holds it longer. One exception, off by default: with `DNS_AUDIT_AXFR_CHECK` set, the DNS-security audit (060) opens TCP/53 to each of the scanned domain's nameservers, at the first IPv4 address each nameserver's name resolves to — an address no host check looked at, which neither the guard nor the pin covers. Port validation (1–65535), CPE format enforcement, dependency injection for test isolation. **Server-startup authentication is required** — see next section.
242
+ Security: SSRF protection on every host the MCP server is handed — the address it resolves to is checked (every answer), loopback, link-local and cloud-metadata addresses are always refused, and RFC 1918, CGNAT and fc00::/7 are refused unless `NSA_ALLOW_ALL_HOSTS` is set; a redirect hop the scanned target names is checked the same way by the plugin that would follow it, and a UPnP device's description URL is fetched only when it names the very address that answered the discovery — so a device that answers from a link-local or loopback address (`169.254.x.x`, `127.0.0.1`) has its description fetched there. Since CE 1.0.0 a scan connects to the addresses its check resolved — the target's, and each redirect hop's — so a name that answers one address to the check and another when the scan connects (DNS rebinding) is not followed. Every answer is checked, and each connection the scan makes lands on one of the checked answers: an in-process connection may use any of them (Node tries them in turn), and the system tools a scan runs (`ping`, `nping`, `hping3`, `nmap`, `curl`, and `arp` for an address on a local network) are handed one — the first IPv4 answer when there is one. Limits: with `NODE_USE_ENV_PROXY` and a proxy configured, an HTTP request goes through the proxy by name and the proxy resolves it; and a request can reuse an idle connection the same process opened earlier to the same name, for as long as that connection stays idle in the pool — a few seconds by default; a server's Keep-Alive hint holds it longer. A `--webhook-url` alert does not share that pool: each alert opens its own connection to the address its check resolved, and closes it. And the address classifier does not recognise a NAT64 address (`64:ff9b::/96`) that embeds a blocked IPv4 address. One exception, off by default: with `DNS_AUDIT_AXFR_CHECK` set, the DNS-security audit (060) opens TCP/53 to each of the scanned domain's nameservers, at the first IPv4 address each nameserver's name resolves to — an address no host check resolved, so the audit classifies it itself before connecting: loopback, link-local, cloud-metadata and unspecified addresses are refused in every configuration, `NSA_ALLOW_ALL_HOSTS` included, and RFC 1918, CGNAT and fc00::/7 addresses unless `NSA_ALLOW_ALL_HOSTS` is set. It connects to the literal it classified, and a refused nameserver is recorded as not attempted, never graded. Port validation (1–65535), CPE format enforcement, dependency injection for test isolation. **Server-startup authentication is required** — see next section.
238
243
 
239
244
  ### Authentication (required)
240
245
 
@@ -322,12 +327,24 @@ The exact `NSA_MCP_AUTH_KEY` value to paste is printed by `nsauditor-ai mcp inst
322
327
  or `on` turn it on. It admits private ranges only: loopback, link-local and cloud-metadata addresses stay refused over
323
328
  MCP whether they are written as an address in any spelling or reached through what a host name resolves to — every
324
329
  answer is checked. Since CE 1.0.0 the scan connects to the answers the check resolved, so a name whose answer changes
325
- between the check and the scan (DNS rebinding) is not followed — the two limits are in the Security note above
326
- - `FTP_CHECK_ANON=true` — lets the FTP check try an anonymous login (off by default; without it `scan_host` never reports anonymous FTP). `DNS_CHECK_AXFR=true` with `DNS_AXFR_DOMAIN=<zone>` — lets the DNS check try a zone transfer of that zone (off by default). `DNS_AUDIT_AXFR_CHECK=true` (or `1`, `yes`, `on`) — lets the DNS-security audit (060) try a zone transfer of the scanned domain against each of its nameservers (off by default; it connects to each nameserver's own address, outside the scan's host check — see the Security note above). `SMB_NULL_SESSION=true` — lets the NetBIOS check try an SMB null session (off by default). The FTP, DNS and NetBIOS checks, when off or when they ran and could not complete (a timeout, a dropped connection), are recorded and reported as not tested with their reason — never as refused; the DNS-security audit's attempt records only an open transfer, so its absence is not a measured "closed". The same variables govern the CLI scan.
327
- - `PLUGIN_TIMEOUT_MS=5000` — the per-plugin budget for `scan_host` and `probe_service` (default 30000), and it binds every plugin there, including one that declares a longer budget of its own. It bounds each PLUGIN, not the call: `scan_host` runs a host's plugins one after another, so the call takes roughly the sum of their times. On one router a `scan_host` call timed out in Claude Desktop on 2026-08-10 and one returned within 138 s on 2026-09-30, so neither outcome is promised: a call that times out returned no result — never read it as a clean host. On a real home gateway the plugins of one full scan took ~113 s between them at the default budget.
330
+ between the check and the scan (DNS rebinding) is not followed — the limits are in the Security note above
331
+ - `FTP_CHECK_ANON=true` — lets the FTP check try an anonymous login (off by default; without it `scan_host` never reports anonymous FTP). `DNS_CHECK_AXFR=true` with `DNS_AXFR_DOMAIN=<zone>` — lets the DNS check try a zone transfer of that zone (off by default). `DNS_AUDIT_AXFR_CHECK=true` (or `1`, `yes`, `on`) — lets the DNS-security audit (060) try a zone transfer of the scanned domain against each of its nameservers (off by default; it connects to each nameserver's own address, which it classifies first — see the Security note above). `SMB_NULL_SESSION=true` — lets the NetBIOS check try an SMB null session (off by default). The FTP, DNS and NetBIOS checks, when off or when they ran and could not complete (a timeout, a dropped connection), are recorded and reported as not tested with their reason — never as refused; the DNS-security audit records what each nameserver answered (transferred the zone, answered without transferring it, refused, or no answer) and why a nameserver it did not ask was not asked. The two zone-transfer checks ask different hosts: the DNS check asks the scanned host, the DNS-security audit asks each of the domain's nameservers. When the scanned host is itself one of the domain's nameservers and `DNS_AXFR_DOMAIN` names that domain, a report carries a finding from each — two measurements of one exposure, not two exposures. The same variables govern the CLI scan.
332
+ - `PLUGIN_TIMEOUT_MS=5000` — the per-plugin budget for `scan_host` and `probe_service` (default 30000), and it binds every plugin there, including one that declares a longer budget of its own. It bounds each PLUGIN, not the call: `scan_host` runs a host's plugins one after another, so the call takes roughly the sum of their times — on a real home gateway the plugins of one full scan took ~113 s between them at the default budget, and 54–122 s across five runs on another router. Without a call limit, on one router a `scan_host` call timed out in Claude Desktop on 2026-08-10 and one returned within 138 s on 2026-09-30. The call is bounded by `SCAN_HOST_WALL_MS` (below).
333
+ - `SCAN_HOST_WALL_MS` — the time limit for the whole `scan_host` CALL (default 50000; `0` turns it off). Three walls, side by side: `PLUGIN_TIMEOUT_MS` bounds each network plugin, `CLOUD_PLUGIN_TIMEOUT_MS` each cloud plugin, and `SCAN_HOST_WALL_MS` the `scan_host` call. It exists because the CLIENT may end a tool call at a limit of its own, and a call it ends returns nothing at all — never read that as a clean host: on 2026-10-21 Claude Desktop 3.x ended both of two measured `scan_host` calls at about 60 s, whatever `MCP_TIMEOUT` said. With the limit, the call ends first, keeping 3 s for the summary and the response: a plugin still running at the limit is stopped, and the plugins after it are not started. Each reads `timeout` in `manifest`; the response's `callWall` lists them in the order they would have run (`notStarted`, `cutShort`, beside `limitMs` and `reached`, present on every call); and the response's `note` and the head of `markdown` say the scan is partial — not a clean result. **What it costs, measured:** the MCP Scanner (070) runs early, at priority 70, and on one home router that dropped its probes it took 44.6 s. With `PLUGIN_TIMEOUT_MS` at 45 s or more, it runs into the limit and the plugins after it in priority order are not started — on an Enterprise install the Zero Trust Assessment (1023), then the DNS-security audit (060) through to the TLS Scanner (011) and the TLS Certificate & Cipher Auditor (040), so the TLS and certificate checks are the first lost. At the 30 s default, 070 stops at its own 30 s and the seconds left go to the plugins after it, in that order. For a complete scan of such a host, run the CLI, or raise `SCAN_HOST_WALL_MS` in a client that does not cut the call.
328
334
  - `PLUGIN_TIMEOUT_CEILING_MS` — the upper bound on any plugin's DECLARED budget (default 120000). A few plugins declare their own budget because their cost grows with what they scan (the UPnP and MCP scanners here; several cloud auditors in Enterprise). On the CLI a declaration outranks `PLUGIN_TIMEOUT_MS`, so this is the setting that caps every plugin there. The MCP tools already bind declarations with their own limits.
329
335
  - `CLOUD_SCAN_CONCURRENCY` — max cloud plugins run at once by `scan_cloud` (default 20).
330
336
  - `CLOUD_PLUGIN_TIMEOUT_MS` — per-plugin timeout for `scan_cloud` (default 25000; independent of the network `PLUGIN_TIMEOUT_MS`). It bounds each plugin, not the call — see the all-region note above before raising it.
337
+ - `NSA_MCP_RESPONSE_DIR` — off unless set. An **absolute** directory where the server saves each tool response,
338
+ exactly as it returned it, as `<call_id>.txt` (the directory is created `0700` if missing, each file `0600`, and an
339
+ existing file is never overwritten). It exists for `nsauditor-ai mcp verify-call <call_id> --response <file>`, which
340
+ needs the whole response: Claude Desktop shows long tool output truncated, and a truncated copy cannot be verified.
341
+ Beside each response the server saves its request as `<call_id>.request.json`: the tool's name and the call's own
342
+ arguments, verbatim (a `scan_host` host, `scan_cloud`'s providers and regions) — both files or neither, `0600`.
343
+ `verify-call <call_id> --response <file> --request <file>` checks both: the server logs a digest of each.
344
+ When the copies were saved, the response's receipt footer says so (it names the files, not the directory). The files
345
+ hold scan results and the targets you asked about — keep the directory private, and clear it yourself; nothing
346
+ removes them. It is a server setting, not a
347
+ scan target: set it in this `env` block (not in an `NSA_ENV_FILE` file), then fully quit and reopen Claude Desktop.
331
348
  - `AI_PROVIDER` and API key — optional, enables AI-powered analysis of scan results
332
349
 
333
350
  #### `NSA_ENV_FILE` — point the MCP server at an environment file
@@ -463,12 +480,12 @@ nsauditor-ai --version (or -v, or `version`)
463
480
  | `--out <dir>` | Custom output directory — applies to the per-scan folder *and* to alternate-format files (SARIF/CSV/Markdown) | `out/` |
464
481
  | `--parallel <n>` | Concurrent host scans | `1` |
465
482
  | `--output-format <fmt>` | Additional output format: `sarif` (CI/CD) · `csv` (spreadsheet) · `md` or `markdown` (chat/PR/Slack quotable) | — |
466
- | `--fail-on <sev>` | Exit code 1 if any finding is ≥ severity (`critical\|high\|medium\|low\|info`). It reads the same findings the Markdown, SARIF and CSV reports count — one shared table grades them, one finding per item: anonymous FTP login, DNS zone transfer and MCP unauthenticated access or tool listing are critical; an SNMP default community, an expired certificate, an SMB null session, MCP cleartext transport and a deprecated MCP protocol are high; weak SSH algorithms, weak TLS protocols and negotiated ciphers, dangerous HTTP methods, a self-signed certificate and an exposed MCP Inspector are medium; the TLS-certificate (040), debug-endpoint (050) and DNS-security (060) audits grade each finding at its own severity, and 060 grades the scanned domain's SPF / DMARC / NS posture (and, with `DNS_AUDIT_AXFR_CHECK` set, an open zone transfer) whether or not the host answered on 53/udp. **Since 1.3.0 it reads all of these — a pipeline that passed before may fail on findings that were always there.** Anonymous FTP login, zone transfer and the SMB null session are tested only when `FTP_CHECK_ANON=true` / `DNS_CHECK_AXFR=true` with `DNS_AXFR_DOMAIN` / `SMB_NULL_SESSION=true` are set (the DNS-security audit's own zone-transfer attempt only when `DNS_AUDIT_AXFR_CHECK` is set); dangerous HTTP methods are read only where the HTTP probe read an Allow header; where it read none they are not tested, and that never trips the gate. Any concluded scan counts as info — so `--fail-on info` fails every scan that concludes. It does not read Enterprise's CVE rows or agent findings, so exit 0 is not a clean host; exit 2 for an unknown severity or no conclusion | — |
483
+ | `--fail-on <sev>` | Exit code 1 if any finding is ≥ severity (`critical\|high\|medium\|low\|info`). A flag the CLI refuses, or a scan target its address guard refuses, exits 2 with one `[ERROR]` line, so exit 1 stays this verdict. It reads the same findings the Markdown, SARIF and CSV reports count — one shared table grades them, one finding per item: anonymous FTP login, DNS zone transfer and MCP unauthenticated access or tool listing are critical; an SNMP default community, an expired certificate, an SMB null session, MCP cleartext transport and a deprecated MCP protocol are high; weak SSH algorithms, weak TLS protocols and negotiated ciphers, dangerous HTTP methods, a self-signed certificate and an exposed MCP Inspector are medium; the TLS-certificate (040), debug-endpoint (050) and DNS-security (060) audits grade each finding at its own severity, and 060 grades the scanned domain's SPF / DMARC / NS posture (and, with `DNS_AUDIT_AXFR_CHECK` set, an open zone transfer) whether or not the host answered on 53/udp. **Since 1.3.0 it reads all of these — a pipeline that passed before may fail on findings that were always there.** Anonymous FTP login, zone transfer and the SMB null session are tested only when `FTP_CHECK_ANON=true` / `DNS_CHECK_AXFR=true` with `DNS_AXFR_DOMAIN` / `SMB_NULL_SESSION=true` are set (the DNS-security audit's own zone-transfer attempt only when `DNS_AUDIT_AXFR_CHECK` is set); dangerous HTTP methods are read only where the HTTP probe read an Allow header; where it read none they are not tested, and that never trips the gate. Any concluded scan counts as info — so `--fail-on info` fails every scan that concludes. It does not read Enterprise's CVE rows or agent findings, so exit 0 is not a clean host; exit 2 for an unknown severity or no conclusion | — |
467
484
  | `--insecure-https` | Accept self-signed TLS certificates | `false` |
468
485
  | `--watch` | CTEM continuous **alerting** loop — re-scan on `--interval`, compare each host with the previous cycle, webhook for a host whose scan changed and that carries a finding at or above `--alert-severity`; the first cycle sets the baseline and alerts nobody (see Continuous Monitoring). Not an evidence cadence: no retention, no cross-run aggregation, skips SARIF/CSV/Markdown + `--fail-on`, dies with the process. Scans each distinct host once per cycle (a one-shot `--host X,X` scans `X` twice). Use a scheduler for SOC 2 Type II history. | `false` |
469
486
  | `--interval <min>` | Rescan interval in minutes (requires `--watch`) | `60` |
470
487
  | `--webhook-url <url>` | Webhook URL for watch-mode alerts (see `--watch`) | — |
471
- | `--alert-severity <sev>` | Minimum severity for webhook alerts | `high` |
488
+ | `--alert-severity <sev>` | Minimum severity for webhook alerts: `critical`, `high`, `medium`, `low` or `info`, in any case — any other value is refused | `high` |
472
489
  | `--alert-every-cycle` | Alert every host with a finding at or above `--alert-severity` on every watch cycle, the first included (default: only hosts whose scan changed since the previous cycle) | `false` |
473
490
  | `--alert-payload <mode>` | `full` — the watch alert as it has always been · `minimal` — drops the host, the service and the description: a count, and each finding's port, protocol and severity | `full` |
474
491
  | `--compliance <fw>` | Compliance framework to map findings into. Accepts CSV for multi-framework runs (e.g. `soc2`, `hipaa`, `nist-csf`, `pci-dss`, `iso-27001`, `cis-v8`, `gdpr`, `nist-800-171`, or any combination like `soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171` — `all` expands to the same eight). **Enterprise license required.** Supported frameworks as of EE 0.40.0 — **eight**: `soc2` (AICPA TSC 2017) + `hipaa` (HIPAA Security Rule §164.312 Technical Safeguards) + `nist-csf` (NIST Cybersecurity Framework 2.0 Core, CSWP 29 Feb 2024) + `pci-dss` (PCI DSS v4.0.1, PCI SSC June 2024 errata) + `iso-27001` (ISO/IEC 27001:2022, ISO + IEC October 2022) + `cis-v8` (CIS Critical Security Controls v8, CIS May 2021 / v8.1 errata June 2024) + `gdpr` (GDPR Article 32 / Security of Processing, Regulation (EU) 2016/679 — Art. 32 infrastructure substrate, **not** GDPR compliance) + `nist-800-171` (NIST SP 800-171 Rev 2 — evidence substrate for **CMMC Level 2 preparation**, **not** a CMMC certification, **not** a FedRAMP authorization, and no MET/NOT MET determination or SPRS score). See `@nsasoft/nsauditor-ai-ee` README for per-framework coverage details. | — |
@@ -944,7 +961,7 @@ Commonly requested plugins: RDP, VNC, SMTP/POP3/IMAP, MySQL/PostgreSQL/MSSQL/Mon
944
961
 
945
962
  For the full technical architecture, see [ARCHITECTURE.md](docs/architecture.md).
946
963
 
947
- **Tech stack:** Node.js 24+ · ES Modules (.mjs) · OpenAI + Anthropic SDKs · Node.js built-in test runner · MCP stdio transport
964
+ **Tech stack:** Node.js 24.22.1+ · ES Modules (.mjs) · OpenAI + Anthropic SDKs · Node.js built-in test runner · MCP stdio transport
948
965
 
949
966
  **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)
950
967
 
package/cli.mjs CHANGED
@@ -19,7 +19,7 @@ import { buildCsv } from './utils/export_csv.mjs';
19
19
  import { buildMarkdownReport } from './utils/report_md.mjs';
20
20
  import { conclusionFindings, maxRank, severityRank as flagSeverityRank, OPEN_SERVICE_SEVERITY } from './utils/service_flags.mjs';
21
21
  import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE, historyServiceEntry, historyHostEntry } from './utils/scan_history.mjs';
22
- import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
22
+ import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine, aiInputCoverage, aiTruncationCaveat } from './utils/ai_stage.mjs';
23
23
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
24
24
  import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } from './utils/capabilities.mjs';
25
25
  import { createScheduler } from './utils/scheduler.mjs';
@@ -571,9 +571,14 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
571
571
  clearTimeout(aiTimer);
572
572
  }
573
573
 
574
+ // B113a: what share of the input the provider says it read. Written BESIDE the raw response, never into it.
575
+ const inputCoverage = aiInputCoverage({ resp, promptText, userContent });
576
+ const truncationCaveat = inputCoverage?.inputTruncated ? aiTruncationCaveat(inputCoverage) : null;
577
+
574
578
  // Write full AI response
575
579
  try {
576
- await fsp.writeFile(aiResponsePath, JSON.stringify(resp, null, 2), 'utf8');
580
+ const recorded = inputCoverage ? { ...JSON.parse(JSON.stringify(resp)), nsauditorMeta: { inputCoverage } } : resp;
581
+ await fsp.writeFile(aiResponsePath, JSON.stringify(recorded, null, 2), 'utf8');
577
582
  console.log(`[${providerLabel}] Wrote AI response:`, aiResponsePath);
578
583
  } catch (e) {
579
584
  console.warn(`[${providerLabel}] Failed to write AI response:`, e?.message || e);
@@ -582,6 +587,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
582
587
  // Write TXT & HTML
583
588
  try {
584
589
  const lines = [
590
+ ...(truncationCaveat ? [truncationCaveat, ``] : []),
585
591
  `Model: ${model}`,
586
592
  `Provider: ${providerLabel}`,
587
593
  `When: ${new Date().toISOString()}`,
@@ -601,7 +607,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
601
607
  host,
602
608
  whenIso: new Date().toISOString(),
603
609
  model,
604
- md: aiConclusionText.trim()
610
+ md: `${truncationCaveat ? `> ${truncationCaveat}\n\n` : ''}${aiConclusionText.trim()}`
605
611
  });
606
612
  await fsp.writeFile(aiHtmlPath, html, 'utf8');
607
613
  console.log(`[${providerLabel}] Wrote AI HTML:`, aiHtmlPath);
@@ -623,6 +629,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
623
629
  // review fold D: a 200 with no extractable text is NOT "ok" — the
624
630
  // end-of-scan summary must not read "OK" when there is no conclusion.
625
631
  ai_status: (typeof aiConclusionText === 'string' && aiConclusionText.trim()) ? 'ok' : 'empty',
632
+ ai_input_coverage: inputCoverage,
626
633
  };
627
634
  } catch (err) {
628
635
  const errMsg = String(err?.message || err);
@@ -677,6 +684,18 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
677
684
 
678
685
  /* ------------------------------- CLI ----------------------------------- */
679
686
 
687
+ /**
688
+ * Why a webhook URL flag's value is refused before any address is looked at, or null: it is not a URL, or its scheme is
689
+ * not one the flag takes. The value itself is never repeated — a Slack or Teams webhook URL carries its own credential.
690
+ * @param {string} flag @param {unknown} value @param {string[]} schemes e.g. ['https:'] or ['http:', 'https:']
691
+ */
692
+ function webhookSchemeRefusal(flag, value, schemes) {
693
+ const wanted = schemes.length === 1 ? `an ${schemes[0].replace(':', '')} URL` : `an ${schemes.map((x) => x.replace(':', '')).join(' or ')} URL`;
694
+ let protocol;
695
+ try { protocol = new URL(String(value)).protocol; } catch { return `${flag} must be ${wanted}; the value given is not a URL`; }
696
+ return schemes.includes(protocol) ? null : `${flag} must be ${wanted}; got ${protocol}`;
697
+ }
698
+
680
699
  export async function parseArgs(argv) {
681
700
  const args = { cmd: 'scan', host: undefined, plugins: 'all', insecureHttps: false };
682
701
  const a = argv.slice(2);
@@ -773,25 +792,40 @@ export async function parseArgs(argv) {
773
792
  args.watch = !!(get('watch'));
774
793
  const intervalVal = get('interval');
775
794
  args.intervalMinutes = (intervalVal && intervalVal !== true) ? Math.max(1, parseInt(intervalVal, 10) || 60) : 60;
795
+ // Every refusal from here on THROWS with code EFLAGVALUE (mapped to exit 2 at the entry point), never `process.exit`:
796
+ // parseArgs is exported and driven in-process by the suite, and an exit here kills the runner mid-file (the EFLAGSHAPE
797
+ // lesson — `--webhook-url` exited here until the next Community release after 1.0.0).
798
+ const flagValue = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
776
799
  const whUrl = get('webhook-url') || get('webhook_url') || null;
777
800
  if (whUrl && whUrl !== true) {
801
+ // Not a URL, or a scheme other than http(s), is named as that — never as an internal address.
802
+ const schemeRefusal = webhookSchemeRefusal('--webhook-url', whUrl, ['http:', 'https:']);
803
+ if (schemeRefusal) throw flagValue(schemeRefusal);
778
804
  if (!(await isSafeWebhookUrl(whUrl))) {
779
- console.error(`[ERROR] Webhook URL rejected: private/loopback/metadata addresses are not allowed.`);
780
- process.exit(2);
805
+ throw flagValue('Webhook URL rejected: private/loopback/metadata addresses are not allowed.');
806
+ }
807
+ // A plain-http alert URL is accepted (documented; whether to refuse it is a MAJOR-shaped decision, boarded), and the
808
+ // operator is told once what it costs. The URL itself is never printed — it carries its own credential.
809
+ if (new URL(whUrl).protocol === 'http:') {
810
+ console.warn('[WARN] --webhook-url is a plain http URL: findings leave in the clear over plaintext HTTP on every '
811
+ + 'watch alert sent to it — use an https URL');
781
812
  }
782
813
  args.webhookUrl = whUrl;
783
814
  } else {
784
815
  args.webhookUrl = null;
785
816
  }
786
- const alertSev = get('alert-severity') || get('alert_severity') || null;
787
- args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
817
+ // The accepted values are the alert path's own rank table (SEVERITY_RANK), in any case. An unrecognised one used to
818
+ // become `high` in silence — the rank fallback — while the alert echoed the typo; it is refused like every value flag.
819
+ const alertSev = get('alert-severity') ?? get('alert_severity');
820
+ const ALERT_SEVERITIES = Object.keys(SEVERITY_RANK);
821
+ if (alertSev === undefined || alertSev === null) args.alertSeverity = 'high';
822
+ else if (alertSev === true || !ALERT_SEVERITIES.includes(String(alertSev).toLowerCase())) {
823
+ throw flagValue(`--alert-severity must be one of ${ALERT_SEVERITIES.join(' | ')}`);
824
+ } else args.alertSeverity = String(alertSev).toLowerCase();
788
825
  // R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
789
826
  args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
790
827
 
791
828
  // ── EE 2.0.0 (cargo 3, D3/D5): the drift notification and the watch alert's payload mode ─────────────────────────
792
- // Every refusal THROWS with code EFLAGVALUE (mapped to exit 2 at the entry point), never `process.exit`: parseArgs is
793
- // exported and driven in-process by the suite, and an exit here kills the runner mid-file (the EFLAGSHAPE lesson).
794
- const flagValue = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
795
829
  const oneOf = (flag, allowed, dflt) => {
796
830
  const v = get(flag);
797
831
  if (v === undefined) return dflt;
@@ -816,10 +850,12 @@ export async function parseArgs(argv) {
816
850
  // https ONLY (the architect seat's read, F3): the URL is a credential and the body is scan content, so neither
817
851
  // crosses the network in the clear. --webhook-url is unchanged.
818
852
  let notifyProtocol = null;
819
- try { notifyProtocol = new URL(notifyUrl).protocol; } catch { /* not a URL — the SSRF check below refuses it */ }
853
+ try { notifyProtocol = new URL(notifyUrl).protocol; } catch { /* not a URL — named just below */ }
820
854
  if (notifyProtocol === 'http:') {
821
855
  throw flagValue('--notify-webhook must be an https URL: it carries its own credential, and the drift it sends is scan content');
822
856
  }
857
+ const notifySchemeRefusal = webhookSchemeRefusal('--notify-webhook', notifyUrl, ['https:']);
858
+ if (notifySchemeRefusal) throw flagValue(notifySchemeRefusal);
823
859
  // The SAME boundary check --webhook-url gets: public destinations only, DNS-resolved.
824
860
  if (!(await isSafeWebhookUrl(notifyUrl))) {
825
861
  throw flagValue('--notify-webhook rejected: private/loopback/metadata addresses are not allowed');
@@ -1013,26 +1049,37 @@ const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
1013
1049
  * PINNED SINCE EE 2.0.0 (R1b — the operator's ruling "CLI pins too"): it RETURNS the answer set it resolved, and
1014
1050
  * scanSingleHost runs the plugins inside `runPinned` with it, so the scan connects to the addresses this check looked at.
1015
1051
  * NSA_ALLOW_ALL_HOSTS relaxes WHICH answers are admitted, never whether one may change between the check and the connect:
1016
- * under it the name is resolved WITHOUT a refusal and still pinned (an unresolvable name scans unpinned, as before).
1052
+ * under it the name is resolved WITHOUT a refusal and still pinned; a name that does not resolve is refused
1053
+ * (ETARGETUNRESOLVED) under it as without it — until the Community release after 1.0.0 it scanned unpinned.
1017
1054
  * @param {string} host
1018
1055
  * @param {Record<string,string|undefined>} [env]
1019
1056
  * @returns {Promise<Array<{ address: string, family: 4|6 }> | null>} the answers to pin; null for a literal or a sentinel
1020
1057
  */
1021
1058
  export async function assertScanTargetAllowed(host, env = process.env) {
1059
+ // A refusal is an operator error, coded so the entry point names it and exits 2 rather than printing a stack.
1060
+ const refuse = (msg) => Object.assign(new Error(msg), { code: 'ETARGETREFUSED' });
1061
+ // A name that does not resolve was not refused by the guard — it cannot be scanned at all, because a scan connects only
1062
+ // to the addresses its check resolved. Its own code and its own sentence, with or without NSA_ALLOW_ALL_HOSTS (which
1063
+ // admits every address range, never an unresolved name). MCP refuses it the same way.
1064
+ const unresolved = (err) => Object.assign(new Error(`Host could not be resolved: ${host} (${err?.code ?? 'no answer'}) — `
1065
+ + 'a scan connects only to the addresses its host check resolved, so a target that does not resolve is not scanned'),
1066
+ { code: 'ETARGETUNRESOLVED' });
1067
+ const BLOCKED_ANSWER = 'Host resolves to blocked IP range';
1022
1068
  const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
1023
1069
  if (isCloudSentinel) return null;
1024
1070
  if (allowAllHosts(env)) {
1025
1071
  if (canonicalIp(host)) return null;
1026
- try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch { return null; }
1072
+ try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch (err) { throw unresolved(err); }
1027
1073
  }
1028
1074
  if (isBlockedIp(host)) {
1029
- throw new Error(`Scanning blocked address range is not allowed: ${host}`);
1075
+ throw refuse(`Scanning blocked address range is not allowed: ${host}`);
1030
1076
  }
1031
1077
  if (!canonicalIp(host)) {
1032
1078
  try {
1033
1079
  return await resolveAndValidateAll(host);
1034
1080
  } catch (err) {
1035
- throw new Error(`Host rejected by SSRF guard: ${err.message}`);
1081
+ if (err?.message === BLOCKED_ANSWER) throw refuse(`Host rejected by SSRF guard: ${err.message}`);
1082
+ throw unresolved(err);
1036
1083
  }
1037
1084
  }
1038
1085
  return null;
@@ -1185,12 +1232,12 @@ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1185
1232
  conclusion.result.eeEnrichmentError = String(err?.message ?? err);
1186
1233
  }
1187
1234
 
1188
- const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error } = await maybeSendToOpenAI({
1235
+ const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error, ai_input_coverage = null } = await maybeSendToOpenAI({
1189
1236
  host, results, conclusion, promptMode, outDir, runId: opts?.runId ?? null, pluginStatus,
1190
1237
  });
1191
1238
  // BUG1 (fail-VISIBLE): a one-line end-of-scan AI status so a failed/aborted AI
1192
1239
  // conclusion is not silently swallowed under the per-stage logs.
1193
- console.log(aiSummaryLine({ host, ai_status, ai_error }));
1240
+ console.log(aiSummaryLine({ host, ai_status, ai_error, ai_input_coverage }));
1194
1241
 
1195
1242
  // --- Scan history: record & compare ---
1196
1243
  let scanDiff = null;
@@ -1266,6 +1313,7 @@ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1266
1313
  hostAppended = await appendHostWritten(runRecordRoot, opts.runId, {
1267
1314
  host, dir: path.basename(outDir),
1268
1315
  scopeScanned: hostScopeScanned,
1316
+ aiInputCoverage: ai_input_coverage, // B113a: per host, beside the scope
1269
1317
  });
1270
1318
  }
1271
1319
 
@@ -1850,7 +1898,7 @@ Scan options:
1850
1898
  one-shot --host X,X scans X twice.
1851
1899
  --interval <minutes> Watch interval (default 60)
1852
1900
  --webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
1853
- --alert-severity <sev> Min severity to alert on (default: high)
1901
+ --alert-severity <sev> Min severity to alert on: critical|high|medium|low|info (default: high)
1854
1902
  --alert-every-cycle Alert every host with a finding at or above --alert-severity
1855
1903
  on every --watch cycle, the first included (default: only
1856
1904
  hosts whose scan changed)
@@ -2414,7 +2462,14 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2414
2462
  console.log('');
2415
2463
  console.log(' Verify with: nsauditor-ai license --status');
2416
2464
  } else {
2417
- console.log('Usage: nsauditor-ai license --status | --capabilities | --plugins | install <KEY> | --reset [--purge]');
2465
+ const usage = 'Usage: nsauditor-ai license --status | --capabilities | --plugins | install <KEY> | --reset [--purge]';
2466
+ // B99: anything that reached here — no subcommand at all, a typo, or one a later release adds — is a call that
2467
+ // FAILED: the usage goes to stderr and the exit code is 2, as `feed` and `compliance` with no verb do, so a script
2468
+ // or an AI agent never reads it as a success. The explicit ask for the usage is `license --help`, which the global
2469
+ // help answers with exit 0.
2470
+ console.error(rawArgs.length <= 1 ? 'missing license subcommand' : `unknown license subcommand: ${rawArgs[1]}`);
2471
+ console.error(usage);
2472
+ process.exit(2);
2418
2473
  }
2419
2474
  process.exit(0);
2420
2475
  }
@@ -2863,11 +2918,19 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2863
2918
  const { readFile, stat } = await import('node:fs/promises');
2864
2919
  const { join: _join } = await import('node:path');
2865
2920
  const { homedir: _homedir } = await import('node:os');
2866
- const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2921
+ const { responseDigest, requestDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2867
2922
  const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
2868
2923
  const uuid = rawArgs[2];
2869
2924
  const respAt = rawArgs.indexOf('--response');
2870
2925
  const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
2926
+ // B63 (2.1.0): the request saved beside the response (<call_id>.request.json), checked against the digest the
2927
+ // server logged of that file's bytes. Optional; absent, every output below is what it was.
2928
+ const reqAt = rawArgs.indexOf('--request');
2929
+ const reqPath = reqAt > 0 ? rawArgs[reqAt + 1] : null;
2930
+ if (reqAt > 0 && !reqPath) {
2931
+ console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|-> --request <file>');
2932
+ process.exit(2);
2933
+ }
2871
2934
  if (!uuid || (respAt > 0 && !respPath)) {
2872
2935
  console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
2873
2936
  console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
@@ -2894,6 +2957,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2894
2957
  process.exit(2);
2895
2958
  }
2896
2959
  }
2960
+ let reqBytes = null;
2961
+ if (reqPath) {
2962
+ try { reqBytes = await readFile(reqPath); } catch (err) {
2963
+ console.error(`✗ Could not read the request from ${reqPath}: ${err.message}`);
2964
+ process.exit(2);
2965
+ }
2966
+ }
2897
2967
  let logExists = false;
2898
2968
  try { await stat(logPath); logExists = true; } catch { /* missing */ }
2899
2969
  if (!logExists) {
@@ -2928,28 +2998,48 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2928
2998
  })();
2929
2999
  const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
2930
3000
  const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
3001
+ // With --request, the request is checked after the response and the exit is the worse of the two: any refutation
3002
+ // is 1, otherwise anything indeterminate is 3. Without it, each exit below is exactly what it was.
3003
+ const finish = (code) => {
3004
+ if (reqBytes == null) process.exit(code);
3005
+ const recorded = entries.map((e) => e.request_digest).filter((d) => typeof d === 'string');
3006
+ let reqCode;
3007
+ if (recorded.length === 0) {
3008
+ console.log(`? The request is not bound: this server recorded no request digest for call_id ${uuid}.`);
3009
+ console.log(' The call was not saved with NSA_MCP_RESPONSE_DIR set, or it predates request binding.');
3010
+ reqCode = 3;
3011
+ } else if (recorded.includes(requestDigest(reqBytes))) {
3012
+ console.log(`✓ This request is the one the server recorded for that call — ${issuedLine}.`);
3013
+ reqCode = 0;
3014
+ } else {
3015
+ console.log(`✗ The request does not match what this server recorded for that call (${issuedLine}).`);
3016
+ console.log(' It was changed after the server wrote it, or it belongs to another call.');
3017
+ reqCode = 1;
3018
+ }
3019
+ process.exit(code === 1 || reqCode === 1 ? 1 : (code === 3 || reqCode === 3 ? 3 : 0));
3020
+ };
2931
3021
  if (digests.length === 0) {
2932
3022
  console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2933
3023
  console.log(' This call predates response binding: no digest of its response was recorded, so the text');
2934
3024
  console.log(' cannot be bound to it. The id alone does not verify a response.');
2935
- process.exit(3);
3025
+ finish(3);
2936
3026
  }
2937
3027
  if (responseText == null) {
2938
3028
  console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2939
3029
  console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
2940
3030
  console.log(' pasted under a fabricated one. Save the raw tool response and run:');
2941
3031
  console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
2942
- process.exit(3);
3032
+ finish(3);
2943
3033
  }
2944
3034
  if (digests.includes(responseDigest(bodyOf(responseText)))) {
2945
3035
  console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
2946
3036
  console.log(` log: ${logPath}`);
2947
- process.exit(0);
3037
+ finish(0);
2948
3038
  }
2949
3039
  console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
2950
3040
  console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
2951
3041
  console.log(' call — re-copy the RAW tool output and verify again.');
2952
- process.exit(1);
3042
+ finish(1);
2953
3043
  } else {
2954
3044
  console.log('Usage:');
2955
3045
  console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
@@ -2959,6 +3049,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2959
3049
  console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
2960
3050
  console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
2961
3051
  console.log(' nsauditor-ai mcp verify-call <uuid> --response <file|-> Check that a saved tool response is the text this server produced for that call');
3052
+ console.log(' [--request <file>] …and that a saved request (<call_id>.request.json) is the one it recorded');
2962
3053
  console.log('');
2963
3054
  console.log('Environment variables:');
2964
3055
  console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
@@ -3739,7 +3830,9 @@ export function pluginSelectionRefusal(plugins, pm) {
3739
3830
  * the suite (tests/cli_bin_entrypoint.test.mjs).
3740
3831
  */
3741
3832
  export function exitOnMainRejection(err, { error = console.error, exit = process.exit } = {}) {
3742
- if (err?.code === 'EFLAGSHAPE' || err?.code === 'EFLAGVALUE') {
3833
+ // A refused scan target (ETARGETREFUSED) is the same class: the operator asked for something the guard does not allow —
3834
+ // and so is one that does not resolve (ETARGETUNRESOLVED): nothing can be scanned at a name with no address.
3835
+ if (err?.code === 'EFLAGSHAPE' || err?.code === 'EFLAGVALUE' || err?.code === 'ETARGETREFUSED' || err?.code === 'ETARGETUNRESOLVED') {
3743
3836
  error(`[ERROR] ${err.message}`);
3744
3837
  return exit(2);
3745
3838
  }