Matthias Andreas Benkard | 1ba5381 | 2022-12-27 17:32:58 +0100 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # -*- coding: utf-8 -*- |
| 3 | |
| 4 | # Copyright (c) 2020, Dennis Kalbhen <d.kalbhen@heinlein-support.de> |
| 5 | # Copyright (c) 2020, Carsten Rosenberg <c.rosenberg@heinlein-support.de> |
| 6 | # |
| 7 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 8 | # you may not use this file except in compliance with the License. |
| 9 | # You may obtain a copy of the License at |
| 10 | # |
| 11 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | # |
| 13 | # Unless required by applicable law or agreed to in writing, software |
| 14 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 16 | # See the License for the specific language governing permissions and |
| 17 | # limitations under the License. |
| 18 | |
| 19 | ### |
| 20 | # |
| 21 | # olefy is a little helper socket to use oletools with rspamd. (https://rspamd.com) |
| 22 | # Please find updates and issues here: https://github.com/HeinleinSupport/olefy |
| 23 | # |
| 24 | ### |
| 25 | |
| 26 | from subprocess import Popen, PIPE |
| 27 | import sys |
| 28 | import os |
| 29 | import logging |
| 30 | import asyncio |
| 31 | import time |
| 32 | import magic |
| 33 | import re |
| 34 | |
| 35 | # merge variables from /etc/olefy.conf and the defaults |
| 36 | olefy_listen_addr_string = os.getenv('OLEFY_BINDADDRESS', '127.0.0.1,::1') |
| 37 | olefy_listen_port = int(os.getenv('OLEFY_BINDPORT', '10050')) |
| 38 | olefy_tmp_dir = os.getenv('OLEFY_TMPDIR', '/tmp') |
| 39 | olefy_python_path = os.getenv('OLEFY_PYTHON_PATH', '/usr/bin/python3') |
| 40 | olefy_olevba_path = os.getenv('OLEFY_OLEVBA_PATH', '/usr/local/bin/olevba3') |
| 41 | # 10:DEBUG, 20:INFO, 30:WARNING, 40:ERROR, 50:CRITICAL |
| 42 | olefy_loglvl = int(os.getenv('OLEFY_LOGLVL', 20)) |
| 43 | olefy_min_length = int(os.getenv('OLEFY_MINLENGTH', 500)) |
| 44 | olefy_del_tmp = int(os.getenv('OLEFY_DEL_TMP', 1)) |
| 45 | olefy_del_tmp_failed = int(os.getenv('OLEFY_DEL_TMP_FAILED', 1)) |
| 46 | |
| 47 | # internal used variables |
| 48 | request_time = '0000000000.000000' |
| 49 | olefy_protocol = 'OLEFY' |
| 50 | olefy_ping = 'PING' |
| 51 | olefy_protocol_sep = '\n\n' |
| 52 | olefy_headers = {} |
| 53 | |
| 54 | # init logging |
| 55 | logger = logging.getLogger('olefy') |
| 56 | logging.basicConfig(stream=sys.stdout, level=olefy_loglvl, format='olefy %(levelname)s %(funcName)s %(message)s') |
| 57 | |
| 58 | logger.debug('olefy listen address string: {} (type {})'.format(olefy_listen_addr_string, type(olefy_listen_addr_string))) |
| 59 | |
| 60 | if not olefy_listen_addr_string: |
| 61 | olefy_listen_addr = "" |
| 62 | else: |
| 63 | addr_re = re.compile('[\[" \]]') |
| 64 | olefy_listen_addr = addr_re.sub('', olefy_listen_addr_string.replace("'", "")).split(',') |
| 65 | |
| 66 | # log runtime variables |
| 67 | logger.info('olefy listen address: {} (type: {})'.format(olefy_listen_addr, type(olefy_listen_addr))) |
| 68 | logger.info('olefy listen port: {}'.format(olefy_listen_port)) |
| 69 | logger.info('olefy tmp dir: {}'.format(olefy_tmp_dir)) |
| 70 | logger.info('olefy python path: {}'.format(olefy_python_path)) |
| 71 | logger.info('olefy olvba path: {}'.format(olefy_olevba_path)) |
| 72 | logger.info('olefy log level: {}'.format(olefy_loglvl)) |
| 73 | logger.info('olefy min file length: {}'.format(olefy_min_length)) |
| 74 | logger.info('olefy delete tmp file: {}'.format(olefy_del_tmp)) |
| 75 | logger.info('olefy delete tmp file when failed: {}'.format(olefy_del_tmp_failed)) |
| 76 | |
| 77 | if not os.path.isfile(olefy_python_path): |
| 78 | logger.critical('python path not found: {}'.format(olefy_python_path)) |
| 79 | exit(1) |
| 80 | if not os.path.isfile(olefy_olevba_path): |
| 81 | logger.critical('olevba path not found: {}'.format(olefy_olevba_path)) |
| 82 | exit(1) |
| 83 | |
| 84 | # olefy protocol function |
| 85 | def protocol_split( olefy_line ): |
| 86 | header_lines = olefy_line.split('\n') |
| 87 | for line in header_lines: |
| 88 | if line == 'OLEFY/1.0': |
| 89 | olefy_headers['olefy'] = line |
| 90 | elif line != '': |
| 91 | kv = line.split(': ') |
| 92 | if kv[0] != '' and kv[1] != '': |
| 93 | olefy_headers[kv[0]] = kv[1] |
| 94 | logger.debug('olefy_headers: {}'.format(olefy_headers)) |
| 95 | |
| 96 | # calling oletools |
| 97 | def oletools( stream, tmp_file_name, lid ): |
| 98 | if olefy_min_length > stream.__len__(): |
| 99 | logger.error('{} {} bytes (Not Scanning! File smaller than {!r})'.format(lid, stream.__len__(), olefy_min_length)) |
| 100 | out = b'[ { "error": "File too small" } ]' |
| 101 | else: |
| 102 | tmp_file = open(tmp_file_name, 'wb') |
| 103 | tmp_file.write(stream) |
| 104 | tmp_file.close() |
| 105 | |
| 106 | file_magic = magic.Magic(mime=True, uncompress=True) |
| 107 | file_mime = file_magic.from_file(tmp_file_name) |
| 108 | logger.info('{} {} (libmagic output)'.format(lid, file_mime)) |
| 109 | |
| 110 | # do the olefy |
| 111 | cmd_tmp = Popen([olefy_python_path, olefy_olevba_path, '-a', '-j' , '-l', 'error', tmp_file_name], stdout=PIPE, stderr=PIPE) |
| 112 | out, err = cmd_tmp.communicate() |
| 113 | out = bytes(out.decode('utf-8', 'ignore').replace(' ', ' ').replace('\t', '').replace('\n', '').replace('XLMMacroDeobfuscator: pywin32 is not installed (only is required if you want to use MS Excel)', ''), encoding="utf-8") |
| 114 | failed = False |
| 115 | if out.__len__() < 30: |
| 116 | logger.error('{} olevba returned <30 chars - rc: {!r}, response: {!r}, error: {!r}'.format(lid,cmd_tmp.returncode, |
| 117 | out.decode('utf-8', 'ignore'), err.decode('utf-8', 'ignore'))) |
| 118 | out = b'[ { "error": "Unhandled error - too short olevba response" } ]' |
| 119 | failed = True |
| 120 | elif err.__len__() > 10 and cmd_tmp.returncode == 9: |
| 121 | logger.error("{} olevba stderr >10 chars - rc: {!r}, response: {!r}".format(lid, cmd_tmp.returncode, err.decode("utf-8", "ignore"))) |
| 122 | out = b'[ { "error": "Decrypt failed" } ]' |
| 123 | failed = True |
| 124 | elif err.__len__() > 10 and cmd_tmp.returncode > 9: |
| 125 | logger.error('{} olevba stderr >10 chars - rc: {!r}, response: {!r}'.format(lid, cmd_tmp.returncode, err.decode('utf-8', 'ignore'))) |
| 126 | out = b'[ { "error": "Unhandled oletools error" } ]' |
| 127 | failed = True |
| 128 | elif cmd_tmp.returncode != 0: |
| 129 | logger.error('{} olevba exited with code {!r}; err: {!r}'.format(lid, cmd_tmp.returncode, err.decode('utf-8', 'ignore'))) |
| 130 | failed = True |
| 131 | |
| 132 | if failed and olefy_del_tmp_failed == 0: |
| 133 | logger.debug('{} {} FAILED: not deleting tmp file'.format(lid, tmp_file_name)) |
| 134 | elif olefy_del_tmp == 1: |
| 135 | logger.debug('{} {} deleting tmp file'.format(lid, tmp_file_name)) |
| 136 | os.remove(tmp_file_name) |
| 137 | |
| 138 | logger.debug('{} response: {}'.format(lid, out.decode('utf-8', 'ignore'))) |
| 139 | return out + b'\t\n\n\t' |
| 140 | |
| 141 | # Asyncio data handling, default AIO-Functions |
| 142 | class AIO(asyncio.Protocol): |
| 143 | def __init__(self): |
| 144 | self.extra = bytearray() |
| 145 | |
| 146 | def connection_made(self, transport): |
| 147 | global request_time |
| 148 | peer = transport.get_extra_info('peername') |
| 149 | logger.debug('{} new connection was made'.format(peer)) |
| 150 | self.transport = transport |
| 151 | request_time = str(time.time()) |
| 152 | |
| 153 | def data_received(self, request, msgid=1): |
| 154 | peer = self.transport.get_extra_info('peername') |
| 155 | logger.debug('{} data received from new connection'.format(peer)) |
| 156 | self.extra.extend(request) |
| 157 | |
| 158 | def eof_received(self): |
| 159 | peer = self.transport.get_extra_info('peername') |
| 160 | olefy_protocol_err = False |
| 161 | proto_ck = self.extra[0:2000].decode('utf-8', 'ignore') |
| 162 | |
| 163 | headers = proto_ck[0:proto_ck.find(olefy_protocol_sep)] |
| 164 | |
| 165 | if olefy_protocol == headers[0:5]: |
| 166 | self.extra = bytearray(self.extra[len(headers)+2:len(self.extra)]) |
| 167 | protocol_split(headers) |
| 168 | else: |
| 169 | olefy_protocol_err = True |
| 170 | |
| 171 | if olefy_ping == headers[0:4]: |
| 172 | is_ping = True |
| 173 | else: |
| 174 | is_ping = False |
| 175 | rspamd_id = olefy_headers['Rspamd-ID'][:6] or '' |
| 176 | lid = 'Rspamd-ID' in olefy_headers and '<'+rspamd_id+'>' |
| 177 | tmp_file_name = olefy_tmp_dir+'/'+request_time+'.'+str(peer[1])+'.'+rspamd_id |
| 178 | logger.debug('{} {} choosen as tmp filename'.format(lid, tmp_file_name)) |
| 179 | |
| 180 | if not is_ping or olefy_loglvl == 10: |
| 181 | logger.info('{} {} bytes (stream size)'.format(lid, self.extra.__len__())) |
| 182 | |
| 183 | if olefy_ping == headers[0:4]: |
| 184 | logger.debug('{} PING request'.format(peer)) |
| 185 | out = b'PONG' |
| 186 | elif olefy_protocol_err == True or olefy_headers['olefy'] != 'OLEFY/1.0': |
| 187 | logger.error('{} Protocol ERROR: no OLEFY/1.0 found'.format(lid)) |
| 188 | out = b'[ { "error": "Protocol error" } ]' |
| 189 | elif 'Method' in olefy_headers: |
| 190 | if olefy_headers['Method'] == 'oletools': |
| 191 | out = oletools(self.extra, tmp_file_name, lid) |
| 192 | else: |
| 193 | logger.error('Protocol ERROR: Method header not found') |
| 194 | out = b'[ { "error": "Protocol error: Method header not found" } ]' |
| 195 | |
| 196 | self.transport.write(out) |
| 197 | if not is_ping or olefy_loglvl == 10: |
| 198 | logger.info('{} {} response send: {!r}'.format(lid, peer, out)) |
| 199 | self.transport.close() |
| 200 | |
| 201 | |
| 202 | # start the listeners |
| 203 | loop = asyncio.get_event_loop() |
| 204 | # each client connection will create a new protocol instance |
| 205 | coro = loop.create_server(AIO, olefy_listen_addr, olefy_listen_port) |
| 206 | server = loop.run_until_complete(coro) |
| 207 | for sockets in server.sockets: |
| 208 | logger.info('serving on {}'.format(sockets.getsockname())) |
| 209 | |
| 210 | # XXX serve requests until KeyboardInterrupt, not needed for production |
| 211 | try: |
| 212 | loop.run_forever() |
| 213 | except KeyboardInterrupt: |
| 214 | pass |
| 215 | |
| 216 | # graceful shutdown/reload |
| 217 | server.close() |
| 218 | loop.run_until_complete(server.wait_closed()) |
| 219 | loop.close() |
| 220 | logger.info('stopped serving') |