malsearch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
malsearch/VERSION.txt ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
malsearch/__info__.py ADDED
@@ -0,0 +1,15 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """MalSearch package information.
3
+
4
+ """
5
+ import os
6
+
7
+ __author__ = "Alexandre D'Hondt"
8
+ __copyright__ = "© 2024 A. D'Hondt"
9
+ __email__ = "alexandre.dhondt@gmail.com"
10
+ __license__ = "GPLv3 (https://www.gnu.org/licenses/gpl-3.0.fr.html)"
11
+ __source__ = "https://github.com/packing-box/malsearch"
12
+
13
+ with open(os.path.join(os.path.dirname(__file__), "VERSION.txt")) as f:
14
+ __version__ = f.read().strip()
15
+
malsearch/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .clients import *
3
+ from .clients import __all__ as _clients
4
+
5
+
6
+ __all__ = ["download_sample", "download_samples"] + _clients
7
+
8
+ _CLIENTS_MAP = {n.lower(): globals()[n] for n in _clients}
9
+
10
+
11
+ def _valid_conf(path):
12
+ from configparser import ConfigParser
13
+ from os.path import exists, expanduser
14
+ path = expanduser(path)
15
+ if not exists(path):
16
+ raise ValueError("configuration file does not exist")
17
+ conf = ConfigParser()
18
+ try:
19
+ conf.read(path)
20
+ conf.path = path
21
+ except:
22
+ raise ValueError("invalid configuration file")
23
+ return conf
24
+
25
+
26
+ def download_sample(hash, config=None, **kwargs):
27
+ import logging
28
+ logger = logging.getLogger("malsearch")
29
+ if config is None:
30
+ logger.error("no configuration file provided")
31
+ logger.info(f"you can create one at {config} manually (INI format with section 'API keys')")
32
+ else:
33
+ import datetime as dt
34
+ from os.path import exists, join
35
+ p = join(kwargs.get('output_dir', "."), hash)
36
+ if exists(p) and not kwargs.get('overwrite'):
37
+ logger.info(f"'{p}' already exists")
38
+ return
39
+ if isinstance(config, str):
40
+ config = _valid_conf(config)
41
+ clients = []
42
+ for n in config['API keys']:
43
+ if n in (kwargs.get('skip') or []):
44
+ logger.debug(f"{n} skipped")
45
+ continue
46
+ if config.has_section("Disabled"):
47
+ t = config['Disabled'].get(n)
48
+ if t is not None:
49
+ try:
50
+ if dt.datetime.strptime(t, "%d/%m/%Y %H:%M:%S") < dt.datetime.now():
51
+ from contextlib import nullcontext
52
+ with kwargs.get('lock') or nullcontext():
53
+ config['Disabled'].pop(n)
54
+ with open(config.path, 'w') as f:
55
+ config.write(f)
56
+ else:
57
+ logger.warning(f"{n} is disabled until {t}")
58
+ continue
59
+ except ValueError:
60
+ logger.warning(f"{n} is disabled")
61
+ continue
62
+ clients.append(n)
63
+ if len(clients) == 0:
64
+ logger.warning("no download client available/enabled")
65
+ logger.debug(f"clients: {', '.join(clients)}")
66
+ for n in clients:
67
+ logger.debug(f"trying {n}...")
68
+ cls = _CLIENTS_MAP[n]
69
+ if cls.__base__.__name__ == "API":
70
+ kwargs['api_key'] = config['API keys'].get(n)
71
+ client = cls(config=config, **kwargs)
72
+ try:
73
+ client.get_file_by_hash(hash)
74
+ if hasattr(client, "content") and client.content is not None and len(client.content) > 0:
75
+ logger.debug("found sample !")
76
+ return
77
+ except ValueError as e:
78
+ logger.debug(e)
79
+ except Exception as e:
80
+ logger.exception(e)
81
+ logger.warning(f"could not find the sample with hash {hash}")
82
+
83
+
84
+ def download_samples(*hashes, max_workers=5, **kwargs):
85
+ from concurrent.futures import ThreadPoolExecutor as Pool
86
+ from threading import Lock
87
+ kwargs['lock'] = Lock()
88
+ with Pool(max_workers=max_workers) as executor:
89
+ for h in hashes:
90
+ executor.submit(download_sample, h.lower(), **kwargs)
malsearch/__main__.py ADDED
@@ -0,0 +1,60 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__info__ import __author__, __copyright__, __email__, __license__, __source__, __version__
3
+
4
+
5
+ def _parser(name, description, examples):
6
+ from argparse import ArgumentParser, RawTextHelpFormatter
7
+ descr = f"{name} {__version__}\n\nAuthor : {__author__} ({__email__})\nCopyright: {__copyright__}\nLicense :" \
8
+ f" {__license__}\nSource : {__source__}\n\n{description}.\n\n"
9
+ return ArgumentParser(description=descr, formatter_class=RawTextHelpFormatter, add_help=False,
10
+ epilog="usage examples:\n- " + "\n- ".join(examples) if len(examples) > 0 else None)
11
+
12
+
13
+ def _setup(parser):
14
+ args = parser.parse_args()
15
+ if hasattr(args, "verbose"):
16
+ import logging
17
+ logging.basicConfig(level=[logging.INFO, logging.DEBUG][args.verbose])
18
+ if not args.disable_cache:
19
+ from requests_cache import install_cache
20
+ install_cache("malsearch",
21
+ use_cache_dir=True, # Save files in the default user cache dir
22
+ cache_control=True, # Use Cache-Control response headers for expiration, if available
23
+ expire_after=86400, # Otherwise expire responses after one day
24
+ allowable_codes=[404], # Cache 400 responses as a solemn reminder of your failures
25
+ allowable_methods=['GET', 'POST'], # Cache whatever HTTP methods you want
26
+ ignored_parameters=['api_key'], # Don't match this request param, and redact if from the cache
27
+ match_headers=['Accept-Language'], # Cache a different response per language
28
+ stale_if_error=True, # In case of request errors, use stale cache data if possible
29
+ )
30
+ return args
31
+
32
+
33
+ def main():
34
+ from os import makedirs
35
+ from .__init__ import _valid_conf, download_samples
36
+ from .clients.__common__ import _valid_hash
37
+ parser = _parser("MalSearch", "This tool is aimed to search for malware samples across some public databases", [])
38
+ parser.add_argument("sample_hash", type=_valid_hash, nargs="*", help="input hash")
39
+ parser.add_argument("-f", "--from-file", help="get hashes from the target file (newline-separated list)")
40
+ opt = parser.add_argument_group("optional arguments")
41
+ opt.add_argument("-c", "--config", default="~/.malsearch.conf", type=_valid_conf, help="INI configuration file")
42
+ opt.add_argument("-o", "--output-dir", default=".", help="output directory for downloaded samples")
43
+ opt.add_argument("-s", "--skip", nargs="*", help="skip the specified clients while downloading samples")
44
+ opt.add_argument("-u", "--unpacked", action="store_true",
45
+ help="if available and target sample is packed, download unpacked version too")
46
+ opt.add_argument("--disable-cache", action="store_true", help="disable requests cache")
47
+ extra = parser.add_argument_group("extra arguments")
48
+ extra.add_argument("-h", "--help", action="help", help="show this help message and exit")
49
+ extra.add_argument("-v", "--verbose", action="store_true", help="display debug information (default: False)")
50
+ args = _setup(parser)
51
+ if args.from_file is not None:
52
+ with open(args.from_file) as f:
53
+ for h in f.readlines():
54
+ args.sample_hash.append(_valid_hash(h.strip()))
55
+ makedirs(args.output_dir, exist_ok=True)
56
+ if len(args.sample_hash) > 0:
57
+ download_samples(*args.sample_hash, **vars(args))
58
+ else:
59
+ import logging
60
+ logging.getLogger("malsearch").warning("nothing to download")
@@ -0,0 +1,162 @@
1
+ # -*- coding: UTF-8 -*-
2
+ import logging
3
+
4
+
5
+ __all__ = ["API", "Web"]
6
+
7
+ _MIN_BACKOFF = 300
8
+
9
+ logger = logging.getLogger("malsearch")
10
+
11
+
12
+ def _valid_hash(hash, *types):
13
+ import re
14
+ types_map = {'md5': 32, 'sha1': 40, 56: 'sha224', 'sha256': 64, 'sha384': 96, 'sha512': 128}
15
+ pattern = "|".join(f"[0-9a-f]{{{types_map[t]}}}" for t in (types or types_map.keys()))
16
+ if re.match(r"{}".format(f"^{pattern}$"), hash, re.I) is None:
17
+ raise ValueError("hash type not supported")
18
+ return hash.lower()
19
+
20
+
21
+ def hashtype(*types):
22
+ def _wrapper(f):
23
+ from functools import wraps
24
+ @wraps(f)
25
+ def _subwrapper(*args, **kwargs):
26
+ _valid_hash(args[0] if isinstance(args[0], str) else args[1], *types)
27
+ return f(*args, **kwargs)
28
+ return _subwrapper
29
+ return _wrapper
30
+
31
+
32
+ class _Base:
33
+ def __init__(self, **kwargs):
34
+ self.name = self.__class__.__name__.lower()
35
+ for k, v in kwargs.items():
36
+ setattr(self, f"_{k}", v)
37
+
38
+ def _decode(self, codec):
39
+ try:
40
+ import codext as codecs
41
+ except ImportError:
42
+ import codecs
43
+ try:
44
+ self.content = codecs.decode(self.content, codec)
45
+ except ValueError as e:
46
+ logger.debug(f"cannot decode ({e})")
47
+ except Exception as e:
48
+ logger.exception(e)
49
+ return self
50
+
51
+ def _save(self, filename):
52
+ from os.path import join
53
+ try:
54
+ c = self.content
55
+ with open(join(self._output_dir, filename), 'wb') as f:
56
+ f.write(c)
57
+ except AttributeError:
58
+ logger.debug("cannot save (no content downloaded)")
59
+ except Exception as e:
60
+ logger.exception(e)
61
+ return self
62
+
63
+ def _unzip(self, password=None):
64
+ from io import BytesIO
65
+ try:
66
+ with BytesIO(self.content) as buffer:
67
+ from pyzipper import AESZipFile
68
+ from zipfile import ZipFile
69
+ for ZipClass in [ZipFile, AESZipFile]:
70
+ try:
71
+ with ZipClass(buffer) as zip_file:
72
+ if password:
73
+ zip_file.setpassword(password)
74
+ with zip_file.open(zip_file.namelist()[0]) as file:
75
+ self.content = file.read()
76
+ logger.debug("unzipped with {ZipClass}")
77
+ break
78
+ except NotImplementedError:
79
+ continue
80
+ except AttributeError:
81
+ logger.debug("cannot unzip (no content downloaded)")
82
+ except Exception as e:
83
+ logger.exception(e)
84
+ return self
85
+
86
+
87
+ class API(_Base):
88
+ def __request(self, path, **kwargs):
89
+ from requests import exceptions, get, post
90
+ for i in ["headers", "params"]:
91
+ n = getattr(self, f"_api_key_{i.rstrip('s')}", None)
92
+ if n:
93
+ d = kwargs.pop(i, {}) or {}
94
+ d[n] = self._api_key
95
+ kwargs[i] = d
96
+ n = getattr(self, "_auth_method", None)
97
+ if n:
98
+ kwargs['headers'] = kwargs['headers'] or {}
99
+ kwargs['headers']['Authorization'] = f"{self._auth_method} {self._api_key}"
100
+ r = (get if 'params' in kwargs else post)(f"{self.url}/{path}", **kwargs)
101
+ try:
102
+ r.raise_for_status()
103
+ self.content = r.content
104
+ except exceptions.RequestException as e:
105
+ if r.status_code == 403:
106
+ import datetime as dt
107
+ from contextlib import nullcontext
108
+ with kwargs.get('lock') or nullcontext():
109
+ if not self._config.has_section("Disabled"):
110
+ self._config.add_section("Disabled")
111
+ boff = dt.timedelta(seconds=getattr(self, "backoff", _MIN_BACKOFF))
112
+ self._config['Disabled'][self.name] = dt.datetime.strftime(dt.datetime.now() + boff,
113
+ "%d/%m/%Y %H:%M:%S")
114
+ with open(self._config.path, 'w') as f:
115
+ self._config.write(f)
116
+ logger.exception(e)
117
+ try:
118
+ self.json = r.json()
119
+ except:
120
+ pass
121
+
122
+ def _get(self, path, params=None, headers=None):
123
+ """ Perform a GET request.
124
+
125
+ :param path: API endpoint (relative path).
126
+ :param params: Dictionary of URL parameters.
127
+ :param headers: Optional dictionary of request headers.
128
+ :return: JSON data or error message.
129
+ """
130
+ self.__request(path, params=params, headers=headers)
131
+ return self
132
+
133
+ def _post(self, path, data=None, json=None, headers=None):
134
+ """ Perform a POST request.
135
+
136
+ :param path: API endpoint (relative path).
137
+ :param data: Dictionary or list of tuples for form-encoded data.
138
+ :param json: Dictionary to send JSON data.
139
+ :param headers: Optional dictionary of request headers.
140
+ :return: JSON data or error message.
141
+ """
142
+ self.__request(path, data=data, json=json, headers=headers)
143
+ return self
144
+
145
+
146
+ class Web(_Base):
147
+ def _get(self, path, params=None, headers=None):
148
+ """ Perform a GET request.
149
+
150
+ :param path: API endpoint (relative path).
151
+ :param params: Dictionary of URL parameters.
152
+ :param headers: Optional dictionary of request headers.
153
+ :return: BeautifulSoup-parsed HTML object or None.
154
+ """
155
+ from bs4 import BeautifulSoup
156
+ from requests import exceptions, get
157
+ try:
158
+ r = get(path, params=params, headers=headers)
159
+ r.raise_for_status()
160
+ return BeautifulSoup(r.content, "html.parser")
161
+ except exceptions.RequestException as e:
162
+ return
@@ -0,0 +1,11 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .malpedia import Malpedia
3
+ from .malshare import MalShare
4
+ from .malwarebazaar import MalwareBazaar
5
+ from .triage import Triage
6
+ from .virusshare import VirusShare
7
+ from .virustotal import VirusTotal
8
+
9
+
10
+ __all__ = ["Malpedia", "MalShare", "MalwareBazaar", "Triage", "VirusShare", "VirusTotal"]
11
+
@@ -0,0 +1,16 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__common__ import hashtype, API
3
+
4
+
5
+ __all__ = ["Malpedia"]
6
+
7
+
8
+ class Malpedia(API):
9
+ doc = "https://malpedia.caad.fkie.fraunhofer.de/usage/api"
10
+ url = "https://malpedia.caad.fkie.fraunhofer.de/api"
11
+ _auth_method = "APIToken"
12
+
13
+ @hashtype("md5", "sha256")
14
+ def get_file_by_hash(self, hash):
15
+ self._get(f"get/sample/{hash}/zip")._save(hash)
16
+ #TODO: parse ZIP for unpacked version
@@ -0,0 +1,15 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__common__ import hashtype, API
3
+
4
+
5
+ __all__ = ["MalShare"]
6
+
7
+
8
+ class MalShare(API):
9
+ doc = "https://malshare.com/doc.php"
10
+ url = "https://malshare.com"
11
+ _api_key_param = "api_key"
12
+
13
+ @hashtype("md5", "sha1", "sha256")
14
+ def get_file_by_hash(self, hash):
15
+ self._get("api.php", params={'action': "getfile", 'hash': hash})._save(hash)
@@ -0,0 +1,16 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__common__ import hashtype, API
3
+
4
+
5
+ __all__ = ["MalwareBazaar"]
6
+
7
+
8
+ class MalwareBazaar(API):
9
+ doc = "https://bazaar.abuse.ch/api"
10
+ url = "https://mb-api.abuse.ch/api/v1"
11
+ _api_key_header = "API-KEY"
12
+
13
+ @hashtype("sha256")
14
+ def get_file_by_hash(self, hash):
15
+ # daily limit: 2000/IP/day
16
+ self._post("", data={'query': "get_file", f'sha256_hash': hash})._unzip(b"infected")._save(hash)
@@ -0,0 +1,21 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__common__ import hashtype, API
3
+
4
+
5
+ __all__ = ["Triage"]
6
+
7
+
8
+ class Triage(API):
9
+ doc = "https://tria.ge/docs"
10
+ url = "https://tria.ge/api/v0"
11
+ _auth_method = "Bearer"
12
+
13
+ @hashtype("md5", "sha1", "sha256", "sha512")
14
+ def get_file_by_hash(self, hash):
15
+ hashtype = {32: "md5", 40: "sha1", 64: "sha256", 128: "sha512"}[len(hash)]
16
+ self._get("search", params={'query': f"{hashtype}:{hash}"})
17
+ try:
18
+ sample_id = self.json['data'][0]['id']
19
+ self._get(f"samples/{sample_id}/sample")._save(hash)
20
+ except IndexError:
21
+ delattr(self, "content")
@@ -0,0 +1,15 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__common__ import hashtype, API
3
+
4
+
5
+ __all__ = ["VirusShare"]
6
+
7
+
8
+ class VirusShare(API):
9
+ doc = "https://virusshare.com/apiv2_reference"
10
+ url = "https://virusshare.com/apiv2"
11
+ _api_key_param = "apikey"
12
+
13
+ @hashtype("md5", "sha1", "sha224", "sha256", "sha384", "sha512")
14
+ def get_file_by_hash(self, hash):
15
+ self._get("download", params={'hash': hash})._save(hash)
@@ -0,0 +1,21 @@
1
+ # -*- coding: UTF-8 -*-
2
+ from .__common__ import hashtype, API
3
+
4
+
5
+ __all__ = ["VirusTotal"]
6
+
7
+
8
+ class VirusTotal(API):
9
+ doc = "https://docs.virustotal.com/reference/overview"
10
+ url = "https://www.virustotal.com/api/v3"
11
+ _api_key_header = "X-Apikey"
12
+
13
+ @hashtype("md5", "sha1", "sha256")
14
+ def get_file_by_hash(self, hash):
15
+ if self._unpacked:
16
+ data = self._get(f"/files/{hash}")
17
+ for n in data['data']['attributes']['names']:
18
+ if n.endswith("_unpacked"):
19
+ hash2, _ = n.split("_", 1)
20
+ self._get(f"/files/{hash2}/download")._save(hash2)
21
+ self._get(f"files/{hash}/download")._unzip(b"infected")._save(hash)