Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
110 changes: 66 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
# Cloud Email Scanner Server
# Email Content Scanning Daemon

[![Build][ci-img]][ci-url] [![Cover][cov-img]][cov-url] [![Qlty][qlty-img]][qlty-url]

A web service that receives emails via HTTP and scans them with one or many filtering engines (antivirus, antispam, policy engines, etc.)

## 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
Expand All @@ -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
Expand Down
17 changes: 14 additions & 3 deletions cloud-email-scanner.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]

Expand Down
5 changes: 4 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ module.exports = [
},
rules: {
'no-console': 'off',
'no-unused-vars': 'warn',
'no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
},
},
]
140 changes: 140 additions & 0 deletions html/css/ecsd.css
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 0 additions & 9 deletions html/css/ess.css

This file was deleted.

Loading