nsauditor-ai 0.2.55 → 0.2.56

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.
@@ -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
+ }
@@ -636,6 +636,12 @@ function deltaBasis(delta) {
636
636
  return `comparable: ${legs} present in both runs; framework enumeration: ${fw}; ${integrity}`;
637
637
  }
638
638
 
639
+ // What a not-comparable row's direction means, in the words a client reads.
640
+ const NC_DIRECTION_WORDS = Object.freeze({
641
+ appeared: 'appeared in this run — not counted as NEW',
642
+ disappeared: 'absent from this run — not counted as RESOLVED',
643
+ });
644
+
639
645
  function deltaRow(bucket, f, basis) {
640
646
  // ⚠️ THE REGION RIDES THE RESOURCE CELL (board E1(b)). Until E1 the region reached this table
641
647
  // only as a ` [<region>]` suffix that EE's region stamper had written INTO `resource` — which
@@ -674,15 +680,22 @@ ${escapeHtml(delta.refusal.detail)}</p>
674
680
  }
675
681
 
676
682
  const rows = [
677
- ...delta.resolved.map((f) => deltaRow('resolved', f, deltaBasis(delta))),
678
- ...delta.newFindings.map((f) => deltaRow('new', f, deltaBasis(delta))),
683
+ // A UDP row carries WHY its port counts as measured (1.1.1 — `basisNote`, e.g. "53/udp answered in the other run
684
+ // (dns open · dnsmasq 2.79)"), so a resolved UDP row shows its work on the row, never in a footer.
685
+ ...delta.resolved.map((f) => deltaRow('resolved', f, `${deltaBasis(delta)}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
686
+ ...delta.newFindings.map((f) => deltaRow('new', f, `${deltaBasis(delta)}${f.basisNote ? `; ${f.basisNote}` : ''}`)),
679
687
  ...delta.changed.map((f) => deltaRow('changed', f, `${deltaBasis(delta)}; severity ${escapeHtml(String(f.from))} → ${escapeHtml(String(f.to))}`)),
680
688
  // The reason rides the row, not a legend.
681
689
  // ⚠️ THE CODE IS PRINTED VERBATIM, AND THAT IS THE DESIGN. A render-time label map was tried
682
690
  // here and reverted: it moved this seam and left the terminal's, so one outcome carried two
683
691
  // names. The noun that made a code wrong in front of a human was removed from the CODE
684
692
  // 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}`)),
693
+ // ⚠️ THE DIRECTION RIDES THE ROW (1.1.1, the audit seat's fold). Every detail sentence is direction-neutral by
694
+ // construction ("open in the run that HOLDS this finding"), so without this a client could not tell a finding that
695
+ // APPEARED — a new-exposure candidate the run could not confirm — from one that VANISHED. The terminal view has
696
+ // always printed it; this page had not.
697
+ ...delta.notComparable.map((f) => deltaRow('not-comparable', f,
698
+ `${NC_DIRECTION_WORDS[f.direction] ?? 'direction not recorded'}; ${f.reason}: ${f.detail}`)),
686
699
  ].join('\n');
687
700
 
688
701
  const counts = `${delta.newFindings.length} new · ${delta.resolved.length} resolved · `
@@ -12,6 +12,7 @@
12
12
  // can't recur in a new format writer (Task N.17).
13
13
  // - `OPENAI_OUT_PATH` is honored as a legacy fallback.
14
14
 
15
+ import fsp from 'node:fs/promises';
15
16
  import path from 'node:path';
16
17
  import { toCleanPath } from './path_helpers.mjs';
17
18
 
@@ -57,3 +58,70 @@ export function resolveBaseOutDir() {
57
58
  }
58
59
 
59
60
  // (toCleanPath moved to utils/path_helpers.mjs in v0.1.20 — no _internals export needed.)
61
+
62
+ // A per-host directory's timestamp: YYYYMMDD_HHMMSS in LOCAL time, second granularity. Moved here from cli.mjs
63
+ // unchanged, beside the helper that uses it, so the directory name has ONE definition.
64
+ export function nowStamp(d = new Date()) {
65
+ const pad = (n) => String(n).padStart(2, '0');
66
+ return (
67
+ d.getFullYear().toString() +
68
+ pad(d.getMonth() + 1) +
69
+ pad(d.getDate()) + '_' +
70
+ pad(d.getHours()) +
71
+ pad(d.getMinutes()) +
72
+ pad(d.getSeconds())
73
+ );
74
+ }
75
+
76
+ // The host as a directory-name component. Moved here from cli.mjs unchanged — the per-host report filenames
77
+ // (`scan_<host>.sarif.json` / `.csv` / `.md`) use it too, so both import it from here.
78
+ export const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
79
+
80
+ /**
81
+ * Create a FRESH per-host output directory and return its path.
82
+ *
83
+ * ⚠️ AN EXCLUSIVE CREATE, NEVER `recursive: true` ON THE LEAF (1.1.1 board — Gate 3-B on build 12). The name is
84
+ * `${safeHost(host)}_${nowStamp()}`, second-granular, so two scans of one host reaching this line in the same
85
+ * second computed the SAME name, and a recursive mkdir said yes to both: measured, three localhost scans wrote TWO
86
+ * directories and run 2's `scan_conclusion_raw.json` was overwritten by run 3's, exit 0, no warning. It is worse when
87
+ * the later run's finding queue is EMPTY — Enterprise writes `scan_finding_queue.json` only when there is something
88
+ * to write, so the earlier run's queue stayed in the shared directory and the later run's seal covered it as its own.
89
+ * The same second is reachable three ways: back-to-back runs, one run naming a host twice (`--host X,X`), and the
90
+ * repeated hour when daylight saving ends (the stamp is local time).
91
+ *
92
+ * So the leaf is created with `recursive: false`, which fails with EEXIST instead of reusing a directory, and a
93
+ * collision takes the next free suffix (`_2`, `_3`, …) — a COUNTER, not the run id: the run id is shared by every
94
+ * host of one run (so it cannot separate `--host X,X`) and is unset in watch mode. A WARNING naming both directories
95
+ * goes to STDOUT, where a scan's own progress lines go.
96
+ *
97
+ * ⚠️ When the seconds differ nothing changes: the name is byte-identical to the one this code has always written,
98
+ * because every reader, every run record's `hostsWritten[].dir` and every manifest keys on it.
99
+ *
100
+ * @param {string} baseOutDir created if absent (recursively — the BASE may already exist; the leaf may not)
101
+ * @param {string} host
102
+ * @param {{ stamp?: () => string, log?: (line: string) => void, max?: number }} [opts]
103
+ * `stamp` and `log` are seams; every real invocation takes the defaults.
104
+ * @returns {Promise<string>} the directory created
105
+ */
106
+ export async function createHostOutDir(baseOutDir, host, { stamp = nowStamp, log = console.log, max = 100 } = {}) {
107
+ await fsp.mkdir(baseOutDir, { recursive: true });
108
+ // The stamp is taken ONCE: a suffix always belongs to the second it collided with.
109
+ const first = `${safeHost(host)}_${stamp()}`;
110
+ for (let n = 1; n <= max; n += 1) {
111
+ const dir = path.join(baseOutDir, n === 1 ? first : `${first}_${n}`);
112
+ try {
113
+ await fsp.mkdir(dir, { recursive: false });
114
+ } catch (e) {
115
+ if (e?.code === 'EEXIST') continue;
116
+ throw e;
117
+ }
118
+ if (n > 1) {
119
+ log(`[scan] WARNING: ${path.join(baseOutDir, first)} already exists — another scan of ${host} started in the same `
120
+ + `second — so this scan writes to ${dir} instead. Neither run's evidence overwrites the other's.`);
121
+ }
122
+ return dir;
123
+ }
124
+ // ⚠️ NEVER fall back to reusing an existing directory: that is the overwrite this function exists to prevent.
125
+ throw new Error(`could not create a fresh output directory for ${host} under ${baseOutDir}: `
126
+ + `${first} and ${max - 1} suffixed names already exist`);
127
+ }
@@ -25,6 +25,7 @@ import {
25
25
  } from './run_record.mjs';
26
26
  import { CE_RETENTION_MS } from './scan_history.mjs';
27
27
  import { canonicalHost } from './cloud_providers.mjs';
28
+ import { isUdpTransport, TRANSPORT_OF_LABEL } from './scan_delta.mjs';
28
29
 
29
30
  const refuse = (reason, message) => ({ ok: false, reason, message });
30
31
 
@@ -213,6 +214,12 @@ export function shapeFinding(host, f, plugin = null, pluginName = null) {
213
214
  // FIELD and never a suffix: a suffix is a value change that fabricates churn across an
214
215
  // upgrade, a field is recomputed identically on both sides of any comparison.
215
216
  region,
217
+ // ⚠️ NO TRANSPORT ON THE PLUGIN PATH (1.1.1), and deliberately null rather than read from `details.protocol`:
218
+ // there it is a cloud RULE's protocol (an SG / NSG ingress rule, 1170 · 1221), not the transport of a service
219
+ // finding, and no shipped plugin emits a service finding over UDP (the UDP probes write `data[]` and the
220
+ // concluder's services, never `result.findings`). So the delta's UDP rule does not reach a plugin row — the
221
+ // declared limit, pinned in tests/scan_delta_udp_port_not_measured.test.mjs.
222
+ protocol: null,
216
223
  // ⚠️ AN EVIDENCE GAP IS SCOPE, NOT A FINDING, and the delta cannot tell them apart without
217
224
  // this. A gap record says "the scanner could not read this surface" — so a finding that
218
225
  // vanished behind one was not fixed, nobody looked. It is carried on the finding rather than
@@ -294,6 +301,10 @@ function shapeQueueEntry(host, q) {
294
301
  return {
295
302
  host,
296
303
  port: q?.port ?? q?.target?.port ?? null,
304
+ // THE PRODUCER'S PROTOCOL LABEL (1.1.1), verbatim — `udp`, `upnp`, `llmnr`, `mdns`, `https`, … It is an
305
+ // application label, not a transport; the delta's `isUdpTransport` decides which labels are UDP. Before this the
306
+ // loader dropped it, so a vanished 161/udp row was judged by the TCP port rule and read RESOLVED.
307
+ protocol: typeof q?.target?.protocol === 'string' ? q.target.protocol : null,
297
308
  severity: q?.severity != null ? String(q.severity).toUpperCase() : 'INFO',
298
309
  title: q?.title ?? null,
299
310
  detail: q?.description ?? q?.detail ?? null,
@@ -503,6 +514,55 @@ export function portScanOf(raw) {
503
514
  return { tcpOpen: ports(res.tcpOpen), tcpClosed: ports(res.tcpClosed) };
504
515
  }
505
516
 
517
+ // What the run recorded about each UDP port (1.1.1) — the delta's UDP oracle. Read from the concluder's service set,
518
+ // the SAME set the queue producers (the analysis agents and the intelligence engine) are pure over, so whether a UDP
519
+ // row's absence is a measurement depends on what this set says about its port: see `udpPortMeasurement` in
520
+ // scan_delta.mjs, which both the delta and Enterprise's MTTR call. Only UDP-transport labels (a `53/tcp` record never
521
+ // stands in for `53/udp`), port > 0. Measured over 27 real router raws the UDP statuses are `open` / `no response` /
522
+ // `unknown` — never `closed`, which the concluder writes only from ECONNREFUSED (an ICMP port-unreachable, a real
523
+ // negative). Null when the raw carries no service set: no oracle, never "nothing answered".
524
+ export function udpServicesOf(raw) {
525
+ const services = raw?.conclusion?.result?.services;
526
+ if (!Array.isArray(services)) return null;
527
+ const out = [];
528
+ for (const s of services) {
529
+ const port = Number(s?.port);
530
+ if (!Number.isInteger(port) || port <= 0 || !isUdpTransport(s?.protocol)) continue;
531
+ out.push({ port, protocol: String(s.protocol).toLowerCase(), service: s?.service ?? null, status: s?.status ?? null,
532
+ program: s?.program ?? null, version: s?.version ?? null });
533
+ }
534
+ return out.sort((a, b) => a.port - b.port);
535
+ }
536
+
537
+ // Every transport's service identity (1.2.0 build 2) — the oracle for `serviceIdentityAt` in scan_delta.mjs, which the
538
+ // delta and Enterprise's MTTR both call to decide whether a CVE row vanished while the SAME program and version still
539
+ // answered. Read from the concluder's service set, the set the CVE mapper attributes from; port > 0 and a TCP or UDP
540
+ // transport only. Null when the raw carries no service set: no oracle, never "nothing answered".
541
+ export function servicesOf(raw) {
542
+ const services = raw?.conclusion?.result?.services;
543
+ if (!Array.isArray(services)) return null;
544
+ const out = [];
545
+ for (const s of services) {
546
+ const port = Number(s?.port);
547
+ const t = typeof s?.protocol === 'string' ? TRANSPORT_OF_LABEL[s.protocol.toLowerCase()] : null;
548
+ if (!Number.isInteger(port) || port <= 0 || (t !== 'tcp' && t !== 'udp')) continue;
549
+ out.push({ port, protocol: String(s.protocol).toLowerCase(), service: s?.service ?? null, status: s?.status ?? null,
550
+ program: s?.program ?? null, version: s?.version ?? null });
551
+ }
552
+ return out.sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol));
553
+ }
554
+
555
+ // WHETHER ENTERPRISE'S STAGE RAN ON THIS HOST (1.1.1 — the audit seat's T1-c ruling). The scan writes
556
+ // `conclusion.result.eeLoadError` when the installed Enterprise package failed to LOAD (utils/ee_load.mjs) and
557
+ // `conclusion.result.eeEnrichmentError` when its enrichment THREW; either way its analysis agents and CVE mapper produced
558
+ // nothing on the host. Null when neither was recorded — which is also what a Community-only scan reads.
559
+ export function eeStageOf(raw) {
560
+ const r = raw?.conclusion?.result ?? {};
561
+ const loadError = typeof r.eeLoadError === 'string' ? r.eeLoadError : null;
562
+ const enrichmentError = typeof r.eeEnrichmentError === 'string' ? r.eeEnrichmentError : null;
563
+ return loadError || enrichmentError ? { loadError, enrichmentError } : null;
564
+ }
565
+
506
566
  function shapeHost(host, dir, raw) {
507
567
  const envelopes = Array.isArray(raw.results) ? raw.results : [];
508
568
  const up = envelopes.some((e) => e?.result?.up === true);
@@ -520,6 +580,9 @@ function shapeHost(host, dir, raw) {
520
580
  // destroyed here, before the consumer ever saw it.
521
581
  pluginStatusRecorded: Array.isArray(raw.pluginStatus),
522
582
  portScan: portScanOf(raw),
583
+ udpServices: udpServicesOf(raw),
584
+ services: servicesOf(raw),
585
+ eeStage: eeStageOf(raw),
523
586
  };
524
587
  }
525
588
 
@@ -537,7 +600,7 @@ function buildModel(rec, hosts, counts) {
537
600
  // which utils/host_iterator.mjs de-duplicates). A name-keyed Map is last-write-wins and
538
601
  // silently drops every same-named host's own plugin table but the final one's.
539
602
  plugins.byHost.push({ host: h.host, dir: h.dir, status: h.pluginStatus,
540
- pluginStatusRecorded: h.pluginStatusRecorded, portScan: h.portScan });
603
+ pluginStatusRecorded: h.pluginStatusRecorded, portScan: h.portScan, udpServices: h.udpServices, services: h.services, eeStage: h.eeStage });
541
604
  for (const ps of h.pluginStatus) {
542
605
  if (ps?.status === 'ran') plugins.ran += 1;
543
606
  else if (ps?.status === 'skipped') plugins.skipped += 1;
@@ -237,6 +237,19 @@ export function buildMarkdownReport(scanData) {
237
237
  } else {
238
238
  lines.push(`- **Security findings:** 0`);
239
239
  }
240
+ // 1.2.0 build 3: this renderer's findings are the service-check FLAGS only — nothing on the scan path fills a service's
241
+ // CVEs, and Enterprise's analysis agents and exploit intelligence write elsewhere. Without this line "Security findings:
242
+ // 0" read as a clean verdict over a host whose CLI run carried 16 CVEs (the Gate 3-A preparation's P8).
243
+ // The counted list names only flags a conclusion can carry: dangerousMethods is read by extractFindings but no adapter puts
244
+ // it on a service record (tests/concluder_drops_honesty.test.mjs pins that), so it is named among what is NOT counted.
245
+ lines.push('- **Scope:** counts only these service-check flags: weak SSH algorithms, SNMP default community, weak TLS '
246
+ + 'protocols / ciphers, and anonymous FTP login and DNS zone transfer when the scan\'s environment enables those two '
247
+ + 'checks (FTP_CHECK_ANON; DNS_CHECK_AXFR with DNS_AXFR_DOMAIN — both off by default). It does not count a self-signed '
248
+ + 'certificate or the MCP server checks (both on the service records), nor dangerous HTTP methods, SMB null sessions or '
249
+ + 'the TLS-certificate, DNS-security and debug-endpoint auditors\' results (the scan\'s conclusion does not carry them); '
250
+ + 'it does not look up CVEs, and does not include Enterprise analysis-agent findings or exploit intelligence (a CLI scan '
251
+ + 'with the Enterprise package and a Pro or Enterprise licence records CVE and agent findings in scan_finding_queue.json, '
252
+ + 'when there are any).');
240
253
  lines.push('');
241
254
 
242
255
  // ---- Services table ----
@@ -267,7 +280,9 @@ export function buildMarkdownReport(scanData) {
267
280
  lines.push(`## Findings`);
268
281
  lines.push('');
269
282
  if (findings.length === 0) {
270
- lines.push('_No security findings._');
283
+ lines.push('_None of the counted service-check flags fired. This is not a statement that the host has no known '
284
+ + 'vulnerabilities — CVE lookups, analysis-agent findings, the MCP server checks and several checks\' results are not '
285
+ + 'part of this count (see Scope above), and anonymous FTP login and zone transfer are tested only when enabled._');
271
286
  lines.push('');
272
287
  } else {
273
288
  findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));