Commit 40694d71ff364a46bb21aeec14a66f1007902275
Committed by
GitHub
Merge pull request #441 from christian-intra2net/is-encrypted-by-msoffcrypto
Is encrypted by msoffcrypto
Showing
12 changed files
with
243 additions
and
65 deletions
oletools/crypto.py
| @@ -168,6 +168,7 @@ def enable_logging(): | @@ -168,6 +168,7 @@ def enable_logging(): | ||
| 168 | """ | 168 | """ |
| 169 | log.setLevel(logging.NOTSET) | 169 | log.setLevel(logging.NOTSET) |
| 170 | 170 | ||
| 171 | + | ||
| 171 | def is_encrypted(some_file): | 172 | def is_encrypted(some_file): |
| 172 | """ | 173 | """ |
| 173 | Determine whether document contains encrypted content. | 174 | Determine whether document contains encrypted content. |
| @@ -197,17 +198,55 @@ def is_encrypted(some_file): | @@ -197,17 +198,55 @@ def is_encrypted(some_file): | ||
| 197 | :returns: True if (and only if) the file contains encrypted content | 198 | :returns: True if (and only if) the file contains encrypted content |
| 198 | """ | 199 | """ |
| 199 | log.debug('is_encrypted') | 200 | log.debug('is_encrypted') |
| 200 | - if isinstance(some_file, OleFileIO): | ||
| 201 | - return is_encrypted_ole(some_file) # assume it is OleFileIO | ||
| 202 | - if zipfile.is_zipfile(some_file): | ||
| 203 | - return is_encrypted_zip(some_file) | ||
| 204 | - # otherwise assume it is the name of an ole file | ||
| 205 | - return is_encrypted_ole(OleFileIO(some_file)) | ||
| 206 | 201 | ||
| 202 | + # ask msoffcrypto if possible | ||
| 203 | + if check_msoffcrypto(): | ||
| 204 | + log.debug('Checking for encryption using msoffcrypto') | ||
| 205 | + file_handle = None | ||
| 206 | + file_pos = None | ||
| 207 | + try: | ||
| 208 | + if isinstance(some_file, OleFileIO): | ||
| 209 | + # TODO: hacky, replace once msoffcrypto-tools accepts OleFileIO | ||
| 210 | + file_handle = some_file.fp | ||
| 211 | + file_pos = file_handle.tell() | ||
| 212 | + file_handle.seek(0) | ||
| 213 | + else: | ||
| 214 | + file_handle = open(some_file, 'rb') | ||
| 215 | + | ||
| 216 | + return msoffcrypto.OfficeFile(file_handle).is_encrypted() | ||
| 217 | + | ||
| 218 | + except Exception as exc: | ||
| 219 | + log.warning('msoffcrypto failed to interpret file {} or determine ' | ||
| 220 | + 'whether it is encrypted: {}' | ||
| 221 | + .format(file_handle.name, exc)) | ||
| 222 | + | ||
| 223 | + finally: | ||
| 224 | + try: | ||
| 225 | + if file_pos is not None: # input was OleFileIO | ||
| 226 | + file_handle.seek(file_pos) | ||
| 227 | + else: # input was file name | ||
| 228 | + file_handle.close() | ||
| 229 | + except Exception as exc: | ||
| 230 | + log.warning('Ignoring error during clean up: {}'.format(exc)) | ||
| 231 | + | ||
| 232 | + # if that failed, try ourselves with older and less accurate code | ||
| 233 | + try: | ||
| 234 | + if isinstance(some_file, OleFileIO): | ||
| 235 | + return _is_encrypted_ole(some_file) | ||
| 236 | + if zipfile.is_zipfile(some_file): | ||
| 237 | + return _is_encrypted_zip(some_file) | ||
| 238 | + # otherwise assume it is the name of an ole file | ||
| 239 | + return _is_encrypted_ole(OleFileIO(some_file)) | ||
| 240 | + except Exception as exc: | ||
| 241 | + log.warning('Failed to check {} for encryption ({}); assume it is not ' | ||
| 242 | + 'encrypted.'.format(some_file, exc)) | ||
| 207 | 243 | ||
| 208 | -def is_encrypted_zip(filename): | 244 | + return False |
| 245 | + | ||
| 246 | + | ||
| 247 | +def _is_encrypted_zip(filename): | ||
| 209 | """Specialization of :py:func:`is_encrypted` for zip-based files.""" | 248 | """Specialization of :py:func:`is_encrypted` for zip-based files.""" |
| 210 | - log.debug('is_encrypted_zip') | 249 | + log.debug('Checking for encryption in zip file') |
| 211 | # TODO: distinguish OpenXML from normal zip files | 250 | # TODO: distinguish OpenXML from normal zip files |
| 212 | # try to decrypt a few bytes from first entry | 251 | # try to decrypt a few bytes from first entry |
| 213 | with zipfile.ZipFile(filename, 'r') as zipper: | 252 | with zipfile.ZipFile(filename, 'r') as zipper: |
| @@ -220,9 +259,9 @@ def is_encrypted_zip(filename): | @@ -220,9 +259,9 @@ def is_encrypted_zip(filename): | ||
| 220 | return 'crypt' in str(rt_err) | 259 | return 'crypt' in str(rt_err) |
| 221 | 260 | ||
| 222 | 261 | ||
| 223 | -def is_encrypted_ole(ole): | 262 | +def _is_encrypted_ole(ole): |
| 224 | """Specialization of :py:func:`is_encrypted` for ole files.""" | 263 | """Specialization of :py:func:`is_encrypted` for ole files.""" |
| 225 | - log.debug('is_encrypted_ole') | 264 | + log.debug('Checking for encryption in OLE file') |
| 226 | # check well known property for password protection | 265 | # check well known property for password protection |
| 227 | # (this field may be missing for Powerpoint2000, for example) | 266 | # (this field may be missing for Powerpoint2000, for example) |
| 228 | # TODO: check whether password protection always implies encryption. Could | 267 | # TODO: check whether password protection always implies encryption. Could |
| @@ -256,8 +295,6 @@ def is_encrypted_ole(ole): | @@ -256,8 +295,6 @@ def is_encrypted_ole(ole): | ||
| 256 | f_encrypted = (temp16 & 0x0100) >> 8 | 295 | f_encrypted = (temp16 & 0x0100) >> 8 |
| 257 | if f_encrypted: | 296 | if f_encrypted: |
| 258 | return True | 297 | return True |
| 259 | - except Exception: | ||
| 260 | - raise | ||
| 261 | finally: | 298 | finally: |
| 262 | if stream is not None: | 299 | if stream is not None: |
| 263 | stream.close() | 300 | stream.close() |
| @@ -324,6 +361,8 @@ def decrypt(filename, passwords=None, **temp_file_args): | @@ -324,6 +361,8 @@ def decrypt(filename, passwords=None, **temp_file_args): | ||
| 324 | crypto_file = msoffcrypto.OfficeFile(reader) | 361 | crypto_file = msoffcrypto.OfficeFile(reader) |
| 325 | except Exception as exc: # e.g. ppt, not yet supported by msoffcrypto | 362 | except Exception as exc: # e.g. ppt, not yet supported by msoffcrypto |
| 326 | if 'Unrecognized file format' in str(exc): | 363 | if 'Unrecognized file format' in str(exc): |
| 364 | + log.debug('Caught exception', exc_info=True) | ||
| 365 | + | ||
| 327 | # raise different exception without stack trace of original exc | 366 | # raise different exception without stack trace of original exc |
| 328 | if sys.version_info.major == 2: | 367 | if sys.version_info.major == 2: |
| 329 | raise UnsupportedEncryptionError(filename) | 368 | raise UnsupportedEncryptionError(filename) |
| @@ -337,6 +376,7 @@ def decrypt(filename, passwords=None, **temp_file_args): | @@ -337,6 +376,7 @@ def decrypt(filename, passwords=None, **temp_file_args): | ||
| 337 | .format(filename)) | 376 | .format(filename)) |
| 338 | 377 | ||
| 339 | for password in passwords: | 378 | for password in passwords: |
| 379 | + log.debug('Trying to decrypt with password {!r}'.format(password)) | ||
| 340 | write_descriptor = None | 380 | write_descriptor = None |
| 341 | write_handle = None | 381 | write_handle = None |
| 342 | decrypt_file = None | 382 | decrypt_file = None |
| @@ -354,6 +394,8 @@ def decrypt(filename, passwords=None, **temp_file_args): | @@ -354,6 +394,8 @@ def decrypt(filename, passwords=None, **temp_file_args): | ||
| 354 | write_handle = None | 394 | write_handle = None |
| 355 | break | 395 | break |
| 356 | except Exception: | 396 | except Exception: |
| 397 | + log.debug('Failed to decrypt', exc_info=True) | ||
| 398 | + | ||
| 357 | # error-clean up: close everything and del temp file | 399 | # error-clean up: close everything and del temp file |
| 358 | if write_handle: | 400 | if write_handle: |
| 359 | write_handle.close() | 401 | write_handle.close() |
| @@ -363,4 +405,5 @@ def decrypt(filename, passwords=None, **temp_file_args): | @@ -363,4 +405,5 @@ def decrypt(filename, passwords=None, **temp_file_args): | ||
| 363 | os.unlink(decrypt_file) | 405 | os.unlink(decrypt_file) |
| 364 | decrypt_file = None | 406 | decrypt_file = None |
| 365 | # if we reach this, all passwords were tried without success | 407 | # if we reach this, all passwords were tried without success |
| 408 | + log.debug('All passwords failed') | ||
| 366 | return decrypt_file | 409 | return decrypt_file |
oletools/msodde.py
| @@ -987,6 +987,9 @@ def process_maybe_encrypted(filepath, passwords=None, crypto_nesting=0, | @@ -987,6 +987,9 @@ def process_maybe_encrypted(filepath, passwords=None, crypto_nesting=0, | ||
| 987 | try: | 987 | try: |
| 988 | logger.debug('Trying to decrypt file') | 988 | logger.debug('Trying to decrypt file') |
| 989 | decrypted_file = crypto.decrypt(filepath, passwords) | 989 | decrypted_file = crypto.decrypt(filepath, passwords) |
| 990 | + if not decrypted_file: | ||
| 991 | + logger.error('Decrypt failed, run with debug output to get details') | ||
| 992 | + raise crypto.WrongEncryptionPassword(filepath) | ||
| 990 | logger.info('Analyze decrypted file') | 993 | logger.info('Analyze decrypted file') |
| 991 | result = process_maybe_encrypted(decrypted_file, passwords, | 994 | result = process_maybe_encrypted(decrypted_file, passwords, |
| 992 | crypto_nesting+1, **kwargs) | 995 | crypto_nesting+1, **kwargs) |
oletools/olevba.py
| @@ -3893,6 +3893,7 @@ def process_file(filename, data, container, options, crypto_nesting=0): | @@ -3893,6 +3893,7 @@ def process_file(filename, data, container, options, crypto_nesting=0): | ||
| 3893 | [crypto.WRITE_PROTECT_ENCRYPTION_PASSWORD, ] | 3893 | [crypto.WRITE_PROTECT_ENCRYPTION_PASSWORD, ] |
| 3894 | decrypted_file = crypto.decrypt(filename, passwords) | 3894 | decrypted_file = crypto.decrypt(filename, passwords) |
| 3895 | if not decrypted_file: | 3895 | if not decrypted_file: |
| 3896 | + log.error('Decrypt failed, run with debug output to get details') | ||
| 3896 | raise crypto.WrongEncryptionPassword(filename) | 3897 | raise crypto.WrongEncryptionPassword(filename) |
| 3897 | log.info('Working on decrypted file') | 3898 | log.info('Working on decrypted file') |
| 3898 | return process_file(decrypted_file, data, container or filename, | 3899 | return process_file(decrypted_file, data, container or filename, |
tests/common/log_helper/log_helper_test_main.py
| @@ -34,12 +34,16 @@ def init_logging_and_log(args): | @@ -34,12 +34,16 @@ def init_logging_and_log(args): | ||
| 34 | level = args[-1] | 34 | level = args[-1] |
| 35 | use_json = 'as-json' in args | 35 | use_json = 'as-json' in args |
| 36 | throw = 'throw' in args | 36 | throw = 'throw' in args |
| 37 | + percent_autoformat = '%-autoformat' in args | ||
| 37 | 38 | ||
| 38 | if 'enable' in args: | 39 | if 'enable' in args: |
| 39 | log_helper.enable_logging(use_json, level, stream=sys.stdout) | 40 | log_helper.enable_logging(use_json, level, stream=sys.stdout) |
| 40 | 41 | ||
| 41 | _log() | 42 | _log() |
| 42 | 43 | ||
| 44 | + if percent_autoformat: | ||
| 45 | + logger.info('The %s is %d.', 'answer', 47) | ||
| 46 | + | ||
| 43 | if throw: | 47 | if throw: |
| 44 | raise Exception('An exception occurred before ending the logging') | 48 | raise Exception('An exception occurred before ending the logging') |
| 45 | 49 |
tests/common/log_helper/test_log_helper.py
| @@ -112,6 +112,11 @@ class TestLogHelper(unittest.TestCase): | @@ -112,6 +112,11 @@ class TestLogHelper(unittest.TestCase): | ||
| 112 | ] | 112 | ] |
| 113 | self.assertEqual(jout, jexpect) | 113 | self.assertEqual(jout, jexpect) |
| 114 | 114 | ||
| 115 | + def test_percent_autoformat(self): | ||
| 116 | + """Test that auto-formatting of log strings with `%` works.""" | ||
| 117 | + output = self._run_test(['enable', '%-autoformat', 'info']) | ||
| 118 | + self.assertIn('The answer is 47.', output) | ||
| 119 | + | ||
| 115 | def test_json_correct_on_exceptions(self): | 120 | def test_json_correct_on_exceptions(self): |
| 116 | """ | 121 | """ |
| 117 | Test that even on unhandled exceptions our JSON is always correct | 122 | Test that even on unhandled exceptions our JSON is always correct |
tests/msodde/test_crypto.py
| @@ -2,7 +2,7 @@ | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | ||
| 3 | import sys | 3 | import sys |
| 4 | import unittest | 4 | import unittest |
| 5 | -from os.path import join as pjoin | 5 | +from os.path import basename, join as pjoin |
| 6 | 6 | ||
| 7 | from tests.test_utils import DATA_BASE_DIR | 7 | from tests.test_utils import DATA_BASE_DIR |
| 8 | 8 | ||
| @@ -11,8 +11,8 @@ from oletools import msodde | @@ -11,8 +11,8 @@ from oletools import msodde | ||
| 11 | 11 | ||
| 12 | 12 | ||
| 13 | @unittest.skipIf(not crypto.check_msoffcrypto(), | 13 | @unittest.skipIf(not crypto.check_msoffcrypto(), |
| 14 | - 'Module msoffcrypto not installed for python{}.{}' | ||
| 15 | - .format(sys.version_info.major, sys.version_info.minor)) | 14 | + 'Module msoffcrypto not installed for {}' |
| 15 | + .format(basename(sys.executable))) | ||
| 16 | class MsoddeCryptoTest(unittest.TestCase): | 16 | class MsoddeCryptoTest(unittest.TestCase): |
| 17 | """Test integration of decryption in msodde.""" | 17 | """Test integration of decryption in msodde.""" |
| 18 | def test_standard_password(self): | 18 | def test_standard_password(self): |
tests/oleid/test_basic.py
| @@ -20,7 +20,7 @@ class TestOleIDBasic(unittest.TestCase): | @@ -20,7 +20,7 @@ class TestOleIDBasic(unittest.TestCase): | ||
| 20 | """Run all file in test-data through oleid and compare to known ouput""" | 20 | """Run all file in test-data through oleid and compare to known ouput""" |
| 21 | # this relies on order of indicators being constant, could relax that | 21 | # this relies on order of indicators being constant, could relax that |
| 22 | # Also requires that files have the correct suffixes (no rtf in doc) | 22 | # Also requires that files have the correct suffixes (no rtf in doc) |
| 23 | - NON_OLE_SUFFIXES = ('.xml', '.csv', '.rtf', '') | 23 | + NON_OLE_SUFFIXES = ('.xml', '.csv', '.rtf', '', '.odt', '.ods', '.odp') |
| 24 | NON_OLE_VALUES = (False, ) | 24 | NON_OLE_VALUES = (False, ) |
| 25 | WORD = b'Microsoft Office Word' | 25 | WORD = b'Microsoft Office Word' |
| 26 | PPT = b'Microsoft Office PowerPoint' | 26 | PPT = b'Microsoft Office PowerPoint' |
| @@ -121,6 +121,33 @@ class TestOleIDBasic(unittest.TestCase): | @@ -121,6 +121,33 @@ class TestOleIDBasic(unittest.TestCase): | ||
| 121 | 'msodde/harmless-clean.docx': (False,), | 121 | 'msodde/harmless-clean.docx': (False,), |
| 122 | 'oleform/oleform-PR314.docm': (False,), | 122 | 'oleform/oleform-PR314.docm': (False,), |
| 123 | 'basic/encrypted.docx': CRYPT, | 123 | 'basic/encrypted.docx': CRYPT, |
| 124 | + 'oleobj/external_link/sample_with_external_link_to_doc.docx': (False,), | ||
| 125 | + 'oleobj/external_link/sample_with_external_link_to_doc.xlsb': (False,), | ||
| 126 | + 'oleobj/external_link/sample_with_external_link_to_doc.dotm': (False,), | ||
| 127 | + 'oleobj/external_link/sample_with_external_link_to_doc.xlsm': (False,), | ||
| 128 | + 'oleobj/external_link/sample_with_external_link_to_doc.pptx': (False,), | ||
| 129 | + 'oleobj/external_link/sample_with_external_link_to_doc.dotx': (False,), | ||
| 130 | + 'oleobj/external_link/sample_with_external_link_to_doc.docm': (False,), | ||
| 131 | + 'oleobj/external_link/sample_with_external_link_to_doc.potm': (False,), | ||
| 132 | + 'oleobj/external_link/sample_with_external_link_to_doc.xlsx': (False,), | ||
| 133 | + 'oleobj/external_link/sample_with_external_link_to_doc.potx': (False,), | ||
| 134 | + 'oleobj/external_link/sample_with_external_link_to_doc.ppsm': (False,), | ||
| 135 | + 'oleobj/external_link/sample_with_external_link_to_doc.pptm': (False,), | ||
| 136 | + 'oleobj/external_link/sample_with_external_link_to_doc.ppsx': (False,), | ||
| 137 | + 'encrypted/autostart-encrypt-standardpassword.xlsm': | ||
| 138 | + (True, False, 'unknown', True, False, False, False, False, False, False, 0), | ||
| 139 | + 'encrypted/autostart-encrypt-standardpassword.xls': | ||
| 140 | + (True, True, EXCEL, True, False, True, True, False, False, False, 0), | ||
| 141 | + 'encrypted/dde-test-encrypt-standardpassword.xlsx': | ||
| 142 | + (True, False, 'unknown', True, False, False, False, False, False, False, 0), | ||
| 143 | + 'encrypted/dde-test-encrypt-standardpassword.xlsm': | ||
| 144 | + (True, False, 'unknown', True, False, False, False, False, False, False, 0), | ||
| 145 | + 'encrypted/autostart-encrypt-standardpassword.xlsb': | ||
| 146 | + (True, False, 'unknown', True, False, False, False, False, False, False, 0), | ||
| 147 | + 'encrypted/dde-test-encrypt-standardpassword.xls': | ||
| 148 | + (True, True, EXCEL, True, False, False, True, False, False, False, 0), | ||
| 149 | + 'encrypted/dde-test-encrypt-standardpassword.xlsb': | ||
| 150 | + (True, False, 'unknown', True, False, False, False, False, False, False, 0), | ||
| 124 | } | 151 | } |
| 125 | 152 | ||
| 126 | indicator_names = [] | 153 | indicator_names = [] |
| @@ -148,7 +175,8 @@ class TestOleIDBasic(unittest.TestCase): | @@ -148,7 +175,8 @@ class TestOleIDBasic(unittest.TestCase): | ||
| 148 | OLE_VALUES[name])) | 175 | OLE_VALUES[name])) |
| 149 | except KeyError: | 176 | except KeyError: |
| 150 | print('Should add oleid output for {} to {} ({})' | 177 | print('Should add oleid output for {} to {} ({})' |
| 151 | - .format(name, __name__, values[3:])) | 178 | + .format(name, __name__, values)) |
| 179 | + | ||
| 152 | 180 | ||
| 153 | # just in case somebody calls this file as a script | 181 | # just in case somebody calls this file as a script |
| 154 | if __name__ == '__main__': | 182 | if __name__ == '__main__': |
tests/olevba/test_basic.py
| @@ -3,21 +3,71 @@ Test basic functionality of olevba[3] | @@ -3,21 +3,71 @@ Test basic functionality of olevba[3] | ||
| 3 | """ | 3 | """ |
| 4 | 4 | ||
| 5 | import unittest | 5 | import unittest |
| 6 | -import sys | ||
| 7 | -if sys.version_info.major <= 2: | ||
| 8 | - from oletools import olevba | ||
| 9 | -else: | ||
| 10 | - from oletools import olevba3 as olevba | ||
| 11 | import os | 6 | import os |
| 12 | from os.path import join | 7 | from os.path import join |
| 8 | +import re | ||
| 13 | 9 | ||
| 14 | # Directory with test data, independent of current working directory | 10 | # Directory with test data, independent of current working directory |
| 15 | -from tests.test_utils import DATA_BASE_DIR | 11 | +from tests.test_utils import DATA_BASE_DIR, call_and_capture |
| 16 | 12 | ||
| 17 | 13 | ||
| 18 | class TestOlevbaBasic(unittest.TestCase): | 14 | class TestOlevbaBasic(unittest.TestCase): |
| 19 | """Tests olevba basic functionality""" | 15 | """Tests olevba basic functionality""" |
| 20 | 16 | ||
| 17 | + def test_text_behaviour(self): | ||
| 18 | + """Test behaviour of olevba when presented with pure text file.""" | ||
| 19 | + self.do_test_behaviour('text') | ||
| 20 | + | ||
| 21 | + def test_empty_behaviour(self): | ||
| 22 | + """Test behaviour of olevba when presented with pure text file.""" | ||
| 23 | + self.do_test_behaviour('empty') | ||
| 24 | + | ||
| 25 | + def do_test_behaviour(self, filename): | ||
| 26 | + """Helper for test_{text,empty}_behaviour.""" | ||
| 27 | + input_file = join(DATA_BASE_DIR, 'basic', filename) | ||
| 28 | + output, _ = call_and_capture('olevba', args=(input_file, )) | ||
| 29 | + | ||
| 30 | + # check output | ||
| 31 | + self.assertTrue(re.search(r'^Type:\s+Text\s*$', output, re.MULTILINE), | ||
| 32 | + msg='"Type: Text" not found in output:\n' + output) | ||
| 33 | + self.assertTrue(re.search(r'^No suspicious .+ found.$', output, | ||
| 34 | + re.MULTILINE), | ||
| 35 | + msg='"No suspicous...found" not found in output:\n' + \ | ||
| 36 | + output) | ||
| 37 | + self.assertNotIn('error', output.lower()) | ||
| 38 | + | ||
| 39 | + # check warnings | ||
| 40 | + for line in output.splitlines(): | ||
| 41 | + if line.startswith('WARNING ') and 'encrypted' in line: | ||
| 42 | + continue # encryption warnings are ok | ||
| 43 | + elif 'warn' in line.lower(): | ||
| 44 | + raise self.fail('Found "warn" in output line: "{}"' | ||
| 45 | + .format(line.rstrip())) | ||
| 46 | + self.assertIn('not encrypted', output) | ||
| 47 | + | ||
| 48 | + def test_rtf_behaviour(self): | ||
| 49 | + """Test behaviour of olevba when presented with an rtf file.""" | ||
| 50 | + input_file = join(DATA_BASE_DIR, 'msodde', 'RTF-Spec-1.7.rtf') | ||
| 51 | + output, ret_code = call_and_capture('olevba', args=(input_file, ), | ||
| 52 | + accept_nonzero_exit=True) | ||
| 53 | + | ||
| 54 | + # check that return code is olevba.RETURN_OPEN_ERROR | ||
| 55 | + self.assertEqual(ret_code, 5) | ||
| 56 | + | ||
| 57 | + # check output: | ||
| 58 | + self.assertIn('FileOpenError', output) | ||
| 59 | + self.assertIn('is RTF', output) | ||
| 60 | + self.assertIn('rtfobj.py', output) | ||
| 61 | + self.assertIn('not encrypted', output) | ||
| 62 | + | ||
| 63 | + # check warnings | ||
| 64 | + for line in output.splitlines(): | ||
| 65 | + if line.startswith('WARNING ') and 'encrypted' in line: | ||
| 66 | + continue # encryption warnings are ok | ||
| 67 | + elif 'warn' in line.lower(): | ||
| 68 | + raise self.fail('Found "warn" in output line: "{}"' | ||
| 69 | + .format(line.rstrip())) | ||
| 70 | + | ||
| 21 | def test_crypt_return(self): | 71 | def test_crypt_return(self): |
| 22 | """ | 72 | """ |
| 23 | Tests that encrypted files give a certain return code. | 73 | Tests that encrypted files give a certain return code. |
| @@ -28,23 +78,23 @@ class TestOlevbaBasic(unittest.TestCase): | @@ -28,23 +78,23 @@ class TestOlevbaBasic(unittest.TestCase): | ||
| 28 | CRYPT_DIR = join(DATA_BASE_DIR, 'encrypted') | 78 | CRYPT_DIR = join(DATA_BASE_DIR, 'encrypted') |
| 29 | CRYPT_RETURN_CODE = 9 | 79 | CRYPT_RETURN_CODE = 9 |
| 30 | ADD_ARGS = [], ['-d', ], ['-a', ], ['-j', ], ['-t', ] | 80 | ADD_ARGS = [], ['-d', ], ['-a', ], ['-j', ], ['-t', ] |
| 31 | - EXCEPTIONS = ['autostart-encrypt-standardpassword.xlsm', # These ... | ||
| 32 | - 'autostart-encrypt-standardpassword.xlsb', # files ... | ||
| 33 | - 'dde-test-encrypt-standardpassword.xls', # are ... | ||
| 34 | - 'dde-test-encrypt-standardpassword.xlsx', # decrypted | ||
| 35 | - 'dde-test-encrypt-standardpassword.xlsm', # per ... | ||
| 36 | - 'dde-test-encrypt-standardpassword.xlsb'] # default. | 81 | + EXCEPTIONS = ['autostart-encrypt-standardpassword.xls', # These ... |
| 82 | + 'autostart-encrypt-standardpassword.xlsm', # files ... | ||
| 83 | + 'autostart-encrypt-standardpassword.xlsb', # are ... | ||
| 84 | + 'dde-test-encrypt-standardpassword.xls', # automati... | ||
| 85 | + 'dde-test-encrypt-standardpassword.xlsx', # ...cally... | ||
| 86 | + 'dde-test-encrypt-standardpassword.xlsm', # decrypted. | ||
| 87 | + 'dde-test-encrypt-standardpassword.xlsb'] | ||
| 37 | for filename in os.listdir(CRYPT_DIR): | 88 | for filename in os.listdir(CRYPT_DIR): |
| 38 | if filename in EXCEPTIONS: | 89 | if filename in EXCEPTIONS: |
| 39 | continue | 90 | continue |
| 40 | full_name = join(CRYPT_DIR, filename) | 91 | full_name = join(CRYPT_DIR, filename) |
| 41 | for args in ADD_ARGS: | 92 | for args in ADD_ARGS: |
| 42 | - try: | ||
| 43 | - ret_code = olevba.main(args + [full_name, ]) | ||
| 44 | - except SystemExit as se: | ||
| 45 | - ret_code = se.code or 0 # se.code can be None | 93 | + _, ret_code = call_and_capture('olevba', |
| 94 | + args=[full_name, ] + args, | ||
| 95 | + accept_nonzero_exit=True) | ||
| 46 | self.assertEqual(ret_code, CRYPT_RETURN_CODE, | 96 | self.assertEqual(ret_code, CRYPT_RETURN_CODE, |
| 47 | - msg='Wrong return code {} for args {}' | 97 | + msg='Wrong return code {} for args {}'\ |
| 48 | .format(ret_code, args + [filename, ])) | 98 | .format(ret_code, args + [filename, ])) |
| 49 | 99 | ||
| 50 | 100 |
tests/olevba/test_crypto.py
| @@ -2,20 +2,18 @@ | @@ -2,20 +2,18 @@ | ||
| 2 | 2 | ||
| 3 | import sys | 3 | import sys |
| 4 | import unittest | 4 | import unittest |
| 5 | -import os | ||
| 6 | -from os.path import join as pjoin | ||
| 7 | -from subprocess import check_output, CalledProcessError | 5 | +from os.path import basename, join as pjoin |
| 8 | import json | 6 | import json |
| 9 | from collections import OrderedDict | 7 | from collections import OrderedDict |
| 10 | 8 | ||
| 11 | -from tests.test_utils import DATA_BASE_DIR, SOURCE_BASE_DIR | 9 | +from tests.test_utils import DATA_BASE_DIR, call_and_capture |
| 12 | 10 | ||
| 13 | from oletools import crypto | 11 | from oletools import crypto |
| 14 | 12 | ||
| 15 | 13 | ||
| 16 | @unittest.skipIf(not crypto.check_msoffcrypto(), | 14 | @unittest.skipIf(not crypto.check_msoffcrypto(), |
| 17 | - 'Module msoffcrypto not installed for python{}.{}' | ||
| 18 | - .format(sys.version_info.major, sys.version_info.minor)) | 15 | + 'Module msoffcrypto not installed for {}' |
| 16 | + .format(basename(sys.executable))) | ||
| 19 | class OlevbaCryptoWriteProtectTest(unittest.TestCase): | 17 | class OlevbaCryptoWriteProtectTest(unittest.TestCase): |
| 20 | """ | 18 | """ |
| 21 | Test documents that are 'write-protected' through encryption. | 19 | Test documents that are 'write-protected' through encryption. |
| @@ -34,25 +32,12 @@ class OlevbaCryptoWriteProtectTest(unittest.TestCase): | @@ -34,25 +32,12 @@ class OlevbaCryptoWriteProtectTest(unittest.TestCase): | ||
| 34 | """ | 32 | """ |
| 35 | def test_autostart(self): | 33 | def test_autostart(self): |
| 36 | """Check that autostart macro is found in xls[mb] sample file.""" | 34 | """Check that autostart macro is found in xls[mb] sample file.""" |
| 37 | - # create a PYTHONPATH environment var to prefer our olevba | ||
| 38 | - env = os.environ | ||
| 39 | - try: | ||
| 40 | - env['PYTHONPATH'] = SOURCE_BASE_DIR + os.pathsep + \ | ||
| 41 | - os.environ['PYTHONPATH'] | ||
| 42 | - except KeyError: | ||
| 43 | - env['PYTHONPATH'] = SOURCE_BASE_DIR | ||
| 44 | - | ||
| 45 | for suffix in 'xlsm', 'xlsb': | 35 | for suffix in 'xlsm', 'xlsb': |
| 46 | example_file = pjoin( | 36 | example_file = pjoin( |
| 47 | DATA_BASE_DIR, 'encrypted', | 37 | DATA_BASE_DIR, 'encrypted', |
| 48 | 'autostart-encrypt-standardpassword.' + suffix) | 38 | 'autostart-encrypt-standardpassword.' + suffix) |
| 49 | - try: | ||
| 50 | - output = check_output([sys.executable, '-m', 'olevba', '-j', | ||
| 51 | - example_file], | ||
| 52 | - universal_newlines=True, env=env) | ||
| 53 | - except CalledProcessError as err: | ||
| 54 | - print(err.output) | ||
| 55 | - raise | 39 | + output, _ = call_and_capture('olevba', args=('-j', example_file), |
| 40 | + exclude_stderr=True) | ||
| 56 | data = json.loads(output, object_pairs_hook=OrderedDict) | 41 | data = json.loads(output, object_pairs_hook=OrderedDict) |
| 57 | # debug: json.dump(data, sys.stdout, indent=4) | 42 | # debug: json.dump(data, sys.stdout, indent=4) |
| 58 | self.assertEqual(len(data), 4) | 43 | self.assertEqual(len(data), 4) |
tests/test-data/encrypted/autostart-encrypt-standardpassword.xls
0 → 100755
No preview for this file type
tests/test_utils/__init__.py
| 1 | -from os.path import dirname, join, abspath | ||
| 2 | - | ||
| 3 | -# Base dir of project, contains subdirs "tests" and "oletools" and README.md | ||
| 4 | -PROJECT_ROOT = dirname(dirname(dirname(abspath(__file__)))) | ||
| 5 | - | ||
| 6 | -# Directory with test data, independent of current working directory | ||
| 7 | -DATA_BASE_DIR = join(PROJECT_ROOT, 'tests', 'test-data') | ||
| 8 | - | ||
| 9 | -# Directory with source code | ||
| 10 | -SOURCE_BASE_DIR = join(PROJECT_ROOT, 'oletools') | 1 | +from .utils import * |
tests/test_utils/utils.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | ||
| 2 | + | ||
| 3 | +"""Utils generally useful for unittests.""" | ||
| 4 | + | ||
| 5 | +import sys | ||
| 6 | +import os | ||
| 7 | +from os.path import dirname, join, abspath | ||
| 8 | +from subprocess import check_output, PIPE, STDOUT, CalledProcessError | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +# Base dir of project, contains subdirs "tests" and "oletools" and README.md | ||
| 12 | +PROJECT_ROOT = dirname(dirname(dirname(abspath(__file__)))) | ||
| 13 | + | ||
| 14 | +# Directory with test data, independent of current working directory | ||
| 15 | +DATA_BASE_DIR = join(PROJECT_ROOT, 'tests', 'test-data') | ||
| 16 | + | ||
| 17 | +# Directory with source code | ||
| 18 | +SOURCE_BASE_DIR = join(PROJECT_ROOT, 'oletools') | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def call_and_capture(module, args=None, accept_nonzero_exit=False, | ||
| 22 | + exclude_stderr=False): | ||
| 23 | + """ | ||
| 24 | + Run module as script, capturing and returning output and return code. | ||
| 25 | + | ||
| 26 | + This is the best way to capture a module's stdout and stderr; trying to | ||
| 27 | + modify sys.stdout/sys.stderr to StringIO-Buffers frequently causes trouble. | ||
| 28 | + | ||
| 29 | + Only drawback sofar: stdout and stderr are merged into one (which is | ||
| 30 | + what users see on their shell as well). When testing for json-compatible | ||
| 31 | + output you should `exclude_stderr` to `False` since logging ignores stderr, | ||
| 32 | + so unforseen warnings (e.g. issued by pypy) would mess up your json. | ||
| 33 | + | ||
| 34 | + :param str module: name of module to test, e.g. `olevba` | ||
| 35 | + :param args: arguments for module's main function | ||
| 36 | + :param bool fail_nonzero: Raise error if command returns non-0 return code | ||
| 37 | + :param bool exclude_stderr: Exclude output to `sys.stderr` from output | ||
| 38 | + (e.g. if parsing output through json) | ||
| 39 | + :returns: ret_code, output | ||
| 40 | + :rtype: int, str | ||
| 41 | + """ | ||
| 42 | + # create a PYTHONPATH environment var to prefer our current code | ||
| 43 | + env = os.environ.copy() | ||
| 44 | + try: | ||
| 45 | + env['PYTHONPATH'] = SOURCE_BASE_DIR + os.pathsep + \ | ||
| 46 | + os.environ['PYTHONPATH'] | ||
| 47 | + except KeyError: | ||
| 48 | + env['PYTHONPATH'] = SOURCE_BASE_DIR | ||
| 49 | + | ||
| 50 | + # ensure args is a tuple | ||
| 51 | + my_args = tuple(args) if args else () | ||
| 52 | + | ||
| 53 | + ret_code = -1 | ||
| 54 | + try: | ||
| 55 | + output = check_output((sys.executable, '-m', module) + my_args, | ||
| 56 | + universal_newlines=True, env=env, | ||
| 57 | + stderr=PIPE if exclude_stderr else STDOUT) | ||
| 58 | + ret_code = 0 | ||
| 59 | + | ||
| 60 | + except CalledProcessError as err: | ||
| 61 | + if accept_nonzero_exit: | ||
| 62 | + ret_code = err.returncode | ||
| 63 | + output = err.output | ||
| 64 | + else: | ||
| 65 | + print(err.output) | ||
| 66 | + raise | ||
| 67 | + | ||
| 68 | + return output, ret_code |