├── multiav ├── web │ ├── contrib │ │ ├── __init__.py │ │ └── template.py │ ├── __init__.py │ ├── python23.py │ ├── test.py │ ├── wsgi.py │ ├── wsgiserver │ │ ├── ssl_builtin.py │ │ └── ssl_pyopenssl.py │ ├── webopenid.py │ ├── http.py │ ├── net.py │ ├── browser.py │ └── session.py ├── scripts │ ├── runserver.py │ ├── multiav-scan.py │ └── multiav-client.py ├── __init__.py ├── enumencoder.py ├── safeconfigparserextended.py ├── templates │ ├── error.html │ ├── search.html │ ├── about.html │ ├── system.html │ ├── update.html │ ├── search_results.html │ ├── index.html │ └── last.html ├── exceptions.py ├── threadedpromise.py ├── parallelpromise.py ├── multiactionpromise.py ├── postfile.py ├── static │ ├── multiav.js │ ├── bg.svg │ └── multiav.css ├── client.py ├── config.cfg └── promiseexecutorpool.py ├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── images │ ├── multiav-search.png │ ├── multiav-upload.png │ ├── multiav-scan-running.png │ ├── multiav-update-complete.png │ ├── multiav-autoscale-overview.png │ └── multiav-update-in-progress.png ├── usage.rst ├── installation.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── tests ├── __init__.py └── test_multiav.py ├── requirements.txt ├── HISTORY.rst ├── setup.cfg ├── AUTHORS.rst ├── tox.ini ├── MANIFEST.in ├── templates ├── error.html ├── about.html ├── template.html ├── search.html ├── results.html ├── update.html ├── last.html ├── scanners.html ├── search_results.html └── index.html ├── .editorconfig ├── .travis.yml ├── .gitignore ├── LICENSE ├── setup.py ├── Makefile ├── CONTRIBUTING.rst └── README.md /multiav/web/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/images/multiav-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jampe/MultiAV-Extended/HEAD/docs/images/multiav-search.png -------------------------------------------------------------------------------- /docs/images/multiav-upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jampe/MultiAV-Extended/HEAD/docs/images/multiav-upload.png -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Usage 3 | ======== 4 | 5 | To use MultiAV in a project:: 6 | 7 | import multiav 8 | -------------------------------------------------------------------------------- /docs/images/multiav-scan-running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jampe/MultiAV-Extended/HEAD/docs/images/multiav-scan-running.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bumpversion==0.5.3 2 | wheel==0.36.2 3 | web.py==0.62 4 | rwlock==0.0.7 5 | promise==2.3 6 | requests==2.25.1 7 | -------------------------------------------------------------------------------- /docs/images/multiav-update-complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jampe/MultiAV-Extended/HEAD/docs/images/multiav-update-complete.png -------------------------------------------------------------------------------- /docs/images/multiav-autoscale-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jampe/MultiAV-Extended/HEAD/docs/images/multiav-autoscale-overview.png -------------------------------------------------------------------------------- /docs/images/multiav-update-in-progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jampe/MultiAV-Extended/HEAD/docs/images/multiav-update-in-progress.png -------------------------------------------------------------------------------- /multiav/scripts/runserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from multiav.webapi import app 4 | 5 | if __name__ == "__main__": 6 | app.run() 7 | -------------------------------------------------------------------------------- /multiav/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'Joxean Koret' 4 | __email__ = 'admin@joxeankoret.com' 5 | __version__ = '0.1.0' 6 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 0.1.0 (2014-02-15) 7 | --------------------- 8 | 9 | * First release on PyPI. 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:multiav/__init__.py] 9 | 10 | [wheel] 11 | universal = 1 12 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Joxean Koret 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py26, py27, py33, py34 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir}:{toxinidir}/multiav 7 | commands = python setup.py test 8 | deps = 9 | -r{toxinidir}/requirements.txt 10 | -------------------------------------------------------------------------------- /multiav/enumencoder.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from enum import Enum 4 | 5 | class EnumEncoder(json.JSONEncoder): 6 | def default(self, obj): 7 | if isinstance(obj, Enum): 8 | return obj.value 9 | return json.JSONEncoder.default(self, obj) -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ easy_install multiav 8 | 9 | Or, if you have virtualenvwrapper installed:: 10 | 11 | $ mkvirtualenv multiav 12 | $ pip install multiav 13 | -------------------------------------------------------------------------------- /multiav/safeconfigparserextended.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | class SafeConfigParserExtended(configparser.SafeConfigParser): 4 | def gets(self, section, option, default): 5 | if self.has_option(section, option): 6 | return self.get(section, option) 7 | return default -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat 12 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | $def with(msg) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |

Error

10 | $msg 11 |
12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /multiav/templates/error.html: -------------------------------------------------------------------------------- 1 | $def with(msg) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |

Error

10 | $msg 11 |
12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | 5 | python: 6 | - "3.4" 7 | - "3.3" 8 | - "2.7" 9 | - "2.6" 10 | - "pypy" 11 | 12 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 13 | install: pip install -r requirements.txt 14 | 15 | # command to run tests, e.g. python setup.py test 16 | script: python setup.py test 17 | -------------------------------------------------------------------------------- /tests/test_multiav.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_multiav 6 | ---------------------------------- 7 | 8 | Tests for `multiav` module. 9 | """ 10 | 11 | import unittest 12 | 13 | from multiav import multiav 14 | 15 | 16 | class TestMultiav(unittest.TestCase): 17 | 18 | def setUp(self): 19 | pass 20 | 21 | def test_something(self): 22 | pass 23 | 24 | def tearDown(self): 25 | pass 26 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. multiav documentation master file, created by 2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to MultiAV's documentation! 7 | ====================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | readme 15 | installation 16 | usage 17 | contributing 18 | authors 19 | history 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | -------------------------------------------------------------------------------- /multiav/scripts/multiav-scan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from multiav.core import CMultiAV, AV_SPEED_ALL 4 | 5 | 6 | # ----------------------------------------------------------------------- 7 | def main(path): 8 | multi_av = CMultiAV() 9 | ret = multi_av.scan(path, AV_SPEED_ALL) 10 | 11 | import pprint 12 | pprint.pprint(ret) 13 | 14 | 15 | # ----------------------------------------------------------------------- 16 | def usage(): 17 | print "Usage:", sys.argv[0], "" 18 | 19 | if __name__ == "__main__": 20 | if len(sys.argv) == 1: 21 | usage() 22 | else: 23 | main(sys.argv[1]) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | htmlcov 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # Complexity 39 | output/*.html 40 | output/*/index.html 41 | 42 | # Sphinx 43 | docs/_build 44 | .vscode/ 45 | multiav/multiav.db.scans-d2 46 | multiav/multiav.db 47 | -------------------------------------------------------------------------------- /multiav/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | #----------------------------------------------------------------------- 3 | class PullPluginException(Exception): 4 | pass 5 | 6 | #----------------------------------------------------------------------- 7 | class StartPluginException(Exception): 8 | pass 9 | 10 | #----------------------------------------------------------------------- 11 | class CreateNetworkException(Exception): 12 | pass 13 | 14 | #----------------------------------------------------------------------- 15 | class CreateDockerMachineMachineException(Exception): 16 | pass 17 | 18 | #----------------------------------------------------------------------- 19 | class StopDockerMachineMachineException(Exception): 20 | pass -------------------------------------------------------------------------------- /multiav/web/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """web.py: makes web apps (http://webpy.org)""" 3 | 4 | from __future__ import generators 5 | 6 | __version__ = "0.37" 7 | __author__ = [ 8 | "Aaron Swartz ", 9 | "Anand Chitipothu " 10 | ] 11 | __license__ = "public domain" 12 | __contributors__ = "see http://webpy.org/changes" 13 | 14 | import utils, db, net, wsgi, http, webapi, httpserver, debugerror 15 | import template, form 16 | 17 | import session 18 | 19 | from utils import * 20 | from db import * 21 | from net import * 22 | from wsgi import * 23 | from http import * 24 | from webapi import * 25 | from httpserver import * 26 | from debugerror import * 27 | from application import * 28 | from browser import * 29 | try: 30 | import webopenid as openid 31 | except ImportError: 32 | pass # requires openid module 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MultiAV scanner wrapper version 0.0.1 2 | Copyright (c) 2014, Joxean Koret 3 | 4 | License: 5 | 6 | MultiAV is free software: you can redistribute it and/or modify it 7 | under the terms of the GNU Lesser Public License as published by the 8 | Free Software Foundation, either version 3 of the License, or (at your 9 | option) any later version. 10 | 11 | MultiAV is distributed in the hope that it will be useful, but 12 | WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU Lesser Public License for more details. 15 | 16 | You should have received a copy of the GNU Lesser Public License 17 | along with DoctestAll. If not, see 18 | . 19 | 20 | You should have received a copy of the GNU General Public License 21 | along with this program. If not, see 22 | 23 | -------------------------------------------------------------------------------- /templates/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |

MultiAV

8 |
9 |
10 |

Navigation

11 | 19 |
20 |
21 |

Simple multi-antivirus scanning engine's frontend.

22 | Created by Joxean Koret after discovering the price of OPSWAT Metascan and knowing the real work behind it.

23 |
24 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /templates/template.html: -------------------------------------------------------------------------------- 1 | $def with (results) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

Joxean Koret's MultiAV Enhanced

9 |
10 |
11 |

Navigation

12 | 17 |
18 |
19 | 20 | 34 |
21 |

Scan results

22 | 23 | 24 | 25 | 26 | 27 | $for result in results: 28 | 29 | 30 | 31 | 32 |
AntivirusMalware name
$result.av_name$result.malware_name
33 |
35 |
36 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /templates/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |

MultiAV

8 |
9 |
10 |

Navigation

11 | 19 |
20 |
21 | 22 | 31 |
23 |

Search

24 |

Enter the MD5, SHA1, SHA256 or a malware name to search for sample reports. You can use the usual '%' and '?' SQL wildcards. Use ',' to seperate querys.

25 |
26 | 27 |
28 | 29 |
30 |
32 |
33 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /multiav/threadedpromise.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from sys import exc_info 3 | 4 | from promise import Promise 5 | 6 | # starts the executor function non blocking in a seperate thread 7 | class ThreadedPromise(Promise): 8 | def __init__(self, executor=None, scheduler=None): 9 | Promise.__init__(self, executor, scheduler) 10 | self.thread = threading.Thread() 11 | 12 | def wait(self): 13 | try: 14 | self.join() 15 | except: 16 | pass 17 | 18 | def _resolve_from_executor(self, executor): 19 | # type: (Callable[[Callable[[T], None], Callable[[Exception], None]], None]) -> None 20 | # self._capture_stacktrace() 21 | synchronous = True 22 | 23 | def resolve(value): 24 | # type: (T) -> None 25 | self._resolve_callback(value) 26 | 27 | def reject(reason, traceback=None): 28 | # type: (Exception, TracebackType) -> None 29 | self._reject_callback(reason, synchronous, traceback) 30 | 31 | error = None 32 | traceback = None 33 | try: 34 | self.thread = threading.Thread(target=executor, args=(resolve, reject)) 35 | self.thread.start() 36 | except Exception as e: 37 | traceback = exc_info()[2] 38 | error = e 39 | 40 | synchronous = False 41 | 42 | if error is not None: 43 | self._reject_callback(error, True, traceback) -------------------------------------------------------------------------------- /multiav/web/python23.py: -------------------------------------------------------------------------------- 1 | """Python 2.3 compatabilty""" 2 | import threading 3 | 4 | class threadlocal(object): 5 | """Implementation of threading.local for python2.3. 6 | """ 7 | def __getattribute__(self, name): 8 | if name == "__dict__": 9 | return threadlocal._getd(self) 10 | else: 11 | try: 12 | return object.__getattribute__(self, name) 13 | except AttributeError: 14 | try: 15 | return self.__dict__[name] 16 | except KeyError: 17 | raise AttributeError, name 18 | 19 | def __setattr__(self, name, value): 20 | self.__dict__[name] = value 21 | 22 | def __delattr__(self, name): 23 | try: 24 | del self.__dict__[name] 25 | except KeyError: 26 | raise AttributeError, name 27 | 28 | def _getd(self): 29 | t = threading.currentThread() 30 | if not hasattr(t, '_d'): 31 | # using __dict__ of thread as thread local storage 32 | t._d = {} 33 | 34 | _id = id(self) 35 | # there could be multiple instances of threadlocal. 36 | # use id(self) as key 37 | if _id not in t._d: 38 | t._d[_id] = {} 39 | return t._d[_id] 40 | 41 | if __name__ == '__main__': 42 | d = threadlocal() 43 | d.x = 1 44 | print d.__dict__ 45 | print d.x 46 | -------------------------------------------------------------------------------- /multiav/web/test.py: -------------------------------------------------------------------------------- 1 | """test utilities 2 | (part of web.py) 3 | """ 4 | import unittest 5 | import sys, os 6 | import web 7 | 8 | TestCase = unittest.TestCase 9 | TestSuite = unittest.TestSuite 10 | 11 | def load_modules(names): 12 | return [__import__(name, None, None, "x") for name in names] 13 | 14 | def module_suite(module, classnames=None): 15 | """Makes a suite from a module.""" 16 | if classnames: 17 | return unittest.TestLoader().loadTestsFromNames(classnames, module) 18 | elif hasattr(module, 'suite'): 19 | return module.suite() 20 | else: 21 | return unittest.TestLoader().loadTestsFromModule(module) 22 | 23 | def doctest_suite(module_names): 24 | """Makes a test suite from doctests.""" 25 | import doctest 26 | suite = TestSuite() 27 | for mod in load_modules(module_names): 28 | suite.addTest(doctest.DocTestSuite(mod)) 29 | return suite 30 | 31 | def suite(module_names): 32 | """Creates a suite from multiple modules.""" 33 | suite = TestSuite() 34 | for mod in load_modules(module_names): 35 | suite.addTest(module_suite(mod)) 36 | return suite 37 | 38 | def runTests(suite): 39 | runner = unittest.TextTestRunner() 40 | return runner.run(suite) 41 | 42 | def main(suite=None): 43 | if not suite: 44 | main_module = __import__('__main__') 45 | # allow command line switches 46 | args = [a for a in sys.argv[1:] if not a.startswith('-')] 47 | suite = module_suite(main_module, args or None) 48 | 49 | result = runTests(suite) 50 | sys.exit(not result.wasSuccessful()) 51 | 52 | -------------------------------------------------------------------------------- /templates/results.html: -------------------------------------------------------------------------------- 1 | $def with (results, filename, hashes) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |
10 |
11 |

Navigation

12 | 20 |
21 |
22 | Actions: Export result as csv 23 | 24 | 52 |
25 |

Scan results for $filename

26 |
27 | MD5:$hashes[0]
28 | SHA1:$hashes[1]
29 | SHA256:$hashes[2]
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | $for result in results: 39 | 40 | 41 | 42 | 43 | 49 | 50 |
AntivirusBinary versionEngine/data versionMalware name
$result$results[result]['scanner_binary_version']$results[result]['scanner_engine_data_version'] 44 | $if len(results[result]['result'])!= 0: 45 | $:"
".join(results[result]['result'].values()) 46 | $else: 47 | - 48 |
51 |
53 |
54 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /multiav/parallelpromise.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | 4 | from sys import exc_info 5 | from threading import Thread, Event 6 | from promise import Promise 7 | 8 | # These are the potential states of a promise 9 | STATE_PENDING = -1 10 | STATE_REJECTED = 0 11 | STATE_FULFILLED = 1 12 | 13 | 14 | # starts the executor function non blocking in a seperate thread 15 | class ParallelPromise(Promise): 16 | def __init__(self, executor=None, scheduler=None): 17 | Promise.__init__(self, executor, scheduler) 18 | 19 | def wait(self, timeout=None): 20 | e = Event() 21 | 22 | def on_resolve_or_reject(_): 23 | e.set() 24 | 25 | self._then(on_resolve_or_reject, on_resolve_or_reject) 26 | waited = e.wait(timeout) 27 | if not waited: 28 | raise Exception("Timeout") 29 | 30 | def _resolve_from_executor(self, executor): 31 | # type: (Callable[[Callable[[T], None], Callable[[Exception], None]], None]) -> None 32 | # self._capture_stacktrace() 33 | synchronous = True 34 | 35 | def resolve(value): 36 | # type: (T) -> None 37 | self._resolve_callback(value) 38 | 39 | def reject(reason, traceback=None): 40 | # type: (Exception, TracebackType) -> None 41 | self._reject_callback(reason, synchronous, traceback) 42 | 43 | error = None 44 | traceback = None 45 | try: 46 | self.thread = Thread(target=executor, args=(resolve, reject)) 47 | self.thread.daemon = True 48 | self.thread.start() 49 | except Exception as e: 50 | traceback = exc_info()[2] 51 | error = e 52 | 53 | synchronous = False 54 | 55 | if error is not None: 56 | self._reject_callback(error, True, traceback) -------------------------------------------------------------------------------- /multiav/multiactionpromise.py: -------------------------------------------------------------------------------- 1 | from promise import Promise 2 | 3 | #----------------------------------------------------------------------- 4 | class MultiActionPromise(Promise): 5 | def __init__(self, engine_promises=None): 6 | Promise.__init__(self) 7 | 8 | self._engine_promises = dict() 9 | self._engine_name_lookup = dict() 10 | if engine_promises is not None: 11 | for engine, engine_promise in engine_promises.items(): 12 | engine_promise.then(self._did_all_engine_promises_run, self._did_all_engine_promises_run) 13 | self._engine_promises[engine] = engine_promise 14 | self._engine_name_lookup[engine.container_name] = engine_promise 15 | 16 | def _did_all_engine_promises_run(self, res): 17 | not_pending = True 18 | failed_promises = [] 19 | for engine, engine_promise in self._engine_promises.items(): 20 | not_pending &= engine_promise._state != -1 21 | if engine_promise._state == 0: 22 | failed_promises.append(engine.name) 23 | 24 | if not_pending: 25 | if len(failed_promises) == 0: 26 | self.do_resolve("All done") 27 | else: 28 | self.do_reject(Exception("Failed: " + ", ".join(failed_promises))) 29 | 30 | def get_engine_promise(self, engine): 31 | if isinstance(engine, str): 32 | return self._engine_name_lookup[engine] 33 | else: 34 | return self._engine_name_lookup[engine.name] 35 | 36 | def engine_then(self, did_fulfill=None, did_reject=None): 37 | #print(self._engine_promises) 38 | for engine, engine_promise in self._engine_promises.items(): 39 | engine_promise.then(did_fulfill, did_reject) 40 | 41 | return self 42 | 43 | def get_scanning_engines(self): 44 | return list(self._engine_promises) -------------------------------------------------------------------------------- /multiav/templates/search.html: -------------------------------------------------------------------------------- 1 | $def with (search_error) 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |
10 | MultiAV 11 |
12 | 19 | 25 |
26 |
27 |
28 |
29 | 36 | $if search_error != None: 37 |
38 |

Search error: $search_error

39 |
40 |
41 |

Hint #1: You can search for the MD5, SHA1, SHA256 hash or the filename.

42 |

Hint #2: You can use the usual '%' and '?' SQL wildcards.

43 |

Hint #3: Use ',' to seperate querys and execute multiple searches at once!.

44 |
45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | with open('README.md') as readme_file: 12 | readme = readme_file.read() 13 | 14 | with open('HISTORY.rst') as history_file: 15 | history = history_file.read().replace('.. :changelog:', '') 16 | 17 | requirements = [ 18 | 'web.py==0.40-dev1', 19 | 'rwlock==0.0.7', 20 | 'promise==2.2.1', 21 | 'requests==2.18.4', 22 | ] 23 | 24 | test_requirements = [ 25 | # TODO: put package test requirements here 26 | ] 27 | 28 | setup( 29 | name='multiav', 30 | version='0.1.0', 31 | description="MultiAV scanner with Python and JSON API", 32 | long_description=readme + '\n\n' + history, 33 | author="Joxean Koret", 34 | author_email='admin@joxeankoret.com', 35 | url='https://github.com/joxeankoret/multiav', 36 | packages=[ 37 | 'multiav', 38 | ], 39 | package_dir={'multiav': 40 | 'multiav'}, 41 | include_package_data=True, 42 | install_requires=requirements, 43 | license="ISCL", 44 | zip_safe=False, 45 | keywords='multiav', 46 | classifiers=[ 47 | 'Development Status :: 2 - Pre-Alpha', 48 | 'Intended Audience :: Developers', 49 | 'License :: OSI Approved :: ISC License (ISCL)', 50 | 'Natural Language :: English', 51 | "Programming Language :: Python :: 2", 52 | 'Programming Language :: Python :: 2.6', 53 | 'Programming Language :: Python :: 2.7', 54 | 'Programming Language :: Python :: 3', 55 | 'Programming Language :: Python :: 3.3', 56 | 'Programming Language :: Python :: 3.4', 57 | ], 58 | test_suite='tests', 59 | tests_require=test_requirements, 60 | scripts=[ 61 | 'multiav/scripts/multiav-scan.py', 62 | 'multiav/scripts/multiav-client.py', 63 | 'multiav/scripts/runserver.py'] 64 | ) 65 | -------------------------------------------------------------------------------- /multiav/postfile.py: -------------------------------------------------------------------------------- 1 | import httplib 2 | import mimetypes 3 | 4 | 5 | def post_multipart(host, selector, fields, files): 6 | """ 7 | Post fields and files to an http host as multipart/form-data. 8 | fields is a sequence of (name, value) elements for regular form fields. 9 | files is a sequence of (name, filename, value) elements for data to be uploaded as files 10 | Return the server's response page. 11 | """ 12 | content_type, body = encode_multipart_formdata(fields, files) 13 | h = httplib.HTTP(host) 14 | h.putrequest('POST', selector) 15 | h.putheader('content-type', content_type) 16 | h.putheader('content-length', str(len(body))) 17 | h.endheaders() 18 | h.send(body) 19 | errcode, errmsg, headers = h.getreply() 20 | return h.file.read() 21 | 22 | 23 | def encode_multipart_formdata(fields, files): 24 | """ 25 | fields is a sequence of (name, value) elements for regular form fields. 26 | files is a sequence of (name, filename, value) elements for data to be uploaded as files 27 | Return (content_type, body) ready for httplib.HTTP instance 28 | """ 29 | BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$' 30 | CRLF = '\r\n' 31 | L = [] 32 | for (key, value) in fields: 33 | L.append('--' + BOUNDARY) 34 | L.append('Content-Disposition: form-data; name="%s"' % key) 35 | L.append('') 36 | L.append(value) 37 | for (key, filename, value) in files: 38 | L.append('--' + BOUNDARY) 39 | L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename)) 40 | L.append('Content-Type: %s' % get_content_type(filename)) 41 | L.append('') 42 | L.append(value) 43 | L.append('--' + BOUNDARY + '--') 44 | L.append('') 45 | body = CRLF.join(L) 46 | content_type = 'multipart/form-data; boundary=%s' % BOUNDARY 47 | return content_type, body 48 | 49 | 50 | def get_content_type(filename): 51 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 52 | -------------------------------------------------------------------------------- /templates/update.html: -------------------------------------------------------------------------------- 1 | $def with (scanservers) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |
10 |
11 |

Navigation

12 | 20 |
21 |
22 | 23 | $if len(scanservers) > 0: 24 | $for server in scanservers: 25 | 26 | 49 | 50 | $else: 51 | 52 | 53 | 54 |
27 |

$server

28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | $for scanner in scanservers[server]: 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
AntivirusOld Binary VersionNew Binary VersionOld Engine Data VersionNew Engine Data VersionUpdated
$scanner$scanservers[server][scanner]['old_binary_version']$scanservers[server][scanner]['new_binary_version']$scanservers[server][scanner]['old_engine_data_version']$scanservers[server][scanner]['new_engine_data_version']$scanservers[server][scanner]['status']
47 |
48 |
-
55 |
56 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /multiav/static/multiav.js: -------------------------------------------------------------------------------- 1 | // code orginally from: https://www.w3schools.com/w3css/w3css_tabulators.asp 2 | // modified by danieljampen 3 | function openTab(evt, tabName) { 4 | var i, x, tablinks; 5 | 6 | var container = evt.currentTarget.parentNode; 7 | while(container.tagName != "TABLE") { container = container.parentNode; } 8 | 9 | x = container.getElementsByClassName("tab"); 10 | for (i = 0; i < x.length; i++) { 11 | x[i].style.display = "none"; 12 | } 13 | tablinks = container.getElementsByClassName("tablink"); 14 | for (i = 0; i < tablinks.length; i++) { 15 | tablinks[i].className = tablinks[i].className.replace("active", ""); 16 | } 17 | container.getElementsByClassName(tabName)[0].style.display = "block"; 18 | evt.currentTarget.className += " active"; 19 | } 20 | 21 | function collapse(e) { 22 | var classnames = e.className.split(" "); 23 | delete classnames[classnames.length -1] 24 | childs = document.getElementsByClassName(classnames.join(" ")); 25 | 26 | displayStyle = ""; 27 | if(childs[1].style.display == "none") { 28 | displayStyle = ""; 29 | } else { 30 | displayStyle = "none"; 31 | } 32 | 33 | for(var i in childs) { 34 | if(childs[i] != e) { 35 | childs[i].style.display = displayStyle; 36 | } 37 | } 38 | } 39 | 40 | document.addEventListener('DOMContentLoaded', function() { 41 | document.querySelector(".toggle_checkbox").onclick = function(e){ 42 | if(e.target.localName != "input") { 43 | let c = document.querySelector(".toggle_checkbox input[type=checkbox]"); 44 | c.checked = !c.checked; 45 | } 46 | }; 47 | 48 | if(document.getElementById("refreshtoggle") != null) 49 | { 50 | if(typeof enableRefresh !== 'undefined') { 51 | document.getElementById("refreshtoggle").checked = enableRefresh; 52 | } 53 | 54 | setInterval(function () { 55 | if (document.getElementById("refreshtoggle").checked) 56 | window.location.href = window.location.href; 57 | }, 5000); 58 | } 59 | }); -------------------------------------------------------------------------------- /templates/last.html: -------------------------------------------------------------------------------- 1 | $def with (rows, backpage, page, nextpage) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |
10 |
11 |

Navigation

12 | 20 |
21 |
22 | Actions: Export shown results as csv Export all stored results as csv 23 |
24 |
25 | Page Navigation: << Back Next >> 26 |
27 | 28 | $for results in rows: 29 | 58 | 59 |
30 |

Scan results for '$results[0]'

31 | Date:$results[5]
32 | MD5:$results[2]
33 | SHA1:$results[3]
34 | SHA256:$results[4]
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | $for result in results[1]: 44 | 45 | 46 | 47 | 48 | 54 | 55 |
AntivirusBinary versionEngine/data versionMalware name
$result$results[1][result]['scanner_binary_version']$results[1][result]['scanner_engine_data_version'] 49 | $if len(results[1][result]['result']) > 0: 50 | $:"
".join(results[1][result]['result'].values()) 51 | $else: 52 | - 53 |
56 |
57 |
60 |
61 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /templates/scanners.html: -------------------------------------------------------------------------------- 1 | $def with (scanservers) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |
10 |
11 |

Navigation

12 | 20 |
21 |
22 |

Scanners

23 |
24 | 25 |
26 |
27 | 28 |
29 | 30 | 31 | $if len(scanservers) > 0: 32 | $for server in scanservers: 33 | 34 | 57 | 58 | $else: 59 | 60 | 61 | 62 |
35 |

Server: $server

36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | $for scanner in scanservers[server]: 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
AntivirusBinary versionEngine/data versionActiveLast scanner updateLast information refresh
$scanner.name$scanner.binary_version$scanner.engine_data_version$scanner.active$scanner.last_update_date$scanner.last_refresh_date
55 |
56 |
No data
63 |
64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean 2 | define BROWSER_PYSCRIPT 3 | import os, webbrowser, sys 4 | try: 5 | from urllib import pathname2url 6 | except: 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 13 | 14 | help: 15 | @echo "clean - remove all build, test, coverage and Python artifacts" 16 | @echo "clean-build - remove build artifacts" 17 | @echo "clean-pyc - remove Python file artifacts" 18 | @echo "clean-test - remove test and coverage artifacts" 19 | @echo "lint - check style with flake8" 20 | @echo "test - run tests quickly with the default Python" 21 | @echo "test-all - run tests on every Python version with tox" 22 | @echo "coverage - check code coverage quickly with the default Python" 23 | @echo "docs - generate Sphinx HTML documentation, including API docs" 24 | @echo "release - package and upload a release" 25 | @echo "dist - package" 26 | @echo "install - install the package to the active Python's site-packages" 27 | 28 | clean: clean-build clean-pyc clean-test 29 | 30 | clean-build: 31 | rm -fr build/ 32 | rm -fr dist/ 33 | rm -fr .eggs/ 34 | find . -name '*.egg-info' -exec rm -fr {} + 35 | find . -name '*.egg' -exec rm -f {} + 36 | 37 | clean-pyc: 38 | find . -name '*.pyc' -exec rm -f {} + 39 | find . -name '*.pyo' -exec rm -f {} + 40 | find . -name '*~' -exec rm -f {} + 41 | find . -name '__pycache__' -exec rm -fr {} + 42 | 43 | clean-test: 44 | rm -fr .tox/ 45 | rm -f .coverage 46 | rm -fr htmlcov/ 47 | 48 | lint: 49 | flake8 multiav tests 50 | 51 | test: 52 | python setup.py test 53 | 54 | test-all: 55 | tox 56 | 57 | coverage: 58 | coverage run --source multiav setup.py test 59 | coverage report -m 60 | coverage html 61 | $(BROWSER) htmlcov/index.html 62 | 63 | docs: 64 | rm -f docs/multiav.rst 65 | rm -f docs/modules.rst 66 | sphinx-apidoc -o docs/ multiav 67 | $(MAKE) -C docs clean 68 | $(MAKE) -C docs html 69 | $(BROWSER) docs/_build/html/index.html 70 | 71 | release: clean 72 | python setup.py sdist upload 73 | python setup.py bdist_wheel upload 74 | 75 | dist: clean 76 | python setup.py sdist 77 | python setup.py bdist_wheel 78 | ls -l dist 79 | 80 | install: clean 81 | python setup.py install 82 | -------------------------------------------------------------------------------- /multiav/templates/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 |
8 |
9 | MultiAV 10 |
11 | 18 | 24 |
25 |
26 |
27 |
28 |

About, Credits and History

29 |

MultiAV: Simple multi-antivirus scanning engine's frontend.

30 | Created by Joxean Koret after discovering the price of OPSWAT Metascan and knowing the real work behind it.
31 | Original Source @ Github: github.com/joxeankoret/multiav
32 |
33 |
34 |

Malice: VirusTotal Wanna Be - Now with 100% more Hipster

35 | Created by blacktop. Malice's mission is to be a free open source version of VirusTotal that anyone can use at any scale from an independent researcher to a fortune 500 company.
36 | Original Source @ Github: github.com/maliceio/malice
37 | AV Docker Containers @ Github: github.com/malice-plugins
38 |
39 |
40 |

MultiAV: Extended MultiAV fronted and scans using malice plugins.

41 | Created by jampe. Combine the awesome approaches of blacktop's av docker plugins with MultiAVs simplicity and pythonness!
42 | Source @ Github: github.com/danieljampen/multiav 43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /multiav/web/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI Utilities 3 | (from web.py) 4 | """ 5 | 6 | import os, sys 7 | 8 | import http 9 | import webapi as web 10 | from utils import listget 11 | from net import validaddr, validip 12 | import httpserver 13 | 14 | def runfcgi(func, addr=('localhost', 8000)): 15 | """Runs a WSGI function as a FastCGI server.""" 16 | import flup.server.fcgi as flups 17 | return flups.WSGIServer(func, multiplexed=True, bindAddress=addr, debug=False).run() 18 | 19 | def runscgi(func, addr=('localhost', 4000)): 20 | """Runs a WSGI function as an SCGI server.""" 21 | import flup.server.scgi as flups 22 | return flups.WSGIServer(func, bindAddress=addr, debug=False).run() 23 | 24 | def runwsgi(func): 25 | """ 26 | Runs a WSGI-compatible `func` using FCGI, SCGI, or a simple web server, 27 | as appropriate based on context and `sys.argv`. 28 | """ 29 | 30 | if os.environ.has_key('SERVER_SOFTWARE'): # cgi 31 | os.environ['FCGI_FORCE_CGI'] = 'Y' 32 | 33 | if (os.environ.has_key('PHP_FCGI_CHILDREN') #lighttpd fastcgi 34 | or os.environ.has_key('SERVER_SOFTWARE')): 35 | return runfcgi(func, None) 36 | 37 | if 'fcgi' in sys.argv or 'fastcgi' in sys.argv: 38 | args = sys.argv[1:] 39 | if 'fastcgi' in args: args.remove('fastcgi') 40 | elif 'fcgi' in args: args.remove('fcgi') 41 | if args: 42 | return runfcgi(func, validaddr(args[0])) 43 | else: 44 | return runfcgi(func, None) 45 | 46 | if 'scgi' in sys.argv: 47 | args = sys.argv[1:] 48 | args.remove('scgi') 49 | if args: 50 | return runscgi(func, validaddr(args[0])) 51 | else: 52 | return runscgi(func) 53 | 54 | return httpserver.runsimple(func, validip(listget(sys.argv, 1, ''))) 55 | 56 | def _is_dev_mode(): 57 | # Some embedded python interpreters won't have sys.arv 58 | # For details, see https://github.com/webpy/webpy/issues/87 59 | argv = getattr(sys, "argv", []) 60 | 61 | # quick hack to check if the program is running in dev mode. 62 | if os.environ.has_key('SERVER_SOFTWARE') \ 63 | or os.environ.has_key('PHP_FCGI_CHILDREN') \ 64 | or 'fcgi' in argv or 'fastcgi' in argv \ 65 | or 'mod_wsgi' in argv: 66 | return False 67 | return True 68 | 69 | # When running the builtin-server, enable debug mode if not already set. 70 | web.config.setdefault('debug', _is_dev_mode()) 71 | -------------------------------------------------------------------------------- /multiav/static/bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Layer 1 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /multiav/web/wsgiserver/ssl_builtin.py: -------------------------------------------------------------------------------- 1 | """A library for integrating Python's builtin ``ssl`` library with CherryPy. 2 | 3 | The ssl module must be importable for SSL functionality. 4 | 5 | To use this module, set ``CherryPyWSGIServer.ssl_adapter`` to an instance of 6 | ``BuiltinSSLAdapter``. 7 | """ 8 | 9 | try: 10 | import ssl 11 | except ImportError: 12 | ssl = None 13 | 14 | from cherrypy import wsgiserver 15 | 16 | 17 | class BuiltinSSLAdapter(wsgiserver.SSLAdapter): 18 | """A wrapper for integrating Python's builtin ssl module with CherryPy.""" 19 | 20 | certificate = None 21 | """The filename of the server SSL certificate.""" 22 | 23 | private_key = None 24 | """The filename of the server's private key file.""" 25 | 26 | def __init__(self, certificate, private_key, certificate_chain=None): 27 | if ssl is None: 28 | raise ImportError("You must install the ssl module to use HTTPS.") 29 | self.certificate = certificate 30 | self.private_key = private_key 31 | self.certificate_chain = certificate_chain 32 | 33 | def bind(self, sock): 34 | """Wrap and return the given socket.""" 35 | return sock 36 | 37 | def wrap(self, sock): 38 | """Wrap and return the given socket, plus WSGI environ entries.""" 39 | try: 40 | s = ssl.wrap_socket(sock, do_handshake_on_connect=True, 41 | server_side=True, certfile=self.certificate, 42 | keyfile=self.private_key, ssl_version=ssl.PROTOCOL_SSLv23) 43 | except ssl.SSLError, e: 44 | if e.errno == ssl.SSL_ERROR_EOF: 45 | # This is almost certainly due to the cherrypy engine 46 | # 'pinging' the socket to assert it's connectable; 47 | # the 'ping' isn't SSL. 48 | return None, {} 49 | elif e.errno == ssl.SSL_ERROR_SSL: 50 | if e.args[1].endswith('http request'): 51 | # The client is speaking HTTP to an HTTPS server. 52 | raise wsgiserver.NoSSLError 53 | raise 54 | return s, self.get_environ(s) 55 | 56 | # TODO: fill this out more with mod ssl env 57 | def get_environ(self, sock): 58 | """Create WSGI environ entries to be merged into each request.""" 59 | cipher = sock.cipher() 60 | ssl_environ = { 61 | "wsgi.url_scheme": "https", 62 | "HTTPS": "on", 63 | 'SSL_PROTOCOL': cipher[1], 64 | 'SSL_CIPHER': cipher[0] 65 | ## SSL_VERSION_INTERFACE string The mod_ssl program version 66 | ## SSL_VERSION_LIBRARY string The OpenSSL program version 67 | } 68 | return ssl_environ 69 | 70 | def makefile(self, sock, mode='r', bufsize=-1): 71 | return wsgiserver.CP_fileobject(sock, mode, bufsize) 72 | 73 | -------------------------------------------------------------------------------- /templates/search_results.html: -------------------------------------------------------------------------------- 1 | $def with (rows, query) 2 | 3 | 4 | 5 | 6 | 7 |
8 |

MultiAV

9 |
10 |
11 |

Navigation

12 | 20 |
21 |
22 | Actions: Export results as csv 23 | 24 | $for results in rows: 25 | 72 | 73 |
26 |

Scan results for '$results[0]'

27 | Date:$results[6]
28 | Filename:$results[0]
29 | MD5:$results[3]
30 | SHA1:$results[4]
31 | SHA256:$results[5]
32 |

Metadata

33 | $for metadataplugin in results[2].keys(): 34 | $metadataplugin
35 | $for key in results[2][metadataplugin].keys(): 36 | $if type(results[2][metadataplugin][key]) is dict: 37 | $for subkey in results[2][metadataplugin][key].keys(): 38 |     $subkey:$results[2][metadataplugin][key][subkey]
39 | $elif type(results[2][metadataplugin][key]) is list: 40 |   $key
41 | $for item in results[2][metadataplugin][key]: 42 |     $item
43 | $elif key != "plugin_type": 44 |   $key:$results[2][metadataplugin][key]
45 |
46 |

Anti Virus Results

47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | $for result in results[1]: 55 | 56 | 57 | 66 | 67 | 68 | 69 |
NameResultEngineUpdated
$result 58 | $if "result" in results[1][result].keys() and results[1][result]['result'] != "": 59 | $if type(results[1][result]['result']) is list: 60 | $:"
".join(results[1][result]['result'].values()) 61 | $else: 62 | $results[1][result]['result'] 63 | $else: 64 | Clean 65 |
$results[1][result]['engine']$results[1][result]['updated']
70 |
71 |
74 |
75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /multiav/templates/system.html: -------------------------------------------------------------------------------- 1 | $def with (statistics) 2 | 3 | $def result_data_table_row(key, value, level, classes): 4 | $ padding_style = "padding-left: {0}px".format(level * 10) 5 | $ classes = classes + [key] 6 | $ classes_string = " ".join(classes) 7 | 8 | $if type(value) is dict: 9 | $if not "--" in key: 10 | 11 | 12 | $key 13 |