nsauditor-ai 0.2.9 → 0.2.11

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,12 @@ 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.9 + Enterprise 0.19.4** (June 2026) — paired README-refresh bump (no Community Edition code change this cycle). **EE 0.19.4 "Routing-Integrity Hardening"** closes the routing false-clean class — a real finding (or a visible evidence-gap) that mapped to **zero** compliance controls while the verdict stayed green: a build-time **routing guard** (a complete-partition `nativeFrameworks` allowlist) so no marked gap routes nowhere + GuardDuty (1200) dedupe + class-O routing; a **deferred-scope unmark** across 8 plugins (a capability boundary is not an evidence-gap → the MCP "unverified" list shrinks); the 1160 AWS-default VPC-endpoint full-access policy down-rated **CRITICAL→MEDIUM** with cross-framework routing (PCI 1.4.1 / ISO A.8.22 / CIS 12.2 + SOC 2 CC6.6 / HIPAA 164.312(a)(1)); and 1150 SQS/SNS **alarm-independence** (alarm posture survives a `Get*Attributes` deny; all four `alarm-coverage-unverifiable` causes fail-close soc2{A1.2,CC7.2}+hipaa{164.312(b)}). **PCI DSS matrix shifts 20/8/39 → 19/9/39** (Req 7.2.2 covered→partial — an honest down-rate of a QSA-flagged overclaim, backed by 1030 over-privilege detection). Plugin count UNCHANGED at 28; the other five matrices UNCHANGED. **EE 0.19.4 requires CE 0.2.8+.**
20
+ <!-- DRAFT pending operator legal review (GDPR-cycle scope-doctrine copy). Not yet published. -->
21
+ **Latest: CE 0.2.11 + Enterprise 0.20.0** (June 2026) — paired content bump for the Enterprise **GDPR Article 32 (Security of Processing)** cycle, the **seventh** compliance framework. The Enterprise engine now substrate-evidences **GDPR Article 32 infrastructure substrate** — Art. 32 only, **not** GDPR compliance (GDPR is a 99-article legal regime; Art. 32 security-of-processing is the only article an infrastructure scanner can evidence). The CE-side change: the MCP `scan_cloud` tool description now lists GDPR Article 32 among the mapped frameworks, and the agent-skill teaches the 7th framework (scope doctrine · four-factor proportionality · the Art. 83(4) lower fine tier). No CE engine behavior change. Paired **EE 0.20.0** (the framework lives in the Enterprise engine; matrix 4 covered + 5 partial + 2 OOS across 11 Art. 32 sub-measure units) + agent-skill 0.2.11. **EE 0.20.0 requires CE 0.2.8+.** See [CHANGELOG.md](./CHANGELOG.md).
21
22
 
22
- **Prior: CE 0.2.8 + Enterprise 0.19.3** — real CE bump (MCP `scan_cloud` routing description + actionable gap-list visibility) paired with EE 0.19.3 "MCP affordance + class-O truncation sweep" (1080/1221/1180 + class-O sweep across 8 AWS auditors). See [CHANGELOG.md](./CHANGELOG.md).
23
+ **Prior: CE 0.2.10 + Enterprise 0.19.4** — MCP affordance II: `scan_cloud` rolls up MEDIUM/LOW findings per provider by category + a NEW Enterprise-gated `get_findings` drill-down tool. See [CHANGELOG.md](./CHANGELOG.md).
24
+
25
+ **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
26
 
24
27
  → Full release history: **[CHANGELOG.md](./CHANGELOG.md)**
25
28
  → 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, RESOURCE_KEYS } 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, 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.',
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 using the SAME
530
+ // key priority as describeFinding (the shared RESOURCE_KEYS imported from
531
+ // cloud_finding_summary.mjs) — avoids split-after-truncation ambiguity when
532
+ // describeFinding truncates into the ' — ' separator, and can never drift from it.
533
+ let res = '';
534
+ for (const k of 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.9",
3
+ "version": "0.2.11",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -9,7 +9,10 @@ const RANK = { CRITICAL: 5, HIGH: 4, MEDIUM: 3, LOW: 2, INFO: 1, PASS: 0 };
9
9
  // Resource-identifying keys, most-specific first so a precise label wins over the
10
10
  // generic resource/name/arn fallbacks. Grounded in the REAL EE plugin emissions
11
11
  // (S3 1020 emits `bucket`, Key Vault `vault`, DynamoDB `table`, KMS `key`, …).
12
- const 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'];
12
+ // Exported as the SINGLE SOURCE OF TRUTH get_findings (mcp_server.mjs) imports this
13
+ // rather than re-declaring the list, so the two resource-extraction surfaces can never
14
+ // drift on which key names a finding's resource.
15
+ export const 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'];
13
16
  const REASON_KEYS = ['classification', 'title', 'message', 'reason', 'finding', 'detail'];
14
17
  const UNKNOWN_PROVIDER = 'unknown';
15
18
  // Scan-coverage gap prose — an EXACT 1:1 mirror of the EE evidence-gap anchor
@@ -57,7 +60,7 @@ export function describeFinding(x, opts = {}) {
57
60
  else for (const k of REASON_KEYS) { if (x[k]) { why = String(x[k]); break; } }
58
61
  why = why.replace(ROUTING_PREFIX_RE, '');
59
62
  const s = ((res ? res + ' — ' : '') + why).trim();
60
- if (s) return s.slice(0, 160);
63
+ if (s) return s.length > 160 ? s.slice(0, 160).replace(/\s+\S*$/, '') + '…' : s;
61
64
  const sev = String(x.severity || x.level || '').toUpperCase();
62
65
  return sev ? sev + ' finding (no description)' : 'finding (no description)';
63
66
  }
@@ -97,7 +100,7 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
97
100
  // bucketed under 'unknown' rather than silently dropped (defense-in-depth against a
98
101
  // future id collision / cloudProvider drift — never let a real finding vanish).
99
102
  const prov = providerOf(r?.id ?? r?.result?.id) || UNKNOWN_PROVIDER;
100
- const bucket = (out[prov] ||= { counts: {}, findings: [], evidenceGaps: [], truncated: false });
103
+ const bucket = (out[prov] ||= { counts: {}, findings: [], evidenceGaps: [], truncated: false, _rollup: { MEDIUM: new Map(), LOW: new Map() } });
101
104
  for (const x of found) {
102
105
  const sev = String(x?.severity || x?.level || 'INFO').toUpperCase();
103
106
  bucket.counts[sev] = (bucket.counts[sev] || 0) + 1;
@@ -111,10 +114,11 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
111
114
  // fact is incoherent); carry the first actionable clause as a companion so a
112
115
  // mixed rollup's actionable content still reaches the caller (review fold D3).
113
116
  const gapEntry = { severity: sev, plugin: String(r?.id ?? ''), title: describeFinding(x, { prefer: 'gap' }) };
117
+ gapEntry.gapKind = (x.details.walkthroughRequired === true) ? 'walkthrough-required' : 'couldnt-read';
114
118
  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);
119
+ const actionableClauses = clauses.filter((i) => classifyClause(i) === 'actionable');
120
+ if (actionableClauses.length && clauses.some((i) => classifyClause(i) === 'gap')) {
121
+ gapEntry.action = actionableClauses.map((a) => a.replace(ROUTING_PREFIX_RE, '')).join(' · ').slice(0, 240);
118
122
  }
119
123
  // For CRITICAL/HIGH the actionable clause is normally itemized in the findings
120
124
  // list above, so the companion would duplicate it — but the findings list is
@@ -124,6 +128,13 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
124
128
  if (findingEntry) gapEntry._findingRef = findingEntry;
125
129
  bucket.evidenceGaps.push(gapEntry);
126
130
  }
131
+ if ((sev === 'MEDIUM' || sev === 'LOW') && !(x && x.details && x.details.evidenceGap === true)) {
132
+ const cat = (x && x.details && x.details.category)
133
+ ? String(x.details.category)
134
+ : `uncategorized(${String(r?.id ?? '')})`;
135
+ const m = bucket._rollup[sev];
136
+ m.set(cat, (m.get(cat) || 0) + 1);
137
+ }
127
138
  }
128
139
  }
129
140
  // Sort by severity (CRITICAL first) THEN truncate — so a CRITICAL is never evicted
@@ -141,6 +152,13 @@ export function summarizeCloudFindings(results, providerOf, cap = Number(process
141
152
  delete g._findingRef; // internal ref must never reach the MCP payload
142
153
  }
143
154
  }
155
+ b.rollup = {
156
+ MEDIUM: [...b._rollup.MEDIUM].map(([category, count]) => ({ category, count }))
157
+ .sort((a, c) => c.count - a.count || a.category.localeCompare(c.category)),
158
+ LOW: [...b._rollup.LOW].map(([category, count]) => ({ category, count }))
159
+ .sort((a, c) => c.count - a.count || a.category.localeCompare(c.category)),
160
+ };
161
+ delete b._rollup;
144
162
  }
145
163
  return out;
146
164
  }
@@ -174,7 +192,7 @@ export function incompleteCoverageAdvisory(scanScope) {
174
192
  }
175
193
 
176
194
  /** Compact markdown from a summary. Named providers first, then any extras (e.g. 'unknown') so nothing is hidden. */
177
- export function renderCloudFindingsMarkdown(summary, providers) {
195
+ export function renderCloudFindingsMarkdown(summary, providers, opts = {}) {
178
196
  const named = providers && providers.length ? providers.slice() : [];
179
197
  // Exclude the meta-key _incompleteCoverage from the provider rendering loop.
180
198
  const order = [...named, ...Object.keys(summary).filter((p) => !named.includes(p) && p !== '_incompleteCoverage')];
@@ -187,6 +205,14 @@ export function renderCloudFindingsMarkdown(summary, providers) {
187
205
  if (b.truncated) lines.push(`- _…CRITICAL/HIGH list truncated; see counts above for totals._`);
188
206
  for (const g of (b.evidenceGaps || [])) lines.push(`- **[⚠ EVIDENCE GAP — unverified]** ${g.plugin}: ${g.title}${g.action ? ` · actionable: ${g.action}` : ''}`);
189
207
  if (b.evidenceGapsTruncated) lines.push(`- _…evidence-gap list truncated; see LOW count for totals._`);
208
+ const drill = opts.getFindingsAvailable ? ' — drill any category via get_findings' : '';
209
+ for (const tier of ['MEDIUM', 'LOW']) {
210
+ const rows = (b.rollup && b.rollup[tier]) || [];
211
+ if (!rows.length) continue;
212
+ const total = c[tier] || rows.reduce((n, rr) => n + rr.count, 0);
213
+ const body = rows.map((rr) => `${rr.category} ×${rr.count}`).join(' · ');
214
+ lines.push(`- **${tier} (${total})** ${body}${drill}`);
215
+ }
190
216
  lines.push('');
191
217
  }
192
218
  // Append the incomplete-coverage advisory (when present) as an informational note.