From 5ec234725d7c73e9a3677fb25e85759b71d5c012 Mon Sep 17 00:00:00 2001 From: valkiriaaquatica Date: Sat, 24 Aug 2024 20:05:46 +0000 Subject: [PATCH 1/4] add event source for tenable --- extensions/eda/rulebooks/event_tenable.yml | 15 ++ plugins/event_source/eventstenable.py | 195 +++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 extensions/eda/rulebooks/event_tenable.yml create mode 100644 plugins/event_source/eventstenable.py diff --git a/extensions/eda/rulebooks/event_tenable.yml b/extensions/eda/rulebooks/event_tenable.yml new file mode 100644 index 0000000..93bb74b --- /dev/null +++ b/extensions/eda/rulebooks/event_tenable.yml @@ -0,0 +1,15 @@ +--- +- name: Retrieve critical vullnerabilities from Tenable API every 30 minutes + hosts: localhost + sources: + - valkiriaaquatica.tenable.eventstenable: + endpoint: "workbenches/vulnerabilities?filter.0.filter=severity&filter.0.quality=eq&filter.0.value=Critical" + data_key: "vulnerabilities" + interval: 30 + + rules: + - name: Run ansible hello default EDA playbook if critical vulnerbaility comes from 12345 plugin + condition: event.tenable.plugin_id == "12345" + action: + run_playbook: + name: ansible.eda.hello \ No newline at end of file diff --git a/plugins/event_source/eventstenable.py b/plugins/event_source/eventstenable.py new file mode 100644 index 0000000..c10966d --- /dev/null +++ b/plugins/event_source/eventstenable.py @@ -0,0 +1,195 @@ +""" +eventstenable.py + +An ansible-rulebook event source scrapper plugin module for fetching data from public Tenable API using its API. + +Arguments: +--------- + endpoint: The API endpoint to query, relative to the base Tenable API URL. + This should be a valid endpoint within the Tenable API, e.g., + "workbenches/vulnerabilities". Required. + data_key: The key in the JSON response that contains the list of items to be + processed. Default is "data", place "" to none key to filter. + Easy to look ip up, just go to the Tenable + API official docs and for example for: https://developer.tenable.com/reference/io-filters-assets-list + data_key is filters + tenable_access_key: The access key for authenticating with the Tenable API. + Can be provided directly or set as an environment + variable TENABLE_ACCESS_KEY. Required. + tenable_secret_key: The secret key for authenticating with the Tenable API. + Can be provided directly or set as an environment + variable TENABLE_SECRET_KEY. Required. + interval: The interval in minutes at which the API should be queried. + Default is 5 minutes. + +Example: +------- + # gets all vulnerabilities querying every 10 minutes + - valkiriaaquatica.tenable.eventstenable: + endpoint: "workbenches/vulnerabilities" + data_key: "vulnerabilities" + tenable_access_key: "{{ TENABLE_ACCESS_KEY }}" + tenable_secret_key: "{{ TENABLE_SECRET_KEY }}" + interval: 10 + + # gets all critical vulnerabilites every 5 minutes + - valkiriaaquatica.tenable.eventstenable: + endpoint: "workbenches/vulnerabilities?filter.0.filter=severity&filter.0.quality=eq&filter.0.value=Critical" + data_key: "vulnerabilities" + tenable_access_key: "{{ TENABLE_ACCESS_KEY }}" + tenable_secret_key: "{{ TENABLE_SECRET_KEY }}" + interval: 0.5 + + # gets all vulnerabilites from an asset every 30 seconds using enviroment vars + - valkiriaaquatica.tenable.eventstenable: + endpoint: "workbenches/assets/0004544c-0a6d-45ee-9e5f-c1fbc436092b/vulnerabilities" + data_key: "vulnerabilities" + interval: 0.5 + + # gets plugins in nested json every day using enviroment vars + - valkiriaaquatica.tenable.eventstenable: + endpoint: "plugins/plugin" + data_key: "data.plugin_details" + interval: 1440 + + # gets all agent groups querying every 30 minuted using enviroment vars + - valkiriaaquatica.tenable.eventstenable: + endpoint: "scanners/null/agent-groups" + data_key: "groups" + interval: 30 +""" + +import asyncio +import json +import os +from urllib.parse import urlencode +from urllib.error import HTTPError, URLError +from ansible.module_utils.urls import Request +from typing import Any, Dict + + +class AuthenticationError(Exception): + pass + + +class BadRequestError(Exception): + pass + + +class TenableAPIError(Exception): + pass + + +class UnexpectedAPIResponse(Exception): + pass + + +def get_tenable_credentials(access_key=None, secret_key=None): + access_key = access_key or os.getenv("TENABLE_ACCESS_KEY") + secret_key = secret_key or os.getenv("TENABLE_SECRET_KEY") + if not access_key or not secret_key: + raise ValueError("Access key and secret key are required for Tenable API.") + + return access_key, secret_key + + +class TenableAPI: + def __init__(self, access_key=None, secret_key=None): + self.access_key, self.secret_key = get_tenable_credentials(access_key, secret_key) + self.base_url = "https://cloud.tenable.com" + self.headers = { + "Accept": "application/json", + "X-ApiKeys": f"accessKey={self.access_key};secretKey={self.secret_key}", + } + self.client = Request(validate_certs=True, timeout=30) + + def request(self, method: str, endpoint: str, data: dict = None) -> dict: + url = f"{self.base_url}/{endpoint}" + + if data: + data = json.dumps(data).encode("utf-8") + if method in ["PATCH", "POST", "PUT"]: + self.headers["Content-Type"] = "application/json" + else: + self.headers.pop("Content-Type", None) + + try: + response = self.client.open(method=method, url=url, headers=self.headers, data=data) + response_body = response.read() + if response_body: + return json.loads(response_body.decode("utf-8")) + return {} + except HTTPError as e: + error_data = e.read().decode("utf-8") + if e.code == 400: + raise BadRequestError(error_data, e.code) + elif e.code == 401: + raise AuthenticationError("Authentication failure. Please check your API keys.", e.code) + else: + raise UnexpectedAPIResponse(e.code, error_data) + except URLError as e: + raise TenableAPIError(f"URL error occurred: {str(e)}", None) + except TimeoutError as e: + raise TenableAPIError(f"Request timed out: {str(e)}", None) + + +def get_nested_value(d, keys): + """Recursively get a value from a nested dictionary just when nested is in json response""" + for key in keys: + if isinstance(d, dict) and key in d: + d = d[key] + else: + return None + return d + + +async def main(queue: asyncio.Queue, args: Dict[str, Any]): + valid_keys = {"endpoint", "data_key", "tenable_access_key", "tenable_secret_key", "interval"} + + for key in args: + if key not in valid_keys: + raise ValueError(f"Invalid argument '{key}' provided.") + + endpoint = args.get("endpoint") + data_key = args.get("data_key", "data") + access_key = args.get("tenable_access_key") + secret_key = args.get("tenable_secret_key") + interval_minutes = args.get("interval", 5) + interval_seconds = interval_minutes * 60 + + if not endpoint: + raise ValueError("Endpoint must be provided, It cannot be empty.") + + tenable_api = TenableAPI(access_key=access_key, secret_key=secret_key) + + while True: + try: + response = tenable_api.request(method="GET", endpoint=endpoint) + keys = data_key.split(".") + data_to_process = get_nested_value(response, keys) + + if data_to_process: + for item in data_to_process: + await queue.put({"tenable": item}) + else: + await queue.put({"tenable": response}) + + await asyncio.sleep(interval_seconds) + + except asyncio.CancelledError: + break + except Exception as e: + print(f"Error in Tenable plugin: {e}") + + +if __name__ == "__main__": + """MockQueue if running directly.""" + + class MockQueue(asyncio.Queue[Any]): + """A fake queue.""" + + async def put(self: "MockQueue", event: dict) -> None: + """Print the event.""" + print(event) # noqa: T201 + + asyncio.run(main(MockQueue(), {})) From f0be89f757bb5cb400f8fe54f126f2dcaf5e566e Mon Sep 17 00:00:00 2001 From: valkiriaaquatica Date: Sat, 24 Aug 2024 21:04:42 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix=20isort=20tests=C2=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/event_source/eventstenable.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/event_source/eventstenable.py b/plugins/event_source/eventstenable.py index c10966d..280917f 100644 --- a/plugins/event_source/eventstenable.py +++ b/plugins/event_source/eventstenable.py @@ -62,10 +62,13 @@ import asyncio import json import os +from typing import Any +from typing import Dict +from urllib.error import HTTPError +from urllib.error import URLError from urllib.parse import urlencode -from urllib.error import HTTPError, URLError + from ansible.module_utils.urls import Request -from typing import Any, Dict class AuthenticationError(Exception): From b44aef0dbf1c90ddb6b14ee0dfcf5095d22be4f6 Mon Sep 17 00:00:00 2001 From: valkiriaaquatica Date: Sat, 24 Aug 2024 21:25:52 +0000 Subject: [PATCH 3/4] delete importaiton not used --- plugins/event_source/eventstenable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/event_source/eventstenable.py b/plugins/event_source/eventstenable.py index 280917f..1bddf4c 100644 --- a/plugins/event_source/eventstenable.py +++ b/plugins/event_source/eventstenable.py @@ -66,7 +66,6 @@ from typing import Dict from urllib.error import HTTPError from urllib.error import URLError -from urllib.parse import urlencode from ansible.module_utils.urls import Request From 4cbcfb2838f741f30af116fd15339ab05b06f933 Mon Sep 17 00:00:00 2001 From: valkiriaaquatica Date: Sat, 24 Aug 2024 21:33:32 +0000 Subject: [PATCH 4/4] add readme docs for event source --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 7fc65b2..0e3caf5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,30 @@ compose: hash_id: id | md5 ``` +### Event Driven - Ansible Rulebooks + +Fetch data from an event source like Tenable Public API can be made using the plugin eventstenable +that can be found in /plugins/event_source/eventstenable.py +An easy example is: + +```yaml +--- +- name: Retrieve critical vullnerabilities from Tenable API every 30 minutes + hosts: localhost + sources: + - valkiriaaquatica.tenable.eventstenable: + endpoint: "workbenches/vulnerabilities?filter.0.filter=severity&filter.0.quality=eq&filter.0.value=Critical" + data_key: "vulnerabilities" + interval: 30 + + rules: + - name: Run ansible hello default EDA playbook if critical vulnerbaility comes from 12345 plugin + condition: event.tenable.plugin_id == "12345" + action: + run_playbook: + name: ansible.eda.hello +``` + ## Contributing There are many ways in which you can participate in the project, for example: