nsauditor-ai 0.2.54 → 0.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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';
@@ -28,9 +28,12 @@ import { scrubByKey } from './utils/redact.mjs';
28
28
  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
- import { resolveBaseOutDir } from './utils/output_dir.mjs';
31
+ import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
32
+ import { loadEnterprise } from './utils/ee_load.mjs';
33
+ import { deriveFindingsCount } from './utils/report_inputs.mjs';
32
34
  import { toCleanPath } from './utils/path_helpers.mjs';
33
- import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE } from './utils/run_record.mjs';
35
+ import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE, aggregateNvdCache, abortReasonOf } from './utils/run_record.mjs';
36
+ import { buildSinceView } from './utils/scan_delta_view.mjs';
34
37
  import { loadRun } from './utils/report_inputs.mjs';
35
38
  import { loadBrand } from './utils/brand.mjs';
36
39
  import { renderExecutiveReport } from './utils/executive_report.mjs';
@@ -43,23 +46,47 @@ const parseBool = (val, def = false) => {
43
46
  if (!s && def != null) return !!def;
44
47
  return ['true', '1', 'yes', 'on', 'y'].includes(s);
45
48
  };
46
- const nowStamp = () => {
47
- const d = new Date();
48
- const pad = (n) => String(n).padStart(2, '0');
49
- return (
50
- d.getFullYear().toString() +
51
- pad(d.getMonth() + 1) +
52
- pad(d.getDate()) + '_' +
53
- pad(d.getHours()) +
54
- pad(d.getMinutes()) +
55
- pad(d.getSeconds())
56
- );
57
- };
58
- const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
49
+ // `nowStamp` and `safeHost` live in utils/output_dir.mjs beside `createHostOutDir`, so the directory name has one definition.
59
50
  // toCleanPath imported from ./utils/path_helpers.mjs (consolidated in v0.1.20)
60
51
 
61
- /** Minimal redactor used if nothing external is provided. */
62
- function redactSensitiveForAI(input, targetHost) {
52
+ /**
53
+ * ONE address rule for the whole AI payload, with our own identifiers carved out.
54
+ *
55
+ * ⚠️ SHARED BECAUSE IT WAS DUPLICATED AND ONE COPY WAS WRONG. `-` is a non-word character, so a
56
+ * bare `\b(?:(?:\d{1,3}\.){3}\d{1,3})\b` matches the tail of `EE-0.3.2.4` and rewrites a
57
+ * four-component identifier as an address. That shipped: two AI payloads carry
58
+ * `plugin 1020 (EE-[IP] fold)`. The identical rule was applied a second time to
59
+ * `payloadForAI.summary` — the same defect one field over, not live only because a network-scan
60
+ * summary happens to carry no identifier. Two copies of a rule, one of which was just found
61
+ * wrong, is how the next instance arrives; the POLICY differs between call sites (one keeps
62
+ * private addresses, the other scrubs every address) so the policy is the parameter and the rule
63
+ * is not.
64
+ *
65
+ * ⚠️ ONE ALTERNATION, NEVER TWO PASSES. Scrubbing addresses first destroys the identifier, and
66
+ * restoring identifiers afterwards cannot tell a real address from one that was always an id.
67
+ * With a single pass each construct consumes its own extent, so an address standing NEXT TO an
68
+ * identifier is still scrubbed.
69
+ *
70
+ * ⚠️ DECLARED RESIDUAL: an address wearing our own prefix — `EE-203.0.113.5` — is preserved
71
+ * verbatim, where the old rule scrubbed it. No value test can separate it from a real
72
+ * `EE-RT.11.1.2.5`, because the two are the same shape. Pinned in
73
+ * `tests/redact_product_ids.test.mjs` so the limit is stated rather than silent.
74
+ */
75
+ const PRODUCT_ID_OR_IPV4 = /(\bEE-(?:RT\.)?\d+(?:\.\d+)*\b)|(\b(?:\d{1,3}\.){3}\d{1,3}\b)/g;
76
+ export function scrubIPv4KeepingProductIds(str, replaceAddress) {
77
+ return String(str).replace(PRODUCT_ID_OR_IPV4,
78
+ (m, productId, ip) => (productId !== undefined ? productId : replaceAddress(ip)));
79
+ }
80
+
81
+ /**
82
+ * Minimal redactor used if nothing external is provided.
83
+ *
84
+ * ⚠️ EXPORTED SO IT CAN BE DRIVEN. A redactor's FALSE POSITIVE is invisible from the redacted
85
+ * artifact alone — `EE-[IP]` reads like a redaction that worked — so the only way to see one is
86
+ * to compare a raw against its payload, which needs the real function rather than a re-spelling
87
+ * of its rules. `tests/redact_product_ids.test.mjs` is that census.
88
+ */
89
+ export function redactSensitiveForAI(input, targetHost) {
63
90
  const DROP_KEYS = new Set([
64
91
  'ip6', 'deviceWebPage', 'deviceWebPageInstruction',
65
92
  'hardwareVersion', 'firmwareVersion'
@@ -76,7 +103,8 @@ function redactSensitiveForAI(input, targetHost) {
76
103
  s = s.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/gi, '[MAC]'); // MAC
77
104
  s = s.replace(/\bfe80::[0-9a-f:]+\b/gi, '[FE80::/64]'); // IPv6 link-local
78
105
  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]'));
106
+ // The shared rule; this call site's POLICY keeps private addresses and masks public ones.
107
+ s = scrubIPv4KeepingProductIds(s, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
80
108
  return s;
81
109
  };
82
110
 
@@ -118,17 +146,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
118
146
  : await resolveSecret(process.env.OPENAI_API_KEY);
119
147
  const key = keyRaw ? String(keyRaw).trim() : null;
120
148
 
121
- // Per-scan folder. Caller (scanSingleHost) may pass a pre-computed outDir so
122
- // that EE enrichment + compliance artifacts share the same folder as the AI
123
- // outputs — otherwise compute one here for legacy callers.
124
- let outDir = presetOutDir;
125
- if (!outDir) {
126
- const baseOutDir = resolveBaseOutDir();
127
- await fsp.mkdir(baseOutDir, { recursive: true });
128
- const ts = nowStamp();
129
- const runDir = `${safeHost(host)}_${ts}`;
130
- outDir = path.join(baseOutDir, runDir);
131
- }
149
+ // Per-scan folder — REQUIRED. Its only caller, scanSingleHost, creates it exclusively (createHostOutDir) so that EE
150
+ // enrichment, compliance artifacts and the AI outputs share one folder. ⚠️ The fallback that used to compose a
151
+ // directory here (`${safeHost(host)}_${nowStamp()}`, recursive mkdir) had no caller and was the same-second
152
+ // collision's shape kept warm (1.1.1, audit seat's fold); it is gone, and a missing outDir is an error.
153
+ if (!presetOutDir) throw new Error('maybeSendToOpenAI: outDir is required — scanSingleHost creates it with createHostOutDir');
154
+ const outDir = presetOutDir;
132
155
  await fsp.mkdir(outDir, { recursive: true });
133
156
 
134
157
  // Paths (fixed names inside per-scan folder)
@@ -280,8 +303,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
280
303
 
281
304
  // Redact any remaining IP addresses in the summary field
282
305
  if (typeof payloadForAI.summary === 'string') {
283
- payloadForAI.summary = payloadForAI.summary
284
- .replace(/\b(?:(?:\d{1,3}\.){3}\d{1,3})\b/g, '[REDACTED_HOST]');
306
+ // ⚠️ THE SAME RULE, A DIFFERENT POLICY: every address goes, private ones included. It
307
+ // carried its own copy of the address regex — the copy that was NOT fixed when the
308
+ // identifier defect was found in the redactor, which is exactly how the second instance
309
+ // of a class arrives. Measured at the time: neither live payload's summary carries an
310
+ // `EE-` token, so this was a latent duplicate rather than a live defect.
311
+ payloadForAI.summary = scrubIPv4KeepingProductIds(payloadForAI.summary, () => '[REDACTED_HOST]');
285
312
  }
286
313
 
287
314
  // Also redact private IPs in nested service/evidence strings
@@ -810,6 +837,8 @@ export async function parseArgs(argv) {
810
837
  args.format = str('format');
811
838
  const runVal = get('run');
812
839
  args.run = runVal === undefined ? undefined : runVal;
840
+ const sinceVal = get('since');
841
+ args.since = sinceVal === undefined ? undefined : sinceVal;
813
842
  const brandVal = get('brand');
814
843
  args.brand = brandVal === undefined ? undefined : brandVal;
815
844
  const reportOutVal = get('out');
@@ -819,6 +848,22 @@ export async function parseArgs(argv) {
819
848
  return args;
820
849
  }
821
850
 
851
+ /**
852
+ * The LAST refusal main() makes before it resolves hosts, as a value: an unknown command, then a `scan` with
853
+ * 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
854
+ * with `code`; the documented-command census (EE) drives the same function over every `nsauditor-ai scan` the
855
+ * product teaches, so a documented command is judged by the code that would refuse it rather than by a copy
856
+ * of its condition. A positional target is not a target (parseArgs reads the host only from a flag).
857
+ * ⚠️ A value-less `--host` parses to `true` and passes here; main() then fails in parseHostArg (boarded).
858
+ * @param {{ cmd?: string, host?: string|true, hostFile?: string }} args parseArgs' result
859
+ * @returns {{ message: string, code: number }|null}
860
+ */
861
+ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
862
+ if (cmd !== 'scan') return { message: `Unknown command: ${cmd}`, code: 2 };
863
+ if (!hostFile && !host) return { message: 'Fatal: --host or --host-file is required', code: 2 };
864
+ return null;
865
+ }
866
+
822
867
  // EE-0.3.2.5 (CE side): cloud-provider sentinel hosts ('aws' / 'gcp' /
823
868
  // 'azure', case-insensitive) are NOT real DNS names — they're scoping
824
869
  // tokens that EE cloud-scanner plugins (020/021/022/023/030) interpret
@@ -869,11 +914,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
869
914
  // Pre-compute the per-scan output folder so EE enrichment, compliance
870
915
  // artifacts, and AI outputs all land in the same directory. maybeSendToOpenAI
871
916
  // will reuse this presetOutDir below.
872
- const baseOutDir = resolveBaseOutDir();
873
- await fsp.mkdir(baseOutDir, { recursive: true });
874
- const ts = nowStamp();
875
- const outDir = path.join(baseOutDir, `${safeHost(host)}_${ts}`);
876
- await fsp.mkdir(outDir, { recursive: true });
917
+ //
918
+ // ⚠️ AN EXCLUSIVE CREATE (1.1.1 board): two scans of one host reaching this line in the same second used to share
919
+ // one directory and the later one overwrote the earlier one's evidence — see createHostOutDir. `opts.nowStamp` is
920
+ // a test seam (testHooks.nowStamp), absent in every real invocation.
921
+ const outDir = await createHostOutDir(resolveBaseOutDir(), host, opts?.nowStamp ? { stamp: opts.nowStamp } : {});
877
922
 
878
923
  // EE enrichment hook — no-op if @nsasoft/nsauditor-ai-ee is not installed
879
924
  // or the license tier doesn't grant intelligenceEngine. Compliance + outDir
@@ -908,11 +953,28 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
908
953
  // claim about the whole run, not a single host).
909
954
  let kevDataAsOf = null;
910
955
  let epssDataAsOf = null;
911
- try {
912
- // `opts.importEE`: test-only injection seam, mirroring `preflightNsauditorPosture`'s
913
- // `importEE` option elsewhere in this file — absent in every real invocation.
914
- ee = opts?.importEE ? await opts.importEE() : await import('@nsasoft/nsauditor-ai-ee');
915
- } catch { /* EE not installed — CE proceeds unchanged. The ONLY silent case. */ }
956
+ // Enterprise's report of WHERE the NVD store/cache it read lives (board item 23) — null without EE.
957
+ let nvdCache = null;
958
+ // ⚠️ "NOT INSTALLED" AND "INSTALLED BUT BROKEN" ARE TWO EVENTS (1.1.1, the audit seat's T1-c ruling). This used to be
959
+ // a bare `catch {}` read as "EE not installed — the ONLY silent case", and every import failure landed in it — measured
960
+ // at EE 174212f: Enterprise imported names this Community did not export, so below the floor the whole EE stage
961
+ // vanished without a word and the Pro delta read its rows RESOLVED. The package is RESOLVED first (utils/ee_load.mjs):
962
+ // absence stays silent; a failed load is named on stderr and recorded on the conclusion, beside `eeEnrichmentError`.
963
+ // `opts.importEE` / `opts.resolveEE`: test-only seams — absent in every real invocation.
964
+ const eeLoad = await loadEnterprise({ importEE: opts?.importEE ?? null, resolveEE: opts?.resolveEE ?? null });
965
+ ee = eeLoad.ee;
966
+ if (eeLoad.loadError) {
967
+ console.error(`[EE] Enterprise is installed but FAILED TO LOAD — this scan has no compliance report, no `
968
+ + `intelligence enrichment and no analysis-agent findings: ${eeLoad.loadError}`);
969
+ conclusion.result = conclusion.result || {};
970
+ conclusion.result.eeLoadError = eeLoad.loadError;
971
+ }
972
+ // ⚠️ HOISTED DELIBERATELY, and only this value. `eeEnrichment` is `const` INSIDE the try below,
973
+ // so reading it at the `appendHostWritten` call ~100 lines on is a ReferenceError — which the
974
+ // suite caught as 16 failures rather than one, because every history comparison swallowed it as
975
+ // "Failed to record/compare scan". Widening `eeEnrichment` itself would put the whole EE
976
+ // envelope in a scope that does not need it; one holder is the smaller contract.
977
+ let hostScopeScanned = null;
916
978
  try {
917
979
  const eeEnrichment = ee ? await ee.enrichScan(conclusion, {
918
980
  host,
@@ -925,8 +987,14 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
925
987
  complianceHistoryRoot: opts.complianceHistoryRoot,
926
988
  slaPolicy: opts.slaPolicy,
927
989
  results,
990
+ // ⚠️ WHAT EACH UPSTREAM DID, forwarded so EE can tell "nothing to map" from "my inputs
991
+ // never ran". Without it the CPE mapper returns a silently empty result when discovery
992
+ // times out, and the DISAPPEARANCE of its own coverage-gap records reads to the cross-run
993
+ // delta as findings RESOLVED — measured on a live run, three such rows.
994
+ pluginStatus,
928
995
  onWarn: (msg) => console.warn(`[EE] ${msg}`),
929
996
  }) : null;
997
+ hostScopeScanned = eeEnrichment?.scopeScanned ?? null;
930
998
  // ⚠️ Read from `exploitIntel.stores`, NOT a top-level `exploit` key — measured against
931
999
  // EE's actual `enrichScan` return shape (`index.mjs`'s final `return { ... exploitIntel:
932
1000
  // ctx.exploitIntel, ... }`), which nests the per-store `dataAsOf` at
@@ -938,6 +1006,10 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
938
1006
  const stores = eeEnrichment?.exploitIntel?.stores;
939
1007
  kevDataAsOf = stores?.kev?.dataAsOf ?? null;
940
1008
  epssDataAsOf = stores?.epss?.dataAsOf ?? null;
1009
+ const nvd = eeEnrichment?.nvdStore;
1010
+ nvdCache = (nvd && typeof nvd.path === 'string')
1011
+ ? { path: nvd.path, source: nvd.source ?? null, state: nvd.state ?? null, responseCache: nvd.responseCache ?? null }
1012
+ : null;
941
1013
  if (eeEnrichment?.enrichedPrompt) {
942
1014
  conclusion.result = conclusion.result || {};
943
1015
  conclusion.result.eeEnrichment = eeEnrichment;
@@ -970,7 +1042,36 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
970
1042
  // through it, whatever its own enrichment found.
971
1043
  let hostAppended = false;
972
1044
  try {
973
- const outRoot = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out').replace(/\.[^/.]+$/, '') || 'out';
1045
+ // ⚠️ `resolveBaseOutDir()`, NOT THE INLINE STRIP THAT USED TO BE HERE (board C3). The old
1046
+ // expression was `toCleanPath(...).replace(/\.[^/.]+$/, '')` — remove everything after the
1047
+ // last dot — so `--out …/ee-1.1.0` wrote the scan into `ee-1.1.0/` and its HISTORY into a
1048
+ // SIBLING `ee-1.1/`, created silently beside it. Both `ee-1.1/` and `ee-1.0/` are live
1049
+ // strays in this project's own evidence tree, which is how it was found: by looking at the
1050
+ // directory, not at the code.
1051
+ //
1052
+ // It is the EE 0.32.8 evidence-misplacement bug — "has a dot" read as "is a file" — surviving
1053
+ // in the one call site that never adopted the fixed resolver. A repair applied to a helper
1054
+ // does not reach a caller that does not use it, and the version-named directory convention
1055
+ // this project uses for its own evidence is exactly the shape that breaks.
1056
+ const outRoot = resolveBaseOutDir();
1057
+
1058
+ // ⚠️ AND THE MOVE IS ANNOUNCED RATHER THAN SILENT. A user whose previous scans wrote history
1059
+ // to the stray sibling would otherwise find this scan reporting no prior run — a correct
1060
+ // statement about the new location that reads as "nothing changed since last time". Name the
1061
+ // file instead; it is not read any more and it is not deleted either. Same discipline as the
1062
+ // report command's stale-artifact notice: never let an old file pass for a current one.
1063
+ const legacyHistoryRoot = toCleanPath(
1064
+ process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out'
1065
+ ).replace(/\.[^/.]+$/, '') || 'out';
1066
+ if (legacyHistoryRoot !== outRoot) {
1067
+ const here = await fsp.access(path.join(outRoot, HISTORY_FILE)).then(() => true, () => false);
1068
+ const there = await fsp.access(path.join(legacyHistoryRoot, HISTORY_FILE)).then(() => true, () => false);
1069
+ if (!here && there) {
1070
+ console.warn(`[scan] scan history now lives in ${outRoot}. An earlier build wrote it to `
1071
+ + `${legacyHistoryRoot}, which is NOT read any more — this scan starts a new history there. `
1072
+ + 'The old file is left on disk; nothing was deleted.');
1073
+ }
1074
+ }
974
1075
 
975
1076
  // Append this host to the run record now that its directory has landed — inside the
976
1077
  // SAME try/catch as the rest of scan-history recording. utils/run_record.mjs documents
@@ -994,7 +1095,15 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
994
1095
  // that reintroduces the same bug for the run record.
995
1096
  const runRecordRoot = resolveBaseOutDir();
996
1097
  if (opts?.runId) {
997
- hostAppended = await appendHostWritten(runRecordRoot, opts.runId, { host, dir: path.basename(outDir) });
1098
+ // ⚠️ THE EFFECTIVE SCOPE THIS HOST'S SCAN COVERED, never the flag — EE's resolver reports
1099
+ // what it actually resolved and CE records it per host, which is per PROVIDER on a cloud
1100
+ // scan. `null` on a network host or a CE-only install, and `null` means UNKNOWN rather than
1101
+ // "covered nothing": the delta stays silent when BOTH records lack it and fails closed when
1102
+ // only one carries it.
1103
+ hostAppended = await appendHostWritten(runRecordRoot, opts.runId, {
1104
+ host, dir: path.basename(outDir),
1105
+ scopeScanned: hostScopeScanned,
1106
+ });
998
1107
  }
999
1108
 
1000
1109
  const services = conclusion?.result?.services ?? [];
@@ -1015,7 +1124,36 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1015
1124
  const f = r?.result?.findings;
1016
1125
  return n + (Array.isArray(f) ? f.length : 0);
1017
1126
  }, 0);
1018
- const findingsCount = serviceFindingsCount + cloudFindingsCount;
1127
+ // ⚠️ DERIVED FROM THE REPORT LOADER, NOT SUMMED HERE (board C10). The two sums above are
1128
+ // KEPT because `cloudFindingsCount` still ships as its own field, but they no longer decide
1129
+ // `findingsCount`: they could not see the finding QUEUE, so a network host whose findings are
1130
+ // all CVE matches recorded ZERO — measured on the real 192.168.1.1 run, 0 against 37 queue
1131
+ // entries of which 21 carried CVEs. `scan_history` is a COMPARISON channel, so that zero
1132
+ // became "no change" on every subsequent scan, forever.
1133
+ //
1134
+ // The previous repair of this same channel added `cloudFindingsCount` when cloud plugins
1135
+ // recorded 0 over a 201-finding scan. That was per-producer, and a per-producer repair closes
1136
+ // only the producers it enumerates — which is how this channel came to be wrong twice. Using
1137
+ // the shaping `report --since` already uses means a producer the loader learns to read is
1138
+ // counted by BOTH channels on the same day, with nobody remembering to update this line.
1139
+ //
1140
+ // Read from DISK, not from memory: these are the exact bytes `report --since` will read, so
1141
+ // the two channels count the same artifacts and not two views of them. Both are written by
1142
+ // now — the envelope at the output stage, the queue by the enrichment hook.
1143
+ // ⚠️ ONE CALL FOR BOTH THE NUMBER AND ITS BASIS. They were two statements here, and a mutant
1144
+ // that reverted the count to the legacy sum kept the basis stamp — a line claiming to have
1145
+ // been counted the new way while holding an old-way number, which the end-to-end leg could
1146
+ // not see. Only the thing that produces the number may assert how it was produced.
1147
+ const derived = await deriveFindingsCount(outDir, host);
1148
+ const findingsCount = derived ? derived.count : (serviceFindingsCount + cloudFindingsCount);
1149
+ const findingsCountBasis = derived ? derived.basis : null;
1150
+ if (!derived) {
1151
+ // Falls back to the legacy sum WITHOUT claiming the new basis, so `computeDiff` treats the
1152
+ // line as pre-1.1.0 and refuses to subtract it against a properly-counted one rather than
1153
+ // silently comparing two different definitions of a finding.
1154
+ console.warn('[scan] could not derive findingsCount from the written artifacts; recording the '
1155
+ + 'legacy sum and marking the line as un-based, so no comparison is made across it.');
1156
+ }
1019
1157
 
1020
1158
  const scanSummary = {
1021
1159
  timestamp: new Date().toISOString(),
@@ -1024,6 +1162,14 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1024
1162
  openPorts: services.filter((s) => s.status === 'open').map((s) => s.port),
1025
1163
  os: conclusion?.result?.host?.os ?? null,
1026
1164
  findingsCount,
1165
+ findingsCountBasis,
1166
+ // ⚠️ THE TIER THAT PRODUCED THE COUNT (board E9), read from the SAME resolver this file
1167
+ // already branches on twelve lines below, never re-derived. The tiers do not count the
1168
+ // same things — Enterprise writes a finding queue that Community never produces — so
1169
+ // `computeDiff` refuses to subtract across a tier boundary, and it can only do that if the
1170
+ // line says which tier counted. Without this field every leg of that refusal is
1171
+ // unreachable in production while its unit tests stay green.
1172
+ tier: getTierFromEnv(),
1027
1173
  cloudFindingsCount,
1028
1174
  services: services.map((s) => ({
1029
1175
  port: s.port, protocol: s.protocol ?? 'tcp',
@@ -1054,7 +1200,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1054
1200
  return {
1055
1201
  host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
1056
1202
  // Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
1057
- hostAppended, kevDataAsOf, epssDataAsOf,
1203
+ hostAppended, kevDataAsOf, epssDataAsOf, nvdCache,
1058
1204
  };
1059
1205
  }
1060
1206
 
@@ -1123,7 +1269,7 @@ async function readSecretFromStdin(keyName) {
1123
1269
  });
1124
1270
  }
1125
1271
 
1126
- function maxSeverityInConclusion(conclusion) {
1272
+ export function maxSeverityInConclusion(conclusion) {
1127
1273
  const services = conclusion?.result?.services || [];
1128
1274
  let max = 0;
1129
1275
 
@@ -1245,7 +1391,9 @@ export async function preflightGrcIfRequested(env, opts = {}) {
1245
1391
  // test drive the real scan pipeline (including this function's own run-record wiring) against
1246
1392
  // a controlled, injected EE module — e.g. one whose `enrichScan` reports a specific KEV/EPSS
1247
1393
  // `dataAsOf` — without depending on the real `@nsasoft/nsauditor-ai-ee` package, a real store
1248
- // file, or the machine's own license/tier state being any particular thing.
1394
+ // file, or the machine's own license/tier state being any particular thing. An injected `importEE` counts as an
1395
+ // INSTALLED package (utils/ee_load.mjs): to describe one that is NOT installed, pass `testHooks.resolveEE` throwing —
1396
+ // a throwing `importEE` alone is now a package that is installed and FAILED to load, which the scan reports.
1249
1397
  // Reconstructs a single tier word ('ce' | 'pro' | 'enterprise') from the resolved capability
1250
1398
  // map — never from getTierFromEnv()'s own global cache. `runReport` below deliberately never
1251
1399
  // reads that cache: `caps` is passed in as an argument specifically so Pro is reachable from the
@@ -1281,7 +1429,9 @@ function tierLabelFromCaps(caps) {
1281
1429
  * or refused (bad/missing flags, a flag mismatch, a tier refusal, `no-run`, `record-unreadable`,
1282
1430
  * a write failure) — fix the request or the environment. **1** = `loadRun` refused for any other
1283
1431
  * 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).
1432
+ * still 0 — the exit code must never encode whether findings exist, and with `--since` it must
1433
+ * never encode WHETHER ANYTHING CHANGED either: a no-change delta and a twelve-new-exposure
1434
+ * delta both exit 0, and only could-not-measure is non-zero).
1285
1435
  *
1286
1436
  * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
1287
1437
  */
@@ -1309,7 +1459,7 @@ export async function runReport(args, caps) {
1309
1459
  // catch this after the fact — it must be caught here or never. Same precedent already applied
1310
1460
  // to `--sla-policy`/`--compliance-history` in the scan path, below.
1311
1461
  for (const [flag, val] of [['--from', args.from], ['--brand', args.brand],
1312
- ['--run', args.run], ['--out', args.out]]) {
1462
+ ['--run', args.run], ['--out', args.out], ['--since', args.since]]) {
1313
1463
  if (val === true) {
1314
1464
  logErr(`\`report\` needs a value for ${flag}. A flag that quietly does nothing is how an `
1315
1465
  + 'operator concludes a subject was resolved.');
@@ -1341,7 +1491,7 @@ export async function runReport(args, caps) {
1341
1491
  { tier: tierLabelFromCaps(caps) });
1342
1492
  if (!loaded.ok) {
1343
1493
  logErr(loaded.message);
1344
- // MAP BY REASON, not by "loadRun said no" — an EXHAUSTIVE switch over the ten documented
1494
+ // MAP BY REASON, not by "loadRun said no" — an EXHAUSTIVE switch over the eleven documented
1345
1495
  // reasons, with a loud default. `no-run` and `record-unreadable` are COMMAND/environment
1346
1496
  // problems (fix the invocation or the filesystem); every other reason is a RUN problem the
1347
1497
  // operator fixes by re-scanning.
@@ -1349,6 +1499,7 @@ export async function runReport(args, caps) {
1349
1499
  case 'no-run':
1350
1500
  case 'record-unreadable':
1351
1501
  return finish(2);
1502
+ case 'aborted-run':
1352
1503
  case 'ambiguous-run':
1353
1504
  case 'binding-mismatch':
1354
1505
  case 'incomplete-run':
@@ -1396,6 +1547,48 @@ export async function runReport(args, caps) {
1396
1547
  log('[report] coverage: incomplete — the run did not record completion');
1397
1548
  }
1398
1549
 
1550
+ // ── `--since`: the cross-run delta. Placed AFTER the coverage caveats so a partial or
1551
+ // incomplete CURRENT run is already disclosed before any delta verdict is read.
1552
+ let sinceDelta = null;
1553
+ // A refused COMPARISON still renders and still exits 2: the artifact says the comparison was
1554
+ // refused, and could-not-measure stays loud.
1555
+ let sinceRefusalCode = 0;
1556
+ if (typeof args.since === 'string' && args.since !== '') {
1557
+ const view = await buildSinceView({ outRoot: args.from, model, since: args.since,
1558
+ allowPartial: !!args.allowPartial, tier: tierLabelFromCaps(caps) });
1559
+ view.out.forEach(log);
1560
+ view.err.forEach(logErr);
1561
+ // ⚠️ TWO KINDS OF EXIT 2, AND THE ARTIFACT IS WHAT DISTINGUISHES THEM (board C9). This line
1562
+ // used to `return finish(view.code)` for BOTH, so a refused comparison wrote no file at all
1563
+ // — and `executive_report.mjs`'s `delta-refused` block, which renders exactly the honest
1564
+ // artifact, was unreachable through every shipped path. The consultant's deliverable is
1565
+ // `--out client.html`; a refusal that leaves a STALE client.html there is worse than the
1566
+ // refusal, because the file they hand a client is from an earlier run and says nothing of it.
1567
+ // ⚠️ AND THE ARTIFACT MUST BE ABLE TO CARRY THE REFUSAL, OR IT MUST NOT BE WRITTEN. Only the
1568
+ // executive report renders the delta; `renderJiraCsv` never receives it. Writing a CSV that
1569
+ // says nothing while exiting 2 would make "exit 2 WITH an artifact" mean one thing for HTML
1570
+ // and the opposite for CSV — a contract that depends on the format is not a contract. Any
1571
+ // format that cannot state the refusal falls back to the request/run behaviour: write
1572
+ // nothing, name any stale file, exit 2.
1573
+ if (view.code !== 0 && (!view.delta || format !== 'executive')) {
1574
+ // The REQUEST or the RUN ITSELF was refused: nothing honest to render. Say so about any
1575
+ // file already on disk, so it cannot pass as this run's output.
1576
+ // ⚠️ `fsp`, NOT `fs` — this module imports only `node:fs/promises`. The first draft wrote
1577
+ // `fs.existsSync` and threw ReferenceError on the refusal path. It surfaced immediately
1578
+ // here; the same slip in `report_inputs.mjs` earlier this cycle sat under a bare `catch`
1579
+ // and read CLEAN. Same bug, opposite visibility, and the difference was the catch.
1580
+ const stale = args.out ? await fsp.access(args.out).then(() => true, () => false) : false;
1581
+ if (stale) {
1582
+ logErr(`[report] ${args.out} was NOT rewritten; it is from an earlier run.`);
1583
+ }
1584
+ return finish(view.code);
1585
+ }
1586
+ // ⚠️ CARRIED INTO THE ARTIFACT, not only printed. stdout reaches the OPERATOR, who knows what
1587
+ // the tool does; the HTML reaches the CLIENT being billed.
1588
+ sinceDelta = view.delta ?? null;
1589
+ sinceRefusalCode = view.code !== 0 ? view.code : 0;
1590
+ }
1591
+
1399
1592
  let body;
1400
1593
  let ext;
1401
1594
  if (format === 'executive') {
@@ -1404,7 +1597,7 @@ export async function runReport(args, caps) {
1404
1597
  logErr(brandResult.message);
1405
1598
  return finish(2);
1406
1599
  }
1407
- body = renderExecutiveReport(model, brandResult.brand, { renderedAt: new Date() });
1600
+ body = renderExecutiveReport(model, brandResult.brand, { renderedAt: new Date(), delta: sinceDelta });
1408
1601
  ext = 'html';
1409
1602
  } else {
1410
1603
  body = renderJiraCsv(model);
@@ -1428,6 +1621,17 @@ export async function runReport(args, caps) {
1428
1621
  return finish(2);
1429
1622
  }
1430
1623
  log(`[report] wrote ${outPath}`);
1624
+ // ⚠️ THE ARTIFACT IS WRITTEN AND THE EXIT IS STILL 2 when the COMPARISON was refused. The
1625
+ // report rendered — that is what the 0 would be claiming — but the thing the operator ASKED
1626
+ // for could not be measured, and could-not-measure stays loud everywhere in this repo. The
1627
+ // artifact itself says which happened, so the pair is unambiguous: exit 2 WITH a file means
1628
+ // the comparison was refused and the file says so; exit 2 WITHOUT one means the request or the
1629
+ // run itself was refused.
1630
+ if (sinceRefusalCode !== 0) {
1631
+ logErr(`[report] the report was written, but the requested comparison was REFUSED — `
1632
+ + `${outPath} states that and contains no resolved, new or changed rows.`);
1633
+ return finish(sinceRefusalCode);
1634
+ }
1431
1635
  return finish(0);
1432
1636
  }
1433
1637
 
@@ -1468,25 +1672,35 @@ Scan options:
1468
1672
  --aws-profile <name> Use a named profile from the OS-default ~/.aws/credentials.
1469
1673
  Implies CLOUD_PROVIDER=aws; overrides explicit AWS_* keys.
1470
1674
  --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.
1675
+ or 'all' (every account-enabled region). Default: no intent —
1676
+ region-scoped checks use AWS_REGION, else one region (with an
1677
+ incomplete-coverage notice); CloudTrail trail discovery,
1678
+ GuardDuty/Inspector and EC2 instances attempt every region.
1473
1679
  --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
1474
- --ports <range> Override port list (e.g. 22,80,443 or 1-1000)
1680
+ --ports <list> Ports ADDED to the default set (e.g. 22,80,443 or 161/udp;
1681
+ a range such as 1-1000 is not parsed and adds nothing)
1475
1682
  --out <dir> Output directory for scan artifacts
1476
1683
  --parallel <n> Parallel host concurrency (default 1)
1477
- --fail-on <severity> Exit non-zero if any finding ≥ severity
1684
+ --fail-on <severity> Exit 1 if a gated flag ≥ severity: anonymous FTP / zone
1685
+ transfer (critical; tested only with FTP_CHECK_ANON /
1686
+ DNS_CHECK_AXFR set), weak SSH algorithms (medium); any
1687
+ concluded scan is info. Dangerous HTTP methods are read
1688
+ but never reach the conclusion today. Not SNMP community,
1689
+ weak TLS, MCP checks, CVEs or agent findings — exit 0 is
1690
+ not a clean host. Exit 2: unknown severity or no conclusion.
1478
1691
  --output-format <fmt> Additional report format: sarif | csv | md
1479
1692
  --insecure-https Skip TLS validation on probed HTTPS targets
1480
1693
  --watch CTEM continuous ALERTING mode: re-scan on --interval,
1481
- diff against the previous cycle, fire --webhook-url when a
1482
- change crosses --alert-severity. NOT an evidence cadence —
1694
+ compare each host with its previous scan ([ScanHistory]
1695
+ lines); --webhook-url does NOT fire on a service, version or
1696
+ finding change in this release. NOT an evidence cadence —
1483
1697
  it adds no retention or cross-run aggregation, skips SARIF/
1484
1698
  CSV/Markdown output and --fail-on, and dies with this process.
1485
1699
  Each tick is an ordinary scan, so with --compliance it writes
1486
1700
  that tick's artifacts; nothing relates them across ticks. For
1487
1701
  SOC 2 Type II history use a scheduler (cron/systemd/CI).
1488
1702
  --interval <minutes> Watch interval (default 60)
1489
- --webhook-url <url> Send delta alerts (must be public; private/loopback blocked)
1703
+ --webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
1490
1704
  --alert-severity <sev> Min severity to alert on (default: high)
1491
1705
  --compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
1492
1706
  soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
@@ -1850,17 +2064,19 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1850
2064
  for (const plugin of sorted) {
1851
2065
  const required = Array.isArray(plugin.requiredCapabilities) ? plugin.requiredCapabilities : [];
1852
2066
  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}`;
2067
+ // The label is the tier the GATE enforces, derived from `requiredCapabilities` alone — the
2068
+ // list the plugin manager checks before it runs a plugin. A manifest's `tier` word is NOT
2069
+ // read: until 0.2.55 it outranked the derivation, and EE 1020 / 1030 declared `tier: "pro"`
2070
+ // over `cloudScanners`, so at Community tier this list said Pro unlocks two plugins Pro does
2071
+ // not. (Before that, EE 1021 / 1022 / 1023 declared no capabilities at all and this line
2072
+ // showed them `✓ active` at every tier.) A capability the registry does not define is
2073
+ // granted by no licence — the plugin never loads — so it is NAMED rather than labelled
2074
+ // with a tier; `inferRequiredTier` skips unknown names and would print "requires: ce".
2075
+ // Reachable only when allMet is false, i.e. `required` is non-empty.
2076
+ const unknown = required.filter((c) => !Object.hasOwn(CAPABILITIES, c));
2077
+ const status = allMet ? '✓ active'
2078
+ : unknown.length > 0 ? `✗ requires a capability no licence grants: ${unknown.join(', ')}`
2079
+ : `✗ requires: ${inferRequiredTier(required)}`;
1864
2080
  // Layout matches the EE README example:
1865
2081
  // " 003 SSH Scanner ✓ active"
1866
2082
  const idStr = String(plugin.id ?? '?').padEnd(3);
@@ -2662,6 +2878,9 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2662
2878
  extrasDir: F.extrasDir || undefined,
2663
2879
  });
2664
2880
  console.log(`imported ${out.imported} CVE(s), skipped ${out.skipped}, errors ${out.errors}`);
2881
+ // WHERE it went — the same location every scan reads, from any directory (Enterprise 1.1.0).
2882
+ // Printed because an import into a place the operator did not expect is an import nobody uses.
2883
+ if (out.store?.path) console.log(` store: ${out.store.path} (${out.store.source})`);
2665
2884
  // ⚠️ THE SKIP SPLIT PRINTS HERE BECAUSE THE DATA-LOSS TICKET ORIGINATES HERE.
2666
2885
  // On a real NVD year file ~24% of records are skipped (measured on the 2024 feed:
2667
2886
  // 39,219 in, 29,866 imported, 9,353 skipped). An operator reading a bare "skipped 9353"
@@ -2916,23 +3135,21 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2916
3135
  process.exit(code);
2917
3136
  }
2918
3137
 
2919
- if (cmd !== 'scan') {
2920
- console.error(`Unknown command: ${cmd}`);
2921
- process.exit(2);
3138
+ const targetRefusal = scanTargetRefusal({ cmd, host, hostFile });
3139
+ if (targetRefusal) {
3140
+ console.error(targetRefusal.message);
3141
+ process.exit(targetRefusal.code);
2922
3142
  }
2923
3143
 
2924
- // Resolve host list
3144
+ // Resolve host list — scanTargetRefusal has established that hostFile or host is set.
2925
3145
  let hosts;
2926
3146
  if (hostFile) {
2927
3147
  // Reuse the up-front resolution done for the CLOUD_PROVIDER reconcile (avoids a
2928
3148
  // second parse + duplicate suspicious-line warnings); re-parse only if that was
2929
3149
  // skipped/failed so a genuine host-file error still surfaces here as before.
2930
3150
  hosts = resolvedHosts ?? await parseHostFile(hostFile);
2931
- } else if (host) {
2932
- hosts = await parseHostArg(host);
2933
3151
  } else {
2934
- console.error('Fatal: --host or --host-file is required');
2935
- process.exit(2);
3152
+ hosts = await parseHostArg(host);
2936
3153
  }
2937
3154
 
2938
3155
  if (!hosts || hosts.length === 0) {
@@ -2942,6 +3159,10 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2942
3159
 
2943
3160
  const opts = { insecureHttps };
2944
3161
  if (testHooks.importEE) opts.importEE = testHooks.importEE;
3162
+ if (testHooks.resolveEE) opts.resolveEE = testHooks.resolveEE;
3163
+ // `testHooks.nowStamp`: a test seam, absent in every real invocation — it pins the per-host directory's second so a
3164
+ // leg can drive the same-second collision through the shipped scan path.
3165
+ if (testHooks.nowStamp) opts.nowStamp = testHooks.nowStamp;
2945
3166
  if (ports) opts.ports = ports;
2946
3167
  if (compliance) opts.compliance = compliance;
2947
3168
  if (complianceScope) opts.complianceScope = complianceScope;
@@ -3116,6 +3337,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3116
3337
  // Collect all scan outputs for post-processing
3117
3338
  const scanOutputs = [];
3118
3339
 
3340
+ // ⚠️ AN ABORT MUST STILL FINALISE THE RUN RECORD (board item 2). A host that throws — the SSRF guard
3341
+ // refusing a private address is the measured case — used to escape straight past the finalize block
3342
+ // below, leaving an open, unsealed record that every later report called `chain-unreadable`. The
3343
+ // error is held, the record is finalised and sealed as `aborted` with its reason, and the error is
3344
+ // then re-thrown so the scan's exit is exactly what it was.
3345
+ let scanAbort = null;
3346
+ try {
3119
3347
  // Single host — preserve original behaviour (flat output)
3120
3348
  if (hosts.length === 1) {
3121
3349
  const out = await scanSingleHost(pm, hosts[0], plugins, opts, promptMode);
@@ -3160,6 +3388,9 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3160
3388
  };
3161
3389
  console.log(JSON.stringify(out, null, 2));
3162
3390
  }
3391
+ } catch (err) {
3392
+ scanAbort = err;
3393
+ }
3163
3394
 
3164
3395
  // ── Run record: FINALIZE, after every host has completed (or failed) ───────
3165
3396
  // Wrapped for the same reason the START block is: a malformed argument here must
@@ -3168,15 +3399,25 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3168
3399
  const writtenOutputs = scanOutputs.filter((o) => o && o.hostAppended === true);
3169
3400
  const kevAgg = aggregateStoreLoad(writtenOutputs, 'kevDataAsOf', 'KEV');
3170
3401
  const epssAgg = aggregateStoreLoad(writtenOutputs, 'epssDataAsOf', 'EPSS');
3171
- await finalizeRunRecord(outRoot, runId, {
3402
+ const nvdAgg = aggregateNvdCache(writtenOutputs.map((o) => o.nvdCache ?? null));
3403
+ if (nvdAgg.warning) console.warn(`[RunRecord] ${nvdAgg.warning}`);
3404
+ // `testHooks._finalizeRunRecord`: a test seam, absent in every real invocation — it lets a leg prove
3405
+ // that a finalize which itself fails never replaces the scan's own error.
3406
+ await (testHooks?._finalizeRunRecord ?? finalizeRunRecord)(outRoot, runId, {
3172
3407
  finishedAt: new Date().toISOString(),
3173
3408
  kevLoaded: kevAgg.loaded, kevSnapshot: kevAgg.snapshot,
3174
3409
  epssLoaded: epssAgg.loaded, epssSnapshot: epssAgg.snapshot,
3410
+ nvdCache: nvdAgg.value,
3411
+ status: scanAbort ? 'aborted' : 'finished',
3412
+ // A CODE plus the error's MESSAGE (first line), never a stack: a stack carries local paths.
3413
+ abortReason: scanAbort ? abortReasonOf(scanAbort) : null,
3175
3414
  });
3176
3415
  if (getTierFromEnv() === 'ce') await pruneRunRecordsForCE(outRoot);
3177
3416
  } catch (err) {
3178
3417
  console.warn('[RunRecord] Failed to finalize/prune run record:', err?.message || err);
3179
3418
  }
3419
+ // The record is sealed; now the abort takes its original course.
3420
+ if (scanAbort) throw scanAbort;
3180
3421
 
3181
3422
  // --- SARIF output ---
3182
3423
  const wantSarif = outputFormat && String(outputFormat).toLowerCase().includes('sarif');