nsauditor-ai 0.2.54 → 0.2.55

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/cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { buildMarkdownReport } from './utils/report_md.mjs';
20
20
  import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
21
21
  import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
22
22
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
23
- import { resolveCapabilities, hasCapability, inferRequiredTier } from './utils/capabilities.mjs';
23
+ import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } from './utils/capabilities.mjs';
24
24
  import { createScheduler } from './utils/scheduler.mjs';
25
25
  import { buildDeltaReport, formatDeltaSummary, hasSignificantChanges } from './utils/delta_reporter.mjs';
26
26
  import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
@@ -29,8 +29,10 @@ import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
29
29
  import { getAllTechniques } from './utils/attack_map.mjs';
30
30
  import { TOOL_VERSION } from './utils/tool_version.mjs';
31
31
  import { resolveBaseOutDir } from './utils/output_dir.mjs';
32
+ import { deriveFindingsCount } from './utils/report_inputs.mjs';
32
33
  import { toCleanPath } from './utils/path_helpers.mjs';
33
- import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE } from './utils/run_record.mjs';
34
+ import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE, aggregateNvdCache, abortReasonOf } from './utils/run_record.mjs';
35
+ import { buildSinceView } from './utils/scan_delta_view.mjs';
34
36
  import { loadRun } from './utils/report_inputs.mjs';
35
37
  import { loadBrand } from './utils/brand.mjs';
36
38
  import { renderExecutiveReport } from './utils/executive_report.mjs';
@@ -58,8 +60,44 @@ const nowStamp = () => {
58
60
  const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
59
61
  // toCleanPath imported from ./utils/path_helpers.mjs (consolidated in v0.1.20)
60
62
 
61
- /** Minimal redactor used if nothing external is provided. */
62
- function redactSensitiveForAI(input, targetHost) {
63
+ /**
64
+ * ONE address rule for the whole AI payload, with our own identifiers carved out.
65
+ *
66
+ * ⚠️ SHARED BECAUSE IT WAS DUPLICATED AND ONE COPY WAS WRONG. `-` is a non-word character, so a
67
+ * bare `\b(?:(?:\d{1,3}\.){3}\d{1,3})\b` matches the tail of `EE-0.3.2.4` and rewrites a
68
+ * four-component identifier as an address. That shipped: two AI payloads carry
69
+ * `plugin 1020 (EE-[IP] fold)`. The identical rule was applied a second time to
70
+ * `payloadForAI.summary` — the same defect one field over, not live only because a network-scan
71
+ * summary happens to carry no identifier. Two copies of a rule, one of which was just found
72
+ * wrong, is how the next instance arrives; the POLICY differs between call sites (one keeps
73
+ * private addresses, the other scrubs every address) so the policy is the parameter and the rule
74
+ * is not.
75
+ *
76
+ * ⚠️ ONE ALTERNATION, NEVER TWO PASSES. Scrubbing addresses first destroys the identifier, and
77
+ * restoring identifiers afterwards cannot tell a real address from one that was always an id.
78
+ * With a single pass each construct consumes its own extent, so an address standing NEXT TO an
79
+ * identifier is still scrubbed.
80
+ *
81
+ * ⚠️ DECLARED RESIDUAL: an address wearing our own prefix — `EE-203.0.113.5` — is preserved
82
+ * verbatim, where the old rule scrubbed it. No value test can separate it from a real
83
+ * `EE-RT.11.1.2.5`, because the two are the same shape. Pinned in
84
+ * `tests/redact_product_ids.test.mjs` so the limit is stated rather than silent.
85
+ */
86
+ const PRODUCT_ID_OR_IPV4 = /(\bEE-(?:RT\.)?\d+(?:\.\d+)*\b)|(\b(?:\d{1,3}\.){3}\d{1,3}\b)/g;
87
+ export function scrubIPv4KeepingProductIds(str, replaceAddress) {
88
+ return String(str).replace(PRODUCT_ID_OR_IPV4,
89
+ (m, productId, ip) => (productId !== undefined ? productId : replaceAddress(ip)));
90
+ }
91
+
92
+ /**
93
+ * Minimal redactor used if nothing external is provided.
94
+ *
95
+ * ⚠️ EXPORTED SO IT CAN BE DRIVEN. A redactor's FALSE POSITIVE is invisible from the redacted
96
+ * artifact alone — `EE-[IP]` reads like a redaction that worked — so the only way to see one is
97
+ * to compare a raw against its payload, which needs the real function rather than a re-spelling
98
+ * of its rules. `tests/redact_product_ids.test.mjs` is that census.
99
+ */
100
+ export function redactSensitiveForAI(input, targetHost) {
63
101
  const DROP_KEYS = new Set([
64
102
  'ip6', 'deviceWebPage', 'deviceWebPageInstruction',
65
103
  'hardwareVersion', 'firmwareVersion'
@@ -76,7 +114,8 @@ function redactSensitiveForAI(input, targetHost) {
76
114
  s = s.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/gi, '[MAC]'); // MAC
77
115
  s = s.replace(/\bfe80::[0-9a-f:]+\b/gi, '[FE80::/64]'); // IPv6 link-local
78
116
  s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
79
- s = s.replace(/\b(?:(?:\d{1,3}\.){3}\d{1,3})\b/g, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
117
+ // The shared rule; this call site's POLICY keeps private addresses and masks public ones.
118
+ s = scrubIPv4KeepingProductIds(s, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
80
119
  return s;
81
120
  };
82
121
 
@@ -280,8 +319,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
280
319
 
281
320
  // Redact any remaining IP addresses in the summary field
282
321
  if (typeof payloadForAI.summary === 'string') {
283
- payloadForAI.summary = payloadForAI.summary
284
- .replace(/\b(?:(?:\d{1,3}\.){3}\d{1,3})\b/g, '[REDACTED_HOST]');
322
+ // ⚠️ THE SAME RULE, A DIFFERENT POLICY: every address goes, private ones included. It
323
+ // carried its own copy of the address regex — the copy that was NOT fixed when the
324
+ // identifier defect was found in the redactor, which is exactly how the second instance
325
+ // of a class arrives. Measured at the time: neither live payload's summary carries an
326
+ // `EE-` token, so this was a latent duplicate rather than a live defect.
327
+ payloadForAI.summary = scrubIPv4KeepingProductIds(payloadForAI.summary, () => '[REDACTED_HOST]');
285
328
  }
286
329
 
287
330
  // Also redact private IPs in nested service/evidence strings
@@ -810,6 +853,8 @@ export async function parseArgs(argv) {
810
853
  args.format = str('format');
811
854
  const runVal = get('run');
812
855
  args.run = runVal === undefined ? undefined : runVal;
856
+ const sinceVal = get('since');
857
+ args.since = sinceVal === undefined ? undefined : sinceVal;
813
858
  const brandVal = get('brand');
814
859
  args.brand = brandVal === undefined ? undefined : brandVal;
815
860
  const reportOutVal = get('out');
@@ -819,6 +864,22 @@ export async function parseArgs(argv) {
819
864
  return args;
820
865
  }
821
866
 
867
+ /**
868
+ * The LAST refusal main() makes before it resolves hosts, as a value: an unknown command, then a `scan` with
869
+ * neither --host nor --host-file. The earlier flag, env, posture, GRC and region refusals are not modelled here. `null` means the command gets past both. main() prints `message` and exits
870
+ * with `code`; the documented-command census (EE) drives the same function over every `nsauditor-ai scan` the
871
+ * product teaches, so a documented command is judged by the code that would refuse it rather than by a copy
872
+ * of its condition. A positional target is not a target (parseArgs reads the host only from a flag).
873
+ * ⚠️ A value-less `--host` parses to `true` and passes here; main() then fails in parseHostArg (boarded).
874
+ * @param {{ cmd?: string, host?: string|true, hostFile?: string }} args parseArgs' result
875
+ * @returns {{ message: string, code: number }|null}
876
+ */
877
+ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
878
+ if (cmd !== 'scan') return { message: `Unknown command: ${cmd}`, code: 2 };
879
+ if (!hostFile && !host) return { message: 'Fatal: --host or --host-file is required', code: 2 };
880
+ return null;
881
+ }
882
+
822
883
  // EE-0.3.2.5 (CE side): cloud-provider sentinel hosts ('aws' / 'gcp' /
823
884
  // 'azure', case-insensitive) are NOT real DNS names — they're scoping
824
885
  // tokens that EE cloud-scanner plugins (020/021/022/023/030) interpret
@@ -908,11 +969,19 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
908
969
  // claim about the whole run, not a single host).
909
970
  let kevDataAsOf = null;
910
971
  let epssDataAsOf = null;
972
+ // Enterprise's report of WHERE the NVD store/cache it read lives (board item 23) — null without EE.
973
+ let nvdCache = null;
911
974
  try {
912
975
  // `opts.importEE`: test-only injection seam, mirroring `preflightNsauditorPosture`'s
913
976
  // `importEE` option elsewhere in this file — absent in every real invocation.
914
977
  ee = opts?.importEE ? await opts.importEE() : await import('@nsasoft/nsauditor-ai-ee');
915
978
  } catch { /* EE not installed — CE proceeds unchanged. The ONLY silent case. */ }
979
+ // ⚠️ HOISTED DELIBERATELY, and only this value. `eeEnrichment` is `const` INSIDE the try below,
980
+ // so reading it at the `appendHostWritten` call ~100 lines on is a ReferenceError — which the
981
+ // suite caught as 16 failures rather than one, because every history comparison swallowed it as
982
+ // "Failed to record/compare scan". Widening `eeEnrichment` itself would put the whole EE
983
+ // envelope in a scope that does not need it; one holder is the smaller contract.
984
+ let hostScopeScanned = null;
916
985
  try {
917
986
  const eeEnrichment = ee ? await ee.enrichScan(conclusion, {
918
987
  host,
@@ -925,8 +994,14 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
925
994
  complianceHistoryRoot: opts.complianceHistoryRoot,
926
995
  slaPolicy: opts.slaPolicy,
927
996
  results,
997
+ // ⚠️ WHAT EACH UPSTREAM DID, forwarded so EE can tell "nothing to map" from "my inputs
998
+ // never ran". Without it the CPE mapper returns a silently empty result when discovery
999
+ // times out, and the DISAPPEARANCE of its own coverage-gap records reads to the cross-run
1000
+ // delta as findings RESOLVED — measured on a live run, three such rows.
1001
+ pluginStatus,
928
1002
  onWarn: (msg) => console.warn(`[EE] ${msg}`),
929
1003
  }) : null;
1004
+ hostScopeScanned = eeEnrichment?.scopeScanned ?? null;
930
1005
  // ⚠️ Read from `exploitIntel.stores`, NOT a top-level `exploit` key — measured against
931
1006
  // EE's actual `enrichScan` return shape (`index.mjs`'s final `return { ... exploitIntel:
932
1007
  // ctx.exploitIntel, ... }`), which nests the per-store `dataAsOf` at
@@ -938,6 +1013,10 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
938
1013
  const stores = eeEnrichment?.exploitIntel?.stores;
939
1014
  kevDataAsOf = stores?.kev?.dataAsOf ?? null;
940
1015
  epssDataAsOf = stores?.epss?.dataAsOf ?? null;
1016
+ const nvd = eeEnrichment?.nvdStore;
1017
+ nvdCache = (nvd && typeof nvd.path === 'string')
1018
+ ? { path: nvd.path, source: nvd.source ?? null, state: nvd.state ?? null, responseCache: nvd.responseCache ?? null }
1019
+ : null;
941
1020
  if (eeEnrichment?.enrichedPrompt) {
942
1021
  conclusion.result = conclusion.result || {};
943
1022
  conclusion.result.eeEnrichment = eeEnrichment;
@@ -970,7 +1049,36 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
970
1049
  // through it, whatever its own enrichment found.
971
1050
  let hostAppended = false;
972
1051
  try {
973
- const outRoot = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out').replace(/\.[^/.]+$/, '') || 'out';
1052
+ // ⚠️ `resolveBaseOutDir()`, NOT THE INLINE STRIP THAT USED TO BE HERE (board C3). The old
1053
+ // expression was `toCleanPath(...).replace(/\.[^/.]+$/, '')` — remove everything after the
1054
+ // last dot — so `--out …/ee-1.1.0` wrote the scan into `ee-1.1.0/` and its HISTORY into a
1055
+ // SIBLING `ee-1.1/`, created silently beside it. Both `ee-1.1/` and `ee-1.0/` are live
1056
+ // strays in this project's own evidence tree, which is how it was found: by looking at the
1057
+ // directory, not at the code.
1058
+ //
1059
+ // It is the EE 0.32.8 evidence-misplacement bug — "has a dot" read as "is a file" — surviving
1060
+ // in the one call site that never adopted the fixed resolver. A repair applied to a helper
1061
+ // does not reach a caller that does not use it, and the version-named directory convention
1062
+ // this project uses for its own evidence is exactly the shape that breaks.
1063
+ const outRoot = resolveBaseOutDir();
1064
+
1065
+ // ⚠️ AND THE MOVE IS ANNOUNCED RATHER THAN SILENT. A user whose previous scans wrote history
1066
+ // to the stray sibling would otherwise find this scan reporting no prior run — a correct
1067
+ // statement about the new location that reads as "nothing changed since last time". Name the
1068
+ // file instead; it is not read any more and it is not deleted either. Same discipline as the
1069
+ // report command's stale-artifact notice: never let an old file pass for a current one.
1070
+ const legacyHistoryRoot = toCleanPath(
1071
+ process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out'
1072
+ ).replace(/\.[^/.]+$/, '') || 'out';
1073
+ if (legacyHistoryRoot !== outRoot) {
1074
+ const here = await fsp.access(path.join(outRoot, HISTORY_FILE)).then(() => true, () => false);
1075
+ const there = await fsp.access(path.join(legacyHistoryRoot, HISTORY_FILE)).then(() => true, () => false);
1076
+ if (!here && there) {
1077
+ console.warn(`[scan] scan history now lives in ${outRoot}. An earlier build wrote it to `
1078
+ + `${legacyHistoryRoot}, which is NOT read any more — this scan starts a new history there. `
1079
+ + 'The old file is left on disk; nothing was deleted.');
1080
+ }
1081
+ }
974
1082
 
975
1083
  // Append this host to the run record now that its directory has landed — inside the
976
1084
  // SAME try/catch as the rest of scan-history recording. utils/run_record.mjs documents
@@ -994,7 +1102,15 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
994
1102
  // that reintroduces the same bug for the run record.
995
1103
  const runRecordRoot = resolveBaseOutDir();
996
1104
  if (opts?.runId) {
997
- hostAppended = await appendHostWritten(runRecordRoot, opts.runId, { host, dir: path.basename(outDir) });
1105
+ // ⚠️ THE EFFECTIVE SCOPE THIS HOST'S SCAN COVERED, never the flag — EE's resolver reports
1106
+ // what it actually resolved and CE records it per host, which is per PROVIDER on a cloud
1107
+ // scan. `null` on a network host or a CE-only install, and `null` means UNKNOWN rather than
1108
+ // "covered nothing": the delta stays silent when BOTH records lack it and fails closed when
1109
+ // only one carries it.
1110
+ hostAppended = await appendHostWritten(runRecordRoot, opts.runId, {
1111
+ host, dir: path.basename(outDir),
1112
+ scopeScanned: hostScopeScanned,
1113
+ });
998
1114
  }
999
1115
 
1000
1116
  const services = conclusion?.result?.services ?? [];
@@ -1015,7 +1131,36 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1015
1131
  const f = r?.result?.findings;
1016
1132
  return n + (Array.isArray(f) ? f.length : 0);
1017
1133
  }, 0);
1018
- const findingsCount = serviceFindingsCount + cloudFindingsCount;
1134
+ // ⚠️ DERIVED FROM THE REPORT LOADER, NOT SUMMED HERE (board C10). The two sums above are
1135
+ // KEPT because `cloudFindingsCount` still ships as its own field, but they no longer decide
1136
+ // `findingsCount`: they could not see the finding QUEUE, so a network host whose findings are
1137
+ // all CVE matches recorded ZERO — measured on the real 192.168.1.1 run, 0 against 37 queue
1138
+ // entries of which 21 carried CVEs. `scan_history` is a COMPARISON channel, so that zero
1139
+ // became "no change" on every subsequent scan, forever.
1140
+ //
1141
+ // The previous repair of this same channel added `cloudFindingsCount` when cloud plugins
1142
+ // recorded 0 over a 201-finding scan. That was per-producer, and a per-producer repair closes
1143
+ // only the producers it enumerates — which is how this channel came to be wrong twice. Using
1144
+ // the shaping `report --since` already uses means a producer the loader learns to read is
1145
+ // counted by BOTH channels on the same day, with nobody remembering to update this line.
1146
+ //
1147
+ // Read from DISK, not from memory: these are the exact bytes `report --since` will read, so
1148
+ // the two channels count the same artifacts and not two views of them. Both are written by
1149
+ // now — the envelope at the output stage, the queue by the enrichment hook.
1150
+ // ⚠️ ONE CALL FOR BOTH THE NUMBER AND ITS BASIS. They were two statements here, and a mutant
1151
+ // that reverted the count to the legacy sum kept the basis stamp — a line claiming to have
1152
+ // been counted the new way while holding an old-way number, which the end-to-end leg could
1153
+ // not see. Only the thing that produces the number may assert how it was produced.
1154
+ const derived = await deriveFindingsCount(outDir, host);
1155
+ const findingsCount = derived ? derived.count : (serviceFindingsCount + cloudFindingsCount);
1156
+ const findingsCountBasis = derived ? derived.basis : null;
1157
+ if (!derived) {
1158
+ // Falls back to the legacy sum WITHOUT claiming the new basis, so `computeDiff` treats the
1159
+ // line as pre-1.1.0 and refuses to subtract it against a properly-counted one rather than
1160
+ // silently comparing two different definitions of a finding.
1161
+ console.warn('[scan] could not derive findingsCount from the written artifacts; recording the '
1162
+ + 'legacy sum and marking the line as un-based, so no comparison is made across it.');
1163
+ }
1019
1164
 
1020
1165
  const scanSummary = {
1021
1166
  timestamp: new Date().toISOString(),
@@ -1024,6 +1169,14 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1024
1169
  openPorts: services.filter((s) => s.status === 'open').map((s) => s.port),
1025
1170
  os: conclusion?.result?.host?.os ?? null,
1026
1171
  findingsCount,
1172
+ findingsCountBasis,
1173
+ // ⚠️ THE TIER THAT PRODUCED THE COUNT (board E9), read from the SAME resolver this file
1174
+ // already branches on twelve lines below, never re-derived. The tiers do not count the
1175
+ // same things — Enterprise writes a finding queue that Community never produces — so
1176
+ // `computeDiff` refuses to subtract across a tier boundary, and it can only do that if the
1177
+ // line says which tier counted. Without this field every leg of that refusal is
1178
+ // unreachable in production while its unit tests stay green.
1179
+ tier: getTierFromEnv(),
1027
1180
  cloudFindingsCount,
1028
1181
  services: services.map((s) => ({
1029
1182
  port: s.port, protocol: s.protocol ?? 'tcp',
@@ -1054,7 +1207,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1054
1207
  return {
1055
1208
  host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
1056
1209
  // Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
1057
- hostAppended, kevDataAsOf, epssDataAsOf,
1210
+ hostAppended, kevDataAsOf, epssDataAsOf, nvdCache,
1058
1211
  };
1059
1212
  }
1060
1213
 
@@ -1281,7 +1434,9 @@ function tierLabelFromCaps(caps) {
1281
1434
  * or refused (bad/missing flags, a flag mismatch, a tier refusal, `no-run`, `record-unreadable`,
1282
1435
  * a write failure) — fix the request or the environment. **1** = `loadRun` refused for any other
1283
1436
  * reason — fix the run by re-scanning. **0** = rendered (a caveated or zero-finding render is
1284
- * still 0 — the exit code must never encode whether findings exist).
1437
+ * still 0 — the exit code must never encode whether findings exist, and with `--since` it must
1438
+ * never encode WHETHER ANYTHING CHANGED either: a no-change delta and a twelve-new-exposure
1439
+ * delta both exit 0, and only could-not-measure is non-zero).
1285
1440
  *
1286
1441
  * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
1287
1442
  */
@@ -1309,7 +1464,7 @@ export async function runReport(args, caps) {
1309
1464
  // catch this after the fact — it must be caught here or never. Same precedent already applied
1310
1465
  // to `--sla-policy`/`--compliance-history` in the scan path, below.
1311
1466
  for (const [flag, val] of [['--from', args.from], ['--brand', args.brand],
1312
- ['--run', args.run], ['--out', args.out]]) {
1467
+ ['--run', args.run], ['--out', args.out], ['--since', args.since]]) {
1313
1468
  if (val === true) {
1314
1469
  logErr(`\`report\` needs a value for ${flag}. A flag that quietly does nothing is how an `
1315
1470
  + 'operator concludes a subject was resolved.');
@@ -1341,7 +1496,7 @@ export async function runReport(args, caps) {
1341
1496
  { tier: tierLabelFromCaps(caps) });
1342
1497
  if (!loaded.ok) {
1343
1498
  logErr(loaded.message);
1344
- // MAP BY REASON, not by "loadRun said no" — an EXHAUSTIVE switch over the ten documented
1499
+ // MAP BY REASON, not by "loadRun said no" — an EXHAUSTIVE switch over the eleven documented
1345
1500
  // reasons, with a loud default. `no-run` and `record-unreadable` are COMMAND/environment
1346
1501
  // problems (fix the invocation or the filesystem); every other reason is a RUN problem the
1347
1502
  // operator fixes by re-scanning.
@@ -1349,6 +1504,7 @@ export async function runReport(args, caps) {
1349
1504
  case 'no-run':
1350
1505
  case 'record-unreadable':
1351
1506
  return finish(2);
1507
+ case 'aborted-run':
1352
1508
  case 'ambiguous-run':
1353
1509
  case 'binding-mismatch':
1354
1510
  case 'incomplete-run':
@@ -1396,6 +1552,48 @@ export async function runReport(args, caps) {
1396
1552
  log('[report] coverage: incomplete — the run did not record completion');
1397
1553
  }
1398
1554
 
1555
+ // ── `--since`: the cross-run delta. Placed AFTER the coverage caveats so a partial or
1556
+ // incomplete CURRENT run is already disclosed before any delta verdict is read.
1557
+ let sinceDelta = null;
1558
+ // A refused COMPARISON still renders and still exits 2: the artifact says the comparison was
1559
+ // refused, and could-not-measure stays loud.
1560
+ let sinceRefusalCode = 0;
1561
+ if (typeof args.since === 'string' && args.since !== '') {
1562
+ const view = await buildSinceView({ outRoot: args.from, model, since: args.since,
1563
+ allowPartial: !!args.allowPartial, tier: tierLabelFromCaps(caps) });
1564
+ view.out.forEach(log);
1565
+ view.err.forEach(logErr);
1566
+ // ⚠️ TWO KINDS OF EXIT 2, AND THE ARTIFACT IS WHAT DISTINGUISHES THEM (board C9). This line
1567
+ // used to `return finish(view.code)` for BOTH, so a refused comparison wrote no file at all
1568
+ // — and `executive_report.mjs`'s `delta-refused` block, which renders exactly the honest
1569
+ // artifact, was unreachable through every shipped path. The consultant's deliverable is
1570
+ // `--out client.html`; a refusal that leaves a STALE client.html there is worse than the
1571
+ // refusal, because the file they hand a client is from an earlier run and says nothing of it.
1572
+ // ⚠️ AND THE ARTIFACT MUST BE ABLE TO CARRY THE REFUSAL, OR IT MUST NOT BE WRITTEN. Only the
1573
+ // executive report renders the delta; `renderJiraCsv` never receives it. Writing a CSV that
1574
+ // says nothing while exiting 2 would make "exit 2 WITH an artifact" mean one thing for HTML
1575
+ // and the opposite for CSV — a contract that depends on the format is not a contract. Any
1576
+ // format that cannot state the refusal falls back to the request/run behaviour: write
1577
+ // nothing, name any stale file, exit 2.
1578
+ if (view.code !== 0 && (!view.delta || format !== 'executive')) {
1579
+ // The REQUEST or the RUN ITSELF was refused: nothing honest to render. Say so about any
1580
+ // file already on disk, so it cannot pass as this run's output.
1581
+ // ⚠️ `fsp`, NOT `fs` — this module imports only `node:fs/promises`. The first draft wrote
1582
+ // `fs.existsSync` and threw ReferenceError on the refusal path. It surfaced immediately
1583
+ // here; the same slip in `report_inputs.mjs` earlier this cycle sat under a bare `catch`
1584
+ // and read CLEAN. Same bug, opposite visibility, and the difference was the catch.
1585
+ const stale = args.out ? await fsp.access(args.out).then(() => true, () => false) : false;
1586
+ if (stale) {
1587
+ logErr(`[report] ${args.out} was NOT rewritten; it is from an earlier run.`);
1588
+ }
1589
+ return finish(view.code);
1590
+ }
1591
+ // ⚠️ CARRIED INTO THE ARTIFACT, not only printed. stdout reaches the OPERATOR, who knows what
1592
+ // the tool does; the HTML reaches the CLIENT being billed.
1593
+ sinceDelta = view.delta ?? null;
1594
+ sinceRefusalCode = view.code !== 0 ? view.code : 0;
1595
+ }
1596
+
1399
1597
  let body;
1400
1598
  let ext;
1401
1599
  if (format === 'executive') {
@@ -1404,7 +1602,7 @@ export async function runReport(args, caps) {
1404
1602
  logErr(brandResult.message);
1405
1603
  return finish(2);
1406
1604
  }
1407
- body = renderExecutiveReport(model, brandResult.brand, { renderedAt: new Date() });
1605
+ body = renderExecutiveReport(model, brandResult.brand, { renderedAt: new Date(), delta: sinceDelta });
1408
1606
  ext = 'html';
1409
1607
  } else {
1410
1608
  body = renderJiraCsv(model);
@@ -1428,6 +1626,17 @@ export async function runReport(args, caps) {
1428
1626
  return finish(2);
1429
1627
  }
1430
1628
  log(`[report] wrote ${outPath}`);
1629
+ // ⚠️ THE ARTIFACT IS WRITTEN AND THE EXIT IS STILL 2 when the COMPARISON was refused. The
1630
+ // report rendered — that is what the 0 would be claiming — but the thing the operator ASKED
1631
+ // for could not be measured, and could-not-measure stays loud everywhere in this repo. The
1632
+ // artifact itself says which happened, so the pair is unambiguous: exit 2 WITH a file means
1633
+ // the comparison was refused and the file says so; exit 2 WITHOUT one means the request or the
1634
+ // run itself was refused.
1635
+ if (sinceRefusalCode !== 0) {
1636
+ logErr(`[report] the report was written, but the requested comparison was REFUSED — `
1637
+ + `${outPath} states that and contains no resolved, new or changed rows.`);
1638
+ return finish(sinceRefusalCode);
1639
+ }
1431
1640
  return finish(0);
1432
1641
  }
1433
1642
 
@@ -1468,8 +1677,10 @@ Scan options:
1468
1677
  --aws-profile <name> Use a named profile from the OS-default ~/.aws/credentials.
1469
1678
  Implies CLOUD_PROVIDER=aws; overrides explicit AWS_* keys.
1470
1679
  --aws-region <r> AWS region scope: one (us-east-1), CSV (us-east-1,eu-west-1),
1471
- or 'all' (every account-enabled region). Default: AWS_REGION
1472
- if set, else a single region with an incomplete-coverage notice.
1680
+ or 'all' (every account-enabled region). Default: no intent —
1681
+ region-scoped checks use AWS_REGION, else one region (with an
1682
+ incomplete-coverage notice); CloudTrail trail discovery,
1683
+ GuardDuty/Inspector and EC2 instances attempt every region.
1473
1684
  --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
1474
1685
  --ports <range> Override port list (e.g. 22,80,443 or 1-1000)
1475
1686
  --out <dir> Output directory for scan artifacts
@@ -1850,17 +2061,19 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1850
2061
  for (const plugin of sorted) {
1851
2062
  const required = Array.isArray(plugin.requiredCapabilities) ? plugin.requiredCapabilities : [];
1852
2063
  const allMet = required.length === 0 || required.every((c) => Boolean(caps[c]));
1853
- // Reviewer M2 fold: derive the required tier from the unmet
1854
- // capability set so the "requires: …" label is accurate even
1855
- // when the plugin doesn't declare a `tier` field. EE plugins
1856
- // 021/022/023 (no `tier` declaration) require `cloudScanners`
1857
- // which is enterprise-gated — pre-fold they showed
1858
- // "requires: pro" misleadingly. Now they show "requires:
1859
- // enterprise" via inferRequiredTier(). plugin.tier is the
1860
- // operator-declared override; fall back to inference.
1861
- const inferredTier = inferRequiredTier(required);
1862
- const requiresLabel = plugin.tier ?? inferredTier ?? 'pro';
1863
- const status = allMet ? '✓ active' : `✗ requires: ${requiresLabel}`;
2064
+ // The label is the tier the GATE enforces, derived from `requiredCapabilities` alone — the
2065
+ // list the plugin manager checks before it runs a plugin. A manifest's `tier` word is NOT
2066
+ // read: until 0.2.55 it outranked the derivation, and EE 1020 / 1030 declared `tier: "pro"`
2067
+ // over `cloudScanners`, so at Community tier this list said Pro unlocks two plugins Pro does
2068
+ // not. (Before that, EE 1021 / 1022 / 1023 declared no capabilities at all and this line
2069
+ // showed them `✓ active` at every tier.) A capability the registry does not define is
2070
+ // granted by no licence — the plugin never loads — so it is NAMED rather than labelled
2071
+ // with a tier; `inferRequiredTier` skips unknown names and would print "requires: ce".
2072
+ // Reachable only when allMet is false, i.e. `required` is non-empty.
2073
+ const unknown = required.filter((c) => !Object.hasOwn(CAPABILITIES, c));
2074
+ const status = allMet ? '✓ active'
2075
+ : unknown.length > 0 ? `✗ requires a capability no licence grants: ${unknown.join(', ')}`
2076
+ : `✗ requires: ${inferRequiredTier(required)}`;
1864
2077
  // Layout matches the EE README example:
1865
2078
  // " 003 SSH Scanner ✓ active"
1866
2079
  const idStr = String(plugin.id ?? '?').padEnd(3);
@@ -2662,6 +2875,9 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2662
2875
  extrasDir: F.extrasDir || undefined,
2663
2876
  });
2664
2877
  console.log(`imported ${out.imported} CVE(s), skipped ${out.skipped}, errors ${out.errors}`);
2878
+ // WHERE it went — the same location every scan reads, from any directory (Enterprise 1.1.0).
2879
+ // Printed because an import into a place the operator did not expect is an import nobody uses.
2880
+ if (out.store?.path) console.log(` store: ${out.store.path} (${out.store.source})`);
2665
2881
  // ⚠️ THE SKIP SPLIT PRINTS HERE BECAUSE THE DATA-LOSS TICKET ORIGINATES HERE.
2666
2882
  // On a real NVD year file ~24% of records are skipped (measured on the 2024 feed:
2667
2883
  // 39,219 in, 29,866 imported, 9,353 skipped). An operator reading a bare "skipped 9353"
@@ -2916,23 +3132,21 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2916
3132
  process.exit(code);
2917
3133
  }
2918
3134
 
2919
- if (cmd !== 'scan') {
2920
- console.error(`Unknown command: ${cmd}`);
2921
- process.exit(2);
3135
+ const targetRefusal = scanTargetRefusal({ cmd, host, hostFile });
3136
+ if (targetRefusal) {
3137
+ console.error(targetRefusal.message);
3138
+ process.exit(targetRefusal.code);
2922
3139
  }
2923
3140
 
2924
- // Resolve host list
3141
+ // Resolve host list — scanTargetRefusal has established that hostFile or host is set.
2925
3142
  let hosts;
2926
3143
  if (hostFile) {
2927
3144
  // Reuse the up-front resolution done for the CLOUD_PROVIDER reconcile (avoids a
2928
3145
  // second parse + duplicate suspicious-line warnings); re-parse only if that was
2929
3146
  // skipped/failed so a genuine host-file error still surfaces here as before.
2930
3147
  hosts = resolvedHosts ?? await parseHostFile(hostFile);
2931
- } else if (host) {
2932
- hosts = await parseHostArg(host);
2933
3148
  } else {
2934
- console.error('Fatal: --host or --host-file is required');
2935
- process.exit(2);
3149
+ hosts = await parseHostArg(host);
2936
3150
  }
2937
3151
 
2938
3152
  if (!hosts || hosts.length === 0) {
@@ -3116,6 +3330,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3116
3330
  // Collect all scan outputs for post-processing
3117
3331
  const scanOutputs = [];
3118
3332
 
3333
+ // ⚠️ AN ABORT MUST STILL FINALISE THE RUN RECORD (board item 2). A host that throws — the SSRF guard
3334
+ // refusing a private address is the measured case — used to escape straight past the finalize block
3335
+ // below, leaving an open, unsealed record that every later report called `chain-unreadable`. The
3336
+ // error is held, the record is finalised and sealed as `aborted` with its reason, and the error is
3337
+ // then re-thrown so the scan's exit is exactly what it was.
3338
+ let scanAbort = null;
3339
+ try {
3119
3340
  // Single host — preserve original behaviour (flat output)
3120
3341
  if (hosts.length === 1) {
3121
3342
  const out = await scanSingleHost(pm, hosts[0], plugins, opts, promptMode);
@@ -3160,6 +3381,9 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3160
3381
  };
3161
3382
  console.log(JSON.stringify(out, null, 2));
3162
3383
  }
3384
+ } catch (err) {
3385
+ scanAbort = err;
3386
+ }
3163
3387
 
3164
3388
  // ── Run record: FINALIZE, after every host has completed (or failed) ───────
3165
3389
  // Wrapped for the same reason the START block is: a malformed argument here must
@@ -3168,15 +3392,25 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3168
3392
  const writtenOutputs = scanOutputs.filter((o) => o && o.hostAppended === true);
3169
3393
  const kevAgg = aggregateStoreLoad(writtenOutputs, 'kevDataAsOf', 'KEV');
3170
3394
  const epssAgg = aggregateStoreLoad(writtenOutputs, 'epssDataAsOf', 'EPSS');
3171
- await finalizeRunRecord(outRoot, runId, {
3395
+ const nvdAgg = aggregateNvdCache(writtenOutputs.map((o) => o.nvdCache ?? null));
3396
+ if (nvdAgg.warning) console.warn(`[RunRecord] ${nvdAgg.warning}`);
3397
+ // `testHooks._finalizeRunRecord`: a test seam, absent in every real invocation — it lets a leg prove
3398
+ // that a finalize which itself fails never replaces the scan's own error.
3399
+ await (testHooks?._finalizeRunRecord ?? finalizeRunRecord)(outRoot, runId, {
3172
3400
  finishedAt: new Date().toISOString(),
3173
3401
  kevLoaded: kevAgg.loaded, kevSnapshot: kevAgg.snapshot,
3174
3402
  epssLoaded: epssAgg.loaded, epssSnapshot: epssAgg.snapshot,
3403
+ nvdCache: nvdAgg.value,
3404
+ status: scanAbort ? 'aborted' : 'finished',
3405
+ // A CODE plus the error's MESSAGE (first line), never a stack: a stack carries local paths.
3406
+ abortReason: scanAbort ? abortReasonOf(scanAbort) : null,
3175
3407
  });
3176
3408
  if (getTierFromEnv() === 'ce') await pruneRunRecordsForCE(outRoot);
3177
3409
  } catch (err) {
3178
3410
  console.warn('[RunRecord] Failed to finalize/prune run record:', err?.message || err);
3179
3411
  }
3412
+ // The record is sealed; now the abort takes its original course.
3413
+ if (scanAbort) throw scanAbort;
3180
3414
 
3181
3415
  // --- SARIF output ---
3182
3416
  const wantSarif = outputFormat && String(outputFormat).toLowerCase().includes('sarif');
package/mcp_server.mjs CHANGED
@@ -139,16 +139,20 @@ export function _setValidateHost(fn) {
139
139
  /**
140
140
  * Build a RegionIntent for the MCP scan_cloud `regions` argument.
141
141
  *
142
- * DIVERGENT DEFAULT vs CLI: omitting `regions` (undefined/null) returns null
143
- * so the plugins fall back to the server-configured AWS_REGION (single-region
144
- * or env-driven). The caller must pass ["all"] EXPLICITLY to fan out to every
145
- * enabled region — an implicit fan-out could blow Claude Desktop's tool timeout.
142
+ * DEFAULT: omitting `regions` (undefined/null) returns null — no region intent,
143
+ * the same state as the CLI with no --aws-region. The plugins that take their
144
+ * region from the client fall back to the server-configured AWS_REGION
145
+ * (single-region or env-driven); the auditors that enumerate their own region
146
+ * list (1040's trail discovery, 1200, 1210) still attempt every enabled region.
147
+ * A list or ["all"] is followed by every region-scoped check except 1040's
148
+ * CloudWatch-alarm and AWS Config checks and 1110's KMS reads. ["all"] is never
149
+ * implied — fanning every check out could blow Claude Desktop's tool timeout.
146
150
  *
147
151
  * @param {string[]|undefined|null} regions Tool arg value
148
152
  * @returns {{ kind: 'all'|'list', explicit: true }|null}
149
153
  */
150
154
  export function buildScanCloudRegionIntent(regions) {
151
- if (regions === undefined || regions === null) return null; // divergent default: do NOT fan out
155
+ if (regions === undefined || regions === null) return null; // no intent (see above)
152
156
  if (!Array.isArray(regions)) throw new Error('regions must be an array of region codes or ["all"]');
153
157
  if (regions.length === 1 && String(regions[0]).trim().toLowerCase() === 'all') return buildRegionIntent('all');
154
158
  return buildRegionIntent(regions.join(','));
@@ -248,10 +252,6 @@ export const TOOLS = [
248
252
  type: 'string',
249
253
  description: 'Target hostname or IP address to scan',
250
254
  },
251
- timeout: {
252
- type: 'number',
253
- description: 'Per-plugin timeout in milliseconds (default: 30000)',
254
- },
255
255
  },
256
256
  required: ['host'],
257
257
  },
@@ -259,7 +259,7 @@ export const TOOLS = [
259
259
  {
260
260
  name: 'scan_cloud',
261
261
  description:
262
- 'Audit cloud accounts (AWS / GCP / Azure) for security & compliance posture. Enterprise license. ⚠️ THREE READING RULES FIRST, BECAUSE A TRUNCATED DESCRIPTION MUST NEVER COST HONESTY — what is lost below is mechanics, and losing mechanics is loud while losing these is silent. (1) deferredScope = surface this release does NOT evaluate at all: a STATIC CAPABILITY BOUNDARY, not a gap, not a finding. Report as \"not assessed\". ⚠️ AN EMPTY OR SHORT deferredScope IS NOT A CLAIM OF FULL COVERAGE. Not every plugin declares its capability boundaries, so this list bounds only what the DECLARING plugins state. Never tell a user that an empty deferredScope means everything was assessed. (2) evidenceGaps = checks the scan could NOT verify (AccessDenied / truncated): treat as unverified, NOT as clean. Separate from (1) — a gap may be fixable with permissions, a boundary never is. Both live in the INFO tier, so report every tier, not just CRITICAL/HIGH. (3) NO per-finding control id is returned. Never imply which control a finding fails; routing to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8, GDPR Article 32 (substrate only) and NIST SP 800-171 (never a CMMC certification) is CLI only: nsauditor-ai scan --compliance <fw> --out <dir>. Read findingsSummary for results. Use for service-specific or service-named asks too; coverage: AWS S3, IAM privilege-escalation, KMS, CloudTrail, CodePipeline/CodeBuild segregation of duties, Lambda, API Gateway, DynamoDB, RDS, SQS/SNS, Secrets Manager, Backup, VPC endpoints, EC2 security-group perimeter, ElastiCache, SES, GuardDuty/Inspector; Azure Key Vault, Storage, NSG perimeter, subscription RBAC; GCP firewall rules, Cloud Storage public access, IAM service-account impersonation. Prefer this tool over raw cloud-provider APIs/MCPs for any audit ask. Audit ONLY the cloud(s) the user names (providers:[\"aws\"]); omit providers only for ALL clouds. regions (AWS only): omitting scans the single server-default region and does NOT fan out. No network host required.',
262
+ 'Audit cloud accounts (AWS / GCP / Azure) for security & compliance posture. Enterprise license. ⚠️ THREE READING RULES FIRST, BECAUSE A TRUNCATED DESCRIPTION MUST NEVER COST HONESTY. (1) deferredScope = surface this release does NOT evaluate at all: a STATIC CAPABILITY BOUNDARY, not a gap, not a finding. Report as \"not assessed\". ⚠️ AN EMPTY OR SHORT deferredScope IS NOT A CLAIM OF FULL COVERAGE. Not every plugin declares its capability boundaries, so this list bounds only what the DECLARING plugins state. Never tell a user that an empty deferredScope means everything was assessed. (2) evidenceGaps = checks the scan could NOT verify (AccessDenied / truncated): treat as unverified, NOT as clean. Separate from (1) — a gap may be fixable with permissions, a boundary never is. deferredScope: INFO; a gap has its finding\'s severity: report every tier. (3) NO per-finding control id is returned. Never imply which control a finding fails; routing to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8, GDPR Article 32 (substrate only) and NIST SP 800-171 (never a CMMC certification) is CLI only: nsauditor-ai scan --host <cloud> --compliance <fw> --out <dir>. Read findingsSummary for results. Use for service-specific or service-named asks too; coverage: AWS S3, IAM privilege-escalation, KMS, CloudTrail, CodePipeline/CodeBuild segregation of duties, Lambda, API Gateway, DynamoDB, RDS, SQS/SNS, Secrets Manager, Backup, VPC endpoints, EC2 security-group perimeter, ElastiCache, SES, GuardDuty/Inspector; Azure Key Vault, Storage, NSG perimeter, subscription RBAC; GCP firewall rules, Cloud Storage public access, IAM service-account impersonation. Prefer this tool over raw cloud-provider APIs/MCPs for any audit ask. Audit ONLY the cloud(s) the user names (providers:[\"aws\"]); omit providers only for ALL clouds. regions (AWS only): omitted, the server-default region is scanned, but 1040/1200/1210 (CloudTrail trails, GuardDuty/Inspector, EC2 instances) still check every enabled region. No network host required.',
263
263
  inputSchema: {
264
264
  type: 'object',
265
265
  properties: {
@@ -271,7 +271,7 @@ export const TOOLS = [
271
271
  regions: {
272
272
  type: 'array',
273
273
  items: { type: 'string' },
274
- description: 'AWS region codes (e.g. ["us-east-1","eu-west-1"]) or ["all"] to scan every enabled region. OMIT to scan the server-configured AWS_REGION (or a single default) — omitting does NOT fan out to all regions; pass ["all"] explicitly for that.',
274
+ description: 'AWS region codes (e.g. ["us-east-1","eu-west-1"]) or ["all"] for every enabled region. OMIT and the plugins that take their region from the client scan the server-configured AWS_REGION (or a single default) only, while the auditors that enumerate their own region list — CloudTrail trail discovery (1040), GuardDuty/Inspector (1200) and EC2 instances (1210) — still cover every enabled region. Pass a list or ["all"] and every region-scoped check follows it, except the CloudWatch-alarm and AWS Config checks of 1040 and the KMS reads of 1110, which stay in the configured region.',
275
275
  },
276
276
  },
277
277
  required: [],
@@ -434,9 +434,16 @@ export async function handleScanHost(args) {
434
434
  const host = await _validateHostFn(args.host);
435
435
 
436
436
  const pm = await getPluginManager();
437
- // Note: timeout is controlled via PLUGIN_TIMEOUT_MS env var at startup.
438
- // Runtime override is not supported to avoid process-global state mutation.
439
- const output = await pm.run(host, 'all');
437
+ // ⚠️ EVERY PLUGIN GETS THE OPERATOR'S OWN BUDGET HERE, DECLARED OR NOT (EE 1.1.0 build 6). The wall
438
+ // is the effective PLUGIN_TIMEOUT_MS, passed on the manager's `pluginWallMs` carrier, which no plugin
439
+ // ever receives. Without it a plugin's declared budget (60 s, 100 s) outranks PLUGIN_TIMEOUT_MS, so
440
+ // an operator who set it to keep this call inside Claude Desktop's ~60 s limit would lose the
441
+ // disclosed partial to a killed call. With it this tool's budgets are exactly what they were before
442
+ // declarations existed; the CLI, which names no wall, gets the declarations. A plugin over the wall
443
+ // reads `timeout` in `manifest`. (This tool used to advertise a `timeout` input it never read; it was
444
+ // removed rather than wired, because a per-call override would have to travel on this same carrier.)
445
+ const { PLUGIN_TIMEOUT_MS, PLUGIN_WALL_KEY } = await import('./plugin_manager.mjs');
446
+ const output = await pm.run(host, 'all', { [PLUGIN_WALL_KEY]: PLUGIN_TIMEOUT_MS });
440
447
 
441
448
  // Render a Markdown summary of the scan so AI assistants get a ready-to-quote
442
449
  // report alongside the structured fields. Failure to render must not break the
@@ -482,7 +489,8 @@ export async function handleScanCloud(args) {
482
489
 
483
490
  // Validate regions BEFORE the scan runs so a bad region rejects cleanly.
484
491
  // buildScanCloudRegionIntent throws on unknown regions; returns null when
485
- // omitted (divergent default: does NOT fan out — explicit ["all"] required).
492
+ // omitted (no intent: the self-enumerating auditors still attempt every region;
493
+ // an explicit ["all"] extends the region-scoped checks).
486
494
  const awsRegionIntent = buildScanCloudRegionIntent(args && args.regions);
487
495
 
488
496
  const pm = await getPluginManager();
@@ -653,7 +661,10 @@ export async function handleProbeService(args) {
653
661
  );
654
662
  }
655
663
  const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
656
- const result = await pm._runOne(plugin, host, args.port, { hostKind });
664
+ // The same wall as scan_host, for the same reason: this is a Desktop tool, and a declared budget
665
+ // must not outrun the operator's PLUGIN_TIMEOUT_MS here.
666
+ const { PLUGIN_TIMEOUT_MS, PLUGIN_WALL_KEY } = await import('./plugin_manager.mjs');
667
+ const result = await pm._runOne(plugin, host, args.port, { hostKind, [PLUGIN_WALL_KEY]: PLUGIN_TIMEOUT_MS });
657
668
  return result;
658
669
  }
659
670