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