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