-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
CrowdStrike -> Tenable One Connector
- Loading branch information
1 parent
ea161e4
commit 1b07425
Showing
18 changed files
with
2,384 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .api.session import CrowdStrikeAPI |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
# |
Oops, something went wrong.