├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── .travis.yml ├── test ├── Makefile ├── test-lib.sh ├── 3000-headers.sh ├── 9000-legacy.sh ├── 2000-junk.sh ├── 0000-basic.sh ├── 4000-allowlist.sh └── 1000-block.sh ├── LICENSE ├── README.md ├── filter-senderscore.8 └── filter-senderscore.go /.gitignore: -------------------------------------------------------------------------------- 1 | filter-senderscore 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [poolpOrg] 2 | patreon: gilles 3 | 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | script: go build && cd test && make check 4 | -------------------------------------------------------------------------------- /test/Makefile: -------------------------------------------------------------------------------- 1 | check: 2 | @./0000-basic.sh 2>/dev/null 3 | @./1000-block.sh 2>/dev/null 4 | @./2000-junk.sh 2>/dev/null 5 | @./3000-headers.sh 2>/dev/null 6 | @./4000-allowlist.sh 2>/dev/null 7 | @./9000-legacy.sh 2>/dev/null 8 | 9 | .PHONY: check 10 | -------------------------------------------------------------------------------- /test/test-lib.sh: -------------------------------------------------------------------------------- 1 | [ -z "$FILTER_BIN" ] && FILTER_BIN="$(pwd)/../filter-senderscore" 2 | [ -z "$FILTER_OPTS" ] && FILTER_OPTS='-testMode' 3 | 4 | test_init() { 5 | TEST_DIR="$(mktemp -d)" 6 | cd "$TEST_DIR" || return 1 7 | i=0 8 | ret=0 9 | } 10 | 11 | test_complete() { 12 | rm -r "$TEST_DIR" 13 | echo "1..$i" 14 | return "$ret" 15 | } 16 | 17 | test_run() { 18 | i=$(($i + 1)) 19 | if eval "$2"; then 20 | printf "ok" 21 | else 22 | printf "not ok" 23 | ret=1 24 | fi 25 | echo " $i - $1" 26 | } 27 | 28 | test_cmp() { 29 | diff -u "$1" "$2" >&2 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Set up Go 1.12 11 | uses: actions/setup-go@v1 12 | with: 13 | go-version: 1.12 14 | id: go 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v1 18 | 19 | - name: Get dependencies 20 | run: | 21 | go get -v -t -d ./... 22 | if [ -f Gopkg.toml ]; then 23 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 24 | dep ensure 25 | fi 26 | 27 | - name: Build 28 | run: go build -v . 29 | -------------------------------------------------------------------------------- /test/3000-headers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./test-lib.sh 4 | 5 | test_init 6 | 7 | test_run 'test the scoreHeader parameter' ' 8 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -scoreHeader | sed "0,/^register|ready/d" >actual && 9 | config|ready 10 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.42:33174|1.1.1.1:25 11 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.42:33174|1.1.1.1:25 12 | filter|0.5|0|smtp-in|data-line|7641df9771b4ed00|1ef1c203cc576e5d|. 13 | EOD 14 | cat <<-EOD >expected && 15 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 16 | filter-dataline|7641df9771b4ed00|1ef1c203cc576e5d|X-SenderScore: 42 17 | filter-dataline|7641df9771b4ed00|1ef1c203cc576e5d|. 18 | EOD 19 | test_cmp actual expected 20 | ' 21 | 22 | test_complete 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Gilles Chehade 3 | * 4 | * Permission to use, copy, modify, and distribute this software for any 5 | * purpose with or without fee is hereby granted, provided that the above 6 | * copyright notice and this permission notice appear in all copies. 7 | * 8 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | */ 16 | -------------------------------------------------------------------------------- /test/9000-legacy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./test-lib.sh 4 | 5 | test_init 6 | 7 | test_run 'test with protocol version 0.4' ' 8 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 | sed "0,/^register|ready/d" >actual && 9 | config|ready 10 | report|0.4|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.100:33174|1.1.1.1:25 11 | filter|0.4|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.100:33174|1.1.1.1:25 12 | EOD 13 | cat <<-EOD >expected && 14 | filter-result|1ef1c203cc576e5d|7641df9771b4ed00|proceed 15 | EOD 16 | test_cmp actual expected 17 | ' 18 | 19 | test_run 'test with protocol version 0.42' ' 20 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 | sed "0,/^register|ready/d" >actual && 21 | config|ready 22 | report|0.42|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.100:33174|1.1.1.1:25 23 | filter|0.42|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.100:33174|1.1.1.1:25 24 | EOD 25 | cat <<-EOD >expected && 26 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 27 | EOD 28 | test_cmp actual expected 29 | ' 30 | 31 | test_complete 32 | -------------------------------------------------------------------------------- /test/2000-junk.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./test-lib.sh 4 | 5 | test_init 6 | 7 | test_run 'test the junkBelow parameter with a non-reputable IP address' ' 8 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -junkBelow 20 | sed "0,/^register|ready/d" >actual && 9 | config|ready 10 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 11 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 12 | EOD 13 | cat <<-EOD >expected && 14 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|junk 15 | EOD 16 | test_cmp actual expected 17 | ' 18 | 19 | test_run 'test the junkBelow parameter with a reputable IP address' ' 20 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -junkBelow 20 | sed "0,/^register|ready/d" >actual && 21 | config|ready 22 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.100:33174|1.1.1.1:25 23 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.100:33174|1.1.1.1:25 24 | EOD 25 | cat <<-EOD >expected && 26 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 27 | EOD 28 | test_cmp actual expected 29 | ' 30 | 31 | test_complete 32 | -------------------------------------------------------------------------------- /test/0000-basic.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./test-lib.sh 4 | 5 | test_init 6 | 7 | test_run 'initialization' ' 8 | echo "config|ready" | "$FILTER_BIN" $FILTER_OPTS | sort >actual && 9 | cat <<-EOD >expected && 10 | register|filter|smtp-in|auth 11 | register|filter|smtp-in|commit 12 | register|filter|smtp-in|connect 13 | register|filter|smtp-in|data 14 | register|filter|smtp-in|data-line 15 | register|filter|smtp-in|ehlo 16 | register|filter|smtp-in|helo 17 | register|filter|smtp-in|mail-from 18 | register|filter|smtp-in|quit 19 | register|filter|smtp-in|rcpt-to 20 | register|filter|smtp-in|starttls 21 | register|ready 22 | register|report|smtp-in|link-connect 23 | register|report|smtp-in|link-disconnect 24 | EOD 25 | test_cmp actual expected 26 | ' 27 | 28 | test_run 'test behavior with invalid stream' ' 29 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 >&2; [ "$?" -eq 1 ] 30 | config|ready 31 | invalid|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 32 | EOD 33 | ' 34 | 35 | test_run 'test behavior with invalid phase' ' 36 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 >&2; [ "$?" -eq 1 ] 37 | config|ready 38 | report|0.5|0|smtp-in|invalid|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 39 | EOD 40 | ' 41 | 42 | test_run 'test behavior with too few atoms' ' 43 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 >&2; [ "$?" -eq 1 ] 44 | config|ready 45 | report|0.5|0|smtp-in|link-connect 46 | EOD 47 | ' 48 | 49 | test_run 'test behavior with invalid session ID' ' 50 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 >&2; [ "$?" -eq 1 ] 51 | config|ready 52 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 53 | filter|0.5|0|smtp-in|connect|7641df9771b4ed01|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 54 | EOD 55 | ' 56 | 57 | test_complete 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # filter-senderscore 2 | 3 | ## Description 4 | This filter performs a SenderScore lookup and allows OpenSMTPD to either block or slow down a 5 | session based on the reputation of the source IP address. 6 | 7 | 8 | ## Features 9 | The filter currently supports: 10 | 11 | - blocking hosts with reputation below a certain value 12 | - adding an `X-SenderScore` header with the score of the source IP address 13 | - adding an `X-Spam` header to hosts with reputation below a certain value 14 | - applying a time penalty proportional to the IP reputation 15 | - allowlisting IP addresses or subnets 16 | 17 | 18 | ## Dependencies 19 | The filter is written in Golang and doesn't have any dependencies beyond the standard library. 20 | 21 | It requires OpenSMTPD 6.6.0 or higher. 22 | 23 | 24 | ## How to install 25 | Install from your operating system's preferred package manager if available. 26 | On OpenBSD: 27 | ``` 28 | $ doas pkg_add filter-senderscore 29 | quirks-3.167 signed on 2019-08-11T14:18:58Z 30 | filter-senderscore-v0.1.0: ok 31 | ``` 32 | 33 | Alternatively, clone the repository, build and install the filter: 34 | ``` 35 | $ cd filter-senderscore/ 36 | $ go build 37 | $ doas install -m 0555 filter-senderscore /usr/local/bin/filter-senderscore 38 | ``` 39 | 40 | On Linux, use sudo(8) instead of doas(1). 41 | 42 | ## How to configure 43 | The filter itself requires no configuration. 44 | 45 | It must be declared in smtpd.conf and attached to a listener: 46 | ``` 47 | filter "senderscore" proc-exec "/usr/local/bin/filter-senderscore -blockBelow 50 -junkBelow 80 -slowFactor 1000" 48 | 49 | listen on all filter "senderscore" 50 | ``` 51 | 52 | `-blockBelow` will display an error banner for sessions with reputation score below value then disconnect. 53 | 54 | `-blockPhase` will determine at which phase `-blockBelow` will be triggered, defaults to `connect`, valid choices are `connect`, `helo`, `ehlo`, `starttls`, `auth`, `mail-from`, `rcpt-to` and `quit`. Note that `quit` will result in a message at the end of a session and may only be used to warn sender that reputation is degrading as it will not prevent transactions from succeeding. 55 | 56 | `-junkBelow` will prepend the 'X-Spam: yes' header to messages. 57 | 58 | `-slowFactor` will delay all answers to a reputation-related percentage of its value in milliseconds. The formula is `delay * (100 - score) / 100` where `delay` is the argument to the `-slowFactor` parameter and `score` is the reputation score. By default, connections are never delayed. 59 | 60 | `-scoreHeader` will add an X-SenderScore header with reputation value if known. 61 | 62 | `-allowlist ` can be used to specify a file containing a list of IP addresses and subnets in CIDR notation to allowlist, one per line. IP addresses matching any entry in that list automatically receive a score of 100. 63 | -------------------------------------------------------------------------------- /test/4000-allowlist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./test-lib.sh 4 | 5 | test_init 6 | 7 | test_run 'test IP address allowlisting' ' 8 | cat <<-EOD >allowlist && 9 | 1.1.1.1 10 | 3.3.3.3 11 | EOD 12 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -allowlist allowlist | sed "0,/^register|ready/d" >actual && 13 | config|ready 14 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.1.1.1:33174|1.1.1.1:25 15 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.1.1.1:33174|1.1.1.1:25 16 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed01||pass|2.2.2.2:33174|1.1.1.1:25 17 | filter|0.5|0|smtp-in|connect|7641df9771b4ed01|1ef1c203cc576e5d||pass|2.2.2.2:33174|1.1.1.1:25 18 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed02||pass|3.3.3.3:33174|1.1.1.1:25 19 | filter|0.5|0|smtp-in|connect|7641df9771b4ed02|1ef1c203cc576e5d||pass|3.3.3.3:33174|1.1.1.1:25 20 | EOD 21 | cat <<-EOD >expected && 22 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 23 | filter-result|7641df9771b4ed01|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 24 | filter-result|7641df9771b4ed02|1ef1c203cc576e5d|proceed 25 | EOD 26 | test_cmp actual expected 27 | ' 28 | 29 | test_run 'test subnet allowlisting' ' 30 | cat <<-EOD >allowlist && 31 | 1.1.0.0/16 32 | 1.2.3.0/24 33 | 2.0.0.0/8 34 | EOD 35 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -allowlist allowlist | sed "0,/^register|ready/d" >actual && 36 | config|ready 37 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.1.1.1:33174|1.1.1.1:25 38 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.1.1.1:33174|1.1.1.1:25 39 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed01||pass|2.2.2.2:33174|1.1.1.1:25 40 | filter|0.5|0|smtp-in|connect|7641df9771b4ed01|1ef1c203cc576e5d||pass|2.2.2.2:33174|1.1.1.1:25 41 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed02||pass|3.3.3.3:33174|1.1.1.1:25 42 | filter|0.5|0|smtp-in|connect|7641df9771b4ed02|1ef1c203cc576e5d||pass|3.3.3.3:33174|1.1.1.1:25 43 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed03||pass|1.2.3.4:33174|1.1.1.1:25 44 | filter|0.5|0|smtp-in|connect|7641df9771b4ed03|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 45 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed04||pass|1.2.2.3:33174|1.1.1.1:25 46 | filter|0.5|0|smtp-in|connect|7641df9771b4ed04|1ef1c203cc576e5d||pass|1.2.2.3:33174|1.1.1.1:25 47 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed05||pass|2.3.4.5:33174|1.1.1.1:25 48 | filter|0.5|0|smtp-in|connect|7641df9771b4ed05|1ef1c203cc576e5d||pass|2.3.4.5:33174|1.1.1.1:25 49 | EOD 50 | cat <<-EOD >expected && 51 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 52 | filter-result|7641df9771b4ed01|1ef1c203cc576e5d|proceed 53 | filter-result|7641df9771b4ed02|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 54 | filter-result|7641df9771b4ed03|1ef1c203cc576e5d|proceed 55 | filter-result|7641df9771b4ed04|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 56 | filter-result|7641df9771b4ed05|1ef1c203cc576e5d|proceed 57 | EOD 58 | test_cmp actual expected 59 | ' 60 | 61 | test_complete 62 | -------------------------------------------------------------------------------- /filter-senderscore.8: -------------------------------------------------------------------------------- 1 | .\" Copyright (C) 2020 Ryan Kavanagh 2 | .\" All rights reserved. 3 | .\" Permission to use, copy, modify, and distribute this software for any 4 | .\" purpose with or without fee is hereby granted, provided that the above 5 | .\" copyright notice and this permission notice appear in all copies. 6 | .\" 7 | .\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | .\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | .\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | .\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | .\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | .\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | .\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | .Dd April 12, 2020 15 | .Dt FILTER-SENDERSCORE 8 16 | .Os 17 | .Sh NAME 18 | .Nm filter-senderscore 19 | .Nd SenderScore filter for OpenSMTPD 20 | .Sh SYNOPSIS 21 | .Nm filter-senderscore 22 | .Op Fl blockBelow Ar score 23 | .Op Fl blockPhase Ar phase 24 | .Op Fl junkBelow Ar score 25 | .Op Fl slowFactor Ar factor 26 | .Op Fl scoreHeader 27 | .Sh DESCRIPTION 28 | The 29 | .Nm 30 | filter for the OpenSMTPD 31 | .Pq Xr smtpd 8 32 | server filters sessions based on their SenderScores reputation score. 33 | Its options are: 34 | .Bl -tag -width scoreHeader 35 | .It Fl blockBelow Ar score 36 | Displays an error banner for sessions with a reputation score below 37 | .Ar score 38 | and then disconnects. 39 | .It Fl blockPhase Ar phase 40 | Determines at which phase 41 | .Fl blockBelow 42 | is triggered. 43 | The default is 44 | .Ar connect . 45 | Valid choices are 46 | .Ar connect , 47 | .Ar helo , 48 | .Ar ehlo , 49 | .Ar starttls , 50 | .Ar auth , 51 | .Ar mail-from , 52 | .Ar rcpt-to , 53 | and 54 | .Ar quit . 55 | Note that 56 | .Ar quit 57 | will result in a message at the end of a session and may only be used to warn 58 | the sender that its reputation is degrading, as it will not prevent transactions 59 | from succeeding. 60 | .It Fl junkBelow Ar score 61 | Prepends a 62 | .Ql X-Spam: yes 63 | header to messages for sessions with a reputation score below 64 | .Ar score . 65 | .It Fl slowFactor Ar factor 66 | Delays all answers by this many milliseconds, where 67 | .Ql score 68 | is the reputation score: 69 | .Dl factor \(mi ((factor \(di 100) \(** score) 70 | .It Fl scoreHeader 71 | Adds an 72 | .Ql X-SenderScore 73 | header with the sender's reputation score if known. 74 | .El 75 | .Sh EXIT STATUS 76 | .Ex -std 77 | .Sh EXAMPLES 78 | Adding the following to 79 | .Pa smtpd.conf 80 | enables 81 | .Nm 82 | for all incoming connections. 83 | .Bd -literal 84 | filter "senderscore" proc-exec \\ 85 | "/usr/local/bin/filter-senderscore -blockBelow 50 \\ 86 | -junkBelow 80 \\ 87 | -slowFactor 1000" 88 | 89 | listen on all filter "senderscore" 90 | .Ed 91 | .Sh SEE ALSO 92 | .Xr smtpd.conf 5 93 | .Sh AUTHORS 94 | .Nm 95 | is Copyright \(co 2019 96 | .An -nosplit 97 | .An Gilles Chehade Aq Mt gilles@poolp.org . 98 | This man page is Copyright \(co 2020 99 | .An Ryan Kavanagh Aq Mt rak@debian.org . 100 | Both are distributed under the ISC license. 101 | .Sh BUGS 102 | None known. 103 | -------------------------------------------------------------------------------- /test/1000-block.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . ./test-lib.sh 4 | 5 | test_init 6 | 7 | test_run 'test the connect filter with a non-reputable IP address' ' 8 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 | sed "0,/^register|ready/d" >actual && 9 | config|ready 10 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 11 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 12 | EOD 13 | cat <<-EOD >expected && 14 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 15 | EOD 16 | test_cmp actual expected 17 | ' 18 | 19 | test_run 'test the connect filter with a reputable IP address' ' 20 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 | sed "0,/^register|ready/d" >actual && 21 | config|ready 22 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.100:33174|1.1.1.1:25 23 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.100:33174|1.1.1.1:25 24 | EOD 25 | cat <<-EOD >expected && 26 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 27 | EOD 28 | test_cmp actual expected 29 | ' 30 | 31 | test_run 'test the connect filter with a nonexistent IP address' ' 32 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 | sed "0,/^register|ready/d" >actual && 33 | config|ready 34 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|255.255.255.255:33174|1.1.1.1:25 35 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|255.255.255.255:33174|1.1.1.1:25 36 | EOD 37 | cat <<-EOD >expected && 38 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 39 | EOD 40 | test_cmp actual expected 41 | ' 42 | 43 | test_run 'test block phase: connect' ' 44 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -blockPhase connect | sed "0,/^register|ready/d" >actual && 45 | config|ready 46 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 47 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 48 | EOD 49 | cat <<-EOD >expected && 50 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 51 | EOD 52 | test_cmp actual expected 53 | ' 54 | 55 | test_run 'test block phase: helo' ' 56 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -blockPhase helo | sed "0,/^register|ready/d" >actual && 57 | config|ready 58 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 59 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 60 | filter|0.5|0|smtp-in|helo|7641df9771b4ed00|1ef1c203cc576e5d|localhost 61 | EOD 62 | cat <<-EOD >expected && 63 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 64 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 65 | EOD 66 | test_cmp actual expected 67 | ' 68 | 69 | test_run 'test block phase: ehlo' ' 70 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -blockPhase ehlo | sed "0,/^register|ready/d" >actual && 71 | config|ready 72 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 73 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 74 | filter|0.5|0|smtp-in|ehlo|7641df9771b4ed00|1ef1c203cc576e5d|localhost 75 | EOD 76 | cat <<-EOD >expected && 77 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 78 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 79 | EOD 80 | test_cmp actual expected 81 | ' 82 | 83 | test_run 'test block phase: mail-from' ' 84 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -blockPhase mail-from | sed "0,/^register|ready/d" >actual && 85 | config|ready 86 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 87 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 88 | filter|0.5|0|smtp-in|mail-from|7641df9771b4ed00|1ef1c203cc576e5d|root@localhost 89 | EOD 90 | cat <<-EOD >expected && 91 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 92 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 93 | EOD 94 | test_cmp actual expected 95 | ' 96 | 97 | test_run 'test block phase: rcpt-to' ' 98 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -blockPhase rcpt-to | sed "0,/^register|ready/d" >actual && 99 | config|ready 100 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 101 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 102 | filter|0.5|0|smtp-in|rcpt-to|7641df9771b4ed00|1ef1c203cc576e5d|root@localhost 103 | EOD 104 | cat <<-EOD >expected && 105 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|proceed 106 | filter-result|7641df9771b4ed00|1ef1c203cc576e5d|disconnect|550 your IP reputation is too low for this MX 107 | EOD 108 | test_cmp actual expected 109 | ' 110 | 111 | test_run 'test with invalid block phase: data-line' ' 112 | cat <<-EOD | "$FILTER_BIN" $FILTER_OPTS -blockBelow 20 -blockPhase data-line; [ "$?" -eq 1 ] 113 | config|ready 114 | report|0.5|0|smtp-in|link-connect|7641df9771b4ed00||pass|1.2.3.4:33174|1.1.1.1:25 115 | filter|0.5|0|smtp-in|connect|7641df9771b4ed00|1ef1c203cc576e5d||pass|1.2.3.4:33174|1.1.1.1:25 116 | EOD 117 | ' 118 | 119 | test_complete 120 | -------------------------------------------------------------------------------- /filter-senderscore.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2019 Gilles Chehade 3 | // 4 | // Permission to use, copy, modify, and distribute this software for any 5 | // purpose with or without fee is hereby granted, provided that the above 6 | // copyright notice and this permission notice appear in all copies. 7 | // 8 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | // 16 | 17 | package main 18 | 19 | import ( 20 | "bufio" 21 | "flag" 22 | "fmt" 23 | "net" 24 | "os" 25 | "strconv" 26 | "strings" 27 | 28 | "log" 29 | "time" 30 | ) 31 | 32 | var blockBelow *int 33 | var blockPhase *string 34 | var junkBelow *int 35 | var slowFactor *int 36 | var scoreHeader *bool 37 | var allowlistFile *string 38 | var testMode *bool 39 | var allowlist = make(map[string]bool) 40 | var allowlistMasks = make(map[int]bool) 41 | 42 | var version string 43 | 44 | var outputChannel chan string 45 | 46 | type session struct { 47 | id string 48 | 49 | category int8 50 | score int8 51 | 52 | delay int 53 | first_line bool 54 | } 55 | 56 | var sessions = make(map[string]*session) 57 | 58 | var reporters = map[string]func(string, string, []string){ 59 | "link-connect": linkConnect, 60 | "link-disconnect": linkDisconnect, 61 | } 62 | 63 | var filters = map[string]func(string, string, []string){ 64 | "connect": filterConnect, 65 | 66 | "helo": delayedAnswer, 67 | "ehlo": delayedAnswer, 68 | "starttls": delayedAnswer, 69 | "auth": delayedAnswer, 70 | "mail-from": delayedAnswer, 71 | "rcpt-to": delayedAnswer, 72 | "data": delayedAnswer, 73 | "data-line": dataline, 74 | "commit": delayedAnswer, 75 | 76 | "quit": delayedAnswer, 77 | } 78 | 79 | func linkConnect(phase string, sessionId string, params []string) { 80 | if len(params) != 4 { 81 | log.Fatal("invalid input, shouldn't happen") 82 | } 83 | 84 | s := &session{} 85 | s.first_line = true 86 | s.score = -1 87 | sessions[sessionId] = s 88 | 89 | addr := net.ParseIP(strings.Split(params[2], ":")[0]) 90 | if addr == nil || strings.Contains(addr.String(), ":") { 91 | return 92 | } 93 | 94 | defer func(addr net.IP, s *session) { 95 | fmt.Fprintf(os.Stderr, "link-connect addr=%s score=%d\n", addr, s.score) 96 | }(addr, s) 97 | 98 | for maskOnes := range allowlistMasks { 99 | mask := net.CIDRMask(maskOnes, 32) 100 | maskedAddr := addr.Mask(mask).String() 101 | query := fmt.Sprintf("%s/%d", maskedAddr, maskOnes) 102 | if allowlist[query] { 103 | fmt.Fprintf(os.Stderr, "IP address %s matches allowlisted subnet %s\n", addr, query) 104 | s.score = 100 105 | return 106 | } 107 | } 108 | 109 | atoms := strings.Split(addr.String(), ".") 110 | 111 | if *testMode { 112 | // if test mode is enabled, the Sender Score DNS query is 113 | // skipped and the score is derived directly from the 114 | // connecting IP address; IP addresses ending with 255 can be 115 | // used to simulate missing Sender Score DNS entries 116 | if atoms[3] == "255" { 117 | return 118 | } 119 | } else { 120 | addrs, _ := net.LookupIP(fmt.Sprintf("%s.%s.%s.%s.score.senderscore.com", 121 | atoms[3], atoms[2], atoms[1], atoms[0])) 122 | 123 | if len(addrs) != 1 { 124 | return 125 | } 126 | 127 | resolved := addrs[0].String() 128 | atoms = strings.Split(resolved, ".") 129 | } 130 | 131 | category, _ := strconv.ParseInt(atoms[2], 10, 8) 132 | score, _ := strconv.ParseInt(atoms[3], 10, 8) 133 | 134 | s.category = int8(category) 135 | s.score = int8(score) 136 | } 137 | 138 | func linkDisconnect(phase string, sessionId string, params []string) { 139 | if len(params) != 0 { 140 | log.Fatal("invalid input, shouldn't happen") 141 | } 142 | delete(sessions, sessionId) 143 | } 144 | 145 | func getSession(sessionId string) *session { 146 | s, ok := sessions[sessionId] 147 | if !ok { 148 | log.Fatalf("invalid session ID: %s", sessionId) 149 | } 150 | return s 151 | } 152 | 153 | func filterConnect(phase string, sessionId string, params []string) { 154 | s := getSession(sessionId) 155 | 156 | if *slowFactor > 0 && s.score >= 0 { 157 | s.delay = *slowFactor * (100 - int(s.score)) / 100 158 | } else { 159 | // no slow factor or neutral IP address 160 | s.delay = 0 161 | } 162 | 163 | if s.score != -1 && s.score < int8(*blockBelow) && *blockPhase == "connect" { 164 | delayedDisconnect(sessionId, params) 165 | } else if s.score != -1 && s.score < int8(*junkBelow) { 166 | delayedJunk(sessionId, params) 167 | } else { 168 | delayedProceed(sessionId, params) 169 | } 170 | } 171 | 172 | func produceOutput(msgType string, sessionId string, token string, format string, a ...interface{}) { 173 | var out string 174 | 175 | tokens := strings.Split(version, ".") 176 | hiver, _ := strconv.Atoi(tokens[0]) 177 | lover, _ := strconv.Atoi(tokens[1]) 178 | if hiver == 0 && lover < 5 { 179 | out = msgType + "|" + token + "|" + sessionId 180 | } else { 181 | out = msgType + "|" + sessionId + "|" + token 182 | } 183 | out += "|" + fmt.Sprintf(format, a...) 184 | 185 | if *testMode { 186 | fmt.Println(out) 187 | } else { 188 | outputChannel <- out 189 | } 190 | } 191 | 192 | func dataline(phase string, sessionId string, params []string) { 193 | s := getSession(sessionId) 194 | token := params[0] 195 | line := strings.Join(params[1:], "|") 196 | 197 | if s.first_line == true { 198 | if s.score != -1 && *scoreHeader { 199 | produceOutput("filter-dataline", sessionId, token, "X-SenderScore: %d", s.score) 200 | } 201 | s.first_line = false 202 | } 203 | 204 | produceOutput("filter-dataline", sessionId, token, "%s", line) 205 | } 206 | 207 | func delayedAnswer(phase string, sessionId string, params []string) { 208 | s := getSession(sessionId) 209 | 210 | if s.score != -1 && s.score < int8(*blockBelow) && *blockPhase == phase { 211 | delayedDisconnect(sessionId, params) 212 | return 213 | } 214 | 215 | delayedProceed(sessionId, params) 216 | } 217 | 218 | func delayedJunk(sessionId string, params []string) { 219 | s := getSession(sessionId) 220 | token := params[0] 221 | if *testMode { 222 | waitThenAction(sessionId, token, s.delay, "junk") 223 | } else { 224 | go waitThenAction(sessionId, token, s.delay, "junk") 225 | } 226 | } 227 | 228 | func delayedProceed(sessionId string, params []string) { 229 | s := getSession(sessionId) 230 | token := params[0] 231 | if *testMode { 232 | waitThenAction(sessionId, token, s.delay, "proceed") 233 | } else { 234 | go waitThenAction(sessionId, token, s.delay, "proceed") 235 | } 236 | } 237 | 238 | func delayedDisconnect(sessionId string, params []string) { 239 | s := getSession(sessionId) 240 | token := params[0] 241 | if *testMode { 242 | waitThenAction(sessionId, token, s.delay, "disconnect|550 your IP reputation is too low for this MX") 243 | } else { 244 | go waitThenAction(sessionId, token, s.delay, "disconnect|550 your IP reputation is too low for this MX") 245 | } 246 | } 247 | 248 | func waitThenAction(sessionId string, token string, delay int, format string, a ...interface{}) { 249 | if delay > 0 { 250 | time.Sleep(time.Duration(delay) * time.Millisecond) 251 | } 252 | produceOutput("filter-result", sessionId, token, format, a...) 253 | } 254 | 255 | func filterInit() { 256 | for k := range reporters { 257 | fmt.Printf("register|report|smtp-in|%s\n", k) 258 | } 259 | for k := range filters { 260 | fmt.Printf("register|filter|smtp-in|%s\n", k) 261 | } 262 | fmt.Println("register|ready") 263 | } 264 | 265 | func trigger(currentSlice map[string]func(string, string, []string), atoms []string) { 266 | if handler, ok := currentSlice[atoms[4]]; ok { 267 | handler(atoms[4], atoms[5], atoms[6:]) 268 | } else { 269 | log.Fatalf("invalid phase: %s", atoms[4]) 270 | } 271 | } 272 | 273 | func skipConfig(scanner *bufio.Scanner) { 274 | for { 275 | if !scanner.Scan() { 276 | os.Exit(0) 277 | } 278 | line := scanner.Text() 279 | if line == "config|ready" { 280 | return 281 | } 282 | } 283 | } 284 | 285 | func validatePhase(phase string) { 286 | switch phase { 287 | case "connect", "helo", "ehlo", "starttls", "auth", "mail-from", "rcpt-to", "quit": 288 | return 289 | } 290 | log.Fatalf("invalid block phase: %s", phase) 291 | } 292 | 293 | func loadAllowlists() { 294 | if *allowlistFile == "" { 295 | return 296 | } 297 | 298 | file, err := os.Open(*allowlistFile) 299 | if err != nil { 300 | log.Fatal(err) 301 | } 302 | defer file.Close() 303 | 304 | scanner := bufio.NewScanner(file) 305 | for scanner.Scan() { 306 | line := scanner.Text() 307 | 308 | // remove comments and whitespace, skip empty lines 309 | line = strings.TrimSpace(strings.Split(line, "#")[0]) 310 | if line == "" { 311 | continue 312 | } 313 | 314 | if !strings.Contains(line, "/") { 315 | line += "/32" 316 | } 317 | _, subnet, err := net.ParseCIDR(line) 318 | if err != nil { 319 | log.Fatalf("invalid subnet: %s", subnet) 320 | } 321 | 322 | maskOnes, _ := subnet.Mask.Size() 323 | if !allowlistMasks[maskOnes] { 324 | allowlistMasks[maskOnes] = true 325 | } 326 | subnetStr := subnet.String() 327 | if !allowlist[subnetStr] { 328 | allowlist[subnetStr] = true 329 | fmt.Fprintf(os.Stderr, "Subnet %s added to allowlist\n", subnetStr) 330 | } 331 | } 332 | if err := scanner.Err(); err != nil { 333 | log.Fatal(err) 334 | } 335 | } 336 | 337 | func main() { 338 | blockBelow = flag.Int("blockBelow", -1, "score below which session is blocked") 339 | blockPhase = flag.String("blockPhase", "connect", "phase at which blockBelow triggers") 340 | junkBelow = flag.Int("junkBelow", -1, "score below which session is junked") 341 | slowFactor = flag.Int("slowFactor", -1, "delay factor to apply to sessions") 342 | scoreHeader = flag.Bool("scoreHeader", false, "add X-SenderScore header") 343 | allowlistFile = flag.String("allowlist", "", "file containing a list of IP addresses or subnets in CIDR notation to allowlist, one per line") 344 | testMode = flag.Bool("testMode", false, "skip all DNS queries, process all requests sequentially, only for debugging purposes") 345 | 346 | flag.Parse() 347 | 348 | validatePhase(*blockPhase) 349 | loadAllowlists() 350 | 351 | scanner := bufio.NewScanner(os.Stdin) 352 | skipConfig(scanner) 353 | filterInit() 354 | 355 | if !*testMode { 356 | outputChannel = make(chan string) 357 | go func() { 358 | for line := range outputChannel { 359 | fmt.Println(line) 360 | } 361 | }() 362 | } 363 | 364 | for { 365 | if !scanner.Scan() { 366 | os.Exit(0) 367 | } 368 | 369 | line := scanner.Text() 370 | atoms := strings.Split(line, "|") 371 | if len(atoms) < 6 { 372 | log.Fatalf("missing atoms: %s", line) 373 | } 374 | 375 | version = atoms[1] 376 | 377 | switch atoms[0] { 378 | case "report": 379 | trigger(reporters, atoms) 380 | case "filter": 381 | trigger(filters, atoms) 382 | default: 383 | log.Fatalf("invalid stream: %s", atoms[0]) 384 | } 385 | } 386 | } 387 | --------------------------------------------------------------------------------