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 +2 -2
- package/mcp_server.mjs +95 -6
- package/package.json +1 -1
- package/utils/cloud_finding_summary.mjs +29 -6
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.
|
|
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.
|
|
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
|
|
578
|
-
if (
|
|
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
|
@@ -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
|
|
116
|
-
if (
|
|
117
|
-
gapEntry.action =
|
|
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.
|