forked from MISP/misp-modules
-
Notifications
You must be signed in to change notification settings - Fork 0
/
vmware_nsx.py
621 lines (549 loc) · 22.3 KB
/
vmware_nsx.py
1
2
3
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
#!/usr/bin/env python3
"""
Expansion module integrating with VMware NSX Defender.
"""
import argparse
import base64
import configparser
import datetime
import hashlib
import io
import ipaddress
import json
import logging
import pymisp
import sys
import vt
import zipfile
from urllib import parse
from typing import Any, Dict, List, Optional, Tuple, Union
import tau_clients
from tau_clients import exceptions
from tau_clients import nsx_defender
logger = logging.getLogger("vmware_nsx")
logger.setLevel(logging.DEBUG)
misperrors = {
"error": "Error",
}
mispattributes = {
"input": [
"attachment",
"malware-sample",
"url",
"md5",
"sha1",
"sha256",
],
"format": "misp_standard",
}
moduleinfo = {
"version": "0.2",
"author": "Jason Zhang, Stefano Ortolani",
"description": "Enrich a file or URL with VMware NSX Defender",
"module-type": ["expansion", "hover"],
}
moduleconfig = [
"analysis_url", # optional, defaults to hard-coded values
"analysis_verify_ssl", # optional, defaults to True
"analysis_key", # required
"analysis_api_token", # required
"vt_key", # optional
"misp_url", # optional
"misp_verify_ssl", # optional, defaults to True
"misp_key", # optional
]
DEFAULT_ZIP_PASSWORD = b"infected"
DEFAULT_ENDPOINT = tau_clients.NSX_DEFENDER_DC_WESTUS
WORKFLOW_COMPLETE_TAG = "workflow:state='complete'"
WORKFLOW_INCOMPLETE_TAG = "workflow:state='incomplete'"
VT_DOWNLOAD_TAG = "vt:download"
GALAXY_ATTACK_PATTERNS_UUID = "c4e851fa-775f-11e7-8163-b774922098cd"
class ResultParser:
"""This is a parser to extract *basic* information from a result dictionary."""
def __init__(self, techniques_galaxy: Optional[Dict[str, str]] = None):
"""Constructor."""
self.techniques_galaxy = techniques_galaxy or {}
def parse(self, analysis_link: str, result: Dict[str, Any]) -> pymisp.MISPEvent:
"""
Parse the analysis result into a MISP event.
:param str analysis_link: the analysis link
:param dict[str, any] result: the JSON returned by the analysis client.
:rtype: pymisp.MISPEvent
:return: a MISP event
"""
misp_event = pymisp.MISPEvent()
# Add analysis subject info
if "url" in result["analysis_subject"]:
o = pymisp.MISPObject("url")
o.add_attribute("url", result["analysis_subject"]["url"])
else:
o = pymisp.MISPObject("file")
o.add_attribute("md5", type="md5", value=result["analysis_subject"]["md5"])
o.add_attribute("sha1", type="sha1", value=result["analysis_subject"]["sha1"])
o.add_attribute("sha256", type="sha256", value=result["analysis_subject"]["sha256"])
o.add_attribute(
"mimetype",
category="Payload delivery",
type="mime-type",
value=result["analysis_subject"]["mime_type"]
)
misp_event.add_object(o)
# Add HTTP requests from url analyses
network_dict = result.get("report", {}).get("analysis", {}).get("network", {})
for request in network_dict.get("requests", []):
if not request["url"] and not request["ip"]:
continue
o = pymisp.MISPObject(name="http-request")
o.add_attribute("method", "GET")
if request["url"]:
parsed_uri = parse.urlparse(request["url"])
o.add_attribute("host", parsed_uri.netloc)
o.add_attribute("uri", request["url"])
if request["ip"]:
o.add_attribute("ip-dst", request["ip"])
misp_event.add_object(o)
# Add network behaviors from files
for subject in result.get("report", {}).get("analysis_subjects", []):
# Add DNS requests
for dns_query in subject.get("dns_queries", []):
hostname = dns_query.get("hostname")
# Skip if it is an IP address
try:
if hostname == "wpad" or hostname == "localhost":
continue
# Invalid hostname, e.g., hostname: ZLKKJRPY or 2.2.0.10.in-addr.arpa.
if "." not in hostname or hostname[-1] == ".":
continue
_ = ipaddress.ip_address(hostname)
continue
except ValueError:
pass
o = pymisp.MISPObject(name="domain-ip")
o.add_attribute("hostname", type="hostname", value=hostname)
for ip in dns_query.get("results", []):
o.add_attribute("ip", type="ip-dst", value=ip)
misp_event.add_object(o)
# Add HTTP conversations (as network connection and as http request)
for http_conversation in subject.get("http_conversations", []):
o = pymisp.MISPObject(name="network-connection")
o.add_attribute("ip-src", http_conversation["src_ip"])
o.add_attribute("ip-dst", http_conversation["dst_ip"])
o.add_attribute("src-port", http_conversation["src_port"])
o.add_attribute("dst-port", http_conversation["dst_port"])
o.add_attribute("hostname-dst", http_conversation["dst_host"])
o.add_attribute("layer3-protocol", "IP")
o.add_attribute("layer4-protocol", "TCP")
o.add_attribute("layer7-protocol", "HTTP")
misp_event.add_object(o)
method, path, http_version = http_conversation["url"].split(" ")
if http_conversation["dst_port"] == 80:
uri = "http://{}{}".format(http_conversation["dst_host"], path)
else:
uri = "http://{}:{}{}".format(
http_conversation["dst_host"],
http_conversation["dst_port"],
path
)
o = pymisp.MISPObject(name="http-request")
o.add_attribute("host", http_conversation["dst_host"])
o.add_attribute("method", method)
o.add_attribute("uri", uri)
o.add_attribute("ip-dst", http_conversation["dst_ip"])
misp_event.add_object(o)
# Add sandbox info like score and sandbox type
o = pymisp.MISPObject(name="sandbox-report")
sandbox_type = "saas" if tau_clients.is_task_hosted(analysis_link) else "on-premise"
o.add_attribute("score", result["score"])
o.add_attribute("sandbox-type", sandbox_type)
o.add_attribute("{}-sandbox".format(sandbox_type), "vmware-nsx-defender")
o.add_attribute("permalink", analysis_link)
misp_event.add_object(o)
# Add behaviors
# Check if its not empty first, as at least one attribute has to be set for sb-signature object
if result.get("malicious_activity", []):
o = pymisp.MISPObject(name="sb-signature")
o.add_attribute("software", "VMware NSX Defender")
for activity in result.get("malicious_activity", []):
a = pymisp.MISPAttribute()
a.from_dict(type="text", value=activity)
o.add_attribute("signature", **a)
misp_event.add_object(o)
# Add mitre techniques
for techniques in result.get("activity_to_mitre_techniques", {}).values():
for technique in techniques:
for misp_technique_id, misp_technique_name in self.techniques_galaxy.items():
if technique["id"].casefold() in misp_technique_id.casefold():
# If report details a sub-technique, trust the match
# Otherwise trust it only if the MISP technique is not a sub-technique
if "." in technique["id"] or "." not in misp_technique_id:
misp_event.add_tag(misp_technique_name)
break
return misp_event
def _parse_submission_response(response: Dict[str, Any]) -> Tuple[str, List[str]]:
"""
Parse the response from "submit_*" methods.
:param dict[str, any] response: the client response
:rtype: tuple(str, list[str])
:return: the task_uuid and whether the analysis is available
:raises ValueError: in case of any error
"""
task_uuid = response.get("task_uuid")
if not task_uuid:
raise ValueError("Submission failed, unable to process the data")
if response.get("score") is not None:
tags = [WORKFLOW_COMPLETE_TAG]
else:
tags = [WORKFLOW_INCOMPLETE_TAG]
return task_uuid, tags
def _unzip(zipped_data: bytes, password: bytes = DEFAULT_ZIP_PASSWORD) -> bytes:
"""
Unzip the data.
:param bytes zipped_data: the zipped data
:param bytes password: the password
:rtype: bytes
:return: the unzipped data
:raises ValueError: in case of any error
"""
try:
data_file_object = io.BytesIO(zipped_data)
with zipfile.ZipFile(data_file_object) as zip_file:
sample_hash_name = zip_file.namelist()[0]
return zip_file.read(sample_hash_name, password)
except (IOError, ValueError) as e:
raise ValueError(str(e))
def _download_from_vt(client: vt.Client, file_hash: str) -> bytes:
"""
Download file from VT.
:param vt.Client client: the VT client
:param str file_hash: the file hash
:rtype: bytes
:return: the downloaded data
:raises ValueError: in case of any error
"""
try:
buffer = io.BytesIO()
client.download_file(file_hash, buffer)
buffer.seek(0, 0)
return buffer.read()
except (IOError, vt.APIError) as e:
raise ValueError(str(e))
finally:
# vt.Client likes to free resources at shutdown, and it can be used as context to ease that
# Since the structure of the module does not play well with how MISP modules are organized
# let's play nice and close connections pro-actively (opened by "download_file")
if client:
client.close()
def _get_analysis_tags(
clients: Dict[str, nsx_defender.AnalysisClient],
task_uuid: str,
) -> List[str]:
"""
Get the analysis tags of a task.
:param dict[str, nsx_defender.AnalysisClient] clients: the analysis clients
:param str task_uuid: the task uuid
:rtype: list[str]
:return: the analysis tags
:raises exceptions.ApiError: in case of client errors
:raises exceptions.CommunicationError: in case of client communication errors
"""
client = clients[DEFAULT_ENDPOINT]
response = client.get_analysis_tags(task_uuid)
tags = set([])
for tag in response.get("analysis_tags", []):
tag_header = None
tag_type = tag["data"]["type"]
if tag_type == "av_family":
tag_header = "av-fam"
elif tag_type == "av_class":
tag_header = "av-cls"
elif tag_type == "lastline_malware":
tag_header = "nsx"
if tag_header:
tags.add("{}:{}".format(tag_header, tag["data"]["value"]))
return sorted(tags)
def _get_latest_analysis(
clients: Dict[str, nsx_defender.AnalysisClient],
file_hash: str,
) -> Optional[str]:
"""
Get the latest analysis.
:param dict[str, nsx_defender.AnalysisClient] clients: the analysis clients
:param str file_hash: the hash of the file
:rtype: str|None
:return: the task uuid if present, None otherwise
:raises exceptions.ApiError: in case of client errors
:raises exceptions.CommunicationError: in case of client communication errors
"""
def _parse_expiration(task_info: Dict[str, str]) -> datetime.datetime:
"""
Parse expiration time of a task
:param dict[str, str] task_info: the task
:rtype: datetime.datetime
:return: the parsed datetime object
"""
return datetime.datetime.strptime(task_info["expires"], "%Y-%m-%d %H:%M:%S")
results = []
for data_center, client in clients.items():
response = client.query_file_hash(file_hash=file_hash)
for task in response.get("tasks", []):
results.append(task)
if results:
return sorted(results, key=_parse_expiration)[-1]["task_uuid"]
else:
return None
def _get_mitre_techniques_galaxy(misp_client: pymisp.PyMISP) -> Dict[str, str]:
"""
Get all the MITRE techniques from the MISP galaxy.
:param pymisp.PyMISP misp_client: the MISP client
:rtype: dict[str, str]
:return: all techniques indexed by their id
"""
galaxy_attack_patterns = misp_client.get_galaxy(
galaxy=GALAXY_ATTACK_PATTERNS_UUID,
withCluster=True,
pythonify=True,
)
ret = {}
for cluster in galaxy_attack_patterns.clusters:
ret[cluster.value] = cluster.tag_name
return ret
def introspection() -> Dict[str, Union[str, List[str]]]:
"""
Implement interface.
:return: the supported MISP attributes
:rtype: dict[str, list[str]]
"""
return mispattributes
def version() -> Dict[str, Union[str, List[str]]]:
"""
Implement interface.
:return: the module config inside another dictionary
:rtype: dict[str, list[str]]
"""
moduleinfo["config"] = moduleconfig
return moduleinfo
def handler(q: Union[bool, str] = False) -> Union[bool, Dict[str, Any]]:
"""
Implement interface.
:param bool|str q: the input received
:rtype: bool|dict[str, any]
"""
if q is False:
return False
request = json.loads(q)
config = request.get("config", {})
# Load the client to connect to VMware NSX ATA (hard-fail)
try:
analysis_url = config.get("analysis_url")
login_params = {
"key": config["analysis_key"],
"api_token": config["analysis_api_token"],
}
# If 'analysis_url' is specified we are connecting on-premise
if analysis_url:
analysis_clients = {
DEFAULT_ENDPOINT: nsx_defender.AnalysisClient(
api_url=analysis_url,
login_params=login_params,
verify_ssl=bool(config.get("analysis_verify_ssl", True)),
)
}
logger.info("Connected NSX AnalysisClient to on-premise infrastructure")
else:
analysis_clients = {
data_center: nsx_defender.AnalysisClient(
api_url=tau_clients.NSX_DEFENDER_ANALYSIS_URLS[data_center],
login_params=login_params,
verify_ssl=bool(config.get("analysis_verify_ssl", True)),
) for data_center in [
tau_clients.NSX_DEFENDER_DC_WESTUS,
tau_clients.NSX_DEFENDER_DC_NLEMEA,
]
}
logger.info("Connected NSX AnalysisClient to hosted infrastructure")
except KeyError as ke:
logger.error("Integration with VMware NSX ATA failed to connect: %s", str(ke))
return {"error": "Error connecting to VMware NSX ATA: {}".format(ke)}
# Load the client to connect to MISP (soft-fail)
try:
misp_client = pymisp.PyMISP(
url=config["misp_url"],
key=config["misp_key"],
ssl=bool(config.get("misp_verify_ssl", True)),
)
except (KeyError, pymisp.PyMISPError):
logger.error("Integration with pyMISP disabled: no MITRE techniques tags")
misp_client = None
# Load the client to connect to VT (soft-fail)
try:
vt_client = vt.Client(apikey=config["vt_key"])
except (KeyError, ValueError):
logger.error("Integration with VT disabled: no automatic download of samples")
vt_client = None
# Decode and issue the request
try:
if request["attribute"]["type"] == "url":
sample_url = request["attribute"]["value"]
response = analysis_clients[DEFAULT_ENDPOINT].submit_url(sample_url)
task_uuid, tags = _parse_submission_response(response)
else:
if request["attribute"]["type"] == "malware-sample":
# Raise TypeError
file_data = _unzip(base64.b64decode(request["attribute"]["data"]))
file_name = request["attribute"]["value"].split("|", 1)[0]
hash_value = hashlib.sha1(file_data).hexdigest()
elif request["attribute"]["type"] == "attachment":
# Raise TypeError
file_data = base64.b64decode(request["attribute"]["data"])
file_name = request["attribute"].get("value")
hash_value = hashlib.sha1(file_data).hexdigest()
else:
hash_value = request["attribute"]["value"]
file_data = None
file_name = "{}.bin".format(hash_value)
# Check whether we have a task for that file
tags = []
task_uuid = _get_latest_analysis(analysis_clients, hash_value)
if not task_uuid:
# If we have no analysis, download the sample from VT
if not file_data:
if not vt_client:
raise ValueError("No file available locally and VT is disabled")
file_data = _download_from_vt(vt_client, hash_value)
tags.append(VT_DOWNLOAD_TAG)
# ... and submit it (_download_from_vt fails if no sample availabe)
response = analysis_clients[DEFAULT_ENDPOINT].submit_file(file_data, file_name)
task_uuid, _tags = _parse_submission_response(response)
tags.extend(_tags)
except KeyError as e:
logger.error("Error parsing input: %s", request["attribute"])
return {"error": "Error parsing input: {}".format(e)}
except TypeError as e:
logger.error("Error decoding input: %s", request["attribute"])
return {"error": "Error decoding input: {}".format(e)}
except ValueError as e:
logger.error("Error processing input: %s", request["attribute"])
return {"error": "Error processing input: {}".format(e)}
except (exceptions.CommunicationError, exceptions.ApiError) as e:
logger.error("Error issuing API call: %s", str(e))
return {"error": "Error issuing API call: {}".format(e)}
else:
analysis_link = tau_clients.get_task_link(
uuid=task_uuid,
analysis_url=analysis_clients[DEFAULT_ENDPOINT].base,
prefer_load_balancer=True,
)
# Return partial results if the analysis has yet to terminate
try:
tags.extend(_get_analysis_tags(analysis_clients, task_uuid))
report = analysis_clients[DEFAULT_ENDPOINT].get_result(task_uuid)
except (exceptions.CommunicationError, exceptions.ApiError) as e:
logger.error("Error retrieving the report: %s", str(e))
return {
"results": {
"types": "link",
"categories": ["External analysis"],
"values": analysis_link,
"tags": tags,
}
}
# Return the enrichment
try:
techniques_galaxy = None
if misp_client:
techniques_galaxy = _get_mitre_techniques_galaxy(misp_client)
result_parser = ResultParser(techniques_galaxy=techniques_galaxy)
misp_event = result_parser.parse(analysis_link, report)
for tag in tags:
if tag not in frozenset([WORKFLOW_COMPLETE_TAG]):
misp_event.add_tag(tag)
return {
"results": {
key: json.loads(misp_event.to_json())[key]
for key in ("Attribute", "Object", "Tag")
if (key in misp_event and misp_event[key])
}
}
except pymisp.PyMISPError as e:
logger.error("Error parsing the report: %s", str(e))
return {"error": "Error parsing the report: {}".format(e)}
def main():
"""Main function used to test basic functionalities of the module."""
parser = argparse.ArgumentParser()
parser.add_argument(
"-c",
"--config-file",
dest="config_file",
required=True,
help="the configuration file used for testing",
)
parser.add_argument(
"-t",
"--test-attachment",
dest="test_attachment",
default=None,
help="the path to a test attachment",
)
args = parser.parse_args()
conf = configparser.ConfigParser()
conf.read(args.config_file)
config = {
"analysis_verify_ssl": conf.getboolean("analysis", "analysis_verify_ssl"),
"analysis_key": conf.get("analysis", "analysis_key"),
"analysis_api_token": conf.get("analysis", "analysis_api_token"),
"vt_key": conf.get("vt", "vt_key"),
"misp_url": conf.get("misp", "misp_url"),
"misp_verify_ssl": conf.getboolean("misp", "misp_verify_ssl"),
"misp_key": conf.get("misp", "misp_key"),
}
# TEST 1: submit a URL
j = json.dumps(
{
"config": config,
"attribute": {
"type": "url",
"value": "https://www.google.com",
}
}
)
print(json.dumps(handler(j), indent=4, sort_keys=True))
# TEST 2: submit a file attachment
if args.test_attachment:
with open(args.test_attachment, "rb") as f:
data = f.read()
j = json.dumps(
{
"config": config,
"attribute": {
"type": "attachment",
"value": "test.docx",
"data": base64.b64encode(data).decode("utf-8"),
}
}
)
print(json.dumps(handler(j), indent=4, sort_keys=True))
# TEST 3: submit a file hash that is known by NSX ATA
j = json.dumps(
{
"config": config,
"attribute": {
"type": "md5",
"value": "002c56165a0e78369d0e1023ce044bf0",
}
}
)
print(json.dumps(handler(j), indent=4, sort_keys=True))
# TEST 4 : submit a file hash that is NOT known byt NSX ATA
j = json.dumps(
{
"config": config,
"attribute": {
"type": "sha1",
"value": "2aac25ecdccf87abf6f1651ef2ffb30fcf732250",
}
}
)
print(json.dumps(handler(j), indent=4, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())