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