nsauditor-ai 0.2.50 → 0.2.51

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
@@ -30,6 +30,11 @@ 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
32
  import { toCleanPath } from './utils/path_helpers.mjs';
33
+ import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE } from './utils/run_record.mjs';
34
+ import { loadRun } from './utils/report_inputs.mjs';
35
+ import { loadBrand } from './utils/brand.mjs';
36
+ import { renderExecutiveReport } from './utils/executive_report.mjs';
37
+ import { renderJiraCsv } from './utils/jira_export.mjs';
33
38
 
34
39
  /* ------------------------- helpers & utilities ------------------------- */
35
40
 
@@ -95,7 +100,7 @@ function redactSensitiveForAI(input, targetHost) {
95
100
 
96
101
  /* ------------------------- OpenAI & reporting -------------------------- */
97
102
 
98
- async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basic', outDir: presetOutDir = null }) {
103
+ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basic', outDir: presetOutDir = null, runId = null, pluginStatus = [] }) {
99
104
  // --- env & opts -----------------------------------------------------------
100
105
  const sendEnabled = parseBool(process.env.AI_ENABLED);
101
106
  const redactEnabled = parseBool(process.env.OPENAI_REDACT, true);
@@ -200,7 +205,11 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
200
205
 
201
206
  // --- Admin RAW (unsanitized) JSON + Admin HTML ----------------------------
202
207
  try {
203
- const adminRaw = { host, summary: summaryWithFullSerial, results, conclusion };
208
+ // `runId` ties this per-host raw to the run record written at scan start
209
+ // (utils/run_record.mjs); `pluginStatus` is the manifest classifying every selected
210
+ // plugin as ran|skipped|timeout|error — both additive, no existing consumer reads
211
+ // either name (verified with `grep -rn "adminRaw\." cli.mjs`).
212
+ const adminRaw = { host, summary: summaryWithFullSerial, results, conclusion, runId, pluginStatus };
204
213
  await fsp.writeFile(adminRawPath, JSON.stringify(adminRaw, null, 2), 'utf8');
205
214
  console.log('[OpenAI] Wrote admin RAW:', adminRawPath);
206
215
 
@@ -784,6 +793,29 @@ export async function parseArgs(argv) {
784
793
  const awsRegionVal = get('aws-region');
785
794
  args.awsRegion = awsRegionVal === undefined ? undefined : awsRegionVal;
786
795
 
796
+ // ── the Pro `report` subcommand (Task 8) ────────────────────────────────────────────────────
797
+ // ⚠️ CC-4: `--brand`/`--run`/`--out`/`--from` do NOT go through `str()` — `str()` collapses a
798
+ // value-less flag to the SAME `null` an absent flag produces, and for these four that erases a
799
+ // real distinction. `--brand` absent is a legitimate default (unbranded); `--brand` typed with
800
+ // its value swallowed by a shell glob is an operator MISTAKE that must not silently produce the
801
+ // exact same unbranded report while exiting 0 — "a flag that quietly does nothing is how an
802
+ // operator concludes a subject was resolved" (runReport's own header). Same reasoning as the
803
+ // existing `--sla-policy`/`--compliance-history` tri-state below: keep `get()`'s raw tri-state
804
+ // (undefined absent / `true` value-less / string present) and let `runReport` refuse the `true`
805
+ // case by name. `--format` is deliberately NOT included here — it has no valid "absent" default
806
+ // (both absent and value-less already fail its enum check with a clear message), so collapsing
807
+ // it via `str()` costs nothing.
808
+ const fromVal = get('from');
809
+ args.from = fromVal === undefined ? undefined : fromVal;
810
+ args.format = str('format');
811
+ const runVal = get('run');
812
+ args.run = runVal === undefined ? undefined : runVal;
813
+ const brandVal = get('brand');
814
+ args.brand = brandVal === undefined ? undefined : brandVal;
815
+ const reportOutVal = get('out');
816
+ args.out = reportOutVal === undefined ? undefined : reportOutVal;
817
+ args.allowPartial = a.includes('--allow-partial');
818
+
787
819
  return args;
788
820
  }
789
821
 
@@ -822,7 +854,10 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
822
854
  }
823
855
  }
824
856
 
825
- const { results, conclusion } = await pm.run(host, plugins || 'all', opts);
857
+ // `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
858
+ // Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
859
+ // plugins never loaded — see utils/run_record.mjs.
860
+ const { results, conclusion, manifest: pluginStatus = [] } = await pm.run(host, plugins || 'all', opts);
826
861
 
827
862
  // Enrich conclusion with MITRE ATT&CK technique mapping
828
863
  const techniques = getAllTechniques(conclusion);
@@ -864,8 +899,19 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
864
899
  // is marked, because an operator who asked for `--compliance` and got no report must not
865
900
  // have to diff directories to discover it.
866
901
  let ee = null;
902
+ // The KEV/EPSS store's OWN `dataAsOf`, read from the same enrichment result CE already
903
+ // receives — set (or left null) inside the try below, and hoisted here so it survives
904
+ // past that block's scope. `null` means "not loaded for this host" — either EE is absent,
905
+ // enrichment threw before reaching exploit intel, or the tier/store genuinely has none;
906
+ // all three collapse to the same honest "not evaluated" reading for THIS host, and the
907
+ // top-level scan loop aggregates across hosts (utils/run_record.mjs's `kevLoaded` is a
908
+ // claim about the whole run, not a single host).
909
+ let kevDataAsOf = null;
910
+ let epssDataAsOf = null;
867
911
  try {
868
- ee = await import('@nsasoft/nsauditor-ai-ee');
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');
869
915
  } catch { /* EE not installed — CE proceeds unchanged. The ONLY silent case. */ }
870
916
  try {
871
917
  const eeEnrichment = ee ? await ee.enrichScan(conclusion, {
@@ -881,6 +927,17 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
881
927
  results,
882
928
  onWarn: (msg) => console.warn(`[EE] ${msg}`),
883
929
  }) : null;
930
+ // ⚠️ Read from `exploitIntel.stores`, NOT a top-level `exploit` key — measured against
931
+ // EE's actual `enrichScan` return shape (`index.mjs`'s final `return { ... exploitIntel:
932
+ // ctx.exploitIntel, ... }`), which nests the per-store `dataAsOf` at
933
+ // `exploitIntel.stores.{kev,epss}.dataAsOf`. EE's own comment there says this object is
934
+ // "ALWAYS PRESENT on a Pro scan, including when no store is configured" — so `null` here
935
+ // means the store genuinely was not loaded, not that we asked the wrong field and got
936
+ // `undefined` by accident. Independent of `enrichedPrompt` below: that governs whether the
937
+ // AI prompt gets the enrichment envelope, not whether a store was loaded for this host.
938
+ const stores = eeEnrichment?.exploitIntel?.stores;
939
+ kevDataAsOf = stores?.kev?.dataAsOf ?? null;
940
+ epssDataAsOf = stores?.epss?.dataAsOf ?? null;
884
941
  if (eeEnrichment?.enrichedPrompt) {
885
942
  conclusion.result = conclusion.result || {};
886
943
  conclusion.result.eeEnrichment = eeEnrichment;
@@ -896,15 +953,50 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
896
953
  conclusion.result.eeEnrichmentError = String(err?.message ?? err);
897
954
  }
898
955
 
899
- const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error } = await maybeSendToOpenAI({ host, results, conclusion, promptMode, outDir });
956
+ const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error } = await maybeSendToOpenAI({
957
+ host, results, conclusion, promptMode, outDir, runId: opts?.runId ?? null, pluginStatus,
958
+ });
900
959
  // BUG1 (fail-VISIBLE): a one-line end-of-scan AI status so a failed/aborted AI
901
960
  // conclusion is not silently swallowed under the per-stage logs.
902
961
  console.log(aiSummaryLine({ host, ai_status, ai_error }));
903
962
 
904
963
  // --- Scan history: record & compare ---
905
964
  let scanDiff = null;
965
+ // Whether THIS host actually landed in the run record's `hostsWritten` — distinct from
966
+ // whether the scan itself succeeded. `appendHostWritten` can fail independently (a
967
+ // malformed argument, a disk fault) while the rest of the scan completes cleanly, and the
968
+ // top-level KEV/EPSS aggregation must count only hosts the record actually NAMES — a host
969
+ // whose directory the record never mentions cannot be claimed to have been "evaluated"
970
+ // through it, whatever its own enrichment found.
971
+ let hostAppended = false;
906
972
  try {
907
973
  const outRoot = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out').replace(/\.[^/.]+$/, '') || 'out';
974
+
975
+ // Append this host to the run record now that its directory has landed — inside the
976
+ // SAME try/catch as the rest of scan-history recording. utils/run_record.mjs documents
977
+ // that a caller-side throw (e.g. a `dir`/`host` that fails to coerce) rejects straight to
978
+ // its caller, unlike an fs fault, which `appendHostWritten` always wraps — so this call
979
+ // must sit inside an existing try/catch or a malformed argument could fail an otherwise
980
+ // fine scan. `opts.runId` is unset under CTEM watch mode (no single run record there);
981
+ // skipping in that case is a scope decision, not an error.
982
+ //
983
+ // ⚠️ DELIBERATELY NOT `outRoot` ABOVE. The record NAMES this host's directory
984
+ // (`dir: path.basename(outDir)`), so it must resolve the SAME root `outDir` was created
985
+ // under — resolveBaseOutDir(), the resolver `baseOutDir` above (and maybeSendToOpenAI's
986
+ // own fallback) already use. `outRoot` above is the OLDER inline expression
987
+ // (`toCleanPath(...).replace(/\.[^/.]+$/, '')`), kept as-is here because it also locates
988
+ // `scan_history.jsonl` — a different artifact with no directory references, so moving it
989
+ // would relocate an existing file and is out of scope. The two resolvers agree only when
990
+ // `--out` has no dot in it; resolveBaseOutDir()'s own header comment documents the
991
+ // divergence as a fixed silent evidence-misplacement bug (`--out .../ee-0.32.8` read as a
992
+ // file extension under the inline regex and scattered artifacts into the parent directory,
993
+ // EE 0.32.8 pre-publish smoke gate). Do NOT "tidy" this back to match `outRoot` above —
994
+ // that reintroduces the same bug for the run record.
995
+ const runRecordRoot = resolveBaseOutDir();
996
+ if (opts?.runId) {
997
+ hostAppended = await appendHostWritten(runRecordRoot, opts.runId, { host, dir: path.basename(outDir) });
998
+ }
999
+
908
1000
  const services = conclusion?.result?.services ?? [];
909
1001
  const serviceFindingsCount = services.reduce((n, svc) => {
910
1002
  if (svc.anonymousLogin === true) n++;
@@ -959,7 +1051,44 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
959
1051
  console.warn('[ScanHistory] Failed to record/compare scan:', err?.message || err);
960
1052
  }
961
1053
 
962
- return { host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff };
1054
+ return {
1055
+ host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
1056
+ // Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
1057
+ hostAppended, kevDataAsOf, epssDataAsOf,
1058
+ };
1059
+ }
1060
+
1061
+ // The run record's `kevLoaded`/`kevSnapshot` (and the same pair for epss) are a claim about
1062
+ // the WHOLE run, read by Task 4's consumer as one sentence covering every finding on the
1063
+ // page ("Known-exploited status evaluated against the CISA KEV snapshot dated <date>") — so
1064
+ // the aggregate across hosts must be ALL-OR-NOTHING, never "first non-null" and never "last
1065
+ // host wins":
1066
+ // - loaded:true only if EVERY WRITTEN host reported a non-null `dataAsOf` for this store.
1067
+ // - any written host lacking one -> loaded:false (a warning names how many DID have it) —
1068
+ // a run where the store was present for host 1 and absent for host 2 must not print
1069
+ // "evaluated" over host 2's findings; that is the same false clean this whole fix exists
1070
+ // to remove, with the sign flipped.
1071
+ // - all loaded but disagreeing on the date -> loaded:true, snapshot:null, plus a warning —
1072
+ // a snapshot date true for some hosts and wrong for others is worse than no date at all.
1073
+ // Scoped to hosts with `hostAppended === true` — a host the record does not NAME in
1074
+ // `hostsWritten` cannot be the subject of a claim the record makes, whatever its own
1075
+ // enrichment found (see `hostAppended`'s own comment in scanSingleHost).
1076
+ function aggregateStoreLoad(writtenOutputs, field, label) {
1077
+ if (writtenOutputs.length === 0) return { loaded: false, snapshot: null };
1078
+ const values = writtenOutputs.map((o) => o[field] ?? null);
1079
+ const present = values.filter((v) => v != null);
1080
+ if (present.length < values.length) {
1081
+ console.warn(`[RunRecord] ${label} store was loaded for only ${present.length} of `
1082
+ + `${values.length} written host(s) — recording ${label} as NOT loaded for this run.`);
1083
+ return { loaded: false, snapshot: null };
1084
+ }
1085
+ const unique = [...new Set(present)];
1086
+ if (unique.length > 1) {
1087
+ console.warn(`[RunRecord] ${label} store dataAsOf disagreed across written hosts `
1088
+ + `(${unique.join(', ')}) — recording loaded with no snapshot date.`);
1089
+ return { loaded: true, snapshot: null };
1090
+ }
1091
+ return { loaded: true, snapshot: unique[0] };
963
1092
  }
964
1093
 
965
1094
  /* -------------------- CI/CD severity threshold helpers ------------------- */
@@ -1109,7 +1238,200 @@ export async function preflightGrcIfRequested(env, opts = {}) {
1109
1238
  return { ran: true };
1110
1239
  }
1111
1240
 
1112
- export async function main() {
1241
+ // `testHooks` is additive and test-only — every real invocation (the auto-run at the bottom
1242
+ // of this file, and the published `bin`) calls `main()` with no argument, so `testHooks`
1243
+ // defaults to `{}` and changes nothing about production behaviour. `testHooks.importEE`
1244
+ // mirrors `preflightNsauditorPosture`'s `importEE` option elsewhere in this file: it lets a
1245
+ // test drive the real scan pipeline (including this function's own run-record wiring) against
1246
+ // a controlled, injected EE module — e.g. one whose `enrichScan` reports a specific KEV/EPSS
1247
+ // `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.
1249
+ // Reconstructs a single tier word ('ce' | 'pro' | 'enterprise') from the resolved capability
1250
+ // map — never from getTierFromEnv()'s own global cache. `runReport` below deliberately never
1251
+ // reads that cache: `caps` is passed in as an argument specifically so Pro is reachable from the
1252
+ // test suite via `resolveCapabilities('pro')` with no env override (see runReport's header), and
1253
+ // a refusal message built from a DIFFERENT source than the one that gated the request would be
1254
+ // able to name a tier inconsistent with the caps that were actually checked. resolveCapabilities()
1255
+ // is monotonic (ce ⊆ pro ⊆ enterprise), so checking one sentinel capability per tier, from the
1256
+ // top down, exactly recovers the tier it was resolved from.
1257
+ function tierLabelFromCaps(caps) {
1258
+ if (hasCapability(caps, 'cloudScanners')) return 'enterprise';
1259
+ if (hasCapability(caps, 'intelligenceEngine')) return 'pro';
1260
+ return 'ce';
1261
+ }
1262
+
1263
+ /**
1264
+ * `report` subcommand handler.
1265
+ *
1266
+ * Consumes Tasks 4-7: `loadRun` (utils/report_inputs.mjs), `loadBrand` (utils/brand.mjs),
1267
+ * `renderExecutiveReport` (utils/executive_report.mjs), `renderJiraCsv` (utils/jira_export.mjs).
1268
+ *
1269
+ * `caps` is an ARGUMENT, never resolved inside this function — that is what makes the Pro path
1270
+ * testable via `resolveCapabilities('pro')` with no env override; an env override here would be
1271
+ * a licence bypass in product code. The real CLI dispatch (in `main()`, below) is the only
1272
+ * caller that resolves it from the environment, via `resolveCapabilities(getTierFromEnv())`.
1273
+ *
1274
+ * Never calls `console.log`/`console.error` directly: every line is accumulated into `stdout`/
1275
+ * `stderr` strings and returned, so the CLI dispatch can write them to the real streams and a
1276
+ * handler-level test can assert on exactly what would have been printed without spawning a
1277
+ * process.
1278
+ *
1279
+ * Exit codes (constraint 5 — an EXHAUSTIVE switch below, not an if/else, so an eleventh
1280
+ * `loadRun` reason can never fall through unclassified): **2** = the request itself is malformed
1281
+ * or refused (bad/missing flags, a flag mismatch, a tier refusal, `no-run`, `record-unreadable`,
1282
+ * a write failure) — fix the request or the environment. **1** = `loadRun` refused for any other
1283
+ * 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).
1285
+ *
1286
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
1287
+ */
1288
+ export async function runReport(args, caps) {
1289
+ const outLines = [];
1290
+ const errLines = [];
1291
+ const log = (s) => outLines.push(s);
1292
+ const logErr = (s) => errLines.push(s);
1293
+ const finish = (code) => ({
1294
+ code,
1295
+ stdout: outLines.length ? `${outLines.join('\n')}\n` : '',
1296
+ stderr: errLines.length ? `${errLines.join('\n')}\n` : '',
1297
+ });
1298
+
1299
+ // ⚠️ FLAGS VALIDATE BEFORE THE CAPABILITY GATE (constraint 4). Otherwise every flag error on
1300
+ // CE is reported as a tier problem and no flag validation is testable from the CLI at all.
1301
+ //
1302
+ // CC-4: a value-less `--brand`/`--run`/`--out`/`--from` must be FATAL, not silently absorbed
1303
+ // into the same behaviour as the flag never having been typed. `parseArgs` preserves the raw
1304
+ // tri-state for these four (see its own comment) precisely so this check can tell "the operator
1305
+ // never asked" (undefined — fine, defaults apply) apart from "the operator asked and the shell
1306
+ // ate the value" (`true` — a shell glob that matched nothing, a trailing flag with nothing
1307
+ // after it). `--brand` is the sharpest case: an absent brand and a swallowed brand path both
1308
+ // produce the identical unbranded default report at exit 0, so nothing downstream can ever
1309
+ // catch this after the fact — it must be caught here or never. Same precedent already applied
1310
+ // to `--sla-policy`/`--compliance-history` in the scan path, below.
1311
+ for (const [flag, val] of [['--from', args.from], ['--brand', args.brand],
1312
+ ['--run', args.run], ['--out', args.out]]) {
1313
+ if (val === true) {
1314
+ logErr(`\`report\` needs a value for ${flag}. A flag that quietly does nothing is how an `
1315
+ + 'operator concludes a subject was resolved.');
1316
+ return finish(2);
1317
+ }
1318
+ }
1319
+ if (!args.from || typeof args.from !== 'string') {
1320
+ logErr('`report` needs `--from <out-dir>`.');
1321
+ return finish(2);
1322
+ }
1323
+ const format = String(args.format ?? '');
1324
+ if (!['executive', 'jira'].includes(format)) {
1325
+ logErr('`report` needs `--format executive` or `--format jira`.');
1326
+ return finish(2);
1327
+ }
1328
+ if (args.brand && format === 'jira') {
1329
+ logErr('`--brand` applies to `--format executive` only; the jira format is a CSV with no '
1330
+ + 'branded surface. Refusing rather than ignoring the flag.');
1331
+ return finish(2);
1332
+ }
1333
+
1334
+ if (!hasCapability(caps, 'clientReporting')) {
1335
+ logErr('`report` is a Pro capability. Your licence resolves to tier '
1336
+ + `"${tierLabelFromCaps(caps)}". No file has been written.`);
1337
+ return finish(2);
1338
+ }
1339
+
1340
+ const loaded = await loadRun(args.from, { runId: args.run ?? null, allowPartial: !!args.allowPartial },
1341
+ { tier: tierLabelFromCaps(caps) });
1342
+ if (!loaded.ok) {
1343
+ logErr(loaded.message);
1344
+ // MAP BY REASON, not by "loadRun said no" — an EXHAUSTIVE switch over the ten documented
1345
+ // reasons, with a loud default. `no-run` and `record-unreadable` are COMMAND/environment
1346
+ // problems (fix the invocation or the filesystem); every other reason is a RUN problem the
1347
+ // operator fixes by re-scanning.
1348
+ switch (loaded.reason) {
1349
+ case 'no-run':
1350
+ case 'record-unreadable':
1351
+ return finish(2);
1352
+ case 'ambiguous-run':
1353
+ case 'binding-mismatch':
1354
+ case 'incomplete-run':
1355
+ case 'older-format':
1356
+ case 'partial-hosts':
1357
+ case 'record-absent-inside-window':
1358
+ case 'record-absent-outside-window':
1359
+ case 'unknown-schema':
1360
+ return finish(1);
1361
+ default:
1362
+ logErr(`report: internal error — loadRun returned an unrecognised refusal reason `
1363
+ + `"${loaded.reason}". Refusing rather than guessing whether this is a command or a `
1364
+ + 'run problem.');
1365
+ return finish(2);
1366
+ }
1367
+ }
1368
+
1369
+ const { model } = loaded;
1370
+ log(`[report] runId ${model.runId} · started ${model.startedAt}`);
1371
+ // The container census ran inside loadRun. A container holding finding-like objects that
1372
+ // no reader opens is announced to the OPERATOR here and disclosed to the READER in the
1373
+ // report itself — two audiences, two surfaces, because a warning nobody reads is not a
1374
+ // guard, and the artifact is what reaches the customer.
1375
+ for (const u of (model.unreadContainers ?? [])) {
1376
+ for (const [container, n] of Object.entries(u.unread)) {
1377
+ logErr(`[report] WARNING: ${n} finding-like object(s) on host ${u.host} live in `
1378
+ + `\`${container}\`, which this report does not read. They are NOT in the output.`);
1379
+ }
1380
+ }
1381
+ // Both caveats are independent facts about the SAME run and either can hold without the
1382
+ // other — a run can be missing hosts AND never have recorded completion at once. Two separate
1383
+ // `if`s, deliberately, not `if`/`else if`: the latter would silently drop whichever caveat it
1384
+ // checked second, which is exactly the "operator concludes the wrong thing from what they see"
1385
+ // failure this feature exists to prevent.
1386
+ if (model.coverage.partial) {
1387
+ // ⚠️ `model.coverage.missing` names only the NAMED-missing hosts — an unparseable `--host`
1388
+ // input is counted but can never be named (report_inputs.mjs's own computeMissing split), so
1389
+ // `missing.length` alone would read a run with only an unparseable deficit as "0 missing"
1390
+ // while `coverage.partial` is true. `requested - written` is the authoritative count
1391
+ // regardless of whether the deficit can be named.
1392
+ const gap = Math.max(0, model.coverage.requested - model.coverage.written);
1393
+ log(`[report] coverage: partial — ${gap} of ${model.coverage.requested} not scanned`);
1394
+ }
1395
+ if (model.coverage.incomplete) {
1396
+ log('[report] coverage: incomplete — the run did not record completion');
1397
+ }
1398
+
1399
+ let body;
1400
+ let ext;
1401
+ if (format === 'executive') {
1402
+ const brandResult = await loadBrand(args.brand ?? null);
1403
+ if (!brandResult.ok) {
1404
+ logErr(brandResult.message);
1405
+ return finish(2);
1406
+ }
1407
+ body = renderExecutiveReport(model, brandResult.brand, { renderedAt: new Date() });
1408
+ ext = 'html';
1409
+ } else {
1410
+ body = renderJiraCsv(model);
1411
+ ext = 'csv';
1412
+ }
1413
+
1414
+ // Beside the run record, named for the run — deterministic across re-renders (a re-render of
1415
+ // the SAME run overwrites the same file rather than accumulating one file per render, which
1416
+ // would leave no way to tell which copy is current).
1417
+ const outPath = (args.out && typeof args.out === 'string')
1418
+ ? args.out
1419
+ : path.join(args.from, `report_${model.runId}.${ext}`);
1420
+
1421
+ try {
1422
+ await fsp.writeFile(outPath, body, 'utf8');
1423
+ } catch (e) {
1424
+ // A WRITE FAILURE (an unwritable --out, a directory in its place, a missing parent, a full
1425
+ // disk) is an ENVIRONMENT problem, not a run problem: name the path and refuse loudly rather
1426
+ // than falling through as a silent 0.
1427
+ logErr(`report: could not write ${outPath}: ${e?.message || e}`);
1428
+ return finish(2);
1429
+ }
1430
+ log(`[report] wrote ${outPath}`);
1431
+ return finish(0);
1432
+ }
1433
+
1434
+ export async function main(testHooks = {}) {
1113
1435
  const args = await parseArgs(process.argv);
1114
1436
  const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
1115
1437
 
@@ -1130,6 +1452,7 @@ export async function main() {
1130
1452
  Usage:
1131
1453
  nsauditor-ai [scan] --host <ip|cidr|hostname> [options]
1132
1454
  nsauditor-ai [scan] --host-file <path> [options]
1455
+ nsauditor-ai report --from <dir> --format executive|jira [options] (Pro)
1133
1456
  nsauditor-ai license <subcommand>
1134
1457
  nsauditor-ai security <subcommand>
1135
1458
  nsauditor-ai validate
@@ -1321,6 +1644,30 @@ Examples:
1321
1644
  nsauditor-ai license install enterprise_eyJhbGciOiJFUzI1NiIs...
1322
1645
  nsauditor-ai license --status
1323
1646
 
1647
+ Report subcommand (Pro):
1648
+ nsauditor-ai report --from <dir> --format executive|jira [--run <id>]
1649
+ [--brand <brand.json>] [--out <path>] [--allow-partial]
1650
+ Renders a client-facing deliverable from a completed scan run under --from.
1651
+ --from <dir> The scan --out directory that holds the run's record
1652
+ and per-host evidence. Required.
1653
+ --format executive A self-contained, print-ready HTML report with a no-egress
1654
+ invariant (no live network reference other than an
1655
+ http(s)/mailto <a href>).
1656
+ --format jira A Jira-importer CSV (Summary/Description/Priority/Labels/
1657
+ External ID); the import mapping is done in Jira and has
1658
+ not been verified against a live Jira instance.
1659
+ --run <id> Report a specific run id instead of the newest one under
1660
+ --from.
1661
+ --brand <brand.json> Cover-page branding (company name, prepared-by, contact,
1662
+ logo) for --format executive only.
1663
+ --out <path> Write to this path instead of the default
1664
+ report_<runId>.<ext> beside the run record.
1665
+ --allow-partial Render even if some requested hosts were never written, or
1666
+ the run never recorded completion. The report states this
1667
+ on its cover; it never hides it.
1668
+ A value-less --from/--brand/--run/--out (nothing after the flag, or a shell glob
1669
+ that swallowed the argument) is a fatal error, not a silent default.
1670
+
1324
1671
  Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/pricing/`);
1325
1672
  process.exit(0);
1326
1673
  }
@@ -2558,6 +2905,17 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2558
2905
  }
2559
2906
  }
2560
2907
 
2908
+ if (cmd === 'report') {
2909
+ // The CLI resolves capabilities from the verified licence and hands them to the handler;
2910
+ // the handler never resolves them itself (see runReport()'s own header for why — an env
2911
+ // override there would be a licence bypass in product code, and it is what makes the Pro
2912
+ // path testable via resolveCapabilities('pro') with no env override at all).
2913
+ const { code, stdout, stderr } = await runReport(args, resolveCapabilities(getTierFromEnv()));
2914
+ if (stdout) process.stdout.write(stdout);
2915
+ if (stderr) process.stderr.write(stderr);
2916
+ process.exit(code);
2917
+ }
2918
+
2561
2919
  if (cmd !== 'scan') {
2562
2920
  console.error(`Unknown command: ${cmd}`);
2563
2921
  process.exit(2);
@@ -2583,6 +2941,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2583
2941
  }
2584
2942
 
2585
2943
  const opts = { insecureHttps };
2944
+ if (testHooks.importEE) opts.importEE = testHooks.importEE;
2586
2945
  if (ports) opts.ports = ports;
2587
2946
  if (compliance) opts.compliance = compliance;
2588
2947
  if (complianceScope) opts.complianceScope = complianceScope;
@@ -2694,6 +3053,66 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2694
3053
  return; // keep process alive via setInterval
2695
3054
  }
2696
3055
 
3056
+ // ── Run record: START, before any host runs ────────────────────────────────
3057
+ // Scoped to this (non-watch) path: CTEM watch mode returns above and has its own
3058
+ // indefinite-cycle lifecycle with no single "end" to finalize against — opts.runId
3059
+ // stays unset there, which scanSingleHost's per-host append guard reads.
3060
+ // Written at START so an interrupted scan leaves a record with NO finishedAt — the case
3061
+ // `requested − written` arithmetic cannot see when the crash preceded any host directory.
3062
+ // Wrapped: utils/run_record.mjs documents that a caller-side throw building the record
3063
+ // (never an fs fault, which writeRunStart already wraps) rejects to ITS caller — this
3064
+ // must not be able to fail an otherwise-fine scan.
3065
+ //
3066
+ // ⚠️ resolveBaseOutDir(), NOT the inline `toCleanPath(...).replace(/\.[^/.]+$/, '')`
3067
+ // expression scan-history uses elsewhere in this file. The record NAMES every host
3068
+ // directory it references (`hostsWritten[].dir`), and those directories are created under
3069
+ // resolveBaseOutDir() (scanSingleHost's `baseOutDir`) — the two resolvers diverge for a
3070
+ // dotted `--out` (resolveBaseOutDir()'s own header comment documents this exact shape as a
3071
+ // fixed silent evidence-misplacement bug: `--out .../ee-0.32.8` read as a file extension
3072
+ // under the inline regex). Using the inline expression here would write the record to one
3073
+ // directory while every directory it names lives in another — a coverage lie. Do NOT
3074
+ // "tidy" this back to match the neighbouring inline calls (scan-history intentionally keeps
3075
+ // using the inline expression; it locates `scan_history.jsonl`, a different artifact with
3076
+ // no directory references, so moving IT is out of scope).
3077
+ const outRoot = resolveBaseOutDir();
3078
+ const runId = newRunId();
3079
+ try {
3080
+ // AFTER `all` expansion: the word `all` alone says nothing about what actually ran.
3081
+ // Mirrors PluginManager._resolveSelection()'s own logic using only its public surface
3082
+ // (pm.plugins / pm.findPlugin), rather than reaching into a private method.
3083
+ const pluginSpecForRecord = plugins || 'all';
3084
+ const pluginNamesForRecord = (!pluginSpecForRecord || pluginSpecForRecord === 'all')
3085
+ ? null
3086
+ : (Array.isArray(pluginSpecForRecord) ? pluginSpecForRecord
3087
+ : String(pluginSpecForRecord).split(',').map((s) => s.trim()).filter(Boolean));
3088
+ const pluginsRequested = pluginNamesForRecord
3089
+ ? pluginNamesForRecord.map((n) => pm.findPlugin(n)).filter(Boolean).map((p) => String(p.id ?? ''))
3090
+ : pm.plugins.map((p) => String(p.id ?? ''));
3091
+ let eeVersionForRecord = null;
3092
+ try {
3093
+ const eeManifest = _require('@nsasoft/nsauditor-ai-ee/package.json');
3094
+ eeVersionForRecord = eeManifest?.version ?? null;
3095
+ } catch { /* CE-only install — fine, the record just says so */ }
3096
+ await writeRunStart(outRoot, {
3097
+ runId, startedAt: new Date().toISOString(),
3098
+ hostsRequested: hosts, // the RESOLVED host list, never the --host-file path
3099
+ pluginsRequested,
3100
+ portsRequested: ports ?? null,
3101
+ tier: getTierFromEnv(),
3102
+ ceVersion: TOOL_VERSION, eeVersion: eeVersionForRecord,
3103
+ // kevLoaded/kevSnapshot/epssLoaded/epssSnapshot are DELIBERATELY omitted here.
3104
+ // writeRunStart's own defaults (false/null) are the correct STARTING state — before any
3105
+ // host has run, nothing has been evaluated yet — and an interrupted scan that never
3106
+ // reaches finalizeRunRecord() below is honestly left at that pessimistic default. The
3107
+ // REAL values, aggregated across every host this run actually wrote, are set at
3108
+ // finalize time (see below): this code path serves EE too (`ee.enrichScan` above), and
3109
+ // EE, when a KEV/EPSS store is configured, reports the store's own `dataAsOf`.
3110
+ });
3111
+ } catch (err) {
3112
+ console.warn('[RunRecord] Failed to write run start:', err?.message || err);
3113
+ }
3114
+ opts.runId = runId;
3115
+
2697
3116
  // Collect all scan outputs for post-processing
2698
3117
  const scanOutputs = [];
2699
3118
 
@@ -2742,6 +3161,23 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2742
3161
  console.log(JSON.stringify(out, null, 2));
2743
3162
  }
2744
3163
 
3164
+ // ── Run record: FINALIZE, after every host has completed (or failed) ───────
3165
+ // Wrapped for the same reason the START block is: a malformed argument here must
3166
+ // never fail a scan that otherwise completed successfully.
3167
+ try {
3168
+ const writtenOutputs = scanOutputs.filter((o) => o && o.hostAppended === true);
3169
+ const kevAgg = aggregateStoreLoad(writtenOutputs, 'kevDataAsOf', 'KEV');
3170
+ const epssAgg = aggregateStoreLoad(writtenOutputs, 'epssDataAsOf', 'EPSS');
3171
+ await finalizeRunRecord(outRoot, runId, {
3172
+ finishedAt: new Date().toISOString(),
3173
+ kevLoaded: kevAgg.loaded, kevSnapshot: kevAgg.snapshot,
3174
+ epssLoaded: epssAgg.loaded, epssSnapshot: epssAgg.snapshot,
3175
+ });
3176
+ if (getTierFromEnv() === 'ce') await pruneRunRecordsForCE(outRoot);
3177
+ } catch (err) {
3178
+ console.warn('[RunRecord] Failed to finalize/prune run record:', err?.message || err);
3179
+ }
3180
+
2745
3181
  // --- SARIF output ---
2746
3182
  const wantSarif = outputFormat && String(outputFormat).toLowerCase().includes('sarif');
2747
3183
  if (wantSarif) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
4
4
  "description": "Modular AI-assisted network security audit platform \u2014 Community Edition",
5
5
  "type": "module",
6
6
  "private": false,