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: -![status page image](https://cloud.githubusercontent.com/assets/261635/11162087/56acf54a-8a46-11e5-882c-5d8b5a704d71.png) +- `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 @@ - - - - - - Email Scanning Server - - - - - + Email Content Scanning Daemon + - + - -
-
-

Monitor

-

ESS monitor page

-
-
- +
+

Email Content Scanning Daemon

+

+ A web service that receives email over HTTP and scans it with one or + many filtering engines — antivirus, antispam, and policy engines. +

+

+ Scan a message or check the + engine status. +

+
- - - diff --git a/html/navbar.html b/html/navbar.html index 4d5476c..a4b96cf 100644 --- a/html/navbar.html +++ b/html/navbar.html @@ -1,38 +1,4 @@ -
- - - -
+Email Content Scanning Daemon +Home +Status +Scan diff --git a/html/scan.html b/html/scan.html index 9d33a96..32f5a4b 100644 --- a/html/scan.html +++ b/html/scan.html @@ -1,45 +1,52 @@ - - - - + + + + Scan — Email Content Scanning Daemon + - - - -
-
-
- - -

-
+ + -
- +
+

Scan a message

+ +
+ +
- + -
+ + - + + const form = document.getElementById('scan') + const results = document.getElementById('results') - diff --git a/html/stats.html b/html/stats.html deleted file mode 100644 index ced4c01..0000000 --- a/html/stats.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
-
Counters
-

Pass

-

Fail

-

Total

-
- -
-
ClamAV
-

8,374

-

35

-

0

-
-
-
SpamAssassin
-

1,234

-

6,543

-

0

-
-
diff --git a/html/status.html b/html/status.html index 0a32578..f290363 100644 --- a/html/status.html +++ b/html/status.html @@ -2,81 +2,52 @@ - - - - - - Email Scanning Server - - - - - + Status — Email Content Scanning Daemon + - + + +
+

Engine status

+ + + + + + + + +
ScannerInterface
+
- -
-
- -

ESS status page

-
-
- - - - - diff --git a/lib/base-scanner.js b/lib/base-scanner.js index 32593ce..bb0cac4 100644 --- a/lib/base-scanner.js +++ b/lib/base-scanner.js @@ -184,7 +184,7 @@ class Scanner { return this.parseScanReply(stdout) } - parseScanReply(response) { + parseScanReply(_response) { return 'parseScanReply must be overloaded' } @@ -207,11 +207,11 @@ class Scanner { return pings } - async scanTcp(file) { + async scanTcp(_file) { throw new Error('scanTCP must be overloaded') } - async ping(socket, connOpts) { + async ping(_socket, _connOpts) { throw new Error('ping must be overloaded') } @@ -237,7 +237,7 @@ class Scanner { return pings } - async scanSocket(file) { + async scanSocket(_file) { throw new Error('scanSocket must be overloaded') } } diff --git a/lib/clamav-stream.js b/lib/clamav-stream.js index 41a1938..cbcd27d 100644 --- a/lib/clamav-stream.js +++ b/lib/clamav-stream.js @@ -1,17 +1,12 @@ 'use strict' -const stream = require('stream') - -class clamStream extends stream.Transform { - constructor(options) { - super() - - if (!options) options = {} - stream.Transform.call(this, options) - } +const { Transform } = require('node:stream') +// Frames a file for clamd's INSTREAM command: each chunk is prefixed with its +// length as a big-endian uint32, and a zero-length chunk marks the end. +class ClamStream extends Transform { _transform(chunk, encoding, done) { - const size = new Buffer(4) + const size = Buffer.alloc(4) size.writeInt32BE(chunk.length, 0) this.push(size) this.push(chunk) @@ -19,13 +14,11 @@ class clamStream extends stream.Transform { } _flush(done) { - const size = new Buffer(4) - size.writeInt32BE(0, 0) // string terminator + const size = Buffer.alloc(4) + size.writeInt32BE(0, 0) this.push(size) done() } } -module.exports = function (options) { - return new clamStream(options) -} +module.exports = ClamStream diff --git a/lib/clamav.js b/lib/clamav.js index 9d05540..1cddd67 100644 --- a/lib/clamav.js +++ b/lib/clamav.js @@ -8,7 +8,7 @@ const { promisify } = require('node:util') const logger = require('./logger') const BaseScanner = require('./base-scanner') -const clamStream = require('./clamav-stream') +const ClamStream = require('./clamav-stream') const execAsync = promisify(child.exec) @@ -117,7 +117,7 @@ class Scanner extends BaseScanner { socket.connect(connectOpts, () => { socket.write('zINSTREAM\0', () => { fs.createReadStream(file) - .pipe(new clamStream()) + .pipe(new ClamStream()) .pipe(socket) .on('error', (err) => { logger.error(err) diff --git a/lib/dcc.js b/lib/dcc.js new file mode 100644 index 0000000..8eb1712 --- /dev/null +++ b/lib/dcc.js @@ -0,0 +1,146 @@ +'use strict' + +const fs = require('node:fs') +const net = require('node:net') +const path = require('node:path') + +const logger = require('./logger') +const BaseScanner = require('./base-scanner') + +// dccifd protocol: http://www.dcc-servers.net/dcc/dcc-tree/dccifd.html +class Scanner extends BaseScanner { + constructor(name) { + super() + + this.name = name || 'dcc' + + this.init() + + // dccifd's listener socket (the DCC home dir default); set this in config + // to match your daemon. findSocket also searches /tmp and /var/run. + if (!this.cfg.socket) this.cfg.socket = '/var/dcc/dccifd' + if (!this.cfg.net.host) this.cfg.net.host = '0.0.0.0' + if (!this.cfg.net.port) this.cfg.net.port = '1025' + + this.failFile = path.resolve('test/files/gtube.eml') + this.passFile = path.resolve('test/files/clean.eml') + } + + // The dccifd envelope precedes the message: an options line, then client IP, + // HELO, sender, and recipient(s), terminated by a blank line. ecsd scans a + // bare file with no SMTP context, so the envelope is a placeholder; DCC's + // bulk detection keys off the body checksums, not the envelope. + requestPreamble() { + const envelope = ['header', '127.0.0.1', 'localhost', '', 'postmaster'] + return envelope.join('\n') + '\n\n' + } + + parseScanReply(response) { + const result = { + pass: [], + fail: [], + name: this.name, + raw: response, + error: [], + } + + // First line is the overall disposition; remaining lines are per-recipient + // dispositions and any requested X-DCC headers. + const code = response.split('\n').shift().trim() + + switch (code) { + case 'A': // accept + case 'S': // accept for some recipients + result.pass.push(code) + break + case 'R': // reject — bulk/spam + case 'G': // greylist + result.fail.push(code) + break + case 'T': // temporary failure + result.error.push('dccifd temporary failure') + break + default: + result.error.push(response) + } + + return result + } + + // dccifd has no PING; prove the transport by scanning a known-clean message + // and confirming the daemon returned a parseable disposition. + async tcpAvailable() { + await this.tcpListening() + if (!this.found.tcp) throw new Error('dccifd not listening') + + const results = await this.scanTcp(this.passFile) + if (results.error.length) throw new Error('dccifd health check failed') + + this.available.tcp = true + return true + } + + async socketAvailable() { + await this.socketFound() + if (!this.found.socket) throw new Error('dccifd socket not found') + + const results = await this.scanSocket(this.passFile) + if (results.error.length) throw new Error('dccifd health check failed') + + this.available.socket = true + return true + } + + async scanTcp(file) { + if (!this.found.tcp) throw new Error('TCP listener not found') + return this.scanVia(this.cfg.net, file) + } + + async scanSocket(file) { + if (!file) throw new Error('file is required!') + if (!this.found.socket) throw new Error('unix socket not found') + return this.scanVia(this.found.socket, file) + } + + scanVia(connectOpts, file) { + return new Promise((resolve, reject) => { + const socket = this.getSocket(resolve, reject) + + socket.connect(connectOpts, () => { + socket.write(this.requestPreamble(), () => { + fs.createReadStream(file) + .pipe(socket) + .on('error', (err) => { + logger.error(err) + }) + }) + }) + }) + } + + getSocket(resolve, reject) { + const socket = new net.Socket() + let allData = '' + + socket.setTimeout(this.cfg.timeout * 1000) + socket + .on('end', () => { + resolve(this.parseScanReply(allData)) + }) + .on('data', (data) => { + allData += data + }) + .on('error', (err) => { + logger.error(err) + reject(err) + }) + + return socket + } +} + +module.exports = { + createScanner: (name) => { + return new Scanner(name) + }, +} diff --git a/lib/scanner.js b/lib/scanner.js index 8142ea3..779ea1b 100644 --- a/lib/scanner.js +++ b/lib/scanner.js @@ -11,32 +11,30 @@ const spoolDir = path.resolve(cfg.spool.dir) exports.scanners = [ 'spamassassin', 'rspamd', - 'dspam', - 'messagesniffer', 'dcc', 'virustotal', - 'fprot', - 'f-secure', 'clamav', - 'avg', - 'eset', - 'kaspersky', - 'comodo', - 'bitdefender', 'opendkim', 'opendmarc', + // 'dspam', + // 'messagesniffer', + // 'fprot', + // 'f-secure', + // 'avg', + // 'eset', + // 'kaspersky', + // 'comodo', + // 'bitdefender', ] exports.availableScanners = {} -exports.scanFns = {} - exports.testScanner = async function (s) { let scanner try { - scanner = require('./' + s).createScanner() + scanner = require(`./${s}`).createScanner() } catch { - logger.error('could not load: ' + s) + logger.error(`could not load: ${s}`) return } @@ -44,7 +42,7 @@ exports.testScanner = async function (s) { if (await scanner.isAvailable()) { exports.availableScanners[s] = scanner } else { - logger.error('not available: ' + s) + logger.error(`not available: ${s}`) } } catch { /* availability probe failed; leave scanner out */ @@ -96,11 +94,11 @@ async function scanOne(scanner, file) { exports.get = function (req, res) { res.writeHead(200, { 'content-type': 'text/html' }) res.end( - '
' + - '
' + - '
' + - '' + - '
', + `
+
+
+ +
`, ) } @@ -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) + }) + }) +})