nsauditor-ai 0.2.56 → 0.2.57
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 +41 -40
- package/cli.mjs +171 -147
- package/mcp_server.mjs +64 -37
- package/package.json +1 -1
- package/plugin_manager.mjs +49 -8
- package/plugins/040_tls_cert_auditor.mjs +176 -56
- package/plugins/050_tribe_health.mjs +13 -34
- package/plugins/060_dns_sec_auditor.mjs +24 -30
- package/plugins/dns_scanner.mjs +16 -8
- package/plugins/ftp_banner_check.mjs +21 -13
- package/plugins/host_up_check.mjs +31 -72
- package/plugins/http_probe.mjs +93 -25
- package/plugins/mcp_scanner.mjs +15 -2
- package/plugins/netbios_scanner.mjs +32 -13
- package/plugins/opensearch_scanner.mjs +4 -0
- package/plugins/port_scanner.mjs +29 -49
- package/plugins/result_concluder.mjs +122 -31
- package/plugins/snmp_scanner.mjs +20 -8
- package/plugins/syn_scanner.mjs +2 -1
- package/plugins/upnp_scanner.mjs +82 -13
- package/plugins/webapp_detector.mjs +82 -15
- package/utils/conclusion_utils.mjs +13 -1
- package/utils/cookie_redaction.mjs +50 -0
- package/utils/delta_reporter.mjs +70 -25
- package/utils/executive_report.mjs +34 -7
- package/utils/export_csv.mjs +14 -22
- package/utils/mcp_call_digest.mjs +31 -0
- package/utils/net_validation.mjs +107 -37
- package/utils/output_dir.mjs +4 -2
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_md.mjs +49 -125
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +173 -18
- package/utils/scan_delta_view.mjs +3 -3
- package/utils/scan_history.mjs +149 -0
- package/utils/scheduler.mjs +17 -5
- package/utils/service_flags.mjs +363 -0
- package/utils/watch_cycle.mjs +37 -0
- package/utils/webhook.mjs +2 -1
package/mcp_server.mjs
CHANGED
|
@@ -12,11 +12,12 @@ import { dirname, join } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { responseDigest, RECEIPT_MARKER } from './utils/mcp_call_digest.mjs';
|
|
15
16
|
import { appendFile, mkdir, chmod } from 'node:fs/promises';
|
|
16
17
|
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
-
import { resolveAndValidate } from './utils/net_validation.mjs';
|
|
20
|
+
import { resolveAndValidate, classifyAddress, canonicalIp, allowAllHosts } from './utils/net_validation.mjs';
|
|
20
21
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
22
|
import {
|
|
22
23
|
CallToolRequestSchema,
|
|
@@ -175,10 +176,11 @@ export function buildScanCloudRegionIntent(regions) {
|
|
|
175
176
|
// 1. Embedded in the response text (Claude cannot omit; it's the payload)
|
|
176
177
|
// 2. Persisted to ~/.nsauditor/mcp-calls.log (append-only, mode 0600)
|
|
177
178
|
//
|
|
178
|
-
// Customer verification:
|
|
179
|
-
// nsauditor-ai mcp verify-call <uuid>
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// Customer verification: save the response (footer included) and run
|
|
180
|
+
// nsauditor-ai mcp verify-call <uuid> --response <file>
|
|
181
|
+
// The id alone proves only that this server issued it once — an AI client that has seen an earlier response can paste
|
|
182
|
+
// a real id under a fabricated one. So the server also logs a digest of each response BODY (utils/mcp_call_digest.mjs),
|
|
183
|
+
// and verify-call recomputes it from the saved text (1.3.0 (s6)). See cli.mjs `verify-call`.
|
|
182
184
|
const MCP_CALL_LOG_PATH = join(homedir(), '.nsauditor', 'mcp-calls.log');
|
|
183
185
|
|
|
184
186
|
async function recordToolCall(toolName) {
|
|
@@ -200,11 +202,20 @@ async function recordToolCall(toolName) {
|
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
function appendCallSentinel(text, callId) {
|
|
205
|
+
// The digest of the BODY — the text the receipt is appended to — is what binds this response to its id. It is
|
|
206
|
+
// logged, never printed: a digest in the footer would replay together with the id. Best-effort like the id line: a
|
|
207
|
+
// failed write leaves the call verifiable only as "issued", never as a false ✓.
|
|
208
|
+
try {
|
|
209
|
+
_nodeFs.appendFileSync(MCP_CALL_LOG_PATH, JSON.stringify({ call_id: callId, digest: responseDigest(text) }) + '\n', { encoding: 'utf8' });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
process.stderr.write(`[nsauditor-mcp] call-log digest write failed: ${err.message}\n`);
|
|
212
|
+
}
|
|
203
213
|
return (
|
|
204
|
-
`${text}\n\n
|
|
214
|
+
`${text}\n\n${RECEIPT_MARKER}\n` +
|
|
205
215
|
`call_id: ${callId}\n` +
|
|
206
|
-
`
|
|
207
|
-
`
|
|
216
|
+
`To check that this exact response came from this server, save the whole response (footer included)\n` +
|
|
217
|
+
`to a file and run:\n` +
|
|
218
|
+
` nsauditor-ai mcp verify-call ${callId} --response <file>`
|
|
208
219
|
);
|
|
209
220
|
}
|
|
210
221
|
|
|
@@ -247,17 +258,20 @@ export const TOOLS = [
|
|
|
247
258
|
'Run a full plugin scan on a target host and return structured results: service detection, OS fingerprinting, and'
|
|
248
259
|
+ ' the flags the service checks leave on each service record — weak SSH algorithms, SNMP default community, weak '
|
|
249
260
|
+ 'TLS protocols / ciphers and a self-signed certificate, the MCP server checks, and anonymous FTP login and DNS '
|
|
250
|
-
+ 'zone transfer only when the server\'s environment enables
|
|
251
|
-
+ 'DNS_AXFR_DOMAIN — both off by default).
|
|
252
|
-
+ '
|
|
253
|
-
+ '
|
|
254
|
-
+ '
|
|
261
|
+
+ 'zone transfer only when the server\'s environment enables them (FTP_CHECK_ANON; DNS_CHECK_AXFR with '
|
|
262
|
+
+ 'DNS_AXFR_DOMAIN — both off by default). The records also carry the HTTP probe\'s methods (006, dangerousMethods; '
|
|
263
|
+
+ 'methodsTested false is not tested, never "none"), the NetBIOS/SMB null-session check (014, nullSessionAllowed; '
|
|
264
|
+
+ 'opt-in, SMB_NULL_SESSION) and the TLS-certificate (040, certAudit) and debug-endpoint (050, tribeHealth) audits. For a domain-name target the DNS-security audit (060, dnsSecurity) lands '
|
|
265
|
+
+ 'on a 53/udp record if the scan found one, else in the conclusion\'s evidence; an IP-address target is DECLINED '
|
|
266
|
+
+ '(manifest: skipped, with the reason; markdown: NOT TESTED), so DNS posture is unknown, not clean. Opt-in checks carry '
|
|
267
|
+
+ 'axfrTested / anonymousLoginTested / nullSessionTested; a null result is NOT TESTED, never "refused". Enterprise\'s '
|
|
268
|
+
+ 'zero-trust assessment (1023) reaches the conclusion only as one score line in its evidence, not its '
|
|
269
|
+
+ 'per-dimension findings. It does NOT look up '
|
|
255
270
|
+ 'CVEs and does NOT run the Enterprise analysis agents or exploit intelligence — so zero findings here is NOT a '
|
|
256
271
|
+ 'statement that the host has no known vulnerabilities. For CVEs, call get_vulnerabilities (Pro) with each '
|
|
257
272
|
+ 'service\'s cpe where it names a concrete version; a service whose cpe is null gets no lookup, so its CVE coverage'
|
|
258
|
-
+ ' is unknown, not clean.
|
|
259
|
-
+ '
|
|
260
|
-
+ 'EPSS store is configured.',
|
|
273
|
+
+ ' is unknown, not clean. The CLI scan (Enterprise package, Pro or Enterprise licence) runs the CVE mapper, the '
|
|
274
|
+
+ 'analysis agents and, with a KEV / EPSS store, exploit intelligence.',
|
|
261
275
|
inputSchema: {
|
|
262
276
|
type: 'object',
|
|
263
277
|
properties: {
|
|
@@ -392,38 +406,48 @@ export const TOOLS = [
|
|
|
392
406
|
|
|
393
407
|
/**
|
|
394
408
|
* Validate host to prevent SSRF via loopback, link-local, or cloud metadata.
|
|
395
|
-
*
|
|
409
|
+
*
|
|
410
|
+
* A literal is classified by VALUE in any spelling (net_validation.classifyAddress); a name is
|
|
411
|
+
* resolved and EVERY answer is classified — in both arms of NSA_ALLOW_ALL_HOSTS. Allow-all admits
|
|
412
|
+
* private ranges only: loopback, unspecified, link-local and metadata stay refused, by literal or
|
|
413
|
+
* by answer. A name that does not resolve is refused in both arms.
|
|
414
|
+
*
|
|
415
|
+
* ⚠️ LIMIT (stated, 1.3.0): this does NOT pin the resolved address. It returns the NAME, and each
|
|
416
|
+
* plugin resolves it again when it connects, so a name whose answer changes between this check
|
|
417
|
+
* and the scan (DNS rebinding) is not caught. Pinning is boarded for a later release; it is not in 1.3.0.
|
|
418
|
+
* Before 1.3.0 this comment said the resolution "defeats rebinding"; it never did.
|
|
396
419
|
* @param {string} host
|
|
397
420
|
* @returns {Promise<string>} normalised hostname
|
|
398
421
|
*/
|
|
399
422
|
export async function validateHost(host) {
|
|
400
423
|
const h = String(host).trim().toLowerCase();
|
|
401
424
|
if (!h) throw new Error('Empty host');
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
// and by the DNS resolveAndValidate() layer for all encoding forms.
|
|
425
|
+
const notAllowed = () => new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
|
|
426
|
+
// Cheap pre-check for the obvious spellings; the classifier below is the authority.
|
|
405
427
|
const _isAllDigits = /^\d+$/.test(h);
|
|
406
428
|
const _n = _isAllDigits && h.length <= 10 ? Number(h) : -1;
|
|
407
429
|
const isDecimalLoopback = _n >= 0x7F000000 && _n <= 0x7FFFFFFF;
|
|
408
430
|
if (isDecimalLoopback || /^(localhost|127\.|0\.|::1|0\.0\.0\.0|169\.254\.|fe80:|metadata\.google)/i.test(h)) {
|
|
409
|
-
throw
|
|
431
|
+
throw notAllowed();
|
|
410
432
|
}
|
|
411
433
|
|
|
412
434
|
// Cloud-sentinel hosts (aws/gcp/azure) are scoping tokens, NOT DNS names — they
|
|
413
435
|
// route to the cloud-scanner plugins (probe_service enforces the cloud-intent
|
|
414
436
|
// gate downstream; the CLI whitelists them the same way in scanSingleHost).
|
|
415
|
-
//
|
|
416
|
-
// (otherwise dns.lookup('aws') ENOTFOUND is misread as a blocked address).
|
|
437
|
+
// They are never resolved (dns.lookup('aws') would be ENOTFOUND).
|
|
417
438
|
if (isCloudSentinelHost(h)) return h;
|
|
418
439
|
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
440
|
+
// NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) admits PRIVATE ranges for local-network auditing.
|
|
441
|
+
const allowPrivate = allowAllHosts();
|
|
442
|
+
const cls = classifyAddress(h);
|
|
443
|
+
if (cls === 'always' || (cls === 'private' && !allowPrivate)) throw notAllowed();
|
|
444
|
+
if (canonicalIp(h)) return h; // a permitted literal — nothing to resolve
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
await resolveAndValidate(h, { allowPrivate });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
if (/blocked/.test(String(err && err.message))) throw notAllowed();
|
|
450
|
+
throw new Error(`Host could not be resolved via MCP: ${h}`);
|
|
427
451
|
}
|
|
428
452
|
return h;
|
|
429
453
|
}
|
|
@@ -469,6 +493,8 @@ export async function handleScanHost(args) {
|
|
|
469
493
|
host: output.host,
|
|
470
494
|
conclusion: output.conclusion,
|
|
471
495
|
toolVersion: TOOL_VERSION,
|
|
496
|
+
// Build 4 (Gate 3-A F-1): the manifest, so an audit that did not run — 060's decline of an IP address — is said.
|
|
497
|
+
manifest: output.manifest ?? [],
|
|
472
498
|
});
|
|
473
499
|
}
|
|
474
500
|
} catch { /* swallow — markdown is best-effort */ }
|
|
@@ -477,7 +503,9 @@ export async function handleScanHost(args) {
|
|
|
477
503
|
host: output.host,
|
|
478
504
|
conclusion: output.conclusion ?? null,
|
|
479
505
|
manifest: output.manifest ?? [],
|
|
480
|
-
|
|
506
|
+
// Plugins that RAN, from the manifest — not output.results, which holds one wrapped run per port plus
|
|
507
|
+
// error / timeout / gate-skip envelopes. Same count scan_cloud reports.
|
|
508
|
+
pluginsRan: (output.manifest || []).filter((m) => m.status === 'ran').length,
|
|
481
509
|
markdown,
|
|
482
510
|
};
|
|
483
511
|
}
|
|
@@ -670,7 +698,7 @@ export async function handleProbeService(args) {
|
|
|
670
698
|
const s = String(host).trim().toLowerCase();
|
|
671
699
|
throw new Error(
|
|
672
700
|
`Plugin ${plugin.id} (${plugin.name}) is a network plugin — it does not run against the ` +
|
|
673
|
-
`'${s}' cloud sentinel. Use a network host
|
|
701
|
+
`'${s}' cloud sentinel. Use a network host or IP address, or a cloud auditor with host: "${s}".`,
|
|
674
702
|
);
|
|
675
703
|
}
|
|
676
704
|
const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
|
|
@@ -875,10 +903,9 @@ export function createServer() {
|
|
|
875
903
|
};
|
|
876
904
|
}
|
|
877
905
|
|
|
878
|
-
// CE 0.1.36 (Thread L Phase 2): mint a per-call
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
// exists in the customer's local log file.
|
|
906
|
+
// CE 0.1.36 (Thread L Phase 2): mint a per-call UUID and log it BEFORE the Pro-gate, so even a denial shows the
|
|
907
|
+
// call hit the server. The id alone does NOT authenticate a response — a fabricated response can carry a real id
|
|
908
|
+
// copied from an earlier one — so the receipt also logs a digest of the body (appendCallSentinel, 1.3.0 (s6)).
|
|
882
909
|
const callId = await recordToolCall(name);
|
|
883
910
|
|
|
884
911
|
// Gate Pro-tier tools at the MCP dispatch layer
|
package/package.json
CHANGED
package/plugin_manager.mjs
CHANGED
|
@@ -274,6 +274,16 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
|
|
|
274
274
|
});
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// A UDP row opens its port only when it ANSWERED (1.3.0, B6-4c). The row's own `status` decides when it carries one (the
|
|
278
|
+
// port scanner's 'open' / 'no-response'); otherwise only POSITIVE text counts, never a negative that CONTAINS a positive
|
|
279
|
+
// phrase: "No UDP response" matched `udp response` and "No SNMP response for community …" matched `snmp response`, so a
|
|
280
|
+
// plugin gated on `udp_open` read 'ran' over a silent port.
|
|
281
|
+
const UDP_NEGATIVE = /^no\b|no response|\berror\b/;
|
|
282
|
+
function udpRowAnswered(d, info) {
|
|
283
|
+
if (d?.status != null) return String(d.status).toLowerCase() === "open";
|
|
284
|
+
return !UDP_NEGATIVE.test(info) && /udp response|snmp response|sysdescr|pdu/.test(info);
|
|
285
|
+
}
|
|
286
|
+
|
|
277
287
|
// Heuristics to update context from any plugin's result
|
|
278
288
|
function updateContextFromResult(mod, result, ctx) {
|
|
279
289
|
try {
|
|
@@ -336,9 +346,9 @@ function updateContextFromResult(mod, result, ctx) {
|
|
|
336
346
|
}
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
// UDP hints (rare)
|
|
349
|
+
// UDP hints (rare) — only a row that ANSWERED opens its port (B6-4c, `udpRowAnswered`).
|
|
340
350
|
if (proto === "udp" && Number.isFinite(port)) {
|
|
341
|
-
if (
|
|
351
|
+
if (udpRowAnswered(d, info)) {
|
|
342
352
|
ctx.udpOpen.add(port);
|
|
343
353
|
}
|
|
344
354
|
}
|
|
@@ -373,7 +383,8 @@ function updateContextFromResult(mod, result, ctx) {
|
|
|
373
383
|
}
|
|
374
384
|
if (id === "007" || name.includes("snmp")) {
|
|
375
385
|
const first = rows[0];
|
|
376
|
-
|
|
386
|
+
const firstInfo = safeLower(first?.probe_info || "");
|
|
387
|
+
if (udpRowAnswered(first, firstInfo) && /snmp response/.test(firstInfo) && Number.isFinite(first?.probe_port)) {
|
|
377
388
|
ctx.udpOpen.add(Number(first.probe_port));
|
|
378
389
|
}
|
|
379
390
|
}
|
|
@@ -390,7 +401,8 @@ function updateContextFromResult(mod, result, ctx) {
|
|
|
390
401
|
*/
|
|
391
402
|
export function describeSkipReason(mod, ctx) {
|
|
392
403
|
const req = mod?.requirements || {};
|
|
393
|
-
|
|
404
|
+
// No probe answered, which is no evidence either way — never "down" (1.3.0 lane 1 F).
|
|
405
|
+
if (req.host === 'up' && !ctx.hostUp) return 'no evidence the host is up';
|
|
394
406
|
if (req.host === 'down' && ctx.hostUp) return 'host is up (requires down)';
|
|
395
407
|
if (Array.isArray(req.tcp_open) && req.tcp_open.length) {
|
|
396
408
|
const missing = req.tcp_open.filter(p => !ctx.tcpOpen.has(p));
|
|
@@ -690,6 +702,30 @@ export class PluginManager {
|
|
|
690
702
|
return mergeResultObjects(plugin, filtered);
|
|
691
703
|
}
|
|
692
704
|
|
|
705
|
+
/**
|
|
706
|
+
* The adapter registry handed to the concluder, by plugin ID (1.3.0): every loaded plugin's `conclude` — named or on
|
|
707
|
+
* its default object, as discovery attaches it — with its authoritativePorts and cloudProvider. Ids are a
|
|
708
|
+
* correctness key here, so a built-in wins: CE over EE over a custom NSAUDITOR_PLUGIN_PATH plugin that reuses an id
|
|
709
|
+
* (which would otherwise capture that plugin's records).
|
|
710
|
+
*/
|
|
711
|
+
_concluderAdapters() {
|
|
712
|
+
const RANK = { ce: 0, ee: 1, custom: 2 };
|
|
713
|
+
const map = new Map();
|
|
714
|
+
for (const p of this.plugins || []) {
|
|
715
|
+
if (isConcluder(p) || typeof p?.conclude !== 'function') continue;
|
|
716
|
+
const id = String(p.id);
|
|
717
|
+
const rank = RANK[p._source] ?? 1;
|
|
718
|
+
const prev = map.get(id);
|
|
719
|
+
if (prev && prev.rank <= rank) {
|
|
720
|
+
if (prev.rank < rank) vlog(`plugin id ${id} from ${p._source} ignored for the concluder: a ${prev.source} plugin owns it`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
map.set(id, { rank, source: p._source ?? null, name: p.name ?? null, conclude: p.conclude.bind(p),
|
|
724
|
+
authoritativePorts: p.authoritativePorts ?? null, cloudProvider: p.cloudProvider ?? null });
|
|
725
|
+
}
|
|
726
|
+
return map;
|
|
727
|
+
}
|
|
728
|
+
|
|
693
729
|
async runConcluder(resultsArray) {
|
|
694
730
|
const concluder =
|
|
695
731
|
this.plugins.find((p) => p.id === "008") ||
|
|
@@ -702,16 +738,17 @@ export class PluginManager {
|
|
|
702
738
|
}
|
|
703
739
|
|
|
704
740
|
try {
|
|
741
|
+
const adapters = this._concluderAdapters();
|
|
705
742
|
// Support both signatures:
|
|
706
|
-
// 1) run(pluginResults)
|
|
707
|
-
// 2) run(host, port, { results })
|
|
743
|
+
// 1) run(pluginResults, { adapters })
|
|
744
|
+
// 2) run(host, port, { results, adapters })
|
|
708
745
|
let conclusion;
|
|
709
746
|
if (concluder.run.length >= 3) {
|
|
710
747
|
vlog("Running Result Concluder with plugin results (opts.results signature):", JSON.stringify(resultsArray, null, 2));
|
|
711
|
-
conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}) });
|
|
748
|
+
conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}), adapters });
|
|
712
749
|
} else {
|
|
713
750
|
vlog("Running Result Concluder with plugin results (single-arg signature):", JSON.stringify(resultsArray, null, 2));
|
|
714
|
-
conclusion = await concluder.run(resultsArray);
|
|
751
|
+
conclusion = await concluder.run(resultsArray, { adapters });
|
|
715
752
|
}
|
|
716
753
|
|
|
717
754
|
vlog("Result Concluder raw output:", JSON.stringify(conclusion, null, 2));
|
|
@@ -795,6 +832,9 @@ export class PluginManager {
|
|
|
795
832
|
reason: describeSkipReason(mod, ctx),
|
|
796
833
|
duration_ms: 0,
|
|
797
834
|
});
|
|
835
|
+
// Requested and NOT measured: the same three-state map the run path writes below, so a
|
|
836
|
+
// late consumer cannot read a gated-out upstream as one nobody requested (D26-3).
|
|
837
|
+
ctx.pluginRunStatus.set(String(mod.id || ''), 'skipped');
|
|
798
838
|
continue;
|
|
799
839
|
}
|
|
800
840
|
|
|
@@ -807,6 +847,7 @@ export class PluginManager {
|
|
|
807
847
|
reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`,
|
|
808
848
|
duration_ms: 0,
|
|
809
849
|
});
|
|
850
|
+
ctx.pluginRunStatus.set(String(mod.id || ''), 'skipped');
|
|
810
851
|
continue;
|
|
811
852
|
}
|
|
812
853
|
|