agentshield-sdk 7.2.0 → 7.3.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,640 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield - Visual HTML Security Report Generator
5
+ *
6
+ * Produces a Lighthouse-style HTML report from a SecurityAudit result.
7
+ * Self-contained HTML with inline CSS and SVG. No external dependencies.
8
+ *
9
+ * Usage:
10
+ * const { SecurityAudit } = require('./audit');
11
+ * const { generateHTMLReport, generateReportFile } = require('./report-generator');
12
+ * const report = new SecurityAudit().run();
13
+ * generateReportFile(report, 'shield-report.html');
14
+ *
15
+ * @module report-generator
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { getGrade } = require('./utils');
21
+
22
+ // =========================================================================
23
+ // Color helpers
24
+ // =========================================================================
25
+
26
+ /**
27
+ * Get the gauge color based on score.
28
+ * @param {number} score - 0-100
29
+ * @returns {string} CSS color
30
+ */
31
+ function getScoreColor(score) {
32
+ if (score > 80) return '#0cce6b';
33
+ if (score > 50) return '#ffa400';
34
+ return '#ff4e42';
35
+ }
36
+
37
+ /**
38
+ * Get the severity badge color.
39
+ * @param {string} severity
40
+ * @returns {string} CSS color
41
+ */
42
+ function getSeverityColor(severity) {
43
+ const map = {
44
+ critical: '#ff4e42',
45
+ high: '#ff6d3a',
46
+ medium: '#ffa400',
47
+ low: '#0cce6b',
48
+ };
49
+ return map[severity] || '#888';
50
+ }
51
+
52
+ /**
53
+ * Get the severity background color (lighter).
54
+ * @param {string} severity
55
+ * @returns {string} CSS color
56
+ */
57
+ function getSeverityBg(severity) {
58
+ const map = {
59
+ critical: 'rgba(255,78,66,0.15)',
60
+ high: 'rgba(255,109,58,0.15)',
61
+ medium: 'rgba(255,164,0,0.15)',
62
+ low: 'rgba(12,206,107,0.15)',
63
+ };
64
+ return map[severity] || 'rgba(136,136,136,0.15)';
65
+ }
66
+
67
+ // =========================================================================
68
+ // SVG generators
69
+ // =========================================================================
70
+
71
+ /**
72
+ * Generate a circular gauge SVG for the shield score.
73
+ * @param {number} score - 0-100
74
+ * @param {string} grade - Letter grade
75
+ * @returns {string} SVG markup
76
+ */
77
+ function renderGaugeSVG(score, grade) {
78
+ const color = getScoreColor(score);
79
+ const radius = 56;
80
+ const circumference = 2 * Math.PI * radius;
81
+ const offset = circumference - (score / 100) * circumference;
82
+
83
+ return `<svg class="gauge" width="160" height="160" viewBox="0 0 160 160">
84
+ <circle cx="80" cy="80" r="${radius}" fill="none" stroke="#2a2a3e" stroke-width="10"/>
85
+ <circle cx="80" cy="80" r="${radius}" fill="none" stroke="${color}" stroke-width="10"
86
+ stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
87
+ stroke-linecap="round" transform="rotate(-90 80 80)"
88
+ style="transition: stroke-dashoffset 0.6s ease;"/>
89
+ <text x="80" y="72" text-anchor="middle" fill="${color}" font-size="36" font-weight="700">${score}</text>
90
+ <text x="80" y="94" text-anchor="middle" fill="#ccc" font-size="14" font-weight="500">${grade}</text>
91
+ </svg>`;
92
+ }
93
+
94
+ /**
95
+ * Generate a horizontal bar chart SVG for category breakdown.
96
+ * @param {object} categoryStats - { category: { detected, total, missed } }
97
+ * @returns {string} SVG markup
98
+ */
99
+ function renderCategoryBarsSVG(categoryStats) {
100
+ const entries = Object.entries(categoryStats);
101
+ const barHeight = 28;
102
+ const labelWidth = 200;
103
+ const barMaxWidth = 280;
104
+ const rowHeight = 40;
105
+ const svgHeight = entries.length * rowHeight + 20;
106
+
107
+ let bars = '';
108
+ entries.forEach(([cat, stats], i) => {
109
+ const rate = stats.total > 0 ? stats.detected / stats.total : 0;
110
+ const pct = Math.round(rate * 100);
111
+ const barWidth = Math.max(2, rate * barMaxWidth);
112
+ const color = getScoreColor(pct);
113
+ const y = i * rowHeight + 16;
114
+ const label = cat.replace(/_/g, ' ');
115
+
116
+ bars += `
117
+ <text x="0" y="${y + 18}" fill="#ccc" font-size="13" class="cat-label">${label}</text>
118
+ <rect x="${labelWidth}" y="${y + 4}" width="${barMaxWidth}" height="${barHeight - 6}" rx="4" fill="#2a2a3e"/>
119
+ <rect x="${labelWidth}" y="${y + 4}" width="${barWidth}" height="${barHeight - 6}" rx="4" fill="${color}"/>
120
+ <text x="${labelWidth + barMaxWidth + 10}" y="${y + 18}" fill="#ccc" font-size="13" font-weight="600">${pct}%</text>
121
+ <text x="${labelWidth + barMaxWidth + 52}" y="${y + 18}" fill="#888" font-size="11">(${stats.detected}/${stats.total})</text>`;
122
+ });
123
+
124
+ return `<svg class="category-bars" width="100%" viewBox="0 0 580 ${svgHeight}" preserveAspectRatio="xMinYMin meet">
125
+ ${bars}
126
+ </svg>`;
127
+ }
128
+
129
+ // =========================================================================
130
+ // HTML sections
131
+ // =========================================================================
132
+
133
+ function renderSeverityDistribution(findings) {
134
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
135
+ for (const f of findings) {
136
+ if (counts[f.severity] !== undefined) {
137
+ counts[f.severity]++;
138
+ }
139
+ }
140
+
141
+ const pills = Object.entries(counts).map(([sev, count]) => {
142
+ return `<span class="pill" style="background:${getSeverityBg(sev)};color:${getSeverityColor(sev)};">
143
+ ${sev.toUpperCase()} <strong>${count}</strong>
144
+ </span>`;
145
+ }).join('\n ');
146
+
147
+ return `<div class="severity-dist">${pills}</div>`;
148
+ }
149
+
150
+ function renderTopFindings(findings, limit = 15) {
151
+ if (!findings || findings.length === 0) {
152
+ return '<p class="no-findings">No missed attacks found. All attacks were detected.</p>';
153
+ }
154
+
155
+ const sorted = [...findings].sort((a, b) => {
156
+ const order = { critical: 0, high: 1, medium: 2, low: 3 };
157
+ return (order[a.severity] || 4) - (order[b.severity] || 4);
158
+ });
159
+
160
+ const rows = sorted.slice(0, limit).map(f => {
161
+ const escapedAttack = escapeHtml(f.attack);
162
+ const escapedRec = escapeHtml(f.recommendation);
163
+ return `<div class="finding">
164
+ <div class="finding-header">
165
+ <span class="badge" style="background:${getSeverityColor(f.severity)};">${f.severity.toUpperCase()}</span>
166
+ <span class="finding-category">${f.category.replace(/_/g, ' ')}</span>
167
+ <span class="finding-type">${f.type}</span>
168
+ </div>
169
+ <div class="finding-attack">${escapedAttack}</div>
170
+ <div class="finding-rec">Fix: ${escapedRec}</div>
171
+ </div>`;
172
+ }).join('\n ');
173
+
174
+ const extra = findings.length > limit
175
+ ? `<p class="more-findings">...and ${findings.length - limit} more findings</p>`
176
+ : '';
177
+
178
+ return rows + extra;
179
+ }
180
+
181
+ function escapeHtml(str) {
182
+ if (!str) return '';
183
+ return str
184
+ .replace(/&/g, '&amp;')
185
+ .replace(/</g, '&lt;')
186
+ .replace(/>/g, '&gt;')
187
+ .replace(/"/g, '&quot;');
188
+ }
189
+
190
+ // =========================================================================
191
+ // Main HTML generator
192
+ // =========================================================================
193
+
194
+ /**
195
+ * Generate a self-contained HTML security report.
196
+ *
197
+ * @param {object} auditReport - AuditReport instance from SecurityAudit.run()
198
+ * @param {object} [options]
199
+ * @param {string} [options.title] - Report title
200
+ * @param {number} [options.maxFindings] - Maximum findings to show (default 15)
201
+ * @returns {string} Complete HTML string
202
+ */
203
+ function generateHTMLReport(auditReport, options = {}) {
204
+ const title = options.title || 'Agent Shield Security Report';
205
+ const maxFindings = options.maxFindings || 15;
206
+ const score = auditReport.score || 0;
207
+ const grade = auditReport.grade || getGrade(score);
208
+ const detectionRate = auditReport.detectionRate != null
209
+ ? auditReport.detectionRate.toFixed(1)
210
+ : '0.0';
211
+ const timestamp = new Date().toISOString();
212
+
213
+ const gaugeColor = getScoreColor(score);
214
+
215
+ return `<!DOCTYPE html>
216
+ <html lang="en">
217
+ <head>
218
+ <meta charset="UTF-8">
219
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
220
+ <title>${escapeHtml(title)}</title>
221
+ <style>
222
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
223
+
224
+ body {
225
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
226
+ background: #1a1a2e;
227
+ color: #e0e0e0;
228
+ line-height: 1.6;
229
+ min-height: 100vh;
230
+ }
231
+
232
+ .container {
233
+ max-width: 900px;
234
+ margin: 0 auto;
235
+ padding: 24px 20px 48px;
236
+ }
237
+
238
+ /* Header */
239
+ .report-header {
240
+ text-align: center;
241
+ padding: 32px 0 24px;
242
+ border-bottom: 1px solid #2a2a3e;
243
+ margin-bottom: 32px;
244
+ }
245
+
246
+ .report-header h1 {
247
+ font-size: 24px;
248
+ font-weight: 700;
249
+ color: #fff;
250
+ margin-bottom: 4px;
251
+ letter-spacing: -0.3px;
252
+ }
253
+
254
+ .report-header .subtitle {
255
+ font-size: 14px;
256
+ color: #888;
257
+ }
258
+
259
+ /* Score hero */
260
+ .score-hero {
261
+ display: flex;
262
+ align-items: center;
263
+ justify-content: center;
264
+ gap: 48px;
265
+ padding: 32px 0;
266
+ flex-wrap: wrap;
267
+ }
268
+
269
+ .gauge { display: block; }
270
+
271
+ .score-details {
272
+ display: flex;
273
+ flex-direction: column;
274
+ gap: 12px;
275
+ }
276
+
277
+ .score-stat {
278
+ display: flex;
279
+ align-items: baseline;
280
+ gap: 10px;
281
+ }
282
+
283
+ .score-stat .label {
284
+ font-size: 13px;
285
+ color: #888;
286
+ min-width: 120px;
287
+ }
288
+
289
+ .score-stat .value {
290
+ font-size: 20px;
291
+ font-weight: 700;
292
+ color: #fff;
293
+ }
294
+
295
+ .score-stat .value.green { color: #0cce6b; }
296
+ .score-stat .value.yellow { color: #ffa400; }
297
+ .score-stat .value.red { color: #ff4e42; }
298
+
299
+ /* Cards */
300
+ .card {
301
+ background: #16213e;
302
+ border: 1px solid #2a2a3e;
303
+ border-radius: 10px;
304
+ padding: 24px;
305
+ margin-bottom: 24px;
306
+ }
307
+
308
+ .card h2 {
309
+ font-size: 16px;
310
+ font-weight: 700;
311
+ color: #fff;
312
+ margin-bottom: 16px;
313
+ padding-bottom: 10px;
314
+ border-bottom: 1px solid #2a2a3e;
315
+ }
316
+
317
+ /* Category bars */
318
+ .cat-label { text-transform: capitalize; }
319
+
320
+ .category-bars { display: block; width: 100%; }
321
+
322
+ /* Severity pills */
323
+ .severity-dist {
324
+ display: flex;
325
+ gap: 12px;
326
+ flex-wrap: wrap;
327
+ }
328
+
329
+ .pill {
330
+ display: inline-flex;
331
+ align-items: center;
332
+ gap: 6px;
333
+ padding: 6px 14px;
334
+ border-radius: 20px;
335
+ font-size: 13px;
336
+ font-weight: 600;
337
+ letter-spacing: 0.5px;
338
+ }
339
+
340
+ .pill strong {
341
+ font-size: 18px;
342
+ font-weight: 800;
343
+ }
344
+
345
+ /* Findings */
346
+ .finding {
347
+ padding: 14px 0;
348
+ border-bottom: 1px solid #2a2a3e;
349
+ }
350
+
351
+ .finding:last-child { border-bottom: none; }
352
+
353
+ .finding-header {
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 10px;
357
+ margin-bottom: 6px;
358
+ }
359
+
360
+ .badge {
361
+ display: inline-block;
362
+ padding: 2px 8px;
363
+ border-radius: 4px;
364
+ font-size: 11px;
365
+ font-weight: 700;
366
+ color: #fff;
367
+ text-transform: uppercase;
368
+ letter-spacing: 0.5px;
369
+ }
370
+
371
+ .finding-category {
372
+ font-size: 13px;
373
+ color: #ccc;
374
+ font-weight: 600;
375
+ text-transform: capitalize;
376
+ }
377
+
378
+ .finding-type {
379
+ font-size: 11px;
380
+ color: #666;
381
+ margin-left: auto;
382
+ }
383
+
384
+ .finding-attack {
385
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
386
+ font-size: 12px;
387
+ color: #aaa;
388
+ background: #1a1a2e;
389
+ padding: 6px 10px;
390
+ border-radius: 4px;
391
+ margin: 6px 0;
392
+ word-break: break-word;
393
+ }
394
+
395
+ .finding-rec {
396
+ font-size: 12px;
397
+ color: #70a0ff;
398
+ }
399
+
400
+ .no-findings {
401
+ color: #0cce6b;
402
+ font-weight: 600;
403
+ padding: 16px 0;
404
+ }
405
+
406
+ .more-findings {
407
+ color: #888;
408
+ font-size: 13px;
409
+ padding-top: 12px;
410
+ }
411
+
412
+ /* Metadata */
413
+ .meta-grid {
414
+ display: grid;
415
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
416
+ gap: 16px;
417
+ }
418
+
419
+ .meta-item {
420
+ background: #1a1a2e;
421
+ border-radius: 8px;
422
+ padding: 14px;
423
+ }
424
+
425
+ .meta-item .meta-label {
426
+ font-size: 11px;
427
+ color: #888;
428
+ text-transform: uppercase;
429
+ letter-spacing: 0.5px;
430
+ margin-bottom: 4px;
431
+ }
432
+
433
+ .meta-item .meta-value {
434
+ font-size: 18px;
435
+ font-weight: 700;
436
+ color: #fff;
437
+ }
438
+
439
+ /* Verdict */
440
+ .verdict {
441
+ text-align: center;
442
+ padding: 20px;
443
+ border-radius: 8px;
444
+ font-size: 16px;
445
+ font-weight: 700;
446
+ margin-top: 8px;
447
+ }
448
+
449
+ .verdict.pass { background: rgba(12,206,107,0.1); color: #0cce6b; border: 1px solid rgba(12,206,107,0.3); }
450
+ .verdict.warn { background: rgba(255,164,0,0.1); color: #ffa400; border: 1px solid rgba(255,164,0,0.3); }
451
+ .verdict.fail { background: rgba(255,78,66,0.1); color: #ff4e42; border: 1px solid rgba(255,78,66,0.3); }
452
+
453
+ /* Footer */
454
+ .report-footer {
455
+ text-align: center;
456
+ padding: 24px 0 0;
457
+ margin-top: 32px;
458
+ border-top: 1px solid #2a2a3e;
459
+ font-size: 12px;
460
+ color: #666;
461
+ }
462
+
463
+ .report-footer strong { color: #888; }
464
+
465
+ /* Responsive */
466
+ @media (max-width: 640px) {
467
+ .score-hero { flex-direction: column; gap: 24px; }
468
+ .meta-grid { grid-template-columns: 1fr 1fr; }
469
+ .severity-dist { flex-direction: column; align-items: flex-start; }
470
+ .container { padding: 16px 12px 32px; }
471
+ }
472
+
473
+ /* Print */
474
+ @media print {
475
+ body { background: #fff; color: #222; }
476
+ .container { max-width: 100%; padding: 0; }
477
+ .card { background: #f9f9f9; border-color: #ddd; break-inside: avoid; }
478
+ .report-header { border-color: #ddd; }
479
+ .report-header h1 { color: #111; }
480
+ .score-stat .value { color: #111; }
481
+ .finding-attack { background: #f0f0f0; color: #333; }
482
+ .finding-rec { color: #336; }
483
+ .meta-item { background: #f0f0f0; }
484
+ .meta-item .meta-value { color: #111; }
485
+ .report-footer { border-color: #ddd; color: #999; }
486
+ .cat-label { fill: #333 !important; }
487
+ .pill { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
488
+ .badge { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
489
+ }
490
+ </style>
491
+ </head>
492
+ <body>
493
+ <div class="container">
494
+
495
+ <div class="report-header">
496
+ <h1>${escapeHtml(title)}</h1>
497
+ <div class="subtitle">Pre-deployment security audit powered by Agent Shield</div>
498
+ </div>
499
+
500
+ <!-- Score Hero -->
501
+ <div class="score-hero">
502
+ ${renderGaugeSVG(score, grade)}
503
+ <div class="score-details">
504
+ <div class="score-stat">
505
+ <span class="label">Shield Score</span>
506
+ <span class="value" style="color:${gaugeColor}">${score}/100</span>
507
+ </div>
508
+ <div class="score-stat">
509
+ <span class="label">Grade</span>
510
+ <span class="value" style="color:${gaugeColor}">${grade}</span>
511
+ </div>
512
+ <div class="score-stat">
513
+ <span class="label">Detection Rate</span>
514
+ <span class="value" style="color:${gaugeColor}">${detectionRate}%</span>
515
+ </div>
516
+ <div class="score-stat">
517
+ <span class="label">Attacks Tested</span>
518
+ <span class="value">${auditReport.totalAttacks || 0}</span>
519
+ </div>
520
+ </div>
521
+ </div>
522
+
523
+ <!-- Category Breakdown -->
524
+ <div class="card">
525
+ <h2>Category Breakdown</h2>
526
+ ${renderCategoryBarsSVG(auditReport.categoryStats || {})}
527
+ </div>
528
+
529
+ <!-- Severity Distribution -->
530
+ <div class="card">
531
+ <h2>Threat Severity Distribution</h2>
532
+ ${renderSeverityDistribution(auditReport.findings || [])}
533
+ </div>
534
+
535
+ <!-- Top Findings -->
536
+ <div class="card">
537
+ <h2>Top Findings</h2>
538
+ ${renderTopFindings(auditReport.findings || [], maxFindings)}
539
+ </div>
540
+
541
+ <!-- Scan Metadata -->
542
+ <div class="card">
543
+ <h2>Scan Metadata</h2>
544
+ <div class="meta-grid">
545
+ <div class="meta-item">
546
+ <div class="meta-label">Scan Duration</div>
547
+ <div class="meta-value">${auditReport.elapsed || 0}ms</div>
548
+ </div>
549
+ <div class="meta-item">
550
+ <div class="meta-label">Total Attacks</div>
551
+ <div class="meta-value">${auditReport.totalAttacks || 0}</div>
552
+ </div>
553
+ <div class="meta-item">
554
+ <div class="meta-label">Detected</div>
555
+ <div class="meta-value" style="color:#0cce6b">${auditReport.totalDetected || 0}</div>
556
+ </div>
557
+ <div class="meta-item">
558
+ <div class="meta-label">Missed</div>
559
+ <div class="meta-value" style="color:${(auditReport.totalMissed || 0) > 0 ? '#ff4e42' : '#0cce6b'}">${auditReport.totalMissed || 0}</div>
560
+ </div>
561
+ <div class="meta-item">
562
+ <div class="meta-label">Categories</div>
563
+ <div class="meta-value">${Object.keys(auditReport.categoryStats || {}).length}</div>
564
+ </div>
565
+ <div class="meta-item">
566
+ <div class="meta-label">Sensitivity</div>
567
+ <div class="meta-value">${auditReport.sensitivity || 'high'}</div>
568
+ </div>
569
+ <div class="meta-item">
570
+ <div class="meta-label">Mutations</div>
571
+ <div class="meta-value">${auditReport.includedMutations ? 'Yes' : 'No'}</div>
572
+ </div>
573
+ <div class="meta-item">
574
+ <div class="meta-label">Patterns Checked</div>
575
+ <div class="meta-value">${auditReport.totalAttacks || 0}</div>
576
+ </div>
577
+ </div>
578
+ </div>
579
+
580
+ <!-- Verdict -->
581
+ <div class="card">
582
+ <h2>Verdict</h2>
583
+ ${renderVerdict(score)}
584
+ </div>
585
+
586
+ <div class="report-footer">
587
+ Generated by <strong>Agent Shield</strong> on ${timestamp}
588
+ </div>
589
+
590
+ </div>
591
+ </body>
592
+ </html>`;
593
+ }
594
+
595
+ /**
596
+ * Render the verdict section.
597
+ * @param {number} score
598
+ * @returns {string}
599
+ */
600
+ function renderVerdict(score) {
601
+ if (score >= 95) {
602
+ return '<div class="verdict pass">READY FOR PRODUCTION</div>';
603
+ }
604
+ if (score >= 80) {
605
+ return '<div class="verdict warn">NEEDS IMPROVEMENT - address critical findings before deploying</div>';
606
+ }
607
+ if (score >= 60) {
608
+ return '<div class="verdict fail">NOT READY - significant security gaps detected</div>';
609
+ }
610
+ return '<div class="verdict fail">CRITICAL RISK - do not deploy without major remediation</div>';
611
+ }
612
+
613
+ // =========================================================================
614
+ // File writer
615
+ // =========================================================================
616
+
617
+ /**
618
+ * Generate an HTML report and write it to a file.
619
+ *
620
+ * @param {object} auditReport - AuditReport instance from SecurityAudit.run()
621
+ * @param {string} outputPath - File path to write the HTML report to
622
+ * @param {object} [options] - Options passed to generateHTMLReport
623
+ * @returns {string} The absolute path of the written file
624
+ */
625
+ function generateReportFile(auditReport, outputPath, options = {}) {
626
+ const html = generateHTMLReport(auditReport, options);
627
+ const resolved = path.resolve(outputPath);
628
+ fs.writeFileSync(resolved, html, 'utf-8');
629
+ console.log(`[Agent Shield] HTML report written to ${resolved}`);
630
+ return resolved;
631
+ }
632
+
633
+ // =========================================================================
634
+ // EXPORTS
635
+ // =========================================================================
636
+
637
+ module.exports = {
638
+ generateHTMLReport,
639
+ generateReportFile,
640
+ };