agent-security-scanner-mcp 4.1.0 → 4.2.0

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.
@@ -0,0 +1,254 @@
1
+ // OSV.dev API client for vulnerability enrichment.
2
+ // M1 scope: OSV only. NVD/GHSA as future providers.
3
+
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { buildPurl } from './purl.js';
7
+
8
+ const OSV_BATCH_URL = 'https://api.osv.dev/v1/querybatch';
9
+ const BATCH_SIZE = 1000;
10
+ const FETCH_TIMEOUT = 30000;
11
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
12
+
13
+ /**
14
+ * Query OSV.dev for vulnerabilities affecting a list of packages.
15
+ * Accepts normalized components (with purl, namespace) to avoid lossy flattening.
16
+ * Uses local cache to avoid redundant network calls.
17
+ * @param {Array<{ name: string, version: string, ecosystem: string, purl?: string, namespace?: string }>} packages
18
+ * @param {{ cacheDir?: string }} [options={}]
19
+ * @returns {Promise<Map<string, object[]>>} Map of cacheKey → vulnerability list
20
+ */
21
+ export async function queryBatch(packages, options = {}) {
22
+ const { cacheDir } = options;
23
+ const results = new Map();
24
+ const uncached = [];
25
+
26
+ // Check cache first
27
+ for (const pkg of packages) {
28
+ const cacheKey = getPackageIdentity(pkg);
29
+ const cached = readCache(cacheDir, cacheKey);
30
+ if (cached !== null) {
31
+ if (cached.length > 0) results.set(cacheKey, cached);
32
+ continue;
33
+ }
34
+ uncached.push(pkg);
35
+ }
36
+
37
+ if (uncached.length === 0) return results;
38
+
39
+ // Map ecosystem names to OSV ecosystem names
40
+ const ecosystemMap = {
41
+ npm: 'npm',
42
+ pypi: 'PyPI',
43
+ rubygems: 'RubyGems',
44
+ crates: 'crates.io',
45
+ go: 'Go',
46
+ java: 'Maven',
47
+ dart: 'Pub',
48
+ };
49
+
50
+ // Batch into chunks of BATCH_SIZE
51
+ for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
52
+ const chunk = uncached.slice(i, i + BATCH_SIZE);
53
+ const queries = chunk.map(pkg => ({
54
+ package: {
55
+ name: pkg.ecosystem === 'java' && pkg.namespace
56
+ ? `${pkg.namespace}:${pkg.name}` : pkg.name,
57
+ ecosystem: ecosystemMap[pkg.ecosystem] || pkg.ecosystem,
58
+ },
59
+ version: pkg.version,
60
+ }));
61
+
62
+ try {
63
+ const response = await fetchWithRetry(OSV_BATCH_URL, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ queries }),
67
+ });
68
+
69
+ if (!response.ok) continue;
70
+
71
+ const data = await response.json();
72
+ const batchResults = data.results || [];
73
+
74
+ for (let j = 0; j < chunk.length; j++) {
75
+ const pkg = chunk[j];
76
+ const cacheKey = getPackageIdentity(pkg);
77
+ const vulns = (batchResults[j] && batchResults[j].vulns) || [];
78
+ const mapped = vulns.map(v => mapOsvVulnerability(v, pkg));
79
+
80
+ writeCache(cacheDir, cacheKey, mapped);
81
+ if (mapped.length > 0) results.set(cacheKey, mapped);
82
+ }
83
+ } catch {
84
+ // Network error — skip this batch, don't cache
85
+ }
86
+ }
87
+
88
+ return results;
89
+ }
90
+
91
+ async function fetchWithRetry(url, options, retries = 1) {
92
+ const controller = new AbortController();
93
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
94
+
95
+ try {
96
+ const response = await fetch(url, { ...options, signal: controller.signal });
97
+ clearTimeout(timeout);
98
+ if (response.status === 429 && retries > 0) {
99
+ await new Promise(r => setTimeout(r, 2000));
100
+ return fetchWithRetry(url, options, retries - 1);
101
+ }
102
+ return response;
103
+ } catch (err) {
104
+ clearTimeout(timeout);
105
+ if (retries > 0) {
106
+ await new Promise(r => setTimeout(r, 1000));
107
+ return fetchWithRetry(url, options, retries - 1);
108
+ }
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Map an OSV vulnerability to CycloneDX vulnerability format.
115
+ */
116
+ function mapOsvVulnerability(osvEntry, pkg) {
117
+ const cveId = (osvEntry.aliases || []).find(a => a.startsWith('CVE-')) || osvEntry.id;
118
+ const severity = extractSeverity(osvEntry);
119
+ const fixedVersion = extractFixedVersion(osvEntry, pkg);
120
+
121
+ return {
122
+ id: cveId,
123
+ source: { name: 'OSV', url: `https://osv.dev/vulnerability/${osvEntry.id}` },
124
+ ratings: [{
125
+ score: severity.score,
126
+ severity: severity.level,
127
+ method: severity.method || 'other',
128
+ }],
129
+ description: osvEntry.summary || osvEntry.details || '',
130
+ affects: [{
131
+ ref: getPackageIdentity(pkg),
132
+ }],
133
+ ...(fixedVersion && {
134
+ recommendation: `Upgrade to ${fixedVersion}`,
135
+ }),
136
+ properties: [
137
+ { name: 'osv:id', value: osvEntry.id },
138
+ ...(osvEntry.aliases || []).filter(a => a !== cveId).map(a => ({ name: 'alias', value: a })),
139
+ ],
140
+ };
141
+ }
142
+
143
+ function getPackageIdentity(pkg) {
144
+ if (pkg.purl) return pkg.purl;
145
+
146
+ const purl = buildPurl(pkg.ecosystem, pkg.name, pkg.version, pkg.namespace);
147
+ if (purl) return purl;
148
+
149
+ if (pkg.namespace) {
150
+ return `${pkg.ecosystem}:${pkg.namespace}:${pkg.name}@${pkg.version}`;
151
+ }
152
+ return `${pkg.ecosystem}:${pkg.name}@${pkg.version}`;
153
+ }
154
+
155
+ function extractSeverity(osvEntry) {
156
+ // Try CVSS from severity array
157
+ if (osvEntry.severity && osvEntry.severity.length > 0) {
158
+ for (const sev of osvEntry.severity) {
159
+ if (sev.type === 'CVSS_V3') {
160
+ const score = parseCvssScore(sev.score);
161
+ if (score !== null) {
162
+ return { score, level: scoreToLevel(score), method: 'CVSSv3' };
163
+ }
164
+ }
165
+ }
166
+ }
167
+ // Try database_specific
168
+ if (osvEntry.database_specific && osvEntry.database_specific.severity) {
169
+ const level = osvEntry.database_specific.severity.toLowerCase();
170
+ return { score: levelToScore(level), level, method: 'other' };
171
+ }
172
+ return { score: 0, level: 'unknown', method: 'other' };
173
+ }
174
+
175
+ function parseCvssScore(cvssString) {
176
+ if (typeof cvssString === 'number') return cvssString;
177
+ // CVSS vector strings don't contain the score directly; try to extract from a numeric prefix
178
+ const num = parseFloat(cvssString);
179
+ return isNaN(num) ? null : num;
180
+ }
181
+
182
+ /**
183
+ * Map CVSS score to severity level.
184
+ * @param {number} score
185
+ * @returns {string}
186
+ */
187
+ export function scoreToLevel(score) {
188
+ if (score >= 9.0) return 'critical';
189
+ if (score >= 7.0) return 'high';
190
+ if (score >= 4.0) return 'medium';
191
+ if (score > 0) return 'low';
192
+ return 'unknown';
193
+ }
194
+
195
+ function levelToScore(level) {
196
+ switch (level) {
197
+ case 'critical': return 9.5;
198
+ case 'high': return 7.5;
199
+ case 'medium': return 5.0;
200
+ case 'moderate': return 5.0;
201
+ case 'low': return 2.5;
202
+ default: return 0;
203
+ }
204
+ }
205
+
206
+ function extractFixedVersion(osvEntry, pkg) {
207
+ if (!osvEntry.affected) return null;
208
+ for (const affected of osvEntry.affected) {
209
+ if (affected.package && affected.package.name === pkg.name) {
210
+ for (const range of affected.ranges || []) {
211
+ for (const event of range.events || []) {
212
+ if (event.fixed) return event.fixed;
213
+ }
214
+ }
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ // ─── Local cache ─────────────────────────────────────────────────────
221
+
222
+ function getCachePath(cacheDir, key) {
223
+ if (!cacheDir) return null;
224
+ // Sanitize key for filesystem
225
+ const safe = key.replace(/[^a-zA-Z0-9@._-]/g, '_');
226
+ return join(cacheDir, `${safe}.json`);
227
+ }
228
+
229
+ function readCache(cacheDir, key) {
230
+ const path = getCachePath(cacheDir, key);
231
+ if (!path || !existsSync(path)) return null;
232
+
233
+ try {
234
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
235
+ if (Date.now() - data.timestamp > CACHE_TTL_MS) return null; // expired
236
+ return data.vulns;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ function writeCache(cacheDir, key, vulns) {
243
+ const path = getCachePath(cacheDir, key);
244
+ if (!path) return;
245
+
246
+ try {
247
+ if (!existsSync(cacheDir)) {
248
+ mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
249
+ }
250
+ writeFileSync(path, JSON.stringify({ timestamp: Date.now(), vulns }, null, 2), { mode: 0o600 });
251
+ } catch {
252
+ // Cache write failure is non-fatal
253
+ }
254
+ }
@@ -0,0 +1,90 @@
1
+ // Package URL (PURL) builder — https://github.com/package-url/purl-spec
2
+
3
+ const ECOSYSTEM_TO_PURL_TYPE = {
4
+ npm: 'npm',
5
+ pypi: 'pypi',
6
+ rubygems: 'gem',
7
+ crates: 'cargo',
8
+ go: 'golang',
9
+ java: 'maven',
10
+ dart: 'pub',
11
+ perl: 'cpan',
12
+ raku: 'cpan',
13
+ };
14
+
15
+ const PURL_TYPE_TO_ECOSYSTEM = {
16
+ npm: 'npm',
17
+ pypi: 'pypi',
18
+ gem: 'rubygems',
19
+ cargo: 'crates',
20
+ golang: 'go',
21
+ maven: 'java',
22
+ pub: 'dart',
23
+ cpan: 'perl',
24
+ };
25
+
26
+ /**
27
+ * Build a Package URL string.
28
+ * @param {string} ecosystem - Internal ecosystem name (npm, pypi, etc.)
29
+ * @param {string} name - Package name
30
+ * @param {string} [version] - Package version
31
+ * @param {string} [namespace] - Optional namespace (e.g. Maven groupId)
32
+ * @returns {string} PURL string like pkg:npm/express@4.18.2
33
+ */
34
+ export function buildPurl(ecosystem, name, version, namespace) {
35
+ const type = ECOSYSTEM_TO_PURL_TYPE[ecosystem] || ecosystem;
36
+ if (!name) return '';
37
+
38
+ let qualifiedName;
39
+
40
+ if (type === 'npm' && name.startsWith('@')) {
41
+ // Scoped npm: @scope/name → namespace=scope, name=name
42
+ const [scope, pkg] = name.split('/');
43
+ qualifiedName = `${encodeURIComponent(scope)}/${pkg}`;
44
+ } else if (type === 'maven' && namespace) {
45
+ qualifiedName = `${namespace}/${name}`;
46
+ } else if (type === 'golang') {
47
+ // Go modules use the full module path as-is
48
+ qualifiedName = name;
49
+ } else {
50
+ qualifiedName = name;
51
+ }
52
+
53
+ return version
54
+ ? `pkg:${type}/${qualifiedName}@${version}`
55
+ : `pkg:${type}/${qualifiedName}`;
56
+ }
57
+
58
+ /**
59
+ * Parse a PURL string back into components.
60
+ * @param {string} purl
61
+ * @returns {{ type: string, namespace?: string, name: string, version?: string } | null}
62
+ */
63
+ export function parsePurl(purl) {
64
+ const match = purl.match(/^pkg:([^/]+)\/(.+?)(?:@(.+))?$/);
65
+ if (!match) return null;
66
+
67
+ const [, type, qualifiedName, version] = match;
68
+ const slashIdx = qualifiedName.lastIndexOf('/');
69
+
70
+ if (slashIdx !== -1) {
71
+ return {
72
+ type,
73
+ namespace: decodeURIComponent(qualifiedName.slice(0, slashIdx)),
74
+ name: qualifiedName.slice(slashIdx + 1),
75
+ ...(version && { version }),
76
+ };
77
+ }
78
+
79
+ return {
80
+ type,
81
+ name: qualifiedName,
82
+ ...(version && { version }),
83
+ };
84
+ }
85
+
86
+ export function ecosystemFromPurlType(type) {
87
+ return PURL_TYPE_TO_ECOSYSTEM[type] || type;
88
+ }
89
+
90
+ export { ECOSYSTEM_TO_PURL_TYPE, PURL_TYPE_TO_ECOSYSTEM };
@@ -0,0 +1,88 @@
1
+ // Normalized SBOM component model — decoupled from any output format.
2
+ // CycloneDX, SPDX, and all tools operate on this model.
3
+
4
+ import { buildPurl, parsePurl, ecosystemFromPurlType } from './purl.js';
5
+
6
+ /**
7
+ * Create a normalized component.
8
+ * @param {{ name: string, version?: string, ecosystem: string, isDev?: boolean, isDirect?: boolean, scope?: string, namespace?: string }} opts
9
+ * @returns {{ name: string, version: string, ecosystem: string, isDev: boolean, isDirect: boolean, scope: string, purl: string, namespace?: string }}
10
+ */
11
+ export function createComponent({ name, version, ecosystem, isDev = false, isDirect = false, scope, namespace }) {
12
+ return {
13
+ name,
14
+ version: version || 'unknown',
15
+ ecosystem,
16
+ isDev,
17
+ isDirect,
18
+ scope: scope || (isDev ? 'optional' : 'required'),
19
+ purl: buildPurl(ecosystem, name, version, namespace),
20
+ ...(namespace && { namespace }),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Create a dependency edge between two components.
26
+ * @param {string} fromPurl - PURL of the dependent
27
+ * @param {string} toPurl - PURL of the dependency
28
+ * @returns {{ from: string, to: string }}
29
+ */
30
+ export function createEdge(fromPurl, toPurl) {
31
+ return { from: fromPurl, to: toPurl };
32
+ }
33
+
34
+ /**
35
+ * Create a component list with metadata and dependency graph.
36
+ * @param {object[]} components - Array of normalized components
37
+ * @param {object[]} [edges=[]] - Array of dependency edges
38
+ * @param {{ name?: string, version?: string, ecosystems?: string[] }} [metadata={}]
39
+ * @returns {{ components: object[], edges: object[], metadata: object }}
40
+ */
41
+ export function createComponentList(components = [], edges = [], metadata = {}) {
42
+ const ecosystems = [...new Set(components.map(c => c.ecosystem))];
43
+ return {
44
+ components,
45
+ edges,
46
+ metadata: {
47
+ name: metadata.name || 'unknown',
48
+ version: metadata.version || '0.0.0',
49
+ ecosystems,
50
+ total: components.length,
51
+ direct: components.filter(c => c.isDirect).length,
52
+ dev: components.filter(c => c.isDev).length,
53
+ ...metadata,
54
+ },
55
+ };
56
+ }
57
+
58
+ function getPropertyValue(component, name) {
59
+ return component.properties?.find(p => p.name === name)?.value;
60
+ }
61
+
62
+ /**
63
+ * Reconstruct a normalized component from a CycloneDX component entry.
64
+ * Preserves canonical PURL identity and Maven group when present.
65
+ * @param {object} component
66
+ * @returns {{ name: string, version: string, ecosystem: string, isDev: boolean, isDirect: boolean, scope: string, purl: string, namespace?: string }}
67
+ */
68
+ export function componentFromBomComponent(component) {
69
+ const parsed = component.purl ? parsePurl(component.purl) : null;
70
+ const ecosystem = getPropertyValue(component, 'cdx:ecosystem')
71
+ || (parsed ? ecosystemFromPurlType(parsed.type) : 'unknown');
72
+ const namespace = component.group || parsed?.namespace;
73
+ const name = parsed?.name || component.name;
74
+ const version = parsed?.version || component.version || 'unknown';
75
+ const isDev = getPropertyValue(component, 'cdx:development') === 'true';
76
+ const purl = component.purl || buildPurl(ecosystem, name, version, namespace);
77
+
78
+ return {
79
+ name,
80
+ version,
81
+ ecosystem,
82
+ isDev,
83
+ isDirect: false,
84
+ scope: component.scope || (isDev ? 'optional' : 'required'),
85
+ purl,
86
+ ...(namespace && { namespace }),
87
+ };
88
+ }
@@ -1,31 +1,35 @@
1
1
  // src/tools/compliance-controls.js — get_compliance_controls MCP tool (thin wrapper)
2
2
 
3
3
  import { z } from 'zod';
4
- import { loadControls, filterControls } from '../lib/compliance-controls.js';
4
+ import { loadControls, filterControls, listFrameworks } from '../lib/compliance-controls.js';
5
5
 
6
6
  export const complianceControlsSchema = {
7
- domain: z.enum(['security', 'safety', 'all']).optional().describe("Filter by domain"),
7
+ framework: z.string().optional().describe("Framework to query (default: aiuc-1). Use 'soc2-technical', 'gdpr-technical', etc."),
8
+ domain: z.string().optional().describe("Filter by domain (e.g. 'security', 'safety', 'all'). Accepted values depend on the framework."),
8
9
  control_ids: z.array(z.string()).optional().describe("Specific control IDs to retrieve"),
9
10
  owasp_filter: z.array(z.string()).optional().describe("Filter by OWASP LLM tags (e.g. LLM01)"),
10
11
  verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level"),
11
12
  };
12
13
 
13
- export async function getComplianceControls({ domain, control_ids, owasp_filter, verbosity }) {
14
+ export async function getComplianceControls({ framework, domain, control_ids, owasp_filter, verbosity }) {
15
+ const fw = framework || 'aiuc-1';
14
16
  const level = verbosity || 'compact';
15
17
 
16
18
  const controls = filterControls({
19
+ framework: fw,
17
20
  domain: domain || 'all',
18
21
  controlIds: control_ids,
19
22
  owaspFilter: owasp_filter,
20
23
  });
21
24
 
22
- const registry = loadControls();
25
+ const registry = loadControls(fw);
23
26
 
24
27
  let output;
25
28
  switch (level) {
26
29
  case 'minimal':
27
30
  output = {
28
31
  framework: registry.framework,
32
+ domains: registry.domains,
29
33
  controls_count: controls.length,
30
34
  controls: controls.map(c => ({ id: c.id, title: c.title, domain: c.domain })),
31
35
  };
@@ -37,6 +41,8 @@ export async function getComplianceControls({ domain, control_ids, owasp_filter,
37
41
  schema_version: registry.schema_version,
38
42
  source: registry.source,
39
43
  source_snapshot: registry.source_snapshot,
44
+ domains: registry.domains,
45
+ available_frameworks: listFrameworks(),
40
46
  controls_count: controls.length,
41
47
  controls,
42
48
  };
@@ -47,14 +53,18 @@ export async function getComplianceControls({ domain, control_ids, owasp_filter,
47
53
  output = {
48
54
  framework: registry.framework,
49
55
  controls_count: controls.length,
50
- controls: controls.map(c => ({
51
- id: c.id,
52
- title: c.title,
53
- domain: c.domain,
54
- owasp_llm: c.owasp_llm,
55
- scanner_tools: c.scanner_tools,
56
- evaluation: c.evaluation,
57
- })),
56
+ controls: controls.map(c => {
57
+ const out = {
58
+ id: c.id,
59
+ title: c.title,
60
+ domain: c.domain,
61
+ scanner_tools: c.scanner_tools,
62
+ evaluation: c.evaluation,
63
+ };
64
+ if (c.owasp_llm) out.owasp_llm = c.owasp_llm;
65
+ if (c.references) out.references = c.references;
66
+ return out;
67
+ }),
58
68
  };
59
69
  }
60
70
 
@@ -0,0 +1,161 @@
1
+ // src/tools/evaluate-compliance.js — evaluate_compliance MCP tool.
2
+ // Collects evidence from scan + SBOM, evaluates controls per framework, persists optionally.
3
+
4
+ import { z } from 'zod';
5
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { collectEvidence, buildEvaluatorEvidence } from '../lib/compliance-evidence.js';
8
+ import { loadControls } from '../lib/compliance-controls.js';
9
+ import { evaluateAll } from '../lib/compliance-evaluator.js';
10
+ function formatTimestamp(date) {
11
+ const pad = (n) => String(n).padStart(2, '0');
12
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
13
+ }
14
+
15
+ export const evaluateComplianceSchema = {
16
+ directory_path: z.string().describe('Path to project root directory to evaluate'),
17
+ frameworks: z.array(z.string()).optional()
18
+ .describe('Compliance frameworks to evaluate (default: ["aiuc-1"]). Options: aiuc-1, soc2-technical, gdpr-technical'),
19
+ sbom_path: z.string().optional().describe('Path to existing SBOM file (skips SBOM generation)'),
20
+ baseline_path: z.string().optional().describe('Path to SBOM baseline file for drift comparison'),
21
+ save_evidence: z.boolean().optional().describe('Save evidence bundle to .scanner/evidence/ (default: false)'),
22
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
23
+ };
24
+
25
+ export async function evaluateCompliance({ directory_path, frameworks, sbom_path, baseline_path, save_evidence, verbosity }) {
26
+ if (!existsSync(directory_path)) {
27
+ return error(`Directory not found: ${directory_path}`);
28
+ }
29
+
30
+ const fws = (frameworks && frameworks.length > 0) ? frameworks : ['aiuc-1'];
31
+ const level = verbosity || 'compact';
32
+
33
+ // Collect evidence once, evaluate against all requested frameworks
34
+ let bundle;
35
+ try {
36
+ bundle = await collectEvidence({
37
+ directory: directory_path,
38
+ sbomPath: sbom_path,
39
+ baselinePath: baseline_path,
40
+ toolVersion: process.env.npm_package_version || 'unknown',
41
+ verbosity: level,
42
+ });
43
+ } catch (err) {
44
+ return error(`Evidence collection failed: ${err.message}`);
45
+ }
46
+
47
+ // Add requested frameworks to metadata
48
+ bundle.metadata.frameworks = fws;
49
+
50
+ // Build legacy evaluator evidence (for AIUC-1 backward compat)
51
+ const legacyEvidence = buildEvaluatorEvidence(bundle);
52
+
53
+ // Evaluate each framework
54
+ const frameworkResults = [];
55
+ for (const fw of fws) {
56
+ try {
57
+ const registry = loadControls(fw);
58
+ const result = evaluateAll(registry.controls, legacyEvidence, bundle);
59
+ frameworkResults.push({
60
+ framework: registry.framework,
61
+ controls_evaluated: result.controls_evaluated,
62
+ summary: {
63
+ pass: result.pass,
64
+ partial: result.partial,
65
+ fail: result.fail,
66
+ not_evaluated: result.not_evaluated,
67
+ },
68
+ results: result.results,
69
+ });
70
+ } catch (err) {
71
+ frameworkResults.push({
72
+ framework: fw,
73
+ error: err.message,
74
+ });
75
+ }
76
+ }
77
+
78
+ // Persist evidence if requested
79
+ let savedPath = null;
80
+ if (save_evidence) {
81
+ try {
82
+ const evidenceDir = join(directory_path, '.scanner', 'evidence');
83
+ if (!existsSync(evidenceDir)) {
84
+ mkdirSync(evidenceDir, { recursive: true, mode: 0o700 });
85
+ }
86
+ const filename = `${formatTimestamp(new Date())}.json`;
87
+ savedPath = join(evidenceDir, filename);
88
+ const persistBundle = {
89
+ ...bundle,
90
+ compliance: frameworkResults,
91
+ };
92
+ writeFileSync(savedPath, JSON.stringify(persistBundle, null, 2), { encoding: 'utf-8', mode: 0o600 });
93
+ } catch (err) {
94
+ // Non-fatal — report but don't fail
95
+ if (!bundle.errors) bundle.errors = [];
96
+ bundle.errors.push({ source: 'evidence_persist', message: err.message });
97
+ }
98
+ }
99
+
100
+ // Build response based on verbosity
101
+ let response;
102
+ switch (level) {
103
+ case 'minimal':
104
+ response = {
105
+ directory: directory_path,
106
+ tools_run: bundle.tools_run,
107
+ compliance: frameworkResults.map(r => ({
108
+ framework: r.framework,
109
+ summary: r.summary || null,
110
+ error: r.error || undefined,
111
+ })),
112
+ ...(savedPath && { evidence_saved: savedPath }),
113
+ };
114
+ break;
115
+
116
+ case 'full':
117
+ response = {
118
+ evidence: bundle,
119
+ compliance: frameworkResults,
120
+ ...(savedPath && { evidence_saved: savedPath }),
121
+ };
122
+ break;
123
+
124
+ case 'compact':
125
+ default:
126
+ response = {
127
+ directory: directory_path,
128
+ tools_run: bundle.tools_run,
129
+ errors: bundle.errors || undefined,
130
+ scan_summary: bundle.scan ? {
131
+ grade: bundle.scan.grade,
132
+ by_severity: bundle.scan.by_severity,
133
+ } : null,
134
+ sbom_summary: bundle.sbom ? {
135
+ component_count: bundle.sbom.component_count,
136
+ ecosystems: bundle.sbom.ecosystems,
137
+ } : null,
138
+ supply_chain: bundle.supply_chain.vulnerabilities || bundle.supply_chain.hallucinations ? {
139
+ vulnerabilities: bundle.supply_chain.vulnerabilities ? {
140
+ total: bundle.supply_chain.vulnerabilities.total,
141
+ by_severity: bundle.supply_chain.vulnerabilities.by_severity,
142
+ } : null,
143
+ hallucinations: bundle.supply_chain.hallucinations ? {
144
+ hallucinated_count: bundle.supply_chain.hallucinations.hallucinated_count,
145
+ } : null,
146
+ drift: bundle.supply_chain.drift,
147
+ } : null,
148
+ compliance: frameworkResults,
149
+ ...(savedPath && { evidence_saved: savedPath }),
150
+ };
151
+ break;
152
+ }
153
+
154
+ return {
155
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
156
+ };
157
+ }
158
+
159
+ function error(msg) {
160
+ return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
161
+ }