First commit
This commit is contained in:
commit
816918f041
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.**.swp
|
||||||
|
__pycache__/**
|
15
README.md
Normal file
15
README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Introduction
|
||||||
|
This tool can help SOC analyst to identify any threat
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
First, you should create a virtualenv:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ virtualenv ~/venv/baoSOC
|
||||||
|
$ source ~/venv/baoSOC/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
And install all packages the tool need:
|
||||||
|
```
|
||||||
|
$ pip install -r requirements.txt
|
||||||
|
```
|
2
config
Normal file
2
config
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
api_key: f4c451920a7e41ec344e16e6d36a1b7951bf23a8d224b796cb08301e65bf3114
|
||||||
|
dnsbl: dnsbl.txt
|
13
config.py
Normal file
13
config.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/venv python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
VT_ATTRIBUTES_MAPPING = {
|
||||||
|
'asn': 'str',
|
||||||
|
'as_owner': 'int',
|
||||||
|
'continent': 'str',
|
||||||
|
'country': 'str',
|
||||||
|
'last_analysis_date': 'date',
|
||||||
|
'regional_internet_registry': 'str',
|
||||||
|
'network': 'str',
|
||||||
|
'ip': 'str'
|
||||||
|
}
|
278
dns.py
Normal file
278
dns.py
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
#!/usr/bin/venv python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from scapy.all import *
|
||||||
|
from scapy.layers.dns import DNSQR, DNSRR, DNS
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from reports import generateHtmlReport, createReportsDirectory, getTodayDate
|
||||||
|
from tunneling import tunnelingDNSAttacks
|
||||||
|
from config import VT_ATTRIBUTES_MAPPING
|
||||||
|
import whois
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
|
||||||
|
class DNS:
|
||||||
|
def __init__(self, api_key):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whois(self, fqdn):
|
||||||
|
report = dict()
|
||||||
|
w = whois.whois(fqdn)
|
||||||
|
report['domain_name'] = w.domain_name
|
||||||
|
report['expiration_date'] = w.expiration_date
|
||||||
|
report['creation_date'] = w.creation_date
|
||||||
|
report['updated_date'] = w.updated_date
|
||||||
|
#report['data'] = w.text
|
||||||
|
report['ns'] = w.name_servers
|
||||||
|
report['admin_name'] = w.admin_name
|
||||||
|
return report
|
||||||
|
|
||||||
|
def resolver(self, fqdn):
|
||||||
|
report = dict()
|
||||||
|
|
||||||
|
res_query = dns.resolver.resolve(fqdn)
|
||||||
|
|
||||||
|
for rdata in res_query:
|
||||||
|
print(rdata.target)
|
||||||
|
|
||||||
|
def _getType(t):
|
||||||
|
"""
|
||||||
|
This function identify the message type according to the RFC 1035:
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1035#section-3.2.2
|
||||||
|
"""
|
||||||
|
rtype = {
|
||||||
|
1: 'A', 2: 'NS', 3: 'MD', 4: 'MF',
|
||||||
|
5: 'CNAME', 6: 'SOA', 7: 'MB', 8: 'MG', 9: 'MR',
|
||||||
|
10: 'NULL', 11: 'WKS', 12: 'PTR', 13: 'HINFO', 14: 'MINFO',
|
||||||
|
15: 'MX', 16: 'TXT',
|
||||||
|
28: 'AAAA'
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return rtype[t]
|
||||||
|
except KeyError:
|
||||||
|
return t
|
||||||
|
|
||||||
|
def readPcapFile(pcap):
|
||||||
|
"""
|
||||||
|
This function read the pcap file with scapy
|
||||||
|
"""
|
||||||
|
data = None
|
||||||
|
try:
|
||||||
|
data = rdpcap(pcap)
|
||||||
|
except Scapy_Exception:
|
||||||
|
print(f"Failed to read {pcap}")
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _privateIP(ip):
|
||||||
|
"""
|
||||||
|
This function check if the ip is from RFC 1918
|
||||||
|
https://stackoverflow.com/questions/2814002/private-ip-address-identifier-in-regular-expression
|
||||||
|
"""
|
||||||
|
res = False
|
||||||
|
rfs1918 = [
|
||||||
|
'(^10\.)',
|
||||||
|
'(^172\.1[6-9]\.)',
|
||||||
|
'(^172\.2[0-9]\.)',
|
||||||
|
'(^172\.3[0-1]\.)',
|
||||||
|
'(^192\.168\.)',
|
||||||
|
]
|
||||||
|
for privateIP in rfs1918:
|
||||||
|
if re.search(privateIP, ip):
|
||||||
|
res = True
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def getIPVirusTotal(api_key, ip, report):
|
||||||
|
"""
|
||||||
|
This function get information of the IP
|
||||||
|
"""
|
||||||
|
url = f"https://www.virustotal.com/api/v3/ip_addresses/{ip}"
|
||||||
|
headers = {
|
||||||
|
'x-apikey': api_key,
|
||||||
|
}
|
||||||
|
res = requests.get(url, headers=headers).json()
|
||||||
|
data = dict()
|
||||||
|
data['ip'] = ip
|
||||||
|
|
||||||
|
if 'error' in res:
|
||||||
|
report.append({
|
||||||
|
'error': res['error']['message'],
|
||||||
|
'ip': ip
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
vt = res['data']['attributes']
|
||||||
|
for entry in VT_ATTRIBUTES_MAPPING.keys():
|
||||||
|
if entry in vt:
|
||||||
|
try:
|
||||||
|
data[entry] = vt[entry]
|
||||||
|
except KeyError:
|
||||||
|
data[entry] = 'Unknown'
|
||||||
|
|
||||||
|
report.append(data)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = checkArguments()
|
||||||
|
report = {}
|
||||||
|
report['queries'] = list()
|
||||||
|
report['domains'] = list()
|
||||||
|
report['graphics'] = dict()
|
||||||
|
report['ip'] = list()
|
||||||
|
report['vt'] = list()
|
||||||
|
report['dnstunneling'] = dict()
|
||||||
|
|
||||||
|
if not args.file:
|
||||||
|
print("Please, specify the option --file")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
if not args.config:
|
||||||
|
print("Please, specify the config file ith the parameter --config")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
# Read the config file
|
||||||
|
config = readConfigFile(args.config)
|
||||||
|
if config is None:
|
||||||
|
print("Failed to read the config file")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
# Get the API key for VirusTotal
|
||||||
|
checkVt = True
|
||||||
|
try:
|
||||||
|
api_key = config["api_key"]
|
||||||
|
except KeyError:
|
||||||
|
print("Can't find the key in the config file. Bypass the check to VirusTotal")
|
||||||
|
checkVt = False
|
||||||
|
#exit(0)
|
||||||
|
|
||||||
|
# Check if DNS BlackList is specified
|
||||||
|
try:
|
||||||
|
dnsbl = config["dnsbl"]
|
||||||
|
except KeyError:
|
||||||
|
print("Can't find the dnsbl in the config file")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
data = readPcapFile(args.file)
|
||||||
|
if data is None:
|
||||||
|
print("Failed to read the pcap file")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
# For number of request into the domain
|
||||||
|
numRequest = dict()
|
||||||
|
numIPDst = dict()
|
||||||
|
allIPs = list()
|
||||||
|
allDomains = list()
|
||||||
|
|
||||||
|
print("Parsing DNS capture")
|
||||||
|
for d in data:
|
||||||
|
if d.haslayer(DNS):
|
||||||
|
# For DNS query
|
||||||
|
if isinstance(d.qd, DNSQR):
|
||||||
|
query = d.qd.qname
|
||||||
|
qtype = _getType(d.qd.qtype)
|
||||||
|
src = d[IP].src
|
||||||
|
dst = d[IP].dst
|
||||||
|
# print(f"{d[DNS].id}; SRC: {src}; DST: {dst}, Query: {query}; TYPE: {qtype}; Size: {len(d[DNS])}")
|
||||||
|
report['queries'].append({
|
||||||
|
'id': d[DNS].id,
|
||||||
|
'src': src,
|
||||||
|
'dst': dst,
|
||||||
|
'query': query,
|
||||||
|
'type': qtype,
|
||||||
|
'len': len(d[DNS]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Count the number of request for a domain
|
||||||
|
if query not in numRequest:
|
||||||
|
numRequest[query] = 1
|
||||||
|
else:
|
||||||
|
numRequest[query] += 1
|
||||||
|
|
||||||
|
# Count the number of IP dst
|
||||||
|
if dst not in numIPDst:
|
||||||
|
numIPDst[dst] = 1
|
||||||
|
else:
|
||||||
|
numIPDst[dst] += 1
|
||||||
|
|
||||||
|
if not _privateIP(dst) and dst not in allIPs:
|
||||||
|
allIPs.append(dst)
|
||||||
|
|
||||||
|
allDomains.append({
|
||||||
|
'query': query,
|
||||||
|
'src': src,
|
||||||
|
'len': len(d[DNS]),
|
||||||
|
})
|
||||||
|
# For DNS response
|
||||||
|
if isinstance(d.an, DNSRR):
|
||||||
|
ans = d.an.rrname
|
||||||
|
atype = _getType(d.an.type)
|
||||||
|
src = d[IP].src
|
||||||
|
dst = d[IP].dst
|
||||||
|
# print(f"{d[DNS].id}; SRC: {src}; DST: {dst}, Answer: {ans}; TYPE: {atype}")
|
||||||
|
report['queries'].append({
|
||||||
|
'id': d[DNS].id,
|
||||||
|
'src': src,
|
||||||
|
'dst': dst,
|
||||||
|
'answer': ans,
|
||||||
|
'type': atype,
|
||||||
|
'len': len(d[DNS]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create directory for the report
|
||||||
|
print("Generating directory for reports")
|
||||||
|
reportName = createReportsDirectory()
|
||||||
|
|
||||||
|
# Draw matplotlib
|
||||||
|
today = getTodayDate()
|
||||||
|
fname_domain = f'domain_{today}.png'
|
||||||
|
fname_ip = f'ip_{today}.png'
|
||||||
|
report['graphics']['domain'] = fname_domain
|
||||||
|
report['graphics']['ip'] = fname_ip
|
||||||
|
|
||||||
|
x = list(numRequest.keys())[0:20]
|
||||||
|
y = list(numRequest.values())[0:20]
|
||||||
|
|
||||||
|
plt.figure(figsize=(15, 5))
|
||||||
|
plt.barh(x, y)
|
||||||
|
#plt.show()
|
||||||
|
plt.savefig(
|
||||||
|
fname=f"reports/{today}/{fname_domain}",
|
||||||
|
dpi='figure',
|
||||||
|
format='png'
|
||||||
|
)
|
||||||
|
x = list(numIPDst.keys())[0:20]
|
||||||
|
y = list(numIPDst.values())[0:20]
|
||||||
|
|
||||||
|
plt.figure(figsize=(10, 5))
|
||||||
|
plt.barh(x, y, 0.5)
|
||||||
|
#plt.show()
|
||||||
|
plt.savefig(
|
||||||
|
fname=f"reports/{today}/{fname_ip}",
|
||||||
|
dpi='figure',
|
||||||
|
format='png'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Identify DNS Tunneling attacks
|
||||||
|
print("Analyzing DNS capture for identifying DNS Tunneling")
|
||||||
|
tunnelingDNSAttacks(report['dnstunneling'], allDomains, dnsbl)
|
||||||
|
|
||||||
|
# For external IP, use curl to VirusTotal for analyzing the IP
|
||||||
|
checkVt = False
|
||||||
|
if checkVt:
|
||||||
|
print("Getting IP informations to VirusTotal")
|
||||||
|
for ip in allIPs:
|
||||||
|
getIPVirusTotal(api_key, ip, report['vt'])
|
||||||
|
|
||||||
|
# We generating the report
|
||||||
|
print("Generating the report")
|
||||||
|
generateHtmlReport(report)
|
||||||
|
|
||||||
|
print(f"Report generated at this directory: {reportName}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
97
main.py
Normal file
97
main.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/venv python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from config import VT_ATTRIBUTES_MAPPING
|
||||||
|
from vt import VT
|
||||||
|
from dns import DNS
|
||||||
|
|
||||||
|
|
||||||
|
def checkArguments():
|
||||||
|
parser = ArgumentParser(description="baoSOC")
|
||||||
|
parser.add_argument('-c', '--config', help='Config file')
|
||||||
|
parser.add_argument('--hash', help='Hash file', action='store_true')
|
||||||
|
parser.add_argument('--dns', help='WhoIs')
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def usage():
|
||||||
|
print("------------------------------")
|
||||||
|
print("| baoSOC |")
|
||||||
|
print("------------------------------\n")
|
||||||
|
print("A tool for SOC analyst\n")
|
||||||
|
print("Usage: main.py [COMMAND]")
|
||||||
|
print("-c PATH, --config PATH\t\tConfig file - mandatory")
|
||||||
|
print("--hash FILE\t\t\tHash the file and check in VirusTotal")
|
||||||
|
print("--dns FQDN\t\t\tGet information regarding the domain with whois and VirusTotal")
|
||||||
|
|
||||||
|
def mainMenu():
|
||||||
|
print("\n baoSOC ")
|
||||||
|
print(" What would you like to do? ")
|
||||||
|
print("\n OPTION 1: Sanitise URL For emails ")
|
||||||
|
print(" OPTION 2: Decoders (PP, URL, SafeLinks) ")
|
||||||
|
print(" OPTION 3: Reputation Checker")
|
||||||
|
print(" OPTION 4: DNS Tools")
|
||||||
|
print(" OPTION 5: Hashing Function")
|
||||||
|
print(" OPTION 6: Phishing Analysis")
|
||||||
|
print(" OPTION 7: URL scan")
|
||||||
|
print(" OPTION 9: Extras")
|
||||||
|
print(" OPTION 0: Exit Tool")
|
||||||
|
|
||||||
|
def readConfigFile(config):
|
||||||
|
"""
|
||||||
|
This function read the config file
|
||||||
|
"""
|
||||||
|
data = {}
|
||||||
|
try:
|
||||||
|
with open(config, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Split each line into te dictionary
|
||||||
|
for line in lines:
|
||||||
|
l = line.split(":")
|
||||||
|
lineParsed = l[1].replace(" ", "")
|
||||||
|
lineParsed = lineParsed.replace("\n", "")
|
||||||
|
data[l[0]] = lineParsed
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = checkArguments()
|
||||||
|
|
||||||
|
if not args.config:
|
||||||
|
usage()
|
||||||
|
exit(1);
|
||||||
|
|
||||||
|
# Read the config file
|
||||||
|
config = readConfigFile(args.config)
|
||||||
|
if config is None:
|
||||||
|
print("Failed to read the config file")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
#vt = VT(config['api_key'])
|
||||||
|
#report = list()
|
||||||
|
#print(vt.getIPVirusTotal("1.1.1.1", report))
|
||||||
|
|
||||||
|
if args.dns:
|
||||||
|
dns = DNS(config['api_key'])
|
||||||
|
|
||||||
|
print("IP information:\n")
|
||||||
|
|
||||||
|
print("\nReport with Whois:\n")
|
||||||
|
report = dns.whois(args.dns)
|
||||||
|
for key in report.keys():
|
||||||
|
if isinstance(report[key], list):
|
||||||
|
print(f"{key}:")
|
||||||
|
for value in report[key]:
|
||||||
|
print(f"\t{value}")
|
||||||
|
else:
|
||||||
|
print(f"{key}: {report[key]}")
|
||||||
|
|
||||||
|
print("\nReport with VirusTotal:\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
157
reports.py
Normal file
157
reports.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/venv python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from os import mkdir
|
||||||
|
from config import VT_ATTRIBUTES_MAPPING
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
|
||||||
|
def generateHtmlReport(data):
|
||||||
|
env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader("reports/templates"),
|
||||||
|
autoescape=jinja2.select_autoescape()
|
||||||
|
)
|
||||||
|
|
||||||
|
_queriesReport(data['queries'], env)
|
||||||
|
_graphicsReport(data['graphics'], env)
|
||||||
|
_vtReport(data['vt'], env)
|
||||||
|
_dnsTunnelingReports(data['dnstunneling'], env)
|
||||||
|
|
||||||
|
def _indexReport():
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _queriesReport(queries, env):
|
||||||
|
"""
|
||||||
|
This function generate the report for queries
|
||||||
|
"""
|
||||||
|
today = getTodayDate()
|
||||||
|
dataJinja2 = dict()
|
||||||
|
dataJinja2['title'] = 'Queries'
|
||||||
|
dataJinja2['year'] = '2023'
|
||||||
|
dataJinja2['queries'] = queries
|
||||||
|
|
||||||
|
tmpl = env.get_template('queries.html.j2')
|
||||||
|
|
||||||
|
render = tmpl.render(data=dataJinja2)
|
||||||
|
|
||||||
|
with open(f"reports/{today}/reports_queries.html", "w") as f:
|
||||||
|
f.write(render)
|
||||||
|
|
||||||
|
def _graphicsReport(graphics, env):
|
||||||
|
today = getTodayDate()
|
||||||
|
|
||||||
|
dataJinja2 = dict()
|
||||||
|
dataJinja2['title'] = 'Graphics'
|
||||||
|
dataJinja2['year'] = '2023'
|
||||||
|
dataJinja2['graphics'] = graphics
|
||||||
|
|
||||||
|
tmpl = env.get_template('graphics.html.j2')
|
||||||
|
|
||||||
|
render = tmpl.render(data=dataJinja2)
|
||||||
|
|
||||||
|
with open(f"reports/{today}/reports_graphics.html", "w") as f:
|
||||||
|
f.write(render)
|
||||||
|
|
||||||
|
def _vtReport(vt, env):
|
||||||
|
today = getTodayDate()
|
||||||
|
# For testing
|
||||||
|
#vt = list()
|
||||||
|
#vt.append({
|
||||||
|
# 'ip': '1.2.3.4',
|
||||||
|
# 'asn': 3215,
|
||||||
|
# 'as_owner': 'Orange',
|
||||||
|
# 'continent': 'EU',
|
||||||
|
# 'country': 'FR',
|
||||||
|
# 'last_analysis_date': 1686839532,
|
||||||
|
# 'regional_internet_registry': 'RIPE NCC',
|
||||||
|
# 'network': '1.2.3.0/24'
|
||||||
|
#})
|
||||||
|
#vt.append({
|
||||||
|
# 'ip': '2.2.2.1',
|
||||||
|
# 'asn': 3215,
|
||||||
|
# 'as_owner': 'Orange',
|
||||||
|
# 'continent': 'EU',
|
||||||
|
# 'country': 'FR',
|
||||||
|
# 'last_analysis_date': 1686839532,
|
||||||
|
# 'regional_internet_registry': 'RIPE NCC',
|
||||||
|
# 'network': '2.2.2.0/24'
|
||||||
|
#})
|
||||||
|
#vt.append({
|
||||||
|
# 'ip': '3.3.3.1',
|
||||||
|
# 'asn': 3215,
|
||||||
|
# 'as_owner': 'Orange',
|
||||||
|
# 'continent': 'EU',
|
||||||
|
# 'country': 'FR',
|
||||||
|
# 'last_analysis_date': 1686839532,
|
||||||
|
# 'regional_internet_registry': 'RIPE NCC',
|
||||||
|
# 'network': '3.3.3.0/24'
|
||||||
|
#})
|
||||||
|
|
||||||
|
dataJinja2 = dict()
|
||||||
|
dataJinja2['title'] = 'VirusTotal'
|
||||||
|
dataJinja2['year'] = '2023'
|
||||||
|
dataJinja2['vt'] = list()
|
||||||
|
|
||||||
|
tmpl = env.get_template('vt.html.j2')
|
||||||
|
|
||||||
|
body = str()
|
||||||
|
|
||||||
|
for entry in vt:
|
||||||
|
vtEntry = dict()
|
||||||
|
if 'error' not in entry:
|
||||||
|
for vt in VT_ATTRIBUTES_MAPPING.keys():
|
||||||
|
try:
|
||||||
|
vtAttributes = VT_ATTRIBUTES_MAPPING[vt]
|
||||||
|
if 'date' in vtAttributes:
|
||||||
|
value = datetime.fromtimestamp(int(entry[vt]))
|
||||||
|
else:
|
||||||
|
value = entry[vt]
|
||||||
|
vtEntry[vt] = value
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
dataJinja2['vt'].append({
|
||||||
|
'ip': entry['ip'],
|
||||||
|
'data': vtEntry
|
||||||
|
})
|
||||||
|
|
||||||
|
render = tmpl.render(data=dataJinja2)
|
||||||
|
|
||||||
|
with open(f"reports/{today}/reports_vt.html", "w") as f:
|
||||||
|
f.write(render)
|
||||||
|
|
||||||
|
def _dnsTunnelingReports(dnstunneling, env):
|
||||||
|
today = getTodayDate()
|
||||||
|
|
||||||
|
dataJinja2 = dict()
|
||||||
|
dataJinja2['title'] = 'DNS Tunneling'
|
||||||
|
dataJinja2['year'] = '2023'
|
||||||
|
dataJinja2['dnstunneling'] = dnstunneling
|
||||||
|
|
||||||
|
tmpl = env.get_template('dnsTunneling.html.j2')
|
||||||
|
|
||||||
|
render = tmpl.render(data=dataJinja2)
|
||||||
|
|
||||||
|
with open(f"reports/{today}/reports_dns_tunneling.html", "w") as f:
|
||||||
|
f.write(render)
|
||||||
|
|
||||||
|
def createReportsDirectory():
|
||||||
|
"""
|
||||||
|
This function will create the reports directory
|
||||||
|
Return the report name or None if failed
|
||||||
|
"""
|
||||||
|
today = getTodayDate()
|
||||||
|
name = f"reports/{today}"
|
||||||
|
try:
|
||||||
|
mkdir(name)
|
||||||
|
except FileExistsError:
|
||||||
|
print("Reports directory already created")
|
||||||
|
return name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def getTodayDate():
|
||||||
|
"""
|
||||||
|
This function genrate the today datetime at this format:
|
||||||
|
year_month_day
|
||||||
|
"""
|
||||||
|
return datetime.now().isoformat()[0:10].replace("-", "_")
|
7
reports/templates/bootstrap.bundle.min.js
vendored
Normal file
7
reports/templates/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
reports/templates/bootstrap.min.css
vendored
Normal file
6
reports/templates/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
56
reports/templates/dnsTunneling.html.j2
Normal file
56
reports/templates/dnsTunneling.html.j2
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="auto">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
|
||||||
|
<meta name="generator" content="Hugo 0.112.5">
|
||||||
|
<title>{{ data['title'] }}</title>
|
||||||
|
<link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/jumbotron/">
|
||||||
|
<link href="../templates/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container py-4">
|
||||||
|
<header class="pb-3 mb-4 border-bottom">
|
||||||
|
<span class="d-flex align-items-center text-body-emphasis text-decoration-none; fs-4">Report</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% include 'nav.html.j2' %}
|
||||||
|
|
||||||
|
<div class="p-5 mb-4 bg-body-tertiary rounded-3">
|
||||||
|
<div class="container-fluid py-5">
|
||||||
|
<h1 class="display-5 fw-bold">Report - DNS Tunneling</h1>
|
||||||
|
<p class="col-md-8 fs-4">Report of the day... blablabla</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>DNS Blacklist</h2>
|
||||||
|
{% for item in data['dnstunneling']['blackDomain'] %}
|
||||||
|
<h3>{{ item }}</h3>
|
||||||
|
<p>Sources</p>
|
||||||
|
<ul>
|
||||||
|
{% for src in data['dnstunneling']['blackDomain'][item] %}
|
||||||
|
<li>{{ src }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<h2>DNS datagram oversized</h2>
|
||||||
|
<p>In this section, we analyzed all sources who done DNS request with datagram oversized (200 bytes) and that can be a DNS tunneling.</p>
|
||||||
|
{% for item in data['dnstunneling']['oversized'] %}
|
||||||
|
<h3>{{ item }}</h3>
|
||||||
|
<p>Sources</p>
|
||||||
|
<ul>
|
||||||
|
{% for entry in data['dnstunneling']['oversized'][item] %}
|
||||||
|
<li>{{ entry['source'] }} ({{ entry['len'] }} bytes)</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
{% include 'footer.html.j2' %}
|
11
reports/templates/footer.html.j2
Normal file
11
reports/templates/footer.html.j2
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
<footer class="pt-3 mt-4 text-body-secondary border-top">
|
||||||
|
© {{ data['year'] }}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="./templates/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
36
reports/templates/graphics.html.j2
Normal file
36
reports/templates/graphics.html.j2
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="auto">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
|
||||||
|
<meta name="generator" content="Hugo 0.112.5">
|
||||||
|
<title>{{ data['title'] }}</title>
|
||||||
|
<link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/jumbotron/">
|
||||||
|
<link href="../templates/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container py-4">
|
||||||
|
<header class="pb-3 mb-4 border-bottom">
|
||||||
|
<span class="d-flex align-items-center text-body-emphasis text-decoration-none; fs-4">Report</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% include 'nav.html.j2' %}
|
||||||
|
|
||||||
|
<div class="p-5 mb-4 bg-body-tertiary rounded-3">
|
||||||
|
<div class="container-fluid py-5">
|
||||||
|
<h1 class="display-5 fw-bold">Report - graphics</h1>
|
||||||
|
<p class="col-md-8 fs-4">Report of the day... blablabla</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Domains (20 first entries)</h3>
|
||||||
|
<img src="{{ data['graphics']['domain'] }}" title="Graphic" />
|
||||||
|
|
||||||
|
<h3>Destinations IP (20 first entries)</h3>
|
||||||
|
<img src="{{ data['graphics']['ip'] }}" title="Graphic" />
|
||||||
|
|
||||||
|
{% include 'footer.html.j2' %}
|
113
reports/templates/index.html.j2
Normal file
113
reports/templates/index.html.j2
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="auto">
|
||||||
|
<head><script src="../assets/js/color-modes.js"></script>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
|
||||||
|
<meta name="generator" content="Hugo 0.112.5">
|
||||||
|
<title>Jumbotron example · Bootstrap v5.3</title>
|
||||||
|
|
||||||
|
<link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/jumbotron/">
|
||||||
|
|
||||||
|
<link href="./templates/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bd-placeholder-img {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
text-anchor: middle;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bd-placeholder-img-lg {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.b-example-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 3rem;
|
||||||
|
background-color: rgba(0, 0, 0, .1);
|
||||||
|
border: solid rgba(0, 0, 0, .15);
|
||||||
|
border-width: 1px 0;
|
||||||
|
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b-example-vr {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi {
|
||||||
|
vertical-align: -.125em;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-scroller {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
height: 2.75rem;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-scroller .nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-top: -1px;
|
||||||
|
overflow-x: auto;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bd-primary {
|
||||||
|
--bd-violet-bg: #712cf9;
|
||||||
|
--bd-violet-rgb: 112.520718, 44.062154, 249.437846;
|
||||||
|
|
||||||
|
--bs-btn-font-weight: 600;
|
||||||
|
--bs-btn-color: var(--bs-white);
|
||||||
|
--bs-btn-bg: var(--bd-violet-bg);
|
||||||
|
--bs-btn-border-color: var(--bd-violet-bg);
|
||||||
|
--bs-btn-hover-color: var(--bs-white);
|
||||||
|
--bs-btn-hover-bg: #6528e0;
|
||||||
|
--bs-btn-hover-border-color: #6528e0;
|
||||||
|
--bs-btn-focus-shadow-rgb: var(--bd-violet-rgb);
|
||||||
|
--bs-btn-active-color: var(--bs-btn-hover-color);
|
||||||
|
--bs-btn-active-bg: #5a23c8;
|
||||||
|
--bs-btn-active-border-color: #5a23c8;
|
||||||
|
}
|
||||||
|
.bd-mode-toggle {
|
||||||
|
z-index: 1500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container py-4">
|
||||||
|
<header class="pb-3 mb-4 border-bottom">
|
||||||
|
<span class="d-flex align-items-center text-body-emphasis text-decoration-none; fs-4">Report</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-5 mb-4 bg-body-tertiary rounded-3">
|
||||||
|
<div class="container-fluid py-5">
|
||||||
|
<h1 class="display-5 fw-bold">Report</h1>
|
||||||
|
<p class="col-md-8 fs-4">Report of the day... blablabla</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for plugin in data['plugins'] %}
|
||||||
|
{% include plugin %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
|
{% include 'footer.html.j2' %}
|
||||||
|
|
16
reports/templates/nav.html.j2
Normal file
16
reports/templates/nav.html.j2
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<nav>
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="reports_queries.html">Queries</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="reports_graphics.html">Graphics</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="reports_vt.html">VirusTotal</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="reports_dns_tunneling.html">DNS Tunneling</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
43
reports/templates/queries.html.j2
Normal file
43
reports/templates/queries.html.j2
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="auto">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
|
||||||
|
<meta name="generator" content="Hugo 0.112.5">
|
||||||
|
<title>{{ data['title'] }}</title>
|
||||||
|
<link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/jumbotron/">
|
||||||
|
<link href="../templates/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container py-4">
|
||||||
|
<header class="pb-3 mb-4 border-bottom">
|
||||||
|
<span class="d-flex align-items-center text-body-emphasis text-decoration-none; fs-4">Report</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% include 'nav.html.j2' %}
|
||||||
|
|
||||||
|
<div class="p-5 mb-4 bg-body-tertiary rounded-3">
|
||||||
|
<div class="container-fluid py-5">
|
||||||
|
<h1 class="display-5 fw-bold">Report - queries</h1>
|
||||||
|
<p class="col-md-8 fs-4">Report of the day... blablabla</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for item in data['queries'] %}
|
||||||
|
Packet id: {{ item['id'] }}<br />
|
||||||
|
<span style="margin-left:15pt;">Src: {{ item['src'] }};
|
||||||
|
Dest: {{ item['dst'] }};
|
||||||
|
{% if 'query' in item %}
|
||||||
|
Query: {{ item['query'] }};
|
||||||
|
{% elif 'answer' in item %}
|
||||||
|
Answer: {{ item['answer'] }};
|
||||||
|
{% endif %}
|
||||||
|
Type: {{ item['type'] }}
|
||||||
|
Len: {{ item['len'] }}</span><br /><br />
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% include 'footer.html.j2' %}
|
44
reports/templates/vt.html.j2
Normal file
44
reports/templates/vt.html.j2
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="auto">
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
|
||||||
|
<meta name="generator" content="Hugo 0.112.5">
|
||||||
|
<title>{{ data['title'] }}</title>
|
||||||
|
<link rel="canonical" href="https://getbootstrap.com/docs/5.3/examples/jumbotron/">
|
||||||
|
<link href="../templates/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container py-4">
|
||||||
|
<header class="pb-3 mb-4 border-bottom">
|
||||||
|
<span class="d-flex align-items-center text-body-emphasis text-decoration-none; fs-4">Report</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% include 'nav.html.j2' %}
|
||||||
|
|
||||||
|
<div class="p-5 mb-4 bg-body-tertiary rounded-3">
|
||||||
|
<div class="container-fluid py-5">
|
||||||
|
<h1 class="display-5 fw-bold">Report</h1>
|
||||||
|
<p class="col-md-8 fs-4">Get IP information for each public IP addresses from the DNS capture. Get informations from <a href='https://www.virustotal.com' title='VirusTotal'/>VirusTotal</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for entry in data['vt'] %}
|
||||||
|
<h3>{{ entry['ip']}}</h3>
|
||||||
|
{% if 'error' in entry %}
|
||||||
|
<p style='color:red'>{{ entry['error'] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<ul>
|
||||||
|
{% for vt in entry['data'] %}
|
||||||
|
<li><strong>{{ vt }}</strong>: {{ entry['data'][vt] }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% include 'footer.html.j2' %}
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
pip
|
||||||
|
scapy
|
||||||
|
ArgumentParser
|
||||||
|
matplotlib
|
||||||
|
numpy
|
||||||
|
vt-py
|
||||||
|
requests
|
||||||
|
jinja2
|
||||||
|
python-whois
|
||||||
|
git+https://github.com/rthalley/dnspython.git
|
82
tunneling.py
Normal file
82
tunneling.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/venv python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
def tunnelingDNSAttacks(report, queries, dnsbl):
|
||||||
|
"""
|
||||||
|
This function identify if we have a DNS tunneling
|
||||||
|
We can identify the payload size (512 bytes) and for a specific domain, how many occurs it appear.
|
||||||
|
For the payload size, according to the RFC 1035: https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.4
|
||||||
|
The maximum UDP message is 512 or less.
|
||||||
|
If we detect a lot of request to a specific domain, that can be a DNS tunneling
|
||||||
|
"""
|
||||||
|
report['blackDomain'] = _blackDomain(queries, dnsbl)
|
||||||
|
report['oversized'] = _oversized(queries)
|
||||||
|
|
||||||
|
def _blackDomain(queries, dnsbl) -> list:
|
||||||
|
""""
|
||||||
|
This function identify if a dns domain is in a blacklist
|
||||||
|
Return the report
|
||||||
|
"""
|
||||||
|
report = dict()
|
||||||
|
|
||||||
|
# Read the black domain list
|
||||||
|
dataDnsbl = list()
|
||||||
|
with open(dnsbl, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
dataDnsbl.append(line.replace("\n", ""))
|
||||||
|
|
||||||
|
for query in queries:
|
||||||
|
domain = _splitDomain(query['query'])
|
||||||
|
|
||||||
|
# Check if in dns blacklist
|
||||||
|
if domain in dataDnsbl:
|
||||||
|
if domain not in report:
|
||||||
|
report[domain] = list()
|
||||||
|
report[domain].append(query['src'])
|
||||||
|
else:
|
||||||
|
if query['src'] not in report[domain]:
|
||||||
|
report[domain].append(query['src'])
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _oversized(queries) -> dict:
|
||||||
|
"""
|
||||||
|
This function analyzed the payload size and if it's oversize, that can be a DNS tunneling
|
||||||
|
"""
|
||||||
|
report = dict()
|
||||||
|
|
||||||
|
for query in queries:
|
||||||
|
if query['len'] > 220:
|
||||||
|
domain = _splitDomain(query['query'])
|
||||||
|
|
||||||
|
if domain not in report:
|
||||||
|
report[domain] = list()
|
||||||
|
report[domain].append({
|
||||||
|
'source': query['src'],
|
||||||
|
'len': query['len'],
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if query['src'] not in report[domain]:
|
||||||
|
report[domain].append({
|
||||||
|
'source': query['src'],
|
||||||
|
'len': query['len'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _splitDomain(query) -> str:
|
||||||
|
"""
|
||||||
|
This function split the query for identifying the domain
|
||||||
|
"""
|
||||||
|
# Split to get the country code and the TLD
|
||||||
|
domainSplitted = str(query).split(".")
|
||||||
|
l = len(domainSplitted)
|
||||||
|
tld = domainSplitted[l - 3:l - 2]
|
||||||
|
cc = domainSplitted[l - 2 :l - 1]
|
||||||
|
try:
|
||||||
|
domain = f"{tld[0]}.{cc[0]}"
|
||||||
|
except IndexError:
|
||||||
|
print(f"Failed to parse the domain {query}")
|
||||||
|
domain = None
|
||||||
|
|
||||||
|
return domain
|
48
vt.py
Normal file
48
vt.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/ur/bin/env python3
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class VT:
|
||||||
|
def __init__(self, api_key):
|
||||||
|
self._url = "https://www.virustotal.com/api/v3"
|
||||||
|
self._headers = {
|
||||||
|
'x-apki-key': api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
def getIPVirusTotal(self, ip, report):
|
||||||
|
res = requests.get(
|
||||||
|
f"{self._url}/ip_addresses/{ip}",
|
||||||
|
headers=self._headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
data = dict()
|
||||||
|
data['ip'] = ip
|
||||||
|
|
||||||
|
if 'error' in res:
|
||||||
|
report.append({
|
||||||
|
'error': res['error']['message'],
|
||||||
|
'ip': ip
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
vt = res['data']['attributes']
|
||||||
|
for entry in VT_ATTRIBUTES_MAPPING.keys():
|
||||||
|
if entry in vt:
|
||||||
|
try:
|
||||||
|
data[entry] = vt[entry]
|
||||||
|
except KeyError:
|
||||||
|
data[entry] = 'Unknown'
|
||||||
|
|
||||||
|
report.append(data)
|
||||||
|
|
||||||
|
def getRateFromHash(self, h, report):
|
||||||
|
headers = self._headers
|
||||||
|
headers['resource'] = h
|
||||||
|
|
||||||
|
res = requests.get(
|
||||||
|
f"{self._url}/file/report",
|
||||||
|
headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
data = dict()
|
Loading…
Reference in New Issue
Block a user