From 4422db586d26419f3eac6e239bdd60ba35ae263b Mon Sep 17 00:00:00 2001 From: Quim Montal Date: Fri, 12 Oct 2018 16:30:14 +0200 Subject: [PATCH] Jira module fully working (#104) * clean OS X .DS_Store files * fix nessus end of line carriage, added JIRA args * JIRA module fully working * jira module working with nessus * added check on already existing jira config, update README * qualys_vm<->jira working, qualys_vm database entries with qualys_vm, improved checks * JIRA module updates ticket's assets and comments update * added JIRA auto-close function for resolved vulnerabitilies * fix if components variable empty issue * fix creation of new ticket after updating existing one * final fixes, added extra line in template * added vulnerability criticality as label in order to be able to filter --- .DS_Store | Bin 8196 -> 0 bytes .gitignore | 3 + README.md | 8 + bin/vuln_whisperer | 12 +- requirements.txt | 8 +- setup.py | 4 +- vulnwhisp/.DS_Store | Bin 6148 -> 0 bytes vulnwhisp/base/config.py | 47 ++- vulnwhisp/frameworks/nessus.py | 440 +++++++++++------------ vulnwhisp/reporting/__init__.py | 0 vulnwhisp/reporting/jira_api.py | 320 +++++++++++++++++ vulnwhisp/reporting/resources/ticket.tpl | 32 ++ vulnwhisp/vulnwhisp.py | 280 ++++++++++++++- 13 files changed, 922 insertions(+), 232 deletions(-) delete mode 100644 .DS_Store delete mode 100644 vulnwhisp/.DS_Store create mode 100755 vulnwhisp/reporting/__init__.py create mode 100644 vulnwhisp/reporting/jira_api.py create mode 100644 vulnwhisp/reporting/resources/ticket.tpl diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 901341c2d258a81efe8251afb95c2820b4fdb679..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMO>h)N6n-xRm=1(Y5Rx6j!b&O-Fo7gMew3e0Hef5b_W} zB&t&!Aar5@#si#@C{*d2GJ3#36@w51r8>!Dd^lk|z!`~B9Z;$R1~X$2p`e(Z^kQN- zU|eEYh8_q#u+Rhi_%O&p7Hn|tkKet?lU!#=W#&iJbqUw;b;~g`LfJA*8Zz8(Ifk3> zaI=o#iSHz(Kv9^&M%2jERBOZTx>#d#<8)nYsP)x}MfM5=jIGkTx{vgXsA|?TpEYgWw{m8u?wj6- zTIP|>-D%f3kuh^*Uw_)Qvl9+U%G0`?&eCAB-L{j~1=Abw&2v7l4S8$kBxXz3l&R%w85<(sIKFkpGBbU7%kaks zbuVo?hUJX)=Pb`k*=Db!PnhLc$JlH|Wu#QwplvLO=Bp@bwK9-RS!c7Be_qV7X|u+L z4O;mz*Wj}l>W2ywlIqQxc8W_98IwvwQD)UOWu>fE+rd;_>GjAG*;CJUG1X(e&(p?= z9<7P(QB;HV*?P*fWmgMpQ`8>TpOfaw&b4g6!Uodr!~_}XiD?hUHSIY*?NQzHk58Ho zE$IuIyR!hEL%>Y{w(m zgU9e0OyV$}#t|IFGw9+ZX3)bayoj&htN1#;iSOezypA{UCf>r2@e}+MZ{r=jE7w<4 z;OtS^E|mB^)fPFP#M$6@77Z@O+32CePbdBp&fZ%siTc>O^_!||>zi8k#pkwj-Y(wH zB|#p^4#AoNE5tTFKu1n#Z9^4{_7Se;B z@i2B_H<2)jeb|o!IEX1Uh=OA{j%SI2S5B z=_KMc!bPZ;OJ*F~b;d|qAnk*SVe4V>z%see2#@~{E&cod!(vXj&d>v)2mXNvu(&hX z*+Gg+UFba4j!_<^%o86sBT=YOA;NJ&5snki{b5M;7!Bn*@c?HeQV*qn{fB^xX!!nz L@4w*Qqg?zACA#E- diff --git a/.gitignore b/.gitignore index 170c4a8..9a26eca 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,6 @@ ENV/ # mypy .mypy_cache/ + +# Mac +.DS_Store diff --git a/README.md b/README.md index 2a96b9b..9e13a05 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,16 @@ Currently Supports - [ ] [Nexpose](https://www.rapid7.com/products/nexpose/) - [ ] [Insight VM](https://www.rapid7.com/products/insightvm/) - [ ] [NMAP](https://nmap.org/) +- [ ] [Burp Suite](https://portswigger.net/burp) +- [ ] [OWASP ZAP](https://www.zaproxy.org/) - [ ] More to come +### Reporting Frameworks + +- [X] [ELK](https://www.elastic.co/elk-stack) +- [X] [Jira](https://www.atlassian.com/software/jira) +- [ ] [Splunk](https://www.splunk.com/) + Getting Started =============== diff --git a/bin/vuln_whisperer b/bin/vuln_whisperer index ee77ab6..98b8686 100644 --- a/bin/vuln_whisperer +++ b/bin/vuln_whisperer @@ -24,6 +24,10 @@ def main(): help='Path of config file', type=lambda x: isFileValid(parser, x.strip())) parser.add_argument('-s', '--section', dest='section', required=False, help='Section in config') + parser.add_argument('--source', dest='source', required=False, + help='JIRA required only! Source scanner to report') + parser.add_argument('-n', '--scanname', dest='scanname', required=False, + help='JIRA required only! Scan name from scan to report') parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', default=True, help='Prints status out to screen (defaults to True)') parser.add_argument('-u', '--username', dest='username', required=False, default=None, type=lambda x: x.strip(), help='The NESSUS username') @@ -46,7 +50,9 @@ def main(): profile=section, verbose=args.verbose, username=args.username, - password=args.password) + password=args.password, + source=args.source, + scanname=args.scanname) vw.whisper_vulnerabilities() sys.exit(1) @@ -56,7 +62,9 @@ def main(): profile=args.section, verbose=args.verbose, username=args.username, - password=args.password) + password=args.password, + source=args.source, + scanname=args.scanname) vw.whisper_vulnerabilities() sys.exit(1) diff --git a/requirements.txt b/requirements.txt index 839bfcb..b0d2d3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ pandas==0.20.3 -setuptools==0.9.8 +setuptools==40.4.3 pytz==2017.2 Requests==2.18.3 -qualysapi==4.1.0 +#qualysapi==4.1.0 lxml==4.1.1 -bs4 \ No newline at end of file +bs4 +jira +bottle diff --git a/setup.py b/setup.py index e5b8277..baeb231 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name='VulnWhisperer', - version='1.5.0', + version='1.7.1', packages=find_packages(), url='https://github.com/austin-taylor/vulnwhisperer', license="""MIT License @@ -26,7 +26,7 @@ setup( SOFTWARE.""", author='Austin Taylor', author_email='email@austintaylor.io', - description='Vulnerability assessment framework aggregator', + description='Vulnerability Assessment Framework Aggregator', scripts=['bin/vuln_whisperer'] ) diff --git a/vulnwhisp/.DS_Store b/vulnwhisp/.DS_Store deleted file mode 100644 index 2da73037501a31a82b10562a5ed0f011fd3a5786..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~F$w}f3`G;&V!>uh%V|7-Hy9Q@ffrCwY(zoPdXDZ-CJ3(9BJu;tpJXO1`-+{7 zh-iP?%|$v9Y2l_avoJ74-pE!qa+UpkbvYf+rvqwMAH`W)!#f%5$2NroNPq-LfCNb3 zhX~lc4QnS=8A*TyNZ?7pz7Gj*nnO!f|8yYu2mozRcEj3d323qcG>4X|sK7L)2aQ(s zF~sWL4oz_`hnA|fT{MOdjVG&3F)*#|q6rC1vkLuHq@&4g1L!&>UK-q5|WOfMZ}Ffv*yH E0HHP#t^fc4 diff --git a/vulnwhisp/base/config.py b/vulnwhisp/base/config.py index 3adacb1..f6e4ba6 100644 --- a/vulnwhisp/base/config.py +++ b/vulnwhisp/base/config.py @@ -25,6 +25,49 @@ class vwConfig(object): enabled = [] check = ["true", "True", "1"] for section in self.config.sections(): - if self.get(section, "enabled") in check: - enabled.append(section) + try: + if self.get(section, "enabled") in check: + enabled.append(section) + except: + print "[INFO] Section {} has no option 'enabled'".format(section) return enabled + + def exists_jira_profiles(self, profiles): + # get list of profiles source_scanner.scan_name + for profile in profiles: + if not self.config.has_section(self.normalize_section(profile)): + print "[INFO] JIRA Scan Profile missing" + return False + return True + + + def update_jira_profiles(self, profiles): + # create JIRA profiles in the ini config file + + for profile in profiles: + #IMPORTANT profile scans/results will be normalized to lower and "_" instead of spaces for ini file section + section_name = self.normalize_section(profile) + try: + self.get(section_name, "source") + print "Skipping creating of section '{}'; already exists".format(section_name) + except: + print "Creating config section for '{}'".format(section_name) + self.config.add_section(section_name) + self.config.set(section_name,'source',profile.split('.')[0]) + # in case any scan name contains '.' character + self.config.set(section_name,'scan_name','.'.join(profile.split('.')[1:])) + self.config.set(section_name,'jira_project', "") + self.config.set(section_name,'; if multiple components, separate by ","') + self.config.set(section_name,'components', "") + self.config.set(section_name,'; minimum criticality to report (low, medium, high or critical)') + self.config.set(section_name,'min_critical_to_report', 'high') + + # writing changes back to file + with open(self.config_in, 'w') as configfile: + self.config.write(configfile) + + return + + def normalize_section(self, profile): + profile = "jira.{}".format(profile.lower().replace(" ","_")) + return profile diff --git a/vulnwhisp/frameworks/nessus.py b/vulnwhisp/frameworks/nessus.py index 09f4e3d..4b335a9 100755 --- a/vulnwhisp/frameworks/nessus.py +++ b/vulnwhisp/frameworks/nessus.py @@ -1,224 +1,224 @@ -import requests -from requests.packages.urllib3.exceptions import InsecureRequestWarning -requests.packages.urllib3.disable_warnings(InsecureRequestWarning) - -import pytz -from datetime import datetime -import json -import sys -import time +import requests +from requests.packages.urllib3.exceptions import InsecureRequestWarning +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + +import pytz +from datetime import datetime +import json +import sys +import time from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) - - -class NessusAPI(object): - SESSION = '/session' - FOLDERS = '/folders' - SCANS = '/scans' - SCAN_ID = SCANS + '/{scan_id}' - HOST_VULN = SCAN_ID + '/hosts/{host_id}' - PLUGINS = HOST_VULN + '/plugins/{plugin_id}' - EXPORT = SCAN_ID + '/export' - EXPORT_TOKEN_DOWNLOAD = '/scans/exports/{token_id}/download' - EXPORT_FILE_DOWNLOAD = EXPORT + '/{file_id}/download' - EXPORT_STATUS = EXPORT + '/{file_id}/status' - EXPORT_HISTORY = EXPORT + '?history_id={history_id}' - - def __init__(self, hostname=None, port=None, username=None, password=None, verbose=True): - if username is None or password is None: - raise Exception('ERROR: Missing username or password.') - - self.user = username - self.password = password - self.base = 'https://{hostname}:{port}'.format(hostname=hostname, port=port) - self.verbose = verbose - - self.headers = { - 'Origin': self.base, - 'Accept-Encoding': 'gzip, deflate, br', - 'Accept-Language': 'en-US,en;q=0.8', - 'User-Agent': 'VulnWhisperer for Nessus', - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/javascript, */*; q=0.01', - 'Referer': self.base, - 'X-Requested-With': 'XMLHttpRequest', - 'Connection': 'keep-alive', - 'X-Cookie': None - } - - self.login() - self.scan_ids = self.get_scan_ids() - - def vprint(self, msg): - if self.verbose: - print(msg) - - def login(self): - resp = self.get_token() - if resp.status_code is 200: - self.headers['X-Cookie'] = 'token={token}'.format(token=resp.json()['token']) - else: - raise Exception('[FAIL] Could not login to Nessus') - - def request(self, url, data=None, headers=None, method='POST', download=False, json=False): - if headers is None: - headers = self.headers - timeout = 0 - success = False - - url = self.base + url - methods = {'GET': requests.get, - 'POST': requests.post, - 'DELETE': requests.delete} - - while (timeout <= 10) and (not success): - data = methods[method](url, data=data, headers=self.headers, verify=False) - if data.status_code == 401: - if url == self.base + self.SESSION: - break - try: - self.login() - timeout += 1 - self.vprint('[INFO] Token refreshed') - except Exception as e: - self.vprint('[FAIL] Could not refresh token\nReason: %s' % e) - else: - success = True - - if json: - data = data.json() - if download: - return data.content - return data - - def get_token(self): - auth = '{"username":"%s", "password":"%s"}' % (self.user, self.password) - token = self.request(self.SESSION, data=auth, json=False) - return token - - def logout(self): - self.request(self.SESSION, method='DELETE') - - def get_folders(self): - folders = self.request(self.FOLDERS, method='GET', json=True) - return folders - - def get_scans(self): - scans = self.request(self.SCANS, method='GET', json=True) - return scans - - def get_scan_ids(self): - scans = self.get_scans() - scan_ids = [scan_id['id'] for scan_id in scans['scans']] if scans['scans'] else [] - return scan_ids - - def count_scan(self, scans, folder_id): - count = 0 - for scan in scans: - if scan['folder_id'] == folder_id: count = count + 1 - return count - - def print_scans(self, data): - for folder in data['folders']: - print("\\{0} - ({1})\\".format(folder['name'], self.count_scan(data['scans'], folder['id']))) - for scan in data['scans']: - if scan['folder_id'] == folder['id']: - print( - "\t\"{0}\" - sid:{1} - uuid: {2}".format(scan['name'].encode('utf-8'), scan['id'], scan['uuid'])) - - def get_scan_details(self, scan_id): - data = self.request(self.SCAN_ID.format(scan_id=scan_id), method='GET', json=True) - return data - - def get_scan_history(self, scan_id): - data = self.request(self.SCAN_ID.format(scan_id=scan_id), method='GET', json=True) - return data['history'] - - def get_scan_hosts(self, scan_id): - data = self.request(self.SCAN_ID.format(scan_id=scan_id), method='GET', json=True) - return data['hosts'] - - def get_host_vulnerabilities(self, scan_id, host_id): - query = self.HOST_VULN.format(scan_id=scan_id, host_id=host_id) - data = self.request(query, method='GET', json=True) - return data - - def get_plugin_info(self, scan_id, host_id, plugin_id): - query = self.PLUGINS.format(scan_id=scan_id, host_id=host_id, plugin_id=plugin_id) - data = self.request(query, method='GET', json=True) - return data - - def export_scan(self, scan_id, history_id): - data = {'format': 'csv'} - query = self.EXPORT_REPORT.format(scan_id=scan_id, history_id=history_id) - req = self.request(query, data=data, method='POST') - return req - - def download_scan(self, scan_id=None, history=None, export_format="", chapters="", dbpasswd="", profile=""): - running = True - counter = 0 - - data = {'format': export_format} - if not history: - query = self.EXPORT.format(scan_id=scan_id) - else: - query = self.EXPORT_HISTORY.format(scan_id=scan_id, history_id=history) - scan_id = str(scan_id) - req = self.request(query, data=json.dumps(data), method='POST', json=True) - try: - file_id = req['file'] - token_id = req['token'] if 'token' in req else req['temp_token'] - except Exception as e: - print("[ERROR] %s" % e) - print('Download for file id ' + str(file_id) + '.') - while running: - time.sleep(2) - counter += 2 - report_status = self.request(self.EXPORT_STATUS.format(scan_id=scan_id, file_id=file_id), method='GET', - json=True) - running = report_status['status'] != 'ready' - sys.stdout.write(".") - sys.stdout.flush() - if counter % 60 == 0: - print("") - - print("") - if profile=='tenable': - content = self.request(self.EXPORT_FILE_DOWNLOAD.format(scan_id=scan_id, file_id=file_id), method='GET', download=True) - else: - content = self.request(self.EXPORT_TOKEN_DOWNLOAD.format(token_id=token_id), method='GET', download=True) - return content - - @staticmethod - def merge_dicts(self, *dict_args): - """ - Given any number of dicts, shallow copy and merge into a new dict, - precedence goes to key value pairs in latter dicts. - """ - result = {} - for dictionary in dict_args: - result.update(dictionary) - return result - - def get_utc_from_local(self, date_time, local_tz=None, epoch=True): - date_time = datetime.fromtimestamp(date_time) - if local_tz is None: - local_tz = pytz.timezone('US/Central') - else: - local_tz = pytz.timezone(local_tz) - # print date_time - local_time = local_tz.normalize(local_tz.localize(date_time)) - local_time = local_time.astimezone(pytz.utc) - if epoch: - naive = local_time.replace(tzinfo=None) - local_time = int((naive - datetime(1970, 1, 1)).total_seconds()) - return local_time - - def tz_conv(self, tz): - time_map = {'Eastern Standard Time': 'US/Eastern', - 'Central Standard Time': 'US/Central', - 'Pacific Standard Time': 'US/Pacific', - 'None': 'US/Central'} - return time_map.get(tz, None) + + +class NessusAPI(object): + SESSION = '/session' + FOLDERS = '/folders' + SCANS = '/scans' + SCAN_ID = SCANS + '/{scan_id}' + HOST_VULN = SCAN_ID + '/hosts/{host_id}' + PLUGINS = HOST_VULN + '/plugins/{plugin_id}' + EXPORT = SCAN_ID + '/export' + EXPORT_TOKEN_DOWNLOAD = '/scans/exports/{token_id}/download' + EXPORT_FILE_DOWNLOAD = EXPORT + '/{file_id}/download' + EXPORT_STATUS = EXPORT + '/{file_id}/status' + EXPORT_HISTORY = EXPORT + '?history_id={history_id}' + + def __init__(self, hostname=None, port=None, username=None, password=None, verbose=True): + if username is None or password is None: + raise Exception('ERROR: Missing username or password.') + + self.user = username + self.password = password + self.base = 'https://{hostname}:{port}'.format(hostname=hostname, port=port) + self.verbose = verbose + + self.headers = { + 'Origin': self.base, + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.8', + 'User-Agent': 'VulnWhisperer for Nessus', + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/javascript, */*; q=0.01', + 'Referer': self.base, + 'X-Requested-With': 'XMLHttpRequest', + 'Connection': 'keep-alive', + 'X-Cookie': None + } + + self.login() + self.scan_ids = self.get_scan_ids() + + def vprint(self, msg): + if self.verbose: + print(msg) + + def login(self): + resp = self.get_token() + if resp.status_code is 200: + self.headers['X-Cookie'] = 'token={token}'.format(token=resp.json()['token']) + else: + raise Exception('[FAIL] Could not login to Nessus') + + def request(self, url, data=None, headers=None, method='POST', download=False, json=False): + if headers is None: + headers = self.headers + timeout = 0 + success = False + + url = self.base + url + methods = {'GET': requests.get, + 'POST': requests.post, + 'DELETE': requests.delete} + + while (timeout <= 10) and (not success): + data = methods[method](url, data=data, headers=self.headers, verify=False) + if data.status_code == 401: + if url == self.base + self.SESSION: + break + try: + self.login() + timeout += 1 + self.vprint('[INFO] Token refreshed') + except Exception as e: + self.vprint('[FAIL] Could not refresh token\nReason: %s' % e) + else: + success = True + + if json: + data = data.json() + if download: + return data.content + return data + + def get_token(self): + auth = '{"username":"%s", "password":"%s"}' % (self.user, self.password) + token = self.request(self.SESSION, data=auth, json=False) + return token + + def logout(self): + self.request(self.SESSION, method='DELETE') + + def get_folders(self): + folders = self.request(self.FOLDERS, method='GET', json=True) + return folders + + def get_scans(self): + scans = self.request(self.SCANS, method='GET', json=True) + return scans + + def get_scan_ids(self): + scans = self.get_scans() + scan_ids = [scan_id['id'] for scan_id in scans['scans']] if scans['scans'] else [] + return scan_ids + + def count_scan(self, scans, folder_id): + count = 0 + for scan in scans: + if scan['folder_id'] == folder_id: count = count + 1 + return count + + def print_scans(self, data): + for folder in data['folders']: + print("\\{0} - ({1})\\".format(folder['name'], self.count_scan(data['scans'], folder['id']))) + for scan in data['scans']: + if scan['folder_id'] == folder['id']: + print( + "\t\"{0}\" - sid:{1} - uuid: {2}".format(scan['name'].encode('utf-8'), scan['id'], scan['uuid'])) + + def get_scan_details(self, scan_id): + data = self.request(self.SCAN_ID.format(scan_id=scan_id), method='GET', json=True) + return data + + def get_scan_history(self, scan_id): + data = self.request(self.SCAN_ID.format(scan_id=scan_id), method='GET', json=True) + return data['history'] + + def get_scan_hosts(self, scan_id): + data = self.request(self.SCAN_ID.format(scan_id=scan_id), method='GET', json=True) + return data['hosts'] + + def get_host_vulnerabilities(self, scan_id, host_id): + query = self.HOST_VULN.format(scan_id=scan_id, host_id=host_id) + data = self.request(query, method='GET', json=True) + return data + + def get_plugin_info(self, scan_id, host_id, plugin_id): + query = self.PLUGINS.format(scan_id=scan_id, host_id=host_id, plugin_id=plugin_id) + data = self.request(query, method='GET', json=True) + return data + + def export_scan(self, scan_id, history_id): + data = {'format': 'csv'} + query = self.EXPORT_REPORT.format(scan_id=scan_id, history_id=history_id) + req = self.request(query, data=data, method='POST') + return req + + def download_scan(self, scan_id=None, history=None, export_format="", chapters="", dbpasswd="", profile=""): + running = True + counter = 0 + + data = {'format': export_format} + if not history: + query = self.EXPORT.format(scan_id=scan_id) + else: + query = self.EXPORT_HISTORY.format(scan_id=scan_id, history_id=history) + scan_id = str(scan_id) + req = self.request(query, data=json.dumps(data), method='POST', json=True) + try: + file_id = req['file'] + token_id = req['token'] if 'token' in req else req['temp_token'] + except Exception as e: + print("[ERROR] %s" % e) + print('Download for file id ' + str(file_id) + '.') + while running: + time.sleep(2) + counter += 2 + report_status = self.request(self.EXPORT_STATUS.format(scan_id=scan_id, file_id=file_id), method='GET', + json=True) + running = report_status['status'] != 'ready' + sys.stdout.write(".") + sys.stdout.flush() + if counter % 60 == 0: + print("") + + print("") + if profile=='tenable': + content = self.request(self.EXPORT_FILE_DOWNLOAD.format(scan_id=scan_id, file_id=file_id), method='GET', download=True) + else: + content = self.request(self.EXPORT_TOKEN_DOWNLOAD.format(token_id=token_id), method='GET', download=True) + return content + + @staticmethod + def merge_dicts(self, *dict_args): + """ + Given any number of dicts, shallow copy and merge into a new dict, + precedence goes to key value pairs in latter dicts. + """ + result = {} + for dictionary in dict_args: + result.update(dictionary) + return result + + def get_utc_from_local(self, date_time, local_tz=None, epoch=True): + date_time = datetime.fromtimestamp(date_time) + if local_tz is None: + local_tz = pytz.timezone('US/Central') + else: + local_tz = pytz.timezone(local_tz) + # print date_time + local_time = local_tz.normalize(local_tz.localize(date_time)) + local_time = local_time.astimezone(pytz.utc) + if epoch: + naive = local_time.replace(tzinfo=None) + local_time = int((naive - datetime(1970, 1, 1)).total_seconds()) + return local_time + + def tz_conv(self, tz): + time_map = {'Eastern Standard Time': 'US/Eastern', + 'Central Standard Time': 'US/Central', + 'Pacific Standard Time': 'US/Pacific', + 'None': 'US/Central'} + return time_map.get(tz, None) diff --git a/vulnwhisp/reporting/__init__.py b/vulnwhisp/reporting/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/vulnwhisp/reporting/jira_api.py b/vulnwhisp/reporting/jira_api.py new file mode 100644 index 0000000..5743c38 --- /dev/null +++ b/vulnwhisp/reporting/jira_api.py @@ -0,0 +1,320 @@ +import json +from datetime import datetime, timedelta + +from jira import JIRA +import requests +from bottle import template +import re + +class JiraAPI(object): #NamedLogger): + __logname__="vjira" + + #TODO implement logging + + def __init__(self, hostname=None, username=None, password=None, debug=False, clean_obsolete=True, max_time_window=6): + #self.setup_logger(debug=debug) + if "https://" not in hostname: + hostname = "https://{}".format(hostname) + self.username = username + self.password = password + self.jira = JIRA(options={'server': hostname}, basic_auth=(self.username, self.password)) + #self.logger.info("Created vjira service for {}".format(server)) + self.all_tickets = [] + self.JIRA_REOPEN_ISSUE = "Reopen Issue" + self.JIRA_CLOSE_ISSUE = "Close Issue" + self.max_time_tracking = max_time_window #in months + # + self.JIRA_RESOLUTION_OBSOLETE = "Obsolete" + self.JIRA_RESOLUTION_FIXED = "Fixed" + self.clean_obsolete = clean_obsolete + self.template_path = 'vulnwhisp/reporting/resources/ticket.tpl' + + def create_ticket(self, title, desc, project="IS", components=[], tags=[]): + labels = ['vulnerability_management'] + for tag in tags: + labels.append(str(tag)) + + #self.logger.info("creating ticket for project {} title[20] {}".format(project, title[:20])) + #self.logger.info("project {} has a component requirement: {}".format(project, self.PROJECT_COMPONENT_TABLE[project])) + project_obj = self.jira.project(project) + components_ticket = [] + for component in components: + exists = False + for c in project_obj.components: + if component == c.name: + #self.logger.debug("resolved component name {} to id {}".format(component_name, c.id)ra python) + components_ticket.append({ "id": c.id }) + exists=True + if not exists: + print "[ERROR] Error creating Ticket: component {} not found".format(component) + return 0 + + new_issue = self.jira.create_issue(project=project, + summary=title, + description=desc, + issuetype={'name': 'Bug'}, + labels=labels, + components=components_ticket) + + print "[SUCCESS] Ticket {} has been created".format(new_issue) + return new_issue + + #Basic JIRA Metrics + def metrics_open_tickets(self, project=None): + jql = "labels= vulnerability_management and resolution = Unresolved" + if project: + jql += " and (project='{}')".format(project) + print jql + return len(self.jira.search_issues(jql, maxResults=0)) + + def metrics_closed_tickets(self, project=None): + jql = "labels= vulnerability_management and NOT resolution = Unresolved" + if project: + jql += " and (project='{}')".format(project) + return len(self.jira.search_issues(jql, maxResults=0)) + + def sync(self, vulnerabilities, project, components=[]): + #JIRA structure of each vulnerability: [source, scan_name, title, diagnosis, consequence, solution, ips, risk, references] + print "JIRA Sync started" + + # [HIGIENE] close tickets older than 6 months as obsolete + # Higiene clean up affects to all tickets created by the module, filters by label 'vulnerability_management' + if self.clean_obsolete: + self.close_obsolete_tickets() + + for vuln in vulnerabilities: + # JIRA doesn't allow labels with spaces, so making sure that the scan_name doesn't have spaces + # if it has, they will be replaced by "_" + if " " in vuln['scan_name']: + vuln['scan_name'] = "_".join(vuln['scan_name'].split(" ")) + + exists = False + to_update = False + ticketid = "" + ticket_assets = [] + exists, to_update, ticketid, ticket_assets = self.check_vuln_already_exists(vuln) + + if exists: + # If ticket "resolved" -> reopen, as vulnerability is still existent + self.reopen_ticket(ticketid) + continue + elif to_update: + self.ticket_update_assets(vuln, ticketid, ticket_assets) + continue + + try: + tpl = template(self.template_path, vuln) + except Exception as e: + print e + return 0 + self.create_ticket(title=vuln['title'], desc=tpl, project=project, components=components, tags=[vuln['source'], vuln['scan_name'], 'vulnerability', vuln['risk']]) + + self.close_fixed_tickets(vulnerabilities) + # we reinitialize so the next sync redoes the query with their specific variables + self.all_tickets = [] + return True + + def check_vuln_already_exists(self, vuln): + # we need to return if the vulnerability has already been reported and the ID of the ticket for further processing + #function returns array [duplicated(bool), update(bool), ticketid, ticket_assets] + title = vuln['title'] + labels = [vuln['source'], vuln['scan_name'], 'vulnerability_management', 'vulnerability'] + #list(set()) to remove duplicates + assets = list(set(re.findall(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", ",".join(vuln['ips'])))) + + if not self.all_tickets: + print "Retrieving all JIRA tickets with the following tags {}".format(labels) + # we want to check all JIRA tickets, to include tickets moved to other queues + # will exclude tickets older than 6 months, old tickets will get closed for higiene and recreated if still vulnerable + jql = "{} AND NOT labels=advisory AND created >=startOfMonth(-{})".format(" AND ".join(["labels={}".format(label) for label in labels]), self.max_time_tracking) + + self.all_tickets = self.jira.search_issues(jql, maxResults=0) + + #WARNING: function IGNORES DUPLICATES, after finding a "duplicate" will just return it exists + #it wont iterate over the rest of tickets looking for other possible duplicates/similar issues + print "Comparing Vulnerabilities to created tickets" + for index in range(len(self.all_tickets)-1): + checking_ticketid, checking_title, checking_assets = self.ticket_get_unique_fields(self.all_tickets[index]) + if title == checking_title: + difference = list(set(assets).symmetric_difference(checking_assets)) + #to check intersection - set(assets) & set(checking_assets) + if difference: + print "Asset mismatch, ticket to update. TickedID: {}".format(checking_ticketid) + return False, True, checking_ticketid, checking_assets #this will automatically validate + else: + print "Confirmed duplicated. TickedID: {}".format(checking_ticketid) + return True, False, checking_ticketid, [] #this will automatically validate + return False, False, "", [] + + def ticket_get_unique_fields(self, ticket): + title = ticket.raw.get('fields', {}).get('summary').encode("ascii").strip() + ticketid = ticket.key.encode("ascii") + try: + affected_assets_section = ticket.raw.get('fields', {}).get('description').encode("ascii").split("{panel:title=Affected Assets}")[1].split("{panel}")[0] + assets = list(set(re.findall(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", affected_assets_section))) + except: + print "[ERROR] Ticket IPs regex failed. Ticket ID: {}".format(ticketid) + assets = [] + + return ticketid, title, assets + + def ticket_update_assets(self, vuln, ticketid, ticket_assets): + # correct description will always be in the vulnerability to report, only needed to update description to new one + print "Ticket {} exists, UPDATE requested".format(ticketid) + + if self.is_ticket_resolved(self.jira.issue(ticketid)): + self.reopen_ticket(ticketid) + try: + tpl = template(self.template_path, vuln) + except Exception as e: + print e + return 0 + + ticket_obj = self.jira.issue(ticketid) + ticket_obj.update() + assets = list(set(re.findall(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", ",".join(vuln['ips'])))) + difference = list(set(assets).symmetric_difference(ticket_assets)) + + comment = '' + #put a comment with the assets that have been added/removed + for asset in difference: + if asset in assets: + comment += "Asset {} have been added to the ticket as vulnerability *has been newly detected*.\n".format(asset) + elif asset in ticket_assets: + comment += "Asset {} have been removed from the ticket as vulnerability *has been resolved*.\n".format(asset) + + ticket_obj.fields.labels.append('updated') + try: + ticket_obj.update(description=tpl, comment=comment, fields={"labels":ticket_obj.fields.labels}) + print "Ticket {} updated successfully".format(ticketid) + except: + print "[ERROR] Error while trying up update ticket {}".format(ticketid) + return 0 + + def close_fixed_tickets(self, vulnerabilities): + # close tickets which vulnerabilities have been resolved and are still open + found_vulns = [] + for vuln in vulnerabilities: + found_vulns.append(vuln['title']) + + comment = '''This ticket is being closed as it appears that the vulnerability no longer exists. + If the vulnerability reappears, a new ticket will be opened.''' + + for ticket in self.all_tickets: + if ticket.raw['fields']['summary'].strip() in found_vulns: + print "Ticket {} is still vulnerable".format(ticket) + continue + print "Ticket {} is no longer vulnerable".format(ticket) + self.close_ticket(ticket, self.JIRA_RESOLUTION_FIXED, comment) + return 0 + + + def is_ticket_reopenable(self, ticket_obj): + transitions = self.jira.transitions(ticket_obj) + for transition in transitions: + if transition.get('name') == self.JIRA_REOPEN_ISSUE: + #print "ticket is reopenable" + return True + print "[ERROR] Ticket can't be opened. Check Jira transitions." + return False + + def is_ticket_closeable(self, ticket_obj): + transitions = self.jira.transitions(ticket_obj) + for transition in transitions: + if transition.get('name') == self.JIRA_CLOSE_ISSUE: + return True + print "[ERROR] Ticket can't closed. Check Jira transitions." + return False + + def is_ticket_resolved(self, ticket_obj): + #Checks if a ticket is resolved or not + if ticket_obj is not None: + if ticket_obj.raw['fields'].get('resolution') is not None: + if ticket_obj.raw['fields'].get('resolution').get('name') != 'Unresolved': + print "Checked ticket {} is already closed".format(ticket_obj) + #logger.info("ticket {} is closed".format(ticketid)) + return True + print "Checked ticket {} is already open".format(ticket_obj) + return False + + + def is_risk_accepted(self, ticket_obj): + if ticket_obj is not None: + if ticket_obj.raw['fields'].get('labels') is not None: + labels = ticket_obj.raw['fields'].get('labels') + print labels + if "risk_accepted" in labels: + print "Ticket {} accepted risk, will be ignored".format(ticket_obj) + return True + elif "server_decomission" in labels: + print "Ticket {} server decomissioned, will be ignored".format(ticket_obj) + return True + print "Ticket {} risk has not been accepted".format(ticket_obj) + return False + + def reopen_ticket(self, ticketid): + print "Ticket {} exists, REOPEN requested".format(ticketid) + # this will reopen a ticket by ticketid + ticket_obj = self.jira.issue(ticketid) + + if self.is_ticket_resolved(ticket_obj): + #print "ticket is resolved" + if not self.is_risk_accepted(ticket_obj): + try: + if self.is_ticket_reopenable(ticket_obj): + comment = '''This ticket has been reopened due to the vulnerability not having been fixed (if multiple assets are affected, all need to be fixed; if the server is down, lastest known vulnerability might be the one reported). + In the case of the team accepting the risk and wanting to close the ticket, please add the label "*risk_accepted*" to the ticket before closing it. + If server has been decomissioned, please add the label "*server_decomission*" to the ticket before closing it. + If you have further doubts, please contact the Security Team.''' + error = self.jira.transition_issue(issue=ticketid, transition=self.JIRA_REOPEN_ISSUE, comment = comment) + print "[SUCCESS] ticket {} reopened successfully".format(ticketid) + #logger.info("ticket {} reopened successfully".format(ticketid)) + return 1 + except Exception as e: + # continue with ticket data so that a new ticket is created in place of the "lost" one + print "[ERROR] error reopening ticket {}: {}".format(ticketid, e) + #logger.error("error reopening ticket {}: {}".format(ticketid, e)) + return 0 + return 0 + + def close_ticket(self, ticketid, resolution, comment): + # this will close a ticket by ticketid + print "Ticket {} exists, CLOSE requested".format(ticketid) + ticket_obj = self.jira.issue(ticketid) + if not self.is_ticket_resolved(ticket_obj): + try: + if self.is_ticket_closeable(ticket_obj): + error = self.jira.transition_issue(issue=ticketid, transition=self.JIRA_CLOSE_ISSUE, comment = comment, resolution = {"name": resolution }) + print "[SUCCESS] ticket {} closed successfully".format(ticketid) + #logger.info("ticket {} reopened successfully".format(ticketid)) + return 1 + except Exception as e: + # continue with ticket data so that a new ticket is created in place of the "lost" one + print "[ERROR] error closing ticket {}: {}".format(ticketid, e) + #logger.error("error closing ticket {}: {}".format(ticketid, e)) + return 0 + + return 0 + + def close_obsolete_tickets(self): + # Close tickets older than 6 months, vulnerabilities not solved will get created a new ticket + print "Closing obsolete tickets older than {} months".format(self.max_time_tracking) + jql = "labels=vulnerability_management AND created