polyswarm-engine 3.1.1__py2.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.
- polyswarm_engine/__init__.py +49 -0
- polyswarm_engine/backend.py +302 -0
- polyswarm_engine/bidutils.py +69 -0
- polyswarm_engine/bounty.py +387 -0
- polyswarm_engine/celeryconfig.py +76 -0
- polyswarm_engine/cli.py +316 -0
- polyswarm_engine/command.py +34 -0
- polyswarm_engine/constants.py +37 -0
- polyswarm_engine/engine.py +123 -0
- polyswarm_engine/exceptions.py +41 -0
- polyswarm_engine/log_config.py +104 -0
- polyswarm_engine/py.typed +0 -0
- polyswarm_engine/settings.py +41 -0
- polyswarm_engine/typing.py +125 -0
- polyswarm_engine/utils.py +434 -0
- polyswarm_engine/wine.py +125 -0
- polyswarm_engine/wsgi.py +122 -0
- polyswarm_engine-3.1.1.dist-info/METADATA +361 -0
- polyswarm_engine-3.1.1.dist-info/RECORD +21 -0
- polyswarm_engine-3.1.1.dist-info/WHEEL +6 -0
- polyswarm_engine-3.1.1.dist-info/top_level.txt +1 -0
polyswarm_engine/cli.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import builtins
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import pathlib
|
|
10
|
+
import typing as t
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
import polyswarm_engine.settings
|
|
15
|
+
from polyswarm_engine.bounty import forge_local_bounty
|
|
16
|
+
from polyswarm_engine.constants import (
|
|
17
|
+
ARTIFACT_TYPES,
|
|
18
|
+
BENIGN,
|
|
19
|
+
EICAR_CONTENT,
|
|
20
|
+
FILE_ARTIFACT,
|
|
21
|
+
MALICIOUS,
|
|
22
|
+
UNKNOWN,
|
|
23
|
+
URL_ARTIFACT,
|
|
24
|
+
)
|
|
25
|
+
from polyswarm_engine.utils import is_fifo
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Artifact type for manually constructed bounties
|
|
30
|
+
BOUNTY_ARTIFACT = "bounty"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
def engine_cli():
|
|
35
|
+
from logging.config import dictConfig
|
|
36
|
+
|
|
37
|
+
from polyswarm_engine import log_config
|
|
38
|
+
|
|
39
|
+
dictConfig(log_config.get_logging(handler='click'))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@engine_cli.command("devserver")
|
|
43
|
+
@click.option('--port', '-p', help='Server port', type=int, default=8000, show_default=True)
|
|
44
|
+
@click.option('--secret', '-s', help='Webhook secret [env: PSENGINE_WEBHOOK_SECRET]', envvar='PSENGINE_WEBHOOK_SECRET')
|
|
45
|
+
@click.pass_obj
|
|
46
|
+
def devserver(engine, port, secret, **kwargs):
|
|
47
|
+
"""
|
|
48
|
+
Simple HTTP server only usable during development
|
|
49
|
+
"""
|
|
50
|
+
from logging.config import dictConfig
|
|
51
|
+
from wsgiref.simple_server import make_server
|
|
52
|
+
|
|
53
|
+
from polyswarm_engine import log_config
|
|
54
|
+
from polyswarm_engine.wsgi import ValidateSenderMiddleware, application as wsgi_app
|
|
55
|
+
|
|
56
|
+
dictConfig(log_config.get_logging())
|
|
57
|
+
|
|
58
|
+
wsgi_app = ValidateSenderMiddleware(wsgi_app, secret=secret)
|
|
59
|
+
with make_server('', port, wsgi_app) as httpd:
|
|
60
|
+
print("Serving {} on port {}, control-C to stop".format(engine.name, port))
|
|
61
|
+
httpd.serve_forever()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@engine_cli.command('create-vhost', context_settings=dict(show_default=True))
|
|
65
|
+
@click.option('--vhost', envvar='PSENGINE_BROKER_VHOST', default='engines')
|
|
66
|
+
@click.option('--broker', envvar='PSENGINE_BROKER_URL', default='amqp://user:password@rabbitmq:5672')
|
|
67
|
+
def create_vhost(vhost: str, broker: str):
|
|
68
|
+
"""Ensure that a vhost exists in the RabbitMQ broker"""
|
|
69
|
+
from urllib import parse
|
|
70
|
+
import requests
|
|
71
|
+
from polyswarm_engine.celeryconfig import CeleryConfig
|
|
72
|
+
|
|
73
|
+
broker_url = CeleryConfig(broker=broker, vhost=vhost).broker_url
|
|
74
|
+
parsed_url = parse.urlparse(broker_url)
|
|
75
|
+
vhost = vhost or parsed_url.path.strip(' /')
|
|
76
|
+
|
|
77
|
+
if vhost:
|
|
78
|
+
logger.info("Creating '%s' vhost", vhost)
|
|
79
|
+
create_url = parse.urlunparse(
|
|
80
|
+
('http', f'{parsed_url.hostname}:1{parsed_url.port}', f'/api/vhosts/{vhost}', '', '', '')
|
|
81
|
+
)
|
|
82
|
+
r = requests.put(create_url, auth=(parsed_url.username, parsed_url.password))
|
|
83
|
+
r.raise_for_status()
|
|
84
|
+
click.echo(f'Successfully create vhost {vhost}')
|
|
85
|
+
else:
|
|
86
|
+
click.echo('No vhost defined for the celery broker')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _gather_analyses(backend, artifacts, artifact_type):
|
|
90
|
+
futures = list()
|
|
91
|
+
for artifact in artifacts:
|
|
92
|
+
bounty = _make_bounty(artifact, artifact_type)
|
|
93
|
+
analysis = backend.analyze(bounty)
|
|
94
|
+
result = (artifact, bounty, analysis)
|
|
95
|
+
|
|
96
|
+
# If our result is already ready, print it immediately
|
|
97
|
+
if analysis.ready():
|
|
98
|
+
yield result
|
|
99
|
+
else:
|
|
100
|
+
futures.append(result)
|
|
101
|
+
|
|
102
|
+
yield from futures
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@engine_cli.command("analyze", help="Analyze artifacts")
|
|
106
|
+
@click.option("-v", "--verbose", count=True)
|
|
107
|
+
@click.option("--check-empty", help="Verify this engine can analyze an empty bounty", default=False, is_flag=True)
|
|
108
|
+
@click.option("--check-eicar", help="Verify this engine can analyze EICAR test file", default=False, is_flag=True)
|
|
109
|
+
@click.option(
|
|
110
|
+
'--check-wicar',
|
|
111
|
+
'--check-exploit-url',
|
|
112
|
+
help='Verify this engine can analyze the WICAR exploit kit URL',
|
|
113
|
+
default=False,
|
|
114
|
+
is_flag=True,
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--artifact-type",
|
|
118
|
+
"-t",
|
|
119
|
+
type=click.Choice([BOUNTY_ARTIFACT, *ARTIFACT_TYPES], case_sensitive=False),
|
|
120
|
+
default=FILE_ARTIFACT,
|
|
121
|
+
help="Artifact type to use when constructing bounties. "
|
|
122
|
+
f"'{BOUNTY_ARTIFACT}' loads manually constructed bounties, "
|
|
123
|
+
"treating each argument as the path to a JSON-encoded bounty object"
|
|
124
|
+
)
|
|
125
|
+
@click.argument("artifacts", nargs=-1)
|
|
126
|
+
@click.pass_obj
|
|
127
|
+
def analyze(engine, artifacts, artifact_type, verbose, check_eicar, check_empty, check_wicar, **kwargs):
|
|
128
|
+
# force celery backend to be eager when running local analyze
|
|
129
|
+
os.environ['PSENGINE_TASK_ALWAYS_EAGER'] = '1'
|
|
130
|
+
importlib.reload(polyswarm_engine.settings)
|
|
131
|
+
|
|
132
|
+
with engine.create_backend() as backend:
|
|
133
|
+
if check_eicar:
|
|
134
|
+
analysis = backend.analyze(_make_bounty(EICAR_CONTENT, FILE_ARTIFACT)).get()
|
|
135
|
+
_check_analysis(analysis, expected={MALICIOUS})
|
|
136
|
+
|
|
137
|
+
if check_empty:
|
|
138
|
+
analysis = backend.analyze(_make_bounty(b'', FILE_ARTIFACT)).get()
|
|
139
|
+
_check_analysis(analysis, expected={BENIGN, UNKNOWN})
|
|
140
|
+
|
|
141
|
+
if check_wicar:
|
|
142
|
+
# MS05-054 Microsoft Internet Explorer JavaScript OnLoad Handler
|
|
143
|
+
url = "http://malware.wicar.org/data/ms05_054_onload.html"
|
|
144
|
+
analysis = backend.analyze(_make_bounty(url, URL_ARTIFACT)).get()
|
|
145
|
+
_check_analysis(analysis, expected={MALICIOUS})
|
|
146
|
+
|
|
147
|
+
for artifact, bounty, future in _gather_analyses(backend, artifacts, artifact_type):
|
|
148
|
+
if artifact and len(artifacts) > 1:
|
|
149
|
+
_echo(f"{artifact:-^80}", ostream="stderr")
|
|
150
|
+
|
|
151
|
+
if verbose:
|
|
152
|
+
_echo("Bounty: ", nl=False, ostream="stderr")
|
|
153
|
+
_echo(bounty, bold=True, ostream="stderr")
|
|
154
|
+
_echo("Analysis: ", nl=False, ostream="stderr")
|
|
155
|
+
|
|
156
|
+
_echo(future.get(), bold=bool(verbose))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _check_analysis(analysis, expected):
|
|
160
|
+
_echo(analysis)
|
|
161
|
+
assert isinstance(analysis, t.Mapping)
|
|
162
|
+
assert analysis["verdict"] in expected, f"Received '{analysis['verdict']}' instead of {' or '.join(expected)}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _make_bounty(artifact, artifact_type, **kwargs):
|
|
166
|
+
forge = functools.partial(forge_local_bounty, artifact_type=artifact_type, **kwargs)
|
|
167
|
+
|
|
168
|
+
if artifact_type == BOUNTY_ARTIFACT:
|
|
169
|
+
return json.load(click.open_file(artifact, "rb"))
|
|
170
|
+
elif isinstance(artifact, bytes):
|
|
171
|
+
return forge(data=artifact)
|
|
172
|
+
elif artifact == "-" or is_fifo(artifact):
|
|
173
|
+
return forge(stream=click.open_file(artifact, "rb"))
|
|
174
|
+
elif artifact_type == URL_ARTIFACT:
|
|
175
|
+
return forge(data=artifact)
|
|
176
|
+
elif artifact_type == FILE_ARTIFACT:
|
|
177
|
+
return forge(path=artifact)
|
|
178
|
+
else:
|
|
179
|
+
raise ValueError(f"Invalid artifact: {artifact}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@engine_cli.command("create-bounty", help="Make a fresh bounty from a file or URL artifact")
|
|
183
|
+
@click.option(
|
|
184
|
+
"--artifact-type",
|
|
185
|
+
"-t",
|
|
186
|
+
type=click.Choice(list(ARTIFACT_TYPES), case_sensitive=False),
|
|
187
|
+
default=FILE_ARTIFACT,
|
|
188
|
+
help="Artifact type to use when constructing bounties. "
|
|
189
|
+
)
|
|
190
|
+
@click.option("--expiration", type=int, default=60 * 60 * 24 * 365, help="Number of seconds until bounty expiration")
|
|
191
|
+
@click.option("--response-url", help="The URL to send results to")
|
|
192
|
+
@click.argument("artifact")
|
|
193
|
+
def create_bounty(artifact, artifact_type, expiration, response_url):
|
|
194
|
+
bounty = _make_bounty(artifact, artifact_type, expiration=dt.timedelta(seconds=expiration))
|
|
195
|
+
|
|
196
|
+
if response_url:
|
|
197
|
+
bounty["response_url"] = response_url
|
|
198
|
+
|
|
199
|
+
_echo(bounty)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@engine_cli.command(
|
|
203
|
+
"worker",
|
|
204
|
+
help="Start celery worker",
|
|
205
|
+
context_settings=dict(ignore_unknown_options=True),
|
|
206
|
+
)
|
|
207
|
+
@click.argument("celery_args", nargs=-1)
|
|
208
|
+
@click.pass_obj
|
|
209
|
+
def worker(engine, celery_args, **kwargs):
|
|
210
|
+
from logging.config import dictConfig
|
|
211
|
+
|
|
212
|
+
from polyswarm_engine import log_config
|
|
213
|
+
|
|
214
|
+
dictConfig(log_config.get_logging())
|
|
215
|
+
|
|
216
|
+
with engine.create_backend() as backend:
|
|
217
|
+
backend.app.worker_main(argv=["worker", *celery_args])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class EngineCommandsGroup(click.MultiCommand):
|
|
221
|
+
def list_commands(self, ctx):
|
|
222
|
+
return ctx.obj.cmd
|
|
223
|
+
|
|
224
|
+
def get_command(self, ctx, name):
|
|
225
|
+
engine = ctx.obj
|
|
226
|
+
cmd = engine.cmd[name]
|
|
227
|
+
func = cmd["func"]
|
|
228
|
+
argspec = inspect.getfullargspec(func)
|
|
229
|
+
docstr = cmd["doc"]
|
|
230
|
+
|
|
231
|
+
def callback(**params):
|
|
232
|
+
args = []
|
|
233
|
+
|
|
234
|
+
for arg in set(argspec.args) & set(params.keys()):
|
|
235
|
+
args.append(params.pop(arg))
|
|
236
|
+
|
|
237
|
+
if argspec.varargs in params:
|
|
238
|
+
args.extend(params.pop(argspec.varargs))
|
|
239
|
+
|
|
240
|
+
with engine.create_backend():
|
|
241
|
+
result = func(*args, **params)
|
|
242
|
+
_echo(result, fg="black", bold=True)
|
|
243
|
+
|
|
244
|
+
return click.Command(
|
|
245
|
+
name=name,
|
|
246
|
+
callback=callback,
|
|
247
|
+
help=docstr or name,
|
|
248
|
+
short_help=docstr.split("\n")[0] or None,
|
|
249
|
+
params=list(self._argspec_to_params(argspec)),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _argspec_to_params(spec: "inspect.FullArgSpec") -> "t.Iterator[click.Parameter]":
|
|
254
|
+
"""Convert the function signature of a command to `click.Parameter` objects"""
|
|
255
|
+
|
|
256
|
+
def get_type(param_name):
|
|
257
|
+
if not spec.annotations:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
typ = spec.annotations.get(param_name)
|
|
261
|
+
|
|
262
|
+
if isinstance(typ, str):
|
|
263
|
+
if hasattr(builtins, typ):
|
|
264
|
+
return getattr(builtins, typ)
|
|
265
|
+
elif typ == "Path" or typ == "pathlib.Path":
|
|
266
|
+
return pathlib.Path
|
|
267
|
+
else:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
return typ
|
|
271
|
+
|
|
272
|
+
# `kwonlyargs` is a list of keyword-only parameter names in declaration order
|
|
273
|
+
if spec.kwonlyargs:
|
|
274
|
+
for name in spec.kwonlyargs:
|
|
275
|
+
# `kwonlydefaults` holds dictionary mapping parameter names from `kwonlyargs`
|
|
276
|
+
# to the default values used if no argument is supplied
|
|
277
|
+
if spec.kwonlydefaults and name in spec.kwonlydefaults:
|
|
278
|
+
yield click.Option([f"--{name}"], default=spec.kwonlydefaults[name], type=get_type(name))
|
|
279
|
+
else:
|
|
280
|
+
yield click.Option([f"--{name}"], required=True, type=get_type(name))
|
|
281
|
+
|
|
282
|
+
# `args` is a list of the positional parameter names
|
|
283
|
+
if spec.args:
|
|
284
|
+
# `defaults` is an n-tuple of default argument values for the last n positional parameters
|
|
285
|
+
if spec.defaults:
|
|
286
|
+
index = len(spec.args) - len(spec.defaults)
|
|
287
|
+
|
|
288
|
+
# yield each of the positional args w/o any associated defaults
|
|
289
|
+
for name in spec.args[:index]:
|
|
290
|
+
yield click.Argument([name], required=True, type=get_type(name))
|
|
291
|
+
|
|
292
|
+
# ... and then the rest of the positional args w/ default values
|
|
293
|
+
for name, default in zip(spec.args[index:], spec.defaults):
|
|
294
|
+
yield click.Argument([name], default=default, type=get_type(name))
|
|
295
|
+
else:
|
|
296
|
+
for name in spec.args:
|
|
297
|
+
yield click.Argument([name], required=True, type=get_type(name))
|
|
298
|
+
|
|
299
|
+
# `varargs` is the name of the * parameter or `None` if not accepted.
|
|
300
|
+
if spec.varargs:
|
|
301
|
+
yield click.Argument([spec.varargs], nargs=-1)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
engine_cli.add_command(EngineCommandsGroup(name="commands", help="Engine commands"))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _echo(msg, **echo_options):
|
|
308
|
+
if msg is None:
|
|
309
|
+
return
|
|
310
|
+
elif isinstance(msg, dict):
|
|
311
|
+
msg = json.dumps(msg, indent=2)
|
|
312
|
+
|
|
313
|
+
if "ostream" in echo_options:
|
|
314
|
+
echo_options["file"] = click.get_text_stream(echo_options.pop("ostream"))
|
|
315
|
+
|
|
316
|
+
click.secho(msg, **echo_options)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from .utils import get_func_name, get_func_qual
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommandRegistry:
|
|
7
|
+
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._metadata = dict()
|
|
10
|
+
|
|
11
|
+
def __iter__(self):
|
|
12
|
+
return iter(self._metadata)
|
|
13
|
+
|
|
14
|
+
def __getitem__(self, name):
|
|
15
|
+
return {"func": self.__dict__[name], **self._metadata[name]}
|
|
16
|
+
|
|
17
|
+
def __getattr__(self, name):
|
|
18
|
+
# XXX: This is here to make sure that `mypy` doesn't raise an error for items inside `self.__dict__`
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
def _add(self, func, name=None):
|
|
22
|
+
module, fnname = get_func_qual(func)
|
|
23
|
+
name = name or fnname
|
|
24
|
+
|
|
25
|
+
if name.startswith('_'):
|
|
26
|
+
raise ValueError("Cannot use a command name starting with '_'")
|
|
27
|
+
|
|
28
|
+
self._metadata[name] = {
|
|
29
|
+
"name": name,
|
|
30
|
+
"qualname": get_func_name(func),
|
|
31
|
+
"module": module,
|
|
32
|
+
"doc": inspect.getdoc(func) or "",
|
|
33
|
+
}
|
|
34
|
+
self.__dict__[name] = func
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
import uuid
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from .typing import AnalysisResult, ArtifactType
|
|
6
|
+
|
|
7
|
+
URL_MIMETYPE = "text/uri-list"
|
|
8
|
+
|
|
9
|
+
FILE_ARTIFACT: "ArtifactType" = "FILE"
|
|
10
|
+
URL_ARTIFACT: "ArtifactType" = "URL"
|
|
11
|
+
ARTIFACT_TYPES: "t.Set[ArtifactType]" = set(t.get_args(ArtifactType))
|
|
12
|
+
|
|
13
|
+
# Analysis Conclusions
|
|
14
|
+
BENIGN: "AnalysisResult" = "benign"
|
|
15
|
+
MALICIOUS: "AnalysisResult" = "malicious"
|
|
16
|
+
SUSPICIOUS: "AnalysisResult" = "suspicious"
|
|
17
|
+
UNKNOWN: "AnalysisResult" = "unknown"
|
|
18
|
+
AnalysisConclusions: "t.Set[AnalysisResult]" = set(t.get_args(AnalysisResult))
|
|
19
|
+
|
|
20
|
+
# These defined UUIDv5 namespaces are necessary to support the goal of semantic equivalence of some bounty objects.
|
|
21
|
+
# See: ``polyswarm_engine.bounty._forge_bounty_uuid``
|
|
22
|
+
BOUNTY_UUID = uuid.UUID("fafee1eb-ee7d-4b31-bee5-1547bd26c731")
|
|
23
|
+
FILE_BOUNTY_UUID = uuid.uuid5(BOUNTY_UUID, FILE_ARTIFACT)
|
|
24
|
+
URL_BOUNTY_UUID = uuid.uuid5(BOUNTY_UUID, URL_ARTIFACT)
|
|
25
|
+
|
|
26
|
+
SKIPPED_COMMENT = "SKIPPED"
|
|
27
|
+
SKIPPED_ENCRYPTED_COMMENT = f"{SKIPPED_COMMENT}:ENCRYPTED"
|
|
28
|
+
SKIPPED_HIGHCOMPRESSION_COMMENT = f"{SKIPPED_COMMENT}:DECOMPRESSION-UNSAFE"
|
|
29
|
+
SKIPPED_UNSUPPORTED_COMMENT = f"{SKIPPED_COMMENT}:TYPE-UNSUPPORTED"
|
|
30
|
+
SKIPPED_CANNOT_FETCH_COMMENT = f'{SKIPPED_COMMENT}:CANNOT-FETCH'
|
|
31
|
+
|
|
32
|
+
EICAR_CONTENT = base64.b64decode(
|
|
33
|
+
b'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo='
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# For easing the bid maths
|
|
37
|
+
NCT_TO_WEI_CONVERSION = 10**18
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from polyswarm_engine.backend import CeleryBackend
|
|
6
|
+
from polyswarm_engine.cli import engine_cli
|
|
7
|
+
from polyswarm_engine.command import CommandRegistry
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EngineManager:
|
|
13
|
+
def __init__(self, name, vendor=None, config=None, backend_kwargs: dict = None, **kwargs):
|
|
14
|
+
self.name: str = name
|
|
15
|
+
self.vendor: str = vendor
|
|
16
|
+
self.config = config or dict()
|
|
17
|
+
self.ctx = dict()
|
|
18
|
+
self.backend: CeleryBackend|None = None
|
|
19
|
+
self.backend_kwargs = dict()
|
|
20
|
+
self.cmd = CommandRegistry()
|
|
21
|
+
# in case a lifecycle is not defined, use a nop context manager
|
|
22
|
+
self._lifecycle = lambda: contextlib.nullcontext()
|
|
23
|
+
self._head = None
|
|
24
|
+
self._analyze = None
|
|
25
|
+
|
|
26
|
+
def cli(self):
|
|
27
|
+
engine_cli(prog_name=self.name, obj=self)
|
|
28
|
+
|
|
29
|
+
@contextlib.contextmanager
|
|
30
|
+
def create_backend(self):
|
|
31
|
+
"""
|
|
32
|
+
Start with backend
|
|
33
|
+
|
|
34
|
+
Example
|
|
35
|
+
-------
|
|
36
|
+
|
|
37
|
+
>>> with Engine.create_backend() as backend:
|
|
38
|
+
>>> ...
|
|
39
|
+
"""
|
|
40
|
+
self.backend = CeleryBackend(
|
|
41
|
+
self.name,
|
|
42
|
+
self._analyze,
|
|
43
|
+
self._head,
|
|
44
|
+
self._lifecycle,
|
|
45
|
+
**self.backend_kwargs,
|
|
46
|
+
)
|
|
47
|
+
with self.backend.run() as backend:
|
|
48
|
+
yield backend
|
|
49
|
+
self.backend = None
|
|
50
|
+
|
|
51
|
+
def expose_command(self, func: "EngineCommandCallable"):
|
|
52
|
+
"""Decorate to expose an internal engine function"""
|
|
53
|
+
self.cmd._add(func)
|
|
54
|
+
return func
|
|
55
|
+
|
|
56
|
+
def register_analyzer(self, func: "EngineAnalyzeCallable"):
|
|
57
|
+
"""Decorator used to register this engine's analyzer function
|
|
58
|
+
|
|
59
|
+
Example::
|
|
60
|
+
|
|
61
|
+
@engine.register_analyzer
|
|
62
|
+
def analyze(bounty: polyswarm_engine.Bounty) -> polyswarm_engine.Analysis:
|
|
63
|
+
result = engine.cmd.scan_stream(get_artifact_stream(bounty))
|
|
64
|
+
|
|
65
|
+
analysis = {"verdict": polyswarm_engine.UNKNOWN}
|
|
66
|
+
|
|
67
|
+
if result["is_malicious"]:
|
|
68
|
+
analysis["verdict"] = polyswarm_engine.MALICIOUS
|
|
69
|
+
|
|
70
|
+
if "result_name" in result:
|
|
71
|
+
analysis["metadata"] = {"malware_family": result["result_name"]}
|
|
72
|
+
|
|
73
|
+
return analysis
|
|
74
|
+
"""
|
|
75
|
+
self._analyze = func
|
|
76
|
+
return func
|
|
77
|
+
|
|
78
|
+
def register_head(self, func: "EngineHeadCallable"):
|
|
79
|
+
"""Decorator used to gather engine metadata at startup
|
|
80
|
+
|
|
81
|
+
Notes::
|
|
82
|
+
|
|
83
|
+
This should decorate a function that gathers any data you'd
|
|
84
|
+
like to include with your analyses, but which isn't produced
|
|
85
|
+
as part of the scanning process such as:
|
|
86
|
+
|
|
87
|
+
- Engine version
|
|
88
|
+
- Signature version
|
|
89
|
+
- Current environment
|
|
90
|
+
|
|
91
|
+
Example::
|
|
92
|
+
|
|
93
|
+
@engine.register_head
|
|
94
|
+
def head() -> polyswarm_engine.AnalysisMetadata:
|
|
95
|
+
info = engine.cmd.info()
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"product": info["productName"],
|
|
99
|
+
"scanner": {
|
|
100
|
+
"vendor_version": info["productVersion"],
|
|
101
|
+
"signatures_version": info["vbaseVersion"],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
"""
|
|
105
|
+
self._head = func
|
|
106
|
+
return func
|
|
107
|
+
|
|
108
|
+
def register_lifecycle_manager(self, func: "EngineLifecycleCallable"):
|
|
109
|
+
"""Wraps a function acting as a engine lifecycle ContextManager
|
|
110
|
+
|
|
111
|
+
Example::
|
|
112
|
+
|
|
113
|
+
@engine.register_lifecycle_manager
|
|
114
|
+
def lifecycle(Engine):
|
|
115
|
+
pid = run([Engine.config["DAEMON"], "--start"]) # Setup worker
|
|
116
|
+
yield
|
|
117
|
+
terminate(pid) # Worker has terminated, run cleanup code...
|
|
118
|
+
"""
|
|
119
|
+
self._lifecycle = contextlib.contextmanager(func)
|
|
120
|
+
return func
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = ["EngineManager"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
class EngineException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EngineTimeoutError(EngineException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EngineFileNotFoundError(EngineException):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EngineWorkerInterrupt(EngineException):
|
|
14
|
+
""" An exception that is not KeyboardInterrupt to allow subprocesses
|
|
15
|
+
to be interrupted.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnginePollingException(EngineException):
|
|
22
|
+
"""Base exception that stores all return values of attempted polls"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, last=None):
|
|
25
|
+
self.last = last
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EngineExpiredException(EnginePollingException):
|
|
29
|
+
"""Exception raised if polling function times out"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EngineMaxCallException(EnginePollingException):
|
|
33
|
+
"""Exception raised if maximum number of iterations is exceeded"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BountyException(Exception):
|
|
37
|
+
"""Bounty had problems"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BountyFetchException(Exception):
|
|
41
|
+
"""Bounty artifact could not be fetched"""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from datetime import datetime, timezone as tz
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from polyswarm_engine.settings import LOG_LEVEL, LOG_FORMAT
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from pythonjsonlogger import jsonlogger
|
|
8
|
+
except ImportError:
|
|
9
|
+
jsonlogger = None
|
|
10
|
+
else:
|
|
11
|
+
|
|
12
|
+
class JSONFormatter(jsonlogger.JsonFormatter):
|
|
13
|
+
"""
|
|
14
|
+
Class to add custom JSON fields to our logger.
|
|
15
|
+
Presently just adds a timestamp if one isn't present and the log level.
|
|
16
|
+
INFO: https://github.com/madzak/python-json-logger#customizing-fields
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def add_fields(self, log_record, record, message_dict):
|
|
20
|
+
super(JSONFormatter, self).add_fields(log_record, record, message_dict)
|
|
21
|
+
if not log_record.get('timestamp'):
|
|
22
|
+
# this doesn't use record.created, so it is slightly off
|
|
23
|
+
now = datetime.now(tz.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')
|
|
24
|
+
log_record['timestamp'] = now
|
|
25
|
+
if log_record.get('level'):
|
|
26
|
+
log_record['level'] = log_record['level'].upper()
|
|
27
|
+
else:
|
|
28
|
+
log_record['level'] = record.levelname
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import click
|
|
33
|
+
import click_log
|
|
34
|
+
except ImportError:
|
|
35
|
+
click_log = None
|
|
36
|
+
else:
|
|
37
|
+
# adding color to INFO log messages as well
|
|
38
|
+
click_log.core.ColorFormatter.colors['info'] = dict(fg='green')
|
|
39
|
+
|
|
40
|
+
class NamedColorFormatter(logging.Formatter):
|
|
41
|
+
colors = {
|
|
42
|
+
'error': dict(fg='red'),
|
|
43
|
+
'exception': dict(fg='red'),
|
|
44
|
+
'critical': dict(fg='red'),
|
|
45
|
+
'debug': dict(fg='blue'),
|
|
46
|
+
'warning': dict(fg='yellow'),
|
|
47
|
+
'info': dict(fg='green'),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def format(self, record):
|
|
51
|
+
if not record.exc_info:
|
|
52
|
+
level = record.levelname.lower()
|
|
53
|
+
msg = logging.Formatter.format(self, record)
|
|
54
|
+
if level in self.colors:
|
|
55
|
+
sopts = self.colors[level]
|
|
56
|
+
lines = msg.splitlines()
|
|
57
|
+
msg = '\n'.join(click.style(x, **sopts) for x in lines) # type: ignore
|
|
58
|
+
return msg
|
|
59
|
+
return logging.Formatter.format(self, record)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_logging(log_level=None, handler='console'):
|
|
63
|
+
log_level = log_level or LOG_LEVEL
|
|
64
|
+
return {
|
|
65
|
+
'version': 1,
|
|
66
|
+
'disable_existing_loggers': False,
|
|
67
|
+
'formatters': {
|
|
68
|
+
'text': {
|
|
69
|
+
'format': '%(asctime)s - %(levelname)-2s [%(filename)s:%(lineno)d][%(funcName)1s] %(message)s',
|
|
70
|
+
},
|
|
71
|
+
'json': {
|
|
72
|
+
'format': '%(asctime)s %(levelname) %(message) %(filename) %(lineno) %(funcName)',
|
|
73
|
+
'class': 'polyswarm_engine.log_config.JSONFormatter',
|
|
74
|
+
},
|
|
75
|
+
'click': {
|
|
76
|
+
'format': '%(asctime)s - %(levelname)-2s [%(filename)s:%(lineno)d][%(funcName)1s] %(message)s',
|
|
77
|
+
'class': 'polyswarm_engine.log_config.NamedColorFormatter',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
'handlers': {
|
|
81
|
+
'console': {
|
|
82
|
+
'level': log_level,
|
|
83
|
+
'class': 'logging.StreamHandler',
|
|
84
|
+
'formatter': LOG_FORMAT,
|
|
85
|
+
},
|
|
86
|
+
'click': {
|
|
87
|
+
'level': log_level,
|
|
88
|
+
'class': 'click_log.core.ClickHandler',
|
|
89
|
+
'formatter': 'click',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
'loggers': {
|
|
93
|
+
'polyswarm_engine': {
|
|
94
|
+
'level': log_level,
|
|
95
|
+
},
|
|
96
|
+
'celery': {
|
|
97
|
+
'level': log_level,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
'root': {
|
|
101
|
+
'handlers': [handler],
|
|
102
|
+
'level': log_level,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
File without changes
|