Details | Last modification | View Log | RSS feed
Rev | Author | Line No. | Line |
---|---|---|---|
1 | magnus | 1 | package Greylisting; |
2 | # |
||
3 | # $Id: Greylisting.pm,v 1.4 2006/01/11 17:17:28 marcmerlin Exp $ |
||
4 | # |
||
5 | |||
6 | # General Greylisting Plugin, written by Marc MERLIN <marc_soft@merlins.org> |
||
7 | # (Kristopher Austin gets the credit for the original port to an SA 3.0 plugin) |
||
8 | # |
||
9 | # This was originally written to implement greylisting in SA-Exim, although |
||
10 | # I have tried to make it more general and allow for reuse in other MTAs |
||
11 | # (although they will need to |
||
12 | # 1) be running SA at SMTP time |
||
13 | # 2) Provide the list of rcpt to and env from in some headers for SA to read |
||
14 | # 3) Provide the IP of the connecting host ) |
||
15 | # |
||
16 | # This rule should get a negative score so that if we've already seen the |
||
17 | # greylisting tuplet before, we lower the score, which hopefully brings us from |
||
18 | # a tempreject to an accept (at least that's how sa-exim does it) |
||
19 | # |
||
20 | # -- Marc 2004/01/19 |
||
21 | |||
22 | use strict; |
||
23 | use Mail::SpamAssassin::Plugin; |
||
24 | our @ISA = qw(Mail::SpamAssassin::Plugin); |
||
25 | |||
26 | sub new |
||
27 | { |
||
28 | my ($class, $mailsa) = @_; |
||
29 | $class = ref($class) || $class; |
||
30 | my $self = $class->SUPER::new($mailsa); |
||
31 | bless ($self, $class); |
||
32 | $self->register_eval_rule ("greylisting"); |
||
33 | return $self; |
||
34 | } |
||
35 | |||
36 | |||
37 | sub check_end |
||
38 | { |
||
39 | my ($self, $permsgstatus) = @_; |
||
40 | |||
41 | if (not $self->{'rangreylisting'}) |
||
42 | { |
||
43 | Mail::SpamAssassin::Plugin::dbg("GREYLISTING: greylisting didn't run since the configuration wasn't setup to call us"); |
||
44 | } |
||
45 | } |
||
46 | |||
47 | # Greylisting happens depending on the SA score, so we want to run it last, |
||
48 | # which is why we give it a high priority |
||
49 | sub greylisting |
||
50 | { |
||
51 | my ($self, $permsgstatus, $optionhash) = @_; |
||
52 | |||
53 | my $connectip; |
||
54 | my $envfrom; |
||
55 | my $rcptto; |
||
56 | my @rcptto; |
||
57 | my $iswhitelisted=0; |
||
58 | my $err; |
||
59 | my $mesgid = $permsgstatus->get('Message-Id')."\n"; |
||
60 | my $mesgidfn; |
||
61 | my $tuplet; |
||
62 | my $sascore = $permsgstatus->get_score(); |
||
63 | my $dontcheckscore; |
||
64 | my %option; |
||
65 | |||
66 | Mail::SpamAssassin::Plugin::dbg("GREYLISTING: called function"); |
||
67 | |||
68 | $optionhash =~ s/;/,/g; |
||
69 | # This is safe, right? (users shouldn't be able to set it in their config) |
||
70 | %option=eval $optionhash; |
||
71 | $self->{'rangreylisting'}=1; |
||
72 | |||
73 | foreach my $reqoption (qw ( method greylistsecs dontgreylistthreshold |
||
74 | connectiphdr envfromhdr rcpttohdr greylistnullfrom greylistfourthbyte )) |
||
75 | { |
||
76 | die "Greylist option $reqoption missing from SA config" unless (defined $option{$reqoption}); |
||
77 | } |
||
78 | |||
79 | $dontcheckscore = $option{'dontgreylistthreshold'}; |
||
80 | |||
81 | |||
82 | # No newlines, thank you (yes, you need this twice apparently) |
||
83 | chomp ($mesgid); |
||
84 | chomp ($mesgid); |
||
85 | # Newline in the middle mesgids, are you serious? Get rid of them here |
||
86 | $mesgid =~ s/\012/|/g; |
||
87 | |||
88 | # For stuff that we know is spam, don't greylist the host |
||
89 | # (that might help later spam with a lower score to come in) |
||
90 | if ($sascore >= $dontcheckscore) |
||
91 | { |
||
92 | Mail::SpamAssassin::Plugin::dbg("GREYLISTING: skipping greylisting on $mesgid, since score is already $sascore and you configured greylisting not to bother with anything above $dontcheckscore"); |
||
93 | return 0; |
||
94 | } |
||
95 | else |
||
96 | { |
||
97 | Mail::SpamAssassin::Plugin::dbg("GREYLISTING: running greylisting on $mesgid, since score is too low ($sascore) and you configured greylisting to greylist anything under $dontcheckscore"); |
||
98 | } |
||
99 | |||
100 | if (not $connectip = $permsgstatus->get($option{'connectiphdr'})) |
||
101 | { |
||
102 | warn "Couldn't get Connecting IP header $option{'connectiphdr'} for message $mesgid, skipping greylisting call\n"; |
||
103 | return 0; |
||
104 | } |
||
105 | chomp($connectip); |
||
106 | # Clean up input (for security, if you use files/dirs) |
||
107 | $connectip =~ /([\d.:]+)/; |
||
108 | $connectip = ($1 or ""); |
||
109 | |||
110 | # Account for a null envelope from |
||
111 | if (not defined ($envfrom = $permsgstatus->get($option{'envfromhdr'}))) |
||
112 | { |
||
113 | warn "Couldn't get Envelope From header $option{'envfromhdr'} for message $mesgid, skipping greylisting call\n"; |
||
114 | return 0; |
||
115 | } |
||
116 | chomp($envfrom); |
||
117 | # Clean up input (for security, if you use files/dirs) |
||
118 | $envfrom =~ s#/#-#g; |
||
119 | if (not $envfrom) |
||
120 | { |
||
121 | $envfrom="<>"; |
||
122 | return 0 if (not $option{'greylistnullfrom'}); |
||
123 | } |
||
124 | |||
125 | if (not $rcptto = $permsgstatus->get($option{'rcpttohdr'})) |
||
126 | { |
||
127 | warn "Couldn't get Rcpt To header $option{'rcpttohdr'} for message $mesgid, skipping greylisting call\n"; |
||
128 | return 0; |
||
129 | } |
||
130 | chomp($rcptto); |
||
131 | # Clean up input (for security, if you use files/dirs) |
||
132 | $rcptto =~ s#/#-#g; |
||
133 | @rcptto = split(/, /, $rcptto); |
||
134 | |||
135 | |||
136 | umask 0007; |
||
137 | |||
138 | foreach $rcptto (@rcptto) |
||
139 | { |
||
140 | # The dir method is easy to fiddle with and expire records in (with |
||
141 | # a find | rm) but it's probably more I/O extensive than a real DB |
||
142 | # and suffers from directory size problems if a specific IP is sending |
||
143 | # generating tens of thousands of tuplets. -- Marc |
||
144 | # That said, I prefer formats I can easily tinker with, and not having |
||
145 | # to worry about buggy locking and so forth |
||
146 | |||
147 | if ($option{'method'} eq "dir") |
||
148 | { |
||
149 | my $tmpvar; |
||
150 | |||
151 | # The clean strings are hardcoded because it's hard to do a variable |
||
152 | # substitution within a tr (and using the eval solution is too |
||
153 | # resource expensive) |
||
154 | # envfrom could be cleaned outside of the loop, but the other method |
||
155 | # options might now want that |
||
156 | $envfrom =~ tr/!#%()*+,-.0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~/_/c; |
||
157 | # clean variables to run properly under -T |
||
158 | $envfrom =~ /(.+)/; |
||
159 | $tmpvar = ($1 or ""); |
||
160 | # work around bug in perl untaint in perl 5.8 |
||
161 | $envfrom=undef; |
||
162 | $envfrom=$tmpvar; |
||
163 | $rcptto =~ tr/!#%()*+,-.0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~/_/c; |
||
164 | $rcptto =~ /(.+)/; |
||
165 | $tmpvar = ($1 or ""); |
||
166 | $rcptto=undef; |
||
167 | $rcptto=$tmpvar; |
||
168 | |||
169 | die "greylist option dir not passed, even though method was set to dir" unless ($option{'dir'}); |
||
170 | |||
171 | # connectip is supposed to be untainted now, but I was still getting |
||
172 | # some insecure dependecy error messages sometimes (perl 5.8 problem apparently) |
||
173 | $connectip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/; |
||
174 | my ($ipbyte1, $ipbyte2, $ipbyte3, $ipbyte4) = ($1, $2, $3, $4); |
||
175 | my $ipdir1 = "$option{'dir'}/$ipbyte1"; |
||
176 | my $ipdir2 = "$ipdir1/$ipbyte2"; |
||
177 | my $ipdir3 = "$ipdir2/$ipbyte3"; |
||
178 | my $ipdir4; |
||
179 | my $tupletdir; |
||
180 | |||
181 | $ipdir4 = "$ipdir3"; |
||
182 | $ipdir4 .= "/$ipbyte4" if ($option{'greylistfourthbyte'}); |
||
183 | $tupletdir = "$ipdir4/$envfrom"; |
||
184 | |||
185 | $tuplet = "$tupletdir/$rcptto"; |
||
186 | |||
187 | # make directory whether it's there or not (faster than test and set) |
||
188 | mkdir $ipdir1; |
||
189 | mkdir $ipdir2; |
||
190 | mkdir $ipdir3; |
||
191 | mkdir $ipdir4; |
||
192 | mkdir $tupletdir; |
||
193 | |||
194 | if (not -e $tuplet) |
||
195 | { |
||
196 | # If the tuplets aren't there, we create them and continue in |
||
197 | # case there are other ones (one of them might be whitelisted |
||
198 | # already) |
||
199 | $err="creating $tuplet"; |
||
200 | open (TUPLET, ">$tuplet") or goto greylisterror; |
||
201 | print TUPLET time."\n"; |
||
202 | print TUPLET "Status: Greylisted\n"; |
||
203 | print TUPLET "Last Message-Id: $mesgid\n"; |
||
204 | print TUPLET "Whitelisted Count: 0\n"; |
||
205 | print TUPLET "Query Count: 1\n"; |
||
206 | print TUPLET "SA Score: $sascore\n"; |
||
207 | $err="closing first-written $tuplet"; |
||
208 | close TUPLET or goto greylisterror; |
||
209 | } |
||
210 | else |
||
211 | { |
||
212 | my $time; |
||
213 | my $status; |
||
214 | my $whitelistcount; |
||
215 | my $querycount; |
||
216 | |||
217 | # Take into account race condition of expiring deletes and us |
||
218 | # running |
||
219 | $err="reading $tuplet"; |
||
220 | open (TUPLET, "<$tuplet") or goto greylisterror; |
||
221 | $err="Couldn't read time"; |
||
222 | defined ($time=<TUPLET>) or goto greylisterror; |
||
223 | chomp ($time); |
||
224 | |||
225 | $err="Couldn't read status"; |
||
226 | defined ($status=<TUPLET>) or goto greylisterror; |
||
227 | chomp ($status); |
||
228 | $err="Couldn't extract Status from $status"; |
||
229 | $status =~ s/^Status: // or goto greylisterror; |
||
230 | |||
231 | # Skip Mesg-Id |
||
232 | $err="Couldn't skip Mesg-Id"; |
||
233 | defined ($_=<TUPLET>) or goto greylisterror; |
||
234 | |||
235 | $err="Couldn't read whitelistcount"; |
||
236 | defined ($whitelistcount=<TUPLET>) or goto greylisterror; |
||
237 | chomp ($whitelistcount); |
||
238 | $err="Couldn't extract Whitelisted Count from $whitelistcount"; |
||
239 | $whitelistcount =~ s/^Whitelisted Count: // or goto greylisterror; |
||
240 | |||
241 | $err="Couldn't read querycount"; |
||
242 | defined ($querycount=<TUPLET>) or goto greylisterror; |
||
243 | chomp ($querycount); |
||
244 | $err="Couldn't extract Query Count from $querycount"; |
||
245 | $querycount =~ s/^Query Count: // or goto greylisterror; |
||
246 | close (TUPLET); |
||
247 | |||
248 | $querycount++; |
||
249 | if ((time - $time) > $option{'greylistsecs'}) |
||
250 | { |
||
251 | $status="Whitelisted"; |
||
252 | $whitelistcount++; |
||
253 | } |
||
254 | |||
255 | $err="re-writing $tuplet"; |
||
256 | open (TUPLET, ">$tuplet") or goto greylisterror; |
||
257 | print TUPLET "$time\n"; |
||
258 | print TUPLET "Status: $status\n"; |
||
259 | print TUPLET "Last Message-Id: $mesgid\n"; |
||
260 | print TUPLET "Whitelisted Count: $whitelistcount\n"; |
||
261 | print TUPLET "Query Count: $querycount\n"; |
||
262 | print TUPLET "SA Score: $sascore\n"; |
||
263 | $err="closing re-written $tuplet"; |
||
264 | close TUPLET or goto greylisterror; |
||
265 | |||
266 | # We continue processing the other recipients, to setup or |
||
267 | # update their counters |
||
268 | if ($status eq "Whitelisted") |
||
269 | { |
||
270 | $iswhitelisted=1; |
||
271 | } |
||
272 | } |
||
273 | } |
||
274 | elsif ($option{'method'} eq "file") |
||
275 | { |
||
276 | warn "codeme (file greylisting)\n"; |
||
277 | } |
||
278 | elsif ($option{'method'} eq "db") |
||
279 | { |
||
280 | warn "codeme (db greylisting)\n"; |
||
281 | } |
||
282 | } |
||
283 | |||
284 | Mail::SpamAssassin::Plugin::dbg("GREYLISTING: computed greylisting on tuplet, saved info in $tuplet and whitelist status is $iswhitelisted"); |
||
285 | return $iswhitelisted; |
||
286 | |||
287 | greylisterror: |
||
288 | warn "Reached greylisterror: $err / $!"; |
||
289 | # delete tuplet since it apparently had issues but don't check for errors |
||
290 | # in case it was a permission denied on write |
||
291 | unlink ($tuplet); |
||
292 | return $iswhitelisted; |
||
293 | } |
||
294 | |||
295 | |||
296 | 1; |