nsauditor-ai 1.0.0 → 1.1.0
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 +34 -22
- package/cli.mjs +97 -19
- package/mcp_server.mjs +250 -25
- package/package.json +7 -3
- 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/cloud_finding_summary.mjs +56 -6
- package/utils/delta_reporter.mjs +1 -1
- package/utils/mcp_call_digest.mjs +6 -0
- package/utils/net_validation.mjs +2 -1
- package/utils/report_inputs.mjs +8 -0
- package/utils/report_md.mjs +18 -0
- 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,22 @@ 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.0 + Enterprise 2.1.0 — *Node.js 24.22.1; scan_host stops at a time limit and names what it did not run; a saved response keeps its request beside it; a multi-entry cloud title says so; get_findings says what a scan holds*.**
|
|
21
|
+
⚠️ **The supported runtime floor rises to Node.js 24.22.1** — the October 2026 Node.js security release for the 24
|
|
22
|
+
line (`engines.node >=24.22.1`); npm only warns on an older runtime, so check `node --version`. Through the MCP server,
|
|
23
|
+
`scan_host` stops at a total time limit by default (`SCAN_HOST_WALL_MS`, 50 s; `0` turns it off) and names the plugins it
|
|
24
|
+
did not run; with `NSA_MCP_RESPONSE_DIR` set, each response is saved with its request beside it, and
|
|
25
|
+
`nsauditor-ai mcp verify-call` checks both; a CRITICAL, HIGH or evidence-gap title in `scan_cloud` says when its finding
|
|
26
|
+
holds more entries than it shows (` (+N more via get_findings)`); and a `get_findings` category filter that matches
|
|
27
|
+
nothing returns the categories the scan does hold.
|
|
28
28
|
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
29
|
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
30
|
and a scan that discovered ports with the Nmap plugin (024) alone records no port oracle, so an analysis agent's or the
|
|
31
31
|
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
32
|
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).
|
|
33
|
+
Plugin count UNCHANGED at 27 Community + 29 Enterprise; every coverage matrix UNCHANGED; Enterprise 2.1.0 pairs with this
|
|
34
|
+
release, and its peer floor stays at `nsauditor-ai >= 1.0.0`. The free last-vs-current webhook alerting delta is
|
|
35
|
+
untouched and stays free (see Continuous Monitoring below).
|
|
36
36
|
|
|
37
37
|
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
38
|
|
|
@@ -174,7 +174,7 @@ Results land in `./out/<host>_<timestamp>/` (two scans of one host whose plugin
|
|
|
174
174
|
| `scan_results.csv` | CSV — only with `--output-format csv` |
|
|
175
175
|
| `scan_report.md` | GitHub-flavored Markdown report — only with `--output-format md` (or `markdown`) |
|
|
176
176
|
|
|
177
|
-
> Requires Node.js 24 or newer (tested on Node 24).
|
|
177
|
+
> 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
178
|
|
|
179
179
|
---
|
|
180
180
|
|
|
@@ -200,7 +200,7 @@ found". A higher tier does not add tools; it unlocks the ones already in the lis
|
|
|
200
200
|
|
|
201
201
|
| Tool | Purpose |
|
|
202
202
|
|---|---|
|
|
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
|
|
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 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
204
|
| `list_plugins` | List available scanner plugins with their IDs, priorities and requirements |
|
|
205
205
|
| `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
206
|
|
|
@@ -208,7 +208,7 @@ found". A higher tier does not add tools; it unlocks the ones already in the lis
|
|
|
208
208
|
|
|
209
209
|
| Tool | Purpose |
|
|
210
210
|
|---|---|
|
|
211
|
-
| `probe_service` | Run one specific plugin against a single `host:port` |
|
|
211
|
+
| `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
212
|
| `get_vulnerabilities` | Look up known CVEs for a CPE (Common Platform Enumeration) string via the NVD API |
|
|
213
213
|
|
|
214
214
|
**Unlocked by Enterprise:**
|
|
@@ -234,7 +234,7 @@ found". A higher tier does not add tools; it unlocks the ones already in the lis
|
|
|
234
234
|
> has no MCP client in the path — there you can raise `PLUGIN_TIMEOUT_MS` (e.g. `90000`) freely. On the CLI a
|
|
235
235
|
> plugin that declares its own budget already gets it; `PLUGIN_TIMEOUT_CEILING_MS` caps those.
|
|
236
236
|
|
|
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
|
|
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 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
238
|
|
|
239
239
|
### Authentication (required)
|
|
240
240
|
|
|
@@ -322,12 +322,24 @@ The exact `NSA_MCP_AUTH_KEY` value to paste is printed by `nsauditor-ai mcp inst
|
|
|
322
322
|
or `on` turn it on. It admits private ranges only: loopback, link-local and cloud-metadata addresses stay refused over
|
|
323
323
|
MCP whether they are written as an address in any spelling or reached through what a host name resolves to — every
|
|
324
324
|
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
|
|
325
|
+
between the check and the scan (DNS rebinding) is not followed — the 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, 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.
|
|
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 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).
|
|
328
|
+
- `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
329
|
- `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
330
|
- `CLOUD_SCAN_CONCURRENCY` — max cloud plugins run at once by `scan_cloud` (default 20).
|
|
330
331
|
- `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.
|
|
332
|
+
- `NSA_MCP_RESPONSE_DIR` — off unless set. An **absolute** directory where the server saves each tool response,
|
|
333
|
+
exactly as it returned it, as `<call_id>.txt` (the directory is created `0700` if missing, each file `0600`, and an
|
|
334
|
+
existing file is never overwritten). It exists for `nsauditor-ai mcp verify-call <call_id> --response <file>`, which
|
|
335
|
+
needs the whole response: Claude Desktop shows long tool output truncated, and a truncated copy cannot be verified.
|
|
336
|
+
Beside each response the server saves its request as `<call_id>.request.json`: the tool's name and the call's own
|
|
337
|
+
arguments, verbatim (a `scan_host` host, `scan_cloud`'s providers and regions) — both files or neither, `0600`.
|
|
338
|
+
`verify-call <call_id> --response <file> --request <file>` checks both: the server logs a digest of each.
|
|
339
|
+
When the copies were saved, the response's receipt footer says so (it names the files, not the directory). The files
|
|
340
|
+
hold scan results and the targets you asked about — keep the directory private, and clear it yourself; nothing
|
|
341
|
+
removes them. It is a server setting, not a
|
|
342
|
+
scan target: set it in this `env` block (not in an `NSA_ENV_FILE` file), then fully quit and reopen Claude Desktop.
|
|
331
343
|
- `AI_PROVIDER` and API key — optional, enables AI-powered analysis of scan results
|
|
332
344
|
|
|
333
345
|
#### `NSA_ENV_FILE` — point the MCP server at an environment file
|
|
@@ -463,12 +475,12 @@ nsauditor-ai --version (or -v, or `version`)
|
|
|
463
475
|
| `--out <dir>` | Custom output directory — applies to the per-scan folder *and* to alternate-format files (SARIF/CSV/Markdown) | `out/` |
|
|
464
476
|
| `--parallel <n>` | Concurrent host scans | `1` |
|
|
465
477
|
| `--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 | — |
|
|
478
|
+
| `--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
479
|
| `--insecure-https` | Accept self-signed TLS certificates | `false` |
|
|
468
480
|
| `--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
481
|
| `--interval <min>` | Rescan interval in minutes (requires `--watch`) | `60` |
|
|
470
482
|
| `--webhook-url <url>` | Webhook URL for watch-mode alerts (see `--watch`) | — |
|
|
471
|
-
| `--alert-severity <sev>` | Minimum severity for webhook alerts | `high` |
|
|
483
|
+
| `--alert-severity <sev>` | Minimum severity for webhook alerts: `critical`, `high`, `medium`, `low` or `info`, in any case — any other value is refused | `high` |
|
|
472
484
|
| `--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
485
|
| `--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
486
|
| `--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 +956,7 @@ Commonly requested plugins: RDP, VNC, SMTP/POP3/IMAP, MySQL/PostgreSQL/MSSQL/Mon
|
|
|
944
956
|
|
|
945
957
|
For the full technical architecture, see [ARCHITECTURE.md](docs/architecture.md).
|
|
946
958
|
|
|
947
|
-
**Tech stack:** Node.js 24+ · ES Modules (.mjs) · OpenAI + Anthropic SDKs · Node.js built-in test runner · MCP stdio transport
|
|
959
|
+
**Tech stack:** Node.js 24.22.1+ · ES Modules (.mjs) · OpenAI + Anthropic SDKs · Node.js built-in test runner · MCP stdio transport
|
|
948
960
|
|
|
949
961
|
**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
962
|
|
package/cli.mjs
CHANGED
|
@@ -677,6 +677,18 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
677
677
|
|
|
678
678
|
/* ------------------------------- CLI ----------------------------------- */
|
|
679
679
|
|
|
680
|
+
/**
|
|
681
|
+
* 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
|
|
682
|
+
* not one the flag takes. The value itself is never repeated — a Slack or Teams webhook URL carries its own credential.
|
|
683
|
+
* @param {string} flag @param {unknown} value @param {string[]} schemes e.g. ['https:'] or ['http:', 'https:']
|
|
684
|
+
*/
|
|
685
|
+
function webhookSchemeRefusal(flag, value, schemes) {
|
|
686
|
+
const wanted = schemes.length === 1 ? `an ${schemes[0].replace(':', '')} URL` : `an ${schemes.map((x) => x.replace(':', '')).join(' or ')} URL`;
|
|
687
|
+
let protocol;
|
|
688
|
+
try { protocol = new URL(String(value)).protocol; } catch { return `${flag} must be ${wanted}; the value given is not a URL`; }
|
|
689
|
+
return schemes.includes(protocol) ? null : `${flag} must be ${wanted}; got ${protocol}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
680
692
|
export async function parseArgs(argv) {
|
|
681
693
|
const args = { cmd: 'scan', host: undefined, plugins: 'all', insecureHttps: false };
|
|
682
694
|
const a = argv.slice(2);
|
|
@@ -773,25 +785,40 @@ export async function parseArgs(argv) {
|
|
|
773
785
|
args.watch = !!(get('watch'));
|
|
774
786
|
const intervalVal = get('interval');
|
|
775
787
|
args.intervalMinutes = (intervalVal && intervalVal !== true) ? Math.max(1, parseInt(intervalVal, 10) || 60) : 60;
|
|
788
|
+
// Every refusal from here on THROWS with code EFLAGVALUE (mapped to exit 2 at the entry point), never `process.exit`:
|
|
789
|
+
// parseArgs is exported and driven in-process by the suite, and an exit here kills the runner mid-file (the EFLAGSHAPE
|
|
790
|
+
// lesson — `--webhook-url` exited here until the next Community release after 1.0.0).
|
|
791
|
+
const flagValue = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
|
|
776
792
|
const whUrl = get('webhook-url') || get('webhook_url') || null;
|
|
777
793
|
if (whUrl && whUrl !== true) {
|
|
794
|
+
// Not a URL, or a scheme other than http(s), is named as that — never as an internal address.
|
|
795
|
+
const schemeRefusal = webhookSchemeRefusal('--webhook-url', whUrl, ['http:', 'https:']);
|
|
796
|
+
if (schemeRefusal) throw flagValue(schemeRefusal);
|
|
778
797
|
if (!(await isSafeWebhookUrl(whUrl))) {
|
|
779
|
-
|
|
780
|
-
|
|
798
|
+
throw flagValue('Webhook URL rejected: private/loopback/metadata addresses are not allowed.');
|
|
799
|
+
}
|
|
800
|
+
// A plain-http alert URL is accepted (documented; whether to refuse it is a MAJOR-shaped decision, boarded), and the
|
|
801
|
+
// operator is told once what it costs. The URL itself is never printed — it carries its own credential.
|
|
802
|
+
if (new URL(whUrl).protocol === 'http:') {
|
|
803
|
+
console.warn('[WARN] --webhook-url is a plain http URL: findings leave in the clear over plaintext HTTP on every '
|
|
804
|
+
+ 'watch alert sent to it — use an https URL');
|
|
781
805
|
}
|
|
782
806
|
args.webhookUrl = whUrl;
|
|
783
807
|
} else {
|
|
784
808
|
args.webhookUrl = null;
|
|
785
809
|
}
|
|
786
|
-
|
|
787
|
-
|
|
810
|
+
// The accepted values are the alert path's own rank table (SEVERITY_RANK), in any case. An unrecognised one used to
|
|
811
|
+
// become `high` in silence — the rank fallback — while the alert echoed the typo; it is refused like every value flag.
|
|
812
|
+
const alertSev = get('alert-severity') ?? get('alert_severity');
|
|
813
|
+
const ALERT_SEVERITIES = Object.keys(SEVERITY_RANK);
|
|
814
|
+
if (alertSev === undefined || alertSev === null) args.alertSeverity = 'high';
|
|
815
|
+
else if (alertSev === true || !ALERT_SEVERITIES.includes(String(alertSev).toLowerCase())) {
|
|
816
|
+
throw flagValue(`--alert-severity must be one of ${ALERT_SEVERITIES.join(' | ')}`);
|
|
817
|
+
} else args.alertSeverity = String(alertSev).toLowerCase();
|
|
788
818
|
// R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
|
|
789
819
|
args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
|
|
790
820
|
|
|
791
821
|
// ── 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
822
|
const oneOf = (flag, allowed, dflt) => {
|
|
796
823
|
const v = get(flag);
|
|
797
824
|
if (v === undefined) return dflt;
|
|
@@ -816,10 +843,12 @@ export async function parseArgs(argv) {
|
|
|
816
843
|
// https ONLY (the architect seat's read, F3): the URL is a credential and the body is scan content, so neither
|
|
817
844
|
// crosses the network in the clear. --webhook-url is unchanged.
|
|
818
845
|
let notifyProtocol = null;
|
|
819
|
-
try { notifyProtocol = new URL(notifyUrl).protocol; } catch { /* not a URL —
|
|
846
|
+
try { notifyProtocol = new URL(notifyUrl).protocol; } catch { /* not a URL — named just below */ }
|
|
820
847
|
if (notifyProtocol === 'http:') {
|
|
821
848
|
throw flagValue('--notify-webhook must be an https URL: it carries its own credential, and the drift it sends is scan content');
|
|
822
849
|
}
|
|
850
|
+
const notifySchemeRefusal = webhookSchemeRefusal('--notify-webhook', notifyUrl, ['https:']);
|
|
851
|
+
if (notifySchemeRefusal) throw flagValue(notifySchemeRefusal);
|
|
823
852
|
// The SAME boundary check --webhook-url gets: public destinations only, DNS-resolved.
|
|
824
853
|
if (!(await isSafeWebhookUrl(notifyUrl))) {
|
|
825
854
|
throw flagValue('--notify-webhook rejected: private/loopback/metadata addresses are not allowed');
|
|
@@ -1013,26 +1042,37 @@ const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
|
|
|
1013
1042
|
* PINNED SINCE EE 2.0.0 (R1b — the operator's ruling "CLI pins too"): it RETURNS the answer set it resolved, and
|
|
1014
1043
|
* scanSingleHost runs the plugins inside `runPinned` with it, so the scan connects to the addresses this check looked at.
|
|
1015
1044
|
* 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
|
|
1045
|
+
* under it the name is resolved WITHOUT a refusal and still pinned; a name that does not resolve is refused
|
|
1046
|
+
* (ETARGETUNRESOLVED) under it as without it — until the Community release after 1.0.0 it scanned unpinned.
|
|
1017
1047
|
* @param {string} host
|
|
1018
1048
|
* @param {Record<string,string|undefined>} [env]
|
|
1019
1049
|
* @returns {Promise<Array<{ address: string, family: 4|6 }> | null>} the answers to pin; null for a literal or a sentinel
|
|
1020
1050
|
*/
|
|
1021
1051
|
export async function assertScanTargetAllowed(host, env = process.env) {
|
|
1052
|
+
// A refusal is an operator error, coded so the entry point names it and exits 2 rather than printing a stack.
|
|
1053
|
+
const refuse = (msg) => Object.assign(new Error(msg), { code: 'ETARGETREFUSED' });
|
|
1054
|
+
// A name that does not resolve was not refused by the guard — it cannot be scanned at all, because a scan connects only
|
|
1055
|
+
// to the addresses its check resolved. Its own code and its own sentence, with or without NSA_ALLOW_ALL_HOSTS (which
|
|
1056
|
+
// admits every address range, never an unresolved name). MCP refuses it the same way.
|
|
1057
|
+
const unresolved = (err) => Object.assign(new Error(`Host could not be resolved: ${host} (${err?.code ?? 'no answer'}) — `
|
|
1058
|
+
+ 'a scan connects only to the addresses its host check resolved, so a target that does not resolve is not scanned'),
|
|
1059
|
+
{ code: 'ETARGETUNRESOLVED' });
|
|
1060
|
+
const BLOCKED_ANSWER = 'Host resolves to blocked IP range';
|
|
1022
1061
|
const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
|
|
1023
1062
|
if (isCloudSentinel) return null;
|
|
1024
1063
|
if (allowAllHosts(env)) {
|
|
1025
1064
|
if (canonicalIp(host)) return null;
|
|
1026
|
-
try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch {
|
|
1065
|
+
try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch (err) { throw unresolved(err); }
|
|
1027
1066
|
}
|
|
1028
1067
|
if (isBlockedIp(host)) {
|
|
1029
|
-
throw
|
|
1068
|
+
throw refuse(`Scanning blocked address range is not allowed: ${host}`);
|
|
1030
1069
|
}
|
|
1031
1070
|
if (!canonicalIp(host)) {
|
|
1032
1071
|
try {
|
|
1033
1072
|
return await resolveAndValidateAll(host);
|
|
1034
1073
|
} catch (err) {
|
|
1035
|
-
throw
|
|
1074
|
+
if (err?.message === BLOCKED_ANSWER) throw refuse(`Host rejected by SSRF guard: ${err.message}`);
|
|
1075
|
+
throw unresolved(err);
|
|
1036
1076
|
}
|
|
1037
1077
|
}
|
|
1038
1078
|
return null;
|
|
@@ -1850,7 +1890,7 @@ Scan options:
|
|
|
1850
1890
|
one-shot --host X,X scans X twice.
|
|
1851
1891
|
--interval <minutes> Watch interval (default 60)
|
|
1852
1892
|
--webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
|
|
1853
|
-
--alert-severity <sev> Min severity to alert on (default: high)
|
|
1893
|
+
--alert-severity <sev> Min severity to alert on: critical|high|medium|low|info (default: high)
|
|
1854
1894
|
--alert-every-cycle Alert every host with a finding at or above --alert-severity
|
|
1855
1895
|
on every --watch cycle, the first included (default: only
|
|
1856
1896
|
hosts whose scan changed)
|
|
@@ -2863,11 +2903,19 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2863
2903
|
const { readFile, stat } = await import('node:fs/promises');
|
|
2864
2904
|
const { join: _join } = await import('node:path');
|
|
2865
2905
|
const { homedir: _homedir } = await import('node:os');
|
|
2866
|
-
const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
|
|
2906
|
+
const { responseDigest, requestDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
|
|
2867
2907
|
const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
|
|
2868
2908
|
const uuid = rawArgs[2];
|
|
2869
2909
|
const respAt = rawArgs.indexOf('--response');
|
|
2870
2910
|
const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
|
|
2911
|
+
// B63 (2.1.0): the request saved beside the response (<call_id>.request.json), checked against the digest the
|
|
2912
|
+
// server logged of that file's bytes. Optional; absent, every output below is what it was.
|
|
2913
|
+
const reqAt = rawArgs.indexOf('--request');
|
|
2914
|
+
const reqPath = reqAt > 0 ? rawArgs[reqAt + 1] : null;
|
|
2915
|
+
if (reqAt > 0 && !reqPath) {
|
|
2916
|
+
console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|-> --request <file>');
|
|
2917
|
+
process.exit(2);
|
|
2918
|
+
}
|
|
2871
2919
|
if (!uuid || (respAt > 0 && !respPath)) {
|
|
2872
2920
|
console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
|
|
2873
2921
|
console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
|
|
@@ -2894,6 +2942,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2894
2942
|
process.exit(2);
|
|
2895
2943
|
}
|
|
2896
2944
|
}
|
|
2945
|
+
let reqBytes = null;
|
|
2946
|
+
if (reqPath) {
|
|
2947
|
+
try { reqBytes = await readFile(reqPath); } catch (err) {
|
|
2948
|
+
console.error(`✗ Could not read the request from ${reqPath}: ${err.message}`);
|
|
2949
|
+
process.exit(2);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2897
2952
|
let logExists = false;
|
|
2898
2953
|
try { await stat(logPath); logExists = true; } catch { /* missing */ }
|
|
2899
2954
|
if (!logExists) {
|
|
@@ -2928,28 +2983,48 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2928
2983
|
})();
|
|
2929
2984
|
const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
|
|
2930
2985
|
const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
|
|
2986
|
+
// With --request, the request is checked after the response and the exit is the worse of the two: any refutation
|
|
2987
|
+
// is 1, otherwise anything indeterminate is 3. Without it, each exit below is exactly what it was.
|
|
2988
|
+
const finish = (code) => {
|
|
2989
|
+
if (reqBytes == null) process.exit(code);
|
|
2990
|
+
const recorded = entries.map((e) => e.request_digest).filter((d) => typeof d === 'string');
|
|
2991
|
+
let reqCode;
|
|
2992
|
+
if (recorded.length === 0) {
|
|
2993
|
+
console.log(`? The request is not bound: this server recorded no request digest for call_id ${uuid}.`);
|
|
2994
|
+
console.log(' The call was not saved with NSA_MCP_RESPONSE_DIR set, or it predates request binding.');
|
|
2995
|
+
reqCode = 3;
|
|
2996
|
+
} else if (recorded.includes(requestDigest(reqBytes))) {
|
|
2997
|
+
console.log(`✓ This request is the one the server recorded for that call — ${issuedLine}.`);
|
|
2998
|
+
reqCode = 0;
|
|
2999
|
+
} else {
|
|
3000
|
+
console.log(`✗ The request does not match what this server recorded for that call (${issuedLine}).`);
|
|
3001
|
+
console.log(' It was changed after the server wrote it, or it belongs to another call.');
|
|
3002
|
+
reqCode = 1;
|
|
3003
|
+
}
|
|
3004
|
+
process.exit(code === 1 || reqCode === 1 ? 1 : (code === 3 || reqCode === 3 ? 3 : 0));
|
|
3005
|
+
};
|
|
2931
3006
|
if (digests.length === 0) {
|
|
2932
3007
|
console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
|
|
2933
3008
|
console.log(' This call predates response binding: no digest of its response was recorded, so the text');
|
|
2934
3009
|
console.log(' cannot be bound to it. The id alone does not verify a response.');
|
|
2935
|
-
|
|
3010
|
+
finish(3);
|
|
2936
3011
|
}
|
|
2937
3012
|
if (responseText == null) {
|
|
2938
3013
|
console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
|
|
2939
3014
|
console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
|
|
2940
3015
|
console.log(' pasted under a fabricated one. Save the raw tool response and run:');
|
|
2941
3016
|
console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
|
|
2942
|
-
|
|
3017
|
+
finish(3);
|
|
2943
3018
|
}
|
|
2944
3019
|
if (digests.includes(responseDigest(bodyOf(responseText)))) {
|
|
2945
3020
|
console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
|
|
2946
3021
|
console.log(` log: ${logPath}`);
|
|
2947
|
-
|
|
3022
|
+
finish(0);
|
|
2948
3023
|
}
|
|
2949
3024
|
console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
|
|
2950
3025
|
console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
|
|
2951
3026
|
console.log(' call — re-copy the RAW tool output and verify again.');
|
|
2952
|
-
|
|
3027
|
+
finish(1);
|
|
2953
3028
|
} else {
|
|
2954
3029
|
console.log('Usage:');
|
|
2955
3030
|
console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
|
|
@@ -2959,6 +3034,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2959
3034
|
console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
|
|
2960
3035
|
console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
|
|
2961
3036
|
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');
|
|
3037
|
+
console.log(' [--request <file>] …and that a saved request (<call_id>.request.json) is the one it recorded');
|
|
2962
3038
|
console.log('');
|
|
2963
3039
|
console.log('Environment variables:');
|
|
2964
3040
|
console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
|
|
@@ -3739,7 +3815,9 @@ export function pluginSelectionRefusal(plugins, pm) {
|
|
|
3739
3815
|
* the suite (tests/cli_bin_entrypoint.test.mjs).
|
|
3740
3816
|
*/
|
|
3741
3817
|
export function exitOnMainRejection(err, { error = console.error, exit = process.exit } = {}) {
|
|
3742
|
-
|
|
3818
|
+
// A refused scan target (ETARGETREFUSED) is the same class: the operator asked for something the guard does not allow —
|
|
3819
|
+
// and so is one that does not resolve (ETARGETUNRESOLVED): nothing can be scanned at a name with no address.
|
|
3820
|
+
if (err?.code === 'EFLAGSHAPE' || err?.code === 'EFLAGVALUE' || err?.code === 'ETARGETREFUSED' || err?.code === 'ETARGETUNRESOLVED') {
|
|
3743
3821
|
error(`[ERROR] ${err.message}`);
|
|
3744
3822
|
return exit(2);
|
|
3745
3823
|
}
|