agent-security-scanner-mcp 3.7.0 → 3.9.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.
- package/README.md +156 -10
- package/analyzer.py +22 -5
- package/cross_file_analyzer.py +216 -0
- package/daemon.py +179 -0
- package/index.js +279 -3
- package/package.json +19 -5
- package/packages/npm-bloom.json +1 -0
- package/pattern_matcher.py +1 -0
- package/regex_fallback.py +199 -1
- package/requirements.txt +1 -0
- package/rules/prompt-injection.security.yaml +273 -41
- package/scripts/postinstall.js +60 -0
- package/skills/openclaw/SKILL.md +102 -0
- package/skills/security-review.md +139 -0
- package/skills/security-scan-batch.md +107 -0
- package/skills/security-scanner.md +76 -0
- package/src/cli/doctor.js +29 -1
- package/src/cli/init.js +93 -0
- package/src/cli/report.js +444 -0
- package/src/config.js +247 -0
- package/src/context.js +289 -0
- package/src/daemon-client.js +233 -0
- package/src/dedup.js +129 -0
- package/src/fix-patterns.js +76 -19
- package/src/history.js +159 -0
- package/src/tools/check-package.js +36 -12
- package/src/tools/fix-security.js +32 -5
- package/src/tools/import-resolver.js +249 -0
- package/src/tools/project-context.js +365 -0
- package/src/tools/scan-action.js +489 -0
- package/src/tools/scan-mcp.js +922 -0
- package/src/tools/scan-project.js +16 -4
- package/src/tools/scan-prompt.js +292 -527
- package/src/tools/scan-security.js +37 -6
- package/src/typosquat.js +210 -0
- package/src/utils.js +215 -8
- package/templates/gitlab-ci-security.yml +225 -0
- package/templates/pre-commit-hook.sh +233 -0
- package/src/tools/garak-bridge.js +0 -209
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// src/cli/report.js — Generate HTML (or JSON) security report for a project.
|
|
2
|
+
|
|
3
|
+
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { resolve, join } from 'path';
|
|
5
|
+
import { scanProject } from '../tools/scan-project.js';
|
|
6
|
+
import { saveResult, loadHistory, getTrends, diffResults } from '../history.js';
|
|
7
|
+
|
|
8
|
+
// Grade color mapping
|
|
9
|
+
const GRADE_COLORS = {
|
|
10
|
+
A: '#22c55e', // green
|
|
11
|
+
B: '#84cc16', // lime
|
|
12
|
+
C: '#eab308', // yellow
|
|
13
|
+
D: '#f97316', // orange
|
|
14
|
+
F: '#ef4444', // red
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Severity color mapping
|
|
18
|
+
const SEVERITY_COLORS = {
|
|
19
|
+
error: '#ef4444',
|
|
20
|
+
warning: '#f97316',
|
|
21
|
+
info: '#3b82f6',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function escapeHtml(str) {
|
|
25
|
+
return String(str)
|
|
26
|
+
.replace(/&/g, '&')
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build SVG bar chart for severity breakdown
|
|
33
|
+
function buildSeverityChart(bySeverity) {
|
|
34
|
+
const error = bySeverity.error || 0;
|
|
35
|
+
const warning = bySeverity.warning || 0;
|
|
36
|
+
const info = bySeverity.info || 0;
|
|
37
|
+
const max = Math.max(error, warning, info, 1);
|
|
38
|
+
|
|
39
|
+
const barWidth = 200;
|
|
40
|
+
const barHeight = 24;
|
|
41
|
+
const gap = 8;
|
|
42
|
+
const labelWidth = 70;
|
|
43
|
+
const countWidth = 40;
|
|
44
|
+
|
|
45
|
+
const bars = [
|
|
46
|
+
{ label: 'Critical', count: error, color: SEVERITY_COLORS.error },
|
|
47
|
+
{ label: 'Warning', count: warning, color: SEVERITY_COLORS.warning },
|
|
48
|
+
{ label: 'Info', count: info, color: SEVERITY_COLORS.info },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const svgHeight = bars.length * (barHeight + gap) + gap;
|
|
52
|
+
const svgWidth = labelWidth + barWidth + countWidth + 20;
|
|
53
|
+
|
|
54
|
+
let svg = `<svg width="${svgWidth}" height="${svgHeight}" xmlns="http://www.w3.org/2000/svg" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px;">`;
|
|
55
|
+
|
|
56
|
+
bars.forEach((bar, i) => {
|
|
57
|
+
const y = gap + i * (barHeight + gap);
|
|
58
|
+
const width = max > 0 ? Math.max((bar.count / max) * barWidth, bar.count > 0 ? 4 : 0) : 0;
|
|
59
|
+
|
|
60
|
+
svg += `<text x="0" y="${y + barHeight / 2 + 4}" fill="#374151">${bar.label}</text>`;
|
|
61
|
+
svg += `<rect x="${labelWidth}" y="${y}" width="${width}" height="${barHeight}" rx="4" fill="${bar.color}" opacity="0.85"/>`;
|
|
62
|
+
svg += `<text x="${labelWidth + barWidth + 8}" y="${y + barHeight / 2 + 4}" fill="#374151" font-weight="600">${bar.count}</text>`;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
svg += '</svg>';
|
|
66
|
+
return svg;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Build SVG trend sparkline for grades over time
|
|
70
|
+
function buildGradeTrend(grades) {
|
|
71
|
+
if (grades.length < 2) return '';
|
|
72
|
+
|
|
73
|
+
const gradeValues = { A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
74
|
+
const width = 300;
|
|
75
|
+
const height = 80;
|
|
76
|
+
const padding = 20;
|
|
77
|
+
const plotWidth = width - padding * 2;
|
|
78
|
+
const plotHeight = height - padding * 2;
|
|
79
|
+
|
|
80
|
+
const points = grades.map((g, i) => {
|
|
81
|
+
const x = padding + (i / (grades.length - 1)) * plotWidth;
|
|
82
|
+
const y = padding + (1 - (gradeValues[g.grade] || 0) / 4) * plotHeight;
|
|
83
|
+
return { x, y, grade: g.grade, date: g.date };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
let svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 10px;">`;
|
|
87
|
+
|
|
88
|
+
// Grid lines for each grade
|
|
89
|
+
['A', 'B', 'C', 'D', 'F'].forEach(grade => {
|
|
90
|
+
const y = padding + (1 - (gradeValues[grade]) / 4) * plotHeight;
|
|
91
|
+
svg += `<line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/>`;
|
|
92
|
+
svg += `<text x="${padding - 14}" y="${y + 3}" fill="#9ca3af" font-size="9">${grade}</text>`;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Line connecting points
|
|
96
|
+
const pathData = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
|
97
|
+
svg += `<path d="${pathData}" fill="none" stroke="#6366f1" stroke-width="2"/>`;
|
|
98
|
+
|
|
99
|
+
// Dots
|
|
100
|
+
points.forEach(p => {
|
|
101
|
+
const color = GRADE_COLORS[p.grade] || '#6b7280';
|
|
102
|
+
svg += `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${color}"/>`;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
svg += '</svg>';
|
|
106
|
+
return svg;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Build category breakdown table
|
|
110
|
+
function buildCategoryTable(byCategory) {
|
|
111
|
+
if (!byCategory || Object.keys(byCategory).length === 0) return '';
|
|
112
|
+
|
|
113
|
+
const sorted = Object.entries(byCategory).sort((a, b) => b[1] - a[1]);
|
|
114
|
+
|
|
115
|
+
let html = `<table style="border-collapse: collapse; width: 100%; margin-top: 8px;">
|
|
116
|
+
<thead><tr style="border-bottom: 2px solid #e5e7eb;">
|
|
117
|
+
<th style="text-align: left; padding: 8px 12px; color: #374151;">Category</th>
|
|
118
|
+
<th style="text-align: right; padding: 8px 12px; color: #374151;">Issues</th>
|
|
119
|
+
</tr></thead><tbody>`;
|
|
120
|
+
|
|
121
|
+
for (const [category, count] of sorted) {
|
|
122
|
+
html += `<tr style="border-bottom: 1px solid #f3f4f6;">
|
|
123
|
+
<td style="padding: 6px 12px; color: #4b5563;">${escapeHtml(category)}</td>
|
|
124
|
+
<td style="padding: 6px 12px; text-align: right; font-weight: 600; color: #374151;">${count}</td>
|
|
125
|
+
</tr>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
html += '</tbody></table>';
|
|
129
|
+
return html;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build top offending files table
|
|
133
|
+
function buildFileTable(byFile) {
|
|
134
|
+
if (!byFile || Object.keys(byFile).length === 0) return '';
|
|
135
|
+
|
|
136
|
+
const sorted = Object.entries(byFile)
|
|
137
|
+
.filter(([, count]) => count > 0)
|
|
138
|
+
.sort((a, b) => b[1] - a[1])
|
|
139
|
+
.slice(0, 15);
|
|
140
|
+
|
|
141
|
+
if (sorted.length === 0) return '';
|
|
142
|
+
|
|
143
|
+
let html = `<table style="border-collapse: collapse; width: 100%; margin-top: 8px;">
|
|
144
|
+
<thead><tr style="border-bottom: 2px solid #e5e7eb;">
|
|
145
|
+
<th style="text-align: left; padding: 8px 12px; color: #374151;">File</th>
|
|
146
|
+
<th style="text-align: right; padding: 8px 12px; color: #374151;">Issues</th>
|
|
147
|
+
</tr></thead><tbody>`;
|
|
148
|
+
|
|
149
|
+
for (const [file, count] of sorted) {
|
|
150
|
+
html += `<tr style="border-bottom: 1px solid #f3f4f6;">
|
|
151
|
+
<td style="padding: 6px 12px; color: #4b5563; font-family: monospace; font-size: 13px;">${escapeHtml(file)}</td>
|
|
152
|
+
<td style="padding: 6px 12px; text-align: right; font-weight: 600; color: #374151;">${count}</td>
|
|
153
|
+
</tr>`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
html += '</tbody></table>';
|
|
157
|
+
return html;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Build diff section showing new/fixed issues
|
|
161
|
+
function buildDiffSection(diff) {
|
|
162
|
+
if (!diff) return '';
|
|
163
|
+
|
|
164
|
+
let html = '<div style="margin-top: 24px;">';
|
|
165
|
+
html += '<h2 style="color: #1f2937; font-size: 18px; margin-bottom: 12px;">Changes Since Last Scan</h2>';
|
|
166
|
+
|
|
167
|
+
html += `<div style="display: flex; gap: 24px; margin-bottom: 16px;">`;
|
|
168
|
+
html += `<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 12px 20px; flex: 1; text-align: center;">
|
|
169
|
+
<div style="font-size: 24px; font-weight: 700; color: #ef4444;">${diff.new_issues.length}</div>
|
|
170
|
+
<div style="font-size: 13px; color: #991b1b;">New Issues</div>
|
|
171
|
+
</div>`;
|
|
172
|
+
html += `<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 12px 20px; flex: 1; text-align: center;">
|
|
173
|
+
<div style="font-size: 24px; font-weight: 700; color: #22c55e;">${diff.fixed_issues.length}</div>
|
|
174
|
+
<div style="font-size: 13px; color: #166534;">Fixed Issues</div>
|
|
175
|
+
</div>`;
|
|
176
|
+
html += `<div style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 20px; flex: 1; text-align: center;">
|
|
177
|
+
<div style="font-size: 24px; font-weight: 700; color: #6b7280;">${diff.unchanged}</div>
|
|
178
|
+
<div style="font-size: 13px; color: #4b5563;">Unchanged</div>
|
|
179
|
+
</div>`;
|
|
180
|
+
html += '</div>';
|
|
181
|
+
|
|
182
|
+
// List new issues
|
|
183
|
+
if (diff.new_issues.length > 0) {
|
|
184
|
+
html += '<h3 style="color: #991b1b; font-size: 15px; margin: 12px 0 8px;">New Issues</h3>';
|
|
185
|
+
html += '<ul style="list-style: none; padding: 0; margin: 0;">';
|
|
186
|
+
for (const issue of diff.new_issues.slice(0, 20)) {
|
|
187
|
+
const sevColor = SEVERITY_COLORS[issue.severity] || '#6b7280';
|
|
188
|
+
html += `<li style="padding: 4px 0; font-size: 13px; color: #374151;">
|
|
189
|
+
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${sevColor}; margin-right: 8px;"></span>
|
|
190
|
+
<strong>${escapeHtml(issue.ruleId || '')}</strong> in <code style="background: #f3f4f6; padding: 1px 4px; border-radius: 3px;">${escapeHtml(issue.file || '')}</code> line ${(issue.line || 0) + 1}
|
|
191
|
+
</li>`;
|
|
192
|
+
}
|
|
193
|
+
html += '</ul>';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// List fixed issues
|
|
197
|
+
if (diff.fixed_issues.length > 0) {
|
|
198
|
+
html += '<h3 style="color: #166534; font-size: 15px; margin: 12px 0 8px;">Fixed Issues</h3>';
|
|
199
|
+
html += '<ul style="list-style: none; padding: 0; margin: 0;">';
|
|
200
|
+
for (const issue of diff.fixed_issues.slice(0, 20)) {
|
|
201
|
+
html += `<li style="padding: 4px 0; font-size: 13px; color: #374151;">
|
|
202
|
+
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; margin-right: 8px;"></span>
|
|
203
|
+
<strong>${escapeHtml(issue.ruleId || '')}</strong> in <code style="background: #f3f4f6; padding: 1px 4px; border-radius: 3px;">${escapeHtml(issue.file || '')}</code> line ${(issue.line || 0) + 1}
|
|
204
|
+
</li>`;
|
|
205
|
+
}
|
|
206
|
+
html += '</ul>';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
html += '</div>';
|
|
210
|
+
return html;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build issues detail table
|
|
214
|
+
function buildIssuesTable(issues) {
|
|
215
|
+
if (!issues || issues.length === 0) return '';
|
|
216
|
+
|
|
217
|
+
// Sort by severity: error first, then warning, then info
|
|
218
|
+
const order = { error: 0, warning: 1, info: 2 };
|
|
219
|
+
const sorted = [...issues].sort((a, b) => (order[a.severity] ?? 2) - (order[b.severity] ?? 2));
|
|
220
|
+
const shown = sorted.slice(0, 100);
|
|
221
|
+
|
|
222
|
+
let html = `<table style="border-collapse: collapse; width: 100%; margin-top: 8px; font-size: 13px;">
|
|
223
|
+
<thead><tr style="border-bottom: 2px solid #e5e7eb;">
|
|
224
|
+
<th style="text-align: left; padding: 8px 8px; color: #374151;">Severity</th>
|
|
225
|
+
<th style="text-align: left; padding: 8px 8px; color: #374151;">Rule</th>
|
|
226
|
+
<th style="text-align: left; padding: 8px 8px; color: #374151;">File</th>
|
|
227
|
+
<th style="text-align: right; padding: 8px 8px; color: #374151;">Line</th>
|
|
228
|
+
<th style="text-align: left; padding: 8px 8px; color: #374151;">Message</th>
|
|
229
|
+
</tr></thead><tbody>`;
|
|
230
|
+
|
|
231
|
+
for (const issue of shown) {
|
|
232
|
+
const sevColor = SEVERITY_COLORS[issue.severity] || '#6b7280';
|
|
233
|
+
const sevLabel = issue.severity === 'error' ? 'CRITICAL' : issue.severity === 'warning' ? 'WARNING' : 'INFO';
|
|
234
|
+
html += `<tr style="border-bottom: 1px solid #f3f4f6;">
|
|
235
|
+
<td style="padding: 6px 8px;"><span style="display: inline-block; padding: 2px 8px; border-radius: 4px; background: ${sevColor}; color: white; font-size: 11px; font-weight: 600;">${sevLabel}</span></td>
|
|
236
|
+
<td style="padding: 6px 8px; font-family: monospace; color: #4b5563;">${escapeHtml(issue.ruleId || '')}</td>
|
|
237
|
+
<td style="padding: 6px 8px; font-family: monospace; color: #4b5563; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(issue.file || '')}</td>
|
|
238
|
+
<td style="padding: 6px 8px; text-align: right; color: #6b7280;">${(issue.line || 0) + 1}</td>
|
|
239
|
+
<td style="padding: 6px 8px; color: #374151; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(issue.message || '')}</td>
|
|
240
|
+
</tr>`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
html += '</tbody></table>';
|
|
244
|
+
|
|
245
|
+
if (issues.length > 100) {
|
|
246
|
+
html += `<p style="color: #6b7280; font-size: 13px; margin-top: 8px;">Showing 100 of ${issues.length} issues.</p>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return html;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Generate the full HTML report
|
|
253
|
+
function generateHtml(scanResult, history, diff) {
|
|
254
|
+
const grade = scanResult.grade || 'A';
|
|
255
|
+
const gradeColor = GRADE_COLORS[grade] || '#6b7280';
|
|
256
|
+
const bySeverity = scanResult.by_severity || { error: 0, warning: 0, info: 0 };
|
|
257
|
+
const byCategory = scanResult.by_category || {};
|
|
258
|
+
const byFile = scanResult.by_file || {};
|
|
259
|
+
const issues = scanResult.issues || [];
|
|
260
|
+
const filesScanned = scanResult.files_scanned || 0;
|
|
261
|
+
const issuesCount = scanResult.issues_count || scanResult.total || 0;
|
|
262
|
+
const directory = scanResult.directory || '';
|
|
263
|
+
const timestamp = new Date().toISOString();
|
|
264
|
+
|
|
265
|
+
// Trend data
|
|
266
|
+
let trendSection = '';
|
|
267
|
+
if (history.grades && history.grades.length >= 2) {
|
|
268
|
+
trendSection = `
|
|
269
|
+
<div style="margin-top: 24px;">
|
|
270
|
+
<h2 style="color: #1f2937; font-size: 18px; margin-bottom: 12px;">Grade Trend</h2>
|
|
271
|
+
${buildGradeTrend(history.grades)}
|
|
272
|
+
</div>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Diff section
|
|
276
|
+
const diffSection = diff ? buildDiffSection(diff) : '';
|
|
277
|
+
|
|
278
|
+
return `<!DOCTYPE html>
|
|
279
|
+
<html lang="en">
|
|
280
|
+
<head>
|
|
281
|
+
<meta charset="UTF-8">
|
|
282
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
283
|
+
<title>Security Report - ${escapeHtml(directory)}</title>
|
|
284
|
+
</head>
|
|
285
|
+
<body style="margin: 0; padding: 0; background: #f9fafb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1f2937;">
|
|
286
|
+
<div style="max-width: 900px; margin: 0 auto; padding: 32px 24px;">
|
|
287
|
+
|
|
288
|
+
<!-- Header -->
|
|
289
|
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px;">
|
|
290
|
+
<div>
|
|
291
|
+
<h1 style="margin: 0 0 4px; font-size: 24px; color: #111827;">Security Report</h1>
|
|
292
|
+
<p style="margin: 0; font-size: 14px; color: #6b7280;">${escapeHtml(directory)}</p>
|
|
293
|
+
<p style="margin: 4px 0 0; font-size: 12px; color: #9ca3af;">Generated ${timestamp}</p>
|
|
294
|
+
</div>
|
|
295
|
+
<div style="text-align: center; background: white; border: 3px solid ${gradeColor}; border-radius: 16px; width: 80px; height: 80px; display: flex; flex-direction: column; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
296
|
+
<div style="font-size: 36px; font-weight: 800; color: ${gradeColor}; line-height: 1;">${grade}</div>
|
|
297
|
+
<div style="font-size: 10px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px;">Grade</div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<!-- Summary Cards -->
|
|
302
|
+
<div style="display: flex; gap: 16px; margin-bottom: 24px;">
|
|
303
|
+
<div style="background: white; border-radius: 12px; padding: 16px 20px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
304
|
+
<div style="font-size: 28px; font-weight: 700; color: #111827;">${filesScanned}</div>
|
|
305
|
+
<div style="font-size: 13px; color: #6b7280;">Files Scanned</div>
|
|
306
|
+
</div>
|
|
307
|
+
<div style="background: white; border-radius: 12px; padding: 16px 20px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
308
|
+
<div style="font-size: 28px; font-weight: 700; color: ${issuesCount > 0 ? '#ef4444' : '#22c55e'};">${issuesCount}</div>
|
|
309
|
+
<div style="font-size: 13px; color: #6b7280;">Total Issues</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div style="background: white; border-radius: 12px; padding: 16px 20px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
312
|
+
<div style="font-size: 28px; font-weight: 700; color: #ef4444;">${bySeverity.error || 0}</div>
|
|
313
|
+
<div style="font-size: 13px; color: #6b7280;">Critical</div>
|
|
314
|
+
</div>
|
|
315
|
+
<div style="background: white; border-radius: 12px; padding: 16px 20px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
316
|
+
<div style="font-size: 28px; font-weight: 700; color: #f97316;">${bySeverity.warning || 0}</div>
|
|
317
|
+
<div style="font-size: 13px; color: #6b7280;">Warnings</div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<!-- Severity Chart -->
|
|
322
|
+
<div style="background: white; border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
323
|
+
<h2 style="margin: 0 0 12px; font-size: 18px; color: #1f2937;">Findings by Severity</h2>
|
|
324
|
+
${buildSeverityChart(bySeverity)}
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<!-- Two-column: Categories + Top Files -->
|
|
328
|
+
<div style="display: flex; gap: 24px; margin-bottom: 24px;">
|
|
329
|
+
<div style="background: white; border-radius: 12px; padding: 20px 24px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
330
|
+
<h2 style="margin: 0 0 8px; font-size: 18px; color: #1f2937;">Findings by Category</h2>
|
|
331
|
+
${buildCategoryTable(byCategory)}
|
|
332
|
+
</div>
|
|
333
|
+
<div style="background: white; border-radius: 12px; padding: 20px 24px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
334
|
+
<h2 style="margin: 0 0 8px; font-size: 18px; color: #1f2937;">Top Offending Files</h2>
|
|
335
|
+
${buildFileTable(byFile)}
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<!-- Trend Section (if history exists) -->
|
|
340
|
+
${trendSection ? `<div style="background: white; border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">${trendSection}</div>` : ''}
|
|
341
|
+
|
|
342
|
+
<!-- Diff Section (if previous scan exists) -->
|
|
343
|
+
${diffSection ? `<div style="background: white; border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">${diffSection}</div>` : ''}
|
|
344
|
+
|
|
345
|
+
<!-- Issues Detail -->
|
|
346
|
+
${issues.length > 0 ? `
|
|
347
|
+
<div style="background: white; border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
348
|
+
<h2 style="margin: 0 0 8px; font-size: 18px; color: #1f2937;">All Issues</h2>
|
|
349
|
+
${buildIssuesTable(issues)}
|
|
350
|
+
</div>` : ''}
|
|
351
|
+
|
|
352
|
+
<!-- Footer -->
|
|
353
|
+
<div style="text-align: center; padding: 16px 0; color: #9ca3af; font-size: 12px;">
|
|
354
|
+
Generated by agent-security-scanner-mcp
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
</div>
|
|
358
|
+
</body>
|
|
359
|
+
</html>`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Run the report CLI command.
|
|
364
|
+
*
|
|
365
|
+
* @param {string[]} args - CLI arguments: <directory> [--json] [--days N]
|
|
366
|
+
*/
|
|
367
|
+
export async function runReport(args) {
|
|
368
|
+
const dirArg = args.find(a => !a.startsWith('--'));
|
|
369
|
+
if (!dirArg) {
|
|
370
|
+
console.error('Usage: agent-security-scanner-mcp report <directory> [--json] [--days N]');
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const dirPath = resolve(dirArg);
|
|
375
|
+
if (!existsSync(dirPath)) {
|
|
376
|
+
console.error(` Error: Directory not found: ${dirPath}\n`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const jsonOutput = args.includes('--json');
|
|
381
|
+
const daysIdx = args.indexOf('--days');
|
|
382
|
+
const days = daysIdx !== -1 && args[daysIdx + 1] ? parseInt(args[daysIdx + 1], 10) : 90;
|
|
383
|
+
|
|
384
|
+
console.log(`\n Scanning ${dirPath}...\n`);
|
|
385
|
+
|
|
386
|
+
// Run the project scan with full verbosity to get all data
|
|
387
|
+
const result = await scanProject({
|
|
388
|
+
directory_path: dirPath,
|
|
389
|
+
verbosity: 'full',
|
|
390
|
+
});
|
|
391
|
+
const scanResult = JSON.parse(result.content[0].text);
|
|
392
|
+
|
|
393
|
+
// Save result to history
|
|
394
|
+
const savedPath = saveResult(dirPath, scanResult);
|
|
395
|
+
console.log(` Results saved to ${savedPath}`);
|
|
396
|
+
|
|
397
|
+
// Load history for trends
|
|
398
|
+
const trends = getTrends(dirPath, days);
|
|
399
|
+
|
|
400
|
+
// Load previous scan for diff (second-to-last entry, since we just saved the current one)
|
|
401
|
+
const history = loadHistory(dirPath, days);
|
|
402
|
+
let diff = null;
|
|
403
|
+
if (history.length >= 2) {
|
|
404
|
+
const previous = history[history.length - 2];
|
|
405
|
+
diff = diffResults(scanResult, previous);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (jsonOutput) {
|
|
409
|
+
// JSON output mode
|
|
410
|
+
const jsonReport = {
|
|
411
|
+
...scanResult,
|
|
412
|
+
trends,
|
|
413
|
+
diff,
|
|
414
|
+
generated_at: new Date().toISOString(),
|
|
415
|
+
};
|
|
416
|
+
console.log(JSON.stringify(jsonReport, null, 2));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Generate HTML report
|
|
421
|
+
const html = generateHtml(scanResult, trends, diff);
|
|
422
|
+
const scannerDir = join(dirPath, '.scanner');
|
|
423
|
+
mkdirSync(scannerDir, { recursive: true });
|
|
424
|
+
const reportPath = join(scannerDir, 'report.html');
|
|
425
|
+
writeFileSync(reportPath, html);
|
|
426
|
+
|
|
427
|
+
console.log(` Report written to ${reportPath}`);
|
|
428
|
+
|
|
429
|
+
// Print summary
|
|
430
|
+
const grade = scanResult.grade || 'A';
|
|
431
|
+
const total = scanResult.issues_count || scanResult.total || 0;
|
|
432
|
+
const filesScanned = scanResult.files_scanned || 0;
|
|
433
|
+
console.log(`\n Grade: ${grade} | ${total} issue(s) across ${filesScanned} file(s)`);
|
|
434
|
+
|
|
435
|
+
if (diff) {
|
|
436
|
+
const newCount = diff.new_issues.length;
|
|
437
|
+
const fixedCount = diff.fixed_issues.length;
|
|
438
|
+
if (newCount > 0 || fixedCount > 0) {
|
|
439
|
+
console.log(` Changes: +${newCount} new, -${fixedCount} fixed`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
console.log('');
|
|
444
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// .scannerrc configuration loading and filtering.
|
|
2
|
+
// Supports YAML (.scannerrc.yaml/.yml) and JSON (.scannerrc.json) project configs.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { dirname, join, resolve, sep } from 'path';
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
version: 1,
|
|
10
|
+
suppress: [],
|
|
11
|
+
exclude: ['node_modules/**', 'vendor/**', 'dist/**', '**/*.min.js'],
|
|
12
|
+
severity_threshold: 'info',
|
|
13
|
+
confidence_threshold: 'LOW',
|
|
14
|
+
policy: {
|
|
15
|
+
block_on: 'error', // severity that causes a policy failure: 'error', 'warning', 'info'
|
|
16
|
+
max_critical: null, // max allowed critical issues (null = no limit)
|
|
17
|
+
max_warning: null, // max allowed warnings (null = no limit)
|
|
18
|
+
required_grade: null, // minimum required grade: 'A', 'B', 'C', 'D', 'F' (null = no requirement)
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const SEVERITY_ORDER = { info: 0, warning: 1, error: 2 };
|
|
23
|
+
const CONFIDENCE_ORDER = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
|
24
|
+
|
|
25
|
+
// Simple glob-to-regex converter (no external dependency)
|
|
26
|
+
function globToRegex(pattern) {
|
|
27
|
+
let regex = '';
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < pattern.length) {
|
|
30
|
+
const c = pattern[i];
|
|
31
|
+
if (c === '*') {
|
|
32
|
+
if (pattern[i + 1] === '*') {
|
|
33
|
+
if (pattern[i + 2] === '/') {
|
|
34
|
+
regex += '(?:.+/)?';
|
|
35
|
+
i += 3;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
regex += '.*';
|
|
39
|
+
i += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
regex += '[^/]*';
|
|
43
|
+
} else if (c === '?') {
|
|
44
|
+
regex += '[^/]';
|
|
45
|
+
} else if (c === '{') {
|
|
46
|
+
regex += '(?:';
|
|
47
|
+
} else if (c === '}') {
|
|
48
|
+
regex += ')';
|
|
49
|
+
} else if (c === ',') {
|
|
50
|
+
regex += '|';
|
|
51
|
+
} else if ('.+^$|()[]\\'.includes(c)) {
|
|
52
|
+
regex += '\\' + c;
|
|
53
|
+
} else {
|
|
54
|
+
regex += c;
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return new RegExp('^' + regex + '$');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function matchGlob(filePath, pattern) {
|
|
62
|
+
// Normalize path separators
|
|
63
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
64
|
+
const re = globToRegex(pattern);
|
|
65
|
+
// Test against both full path and basename
|
|
66
|
+
return re.test(normalized) || re.test(normalized.split('/').pop());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Walk up from filePath to find config file
|
|
70
|
+
function findConfigFile(startPath) {
|
|
71
|
+
const names = ['.scannerrc.yaml', '.scannerrc.yml', '.scannerrc.json'];
|
|
72
|
+
let dir = resolve(dirname(startPath));
|
|
73
|
+
const root = resolve('/');
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < 50; i++) {
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const candidate = join(dir, name);
|
|
78
|
+
if (existsSync(candidate)) return candidate;
|
|
79
|
+
}
|
|
80
|
+
const parent = dirname(dir);
|
|
81
|
+
if (parent === dir || dir === root) break;
|
|
82
|
+
dir = parent;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseYaml(filePath) {
|
|
88
|
+
try {
|
|
89
|
+
const result = execFileSync('python3', [
|
|
90
|
+
'-c',
|
|
91
|
+
'import yaml,json,sys; print(json.dumps(yaml.safe_load(open(sys.argv[1]))))',
|
|
92
|
+
filePath,
|
|
93
|
+
], { encoding: 'utf-8', timeout: 5000 });
|
|
94
|
+
return JSON.parse(result.trim());
|
|
95
|
+
} catch {
|
|
96
|
+
// Fallback: try simple key-value parsing for basic configs
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function loadConfig(filePath) {
|
|
102
|
+
const configFile = findConfigFile(filePath);
|
|
103
|
+
if (!configFile) return { ...DEFAULT_CONFIG };
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
let parsed;
|
|
107
|
+
if (configFile.endsWith('.json')) {
|
|
108
|
+
parsed = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
109
|
+
} else {
|
|
110
|
+
parsed = parseYaml(configFile);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_CONFIG };
|
|
114
|
+
|
|
115
|
+
const parsedPolicy = parsed.policy && typeof parsed.policy === 'object' ? parsed.policy : {};
|
|
116
|
+
return {
|
|
117
|
+
version: parsed.version || DEFAULT_CONFIG.version,
|
|
118
|
+
suppress: Array.isArray(parsed.suppress) ? parsed.suppress : DEFAULT_CONFIG.suppress,
|
|
119
|
+
exclude: Array.isArray(parsed.exclude) ? parsed.exclude : DEFAULT_CONFIG.exclude,
|
|
120
|
+
severity_threshold: parsed.severity_threshold || DEFAULT_CONFIG.severity_threshold,
|
|
121
|
+
confidence_threshold: parsed.confidence_threshold || DEFAULT_CONFIG.confidence_threshold,
|
|
122
|
+
policy: {
|
|
123
|
+
block_on: parsedPolicy.block_on || DEFAULT_CONFIG.policy.block_on,
|
|
124
|
+
max_critical: parsedPolicy.max_critical ?? DEFAULT_CONFIG.policy.max_critical,
|
|
125
|
+
max_warning: parsedPolicy.max_warning ?? DEFAULT_CONFIG.policy.max_warning,
|
|
126
|
+
required_grade: parsedPolicy.required_grade ?? DEFAULT_CONFIG.policy.required_grade,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return { ...DEFAULT_CONFIG };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function shouldExcludeFile(filePath, config) {
|
|
135
|
+
if (!config.exclude || config.exclude.length === 0) return false;
|
|
136
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
137
|
+
return config.exclude.some(pattern => matchGlob(normalized, pattern));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function shouldSuppressRule(ruleId, filePath, config) {
|
|
141
|
+
if (!config.suppress || config.suppress.length === 0) return false;
|
|
142
|
+
|
|
143
|
+
for (const entry of config.suppress) {
|
|
144
|
+
const rule = typeof entry === 'string' ? entry : entry.rule;
|
|
145
|
+
if (!rule) continue;
|
|
146
|
+
|
|
147
|
+
// Check if rule pattern matches
|
|
148
|
+
const ruleMatches = matchGlob(ruleId, rule);
|
|
149
|
+
if (!ruleMatches) continue;
|
|
150
|
+
|
|
151
|
+
// Check path restriction if present
|
|
152
|
+
if (entry.paths && Array.isArray(entry.paths)) {
|
|
153
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
154
|
+
const pathMatches = entry.paths.some(p => matchGlob(normalized, p));
|
|
155
|
+
if (!pathMatches) continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function meetsSeverityThreshold(severity, config) {
|
|
165
|
+
const threshold = config.severity_threshold || 'info';
|
|
166
|
+
const severityLevel = SEVERITY_ORDER[severity] ?? 0;
|
|
167
|
+
const thresholdLevel = SEVERITY_ORDER[threshold] ?? 0;
|
|
168
|
+
return severityLevel >= thresholdLevel;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function meetsConfidenceThreshold(confidence, config) {
|
|
172
|
+
const threshold = config.confidence_threshold || 'LOW';
|
|
173
|
+
const confidenceLevel = CONFIDENCE_ORDER[confidence] ?? 0;
|
|
174
|
+
const thresholdLevel = CONFIDENCE_ORDER[threshold] ?? 0;
|
|
175
|
+
return confidenceLevel >= thresholdLevel;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const GRADE_ORDER = { A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
179
|
+
|
|
180
|
+
export function evaluatePolicy(scanResult, config) {
|
|
181
|
+
const violations = [];
|
|
182
|
+
const policy = config && config.policy ? config.policy : DEFAULT_CONFIG.policy;
|
|
183
|
+
|
|
184
|
+
// Check block_on severity
|
|
185
|
+
const blockOn = policy.block_on || 'error';
|
|
186
|
+
const severityKeys = [];
|
|
187
|
+
if (blockOn === 'info') severityKeys.push('info', 'warning', 'error');
|
|
188
|
+
else if (blockOn === 'warning') severityKeys.push('warning', 'error');
|
|
189
|
+
else severityKeys.push('error');
|
|
190
|
+
|
|
191
|
+
const bySeverity = scanResult.by_severity || {};
|
|
192
|
+
for (const key of severityKeys) {
|
|
193
|
+
if ((bySeverity[key] || 0) > 0) {
|
|
194
|
+
violations.push(`Policy violation: found ${bySeverity[key]} ${key} issue(s) (block_on: ${blockOn})`);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check max_critical
|
|
200
|
+
if (policy.max_critical !== null && policy.max_critical !== undefined) {
|
|
201
|
+
const criticalCount = bySeverity.error || 0;
|
|
202
|
+
if (criticalCount > policy.max_critical) {
|
|
203
|
+
violations.push(`Policy violation: ${criticalCount} critical issue(s) exceeds max_critical (${policy.max_critical})`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check max_warning
|
|
208
|
+
if (policy.max_warning !== null && policy.max_warning !== undefined) {
|
|
209
|
+
const warningCount = bySeverity.warning || 0;
|
|
210
|
+
if (warningCount > policy.max_warning) {
|
|
211
|
+
violations.push(`Policy violation: ${warningCount} warning(s) exceeds max_warning (${policy.max_warning})`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check required_grade
|
|
216
|
+
if (policy.required_grade) {
|
|
217
|
+
const actualGrade = scanResult.grade || 'A';
|
|
218
|
+
const requiredLevel = GRADE_ORDER[policy.required_grade] ?? 0;
|
|
219
|
+
const actualLevel = GRADE_ORDER[actualGrade] ?? 0;
|
|
220
|
+
if (actualLevel < requiredLevel) {
|
|
221
|
+
violations.push(`Policy violation: grade ${actualGrade} does not meet required_grade (${policy.required_grade})`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
passed: violations.length === 0,
|
|
227
|
+
violations,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function applyConfig(findings, filePath, config) {
|
|
232
|
+
if (!Array.isArray(findings)) return findings;
|
|
233
|
+
if (!config) return findings;
|
|
234
|
+
|
|
235
|
+
return findings.filter(finding => {
|
|
236
|
+
// Check rule suppression
|
|
237
|
+
if (shouldSuppressRule(finding.ruleId, filePath, config)) return false;
|
|
238
|
+
|
|
239
|
+
// Check severity threshold
|
|
240
|
+
if (!meetsSeverityThreshold(finding.severity, config)) return false;
|
|
241
|
+
|
|
242
|
+
// Check confidence threshold
|
|
243
|
+
if (!meetsConfidenceThreshold(finding.confidence || 'MEDIUM', config)) return false;
|
|
244
|
+
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
}
|