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
package/cli.mjs
CHANGED
|
@@ -17,18 +17,21 @@ import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
|
|
|
17
17
|
import { buildSarifLog } from './utils/sarif.mjs';
|
|
18
18
|
import { buildCsv } from './utils/export_csv.mjs';
|
|
19
19
|
import { buildMarkdownReport } from './utils/report_md.mjs';
|
|
20
|
-
import {
|
|
20
|
+
import { conclusionFindings, maxRank, severityRank as flagSeverityRank, OPEN_SERVICE_SEVERITY } from './utils/service_flags.mjs';
|
|
21
|
+
import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE, historyServiceEntry, historyHostEntry } from './utils/scan_history.mjs';
|
|
21
22
|
import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
|
|
22
23
|
import { getTierFromEnv, loadLicense } from './utils/license.mjs';
|
|
23
24
|
import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } from './utils/capabilities.mjs';
|
|
24
25
|
import { createScheduler } from './utils/scheduler.mjs';
|
|
25
|
-
import {
|
|
26
|
+
import { watchCycle } from './utils/watch_cycle.mjs';
|
|
27
|
+
import { redactSetCookieLines } from './utils/cookie_redaction.mjs';
|
|
26
28
|
import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
|
|
27
29
|
import { scrubByKey } from './utils/redact.mjs';
|
|
28
|
-
import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
|
|
30
|
+
import { isBlockedIp, resolveAndValidate, allowAllHosts, canonicalIp } from './utils/net_validation.mjs';
|
|
29
31
|
import { getAllTechniques } from './utils/attack_map.mjs';
|
|
30
32
|
import { TOOL_VERSION } from './utils/tool_version.mjs';
|
|
31
|
-
import { resolveBaseOutDir } from './utils/output_dir.mjs';
|
|
33
|
+
import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
|
|
34
|
+
import { loadEnterprise } from './utils/ee_load.mjs';
|
|
32
35
|
import { deriveFindingsCount } from './utils/report_inputs.mjs';
|
|
33
36
|
import { toCleanPath } from './utils/path_helpers.mjs';
|
|
34
37
|
import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE, aggregateNvdCache, abortReasonOf } from './utils/run_record.mjs';
|
|
@@ -45,19 +48,7 @@ const parseBool = (val, def = false) => {
|
|
|
45
48
|
if (!s && def != null) return !!def;
|
|
46
49
|
return ['true', '1', 'yes', 'on', 'y'].includes(s);
|
|
47
50
|
};
|
|
48
|
-
|
|
49
|
-
const d = new Date();
|
|
50
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
51
|
-
return (
|
|
52
|
-
d.getFullYear().toString() +
|
|
53
|
-
pad(d.getMonth() + 1) +
|
|
54
|
-
pad(d.getDate()) + '_' +
|
|
55
|
-
pad(d.getHours()) +
|
|
56
|
-
pad(d.getMinutes()) +
|
|
57
|
-
pad(d.getSeconds())
|
|
58
|
-
);
|
|
59
|
-
};
|
|
60
|
-
const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
|
|
51
|
+
// `nowStamp` and `safeHost` live in utils/output_dir.mjs beside `createHostOutDir`, so the directory name has one definition.
|
|
61
52
|
// toCleanPath imported from ./utils/path_helpers.mjs (consolidated in v0.1.20)
|
|
62
53
|
|
|
63
54
|
/**
|
|
@@ -116,6 +107,9 @@ export function redactSensitiveForAI(input, targetHost) {
|
|
|
116
107
|
s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
|
|
117
108
|
// The shared rule; this call site's POLICY keeps private addresses and masks public ones.
|
|
118
109
|
s = scrubIPv4KeepingProductIds(s, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
|
|
110
|
+
// A scanned service's Set-Cookie VALUE is its session token: redacted here whatever a producer kept (1.3.0, both
|
|
111
|
+
// seams ruled — the webapp detector still reflects its headers into the result).
|
|
112
|
+
s = redactSetCookieLines(s);
|
|
119
113
|
return s;
|
|
120
114
|
};
|
|
121
115
|
|
|
@@ -157,17 +151,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
157
151
|
: await resolveSecret(process.env.OPENAI_API_KEY);
|
|
158
152
|
const key = keyRaw ? String(keyRaw).trim() : null;
|
|
159
153
|
|
|
160
|
-
// Per-scan folder.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
if (!outDir)
|
|
165
|
-
|
|
166
|
-
await fsp.mkdir(baseOutDir, { recursive: true });
|
|
167
|
-
const ts = nowStamp();
|
|
168
|
-
const runDir = `${safeHost(host)}_${ts}`;
|
|
169
|
-
outDir = path.join(baseOutDir, runDir);
|
|
170
|
-
}
|
|
154
|
+
// Per-scan folder — REQUIRED. Its only caller, scanSingleHost, creates it exclusively (createHostOutDir) so that EE
|
|
155
|
+
// enrichment, compliance artifacts and the AI outputs share one folder. ⚠️ The fallback that used to compose a
|
|
156
|
+
// directory here (`${safeHost(host)}_${nowStamp()}`, recursive mkdir) had no caller and was the same-second
|
|
157
|
+
// collision's shape kept warm (1.1.1, audit seat's fold); it is gone, and a missing outDir is an error.
|
|
158
|
+
if (!presetOutDir) throw new Error('maybeSendToOpenAI: outDir is required — scanSingleHost creates it with createHostOutDir');
|
|
159
|
+
const outDir = presetOutDir;
|
|
171
160
|
await fsp.mkdir(outDir, { recursive: true });
|
|
172
161
|
|
|
173
162
|
// Paths (fixed names inside per-scan folder)
|
|
@@ -734,6 +723,8 @@ export async function parseArgs(argv) {
|
|
|
734
723
|
}
|
|
735
724
|
const alertSev = get('alert-severity') || get('alert_severity') || null;
|
|
736
725
|
args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
|
|
726
|
+
// R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
|
|
727
|
+
args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
|
|
737
728
|
|
|
738
729
|
// Compliance: framework selector + scope file. Forwarded to EE's
|
|
739
730
|
// runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
|
|
@@ -895,25 +886,37 @@ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
|
|
|
895
886
|
// rather than per scanSingleHost() call (reviewer L1 fold).
|
|
896
887
|
const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
|
|
897
888
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
889
|
+
/**
|
|
890
|
+
* The CLI's scan-target SSRF guard (exported so tests drive the REAL code — 1.3.0 lane 1 E).
|
|
891
|
+
* Cloud sentinels (aws/gcp/azure) skip it. NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) lifts the WHOLE
|
|
892
|
+
* guard on the CLI, loopback and metadata included — documented, and the local-network smoke tests
|
|
893
|
+
* scan loopback that way. Otherwise a literal is classified by value in any spelling, and anything
|
|
894
|
+
* that is not a literal is resolved with EVERY answer checked. Before 1.3.0 a digits-and-dots string
|
|
895
|
+
* skipped resolution (/^[\\d.:[\\]]+$/), so '6425673729' — which the OS reads as 127.0.0.1 — was
|
|
896
|
+
* scanned unchecked.
|
|
897
|
+
* @param {string} host
|
|
898
|
+
* @param {Record<string,string|undefined>} [env]
|
|
899
|
+
*/
|
|
900
|
+
export async function assertScanTargetAllowed(host, env = process.env) {
|
|
902
901
|
const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
|
|
903
|
-
|
|
904
|
-
if (
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
} catch (err) {
|
|
913
|
-
throw new Error(`Host rejected by SSRF guard: ${err.message}`);
|
|
914
|
-
}
|
|
902
|
+
if (isCloudSentinel || allowAllHosts(env)) return;
|
|
903
|
+
if (isBlockedIp(host)) {
|
|
904
|
+
throw new Error(`Scanning blocked address range is not allowed: ${host}`);
|
|
905
|
+
}
|
|
906
|
+
if (!canonicalIp(host)) {
|
|
907
|
+
try {
|
|
908
|
+
await resolveAndValidate(host);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
throw new Error(`Host rejected by SSRF guard: ${err.message}`);
|
|
915
911
|
}
|
|
916
912
|
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
916
|
+
// SSRF guard — block loopback, private ranges, cloud metadata endpoints.
|
|
917
|
+
// Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
|
|
918
|
+
// Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
|
|
919
|
+
await assertScanTargetAllowed(host);
|
|
917
920
|
|
|
918
921
|
// `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
|
|
919
922
|
// Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
|
|
@@ -930,11 +933,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
930
933
|
// Pre-compute the per-scan output folder so EE enrichment, compliance
|
|
931
934
|
// artifacts, and AI outputs all land in the same directory. maybeSendToOpenAI
|
|
932
935
|
// will reuse this presetOutDir below.
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
await
|
|
936
|
+
//
|
|
937
|
+
// ⚠️ AN EXCLUSIVE CREATE (1.1.1 board): two scans of one host reaching this line in the same second used to share
|
|
938
|
+
// one directory and the later one overwrote the earlier one's evidence — see createHostOutDir. `opts.nowStamp` is
|
|
939
|
+
// a test seam (testHooks.nowStamp), absent in every real invocation.
|
|
940
|
+
const outDir = await createHostOutDir(resolveBaseOutDir(), host, opts?.nowStamp ? { stamp: opts.nowStamp } : {});
|
|
938
941
|
|
|
939
942
|
// EE enrichment hook — no-op if @nsasoft/nsauditor-ai-ee is not installed
|
|
940
943
|
// or the license tier doesn't grant intelligenceEngine. Compliance + outDir
|
|
@@ -971,11 +974,20 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
971
974
|
let epssDataAsOf = null;
|
|
972
975
|
// Enterprise's report of WHERE the NVD store/cache it read lives (board item 23) — null without EE.
|
|
973
976
|
let nvdCache = null;
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
977
|
+
// ⚠️ "NOT INSTALLED" AND "INSTALLED BUT BROKEN" ARE TWO EVENTS (1.1.1, the audit seat's T1-c ruling). This used to be
|
|
978
|
+
// a bare `catch {}` read as "EE not installed — the ONLY silent case", and every import failure landed in it — measured
|
|
979
|
+
// at EE 174212f: Enterprise imported names this Community did not export, so below the floor the whole EE stage
|
|
980
|
+
// vanished without a word and the Pro delta read its rows RESOLVED. The package is RESOLVED first (utils/ee_load.mjs):
|
|
981
|
+
// absence stays silent; a failed load is named on stderr and recorded on the conclusion, beside `eeEnrichmentError`.
|
|
982
|
+
// `opts.importEE` / `opts.resolveEE`: test-only seams — absent in every real invocation.
|
|
983
|
+
const eeLoad = await loadEnterprise({ importEE: opts?.importEE ?? null, resolveEE: opts?.resolveEE ?? null });
|
|
984
|
+
ee = eeLoad.ee;
|
|
985
|
+
if (eeLoad.loadError) {
|
|
986
|
+
console.error(`[EE] Enterprise is installed but FAILED TO LOAD — this scan has no compliance report, no `
|
|
987
|
+
+ `intelligence enrichment and no analysis-agent findings: ${eeLoad.loadError}`);
|
|
988
|
+
conclusion.result = conclusion.result || {};
|
|
989
|
+
conclusion.result.eeLoadError = eeLoad.loadError;
|
|
990
|
+
}
|
|
979
991
|
// ⚠️ HOISTED DELIBERATELY, and only this value. `eeEnrichment` is `const` INSIDE the try below,
|
|
980
992
|
// so reading it at the `appendHostWritten` call ~100 lines on is a ReferenceError — which the
|
|
981
993
|
// suite caught as 16 failures rather than one, because every history comparison swallowed it as
|
|
@@ -1041,6 +1053,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1041
1053
|
|
|
1042
1054
|
// --- Scan history: record & compare ---
|
|
1043
1055
|
let scanDiff = null;
|
|
1056
|
+
// The per-host summary [ScanHistory] records — also what a --watch cycle compares (1.3.0 items 4 + 11). Null when the
|
|
1057
|
+
// history step failed before building it; the watch delta reads null as NOT COMPARED, never as an empty scan.
|
|
1058
|
+
let scanSummary = null;
|
|
1044
1059
|
// Whether THIS host actually landed in the run record's `hostsWritten` — distinct from
|
|
1045
1060
|
// whether the scan itself succeeded. `appendHostWritten` can fail independently (a
|
|
1046
1061
|
// malformed argument, a disk fault) while the rest of the scan completes cleanly, and the
|
|
@@ -1114,15 +1129,8 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1114
1129
|
}
|
|
1115
1130
|
|
|
1116
1131
|
const services = conclusion?.result?.services ?? [];
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
if (svc.axfrAllowed === true) n++;
|
|
1120
|
-
if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
|
|
1121
|
-
if (Array.isArray(svc.dangerousMethods)) n += svc.dangerousMethods.length;
|
|
1122
|
-
const cves = svc.cves || svc.cve || [];
|
|
1123
|
-
if (Array.isArray(cves)) n += cves.length;
|
|
1124
|
-
return n;
|
|
1125
|
-
}, 0);
|
|
1132
|
+
// The legacy fallback's service share, graded by the shared service-flag table (1.3.0 (s1)) like every other reader.
|
|
1133
|
+
const serviceFindingsCount = conclusionFindings(conclusion, host).length;
|
|
1126
1134
|
// review fold R-1: cloud plugins emit findings on `results[].result.findings`,
|
|
1127
1135
|
// NOT as service-level attrs — so a cloud (--host aws) scan recorded
|
|
1128
1136
|
// findingsCount:0 in scan_history over a 201-finding scan (a false-clean
|
|
@@ -1162,7 +1170,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1162
1170
|
+ 'legacy sum and marking the line as un-based, so no comparison is made across it.');
|
|
1163
1171
|
}
|
|
1164
1172
|
|
|
1165
|
-
|
|
1173
|
+
scanSummary = {
|
|
1166
1174
|
timestamp: new Date().toISOString(),
|
|
1167
1175
|
host,
|
|
1168
1176
|
servicesCount: services.length,
|
|
@@ -1178,10 +1186,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1178
1186
|
// unreachable in production while its unit tests stay green.
|
|
1179
1187
|
tier: getTierFromEnv(),
|
|
1180
1188
|
cloudFindingsCount,
|
|
1181
|
-
services: services.map(
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
})),
|
|
1189
|
+
services: services.map(historyServiceEntry),
|
|
1190
|
+
// 1.3.0 (s1) B: the basis stamp and the host-level service checks (a domain's DNS posture, wherever it landed).
|
|
1191
|
+
...historyHostEntry(conclusion),
|
|
1185
1192
|
};
|
|
1186
1193
|
|
|
1187
1194
|
// Retrieve previous scan for this host before recording the new one
|
|
@@ -1208,6 +1215,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
|
|
|
1208
1215
|
host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
|
|
1209
1216
|
// Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
|
|
1210
1217
|
hostAppended, kevDataAsOf, epssDataAsOf, nvdCache,
|
|
1218
|
+
// Additive (1.3.0 items 4 + 11): what a --watch cycle compares, host by host.
|
|
1219
|
+
scanSummary,
|
|
1220
|
+
// Additive (1.3.0 build 4, Gate 3-A F-1): the plugin manifest, so the Markdown report says an audit that did not run
|
|
1221
|
+
// — 060's decline of an IP address — the same way scan_host's does. The run record already carries it.
|
|
1222
|
+
pluginStatus,
|
|
1211
1223
|
};
|
|
1212
1224
|
}
|
|
1213
1225
|
|
|
@@ -1248,13 +1260,6 @@ function aggregateStoreLoad(writtenOutputs, field, label) {
|
|
|
1248
1260
|
|
|
1249
1261
|
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
1250
1262
|
|
|
1251
|
-
/**
|
|
1252
|
-
* Determine the maximum severity level present in a conclusion's services.
|
|
1253
|
-
* Checks security findings (anonymousLogin, axfrAllowed, weakAlgorithms,
|
|
1254
|
-
* dangerousMethods, CVEs) as well as open service status.
|
|
1255
|
-
* @param {object} conclusion
|
|
1256
|
-
* @returns {number} highest severity rank found (0-4)
|
|
1257
|
-
*/
|
|
1258
1263
|
async function readSecretFromStdin(keyName) {
|
|
1259
1264
|
if (!process.stdin.isTTY) {
|
|
1260
1265
|
// Piped input
|
|
@@ -1276,33 +1281,18 @@ async function readSecretFromStdin(keyName) {
|
|
|
1276
1281
|
});
|
|
1277
1282
|
}
|
|
1278
1283
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
// CVEs
|
|
1293
|
-
const cves = svc.cves || svc.cve || [];
|
|
1294
|
-
if (Array.isArray(cves)) {
|
|
1295
|
-
for (const cve of cves) {
|
|
1296
|
-
const sev = typeof cve === 'string' ? 'high' : String(cve?.severity || 'high').toLowerCase();
|
|
1297
|
-
max = Math.max(max, SEVERITY_RANK[sev] ?? SEVERITY_RANK.high);
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
// Open service → Info (baseline)
|
|
1302
|
-
if (svc.status === 'open') max = Math.max(max, SEVERITY_RANK.info);
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
return max;
|
|
1284
|
+
/**
|
|
1285
|
+
* The highest severity rank (0-4) among the findings a conclusion carries — the `--fail-on` gate.
|
|
1286
|
+
* @param {object} conclusion
|
|
1287
|
+
* @returns {number} highest severity rank found (0-4); any concluded scan is at least info (0)
|
|
1288
|
+
*/
|
|
1289
|
+
export function maxSeverityInConclusion(conclusion) {
|
|
1290
|
+
// Every grade comes from the shared service-flag table (utils/service_flags.mjs, 1.3.0 (s1)) — the gate reads what the
|
|
1291
|
+
// Markdown report, SARIF and the CSV read, including a payload that landed in the conclusion's evidence. An open service
|
|
1292
|
+
// is graded OPEN_SERVICE_SEVERITY, the grade SARIF gives it ((s2)); any concluded scan is at least info.
|
|
1293
|
+
const open = (conclusion?.result?.services || []).some((s) => s.status === 'open');
|
|
1294
|
+
return Math.max(SEVERITY_RANK.info, open ? flagSeverityRank(OPEN_SERVICE_SEVERITY) : -1,
|
|
1295
|
+
maxRank(conclusionFindings(conclusion, '')));
|
|
1306
1296
|
}
|
|
1307
1297
|
|
|
1308
1298
|
/**
|
|
@@ -1398,7 +1388,9 @@ export async function preflightGrcIfRequested(env, opts = {}) {
|
|
|
1398
1388
|
// test drive the real scan pipeline (including this function's own run-record wiring) against
|
|
1399
1389
|
// a controlled, injected EE module — e.g. one whose `enrichScan` reports a specific KEV/EPSS
|
|
1400
1390
|
// `dataAsOf` — without depending on the real `@nsasoft/nsauditor-ai-ee` package, a real store
|
|
1401
|
-
// file, or the machine's own license/tier state being any particular thing.
|
|
1391
|
+
// file, or the machine's own license/tier state being any particular thing. An injected `importEE` counts as an
|
|
1392
|
+
// INSTALLED package (utils/ee_load.mjs): to describe one that is NOT installed, pass `testHooks.resolveEE` throwing —
|
|
1393
|
+
// a throwing `importEE` alone is now a package that is installed and FAILED to load, which the scan reports.
|
|
1402
1394
|
// Reconstructs a single tier word ('ce' | 'pro' | 'enterprise') from the resolved capability
|
|
1403
1395
|
// map — never from getTierFromEnv()'s own global cache. `runReport` below deliberately never
|
|
1404
1396
|
// reads that cache: `caps` is passed in as an argument specifically so Pro is reachable from the
|
|
@@ -1642,7 +1634,7 @@ export async function runReport(args, caps) {
|
|
|
1642
1634
|
|
|
1643
1635
|
export async function main(testHooks = {}) {
|
|
1644
1636
|
const args = await parseArgs(process.argv);
|
|
1645
|
-
const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
|
|
1637
|
+
const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, alertEveryCycle, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
|
|
1646
1638
|
|
|
1647
1639
|
// Version: handled before license verification so it works without a key.
|
|
1648
1640
|
// CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
|
|
@@ -1682,23 +1674,45 @@ Scan options:
|
|
|
1682
1674
|
incomplete-coverage notice); CloudTrail trail discovery,
|
|
1683
1675
|
GuardDuty/Inspector and EC2 instances attempt every region.
|
|
1684
1676
|
--plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
|
|
1685
|
-
--ports <
|
|
1677
|
+
--ports <list> Ports ADDED to the default set (e.g. 22,80,443 or 161/udp;
|
|
1678
|
+
a range such as 1-1000 is not parsed and adds nothing)
|
|
1686
1679
|
--out <dir> Output directory for scan artifacts
|
|
1687
1680
|
--parallel <n> Parallel host concurrency (default 1)
|
|
1688
|
-
--fail-on <severity> Exit
|
|
1681
|
+
--fail-on <severity> Exit 1 if a finding ≥ severity — the findings the md /
|
|
1682
|
+
SARIF / CSV reports count, graded by one shared table:
|
|
1683
|
+
SNMP default community, weak SSH / TLS, MCP checks, SMB
|
|
1684
|
+
null session, self-signed / expired certificates, the
|
|
1685
|
+
040 / 050 / 060 audits (060: the domain's SPF / DMARC /
|
|
1686
|
+
NS), and Dangerous HTTP methods (medium) only where an
|
|
1687
|
+
Allow header was read. Anonymous FTP / zone transfer /
|
|
1688
|
+
null session only with FTP_CHECK_ANON / DNS_CHECK_AXFR /
|
|
1689
|
+
SMB_NULL_SESSION set; not tested never trips it; any
|
|
1690
|
+
concluded scan is info. Not CVEs or agent findings —
|
|
1691
|
+
exit 0 is not a clean host. Exit 2: unknown severity or
|
|
1692
|
+
no conclusion.
|
|
1689
1693
|
--output-format <fmt> Additional report format: sarif | csv | md
|
|
1690
1694
|
--insecure-https Skip TLS validation on probed HTTPS targets
|
|
1691
|
-
--watch CTEM continuous ALERTING mode: re-scan on --interval
|
|
1692
|
-
|
|
1693
|
-
|
|
1695
|
+
--watch CTEM continuous ALERTING mode: re-scan on --interval and
|
|
1696
|
+
compare each host with the previous cycle. --webhook-url
|
|
1697
|
+
alerts a host whose scan changed (a service, its finding
|
|
1698
|
+
count or a service check, or a comparison that could not be
|
|
1699
|
+
made) and that has a finding at or above --alert-severity.
|
|
1700
|
+
The first cycle sets the baseline and alerts nobody; a host
|
|
1701
|
+
whose scan failed is reported on stdout and does not alert in
|
|
1702
|
+
this release. NOT an evidence cadence —
|
|
1694
1703
|
it adds no retention or cross-run aggregation, skips SARIF/
|
|
1695
1704
|
CSV/Markdown output and --fail-on, and dies with this process.
|
|
1696
1705
|
Each tick is an ordinary scan, so with --compliance it writes
|
|
1697
1706
|
that tick's artifacts; nothing relates them across ticks. For
|
|
1698
1707
|
SOC 2 Type II history use a scheduler (cron/systemd/CI).
|
|
1708
|
+
Watch mode scans each distinct host once per cycle; a
|
|
1709
|
+
one-shot --host X,X scans X twice.
|
|
1699
1710
|
--interval <minutes> Watch interval (default 60)
|
|
1700
|
-
--webhook-url <url> Send
|
|
1711
|
+
--webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
|
|
1701
1712
|
--alert-severity <sev> Min severity to alert on (default: high)
|
|
1713
|
+
--alert-every-cycle Alert every host with a finding at or above --alert-severity
|
|
1714
|
+
on every --watch cycle, the first included (default: only
|
|
1715
|
+
hosts whose scan changed)
|
|
1702
1716
|
--compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
|
|
1703
1717
|
soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
|
|
1704
1718
|
(aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
|
|
@@ -2687,30 +2701,45 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2687
2701
|
}
|
|
2688
2702
|
}
|
|
2689
2703
|
} else if (subCmd === 'verify-call') {
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
//
|
|
2693
|
-
//
|
|
2694
|
-
//
|
|
2695
|
-
// proven real call. UUID absent → fabricated (or log was rotated/
|
|
2696
|
-
// deleted; we say "unverifiable" rather than "fake").
|
|
2704
|
+
// 1.3.0 (s6): BIND THE RESPONSE TEXT, not only the id. The server logs each call's id and a digest of the response
|
|
2705
|
+
// body (utils/mcp_call_digest.mjs); this recomputes the digest from the text the user saved. The id alone proves
|
|
2706
|
+
// only that the server issued it once — an AI client that has seen an earlier response can paste a real id under
|
|
2707
|
+
// a fabricated one — so an id with no text is INDETERMINATE, never "verified".
|
|
2708
|
+
// Exit: 0 verified · 1 refuted (never issued, or the text does not match) · 2 usage · 3 indeterminate.
|
|
2697
2709
|
const { readFile, stat } = await import('node:fs/promises');
|
|
2698
2710
|
const { join: _join } = await import('node:path');
|
|
2699
2711
|
const { homedir: _homedir } = await import('node:os');
|
|
2712
|
+
const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
|
|
2700
2713
|
const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
|
|
2701
2714
|
const uuid = rawArgs[2];
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2715
|
+
const respAt = rawArgs.indexOf('--response');
|
|
2716
|
+
const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
|
|
2717
|
+
if (!uuid || (respAt > 0 && !respPath)) {
|
|
2718
|
+
console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
|
|
2719
|
+
console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
|
|
2705
2720
|
process.exit(2);
|
|
2706
2721
|
}
|
|
2707
|
-
// Conservative UUID v4 shape check — avoid grepping the log with
|
|
2708
|
-
// arbitrary user input.
|
|
2722
|
+
// Conservative UUID v4 shape check — avoid grepping the log with arbitrary user input.
|
|
2709
2723
|
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(uuid)) {
|
|
2710
2724
|
console.error(`✗ Not a valid UUID: ${uuid}`);
|
|
2711
2725
|
console.error(' Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
|
|
2712
2726
|
process.exit(2);
|
|
2713
2727
|
}
|
|
2728
|
+
let responseText = null;
|
|
2729
|
+
if (respPath) {
|
|
2730
|
+
try {
|
|
2731
|
+
if (respPath === '-') {
|
|
2732
|
+
const chunks = [];
|
|
2733
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
2734
|
+
responseText = Buffer.concat(chunks).toString('utf8');
|
|
2735
|
+
} else {
|
|
2736
|
+
responseText = await readFile(respPath, 'utf8');
|
|
2737
|
+
}
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
console.error(`✗ Could not read the response from ${respPath}: ${err.message}`);
|
|
2740
|
+
process.exit(2);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2714
2743
|
let logExists = false;
|
|
2715
2744
|
try { await stat(logPath); logExists = true; } catch { /* missing */ }
|
|
2716
2745
|
if (!logExists) {
|
|
@@ -2723,33 +2752,50 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2723
2752
|
const raw = await readFile(logPath, 'utf8');
|
|
2724
2753
|
// Look for an exact JSON-string match on call_id to avoid prefix collisions.
|
|
2725
2754
|
const needle = `"call_id":"${uuid.toLowerCase()}"`;
|
|
2726
|
-
const
|
|
2727
|
-
|
|
2755
|
+
const entries = raw.split('\n').filter((l) => l.includes(needle))
|
|
2756
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
2757
|
+
if (entries.length === 0) {
|
|
2728
2758
|
console.log(`✗ call_id not found in ${logPath}`);
|
|
2729
2759
|
console.log('');
|
|
2730
2760
|
console.log(` ${uuid}`);
|
|
2731
2761
|
console.log('');
|
|
2732
2762
|
console.log(' This UUID was NOT issued by this MCP server. Most likely cause:');
|
|
2733
|
-
console.log('
|
|
2734
|
-
console.log(' (See
|
|
2763
|
+
console.log(' the AI client fabricated the response without invoking the server.');
|
|
2764
|
+
console.log(' (See docs/mcp-verification.md.)');
|
|
2735
2765
|
process.exit(1);
|
|
2736
2766
|
}
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
console.log(
|
|
2767
|
+
const issued = entries.find((e) => e.tool) ?? entries[0];
|
|
2768
|
+
const ago = (() => {
|
|
2769
|
+
const ms = Date.now() - Date.parse(issued.ts ?? '');
|
|
2770
|
+
if (!Number.isFinite(ms)) return 'at an unrecorded time';
|
|
2771
|
+
const units = [['day', 86400000], ['hour', 3600000], ['minute', 60000]];
|
|
2772
|
+
for (const [u, n] of units) if (ms >= n) { const k = Math.floor(ms / n); return `${k} ${u}${k === 1 ? '' : 's'} ago`; }
|
|
2773
|
+
return 'less than a minute ago';
|
|
2774
|
+
})();
|
|
2775
|
+
const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
|
|
2776
|
+
const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
|
|
2777
|
+
if (digests.length === 0) {
|
|
2778
|
+
console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
|
|
2779
|
+
console.log(' This call predates response binding: no digest of its response was recorded, so the text');
|
|
2780
|
+
console.log(' cannot be bound to it. The id alone does not verify a response.');
|
|
2781
|
+
process.exit(3);
|
|
2782
|
+
}
|
|
2783
|
+
if (responseText == null) {
|
|
2784
|
+
console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
|
|
2785
|
+
console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
|
|
2786
|
+
console.log(' pasted under a fabricated one. Save the raw tool response and run:');
|
|
2787
|
+
console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
|
|
2788
|
+
process.exit(3);
|
|
2789
|
+
}
|
|
2790
|
+
if (digests.includes(responseDigest(bodyOf(responseText)))) {
|
|
2791
|
+
console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
|
|
2750
2792
|
console.log(` log: ${logPath}`);
|
|
2751
2793
|
process.exit(0);
|
|
2752
2794
|
}
|
|
2795
|
+
console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
|
|
2796
|
+
console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
|
|
2797
|
+
console.log(' call — re-copy the RAW tool output and verify again.');
|
|
2798
|
+
process.exit(1);
|
|
2753
2799
|
} else {
|
|
2754
2800
|
console.log('Usage:');
|
|
2755
2801
|
console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
|
|
@@ -2758,7 +2804,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
2758
2804
|
console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
|
|
2759
2805
|
console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
|
|
2760
2806
|
console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
|
|
2761
|
-
console.log(' nsauditor-ai mcp verify-call <uuid>
|
|
2807
|
+
console.log(' nsauditor-ai mcp verify-call <uuid> --response <file|-> Check that a saved tool response is the text this server produced for that call');
|
|
2762
2808
|
console.log('');
|
|
2763
2809
|
console.log('Environment variables:');
|
|
2764
2810
|
console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
|
|
@@ -3156,6 +3202,10 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3156
3202
|
|
|
3157
3203
|
const opts = { insecureHttps };
|
|
3158
3204
|
if (testHooks.importEE) opts.importEE = testHooks.importEE;
|
|
3205
|
+
if (testHooks.resolveEE) opts.resolveEE = testHooks.resolveEE;
|
|
3206
|
+
// `testHooks.nowStamp`: a test seam, absent in every real invocation — it pins the per-host directory's second so a
|
|
3207
|
+
// leg can drive the same-second collision through the shipped scan path.
|
|
3208
|
+
if (testHooks.nowStamp) opts.nowStamp = testHooks.nowStamp;
|
|
3159
3209
|
if (ports) opts.ports = ports;
|
|
3160
3210
|
if (compliance) opts.compliance = compliance;
|
|
3161
3211
|
if (complianceScope) opts.complianceScope = complianceScope;
|
|
@@ -3190,9 +3240,6 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3190
3240
|
// --- CTEM: continuous watch mode ---
|
|
3191
3241
|
if (watch) {
|
|
3192
3242
|
const intervalMs = intervalMinutes * 60 * 1000;
|
|
3193
|
-
console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${hosts.length}`);
|
|
3194
|
-
if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}`);
|
|
3195
|
-
|
|
3196
3243
|
let previousCycleResults = null;
|
|
3197
3244
|
|
|
3198
3245
|
const scheduler = createScheduler({
|
|
@@ -3209,44 +3256,20 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3209
3256
|
onCycleComplete: async (results) => {
|
|
3210
3257
|
console.log(`[CTEM] Cycle complete. Scanned ${results.size} host(s).`);
|
|
3211
3258
|
|
|
3212
|
-
//
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
const
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
if (hostSev >= sevRank) {
|
|
3225
|
-
const services = scanOut.conclusion?.result?.services || [];
|
|
3226
|
-
const findings = services.filter((svc) => {
|
|
3227
|
-
let svcSev = 0;
|
|
3228
|
-
if (svc.anonymousLogin === true || svc.axfrAllowed === true) svcSev = SEVERITY_RANK.critical;
|
|
3229
|
-
if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
|
|
3230
|
-
if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
|
|
3231
|
-
const cves = svc.cves || svc.cve || [];
|
|
3232
|
-
if (Array.isArray(cves) && cves.length) svcSev = Math.max(svcSev, SEVERITY_RANK.high);
|
|
3233
|
-
return svcSev >= sevRank;
|
|
3234
|
-
});
|
|
3235
|
-
|
|
3236
|
-
if (findings.length > 0) {
|
|
3237
|
-
const payload = buildAlertPayload(h, findings, alertSeverity);
|
|
3238
|
-
const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
|
|
3239
|
-
if (webhookResult.success) {
|
|
3240
|
-
console.log(`[CTEM] Webhook alert sent for ${h}`);
|
|
3241
|
-
} else {
|
|
3242
|
-
console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
}
|
|
3259
|
+
// What stdout says and whom the webhook alerts, both from ONE predicate (utils/watch_cycle.mjs, 1.3.0 items 4 + 11).
|
|
3260
|
+
const { text, alerts } = watchCycle(results, previousCycleResults,
|
|
3261
|
+
{ alertRank: SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high, everyCycle: alertEveryCycle });
|
|
3262
|
+
console.log(text ?? '[CTEM] First cycle complete — it establishes the baseline. Delta reporting begins on the next cycle.');
|
|
3263
|
+
if (webhookUrl) {
|
|
3264
|
+
for (const { host: h, findings } of alerts) {
|
|
3265
|
+
const payload = buildAlertPayload(h, findings, alertSeverity);
|
|
3266
|
+
const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
|
|
3267
|
+
if (webhookResult.success) {
|
|
3268
|
+
console.log(`[CTEM] Webhook alert sent for ${h}`);
|
|
3269
|
+
} else {
|
|
3270
|
+
console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
|
|
3246
3271
|
}
|
|
3247
3272
|
}
|
|
3248
|
-
} else {
|
|
3249
|
-
console.log('[CTEM] First cycle complete. Delta reporting will begin on next cycle.');
|
|
3250
3273
|
}
|
|
3251
3274
|
|
|
3252
3275
|
previousCycleResults = results;
|
|
@@ -3263,6 +3286,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3263
3286
|
process.on('SIGINT', shutdown);
|
|
3264
3287
|
process.on('SIGTERM', shutdown);
|
|
3265
3288
|
|
|
3289
|
+
// The banner reads the scheduler's own distinct list: a cycle scans each host once (1.3.0 lane 4, item 10).
|
|
3290
|
+
const dropped = scheduler.duplicatesDropped
|
|
3291
|
+
? ` (${scheduler.duplicatesDropped} duplicate host(s) dropped — watch mode scans each host once per cycle)` : '';
|
|
3292
|
+
console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${scheduler.hosts.length}${dropped}`);
|
|
3293
|
+
if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}, alerting `
|
|
3294
|
+
+ (alertEveryCycle ? 'every host with such a finding, every cycle' : 'hosts whose scan changed (the first cycle sets the baseline)'));
|
|
3295
|
+
|
|
3266
3296
|
scheduler.start();
|
|
3267
3297
|
return; // keep process alive via setInterval
|
|
3268
3298
|
}
|
|
@@ -3469,6 +3499,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
3469
3499
|
host: scanOut.host,
|
|
3470
3500
|
conclusion: scanOut.conclusion,
|
|
3471
3501
|
toolVersion: TOOL_VERSION,
|
|
3502
|
+
manifest: scanOut.pluginStatus ?? [],
|
|
3472
3503
|
});
|
|
3473
3504
|
const mdFileName = scanOutputs.length > 1
|
|
3474
3505
|
? `scan_${safeHost(scanOut.host)}.md`
|