pompelmi 1.6.0 → 1.8.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/BADGE.md +29 -0
- package/README.md +81 -156
- package/action/Dockerfile +18 -0
- package/action/entrypoint.sh +23 -0
- package/action/package.json +11 -0
- package/action/scanner.js +165 -0
- package/action.yml +29 -0
- package/package.json +1 -1
- package/src/index.js +2 -1
- package/src/middleware.js +36 -0
package/BADGE.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Scanned by pompelmi — Badge
|
|
2
|
+
|
|
3
|
+
Add this badge to your repository's `README.md` to show that your file uploads are scanned with pompelmi.
|
|
4
|
+
|
|
5
|
+
## Markdown
|
|
6
|
+
|
|
7
|
+
```markdown
|
|
8
|
+
[](https://github.com/pompelmi/pompelmi)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## HTML
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<a href="https://github.com/pompelmi/pompelmi">
|
|
15
|
+
<img src="https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github" alt="Scanned by pompelmi">
|
|
16
|
+
</a>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## RST (reStructuredText)
|
|
20
|
+
|
|
21
|
+
```rst
|
|
22
|
+
.. image:: https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github
|
|
23
|
+
:target: https://github.com/pompelmi/pompelmi
|
|
24
|
+
:alt: Scanned by pompelmi
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Preview
|
|
28
|
+
|
|
29
|
+
[](https://github.com/pompelmi/pompelmi)
|
package/README.md
CHANGED
|
@@ -15,10 +15,22 @@
|
|
|
15
15
|
<img src="https://img.shields.io/badge/license-ISC-blue" alt="license">
|
|
16
16
|
<a href="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml"><img src="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
17
17
|
<a href="https://github.com/pompelmi/pompelmi/actions/workflows/release.yml"><img src="https://github.com/pompelmi/pompelmi/actions/workflows/release.yml/badge.svg" alt="npm publish"></a>
|
|
18
|
+
<a href="https://github.com/pompelmi/pompelmi"><img src="https://img.shields.io/badge/scanned%20by-pompelmi-orange?logo=github" alt="Scanned by pompelmi"></a>
|
|
18
19
|
</p>
|
|
19
20
|
|
|
20
21
|
---
|
|
21
22
|
|
|
23
|
+
## Documentation
|
|
24
|
+
|
|
25
|
+
| Guide | Description |
|
|
26
|
+
|-------|-------------|
|
|
27
|
+
| [Getting Started](./docs/getting-started.md) | Installation, prerequisites, quickstart examples |
|
|
28
|
+
| [API Reference](./docs/api.md) | Full function signatures, options, verdicts, error conditions |
|
|
29
|
+
| [Docker / Remote Scanning](./docs/docker.md) | TCP sidecar, UNIX socket mount, docker-compose patterns |
|
|
30
|
+
| [GitHub Action](./docs/github-action.md) | CI scanning, inputs/outputs, caching, example workflows |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
22
34
|
## Overview
|
|
23
35
|
|
|
24
36
|
pompelmi is a minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that exposes a single async function — `scan()` — and returns one of three typed verdict Symbols: `Verdict.Clean`, `Verdict.Malicious`, or `Verdict.ScanError`. Full documentation at [pompelmi.app](https://pompelmi.app).
|
|
@@ -265,23 +277,17 @@ if (result === Verdict.ScanError) console.warn('Scan incomplete.');
|
|
|
265
277
|
|
|
266
278
|
Pass `host` and `port` (or `socket`) to switch from the local `clamscan` CLI to the clamd daemon. Everything else — the returned verdicts, error types — is identical.
|
|
267
279
|
|
|
268
|
-
**TCP
|
|
280
|
+
**TCP:**
|
|
269
281
|
```js
|
|
270
|
-
const result = await scan('/path/to/file.zip', {
|
|
271
|
-
host: '127.0.0.1',
|
|
272
|
-
port: 3310,
|
|
273
|
-
timeout: 30_000, // socket idle timeout, ms — default 15 000
|
|
274
|
-
});
|
|
282
|
+
const result = await scan('/path/to/file.zip', { host: '127.0.0.1', port: 3310 });
|
|
275
283
|
```
|
|
276
284
|
|
|
277
|
-
**UNIX socket
|
|
285
|
+
**UNIX socket:**
|
|
278
286
|
```js
|
|
279
|
-
const result = await scan('/path/to/file.zip', {
|
|
280
|
-
socket: '/run/clamav/clamd.sock', // path to clamd's UNIX domain socket
|
|
281
|
-
});
|
|
287
|
+
const result = await scan('/path/to/file.zip', { socket: '/run/clamav/clamd.sock' });
|
|
282
288
|
```
|
|
283
289
|
|
|
284
|
-
|
|
290
|
+
See **[docs/docker.md](./docs/docker.md)** for Docker Compose examples, UNIX socket volume mounts, `scanBuffer` / `scanStream` in clamd mode, and connection retry patterns.
|
|
285
291
|
|
|
286
292
|
---
|
|
287
293
|
|
|
@@ -302,155 +308,24 @@ When none of `socket`, `host`, or `port` is provided, pompelmi spawns `clamscan
|
|
|
302
308
|
|
|
303
309
|
## API Reference
|
|
304
310
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
```ts
|
|
308
|
-
scan(
|
|
309
|
-
filePath: string,
|
|
310
|
-
options?: { socket?: string; host?: string; port?: number; timeout?: number }
|
|
311
|
-
): Promise<symbol>
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
**Returns** a Promise that resolves to one of:
|
|
315
|
-
|
|
316
|
-
| Verdict | ClamAV exit code / response | Meaning |
|
|
317
|
-
|---------------------|-----------------------------|-------------------------------------------------------------------------|
|
|
318
|
-
| `Verdict.Clean` | `0` / `stream: OK` | No threats found. |
|
|
319
|
-
| `Verdict.Malicious` | `1` / `<name> FOUND` | A known virus or malware signature was matched. |
|
|
320
|
-
| `Verdict.ScanError` | `2` / other response | Scan failed — I/O error, encrypted archive, permission denied. Treat file as untrusted. |
|
|
321
|
-
|
|
322
|
-
**Rejects** with an `Error` in these cases:
|
|
323
|
-
|
|
324
|
-
| Condition | Error message |
|
|
325
|
-
|---------------------------------------|-----------------------------------|
|
|
326
|
-
| `filePath` is not a string | `filePath must be a string` |
|
|
327
|
-
| File does not exist | `File not found: <path>` |
|
|
328
|
-
| `clamscan` not in PATH | `ENOENT` (from the OS) |
|
|
329
|
-
| ClamAV returns an unknown exit code | `Unexpected exit code: N` |
|
|
330
|
-
| Process killed by signal | `Process killed by signal: <SIG>` |
|
|
331
|
-
| clamd connection timed out | `clamd connection timed out after Nms` |
|
|
332
|
-
|
|
333
|
-
Each `Verdict` Symbol exposes a `.description` property for safe serialisation:
|
|
334
|
-
|
|
335
|
-
```js
|
|
336
|
-
Verdict.Clean.description // 'Clean'
|
|
337
|
-
Verdict.Malicious.description // 'Malicious'
|
|
338
|
-
Verdict.ScanError.description // 'ScanError'
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
---
|
|
342
|
-
|
|
343
|
-
### `scanBuffer(buffer, [options])`
|
|
311
|
+
See **[docs/api.md](./docs/api.md)** for the full reference: function signatures, options table, verdict Symbols, error conditions, and error handling patterns.
|
|
344
312
|
|
|
345
|
-
|
|
346
|
-
scanBuffer(
|
|
347
|
-
buffer: Buffer,
|
|
348
|
-
options?: { socket?: string; host?: string; port?: number; timeout?: number }
|
|
349
|
-
): Promise<symbol>
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
| Parameter | Type | Description |
|
|
353
|
-
|---|---|---|
|
|
354
|
-
| `buffer` | `Buffer` | The in-memory buffer to scan |
|
|
355
|
-
| `options` | `object` | Same options as `scan()` — socket, host, port, timeout |
|
|
356
|
-
|
|
357
|
-
**Returns** the same three Symbol verdicts as `scan()`: `Verdict.Clean`, `Verdict.Malicious`, `Verdict.ScanError`.
|
|
313
|
+
**Quick summary:**
|
|
358
314
|
|
|
359
|
-
|
|
315
|
+
| Function | Input | clamd mode disk I/O |
|
|
316
|
+
|----------|-------|---------------------|
|
|
317
|
+
| `scan(filePath, [options])` | File path on disk | None (streamed) |
|
|
318
|
+
| `scanBuffer(buffer, [options])` | `Buffer` | None (streamed) |
|
|
319
|
+
| `scanStream(stream, [options])` | Node.js `Readable` | None (streamed) |
|
|
320
|
+
| `scanDirectory(dirPath, [options])` | Directory path | None (streamed) |
|
|
360
321
|
|
|
361
|
-
|
|
362
|
-
|---|---|
|
|
363
|
-
| `buffer` is not a Buffer | `buffer must be a Buffer` |
|
|
364
|
-
| `buffer` is empty | `buffer is empty` |
|
|
322
|
+
All four functions accept the same `options` object and resolve to the same three verdict Symbols:
|
|
365
323
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
```ts
|
|
373
|
-
scanStream(
|
|
374
|
-
stream: Readable,
|
|
375
|
-
options?: { socket?: string; host?: string; port?: number; timeout?: number }
|
|
376
|
-
): Promise<symbol>
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
| Parameter | Type | Description |
|
|
380
|
-
|---|---|---|
|
|
381
|
-
| `stream` | `Readable` | Node.js Readable stream to scan |
|
|
382
|
-
| `options` | `object` | Same options as `scan()` — socket, host, port, timeout |
|
|
383
|
-
|
|
384
|
-
**Returns** the same three Symbol verdicts as `scan()`: `Verdict.Clean`, `Verdict.Malicious`, `Verdict.ScanError`.
|
|
385
|
-
|
|
386
|
-
**Rejects** with the same error types as `scan()` where applicable, plus:
|
|
387
|
-
|
|
388
|
-
| Condition | Error message |
|
|
389
|
-
|---|---|
|
|
390
|
-
| `stream` is not a Readable | `stream must be a Readable` |
|
|
391
|
-
| Stream emits error | propagated as-is |
|
|
392
|
-
|
|
393
|
-
In clamd mode (`socket`, `host`, or `port` provided), the stream is piped directly to clamd via the INSTREAM protocol — no data is written to disk. In local mode, the stream is piped to a temp file in `os.tmpdir()` that is deleted automatically in a `finally` block.
|
|
394
|
-
|
|
395
|
-
---
|
|
396
|
-
|
|
397
|
-
### `scanDirectory(dirPath, [options])`
|
|
398
|
-
|
|
399
|
-
```ts
|
|
400
|
-
scanDirectory(
|
|
401
|
-
dirPath: string,
|
|
402
|
-
options?: { socket?: string; host?: string; port?: number; timeout?: number }
|
|
403
|
-
): Promise<{ clean: string[], malicious: string[], errors: string[] }>
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
Recursively scans every file in `dirPath` and returns three arrays of absolute paths.
|
|
407
|
-
|
|
408
|
-
| Field | Type | Description |
|
|
409
|
-
|---|---|---|
|
|
410
|
-
| `clean` | `string[]` | Paths of files with no threats found |
|
|
411
|
-
| `malicious` | `string[]` | Paths of files with a matched signature |
|
|
412
|
-
| `errors` | `string[]` | Paths of files that could not be scanned |
|
|
413
|
-
|
|
414
|
-
Per-file scan failures are caught and collected into `errors` — the function never throws because of an individual file.
|
|
415
|
-
|
|
416
|
-
**Rejects** with an `Error` in these cases:
|
|
417
|
-
|
|
418
|
-
| Condition | Error message |
|
|
419
|
-
|---|---|
|
|
420
|
-
| `dirPath` is not a string | `dirPath must be a string` |
|
|
421
|
-
| Directory does not exist | `Directory not found: <path>` |
|
|
422
|
-
|
|
423
|
-
---
|
|
424
|
-
|
|
425
|
-
### `ClamAVInstaller()` _(internal)_
|
|
426
|
-
|
|
427
|
-
Installs ClamAV using the platform's native package manager. Resolves immediately if ClamAV is already installed.
|
|
428
|
-
|
|
429
|
-
```ts
|
|
430
|
-
ClamAVInstaller(): Promise<string>
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
| Platform | Package manager | Command |
|
|
434
|
-
|----------|-----------------|-------------------------------------------|
|
|
435
|
-
| macOS | Homebrew | `brew install clamav` |
|
|
436
|
-
| Linux | apt-get | `sudo apt-get install -y clamav clamav-daemon` |
|
|
437
|
-
| Windows | Chocolatey | `choco install clamav -y` |
|
|
438
|
-
|
|
439
|
-
---
|
|
440
|
-
|
|
441
|
-
### `updateClamAVDatabase()` _(internal)_
|
|
442
|
-
|
|
443
|
-
Runs `freshclam` to download or refresh the virus definition database. Skips if the database file is already present.
|
|
444
|
-
|
|
445
|
-
```ts
|
|
446
|
-
updateClamAVDatabase(): Promise<string>
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
| Platform | Database path |
|
|
450
|
-
|----------|---------------------------------------|
|
|
451
|
-
| macOS | `/usr/local/share/clamav/main.cvd` |
|
|
452
|
-
| Linux | `/var/lib/clamav/main.cvd` |
|
|
453
|
-
| Windows | `C:\ProgramData\ClamAV\main.cvd` |
|
|
324
|
+
| Symbol | Meaning |
|
|
325
|
+
|--------|---------|
|
|
326
|
+
| `Verdict.Clean` | No threats found |
|
|
327
|
+
| `Verdict.Malicious` | Known signature matched |
|
|
328
|
+
| `Verdict.ScanError` | Scan could not complete — treat as untrusted |
|
|
454
329
|
|
|
455
330
|
---
|
|
456
331
|
|
|
@@ -499,6 +374,56 @@ The [`examples/`](./examples/) directory contains standalone runnable scripts. E
|
|
|
499
374
|
|
|
500
375
|
---
|
|
501
376
|
|
|
377
|
+
## GitHub Action
|
|
378
|
+
|
|
379
|
+
[](https://github.com/marketplace/actions/pompelmi-clamav-scanner)
|
|
380
|
+
|
|
381
|
+
Scan any repository for viruses on every push or pull request — ClamAV is bundled inside a Docker container, virus definitions are auto-updated at runtime, and no external services are required.
|
|
382
|
+
|
|
383
|
+
### Minimal usage
|
|
384
|
+
|
|
385
|
+
```yaml
|
|
386
|
+
- uses: actions/checkout@v4
|
|
387
|
+
|
|
388
|
+
- name: Virus scan
|
|
389
|
+
uses: pompelmi/pompelmi@v1.7.0
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Full example
|
|
393
|
+
|
|
394
|
+
```yaml
|
|
395
|
+
- uses: actions/checkout@v4
|
|
396
|
+
|
|
397
|
+
- name: Virus scan
|
|
398
|
+
id: scan
|
|
399
|
+
uses: pompelmi/pompelmi@v1.7.0
|
|
400
|
+
with:
|
|
401
|
+
path: 'uploads/' # scan a subdirectory instead of the whole workspace
|
|
402
|
+
fail-on-virus: 'true' # fail the workflow step on detection (default)
|
|
403
|
+
|
|
404
|
+
- name: Print infected files
|
|
405
|
+
if: always()
|
|
406
|
+
run: echo "${{ steps.scan.outputs.infected-files }}"
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Inputs
|
|
410
|
+
|
|
411
|
+
| Input | Description | Default |
|
|
412
|
+
|-------|-------------|---------|
|
|
413
|
+
| `path` | Directory or file to scan | `.` (full workspace) |
|
|
414
|
+
| `fail-on-virus` | Fail the workflow step when infected files are found | `true` |
|
|
415
|
+
|
|
416
|
+
### Outputs
|
|
417
|
+
|
|
418
|
+
| Output | Description |
|
|
419
|
+
|--------|-------------|
|
|
420
|
+
| `infected-files` | Newline-separated list of infected file paths (empty when clean) |
|
|
421
|
+
| `status` | `"clean"` or `"infected"` |
|
|
422
|
+
|
|
423
|
+
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)**.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
502
427
|
## Contributing
|
|
503
428
|
|
|
504
429
|
Full documentation and guides are available in the [Wiki](https://github.com/pompelmi/pompelmi/wiki).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
FROM node:20-slim
|
|
2
|
+
|
|
3
|
+
RUN apt-get update && \
|
|
4
|
+
apt-get install -y --no-install-recommends clamav clamav-freshclam && \
|
|
5
|
+
rm -rf /var/lib/apt/lists/* && \
|
|
6
|
+
sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf && \
|
|
7
|
+
mkdir -p /var/lib/clamav /run/clamav
|
|
8
|
+
|
|
9
|
+
WORKDIR /action
|
|
10
|
+
|
|
11
|
+
RUN npm install pompelmi
|
|
12
|
+
|
|
13
|
+
COPY entrypoint.sh ./entrypoint.sh
|
|
14
|
+
COPY scanner.js ./scanner.js
|
|
15
|
+
|
|
16
|
+
RUN chmod +x ./entrypoint.sh
|
|
17
|
+
|
|
18
|
+
ENTRYPOINT ["/action/entrypoint.sh"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
SCAN_PATH="${1:-.}"
|
|
5
|
+
FAIL_ON_VIRUS="${2:-true}"
|
|
6
|
+
|
|
7
|
+
# Resolve relative paths against the GitHub workspace mount point
|
|
8
|
+
WORKSPACE="${GITHUB_WORKSPACE:-/github/workspace}"
|
|
9
|
+
case "$SCAN_PATH" in
|
|
10
|
+
/*) FULL_PATH="$SCAN_PATH" ;;
|
|
11
|
+
*) FULL_PATH="${WORKSPACE}/${SCAN_PATH}" ;;
|
|
12
|
+
esac
|
|
13
|
+
|
|
14
|
+
echo "::group::Updating ClamAV virus definitions (freshclam)"
|
|
15
|
+
# Remove any stale lock left from previous container runs
|
|
16
|
+
rm -f /run/clamav/freshclam.pid /run/lock/freshclam 2>/dev/null || true
|
|
17
|
+
freshclam --quiet 2>&1 \
|
|
18
|
+
&& echo "Definitions updated successfully." \
|
|
19
|
+
|| echo "Warning: freshclam update failed — scanning with cached definitions."
|
|
20
|
+
echo "::endgroup::"
|
|
21
|
+
|
|
22
|
+
echo "Scanning: ${FULL_PATH}"
|
|
23
|
+
exec node /action/scanner.js "${FULL_PATH}" "${FAIL_ON_VIRUS}"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { scan, scanDirectory, Verdict } = require('pompelmi');
|
|
6
|
+
|
|
7
|
+
const scanPath = process.argv[2] || '.';
|
|
8
|
+
const failOnVirus = process.argv[3] !== 'false';
|
|
9
|
+
|
|
10
|
+
async function writeReport(clean, malicious, errors, outputDir) {
|
|
11
|
+
const total = clean.length + malicious.length + errors.length;
|
|
12
|
+
const status = malicious.length > 0 ? 'infected' : 'clean';
|
|
13
|
+
|
|
14
|
+
const rows = [
|
|
15
|
+
...clean.map(f => ({ file: f, status: 'clean', verdict: 'Clean' })),
|
|
16
|
+
...malicious.map(f => ({ file: f, status: 'infected', verdict: 'Malicious' })),
|
|
17
|
+
...errors.map(f => ({ file: f, status: 'error', verdict: 'ScanError' })),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const json = JSON.stringify({ status, total, clean: clean.length, malicious: malicious.length, errors: errors.length, files: rows }, null, 2);
|
|
21
|
+
fs.writeFileSync(path.join(outputDir, 'report.json'), json);
|
|
22
|
+
|
|
23
|
+
const tableRows = rows.map(r => {
|
|
24
|
+
const color = r.status === 'clean' ? '#22c55e' : r.status === 'infected' ? '#ef4444' : '#f59e0b';
|
|
25
|
+
return `<tr><td>${escHtml(r.file)}</td><td style="color:${color};font-weight:bold">${r.verdict}</td></tr>`;
|
|
26
|
+
}).join('\n');
|
|
27
|
+
|
|
28
|
+
const html = `<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8">
|
|
32
|
+
<title>Pompelmi Scan Report</title>
|
|
33
|
+
<style>
|
|
34
|
+
body { font-family: sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; }
|
|
35
|
+
h1 { font-size: 1.5rem; }
|
|
36
|
+
.summary { display: flex; gap: 24px; margin: 16px 0; }
|
|
37
|
+
.stat { background: #f1f5f9; border-radius: 8px; padding: 12px 20px; text-align: center; }
|
|
38
|
+
.stat span { display: block; font-size: 1.8rem; font-weight: bold; }
|
|
39
|
+
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
|
|
40
|
+
th { background: #0f172a; color: #fff; text-align: left; padding: 8px 12px; }
|
|
41
|
+
td { padding: 8px 12px; border-bottom: 1px solid #e2e8f0; word-break: break-all; }
|
|
42
|
+
tr:hover td { background: #f8fafc; }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<h1>${status === 'clean' ? '✅' : '❌'} Pompelmi Scan Report</h1>
|
|
47
|
+
<div class="summary">
|
|
48
|
+
<div class="stat"><span>${total}</span>Total</div>
|
|
49
|
+
<div class="stat"><span style="color:#22c55e">${clean.length}</span>Clean</div>
|
|
50
|
+
<div class="stat"><span style="color:#ef4444">${malicious.length}</span>Infected</div>
|
|
51
|
+
<div class="stat"><span style="color:#f59e0b">${errors.length}</span>Errors</div>
|
|
52
|
+
</div>
|
|
53
|
+
<table>
|
|
54
|
+
<thead><tr><th>File</th><th>Verdict</th></tr></thead>
|
|
55
|
+
<tbody>
|
|
56
|
+
${tableRows}
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</body>
|
|
60
|
+
</html>`;
|
|
61
|
+
fs.writeFileSync(path.join(outputDir, 'report.html'), html);
|
|
62
|
+
|
|
63
|
+
return { jsonPath: path.join(outputDir, 'report.json'), htmlPath: path.join(outputDir, 'report.html') };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escHtml(s) {
|
|
67
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function uploadArtifact(jsonPath, htmlPath) {
|
|
71
|
+
try {
|
|
72
|
+
const { DefaultArtifactClient } = require('@actions/artifact');
|
|
73
|
+
const client = new DefaultArtifactClient();
|
|
74
|
+
await client.uploadArtifact('pompelmi-scan-report', [jsonPath, htmlPath], path.dirname(jsonPath));
|
|
75
|
+
console.log('Scan report artifact uploaded: pompelmi-scan-report');
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.warn(`Could not upload artifact (not running in Actions?): ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main() {
|
|
82
|
+
const resolved = path.resolve(scanPath);
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(resolved)) {
|
|
85
|
+
console.error(`::error::Path not found: ${resolved}`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let clean = [], malicious = [], errors = [];
|
|
90
|
+
const stat = fs.statSync(resolved);
|
|
91
|
+
|
|
92
|
+
if (stat.isDirectory()) {
|
|
93
|
+
const result = await scanDirectory(resolved);
|
|
94
|
+
clean = result.clean;
|
|
95
|
+
malicious = result.malicious;
|
|
96
|
+
errors = result.errors;
|
|
97
|
+
} else {
|
|
98
|
+
const verdict = await scan(resolved);
|
|
99
|
+
if (verdict === Verdict.Clean) clean.push(resolved);
|
|
100
|
+
else if (verdict === Verdict.Malicious) malicious.push(resolved);
|
|
101
|
+
else errors.push(resolved);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const total = clean.length + malicious.length + errors.length;
|
|
105
|
+
const status = malicious.length > 0 ? 'infected' : 'clean';
|
|
106
|
+
|
|
107
|
+
// --- GitHub outputs ---
|
|
108
|
+
const outputFile = process.env.GITHUB_OUTPUT;
|
|
109
|
+
if (outputFile) {
|
|
110
|
+
const lines = [
|
|
111
|
+
`status=${status}`,
|
|
112
|
+
`infected-files<<POMPELMI_EOF`,
|
|
113
|
+
malicious.join('\n'),
|
|
114
|
+
`POMPELMI_EOF`,
|
|
115
|
+
].join('\n') + '\n';
|
|
116
|
+
fs.appendFileSync(outputFile, lines);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Job summary ---
|
|
120
|
+
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
|
121
|
+
if (summaryFile) {
|
|
122
|
+
const icon = status === 'clean' ? '✅' : '❌';
|
|
123
|
+
const rows = [
|
|
124
|
+
`## ${icon} ClamAV Scan Results`,
|
|
125
|
+
'',
|
|
126
|
+
`| Metric | Count |`,
|
|
127
|
+
`|--------|-------|`,
|
|
128
|
+
`| Files scanned | ${total} |`,
|
|
129
|
+
`| Clean | ${clean.length} |`,
|
|
130
|
+
`| Infected | **${malicious.length}** |`,
|
|
131
|
+
`| Errors | ${errors.length} |`,
|
|
132
|
+
];
|
|
133
|
+
if (malicious.length > 0) {
|
|
134
|
+
rows.push('', '### Infected Files', '');
|
|
135
|
+
malicious.forEach(f => rows.push(`- \`${f}\``));
|
|
136
|
+
}
|
|
137
|
+
fs.appendFileSync(summaryFile, rows.join('\n') + '\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Report artifact ---
|
|
141
|
+
const reportDir = process.env.RUNNER_TEMP || '/tmp';
|
|
142
|
+
const { jsonPath, htmlPath } = await writeReport(clean, malicious, errors, reportDir);
|
|
143
|
+
await uploadArtifact(jsonPath, htmlPath);
|
|
144
|
+
|
|
145
|
+
// --- Console ---
|
|
146
|
+
console.log(`\nScan complete — ${total} file(s) scanned`);
|
|
147
|
+
console.log(` Clean: ${clean.length}`);
|
|
148
|
+
console.log(` Infected: ${malicious.length}`);
|
|
149
|
+
console.log(` Errors: ${errors.length}`);
|
|
150
|
+
console.log(` Status: ${status.toUpperCase()}`);
|
|
151
|
+
|
|
152
|
+
if (malicious.length > 0) {
|
|
153
|
+
console.error('\nInfected files:');
|
|
154
|
+
malicious.forEach(f => console.error(` ${f}`));
|
|
155
|
+
if (failOnVirus) {
|
|
156
|
+
console.error('\n::error::Virus(es) detected — failing workflow.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main().catch(err => {
|
|
163
|
+
console.error(`::error::Scanner crashed: ${err.message}`);
|
|
164
|
+
process.exit(2);
|
|
165
|
+
});
|
package/action.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: 'Pompelmi ClamAV Scanner'
|
|
2
|
+
description: 'Scan for viruses with ClamAV (bundled) — no daemon, no cloud, zero external dependencies'
|
|
3
|
+
author: 'pompelmi'
|
|
4
|
+
branding:
|
|
5
|
+
icon: 'shield'
|
|
6
|
+
color: 'orange'
|
|
7
|
+
|
|
8
|
+
inputs:
|
|
9
|
+
path:
|
|
10
|
+
description: 'Directory or file to scan (default: full workspace)'
|
|
11
|
+
required: false
|
|
12
|
+
default: '.'
|
|
13
|
+
fail-on-virus:
|
|
14
|
+
description: 'Fail the workflow step when infected files are found'
|
|
15
|
+
required: false
|
|
16
|
+
default: 'true'
|
|
17
|
+
|
|
18
|
+
outputs:
|
|
19
|
+
infected-files:
|
|
20
|
+
description: 'Newline-separated list of infected file paths (empty when clean)'
|
|
21
|
+
status:
|
|
22
|
+
description: '"clean" or "infected"'
|
|
23
|
+
|
|
24
|
+
runs:
|
|
25
|
+
using: 'docker'
|
|
26
|
+
image: './action/Dockerfile'
|
|
27
|
+
args:
|
|
28
|
+
- ${{ inputs.path }}
|
|
29
|
+
- ${{ inputs.fail-on-virus }}
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { scan, scanBuffer, scanStream, scanDirectory } = require('./ClamAVScanner.js');
|
|
2
2
|
const { Verdict } = require('./verdicts.js');
|
|
3
|
+
const { middleware } = require('./middleware.js');
|
|
3
4
|
|
|
4
|
-
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict };
|
|
5
|
+
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('./ClamAVScanner.js');
|
|
4
|
+
|
|
5
|
+
function middleware(opts = {}) {
|
|
6
|
+
const field = opts.uploadField || 'file';
|
|
7
|
+
|
|
8
|
+
return async function pompelmiMiddleware(req, res, next) {
|
|
9
|
+
let buffers = [];
|
|
10
|
+
|
|
11
|
+
if (req.file) {
|
|
12
|
+
buffers = [req.file.buffer];
|
|
13
|
+
} else if (req.files) {
|
|
14
|
+
const files = Array.isArray(req.files)
|
|
15
|
+
? req.files
|
|
16
|
+
: (req.files[field] || Object.values(req.files).flat());
|
|
17
|
+
buffers = files.map(f => f.buffer).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (buffers.length === 0) return next();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
for (const buf of buffers) {
|
|
24
|
+
const verdict = await scanBuffer(buf, opts);
|
|
25
|
+
if (verdict === Verdict.Malicious) {
|
|
26
|
+
return res.status(403).json({ error: 'Malicious file detected' });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
next(err);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { middleware };
|