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 +42 -25
- package/cli.mjs +118 -25
- package/mcp_server.mjs +280 -38
- package/package.json +6 -5
- package/plugin_manager.mjs +50 -5
- package/plugins/060_dns_sec_auditor.mjs +376 -115
- package/plugins/arp_scanner.mjs +6 -3
- package/plugins/mcp_scanner.mjs +10 -1
- package/plugins/mdns_scanner.mjs +11 -5
- package/plugins/ping_checker.mjs +4 -3
- package/plugins/result_concluder.mjs +16 -2
- package/plugins/tls_scanner.mjs +13 -0
- package/plugins/upnp_scanner.mjs +8 -2
- package/plugins/webapp_detector.mjs +8 -2
- package/plugins/wsd_scanner.mjs +8 -2
- package/utils/ai_stage.mjs +24 -2
- package/utils/cloud_finding_summary.mjs +56 -6
- package/utils/delta_reporter.mjs +1 -1
- package/utils/finding_queue.mjs +9 -0
- package/utils/mcp_call_digest.mjs +15 -2
- package/utils/net_validation.mjs +2 -1
- package/utils/report_inputs.mjs +8 -0
- package/utils/report_md.mjs +18 -0
- package/utils/run_record.mjs +5 -1
- package/utils/scan_delta.mjs +72 -10
- package/utils/scan_history.mjs +24 -9
- package/utils/scan_pin.mjs +19 -1
- package/utils/service_flags.mjs +41 -6
- package/utils/webhook.mjs +11 -6
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A modular, AI-assisted network security audit platform that scans, understands,
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/nsauditor-ai)
|
|
8
8
|
[](LICENSE)
|
|
9
|
-
[](https://nodejs.org)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
10
|
[](#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.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
|
34
|
-
release
|
|
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
|
|
49
|
-
- **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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 —
|
|
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
|
|
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 {
|
|
1072
|
+
try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch (err) { throw unresolved(err); }
|
|
1027
1073
|
}
|
|
1028
1074
|
if (isBlockedIp(host)) {
|
|
1029
|
-
throw
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|