nsauditor-ai 0.2.55 → 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.
Files changed (42) hide show
  1. package/README.md +76 -62
  2. package/cli.mjs +210 -179
  3. package/mcp_server.mjs +71 -31
  4. package/package.json +1 -1
  5. package/plugin_manager.mjs +49 -8
  6. package/plugins/040_tls_cert_auditor.mjs +176 -56
  7. package/plugins/050_tribe_health.mjs +13 -34
  8. package/plugins/060_dns_sec_auditor.mjs +24 -30
  9. package/plugins/dns_scanner.mjs +16 -8
  10. package/plugins/ftp_banner_check.mjs +21 -13
  11. package/plugins/host_up_check.mjs +31 -72
  12. package/plugins/http_probe.mjs +93 -25
  13. package/plugins/mcp_scanner.mjs +15 -1
  14. package/plugins/netbios_scanner.mjs +32 -13
  15. package/plugins/opensearch_scanner.mjs +4 -0
  16. package/plugins/port_scanner.mjs +29 -49
  17. package/plugins/result_concluder.mjs +122 -30
  18. package/plugins/snmp_scanner.mjs +20 -8
  19. package/plugins/syn_scanner.mjs +2 -1
  20. package/plugins/upnp_scanner.mjs +82 -13
  21. package/plugins/webapp_detector.mjs +82 -15
  22. package/utils/conclusion_utils.mjs +13 -1
  23. package/utils/cookie_redaction.mjs +50 -0
  24. package/utils/delta_reporter.mjs +70 -25
  25. package/utils/ee_load.mjs +45 -0
  26. package/utils/executive_report.mjs +48 -8
  27. package/utils/export_csv.mjs +14 -22
  28. package/utils/mcp_call_digest.mjs +31 -0
  29. package/utils/net_validation.mjs +107 -37
  30. package/utils/output_dir.mjs +70 -0
  31. package/utils/plugin_discovery.mjs +4 -2
  32. package/utils/ports_spec.mjs +46 -0
  33. package/utils/report_inputs.mjs +64 -1
  34. package/utils/report_md.mjs +51 -112
  35. package/utils/sarif.mjs +33 -93
  36. package/utils/scan_delta.mjs +514 -38
  37. package/utils/scan_delta_view.mjs +5 -3
  38. package/utils/scan_history.mjs +149 -0
  39. package/utils/scheduler.mjs +17 -5
  40. package/utils/service_flags.mjs +363 -0
  41. package/utils/watch_cycle.mjs +37 -0
  42. package/utils/webhook.mjs +2 -1
@@ -2,7 +2,9 @@
2
2
  // New plugin: Webapp Detector
3
3
  // Uses the in-house zero-dep fingerprinter (utils/tech_fingerprint.mjs) to identify
4
4
  // web applications present on a host.
5
- // Tries HTTPS first (port 443), then HTTP (port 80), and can also try custom ports via opts.ports.
5
+ // Tries https:443, then http:80, then each TCP port --ports adds (both schemes), and stops at the first that answers.
6
+ // THE REACH, stated: 010 runs only when TCP 80 or 443 is open; an added port is tried after https:443 and http:80 and
7
+ // only if neither answers (1.3.0 item 12 — the CLI's --ports string never reached this plugin before).
6
8
  // NOTE: Unlike http_probe, undici/fetch cannot ignore TLS easily per-request, so self-signed
7
9
  // HTTPS will usually fail and the plugin will fall back to HTTP.
8
10
  //
@@ -28,6 +30,8 @@
28
30
  // }
29
31
 
30
32
  import { fingerprint } from '../utils/tech_fingerprint.mjs';
33
+ import { canonicalIp, classifyAddress, resolveAndValidate, allowAllHosts } from '../utils/net_validation.mjs';
34
+ import { parsePortsSpec } from '../utils/ports_spec.mjs';
31
35
 
32
36
  const DEBUG =
33
37
  String(process.env.DEBUG_MODE || '').toLowerCase() === '1' ||
@@ -70,21 +74,83 @@ function normalizeTarget(target) {
70
74
  return (target.host || target.hostname || target.name || '').replace(/^https?:\/\//i, '').split('/')[0];
71
75
  }
72
76
 
77
+ // 1.3.0 lane 2 (R2): redirects are followed HERE, one hop at a time, never by undici. The target chooses
78
+ // a hop, not the operator, and the scan-entry guard ran once, on the host the operator named — so with
79
+ // `redirect: 'follow'` a target could send the scanner to loopback or cloud metadata and have the
80
+ // answer's headers reflected into the result. A hop to the host the operator named (any port, http or
81
+ // https) was admitted at entry and is followed; any other host goes through the MCP guard's policy:
82
+ // loopback, link-local, metadata and unspecified refused in every configuration, private ranges only
83
+ // under NSA_ALLOW_ALL_HOSTS, a name refused if ANY of its answers is. Stated limit, as at entry: the
84
+ // checked name is resolved again by the request (no pin against rebinding).
85
+ const MAX_REDIRECT_HOPS = 5;
86
+
87
+ async function assertHopAllowed(next, entryHost) {
88
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
89
+ throw new Error(`redirect to ${next.href} refused: only http and https hops are followed`);
90
+ }
91
+ const host = next.hostname.replace(/^\[|\]$/g, '');
92
+ if (host.toLowerCase() === String(entryHost).replace(/^\[|\]$/g, '').toLowerCase()) return;
93
+ const allowPrivate = allowAllHosts();
94
+ if (canonicalIp(host)) {
95
+ const cls = classifyAddress(host);
96
+ if (cls === 'always' || (cls === 'private' && !allowPrivate)) {
97
+ throw new Error(`redirect to ${host} refused by the SSRF guard (blocked address range)`);
98
+ }
99
+ return;
100
+ }
101
+ try {
102
+ await resolveAndValidate(host, { allowPrivate });
103
+ } catch (err) {
104
+ throw new Error(`redirect to ${host} refused by the SSRF guard (${err.message})`);
105
+ }
106
+ }
107
+
73
108
  async function fetchOnce(url, signal) {
74
109
  const extra = parseExtraHeaders();
75
- const headers = {
110
+ const base = {
76
111
  'User-Agent': 'Mozilla/5.0 (compatible; NetworkSecurityAuditor/1.18.0; +https://example.invalid)',
77
112
  DNT: '1',
78
- ...extra,
79
113
  };
80
- // global fetch (undici) is available in Node >=18
81
- const res = await fetch(url, { redirect: 'follow', headers, signal });
82
- const finalUrl = res.url || url;
83
- const statusCode = res.status;
84
- const rawHeaders = {};
85
- res.headers.forEach((v, k) => (rawHeaders[k.toLowerCase()] = v));
86
- const html = await res.text();
87
- return { url: finalUrl, statusCode, headers: rawHeaders, html };
114
+ const entry = new URL(url);
115
+ const entryHost = entry.hostname;
116
+ // The operator's HTTP_EXTRA_HEADERS (where an Authorization or an API key goes) are for the origin the
117
+ // operator named. Once a hop leaves that origin — another host, scheme or port — none of them is sent
118
+ // again, even if the chain comes back. (undici's own follow drops only Authorization cross-origin.)
119
+ let leftOrigin = false;
120
+ let current = url;
121
+ for (let hop = 0; ; hop++) {
122
+ const headers = leftOrigin ? base : { ...base, ...extra };
123
+ // global fetch (undici) is available in Node >=18
124
+ const res = await fetch(current, { redirect: 'manual', headers, signal });
125
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
126
+ if (location) {
127
+ if (hop >= MAX_REDIRECT_HOPS) {
128
+ throw new Error(`redirect limit (${MAX_REDIRECT_HOPS} hops) reached at ${current}`);
129
+ }
130
+ const next = new URL(location, current);
131
+ await assertHopAllowed(next, entryHost);
132
+ if (next.origin !== entry.origin) leftOrigin = true;
133
+ await res.body?.cancel?.().catch(() => {});
134
+ current = next.href;
135
+ continue;
136
+ }
137
+ const statusCode = res.status;
138
+ const rawHeaders = {};
139
+ res.headers.forEach((v, k) => (rawHeaders[k.toLowerCase()] = v));
140
+ const html = await res.text();
141
+ return { url: current, statusCode, headers: rawHeaders, html };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The TCP ports --ports adds, in every form a caller passes: the CLI's string ('8443,9090/udp'), an array, or
147
+ * { tcp, udp }. One grammar with the port scanner (utils/ports_spec.mjs); a /udp entry adds no URL.
148
+ */
149
+ export function addedTcpPorts(spec) {
150
+ if (typeof spec === 'string') return parsePortsSpec(spec).tcp;
151
+ if (Array.isArray(spec)) return parsePortsSpec(spec.join(',')).tcp;
152
+ if (spec && typeof spec === 'object' && Array.isArray(spec.tcp)) return parsePortsSpec(spec.tcp.join(',')).tcp;
153
+ return [];
88
154
  }
89
155
 
90
156
  async function tryDetectAt(url) {
@@ -143,8 +209,9 @@ export default {
143
209
 
144
210
  /**
145
211
  * @param {string} host - target hostname or IP
146
- * @param {number} port - optional hint (ignored; detection tries 443 then 80 unless opts.ports provided)
147
- * @param {object} opts - options: { ports?: number[] }
212
+ * @param {number} port - the port the manager dispatched (ignored: each run walks the whole candidate list — the double
213
+ * fetch when 80 and 443 are both open is boarded with the per-port dispatch)
214
+ * @param {object} opts - options: { ports?: string | number[] | { tcp, udp } } — the CLI passes its --ports string
148
215
  */
149
216
  async run(host, port = 0, opts = {}) {
150
217
  const result = {
@@ -168,8 +235,8 @@ export default {
168
235
  set.add(`${proto}://${target}${portPart}/`);
169
236
  };
170
237
 
171
- // If specific ports given, try both schemes for each; else default to 443 then 80
172
- const ports = Array.isArray(opts.ports) && opts.ports.length ? opts.ports : [443, 80];
238
+ // https:443 and http:80 first, then each TCP port --ports ADDS (both schemes) — never in place of the defaults.
239
+ const ports = [443, 80, ...addedTcpPorts(opts.ports).filter((p) => p !== 443 && p !== 80)];
173
240
  for (const p of ports) {
174
241
  if (p === 443) addUrl('https', 443);
175
242
  else if (p === 80) addUrl('http', 80);
@@ -78,6 +78,11 @@ function resolveLiveness(base, other) {
78
78
  return base?.status || 'unknown';
79
79
  }
80
80
 
81
+ // What a service IS (or how it was merged) — the fields authority governs. Everything else on a record is an adapter's
82
+ // finding or flag, and a merge must not drop it.
83
+ export const IDENTITY_FIELDS = new Set(['port', 'protocol', 'service', 'program', 'version', 'cpe', 'status', 'info', 'banner',
84
+ 'source', 'evidence', 'authoritative', '__authoritative']);
85
+
81
86
  // Merge by protocol:port with basic authority precedence.
82
87
  // If 'authoritative' flag is true on a record, it wins over non-authoritative —
83
88
  // for identity. See resolveLiveness above for why status is handled separately.
@@ -104,9 +109,16 @@ export function upsertService(services, next, { authoritative = false } = {}) {
104
109
  return;
105
110
  }
106
111
  if (!authoritative && cur.__authoritative) {
107
- // keep current authoritative, but allow filling blanks
112
+ // keep current authoritative, but allow filling blanks — and carry every NON-identity field the authoritative
113
+ // record lacks (1.3.0): this branch kept only identity, so a non-authoritative adapter that reached an
114
+ // authoritative record (040's certificate audit on 011's 443) landed nothing.
115
+ const carried = {};
116
+ for (const [k, v] of Object.entries(next)) {
117
+ if (!IDENTITY_FIELDS.has(k) && v !== undefined && cur[k] === undefined) carried[k] = v;
118
+ }
108
119
  services[i] = {
109
120
  ...cur,
121
+ ...carried,
110
122
  program: cur.program || next.program || null,
111
123
  version: cur.version || next.version || null,
112
124
  info: cur.info || next.info || null,
@@ -0,0 +1,50 @@
1
+ // utils/cookie_redaction.mjs
2
+ // A scanned service's Set-Cookie VALUE is that service's secret (a session token); its NAME fingerprints a framework
3
+ // (PHPSESSID, JSESSIONID). A Location header's query string can carry an SSO ticket or an OAuth code the same way.
4
+ // 1.3.0 (the audit seat's ruling (A), at BOTH seams): the HTTP probe redacts them when it builds its banner, and the AI
5
+ // redactor redacts any Set-Cookie value it meets, so the AI path holds even where a producer does not.
6
+
7
+ export const REDACTED = '<redacted>';
8
+
9
+ // One Set-Cookie header value may carry several cookies joined with ", " (fetch merges them) — split only where the next
10
+ // chunk starts a new `name=`, so a ", " inside an Expires date is not a boundary.
11
+ const COOKIES = /,\s*(?=[^=;,\s]+=)/;
12
+ // In free text a Set-Cookie line ends at CR, LF, or a literal backslash-r / backslash-n (the webapp detector joins its
13
+ // banner lines with those two-character sequences).
14
+ const SET_COOKIE_LINE = /(set-cookie:[ \t]*)((?:(?!\r|\n|\\r|\\n).)*)/gi;
15
+
16
+ /** One Set-Cookie header value: every cookie's VALUE redacted; its name and attributes (Path, HttpOnly, Expires) kept. */
17
+ export function redactSetCookie(value) {
18
+ return String(value).split(COOKIES).map((c) => c.replace(/^(\s*[^=;\s]+)=[^;]*/, `$1=${REDACTED}`)).join(', ');
19
+ }
20
+
21
+ /** A Location header without its query string and fragment — the query can carry a credential. */
22
+ export function redactLocation(value) {
23
+ const v = String(value);
24
+ const i = v.search(/[?#]/);
25
+ if (i < 0) return v;
26
+ return v[i] === '?' ? `${v.slice(0, i)}?${REDACTED}` : v.slice(0, i);
27
+ }
28
+
29
+ /** Free text (a banner, an evidence row): every `set-cookie:` line's cookie values redacted. */
30
+ export function redactSetCookieLines(text) {
31
+ return String(text).replace(SET_COOKIE_LINE, (_, head, value) => head + redactSetCookie(value));
32
+ }
33
+
34
+ /** Every UNREDACTED `name=value` a Set-Cookie line carries anywhere in a structure — the census helper. */
35
+ export function setCookieValues(x) {
36
+ const found = [];
37
+ const visit = (v) => {
38
+ if (typeof v === 'string') {
39
+ for (const m of v.matchAll(SET_COOKIE_LINE)) {
40
+ for (const c of m[2].split(COOKIES)) {
41
+ const kv = /^\s*([^=;\s]+)=([^;]*)/.exec(c);
42
+ if (kv && kv[2].trim() !== REDACTED) found.push(`${kv[1]}=${kv[2].trim()}`);
43
+ }
44
+ }
45
+ } else if (Array.isArray(v)) v.forEach(visit);
46
+ else if (v && typeof v === 'object') Object.values(v).forEach(visit);
47
+ };
48
+ visit(x);
49
+ return found;
50
+ }
@@ -1,7 +1,69 @@
1
1
  // utils/delta_reporter.mjs
2
2
  // Delta reporting: compare two full scan cycles across multiple hosts.
3
3
 
4
- import { computeDiff } from './scan_history.mjs';
4
+ import { computeDiff, flagsChanged } from './scan_history.mjs';
5
+
6
+ // 1.3.0 lane 4, items 4 + 11. The watch loop hands this module each host's scanSingleHost OUTPUT, which carries no
7
+ // services, finding count or tier — so every cycle compared two empty summaries, stdout read "No significant changes
8
+ // detected." and the webhook gate never opened on a change. The output now carries the summary [ScanHistory] records
9
+ // (`scanSummary`), and the cycle compares those. A host whose summary is MISSING is never read as an empty one: that
10
+ // would print "N service(s) removed" for a scan that failed (the naive fix) or "No changes" (the shipped defect).
11
+
12
+ /** The per-host states in which two cycles are NOT compared — a closed set, held two ways by its test. */
13
+ export const WATCH_NOT_COMPARABLE = Object.freeze(['scan-failed', 'baseline-scan-failed', 'no-summary', 'baseline-no-summary']);
14
+
15
+ const NOT_COMPARED_TEXT = {
16
+ 'scan-failed': (detail) => `scan failed this cycle — not compared${detail ? ` (${detail})` : ''}`,
17
+ 'baseline-scan-failed': () => "the previous cycle's scan failed — not compared",
18
+ 'no-summary': () => 'no scan summary was recorded this cycle — not compared',
19
+ 'baseline-no-summary': () => 'no scan summary was recorded for the previous cycle — not compared',
20
+ };
21
+
22
+ /**
23
+ * What one host's cycle value offers the comparison: `{ summary }`, or `{ failed: detail }` for the scheduler's
24
+ * `{ error }`, or `{ missing: true }` when no summary is there to read.
25
+ */
26
+ export function deltaInputOf(value) {
27
+ if (value && typeof value === 'object') {
28
+ if (value.error != null && !('scanSummary' in value) && !Array.isArray(value.services)) return { failed: String(value.error) };
29
+ if ('scanSummary' in value) return value.scanSummary && typeof value.scanSummary === 'object' ? { summary: value.scanSummary } : { missing: true };
30
+ if (Array.isArray(value.services)) return { summary: value };
31
+ }
32
+ return { missing: true };
33
+ }
34
+
35
+ function notComparedDiff(reason, detail) {
36
+ return {
37
+ newServices: [], removedServices: [], changedServices: [],
38
+ newFindings: null, findingsNotComparable: false, findingsNotComparableReason: null,
39
+ changedFlags: [], flagsNotComparable: false, flagsNotComparableReason: null,
40
+ notCompared: reason,
41
+ summary: NOT_COMPARED_TEXT[reason](detail),
42
+ };
43
+ }
44
+
45
+ /** Compare one host's two cycle values. */
46
+ function hostDiff(current, previous) {
47
+ const cur = deltaInputOf(current);
48
+ if (cur.failed !== undefined) return notComparedDiff('scan-failed', cur.failed);
49
+ if (cur.missing) return notComparedDiff('no-summary');
50
+ if (previous == null) return computeDiff(cur.summary, null);
51
+ const prev = deltaInputOf(previous);
52
+ if (prev.failed !== undefined) return notComparedDiff('baseline-scan-failed');
53
+ if (prev.missing) return notComparedDiff('baseline-no-summary');
54
+ return computeDiff(cur.summary, prev.summary);
55
+ }
56
+
57
+ /**
58
+ * THE one per-host change predicate (ruled): stdout and the webhook read the same definition. A service appeared,
59
+ * went or changed; the finding count moved, or could not be compared; a service check appeared, cleared or could not
60
+ * be compared; or the two cycles could not be compared at all.
61
+ */
62
+ export function hostChanged(diff) {
63
+ if (!diff) return false;
64
+ return Boolean(diff.notCompared || diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length
65
+ || diff.newFindings || diff.findingsNotComparable || flagsChanged(diff));
66
+ }
5
67
 
6
68
  /**
7
69
  * Compare two full scan cycle results and produce a delta report.
@@ -32,9 +94,7 @@ export function buildDeltaReport(currentResults, previousResults) {
32
94
 
33
95
  const hostDiffs = new Map();
34
96
  for (const h of currentHosts) {
35
- const curr = currMap.get(h);
36
- const prev = prevMap.has(h) ? prevMap.get(h) : null;
37
- hostDiffs.set(h, computeDiff(curr, prev));
97
+ hostDiffs.set(h, hostDiff(currMap.get(h), prevMap.has(h) ? prevMap.get(h) : null));
38
98
  }
39
99
 
40
100
  return { newHosts, removedHosts, hostDiffs };
@@ -70,18 +130,11 @@ export function formatDeltaSummary(deltaReport) {
70
130
  if (!deltaReport.newHosts.length && !deltaReport.removedHosts.length) {
71
131
  let anyChange = false;
72
132
  if (deltaReport.hostDiffs) {
73
- for (const diff of deltaReport.hostDiffs.values()) {
74
- // ⚠️ `findingsNotComparable` COUNTS AS A CHANGE (board C10). `newFindings` is `null` when
75
- // the two scans counted findings on a different basis, and null is falsy — so without
76
- // this the diff drops out of both gates and the operator hears NOTHING, which reads as
77
- // "no change since last scan". That is the false clean this item is about, one layer out.
78
- // A comparison we cannot make is news: it is what tells an operator to rescan.
79
- if (diff.newServices?.length || diff.removedServices?.length || diff.changedServices?.length
80
- || diff.newFindings || diff.findingsNotComparable) {
81
- anyChange = true;
82
- break;
83
- }
84
- }
133
+ // ⚠️ A comparison that could not be made COUNTS AS A CHANGE (board C10; 1.3.0 items 4 + 11): `newFindings` is null
134
+ // when the two scans counted on a different basis, and a host whose scan failed has nothing to compare — without
135
+ // this the operator hears NOTHING, which reads as "no change since last scan". A comparison we cannot make is
136
+ // news: it is what tells an operator to rescan. hostChanged is the one predicate the webhook reads too.
137
+ anyChange = [...deltaReport.hostDiffs.values()].some(hostChanged);
85
138
  }
86
139
  if (!anyChange) {
87
140
  lines.push('No significant changes detected.');
@@ -103,15 +156,7 @@ export function hasSignificantChanges(deltaReport) {
103
156
  if (deltaReport.newHosts.length > 0) return true;
104
157
  if (deltaReport.removedHosts.length > 0) return true;
105
158
 
106
- if (deltaReport.hostDiffs) {
107
- for (const diff of deltaReport.hostDiffs.values()) {
108
- if (diff.newServices?.length > 0) return true;
109
- if (diff.removedServices?.length > 0) return true;
110
- if (diff.changedServices?.length > 0) return true;
111
- if (diff.newFindings && diff.newFindings !== 0) return true;
112
- if (diff.findingsNotComparable) return true; // see the note in formatDeltaSummary
113
- }
114
- }
159
+ if (deltaReport.hostDiffs && [...deltaReport.hostDiffs.values()].some(hostChanged)) return true;
115
160
 
116
161
  return false;
117
162
  }
@@ -0,0 +1,45 @@
1
+ // utils/ee_load.mjs — load the Enterprise package, telling "not installed" from "installed and broken" (1.1.1).
2
+ //
3
+ // ⚠️ PRESENCE IS DECIDED BY RESOLUTION, NEVER BY THE ERROR. The scan path used to import inside a bare `catch {}` read as
4
+ // "EE not installed — the ONLY silent case", and every failure landed there: a named import Community does not export
5
+ // (Enterprise installed above the Community it runs on — the measured case at EE 174212f), a missing dependency of
6
+ // Enterprise's own, a syntax error. The scan then ran with no analysis agents, no CVE mapper and no compliance report,
7
+ // said nothing, and the Pro delta read every agent and engine row RESOLVED. An error CODE cannot separate the cases —
8
+ // a dependency Enterprise lacks is `ERR_MODULE_NOT_FOUND` too — so the package's MANIFEST is resolved first (see
9
+ // `isInstalled`): no manifest is absence, the one silent case; installed-but-the-import-throws is a load failure,
10
+ // returned for the caller to report and record.
11
+ //
12
+ // Seams (tests only): `resolveEE(specifier)` replaces the resolver; `importEE()` replaces the import. An import seam
13
+ // with NO resolver means the package is installed — a test that injects a module is describing one that resolves.
14
+
15
+ export const EE_PACKAGE = '@nsasoft/nsauditor-ai-ee';
16
+
17
+ // The DEFAULT resolver, as a replaceable dependency: Enterprise resolves from a development checkout, so a test that must
18
+ // describe a checkout without it replaces this rather than the environment. Nothing in the product assigns it.
19
+ export const resolverDeps = { resolve: (s) => import.meta.resolve(s) };
20
+
21
+ /**
22
+ * PRESENCE IS THE PACKAGE'S MANIFEST, NEVER ITS ENTRY (the audit seat's T1-c fold 2). Resolving the entry answers "does
23
+ * the entry resolve": a package whose main file is missing (a truncated install) or whose exports map does not serve
24
+ * "." throws at RESOLUTION — measured on node 24 with real packages — and read as absent, which is installed-but-broken
25
+ * one layer down. The manifest resolves for any installed package; an exports map that declines to serve it
26
+ * (ERR_PACKAGE_PATH_NOT_EXPORTED) still proves the package directory is there. Only a manifest that cannot be found at
27
+ * all is absence. (Community's MCP server resolves the same manifest for the compliance matrix.)
28
+ */
29
+ function isInstalled(resolve) {
30
+ try { resolve(`${EE_PACKAGE}/package.json`); return true; } catch (err) { return err?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED'; }
31
+ }
32
+
33
+ /**
34
+ * @param {{ importEE?: () => Promise<object>, resolveEE?: (specifier: string) => string }} [opts]
35
+ * @returns {Promise<{ ee: object|null, loadError: string|null }>}
36
+ */
37
+ export async function loadEnterprise({ importEE = null, resolveEE = null } = {}) {
38
+ const resolve = resolveEE ?? (importEE ? null : resolverDeps.resolve);
39
+ if (resolve && !isInstalled(resolve)) return { ee: null, loadError: null };
40
+ try {
41
+ return { ee: await (importEE ?? (() => import(EE_PACKAGE)))(), loadError: null };
42
+ } catch (err) {
43
+ return { ee: null, loadError: String(err?.message ?? err) };
44
+ }
45
+ }
@@ -29,7 +29,7 @@
29
29
  import { escapeHtml } from './brand.mjs';
30
30
  // The two evaluability SENTINELS, imported so the per-row basis is keyed on identity rather
31
31
  // than on prose. `scan_delta.mjs` imports nothing from here, so there is no cycle.
32
- import { SCOPE_NOT_EVALUATED, FRAMEWORK_MOVEMENT_NOT_EVALUATED } from './scan_delta.mjs';
32
+ import { SCOPE_NOT_EVALUATED, FRAMEWORK_MOVEMENT_NOT_EVALUATED, RUN_NAMES, NOT_REQUESTED_RECORD_SINCE_EE, cmpVersion } from './scan_delta.mjs';
33
33
 
34
34
  /* ------------------------------------------------------------------------------------------
35
35
  * egressViolations — deny-by-default. Two levels: every TAG, then EVERY attribute of it
@@ -625,17 +625,50 @@ function describeBrand(v) {
625
625
  //
626
626
  // ⚠️ KEYED ON THE EXPORTED SENTINELS, NEVER ON PROSE. Matching the limit text would rot the first
627
627
  // time somebody copy-edits a sentence, and it would rot SILENTLY in the direction that overstates.
628
- function deltaBasis(delta) {
628
+ //
629
+ // ⚠️ PER ROW (1.3.0, lane 6 — deltaBasis). The sentence used to be ONE for the whole table, so an analysis agent's or the
630
+ // CVE mapper's row read "host, plugin, scope … present in both runs" although an agent is not a requested plugin and its
631
+ // "plugin" leg was never checked. Now:
632
+ // - a plugin's row keeps the legs above;
633
+ // - an agent's row (`producerKind: 'agent'`) names the run that LACKS it — the baseline for a NEW row, this run for a
634
+ // RESOLVED one — as having recorded no evidence gap from its producer covering the row. TRUE BY CONSTRUCTION: the
635
+ // delta refuses a row when that run recorded such a gap, and reads it in that run only (a gap explains what a run is
636
+ // missing, never what it holds), so "either run" would be false;
637
+ // - where that lacking run predates EE 1.3.0 it could not record an input plugin LEFT OUT of the scan, so the row is
638
+ // not refused, and the cell says so; a run that records no EE version says that instead — never assumed either way.
639
+ // Keyed through `cmpVersion`, the delta's one comparator, on the version each run's own record carries.
640
+ function deltaBasis(delta, f = null, bucket = null) {
629
641
  const limits = delta.limits ?? [];
630
642
  const scopeEvaluated = !limits.includes(SCOPE_NOT_EVALUATED);
631
643
  const fwEvaluated = !limits.includes(FRAMEWORK_MOVEMENT_NOT_EVALUATED);
632
- const legs = ['host', 'plugin', scopeEvaluated ? 'scope' : null].filter(Boolean).join(', ');
644
+ const agent = f?.producerKind === 'agent';
645
+ const legs = ['host', agent ? null : 'plugin', scopeEvaluated ? 'scope' : null].filter(Boolean).join(', ');
633
646
  const fw = fwEvaluated ? 'present in both runs' : 'not evaluated';
634
647
  // BOTH sides' integrity: the run being REPORTED is as alterable as the one compared against.
635
648
  const integrity = `baseline ${delta.baselineIntegrity} · current ${delta.currentIntegrity ?? 'not recorded'}`;
636
- return `comparable: ${legs} present in both runs; framework enumeration: ${fw}; ${integrity}`;
649
+ const lacking = bucket === 'new' ? { name: RUN_NAMES.baseline, ee: delta.baselineEeVersion }
650
+ : bucket === 'resolved' ? { name: RUN_NAMES.current, ee: delta.currentEeVersion } : null;
651
+ const producer = !agent ? ''
652
+ : lacking ? `; producer: ${f.plugin} — ${lacking.name} recorded no evidence gap from it covering this row`
653
+ : `; producer: ${f.plugin} — the row is in both runs`;
654
+ let limit = '';
655
+ if (agent && lacking && lacking.ee !== undefined) {
656
+ if (lacking.ee === null) {
657
+ limit = `; limit: ${lacking.name} records no EE version, so whether an input plugin was left out of it is not known`;
658
+ } else if (cmpVersion(lacking.ee, NOT_REQUESTED_RECORD_SINCE_EE) < 0) {
659
+ limit = `; limit: ${lacking.name} predates EE ${NOT_REQUESTED_RECORD_SINCE_EE}, so it could not record an input plugin left `
660
+ + 'out of the scan: an agent row it lacks is not refused';
661
+ }
662
+ }
663
+ return `comparable: ${legs} present in both runs${producer}; framework enumeration: ${fw}; ${integrity}${limit}`;
637
664
  }
638
665
 
666
+ // What a not-comparable row's direction means, in the words a client reads.
667
+ const NC_DIRECTION_WORDS = Object.freeze({
668
+ appeared: 'appeared in this run — not counted as NEW',
669
+ disappeared: 'absent from this run — not counted as RESOLVED',
670
+ });
671
+
639
672
  function deltaRow(bucket, f, basis) {
640
673
  // ⚠️ THE REGION RIDES THE RESOURCE CELL (board E1(b)). Until E1 the region reached this table
641
674
  // only as a ` [<region>]` suffix that EE's region stamper had written INTO `resource` — which
@@ -674,15 +707,22 @@ ${escapeHtml(delta.refusal.detail)}</p>
674
707
  }
675
708
 
676
709
  const rows = [
677
- ...delta.resolved.map((f) => deltaRow('resolved', f, deltaBasis(delta))),
678
- ...delta.newFindings.map((f) => deltaRow('new', f, deltaBasis(delta))),
679
- ...delta.changed.map((f) => deltaRow('changed', f, `${deltaBasis(delta)}; severity ${escapeHtml(String(f.from))} → ${escapeHtml(String(f.to))}`)),
710
+ // A UDP row carries WHY its port counts as measured (1.1.1 — `basisNote`, e.g. "53/udp answered in the other run
711
+ // (dns open · dnsmasq 2.79)"), so a resolved UDP row shows its work on the row, never in a footer.
712
+ ...delta.resolved.map((f) => deltaRow('resolved', f, `${deltaBasis(delta, f, 'resolved')}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
713
+ ...delta.newFindings.map((f) => deltaRow('new', f, `${deltaBasis(delta, f, 'new')}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
714
+ ...delta.changed.map((f) => deltaRow('changed', f, `${deltaBasis(delta, f, 'changed')}; severity ${escapeHtml(String(f.from))} → ${escapeHtml(String(f.to))}`)),
680
715
  // The reason rides the row, not a legend.
681
716
  // ⚠️ THE CODE IS PRINTED VERBATIM, AND THAT IS THE DESIGN. A render-time label map was tried
682
717
  // here and reverted: it moved this seam and left the terminal's, so one outcome carried two
683
718
  // names. The noun that made a code wrong in front of a human was removed from the CODE
684
719
  // instead, which fixes every seam at once. See `NOT_COMPARABLE_REASONS` in scan_delta.mjs.
685
- ...delta.notComparable.map((f) => deltaRow('not-comparable', f, `${f.reason}: ${f.detail}`)),
720
+ // ⚠️ THE DIRECTION RIDES THE ROW (1.1.1, the audit seat's fold). Every detail sentence is direction-neutral by
721
+ // construction ("open in the run that HOLDS this finding"), so without this a client could not tell a finding that
722
+ // APPEARED — a new-exposure candidate the run could not confirm — from one that VANISHED. The terminal view has
723
+ // always printed it; this page had not.
724
+ ...delta.notComparable.map((f) => deltaRow('not-comparable', f,
725
+ `${NC_DIRECTION_WORDS[f.direction] ?? 'direction not recorded'}; ${f.reason}: ${f.detail}`)),
686
726
  ].join('\n');
687
727
 
688
728
  const counts = `${delta.newFindings.length} new · ${delta.resolved.length} resolved · `
@@ -1,6 +1,8 @@
1
1
  // utils/export_csv.mjs
2
2
  // Export scan results as CSV.
3
3
 
4
+ import { csvTokens, PAYLOAD_KEYS } from './service_flags.mjs';
5
+
4
6
  /**
5
7
  * Escape a CSV field value.
6
8
  * - Wrap in double quotes if contains comma, newline, or double quote
@@ -24,30 +26,13 @@ export function escapeCsvField(value) {
24
26
  const COLUMNS = ['host', 'port', 'protocol', 'service', 'program', 'version', 'status', 'cpe', 'security_findings'];
25
27
 
26
28
  /**
27
- * Build a security_findings string from a service record.
28
- * @param {object} svc
29
+ * The security_findings cell for a record: the shared service-flag table's tokens (utils/service_flags.mjs, 1.3.0 (s1)),
30
+ * one per key that fired with its items joined by ';'. A custom SNMP community is never a finding and never printed.
31
+ * @param {object} record
29
32
  * @returns {string}
30
33
  */
31
- function buildFindings(svc) {
32
- const parts = [];
33
-
34
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) {
35
- parts.push(`weak_algorithms:${svc.weakAlgorithms.length}`);
36
- }
37
- if (svc.anonymousLogin) {
38
- parts.push('anonymous_login');
39
- }
40
- if (svc.axfrAllowed) {
41
- parts.push('axfr_allowed');
42
- }
43
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) {
44
- parts.push(`dangerous_methods:${svc.dangerousMethods.join(';')}`);
45
- }
46
- if (svc.community) {
47
- parts.push(`default_community:${svc.community}`);
48
- }
49
-
50
- return parts.join(',');
34
+ function buildFindings(record) {
35
+ return csvTokens(record).join(',');
51
36
  }
52
37
 
53
38
  /**
@@ -77,6 +62,13 @@ export function buildCsv(scanData) {
77
62
  ];
78
63
  return fields.map(escapeCsvField).join(',');
79
64
  });
65
+ // An adapter payload that landed in evidence (no port — a domain's DNS posture when no 53/udp service was found) gets
66
+ // its own row, with no port: its findings must not depend on whether an unrelated port answered.
67
+ for (const e of conclusion?.result?.evidence ?? []) {
68
+ if (!PAYLOAD_KEYS.some((k) => e?.[k] != null)) continue;
69
+ const payload = Object.fromEntries(PAYLOAD_KEYS.filter((k) => e[k] != null).map((k) => [k, e[k]]));
70
+ rows.push([host, '', '', e.from ?? '', '', '', '', '', buildFindings(payload)].map(escapeCsvField).join(','));
71
+ }
80
72
 
81
73
  return [header, ...rows].join('\r\n') + '\r\n';
82
74
  }
@@ -0,0 +1,31 @@
1
+ // utils/mcp_call_digest.mjs
2
+ // What binds an MCP tool response to its call_id (1.3.0 (s6)). The server logs a digest of the response BODY — the text
3
+ // the receipt footer is appended to — and `nsauditor-ai mcp verify-call <id> --response <file>` recomputes it from the
4
+ // text the user saved. The digest is never printed: a printed digest would replay together with the id.
5
+ //
6
+ // Canonicalisation is WHITESPACE-INSENSITIVE and WORD-EXACT (ruled): every whitespace run, newlines included, becomes
7
+ // one space and the ends are trimmed. A copy that a client soft-wrapped or re-flowed still verifies; a changed word
8
+ // does not. Stated limits: a RENDERED copy (Markdown turned into bullets and bold) is a different text and refutes —
9
+ // copy the raw tool output; and differences inside code blocks that are whitespace only (indentation) are not seen.
10
+
11
+ import { createHash } from 'node:crypto';
12
+
13
+ /** The first line of the footer every tool response carries. */
14
+ export const RECEIPT_MARKER = '── MCP call receipt ──';
15
+
16
+ /** The response reduced to its words: every whitespace run becomes one space, the ends trimmed. */
17
+ export function canonicalResponse(text) {
18
+ return String(text ?? '').replace(/\s+/g, ' ').trim();
19
+ }
20
+
21
+ /** SHA-256 (hex) of the canonical response body. */
22
+ export function responseDigest(body) {
23
+ return createHash('sha256').update(canonicalResponse(body), 'utf8').digest('hex');
24
+ }
25
+
26
+ /** The body of a saved response: everything before the receipt footer, which is not part of what the tool produced. */
27
+ export function bodyOf(saved) {
28
+ const text = String(saved ?? '');
29
+ const at = text.lastIndexOf(RECEIPT_MARKER);
30
+ return at < 0 ? text : text.slice(0, at);
31
+ }