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/README.md +56 -3
- package/cli.mjs +443 -7
- package/package.json +1 -1
- package/utils/brand.mjs +252 -0
- package/utils/capabilities.mjs +1 -0
- package/utils/executive_report.mjs +574 -0
- package/utils/jira_export.mjs +174 -0
- package/utils/report_finding_census.mjs +123 -0
- package/utils/report_inputs.mjs +537 -0
- package/utils/run_record.mjs +241 -0
- package/utils/scan_history.mjs +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 {
|
|
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
|
-
|
|
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) {
|