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/cli.mjs
CHANGED
|
@@ -17,15 +17,17 @@ import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
|
|
|
17
17
|
import { buildSarifLog } from './utils/sarif.mjs';
|
|
18
18
|
import { buildCsv } from './utils/export_csv.mjs';
|
|
19
19
|
import { buildMarkdownReport } from './utils/report_md.mjs';
|
|
20
|
-
import {
|
|
20
|
+
import { conclusionFindings, maxRank, severityRank as flagSeverityRank, OPEN_SERVICE_SEVERITY } from './utils/service_flags.mjs';
|
|
21
|
+
import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE, historyServiceEntry, historyHostEntry } from './utils/scan_history.mjs';
|
|
21
22
|
import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
|
|
22
23
|
import { getTierFromEnv, loadLicense } from './utils/license.mjs';
|
|
23
24
|
import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } from './utils/capabilities.mjs';
|
|
24
25
|
import { createScheduler } from './utils/scheduler.mjs';
|
|
25
|
-
import {
|
|
26
|
+
import { watchCycle } from './utils/watch_cycle.mjs';
|
|
27
|
+
import { redactSetCookieLines } from './utils/cookie_redaction.mjs';
|
|
26
28
|
import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
|
|
27
29
|
import { scrubByKey } from './utils/redact.mjs';
|
|
28
|
-
import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
|
|
30
|
+
import { isBlockedIp, resolveAndValidate, allowAllHosts, canonicalIp } from './utils/net_validation.mjs';
|
|
29
31
|
import { getAllTechniques } from './utils/attack_map.mjs';
|
|
30
32
|
import { TOOL_VERSION } from './utils/tool_version.mjs';
|
|
31
33
|
import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
|
|
@@ -105,6 +107,9 @@ export function redactSensitiveForAI(input, targetHost) {
|
|
|
105
107
|
s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
|
|
106
108
|
// The shared rule; this call site's POLICY keeps private addresses and masks public ones.
|
|
107
109
|
s = scrubIPv4KeepingProductIds(s, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
|
|
110
|
+
// A scanned service's Set-Cookie VALUE is its session token: redacted here whatever a producer kept (1.3.0, both
|
|
111
|
+
// seams ruled — the webapp detector still reflects its headers into the result).
|
|
112
|
+
s = redactSetCookieLines(s);
|
|
108
113
|
return s;
|
|
109
114
|
};
|
|
110
115
|
|
|
@@ -718,6 +723,8 @@ export async function parseArgs(argv) {
|
|
|
718
723
|
}
|
|
719
724
|
const alertSev = get('alert-severity') || get('alert_severity') || null;
|
|
720
725
|
args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
|
|
726
|
+
// R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
|
|
727
|
+
args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
|
|
721
728
|
|
|
722
729
|
// Compliance: framework selector + scope file. Forwarded to EE's
|
|
723
730
|
// runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
|
|
@@ -879,25 +886,37 @@ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
|
|
|
879
886
|
// rather than per scanSingleHost() call (reviewer L1 fold).
|
|
880
887
|
const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
|
|
881
888
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
889
|
+
/**
|
|
890
|
+
* The CLI's scan-target SSRF guard (exported so tests drive the REAL code — 1.3.0 lane 1 E).
|
|
891
|
+
* Cloud sentinels (aws/gcp/azure) skip it. NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) lifts the WHOLE
|
|
892
|
+
* guard on the CLI, loopback and metadata included — documented, and the local-network smoke tests
|
|
893
|
+
* scan loopback that way. Otherwise a literal is classified by value in any spelling, and anything
|
|
894
|
+
* that is not a literal is resolved with EVERY answer checked. Before 1.3.0 a digits-and-dots string
|
|
895
|
+
* skipped resolution (/^[\\d.:[\\]]+$/), so '6425673729' — which the OS reads as 127.0.0.1 — was
|
|
896
|
+
* scanned unchecked.
|
|
897
|
+
* @param {string} host
|
|
898
|
+
* @param {Record<string,string|undefined>} [env]
|
|
899
|
+
*/
|
|
900
|
+
export async function assertScanTargetAllowed(host, env = process.env) {
|
|
886
901
|
const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
|
|
887
|
-
|
|
888
|
-
if (
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
} catch (err) {
|
|
897
|
-
throw new Error(`Host rejected by SSRF guard: ${err.message}`);
|
|
898
|
-
}
|
|
902
|
+
if (isCloudSentinel || allowAllHosts(env)) return;
|
|
903
|
+
if (isBlockedIp(host)) {
|
|
904
|
+
throw new Error(`Scanning blocked address range is not allowed: ${host}`);
|
|
905
|
+
}
|
|
906
|
+
if (!canonicalIp(host)) {
|
|
907
|
+
try {
|
|
908
|
+
await resolveAndValidate(host);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
throw new Error(`Host rejected by SSRF guard: ${err.message}`);
|
|
899
911
|
}
|
|
900
912
|
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
916
|
+
// SSRF guard — block loopback, private ranges, cloud metadata endpoints.
|
|
917
|
+
// Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
|
|
918
|
+
// Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
|
|
919
|
+
await assertScanTargetAllowed(host);
|
|
901
920
|
|
|
902
921
|
// `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
|
|
903
922
|
// Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
|
|
@@ -1034,6 +1053,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1034
1053
|
|
|
1035
1054
|
// --- Scan history: record & compare ---
|
|
1036
1055
|
let scanDiff = null;
|
|
1056
|
+
// The per-host summary [ScanHistory] records — also what a --watch cycle compares (1.3.0 items 4 + 11). Null when the
|
|
1057
|
+
// history step failed before building it; the watch delta reads null as NOT COMPARED, never as an empty scan.
|
|
1058
|
+
let scanSummary = null;
|
|
1037
1059
|
// Whether THIS host actually landed in the run record's `hostsWritten` — distinct from
|
|
1038
1060
|
// whether the scan itself succeeded. `appendHostWritten` can fail independently (a
|
|
1039
1061
|
// malformed argument, a disk fault) while the rest of the scan completes cleanly, and the
|
|
@@ -1107,15 +1129,8 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1107
1129
|
}
|
|
1108
1130
|
|
|
1109
1131
|
const services = conclusion?.result?.services ?? [];
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
if (svc.axfrAllowed === true) n++;
|
|
1113
|
-
if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
|
|
1114
|
-
if (Array.isArray(svc.dangerousMethods)) n += svc.dangerousMethods.length;
|
|
1115
|
-
const cves = svc.cves || svc.cve || [];
|
|
1116
|
-
if (Array.isArray(cves)) n += cves.length;
|
|
1117
|
-
return n;
|
|
1118
|
-
}, 0);
|
|
1132
|
+
// The legacy fallback's service share, graded by the shared service-flag table (1.3.0 (s1)) like every other reader.
|
|
1133
|
+
const serviceFindingsCount = conclusionFindings(conclusion, host).length;
|
|
1119
1134
|
// review fold R-1: cloud plugins emit findings on `results[].result.findings`,
|
|
1120
1135
|
// NOT as service-level attrs — so a cloud (--host aws) scan recorded
|
|
1121
1136
|
// findingsCount:0 in scan_history over a 201-finding scan (a false-clean
|
|
@@ -1155,7 +1170,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1155
1170
|
+ 'legacy sum and marking the line as un-based, so no comparison is made across it.');
|
|
1156
1171
|
}
|
|
1157
1172
|
|
|
1158
|
-
|
|
1173
|
+
scanSummary = {
|
|
1159
1174
|
timestamp: new Date().toISOString(),
|
|
1160
1175
|
host,
|
|
1161
1176
|
servicesCount: services.length,
|
|
@@ -1171,10 +1186,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1171
1186
|
// unreachable in production while its unit tests stay green.
|
|
1172
1187
|
tier: getTierFromEnv(),
|
|
1173
1188
|
cloudFindingsCount,
|
|
1174
|
-
services: services.map(
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
})),
|
|
1189
|
+
services: services.map(historyServiceEntry),
|
|
1190
|
+
// 1.3.0 (s1) B: the basis stamp and the host-level service checks (a domain's DNS posture, wherever it landed).
|
|
1191
|
+
...historyHostEntry(conclusion),
|
|
1178
1192
|
};
|
|
1179
1193
|
|
|
1180
1194
|
// Retrieve previous scan for this host before recording the new one
|
|
@@ -1201,6 +1215,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1201
1215
|
host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
|
|
1202
1216
|
// Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
|
|
1203
1217
|
hostAppended, kevDataAsOf, epssDataAsOf, nvdCache,
|
|
1218
|
+
// Additive (1.3.0 items 4 + 11): what a --watch cycle compares, host by host.
|
|
1219
|
+
scanSummary,
|
|
1220
|
+
// Additive (1.3.0 build 4, Gate 3-A F-1): the plugin manifest, so the Markdown report says an audit that did not run
|
|
1221
|
+
// — 060's decline of an IP address — the same way scan_host's does. The run record already carries it.
|
|
1222
|
+
pluginStatus,
|
|
1204
1223
|
};
|
|
1205
1224
|
}
|
|
1206
1225
|
|
|
@@ -1241,13 +1260,6 @@ function aggregateStoreLoad(writtenOutputs, field, label) {
|
|
|
1241
1260
|
|
|
1242
1261
|
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
1243
1262
|
|
|
1244
|
-
/**
|
|
1245
|
-
* Determine the maximum severity level present in a conclusion's services.
|
|
1246
|
-
* Checks security findings (anonymousLogin, axfrAllowed, weakAlgorithms,
|
|
1247
|
-
* dangerousMethods, CVEs) as well as open service status.
|
|
1248
|
-
* @param {object} conclusion
|
|
1249
|
-
* @returns {number} highest severity rank found (0-4)
|
|
1250
|
-
*/
|
|
1251
1263
|
async function readSecretFromStdin(keyName) {
|
|
1252
1264
|
if (!process.stdin.isTTY) {
|
|
1253
1265
|
// Piped input
|
|
@@ -1269,33 +1281,18 @@ async function readSecretFromStdin(keyName) {
|
|
|
1269
1281
|
});
|
|
1270
1282
|
}
|
|
1271
1283
|
|
|
1284
|
+
/**
|
|
1285
|
+
* The highest severity rank (0-4) among the findings a conclusion carries — the `--fail-on` gate.
|
|
1286
|
+
* @param {object} conclusion
|
|
1287
|
+
* @returns {number} highest severity rank found (0-4); any concluded scan is at least info (0)
|
|
1288
|
+
*/
|
|
1272
1289
|
export function maxSeverityInConclusion(conclusion) {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
if (svc.axfrAllowed === true) max = Math.max(max, SEVERITY_RANK.critical);
|
|
1280
|
-
|
|
1281
|
-
// weakAlgorithms or dangerousMethods → Medium
|
|
1282
|
-
if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
|
|
1283
|
-
if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
|
|
1284
|
-
|
|
1285
|
-
// CVEs
|
|
1286
|
-
const cves = svc.cves || svc.cve || [];
|
|
1287
|
-
if (Array.isArray(cves)) {
|
|
1288
|
-
for (const cve of cves) {
|
|
1289
|
-
const sev = typeof cve === 'string' ? 'high' : String(cve?.severity || 'high').toLowerCase();
|
|
1290
|
-
max = Math.max(max, SEVERITY_RANK[sev] ?? SEVERITY_RANK.high);
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
// Open service → Info (baseline)
|
|
1295
|
-
if (svc.status === 'open') max = Math.max(max, SEVERITY_RANK.info);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
return max;
|
|
1290
|
+
// Every grade comes from the shared service-flag table (utils/service_flags.mjs, 1.3.0 (s1)) — the gate reads what the
|
|
1291
|
+
// Markdown report, SARIF and the CSV read, including a payload that landed in the conclusion's evidence. An open service
|
|
1292
|
+
// is graded OPEN_SERVICE_SEVERITY, the grade SARIF gives it ((s2)); any concluded scan is at least info.
|
|
1293
|
+
const open = (conclusion?.result?.services || []).some((s) => s.status === 'open');
|
|
1294
|
+
return Math.max(SEVERITY_RANK.info, open ? flagSeverityRank(OPEN_SERVICE_SEVERITY) : -1,
|
|
1295
|
+
maxRank(conclusionFindings(conclusion, '')));
|
|
1299
1296
|
}
|
|
1300
1297
|
|
|
1301
1298
|
/**
|
|
@@ -1637,7 +1634,7 @@ export async function runReport(args, caps) {
|
|
|
1637
1634
|
|
|
1638
1635
|
export async function main(testHooks = {}) {
|
|
1639
1636
|
const args = await parseArgs(process.argv);
|
|
1640
|
-
const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
|
|
1637
|
+
const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, alertEveryCycle, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
|
|
1641
1638
|
|
|
1642
1639
|
// Version: handled before license verification so it works without a key.
|
|
1643
1640
|
// CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
|
|
@@ -1681,27 +1678,41 @@ Scan options:
|
|
|
1681
1678
|
a range such as 1-1000 is not parsed and adds nothing)
|
|
1682
1679
|
--out <dir> Output directory for scan artifacts
|
|
1683
1680
|
--parallel <n> Parallel host concurrency (default 1)
|
|
1684
|
-
--fail-on <severity> Exit 1 if a
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1681
|
+
--fail-on <severity> Exit 1 if a finding ≥ severity — the findings the md /
|
|
1682
|
+
SARIF / CSV reports count, graded by one shared table:
|
|
1683
|
+
SNMP default community, weak SSH / TLS, MCP checks, SMB
|
|
1684
|
+
null session, self-signed / expired certificates, the
|
|
1685
|
+
040 / 050 / 060 audits (060: the domain's SPF / DMARC /
|
|
1686
|
+
NS), and Dangerous HTTP methods (medium) only where an
|
|
1687
|
+
Allow header was read. Anonymous FTP / zone transfer /
|
|
1688
|
+
null session only with FTP_CHECK_ANON / DNS_CHECK_AXFR /
|
|
1689
|
+
SMB_NULL_SESSION set; not tested never trips it; any
|
|
1690
|
+
concluded scan is info. Not CVEs or agent findings —
|
|
1691
|
+
exit 0 is not a clean host. Exit 2: unknown severity or
|
|
1692
|
+
no conclusion.
|
|
1691
1693
|
--output-format <fmt> Additional report format: sarif | csv | md
|
|
1692
1694
|
--insecure-https Skip TLS validation on probed HTTPS targets
|
|
1693
|
-
--watch CTEM continuous ALERTING mode: re-scan on --interval
|
|
1694
|
-
compare each host with
|
|
1695
|
-
|
|
1696
|
-
|
|
1695
|
+
--watch CTEM continuous ALERTING mode: re-scan on --interval and
|
|
1696
|
+
compare each host with the previous cycle. --webhook-url
|
|
1697
|
+
alerts a host whose scan changed (a service, its finding
|
|
1698
|
+
count or a service check, or a comparison that could not be
|
|
1699
|
+
made) and that has a finding at or above --alert-severity.
|
|
1700
|
+
The first cycle sets the baseline and alerts nobody; a host
|
|
1701
|
+
whose scan failed is reported on stdout and does not alert in
|
|
1702
|
+
this release. NOT an evidence cadence —
|
|
1697
1703
|
it adds no retention or cross-run aggregation, skips SARIF/
|
|
1698
1704
|
CSV/Markdown output and --fail-on, and dies with this process.
|
|
1699
1705
|
Each tick is an ordinary scan, so with --compliance it writes
|
|
1700
1706
|
that tick's artifacts; nothing relates them across ticks. For
|
|
1701
1707
|
SOC 2 Type II history use a scheduler (cron/systemd/CI).
|
|
1708
|
+
Watch mode scans each distinct host once per cycle; a
|
|
1709
|
+
one-shot --host X,X scans X twice.
|
|
1702
1710
|
--interval <minutes> Watch interval (default 60)
|
|
1703
1711
|
--webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
|
|
1704
1712
|
--alert-severity <sev> Min severity to alert on (default: high)
|
|
1713
|
+
--alert-every-cycle Alert every host with a finding at or above --alert-severity
|
|
1714
|
+
on every --watch cycle, the first included (default: only
|
|
1715
|
+
hosts whose scan changed)
|
|
1705
1716
|
--compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
|
|
1706
1717
|
soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
|
|
1707
1718
|
(aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
|
|
@@ -2690,30 +2701,45 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2690
2701
|
}
|
|
2691
2702
|
}
|
|
2692
2703
|
} else if (subCmd === 'verify-call') {
|
|
2693
|
-
//
|
|
2694
|
-
//
|
|
2695
|
-
//
|
|
2696
|
-
//
|
|
2697
|
-
//
|
|
2698
|
-
// proven real call. UUID absent → fabricated (or log was rotated/
|
|
2699
|
-
// deleted; we say "unverifiable" rather than "fake").
|
|
2704
|
+
// 1.3.0 (s6): BIND THE RESPONSE TEXT, not only the id. The server logs each call's id and a digest of the response
|
|
2705
|
+
// body (utils/mcp_call_digest.mjs); this recomputes the digest from the text the user saved. The id alone proves
|
|
2706
|
+
// only that the server issued it once — an AI client that has seen an earlier response can paste a real id under
|
|
2707
|
+
// a fabricated one — so an id with no text is INDETERMINATE, never "verified".
|
|
2708
|
+
// Exit: 0 verified · 1 refuted (never issued, or the text does not match) · 2 usage · 3 indeterminate.
|
|
2700
2709
|
const { readFile, stat } = await import('node:fs/promises');
|
|
2701
2710
|
const { join: _join } = await import('node:path');
|
|
2702
2711
|
const { homedir: _homedir } = await import('node:os');
|
|
2712
|
+
const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
|
|
2703
2713
|
const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
|
|
2704
2714
|
const uuid = rawArgs[2];
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2715
|
+
const respAt = rawArgs.indexOf('--response');
|
|
2716
|
+
const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
|
|
2717
|
+
if (!uuid || (respAt > 0 && !respPath)) {
|
|
2718
|
+
console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
|
|
2719
|
+
console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
|
|
2708
2720
|
process.exit(2);
|
|
2709
2721
|
}
|
|
2710
|
-
// Conservative UUID v4 shape check — avoid grepping the log with
|
|
2711
|
-
// arbitrary user input.
|
|
2722
|
+
// Conservative UUID v4 shape check — avoid grepping the log with arbitrary user input.
|
|
2712
2723
|
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(uuid)) {
|
|
2713
2724
|
console.error(`✗ Not a valid UUID: ${uuid}`);
|
|
2714
2725
|
console.error(' Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
|
|
2715
2726
|
process.exit(2);
|
|
2716
2727
|
}
|
|
2728
|
+
let responseText = null;
|
|
2729
|
+
if (respPath) {
|
|
2730
|
+
try {
|
|
2731
|
+
if (respPath === '-') {
|
|
2732
|
+
const chunks = [];
|
|
2733
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
2734
|
+
responseText = Buffer.concat(chunks).toString('utf8');
|
|
2735
|
+
} else {
|
|
2736
|
+
responseText = await readFile(respPath, 'utf8');
|
|
2737
|
+
}
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
console.error(`✗ Could not read the response from ${respPath}: ${err.message}`);
|
|
2740
|
+
process.exit(2);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2717
2743
|
let logExists = false;
|
|
2718
2744
|
try { await stat(logPath); logExists = true; } catch { /* missing */ }
|
|
2719
2745
|
if (!logExists) {
|
|
@@ -2726,33 +2752,50 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2726
2752
|
const raw = await readFile(logPath, 'utf8');
|
|
2727
2753
|
// Look for an exact JSON-string match on call_id to avoid prefix collisions.
|
|
2728
2754
|
const needle = `"call_id":"${uuid.toLowerCase()}"`;
|
|
2729
|
-
const
|
|
2730
|
-
|
|
2755
|
+
const entries = raw.split('\n').filter((l) => l.includes(needle))
|
|
2756
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
2757
|
+
if (entries.length === 0) {
|
|
2731
2758
|
console.log(`✗ call_id not found in ${logPath}`);
|
|
2732
2759
|
console.log('');
|
|
2733
2760
|
console.log(` ${uuid}`);
|
|
2734
2761
|
console.log('');
|
|
2735
2762
|
console.log(' This UUID was NOT issued by this MCP server. Most likely cause:');
|
|
2736
|
-
console.log('
|
|
2737
|
-
console.log(' (See
|
|
2763
|
+
console.log(' the AI client fabricated the response without invoking the server.');
|
|
2764
|
+
console.log(' (See docs/mcp-verification.md.)');
|
|
2738
2765
|
process.exit(1);
|
|
2739
2766
|
}
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
console.log(
|
|
2767
|
+
const issued = entries.find((e) => e.tool) ?? entries[0];
|
|
2768
|
+
const ago = (() => {
|
|
2769
|
+
const ms = Date.now() - Date.parse(issued.ts ?? '');
|
|
2770
|
+
if (!Number.isFinite(ms)) return 'at an unrecorded time';
|
|
2771
|
+
const units = [['day', 86400000], ['hour', 3600000], ['minute', 60000]];
|
|
2772
|
+
for (const [u, n] of units) if (ms >= n) { const k = Math.floor(ms / n); return `${k} ${u}${k === 1 ? '' : 's'} ago`; }
|
|
2773
|
+
return 'less than a minute ago';
|
|
2774
|
+
})();
|
|
2775
|
+
const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
|
|
2776
|
+
const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
|
|
2777
|
+
if (digests.length === 0) {
|
|
2778
|
+
console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
|
|
2779
|
+
console.log(' This call predates response binding: no digest of its response was recorded, so the text');
|
|
2780
|
+
console.log(' cannot be bound to it. The id alone does not verify a response.');
|
|
2781
|
+
process.exit(3);
|
|
2782
|
+
}
|
|
2783
|
+
if (responseText == null) {
|
|
2784
|
+
console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
|
|
2785
|
+
console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
|
|
2786
|
+
console.log(' pasted under a fabricated one. Save the raw tool response and run:');
|
|
2787
|
+
console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
|
|
2788
|
+
process.exit(3);
|
|
2789
|
+
}
|
|
2790
|
+
if (digests.includes(responseDigest(bodyOf(responseText)))) {
|
|
2791
|
+
console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
|
|
2753
2792
|
console.log(` log: ${logPath}`);
|
|
2754
2793
|
process.exit(0);
|
|
2755
2794
|
}
|
|
2795
|
+
console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
|
|
2796
|
+
console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
|
|
2797
|
+
console.log(' call — re-copy the RAW tool output and verify again.');
|
|
2798
|
+
process.exit(1);
|
|
2756
2799
|
} else {
|
|
2757
2800
|
console.log('Usage:');
|
|
2758
2801
|
console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
|
|
@@ -2761,7 +2804,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2761
2804
|
console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
|
|
2762
2805
|
console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
|
|
2763
2806
|
console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
|
|
2764
|
-
console.log(' nsauditor-ai mcp verify-call <uuid>
|
|
2807
|
+
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');
|
|
2765
2808
|
console.log('');
|
|
2766
2809
|
console.log('Environment variables:');
|
|
2767
2810
|
console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
|
|
@@ -3197,9 +3240,6 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3197
3240
|
// --- CTEM: continuous watch mode ---
|
|
3198
3241
|
if (watch) {
|
|
3199
3242
|
const intervalMs = intervalMinutes * 60 * 1000;
|
|
3200
|
-
console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${hosts.length}`);
|
|
3201
|
-
if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}`);
|
|
3202
|
-
|
|
3203
3243
|
let previousCycleResults = null;
|
|
3204
3244
|
|
|
3205
3245
|
const scheduler = createScheduler({
|
|
@@ -3216,44 +3256,20 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3216
3256
|
onCycleComplete: async (results) => {
|
|
3217
3257
|
console.log(`[CTEM] Cycle complete. Scanned ${results.size} host(s).`);
|
|
3218
3258
|
|
|
3219
|
-
//
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
const
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
if (hostSev >= sevRank) {
|
|
3232
|
-
const services = scanOut.conclusion?.result?.services || [];
|
|
3233
|
-
const findings = services.filter((svc) => {
|
|
3234
|
-
let svcSev = 0;
|
|
3235
|
-
if (svc.anonymousLogin === true || svc.axfrAllowed === true) svcSev = SEVERITY_RANK.critical;
|
|
3236
|
-
if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
|
|
3237
|
-
if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
|
|
3238
|
-
const cves = svc.cves || svc.cve || [];
|
|
3239
|
-
if (Array.isArray(cves) && cves.length) svcSev = Math.max(svcSev, SEVERITY_RANK.high);
|
|
3240
|
-
return svcSev >= sevRank;
|
|
3241
|
-
});
|
|
3242
|
-
|
|
3243
|
-
if (findings.length > 0) {
|
|
3244
|
-
const payload = buildAlertPayload(h, findings, alertSeverity);
|
|
3245
|
-
const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
|
|
3246
|
-
if (webhookResult.success) {
|
|
3247
|
-
console.log(`[CTEM] Webhook alert sent for ${h}`);
|
|
3248
|
-
} else {
|
|
3249
|
-
console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
|
|
3250
|
-
}
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3259
|
+
// What stdout says and whom the webhook alerts, both from ONE predicate (utils/watch_cycle.mjs, 1.3.0 items 4 + 11).
|
|
3260
|
+
const { text, alerts } = watchCycle(results, previousCycleResults,
|
|
3261
|
+
{ alertRank: SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high, everyCycle: alertEveryCycle });
|
|
3262
|
+
console.log(text ?? '[CTEM] First cycle complete — it establishes the baseline. Delta reporting begins on the next cycle.');
|
|
3263
|
+
if (webhookUrl) {
|
|
3264
|
+
for (const { host: h, findings } of alerts) {
|
|
3265
|
+
const payload = buildAlertPayload(h, findings, alertSeverity);
|
|
3266
|
+
const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
|
|
3267
|
+
if (webhookResult.success) {
|
|
3268
|
+
console.log(`[CTEM] Webhook alert sent for ${h}`);
|
|
3269
|
+
} else {
|
|
3270
|
+
console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
|
|
3253
3271
|
}
|
|
3254
3272
|
}
|
|
3255
|
-
} else {
|
|
3256
|
-
console.log('[CTEM] First cycle complete. Delta reporting will begin on next cycle.');
|
|
3257
3273
|
}
|
|
3258
3274
|
|
|
3259
3275
|
previousCycleResults = results;
|
|
@@ -3270,6 +3286,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3270
3286
|
process.on('SIGINT', shutdown);
|
|
3271
3287
|
process.on('SIGTERM', shutdown);
|
|
3272
3288
|
|
|
3289
|
+
// The banner reads the scheduler's own distinct list: a cycle scans each host once (1.3.0 lane 4, item 10).
|
|
3290
|
+
const dropped = scheduler.duplicatesDropped
|
|
3291
|
+
? ` (${scheduler.duplicatesDropped} duplicate host(s) dropped — watch mode scans each host once per cycle)` : '';
|
|
3292
|
+
console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${scheduler.hosts.length}${dropped}`);
|
|
3293
|
+
if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}, alerting `
|
|
3294
|
+
+ (alertEveryCycle ? 'every host with such a finding, every cycle' : 'hosts whose scan changed (the first cycle sets the baseline)'));
|
|
3295
|
+
|
|
3273
3296
|
scheduler.start();
|
|
3274
3297
|
return; // keep process alive via setInterval
|
|
3275
3298
|
}
|
|
@@ -3476,6 +3499,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3476
3499
|
host: scanOut.host,
|
|
3477
3500
|
conclusion: scanOut.conclusion,
|
|
3478
3501
|
toolVersion: TOOL_VERSION,
|
|
3502
|
+
manifest: scanOut.pluginStatus ?? [],
|
|
3479
3503
|
});
|
|
3480
3504
|
const mdFileName = scanOutputs.length > 1
|
|
3481
3505
|
? `scan_${safeHost(scanOut.host)}.md`
|