nsauditor-ai 0.2.56 → 0.2.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -40
- package/cli.mjs +171 -147
- package/mcp_server.mjs +64 -37
- package/package.json +1 -1
- package/plugin_manager.mjs +49 -8
- package/plugins/040_tls_cert_auditor.mjs +176 -56
- package/plugins/050_tribe_health.mjs +13 -34
- package/plugins/060_dns_sec_auditor.mjs +24 -30
- package/plugins/dns_scanner.mjs +16 -8
- package/plugins/ftp_banner_check.mjs +21 -13
- package/plugins/host_up_check.mjs +31 -72
- package/plugins/http_probe.mjs +93 -25
- package/plugins/mcp_scanner.mjs +15 -2
- package/plugins/netbios_scanner.mjs +32 -13
- package/plugins/opensearch_scanner.mjs +4 -0
- package/plugins/port_scanner.mjs +29 -49
- package/plugins/result_concluder.mjs +122 -31
- package/plugins/snmp_scanner.mjs +20 -8
- package/plugins/syn_scanner.mjs +2 -1
- package/plugins/upnp_scanner.mjs +82 -13
- package/plugins/webapp_detector.mjs +82 -15
- package/utils/conclusion_utils.mjs +13 -1
- package/utils/cookie_redaction.mjs +50 -0
- package/utils/delta_reporter.mjs +70 -25
- package/utils/executive_report.mjs +34 -7
- package/utils/export_csv.mjs +14 -22
- package/utils/mcp_call_digest.mjs +31 -0
- package/utils/net_validation.mjs +107 -37
- package/utils/output_dir.mjs +4 -2
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_md.mjs +49 -125
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +173 -18
- package/utils/scan_delta_view.mjs +3 -3
- package/utils/scan_history.mjs +149 -0
- package/utils/scheduler.mjs +17 -5
- package/utils/service_flags.mjs +363 -0
- package/utils/watch_cycle.mjs +37 -0
- package/utils/webhook.mjs +2 -1
package/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
|
-
*
|
|
39
|
-
*
|
|
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(
|
|
43
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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
|
|
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: '
|
|
91
|
-
helpUri:
|
|
92
|
-
properties: { severity:
|
|
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(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
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
|
|
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,
|
package/utils/scan_delta.mjs
CHANGED
|
@@ -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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
834
|
-
+ 'agent set is a function of the licensed capabilities, and a tier difference refuses the comparison
|
|
835
|
-
+ 'rather than narrowing it.
|
|
836
|
-
+ '
|
|
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
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
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 (${
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|