pompelmi 1.13.0 → 1.14.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 CHANGED
@@ -73,6 +73,9 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
73
73
  ## Features
74
74
 
75
75
  - Standalone CLI — scan files from any terminal with `npx pompelmi scan`
76
+ - HTML security dashboard — generate beautiful scan reports with `--report` ([docs](./docs/dashboard.html))
77
+ - SVG share card — shareable scan result card with `--share-card` ([docs](./docs/dashboard.html#share-card))
78
+ - GitHub App — one-click installation for organizations, zero-config PR scanning ([docs](./docs/github-app.html))
76
79
  - Single `scan(filePath, [options])` function — works locally or against a remote clamd instance
77
80
  - `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
78
81
  - `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
@@ -503,6 +506,8 @@ Scan any repository for viruses on every push or pull request — ClamAV is bund
503
506
 
504
507
  A ready-to-copy workflow is available at [`.github/workflows/action-example.yml`](./.github/workflows/action-example.yml). Full reference — inputs, outputs, layer caching, and more examples — in **[docs/github-action.md](./docs/github-action.md)**.
505
508
 
509
+ > **For organizations:** install the [pompelmi GitHub App](./docs/github-app.html) for zero-config scanning on every PR — no workflow file needed.
510
+
506
511
  ---
507
512
 
508
513
  ## Contributing
package/bin/pompelmi.js CHANGED
@@ -67,6 +67,10 @@ function parseArgs(argv) {
67
67
  quiet: false,
68
68
  delete: false,
69
69
  recursive: true,
70
+ report: false,
71
+ reportOutput: null,
72
+ shareCard: false,
73
+ shareCardOutput: null,
70
74
  };
71
75
 
72
76
  let i = 0;
@@ -92,6 +96,14 @@ function parseArgs(argv) {
92
96
  opts.timeout = parseInt(args[++i], 10);
93
97
  } else if (a === '--retries') {
94
98
  opts.retries = parseInt(args[++i], 10);
99
+ } else if (a === '--report') {
100
+ opts.report = true;
101
+ } else if (a === '--share-card') {
102
+ opts.shareCard = true;
103
+ } else if (a === '--output') {
104
+ const next = args[++i];
105
+ if (next && next.endsWith('.svg')) opts.shareCardOutput = next;
106
+ else opts.reportOutput = next;
95
107
  } else if (!a.startsWith('-') && opts.command && !opts.target) {
96
108
  opts.target = a;
97
109
  }
@@ -276,6 +288,26 @@ async function cmdScan(opts) {
276
288
 
277
289
  printResults(results, elapsed, opts);
278
290
 
291
+ if (opts.report) {
292
+ const { generateDashboard } = require('../src/Dashboard.js');
293
+ const outPath = opts.reportOutput || 'pompelmi-report.html';
294
+ generateDashboard(results, {
295
+ elapsed,
296
+ host: opts.host,
297
+ port: opts.port,
298
+ socket: opts.socket,
299
+ outputPath: outPath,
300
+ });
301
+ if (!opts.quiet) console.log(`\n Report saved: ${outPath}`);
302
+ }
303
+
304
+ if (opts.shareCard) {
305
+ const { generateShareCard } = require('../src/ShareCard.js');
306
+ const outPath = opts.shareCardOutput || 'pompelmi-scan-card.svg';
307
+ generateShareCard(results, { outputPath: outPath });
308
+ if (!opts.quiet) console.log(` Share card saved: ${outPath}`);
309
+ }
310
+
279
311
  const hasInfected = results.some(r => r.verdict === 'infected');
280
312
  const hasError = results.some(r => r.verdict === 'error');
281
313
  const clamdDown = results.some(r => r._clamdUnreachable);
@@ -361,6 +393,10 @@ SCAN OPTIONS
361
393
  --json Output results as JSON (no logo, no colors)
362
394
  --quiet, -q Only print infected files and summary
363
395
  --delete Delete infected files after confirmation
396
+ --report Generate an HTML security dashboard report
397
+ --share-card Generate a shareable SVG scan result card
398
+ --output <file> Output path for --report (default: pompelmi-report.html)
399
+ or --share-card (default: pompelmi-scan-card.svg)
364
400
 
365
401
  WATCH OPTIONS
366
402
  --host, --port, --socket, --timeout (same as scan)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -37,7 +37,7 @@
37
37
  "pompelmi": "./bin/pompelmi.js"
38
38
  },
39
39
  "scripts": {
40
- "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node test/scan.test.js",
40
+ "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node test/scan.test.js",
41
41
  "lint": "eslint src/"
42
42
  },
43
43
  "publishConfig": {
@@ -0,0 +1,83 @@
1
+ # @pompelmi/nextjs
2
+
3
+ Next.js middleware for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
4
+
5
+ Supports both the **App Router** (Next.js 13+) and the **Pages Router** (Next.js ≤ 12).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install pompelmi @pompelmi/nextjs
11
+ ```
12
+
13
+ ClamAV must be available on the server — either via the system `clamscan` binary or a running `clamd` daemon.
14
+
15
+ ## App Router (Next.js 13+)
16
+
17
+ ```js
18
+ // app/api/upload/route.js
19
+ import { withPompelmi } from '@pompelmi/nextjs'
20
+
21
+ export const POST = withPompelmi(async (req) => {
22
+ const formData = await req.formData()
23
+ const file = formData.get('file')
24
+ // req.pompelmiVerdict is set — Verdict.Clean is guaranteed here
25
+ return Response.json({ ok: true })
26
+ }, {
27
+ host: 'localhost',
28
+ port: 3310,
29
+ })
30
+ ```
31
+
32
+ With TypeScript:
33
+
34
+ ```ts
35
+ // app/api/upload/route.ts
36
+ import { withPompelmi } from '@pompelmi/nextjs'
37
+
38
+ export const POST = withPompelmi(async (req: Request) => {
39
+ return Response.json({ ok: true })
40
+ }, { host: 'localhost', port: 3310 })
41
+ ```
42
+
43
+ ## Pages Router (Next.js ≤ 12)
44
+
45
+ ```js
46
+ // pages/api/upload.js
47
+ import { withPompelmiHandler } from '@pompelmi/nextjs'
48
+
49
+ async function handler(req, res) {
50
+ // req.pompelmiVerdict is set — Verdict.Clean is guaranteed here
51
+ res.json({ ok: true })
52
+ }
53
+
54
+ export default withPompelmiHandler(handler, {
55
+ host: 'localhost',
56
+ port: 3310,
57
+ })
58
+ ```
59
+
60
+ ## Options
61
+
62
+ All options are forwarded to `pompelmi.scanBuffer()`:
63
+
64
+ | Option | Type | Default | Description |
65
+ |---|---|---|---|
66
+ | `host` | `string` | — | clamd hostname (TCP mode) |
67
+ | `port` | `number` | `3310` | clamd port |
68
+ | `socket` | `string` | — | UNIX socket path |
69
+ | `timeout` | `number` | `15000` | Connection timeout in ms |
70
+ | `retries` | `number` | `0` | Auto-retry count on failure |
71
+
72
+ When neither `host` nor `socket` is provided, pompelmi falls back to the local `clamscan` binary.
73
+
74
+ ## Behaviour
75
+
76
+ - The raw request body is buffered and scanned **before** your handler runs.
77
+ - If the body is malicious, a **400 JSON** response is returned immediately: `{ "error": "Malicious file detected" }`.
78
+ - `req.pompelmiVerdict` is set to the `Verdict` symbol so your handler can inspect it if needed.
79
+ - Scan errors (e.g. clamd unreachable) are **not** blocking — the request proceeds with `Verdict.ScanError`.
80
+
81
+ ## License
82
+
83
+ ISC
@@ -0,0 +1,45 @@
1
+ import type { VerdictValue, ScanOptions } from 'pompelmi';
2
+
3
+ /** Options accepted by withPompelmi / withPompelmiHandler */
4
+ export interface PompelmiNextOptions extends ScanOptions {}
5
+
6
+ /**
7
+ * App Router (Next.js 13+) wrapper.
8
+ * Scans the raw request body before the handler runs.
9
+ * Returns HTTP 400 if the body is malicious.
10
+ *
11
+ * @example
12
+ * // app/api/upload/route.ts
13
+ * import { withPompelmi } from '@pompelmi/nextjs'
14
+ *
15
+ * export const POST = withPompelmi(async (req) => {
16
+ * return Response.json({ ok: true })
17
+ * }, { host: 'localhost', port: 3310 })
18
+ */
19
+ export declare function withPompelmi(
20
+ handler: (req: Request, ctx?: unknown) => Promise<Response>,
21
+ options?: PompelmiNextOptions
22
+ ): (req: Request, ctx?: unknown) => Promise<Response>;
23
+
24
+ /**
25
+ * Pages Router (Next.js ≤ 12) wrapper.
26
+ * Scans the raw request body before the handler runs.
27
+ * Sends HTTP 400 if the body is malicious.
28
+ *
29
+ * @example
30
+ * // pages/api/upload.ts
31
+ * import { withPompelmiHandler } from '@pompelmi/nextjs'
32
+ * import type { NextApiRequest, NextApiResponse } from 'next'
33
+ *
34
+ * async function handler(req: NextApiRequest, res: NextApiResponse) {
35
+ * res.json({ ok: true })
36
+ * }
37
+ *
38
+ * export default withPompelmiHandler(handler, { host: 'localhost', port: 3310 })
39
+ */
40
+ export declare function withPompelmiHandler(
41
+ handler: (req: import('http').IncomingMessage, res: import('http').ServerResponse) => void | Promise<void>,
42
+ options?: PompelmiNextOptions
43
+ ): (req: import('http').IncomingMessage, res: import('http').ServerResponse) => Promise<void>;
44
+
45
+ export { Verdict } from 'pompelmi';
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const { scanBuffer, Verdict } = require('pompelmi');
4
+
5
+ /**
6
+ * Build a pompelmi scan options object from the plugin options,
7
+ * excluding non-scan keys.
8
+ */
9
+ function buildScanOpts(options) {
10
+ const keys = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
11
+ const out = {};
12
+ for (const k of keys) {
13
+ if (options[k] !== undefined) out[k] = options[k];
14
+ }
15
+ return out;
16
+ }
17
+
18
+ /**
19
+ * Read a Request body as a Buffer.
20
+ * Supports Web API Request (App Router) and Node.js IncomingMessage (Pages Router).
21
+ */
22
+ async function bodyBuffer(req) {
23
+ if (typeof req.arrayBuffer === 'function') {
24
+ // Web API Request (Next.js App Router)
25
+ const ab = await req.arrayBuffer();
26
+ return Buffer.from(ab);
27
+ }
28
+ // Node.js IncomingMessage (Pages Router)
29
+ return new Promise((resolve, reject) => {
30
+ const chunks = [];
31
+ req.on('data', c => chunks.push(c));
32
+ req.on('end', () => resolve(Buffer.concat(chunks)));
33
+ req.on('error', reject);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * App Router wrapper (Next.js 13+).
39
+ *
40
+ * Wraps a route handler so that the raw request body is scanned before the
41
+ * handler runs. req.pompelmiVerdict is set to the scan verdict symbol.
42
+ * If the body is malicious a 400 JSON response is returned immediately.
43
+ *
44
+ * @param {Function} handler - async (req, ctx) => Response
45
+ * @param {object} options - ScanOptions (host, port, socket, …)
46
+ * @returns {Function} Next.js App Router handler
47
+ *
48
+ * @example
49
+ * // app/api/upload/route.js
50
+ * import { withPompelmi } from '@pompelmi/nextjs'
51
+ *
52
+ * export const POST = withPompelmi(async (req) => {
53
+ * return Response.json({ ok: true })
54
+ * }, { host: 'localhost', port: 3310 })
55
+ */
56
+ function withPompelmi(handler, options = {}) {
57
+ const scanOpts = buildScanOpts(options);
58
+
59
+ return async function pompelmiHandler(req, ctx) {
60
+ let buf;
61
+ try {
62
+ buf = await bodyBuffer(req);
63
+ } catch (_) {
64
+ return new Response(JSON.stringify({ error: 'Failed to read request body' }), {
65
+ status: 400,
66
+ headers: { 'Content-Type': 'application/json' },
67
+ });
68
+ }
69
+
70
+ const verdict = await scanBuffer(buf, scanOpts);
71
+
72
+ if (verdict === Verdict.Malicious) {
73
+ return new Response(JSON.stringify({ error: 'Malicious file detected' }), {
74
+ status: 400,
75
+ headers: { 'Content-Type': 'application/json' },
76
+ });
77
+ }
78
+
79
+ // Attach verdict so the handler can inspect it if needed
80
+ req.pompelmiVerdict = verdict;
81
+ return handler(req, ctx);
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Pages Router wrapper (Next.js ≤ 12 / Pages API routes).
87
+ *
88
+ * Wraps a Next.js API handler so that the raw request body is scanned before
89
+ * the handler runs. req.pompelmiVerdict is set to the scan verdict symbol.
90
+ * If the body is malicious a 400 JSON response is sent immediately.
91
+ *
92
+ * @param {Function} handler - (req, res) => void | Promise<void>
93
+ * @param {object} options - ScanOptions (host, port, socket, …)
94
+ * @returns {Function} Next.js Pages API handler
95
+ *
96
+ * @example
97
+ * // pages/api/upload.js
98
+ * import { withPompelmiHandler } from '@pompelmi/nextjs'
99
+ *
100
+ * async function handler(req, res) {
101
+ * res.json({ ok: true })
102
+ * }
103
+ *
104
+ * export default withPompelmiHandler(handler, { host: 'localhost', port: 3310 })
105
+ */
106
+ function withPompelmiHandler(handler, options = {}) {
107
+ const scanOpts = buildScanOpts(options);
108
+
109
+ return async function pompelmiPagesHandler(req, res) {
110
+ let buf;
111
+ try {
112
+ buf = await bodyBuffer(req);
113
+ } catch (_) {
114
+ res.status(400).json({ error: 'Failed to read request body' });
115
+ return;
116
+ }
117
+
118
+ const verdict = await scanBuffer(buf, scanOpts);
119
+
120
+ if (verdict === Verdict.Malicious) {
121
+ res.status(400).json({ error: 'Malicious file detected' });
122
+ return;
123
+ }
124
+
125
+ req.pompelmiVerdict = verdict;
126
+ return handler(req, res);
127
+ };
128
+ }
129
+
130
+ module.exports = { withPompelmi, withPompelmiHandler, Verdict };
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@pompelmi/nextjs",
3
+ "version": "1.0.0",
4
+ "description": "Next.js middleware for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/nextjs"
12
+ },
13
+ "keywords": [
14
+ "nextjs",
15
+ "next",
16
+ "clamav",
17
+ "antivirus",
18
+ "virus-scan",
19
+ "malware",
20
+ "file-upload",
21
+ "security",
22
+ "pompelmi",
23
+ "app-router",
24
+ "pages-router"
25
+ ],
26
+ "main": "./index.js",
27
+ "types": "./index.d.ts",
28
+ "scripts": {
29
+ "test": "node --test test/index.test.js"
30
+ },
31
+ "peerDependencies": {
32
+ "next": ">=13",
33
+ "pompelmi": ">=1.13.0"
34
+ },
35
+ "publishConfig": {
36
+ "registry": "https://registry.npmjs.org/",
37
+ "access": "public"
38
+ }
39
+ }
@@ -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 };
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;