def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.logger = self._logger() self.skiplist = FileList(filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True) self.requiredvars = { 'max_lookups': { 'default': '10', 'description': 'maximum number of lookups (RFC defaults to 10)', }, 'skiplist': { 'default': '', 'description': 'File containing a list of domains (one per line) which are not checked' }, 'temperror_retries': { 'default': '3', 'description': 'maximum number of retries on temp error', }, 'temperror_sleep': { 'default': '3', 'description': 'waiting interval between retries on temp error', }, }
def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.requiredvars = { 'domainsfile': { 'description': "File containing a list of domains (one per line) which must be DKIM and/or SPF authenticated", 'default': "/etc/fuglu/auth_required_domains.txt", }, 'failaction': { 'default': 'DUNNO', 'description': "action if the message doesn't pass authentication (DUNNO, REJECT)", }, 'rejectmessage': { 'default': 'sender domain ${header_from_domain} must pass DKIM and/or SPF authentication', 'description': "reject message template if running in pre-queue mode", }, } self.logger = self._logger() self.filelist = FileList(filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True)
def test_filelist(self): self.assertEqual( FileList(filename=self.filename, strip=True, skip_empty=True, skip_comments=True, lowercase=False, additional_filters=None).get_list(), ['CASE?', 'stripped ?']) self.assertEqual( FileList(filename=self.filename, strip=False, skip_empty=True, skip_comments=True, lowercase=False, additional_filters=None).get_list(), ['CASE?', ' stripped ? ', ' ']) self.assertEqual( FileList(filename=self.filename, strip=True, skip_empty=False, skip_comments=False, lowercase=False, additional_filters=None).get_list(), ['CASE?', 'stripped ?', '', '', '# no comment!']) self.assertEqual( FileList(filename=self.filename, strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None).get_list(), ['case?', 'stripped ?'])
def __init__(self, section=None): ScannerPlugin.__init__(self, section) self.logger = self._logger() self.filelist = FileList(strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None, minimum_time_between_reloads=30) self.requiredvars = { 'domainsfile': { 'default': '/etc/fuglu/spearphish-domains', 'description': 'Filename where we load spearphish domains from. One domain per line. If this setting is empty, the check will be applied to all domains.', }, 'virusenginename': { 'default': 'Fuglu SpearPhishing Protection', 'description': 'Name of this plugins av engine', }, 'virusname': { 'default': 'TRAIT.SPEARPHISH', 'description': 'Name to use as virus signature', }, 'virusaction': { 'default': 'DEFAULTVIRUSACTION', 'description': "action if spear phishing attempt is detected (DUNNO, REJECT, DELETE)", }, 'rejectmessage': { 'default': 'threat detected: ${virusname}', 'description': "reject message template if running in pre-queue mode and virusaction=REJECT", }, 'dbconnection': { 'default': "mysql://root@localhost/spfcheck?charset=utf8", 'description': 'SQLAlchemy Connection string. Leave empty to disable SQL lookups', }, 'domain_sql_query': { 'default': "SELECT check_spearphish from domain where domain_name=:domain", 'description': 'get from sql database :domain will be replaced with the actual domain name. must return boolean field check_spearphish', }, 'check_display_part': { 'default': 'False', 'description': "set to True to also check display part of From header (else email part only)", }, }
def _init_nobounce(self): if self.nobounce is None: try: filepath = self.config.get('main', 'nobouncefile') except Exception: filepath = None if filepath and os.path.exists(filepath): self.nobounce = FileList(filepath) elif filepath: self.logger.warning('nobouncefile %s not found' % filepath)
def _is_whitelisted(self, from_domain): whitelist_file = self.config.get(self.section, 'whitelist_file') if whitelist_file == '': return False if self.whitelist is None: self.whitelist = FileList(whitelist_file, lowercase=True) whitelisted = False if from_domain in self.whitelist.get_list(): whitelisted = True return whitelisted
def __init__(self, section=None): ScannerPlugin.__init__(self, section) self.filelist = FileList(strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None, minimum_time_between_reloads=30) self.requiredvars = { 'domainsfile': { 'default': '/etc/fuglu/spearphish-domains', 'description': 'Filename where we load spearphish domains from. One domain per line. If this setting is empty, the check will be applied to all domains.', }, 'virusenginename': { 'default': 'Fuglu SpearPhishing Protection', 'description': 'Name of this plugins av engine', }, 'virusname': { 'default': 'TRAIT.SPEARPHISH', 'description': 'Name to use as virus signature', }, 'virusaction': { 'default': 'DEFAULTVIRUSACTION', 'description': "action if spear phishing attempt is detected (DUNNO, REJECT, DELETE)", }, 'rejectmessage': { 'default': 'threat detected: ${virusname}', 'description': "reject message template if running in pre-queue mode and virusaction=REJECT", }, }
def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.logger = self._logger() self.skiplist = FileList(filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True) self.requiredvars = { 'skiplist': { 'default': '', 'description': 'File containing a list of domains (one per line) which are not checked' }, }
def _init_tldmagic(self): init_tldmagic = False extratlds = [] if self.extratlds is None: extratldfile = self.config.get(self.section, 'extra_tld_file') if extratldfile and os.path.exists(extratldfile): self.extratlds = FileList(extratldfile, lowercase=True) init_tldmagic = True if self.extratlds is not None: extratlds = self.extratlds.get_list() if self.lasttlds != extratlds: # extra tld file changed self.lasttlds = extratlds init_tldmagic = True if self.tldmagic is None or init_tldmagic: self.tldmagic = TLDMagic() for tld in extratlds: # add extra tlds to tldmagic self.tldmagic.add_tld(tld)
def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.requiredvars = { 'domainsfile': { 'description': "File containing a list of domains (one per line) which must be DKIM and/or SPF authenticated", 'default': "/etc/fuglu/auth_required_domains.txt", }, 'failaction': { 'default': 'DUNNO', 'description': "action if the message doesn't pass authentication (DUNNO, REJECT)", }, 'rejectmessage': { 'default': 'sender domain ${header_from_domain} must pass DKIM and/or SPF authentication', 'description': "reject message template if running in pre-queue mode", }, } self.logger = self._logger() self.filelist=FileList(filename=None,strip=True, skip_empty=True, skip_comments=True,lowercase=True)
def __init__(self, section=None): ScannerPlugin.__init__(self, section) self.logger = self._logger() self.filelist = FileList(strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None, minimum_time_between_reloads=30) self.requiredvars = { 'domainsfile': { 'default': '/etc/fuglu/spearphish-domains', 'description': 'Filename where we load spearphish domains from. One domain per line. If this setting is empty, the check will be applied to all domains.', }, 'virusenginename': { 'default': 'Fuglu SpearPhishing Protection', 'description': 'Name of this plugins av engine', }, 'virusname': { 'default': 'TRAIT.SPEARPHISH', 'description': 'Name to use as virus signature', }, 'virusaction': { 'default': 'DEFAULTVIRUSACTION', 'description': "action if spear phishing attempt is detected (DUNNO, REJECT, DELETE)", }, 'rejectmessage': { 'default': 'threat detected: ${virusname}', 'description': "reject message template if running in pre-queue mode and virusaction=REJECT", }, 'dbconnection':{ 'default':"mysql://root@localhost/spfcheck?charset=utf8", 'description':'SQLAlchemy Connection string. Leave empty to disable SQL lookups', }, 'domain_sql_query':{ 'default':"SELECT check_spearphish from domain where domain_name=:domain", 'description':'get from sql database :domain will be replaced with the actual domain name. must return boolean field check_spearphish', }, 'check_display_part': { 'default': 'False', 'description': "set to True to also check display part of From header (else email part only)", }, }
class SpearPhishPlugin(ScannerPlugin): """Mark spear phishing mails as virus The spearphish plugin checks if the sender domain in the "From"-Header matches the envelope recipient Domain ("Mail from my own domain") but the message usesa different envelope sender domain. This blocks many spearphish attempts. Note that this plugin can cause blocks of legitimate mail , for example if the recipient domain is using a third party service to send newsletters in their name. Such services often set the customers domain in the from headers but use their own domains in the envelope for bounce processing. Use the 'Plugin Skipper' or any other form of whitelisting in such cases. """ def __init__(self, section=None): ScannerPlugin.__init__(self, section) self.filelist = FileList(strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None, minimum_time_between_reloads=30) self.requiredvars = { 'domainsfile': { 'default': '/etc/fuglu/spearphish-domains', 'description': 'Filename where we load spearphish domains from. One domain per line. If this setting is empty, the check will be applied to all domains.', }, 'virusenginename': { 'default': 'Fuglu SpearPhishing Protection', 'description': 'Name of this plugins av engine', }, 'virusname': { 'default': 'TRAIT.SPEARPHISH', 'description': 'Name to use as virus signature', }, 'virusaction': { 'default': 'DEFAULTVIRUSACTION', 'description': "action if spear phishing attempt is detected (DUNNO, REJECT, DELETE)", }, 'rejectmessage': { 'default': 'threat detected: ${virusname}', 'description': "reject message template if running in pre-queue mode and virusaction=REJECT", }, } def should_we_check_this_domain(self, suspect): domainsfile = self.config.get(self.section, 'domainsfile') if domainsfile.strip() == '': # empty config -> check all domains return True if not os.path.exists(domainsfile): return False self.filelist.filename = domainsfile envelope_recipient_domain = suspect.to_domain.lower() checkdomains = self.filelist.get_list() return envelope_recipient_domain in checkdomains def examine(self, suspect): if not self.should_we_check_this_domain(suspect): return DUNNO envelope_recipient_domain = suspect.to_domain.lower() envelope_sender_domain = suspect.from_domain.lower() if envelope_sender_domain == envelope_recipient_domain: return DUNNO # we only check the message if the env_sender_domain differs. If it's the same it will be caught by other means (like SPF) header_from_domain = extract_from_domain(suspect) if header_from_domain is None: self._logger().warn( "%s: Could not extract header from domain for spearphish check" % suspect.id) return DUNNO if header_from_domain == envelope_recipient_domain: virusname = self.config.get(self.section, 'virusname') virusaction = self.config.get(self.section, 'virusaction') actioncode = string_to_actioncode(virusaction, self.config) logmsg = '%s: spear phish pattern detected, recipient=%s env_sender_domain=%s header_from_domain=%s' % ( suspect.id, suspect.to_address, envelope_sender_domain, header_from_domain) self._logger().info(logmsg) self.flag_as_phish(suspect, virusname) message = apply_template( self.config.get(self.section, 'rejectmessage'), suspect, {'virusname': virusname}) return actioncode, message return DUNNO def flag_as_phish(self, suspect, virusname): suspect.tags['%s.virus' % self.config.get(self.section, 'virusenginename')] = { 'message content': virusname } suspect.tags['virus'][self.config.get(self.section, 'virusenginename')] = True def __str__(self): return "Spearphish Check" def lint(self): allok = self.checkConfig() and self.lint_file() return allok def lint_file(self): filename = self.config.get(self.section, 'domainsfile') if not os.path.exists(filename): print("Spearphish domains file %s not found" % (filename)) return False return True
class DKIMVerifyPlugin(ScannerPlugin): """**EXPERIMENTAL** This plugin checks the DKIM signature of the message and sets tags... DKIMVerify.sigvalid : True if there was a valid DKIM signature, False if there was an invalid DKIM signature the tag is not set if there was no dkim header at all DKIMVerify.skipreason: set if the verification has been skipped The plugin does not take any action based on the DKIM test result since a failed DKIM validation by itself should not cause a message to be treated any differently. Other plugins might use the DKIM result in combination with other factors to take action (for example a "DMARC" plugin could use this information) It is currently recommended to leave both header and body canonicalization as 'relaxed'. Using 'simple' can cause the signature to fail. """ def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.logger = self._logger() self.skiplist = FileList(filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True) self.requiredvars = { 'skiplist': { 'default': '', 'description': 'File containing a list of domains (one per line) which are not checked' }, } def __str__(self): return "DKIM Verify" def examine(self, suspect): if not DKIMPY_AVAILABLE: suspect.debug("dkimpy not available, can not check") suspect.set_tag('DKIMVerify.skipreason', 'dkimpy library not available') return DUNNO hdr_from_domain = extract_from_domain(suspect) if not hdr_from_domain: self.logger.debug( '%s DKIM Verification skipped, no header from address') suspect.set_tag("DKIMVerify.skipreason", 'no header from address') return DUNNO self.skiplist.filename = self.config.get(self.section, 'skiplist') skiplist = self.skiplist.get_list() if hdr_from_domain in skiplist: self.logger.debug( '%s DKIM Verification skipped, sender domain skiplisted') suspect.set_tag("DKIMVerify.skipreason", 'sender domain skiplisted') return DUNNO source = suspect.get_original_source() if "dkim-signature" not in suspect.get_message_rep(): suspect.set_tag('DKIMVerify.skipreason', 'not dkim signed') suspect.write_sa_temp_header('X-DKIMVerify', 'unsigned') suspect.debug("No dkim signature header found") return DUNNO # use the local logger of the plugin but prepend the fuglu id d = DKIM(source, logger=PrependLoggerMsg(self.logger, prepend=suspect.id, maxlevel=logging.INFO)) try: try: valid = d.verify() except DKIMException as de: self.logger.warning("%s: DKIM validation failed: %s" % (suspect.id, str(de))) valid = False suspect.set_tag("DKIMVerify.sigvalid", valid) suspect.write_sa_temp_header('X-DKIMVerify', 'valid' if valid else 'invalid') except NameError as ne: self.logger.warning( "%s: DKIM validation failed due to missing dependency: %s" % (suspect.id, str(ne))) suspect.set_tag('DKIMVerify.skipreason', 'plugin error') except Exception as e: self.logger.warning("%s: DKIM validation failed: %s" % (suspect.id, str(e))) suspect.set_tag('DKIMVerify.skipreason', 'plugin error') return DUNNO def lint(self): if not DKIMPY_AVAILABLE: print("Missing dependency: dkimpy https://launchpad.net/dkimpy") print("(also requires either dnspython or pydns)") return False return self.check_config()
class SPFPlugin(ScannerPlugin): """**EXPERIMENTAL** This plugin checks the SPF status and sets tag 'SPF.status' to one of the official states 'pass', 'fail', 'neutral', 'softfail, 'permerror', 'temperror' or 'skipped' if the SPF check could not be peformed. Tag 'SPF.explanation' contains a human readable explanation of the result. Additionally information to be used by SA plugin is added The plugin does not take any action based on the SPF test result since. Other plugins might use the SPF result in combination with other factors to take action (for example a "DMARC" plugin could use this information) """ def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.logger = self._logger() self.skiplist = FileList(filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True) self.requiredvars = { 'max_lookups': { 'default': '10', 'description': 'maximum number of lookups (RFC defaults to 10)', }, 'skiplist': { 'default': '', 'description': 'File containing a list of domains (one per line) which are not checked' }, 'temperror_retries': { 'default': '3', 'description': 'maximum number of retries on temp error', }, 'temperror_sleep': { 'default': '3', 'description': 'waiting interval between retries on temp error', }, } def __str__(self): return "SPF Check" def _spf_lookup(self, ip, from_address, helo, retries=3): spf.MAX_LOOKUP = self.config.getint(self.section, 'max_lookups') result, explanation = spf.check2(ip, from_address, helo) if result == 'temperror' and retries > 0: time.sleep(self.config.getint(self.section, 'temperror_sleep')) retries -= 1 result, explanation = self._spf_lookup(ip, from_address, helo, retries) return result, explanation def examine(self, suspect): if not PYSPF_AVAILABLE: suspect.debug("pyspf not available, can not check") self.logger.warning("%s: SPF Check skipped, pyspf unavailable" % suspect.id) suspect.set_tag('SPF.status', 'skipped') suspect.set_tag("SPF.explanation", 'missing dependency') return DUNNO self.skiplist.filename = self.config.get(self.section, 'skiplist') checkdomains = self.skiplist.get_list() if suspect.from_domain in checkdomains: self.logger.debug('%s SPF Check skipped, sender domain skiplisted') suspect.set_tag('SPF.status', 'skipped') suspect.set_tag("SPF.explanation", 'sender domain skiplisted') return DUNNO clientinfo = suspect.get_client_info(self.config) if clientinfo is None: suspect.debug("client info not available for SPF check") self.logger.warning( "%s: SPF Check skipped, could not get client info" % suspect.id) suspect.set_tag('SPF.status', 'skipped') suspect.set_tag("SPF.explanation", 'could not extract client information') return DUNNO helo, ip, revdns = clientinfo retries = self.config.getint(self.section, 'temperror_retries') try: result, explanation = self._spf_lookup(ip, suspect.from_address, helo, retries) suspect.set_tag("SPF.status", result) suspect.set_tag("SPF.explanation", explanation) suspect.write_sa_temp_header('X-SPFCheck', result) suspect.debug("SPF status: %s (%s)" % (result, explanation)) except Exception as e: suspect.set_tag('SPF.status', 'skipped') suspect.set_tag("SPF.explanation", str(e)) self.logger.warning('%s SPF check failed for %s due to %s' % (suspect.id, suspect.from_domain, str(e))) return DUNNO def lint(self): all_ok = self.check_config() if not PYSPF_AVAILABLE: print("Missing dependency: pyspf") all_ok = False if not DNSQUERY_EXTENSION_ENABLED: print( "Missing dependency: no supported DNS libary found: pydns or dnspython" ) all_ok = False if not (IPADDR_AVAILABLE or IPADDRESS_AVAILABLE): print( "Missing dependency: no supported ip address libary found: ipaddr or ipaddress" ) all_ok = False return all_ok
class DomainAction(ScannerPlugin): """Perform Action based on Domains in message body""" def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.logger = self._logger() self.requiredvars = { 'blacklistconfig': { 'default': '/etc/fuglu/rbl.conf', 'description': 'RBL Lookup config file', }, 'checksubdomains': { 'default': 'yes', 'description': 'check subdomains as well (from top to bottom, eg. example.com, bla.example.com, blubb.bla.example.com', }, 'action': { 'default': 'reject', 'description': 'action on hit (reject, delete, etc)', }, 'message': { 'default': '5.7.1 black listed URL ${domain} by ${blacklist}', 'description': 'message template for rejects/ok messages', }, 'maxdomains': { 'default': '10', 'description': 'maximum number of domains to check per message', }, 'extra_tld_file': { 'default': '', 'description': 'directory containing files with extra TLDs (2TLD or inofficial TLDs)' }, } self.rbllookup = None self.tldmagic = None self.extratlds = None self.lasttlds = None def _init_tldmagic(self): init_tldmagic = False extratlds = [] if self.extratlds is None: extratldfile = self.config.get(self.section, 'extra_tld_file') if extratldfile and os.path.exists(extratldfile): self.extratlds = FileList(extratldfile, lowercase=True) init_tldmagic = True if self.extratlds is not None: extratlds = self.extratlds.get_list() if self.lasttlds != extratlds: # extra tld file changed self.lasttlds = extratlds init_tldmagic = True if self.tldmagic is None or init_tldmagic: self.tldmagic = TLDMagic() for tld in extratlds: # add extra tlds to tldmagic self.tldmagic.add_tld(tld) def examine(self, suspect): if not DOMAINMAGIC_AVAILABLE: self.logger.info('Not scanning - Domainmagic not available') return DUNNO if self.rbllookup is None: self.rbllookup = RBLLookup() self.rbllookup.from_config( self.config.get(self.section, 'blacklistconfig')) self._init_tldmagic() urls = suspect.get_tag('body.uris', defaultvalue=[]) #self.logger.info("Body URIs to check: %s"%urls) domains = set(map(fqdn_from_uri, urls)) counter = 0 for domain in domains: counter += 1 if counter > self.config.getint(self.section, 'maxdomains'): self.logger.info("maximum number of domains reached") break tldcount = self.tldmagic.get_tld_count(domain) parts = domain.split('.') if self.config.getboolean(self.section, 'checksubdomains'): subrange = range(tldcount + 1, len(parts) + 1) else: subrange = [tldcount + 1] for subindex in subrange: subdomain = '.'.join(parts[-subindex:]) listings = self.rbllookup.listings(subdomain) for identifier, humanreadable in iter(listings.items()): self.logger.info( "%s : url host %s flagged as %s because %s" % (suspect.id, domain, identifier, humanreadable)) return string_to_actioncode( self.config.get(self.section, 'action'), self.config), apply_template( self.config.get(self.section, 'message'), suspect, dict(domain=domain, blacklist=identifier)) return DUNNO def lint(self): allok = True if not DOMAINMAGIC_AVAILABLE: print( "ERROR: domainmagic lib or one of its dependencies (dnspython/pygeoip) is not installed!" ) allok = False if allok: allok = self.check_config() if allok: extratldfile = self.config.get(self.section, 'extra_tld_file') if extratldfile and not os.path.exists(extratldfile): allok = False print('WARNING: invalid extra_tld_file %s specified' % extratldfile) return allok
class DomainAuthPlugin(ScannerPlugin): """**EXPERIMENTAL** This plugin checks the header from domain against a list of domains which must be authenticated by DKIM and/or SPF. This is somewhat similar to DMARC but instead of asking the sender domain for a DMARC policy record this plugin allows you to force authentication on the recipient side. This plugin depends on tags written by SPFPlugin and DKIMVerifyPlugin, so they must run beforehand. """ def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.requiredvars = { 'domainsfile': { 'description': "File containing a list of domains (one per line) which must be DKIM and/or SPF authenticated", 'default': "/etc/fuglu/auth_required_domains.txt", }, 'failaction': { 'default': 'DUNNO', 'description': "action if the message doesn't pass authentication (DUNNO, REJECT)", }, 'rejectmessage': { 'default': 'sender domain ${header_from_domain} must pass DKIM and/or SPF authentication', 'description': "reject message template if running in pre-queue mode", }, } self.logger = self._logger() self.filelist = FileList( filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True) def examine(self, suspect): self.filelist.filename = self.config.get(self.section, 'domainsfile') checkdomains = self.filelist.get_list() envelope_sender_domain = suspect.from_domain.lower() header_from_domain = extract_from_domain(suspect) if header_from_domain is None: return if header_from_domain not in checkdomains: return # TODO: do we need a tag from dkim to check if the verified dkim domain # actually matches the header from domain? dkimresult = suspect.get_tag('DKIMVerify.sigvalid', False) if dkimresult == True: return DUNNO # DKIM failed, check SPF if envelope senderdomain belongs to header # from domain spfresult = suspect.get_tag('SPF.status', 'unknown') if (envelope_sender_domain == header_from_domain or envelope_sender_domain.endswith('.%s' % header_from_domain)) and spfresult == 'pass': return DUNNO failaction = self.config.get(self.section, 'failaction') actioncode = string_to_actioncode(failaction, self.config) values = dict( header_from_domain=header_from_domain) message = apply_template( self.config.get(self.section, 'rejectmessage'), suspect, values) return actioncode, message def flag_as_spam(self, suspect): suspect.tags['spam']['domainauth'] = True def __str__(self): return "DomainAuth" def lint(self): allok = self.checkConfig() and self.lint_file() return allok def lint_file(self): filename = self.config.get(self.section, 'domainsfile') if not os.path.exists(filename): print("domains file %s not found" % (filename)) return False return True
class EBLLookup(ScannerPlugin): def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.logger = self._logger() self.whitelist = None self.requiredvars = { 'whitelist_file': { 'default': '/etc/fuglu/conf.d/ebl-whitelist.txt', 'description': 'path to file containing whitelisted sender domains', }, 'dnszone': { 'default': 'ebl.msbl.org', 'description': 'the DNS zone to query. defaults to ebl.msbl.org', }, 'hash': { 'default': 'sha1', 'description': 'hash function used by DNS zone. Use one of md5, sha1, sha224, sha256, sha384, sha512' }, 'response': { 'default': '127.0.0.2', 'description': 'expected response of zone query', }, 'action': { 'default': 'dunno', 'description': 'action on hit (dunno, reject, defer, delete). if set to dunno will tag as spam. do not use reject/defer in after queue mode', }, 'messagetemplate': { 'default': '${sender} listed by ${dnszone} : ${message}', 'description': 'reject message template', }, 'maxlookups': { 'default': '10', 'description': 'maximum number of email addresses to check per message', }, 'check_always': { 'default': 'False', 'description': 'set to True to check every suspect. set to False to only check mail that has not yet been classified as spam or virus', }, 'normalisation': { 'default': 'ebl', 'description': 'type of normalisation to be applied to email addresses before hashing. choose one of ebl (full normalisation according to ebl.msbl.org standard), low (lowercase only)' } } def _is_whitelisted(self, from_domain): whitelist_file = self.config.get(self.section, 'whitelist_file') if whitelist_file == '': return False if self.whitelist is None: self.whitelist = FileList(whitelist_file, lowercase=True) whitelisted = False if from_domain in self.whitelist.get_list(): whitelisted = True return whitelisted def _email_normalise_ebl(self, address): if not '@' in address: self.logger.error('Not an email address: %s' % address) return address address = address.lower() lhs, domain = address.rsplit('@', 1) domainparts = domain.split('.') if 'googlemail' in domainparts: # replace googlemail with gmail tld = '.'.join(domainparts[1:]) domain = 'gmail.%s' % tld domainparts = ['gmail', tld] if '+' in lhs: # strip all '+' tags lhs = lhs.split('+')[0] if 'gmail' in domainparts: # discard periods in gmail lhs = lhs.replace('.', '') if 'yahoo' in domainparts or 'ymail' in domainparts: # strip - tags from yahoo lhs = lhs.split('-')[0] lhs = re.sub('^(envelope-from|id|r|receiver)=', '', lhs) # strip mail log prefixes return '%s@%s' % (lhs, domain) def _email_normalise_low(self, address): address = address.lower() return address def _email_normalise(self, address): n = self.config.get(self.section, 'normalisation') if n == 'ebl': address = self._email_normalise_ebl(address) elif n == 'low': address = self._email_normalise_low(address) return address def _create_hash(self, value): hashtype = self.config.get(self.section, 'hash').lower() if hasattr(hashlib, 'algorithms_guaranteed'): algorithms = hashlib.algorithms_guaranteed else: algorithms = ['md5', 'sha1'] #python 2.6 if hashtype in algorithms: hasher = getattr(hashlib, hashtype) myhash = hasher(force_bString(value)).hexdigest() else: myhash = '' return myhash def _ebl_lookup(self, addr_hash): listed = False message = None dnszone = self.config.get(self.section, 'dnszone') response = self.config.get(self.section, 'response') query = '%s.%s' % (addr_hash, dnszone) result = lookup(query) if result is not None: for rec in result: if rec == response: listed = True result = lookup(query, qtype='TXT') if result: message = result[0] break return listed, message def examine(self, suspect): if not DNSQUERY_EXTENSION_ENABLED: return DUNNO if not self.config.getboolean(self.section, 'check_always'): # save the lookup if mail is already tagged as virus or spam if suspect.is_virus() or suspect.is_spam() or suspect.is_blocked(): return DUNNO maxlookups = self.config.getint(self.section, 'maxlookups') emails = suspect.get_tag('emails', defaultvalue=[])[:maxlookups] emails = [self._email_normalise(email) for email in emails] emails = list(set(emails)) #if emails: # self.logger.debug('%s EBL checking addresses %s' % (suspect.id, ', '.join(emails))) listed = False action = DUNNO message = None email = None for email in emails: addr_hash = self._create_hash(email) listed, message = self._ebl_lookup(addr_hash) if listed: break suspect.tags['spam']['EBL'] = listed if listed: self.logger.debug('%s EBL hit for %s' % (suspect.id, email)) action = string_to_actioncode( self.config.get(self.section, 'action')) suspect.tags['EBL.email'] = email suspect.tags['EBL.reason'] = message if action != DUNNO: values = { 'dnszone': self.config.get(self.section, 'dnszone'), 'message': message, } message = apply_template( self.config.get(self.section, 'messagetemplate'), suspect, values) return action, message def lint(self): dnszone = self.config.get(self.section, 'dnszone') print('querying zone %s' % dnszone) lint_ok = True if not self.check_config(): print('Error checking config') lint_ok = False if not DNSQUERY_EXTENSION_ENABLED: print( "no DNS resolver library available - this plugin will do nothing" ) lint_ok = False if hasattr(hashlib, 'algorithms_guaranteed'): algorithms = hashlib.algorithms_guaranteed else: algorithms = ['md5', 'sha1'] #python 2.6 print('old version of hashlib, consider upgrade') hashtype = self.config.get(self.section, 'hash').lower() if hashtype not in algorithms: lint_ok = False print('unsupported hash type %s' % hashtype) normalisation = self.config.get(self.section, 'normalisation') if normalisation not in ['ebl', 'low']: lint_ok = False print('unsupported normalisation type %s' % normalisation) if lint_ok: addr_hash = self._create_hash('*****@*****.**') listed, message = self._ebl_lookup(addr_hash) if not listed: lint_ok = False print('test entry not found in dns zone') else: print('test entry found in dns zone: %s' % message) if lint_ok: whitelist_file = self.config.get(self.section, 'whitelist_file') if whitelist_file.strip() == '': print('No whitelist defined') return lint_ok
class Bounce(object): """Send Mail (Bounces)""" def __init__(self, config): self.logger = logging.getLogger('fuglu.bouncer') self.config = config self.nobounce = None def _init_nobounce(self): if self.nobounce is None: try: filepath = self.config.get('main', 'nobouncefile') except Exception: filepath = None if filepath and os.path.exists(filepath): self.nobounce = FileList(filepath) elif filepath: self.logger.warning('nobouncefile %s not found' % filepath) def _add_required_headers(self, recipient, messagecontent): """add headers required for sending automated mail""" msgrep = email.message_from_bytes(force_bString(messagecontent)) msgrep.set_charset( "utf-8") # define unicode because the messagecontent is unicode if not 'to' in msgrep: msgrep['To'] = Header("<%s>" % recipient).encode() if not 'From' in msgrep: msgrep['from'] = Header("<MAILER-DAEMON@%s>" % socket.gethostname()).encode() if not 'auto-submitted' in msgrep: msgrep['auto-submitted'] = Header('auto-generated').encode() if not 'date' in msgrep: msgrep['Date'] = formatdate(localtime=True) if not 'Message-id' in msgrep: msgrep['Message-ID'] = make_msgid() return msgrep.as_string() def send_template_file(self, recipient, templatefile, suspect, values): """Send a E-Mail Bounce Message Args: recipient (str): Message recipient ([email protected]) templatefile (str): Template to use suspect (fuglu.shared.Suspect) suspect that caused the bounce values :Values to apply to the template. ensure all values are of type <str> If the suspect has the 'nobounce' tag set, the message will not be sent. The same happens if the global configuration 'disablebounces' is set. """ if not os.path.exists(templatefile): self.logger.error('Template file does not exist: %s' % templatefile) return with open(templatefile) as fp: filecontent = fp.read() queueid = self.send_template_string(recipient, filecontent, suspect, values) return queueid def send_template_string(self, recipient, templatecontent, suspect, values): """Send a E-Mail Bounce Message If the suspect has the 'nobounce' tag set, the message will not be sent. The same happens if the global configuration 'disablebounces' is set. Args: recipient (unicode or str) : Message recipient ([email protected]) templatecontent (unicode or str) : Template to use suspect (fuglu.shared.Suspect) : suspect that caused the bounce values : Values to apply to the template """ if suspect.get_tag('nobounce'): self.logger.info( 'Not sending bounce to %s - bounces disabled by plugin' % recipient) return message = apply_template(templatecontent, suspect, values) try: message = self._add_required_headers(recipient, message) except Exception as e: self.logger.warning( 'Bounce message template could not be verified: %s' % str(e)) self.logger.debug('Sending bounce message to %s' % recipient) fromaddress = "<>" queueid = self.send(fromaddress, recipient, message) return queueid def send(self, fromaddress, toaddress, message): """really send message""" if self.config.getboolean('main', 'disablebounces'): self.logger.info( 'Bounces are disabled in config - not sending message to %s' % toaddress) return self._init_nobounce() if self.nobounce and extract_domain( toaddress) in self.nobounce.get_list(): self.logger.info( 'Bounces to this rcpt are disabled - not sending message to %s' % toaddress) return smtpServer = FugluSMTPClient( self.config.get('main', 'bindaddress'), self.config.getint('main', 'outgoingport')) helo = self.config.get('main', 'outgoinghelo') if helo.strip() == '': helo = socket.gethostname() smtpServer.helo(helo) smtpServer.sendmail(fromaddress, toaddress, message) smtpServer.quit() return smtpServer.queueid def _send(self, fromaddress, toaddress, message): """deprecated version of send()""" self.send(fromaddress, toaddress, message)
class DomainAuthPlugin(ScannerPlugin): """**EXPERIMENTAL** This plugin checks the header from domain against a list of domains which must be authenticated by DKIM and/or SPF. This is somewhat similar to DMARC but instead of asking the sender domain for a DMARC policy record this plugin allows you to force authentication on the recipient side. This plugin depends on tags written by SPFPlugin and DKIMVerifyPlugin, so they must run beforehand. """ def __init__(self, config, section=None): ScannerPlugin.__init__(self, config, section) self.requiredvars = { 'domainsfile': { 'description': "File containing a list of domains (one per line) which must be DKIM and/or SPF authenticated", 'default': "/etc/fuglu/auth_required_domains.txt", }, 'failaction': { 'default': 'DUNNO', 'description': "action if the message doesn't pass authentication (DUNNO, REJECT)", }, 'rejectmessage': { 'default': 'sender domain ${header_from_domain} must pass DKIM and/or SPF authentication', 'description': "reject message template if running in pre-queue mode", }, } self.logger = self._logger() self.filelist = FileList( filename=None, strip=True, skip_empty=True, skip_comments=True, lowercase=True) def examine(self, suspect): self.filelist.filename = self.config.get(self.section, 'domainsfile') checkdomains = self.filelist.get_list() envelope_sender_domain = suspect.from_domain.lower() header_from_domain = self.extract_from_domain(suspect) if header_from_domain == None: return if header_from_domain not in checkdomains: return # TODO: do we need a tag from dkim to check if the verified dkim domain # actually matches the header from domain? dkimresult = suspect.get_tag('DKIMVerify.sigvalid', False) if dkimresult == True: return DUNNO # DKIM failed, check SPF if envelope senderdomain belongs to header # from domain spfresult = suspect.get_tag('SPF.status', 'unknown') if (envelope_sender_domain == header_from_domain or envelope_sender_domain.endswith('.%s' % header_from_domain)) and spfresult == 'pass': return DUNNO failaction = self.config.get(self.section, 'failaction') actioncode = string_to_actioncode(failaction, self.config) values = dict( header_from_domain=header_from_domain) message = apply_template( self.config.get(self.section, 'rejectmessage'), suspect, values) return actioncode, message def flag_as_spam(self, suspect): suspect.tags['spam']['domainauth'] = True def extract_from_domain(self, suspect): """ Try to extract from header domain """ try: msgrep = suspect.get_message_rep() address = msgrep.get('From') if address == None: return None start = address.find('<') + 1 if start < 1: start = address.find(':') + 1 if start >= 0: end = string.find(address, '>') if end < 0: end = len(address) retaddr = address[start:end] retaddr = retaddr.strip() if '@' not in retaddr: return None domain = retaddr.split('@', 1)[1] return domain.lower() except: return None def __str__(self): return "DomainAuth" def lint(self): allok = (self.checkConfig() and self.lint_file()) return allok def lint_file(self): filename = self.config.get(self.section, 'domainsfile') if not os.path.exists(filename): print("domains file %s not found" % (filename)) return False return True
class SpearPhishPlugin(ScannerPlugin): """Mark spear phishing mails as virus The spearphish plugin checks if the sender domain in the "From"-Header matches the envelope recipient Domain ("Mail from my own domain") but the message uses a different envelope sender domain. This blocks many spearphish attempts. Note that this plugin can cause blocks of legitimate mail , for example if the recipient domain is using a third party service to send newsletters in their name. Such services often set the customers domain in the from headers but use their own domains in the envelope for bounce processing. Use the 'Plugin Skipper' or any other form of whitelisting in such cases. """ def __init__(self, section=None): ScannerPlugin.__init__(self, section) self.logger = self._logger() self.filelist = FileList(strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None, minimum_time_between_reloads=30) self.requiredvars = { 'domainsfile': { 'default': '/etc/fuglu/spearphish-domains', 'description': 'Filename where we load spearphish domains from. One domain per line. If this setting is empty, the check will be applied to all domains.', }, 'virusenginename': { 'default': 'Fuglu SpearPhishing Protection', 'description': 'Name of this plugins av engine', }, 'virusname': { 'default': 'TRAIT.SPEARPHISH', 'description': 'Name to use as virus signature', }, 'virusaction': { 'default': 'DEFAULTVIRUSACTION', 'description': "action if spear phishing attempt is detected (DUNNO, REJECT, DELETE)", }, 'rejectmessage': { 'default': 'threat detected: ${virusname}', 'description': "reject message template if running in pre-queue mode and virusaction=REJECT", }, 'dbconnection':{ 'default':"mysql://root@localhost/spfcheck?charset=utf8", 'description':'SQLAlchemy Connection string. Leave empty to disable SQL lookups', }, 'domain_sql_query':{ 'default':"SELECT check_spearphish from domain where domain_name=:domain", 'description':'get from sql database :domain will be replaced with the actual domain name. must return boolean field check_spearphish', }, 'check_display_part': { 'default': 'False', 'description': "set to True to also check display part of From header (else email part only)", }, } def get_domain_setting(self, domain, dbconnection, sqlquery, cache, cachename, default_value=None, logger=None): if logger is None: logger = logging.getLogger() cachekey = '%s-%s' % (cachename, domain) cached = cache.get_cache(cachekey) if cached is not None: logger.debug("got cached setting for %s" % domain) return cached settings = default_value try: session = get_session(dbconnection) # get domain settings dom = session.execute(sqlquery, {'domain': domain}).fetchall() if not dom and not dom[0] and len(dom[0]) == 0: logger.warning( "Can not load domain setting - domain %s not found. Using default settings." % domain) else: settings = dom[0][0] session.close() except Exception as e: logger.error("Exception while loading setting for %s : %s" % (domain, str(e))) cache.put_cache(cachekey, settings) logger.debug("refreshed setting for %s" % domain) return settings def should_we_check_this_domain(self,suspect): domainsfile = self.config.get(self.section, 'domainsfile') if domainsfile.strip()=='': # empty config -> check all domains return True if not os.path.exists(domainsfile): return False self.filelist.filename = domainsfile envelope_recipient_domain = suspect.to_domain.lower() checkdomains = self.filelist.get_list() if envelope_recipient_domain in checkdomains: return True dbconnection = self.config.get(self.section, 'dbconnection').strip() sqlquery = self.config.get(self.section,'domain_sql_query') do_check = False if dbconnection != '': cache = get_default_cache() cachename = self.section do_check = self.get_domain_setting(suspect.to_domain, dbconnection, sqlquery, cache, cachename, False, self.logger) return do_check def examine(self, suspect): if not self.should_we_check_this_domain(suspect): return DUNNO envelope_recipient_domain = suspect.to_domain.lower() envelope_sender_domain = suspect.from_domain.lower() if envelope_sender_domain == envelope_recipient_domain: return DUNNO # we only check the message if the env_sender_domain differs. If it's the same it will be caught by other means (like SPF) header_from_domains = [] header_from_domain = extract_from_domain(suspect) if header_from_domain is None: self.logger.warn("%s: Could not extract header from domain for spearphish check" % suspect.id) return DUNNO else: header_from_domains.append(header_from_domain) self.logger.debug('%s: checking domain %s (source: From header address part)' % (suspect.id, header_from_domain)) if self.config.getboolean(self.section, 'check_display_part'): display_from_domain = extract_from_domain(suspect, False) if display_from_domain is not None and display_from_domain not in header_from_domains: header_from_domains.append(display_from_domain) self.logger.debug('%s: checking domain %s (source: From header display part)' % (suspect.id, display_from_domain)) actioncode = DUNNO message = None for header_from_domain in header_from_domains: if header_from_domain == envelope_recipient_domain: virusname = self.config.get(self.section, 'virusname') virusaction = self.config.get(self.section, 'virusaction') actioncode = string_to_actioncode(virusaction, self.config) logmsg = '%s: spear phish pattern detected, env_rcpt_domain=%s env_sender_domain=%s header_from_domain=%s' % \ (suspect.id, envelope_recipient_domain, envelope_sender_domain, header_from_domain) self.logger.info(logmsg) self.flag_as_phish(suspect, virusname) message = apply_template(self.config.get(self.section, 'rejectmessage'), suspect, {'virusname': virusname}) break return actioncode, message def flag_as_phish(self, suspect, virusname): suspect.tags['%s.virus' % self.config.get(self.section, 'virusenginename')] = {'message content': virusname} suspect.tags['virus'][self.config.get(self.section, 'virusenginename')] = True def __str__(self): return "Spearphish Check" def lint(self): allok = self.checkConfig() and self._lint_file() and self._lint_sql() return allok def _lint_file(self): filename = self.config.get(self.section, 'domainsfile') if not os.path.exists(filename): print("Spearphish domains file %s not found" % filename) return False return True def _lint_sql(self): lint_ok = True sqlquery = self.config.get(self.section, 'domain_sql_query') dbconnection = self.config.get(self.section, 'dbconnection').strip() if not ENABLED and dbconnection != '': print('SQLAlchemy not available, cannot use SQL backend') lint_ok = False elif dbconnection == '': print('No DB connection defined. Disabling SQL backend') else: if not sqlquery.lower().startswith('select '): lint_ok = False print('SQL statement must be a SELECT query') if lint_ok: try: conn = get_session(dbconnection) conn.execute(sqlquery, {'domain': 'example.com'}) except Exception as e: lint_ok = False print(str(e)) return lint_ok
class SpearPhishPlugin(ScannerPlugin): """Mark spear phishing mails as virus The spearphish plugin checks if the sender domain in the "From"-Header matches the envelope recipient Domain ("Mail from my own domain") but the message usesa different envelope sender domain. This blocks many spearphish attempts. Note that this plugin can cause blocks of legitimate mail , for example if the recipient domain is using a third party service to send newsletters in their name. Such services often set the customers domain in the from headers but use their own domains in the envelope for bounce processing. Use the 'Plugin Skipper' or any other form of whitelisting in such cases. """ def __init__(self, section=None): ScannerPlugin.__init__(self, section) self.filelist = FileList(strip=True, skip_empty=True, skip_comments=True, lowercase=True, additional_filters=None, minimum_time_between_reloads=30) self.requiredvars = { 'domainsfile': { 'default': '/etc/fuglu/spearphish-domains', 'description': 'Filename where we load spearphish domains from. One domain per line. If this setting is empty, the check will be applied to all domains.', }, 'virusenginename': { 'default': 'Fuglu SpearPhishing Protection', 'description': 'Name of this plugins av engine', }, 'virusname': { 'default': 'TRAIT.SPEARPHISH', 'description': 'Name to use as virus signature', }, 'virusaction': { 'default': 'DEFAULTVIRUSACTION', 'description': "action if spear phishing attempt is detected (DUNNO, REJECT, DELETE)", }, 'rejectmessage': { 'default': 'threat detected: ${virusname}', 'description': "reject message template if running in pre-queue mode and virusaction=REJECT", }, } def should_we_check_this_domain(self,suspect): domainsfile = self.config.get(self.section, 'domainsfile') if domainsfile.strip()=='': # empty config -> check all domains return True if not os.path.exists(domainsfile): return False self.filelist.filename = domainsfile envelope_recipient_domain = suspect.to_domain.lower() checkdomains = self.filelist.get_list() return envelope_recipient_domain in checkdomains def examine(self, suspect): if not self.should_we_check_this_domain(suspect): return DUNNO envelope_recipient_domain = suspect.to_domain.lower() envelope_sender_domain = suspect.from_domain.lower() if envelope_sender_domain == envelope_recipient_domain: return DUNNO # we only check the message if the env_sender_domain differs. If it's the same it will be caught by other means (like SPF) header_from_domain = extract_from_domain(suspect) if header_from_domain is None: self._logger().warn("%s: Could not extract header from domain for spearphish check" % suspect.id) return DUNNO if header_from_domain == envelope_recipient_domain: virusname = self.config.get(self.section, 'virusname') virusaction = self.config.get(self.section, 'virusaction') actioncode = string_to_actioncode(virusaction, self.config) logmsg = '%s: spear phish pattern detected, recipient=%s env_sender_domain=%s header_from_domain=%s' % ( suspect.id, suspect.to_address, envelope_sender_domain, header_from_domain) self._logger().info(logmsg) self.flag_as_phish(suspect, virusname) message = apply_template( self.config.get(self.section, 'rejectmessage'), suspect, {'virusname': virusname}) return actioncode, message return DUNNO def flag_as_phish(self, suspect, virusname): suspect.tags['%s.virus' % self.config.get(self.section, 'virusenginename')] = {'message content': virusname} suspect.tags['virus'][self.config.get(self.section, 'virusenginename')] = True def __str__(self): return "Spearphish Check" def lint(self): allok = (self.checkConfig() and self.lint_file()) return allok def lint_file(self): filename = self.config.get(self.section, 'domainsfile') if not os.path.exists(filename): print("Spearphish domains file %s not found" % (filename)) return False return True