From 09329e0bc2c9553034f1a26b3ab6574d3c63ef38 Mon Sep 17 00:00:00 2001 From: Rudi Klein Date: Sat, 18 May 2024 21:23:35 +0200 Subject: [PATCH] Merger of module and notifier. Rename of files to 'notify' --- .env | 4 +- requirements.txt | 2 +- wazuh-notify-config.yaml | 32 --- wazuh-notify-test-event.json | 76 +++++++ wazuh-notify.py | 105 ++++++++++ wazuh_notify_module.py | 391 +++++++++++++++++++++++++++++++++++ 6 files changed, 575 insertions(+), 35 deletions(-) delete mode 100755 wazuh-notify-config.yaml create mode 100644 wazuh-notify-test-event.json create mode 100755 wazuh-notify.py create mode 100755 wazuh_notify_module.py diff --git a/.env b/.env index be7ea5c..2b864a6 100755 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -DISCORD_WEBHOOK=https://discord.com/api/webhooks/1235943329854783530/lgAd6On2gtLPCAZ0HABXCJvVFut7zTL0eGwYs7akkSQ7LEZA2hGtqKYag5vXMdBXJv6L - +DISCORD_URL=https://discord.com/api/webhooks/1235943329854783530/lgAd6On2gtLPCAZ0HABXCJvVFut7zTL0eGwYs7akkSQ7LEZA2hGtqKYag5vXMdBXJv6L +NTFY_URL=https://ntfy.sh/__KleinTest diff --git a/requirements.txt b/requirements.txt index 6a36804..5b5f95f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -requests~=2.25.1 +requests~=2.31.0 PyYAML~=5.4.1 python-dotenv~=1.0.1 \ No newline at end of file diff --git a/wazuh-notify-config.yaml b/wazuh-notify-config.yaml deleted file mode 100755 index 5f6a6c3..0000000 --- a/wazuh-notify-config.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -#start of yaml - -# This is the yaml config file for both the wazuh-ntfy-notifier.py and wazuh-discord-notifier.py. -# The yaml needs to be in the same folder as the wazuh-ntfy-notifier.py and wazuh-discord-notifier.py - -targets: "discord, ntfy" -full_message: "discord, ntfy" - -# Exclude rules that are listed in the ossec.conf active response definition. - -excluded_rules: "5401, 5403" -excluded_agents: "999" - -# Priority mapping from 0-15 (Wazuh events: threat levels) to 1-5 ( in notification) -# https://documentation.wazuh.com/current/user-manual/ruleset/rules-classification.html - -priority_5: [ 15,14,13,12 ] -priority_4: [ 11,10,9 ] -priority_3: [ 8,7,6 ] -priority_2: [ 5,4 ] -priority_1: [ 3,2,1,0 ] - -sender: "Wazuh (IDS)" -click: "https://google.com" - - -#end of yaml -... - - - diff --git a/wazuh-notify-test-event.json b/wazuh-notify-test-event.json new file mode 100644 index 0000000..b7105eb --- /dev/null +++ b/wazuh-notify-test-event.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "origin": { + "name": "worker01", + "module": "wazuh-execd" + }, + "command": "add", + "parameters": { + "extra_args": [], + "alert": { + "timestamp": "2021-02-01T20:58:44.830+0000", + "rule": { + "level": 15, + "description": "Shellshock attack detected", + "id": "31168", + "mitre": { + "id": [ + "T1068", + "T1190" + ], + "tactic": [ + "Privilege Escalation", + "Initial Access" + ], + "technique": [ + "Exploitation for Privilege Escalation", + "Exploit Public-Facing Application" + ] + }, + "info": "CVE-2014-6271https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-6271", + "firedtimes": 2, + "mail": true, + "groups": [ + "web", + "accesslog", + "attack" + ], + "pci_dss": [ + "11.4" + ], + "gdpr": [ + "IV_35.7.d" + ], + "nist_800_53": [ + "SI.4" + ], + "tsc": [ + "CC6.1", + "CC6.8", + "CC7.2", + "CC7.3" + ] + }, + "agent": { + "id": "000", + "name": "wazuh-server" + }, + "manager": { + "name": "wazuh-server" + }, + "id": "1612213124.6448363", + "full_log": "192.168.0.223 - - [01/Feb/2021:20:58:43 +0000] \"GET / HTTP/1.1\" 200 612 \"-\" \"() { :; }; /bin/cat /etc/passwd\"", + "decoder": { + "name": "web-accesslog" + }, + "data": { + "protocol": "GET", + "srcip": "192.168.0.223", + "id": "200", + "url": "/" + }, + "location": "/var/log/nginx/access.log" + }, + "program": "/var/ossec/active-response/bin/firewall-drop" + } +} \ No newline at end of file diff --git a/wazuh-notify.py b/wazuh-notify.py new file mode 100755 index 0000000..3e56d8b --- /dev/null +++ b/wazuh-notify.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 + +# This script is adapted version of the Python active response script sample, provided by Wazuh, in the documentation: +# https://documentation.wazuh.com/current/user-manual/capabilities/active-response/custom-active-response-scripts.html +# It is provided under the below copyright statement: +# +# Copyright (C) 2015-2022, Wazuh Inc. +# All rights reserved. +# +# This program is free software; you can redistribute it +# and/or modify it under the terms of the GNU General Public +# License (version 2) as published by the FSF - Free Software +# Foundation. +# +# This adapted version is free software. Rudi Klein, april 2024 + +import json +import sys + +import requests + +from wazuh_notify_module import build_notification +from wazuh_notify_module import construct_basic_message +from wazuh_notify_module import exclusions_check +from wazuh_notify_module import get_arguments +from wazuh_notify_module import get_config +from wazuh_notify_module import get_env +from wazuh_notify_module import load_message +from wazuh_notify_module import logger +from wazuh_notify_module import set_environment +from wazuh_notify_module import threat_mapping + + +def main(argv): + # Load the YAML config + config: dict = get_config() + + # Path variables assignments + wazuh_path, ar_path, config_path = set_environment() + + # Get the arguments used with running the script + arg_url, arg_sender, arg_destination, arg_message, arg_priority, arg_tags, arg_click = get_arguments() + + # Check if we are in test mode (test_mode setting in config yaml). If so, load test event instead of live event. + if config.get("test_mode"): + logger(config, "In test mode: using test message wazuh-notify-test-event.json") + + with (open('wazuh-notify-test-event.json') as event_file): + data: dict = json.loads(event_file.read()) + + else: + logger(config, "In live mode: using live message") + data = load_message() + + # Extract the 'alert' section of the (JSON) event + alert = data["parameters"]["alert"] + + # Check the config for any exclusion rules + fire_notification = exclusions_check(config, alert) + + if not fire_notification: + logger(config, "Event excluded, no notification sent. Exiting") + exit() + + # Include a specific control sequence for Discord bold text + if "discord" in config["targets"]: + accent: str = "**" + else: + accent: str = "" + + # Create the notification text to be sent. + notification: str = construct_basic_message(accent, alert) + logger(config, "Notification constructed") + + # todo Not used? + # Get the mapping from event threat level to priority (Discord/ntfy), color (Discord) and mention_flag (Discord) + priority, color, mention = threat_mapping(config, alert['rule']['level'], + alert['rule']['firedtimes']) + + result = "" + # Prepare the messaging platform specific request and execute + if "discord" in config["targets"]: + caller = "discord" + discord_url, _, _ = get_env() + payload = build_notification(caller, config, notification, alert, priority, color, mention) + result = requests.post(discord_url, json=payload) + exit(result) + + if "ntfy" in config["targets"]: + caller = "ntfy" + ntfy_url, _, _ = get_env() + payload = build_notification(caller, config, notification, alert, priority, color, mention) + result = requests.post(ntfy_url, json=payload) + exit(result) + + if "slack" in config["targets"]: + caller = "slack" + slack_url, _, _ = get_env() + payload = build_notification(caller, config, notification, alert, priority, color, mention) + result = requests.post(slack_url, json=payload) + exit(result) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/wazuh_notify_module.py b/wazuh_notify_module.py new file mode 100755 index 0000000..311cada --- /dev/null +++ b/wazuh_notify_module.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 + +import datetime +import getopt +import json +import os +import sys +import time +from os.path import join, dirname +from pathlib import PureWindowsPath, PurePosixPath + +import yaml +from dotenv import load_dotenv + + +# Define paths: wazuh_path = wazuh root directory +# ar_path = active-responses.log path, +# config_path = wazuh-notify-config.yaml +# +def set_environment() -> tuple: + # todo fix reference when running manually/in process + + set_wazuh_path = "/home/rudi/pycharm" + # set_wazuh_path = os.path.abspath(os.path.join(__file__, "../../..")) + set_ar_path = '{0}/logs/wazuh-notifier.log'.format(set_wazuh_path) + set_config_path = '{0}/etc/wazuh-notify-config.yaml'.format(set_wazuh_path) + + return set_wazuh_path, set_ar_path, set_config_path + + +# Set paths for use in this module +wazuh_path, ar_path, config_path = set_environment() + + +# Set structured timestamps for logging and notifications. + + +def set_time_format(): + now_message = time.strftime('%A, %d %b %Y %H:%M:%S') + now_logging = time.strftime('%Y/%m/%d %H:%M:%S') + now_time = time.strftime('%H:%M') + now_weekday = time.strftime('%A') + return now_message, now_logging, now_weekday, now_time + + +# Logger +def logger(config, message): + # todo fix logging + + _, log_path, _ = set_environment() + + if config.get('extended_print', True): + print(time.strftime('%Y/%m/%d %H:%M:%S'), "|", message) + + if config.get("extended_logging"): + with open(ar_path, mode="a") as log_file: + ar_name_posix = str(PurePosixPath(PureWindowsPath(log_path[log_path.find("active-response"):]))) + log_file.write( + str(datetime.datetime.now().strftime( + '%Y/%m/%d %H:%M:%S')) + " " + ar_name_posix + ": " + message + "\n") + else: + pass + + +# Get the content of the .env file + + +def get_env(): + config: dict = get_config() + + try: + dotenv_path = join(dirname(__file__), '.env') + load_dotenv(dotenv_path) + if not os.path.isfile(dotenv_path): + logger(config, ".env not found") + raise Exception(dotenv_path, "file not found") + + # Retrieve url from .env + discord_url = os.getenv("DISCORD_URL") + ntfy_url = os.getenv("NTFY_URL") + slack_url = os.getenv("SLACK_URL") + + except Exception as err: + # output error, and return with an error code + logger(config, str(Exception(err.args))) + exit(err) + + return discord_url, ntfy_url, slack_url + + +# Process configuration settings from wazuh-notify-config.yaml + + +def get_config(): + # DO NOT USE logger() IN THIS FUNCTION. IT WILL CREATE A RECURSION LOOP! + + this_config_path: str = "" + + try: + _, _, this_config_path = set_environment() + + with open(this_config_path, 'r') as ntfier_config: + config: dict = yaml.safe_load(ntfier_config) + except (FileNotFoundError, PermissionError, OSError): + print(time.strftime('%Y/%m/%d %H:%M:%S') + " | " + this_config_path + " missing") + + print(time.strftime('%Y/%m/%d %H:%M:%S') + " | " + "reading config: " + this_config_path) + config['targets'] = config.get('targets', 'ntfy, discord') + config['excluded_rules'] = config.get('excluded_rules', '') + config['excluded_agents'] = config.get('excluded_agents', '') + config['excluded_days'] = config.get('excluded_days', '') + config['excluded_hours'] = config.get('excluded_hours', '') + config['test_mode'] = config.get('test_mode', True) + config['extended_logging'] = config.get('extended_logging', True) + config['extended_print'] = config.get('extended_print', True) + + config['sender'] = 'Wazuh (IDS)' + config['click'] = 'https://wazuh.org' + + return config + + +# Show configuration settings from wazuh-notify-config.yaml + + +def view_config(): + _, _, this_config_path, _ = set_environment() + + try: + with open(this_config_path, 'r') as ntfier_config: + print(ntfier_config.read()) + except (FileNotFoundError, PermissionError, OSError): + print(this_config_path + " does not exist or is not accessible") + return + + # Get params during execution. Params found here override config settings. + + +def get_arguments(): + config: dict = get_config() + # Short options + options: str = "u:s:p:m:t:c:hv" + + # Long options + long_options: list = ["url=", "sender=", "destination=", "priority=", "message=", "tags=", "click=", "help", + "view"] + + help_text: str = """ + -u, --url is the url for the server, ending with a "/". + -s, --sender is the sender of the message, either an app name or a person. + -d, --destination is the NTFY subscription or Discord title, to send the message to. + -p, --priority is the priority of the message, ranging from 1 (lowest), to 5 (highest). + -m, --message is the text of the message to be sent. + -t, --tags is an arbitrary strings of tags (keywords), seperated by a "," (comma). + -c, --click is a link (URL) that can be followed by tapping/clicking inside the message. + -h, --help shows this help message. Must have no value argument. + -v, --view show config. + + """ + url: str + sender: str + destination: str + message: str + priority: int + tags: str + click: str + + url, sender, destination, message, priority, tags, click = "", "", "", "", 0, "", "" + + argument_list: list = sys.argv[1:] + + if not argument_list: + logger(config, 'No argument list found (no arguments provided with script execution') + return url, sender, destination, message, priority, tags, click + + else: + + try: + + logger(config, "Parsing arguments") + + # Parsing arguments + arguments, values = getopt.getopt(argument_list, options, long_options) + + # checking each argument + for current_argument, current_value in arguments: + + if current_argument in ("-h", "--help"): + print(help_text) + exit() + + elif current_argument in ("-v", "--view"): + view_config() + exit() + + elif current_argument in ("-u", "--url"): + url: str = current_value + + elif current_argument in ("-s", "--sender"): + sender: str = current_value + + elif current_argument in ("-d", "--destination"): + destination: str = current_value + + elif current_argument in ("-p", "--priority"): + priority: int = int(current_value) + + elif current_argument in ("-m", "--message"): + message: str = current_value + + elif current_argument in ("-t", "--tags"): + tags: str = current_value + + elif current_argument in ("-c", "--click"): + click: str = current_value + + except getopt.error as err: + # output error, and return with an error code + + logger(config, str(err)) + + return url, sender, destination, message, priority, tags, click + + +# Receive and load message from Wazuh + + +def load_message(): + config: dict = get_config() + + # get alert from stdin + + logger(config, "Loading event message from STDIN") + + input_str: str = "" + for line in sys.stdin: + input_str: str = line + break + + data: json = json.loads(input_str) + + if data.get("command") == "add": + logger(config, "Relevant event data found") + return data + else: + # todo fix error message + sys.exit(1) + + +# Check if there are reasons not to process this event (as per config yaml) + + +def exclusions_check(config, alert): + # Set some environment + now_message, now_logging, now_weekday, now_time = set_time_format() + + # Check the exclusion records from the configuration yaml + ex_hours: tuple = config.get('excluded_hours') + + # Start hour may not be later than end hours. End hour may not exceed 00:00 midnight to avoid day jump + ex_hours = [ex_hours[0], "23:59"] if (ex_hours[1] >= '23:59' or ex_hours[1] < ex_hours[0]) else ex_hours + + # Get some more exclusion records from the config + ex_days = config.get('excluded_days') + ex_agents = config.get("excluded_agents") + ex_rules = config.get("excluded_rules") + + # Check agent and rule from within the event + ev_agent = alert['agent']['id'] + ev_rule = alert['rule']['id'] + + # Let's assume all lights are green + ex_hours_eval, ex_weekday_eval, ev_rule_eval, ev_agent_eval = True, True, True, True + + # Evaluate the conditions for sending a notification. Any False will cause the notification to be discarded. + if (now_time > ex_hours[0]) and (now_time < ex_hours[1]): + logger(config, "excluded: event inside exclusion time frame") + ex_hours_eval = False + elif now_weekday in ex_days: + logger(config, "excluded: event inside excluded weekdays") + ex_weekday_eval = False + elif ev_rule in ex_rules: + logger(config, "excluded: event id inside exclusion list") + ev_rule_eval = False + elif ev_agent in ex_agents: + logger(config, "excluded: event agent inside exclusion list") + ev_rule_eval = False + + notification_eval = ex_hours_eval and ex_weekday_eval and ev_rule_eval and ev_agent + + return notification_eval + + +# Map the event threat level to the appropriate 5-level priority scale and color for use in the notification platforms. + + +def threat_mapping(config, threat_level, fired_times): + # Map threat level v/s priority + + p_map = config.get('priority_map') + + for i in range(len(p_map)): + + if threat_level in p_map[i]["threat_map"]: + color_mapping = p_map[i]["color"] + priority_mapping = 5 - i + if fired_times >= p_map[i]["mention_threshold"]: + mention_flag = "@here" + else: + mention_flag = "" + return priority_mapping, color_mapping, mention_flag + else: + return 0, 0, 0 + + +# Construct the message that will be sent to the notifier platforms + + +def construct_basic_message(accent: str, data: dict) -> str: + # Adding the BOLD text string for Discord use + + basic_msg: str = (accent + + "Agent:" + " " + accent + data["agent"]["name"] + " (" + data["agent"][ + "id"] + ")" + "\n" + accent + + "Rule id: " + accent + data["rule"]["id"] + "\n" + accent + + "Rule: " + accent + data["rule"]["description"] + "\n" + accent + + "Description: " + accent + data["full_log"] + "\n" + accent + + "Threat level: " + accent + str(data["rule"]["level"]) + "\n" + accent + + "Times fired: " + accent + str(data["rule"]["firedtimes"]) + "\n") + + return basic_msg + + +def build_notification(caller, config, notification, alert, priority, color, mention): + click: str = config.get('click') + sender: str = config.get('sender') + priority: str = str(priority) + tags = (str(alert['rule']['groups']).replace("[", "") + .replace("]", "") + .replace("'", "") + .replace(",", ", ") + ) + full_event: str = str(json.dumps(alert, indent=4) + .replace('"', '') + .replace('{', '') + .replace('}', '') + .replace('[', '') + .replace(']', '') + .replace(',', ' ') + ) + # Add the full alert data to the notification + if caller in config["full_message"]: + notification: str = ("\n\n" + notification + "\n" + + "**" + "__Full event__" + "**" + "\n" + "```\n" + full_event + "```") + + # Add Priority & tags to the notification + notification = (notification + "\n\n" + "Priority: " + priority + "\n" + + "Tags: " + tags + "\n\n" + click) + + # Prepare the messaging platform specific notification and execute + if "discord" in config["targets"]: + url, _, _ = get_env() + + payload = {"username": "sender", + "content": mention, + "embeds": [{"description": notification, + "color": color, + "title": sender}]} + return payload + + if "ntfy" in config["targets"]: + caller = "ntfy" + ntfy_url, _, _ = get_env() + + payload = {"username": "sender", + "content": mention, + "embeds": [{"description": notification, + "color": color, + "title": sender}]} + return payload + + if "slack" in config["targets"]: + caller = "slack" + slack_url, _, _ = get_env() + + payload = {"username": "sender", + "content": mention, + "embeds": [{"description": notification, + "color": color, + "title": sender}]} + return payload