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.
Files changed (40) hide show
  1. package/README.md +41 -40
  2. package/cli.mjs +171 -147
  3. package/mcp_server.mjs +64 -37
  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 -2
  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 -31
  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/executive_report.mjs +34 -7
  26. package/utils/export_csv.mjs +14 -22
  27. package/utils/mcp_call_digest.mjs +31 -0
  28. package/utils/net_validation.mjs +107 -37
  29. package/utils/output_dir.mjs +4 -2
  30. package/utils/plugin_discovery.mjs +4 -2
  31. package/utils/ports_spec.mjs +46 -0
  32. package/utils/report_md.mjs +49 -125
  33. package/utils/sarif.mjs +33 -93
  34. package/utils/scan_delta.mjs +173 -18
  35. package/utils/scan_delta_view.mjs +3 -3
  36. package/utils/scan_history.mjs +149 -0
  37. package/utils/scheduler.mjs +17 -5
  38. package/utils/service_flags.mjs +363 -0
  39. package/utils/watch_cycle.mjs +37 -0
  40. package/utils/webhook.mjs +2 -1
package/utils/sarif.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Generate SARIF 2.1.0 output from nsauditor scan results.
3
3
 
4
4
  import { createRequire } from 'node:module';
5
+ import { flagFindings, conclusionFindings, OPEN_SERVICE_SEVERITY } from './service_flags.mjs';
5
6
  const require = createRequire(import.meta.url);
6
7
  const { version: TOOL_VERSION } = require('../package.json');
7
8
 
@@ -35,13 +36,12 @@ function ruleIdFromService(svc) {
35
36
  }
36
37
 
37
38
  /**
38
- * Infer severity from a service's status and other indicators.
39
- * @param {object} svc
39
+ * The "service detected" result's grade: OPEN_SERVICE_SEVERITY from the shared table (1.3.0 (s2)) — an open port is
40
+ * inventory, never a warning of its own. A service that is not open is never graded above it.
40
41
  * @returns {string}
41
42
  */
42
- function inferServiceSeverity(svc) {
43
- if (svc.status === 'open') return 'Medium';
44
- return 'Info';
43
+ function inferServiceSeverity() {
44
+ return OPEN_SERVICE_SEVERITY;
45
45
  }
46
46
 
47
47
  /**
@@ -62,101 +62,31 @@ function buildServiceMessage(svc, host) {
62
62
  }
63
63
 
64
64
  /**
65
- * Build SARIF result entries for security findings on a service.
66
- * @param {object} svc
65
+ * SARIF rule + result entries for graded findings. Every grade comes from the shared service-flag table
66
+ * (utils/service_flags.mjs, 1.3.0 (s1)); the rule ids of the four flags SARIF graded before 1.3.0 are unchanged, since a
67
+ * code-scanning alert is keyed on its rule id.
68
+ * @param {object[]} findings - from the table, each with key/severity/title/ruleId/evidence
67
69
  * @param {string} host
70
+ * @param {string} where - the location text in the message (host:port/protocol, or the host for an evidence payload)
68
71
  * @returns {{ results: object[], rules: object[] }}
69
72
  */
70
- function securityFindingsFromService(svc, host) {
73
+ function sarifEntries(findings, host, where) {
71
74
  const results = [];
72
75
  const rules = [];
73
-
74
- const makeResult = (ruleId, level, message) => ({
75
- ruleId,
76
- level,
77
- message: { text: message },
78
- locations: [{
79
- physicalLocation: {
80
- artifactLocation: { uri: host }
81
- }
82
- }]
83
- });
84
-
85
- // anonymousLogin: true
86
- if (svc.anonymousLogin === true) {
87
- const ruleId = 'ftp-anonymous-login';
76
+ for (const f of findings) {
88
77
  rules.push({
89
- id: ruleId,
90
- shortDescription: { text: 'FTP anonymous login enabled' },
91
- helpUri: `${TOOL_URI}`,
92
- properties: { severity: 'Critical' }
78
+ id: f.ruleId,
79
+ shortDescription: { text: f.key === 'cves' ? `Known vulnerability: ${f.ruleId}` : f.title },
80
+ helpUri: f.key === 'cves' ? `https://nvd.nist.gov/vuln/detail/${f.ruleId}` : TOOL_URI,
81
+ properties: { severity: f.severity }
93
82
  });
94
- results.push(makeResult(ruleId, 'error',
95
- `FTP anonymous login is enabled on ${host}:${svc.port}. This allows unauthenticated access to the FTP server.`));
96
- }
97
-
98
- // axfrAllowed: true
99
- if (svc.axfrAllowed === true) {
100
- const ruleId = 'dns-zone-transfer';
101
- rules.push({
102
- id: ruleId,
103
- shortDescription: { text: 'DNS zone transfer (AXFR) allowed' },
104
- helpUri: `${TOOL_URI}`,
105
- properties: { severity: 'Critical' }
83
+ results.push({
84
+ ruleId: f.ruleId,
85
+ level: severityToLevel(f.severity),
86
+ message: { text: `${f.title} on ${where}.${f.evidence ? ` ${f.evidence}` : ''}` },
87
+ locations: [{ physicalLocation: { artifactLocation: { uri: host } } }]
106
88
  });
107
- results.push(makeResult(ruleId, 'error',
108
- `DNS zone transfer (AXFR) is allowed on ${host}:${svc.port}. This can expose the entire DNS zone to attackers.`));
109
- }
110
-
111
- // weakAlgorithms: [...]
112
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) {
113
- for (const algo of svc.weakAlgorithms) {
114
- const algoName = typeof algo === 'string' ? algo : (algo?.algorithm || algo?.name || String(algo));
115
- const ruleId = `weak-algorithm-${algoName.replace(/[^a-zA-Z0-9._-]/g, '-')}`;
116
- rules.push({
117
- id: ruleId,
118
- shortDescription: { text: `Weak algorithm: ${algoName}` },
119
- helpUri: `${TOOL_URI}`,
120
- properties: { severity: 'Medium' }
121
- });
122
- results.push(makeResult(ruleId, 'warning',
123
- `Weak algorithm "${algoName}" is supported on ${host}:${svc.port}/${svc.protocol || 'tcp'}.`));
124
- }
125
89
  }
126
-
127
- // dangerousMethods: [...]
128
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) {
129
- for (const method of svc.dangerousMethods) {
130
- const ruleId = `http-dangerous-method-${String(method).toLowerCase()}`;
131
- rules.push({
132
- id: ruleId,
133
- shortDescription: { text: `Dangerous HTTP method: ${method}` },
134
- helpUri: `${TOOL_URI}`,
135
- properties: { severity: 'Medium' }
136
- });
137
- results.push(makeResult(ruleId, 'warning',
138
- `Dangerous HTTP method "${method}" is allowed on ${host}:${svc.port}.`));
139
- }
140
- }
141
-
142
- // CVE data (if service has cves or cve array)
143
- const cves = svc.cves || svc.cve || [];
144
- if (Array.isArray(cves)) {
145
- for (const cve of cves) {
146
- const cveId = typeof cve === 'string' ? cve : (cve?.id || cve?.cveId || String(cve));
147
- const cveSeverity = cve?.severity || 'High';
148
- const ruleId = cveId;
149
- rules.push({
150
- id: ruleId,
151
- shortDescription: { text: `Known vulnerability: ${cveId}` },
152
- helpUri: `https://nvd.nist.gov/vuln/detail/${cveId}`,
153
- properties: { severity: cveSeverity }
154
- });
155
- results.push(makeResult(ruleId, severityToLevel(cveSeverity),
156
- `${cveId} affects ${svc.program || svc.service}${svc.version && svc.version !== 'Unknown' ? ' ' + svc.version : ''} on ${host}:${svc.port}.`));
157
- }
158
- }
159
-
160
90
  return { results, rules };
161
91
  }
162
92
 
@@ -175,7 +105,7 @@ export function buildSarifLog(scanData) {
175
105
  for (const svc of services) {
176
106
  // Base service result
177
107
  const ruleId = ruleIdFromService(svc);
178
- const severity = inferServiceSeverity(svc);
108
+ const severity = inferServiceSeverity();
179
109
  const level = severityToLevel(severity);
180
110
  const message = buildServiceMessage(svc, host);
181
111
 
@@ -200,13 +130,23 @@ export function buildSarifLog(scanData) {
200
130
  });
201
131
 
202
132
  // Security findings
203
- const { results: secResults, rules: secRules } = securityFindingsFromService(svc, host);
133
+ const where = `${host}:${svc.port}/${svc.protocol || 'tcp'}`;
134
+ const { results: secResults, rules: secRules } = sarifEntries(flagFindings(svc, { target: where }), host, where);
204
135
  for (const sr of secResults) sarifResults.push(sr);
205
136
  for (const rule of secRules) {
206
137
  if (!rulesMap.has(rule.id)) rulesMap.set(rule.id, rule);
207
138
  }
208
139
  }
209
140
 
141
+ // An adapter payload that landed in EVIDENCE (no port — a domain's DNS posture when no 53/udp service was found) is
142
+ // graded too, located at the host: its grade must not depend on whether an unrelated port answered.
143
+ const onEvidence = conclusionFindings({ evidence: conclusion?.result?.evidence ?? [] }, host);
144
+ const { results: evResults, rules: evRules } = sarifEntries(onEvidence, host, String(host));
145
+ for (const sr of evResults) sarifResults.push(sr);
146
+ for (const rule of evRules) {
147
+ if (!rulesMap.has(rule.id)) rulesMap.set(rule.id, rule);
148
+ }
149
+
210
150
  return {
211
151
  $schema: SARIF_SCHEMA,
212
152
  version: SARIF_VERSION,
@@ -18,10 +18,11 @@
18
18
  // A finding may be called RESOLVED only when its host, plugin, scope and framework enumeration
19
19
  // were in scope in BOTH runs. Otherwise it goes to NOT-COMPARABLE **with its reason** — never
20
20
  // into `resolved`, and never silently dropped.
21
- // ⚠️ 1.2.0 limit, boarded for 1.2.1: "plugin" is a PLUGIN row's own producer. An analysis agent's or the
22
- // CVE mapper's row is derived from other plugins' output, and whether those were requested in both runs
23
- // is NOT checked — narrow `--plugins` in one run and such a row goes into `resolved` (or `new`).
24
- // AGENT_SCOPE_FROM_TIER discloses it; tests/build6_delta_scope_honesty.test.mjs pins it until the check exists.
21
+ // "plugin" is a PLUGIN row's own producer. An analysis agent's or the CVE mapper's row is derived from other
22
+ // plugins' output: from Enterprise 1.3.0 a scan that left one of those plugins out RECORDS it (an input-gap record),
23
+ // and the producer-gap leg refuses the row. A run made before EE 1.3.0 could not record that, so against one such a
24
+ // row still goes into `resolved` (or `new`) — the legacy limit. AGENT_SCOPE_FROM_TIER and the row's Basis cell
25
+ // disclose it; tests/build6_delta_scope_honesty.test.mjs pins both pairs.
25
26
  // Integrity is a fact about BYTES ON DISK, so the caller measures it with `utils/run_chain.mjs`
26
27
  // and passes the verdict in. Keeping it out of this module means the comparability rules can be
27
28
  // driven without a filesystem, and the chain can be driven without the delta.
@@ -109,6 +110,23 @@ export const isUdpTransport = (label) => typeof label === 'string' && TRANSPORT_
109
110
  // The queue producer whose rows are CVE matches over a service's IDENTITY (program + version). Community cannot import
110
111
  // Enterprise, so the name is declared here and Enterprise's test holds it equal to its `INTELLIGENCE_ENGINE_SOURCE`.
111
112
  export const CVE_MAPPER_PRODUCER = 'intelligence_engine';
113
+ // THE PRODUCERS THAT ATTRIBUTE ON A SERVICE'S IDENTITY (1.3.0, lane 6 — F2 (b)). Each writes a row only for a service
114
+ // whose program AND version it can read — the CVE mapper matches CVEs on them, Enterprise's service agent judges
115
+ // end-of-life on them — so a service the run could not identify gives it nothing to attribute, and its silence there
116
+ // is not a measurement. Read at exactly two sites: `tcpServiceMeasurement`'s producer branch, and the delta's reach gate
117
+ // that calls it for a TCP port the other run saw open. The mapper's other sites (its lookup gaps and notes,
118
+ // `vulnerability-data-changed`, the "newly attributed" basis note) are CVE semantics and do NOT read this set.
119
+ // Each member names its own row in the refusal sentence; the set is this table's keys, so no member lacks one.
120
+ const IDENTIFYING_ROW = Object.freeze({
121
+ [CVE_MAPPER_PRODUCER]: Object.freeze({ row: 'a CVE row', denied: 'the CVE mapper nothing to match' }),
122
+ service_agent: Object.freeze({ row: 'an end-of-life row', denied: 'the service agent nothing to judge' }),
123
+ });
124
+ export const IDENTIFYING_PRODUCERS = Object.freeze(Object.keys(IDENTIFYING_ROW));
125
+ // THE RELEASE THAT ADDED THE NOT-REQUESTED CAUSE (1.3.0, lane 6 — F2). From EE 1.3.0 an analysis agent whose input plugin
126
+ // was LEFT OUT of the scan records an input-gap record, so its rows there are refused; a run before it could not record
127
+ // that (its input-gap record, since EE 1.1.0 build 5, covered a plugin that was requested and did not complete). The
128
+ // client's Basis cell keys its legacy sentence on this, through `cmpVersion`, against the run that LACKS the row.
129
+ export const NOT_REQUESTED_RECORD_SINCE_EE = '1.3.0';
112
130
  const identified = (program) => typeof program === 'string' && program.trim() !== '' && program.trim().toLowerCase() !== 'unknown';
113
131
 
114
132
  /**
@@ -155,6 +173,39 @@ export function udpPortMeasurement(port, producer, services, runName = 'the othe
155
173
  const who = identified(s.program) ? ` · ${s.program}${s.version ? ` ${s.version}` : ''}` : '';
156
174
  return { measured: true, basis: `${port}/udp answered in ${runName} (${s.service ?? s.protocol} open${who})` };
157
175
  }
176
+ /**
177
+ * WAS THIS TCP SERVICE MEASURED IN A RUN, FOR THIS PRODUCER? (1.3.0, lane 6 — the TCP-unidentified sibling of F2.) The ONE
178
+ * decision the delta and Enterprise's MTTR call for a TCP port the run's port scanner saw OPEN; `services` is that run's
179
+ * service set, every transport (the loader's `servicesOf`), or null when it recorded none.
180
+ *
181
+ * - an analysis agent outside `IDENTIFYING_PRODUCERS` → MEASURED: it reads the fields its probes filled, so an open
182
+ * port is enough (as for UDP).
183
+ * - an IDENTIFYING producer (the CVE mapper, the service agent) → MEASURED only when an open TCP-transport record on the
184
+ * port carries an IDENTITY — a program AND a version (`identityOf`). Each writes nothing for a service it cannot
185
+ * identify, so a port that answered without one gives it nothing to attribute. MEASURED through the real concluder:
186
+ * with the SSH probe (002) left out, 22/tcp concludes `Unknown` / `Unknown`, and every CVE row on it read RESOLVED —
187
+ * and every end-of-life row the service agent had written there.
188
+ * - no service set at all → `measured: null` (NO ORACLE) — never false: a TCP row with no oracle stays outside, as the TCP
189
+ * port rule does without 003. A UDP record never stands in for TCP.
190
+ * `runName` has NO default: every caller names the run absolutely (the delta its frame's name, MTTR "this scan"), and a
191
+ * relative default is what tests/delta_run_names_absolute.test.mjs allows exactly once, for udpPortMeasurement.
192
+ *
193
+ * @returns {{measured: true, basis: string} | {measured: false, kind: 'unidentified', why: string} | {measured: null, kind: 'no-oracle', why: string}}
194
+ */
195
+ export function tcpServiceMeasurement(port, producer, services, runName) {
196
+ if (!Array.isArray(services)) {
197
+ return { measured: null, kind: 'no-oracle', why: `${runName} recorded no service set, so nothing says what answered there` };
198
+ }
199
+ if (!IDENTIFYING_PRODUCERS.includes(producer)) return { measured: true, basis: `${port}/tcp open in ${runName}` };
200
+ const open = services.filter((s) => Number(s?.port) === Number(port) && transportOf(s?.protocol) === 'tcp' && s?.status === 'open');
201
+ const id = open.map((s) => ({ s, id: identityOf(s.program, s.version) })).find((x) => x.id);
202
+ if (id) return { measured: true, basis: `${port}/tcp answered in ${runName} (${id.s.service ?? id.s.protocol} open · ${id.id.program} ${id.id.version})` };
203
+ const s = open[0];
204
+ const shown = (v) => (identified(v) ? v : 'absent');
205
+ return { measured: false, kind: 'unidentified', why: s
206
+ ? `in ${runName} the TCP service on that port answered but was not identified (program ${shown(s.program)}, version ${shown(s.version)})`
207
+ : `in ${runName} the port was open but no TCP service record on it carries a program and a version` };
208
+ }
158
209
  // ⚠️ A CVE ROW THAT VANISHED WHILE THE SAME PROGRAM AND VERSION STILL ANSWER WAS NOT FIXED (1.2.0 build 2 — the audit
159
210
  // seat's ruling on the build-1 smoke). The CVE mapper attributes a CVE on (program, version) alone, so a row present in
160
211
  // one run only while the IDENTIFIED program AND version at that host:port:transport are the same in both runs cannot
@@ -189,6 +240,38 @@ export function serviceIdentityAt(services, port, protocol) {
189
240
  /** The same identified program AND version — both present. Program is compared case-insensitively, version exactly. */
190
241
  export const sameServiceIdentity = (a, b) => a != null && b != null
191
242
  && a.program.toLowerCase() === b.program.toLowerCase() && a.version === b.version;
243
+ // A message with every ABSOLUTE local path reduced to what a reader needs (1.3.0, B6-4a): its tail after the LAST
244
+ // `node_modules` (the package-relative module, e.g. `@nsasoft/nsauditor-ai-ee/utils/x.mjs`), else its basename when that is
245
+ // a FILE name at depth >= 3 after the root, else `<local path>` (a home directory's basename is the user's name). A load
246
+ // error names absolute paths, and a client artifact must never carry the operator's directory layout — the rule
247
+ // `vulnerabilityDataSource` below states for its own detail. POSIX and Windows paths and a `file://` URL; a path in
248
+ // quotes may hold spaces, and so may an unquoted one that ends in a module file (Node's "imported from <path>"). A path
249
+ // whose closing quote was cut off is still reduced: an opening quote is a lead for the unquoted rules too.
250
+ // ⚠️ LIMIT, pinned: an UNQUOTED path holding a space and NO module extension reduces only up to its first space
251
+ // (`C:\Program Files\x\state` → `<local path> Files\x\state`) — nothing marks where such a path ends. Node quotes those paths,
252
+ // so its own messages are covered; a message composed by hand might not be. A UNC path (`\\server\share`) matches no
253
+ // rule and passes through. And the reduction runs BEFORE the caller's length cap, never after: a cap landing inside
254
+ // `/Users/<name>/` would leave fragments of the user's name.
255
+ const QUOTED_PATH = /(['"`])((?:file:\/\/)?(?:\/|[A-Za-z]:\\)[^'"`\n]*)\1/g;
256
+ const UNQUOTED_MODULE_PATH = /(^|[\s(='"`])((?:file:\/\/)?(?:\/|[A-Za-z]:\\)[^'"\n]*?\.(?:mjs|cjs|js|json|node)(?::\d+)*)(?=$|[\s'"),;])/g;
257
+ const UNQUOTED_PATH = /(^|[\s(='"`])((?:file:\/\/)?(?:\/|[A-Za-z]:\\)[^\s'"()]+)/g;
258
+ const LOCAL_PATH = '<local path>';
259
+ function shortPath(p) {
260
+ const s = p.replace(/^file:\/\//, '');
261
+ const i = Math.max(s.lastIndexOf('node_modules/'), s.lastIndexOf('node_modules\\'));
262
+ if (i >= 0) return s.slice(i + 'node_modules/'.length);
263
+ // A FILE name below the first component survives; anything else is the layout itself. A home directory's basename IS
264
+ // the user's name, and a dotted user name (`/Users/j.doe`) carries an "extension" — hence BOTH conditions.
265
+ const parts = s.replace(/^[A-Za-z]:\\/, '').split(/[\\/]/).filter(Boolean);
266
+ const base = parts.at(-1) ?? '';
267
+ return parts.length >= 3 && /\.[A-Za-z0-9]+(?::\d+)*$/.test(base) ? base : LOCAL_PATH;
268
+ }
269
+ export function withoutLocalPaths(text) {
270
+ return String(text)
271
+ .replace(QUOTED_PATH, (_, q, p) => `${q}${shortPath(p)}${q}`)
272
+ .replace(UNQUOTED_MODULE_PATH, (_, lead, p) => `${lead}${shortPath(p)}`)
273
+ .replace(UNQUOTED_PATH, (_, lead, p) => `${lead}${shortPath(p)}`);
274
+ }
192
275
  /**
193
276
  * WHERE A RUN'S VULNERABILITY DATA CAME FROM, in words, from its run record's `nvdCache` — never a local path, which
194
277
  * would carry the operator's directory layout into a client artifact. `state` is the offline store's (`absent` means
@@ -317,6 +400,12 @@ export const IDENTITY_BASIS_CHANGED_AT = Object.freeze({
317
400
  // rows one count-free title with the count in details. Both put new values into `keyOf` — `port`, `title` — which
318
401
  // CHANGES WHAT THOSE FINDINGS ARE, so the straddle is declared like 1040's region stamp.
319
402
  1023: '1.1.0',
403
+ // ⚠️ 1160 JOINS AT 1.3.0 — THE 1110 KIND, A TEXT CORRECTION (B4-8a). Its dimension-4 row (endpoint type substrate) said
404
+ // "Privacy + CC6.6 substrate evidence: PrivateLink connectivity attestation", and no rule in any pack matches that title;
405
+ // the claim was WITHDRAWN at 1.3.0. The issue IS the title, so every dim-4 row straddling the upgrade would otherwise read
406
+ // as one finding resolved and a new one appearing. Enterprise's instrument cannot measure this mover — its corpus account
407
+ // has no VPC endpoints — so it is premised there on REACH (the 1023 precedent), and the premise goes false the day it can.
408
+ 1160: '1.3.0',
320
409
  // ⚠️ AN ANALYSIS AGENT, NOT A PLUGIN, AND THE FIRST NON-NUMERIC KEY THIS TABLE HAS HELD. A
321
410
  // finding from Enterprise's finding QUEUE emits no resource, no region, no identity qualifier
322
411
  // and no content digest, so `keyOf` reduces to `host · producer · port · TITLE` — the title IS
@@ -330,6 +419,18 @@ export const IDENTITY_BASIS_CHANGED_AT = Object.freeze({
330
419
  // actually emits, because a key outside the vocabulary can never match a finding — and a
331
420
  // declaration that matches nothing is SILENT while the real producer stays undeclared.
332
421
  intelligence_engine: '1.1.0',
422
+ // ⚠️ exposure_agent JOINS AT 1.3.0 — THE SAME KIND: ITS TITLE IS WHAT IT NAMES (lane 6, F2's exposure sibling). Its rows
423
+ // were titled `Management port 22 (ssh) open` / `Database port 3306 (mysql) open`, embedding the service the probe
424
+ // identified — so with the SSH probe left out the same open port titled `Management port 22 (unknown) open`, and a LABEL
425
+ // move read as one row RESOLVED and one NEW. From 1.3.0 the title is port-keyed (`Management port 22 open`) and the
426
+ // service rides `target.service` and the description, so every exposure row straddling the upgrade is declared once.
427
+ exposure_agent: '1.3.0',
428
+ // ⚠️ service_agent JOINS AT 1.3.0 — THE SAME KIND, IN THE SAME RELEASE (lane 6). Its rows were titled `End-of-life OpenSSH
429
+ // 6.6p1 on port 22`, embedding the version it judged — so an identified move that stayed end-of-life (6.6p1 → 6.7p1) read
430
+ // one row RESOLVED and one NEW, and MTTR closed the first while the host was still end-of-life. From 1.3.0 the title is
431
+ // `End-of-life OpenSSH on port 22` and the version rides `target.version` and the description. Declared beside
432
+ // exposure_agent's so a customer takes both agent straddles in one upgrade, said once.
433
+ service_agent: '1.3.0',
333
434
  });
334
435
 
335
436
  // ⚠️ NO SECOND COMPARATOR. This file already has `cmpVersion` (below, used by the
@@ -830,11 +931,10 @@ export const SCOPE_NOT_EVALUATED =
830
931
  // away from — a digest over volatile prose reintroduces the volatility through the digest.
831
932
  export const AGENT_SCOPE_FROM_TIER =
832
933
  'Agent-produced findings: their scope is derived from the run TIER, plus the records a run writes when '
833
- + 'Enterprise failed on a host, an agent did not run, or a requested plugin it reads did not complete — the '
834
- + 'agent set is a function of the licensed capabilities, and a tier difference refuses the comparison outright '
835
- + 'rather than narrowing it. What is NOT compared is whether the plugins an analysis agent or the CVE mapper '
836
- + 'reads were REQUESTED in both runs: when the two runs requested different --plugins, such a row can read '
837
- + 'resolved or new with nothing changed. Keep --plugins identical between compared runs. '
934
+ + 'Enterprise failed on a host, an agent did not run, or a plugin it reads was left out of the scan or did not '
935
+ + 'complete — the agent set is a function of the licensed capabilities, and a tier difference refuses the comparison '
936
+ + 'outright rather than narrowing it. A run made before EE 1.3.0 could not record a plugin left out of it, so in a '
937
+ + 'comparison with one, an agent row that run lacks is not refused; the row\'s Basis cell says so. '
838
938
  + 'Their IDENTITY is also narrower than a plugin finding\'s: an agent finding is keyed on host, '
839
939
  + 'producer, port and title, and on nothing else — it carries no object (resource), no region, '
840
940
  + 'no rule qualifier and NO CONTENT DIGEST. So two agent findings that differ only in text the '
@@ -881,11 +981,12 @@ function incomparabilityReason(f, mine, theirs, names) {
881
981
  // mode this engine was built to avoid on the other axis. Its scope is the run TIER, and the two
882
982
  // whole-comparison refusals above (`ee-presence-differs`, `tier-differs`) guarantee that by the
883
983
  // time control reaches here both sides carry Enterprise and the SAME tier, so the agent SET is
884
- // identical on both. ⚠️ THAT DOES NOT MAKE AN AGENT ROW COMPARABLE (1.2.0 limit, boarded for
885
- // 1.2.1): the agent — and the CVE mapper — derives its rows from other plugins' output, and those
886
- // can differ at the same tier when `--plugins` was narrowed in one run. Nothing here checks them,
887
- // so such a row reads resolved or new; AGENT_SCOPE_FROM_TIER says so. That per-finding INPUT check
888
- // CAN fail through the shipped path — it is owed, not dead code.
984
+ // identical on both. ⚠️ THAT DOES NOT MAKE AN AGENT ROW COMPARABLE BY ITSELF: the agent — and the
985
+ // CVE mapper — derives its rows from other plugins' output, and those can differ at the same tier
986
+ // when `--plugins` was narrowed in one run. From Enterprise 1.3.0 the narrowed run RECORDS it (an
987
+ // input-gap record naming the plugin not requested) and the producer-gap leg below refuses the row;
988
+ // a run made before EE 1.3.0 records nothing, so against one such a row reads resolved or new —
989
+ // AGENT_SCOPE_FROM_TIER and the row's Basis cell say so.
889
990
  if (f.producerKind !== 'agent' && !theirs.plugins.has(f.plugin)) {
890
991
  // ⚠️ THE NOUN HERE IS A LITERAL `plugin` AND THAT IS CORRECT — but only because of the
891
992
  // `producerKind !== 'agent'` guard on this very line, which makes the branch unreachable for
@@ -909,7 +1010,7 @@ function incomparabilityReason(f, mine, theirs, names) {
909
1010
  const what = stage.loadError ? 'failed to load' : 'failed during enrichment';
910
1011
  return { reason: 'evidence-gap',
911
1012
  detail: `Enterprise ${what} on ${f.host} in ${which} — none of its analysis agents or its CVE mapper ran there, so `
912
- + `this ${producerNoun(f)}'s rows were not looked for (${String(stage.loadError ?? stage.enrichmentError).slice(0, 200)})` };
1013
+ + `this ${producerNoun(f)}'s rows were not looked for (${withoutLocalPaths(stage.loadError ?? stage.enrichmentError).slice(0, 200)})` };
913
1014
  }
914
1015
  }
915
1016
  // ⚠️ THE PRODUCER CHANGED WHAT IT NAMES BETWEEN THESE TWO RELEASES, so its two keys for one
@@ -992,6 +1093,17 @@ function incomparabilityReason(f, mine, theirs, names) {
992
1093
  + `${TRANSPORT_OF_LABEL[String(f.protocol ?? '').toLowerCase()] ?? f.protocol} (${f.gapClass}), present in only one of the `
993
1094
  + 'two runs — a coverage gap that opened or cleared, not an exposure that appeared or was fixed.' };
994
1095
  }
1096
+ // ⚠️ A COVERAGE NOTE THAT ENDED IS NOT A REMEDIATION (1.3.0, the audit seat's ruling P — the precedent just above). A
1097
+ // note is the mapper's own record of what it could not show on a service (a truncation; a CVE NVD lists for the product
1098
+ // only at version NA). Its ending fixed nothing, whether or not the service changed: an upgrade ends a note without
1099
+ // remediating anything, and the CVE rows carry the upgrade's signal — so this is answered BEFORE the identity rule. The
1100
+ // VANISHED direction only: a note that appears is a disclosure to be seen, and stays NEW.
1101
+ if (isEngineNoteRow(f) && names.mine === RUN_NAMES.baseline) {
1102
+ return { reason: 'evidence-gap',
1103
+ detail: `this is a coverage note of the CVE mapper (${f.gapClass}) on ${f.port}/`
1104
+ + `${TRANSPORT_OF_LABEL[String(f.protocol ?? '').toLowerCase()] ?? f.protocol}, present in ${names.mine} only — it recorded `
1105
+ + 'what the mapper could not show there, so its ending is not a remediation: nothing was fixed.' };
1106
+ }
995
1107
  if (f.plugin === CVE_MAPPER_PRODUCER && Number(f.port) > 0) {
996
1108
  const lookupGap = theirs.engineLookupGaps?.get(engineLookupGapKey(f.host, f.port, f.protocol));
997
1109
  if (lookupGap) {
@@ -1024,6 +1136,22 @@ function incomparabilityReason(f, mine, theirs, names) {
1024
1136
  + 'reads TCP ports the port scanner saw open; a UDP-transport finding is judged by its own rule, and a '
1025
1137
  + 'host-wide finding by neither.)' };
1026
1138
  }
1139
+ // ⚠️ THE PORT ANSWERED BUT ITS SERVICE WAS NOT IDENTIFIED (1.3.0, lane 6 — F2's TCP-unidentified sibling). A row of an
1140
+ // IDENTIFYING producer (`IDENTIFYING_PRODUCERS`: the CVE mapper, the service agent) on a TCP port the OTHER run's port
1141
+ // scanner saw OPEN, where `tcpServiceMeasurement` finds no open record there carrying a program AND a version: the
1142
+ // producer had nothing to attribute, so the row's absence (or appearance) is not a measurement. Both directions. After the lookup-gap leg (a `no_version_detected` record is the mapper SAYING so, and
1143
+ // answers first) and the port leg above; before the vulnerability-data rule, which needs an identity on both sides.
1144
+ // No service set in the other run → silent, as this TCP rule is without 003 (a stated limit).
1145
+ if (IDENTIFYING_PRODUCERS.includes(f.plugin) && !f.gapClass && other?.open?.has(port)) {
1146
+ const m = tcpServiceMeasurement(port, f.plugin, other.services ?? null, names.theirs);
1147
+ if (m.measured === false) {
1148
+ const w = IDENTIFYING_ROW[f.plugin];
1149
+ return { reason: PORT_NOT_MEASURED_REASON,
1150
+ detail: `${port}/tcp on ${f.host} carries ${w.row} in only one of the two runs, and ${m.why}. An unidentified `
1151
+ + `service gives ${w.denied}, so the absence of ${w.row} there is not a measurement. It is `
1152
+ + 'NOT reported as fixed or as new — rescan once the service\'s program and version can be read.' };
1153
+ }
1154
+ }
1027
1155
  }
1028
1156
  // ⚠️ A UDP-TRANSPORT ROW PRESENT IN ONE RUN ONLY (1.1.1). No UDP producer in this release records a port as
1029
1157
  // CLOSED, so the other run's silence on this port is not a measurement: never RESOLVED, never NEW. Its precondition
@@ -1052,7 +1180,8 @@ function incomparabilityReason(f, mine, theirs, names) {
1052
1180
  // ⚠️ THE SAME PROGRAM AND VERSION ANSWERED IN BOTH RUNS, AND ONE OF THEM HOLDS A CVE ROW THE OTHER DOES NOT (1.2.0
1053
1181
  // build 2). DISAPPEARED only: a newly attributed CVE is new knowledge about an exposure that is real now, so it stays
1054
1182
  // NEW and carries a basis note instead (`withNewBasis`). After the port and UDP legs, so a port that was not measured
1055
- // keeps its own reason; the mapper's CVE rows only — its gap and note rows carry a `gapClass` and are not attributions.
1183
+ // keeps its own reason; the mapper's CVE rows only — its gap and note rows carry a `gapClass`, are not attributions, and
1184
+ // are answered earlier (`isEngineLookupGap`, `isEngineNoteRow`).
1056
1185
  if (f.plugin === CVE_MAPPER_PRODUCER && !f.gapClass && port > 0 && names.mine === RUN_NAMES.baseline) {
1057
1186
  const was = serviceIdentityAt(mine.portState?.get(hostKey(f.host))?.services, port, f.protocol);
1058
1187
  const now = serviceIdentityAt(theirs.portState?.get(hostKey(f.host))?.services, port, f.protocol);
@@ -1103,11 +1232,16 @@ export const ENGINE_GAP_CLASS_KIND = Object.freeze({
1103
1232
  cpe_map_miss: 'lookup-failed',
1104
1233
  no_version_detected: 'lookup-failed',
1105
1234
  truncated_low_severity_cves: 'note', // a COVERAGE NOTE: the lookup worked and some LOW rows were folded
1235
+ // 1.3.0 (B6-4b): the lookup worked; NVD lists ONE CVE for the product only at version NA, which no banner can decide. A
1236
+ // note, never `lookup-failed` — that kind refuses every CVE row on the port, and this one is on every scan of the product.
1237
+ cve_listed_without_version: 'note',
1106
1238
  input_gap: 'input-gap', // the engine's input gap — an evidence gap, handled by the recorded-gap leg
1107
1239
  });
1108
1240
  /** A CVE-mapper lookup-gap row, port-scoped. Written as plain `f.` reads so the boundary contract sees them. */
1109
1241
  export const isEngineLookupGap = (f) => f != null && f.plugin === CVE_MAPPER_PRODUCER
1110
1242
  && ENGINE_GAP_CLASS_KIND[f.gapClass] === 'lookup-failed' && Number(f.port) > 0;
1243
+ /** A CVE-mapper coverage NOTE (1.3.0, ruling P): its class is classified 'note'. Its ending is never a remediation. */
1244
+ export const isEngineNoteRow = (f) => f != null && f.plugin === CVE_MAPPER_PRODUCER && ENGINE_GAP_CLASS_KIND[f.gapClass] === 'note';
1111
1245
  /** The key a lookup gap and the CVE rows it covers share: host (as `hostKey`), port, and the TRANSPORT of the label. */
1112
1246
  export const engineLookupGapKey = (host, port, protocol) =>
1113
1247
  `${hostKey(host)}|${Number(port)}|${TRANSPORT_OF_LABEL[String(protocol ?? '').toLowerCase()] ?? `label:${String(protocol ?? '').toLowerCase()}`}`;
@@ -1128,6 +1262,24 @@ function withUdpBasis(f, theirs, theirName) {
1128
1262
  const m = udpPortMeasurement(port, f.plugin, theirs.portState?.get(hostKey(f.host))?.udpServices ?? null, theirName);
1129
1263
  return m.measured ? { ...f, basisNote: m.basis } : f;
1130
1264
  }
1265
+ /** The closed-port oracle's basis for a TCP row: the other run's port scanner measured the port CLOSED. */
1266
+ export const tcpPortClosedBasis = (port, runName) => `${port}/tcp closed in ${runName}`;
1267
+ // A TCP row of an IDENTIFYING producer that RESOLVED or APPEARED (1.3.0, lane 6 — deltaBasis) shows the measurement that
1268
+ // made it comparable: the TCP decision's OWN basis where the other run saw the port open and identified the service, or
1269
+ // the closed-port basis — never a sentence of its own, so the cell cannot say "identified" about a port nobody identified.
1270
+ // An analysis agent outside the set needs no more than an open port, and its cell claims no more.
1271
+ function withTcpBasis(f, theirs, theirName) {
1272
+ const port = Number(f.port);
1273
+ if (!(port > 0) || f.gapClass || !IDENTIFYING_PRODUCERS.includes(f.plugin)) return f;
1274
+ const ps = theirs.portState?.get(hostKey(f.host));
1275
+ if (ps?.closed?.has(port)) return { ...f, basisNote: tcpPortClosedBasis(port, theirName) };
1276
+ if (ps?.open?.has(port)) {
1277
+ const m = tcpServiceMeasurement(port, f.plugin, ps.services ?? null, theirName);
1278
+ if (m.measured === true) return { ...f, basisNote: m.basis };
1279
+ }
1280
+ return f;
1281
+ }
1282
+ const withMeasuredBasis = (f, theirs, theirName) => (isUdpTransport(f.protocol) ? withUdpBasis(f, theirs, theirName) : withTcpBasis(f, theirs, theirName));
1131
1283
 
1132
1284
  // A CVE row that APPEARED on a service identified the same in both runs (1.2.0 build 2 — MANDATORY, the audit seat's
1133
1285
  // ruling): it stays NEW, and says the service did not change, because "new exposure on my estate" and "new knowledge
@@ -1142,7 +1294,7 @@ function withNewBasis(f, mine, theirs) {
1142
1294
  + `in ${RUN_NAMES.baseline}; the service is unchanged, the vulnerability data is not` };
1143
1295
  }
1144
1296
  }
1145
- return withUdpBasis(f, theirs, RUN_NAMES.baseline);
1297
+ return withMeasuredBasis(f, theirs, RUN_NAMES.baseline);
1146
1298
  }
1147
1299
 
1148
1300
  // The oracle test, separated from the per-finding check so its ABSENCE has somewhere to be
@@ -1410,7 +1562,7 @@ export function buildScanDelta({ baseline, current }) {
1410
1562
  }
1411
1563
  const why = incomparabilityReason(f, bScope, cScope, FRAME_DISAPPEARED);
1412
1564
  if (why) notComparable.push({ ...f, direction: 'disappeared', ...why });
1413
- else resolved.push(withUdpBasis(f, cScope, RUN_NAMES.current));
1565
+ else resolved.push(withMeasuredBasis(f, cScope, RUN_NAMES.current));
1414
1566
  }
1415
1567
  for (const [k, f] of cMap) {
1416
1568
  if (bMap.has(k)) continue;
@@ -1425,6 +1577,9 @@ export function buildScanDelta({ baseline, current }) {
1425
1577
  schema: SCAN_DELTA_SCHEMA, comparable: true, refusal: null,
1426
1578
  newFindings, resolved, unchanged, changed, notComparable,
1427
1579
  baselineIntegrity, currentIntegrity, limits,
1580
+ // Each run's EE version, read off its own record (null when it records none) — append-only. Read by the executive
1581
+ // report's Basis cell, which keys its legacy sentence on the run that LACKS an agent row.
1582
+ baselineEeVersion: bEE, currentEeVersion: cEE,
1428
1583
  coverage: {
1429
1584
  hostsOnlyInBaseline: [...bScope.hosts].filter((h) => !cScope.hosts.has(h)).map((h) => shownHost(bScope, h)),
1430
1585
  hostsOnlyInCurrent: [...cScope.hosts].filter((h) => !bScope.hosts.has(h)).map((h) => shownHost(cScope, h)),
@@ -4,9 +4,9 @@
4
4
  // silently diffing against it. If the selected run was narrow — a `--plugins` subset, one host,
5
5
  // an interrupted run — the delta is practically a wall of NOT-COMPARABLE, and an operator who
6
6
  // meets that twice stops reading the feature. So the baseline's id, timestamp and scope are
7
- // printed BEFORE any verdict. ⚠️ Not for an AGENT row (1.2.0 limit): an analysis agent's or the
8
- // CVE mapper's row whose input plugins a `--plugins` subset dropped reads resolved or new instead
9
- // of NOT-COMPARABLE — see AGENT_SCOPE_FROM_TIER.
7
+ // printed BEFORE any verdict. An analysis agent's or the CVE mapper's row whose input plugin a
8
+ // `--plugins` subset dropped is refused too, from Enterprise 1.3.0 (the narrowed run records it);
9
+ // against a run made before EE 1.3.0 it reads resolved or new — see AGENT_SCOPE_FROM_TIER.
10
10
  //
11
11
  // ⚠️ AND REFUSING IS NOT STONEWALLING. When the selected baseline cannot support a verdict, this
12
12
  // says which earlier records exist and which of them are chain-verified — but it NEVER