diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..bd41b49
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,27 @@
+name: release
+
+on:
+ push:
+ branches: [main, master]
+ paths:
+ - package.json
+
+env:
+ CI: true
+
+jobs:
+ release:
+ uses: haraka/.github/.github/workflows/release.yml@master
+ secrets: inherit
+ permissions:
+ contents: write
+
+ publish:
+ needs: release
+ if: needs.release.outputs.changed == 'true'
+ uses: haraka/.github/.github/workflows/publish.yml@master
+ secrets: inherit
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
diff --git a/README.md b/README.md
index 8d6b806..7e3c47e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Cloud Email Scanner Server
+# Email Content Scanning Daemon
[![Build][ci-img]][ci-url] [![Cover][cov-img]][cov-url] [![Qlty][qlty-img]][qlty-url]
@@ -6,16 +6,16 @@ A web service that receives emails via HTTP and scans them with one or many filt
## Content Scanners
-- [x] spamassassin
-- [x] rspamd
-- [ ] dcc
-- [ ] virustotal
-- [x] clamav
-- [x] bitdefender
-- [x] opendkim
-- [ ] opendmarc
+- [x] [spamassassin](https://spamassassin.apache.org)
+- [x] [rspamd](https://rspamd.com)
+- [x] [dcc](https://www.dcc-servers.net/dcc/)
+- [x] [virustotal](https://www.virustotal.com)
+- [x] [clamav](https://www.clamav.net)
+- [x] [bitdefender](https://www.bitdefender.com/business/antivirus.html)
+- [x] [opendkim](http://www.opendkim.org)
+- [ ] [opendmarc](http://www.opendmarc.org)
-### Still viable?
+### Viable?
- [ ] fprot
- [ ] f-secure
@@ -33,51 +33,73 @@ A web service that receives emails via HTTP and scans them with one or many filt
## Request
-Send emails to be scanned as standard HTTP file upload.
+Send a message to be scanned as a standard HTTP file upload. The form field
+name is ignored — the first uploaded file is scanned.
-`curl -X POST -F eicar=@eicar.eml localhost:8000/scan`
-
-Sent metadata about the connection / message as HTTP headers.
-
-`curl -X POST -F eicar=@eicar.eml -H 'X-GeoIP: AS, CN, Fuzhou' -H 'X-Remote-IP: 27.150.160.26' localhost:8000/scan`
+```sh
+curl -X POST -F upload=@test/files/eicar.eml localhost:8000/scan
+```
## Response
-The response is a JSON encoded array, with the request and response for
-each available scanner.
+The response is a JSON array with one entry per available scanner. Each entry
+reports `pass`, `fail`, and `error` lists alongside the engine's `raw` output;
+a non-empty `fail` means that engine flagged the message.
+
+The example below is a real scan of the bundled EICAR test message, with `raw`
+trimmed for brevity.
```json
[
- {
- "name":"clamav",
- "pass":[],
- "fail":["Eicar-Test-Signature"],
- "error":[],
- "raw":"/Users/matt/Documents/git/ess/spool/upload_9f8c482aaaa10fcf501bf5259c00746c.eml: Eicar-Test-Signature FOUND\n"
- },
- {
- "name":"spamassassin",
- "pass":["ham"],
- "fail":[],
- "raw":"SPAMD/1.1 0
-EX_OK\r\nContent-length: 62\r\nSpam: False ; 2.3 /
-5.0\r\n\r\nAPOSTROPHE_FROM,MISSING_DATE,MISSING_MID,NO_RECEIVED,NO_RELAYS","error":[]},{"pass":["5646b98f634915112796250"],"fail":[],"name":"dspam","raw":"X-DSPAM-Result:
-matt; result=\"Innocent\"; class=\"Whitelisted\"; probability=0.0000; confidence=0.99; signature=5646b98f634915112796250\n",
- "error":[]
- },
- {
- "name":"opendkim",
- "pass":[],
- "fail":["message not signed"],
- "raw":"opendkim: /Users/matt/Documents/git/ess/spool/upload_9f8c482aaaa10fcf501bf5259c00746c.eml: message not signed\n",
- "error":[]
- }
+ {
+ "name": "clamav",
+ "pass": [],
+ "fail": ["Eicar-Signature"],
+ "error": [],
+ "raw": "stream: Eicar-Signature FOUND"
+ },
+ {
+ "name": "virustotal",
+ "pass": [],
+ "fail": [41],
+ "error": []
+ },
+ {
+ "name": "spamassassin",
+ "pass": ["ham"],
+ "fail": [],
+ "error": []
+ },
+ {
+ "name": "rspamd",
+ "pass": [],
+ "fail": [8.5],
+ "error": []
+ },
+ {
+ "name": "dcc",
+ "pass": ["A"],
+ "fail": [],
+ "error": [],
+ "raw": "A\nA\nX-DCC-...; bulk rep Body=3 Fuz1=3 Fuz2=3 rep=23%\n\n"
+ },
+ {
+ "name": "opendkim",
+ "pass": [],
+ "fail": ["message not signed"],
+ "error": []
+ }
]
```
-### Status Page
+## Status
+
+The daemon serves a small web UI (Home, Status, Scan) on the configured listen
+port, backed by two JSON endpoints:
-
+- `GET /status/scannersAll` — every known scanner
+- `GET /status/scannersAvailable` — scanners currently reachable, and the
+ interface (`cli`, `socket`, or `network`) each was detected on
[ci-img]: https://github.com/cloud-ecs/ecsd/actions/workflows/ci.yml/badge.svg
[ci-url]: https://github.com/cloud-ecs/ecsd/actions/workflows/ci.yml
diff --git a/cloud-email-scanner.ini b/cloud-email-scanner.ini
index 6df52f7..069d161 100644
--- a/cloud-email-scanner.ini
+++ b/cloud-email-scanner.ini
@@ -48,6 +48,12 @@ port = 8000
; bin = rspamc
; args =
+[virustotal]
+; cloud API; requires a key (or set VIRUSTOTAL_API_KEY in the environment).
+; Scanning an unknown file uploads it to virustotal.com for analysis.
+; apikey =
+; timeout = 30
+
[avg]
; interface = network | cli
@@ -66,10 +72,15 @@ port = 8000
; args =
[dcc]
-; interface = cli
+; interface = socket | network
+; timeout = 30
+; dccifd listener socket (DCC home dir default)
+; socket = /var/dcc/dccifd
-[dcc.cli]
-; bin = dccifd
+[dcc.net]
+; dccifd has no standard TCP port; point this at your daemon
+; host = 0.0.0.0
+; port = 1025
[kaspersky]
diff --git a/eslint.config.js b/eslint.config.js
index 2909e34..5199842 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -15,7 +15,10 @@ module.exports = [
},
rules: {
'no-console': 'off',
- 'no-unused-vars': 'warn',
+ 'no-unused-vars': [
+ 'warn',
+ { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
+ ],
},
},
]
diff --git a/html/css/ecsd.css b/html/css/ecsd.css
new file mode 100644
index 0000000..7acb2dd
--- /dev/null
+++ b/html/css/ecsd.css
@@ -0,0 +1,140 @@
+:root {
+ --bg: #1b1e24;
+ --panel: #252830;
+ --fg: #e6e8eb;
+ --muted: #9aa0a6;
+ --accent: #4da3ff;
+ --pass: #3fb950;
+ --fail: #f85149;
+ --border: #333842;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family:
+ system-ui,
+ -apple-system,
+ 'Segoe UI',
+ Roboto,
+ Helvetica,
+ Arial,
+ sans-serif;
+ line-height: 1.5;
+ background-color: var(--bg);
+ color: var(--fg);
+}
+
+nav.navbar {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ padding: 0.75rem 1.5rem;
+ background-color: var(--panel);
+ border-bottom: 1px solid var(--border);
+}
+
+nav.navbar .brand {
+ margin-right: auto;
+ font-weight: 600;
+ color: var(--fg);
+ text-decoration: none;
+}
+
+nav.navbar a {
+ color: var(--muted);
+ text-decoration: none;
+}
+
+nav.navbar a:hover {
+ color: var(--accent);
+}
+
+main {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+h1 {
+ font-weight: 600;
+}
+
+a {
+ color: var(--accent);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+th {
+ font-weight: 500;
+ color: var(--muted);
+}
+
+.pass {
+ color: var(--pass);
+}
+
+.fail {
+ color: var(--fail);
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.field {
+ margin-bottom: 1rem;
+}
+
+label {
+ display: block;
+ margin-bottom: 0.25rem;
+ color: var(--muted);
+}
+
+input[type='text'],
+input[type='file'] {
+ width: 100%;
+ padding: 0.5rem;
+ background-color: var(--panel);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
+
+button {
+ padding: 0.5rem 1.25rem;
+ font-weight: 600;
+ color: #0b1622;
+ background-color: var(--accent);
+ border: 0;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+button:hover {
+ opacity: 0.9;
+}
+
+pre {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ overflow: auto;
+ background-color: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+}
diff --git a/html/css/ess.css b/html/css/ess.css
deleted file mode 100644
index d5c11f6..0000000
--- a/html/css/ess.css
+++ /dev/null
@@ -1,9 +0,0 @@
-body {
- padding-top: 50px;
- background-color: #252830;
- color: white;
-}
-.tab-content {
- padding: 40px 15px;
- text-align: center;
-}
diff --git a/html/index.html b/html/index.html
index 0f8d9a1..9e88cf5 100644
--- a/html/index.html
+++ b/html/index.html
@@ -2,47 +2,33 @@
-
',
+ `
`,
)
}
@@ -112,10 +110,10 @@ exports.listAvailable = function (req, res) {
res.json({ scanners: exports.availableScanners })
}
-exports.scanners.forEach(exports.testScanner)
+for (const s of exports.scanners) exports.testScanner(s)
// re-test scanner availability every 2 minutes; unref so it never keeps a
// short-lived process (e.g. the test runner) alive on its own
-setInterval(function () {
- exports.scanners.forEach(exports.testScanner)
+setInterval(() => {
+ for (const s of exports.scanners) exports.testScanner(s)
}, 120 * 1000).unref()
diff --git a/lib/virustotal.js b/lib/virustotal.js
new file mode 100644
index 0000000..bc5da71
--- /dev/null
+++ b/lib/virustotal.js
@@ -0,0 +1,191 @@
+'use strict'
+
+const crypto = require('node:crypto')
+const fs = require('node:fs')
+const https = require('node:https')
+const path = require('node:path')
+
+const BaseScanner = require('./base-scanner')
+
+// EICAR's sha256 is always present in VirusTotal, so a lookup of it doubles as
+// a cheap auth/liveness probe that never uploads anything.
+const EICAR_SHA256 =
+ '275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f'
+
+// https://docs.virustotal.com/reference/overview
+class Scanner extends BaseScanner {
+ constructor(name) {
+ super()
+
+ this.name = name || 'virustotal'
+
+ this.init()
+
+ if (!this.cfg.apikey) this.cfg.apikey = process.env.VIRUSTOTAL_API_KEY
+ if (!this.cfg.baseUrl)
+ this.cfg.baseUrl = 'https://www.virustotal.com/api/v3'
+
+ this.failFile = path.resolve('test/files/eicar.eml')
+ this.passFile = path.resolve('test/files/clean.eml')
+ }
+
+ async isAvailable() {
+ this.available.any = false
+ if (!this.cfg.apikey) return false
+
+ try {
+ const res = await this.request({
+ method: 'GET',
+ path: `/files/${EICAR_SHA256}`,
+ })
+ if (res.status !== 401) this.available.any = 'network'
+ } catch {
+ /* network/auth probe failed; leave unavailable */
+ }
+
+ return this.available.any
+ }
+
+ async scan(file) {
+ const hash = await this.sha256(file)
+
+ // Reuse VirusTotal's existing verdict when it has already seen this file;
+ // only upload (publishing the content to a third party) when it hasn't.
+ let report = await this.lookup(hash)
+ if (!report) {
+ const analysisId = await this.upload(file)
+ report = await this.pollAnalysis(analysisId)
+ }
+
+ return this.parseScanReply(report)
+ }
+
+ parseScanReply(report) {
+ const result = {
+ pass: [],
+ fail: [],
+ name: this.name,
+ raw: report,
+ error: [],
+ }
+
+ const attrs = report && report.data && report.data.attributes
+ const stats = attrs && (attrs.last_analysis_stats || attrs.stats)
+ if (!stats) {
+ result.error.push('no analysis stats in response')
+ return result
+ }
+
+ const flagged = (stats.malicious || 0) + (stats.suspicious || 0)
+ if (flagged > 0) {
+ result.fail.push(flagged)
+ } else {
+ result.pass.push(stats.harmless + stats.undetected || 0)
+ }
+
+ return result
+ }
+
+ async lookup(hash) {
+ const res = await this.request({ method: 'GET', path: `/files/${hash}` })
+ if (res.status === 200) return res.json
+ if (res.status === 404) return null
+ if (res.status === 401)
+ throw new Error('virustotal: unauthorized (check apikey)')
+ throw new Error(`virustotal: lookup returned HTTP ${res.status}`)
+ }
+
+ async upload(file) {
+ const boundary = '----ecsd' + crypto.randomBytes(16).toString('hex')
+ const content = await fs.promises.readFile(file)
+ const head = Buffer.from(
+ `--${boundary}\r\n` +
+ `Content-Disposition: form-data; name="file"; filename="${path.basename(file)}"\r\n` +
+ 'Content-Type: application/octet-stream\r\n\r\n',
+ )
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`)
+ const body = Buffer.concat([head, content, tail])
+
+ const res = await this.request({
+ method: 'POST',
+ path: '/files',
+ headers: {
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
+ 'content-length': body.length,
+ },
+ body,
+ })
+ if (res.status !== 200)
+ throw new Error(`virustotal: upload returned HTTP ${res.status}`)
+
+ return res.json.data.id
+ }
+
+ async pollAnalysis(id) {
+ const deadline = Date.now() + this.cfg.timeout * 1000
+
+ for (;;) {
+ const res = await this.request({ method: 'GET', path: `/analyses/${id}` })
+ if (res.status !== 200) {
+ throw new Error(`virustotal: analysis returned HTTP ${res.status}`)
+ }
+ if (res.json.data.attributes.status === 'completed') return res.json
+ if (Date.now() > deadline)
+ throw new Error('virustotal: analysis timed out')
+
+ await new Promise((r) => setTimeout(r, 3000))
+ }
+ }
+
+ request({ method, path: reqPath, headers = {}, body }) {
+ const url = new URL(this.cfg.baseUrl + reqPath)
+
+ const opts = {
+ method,
+ hostname: url.hostname,
+ path: url.pathname + url.search,
+ headers: { 'x-apikey': this.cfg.apikey, ...headers },
+ }
+
+ return new Promise((resolve, reject) => {
+ const req = https.request(opts, (res) => {
+ let data = ''
+ res.setEncoding('utf8')
+ res.on('data', (chunk) => (data += chunk))
+ res.on('end', () => {
+ let json = null
+ try {
+ json = data ? JSON.parse(data) : null
+ } catch {
+ /* non-JSON body (e.g. an error page); leave json null */
+ }
+ resolve({ status: res.statusCode, json, raw: data })
+ })
+ })
+
+ req.setTimeout(this.cfg.timeout * 1000, () =>
+ req.destroy(new Error('request timed out')),
+ )
+ req.on('error', reject)
+ if (body) req.write(body)
+ req.end()
+ })
+ }
+
+ sha256(file) {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash('sha256')
+ fs.createReadStream(file)
+ .on('error', reject)
+ .on('data', (chunk) => hash.update(chunk))
+ .on('end', () => resolve(hash.digest('hex')))
+ })
+ }
+}
+
+module.exports = {
+ EICAR_SHA256,
+ createScanner: (name) => {
+ return new Scanner(name)
+ },
+}
diff --git a/package.json b/package.json
index 7021eb7..5342143 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ecsd",
- "description": "Cloud enabled Email Content Scanner",
- "version": "0.5.0",
+ "description": "Email Content Scanning Daemon",
+ "version": "0.8.0",
"private": false,
"keywords": [
"email",
diff --git a/routes/error.js b/routes/error.js
index aa485b2..9eed304 100644
--- a/routes/error.js
+++ b/routes/error.js
@@ -1,6 +1,6 @@
'use strict'
-var config = require('../lib/config').loadConfig()
+const config = require('../lib/config').loadConfig()
exports.addErrRoutes = function (app) {
this.load404(app)
@@ -8,11 +8,7 @@ exports.addErrRoutes = function (app) {
}
exports.load404 = function (app) {
- // abandon all hope and serve up a 404
- app.use(function (req, res, next) {
- //console.log('serving up a 404');
-
- // respond with html page
+ app.use(function (req, res) {
if (req.accepts('html')) {
res.status(404).sendFile('404.html', {
root: config.docroot,
@@ -20,7 +16,6 @@ exports.load404 = function (app) {
return
}
- // respond with json
if (req.accepts('json')) {
res.status(404).send({
err: 'Not found',
@@ -33,8 +28,8 @@ exports.load404 = function (app) {
}
exports.loadUnhandled = function (app) {
- // application wide error handler (note the arity of 4)
- app.use(function (err, req, res, next) {
+ // Express only treats middleware as an error handler when it declares 4 args
+ app.use(function (err, req, res, _next) {
console.error(err.stack)
res.status(500).send('Something broke!')
})
diff --git a/routes/scan.js b/routes/scan.js
index 8a97656..a93c4da 100644
--- a/routes/scan.js
+++ b/routes/scan.js
@@ -1,10 +1,8 @@
'use strict'
-var scanner = require('../lib/scanner')
+const scanner = require('../lib/scanner')
exports.public = function (app) {
- // curl -X POST -F eicar=@eicar.eml localhost:8000/scan
-
app.post('/scan', scanner.post)
app.get('/scan', scanner.get)
diff --git a/routes/static.js b/routes/static.js
index 89c7bf1..bcbe87c 100644
--- a/routes/static.js
+++ b/routes/static.js
@@ -1,17 +1,17 @@
'use strict'
-var express = require('express')
-var favicon = require('serve-favicon')
+const express = require('express')
+const favicon = require('serve-favicon')
-var config = require('../lib/config').loadConfig()
-var dr = config.docroot
+const config = require('../lib/config').loadConfig()
+const dr = config.docroot
exports.public = function (app) {
- app.use(favicon(dr + '/img/favicon.ico'))
- app.use('/img/', express.static(dr + '/img', { maxAge: '3d' }))
- app.use('/css/', express.static(dr + '/css', { maxAge: '1d' }))
- app.use('/js/', express.static(dr + '/js'))
- app.use('/fonts/', express.static(dr + '/fonts', { maxAge: '10d' }))
+ app.use(favicon(`${dr}/img/favicon.ico`))
+ app.use('/img/', express.static(`${dr}/img`, { maxAge: '3d' }))
+ app.use('/css/', express.static(`${dr}/css`, { maxAge: '1d' }))
+ app.use('/js/', express.static(`${dr}/js`))
+ app.use('/fonts/', express.static(`${dr}/fonts`, { maxAge: '10d' }))
}
exports.index = function (app) {
@@ -19,9 +19,5 @@ exports.index = function (app) {
}
exports.private = function (app) {
- app.use(function (req, res, next) {
- // console.log('static last: ' + req.path);
- next()
- })
app.use('/', express.static(dr, { redirect: false }))
}
diff --git a/server.js b/server.js
index ac5efbc..bf2a129 100644
--- a/server.js
+++ b/server.js
@@ -1,30 +1,21 @@
'use strict'
-// node.js built-ins
-var http = require('http')
-// var https = require('https');
+const http = require('node:http')
-// npm deps
-var express = require('express')
+const express = require('express')
+const logger = require('morgan')
-// local deps
-var logger = require('morgan')
+const config = require('./lib/config').loadConfig()
-// var logger = require('./lib/logger');
-var config = require('./lib/config').loadConfig()
-
-var app = express()
+const app = express()
app.use(logger('dev'))
require('./routes/static').public(app)
http.createServer(app).listen(config.listen.port)
-console.log('Listening on port %d', config.listen.port)
+console.log(`Listening on port ${config.listen.port}`)
require('./routes/scan').public(app)
-// the session is valid, continue
-// require('./routes/static').private(app);
-
require('./routes/static').index(app)
require('./routes/error').addErrRoutes(app)
diff --git a/test/dcc.js b/test/dcc.js
new file mode 100644
index 0000000..5bd2cc5
--- /dev/null
+++ b/test/dcc.js
@@ -0,0 +1,79 @@
+'use strict'
+
+const assert = require('node:assert/strict')
+const path = require('node:path')
+const { describe, it, before } = require('node:test')
+
+const dcc = require('../lib/dcc').createScanner()
+
+const cleanMsg = path.resolve('test/files/clean.eml')
+
+describe('dcc', () => {
+ describe('parseScanReply', () => {
+ it('passes an accept disposition', () => {
+ const results = dcc.parseScanReply('A\nA\nX-DCC-Reputation: ok\n')
+ assert.equal(results.pass.length, 1)
+ assert.equal(results.fail.length, 0)
+ })
+
+ it('fails a reject disposition', () => {
+ const results = dcc.parseScanReply('R\nR\n')
+ assert.equal(results.fail.length, 1)
+ assert.equal(results.pass.length, 0)
+ })
+
+ it('errors on a temporary failure', () => {
+ const results = dcc.parseScanReply('T\n')
+ assert.equal(results.error.length, 1)
+ })
+ })
+
+ describe('TCP', () => {
+ let avail
+ before(async () => {
+ avail = await dcc.tcpListening().catch(() => false)
+ })
+
+ it('is available', async (t) => {
+ if (!avail) return t.skip()
+ assert.ok(await dcc.tcpAvailable())
+ })
+
+ // DCC flags mail by clearinghouse checksum counts, so a one-off message
+ // can't be forced to "reject"; assert the daemon returns a parsed verdict.
+ it('scans a message', async (t) => {
+ if (!avail) return t.skip()
+ const results = await dcc.scanTcp(cleanMsg)
+ assert.equal(results.error.length, 0)
+ assert.equal(results.pass.length + results.fail.length, 1)
+ })
+ })
+
+ describe('unix socket', () => {
+ let avail
+ before(async () => {
+ avail = await dcc.socketFound().catch(() => false)
+ })
+
+ it('scans a message', async (t) => {
+ if (!avail) return t.skip()
+ const results = await dcc.scanSocket(cleanMsg)
+ assert.equal(results.error.length, 0)
+ assert.equal(results.pass.length + results.fail.length, 1)
+ })
+ })
+
+ describe('scan dispatch', () => {
+ let avail
+ before(async () => {
+ avail = await dcc.isAvailable().catch(() => false)
+ })
+
+ it('scans a message', async (t) => {
+ if (!avail) return t.skip()
+ const results = await dcc.scan(cleanMsg)
+ assert.equal(results.error.length, 0)
+ assert.equal(results.pass.length + results.fail.length, 1)
+ })
+ })
+})
diff --git a/test/virustotal.js b/test/virustotal.js
new file mode 100644
index 0000000..09f2552
--- /dev/null
+++ b/test/virustotal.js
@@ -0,0 +1,81 @@
+'use strict'
+
+const assert = require('node:assert/strict')
+const { describe, it, before } = require('node:test')
+
+const { createScanner, EICAR_SHA256 } = require('../lib/virustotal')
+const vt = createScanner()
+
+describe('virustotal', () => {
+ describe('parseScanReply', () => {
+ it('fails a file with detections', () => {
+ const report = {
+ data: {
+ attributes: {
+ last_analysis_stats: {
+ malicious: 42,
+ suspicious: 1,
+ harmless: 0,
+ undetected: 20,
+ },
+ },
+ },
+ }
+ const results = vt.parseScanReply(report)
+ assert.equal(results.fail.length, 1)
+ assert.equal(results.pass.length, 0)
+ })
+
+ it('passes a clean file', () => {
+ const report = {
+ data: {
+ attributes: {
+ last_analysis_stats: {
+ malicious: 0,
+ suspicious: 0,
+ harmless: 5,
+ undetected: 60,
+ },
+ },
+ },
+ }
+ const results = vt.parseScanReply(report)
+ assert.equal(results.pass.length, 1)
+ assert.equal(results.fail.length, 0)
+ })
+
+ it('reads stats from an analysis response', () => {
+ const report = {
+ data: {
+ attributes: {
+ status: 'completed',
+ stats: { malicious: 3, suspicious: 0, harmless: 0, undetected: 10 },
+ },
+ },
+ }
+ const results = vt.parseScanReply(report)
+ assert.equal(results.fail.length, 1)
+ })
+
+ it('errors when stats are missing', () => {
+ const results = vt.parseScanReply({ data: { attributes: {} } })
+ assert.equal(results.error.length, 1)
+ })
+ })
+
+ describe('live API', () => {
+ let avail
+ before(async () => {
+ avail = await vt.isAvailable().catch(() => false)
+ })
+
+ // Look up EICAR's well-known hash rather than scanning a file, so the test
+ // exercises the real API and parser without uploading anything.
+ it('flags the eicar hash as malicious', async (t) => {
+ if (!avail) return t.skip()
+ const report = await vt.lookup(EICAR_SHA256)
+ const results = vt.parseScanReply(report)
+ assert.ok(results.fail.length)
+ })
+ })
+})