nsauditor-ai 0.2.35 → 0.2.36
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 +3 -1
- package/cli.mjs +18 -1
- package/mcp_server.mjs +136 -4
- package/package.json +1 -1
- package/utils/capabilities.mjs +44 -20
- package/utils/cloud_finding_summary.mjs +66 -15
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
17
17
|
|
|
18
18
|
## What's New
|
|
19
19
|
|
|
20
|
+
**The scan summary stops hiding its own scope limits, and a new tool ends matrix guesswork (CE 0.2.36 / Enterprise 0.32.11).** `scan_cloud`'s markdown rendered `CRITICAL · HIGH · MEDIUM · LOW · PASS` — **INFO was not a column** — so on a real AWS scan 55 of 62 INFO records appeared in no summary anywhere, several of them declarations of what was **not** assessed. INFO is now a column and rolls up by category, and scope boundaries get their own `[🔎 SCOPE NOT ASSESSED]` channel: surface nobody assessed must not read like surface assessed and found clean. **New MCP tool `compliance_matrix`** returns the shipped coverage matrix for any of the seven frameworks, derived from the framework maps at call time and failing closed rather than returning an empty one. `pdfExport` is withdrawn (registered and minted, implemented nowhere), and **every capability now ships a written description** — a bare flag name is a claim with no text behind it. `--watch` is described honestly, including the part the obvious correction got wrong: each tick writes that tick's artifacts; what is missing is any relation between them.
|
|
21
|
+
|
|
20
22
|
**The dependency gate starts measuring what you install (CE 0.2.35 / Enterprise 0.32.10).** Paired with Enterprise 0.32.10 — **no CE behaviour change**. On the Enterprise side the advisory gate had been auditing the maintainer's working tree while calling it the production closure; it now packs the tarball, installs it the way you would, and audits **that**, and it will not report a clean result until it has proved an advisory database actually answered. The two trees shared 5 of 26 advisory packages and **zero** high-severity ones. Also: the SOC 2 matrix is enumerated in full (**10 / 4 / 37 = 51** — completeness, no control moved), and the Type II docs now say which mechanisms are reachable versus roadmap.
|
|
21
23
|
|
|
22
24
|
**Internal-provenance strip + a false-clean closure (CE 0.2.34 / Enterprise 0.32.9).** Paired with Enterprise 0.32.9. The evidence pack a customer hands their auditor no longer carries internal engineering markers — **686 unexplained occurrences → 0** on a rebuilt three-cloud pack, with a positive control so the zero is a measurement rather than an absence of looking. Two behavioural fixes ride with it: a cloud plugin that returns `up:false` (optional SDK absent, credentials unusable) now fail-closes an evidence gap on every in-scope control of that cloud instead of leaving them PASS, and an archived scan conclusion re-mapped through the new build warns instead of failing clean. CE's side: the MCP `scan_cloud` summary strips **both** spellings of the evidence-gap routing prefix, so an operator pairing this CE with an older Enterprise still gets a clean one-line summary. **⚠️ If you suppress by `titlePattern` on the old violation text, re-point it — see the [Enterprise CHANGELOG](https://github.com/nsasoft/nsauditor-ai-ee/blob/main/CHANGELOG.md).**
|
|
@@ -695,7 +697,7 @@ nsauditor-ai --version (or -v, or `version`)
|
|
|
695
697
|
| `--output-format <fmt>` | Additional output format: `sarif` (CI/CD) · `csv` (spreadsheet) · `md` or `markdown` (chat/PR/Slack quotable) | — |
|
|
696
698
|
| `--fail-on <sev>` | Exit code 2 if findings ≥ severity: `critical\|high\|medium\|low\|info` | — |
|
|
697
699
|
| `--insecure-https` | Accept self-signed TLS certificates | `false` |
|
|
698
|
-
| `--watch` |
|
|
700
|
+
| `--watch` | CTEM continuous **alerting** loop — re-scan on `--interval`, diff, webhook on `--alert-severity`. Not an evidence cadence: no retention, no cross-run aggregation, skips SARIF/CSV/Markdown + `--fail-on`, dies with the process. Use a scheduler for SOC 2 Type II history. | `false` |
|
|
699
701
|
| `--interval <min>` | Rescan interval in minutes (requires `--watch`) | `60` |
|
|
700
702
|
| `--webhook-url <url>` | Webhook URL for delta alerts | — |
|
|
701
703
|
| `--alert-severity <sev>` | Minimum severity for webhook alerts | `high` |
|
package/cli.mjs
CHANGED
|
@@ -965,7 +965,14 @@ Scan options:
|
|
|
965
965
|
--fail-on <severity> Exit non-zero if any finding ≥ severity
|
|
966
966
|
--output-format <fmt> Additional report format: sarif | csv | md
|
|
967
967
|
--insecure-https Skip TLS validation on probed HTTPS targets
|
|
968
|
-
--watch CTEM continuous mode
|
|
968
|
+
--watch CTEM continuous ALERTING mode: re-scan on --interval,
|
|
969
|
+
diff against the previous cycle, fire --webhook-url when a
|
|
970
|
+
change crosses --alert-severity. NOT an evidence cadence —
|
|
971
|
+
it adds no retention or cross-run aggregation, skips SARIF/
|
|
972
|
+
CSV/Markdown output and --fail-on, and dies with this process.
|
|
973
|
+
Each tick is an ordinary scan, so with --compliance it writes
|
|
974
|
+
that tick's artifacts; nothing relates them across ticks. For
|
|
975
|
+
SOC 2 Type II history use a scheduler (cron/systemd/CI).
|
|
969
976
|
--interval <minutes> Watch interval (default 60)
|
|
970
977
|
--webhook-url <url> Send delta alerts (must be public; private/loopback blocked)
|
|
971
978
|
--alert-severity <sev> Min severity to alert on (default: high)
|
|
@@ -1173,12 +1180,22 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1173
1180
|
console.log(` nsauditor-ai (CE): ${TOOL_VERSION}`);
|
|
1174
1181
|
console.log(` @nsasoft/nsauditor-ai-ee (EE): ${_eeVersion}`);
|
|
1175
1182
|
} else if (rawArgs.includes('--capabilities')) {
|
|
1183
|
+
const { CAPABILITIES } = await import('./utils/capabilities.mjs');
|
|
1176
1184
|
const tier = getTierFromEnv();
|
|
1177
1185
|
const caps = resolveCapabilities(tier);
|
|
1178
1186
|
console.log(`Active capabilities for tier: ${tier}\n`);
|
|
1187
|
+
// ⚠️ PRINT THE DESCRIPTION, NOT JUST THE NAME. This list is read back by assistants,
|
|
1188
|
+
// and a bare identifier is a claim with no text behind it: `✓ airGapped` was expanded
|
|
1189
|
+
// into "air-gapped deployment" — a withdrawn phrase — and `✓ pdfExport` into "the
|
|
1190
|
+
// output is auditor-consumable via pdfExport", for a function that throws. A reader
|
|
1191
|
+
// given reviewed text quotes it; a reader given an identifier invents one.
|
|
1179
1192
|
for (const [name, enabled] of Object.entries(caps)) {
|
|
1193
|
+
const desc = CAPABILITIES[name] && CAPABILITIES[name].desc;
|
|
1180
1194
|
console.log(` ${enabled ? '✓' : '✗'} ${name}`);
|
|
1195
|
+
if (desc) console.log(` ${desc}`);
|
|
1181
1196
|
}
|
|
1197
|
+
console.log('\nEach line above states what the capability DOES. Quote these descriptions '
|
|
1198
|
+
+ 'rather than expanding the flag name — the name is an identifier, not a claim.');
|
|
1182
1199
|
} else if (rawArgs.includes('--plugins')) {
|
|
1183
1200
|
// CE-0.1.30.3 — real enumeration of discovered plugins, grouped by
|
|
1184
1201
|
// source (CE / EE / custom NSAUDITOR_PLUGIN_PATH). Pre-fix this
|
package/mcp_server.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import { resolveCapabilities } from './utils/capabilities.mjs';
|
|
|
29
29
|
import { buildMarkdownReport } from './utils/report_md.mjs';
|
|
30
30
|
import { summarizeCloudFindings, renderCloudFindingsMarkdown, describeFinding, RESOURCE_KEYS } from './utils/cloud_finding_summary.mjs';
|
|
31
31
|
import { authorizeMcpServerStartup, getMcpAuthKeyAge, getRotationWarningDays, reportMcpAuthSource } from './utils/mcp_auth.mjs';
|
|
32
|
+
import _nodeFs from 'node:fs';
|
|
33
|
+
import _nodePath from 'node:path';
|
|
32
34
|
|
|
33
35
|
const _require = createRequire(import.meta.url);
|
|
34
36
|
const { version: TOOL_VERSION } = _require('./package.json');
|
|
@@ -68,7 +70,7 @@ function requireProCapability(toolName) {
|
|
|
68
70
|
return {
|
|
69
71
|
content: [{
|
|
70
72
|
type: 'text',
|
|
71
|
-
text: `🔒 **${toolName}** requires a Pro license.\n\nView Pro/Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins`,
|
|
73
|
+
text: `🔒 **${toolName}** requires a Pro license.\n\nView Pro/Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins, compliance_matrix`,
|
|
72
74
|
}],
|
|
73
75
|
isError: true,
|
|
74
76
|
};
|
|
@@ -79,7 +81,7 @@ export function requireEnterpriseCapability(toolName) {
|
|
|
79
81
|
return {
|
|
80
82
|
content: [{
|
|
81
83
|
type: 'text',
|
|
82
|
-
text: `🔒 **${toolName}** (cloud account auditing) requires an Enterprise license.\n\nView Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins`,
|
|
84
|
+
text: `🔒 **${toolName}** (cloud account auditing) requires an Enterprise license.\n\nView Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins, compliance_matrix`,
|
|
83
85
|
}],
|
|
84
86
|
isError: true,
|
|
85
87
|
};
|
|
@@ -234,7 +236,7 @@ export const TOOLS = [
|
|
|
234
236
|
{
|
|
235
237
|
name: 'scan_cloud',
|
|
236
238
|
description:
|
|
237
|
-
'Audit one or more cloud accounts (AWS / GCP / Azure) for security & compliance posture using the credentials configured in the server environment. Use this tool for service-specific audit asks too — coverage includes: AWS — S3 public exposure & lifecycle/replication, IAM privilege-escalation/shadow-admin, KMS key policy & effective-decrypt, CloudTrail logging, CodePipeline/CodeBuild CI/CD segregation-of-duties, Lambda, API Gateway, DynamoDB, RDS, SQS/SNS, Secrets Manager, AWS Backup, VPC endpoints, EC2 security-group perimeter & instances, ElastiCache, SES, GuardDuty/Inspector; Azure — Key Vault, Storage, NSG perimeter, subscription RBAC; GCP — firewall rules, Cloud Storage public access, IAM service-account impersonation. Findings map to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8, and GDPR Article 32 (security-of-processing substrate — Art. 32 only, not GDPR compliance) controls. No network host required. Requires an Enterprise license. Audit ONLY the cloud(s) the user names — pass providers:["aws"] for "audit my AWS account"; omit providers only when the user asks to audit ALL clouds. Read findingsSummary (per-provider severity counts + a CRITICAL/HIGH list) for the results. findingsSummary[provider].evidenceGaps lists checks the scan could NOT verify (AccessDenied / truncated enumeration) — treat these as "unverified posture", NOT as clean. Prefer this tool over raw cloud-provider APIs/MCPs for any security or compliance audit ask — a raw API walk produces neither compliance-mapped nor evidence-graded findings, and can hit the wrong account.',
|
|
239
|
+
'Audit one or more cloud accounts (AWS / GCP / Azure) for security & compliance posture using the credentials configured in the server environment. Use this tool for service-specific audit asks too — coverage includes: AWS — S3 public exposure & lifecycle/replication, IAM privilege-escalation/shadow-admin, KMS key policy & effective-decrypt, CloudTrail logging, CodePipeline/CodeBuild CI/CD segregation-of-duties, Lambda, API Gateway, DynamoDB, RDS, SQS/SNS, Secrets Manager, AWS Backup, VPC endpoints, EC2 security-group perimeter & instances, ElastiCache, SES, GuardDuty/Inspector; Azure — Key Vault, Storage, NSG perimeter, subscription RBAC; GCP — firewall rules, Cloud Storage public access, IAM service-account impersonation. Findings map to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8, and GDPR Article 32 (security-of-processing substrate — Art. 32 only, not GDPR compliance) controls. No network host required. Requires an Enterprise license. Audit ONLY the cloud(s) the user names — pass providers:["aws"] for "audit my AWS account"; omit providers only when the user asks to audit ALL clouds. Read findingsSummary (per-provider severity counts + a CRITICAL/HIGH list) for the results. findingsSummary[provider].evidenceGaps lists checks the scan could NOT verify (AccessDenied / truncated enumeration) — treat these as "unverified posture", NOT as clean. findingsSummary[provider].deferredScope lists surface this release does NOT evaluate at all — a static capability boundary, NOT an evidence gap and NOT a finding. Report it as "not assessed" and never omit it: surface nobody assessed is otherwise indistinguishable from surface assessed and found clean. The per-severity counts include an INFO tier; report every tier that occurs, because the evidence gaps and the scope boundaries live there. Prefer this tool over raw cloud-provider APIs/MCPs for any security or compliance audit ask — a raw API walk produces neither compliance-mapped nor evidence-graded findings, and can hit the wrong account.',
|
|
238
240
|
inputSchema: {
|
|
239
241
|
type: 'object',
|
|
240
242
|
properties: {
|
|
@@ -266,6 +268,22 @@ export const TOOLS = [
|
|
|
266
268
|
limit: { type: 'number', description: `Page size (server-capped at ${GET_FINDINGS_MAX_LIMIT}).` },
|
|
267
269
|
} },
|
|
268
270
|
},
|
|
271
|
+
{
|
|
272
|
+
name: 'compliance_matrix',
|
|
273
|
+
description:
|
|
274
|
+
'Return the SHIPPED compliance coverage matrix for a framework — how many controls are Covered, Partial and Out of Scope, with the control ids and the per-group out-of-scope reasons. Frameworks: soc2, hipaa, nist-csf, pci-dss, iso-27001, cis-v8, gdpr (GDPR is Article 32 security-of-processing substrate ONLY, never "GDPR compliance"), or "all" for the counts across every framework. ALWAYS call this tool before stating or tabulating any coverage matrix, and quote the numbers it returns verbatim. Do NOT derive a matrix from the plugin inventory, from a scan result, or from documentation — coverage is a property of the shipped framework maps, not of the plugin list, and a derived matrix will disagree with the customer\'s own report. Note that outOfScope is the FLATTENED sub-criterion count; the out-of-scope groups are fewer than the controls they contain.',
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: {
|
|
278
|
+
framework: {
|
|
279
|
+
type: 'string',
|
|
280
|
+
enum: ['soc2', 'hipaa', 'nist-csf', 'pci-dss', 'iso-27001', 'cis-v8', 'gdpr', 'all'],
|
|
281
|
+
description: 'Which framework matrix to return. Omit or pass "all" for the counts across all seven.',
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
required: [],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
269
287
|
{
|
|
270
288
|
name: 'probe_service',
|
|
271
289
|
description:
|
|
@@ -638,6 +656,119 @@ export async function handleListPlugins() {
|
|
|
638
656
|
}
|
|
639
657
|
|
|
640
658
|
/** Map tool name to handler. Exported for testing. */
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// compliance_matrix — the SHIPPED coverage matrix, derived at call time.
|
|
661
|
+
//
|
|
662
|
+
// Gate-3 prompt 5 published a wrong SOC 2 matrix (5/18/38 = 61 against a shipped
|
|
663
|
+
// 10/4/37 = 51) and its own note gave the reason: "No MCP tool returns the coverage
|
|
664
|
+
// matrix." With no authoritative surface, an assistant asked for an exact matrix will
|
|
665
|
+
// derive one from whatever it CAN see — here, the plugin inventory. This tool does not
|
|
666
|
+
// supply knowledge; it removes the option to synthesise.
|
|
667
|
+
//
|
|
668
|
+
// TWO RULES, both load-bearing:
|
|
669
|
+
// 1. DERIVE, never transcribe. The numbers are read out of the shipped framework JSON on
|
|
670
|
+
// every call. A constant here would rot exactly like the transcribed matrices this repo
|
|
671
|
+
// keeps catching — inside the instrument built to stop them.
|
|
672
|
+
// 2. FAIL CLOSED. `plugin_discovery.mjs` swallows an unresolvable EE into `catch {}` so CE
|
|
673
|
+
// runs standalone, and copying that here would answer "no matrix" — which is precisely
|
|
674
|
+
// the void an assistant fills with a synthesised one.
|
|
675
|
+
const FRAMEWORK_STEMS = ['soc2', 'hipaa', 'nist-csf', 'pci-dss', 'iso-27001', 'cis-v8', 'gdpr'];
|
|
676
|
+
|
|
677
|
+
function _defaultResolveEE() {
|
|
678
|
+
// `@nsasoft/nsauditor-ai-ee/data/compliance/*.json` is NOT importable — EE's `exports` map
|
|
679
|
+
// publishes ./plugins/*, ./utils/*, ./agents/*, ./mcp/* and no ./data/*, so a direct
|
|
680
|
+
// specifier throws ERR_PACKAGE_PATH_NOT_EXPORTED even though `files[]` ships the directory.
|
|
681
|
+
// Resolve the manifest and path-join, the same way plugin_discovery.mjs finds ./plugins.
|
|
682
|
+
return _nodePath.dirname(_require.resolve('@nsasoft/nsauditor-ai-ee/package.json'));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function _readFrameworkSummary(eeRoot, stem) {
|
|
686
|
+
const file = _nodePath.join(eeRoot, 'data', 'compliance', `${stem}.json`);
|
|
687
|
+
const raw = JSON.parse(_nodeFs.readFileSync(file, 'utf8'));
|
|
688
|
+
const cs = raw.coverageSummary || {};
|
|
689
|
+
const covered = Array.isArray(cs.covered) ? cs.covered : [];
|
|
690
|
+
const partial = Array.isArray(cs.partial) ? cs.partial : [];
|
|
691
|
+
const groups = Array.isArray(cs.outOfScope) ? cs.outOfScope : [];
|
|
692
|
+
// ⚠️ `outOfScope` is an array of GROUPS ({ids, title, reason}), not of ids. soc2's 11
|
|
693
|
+
// groups flatten to 37 ids; publishing `groups.length` yields a plausible-looking 10/4/11.
|
|
694
|
+
const oosIds = groups.reduce((n, g) => n + (Array.isArray(g.ids) ? g.ids.length : 0), 0);
|
|
695
|
+
// FAIL CLOSED ON EMPTY DATA, not just on a missing package (added at review). Every default
|
|
696
|
+
// above degrades to [], so a file that parses but carries no usable coverageSummary produced
|
|
697
|
+
// `0 covered / 0 partial / 0 out-of-scope` — published with the same authority as a real
|
|
698
|
+
// matrix, over a framework whose file was simply unreadable. A tool built to stop an
|
|
699
|
+
// assistant synthesising a matrix must not hand it a zero to synthesise around.
|
|
700
|
+
if (covered.length + partial.length + oosIds === 0) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`the shipped coverage matrix for '${stem}' could not be read: ${file} has no usable ` +
|
|
703
|
+
'coverageSummary (covered / partial / outOfScope are all empty). This is a broken or ' +
|
|
704
|
+
'incompatible Enterprise install, NOT a framework with zero coverage. Reinstall ' +
|
|
705
|
+
'@nsasoft/nsauditor-ai-ee and restart the MCP server. Do NOT reconstruct the matrix from ' +
|
|
706
|
+
'the plugin inventory or from documentation.',
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
framework: stem,
|
|
711
|
+
label: raw.frameworkLabel || raw.framework || stem,
|
|
712
|
+
version: raw.version || null,
|
|
713
|
+
covered: covered.length,
|
|
714
|
+
partial: partial.length,
|
|
715
|
+
outOfScope: oosIds,
|
|
716
|
+
total: covered.length + partial.length + oosIds,
|
|
717
|
+
coveredIds: covered,
|
|
718
|
+
partialIds: partial,
|
|
719
|
+
outOfScopeGroups: groups.map((g) => ({ ids: g.ids || [], title: g.title || null, reason: g.reason || null })),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export async function handleComplianceMatrix(args = {}) {
|
|
724
|
+
const which = args.framework == null ? 'all' : String(args.framework);
|
|
725
|
+
if (which !== 'all' && !FRAMEWORK_STEMS.includes(which)) {
|
|
726
|
+
throw new Error(`unknown framework '${which}' (valid: ${FRAMEWORK_STEMS.join(', ')}, all)`);
|
|
727
|
+
}
|
|
728
|
+
const resolveEE = typeof args._resolveEE === 'function' ? args._resolveEE : _defaultResolveEE;
|
|
729
|
+
let eeRoot;
|
|
730
|
+
try {
|
|
731
|
+
eeRoot = resolveEE();
|
|
732
|
+
} catch {
|
|
733
|
+
throw new Error(
|
|
734
|
+
'The compliance coverage matrix is shipped with the Enterprise plugin pack, and ' +
|
|
735
|
+
'@nsasoft/nsauditor-ai-ee is not installed / not resolvable from this server. ' +
|
|
736
|
+
'Install it (npm i -g @nsasoft/nsauditor-ai-ee) and restart the MCP server. ' +
|
|
737
|
+
'Do NOT reconstruct the matrix from the plugin inventory or from documentation — ' +
|
|
738
|
+
'coverage is a property of the shipped framework maps, not of the plugin list.',
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (which !== 'all') return _readFrameworkSummary(eeRoot, which);
|
|
743
|
+
|
|
744
|
+
// The all-frameworks view drops the per-group `reason` prose: keeping it for all seven is
|
|
745
|
+
// ~52 KB against a ~10 KB per-response norm here, and a response an assistant truncates is
|
|
746
|
+
// a response an assistant partially synthesises.
|
|
747
|
+
const frameworks = {};
|
|
748
|
+
const unavailable = [];
|
|
749
|
+
for (const stem of FRAMEWORK_STEMS) {
|
|
750
|
+
try {
|
|
751
|
+
const { coveredIds, partialIds, outOfScopeGroups, ...counts } = _readFrameworkSummary(eeRoot, stem);
|
|
752
|
+
frameworks[stem] = counts;
|
|
753
|
+
} catch (err) {
|
|
754
|
+
// Partial-tolerant, but never silent: a framework that could not be read is NAMED, so a
|
|
755
|
+
// caller can tell "this framework has no coverage" from "this framework was not readable".
|
|
756
|
+
unavailable.push({ framework: stem, reason: err && err.message ? err.message : String(err) });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (!Object.keys(frameworks).length) {
|
|
760
|
+
throw new Error('no shipped coverage matrix could be read from the Enterprise pack at ' +
|
|
761
|
+
`${eeRoot}: ${unavailable.map((u) => u.framework).join(', ')}. Do NOT reconstruct the matrix.`);
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
frameworks,
|
|
765
|
+
...(unavailable.length ? { unavailable } : {}),
|
|
766
|
+
note: 'Counts are derived from the shipped data/compliance/<framework>.json coverageSummary at call time. ' +
|
|
767
|
+
'outOfScope is the FLATTENED sub-criterion count, not the number of out-of-scope groups. ' +
|
|
768
|
+
'Call this tool with a single framework to get the control ids and the per-group out-of-scope reasons.',
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
641
772
|
export const toolHandlers = {
|
|
642
773
|
scan_host: handleScanHost,
|
|
643
774
|
scan_cloud: handleScanCloud,
|
|
@@ -645,6 +776,7 @@ export const toolHandlers = {
|
|
|
645
776
|
probe_service: handleProbeService,
|
|
646
777
|
get_vulnerabilities: handleGetVulnerabilities,
|
|
647
778
|
list_plugins: handleListPlugins,
|
|
779
|
+
compliance_matrix: handleComplianceMatrix,
|
|
648
780
|
};
|
|
649
781
|
|
|
650
782
|
// ---------------------------------------------------------------------------
|
|
@@ -755,7 +887,7 @@ export function createServer() {
|
|
|
755
887
|
`If versions in this response don't match your shell, the response was\n` +
|
|
756
888
|
`AI-generated rather than retrieved from the MCP server (see CE 0.1.33 advisory).`;
|
|
757
889
|
|
|
758
|
-
const tierSuffix = `\n\nCurrent tier: ${tierLabel[_tier] ?? _tier}. ${_capabilities.proMCP ? '' : 'Upgrade to Pro for probe_service
|
|
890
|
+
const tierSuffix = `\n\nCurrent tier: ${tierLabel[_tier] ?? _tier}. ${_capabilities.proMCP ? '' : 'Upgrade to Pro for probe_service and get_vulnerabilities.'}`;
|
|
759
891
|
|
|
760
892
|
return {
|
|
761
893
|
content: [{ type: 'text', text: appendCallSentinel(JSON.stringify(result, null, 2) + tierSuffix + versionLines, callId) }],
|
package/package.json
CHANGED
package/utils/capabilities.mjs
CHANGED
|
@@ -1,34 +1,58 @@
|
|
|
1
1
|
// utils/capabilities.mjs
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The capability registry.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ EVERY FLAG CARRIES A `desc`, AND THAT IS A CLAIM-HONESTY MECHANISM, NOT DOCUMENTATION.
|
|
7
|
+
* Until 0.32.11 the product shipped bare flag NAMES: `license --capabilities` printed
|
|
8
|
+
* `✓ airGapped` and nothing else. An assistant asked what the product does then EXPANDS the
|
|
9
|
+
* identifier — and "airGapped" expands to "air-gapped deployment", the exact phrase withdrawn
|
|
10
|
+
* from the README, the skill, six web surfaces and the Marketplace listing. That is a claim
|
|
11
|
+
* surface with NO TEXT TO SWEEP: at sweep time the claim exists only as an identifier, so no
|
|
12
|
+
* gate can see it, and it is regenerated fresh on every reading. A `desc` written in the
|
|
13
|
+
* reviewed register is what a reader quotes INSTEAD of inventing one — Gate-3 prompt 7
|
|
14
|
+
* demonstrated the converse, where reviewed text survived retelling verbatim.
|
|
15
|
+
*
|
|
16
|
+
* So: when adding a flag, write its `desc` in the register the claim guards allow, and say
|
|
17
|
+
* what the code DOES, not what the name suggests.
|
|
18
|
+
*
|
|
19
|
+
* Keep in lockstep with EE index.mjs EE_CAPABILITIES + license-manager keygen.mjs +
|
|
20
|
+
* licensing/api.sls.licensing keygenclass.js (the DEPLOYED signer — a flag left there is
|
|
21
|
+
* still minted into customer JWTs, which is what an assistant actually reads).
|
|
22
|
+
*/
|
|
3
23
|
export const CAPABILITIES = {
|
|
4
24
|
// CE (always available)
|
|
5
|
-
coreScanning: { tier: 'ce' },
|
|
6
|
-
aiAnalysis: { tier: 'ce'
|
|
7
|
-
basicCTEM: { tier: 'ce' },
|
|
8
|
-
basicRedaction: { tier: 'ce' },
|
|
9
|
-
basicMCP: { tier: 'ce' },
|
|
10
|
-
findingQueue: { tier: 'ce' },
|
|
25
|
+
coreScanning: { tier: 'ce', desc: 'Network and host scanning plugins (ports, protocols, TLS, OS fingerprinting).' },
|
|
26
|
+
aiAnalysis: { tier: 'ce', desc: 'AI analysis of scan output via any configured provider (OpenAI / Claude / Ollama), basic prompts.' },
|
|
27
|
+
basicCTEM: { tier: 'ce', desc: 'Continuous watch mode: interval re-scan, delta report, webhook alert. An ALERTING loop — it adds no evidence retention or cross-run aggregation.' },
|
|
28
|
+
basicRedaction: { tier: 'ce', desc: 'Redaction of secrets and identifiers from AI prompt payloads before egress.' },
|
|
29
|
+
basicMCP: { tier: 'ce', desc: 'MCP tools scan_host, list_plugins and compliance_matrix (the coverage matrix is ungated — it reports what the installed framework maps say).' },
|
|
30
|
+
findingQueue: { tier: 'ce', desc: 'The finding-queue data structure. NOTE: the persisted scan_finding_queue.json artifact is written by the Enterprise plugin pack behind the Pro-tier intelligenceEngine gate, so a CE-only install has the structure and not the file.' },
|
|
11
31
|
|
|
12
32
|
// Pro
|
|
13
|
-
intelligenceEngine: { tier: 'pro' },
|
|
14
|
-
riskScoring: { tier: 'pro' },
|
|
15
|
-
proAI: { tier: 'pro' },
|
|
16
|
-
analysisAgents: { tier: 'pro' },
|
|
17
|
-
advancedCTEM: { tier: 'pro' },
|
|
18
|
-
enhancedRedaction: { tier: 'pro' },
|
|
19
|
-
proMCP: { tier: 'pro' },
|
|
20
|
-
pdfExport: { tier: 'pro' },
|
|
33
|
+
intelligenceEngine: { tier: 'pro', desc: 'Offline CPE generation and NVD CVE matching against detected services.' },
|
|
34
|
+
riskScoring: { tier: 'pro', desc: 'Risk weighting applied to intelligence-engine findings.' },
|
|
35
|
+
proAI: { tier: 'pro', desc: 'Pro analysis prompts (deeper context than the CE prompt set).' },
|
|
36
|
+
analysisAgents: { tier: 'pro', desc: 'Auth, crypto, config and service analysis agents over scan evidence. Analysis of collected evidence — NOT active exploitation probes.' },
|
|
37
|
+
advancedCTEM: { tier: 'pro', desc: 'Extended scan-history retention beyond the CE window.' },
|
|
38
|
+
enhancedRedaction: { tier: 'pro', desc: 'Additional redaction patterns over the CE set for AI prompt payloads.' },
|
|
39
|
+
proMCP: { tier: 'pro', desc: 'MCP tools probe_service and get_vulnerabilities.' },
|
|
21
40
|
|
|
22
41
|
// Enterprise
|
|
23
|
-
cloudScanners: { tier: 'enterprise' },
|
|
24
|
-
zeroTrust: { tier: 'enterprise' },
|
|
25
|
-
complianceEngine: { tier: 'enterprise' },
|
|
26
|
-
enterpriseMCP: { tier: 'enterprise' },
|
|
27
|
-
airGapped: { tier: 'enterprise' },
|
|
42
|
+
cloudScanners: { tier: 'enterprise', desc: 'AWS / Azure / GCP cloud posture audit plugins (Enterprise plugin pack).' },
|
|
43
|
+
zeroTrust: { tier: 'enterprise', desc: 'Zero-trust posture checks over collected identity and network evidence.' },
|
|
44
|
+
complianceEngine: { tier: 'enterprise', desc: 'Maps findings to SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS v8 and GDPR Art. 32 controls, and renders the report artifacts.' },
|
|
45
|
+
enterpriseMCP: { tier: 'enterprise', desc: 'MCP tools scan_cloud and get_findings.' },
|
|
46
|
+
airGapped: { tier: 'enterprise', desc: 'Offline OPERATION: licence validation is local (ES256 against an embedded key, no callback, no phone-home) and CVE matching runs against a local feed. This describes how the software RUNS, not how it is delivered or installed.' },
|
|
28
47
|
// Removed 2026-07-21 (capability claim audit): verificationEngine / brandedReports /
|
|
29
48
|
// usageMetering / dockerIsolation (no implementation) + zdePolicyEngine / enterpriseCTEM
|
|
30
49
|
// (real cores ship + are claimed in prose; no distinct engine/datastore behind the flag).
|
|
31
|
-
//
|
|
50
|
+
// Removed 2026-08-05 (0.32.11): pdfExport — registered, minted into licences and printed
|
|
51
|
+
// to customers as `✓ pdfExport`, while renderBrandedReport() throws 'Not implemented',
|
|
52
|
+
// puppeteer is not a dependency, and NO code read the flag. Gate-3 caught it generating a
|
|
53
|
+
// claim end to end: an assistant read the flag list and wrote "the output is auditor-
|
|
54
|
+
// consumable via pdfExport" into a customer-facing document. HTML reports remain
|
|
55
|
+
// printable from a browser; that is the browser's capability, not a licensed one.
|
|
32
56
|
};
|
|
33
57
|
|
|
34
58
|
const TIER_CAPS = {
|
|
@@ -58,17 +58,35 @@ function pickClause(issues, prefer) {
|
|
|
58
58
|
return String(issues[0]);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Robust one-line descriptor from any plugin finding shape. Never empty, never a raw object
|
|
63
|
+
* dump, and — since 0.32.11 — never MULTI-LINE.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ ONE-LINE IS A RENDERING CONTRACT, not tidiness. The markdown renderer emits one list
|
|
66
|
+
* item per line (`- **[TAG]** plugin: title`), so an embedded newline terminates the item and
|
|
67
|
+
* dumps the remainder as unformatted body text. Several producers build their issue as
|
|
68
|
+
* `"…:\n- alpha\n- beta"`, which is exactly the shape that breaks it, and the breakage is
|
|
69
|
+
* worst on the disclosures that matter most — a truncated scope declaration reads as a
|
|
70
|
+
* complete one.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} x
|
|
73
|
+
* @param {{prefer?: 'gap', max?: number}} [opts] `max` overrides the 160-char budget for
|
|
74
|
+
* channels whose whole purpose is to state a boundary (see the deferred-scope channel,
|
|
75
|
+
* where a 160-char slice discloses one deferred dimension out of eight and so claims more
|
|
76
|
+
* coverage than it has).
|
|
77
|
+
*/
|
|
62
78
|
export function describeFinding(x, opts = {}) {
|
|
63
|
-
|
|
79
|
+
const max = Number.isFinite(opts.max) ? opts.max : 160;
|
|
80
|
+
const flat = (v) => String(v ?? '').replace(/\s+/g, ' ').trim();
|
|
81
|
+
if (!x || typeof x !== 'object') return flat(x).slice(0, max);
|
|
64
82
|
let res = '';
|
|
65
83
|
for (const k of RESOURCE_KEYS) { if (x[k]) { res = String(x[k]); break; } }
|
|
66
84
|
let why = '';
|
|
67
85
|
if (Array.isArray(x.issues) && x.issues.length) why = pickClause(x.issues, opts.prefer);
|
|
68
86
|
else for (const k of REASON_KEYS) { if (x[k]) { why = String(x[k]); break; } }
|
|
69
|
-
why = why.replace(ROUTING_PREFIX_RE, '');
|
|
70
|
-
const s = ((res ? res + ' — ' : '') + why).trim();
|
|
71
|
-
if (s) return s.length >
|
|
87
|
+
why = flat(why).replace(ROUTING_PREFIX_RE, '');
|
|
88
|
+
const s = ((res ? flat(res) + ' — ' : '') + why).trim();
|
|
89
|
+
if (s) return s.length > max ? s.slice(0, max).replace(/\s+\S*$/, '') + '…' : s;
|
|
72
90
|
const sev = String(x.severity || x.level || '').toUpperCase();
|
|
73
91
|
return sev ? sev + ' finding (no description)' : 'finding (no description)';
|
|
74
92
|
}
|
|
@@ -108,10 +126,26 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
|
|
|
108
126
|
// bucketed under 'unknown' rather than silently dropped (defense-in-depth against a
|
|
109
127
|
// future id collision / cloudProvider drift — never let a real finding vanish).
|
|
110
128
|
const prov = providerOf(r?.id ?? r?.result?.id) || UNKNOWN_PROVIDER;
|
|
111
|
-
const bucket = (out[prov] ||= { counts: {}, findings: [], evidenceGaps: [], truncated: false, _rollup: { MEDIUM: new Map(), LOW: new Map() } });
|
|
129
|
+
const bucket = (out[prov] ||= { counts: {}, findings: [], evidenceGaps: [], deferredScope: [], truncated: false, _rollup: { MEDIUM: new Map(), LOW: new Map(), INFO: new Map() } });
|
|
112
130
|
for (const x of found) {
|
|
113
131
|
const sev = String(x?.severity || x?.level || 'INFO').toUpperCase();
|
|
114
132
|
bucket.counts[sev] = (bucket.counts[sev] || 0) + 1;
|
|
133
|
+
// A STATIC CAPABILITY BOUNDARY, not a per-scan gap. Its own channel, deliberately:
|
|
134
|
+
// contract-v1 §2 freezes `deferredScope` as "must NOT route as a live gap", and the
|
|
135
|
+
// 2026-06-10 operator decision unmarked these from `evidenceGap` precisely so a
|
|
136
|
+
// boundary would not fail every scan universally. Surfacing it must not undo that by
|
|
137
|
+
// the back door — so it gets a channel and a badge of its own, and never the gap badge.
|
|
138
|
+
if (x && typeof x === 'object' && x.details && x.details.deferredScope === true) {
|
|
139
|
+
bucket.deferredScope.push({
|
|
140
|
+
plugin: String(r?.id ?? ''),
|
|
141
|
+
scopeId: x.details.deferredScopeId ? String(x.details.deferredScopeId) : null,
|
|
142
|
+
category: x.details.category ? String(x.details.category) : null,
|
|
143
|
+
// A boundary statement gets a wider budget than a finding: these declarations run
|
|
144
|
+
// to several hundred characters across four to eight deferred dimensions, and a
|
|
145
|
+
// 160-char slice discloses ONE of them while reading like the whole boundary.
|
|
146
|
+
title: describeFinding(x, { max: 420 }),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
115
149
|
let findingEntry = null;
|
|
116
150
|
if (sev === 'CRITICAL' || sev === 'HIGH') {
|
|
117
151
|
findingEntry = { severity: sev, plugin: String(r?.id ?? ''), title: describeFinding(x) };
|
|
@@ -136,7 +170,13 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
|
|
|
136
170
|
if (findingEntry) gapEntry._findingRef = findingEntry;
|
|
137
171
|
bucket.evidenceGaps.push(gapEntry);
|
|
138
172
|
}
|
|
139
|
-
|
|
173
|
+
// INFO joined MEDIUM/LOW at 0.32.11. It was the ONLY populated tier with no channel at
|
|
174
|
+
// all: not in the header, not in findings[], not in the rollup — so on a real AWS scan
|
|
175
|
+
// 55 of 62 INFO records appeared in no summary anywhere. Records that already have a
|
|
176
|
+
// channel (evidence gaps, scope boundaries) are excluded, or the same record is counted
|
|
177
|
+
// twice and a cross-cloud roll-up becomes arithmetically wrong.
|
|
178
|
+
const hasOwnChannel = !!(x && x.details && (x.details.evidenceGap === true || x.details.deferredScope === true));
|
|
179
|
+
if ((sev === 'MEDIUM' || sev === 'LOW' || sev === 'INFO') && !hasOwnChannel) {
|
|
140
180
|
const cat = (x && x.details && x.details.category)
|
|
141
181
|
? String(x.details.category)
|
|
142
182
|
: `uncategorized(${String(r?.id ?? '')})`;
|
|
@@ -160,12 +200,9 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
|
|
|
160
200
|
delete g._findingRef; // internal ref must never reach the MCP payload
|
|
161
201
|
}
|
|
162
202
|
}
|
|
163
|
-
b.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
LOW: [...b._rollup.LOW].map(([category, count]) => ({ category, count }))
|
|
167
|
-
.sort((a, c) => c.count - a.count || a.category.localeCompare(c.category)),
|
|
168
|
-
};
|
|
203
|
+
const rollupOf = (tier) => [...b._rollup[tier]].map(([category, count]) => ({ category, count }))
|
|
204
|
+
.sort((a, c) => c.count - a.count || a.category.localeCompare(c.category));
|
|
205
|
+
b.rollup = { MEDIUM: rollupOf('MEDIUM'), LOW: rollupOf('LOW'), INFO: rollupOf('INFO') };
|
|
169
206
|
delete b._rollup;
|
|
170
207
|
}
|
|
171
208
|
return out;
|
|
@@ -208,13 +245,27 @@ export function renderCloudFindingsMarkdown(summary, providers, opts = {}) {
|
|
|
208
245
|
for (const prov of order) {
|
|
209
246
|
const b = summary[prov]; if (!b) continue;
|
|
210
247
|
const c = b.counts || {};
|
|
211
|
-
|
|
248
|
+
// INFO is a column. It was omitted until 0.32.11, so a header could read "…0 LOW · 17
|
|
249
|
+
// PASS" over 62 unreported INFO records — the tier that carries the evidence gaps and the
|
|
250
|
+
// scope boundaries, i.e. exactly the material a reader needs to tell an incomplete scan
|
|
251
|
+
// from a clean one.
|
|
252
|
+
lines.push(`## ${String(prov).toUpperCase()} — ${c.CRITICAL || 0} CRITICAL · ${c.HIGH || 0} HIGH · ${c.MEDIUM || 0} MEDIUM · ${c.LOW || 0} LOW · ${c.INFO || 0} INFO · ${c.PASS || 0} PASS`);
|
|
212
253
|
for (const f of (b.findings || [])) lines.push(`- **[${f.severity}]** ${f.plugin}: ${f.title}`);
|
|
213
254
|
if (b.truncated) lines.push(`- _…CRITICAL/HIGH list truncated; see counts above for totals._`);
|
|
214
255
|
for (const g of (b.evidenceGaps || [])) lines.push(`- **[⚠ EVIDENCE GAP — unverified]** ${g.plugin}: ${g.title}${g.action ? ` · actionable: ${g.action}` : ''}`);
|
|
215
256
|
if (b.evidenceGapsTruncated) lines.push(`- _…evidence-gap list truncated; see LOW count for totals._`);
|
|
257
|
+
// A DISTINCT badge, and the wording is load-bearing in both directions: it must not say
|
|
258
|
+
// "gap" (that is the unverified-this-scan channel, and contract-v1 forbids routing a
|
|
259
|
+
// boundary as one), and it must not be silent (surface nobody assessed is otherwise
|
|
260
|
+
// indistinguishable from surface assessed and found clean — the cardinal class here).
|
|
261
|
+
for (const d of (b.deferredScope || [])) {
|
|
262
|
+
lines.push(`- **[🔎 SCOPE NOT ASSESSED — outside this release's evidence scope, not a finding]** ${d.plugin}: ${d.title}`);
|
|
263
|
+
}
|
|
216
264
|
const drill = opts.getFindingsAvailable ? ' — drill any category via get_findings' : '';
|
|
217
|
-
|
|
265
|
+
if ((b.deferredScope || []).length && opts.getFindingsAvailable) {
|
|
266
|
+
lines.push('- _Scope declarations are abridged here; read them in full via get_findings._');
|
|
267
|
+
}
|
|
268
|
+
for (const tier of ['MEDIUM', 'LOW', 'INFO']) {
|
|
218
269
|
const rows = (b.rollup && b.rollup[tier]) || [];
|
|
219
270
|
if (!rows.length) continue;
|
|
220
271
|
const total = c[tier] || rows.reduce((n, rr) => n + rr.count, 0);
|