├── .gitignore ├── changelog.md ├── readme.md ├── setup.py └── tenable_asc ├── __init__.py ├── asc ├── __init__.py └── assessments.py ├── cli.py └── transform.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .python-version 4 | /keys.txt 5 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.1] 8 | ### Fixed 9 | - The script doesn't handle 404 return codes from ASC #1 10 | 11 | ## [1.0.0] 12 | ### Added 13 | - Initial Version 14 | 15 | [1.0.1]: https://github.com/tenable/integration-asc/compare/1.0.0...1.0.1 16 | [1.0.0]: https://github.com/tenable/integration-asc/compare/3382fbd...1.0.0 -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | > NOTE: This project has been archived and will be removed in the second half of 2024. 2 | 3 | # Tenable.io for Azure Security Center 4 | 5 | > *Please Note:* This script leverages preview APIs for Azure Security Center (ASC). 6 | > While this is expected, the API may change 7 | > unexpectedly on the Microsoft side. However, we plan to update 8 | > this integration as Microsoft updates their APIs for ASC. 9 | 10 | This integration is designed to pull Tenable.io vulnerabilities from Azure assets and summarize (count) them by severity. Once the vulnerabilities are summarized for each Azure asset, the integration creates an Azure Security Center recommendation for each host. The recommendation includes a summary of the number of vulnerabilities on each host and lists them by severity. 11 | 12 | This integration can be run as a one-shot ingest or continuous service. 13 | 14 | 15 | ## Requirements 16 | 17 | * A working Azure connector in your Tenable.io instance 18 | * A set of Azure credentials for the integration to use. You will need to know the App Secret, 19 | App ID, and Tenant ID. See the [Azure documentation][asc_keys] for 20 | instructions. 21 | * A set of Tenable.io API keys with the Administrator role. [See the Tenable.io Generate API Key Instructions][tio_keys] for more information. 22 | * A host to run the script on. This can be located anywhere as the integrations is linking 23 | cloud-to-cloud. 24 | 25 | 26 | ## Setup 27 | ```shell 28 | pip install . 29 | ``` 30 | 31 | ## Options 32 | The following script details, both, command-line arguments and equivalent environment variables. 33 | 34 | ``` 35 | Usage: tenable-asc [OPTIONS] 36 | 37 | Tenable.io -> Azure Security Center Transformer & Ingester 38 | 39 | Options: 40 | --tio-access-key TEXT Tenable.io Access Key 41 | --tio-secret-key TEXT Tenable.io Secret Key 42 | -b, --batch-size INTEGER Export/Import Batch Sizing 43 | -v, --verbose Logging Verbosity 44 | -r, --run-every INTEGER How many hours between recurring imports 45 | --auth-uri TEXT Azure Security Center authentication URI 46 | --azure-uri TEXT Azure Security Center API base URI 47 | --azure-app-id TEXT Azure Security Center application id 48 | --azure-tenant-id TEXT Azure Security Center tenant id 49 | --azure-app-secret TEXT Azure Security Center application secret 50 | --help Show this message and exit. 51 | ``` 52 | 53 | ## Example Usage 54 | 55 | Run the import once: 56 | 57 | ``` 58 | tenable-asc \ 59 | --tio-access-key {TIO_ACCESS_KEY} \ 60 | --tio-secret-key {TIO_SECRET_KEY} \ 61 | --azure-app-id {AZURE_APP_ID} \ 62 | --azure-tenant-id {AZURE_TENANT_ID} \ 63 | --azure-app-secret {AZURE_APP_SECRET} 64 | ``` 65 | 66 | Run the import once an hour: 67 | 68 | ``` 69 | tenable-asc \ 70 | --tio-access-key {TIO_ACCESS_KEY} \ 71 | --tio-secret-key {TIO_SECRET_KEY} \ 72 | --azure-app-id {AZURE_APP_ID} \ 73 | --azure-tenant-id {AZURE_TENANT_ID} \ 74 | --azure-app-secret {AZURE_APP_SECRET} 75 | --run-every 1 76 | ``` 77 | 78 | ## Changelog 79 | [Visit the CHANGELOG](CHANGELOG.md) 80 | 81 | [tio_keys]: https://docs.tenable.com/cloud/Content/Settings/GenerateAPIKey.htm 82 | [asc_keys]: https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal 83 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | 4 | long_description = ''' 5 | Tenable.io -> Microsoft Azure Security Center Bridge 6 | For usage documentation, please refer to the github repository at 7 | https://github.com/tenable/integrations-microsoft-asc 8 | ''' 9 | 10 | setup( 11 | name='tenable-microsoft-asc', 12 | version='1.0.1', 13 | description='', 14 | author='Tenable, Inc.', 15 | long_description=long_description, 16 | author_email='smcgrath@tenable.com', 17 | url='https://github.com/tenable/integrations-microsoft-asc', 18 | license='MIT', 19 | classifiers=[ 20 | 'Development Status :: 4 - Beta', 21 | 'Intended Audience :: Information Technology', 22 | 'Topic :: System :: Networking', 23 | 'Topic :: Other/Nonlisted Topic', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.4', 27 | 'Programming Language :: Python :: 3.5', 28 | 'Programming Language :: Python :: 3.6', 29 | 'Programming Language :: Python :: 3.7', 30 | ], 31 | keywords='tenable tenable_io microsoft securitycenter', 32 | packages=find_packages(exclude=['tests']), 33 | install_requires=[ 34 | 'pytenable>=0.3.28', 35 | 'restfly>=1.1.0', 36 | 'arrow>=0.14.0', 37 | 'Click>=7.0' 38 | ], 39 | entry_points={ 40 | 'console_scripts': [ 41 | 'tenable-asc=tenable_asc.cli:cli', 42 | ], 43 | }, 44 | ) -------------------------------------------------------------------------------- /tenable_asc/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.2' -------------------------------------------------------------------------------- /tenable_asc/asc/__init__.py: -------------------------------------------------------------------------------- 1 | import adal 2 | from tenable_asc import __version__ 3 | from restfly.session import APISession 4 | from .assessments import AssessmentAPI 5 | 6 | class AzureSecurityCenter(APISession): 7 | _vendor='Tenable', 8 | _product='Azure Security Center', 9 | _build=__version__ 10 | _url = 'https://management.azure.com' 11 | _auth_url = 'https://login.microsoftonline.com' 12 | 13 | def __init__(self, tenant_id, app_id, app_secret, **kw): 14 | self._auth_url = kw.pop('auth_url', self._auth_url) 15 | self._tenant_id = tenant_id 16 | self._app_id = app_id 17 | self._app_secret = app_secret 18 | super(AzureSecurityCenter, self).__init__(**kw) 19 | 20 | def _build_session(self, **kwargs): 21 | super(AzureSecurityCenter, self)._build_session(**kwargs) 22 | 23 | # Retreive the access token using the adal library. 24 | self._ctx = adal.AuthenticationContext('{}/{}'.format( 25 | self._auth_url, self._tenant_id)) 26 | resp = self._ctx.acquire_token_with_client_credentials(self._url + '/', 27 | self._app_id, self._app_secret) 28 | 29 | # add the auth bearer header to the session. 30 | self._session.headers.update({ 31 | 'Authorization': 'Bearer {}'.format(resp.get('accessToken')) 32 | }) 33 | 34 | @property 35 | def assessments(self): 36 | return AssessmentAPI(self) -------------------------------------------------------------------------------- /tenable_asc/asc/assessments.py: -------------------------------------------------------------------------------- 1 | from restfly.endpoint import APIEndpoint 2 | 3 | class AssessmentAPI(APIEndpoint): 4 | def create_type(self, subscription, guid, **properties): 5 | ''' 6 | Create a new assessment type for the subscription defined. 7 | 8 | Args: 9 | subscription (str): The subscription id. 10 | guid (str): The new assessment GUID to create. 11 | **properties (dict): 12 | Properties to associate to the new assessment type. 13 | ''' 14 | return self._api.put( 15 | 'subscriptions/{}/providers/Microsoft.Security/assessmentMetadata/{}'.format( 16 | subscription, guid), 17 | params={'api-version': '2019-01-01-preview'}, 18 | json={ 19 | 'tags': {'provider': 'Tenable'}, 20 | 'properties': properties 21 | }).json() 22 | 23 | def create_assessment_finding(self, resource, type_guid, status, **data): 24 | ''' 25 | Create/updates an assessment finding on the defined resource & type. 26 | 27 | Args: 28 | resource (str): The resource id. 29 | type_guid (str): The assessment type GUID. 30 | status (str): 31 | The health status of the finding. The API supports the values: 32 | ``Healthy``, ``Unhealthy``, and ``NotApplicable``. 33 | **data (dict): 34 | Additional datapoints to be added into the ``additionalData`` 35 | sub-document. 36 | ''' 37 | return self._api.put( 38 | '{}/providers/Microsoft.Security/assessments/{}'.format( 39 | resource[1:], type_guid), 40 | params={'api-version': '2019-01-01-preview'}, 41 | json={ 42 | 'properties': { 43 | 'resourceDetails': { 44 | 'Source': 'Azure', 45 | 'Id': resource, 46 | }, 47 | 'status': {'code': status}, 48 | 'additionalData': data, 49 | } 50 | }).json() 51 | -------------------------------------------------------------------------------- /tenable_asc/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' 3 | MIT License 4 | 5 | Copyright (c) 2019 Tenable Network Security, Inc. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | ''' 25 | import click, logging, time 26 | from tenable.io import TenableIO 27 | from .asc import AzureSecurityCenter 28 | from .transform import Tio2ASC 29 | from . import __version__ 30 | 31 | 32 | @click.command() 33 | @click.option('--tio-access-key', 34 | envvar='TIO_ACCESS_KEY', help='Tenable.io Access Key') 35 | @click.option('--tio-secret-key', 36 | envvar='TIO_SECRET_KEY', help='Tenable.io Secret Key') 37 | @click.option('--batch-size', '-b', envvar='BATCH_SIZE', default=1000, 38 | type=click.INT, help='Export/Import Batch Sizing') 39 | @click.option('--verbose', '-v', envvar='VERBOSITY', default=0, 40 | count=True, help='Logging Verbosity') 41 | @click.option('--run-every', '-r', envvar='RUN_EVERY', 42 | type=click.INT, help='How many hours between recurring imports') 43 | @click.option('--auth-uri', envvar='AZURE_AUTH_URI', 44 | help='Azure Security Center authentication URI', 45 | default='https://login.microsoftonline.com') 46 | @click.option('--azure-uri', envvar='AZURE_API_URI', 47 | help='Azure Security Center API base URI', 48 | default='https://management.azure.com') 49 | @click.option('--azure-app-id', envvar='AZURE_APP_ID', 50 | help='Azure Security Center application id') 51 | @click.option('--azure-tenant-id', envvar='AZURE_TENANT_ID', 52 | help='Azure Security Center tenant id') 53 | @click.option('--azure-app-secret', envvar='AZURE_APP_SECRET', 54 | help='Azure Security Center application secret') 55 | @click.option('--subscription', '-s', multiple=True, 56 | help='Only upload this subscription to Azure') 57 | def cli(tio_access_key, tio_secret_key, batch_size, verbose, 58 | run_every, auth_uri, azure_uri, azure_app_id, 59 | azure_tenant_id, azure_app_secret, subscription): 60 | ''' 61 | Tenable.io -> Azure Security Center Transformer & Ingester 62 | ''' 63 | # Setup the logging verbosity. 64 | if verbose == 0: 65 | logging.basicConfig(level=logging.WARNING) 66 | if verbose == 1: 67 | logging.basicConfig(level=logging.INFO) 68 | if verbose > 1: 69 | logging.basicConfig(level=logging.DEBUG) 70 | 71 | # Initiate the Tenable.io API model, the Ingester model, and start the 72 | # ingestion and data transformation. 73 | tio = TenableIO(tio_access_key, tio_secret_key, 74 | vendor='Tenable', 75 | product='Azure Security Center', 76 | build=__version__) 77 | asc = AzureSecurityCenter( 78 | azure_tenant_id, 79 | azure_app_id, 80 | azure_app_secret, 81 | auth_url=auth_uri, 82 | url=azure_uri 83 | ) 84 | ingest = Tio2ASC(tio, asc, allowed_subs=subscription) 85 | ingest.ingest(batch_size) 86 | 87 | # If we are expected to continually re-run the transformer, then we will 88 | # need to track the passage of time and run every X hours, where X is 89 | # defined by the user. 90 | if run_every and run_every > 0: 91 | while True: 92 | sleeper = run_every * 3600 93 | last_run = int(time.time()) 94 | logging.info( 95 | 'Sleeping for {}s before next iteration'.format(sleeper)) 96 | time.sleep(sleeper) 97 | logging.info( 98 | 'Initiating ingest with observed_since={}'.format(last_run)) 99 | ingest.ingest(last_run, batch_size, threads) -------------------------------------------------------------------------------- /tenable_asc/transform.py: -------------------------------------------------------------------------------- 1 | import arrow, time, logging, json 2 | from restfly.errors import NotFoundError, ForbiddenError 3 | 4 | class Tio2ASC: 5 | _cache = dict() 6 | _psubs = list() 7 | allowed_subs = None 8 | 9 | def __init__(self, tio, asc, allowed_subs=None): 10 | self._log = logging.getLogger('{}.{}'.format( 11 | self.__module__, self.__class__.__name__)) 12 | self.tio = tio 13 | self.asc = asc 14 | self.allowed_subs = allowed_subs 15 | 16 | def _cache_asset(self, asset): 17 | ''' 18 | Populate the asset counter sub-doc into the cache dictionary. 19 | ''' 20 | resource = asset.get('azure_resource_id') 21 | if resource: 22 | self._cache[asset['id']] = { 23 | 'resource': resource, 24 | 'subscription': resource.split('/')[2], 25 | 'low': 0, 26 | 'medium': 0, 27 | 'high': 0, 28 | 'critical': 0, 29 | } 30 | self._log.debug( 31 | 'Found Tenable Asset {} with Azure Resource ID of {}'.format( 32 | asset['id'], resource)) 33 | 34 | def _upsert_metadata(self, asset_uuid, unhealthy_thresh): 35 | ''' 36 | upserts the data for the asset UUID from the cache into ASC. 37 | 38 | Args: 39 | asset_uuid (str): The Tenable.io UUID to process. 40 | unhealthy_thresh (str): 41 | At what severity level will a non-zero counter flip the status 42 | to "unhealthy"? 43 | ''' 44 | 45 | # a simple severity matrix to track what each threshold means. 46 | matrix = { 47 | 'low': ['low', 'medium', 'high', 'critical'], 48 | 'medium': ['medium', 'high', 'critical'], 49 | 'high': ['high', 'critical'], 50 | 'critical': ['critical',], 51 | } 52 | 53 | atype = 'ae3222be-cd0a-4ca2-b85e-2ecaf3392b18' 54 | asset = self._cache[asset_uuid] 55 | 56 | # Lets determine the status of the asset. 57 | status = 'Healthy' 58 | for sev in matrix[unhealthy_thresh]: 59 | if asset[sev] > 0: 60 | status = 'Unhealthy' 61 | 62 | # The resource must be in an allowed subscription if the allowed 63 | # subscription list is defined. 64 | if (not self.allowed_subs 65 | or (self.allowed_subs and asset['subscription'] in self.allowed_subs)): 66 | # If the subscription hasn't yet received the assessment type, then 67 | # we will need to create it. 68 | if not asset['subscription'] in self._psubs: 69 | try: 70 | resp = self.asc.assessments.create_type(asset['subscription'], atype, 71 | displayName='Tenable.io Assessment', 72 | assessmentType='Custom', 73 | description='Vulnerabilities were discovered on the resource.', 74 | remediationDescription='Refer to details within Tenable.io for more information', 75 | categories=['Compute',], 76 | secureScoreWeight=50, 77 | preview=True, 78 | ) 79 | except ForbiddenError: 80 | self._log.warning('Not authorized to create type {} in {}'.format( 81 | atype, asset['subscription'])) 82 | return 83 | self._psubs.append(asset['subscription']) 84 | self._log.debug('Azure Responded with: ' + json.dumps(resp)) 85 | 86 | # Generate the finding in Azure Security Center. 87 | try: 88 | resp = self.asc.assessments.create_assessment_finding( 89 | asset['resource'], 90 | atype, 91 | status, 92 | low=asset['low'], 93 | medium=asset['medium'], 94 | high=asset['high'], 95 | critical=asset['critical'], 96 | link=''.join([ 97 | 'https://cloud.tenable.com/tio/app.html#', 98 | '/vulnerability-management/assets/asset-details', 99 | '/{}/overview'.format(asset_uuid) 100 | ])) 101 | except NotFoundError: 102 | self._log.warning('Asset no longer exists {}'.format( 103 | asset['resource'])) 104 | except ForbiddenError: 105 | self._log.warning('Not authorized to submit resource {}'.format( 106 | asset['resource'])) 107 | else: 108 | self._log.debug('Azure Responded with: ' + json.dumps(resp)) 109 | 110 | def ingest(self, age=None, batch_size=1000, unhealthy_thresh='medium'): 111 | ''' 112 | Perform the ingestion 113 | 114 | Args: 115 | age (int, optional): 116 | What is the maximum age of the assets and vulnerability 117 | observations? If left unspecified, the default is the timestamp 118 | from 90 days ago. 119 | batch_size (int, optional): 120 | What is the chunk sizing to use when exporting the data from 121 | Tenable.io? If left unspecified, the default is 1000. 122 | unhealthy_thresh (str, optional): 123 | At what point do we consider the asset in an unhealthy state? 124 | Based on a non-zero number of vulns of the specified severity 125 | level. 126 | ''' 127 | if not age: 128 | age = arrow.utcnow().shift(days=-90).timestamp 129 | self._cache = dict() 130 | 131 | # First we need to populate the cache with the assets that we actually 132 | # care about. To do this we will run an asset export and then pass each 133 | # asset on to the _cache_asset method, which simply checks to see if the 134 | # azure_resource_id attribute is populated, and if so, will generate the 135 | # base sub-dictionary with all of the counters and meta-data that we 136 | # need for later on. 137 | self._log.info('querying Tenable.io for the asset records.') 138 | assets = self.tio.exports.assets(sources=['AZURE'], updated_at=age) 139 | for asset in assets: 140 | self._cache_asset(asset) 141 | self._log.info('discovered {} Azure Assets'.format(len(self._cache))) 142 | 143 | # Now we will initiate the vulnerability export and then leverage the 144 | # cache that we had just generated to count up the number of vulns for 145 | # each severity level. 146 | self._log.info('querying Tenable.io for vulnerability records.') 147 | vulns = self.tio.exports.vulns( 148 | last_found=age, 149 | severity=['low', 'medium', 'high', 'critical'], 150 | state=['open', 'reopened']) 151 | 152 | # Iterate through the vulnerabilities, incrementing the appropriate 153 | # severity counter for eligable assets. 154 | vcounter = 0 155 | for vuln in vulns: 156 | if vuln['asset']['uuid'] in self._cache.keys(): 157 | vcounter += 1 158 | self._cache[vuln['asset']['uuid']][vuln['severity']] += 1 159 | self._log.info('processed {} vulnerabilities out of {} total.'.format( 160 | vcounter, vulns.count)) 161 | 162 | # Iterate over the cache and feed the data into ASC. 163 | for asset in self._cache.keys(): 164 | self._upsert_metadata(asset, unhealthy_thresh) --------------------------------------------------------------------------------