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 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` | Enable CTEM continuous scanning | `false` |
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, get_vulnerabilities, risk_summary, and more.'}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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' }, // Any provider (OpenAI/Claude/Ollama), basic prompts
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
- // Keep in lockstep with EE index.mjs EE_CAPABILITIES + license-manager keygen.mjs.
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
- /** Robust one-line descriptor from any plugin finding shape. Never empty, never a raw object dump. */
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
- if (!x || typeof x !== 'object') return String(x ?? '').slice(0, 160);
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 > 160 ? s.slice(0, 160).replace(/\s+\S*$/, '') + '…' : s;
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
- if ((sev === 'MEDIUM' || sev === 'LOW') && !(x && x.details && x.details.evidenceGap === true)) {
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.rollup = {
164
- MEDIUM: [...b._rollup.MEDIUM].map(([category, count]) => ({ category, count }))
165
- .sort((a, c) => c.count - a.count || a.category.localeCompare(c.category)),
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
- lines.push(`## ${String(prov).toUpperCase()} — ${c.CRITICAL || 0} CRITICAL · ${c.HIGH || 0} HIGH · ${c.MEDIUM || 0} MEDIUM · ${c.LOW || 0} LOW · ${c.PASS || 0} PASS`);
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
- for (const tier of ['MEDIUM', 'LOW']) {
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);