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,271 @@
1
+ import { z } from 'zod';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { discoverDependencies } from '../lib/lockfile-parsers.js';
5
+ import { serialize } from '../lib/cyclonedx.js';
6
+ import { componentFromBomComponent } from '../lib/sbom-component.js';
7
+ import { queryBatch } from '../lib/osv-client.js';
8
+
9
+ export const sbomReportSchema = {
10
+ directory_path: z.string().optional().describe('Path to project root (generates fresh SBOM)'),
11
+ sbom_path: z.string().optional().describe('Path to existing SBOM file'),
12
+ format: z.enum(['html', 'json']).optional().describe('Report format (default: html)'),
13
+ include_vulnerabilities: z.boolean().optional().describe('Include vulnerability scan in report (default: true)'),
14
+ output_path: z.string().optional().describe('Path to write report file. Absent = no write.'),
15
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
16
+ };
17
+
18
+ export async function sbomExportReport({ directory_path, sbom_path, format, include_vulnerabilities, output_path, verbosity }) {
19
+ // Enforce exactly one of directory_path or sbom_path
20
+ if (directory_path && sbom_path) {
21
+ return error('Provide either directory_path or sbom_path, not both.');
22
+ }
23
+ if (!directory_path && !sbom_path) {
24
+ return error('Provide either directory_path or sbom_path.');
25
+ }
26
+
27
+ const outputFormat = format || 'html';
28
+ const includeVulns = include_vulnerabilities !== false;
29
+
30
+ // Load or generate SBOM
31
+ let bom;
32
+ let components;
33
+ let scanComponents;
34
+ if (sbom_path) {
35
+ if (!existsSync(sbom_path)) return error(`SBOM file not found: ${sbom_path}`);
36
+ bom = JSON.parse(readFileSync(sbom_path, 'utf-8'));
37
+ components = bom.components || [];
38
+ scanComponents = components.map(componentFromBomComponent);
39
+ } else {
40
+ if (!existsSync(directory_path)) return error(`Directory not found: ${directory_path}`);
41
+ const componentList = discoverDependencies(directory_path);
42
+ bom = serialize(componentList);
43
+ components = bom.components || [];
44
+ scanComponents = componentList.components;
45
+ }
46
+
47
+ // Vulnerability enrichment
48
+ let vulnerabilities = [];
49
+ let vulnSummary = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
50
+ if (includeVulns && scanComponents.length > 0) {
51
+ const cacheDir = directory_path ? join(directory_path, '.scanner', 'cache', 'vuln') : null;
52
+ const results = await queryBatch(scanComponents, { cacheDir });
53
+ for (const vulns of results.values()) {
54
+ for (const v of vulns) {
55
+ vulnerabilities.push(v);
56
+ const level = v.ratings?.[0]?.severity || 'unknown';
57
+ vulnSummary[level] = (vulnSummary[level] || 0) + 1;
58
+ }
59
+ }
60
+ }
61
+
62
+ const projectName = bom.metadata?.component?.name || 'unknown';
63
+ const projectVersion = bom.metadata?.component?.version || '0.0.0';
64
+
65
+ if (outputFormat === 'json') {
66
+ const response = {
67
+ project: projectName,
68
+ version: projectVersion,
69
+ total_components: components.length,
70
+ total_vulnerabilities: vulnerabilities.length,
71
+ by_severity: vulnSummary,
72
+ bom,
73
+ vulnerabilities,
74
+ };
75
+ if (output_path) {
76
+ writeReport(output_path, JSON.stringify(response, null, 2));
77
+ }
78
+ return {
79
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
80
+ };
81
+ }
82
+
83
+ // Generate HTML report
84
+ const html = generateSbomHtml(projectName, projectVersion, components, vulnerabilities, vulnSummary);
85
+
86
+ if (output_path) {
87
+ writeReport(output_path, html);
88
+ }
89
+
90
+ const level = verbosity || 'compact';
91
+ let response;
92
+ switch (level) {
93
+ case 'minimal':
94
+ response = {
95
+ project: projectName,
96
+ total_components: components.length,
97
+ total_vulnerabilities: vulnerabilities.length,
98
+ by_severity: vulnSummary,
99
+ ...(output_path && { saved_to: output_path }),
100
+ };
101
+ break;
102
+ case 'full':
103
+ response = {
104
+ project: projectName,
105
+ total_components: components.length,
106
+ total_vulnerabilities: vulnerabilities.length,
107
+ by_severity: vulnSummary,
108
+ html,
109
+ ...(output_path && { saved_to: output_path }),
110
+ };
111
+ break;
112
+ case 'compact':
113
+ default:
114
+ response = {
115
+ project: projectName,
116
+ total_components: components.length,
117
+ total_vulnerabilities: vulnerabilities.length,
118
+ by_severity: vulnSummary,
119
+ top_vulnerabilities: vulnerabilities.slice(0, 10).map(v => ({
120
+ id: v.id,
121
+ severity: v.ratings?.[0]?.severity || 'unknown',
122
+ description: (v.description || '').substring(0, 150),
123
+ })),
124
+ ...(output_path && { saved_to: output_path }),
125
+ };
126
+ break;
127
+ }
128
+
129
+ return {
130
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
131
+ };
132
+ }
133
+
134
+ function extractEcosystem(component) {
135
+ if (component.properties) {
136
+ const eco = component.properties.find(p => p.name === 'cdx:ecosystem');
137
+ if (eco) return eco.value;
138
+ }
139
+ if (component.purl) {
140
+ const match = component.purl.match(/^pkg:([^/]+)/);
141
+ if (match) {
142
+ const purlToEco = { npm: 'npm', pypi: 'pypi', gem: 'rubygems', cargo: 'crates', golang: 'go', maven: 'java', pub: 'dart' };
143
+ return purlToEco[match[1]] || match[1];
144
+ }
145
+ }
146
+ return 'unknown';
147
+ }
148
+
149
+ function escapeHtml(str) {
150
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
151
+ }
152
+
153
+ function writeReport(filePath, content) {
154
+ const dir = filePath.substring(0, filePath.lastIndexOf('/'));
155
+ if (dir && !existsSync(dir)) {
156
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
157
+ }
158
+ writeFileSync(filePath, content, { mode: 0o600 });
159
+ }
160
+
161
+ function generateSbomHtml(projectName, projectVersion, components, vulnerabilities, vulnSummary) {
162
+ const totalVulns = vulnerabilities.length;
163
+ const severityColors = { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6', unknown: '#9ca3af' };
164
+
165
+ // Group components by ecosystem
166
+ const byEcosystem = {};
167
+ for (const c of components) {
168
+ const eco = extractEcosystem(c);
169
+ if (!byEcosystem[eco]) byEcosystem[eco] = [];
170
+ byEcosystem[eco].push(c);
171
+ }
172
+
173
+ // Build severity bar chart SVG
174
+ const maxCount = Math.max(vulnSummary.critical, vulnSummary.high, vulnSummary.medium, vulnSummary.low, 1);
175
+ const barWidth = 200;
176
+ const barHeight = 24;
177
+ const gap = 8;
178
+ const labelWidth = 70;
179
+ const bars = [
180
+ { label: 'Critical', count: vulnSummary.critical, color: severityColors.critical },
181
+ { label: 'High', count: vulnSummary.high, color: severityColors.high },
182
+ { label: 'Medium', count: vulnSummary.medium, color: severityColors.medium },
183
+ { label: 'Low', count: vulnSummary.low, color: severityColors.low },
184
+ ];
185
+ const svgH = bars.length * (barHeight + gap) + gap;
186
+ let svg = `<svg width="340" height="${svgH}" xmlns="http://www.w3.org/2000/svg" style="font-family:sans-serif;font-size:13px;">`;
187
+ bars.forEach((b, i) => {
188
+ const y = gap + i * (barHeight + gap);
189
+ const w = maxCount > 0 ? (b.count / maxCount) * barWidth : 0;
190
+ svg += `<text x="0" y="${y + 17}" fill="#333">${b.label}</text>`;
191
+ svg += `<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" rx="4" fill="${b.color}" />`;
192
+ svg += `<text x="${labelWidth + w + 8}" y="${y + 17}" fill="#333">${b.count}</text>`;
193
+ });
194
+ svg += '</svg>';
195
+
196
+ // Build component table
197
+ let componentRows = '';
198
+ for (const c of components.slice(0, 200)) {
199
+ const eco = extractEcosystem(c);
200
+ const scope = c.scope || 'required';
201
+ componentRows += `<tr><td>${escapeHtml(c.name)}</td><td>${escapeHtml(c.version)}</td><td>${escapeHtml(eco)}</td><td>${scope}</td></tr>`;
202
+ }
203
+
204
+ // Build vulnerability table
205
+ let vulnRows = '';
206
+ for (const v of vulnerabilities.slice(0, 100)) {
207
+ const severity = v.ratings?.[0]?.severity || 'unknown';
208
+ const color = severityColors[severity] || '#9ca3af';
209
+ vulnRows += `<tr><td><span style="color:${color};font-weight:600">${escapeHtml(severity.toUpperCase())}</span></td><td>${escapeHtml(v.id)}</td><td>${escapeHtml((v.description || '').substring(0, 200))}</td><td>${escapeHtml(v.recommendation || '')}</td></tr>`;
210
+ }
211
+
212
+ return `<!DOCTYPE html>
213
+ <html lang="en">
214
+ <head>
215
+ <meta charset="UTF-8">
216
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
217
+ <title>SBOM Report — ${escapeHtml(projectName)}</title>
218
+ <style>
219
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 20px; background: #f8fafc; color: #1e293b; }
220
+ .header { background: linear-gradient(135deg, #1e293b 0%, #334155 100%); color: white; padding: 24px 32px; border-radius: 12px; margin-bottom: 24px; }
221
+ .header h1 { margin: 0 0 8px; font-size: 24px; }
222
+ .header p { margin: 0; opacity: 0.8; }
223
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
224
+ .card { background: white; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center; }
225
+ .card .value { font-size: 28px; font-weight: 700; }
226
+ .card .label { font-size: 12px; color: #64748b; margin-top: 4px; }
227
+ .section { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
228
+ .section h2 { margin-top: 0; font-size: 18px; }
229
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
230
+ th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
231
+ th { background: #f8fafc; font-weight: 600; color: #475569; }
232
+ .footer { text-align: center; color: #94a3b8; font-size: 12px; margin-top: 24px; }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <div class="header">
237
+ <h1>SBOM Report</h1>
238
+ <p>${escapeHtml(projectName)} v${escapeHtml(projectVersion)} — Generated ${new Date().toISOString().split('T')[0]}</p>
239
+ </div>
240
+
241
+ <div class="cards">
242
+ <div class="card"><div class="value">${components.length}</div><div class="label">Components</div></div>
243
+ <div class="card"><div class="value" style="color:${totalVulns > 0 ? '#ef4444' : '#22c55e'}">${totalVulns}</div><div class="label">Vulnerabilities</div></div>
244
+ <div class="card"><div class="value" style="color:#ef4444">${vulnSummary.critical}</div><div class="label">Critical</div></div>
245
+ <div class="card"><div class="value" style="color:#f97316">${vulnSummary.high}</div><div class="label">High</div></div>
246
+ <div class="card"><div class="value">${Object.keys(byEcosystem).length}</div><div class="label">Ecosystems</div></div>
247
+ </div>
248
+
249
+ ${totalVulns > 0 ? `<div class="section"><h2>Vulnerability Severity</h2>${svg}</div>` : ''}
250
+
251
+ ${vulnRows ? `<div class="section"><h2>Vulnerabilities (${vulnerabilities.length})</h2><table><thead><tr><th>Severity</th><th>ID</th><th>Description</th><th>Fix</th></tr></thead><tbody>${vulnRows}</tbody></table>${vulnerabilities.length > 100 ? '<p style="color:#94a3b8;font-size:12px">Showing first 100 of ' + vulnerabilities.length + '</p>' : ''}</div>` : ''}
252
+
253
+ <div class="section">
254
+ <h2>Component Inventory (${components.length})</h2>
255
+ <table>
256
+ <thead><tr><th>Name</th><th>Version</th><th>Ecosystem</th><th>Scope</th></tr></thead>
257
+ <tbody>${componentRows}</tbody>
258
+ </table>
259
+ ${components.length > 200 ? '<p style="color:#94a3b8;font-size:12px">Showing first 200 of ' + components.length + '</p>' : ''}
260
+ </div>
261
+
262
+ <div class="footer">
263
+ Generated by ProofLayer agent-security-scanner-mcp — CycloneDX v1.5 — ${new Date().toISOString()}
264
+ </div>
265
+ </body>
266
+ </html>`;
267
+ }
268
+
269
+ function error(msg) {
270
+ return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
271
+ }
@@ -0,0 +1,121 @@
1
+ import { z } from 'zod';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { discoverDependencies } from '../lib/lockfile-parsers.js';
5
+ import { componentFromBomComponent } from '../lib/sbom-component.js';
6
+ import { queryBatch } from '../lib/osv-client.js';
7
+
8
+ export const sbomVulnerabilitiesSchema = {
9
+ directory_path: z.string().optional().describe('Path to project root (generates fresh SBOM)'),
10
+ sbom_path: z.string().optional().describe('Path to existing SBOM file'),
11
+ severity_threshold: z.enum(['critical', 'high', 'medium', 'low']).optional()
12
+ .describe('Only report vulnerabilities at or above this severity (default: low)'),
13
+ verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
14
+ };
15
+
16
+ export async function sbomScanVulnerabilities({ directory_path, sbom_path, severity_threshold, verbosity }) {
17
+ // Enforce exactly one of directory_path or sbom_path
18
+ if (directory_path && sbom_path) {
19
+ return error('Provide either directory_path or sbom_path, not both.');
20
+ }
21
+ if (!directory_path && !sbom_path) {
22
+ return error('Provide either directory_path or sbom_path.');
23
+ }
24
+
25
+ // Load or generate components
26
+ let components;
27
+ let projectName = 'unknown';
28
+ if (sbom_path) {
29
+ if (!existsSync(sbom_path)) return error(`SBOM file not found: ${sbom_path}`);
30
+ let bom;
31
+ try {
32
+ bom = JSON.parse(readFileSync(sbom_path, 'utf-8'));
33
+ } catch {
34
+ return error(`Failed to parse SBOM: ${sbom_path}`);
35
+ }
36
+ components = (bom.components || []).map(componentFromBomComponent);
37
+ projectName = bom.metadata?.component?.name || 'unknown';
38
+ } else {
39
+ if (!existsSync(directory_path)) return error(`Directory not found: ${directory_path}`);
40
+ const componentList = discoverDependencies(directory_path);
41
+ components = componentList.components;
42
+ projectName = componentList.metadata.name;
43
+ }
44
+
45
+ // Query OSV — pass normalized components to preserve purl and namespace
46
+ const cacheDir = directory_path ? join(directory_path, '.scanner', 'cache', 'vuln') : null;
47
+ const results = await queryBatch(components, { cacheDir });
48
+
49
+ // Flatten and apply severity filter
50
+ const threshold = severity_threshold || 'low';
51
+ const severityOrder = { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 };
52
+ const minLevel = severityOrder[threshold] || 0;
53
+
54
+ const allVulns = [];
55
+ const affectedPackages = new Set();
56
+ for (const [key, vulns] of results) {
57
+ for (const vuln of vulns) {
58
+ const level = vuln.ratings?.[0]?.severity || 'unknown';
59
+ if ((severityOrder[level] || 0) >= minLevel) {
60
+ allVulns.push(vuln);
61
+ affectedPackages.add(key);
62
+ }
63
+ }
64
+ }
65
+
66
+ // Count by severity
67
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
68
+ for (const v of allVulns) {
69
+ const level = v.ratings?.[0]?.severity || 'unknown';
70
+ bySeverity[level] = (bySeverity[level] || 0) + 1;
71
+ }
72
+
73
+ const level = verbosity || 'compact';
74
+ let response;
75
+ switch (level) {
76
+ case 'minimal':
77
+ response = {
78
+ project: projectName,
79
+ total_components: components.length,
80
+ total_vulnerabilities: allVulns.length,
81
+ affected_packages: affectedPackages.size,
82
+ by_severity: bySeverity,
83
+ };
84
+ break;
85
+ case 'full':
86
+ response = {
87
+ project: projectName,
88
+ total_components: components.length,
89
+ total_vulnerabilities: allVulns.length,
90
+ affected_packages: [...affectedPackages],
91
+ by_severity: bySeverity,
92
+ vulnerabilities: allVulns,
93
+ };
94
+ break;
95
+ case 'compact':
96
+ default:
97
+ response = {
98
+ project: projectName,
99
+ total_components: components.length,
100
+ total_vulnerabilities: allVulns.length,
101
+ affected_packages: [...affectedPackages],
102
+ by_severity: bySeverity,
103
+ vulnerabilities: allVulns.map(v => ({
104
+ id: v.id,
105
+ severity: v.ratings?.[0]?.severity || 'unknown',
106
+ score: v.ratings?.[0]?.score || 0,
107
+ description: v.description?.substring(0, 200) || '',
108
+ recommendation: v.recommendation || '',
109
+ })),
110
+ };
111
+ break;
112
+ }
113
+
114
+ return {
115
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
116
+ };
117
+ }
118
+
119
+ function error(msg) {
120
+ return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
121
+ }