nsauditor-ai 0.2.8 → 0.2.10

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,9 +17,9 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
17
17
 
18
18
  ## What's New
19
19
 
20
- **Latest: CE 0.2.8 + Enterprise 0.19.3** (June 2026) — a **real CE bump** (MCP affordance): the `scan_cloud` tool description now enumerates the actual service coverage (AWS S3 / IAM / KMS / CloudTrail / CodePipeline-CodeBuild SoD / Lambda / API GW / DynamoDB / RDS / SQS-SNS / Secrets / Backup / VPC endpoints / SG perimeter / ElastiCache / SES / GuardDuty; Azure Key Vault / Storage / NSG / RBAC; GCP firewall / storage / impersonation) + all 6 compliance frameworks, so AI agents route service-named audit asks to the scanner; the evidence-gap list lines are badge-coherent (lead with the GAP clause) and carry the first ACTIONABLE clause as a companion that now survives findings-cap eviction; internal compliance-routing tags are stripped at the presentation layer so gap lines spend their budget on substance. Paired **EE 0.19.3** "MCP affordance + class-O truncation sweep": inline-credential Lambda env-var names + the `AWS_LAMBDA_` exclusion-prefix evasion bypass closed (1080); truncation/AccessDenied evidence-gaps across 8 AWS auditors now fail-close their native controls in all six frameworks instead of routing to zero (class-O sweep incl. the 1110 P-16 grant-bypass); a new Azure NSG dimension flags restricted-port exposure to the tenant-rentable `AzureCloud` service tags (1221); public-subnet Redis replication groups no longer silently downgrade (1180). Plugin count UNCHANGED at 28; all six coverage matrices UNCHANGED. **EE 0.19.3 requires CE 0.2.8+.**
20
+ **Latest: CE 0.2.10 + Enterprise 0.19.4** (June 2026) — a **real CE feature** (MCP affordance II): the `scan_cloud` summary now surfaces actionable findings that used to be invisible. MEDIUM/LOW findings **roll up per provider by category** (`MEDIUM (8) sqs-age-alarm-missing ×2 · sns-failure-alarm-missing ×2 · …`, count-descending, no-silent-cap) so a Desktop agent can no longer narrate "the alarm dimension came back clean" over live monitoring findings. And a NEW Enterprise-gated **`get_findings`** tool drills the most recent scan's per-provider cache (filter by provider/plugin/severity/category, paginate, FULL untruncated text) keyed by a `scanId` the summary footer carries, gated *before* any cache read (a CE/Pro caller gets the same 🔒 denial as `scan_cloud`). Plus collector fixes (all-clauses gap companion · boundary-safe truncation · walkthrough-vs-couldn't-read `gapKind`) and a `scan_cloud` description that steers agents away from raw cloud-provider MCPs. Paired **EE 0.19.4** (unchanged peer `>=0.2.8` already satisfied) + agent-skill 0.2.10 (documents `get_findings`). **EE 0.19.4 requires CE 0.2.8+.**
21
21
 
22
- **Prior: CE 0.2.7 + Enterprise 0.19.2** — paired pin for EE 0.19.2 "Confirmed false-negative tail" (6 more silent misses closed: 1222/1021/1070/1100/1024/1040). See [CHANGELOG.md](./CHANGELOG.md).
22
+ **Prior: CE 0.2.9 + Enterprise 0.19.4** — paired README-refresh pin for EE 0.19.4 "Routing-Integrity Hardening" (PCI matrix 20/8/39 19/9/39; no CE code change). See [CHANGELOG.md](./CHANGELOG.md).
23
23
 
24
24
  → Full release history: **[CHANGELOG.md](./CHANGELOG.md)**
25
25
  → See a sample EE scan output: **[walk-through with synthetic Acme Corp AWS account](https://www.nsauditor.com/ai/docs/sample-scan/)** (no signup required)
package/mcp_server.mjs CHANGED
@@ -26,7 +26,7 @@ import { buildRegionIntent } from './utils/region_intent.mjs';
26
26
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
27
27
  import { resolveCapabilities } from './utils/capabilities.mjs';
28
28
  import { buildMarkdownReport } from './utils/report_md.mjs';
29
- import { summarizeCloudFindings, renderCloudFindingsMarkdown } from './utils/cloud_finding_summary.mjs';
29
+ import { summarizeCloudFindings, renderCloudFindingsMarkdown, describeFinding } from './utils/cloud_finding_summary.mjs';
30
30
  import { authorizeMcpServerStartup, getMcpAuthKeyAge, getRotationWarningDays, reportMcpAuthSource } from './utils/mcp_auth.mjs';
31
31
 
32
32
  const _require = createRequire(import.meta.url);
@@ -73,7 +73,7 @@ function requireProCapability(toolName) {
73
73
  };
74
74
  }
75
75
 
76
- function requireEnterpriseCapability(toolName) {
76
+ export function requireEnterpriseCapability(toolName) {
77
77
  if (_capabilities.enterpriseMCP) return null; // Enterprise: allow
78
78
  return {
79
79
  content: [{
@@ -84,6 +84,18 @@ function requireEnterpriseCapability(toolName) {
84
84
  };
85
85
  }
86
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Per-provider cloud-scan cache (Stage 2b). One slot per provider, last-writer-wins.
89
+ // Populated ONLY by handleScanCloud, which runs behind the Enterprise dispatch gate.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const _cloudScanCache = new Map(); // provider -> { scanId, ts, args, results }
93
+ let _scanIdCounter = 0;
94
+ export function _nextScanId() { return `scan-${++_scanIdCounter}`; }
95
+ export function _putCloudScan(provider, slot) { _cloudScanCache.set(provider, slot); }
96
+ export function _getCloudScan(provider) { return _cloudScanCache.get(provider) || null; }
97
+ export function _resetCloudScanCache() { _cloudScanCache.clear(); _scanIdCounter = 0; }
98
+
87
99
  // ---------------------------------------------------------------------------
88
100
  // Lazy singletons — initialised on first use, overridable for tests
89
101
  // ---------------------------------------------------------------------------
@@ -193,6 +205,9 @@ function appendCallSentinel(text, callId) {
193
205
  // Tool definitions (JSON Schema for input validation)
194
206
  // ---------------------------------------------------------------------------
195
207
 
208
+ // Max findings per page — also referenced in the TOOLS inputSchema description below.
209
+ const GET_FINDINGS_MAX_LIMIT = 20; // ~500 chars/finding -> ~10KB page (Desktop 60s budget)
210
+
196
211
  // Exported for direct testing (the scan_cloud description is a routing surface —
197
212
  // tests pin its service-coverage enumeration + contract clauses).
198
213
  export const TOOLS = [
@@ -218,7 +233,7 @@ export const TOOLS = [
218
233
  {
219
234
  name: 'scan_cloud',
220
235
  description:
221
- '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, and CIS v8 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.',
236
+ '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, and CIS v8 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.',
222
237
  inputSchema: {
223
238
  type: 'object',
224
239
  properties: {
@@ -236,6 +251,20 @@ export const TOOLS = [
236
251
  required: [],
237
252
  },
238
253
  },
254
+ {
255
+ name: 'get_findings',
256
+ description:
257
+ 'Drill into the findings of the MOST RECENT scan_cloud scan (per-provider, per-session cache — NOT live state; cleared when the MCP server restarts). Filter by provider/plugin/severity/category and paginate (cursor/limit). Returns full untruncated finding text. Requires an Enterprise license. Use the scanId from the scan_cloud summary footer; if you get a "re-run scan_cloud" error the cache was cleared or superseded.',
258
+ inputSchema: { type: 'object', properties: {
259
+ scanId: { type: 'string', description: 'scanId from the scan_cloud summary footer (optional; staleness guard).' },
260
+ provider: { type: 'string', enum: ['aws', 'gcp', 'azure'], description: 'Which provider slot to drill (required when multiple are cached).' },
261
+ plugin: { type: 'string', description: 'Filter to one plugin id, e.g. "1150".' },
262
+ severity: { type: 'string', description: 'Filter: CRITICAL|HIGH|MEDIUM|LOW|INFO.' },
263
+ category: { type: 'string', description: 'Filter to one details.category, e.g. "sqs-age-alarm-missing".' },
264
+ cursor: { type: 'string', description: 'Pagination cursor from a previous nextCursor.' },
265
+ limit: { type: 'number', description: `Page size (server-capped at ${GET_FINDINGS_MAX_LIMIT}).` },
266
+ } },
267
+ },
239
268
  {
240
269
  name: 'probe_service',
241
270
  description:
@@ -421,7 +450,8 @@ export async function handleScanCloud(args) {
421
450
  // empty for a cloud conclusion). Best-effort.
422
451
  let markdown = null;
423
452
  try {
424
- markdown = renderCloudFindingsMarkdown(findingsSummary, providers);
453
+ markdown = renderCloudFindingsMarkdown(findingsSummary, providers,
454
+ { getFindingsAvailable: typeof toolHandlers.get_findings === 'function' });
425
455
  } catch { /* swallow — markdown is best-effort */ }
426
456
 
427
457
  // Anti-false-clean: a requested cloud is "audited" ONLY if >=1 of its plugins
@@ -445,6 +475,26 @@ export async function handleScanCloud(args) {
445
475
  // Honest count: completed audits, NOT error/skip envelopes.
446
476
  const pluginsRan = (output.manifest || []).filter((m) => m.status === 'ran').length;
447
477
 
478
+ // Mint ONE scanId for the whole call and write every scanned provider's slot so
479
+ // a follow-up get_findings call can drill into any individual provider from this
480
+ // scan (one-to-many: same scanId on every slot populated by this call).
481
+ const scanId = _nextScanId();
482
+ const ts = Date.now();
483
+ const byProvider = new Map();
484
+ for (const r of (output.results || [])) {
485
+ const prov = providerOf(r?.id ?? r?.result?.id);
486
+ if (!prov) continue;
487
+ if (!byProvider.has(prov)) byProvider.set(prov, []);
488
+ byProvider.get(prov).push(r);
489
+ }
490
+ for (const prov of providers) {
491
+ _putCloudScan(prov, { scanId, ts, args, results: byProvider.get(prov) || [] });
492
+ }
493
+
494
+ if (markdown != null && typeof toolHandlers.get_findings === 'function') {
495
+ markdown += `\n\n> _scanId: ${scanId} — drill any category via get_findings (provider="${providers[0]}")._`;
496
+ }
497
+
448
498
  return {
449
499
  providers,
450
500
  audited: auditedProviders.length > 0,
@@ -453,10 +503,48 @@ export async function handleScanCloud(args) {
453
503
  manifest: output.manifest ?? [],
454
504
  pluginsRan,
455
505
  markdown,
506
+ scanId,
456
507
  ...(notes.length ? { notes } : {}),
457
508
  };
458
509
  }
459
510
 
511
+ export async function handleGetFindings(args = {}) {
512
+ const slots = ['aws', 'gcp', 'azure'].filter((p) => _getCloudScan(p));
513
+ let provider = args.provider && String(args.provider).toLowerCase();
514
+ if (!provider) {
515
+ if (slots.length === 1) provider = slots[0];
516
+ else if (slots.length === 0) return { error: 'No cloud scan is cached for this session — run scan_cloud first. (The cache is per-session and is cleared when the MCP server restarts.)' };
517
+ else return { error: `Multiple cached scans — pass provider (one of: ${slots.map((p) => `${p}:${_getCloudScan(p).scanId}`).join(', ')}).` };
518
+ }
519
+ const slot = _getCloudScan(provider);
520
+ if (!slot) return { error: `No cached scan for provider '${provider}' — run scan_cloud first (per-session cache, cleared on server restart).` };
521
+ if (args.scanId && args.scanId !== slot.scanId) return { error: `scanId '${args.scanId}' is no longer cached (displaced by ${slot.scanId}) — re-run scan_cloud.` };
522
+ let rows = [];
523
+ for (const r of (slot.results || [])) for (const f of (r?.result?.findings ?? r?.findings ?? [])) {
524
+ const sev = String(f?.severity || 'INFO').toUpperCase();
525
+ const cat = (f?.details && f.details.category) ? String(f.details.category) : `uncategorized(${String(r?.id ?? '')})`;
526
+ if (args.severity && sev !== String(args.severity).toUpperCase()) continue;
527
+ if (args.category && cat !== String(args.category)) continue;
528
+ if (args.plugin && String(r?.id) !== String(args.plugin)) continue;
529
+ // Extract the resource identifier directly from the finding object (same key
530
+ // priority as describeFinding's RESOURCE_KEYS) — avoids split-after-truncation
531
+ // ambiguity when describeFinding truncates into the ' — ' separator.
532
+ const FINDING_RESOURCE_KEYS = ['userName','bucket','bucketName','function','functionName','table','tableName','instanceId','group','groupId','key','keyId','vault','vaultName','pipeline','topic','queue','projectId','domain','secretName','roleName','accountName','resource','resourceId','name','arn'];
533
+ let res = '';
534
+ for (const k of FINDING_RESOURCE_KEYS) { if (f && f[k]) { res = String(f[k]); break; } }
535
+ rows.push({ plugin: String(r?.id ?? ''), severity: sev, category: cat,
536
+ resource: res, region: f?.region || f?.details?.region || '',
537
+ text: Array.isArray(f?.issues) ? f.issues.join(' · ') : String(f?.title || f?.classification || '') });
538
+ }
539
+ const start = Number(args.cursor) || 0;
540
+ const limit = Math.min(Number(args.limit) || GET_FINDINGS_MAX_LIMIT, GET_FINDINGS_MAX_LIMIT);
541
+ const page = rows.slice(start, start + limit);
542
+ const out = { provider, scanId: slot.scanId, total: rows.length, findings: page };
543
+ if (Number(args.limit) > GET_FINDINGS_MAX_LIMIT) out.note = `limit capped server-side at ${GET_FINDINGS_MAX_LIMIT} (≈10KB/page).`;
544
+ if (start + limit < rows.length) out.nextCursor = String(start + limit);
545
+ return out;
546
+ }
547
+
460
548
  export async function handleProbeService(args) {
461
549
  if (!args?.host || typeof args.host !== 'string') {
462
550
  throw new Error('Missing required parameter: host');
@@ -516,6 +604,7 @@ export async function handleListPlugins() {
516
604
  export const toolHandlers = {
517
605
  scan_host: handleScanHost,
518
606
  scan_cloud: handleScanCloud,
607
+ get_findings: handleGetFindings,
519
608
  probe_service: handleProbeService,
520
609
  get_vulnerabilities: handleGetVulnerabilities,
521
610
  list_plugins: handleListPlugins,
@@ -574,8 +663,8 @@ export function createServer() {
574
663
  }
575
664
  }
576
665
 
577
- // Gate the Enterprise cloud-audit tool at the MCP dispatch layer.
578
- if (name === 'scan_cloud') {
666
+ // Gate Enterprise cloud-audit tools at the MCP dispatch layer (gate-before-cache-read).
667
+ if (['scan_cloud', 'get_findings'].includes(name)) {
579
668
  const denied = requireEnterpriseCapability(name);
580
669
  if (denied) {
581
670
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -57,7 +57,7 @@ export function describeFinding(x, opts = {}) {
57
57
  else for (const k of REASON_KEYS) { if (x[k]) { why = String(x[k]); break; } }
58
58
  why = why.replace(ROUTING_PREFIX_RE, '');
59
59
  const s = ((res ? res + ' — ' : '') + why).trim();
60
- if (s) return s.slice(0, 160);
60
+ if (s) return s.length > 160 ? s.slice(0, 160).replace(/\s+\S*$/, '') + '…' : s;
61
61
  const sev = String(x.severity || x.level || '').toUpperCase();
62
62
  return sev ? sev + ' finding (no description)' : 'finding (no description)';
63
63
  }
@@ -97,7 +97,7 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
97
97
  // bucketed under 'unknown' rather than silently dropped (defense-in-depth against a
98
98
  // future id collision / cloudProvider drift — never let a real finding vanish).
99
99
  const prov = providerOf(r?.id ?? r?.result?.id) || UNKNOWN_PROVIDER;
100
- const bucket = (out[prov] ||= { counts: {}, findings: [], evidenceGaps: [], truncated: false });
100
+ const bucket = (out[prov] ||= { counts: {}, findings: [], evidenceGaps: [], truncated: false, _rollup: { MEDIUM: new Map(), LOW: new Map() } });
101
101
  for (const x of found) {
102
102
  const sev = String(x?.severity || x?.level || 'INFO').toUpperCase();
103
103
  bucket.counts[sev] = (bucket.counts[sev] || 0) + 1;
@@ -111,10 +111,11 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
111
111
  // fact is incoherent); carry the first actionable clause as a companion so a
112
112
  // mixed rollup's actionable content still reaches the caller (review fold D3).
113
113
  const gapEntry = { severity: sev, plugin: String(r?.id ?? ''), title: describeFinding(x, { prefer: 'gap' }) };
114
+ gapEntry.gapKind = (x.details.walkthroughRequired === true) ? 'walkthrough-required' : 'couldnt-read';
114
115
  const clauses = Array.isArray(x.issues) ? x.issues.filter(Boolean).map(String) : [];
115
- const actionable = clauses.find((i) => classifyClause(i) === 'actionable');
116
- if (actionable && clauses.some((i) => classifyClause(i) === 'gap')) {
117
- gapEntry.action = actionable.replace(ROUTING_PREFIX_RE, '').slice(0, 160);
116
+ const actionableClauses = clauses.filter((i) => classifyClause(i) === 'actionable');
117
+ if (actionableClauses.length && clauses.some((i) => classifyClause(i) === 'gap')) {
118
+ gapEntry.action = actionableClauses.map((a) => a.replace(ROUTING_PREFIX_RE, '')).join(' · ').slice(0, 240);
118
119
  }
119
120
  // For CRITICAL/HIGH the actionable clause is normally itemized in the findings
120
121
  // list above, so the companion would duplicate it — but the findings list is
@@ -124,6 +125,13 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
124
125
  if (findingEntry) gapEntry._findingRef = findingEntry;
125
126
  bucket.evidenceGaps.push(gapEntry);
126
127
  }
128
+ if ((sev === 'MEDIUM' || sev === 'LOW') && !(x && x.details && x.details.evidenceGap === true)) {
129
+ const cat = (x && x.details && x.details.category)
130
+ ? String(x.details.category)
131
+ : `uncategorized(${String(r?.id ?? '')})`;
132
+ const m = bucket._rollup[sev];
133
+ m.set(cat, (m.get(cat) || 0) + 1);
134
+ }
127
135
  }
128
136
  }
129
137
  // Sort by severity (CRITICAL first) THEN truncate — so a CRITICAL is never evicted
@@ -141,6 +149,13 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
141
149
  delete g._findingRef; // internal ref must never reach the MCP payload
142
150
  }
143
151
  }
152
+ b.rollup = {
153
+ MEDIUM: [...b._rollup.MEDIUM].map(([category, count]) => ({ category, count }))
154
+ .sort((a, c) => c.count - a.count || a.category.localeCompare(c.category)),
155
+ LOW: [...b._rollup.LOW].map(([category, count]) => ({ category, count }))
156
+ .sort((a, c) => c.count - a.count || a.category.localeCompare(c.category)),
157
+ };
158
+ delete b._rollup;
144
159
  }
145
160
  return out;
146
161
  }
@@ -174,7 +189,7 @@ export function incompleteCoverageAdvisory(scanScope) {
174
189
  }
175
190
 
176
191
  /** Compact markdown from a summary. Named providers first, then any extras (e.g. 'unknown') so nothing is hidden. */
177
- export function renderCloudFindingsMarkdown(summary, providers) {
192
+ export function renderCloudFindingsMarkdown(summary, providers, opts = {}) {
178
193
  const named = providers && providers.length ? providers.slice() : [];
179
194
  // Exclude the meta-key _incompleteCoverage from the provider rendering loop.
180
195
  const order = [...named, ...Object.keys(summary).filter((p) => !named.includes(p) && p !== '_incompleteCoverage')];
@@ -187,6 +202,14 @@ export function renderCloudFindingsMarkdown(summary, providers) {
187
202
  if (b.truncated) lines.push(`- _…CRITICAL/HIGH list truncated; see counts above for totals._`);
188
203
  for (const g of (b.evidenceGaps || [])) lines.push(`- **[⚠ EVIDENCE GAP — unverified]** ${g.plugin}: ${g.title}${g.action ? ` · actionable: ${g.action}` : ''}`);
189
204
  if (b.evidenceGapsTruncated) lines.push(`- _…evidence-gap list truncated; see LOW count for totals._`);
205
+ const drill = opts.getFindingsAvailable ? ' — drill any category via get_findings' : '';
206
+ for (const tier of ['MEDIUM', 'LOW']) {
207
+ const rows = (b.rollup && b.rollup[tier]) || [];
208
+ if (!rows.length) continue;
209
+ const total = c[tier] || rows.reduce((n, rr) => n + rr.count, 0);
210
+ const body = rows.map((rr) => `${rr.category} ×${rr.count}`).join(' · ');
211
+ lines.push(`- **${tier} (${total})** ${body}${drill}`);
212
+ }
190
213
  lines.push('');
191
214
  }
192
215
  // Append the incomplete-coverage advisory (when present) as an informational note.