diff --git a/nexpose/nexpose.py b/nexpose/nexpose.py index 073493e..99da3ca 100644 --- a/nexpose/nexpose.py +++ b/nexpose/nexpose.py @@ -24,6 +24,8 @@ from .nexpose_credential import Credential_FTP, Credential_HTTP, Credential_SNMP, Credential_SNMPV3, Credential_SSH, Credential_SSH_KEY, Credential_Telnet from .nexpose_discoveryconnection import DiscoveryConnectionProtocol, DiscoveryConnectionSummary, DiscoveryConnectionConfiguration from .nexpose_engine import EngineStatus, EnginePriority, EngineBase, EngineSummary, EngineConfiguration +from .nexpose_eso import Configuration as EsoConfiguration +from .nexpose_integration_options import IntegrationOption from .nexpose_node import NodeScanStatus, NodeBase, Node from .nexpose_privileges import AssetGroupPrivileges, GlobalPrivileges, SitePrivileges from .nexpose_report import ReportStatus, ReportTemplate, ReportConfigurationSummary, ReportConfiguration, ReportSummary @@ -80,6 +82,7 @@ def CreateHeadersWithSessionCookie(session_id): def CreateHeadersWithSessionCookieAndCustomHeader(session_id): headers = CreateHeadersWithSessionCookie(session_id) headers["nexposeCCSessionID"] = "{0}".format(session_id) + headers['X-Requested-With'] = 'XMLHttpRequest' return headers @@ -88,7 +91,7 @@ def ExecuteGet_JSON(session_id, uri, sub_url, timeout, options=None): if options is None: options = {} options = ["{0}={1}".format(a[0], a[1]) for a in iter(options.items())] - headers = CreateHeadersWithSessionCookie(session_id) + headers = CreateHeadersWithSessionCookieAndCustomHeader(session_id) # headers["Accept-Encoding"] = "utf-8" if sub_url.startswith('http'): # TODO: refactor uri & sub_url so that json_utils.resolve_urls can work better uri = sub_url @@ -144,6 +147,12 @@ def ExecuteDelete_JSON(session_id, uri, sub_url, timeout): return False +def ExecuteDelete_JSON_ESO(session_id, uri, sub_url, timeout): + headers = CreateHeadersWithSessionCookieAndCustomHeader(session_id) + uri = uri + sub_url + return ExecuteWebRequest(uri, None, headers, timeout, lambda: 'DELETE') + + def ExecutePagedGet_JSON(session_id, uri, sub_url, timeout, per_page=2147483647): options = {} options["per_page"] = per_page # NOTA: API 2.0 defaults this to 500 if not set @@ -235,6 +244,9 @@ def DownloadFromStreamReader(reader, callback_function=None, block_size=DEFAULT_ APIURL_ASSETS = "assets/{0}/" APIURL_ASSETGROUPS = "asset_groups/{0}/" +ESOURL_CONFIGMANAGER = "/configuration-manager/api/service/" +ESOURL_INTEG_OPTIONS = "/integration-manager-service/api/integration-options/" + class NexposeException(Exception): def __init__(self, message): @@ -273,6 +285,7 @@ def __init__(self, host, port): self._URI_root = BuildURI_root(host, port) self._URI_APIv2d0 = BuildURI_APIv2d0(host, port) self._URI_APIv2d1 = BuildURI_APIv2d1(host, port) + self._URI_APIESO = BuildRootURI(host, port) + 'eso' self.timeout = 60 # @@ -1203,10 +1216,20 @@ def ExecutePagedGet_v20(self, sub_url): def ExecutePagedGet_v21(self, sub_url): return self.ExecutePagedGet_vXX(sub_url, self._URI_APIv2d1) + def ExecutePagedGet_ESO(self, sub_url): + self._RequireAnOpenSession() + result = ExecuteGet_JSON(self._session_id, self._URI_APIESO, sub_url, self.timeout) + return json.loads(result) + def ExecutePost(self, sub_url, post_data): self._RequireAnOpenSession() return ExecutePost_JSON(self._session_id, self._URI_APIv2d0, sub_url, self.timeout, post_data) + def ExecutePost_ESO(self, sub_url, post_data): + self._RequireAnOpenSession() + response = ExecutePost_JSON(self._session_id, self._URI_APIESO, sub_url, self.timeout, post_data) + return json.loads(response) + def ExecutePut(self, sub_url, post_data): self._RequireAnOpenSession() return ExecutePut_JSON(self._session_id, self._URI_APIv2d0, sub_url, self.timeout, post_data) @@ -1215,6 +1238,10 @@ def ExecuteDelete(self, sub_url): self._RequireAnOpenSession() return ExecuteDelete_JSON(self._session_id, self._URI_APIv2d0, sub_url, self.timeout) + def ExecuteDelete_ESO(self, sub_url): + self._RequireAnOpenSession() + return ExecuteDelete_JSON_ESO(self._session_id, self._URI_APIESO, sub_url, self.timeout) + def ExecuteFormPost(self, sub_url, post_data): self._RequireAnOpenSession() return ExecuteWithPostData_FORM(self._session_id, self._URI_root, sub_url, self.timeout, post_data) @@ -2624,3 +2651,130 @@ def DeleteBackup(self, backup_or_filename): backup_or_filename = backup_or_filename.name parameters = {'backupid': backup_or_filename} return self.ExecuteMaintenanceCommand('backupRestore', 'deleteBackup', parameters) + + # + # The following functions implement the ESO Configuration API + # They are used for new-style Discovery connections + # ========================================================================= + + def GetEsoServices(self): + sub_url = ESOURL_CONFIGMANAGER + json_dict = self.ExecutePagedGet_ESO(sub_url) + return json_dict + + def GetEsoConfigurationType(self, service_name): + sub_url = ESOURL_CONFIGMANAGER + 'configurationType/{}'.format(service_name) + json_dict = self.ExecutePagedGet_ESO(sub_url) + return json_dict + + def GetEsoServiceConfigurations(self, service_name): + sub_url = ESOURL_CONFIGMANAGER + 'configuration/{}/'.format(service_name) + json_list = self.ExecutePagedGet_ESO(sub_url) + services = [EsoConfiguration.CreateFromJSON(i) for i in json_list] + return services + + def GetEsoServiceConfigurationByName(self, service_name, config_name): + for config in self.GetEsoServiceConfigurations(service_name): + if config.name == config_name: + return config + else: + raise KeyError("Config {} not found".format(config_name)) + + def GetEsoServiceConfiguration(self, config_or_id): + if isinstance(config_or_id, EsoConfiguration): + config_or_id = config_or_id.id + sub_url = ESOURL_CONFIGMANAGER + 'configuration/id/{}'.format(config_or_id) + json_dict = self.ExecutePagedGet_ESO(sub_url) + return EsoConfiguration.CreateFromJSON(json_dict) + + def SaveEsoServiceConfiguration(self, config, save_integration_options=False): + self._RequireInstanceOf(config, EsoConfiguration) + sub_url = ESOURL_CONFIGMANAGER + 'configuration' + payload = config.as_json() + response = self.ExecutePost_ESO(sub_url, payload) + config_id = int(response['data']) + if config_id < 1: + raise RuntimeError("API returned invalid configID ({}) while attempting to create configuration.".format(config_id)) + config.id = config_id + if save_integration_options: + for opt in config.integration_options: + self.SaveIntegrationOption(opt) + self.StartIntegrationOption(opt) + return config_id + + def DeleteEsoServiceConfiguration(self, config_or_id, delete_integration_options=False): + if isinstance(config_or_id, EsoConfiguration): + if delete_integration_options: + for opt in config_or_id.integration_options: + self.DeleteIntegrationOption(opt) + config_or_id = config_or_id.id + elif delete_integration_options: + raise TypeError("Need config option if deleting integration options") + sub_url = ESOURL_CONFIGMANAGER + 'configuration/{}'.format(config_or_id) + result = self.ExecuteDelete_ESO(sub_url) + if result != 'success': + raise RuntimeError("Failed to delete configuration with ID: {}".format(config_or_id)) + + def PreviewEsoServiceConfiguration(self, config): + self._RequireInstanceOf(config, EsoConfiguration) + sub_url = ESOURL_CONFIGMANAGER + 'configuration/preview' + payload = config.as_json() + response_body = self.ExecutePost_ESO(sub_url, payload) + return response_body['previewAssets'] + + def TestEsoServiceConfiguration(self, config): + self._RequireInstanceOf(config, EsoConfiguration) + sub_url = ESOURL_CONFIGMANAGER + 'configuration/test' + payload = config.as_json() + response_body = self.ExecutePost_ESO(sub_url, payload) + return response_body['data'] + + # + # Implement the integrations manager API + # ========================================================================= + + def GetIntegrationOptions(self): + sub_url = ESOURL_INTEG_OPTIONS + 'options-and-states' + json_list = self.ExecutePagedGet_ESO(sub_url) + integration_options = [ + IntegrationOption.CreateFromJSON(i['integrationOption']) + for i in json_list] + return integration_options + + def SaveIntegrationOption(self, option): + self._RequireInstanceOf(option, IntegrationOption) + sub_url = ESOURL_INTEG_OPTIONS + payload = option.as_json() + response = self.ExecutePost_ESO(sub_url, payload) + option.update(response['data']) + return option.id + + def DeleteIntegrationOption(self, option_or_id): + if isinstance(option_or_id, IntegrationOption): + option_or_id = option_or_id.id + sub_url = ESOURL_INTEG_OPTIONS + '{}/state'.format(option_or_id) + result = json.loads(self.ExecuteDelete_ESO(sub_url)) + msg = result['data'] + if 'being stopped' not in msg: + raise RuntimeError("Failed to delete step with ID: {}: {}".format(option_or_id, msg)) + + def GetIntegrationOption(self, option_id): + for option in self.GetIntegrationOptions(): + if option.id == option_id: + return option + else: + raise KeyError("Option {} not found".format(option_or_id)) + + def GetIntegrationOptionStatus(self, option_or_id): + if isinstance(option_or_id, IntegrationOption): + option_or_id = option_or_id.id + sub_url = ESOURL_INTEG_OPTIONS + '{}/status'.format(option_or_id) + result = self.ExecutePagedGet_ESO(sub_url) + return result['data'] + + def StartIntegrationOption(self, option_or_id): + if isinstance(option_or_id, IntegrationOption): + option_or_id = option_or_id.id + sub_url = ESOURL_INTEG_OPTIONS + '{}/state'.format(option_or_id) + result = self.ExecutePost_ESO(sub_url, {}) + return result['data'] diff --git a/nexpose/nexpose_eso.py b/nexpose/nexpose_eso.py new file mode 100644 index 0000000..1874489 --- /dev/null +++ b/nexpose/nexpose_eso.py @@ -0,0 +1,300 @@ +import copy +from .nexpose_integration_options import IntegrationOption, Step, StepConfiguration, ServiceNames + + +class Configuration(object): + + @classmethod + def CreateFromJSON(cls, json_dict, integration_options=None): + services = { + 'amazon-web-services': AWSConfiguration, + } + service_name = json_dict['serviceName'] + if integration_options is not None: + integration_options = [ + IntegrationOption.CreateFromJSON(i) for i in integration_options] + if service_name in services: + cls = services[service_name] + config = cls( + name=json_dict['configName'], + properties=json_dict['configurationAttributes']['properties'], + id=json_dict['configID'], + integration_options=integration_options, + ) + else: + config = cls( + service_name=json_dict['serviceName'], + name=json_dict['configName'], + properties=json_dict['configurationAttributes']['properties'], + id=json_dict['configID'], + integration_options=integration_options, + ) + return config + + def __init__(self, service_name, name, properties, steps, id=None): + self._id = id + self.properties = copy.deepcopy(properties) + self.service_name = service_name + self.name = name + + @property + def id(self): + return self._id + + @id.setter + def id(self, value): + self._id = id + + def add_property(self, name, prop, type_=None): + if type_ is None: + if isinstance(prop, bool): + type_ = 'Boolean' + elif isinstance(prop, int): + type_ = 'Integer' + elif isinstance(prop, str): + type_ = 'String' + elif isinstance(prop, list): + type_ = 'Array' + else: + raise TypeError("Invalid type {} for prop '{}'".format(type(prop), name)) + data = { + 'valueClass': type_, + } + if type_ == 'Array': + data['items'] = [{'value': v, 'valueClass': 'String'} for v in prop] + else: + data['value'] = prop + self.properties[name] = data + + def get_property(self, name, default=None): + try: + prop = self.properties[name] + except KeyError: + if default is None: + raise + else: + self.add_property(name, default) + prop = self.properties[name] + if self.properties[name]['valueClass'] == 'Array': + prop = [p['value'] for p in prop['items']] + else: + prop = prop['value'] + return prop + + def add_property_item(self, name, item): + if item in self.get_property(name, []): + raise ValueError("{} '{}' already in configuration".format(name, item)) + self.properties[name]['items'].append({'value': item, 'valueClass': 'String'}) + + def _get_step(self, option_name, service_name, type_name=None): + for opt in self._integration_options: + if option_name not in opt.name: + continue + try: + return opt.get_step(service_name, type_name) + except KeyError: + msg = "Unable to find step for option '{}', serviceName '{}'".format( + option_name, service_name) + if type_name is not None: + msg += " and typeName '{}'".format(type_name) + raise KeyError(msg) + else: + raise KeyError("Unknown option_name '{}'".format(option_name)) + + def as_json(self): + json_dict = { + 'serviceName': self.service_name, + 'configName': self.name, + 'configurationAttributes': { + 'valueClass': 'Object', + 'objectType': 'service_configuration', + 'properties': self.properties, + }, + } + if self._id: + json_dict['configID'] = self.id + return json_dict + + + +def _AWS_get_default_verify_opts(): + return IntegrationOption( + name='aws-verify', + steps=[Step( + service_name=ServiceNames.AWS, + config=StepConfiguration(type_name='verify-aws-targets'), + ), Step( + service_name=ServiceNames.NEXPOSE, + config=StepConfiguration( + type_name='verify-external-targets', + previous_type_name='verify-aws-targets', + ), + ), Step( + service_name=ServiceNames.AWS, + config=StepConfiguration( + type_name='verify-aws-targets', + previous_type_name='verify-external-targets', + ), + )], + ) + + +def _AWS_get_default_sync_opts(): + return IntegrationOption( + name='aws-sync', + steps=[Step( + service_name=ServiceNames.AWS, + config=StepConfiguration( + type_name='discover-aws-assets', + ), + ), Step( + service_name=ServiceNames.NEXPOSE, + config=StepConfiguration( + type_name='sync-external-assets', + previous_type_name='discover-aws-assets', + ), + )], + ) + + +class AWSConfiguration(Configuration): + service_name = ServiceNames.AWS + + def __init__(self, name, properties=None, integration_options=None, id=None): + if properties is None: + properties = {} + if integration_options is None: + integration_options = [] + self._id = None + + self.name = name + self._integration_options = integration_options + self.properties = properties + if id is not None: + self.id = id + + def enable_default_integrations(self, site_id, import_tags=False): + self._integration_options += [ + _AWS_get_default_sync_opts(), + _AWS_get_default_verify_opts(), + ] + self.site_id = site_id + self.import_tags = import_tags + + @property + def id(self): + return self._id + + @id.setter + def id(self, value): + self._id = value + for opt in self._integration_options: + if 'Config ID' not in opt.name: + opt.name = "Config ID:{} - {}".format(value, opt.name) + for step in opt.steps: + if step.service_name != ServiceNames.AWS: + continue + step.config.add_property('discoveryConfigID', value) + + # + # Properties stored directly on AWSConfiguration + # + + @property + def regions(self): + return self.get_property('region') + + @regions.setter + def regions(self, regions): + self.add_property('region', list(regions)) + + def add_region(self, region): + self.add_property_item('region', region) + + @property + def engine_inside_aws(self): + return self.get_property('engineInsideAWS') + + @engine_inside_aws.setter + def engine_inside_aws(self, value): + self.add_property('engineInsideAWS', bool(value)) + + @property + def console_inside_aws(self): + return self.get_property('consoleInsideAWS') + + @console_inside_aws.setter + def console_inside_aws(self, value): + self.add_property('consoleInsideAWS', bool(value)) + + @property + def session_name(self): + return self.get_property('sessionName') + + @session_name.setter + def session_name(self, value): + self.add_property('sessionName', str(value)) + + @property + def use_proxy(self): + return self.get_property('useProxy') + + @use_proxy.setter + def use_proxy(self, value): + self.add_property('useProxy', bool(value)) + + @property + def arns(self): + return self.get_property('arn') + + @arns.setter + def arns(self, arns): + self.add_property('arn', list(arns)) + + def add_arn(self, arn): + self.add_property_item('arn', arn) + + + # + # Properties stored on some steps + # + + @property + def import_tags(self): + step = self._get_step( + 'aws-sync', + 'amazon-web-services', + 'discover-aws-assets', + ) + return step.config.get_property('importTags') + + @import_tags.setter + def import_tags(self, value): + step = self._get_step( + 'aws-sync', + 'amazon-web-services', + 'discover-aws-assets', + ) + step.config.add_property('importTags', bool(value)) + + @property + def site_id(self): + step = self._get_step( + 'aws-sync', + 'nexpose', + 'sync-external-assets', + ) + return step.config.get_property('siteID') + + @site_id.setter + def site_id(self, value): + step = self._get_step( + 'aws-sync', + 'nexpose', + 'sync-external-assets', + ) + return step.config.add_property('siteID', int(value)) + + @property + def integration_options(self): + return self._integration_options diff --git a/nexpose/nexpose_integration_options.py b/nexpose/nexpose_integration_options.py new file mode 100644 index 0000000..f96a20d --- /dev/null +++ b/nexpose/nexpose_integration_options.py @@ -0,0 +1,154 @@ +class ServiceNames(object): + AWS = 'amazon-web-services' + NEXPOSE = 'nexpose' + + +class Step(object): + + @classmethod + def CreateFromJSON(cls, json_dict): + uuid = json_dict.get('uuid') + service_name = json_dict['serviceName'] + config = StepConfiguration.CreateFromJSON( + json_dict['stepConfiguration']) + return cls( + service_name=service_name, + config=config, + uuid=uuid, + ) + + def __init__(self, service_name, config, uuid=None): + self.uuid = uuid + self.service_name = service_name + self.config = config + + def as_json(self): + data = { + "serviceName": self.service_name, + "stepConfiguration": self.config.as_json(), + } + if self.uuid is not None: + data['uuid'] = self.uuid, + return data + + +class StepConfiguration(object): + + @classmethod + def CreateFromJSON(cls, json_dict): + type_name = json_dict['typeName'] + previous_type_name = json_dict.get('previousTypeName', "") + props = json_dict.get('configurationParams', {}).get('properties', {}) + integration_option_id = json_dict.get('integrationOptionID') + step_config = cls( + type_name, previous_type_name, + set_defaults=False, + integration_option_id=integration_option_id) + step_config.configuration_params['properties'] = props + return step_config + + def __init__( + self, type_name, previous_type_name="", + set_defaults=True, + workflow_id=None, integration_option_id=None, **properties): + self.type_name = type_name + self.previous_type_name = previous_type_name + if properties is None: + properties = {} + self.configuration_params = { + "valueClass": "Object", + "objectType": "params", + "properties": {}, + } + for prop_name, prop_value in properties.items(): + self.add_property(prop_name, prop_value) + self.workflow_id = workflow_id + self.integration_option_id = integration_option_id + if set_defaults: + self.set_defaults() + + def set_defaults(self): + defaults = {} + if self.type_name == 'discover-aws-assets': + defaults = { + 'excludeAssetsWithTags': '', + 'importTags': False, + 'onlyImportTheseTags': '', + } + for prop_name, default_value in defaults.items(): + if prop_name not in self.configuration_params['properties']: + self.add_property(prop_name, default_value) + + def add_property(self, name, prop, type_=None): + if type_ is None: + if isinstance(prop, bool): + type_ = 'Boolean' + elif isinstance(prop, int): + type_ = 'Integer' + elif isinstance(prop, str): + type_ = 'String' + else: + raise TypeError("Invalid type {} for prop '{}'".format(type(prop), name)) + self.configuration_params['properties'][name] = { + 'valueClass': type_, + 'value': prop, + } + + def get_property(self, name): + return self.configuration_params['properties'][name]['value'] + + def as_json(self): + data = { + "typeName": self.type_name, + "previousTypeName": self.previous_type_name, + "configurationParams": self.configuration_params, + } + if self.workflow_id: + data['workflowID'] = self.workflow_id + if self.integration_option_id: + data['integrationOptionID'] = self.integration_option_id + return data + + +class IntegrationOption(object): + + @classmethod + def CreateFromJSON(cls, json_dict): + name = json_dict.get('name', "") + id_ = json_dict.get('id') + steps = [Step.CreateFromJSON(s) for s in json_dict['steps']] + return cls(name, id_, steps) + + def __init__(self, name="", id=None, steps=None): + if steps is None: + steps = [] + self.name = name + self.id = id + self.steps = steps + + def get_step(self, service_name, type_name=None): + for step in self.steps: + if step.service_name != service_name: + continue + if type_name is not None and step.config.type_name != type_name: + continue + return step + else: + msg = "Unable to find step for serviceName '{}'".format( + service_name) + if type_name is not None: + msg += " and typeName '{}'".format(type_name) + raise KeyError(msg) + + def as_json(self): + data = { + 'name': self.name, + 'steps': [s.as_json() for s in self.steps] + } + if self.id is not None: + data['id'] = self.id + return data + + def update(self, data): + self.id = data['id'] + self.steps = [Step.CreateFromJSON(s) for s in data['steps']] diff --git a/nexpose/nexpose_site.py b/nexpose/nexpose_site.py index c2ce326..00007b6 100644 --- a/nexpose/nexpose_site.py +++ b/nexpose/nexpose_site.py @@ -170,7 +170,7 @@ def AsXML(self, exclude_id): attributes['configVersion'] = self.configversion xml_scanconfig = create_element('ScanConfig', attributes) - xml_scheduling = create_element('Scheduling') + xml_scheduling = create_element('Schedules') for schedule in self.schedules: xml_scheduling.append(schedule) xml_scanconfig.append(xml_scheduling) diff --git a/nexpose/nexpose_vulnerabilityexception.py b/nexpose/nexpose_vulnerabilityexception.py index 7c359f8..e0fd1b2 100644 --- a/nexpose/nexpose_vulnerabilityexception.py +++ b/nexpose/nexpose_vulnerabilityexception.py @@ -2,7 +2,7 @@ from __future__ import (absolute_import, division, print_function, unicode_literals) from builtins import object -from .xml_utils import get_attribute, get_content_of +from .xml_utils import get_attribute, get_content_of, create_element from future import standard_library standard_library.install_aliases() @@ -69,6 +69,35 @@ def CreateFromXML(xml_data): details.asset_port = int(fix_null(get_attribute(xml_data, 'port-no', details.asset_port))) return details + def AsXML(self): + attributes = { + 'exception-id': self.id, + 'vuln-id': self.vulnerability_id, + 'vuln-key': self.vulnerability_key, + 'submitter': self.submitter, + 'reviewer': self.reviewer, + 'status': self.status, + 'reason': self.reason, + 'scope': self.scope, + } + if self.expiration_date: + attributes['expiration-date'] = self.expiration_date + if self.asset_id: + attributes['asset-id'] = self.asset_id + if self.asset_port: + attributes['port-no'] = self.asset_port + xml_data = create_element('VulnerabilityException', attributes) + if self.submitter_comment: + submitter_comment = create_element('submitter-comment') + submitter_comment.text = self.submitter_comment + xml_data.append(submitter_comment) + if self.reviewer_comment: + reviewer_comment = create_element('reviewer-comment') + reviewer_comment.text = self.reviewer_comment + xml_data.append(reviewer_comment) + return xml_data + + def __init__(self): self.id = 0 self.vulnerability_id = ''