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.
- package/README.md +71 -58
- package/cli.mjs +49 -42
- package/mcp_server.mjs +15 -2
- package/package.json +1 -1
- package/plugins/mcp_scanner.mjs +2 -1
- package/plugins/result_concluder.mjs +3 -2
- package/utils/ee_load.mjs +45 -0
- package/utils/executive_report.mjs +16 -3
- package/utils/output_dir.mjs +68 -0
- package/utils/report_inputs.mjs +64 -1
- package/utils/report_md.mjs +16 -1
- package/utils/scan_delta.mjs +359 -38
- package/utils/scan_delta_view.mjs +5 -3
|
@@ -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
|
-
|
|
678
|
-
|
|
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
|
-
|
|
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 · `
|
package/utils/output_dir.mjs
CHANGED
|
@@ -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
|
+
}
|
package/utils/report_inputs.mjs
CHANGED
|
@@ -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;
|
package/utils/report_md.mjs
CHANGED
|
@@ -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('
|
|
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));
|