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 +5 -0
- package/bin/pompelmi.js +36 -0
- package/package.json +2 -2
- package/packages/nextjs/README.md +83 -0
- package/packages/nextjs/index.d.ts +45 -0
- package/packages/nextjs/index.js +130 -0
- package/packages/nextjs/package.json +39 -0
- package/src/Dashboard.js +370 -0
- package/src/ShareCard.js +124 -0
- package/src/index.js +3 -1
- package/types/index.d.ts +51 -0
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.
|
|
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
|
+
}
|
package/src/Dashboard.js
ADDED
|
@@ -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, '&')
|
|
40
|
+
.replace(/</g, '<')
|
|
41
|
+
.replace(/>/g, '>')
|
|
42
|
+
.replace(/"/g, '"');
|
|
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>⚠ 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> —
|
|
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 };
|
package/src/ShareCard.js
ADDED
|
@@ -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, '&')
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"');
|
|
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;
|