Skip to content

Commit

Permalink
CrowdStrike -> Tenable One Connector
Browse files Browse the repository at this point in the history
  • Loading branch information
SteveMcGrath committed Dec 12, 2024
1 parent ea161e4 commit 1b07425
Show file tree
Hide file tree
Showing 18 changed files with 2,384 additions and 0 deletions.
7 changes: 7 additions & 0 deletions connectors/crowdstrike2tone/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Crowdstrike -> T1 Ingest Connector

This connector code will download the assets from Crowdstrike,
transform then upload to T1.

All job management is handled by the pyTenable sync JobManager (currently within the
`feature/sync` branch).
105 changes: 105 additions & 0 deletions connectors/crowdstrike2tone/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env python3

from crowdstrike import CrowdStrikeAPI
from crowdstrike.transform import Transformer

import logging
from enum import Enum

from rich.logging import RichHandler
from tenable.io import TenableIO
from typer import Option, Typer
from typing_extensions import Annotated

app = Typer()

class LogLevels(str, Enum):
debug = 'DEBUG'
info = 'INFO'
warn = 'WARN'
error = 'ERROR'


def setup_logging(log_level: LogLevels) -> None:
"""
Setup logging for qualys integration
"""
fileHandler = logging.FileHandler('crowdstrike2tone.log')
fileHandler.setFormatter(
logging.Formatter(
fmt='%(asctime)s %(levelname)-5.5s %(message)s', datefmt='[%X]'
)
)
logging.basicConfig(
level=log_level.value,
format='%(message)s',
datefmt='[%X]',
handlers=[RichHandler(rich_tracebacks=True), fileHandler],
)

@app.command()
def run(
tio_access_key: Annotated[
str, Option(envvar='TIO_ACCESS_KEY', prompt=True, help='TVM/TIO API Access Key')
],
tio_secret_key: Annotated[
str, Option(envvar='TIO_SECRET_KEY', prompt=True, help='TVM/TIO API Secret Key')
],
crowdstrike_url: Annotated[
str, Option(envvar='CROWDSTRIKE_URL', prompt=True, help='Crowdstrike api url')
],
crowdstrike_client_id: Annotated[
str, Option(envvar='CROWDSTRIKE_CLIENT_ID',
prompt=True,
help='The API client ID to authenticate your API requests.\n'
'https://falcon.crowdstrike.com/support/documentation/1/crowdstrike-api-introduction-for-developers'
)
],
crowdstrike_client_secret: Annotated[
str,
Option(envvar='CROWDSTRIKE_CLIENT_SECRET',
prompt=True,
help='The API client secret to authenticate your API requests.\n'
'https://falcon.crowdstrike.com/support/documentation/1/crowdstrike-api-introduction-for-developers'
)
],
crowdstrike_member_cid: Annotated[
str,
Option(
envvar='CROWDSTRIKE_MEMBER_CID',
help='For MSSP Master CIDs, optionally lock the token to act on behalf of this member CID'
)] = None,
tio_url: Annotated[
str, Option(envvar='TIO_URL', help='TVM/TIO URL')
] = 'https://cloud.tenable.com',
log_level: Annotated[
LogLevels, Option(envvar='LOG_LEVEL', help='Output logging level')
] = 'INFO',
last_seen_days: Annotated[
int,
Option(
envvar='CROWDSTRIKE_LAST_SEEN_DAYS',
help='How many days back from today should we pull crowdstrike data for? '
)] = 1,
download_vulns: Annotated[
bool, Option(help='Import Falson Spotlight Vulndreability Findings?')
] = False,
) -> None:
"""
Run the Crowdstrike connector
"""
setup_logging(log_level)

tvm = TenableIO(access_key=tio_access_key, secret_key=tio_secret_key, url=tio_url)
crwd = CrowdStrikeAPI(
url=crowdstrike_url,
client_id=crowdstrike_client_id,
client_secret=crowdstrike_client_secret,
member_cid=crowdstrike_member_cid,
)
c2t1 = Transformer(tvm=tvm, crwd=crwd)
c2t1.run(get_findings=download_vulns, last_seen_days=last_seen_days)


if __name__ == '__main__':
app()
57 changes: 57 additions & 0 deletions connectors/crowdstrike2tone/connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env python
import logging
from typing import Annotated

from pydantic import AnyHttpUrl, Field, SecretStr
from tenint import Connector, Credential, Settings, TenableVMCredential

from crowdstrike.transform import Transformer


class CrowdstrikeCredential(Credential):
"""
Crowdstrike Credentials
"""

prefix: str = 'crowdstrike'
name: str = 'Crowdstrike'
slug: str = 'crowdstrike'
description: str = 'Crowdstrike API Credential'
client_id: str
client_secret: SecretStr
member_cid: str
url: AnyHttpUrl


class AppSettings(Settings):
"""
Crowdstrike Connector Settings
"""

debug: Annotated[bool, Field(title='Debug')] = False
last_seen_days: Annotated[
int, Field(title='How many days back to get assets/findings')
] = 1
import_findings: Annotated[bool, Field(title='Import Findings')] = False


connector = Connector(
settings=AppSettings, credentials=[CrowdstrikeCredential, TenableVMCredential]
)


@connector.job
def main(config: AppSettings):
"""
Crowdstrike to Tenable One Connector
"""
if config.debug:
logging.getLogger().setLevel('DEBUG')
transformer = Transformer()
transformer.run(
get_findings=config.import_findings, last_seen_days=config.last_seen_days
)


if __name__ == '__main__':
connector.app()
1 change: 1 addition & 0 deletions connectors/crowdstrike2tone/crowdstrike/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .api.session import CrowdStrikeAPI
Empty file.
71 changes: 71 additions & 0 deletions connectors/crowdstrike2tone/crowdstrike/api/assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from typing import Optional, List, Dict, Union
from requests import Response
from box import Box, BoxList
import arrow
from crowdstrike.api.iterator import CrowdstrikeAssetIterator
from restfly.errors import BadRequestError
from restfly.endpoint import APIEndpoint

class AssetsAPI(APIEndpoint):

def list(
self,
limit: int = 5000,
sort: Optional[str] = 'last_seen.asc',
last_seen_days: Optional[int] = 1,
filter: Optional[str] | None = None,
**kwargs,
) -> CrowdstrikeAssetIterator:
"""
Get all hosts
Args:
limit (int): the number of items to return. This should never be more than 5000
as the following api call to get details only accepts max 5000
sort (str, optional): the FQL sort parameter
last_seen_days (int, optional): the number of days back to search back if filter isnt provided.
filter (str, optional): The filter string in FQL format to filter CS assets with
Returns:
CrowdstrikeAssetIterator
"""
_path = 'devices/queries/devices-scroll/v1'
if limit > 5000:
self._log.warning(f'limit must be <= 5000; {limit} provided. Setting to 5000.')
limit = 5000
params = {
'limit': limit,
'sort': sort,
}
if filter:
params['filter'] = filter
else:
last_seen = arrow.utcnow().shift(days=-last_seen_days).format('YYYY-MM-DDTHH:mm:ssZ')
params['filter'] = f"last_seen:>='{last_seen}'" #+provision.status:['Provisioned']"
return CrowdstrikeAssetIterator(
self._api,
_envelope='resources',
_path=_path,
_params=params,
)

def _device_details(
self,
ids: List[str]

) -> Union[Box, BoxList, Response, Dict, List, None]:
"""
Get all device details for the provided CS device ids
Args:
ids (list): list of device ids to get details from.
"""

_path = 'devices/entities/devices/v2'

if len(ids) > 5000:
raise BadRequestError(f'{len(ids)} asset ids provided but only 5000 are supported.')

body = {
'ids': ids
}
return self._post(_path, json=body)
9 changes: 9 additions & 0 deletions connectors/crowdstrike2tone/crowdstrike/api/findings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

from restfly.endpoint import APIEndpoint, APISession

class FindingsAPI(APIEndpoint):
_path = 'asset/host/vm/detection/'

def __init__(self, api: APISession):
raise NotImplementedError()

28 changes: 28 additions & 0 deletions connectors/crowdstrike2tone/crowdstrike/api/iterator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Dict, Any
from restfly.iterator import APIIterator
from copy import copy

class CrowdstrikeAssetIterator(APIIterator):
_path: str
_envelope: str
_params: Dict[str, Any]
_offset: str | None = None
_total_assets: int | None = None

def _get_page(self):
params = copy(self._params)
if self._offset:
params['offset'] = self._offset
#params['skip'] = self._page_size * self.num_pages
#params['top'] = self._page_size
resp = self._api.get(self._path, params=params)
pagination = resp.get('meta').get('pagination')
self._offset = pagination.get('offset', None)
if self._total_assets is None:
self._total_assets = pagination.get('total')
self._log.info(f'Total assets reported by api: {self._total_assets}')
device_ids = resp[self._envelope]
if len(device_ids) == 0:
raise StopIteration()
resp = self._api.assets._device_details(ids=device_ids)
self.page = resp[self._envelope]
87 changes: 87 additions & 0 deletions connectors/crowdstrike2tone/crowdstrike/api/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@

from restfly.session import APISession
from typing import Union, Dict, List
from requests import Response
from box import Box, BoxList

from .assets import AssetsAPI
from .findings import FindingsAPI

import os
import arrow

class CrowdStrikeAPI(APISession):
_box = True
token_expires_at: int | None = None
client_id: str | None = None
client_secret: str | None = None
member_cid: str | None = None
url: str | None = None

"""
Docs:
https://assets.falcon.us-2.crowdstrike.com/support/api/swagger-us2.html
"""
def __init__(self, **kwargs):
params = (
('url', os.environ.get('CROWDSTRIKE_URL')),
('client_id', os.environ.get('CROWDSTRIKE_CLIENT_ID')),
('client_secret', os.environ.get('CROWDSTRIKE_CLIENT_SECRET')),
('member_cid', os.environ.get('CROWDSTRIKE_MEMBER_CID')),
)
for key, envval in params:
if envval and not kwargs.get(key):
kwargs[key] = envval
if not kwargs.get('url'):
raise ConnectionError('No valid url provided')
if not kwargs.get('client_id'):
raise ConnectionError('No valid client_id provided')
if not kwargs.get('client_secret'):
raise ConnectionError('No valid client_secret provided')

super().__init__(**kwargs)

def _authenticate(self, **kwargs) -> None:
if not self.client_id:
self.client_id = kwargs.get('client_id')
if not self.client_secret:
self.client_secret = kwargs.get('client_secret')
if not self.member_cid:
self.member_cid = kwargs.get('member_cid')
data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
}
if self.member_cid is not None:
data['member_cid'] = self.member_cid
ret = self.post('oauth2/token', data=data)
self.token_expires_at = arrow.utcnow().shift(
seconds=int(
ret.get('expires_in', 1799) - 10 # reduce by 10 seconds just to be safe
)).int_timestamp
self._session.headers.update({
'Authorization': 'Bearer {}'.format(ret.get('access_token'))
})


def _req(
self, method: str, path: str, **kwargs
) -> Union[Box, BoxList, Response, Dict, List, None]:
"""
Overload default request function to ensure we update our token before it expires
"""
if self.token_expires_at and arrow.utcnow().int_timestamp >= self.token_expires_at:
self.token_expires_at = None
self._authenticate()

return super()._req( method, path, **kwargs)

@property
def assets(self):
return AssetsAPI(self)

@property
def findings(self):
return FindingsAPI(self)
#
Loading

0 comments on commit 1b07425

Please sign in to comment.