diff --git a/src/analysis/PluginBase.py b/src/analysis/PluginBase.py index 275d7cbf8..ca603ddfa 100644 --- a/src/analysis/PluginBase.py +++ b/src/analysis/PluginBase.py @@ -3,10 +3,10 @@ from queue import Empty from time import time -from helperFunctions.dependency import get_unmatched_dependencies, schedule_dependencies from helperFunctions.parsing import bcolors from helperFunctions.process import ExceptionSafeProcess, terminate_process_and_childs from helperFunctions.tag import TagColor +from objects.file import FileObject from plugins.base import BasePlugin @@ -16,12 +16,12 @@ class AnalysisBasePlugin(BasePlugin): # pylint: disable=too-many-instance-attri recursive flag: If True (default) recursively analyze included files ''' VERSION = 'not set' + SYSTEM_VERSION = None timeout = None def __init__(self, plugin_administrator, config=None, recursive=True, no_multithread=False, timeout=300, offline_testing=False, plugin_path=None): # pylint: disable=too-many-arguments super().__init__(plugin_administrator, config=config, plugin_path=plugin_path) - self.history = set() self.check_config(no_multithread) self.recursive = recursive self.in_queue = Queue() @@ -35,35 +35,22 @@ def __init__(self, plugin_administrator, config=None, recursive=True, no_multith if not offline_testing: self.start_worker() - def add_job(self, fw_object): - if self._job_is_already_done(fw_object): - logging.debug('{} analysis already done -> skip: {}\n Analysis Dependencies: {}'.format( - self.NAME, fw_object.get_uid(), fw_object.analysis_dependency)) - elif self._recursive_condition_is_set(fw_object): - if self._dependencies_are_fulfilled(fw_object): - self.history.add(fw_object.get_uid()) - self.in_queue.put(fw_object) - return - self._reschedule_job(fw_object) + def add_job(self, fw_object: FileObject): + if self._dependencies_are_unfulfilled(fw_object): + logging.error('{}: dependencies of plugin {} not fulfilled'.format(fw_object.get_uid(), self.NAME)) + elif self._analysis_depth_not_reached_yet(fw_object): + self.in_queue.put(fw_object) + return self.out_queue.put(fw_object) - def _reschedule_job(self, fw_object): - unmatched_dependencies = get_unmatched_dependencies([fw_object], self.DEPENDENCIES) - logging.debug('{} rescheduled due to unmatched dependencies:\n {}'.format(fw_object.get_virtual_file_paths(), unmatched_dependencies)) - fw_object.scheduled_analysis = schedule_dependencies(fw_object.scheduled_analysis, unmatched_dependencies, self.NAME) - fw_object.analysis_dependency = fw_object.analysis_dependency.union(set(unmatched_dependencies)) - logging.debug('new schedule for {}:\n {}\nAnalysis Dependencies: {}'.format( - fw_object.get_virtual_file_paths(), fw_object.scheduled_analysis, fw_object.analysis_dependency)) + def _dependencies_are_unfulfilled(self, fw_object: FileObject): + # FIXME plugins can be in processed_analysis and could still be skipped, etc. -> need a way to verify that + # FIXME the analysis ran successfully + return any(dep not in fw_object.processed_analysis for dep in self.DEPENDENCIES) - def _job_is_already_done(self, fw_object): - return (fw_object.get_uid() in self.history) and (self.NAME not in fw_object.analysis_dependency) - - def _recursive_condition_is_set(self, fo): + def _analysis_depth_not_reached_yet(self, fo): return self.recursive or fo.depth == 0 - def _dependencies_are_fulfilled(self, fo): - return get_unmatched_dependencies([fo], self.DEPENDENCIES) == [] - def process_object(self, file_object): # pylint: disable=no-self-use ''' This function must be implemented by the plugin @@ -96,6 +83,8 @@ def shutdown(self): self.in_queue.close() self.out_queue.close() +# ---- internal functions ---- + def add_analysis_tag(self, file_object, tag_name, value, color=TagColor.LIGHT_BLUE, propagate=False): new_tag = { tag_name: { @@ -110,18 +99,16 @@ def add_analysis_tag(self, file_object, tag_name, value, color=TagColor.LIGHT_BL else: file_object.processed_analysis[self.NAME]['tags'].update(new_tag) -# ---- internal functions ---- - def init_dict(self): - results = {} - results['analysis_date'] = time() - results['plugin_version'] = self.VERSION - return results + result_update = {'analysis_date': time(), 'plugin_version': self.VERSION} + if self.SYSTEM_VERSION: + result_update.update({'system_version': self.SYSTEM_VERSION}) + return result_update - def check_config(self, no_multihread): + def check_config(self, no_multithread): if self.NAME not in self.config: self.config.add_section(self.NAME) - if 'threads' not in self.config[self.NAME] or no_multihread: + if 'threads' not in self.config[self.NAME] or no_multithread: self.config.set(self.NAME, 'threads', '1') def start_worker(self): diff --git a/src/analysis/YaraPluginBase.py b/src/analysis/YaraPluginBase.py index 527c593ed..b6a221967 100644 --- a/src/analysis/YaraPluginBase.py +++ b/src/analysis/YaraPluginBase.py @@ -3,6 +3,7 @@ import re import os import subprocess +from pathlib import Path from analysis.PluginBase import AnalysisBasePlugin from helperFunctions.fileSystem import get_src_dir @@ -12,9 +13,9 @@ class YaraBasePlugin(AnalysisBasePlugin): ''' This should be the base for all YARA based analysis plugins ''' - NAME = "Yara_Base_Plugin" - DESCRIPTION = "this is a Yara plugin" - VERSION = "0.0" + NAME = 'Yara_Base_Plugin' + DESCRIPTION = 'this is a Yara plugin' + VERSION = '0.0' def __init__(self, plugin_administrator, config=None, recursive=True, plugin_path=None): ''' @@ -23,8 +24,16 @@ def __init__(self, plugin_administrator, config=None, recursive=True, plugin_pat ''' self.config = config self._get_signature_file(plugin_path) + self.SYSTEM_VERSION = self.get_yara_system_version() super().__init__(plugin_administrator, config=config, recursive=recursive, plugin_path=plugin_path) + def get_yara_system_version(self): + with subprocess.Popen(['yara', '--version'], stdout=subprocess.PIPE) as process: + yara_version = process.stdout.readline().decode().strip() + + access_time = int(Path(self.signature_path).stat().st_mtime) + return '{}_{}'.format(yara_version, access_time) + def process_object(self, file_object): if self.signature_path is not None: with subprocess.Popen('yara --print-meta --print-strings {} {}'.format(self.signature_path, file_object.file_path), shell=True, stdout=subprocess.PIPE) as process: @@ -101,7 +110,7 @@ def _parse_meta_data(meta_data_string): for item in meta_data_string.split(','): if '=' in item: key, value = item.split('=', maxsplit=1) - value = json.loads(value) if value in ['true', 'false'] else value.strip('\"') + value = json.loads(value) if value in ['true', 'false'] else value.strip('"') meta_data[key] = value else: logging.warning('Malformed meta string \'{}\''.format(meta_data_string)) diff --git a/src/helperFunctions/dependency.py b/src/helperFunctions/dependency.py index 57ec74b59..c40fa2a5f 100644 --- a/src/helperFunctions/dependency.py +++ b/src/helperFunctions/dependency.py @@ -1,10 +1,3 @@ -def schedule_dependencies(schedule_list, dependency_list, myself): - for item in dependency_list: - if item not in schedule_list: - schedule_list.append(item) - return [myself] + schedule_list - - def get_unmatched_dependencies(fo_list, dependency_list): missing_dependencies = [] for dependency in dependency_list: diff --git a/src/helperFunctions/merge_generators.py b/src/helperFunctions/merge_generators.py index bf610c311..99ad55adf 100644 --- a/src/helperFunctions/merge_generators.py +++ b/src/helperFunctions/merge_generators.py @@ -1,5 +1,9 @@ from itertools import zip_longest from copy import deepcopy +from random import sample, seed + + +seed() def merge_generators(*generators): @@ -70,3 +74,7 @@ def avg(l): if len(l) == 0: return 0 return sum(l) / len(l) + + +def shuffled(sequence): + return sample(sequence, len(sequence)) diff --git a/src/objects/file.py b/src/objects/file.py index b638495b6..a1784c734 100644 --- a/src/objects/file.py +++ b/src/objects/file.py @@ -22,7 +22,6 @@ def __init__(self, binary=None, file_name=None, file_path=None, scheduled_analys self.depth = 0 self.processed_analysis = {} self.scheduled_analysis = scheduled_analysis - self.analysis_dependency = set() self.comments = [] self.parent_firmware_uids = set() self.temporary_data = {} diff --git a/src/plugins/analysis/crypto_material/view/crypto_material.html b/src/plugins/analysis/crypto_material/view/crypto_material.html index 1f5db054a..b0b951a8e 100644 --- a/src/plugins/analysis/crypto_material/view/crypto_material.html +++ b/src/plugins/analysis/crypto_material/view/crypto_material.html @@ -3,7 +3,7 @@ {% block analysis_result_details %} {% for key in firmware.processed_analysis[selected_analysis] %} - {% if key not in ['summary', 'plugin_version', 'analysis_date', 'tags', 'skipped'] %} + {% if key not in ['summary', 'plugin_version', 'system_version', 'analysis_date', 'tags', 'skipped'] %} Description: {{ key }} diff --git a/src/plugins/analysis/known_vulnerabilities/view/known_vulnerabilities.html b/src/plugins/analysis/known_vulnerabilities/view/known_vulnerabilities.html index e8fe148fa..64ca2b645 100644 --- a/src/plugins/analysis/known_vulnerabilities/view/known_vulnerabilities.html +++ b/src/plugins/analysis/known_vulnerabilities/view/known_vulnerabilities.html @@ -3,7 +3,7 @@ {% block analysis_result_details %} {% for key in firmware.processed_analysis[selected_analysis] %} - {% if key not in ['summary', 'plugin_version', 'analysis_date', 'skipped', 'tags'] %} + {% if key not in ['summary', 'plugin_version', 'analysis_date', 'skipped', 'system_version', 'tags'] %} {{ key }} {{ firmware.processed_analysis[selected_analysis][key]['description'] }} diff --git a/src/plugins/analysis/software_components/view/software_components.html b/src/plugins/analysis/software_components/view/software_components.html index 2ca5599ee..0e2457571 100644 --- a/src/plugins/analysis/software_components/view/software_components.html +++ b/src/plugins/analysis/software_components/view/software_components.html @@ -10,7 +10,7 @@ {% for key in firmware.processed_analysis[selected_analysis] %} - {% if key not in ['summary', 'plugin_version', 'analysis_date', 'tags', 'skipped'] %} + {% if key not in ['summary', 'system_version', 'plugin_version', 'analysis_date', 'tags', 'skipped'] %} {{loop.index - 1}} Software Name: diff --git a/src/scheduler/Analysis.py b/src/scheduler/Analysis.py index 13d5b2d17..6bf03f273 100644 --- a/src/scheduler/Analysis.py +++ b/src/scheduler/Analysis.py @@ -1,15 +1,17 @@ import logging from concurrent.futures import ThreadPoolExecutor from configparser import ConfigParser +from distutils.version import LooseVersion from multiprocessing import Queue, Value -from random import shuffle from queue import Empty from time import sleep, time -from typing import Tuple, List, Optional +from typing import Tuple, List, Optional, Set, Iterable from helperFunctions.compare_sets import substring_is_in_list from helperFunctions.config import read_list_from_config +from helperFunctions.fileSystem import get_file_type_from_binary +from helperFunctions.merge_generators import shuffled from helperFunctions.parsing import bcolors from helperFunctions.plugin import import_plugins from helperFunctions.process import ExceptionSafeProcess, terminate_process_and_childs @@ -57,25 +59,48 @@ def shutdown(self): self.process_queue.close() logging.info('Analysis System offline') - def add_update_task(self, fo): + def add_update_task(self, fo: FileObject): for included_file in self.db_backend_service.get_list_of_all_included_files(fo): child = self.db_backend_service.get_object(included_file) - child.scheduled_analysis = fo.scheduled_analysis - shuffle(child.scheduled_analysis) + child.scheduled_analysis = self._add_dependencies_recursively(fo.scheduled_analysis or []) + child.scheduled_analysis = self._smart_shuffle(child.scheduled_analysis) self.check_further_process_or_complete(child) self.check_further_process_or_complete(fo) - def add_task(self, fo): + def add_task(self, fo: FileObject): ''' This function should be used to add a new firmware object to the scheduler ''' - if fo.scheduled_analysis is None: - fo.scheduled_analysis = MANDATORY_PLUGINS - else: - shuffle(fo.scheduled_analysis) - fo.scheduled_analysis = fo.scheduled_analysis + MANDATORY_PLUGINS + scheduled_plugins = self._add_dependencies_recursively(fo.scheduled_analysis or []) + fo.scheduled_analysis = self._smart_shuffle(scheduled_plugins + MANDATORY_PLUGINS) self.check_further_process_or_complete(fo) + def _smart_shuffle(self, plugin_list: List[str]) -> List[str]: + scheduled_plugins = [] + remaining_plugins = set(plugin_list) + + while len(remaining_plugins) > 0: + next_plugins = self._get_plugins_with_met_dependencies(remaining_plugins, scheduled_plugins) + if not next_plugins: + logging.error('Error: Could not schedule plugins because dependencies cannot be fulfilled: {}'.format(remaining_plugins)) + break + scheduled_plugins[:0] = shuffled(next_plugins) + remaining_plugins.difference_update(next_plugins) + + # assure file type is first for blacklist functionality + if 'file_type' in scheduled_plugins and scheduled_plugins[-1] != 'file_type': + scheduled_plugins.remove('file_type') + scheduled_plugins.append('file_type') + return scheduled_plugins + + def _get_plugins_with_met_dependencies(self, remaining_plugins: Set[str], scheduled_plugins: List[str]) -> List[str]: + met_dependencies = scheduled_plugins + return [ + plugin + for plugin in remaining_plugins + if all(dependency in met_dependencies for dependency in self.analysis_plugins[plugin].DEPENDENCIES) + ] + def get_list_of_available_plugins(self): ''' returns a list of all loaded plugins @@ -84,6 +109,8 @@ def get_list_of_available_plugins(self): plugin_list.sort(key=str.lower) return plugin_list +# ---- internal functions ---- + def get_default_plugins_from_config(self): try: result = {} @@ -114,14 +141,14 @@ def get_plugin_dict(self): result['unpacker'] = ('Additional information provided by the unpacker', True, False) return result +# ---- scheduling functions ---- + def get_scheduled_workload(self): workload = {'analysis_main_scheduler': self.process_queue.qsize()} for plugin in self.analysis_plugins: workload[plugin] = self.analysis_plugins[plugin].in_queue.qsize() return workload -# ---- internal functions ---- - def register_plugin(self, name, plugin_instance): ''' This function is called upon plugin init to announce its presence @@ -134,8 +161,6 @@ def load_plugins(self): plugin = source.load_plugin(plugin_name) plugin.AnalysisPlugin(self, config=self.config) -# ---- scheduling functions ---- - def start_scheduling_process(self): logging.debug('Starting scheduler...') self.schedule_process = ExceptionSafeProcess(target=self.scheduler) @@ -150,6 +175,8 @@ def scheduler(self): else: self.process_next_analysis(task) + # ---- analysis skipping ---- + def process_next_analysis(self, fw_object: FileObject): self.pre_analysis(fw_object) analysis_to_do = fw_object.scheduled_analysis.pop() @@ -159,13 +186,53 @@ def process_next_analysis(self, fw_object: FileObject): else: self._start_or_skip_analysis(analysis_to_do, fw_object) - def _start_or_skip_analysis(self, analysis_to_do, fw_object): - if analysis_to_do in MANDATORY_PLUGINS or self._next_analysis_is_not_blacklisted(analysis_to_do, fw_object): - self.analysis_plugins[analysis_to_do].add_job(fw_object) - else: + def _start_or_skip_analysis(self, analysis_to_do: str, fw_object: FileObject): + if self._analysis_is_already_in_db_and_up_to_date(analysis_to_do, fw_object.get_uid()): + logging.debug('skipping analysis "{}" for {} (analysis already in DB)'.format(analysis_to_do, fw_object.get_uid())) + if analysis_to_do in self._get_cumulative_remaining_dependencies(fw_object.scheduled_analysis): + self._add_completed_analysis_results_to_file_object(analysis_to_do, fw_object) + self.check_further_process_or_complete(fw_object) + elif analysis_to_do not in MANDATORY_PLUGINS and self._next_analysis_is_blacklisted(analysis_to_do, fw_object): logging.debug('skipping analysis "{}" for {} (blacklisted file type)'.format(analysis_to_do, fw_object.get_uid())) fw_object.processed_analysis[analysis_to_do] = self._get_skipped_analysis_result(analysis_to_do) self.check_further_process_or_complete(fw_object) + else: + self.analysis_plugins[analysis_to_do].add_job(fw_object) + + def _add_completed_analysis_results_to_file_object(self, analysis_to_do: str, fw_object: FileObject): + db_entry = self.db_backend_service.get_specific_fields_of_db_entry( + fw_object.get_uid(), {'processed_analysis.{}'.format(analysis_to_do): 1} + ) + desanitized_analysis = self.db_backend_service.retrieve_analysis(db_entry['processed_analysis']) + fw_object.processed_analysis[analysis_to_do] = desanitized_analysis[analysis_to_do] + + def _analysis_is_already_in_db_and_up_to_date(self, analysis_to_do: str, uid: str): + db_entry = self.db_backend_service.get_specific_fields_of_db_entry( + uid, + { + 'processed_analysis.{}.plugin_version'.format(analysis_to_do): 1, + 'processed_analysis.{}.system_version'.format(analysis_to_do): 1 + } + ) + if not db_entry or analysis_to_do not in db_entry['processed_analysis']: + return False + elif 'plugin_version' not in db_entry['processed_analysis'][analysis_to_do]: + logging.error('Plugin Version missing: UID: {}, Plugin: {}'.format(uid, analysis_to_do)) + return False + + analysis_plugin_version = db_entry['processed_analysis'][analysis_to_do]['plugin_version'] + analysis_system_version = db_entry['processed_analysis'][analysis_to_do]['system_version'] \ + if 'system_version' in db_entry['processed_analysis'][analysis_to_do] else None + plugin_version = self.analysis_plugins[analysis_to_do].VERSION + system_version = self.analysis_plugins[analysis_to_do].SYSTEM_VERSION \ + if hasattr(self.analysis_plugins[analysis_to_do], 'SYSTEM_VERSION') else None + + if LooseVersion(analysis_plugin_version) < LooseVersion(plugin_version) or \ + LooseVersion(analysis_system_version or '0') < LooseVersion(system_version or '0'): + return False + return True + +# ---- blacklist and whitelist ---- def _get_skipped_analysis_result(self, analysis_to_do): return { @@ -175,28 +242,27 @@ def _get_skipped_analysis_result(self, analysis_to_do): 'plugin_version': self.analysis_plugins[analysis_to_do].VERSION } - # ---- blacklist and whitelist ---- - - def _next_analysis_is_not_blacklisted(self, next_analysis, fw_object: FileObject): + def _next_analysis_is_blacklisted(self, next_analysis: str, fw_object: FileObject): blacklist, whitelist = self._get_blacklist_and_whitelist(next_analysis) if not (blacklist or whitelist): - return True + return False if blacklist and whitelist: logging.error('{}Configuration of plugin "{}" erroneous{}: found blacklist and whitelist. Ignoring blacklist.'.format( bcolors.FAIL, next_analysis, bcolors.ENDC)) - try: - file_type = fw_object.processed_analysis['file_type']['mime'].lower() - except KeyError: # FIXME file_type analysis is missing (probably due to problem with analysis caching) -> re-schedule - fw_object.scheduled_analysis.extend([next_analysis, 'file_type']) - fw_object.analysis_dependency.add('file_type') - return False + file_type = self._get_file_type_from_object_or_db(fw_object) if whitelist: - return substring_is_in_list(file_type, whitelist) - return not substring_is_in_list(file_type, blacklist) + return not substring_is_in_list(file_type, whitelist) + return substring_is_in_list(file_type, blacklist) + + def _get_file_type_from_object_or_db(self, fw_object: FileObject) -> Optional[str]: + if 'file_type' not in fw_object.processed_analysis: + self._add_completed_analysis_results_to_file_object('file_type', fw_object) - def _get_blacklist_and_whitelist(self, next_analysis): + return fw_object.processed_analysis['file_type']['mime'].lower() + + def _get_blacklist_and_whitelist(self, next_analysis: str) -> Tuple[List, List]: blacklist, whitelist = self._get_blacklist_and_whitelist_from_config(next_analysis) if not (blacklist or whitelist): blacklist, whitelist = self._get_blacklist_and_whitelist_from_plugin(next_analysis) @@ -207,18 +273,20 @@ def _get_blacklist_and_whitelist_from_config(self, analysis_plugin: str) -> Tupl whitelist = read_list_from_config(self.config, analysis_plugin, 'mime_whitelist') return blacklist, whitelist +# ---- result collector functions ---- + def _get_blacklist_and_whitelist_from_plugin(self, analysis_plugin: str) -> Tuple[List, List]: blacklist = self.analysis_plugins[analysis_plugin].MIME_BLACKLIST if hasattr(self.analysis_plugins[analysis_plugin], 'MIME_BLACKLIST') else [] whitelist = self.analysis_plugins[analysis_plugin].MIME_WHITELIST if hasattr(self.analysis_plugins[analysis_plugin], 'MIME_WHITELIST') else [] return blacklist, whitelist -# ---- result collector functions ---- - def start_result_collector(self): logging.debug('Starting result collector') self.result_collector_process = ExceptionSafeProcess(target=self.result_collector) self.result_collector_process.start() +# ---- miscellaneous functions ---- + def result_collector(self): while self.stop_condition.value == 0: nop = True @@ -240,8 +308,6 @@ def _handle_analysis_tags(self, fw, plugin): self.tag_queue.put(check_tags(fw, plugin)) return add_tags_to_object(fw, plugin) -# ---- miscellaneous functions ---- - def check_further_process_or_complete(self, fw_object): if not fw_object.scheduled_analysis: logging.info('Analysis Completed:\n{}'.format(fw_object)) @@ -261,8 +327,24 @@ def check_exceptions(self): return True for process in [self.schedule_process, self.result_collector_process]: if process.exception: - logging.error("{}Exception in scheduler process {}{}".format(bcolors.FAIL, bcolors.ENDC, process.name)) + logging.error('{}Exception in scheduler process {}{}'.format(bcolors.FAIL, bcolors.ENDC, process.name)) logging.error(process.exception[1]) terminate_process_and_childs(process) return True # Error here means nothing will ever get scheduled again. Thing should just break ! return False + + def _add_dependencies_recursively(self, scheduled_analyses: List[str]) -> List[str]: + scheduled_analyses_set = set(scheduled_analyses) + while True: + new_dependencies = self._get_cumulative_remaining_dependencies(scheduled_analyses_set) + if not new_dependencies: + break + scheduled_analyses_set.update(new_dependencies) + return list(scheduled_analyses_set) + + def _get_cumulative_remaining_dependencies(self, scheduled_analyses: Set[str]) -> Set[str]: + return { + dependency + for plugin in scheduled_analyses + for dependency in self.analysis_plugins[plugin].DEPENDENCIES + }.difference(scheduled_analyses) diff --git a/src/storage/db_interface_common.py b/src/storage/db_interface_common.py index ba0e151b0..b08e2d0f7 100644 --- a/src/storage/db_interface_common.py +++ b/src/storage/db_interface_common.py @@ -204,6 +204,9 @@ def _retrieve_binaries(self, sanitized_dict, key): tmp_dict[analysis_key] = report return tmp_dict + def get_specific_fields_of_db_entry(self, uid, field_dict): + return self.file_objects.find_one(uid, field_dict) or self.firmwares.find_one(uid, field_dict) + # --- summary recreation def get_list_of_all_included_files(self, fo): diff --git a/src/storage/db_interface_frontend.py b/src/storage/db_interface_frontend.py index 6a15039c4..7e819a3e2 100644 --- a/src/storage/db_interface_frontend.py +++ b/src/storage/db_interface_frontend.py @@ -166,9 +166,6 @@ def get_other_versions_of_firmware(self, firmware_object): results = self.firmwares.find(query, {'_id': 1, 'version': 1}) return [r for r in results if r['_id'] != firmware_object.get_uid()] - def get_specific_fields_of_db_entry(self, uid, field_dict): - return self.file_objects.find_one(uid, field_dict) or self.firmwares.find_one(uid, field_dict) - def get_specific_fields_for_multiple_entries(self, uid_list, field_dict): query = self._build_search_query_for_uid_list(uid_list) file_object_iterator = self.file_objects.find(query, field_dict) diff --git a/src/test/common_helper.py b/src/test/common_helper.py index 9e780d364..ffdd79bad 100644 --- a/src/test/common_helper.py +++ b/src/test/common_helper.py @@ -64,8 +64,7 @@ class MockFileObject(object): def __init__(self, binary=b'test string', file_path='/bin/ls'): self.binary = binary self.file_path = file_path - self.processed_analysis = {'file_type': { - 'mime': 'application/x-executable'}} + self.processed_analysis = {'file_type': {'mime': 'application/x-executable'}} class DatabaseMock: @@ -330,6 +329,9 @@ def check_unpacking_lock(self, uid): def drop_unpacking_locks(self): self.locks = [] + def get_specific_fields_of_db_entry(self, uid, field_dict): + return None # TODO + def fake_exit(self, *args): pass diff --git a/src/test/integration/common.py b/src/test/integration/common.py index 19afaa728..9fef8e014 100644 --- a/src/test/integration/common.py +++ b/src/test/integration/common.py @@ -22,6 +22,9 @@ def existence_quick_check(self, uid): def add_object(self, fo_fw): self._objects[fo_fw.uid] = fo_fw + def get_specific_fields_of_db_entry(self, uid, field_dict): + pass + def initialize_config(tmp_dir): config = get_config_for_testing(temp_dir=tmp_dir) diff --git a/src/test/mock.py b/src/test/mock.py new file mode 100644 index 000000000..f4a2fe355 --- /dev/null +++ b/src/test/mock.py @@ -0,0 +1,39 @@ +from contextlib import contextmanager +from typing import Callable + + +class MockSpy: + _called = False + _args = None + + def spy_function(self, *args): + self._called = True + self._args = args + + def was_called(self): + return self._called + + +@contextmanager +def mock_spy(o: object, method: str): + spy = MockSpy() + if not hasattr(o, method): + raise AttributeError('{} has no method {}'.format(type(o), method)) + tmp = getattr(o, method) + try: + setattr(o, method, spy.spy_function) + yield spy + finally: + setattr(o, method, tmp) + + +@contextmanager +def mock_patch(o: object, method: str, replacement_method: Callable): + if not hasattr(o, method): + raise AttributeError('{} has no method {}'.format(type(o), method)) + tmp = getattr(o, method) + try: + setattr(o, method, replacement_method) + yield o + finally: + setattr(o, method, tmp) diff --git a/src/test/unit/analysis/test_plugin_base.py b/src/test/unit/analysis/test_plugin_base.py index c87ce239a..e88c08089 100644 --- a/src/test/unit/analysis/test_plugin_base.py +++ b/src/test/unit/analysis/test_plugin_base.py @@ -15,7 +15,7 @@ class TestPluginBase(unittest.TestCase): def setUp(self): config = self.set_up_base_config() - self.pBase = AnalysisBasePlugin(self, config) + self.base_plugin = AnalysisBasePlugin(self, config) @staticmethod def set_up_base_config(): @@ -27,7 +27,7 @@ def set_up_base_config(): return config def tearDown(self): - self.pBase.shutdown() + self.base_plugin.shutdown() gc.collect() def register_plugin(self, name, plugin_object): @@ -45,8 +45,8 @@ def test_start_stop_workers(self): def test_object_processing_no_childs(self): root_object = FileObject(binary=b'root_file') - self.pBase.in_queue.put(root_object) - processed_object = self.pBase.out_queue.get() + self.base_plugin.in_queue.put(root_object) + processed_object = self.base_plugin.out_queue.get() self.assertEqual(processed_object.get_uid(), root_object.get_uid(), 'uid changed') self.assertTrue('base' in processed_object.processed_analysis, 'object not processed') self.assertEqual(processed_object.processed_analysis['base']['plugin_version'], 'not set', 'plugin version missing in results') @@ -56,97 +56,61 @@ def test_object_processing_one_child(self): root_object = FileObject(binary=b'root_file') child_object = FileObject(binary=b'first_child_object') root_object.add_included_file(child_object) - self.pBase.in_queue.put(root_object) - processed_object = self.pBase.out_queue.get() + self.base_plugin.in_queue.put(root_object) + processed_object = self.base_plugin.out_queue.get() self.assertEqual(processed_object.get_uid(), root_object.get_uid(), 'uid changed') self.assertTrue(child_object.get_uid() in root_object.get_included_files_uids(), 'child object not in processed file') def test_get_workload(self): - assert self.pBase.get_workload() == 0 + assert self.base_plugin.get_workload() == 0 class TestPluginBaseAddJob(TestPluginBase): - def test_dependency_condition_check_no_deps(self): - fo = FileObject(binary='test', scheduled_analysis=[]) - self.assertTrue(self.pBase._dependencies_are_fulfilled(fo), 'no deps specified') - - def test_dependency_condition_check_unmatched_deps(self): - self.pBase.DEPENDENCIES = ['foo'] - fo = FileObject(binary='test', scheduled_analysis=[]) - self.assertFalse(self.pBase._dependencies_are_fulfilled(fo), 'deps specified and unmatched') - - def test_dependency_condition_check_matched_deps(self): - self.pBase.DEPENDENCIES = ['foo'] - fo = FileObject(binary='test', scheduled_analysis=[]) - fo.processed_analysis.update({'foo': []}) - self.assertTrue(self.pBase._dependencies_are_fulfilled(fo), 'Fals but deps matched') - - def test_recursive_condition_is_set(self): + def test_analysis_depth_not_reached_yet(self): fo = FileObject(binary='test', scheduled_analysis=[]) fo.depth = 1 - self.pBase.recursive = False - self.assertFalse(self.pBase._recursive_condition_is_set(fo), 'positive but not root object') + self.base_plugin.recursive = False + self.assertFalse(self.base_plugin._analysis_depth_not_reached_yet(fo), 'positive but not root object') fo.depth = 0 - self.pBase.recursive = False - self.assertTrue(self.pBase._recursive_condition_is_set(fo)) + self.base_plugin.recursive = False + self.assertTrue(self.base_plugin._analysis_depth_not_reached_yet(fo)) + fo.depth = 1 - self.pBase.recursive = True - self.assertTrue(self.pBase._recursive_condition_is_set(fo)) + self.base_plugin.recursive = True + self.assertTrue(self.base_plugin._analysis_depth_not_reached_yet(fo)) fo.depth = 0 - self.pBase.recursive = True - self.assertTrue(self.pBase._recursive_condition_is_set(fo)) + self.base_plugin.recursive = True + self.assertTrue(self.base_plugin._analysis_depth_not_reached_yet(fo)) def test__add_job__recursive_is_set(self): fo = FileObject(binary='test', scheduled_analysis=[]) fo.depth = 1 - self.pBase.recursive = False - self.pBase.add_job(fo) - out_fo = self.pBase.out_queue.get(timeout=5) + self.base_plugin.recursive = False + self.base_plugin.add_job(fo) + out_fo = self.base_plugin.out_queue.get(timeout=5) self.assertIsInstance(out_fo, FileObject, 'not added to out_queue') - self.pBase.recursive = True - self.assertTrue(self.pBase._recursive_condition_is_set(fo), 'not positvie but recursive') - - def test_add_job_dependency_not_matched(self): - self.pBase.DEPENDENCIES = ['foo'] - fo = FileObject(binary='test', scheduled_analysis=[]) - self.pBase.add_job(fo) - fo = self.pBase.out_queue.get(timeout=5) - self.assertEqual(fo.scheduled_analysis, ['base', 'foo'], 'analysis not scheduled') - self.assertNotIn('base', fo.processed_analysis, 'base added to processed analysis, but is not processed') + self.base_plugin.recursive = True + self.assertTrue(self.base_plugin._analysis_depth_not_reached_yet(fo), 'not positive but recursive') class TestPluginBaseOffline(TestPluginBase): def setUp(self): - self.pBase = AnalysisBasePlugin(self, config=self.set_up_base_config(), offline_testing=True) - - def test_object_history(self): - test_fo = create_test_file_object() - self.pBase.add_job(test_fo) - result = self.pBase.in_queue.get(timeout=5) - self.assertTrue(self.pBase.out_queue.empty(), 'added to outque but not in history') - self.pBase.add_job(test_fo) - result = self.pBase.out_queue.get(timeout=5) - self.assertTrue(self.pBase.in_queue.empty(), 'added to inque but already in history') - # required dependency check - test_fo.analysis_dependency.add(self.pBase.NAME) - self.pBase.add_job(test_fo) - result = self.pBase.in_queue.get(timeout=5) - self.assertTrue(self.pBase.out_queue.empty(), 'added to out queue but should be reanalyzed because of dependency request') + self.base_plugin = AnalysisBasePlugin(self, config=self.set_up_base_config(), offline_testing=True) def test_get_view_file_path(self): plugin_path = os.path.join(get_src_dir(), 'plugins/analysis/file_type/') code_path = os.path.join(plugin_path, 'code/file_type.py') estimated_view_path = os.path.join(plugin_path, 'view/file_type.html') - assert self.pBase._get_view_file_path(code_path) == estimated_view_path + assert self.base_plugin._get_view_file_path(code_path) == estimated_view_path plugin_path_without_view = os.path.join(get_src_dir(), 'plugins/analysis/dummy/code/dummy.py') - assert self.pBase._get_view_file_path(plugin_path_without_view) is None + assert self.base_plugin._get_view_file_path(plugin_path_without_view) is None class TestPluginNotRunning(TestPluginBase): diff --git a/src/test/unit/helperFunctions/test_dependency.py b/src/test/unit/helperFunctions/test_dependency.py index 151d344df..6260cf57b 100644 --- a/src/test/unit/helperFunctions/test_dependency.py +++ b/src/test/unit/helperFunctions/test_dependency.py @@ -5,7 +5,7 @@ ''' import unittest -from helperFunctions.dependency import schedule_dependencies, get_unmatched_dependencies +from helperFunctions.dependency import get_unmatched_dependencies class MockFileObject: @@ -13,22 +13,6 @@ def __init__(self, processed_analysis_list): self.processed_analysis = processed_analysis_list -class TestHelperFunctionsDependencySchedule(unittest.TestCase): - - def schedule_test(self, schedule_list, dependency_list, myself, out_list): - new_schedule = schedule_dependencies(schedule_list, dependency_list, myself) - self.assertEqual(new_schedule, out_list, 'schedule not correct') - - def test_schedule_simple_case(self): - self.schedule_test(['a', 'b'], ['b'], 'c', ['c', 'a', 'b']) - - def test_schedule_not_in_list(self): - self.schedule_test([], ['a'], 'b', ['b', 'a']) - - def test_schedule_multiple_in_not_in_list(self): - self.schedule_test(['a', 'b'], ['b', 'c', 'd', 'a'], 'e', ['e', 'a', 'b', 'c', 'd']) - - class TestHelperFunctionsDependencyMatch(unittest.TestCase): def unmatched_dependency_test(self, processed_list, dependencies, out_list): diff --git a/src/test/unit/scheduler/test_analysis.py b/src/test/unit/scheduler/test_analysis.py index 6d514af35..89985c2d2 100644 --- a/src/test/unit/scheduler/test_analysis.py +++ b/src/test/unit/scheduler/test_analysis.py @@ -1,13 +1,17 @@ import gc import os +from contextlib import suppress from multiprocessing import Queue from unittest import TestCase, mock +import pytest + from helperFunctions.config import get_config_for_testing from helperFunctions.fileSystem import get_test_data_dir from objects.firmware import Firmware from scheduler.Analysis import AnalysisScheduler, MANDATORY_PLUGINS from test.common_helper import DatabaseMock, fake_exit, MockFileObject +from test.mock import mock_spy, mock_patch class AnalysisSchedulerTest(TestCase): @@ -93,8 +97,26 @@ def test_get_plugin_dict_version(self): self.assertEqual(self.sched.analysis_plugins['file_type'].VERSION, result['file_type'][3], 'version not correct') self.assertEqual(self.sched.analysis_plugins['file_hashes'].VERSION, result['file_hashes'][3], 'version not correct') + def test_process_next_analysis_unknown_plugin(self): + test_fw = Firmware(file_path=os.path.join(get_test_data_dir(), 'get_files_test/testfile1')) + test_fw.scheduled_analysis = ['unknown_plugin'] + + with mock_spy(self.sched, '_start_or_skip_analysis') as spy: + self.sched.process_next_analysis(test_fw) + assert not spy.was_called(), 'unknown plugin should simply be skipped' + + def test_skip_analysis_because_whitelist(self): + self.sched.config.set('dummy_plugin_for_testing_only', 'mime_whitelist', 'foo, bar') + test_fw = Firmware(file_path=os.path.join(get_test_data_dir(), 'get_files_test/testfile1')) + test_fw.scheduled_analysis = ['file_hashes'] + test_fw.processed_analysis['file_type'] = {'mime': 'text/plain'} + self.sched._start_or_skip_analysis('dummy_plugin_for_testing_only', test_fw) + test_fw = self.tmp_queue.get(timeout=10) + assert 'dummy_plugin_for_testing_only' in test_fw.processed_analysis + assert 'skipped' in test_fw.processed_analysis['dummy_plugin_for_testing_only'] + -class TestAnalysisSchedulerBlacklist(AnalysisSchedulerTest): +class TestAnalysisSchedulerBlacklist: test_plugin = 'test_plugin' fo = MockFileObject() @@ -109,6 +131,18 @@ def __init__(self, blacklist=None, whitelist=None): def shutdown(self): pass + @classmethod + def setup_class(cls): + cls.init_patch = mock.patch(target='scheduler.Analysis.AnalysisScheduler.__init__', new=lambda *_: None) + cls.init_patch.start() + cls.sched = AnalysisScheduler() + cls.sched.analysis_plugins = {} + cls.plugin_list = ['no_deps', 'foo', 'bar'] + cls.init_patch.stop() + + def setup(self): + self.sched.config = get_config_for_testing() + def test_get_blacklist_and_whitelist_from_plugin(self): self.sched.analysis_plugins['test_plugin'] = self.PluginMock(['foo'], ['bar']) blacklist, whitelist = self.sched._get_blacklist_and_whitelist_from_plugin('test_plugin') @@ -137,61 +171,200 @@ def test_get_blacklist_and_whitelist__plugin_only(self): blacklist, whitelist = self.sched._get_blacklist_and_whitelist('test_plugin') assert (blacklist, whitelist) == (['foo'], ['bar']) - def test_next_analysis_is_not_blacklisted__blacklisted(self): + def test_next_analysis_is_blacklisted__blacklisted(self): self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(blacklist=['blacklisted_type']) self.fo.processed_analysis['file_type']['mime'] = 'blacklisted_type' - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is False + blacklisted = self.sched._next_analysis_is_blacklisted(self.test_plugin, self.fo) + assert blacklisted is True - def test_next_analysis_is_not_blacklisted__not_blacklisted(self): + def test_next_analysis_is_blacklisted__not_blacklisted(self): self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(blacklist=[]) self.fo.processed_analysis['file_type']['mime'] = 'not_blacklisted_type' - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is True + blacklisted = self.sched._next_analysis_is_blacklisted(self.test_plugin, self.fo) + assert blacklisted is False - def test_next_analysis_is_not_blacklisted__whitelisted(self): + def test_next_analysis_is_blacklisted__whitelisted(self): self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(whitelist=['whitelisted_type']) self.fo.processed_analysis['file_type']['mime'] = 'whitelisted_type' - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is True + blacklisted = self.sched._next_analysis_is_blacklisted(self.test_plugin, self.fo) + assert blacklisted is False - def test_next_analysis_is_not_blacklisted__not_whitelisted(self): + def test_next_analysis_is_blacklisted__not_whitelisted(self): self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(whitelist=['some_other_type']) self.fo.processed_analysis['file_type']['mime'] = 'not_whitelisted_type' - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is False + blacklisted = self.sched._next_analysis_is_blacklisted(self.test_plugin, self.fo) + assert blacklisted is True - def test_next_analysis_is_not_blacklisted__whitelist_precedes_blacklist(self): + def test_next_analysis_is_blacklisted__whitelist_precedes_blacklist(self): self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(blacklist=['test_type'], whitelist=['test_type']) self.fo.processed_analysis['file_type']['mime'] = 'test_type' - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is True + blacklisted = self.sched._next_analysis_is_blacklisted(self.test_plugin, self.fo) + assert blacklisted is False self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(blacklist=[], whitelist=['some_other_type']) self.fo.processed_analysis['file_type']['mime'] = 'test_type' - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is False + blacklisted = self.sched._next_analysis_is_blacklisted(self.test_plugin, self.fo) + assert blacklisted is True - def test_next_analysis_is_not_blacklisted__mime_missing(self): - self.sched.analysis_plugins[self.test_plugin] = self.PluginMock(blacklist=['test_type'], whitelist=['test_type']) - self.fo.processed_analysis['file_type'].pop('mime') - self.fo.scheduled_analysis = [] - self.fo.analysis_dependency = set() - not_blacklisted = self.sched._next_analysis_is_not_blacklisted(self.test_plugin, self.fo) - assert not_blacklisted is False - assert 'file_type' in self.fo.analysis_dependency - assert self.fo.scheduled_analysis == [self.test_plugin, 'file_type'] - - def test_start_or_skip_analysis(self): - self.sched.config.set('dummy_plugin_for_testing_only', 'mime_whitelist', 'foo, bar') - test_fw = Firmware(file_path=os.path.join(get_test_data_dir(), 'get_files_test/testfile1')) - test_fw.scheduled_analysis = ['file_hashes'] - test_fw.processed_analysis['file_type'] = {'mime': 'text/plain'} - self.sched._start_or_skip_analysis('dummy_plugin_for_testing_only', test_fw) - test_fw = self.tmp_queue.get(timeout=10) - assert 'dummy_plugin_for_testing_only' in test_fw.processed_analysis - assert 'skipped' in test_fw.processed_analysis['dummy_plugin_for_testing_only'] + def test_get_blacklist_file_type_from_database(self): + def add_file_type_mock(_, fo): + fo.processed_analysis['file_type'] = {'mime': 'foo_type'} + + file_object = MockFileObject() + file_object.processed_analysis.pop('file_type') + with mock_patch(self.sched, '_add_completed_analysis_results_to_file_object', add_file_type_mock): + result = self.sched._get_file_type_from_object_or_db(file_object) + assert result == 'foo_type' def _add_test_plugin_to_config(self): self.sched.config.add_section('test_plugin') self.sched.config.set('test_plugin', 'mime_blacklist', 'type1, type2') + + +class TestUtilityFunctions: + + class PluginMock: + def __init__(self, dependencies): + self.DEPENDENCIES = dependencies + + @classmethod + def setup_class(cls): + cls.init_patch = mock.patch(target='scheduler.Analysis.AnalysisScheduler.__init__', new=lambda *_: None) + cls.init_patch.start() + cls.scheduler = AnalysisScheduler() + cls.plugin_list = ['no_deps', 'foo', 'bar'] + cls.init_patch.stop() + + def _add_plugins(self): + self.scheduler.analysis_plugins = { + 'no_deps': self.PluginMock(dependencies=[]), + 'foo': self.PluginMock(dependencies=['no_deps']), + 'bar': self.PluginMock(dependencies=['no_deps', 'foo']) + } + + def _add_plugins_with_recursive_dependencies(self): + self.scheduler.analysis_plugins = { + 'p1': self.PluginMock(['p2', 'p3']), + 'p2': self.PluginMock(['p3']), + 'p3': self.PluginMock([]), + 'p4': self.PluginMock(['p5']), + 'p5': self.PluginMock(['p6']), + 'p6': self.PluginMock([]) + } + + @pytest.mark.parametrize('input_data, expected_output', [ + (set(), set()), + ({'p1'}, {'p2', 'p3'}), + ({'p3'}, set()), + ({'p1', 'p2', 'p3', 'p4'}, {'p5'}), + ]) + def test_get_cumulative_remaining_dependencies(self, input_data, expected_output): + self._add_plugins_with_recursive_dependencies() + result = self.scheduler._get_cumulative_remaining_dependencies(input_data) + assert result == expected_output + + @pytest.mark.parametrize('input_data, expected_output', [ + ([], set()), + (['p3'], {'p3'}), + (['p1'], {'p1', 'p2', 'p3'}), + (['p4'], {'p4', 'p5', 'p6'}), + ]) + def test_add_dependencies_recursively(self, input_data, expected_output): + self._add_plugins_with_recursive_dependencies() + result = self.scheduler._add_dependencies_recursively(input_data) + assert set(result) == expected_output + + @pytest.mark.parametrize('remaining, scheduled, expected_output', [ + ({}, [], []), + ({'no_deps', 'foo', 'bar'}, [], ['no_deps']), + ({'foo', 'bar'}, ['no_deps'], ['foo']), + ({'bar'}, ['no_deps', 'foo'], ['bar']), + ]) + def test_get_plugins_with_met_dependencies(self, remaining, scheduled, expected_output): + self._add_plugins() + assert self.scheduler._get_plugins_with_met_dependencies(remaining, scheduled) == expected_output + + @pytest.mark.parametrize('remaining, scheduled, expected_output', [ + ({'bar'}, ['no_deps', 'foo'], {'bar'}), + ({'foo', 'bar'}, ['no_deps', 'foo'], {'foo', 'bar'}), + ]) + def test_get_plugins_with_met_dependencies__completed_analyses(self, remaining, scheduled, expected_output): + self._add_plugins() + assert set(self.scheduler._get_plugins_with_met_dependencies(remaining, scheduled)) == expected_output + + def test_smart_shuffle(self): + self._add_plugins() + result = self.scheduler._smart_shuffle(self.plugin_list) + assert result == ['bar', 'foo', 'no_deps'] + + def test_smart_shuffle__impossible_dependency(self): + self._add_plugins() + self.scheduler.analysis_plugins['impossible'] = self.PluginMock(dependencies=['impossible to meet']) + result = self.scheduler._smart_shuffle(self.plugin_list + ['impossible']) + assert 'impossible' not in result + assert result == ['bar', 'foo', 'no_deps'] + + def test_smart_shuffle__circle_dependency(self): + self.scheduler.analysis_plugins = { + 'p1': self.PluginMock(['p2']), + 'p2': self.PluginMock(['p3']), + 'p3': self.PluginMock(['p1']), + } + result = self.scheduler._smart_shuffle(['p1', 'p2', 'p3']) + assert result == [] + + +class TestAnalysisSkipping: + + class PluginMock: + def __init__(self, version, system_version): + self.VERSION = version + if system_version: + self.SYSTEM_VERSION = system_version + + class BackendMock: + def __init__(self, analysis_entry=None): + self.analysis_entry = analysis_entry if analysis_entry else {} + + def get_specific_fields_of_db_entry(self, *_): + return self.analysis_entry + + @classmethod + def setup_class(cls): + cls.init_patch = mock.patch(target='scheduler.Analysis.AnalysisScheduler.__init__', new=lambda *_: None) + cls.init_patch.start() + + cls.scheduler = AnalysisScheduler() + cls.scheduler.analysis_plugins = {} + + cls.init_patch.stop() + + @pytest.mark.parametrize( + 'plugin_version, plugin_system_version, analysis_plugin_version, ' + 'analysis_system_version, expected_output', [ + ('1.0', None, '1.0', None, True), + ('1.1', None, '1.0', None, False), + ('1.0', None, '1.1', None, True), + ('1.0', '2.0', '1.0', '2.0', True), + ('1.0', '2.0', '1.0', '2.1', True), + ('1.0', '2.1', '1.0', '2.0', False), + ('1.0', '2.0', '1.0', None, False), + ] + ) + def test_analysis_is_already_in_db_and_up_to_date( + self, plugin_version, plugin_system_version, analysis_plugin_version, analysis_system_version, expected_output): + plugin = 'foo' + analysis_entry = {'processed_analysis': {plugin: { + 'plugin_version': analysis_plugin_version, 'system_version': analysis_system_version + }}} + self.scheduler.db_backend_service = self.BackendMock(analysis_entry) + self.scheduler.analysis_plugins[plugin] = self.PluginMock( + version=plugin_version, system_version=plugin_system_version) + assert self.scheduler._analysis_is_already_in_db_and_up_to_date(plugin, '') == expected_output + + def test_analysis_is_already_in_db_and_up_to_date__missing_version(self): + plugin = 'foo' + analysis_entry = {'processed_analysis': {plugin: {}}} + self.scheduler.db_backend_service = self.BackendMock(analysis_entry) + self.scheduler.analysis_plugins[plugin] = self.PluginMock(version='1.0', system_version='1.0') + assert self.scheduler._analysis_is_already_in_db_and_up_to_date(plugin, '') is False