# Ivanti API class/wrapper | Evan Compton (P2886385), updated 11/13/2025 ### ! README | IMPORTANT INFORMATION ! ### # requires an "Ivanti_config.ini" file in the same directory # edit "Ivanti_config_template.ini", then save as "Ivanti_config.ini" ### ? CODE PURPOSE ? ### # the primary purpose of this class/wrapper is to export data as a Pandas Dataframe and/or a CSV file # this class primarily targets these endpoints: host, tag, hostFinding, vulnerability # it should work on other endpoints as well, but the 4 above are the only ones tested # usage examples of this class are at the end of this file # library imports import requests, urllib3, configparser, pandas as pd from requests.adapters import HTTPAdapter from urllib3 import Retry # fix (ignore) SSL verification... # Charter-specific issue; feel free to fix this if you can... from urllib3.exceptions import InsecureRequestWarning urllib3.disable_warnings(InsecureRequestWarning) # Ivanti API class class Ivanti: def __init__(self, config_file='./Ivanti_config.ini'): # read our config file config = configparser.ConfigParser() config.read(config_file) # set up environment & auth PLATFORM = config.get('platform', 'url') + config.get('platform', 'api_ver') IVANTI_API_KEY = config.get('secrets', 'api_key') self.CLIENT_ID = config.get('platform', 'client_id') self.URL_BASE = f'{PLATFORM}/client/{self.CLIENT_ID}' # universal header for our requests self.header = { 'x-api-key': IVANTI_API_KEY, 'content-type': 'application/json' } # dictionaries for filters and fields, sorted with keys by endpoint prefixes self.filters = {} self.fields = {} return # function used for HTTP requests- thank you, Ivanti... useful code def request(max_retries=5, backoff_factor=0.5, status_forcelist=(419,429)): """ Create a Requests session that uses automatic retries. :param max_retries: Maximum number of retries to attempt :type max_retries: int :param backoff_factor: Backoff factor used to calculate time between retries. :type backoff_factor: float :param status_forcelist: A tuple containing the response status codes that should trigger a retry. :type status_forcelist: tuple :return: Requests Session :rtype: Requests Session Object """ session = requests.Session() retry = Retry( total=max_retries, read=max_retries, connect=max_retries, backoff_factor=backoff_factor, status_forcelist=status_forcelist, ) adapter = HTTPAdapter(max_retries=retry) session.mount('https://', adapter) return session # retrieve all filters for an endpoint (tag, host, etc) def get_filters(self, endp='tag'): URL_FILTERS = f'{self.URL_BASE}/{endp}/filter' self.last_resp = self.request().get(URL_FILTERS, headers=self.header, verify=False) self.filters[endp] = self.last_resp.json() return self.filters[endp] # retrieve all fields for an endpoint (tag, host, etc) def get_fields(self, endp='tag'): URL_FIELDS = f'{self.URL_BASE}/{endp}/export/template' self.last_resp = self.request().get(URL_FIELDS, headers=self.header, verify=False) self.fields[endp] = self.last_resp.json()['exportableFields'] return self.fields[endp] # this uses the "{subject}/search" endpoint instead of "{subject}/export" def search(self, endp='tag', save=None, pages=None, size=750): ''' Uses the "/client/{client_id}/{subject}/search" endpoint to export data as JSON. :param endp: String for endpoint name; host, tag, group, etc. (default: "tag") :param save: String for filename to save, end with ".csv" (default: none) :param pages: Integer to limit the number of pages to pull (default: all pages) :param size: Integer defining how many records to pull per page (default: 750 records) :return: Pandas DataFrame ''' # most endpoints follow the same URL structure and usage pattern # filters and fields dont matter for searches- only for exports! URL_SEARCH = f'{self.URL_BASE}/{endp}/search' body = { 'projection': 'basic', # can also be set to 'detail' 'sort': [ { 'field': 'id', 'direction': 'ASC' } ], 'page': 0, 'size': size } # post a search, get first page resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False) if resp.status_code != 200: raise Exception(f'[!] ERROR: Search failed.\n- code: {resp.status_code}\n- text: {resp.text}') totalPages = int(resp.json()['page']['totalPages']) totalRecords = int(resp.json()['page']['totalElements']) body['page'] = int(resp.json()['page']['number']) + 1 msg = f'[?] Search requested for "{endp}"\n[?] Total pages: {totalPages}\n[?] Total records: {totalRecords}\n[?] Batch size: {size}' if pages: msg += f'\n[?] Page limit: {pages} pages' print(msg) # limit results? if pages: totalPages = pages # loop until the last page subject = f'{endp[:-1]}ies' if endp.endswith('y') else f'{endp}s' data = [] while body['page'] < totalPages: resp = self.request().post(URL_SEARCH, headers=self.header, json=body, verify=False) body['page'] = int(resp.json()['page']['number']) + 1 data.extend(resp.json()['_embedded'][subject]) print(f'[?] Page progress: [{body["page"]}/{totalPages}] ({len(data)} total records retrieved)\r', end='') print(f'\n[+] Search completed. {len(data)} records retrieved!') # make a nice dataframe, save file if wanted, return the frame df = pd.DataFrame(data) if save: df.to_csv(save, index=False) return df ### ? EXAMPLE USAGE ? ### # configure the connection and auth, create an instance object #API = Ivanti('./Ivanti_config.ini') # the "search" function goes to the "/client/{clientID}/{subject}/search" endpoint #df = API.search('host', save='IvantiHostsTest_5pages.csv', pages=5) #df = API.search('tag', save='IvantiTagsTest_5pages.csv', pages=5) #df = API.search('hostFinding', save='IvantiHostFindingsTest_5pages.csv', pages=5) #df = API.search('vulnerability', save='IvantiVulnerabilitiesTest_5pages.csv', pages=5) # you can also retrieve all possible filters and exportable fields per subject #filters = API.get_fields('host') #fields = API.get_filters('tag')