pompelmi 1.13.0 → 1.15.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,370 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const pkg = require('../package.json');
5
+
6
+ // Inline grapefruit SVG (no external image dependency)
7
+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" aria-hidden="true">
8
+ <circle cx="16" cy="16" r="15" fill="#e8846a"/>
9
+ <circle cx="16" cy="16" r="12" fill="#f5c4a0"/>
10
+ <circle cx="16" cy="16" r="9" fill="#f9d8c0"/>
11
+ <line x1="16" y1="7" x2="16" y2="25" stroke="#e8846a" stroke-width="0.8"/>
12
+ <line x1="7" y1="16" x2="25" y2="16" stroke="#e8846a" stroke-width="0.8"/>
13
+ <line x1="9.6" y1="9.6" x2="22.4" y2="22.4" stroke="#e8846a" stroke-width="0.8"/>
14
+ <line x1="22.4" y1="9.6" x2="9.6" y2="22.4" stroke="#e8846a" stroke-width="0.8"/>
15
+ <circle cx="16" cy="16" r="2.2" fill="#e8846a" opacity="0.6"/>
16
+ <rect x="14.5" y="1" width="3" height="4" rx="1" fill="#7a9e5a"/>
17
+ <ellipse cx="19" cy="2.5" rx="3.5" ry="1.4" fill="#7a9e5a" transform="rotate(-30 19 2.5)"/>
18
+ </svg>`;
19
+
20
+ /**
21
+ * Normalise input into a flat array of { file, verdict, viruses[] }.
22
+ * Accepts:
23
+ * - Array<{ file, verdict, viruses? }> (CLI / scanOne format)
24
+ * - DirectoryScanResult { clean[], malicious[], errors[] }
25
+ */
26
+ function normalise(scanResults) {
27
+ if (Array.isArray(scanResults)) return scanResults;
28
+
29
+ // DirectoryScanResult shape
30
+ const rows = [];
31
+ for (const f of (scanResults.clean || [])) rows.push({ file: f, verdict: 'clean', viruses: [] });
32
+ for (const f of (scanResults.malicious|| [])) rows.push({ file: f, verdict: 'infected', viruses: [] });
33
+ for (const f of (scanResults.errors || [])) rows.push({ file: f, verdict: 'error', viruses: [] });
34
+ return rows;
35
+ }
36
+
37
+ function escHtml(str) {
38
+ return String(str)
39
+ .replace(/&/g, '&amp;')
40
+ .replace(/</g, '&lt;')
41
+ .replace(/>/g, '&gt;')
42
+ .replace(/"/g, '&quot;');
43
+ }
44
+
45
+ function badge(verdict) {
46
+ if (verdict === 'clean') return `<span class="badge badge-clean">CLEAN</span>`;
47
+ if (verdict === 'infected') return `<span class="badge badge-infected">INFECTED</span>`;
48
+ return `<span class="badge badge-error">ERROR</span>`;
49
+ }
50
+
51
+ /**
52
+ * Generate a self-contained HTML security report.
53
+ *
54
+ * @param {object|Array} scanResults - Array<{file,verdict,viruses?}> or DirectoryScanResult
55
+ * @param {object} [options]
56
+ * @param {number} [options.elapsed] - Scan time in ms
57
+ * @param {string} [options.clamdVersion] - clamd version string if known
58
+ * @param {string} [options.host] - clamd host used
59
+ * @param {string} [options.socket] - UNIX socket used
60
+ * @param {string} [options.outputPath] - Write HTML to this path (optional)
61
+ * @returns {string} Self-contained HTML
62
+ */
63
+ function generateDashboard(scanResults, options = {}) {
64
+ const rows = normalise(scanResults);
65
+ const total = rows.length;
66
+ const infected = rows.filter(r => r.verdict === 'infected').length;
67
+ const clean = rows.filter(r => r.verdict === 'clean').length;
68
+ const errors = rows.filter(r => r.verdict === 'error').length;
69
+ const elapsed = options.elapsed != null ? options.elapsed : null;
70
+ const elapsedStr = elapsed != null ? (elapsed / 1000).toFixed(2) + 's' : '—';
71
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
72
+ const version = pkg.version;
73
+ const allClean = infected === 0 && errors === 0;
74
+
75
+ const connectionStr = options.socket
76
+ ? escHtml(options.socket)
77
+ : options.host
78
+ ? escHtml(`${options.host}:${options.port || 3310}`)
79
+ : 'local clamscan';
80
+
81
+ const infectedRows = rows.filter(r => r.verdict === 'infected');
82
+
83
+ const fileTableRows = rows.map(r => `
84
+ <tr>
85
+ <td class="file-cell">${escHtml(r.file)}</td>
86
+ <td>${badge(r.verdict)}</td>
87
+ <td class="virus-cell">${r.viruses && r.viruses.length ? `<span class="virus-name">${escHtml(r.viruses[0])}</span>` : ''}</td>
88
+ </tr>`).join('');
89
+
90
+ const infectedSection = infectedRows.length === 0 ? '' : `
91
+ <section class="infected-section">
92
+ <h2>&#9888; Infected Files</h2>
93
+ <ul class="infected-list">
94
+ ${infectedRows.map(r => `
95
+ <li>
96
+ <span class="infected-path">${escHtml(r.file)}</span>
97
+ ${r.viruses && r.viruses.length ? `<br><span class="virus-label">Virus: <strong>${escHtml(r.viruses[0])}</strong></span>` : ''}
98
+ </li>`).join('')}
99
+ </ul>
100
+ </section>`;
101
+
102
+ const html = `<!DOCTYPE html>
103
+ <html lang="en">
104
+ <head>
105
+ <meta charset="UTF-8">
106
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
107
+ <title>pompelmi Security Report — ${ts}</title>
108
+ <style>
109
+ /* ── Reset & base ── */
110
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
111
+ :root {
112
+ --bg: #ffffff;
113
+ --bg2: #f8f9fa;
114
+ --border: #e2e8f0;
115
+ --text: #0f172a;
116
+ --muted: #64748b;
117
+ --clean: #16a34a;
118
+ --clean-bg: #dcfce7;
119
+ --infected: #dc2626;
120
+ --inf-bg: #fee2e2;
121
+ --error: #d97706;
122
+ --err-bg: #fef3c7;
123
+ --blue: #2563eb;
124
+ --banner-clean-bg: #dcfce7;
125
+ --banner-clean-txt: #166534;
126
+ --banner-clean-bdr: #86efac;
127
+ --banner-inf-bg: #fee2e2;
128
+ --banner-inf-txt: #991b1b;
129
+ --banner-inf-bdr: #fca5a5;
130
+ }
131
+ @media (prefers-color-scheme: dark) {
132
+ :root {
133
+ --bg: #0f172a;
134
+ --bg2: #1e293b;
135
+ --border: #334155;
136
+ --text: #f1f5f9;
137
+ --muted: #94a3b8;
138
+ --clean: #4ade80;
139
+ --clean-bg: #14532d;
140
+ --infected: #f87171;
141
+ --inf-bg: #7f1d1d;
142
+ --error: #fbbf24;
143
+ --err-bg: #78350f;
144
+ --banner-clean-bg: #14532d;
145
+ --banner-clean-txt: #bbf7d0;
146
+ --banner-clean-bdr: #166534;
147
+ --banner-inf-bg: #7f1d1d;
148
+ --banner-inf-txt: #fecaca;
149
+ --banner-inf-bdr: #991b1b;
150
+ }
151
+ }
152
+ body {
153
+ background: var(--bg);
154
+ color: var(--text);
155
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
156
+ font-size: 15px;
157
+ line-height: 1.6;
158
+ padding: 32px 20px 64px;
159
+ max-width: 960px;
160
+ margin: 0 auto;
161
+ }
162
+ /* ── Header ── */
163
+ .report-header {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 14px;
167
+ border-bottom: 1px solid var(--border);
168
+ padding-bottom: 20px;
169
+ margin-bottom: 28px;
170
+ }
171
+ .report-header svg { flex-shrink: 0; }
172
+ .report-title { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
173
+ .report-sub { font-size: 13px; color: var(--muted); margin-top: 2px; }
174
+ /* ── Banner ── */
175
+ .banner {
176
+ border-radius: 10px;
177
+ padding: 18px 24px;
178
+ margin-bottom: 28px;
179
+ font-size: 18px;
180
+ font-weight: 700;
181
+ border: 1.5px solid;
182
+ }
183
+ .banner.clean { background: var(--banner-clean-bg); color: var(--banner-clean-txt); border-color: var(--banner-clean-bdr); }
184
+ .banner.infected { background: var(--banner-inf-bg); color: var(--banner-inf-txt); border-color: var(--banner-inf-bdr); }
185
+ .banner-icon { font-size: 22px; margin-right: 8px; vertical-align: middle; }
186
+ /* ── Stats grid ── */
187
+ .stats-grid {
188
+ display: grid;
189
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
190
+ gap: 14px;
191
+ margin-bottom: 32px;
192
+ }
193
+ .stat-card {
194
+ background: var(--bg2);
195
+ border: 1px solid var(--border);
196
+ border-radius: 10px;
197
+ padding: 18px 16px 14px;
198
+ text-align: center;
199
+ }
200
+ .stat-value { font-size: 36px; font-weight: 800; line-height: 1; }
201
+ .stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 6px; }
202
+ .stat-total .stat-value { color: var(--blue); }
203
+ .stat-clean .stat-value { color: var(--clean); }
204
+ .stat-infected .stat-value { color: var(--infected); }
205
+ .stat-errors .stat-value { color: var(--error); }
206
+ .stat-time .stat-value { font-size: 24px; }
207
+ /* ── Section headings ── */
208
+ section { margin-bottom: 32px; }
209
+ section h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: var(--text); }
210
+ /* ── File table ── */
211
+ .file-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
212
+ .file-table th {
213
+ text-align: left;
214
+ font-size: 11px;
215
+ font-weight: 600;
216
+ text-transform: uppercase;
217
+ letter-spacing: 0.4px;
218
+ color: var(--muted);
219
+ padding: 8px 12px;
220
+ border-bottom: 1px solid var(--border);
221
+ background: var(--bg2);
222
+ }
223
+ .file-table td {
224
+ padding: 9px 12px;
225
+ border-bottom: 1px solid var(--border);
226
+ vertical-align: middle;
227
+ word-break: break-all;
228
+ }
229
+ .file-table tr:last-child td { border-bottom: none; }
230
+ .file-table tr:hover td { background: var(--bg2); }
231
+ .file-cell { font-family: 'Menlo', 'Consolas', monospace; font-size: 12.5px; color: var(--muted); }
232
+ .virus-cell { font-family: 'Menlo', 'Consolas', monospace; font-size: 12px; }
233
+ .virus-name { color: var(--infected); }
234
+ /* ── Badges ── */
235
+ .badge {
236
+ display: inline-block;
237
+ padding: 2px 9px;
238
+ border-radius: 99px;
239
+ font-size: 11px;
240
+ font-weight: 700;
241
+ letter-spacing: 0.3px;
242
+ white-space: nowrap;
243
+ }
244
+ .badge-clean { background: var(--clean-bg); color: var(--clean); }
245
+ .badge-infected { background: var(--inf-bg); color: var(--infected); }
246
+ .badge-error { background: var(--err-bg); color: var(--error); }
247
+ /* ── Infected section ── */
248
+ .infected-section h2 { color: var(--infected); }
249
+ .infected-list { list-style: none; }
250
+ .infected-list li {
251
+ background: var(--inf-bg);
252
+ border: 1px solid var(--banner-inf-bdr);
253
+ border-radius: 8px;
254
+ padding: 12px 16px;
255
+ margin-bottom: 10px;
256
+ }
257
+ .infected-path { font-family: 'Menlo', 'Consolas', monospace; font-size: 13px; color: var(--infected); }
258
+ .virus-label { font-size: 13px; color: var(--text); margin-top: 4px; display: block; }
259
+ /* ── Metadata ── */
260
+ .meta-table { border-collapse: collapse; font-size: 13.5px; }
261
+ .meta-table td { padding: 6px 14px 6px 0; vertical-align: top; }
262
+ .meta-table td:first-child { color: var(--muted); width: 160px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; padding-top: 8px; }
263
+ /* ── Footer ── */
264
+ .report-footer {
265
+ margin-top: 48px;
266
+ padding-top: 16px;
267
+ border-top: 1px solid var(--border);
268
+ font-size: 12px;
269
+ color: var(--muted);
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 8px;
273
+ }
274
+ /* ── Print ── */
275
+ @media print {
276
+ body { padding: 0; max-width: 100%; color: #000; background: #fff; }
277
+ .banner.clean { background: #dcfce7 !important; color: #166534 !important; }
278
+ .banner.infected { background: #fee2e2 !important; color: #991b1b !important; }
279
+ .stat-card { border: 1px solid #ccc; }
280
+ .badge-clean { background: #dcfce7 !important; color: #166534 !important; }
281
+ .badge-infected { background: #fee2e2 !important; color: #991b1b !important; }
282
+ .badge-error { background: #fef3c7 !important; color: #d97706 !important; }
283
+ .file-table tr:hover td { background: none; }
284
+ }
285
+ </style>
286
+ </head>
287
+ <body>
288
+
289
+ <header class="report-header">
290
+ ${LOGO_SVG}
291
+ <div>
292
+ <div class="report-title">pompelmi Security Report</div>
293
+ <div class="report-sub">Generated ${ts}</div>
294
+ </div>
295
+ </header>
296
+
297
+ <div class="banner ${allClean ? 'clean' : 'infected'}">
298
+ <span class="banner-icon">${allClean ? '✅' : '🚨'}</span>
299
+ ${allClean
300
+ ? `All ${total} file${total === 1 ? '' : 's'} scanned — no threats detected`
301
+ : `${infected} infected file${infected === 1 ? '' : 's'} detected out of ${total} scanned`}
302
+ </div>
303
+
304
+ <div class="stats-grid">
305
+ <div class="stat-card stat-total">
306
+ <div class="stat-value">${total}</div>
307
+ <div class="stat-label">Files scanned</div>
308
+ </div>
309
+ <div class="stat-card stat-clean">
310
+ <div class="stat-value">${clean}</div>
311
+ <div class="stat-label">Clean</div>
312
+ </div>
313
+ <div class="stat-card stat-infected">
314
+ <div class="stat-value">${infected}</div>
315
+ <div class="stat-label">Infected</div>
316
+ </div>
317
+ <div class="stat-card stat-errors">
318
+ <div class="stat-value">${errors}</div>
319
+ <div class="stat-label">Errors</div>
320
+ </div>
321
+ <div class="stat-card stat-time">
322
+ <div class="stat-value">${elapsedStr}</div>
323
+ <div class="stat-label">Scan time</div>
324
+ </div>
325
+ </div>
326
+
327
+ ${infectedSection}
328
+
329
+ <section>
330
+ <h2>All Scanned Files</h2>
331
+ ${total === 0 ? '<p style="color:var(--muted)">No files scanned.</p>' : `
332
+ <table class="file-table">
333
+ <thead>
334
+ <tr>
335
+ <th>File</th>
336
+ <th>Verdict</th>
337
+ <th>Threat name</th>
338
+ </tr>
339
+ </thead>
340
+ <tbody>${fileTableRows}</tbody>
341
+ </table>`}
342
+ </section>
343
+
344
+ <section>
345
+ <h2>Scan Metadata</h2>
346
+ <table class="meta-table">
347
+ <tr><td>Timestamp</td><td>${ts}</td></tr>
348
+ <tr><td>Connection</td><td>${connectionStr}</td></tr>
349
+ ${options.clamdVersion ? `<tr><td>ClamAV</td><td>${escHtml(options.clamdVersion)}</td></tr>` : ''}
350
+ <tr><td>pompelmi</td><td>v${escHtml(version)}</td></tr>
351
+ </table>
352
+ </section>
353
+
354
+ <footer class="report-footer">
355
+ ${LOGO_SVG.replace('width="32" height="32"', 'width="18" height="18"')}
356
+ Generated by <strong>pompelmi v${escHtml(version)}</strong> &mdash;
357
+ <a href="https://pompelmi.app" style="color:inherit">pompelmi.app</a>
358
+ </footer>
359
+
360
+ </body>
361
+ </html>`;
362
+
363
+ if (options.outputPath) {
364
+ fs.writeFileSync(options.outputPath, html, 'utf8');
365
+ }
366
+
367
+ return html;
368
+ }
369
+
370
+ module.exports = { generateDashboard };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const pkg = require('../package.json');
5
+
6
+ /**
7
+ * Normalise scan results (same shapes as Dashboard.js).
8
+ * Returns { total, infected, clean, errors }.
9
+ */
10
+ function stats(scanResults) {
11
+ if (Array.isArray(scanResults)) {
12
+ const total = scanResults.length;
13
+ const infected = scanResults.filter(r => r.verdict === 'infected').length;
14
+ const errors = scanResults.filter(r => r.verdict === 'error').length;
15
+ return { total, infected, clean: total - infected - errors, errors };
16
+ }
17
+ // DirectoryScanResult
18
+ const clean = (scanResults.clean || []).length;
19
+ const infected = (scanResults.malicious|| []).length;
20
+ const errors = (scanResults.errors || []).length;
21
+ return { total: clean + infected + errors, infected, clean, errors };
22
+ }
23
+
24
+ function escXml(str) {
25
+ return String(str)
26
+ .replace(/&/g, '&amp;')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;');
30
+ }
31
+
32
+ /**
33
+ * Generate a shareable SVG card showing the scan result.
34
+ *
35
+ * @param {object|Array} scanResults - Array<{file,verdict}> or DirectoryScanResult
36
+ * @param {object} [options]
37
+ * @param {string} [options.outputPath] - Write SVG to this path (optional)
38
+ * @returns {string} SVG markup
39
+ */
40
+ function generateShareCard(scanResults, options = {}) {
41
+ const { total, infected, clean } = stats(scanResults);
42
+ const version = pkg.version;
43
+ const date = new Date().toISOString().slice(0, 10);
44
+ const allClean = infected === 0;
45
+
46
+ // Card dimensions
47
+ const W = 560;
48
+ const H = 200;
49
+
50
+ // Theme
51
+ const bgColor = allClean ? '#f0fdf4' : '#fff1f2';
52
+ const borderColor = allClean ? '#86efac' : '#fca5a5';
53
+ const accentColor = allClean ? '#16a34a' : '#dc2626';
54
+ const headlineTxt = allClean
55
+ ? `${total} file${total === 1 ? '' : 's'} scanned — All clean`
56
+ : `${total} file${total === 1 ? '' : 's'} scanned — ${infected} infected`;
57
+ const statusIcon = allClean ? '✅' : '🚨';
58
+
59
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="pompelmi scan result: ${escXml(headlineTxt)}">
60
+ <defs>
61
+ <style>
62
+ .card-font { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
63
+ </style>
64
+ </defs>
65
+
66
+ <!-- Background -->
67
+ <rect width="${W}" height="${H}" rx="14" ry="14" fill="${bgColor}" stroke="${borderColor}" stroke-width="2"/>
68
+
69
+ <!-- Grapefruit logo (top-left) -->
70
+ <g transform="translate(24, 24)">
71
+ <circle cx="16" cy="16" r="15" fill="#e8846a"/>
72
+ <circle cx="16" cy="16" r="12" fill="#f5c4a0"/>
73
+ <circle cx="16" cy="16" r="9" fill="#f9d8c0"/>
74
+ <line x1="16" y1="7" x2="16" y2="25" stroke="#e8846a" stroke-width="0.8"/>
75
+ <line x1="7" y1="16" x2="25" y2="16" stroke="#e8846a" stroke-width="0.8"/>
76
+ <line x1="9.6" y1="9.6" x2="22.4" y2="22.4" stroke="#e8846a" stroke-width="0.8"/>
77
+ <line x1="22.4" y1="9.6" x2="9.6" y2="22.4" stroke="#e8846a" stroke-width="0.8"/>
78
+ <circle cx="16" cy="16" r="2.2" fill="#e8846a" opacity="0.6"/>
79
+ <rect x="14.5" y="1" width="3" height="4" rx="1" fill="#7a9e5a"/>
80
+ <ellipse cx="19" cy="2.5" rx="3.5" ry="1.4" fill="#7a9e5a" transform="rotate(-30 19 2.5)"/>
81
+ </g>
82
+
83
+ <!-- Brand name -->
84
+ <text x="64" y="39" class="card-font" font-size="15" font-weight="700" fill="#0f172a">pompelmi</text>
85
+ <text x="64" y="54" class="card-font" font-size="11" fill="#64748b">Virus scan report</text>
86
+
87
+ <!-- Status icon -->
88
+ <text x="${W - 32}" y="46" class="card-font" font-size="28" text-anchor="end">${statusIcon}</text>
89
+
90
+ <!-- Divider -->
91
+ <line x1="24" y1="76" x2="${W - 24}" y2="76" stroke="${borderColor}" stroke-width="1.5"/>
92
+
93
+ <!-- Headline -->
94
+ <text x="${W / 2}" y="112" class="card-font" font-size="20" font-weight="700"
95
+ fill="${accentColor}" text-anchor="middle">${escXml(headlineTxt)}</text>
96
+
97
+ <!-- Stats row -->
98
+ <text x="112" y="145" class="card-font" font-size="28" font-weight="800" fill="#2563eb" text-anchor="middle">${total}</text>
99
+ <text x="112" y="162" class="card-font" font-size="11" fill="#64748b" text-anchor="middle">TOTAL</text>
100
+
101
+ <text x="224" y="145" class="card-font" font-size="28" font-weight="800" fill="#16a34a" text-anchor="middle">${clean}</text>
102
+ <text x="224" y="162" class="card-font" font-size="11" fill="#64748b" text-anchor="middle">CLEAN</text>
103
+
104
+ <text x="336" y="145" class="card-font" font-size="28" font-weight="800" fill="#dc2626" text-anchor="middle">${infected}</text>
105
+ <text x="336" y="162" class="card-font" font-size="11" fill="#64748b" text-anchor="middle">INFECTED</text>
106
+
107
+ <!-- Separator lines between stats -->
108
+ <line x1="170" y1="130" x2="170" y2="168" stroke="${borderColor}" stroke-width="1"/>
109
+ <line x1="282" y1="130" x2="282" y2="168" stroke="${borderColor}" stroke-width="1"/>
110
+
111
+ <!-- Footer: date + version -->
112
+ <line x1="24" y1="178" x2="${W - 24}" y2="178" stroke="${borderColor}" stroke-width="1"/>
113
+ <text x="24" y="193" class="card-font" font-size="11" fill="#94a3b8">${escXml(date)}</text>
114
+ <text x="${W - 24}" y="193" class="card-font" font-size="11" fill="#94a3b8" text-anchor="end">pompelmi v${escXml(version)} • pompelmi.app</text>
115
+ </svg>`;
116
+
117
+ if (options.outputPath) {
118
+ fs.writeFileSync(options.outputPath, svg, 'utf8');
119
+ }
120
+
121
+ return svg;
122
+ }
123
+
124
+ module.exports = { generateShareCard };
@@ -3,6 +3,9 @@
3
3
  const net = require('net');
4
4
  const { Verdict } = require('./verdicts.js');
5
5
 
6
+ // net.createConnection is polyfilled by Bun — no code changes needed
7
+ const isBun = typeof Bun !== 'undefined'; // eslint-disable-line no-unused-vars
8
+
6
9
  const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
7
10
 
8
11
  function parseClamdResponse(raw) {
package/src/index.js CHANGED
@@ -6,5 +6,7 @@ const { createPool } = require('./ClamdPool.js'
6
6
  const { watch } = require('./Watcher.js');
7
7
  const { notify } = require('./WebhookNotifier.js');
8
8
  const { createScanner } = require('./ScanEmitter.js');
9
+ const { generateDashboard } = require('./Dashboard.js');
10
+ const { generateShareCard } = require('./ShareCard.js');
9
11
 
10
- module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner };
12
+ module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard };
package/types/index.d.ts CHANGED
@@ -215,3 +215,54 @@ export interface ScanEmitter extends EventEmitter {
215
215
  * scanner.scanDirectory('/uploads');
216
216
  */
217
217
  export declare function createScanner(options?: ScanOptions): ScanEmitter;
218
+
219
+ /** Options for generateDashboard */
220
+ export interface DashboardOptions {
221
+ /** Scan duration in milliseconds */
222
+ elapsed?: number;
223
+ /** ClamAV version string, if available */
224
+ clamdVersion?: string;
225
+ /** clamd host used for the scan */
226
+ host?: string;
227
+ /** clamd port used for the scan */
228
+ port?: number;
229
+ /** UNIX socket used for the scan */
230
+ socket?: string;
231
+ /** Write the HTML to this path (optional) */
232
+ outputPath?: string;
233
+ }
234
+
235
+ /** A scan result row (output of the CLI or manual scan loop) */
236
+ export interface ScanRow {
237
+ file: string;
238
+ verdict: 'clean' | 'infected' | 'error';
239
+ viruses?: string[];
240
+ }
241
+
242
+ /**
243
+ * Generate a self-contained HTML security dashboard report.
244
+ * Accepts an array of ScanRow objects or a DirectoryScanResult.
245
+ * When outputPath is set, the file is also written to disk.
246
+ * Returns the HTML string.
247
+ */
248
+ export declare function generateDashboard(
249
+ scanResults: ScanRow[] | DirectoryScanResult,
250
+ options?: DashboardOptions
251
+ ): string;
252
+
253
+ /** Options for generateShareCard */
254
+ export interface ShareCardOptions {
255
+ /** Write the SVG to this path (optional) */
256
+ outputPath?: string;
257
+ }
258
+
259
+ /**
260
+ * Generate a shareable SVG card showing the scan summary.
261
+ * Suitable for embedding in READMEs or sharing on social media.
262
+ * When outputPath is set, the file is also written to disk.
263
+ * Returns the SVG string.
264
+ */
265
+ export declare function generateShareCard(
266
+ scanResults: ScanRow[] | DirectoryScanResult,
267
+ options?: ShareCardOptions
268
+ ): string;