diff --git a/Linux/AuditLogCollector b/Linux/AuditLogCollector new file mode 100644 index 0000000..a6b819f Binary files /dev/null and b/Linux/AuditLogCollector differ diff --git a/Linux/AuditLogSubscriber b/Linux/AuditLogSubscriber new file mode 100644 index 0000000..2371bee Binary files /dev/null and b/Linux/AuditLogSubscriber differ diff --git a/README.md b/README.md index ddc2858..69463d6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,25 @@ # Office365 API audit log collector Collect Office365 and Azure audit logs through their respective APIs. No prior knowledge of APIs is required, -onboarding and script usage is described below. Currently supports the following outputs: +onboarding and script usage is described below. There is a GUI for Windows. Currently supports the following outputs: - Azure Analytics Workspace (OMS) - Graylog (or any other source that accepts a simple socket connection) - File +Simply download the executable(s) you need from the Windows or Linux folder.: +- Windows: + - GUI - Office Audit Log Collector.exe + - GUI for collecting audit logs AND subscribing to audit log feeds (see onboarding instructions below) + - Office Audit Log Collector.exe + - Command line tool for collecting audit logs (see syntax below) + - Office Audit Log Subscriber.exe + - Command line tool for subscribing to audit logs feeds (see onboarding instructions below) +- Linux: + - OfficeAuditLogCollector + - Command line tool for collecting audit logs (see syntax below) + - OfficeAuditLogSubscriber + - Command line tool for subscribing to audit logs (see onboarding instructions below) + For a full audit trail schedule to run the script on a regular basis (preferably at least once every day). The last run time is recorded automatically, so that when the script runs again it starts to retrieve audit logs from when it last ran. Feel free to contribute other outputs if you happen to build any. @@ -13,12 +27,13 @@ See the following link for more info on the management APIs: https://msdn.micros ## Roadmap: -- Add an optional GUI - Automate onboarding as much as possible to make it easier to use - Make a container that runs this script - Create a tutorial for automatic onboarding + docker container for the easiest way to run this ## Latest changes: +- Added a GUI for Windows +- Added executables for Windows and Linux - Added Azure Log Analytics Workspace OMS output - Added parameter to resume from last run time (use to not miss any logs when script hasn't run for a while) - Added parameter for amount of hours or days to go back and look for content @@ -51,9 +66,9 @@ See the following link for more info on the management APIs: https://msdn.micros ## Instructions: -### Creating an application in Azure: -- Create the app registration: - - Create app registration itself under Azure AD (own tenant only works fine for single tenant) +### Onboarding: +- Create an app registration: + - Create the app registration itself under Azure AD (own tenant only works fine for single tenant) - Create app secret (only shown once upon creation, store it somewhere safe) - Grant your new app permissions to read the Office API's: - Graph: AuditLog.Read.All diff --git a/ApiConnection.py b/Source/ApiConnection.py similarity index 100% rename from ApiConnection.py rename to Source/ApiConnection.py diff --git a/AuditLogCollector.py b/Source/AuditLogCollector.py similarity index 92% rename from AuditLogCollector.py rename to Source/AuditLogCollector.py index be3710b..7611cd3 100644 --- a/AuditLogCollector.py +++ b/Source/AuditLogCollector.py @@ -1,6 +1,6 @@ # Standard libs -import collections import os +import sys import json import logging import datetime @@ -16,7 +16,7 @@ class AuditLogCollector(ApiConnection.ApiConnection): - def __init__(self, content_types, *args, resume=True, fallback_time=None, + def __init__(self, *args, content_types=None, resume=True, fallback_time=None, file_output=False, output_path=None, graylog_output=False, graylog_address=None, graylog_port=None, azure_oms_output=False, azure_oms_workspace_id=None, azure_oms_shared_key=None, @@ -41,38 +41,46 @@ def __init__(self, content_types, *args, resume=True, fallback_time=None, self.graylog_output = graylog_output self.azure_oms_output = azure_oms_output self.output_path = output_path - self.content_types = content_types + self.content_types = content_types or collections.deque() self._last_run_times = {} + self.resume = resume if resume: self.get_last_run_times() self._fallback_time = fallback_time or datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1) self._known_content = {} - if self.azure_oms_output: - self._azure_oms_interface = AzureOMSInterface.AzureOMSInterface(workspace_id=azure_oms_workspace_id, - shared_key=azure_oms_shared_key) - if self.graylog_output: - self._graylog_interface = GraylogInterface.GraylogInterface(graylog_address=graylog_address, - graylog_port=graylog_port) + self._azure_oms_interface = AzureOMSInterface.AzureOMSInterface(workspace_id=azure_oms_workspace_id, + shared_key=azure_oms_shared_key) + self._graylog_interface = GraylogInterface.GraylogInterface(graylog_address=graylog_address, + graylog_port=graylog_port) self.blobs_to_collect = collections.defaultdict(collections.deque) self.monitor_thread = threading.Thread() self.retrieve_available_content_threads = collections.deque() self.retrieve_content_threads = collections.deque() + self.run_started = None self.logs_retrieved = 0 def run_once(self, start_time=None): """ Check available content and retrieve it, then exit. """ - run_started = datetime.datetime.now() + self._known_content.clear() + self.logs_retrieved = 0 + self._graylog_interface.successfully_sent = 0 + self._graylog_interface.unsuccessfully_sent = 0 + self._azure_oms_interface.successfully_sent = 0 + self._azure_oms_interface.unsuccessfully_sent = 0 + self.run_started = datetime.datetime.now() self._clean_known_content() + if self.resume: + self.get_last_run_times() self.start_monitoring() self.get_all_available_content(start_time=start_time) self.monitor_thread.join() - if self._last_run_times: + if self.resume and self._last_run_times: with open('last_run_times', 'w') as ofile: json.dump(fp=ofile, obj=self._last_run_times) logging.info("Finished. Total logs retrieved: {}. Run time: {}.".format( - self.logs_retrieved, datetime.datetime.now() - run_started)) + self.logs_retrieved, datetime.datetime.now() - self.run_started)) if self.azure_oms_output: logging.info("Azure OMS output report: {} successfully sent, {} errors".format( self._azure_oms_interface.successfully_sent, self._azure_oms_interface.unsuccessfully_sent)) @@ -127,7 +135,7 @@ def get_all_available_content(self, start_time=None): """ for content_type in self.content_types.copy(): if not start_time: - if content_type in self._last_run_times.keys(): + if self.resume and content_type in self._last_run_times.keys(): start_time = self._last_run_times[content_type] else: start_time = self._fallback_time @@ -336,8 +344,12 @@ def known_content(self): elif argsdict['time_hours']: fallback_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=argsdict['time_hours']) - logging.basicConfig(filemode='w', filename=argsdict['log_path'], - level=logging.INFO if not argsdict['debug_logging'] else logging.DEBUG) + logger = logging.getLogger() + fileHandler = logging.FileHandler(argsdict['log_path'], mode='w') + streamHandler = logging.StreamHandler(sys.stdout) + logger.addHandler(streamHandler) + logger.addHandler(fileHandler) + logger.setLevel(logging.INFO if not argsdict['debug_logging'] else logging.DEBUG) logging.log(level=logging.INFO, msg='Starting run @ {0}'.format(datetime.datetime.now())) collector = AuditLogCollector( diff --git a/AuditLogSubscriber.py b/Source/AuditLogSubscriber.py similarity index 80% rename from AuditLogSubscriber.py rename to Source/AuditLogSubscriber.py index faa1d7f..69b309c 100644 --- a/AuditLogSubscriber.py +++ b/Source/AuditLogSubscriber.py @@ -31,23 +31,33 @@ def get_sub_status(self): status = self.make_api_request(url='subscriptions/list', append_url=True) return status.json() - def set_sub_status(self, ctype_stat): + def set_sub_status(self, ctype_stat=None, content_type=None, action=None): """ Args: ctype_stat (tuple): content type, status (enabled | disabled) - Returns: dict """ - if ctype_stat[1] == 'enabled': - action = 'stop' - elif ctype_stat[1] == 'disabled': - action = 'start' - else: - return - status = self.make_api_request(url='subscriptions/{0}?contentType={1}'.format(action, ctype_stat[0]), + content_type = content_type or ctype_stat[0] + if not action: + if ctype_stat[1] == 'enabled': + action = 'stop' + elif ctype_stat[1] == 'disabled': + action = 'start' + else: + return + status = self.make_api_request(url='subscriptions/{0}?contentType={1}'.format(action, content_type), append_url=True, get=False) - logging.info("Set sub status response: {}".format(status)) + logging.debug("Set sub status response: {}".format(status)) + try: + logging.debug("Set sub status json: {}".format(status.json())) + except Exception as e: + pass + if 200 <= status.status_code <= 299: + logging.info('Successfully set sub status: {} > {}'.format(content_type, action)) + else: + raise RuntimeError("Unable to set sub status: {} > {}".format(content_type, action)) + status.close() def interactive(self): diff --git a/AzureOMSInterface.py b/Source/AzureOMSInterface.py similarity index 97% rename from AzureOMSInterface.py rename to Source/AzureOMSInterface.py index 0b0ac2a..1cc0689 100644 --- a/AzureOMSInterface.py +++ b/Source/AzureOMSInterface.py @@ -129,7 +129,12 @@ def post_data(self, body, log_type, time_generated): 'time-generated-field': time_generated } response = self.session.post(uri, data=body, headers=headers) - status_code, json_output = response.status_code, response.json + status_code = response.status_code + try: + json_output = response.json() + except: + json_output = '' + response.close() if 200 <= status_code <= 299: logging.info('Accepted payload:' + body) diff --git a/Source/GUI.py b/Source/GUI.py new file mode 100644 index 0000000..a99fb2a --- /dev/null +++ b/Source/GUI.py @@ -0,0 +1,278 @@ +import AuditLogCollector, AuditLogSubscriber +from kivymd.app import MDApp +from kivy.lang.builder import Builder +from kivy.config import Config +from kivy.clock import Clock +from kivy.properties import StringProperty +import os +import json +import time +import logging +import threading +import datetime +Config.set('graphics', 'width', '450') +Config.set('graphics', 'height', '600') +Config.write() + + +class GUI(MDApp): + + tenant_id = StringProperty() + client_key = StringProperty() + secret_key = StringProperty() + publisher_id = StringProperty() + + def __init__(self, tenant_id="", client_key="", secret_key="", publisher_id="", **kwargs): + + self.title = "Audit log collector" + super().__init__(**kwargs) + self.root_widget = None + self.publisher_id = publisher_id + self.tenant_id = tenant_id + self.client_key = client_key + self.secret_key = secret_key + self.subscriber = AuditLogSubscriber.AuditLogSubscriber() + self.collector = AuditLogCollector.AuditLogCollector() + self.successfully_finished = None + self.running_continuously = False + self.last_run_start = None + self.run_thread = None + + def track_continuous(self, *args): + + if not self.running_continuously: + return + elif self.run_thread and self.run_thread.is_alive(): + time.sleep(1) + return Clock.schedule_once(self.track_continuous) + + target_time = self.last_run_start + datetime.timedelta( + hours=self.root_widget.ids.tab_widget.ids.config_widget.ids.run_time_slider.value) + if datetime.datetime.now() >= target_time: + self.run_collector() + Clock.schedule_once(self.track_run) + else: + time_until_next = str(target_time - datetime.datetime.now()).split('.')[0] + self.root_widget.ids.tab_widget.ids.collector_widget.ids.next_run_label.text = time_until_next + time.sleep(1) + return Clock.schedule_once(self.track_continuous) + + def track_run(self, *args): + + prefix = self.root_widget.ids.tab_widget + if self.run_thread.is_alive(): + prefix.ids.collector_widget.ids.status_label.text = "Status: Running" + self._update_run_statistics() + time.sleep(0.5) + Clock.schedule_once(self.track_run) + else: + if self.successfully_finished is True: + prefix.ids.collector_widget.ids.status_label.text = "Status: Finished" + else: + prefix.ids.collector_widget.ids.status_label.text = "Error: {}".format(self.successfully_finished) + self._update_run_statistics() + prefix.ids.collector_widget.ids.run_time.text = \ + str(datetime.datetime.now() - self.collector.run_started).split(".")[0] + if not self.running_continuously: + prefix.ids.collector_widget.ids.run_once_button.disabled = False + prefix.ids.collector_widget.ids.run_continuous_button.disabled = False + prefix.ids.collector_widget.ids.run_continuous_button.text = 'Run continuously' + + def _update_run_statistics(self): + + prefix = self.root_widget.ids.tab_widget + prefix.ids.collector_widget.ids.run_time.text = str(datetime.datetime.now() - self.collector.run_started) + prefix.ids.collector_widget.ids.retrieved_label.text = str(self.collector.logs_retrieved) + prefix.ids.collector_widget.ids.azure_sent_label.text = str( + self.collector._azure_oms_interface.successfully_sent) + prefix.ids.collector_widget.ids.azure_error_label.text = str( + self.collector._azure_oms_interface.unsuccessfully_sent) + prefix.ids.collector_widget.ids.graylog_sent_label.text = str( + self.collector._graylog_interface.successfully_sent) + prefix.ids.collector_widget.ids.graylog_error_label.text = str( + self.collector._graylog_interface.unsuccessfully_sent) + + def run_once(self): + + self.run_thread = threading.Thread(target=self.run_collector, daemon=True) + self.run_thread.start() + Clock.schedule_once(self.track_run) + + def run_continuous(self): + + if not self.running_continuously: + self.running_continuously = True + self.run_thread = threading.Thread(target=self.run_collector, daemon=True) + self.run_thread.start() + Clock.schedule_once(self.track_continuous) + Clock.schedule_once(self.track_run) + else: + self.running_continuously = False + self.root_widget.ids.tab_widget.ids.collector_widget.ids.next_run_label.text = "-" + self.root_widget.ids.tab_widget.ids.collector_widget.ids.run_continuous_button.text = \ + 'Run continuously' + + def run_collector(self): + + self._prepare_to_run() + self.last_run_start = datetime.datetime.now() + self.root_widget.ids.tab_widget.ids.collector_widget.ids.next_run_label.text = "-" + try: + self.collector.run_once() + self.successfully_finished = True + except Exception as e: + self.successfully_finished = e + + def _prepare_to_run(self): + + prefix = self.root_widget.ids.tab_widget + prefix.ids.collector_widget.ids.run_once_button.disabled = True + prefix.ids.collector_widget.ids.run_continuous_button.disabled = True + if self.running_continuously: + prefix.ids.collector_widget.ids.run_continuous_button.text = 'Stop running continuously' + self.collector.content_types = prefix.ids.subscriber_widget.enabled_content_types + self.collector.resume = prefix.ids.config_widget.ids.resume_switch.active + fallback_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + hours=prefix.ids.config_widget.ids.collect_time_slider.value) + self.collector._fallback_time = fallback_time + self.collector.file_output = prefix.ids.config_widget.ids.file_output_switch.active + self.collector.output_path = prefix.ids.config_widget.ids.file_output_path.text + self.collector.azure_oms_output = prefix.ids.config_widget.ids.oms_output_switch.active + self.collector.graylog_output = prefix.ids.config_widget.ids.graylog_output_switch.active + self.collector._azure_oms_interface.workspace_id = prefix.ids.config_widget.ids.oms_id.text + self.collector._azure_oms_interface.shared_key = prefix.ids.config_widget.ids.oms_key.text + self.collector._graylog_interface.gl_address = prefix.ids.config_widget.ids.graylog_ip.text + self.collector._graylog_interface.gl_port = prefix.ids.config_widget.ids.graylog_port.text + if prefix.ids.config_widget.ids.log_switch.active: + logging.basicConfig(filemode='w', filename='logs.txt', level=logging.DEBUG) + + @property + def guid_example(self): + + return "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + + def build(self): + + self.theme_cls.theme_style = "Dark" + from UX import MainWidget + Builder.load_file(os.path.join(os.path.split(__file__)[0], 'UX/MainWidget.kv')) + self.root_widget = MainWidget.MainWidget() + prefix = self.root_widget.ids.tab_widget + prefix.ids.config_widget.ids.clear_known_content.disabled = not os.path.exists('known_content') + prefix.ids.config_widget.ids.clear_last_run_times.disabled = not os.path.exists('last_run_times') + self.load_settings() + return self.root_widget + + def login(self, tenant_id, client_key, secret_key, publisher_id): + + if self.collector._headers: + return self.logout() + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.disabled = True + self.tenant_id, self.client_key, self.secret_key, self.publisher_id = \ + tenant_id, client_key, secret_key, publisher_id + self.subscriber.tenant_id = tenant_id + self.subscriber.client_key = client_key + self.subscriber.secret_key = secret_key + self.subscriber.publisher_id = publisher_id + if not tenant_id or not client_key or not secret_key: + self.root_widget.connection_widget.ids.status_label.text = \ + "[color=#ff0000]Error logging in: provide tenant ID, client key and secret key. Find them in your " \ + "Azure AD app registration.[/color]" + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.disabled = False + return + try: + self.subscriber.login() + self.root_widget.ids.status_label.text = "[color=#00ff00]Connected![/color]" + except Exception as e: + self.root_widget.ids.status_label.text = "[color=#ff0000]Error logging in: {}[/color]".format(e) + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.disabled = False + return + login_headers = self.subscriber.headers + self.collector._headers = login_headers + + self.root_widget.ids.tab_widget.ids.subscriber_widget.activate_switches() + self.root_widget.ids.tab_widget.ids.subscriber_widget.set_switches() + self.root_widget.ids.tab_widget.ids.collector_widget.ids.run_once_button.disabled = False + self.root_widget.ids.tab_widget.ids.collector_widget.ids.run_continuous_button.disabled = False + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.text = 'Disconnect' + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.disabled = False + + def logout(self): + + self.collector._headers = None + self.subscriber._headers = None + self.root_widget.ids.status_label.text = "[color=#ffff00]Not logged in.[/color]" + self.root_widget.ids.tab_widget.ids.subscriber_widget.deactivate_switches(reset_value=True) + self.root_widget.ids.tab_widget.ids.collector_widget.ids.run_once_button.disabled = True + self.root_widget.ids.tab_widget.ids.collector_widget.ids.run_continuous_button.disabled = True + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.text = 'Connect' + self.root_widget.ids.tab_widget.ids.connection_widget.ids.login_button.disabled = False + + def save_settings(self): + + prefix = self.root_widget.ids.tab_widget + settings = dict() + settings['tenant_id'] = self.tenant_id + settings['client_key'] = self.client_key + settings['include_secret_key'] = prefix.ids.config_widget.ids.include_secret_key_switch.active + if prefix.ids.config_widget.ids.include_secret_key_switch.active: + settings['secret_key'] = self.secret_key + settings['publisher_id'] = self.publisher_id + settings['resume'] = prefix.ids.config_widget.ids.resume_switch.active + settings['run_time'] = prefix.ids.config_widget.ids.run_time_slider.value + settings['fallback_time'] = prefix.ids.config_widget.ids.collect_time_slider.value + settings['file_output'] = prefix.ids.config_widget.ids.file_output_switch.active + settings['output_path'] = prefix.ids.config_widget.ids.file_output_path.text + settings['azure_oms_output'] = prefix.ids.config_widget.ids.oms_output_switch.active + settings['graylog_output'] = prefix.ids.config_widget.ids.graylog_output_switch.active + settings['oms_workspace_id'] = prefix.ids.config_widget.ids.oms_id.text + settings['oms_shared_key'] = prefix.ids.config_widget.ids.oms_key.text + settings['gl_address'] = prefix.ids.config_widget.ids.graylog_ip.text + settings['gl_port'] = prefix.ids.config_widget.ids.graylog_port.text + settings['debug_logging'] = prefix.ids.config_widget.ids.log_switch.active + with open('gui_settings.json', 'w') as ofile: + json.dump(settings, ofile) + + def load_settings(self): + + if not os.path.exists('gui_settings.json'): + return + with open('gui_settings.json', 'r') as ofile: + settings = json.load(ofile) + prefix = self.root_widget.ids.tab_widget + self.tenant_id = settings['tenant_id'] + self.client_key = settings['client_key'] + prefix.ids.config_widget.ids.include_secret_key_switch.active = settings['include_secret_key'] + if prefix.ids.config_widget.ids.include_secret_key_switch.active: + self.secret_key = settings['secret_key'] + self.publisher_id = settings['publisher_id'] + prefix.ids.config_widget.ids.resume_switch.active = settings['resume'] + prefix.ids.config_widget.ids.run_time_slider.value = settings['run_time'] + prefix.ids.config_widget.ids.collect_time_slider.value = settings['fallback_time'] + prefix.ids.config_widget.ids.file_output_switch.active = settings['file_output'] + prefix.ids.config_widget.ids.file_output_path.text = settings['output_path'] + prefix.ids.config_widget.ids.oms_output_switch.active = settings['azure_oms_output'] + prefix.ids.config_widget.ids.graylog_output_switch.active = settings['graylog_output'] + prefix.ids.config_widget.ids.oms_id.text = settings['oms_workspace_id'] + prefix.ids.config_widget.ids.oms_key.text = settings['oms_shared_key'] + prefix.ids.config_widget.ids.graylog_ip.text = settings['gl_address'] + prefix.ids.config_widget.ids.graylog_port.text = settings['gl_port'] + prefix.ids.config_widget.ids.log_switch.active = settings['debug_logging'] + + def clear_known_content(self): + + self.root_widget.ids.tab_widget.ids.config_widget.ids.clear_known_content.disabled = True + if os.path.exists('known_content'): + os.remove('known_content') + + def clear_last_run_times(self): + + self.root_widget.ids.tab_widget.ids.config_widget.ids.clear_last_run_times.disabled = True + if os.path.exists('last_run_times'): + os.remove('last_run_times') + + +if __name__ == '__main__': + + gui = GUI() + gui.run() diff --git a/GraylogInterface.py b/Source/GraylogInterface.py similarity index 100% rename from GraylogInterface.py rename to Source/GraylogInterface.py diff --git a/Source/UX/CollectorWidget.kv b/Source/UX/CollectorWidget.kv new file mode 100644 index 0000000..f677e76 --- /dev/null +++ b/Source/UX/CollectorWidget.kv @@ -0,0 +1,89 @@ +: + orientation: 'tb-lr' + BoxLayout: + orientation: "vertical" + size_hint_y: None + height: '70dp' + ScalingLabel: + text: "Collect logs once on demand." + MDRaisedButton: + id: run_once_button + disabled: True + padding: 10, 0 + text: "Run once" + on_press: app.run_once() + MDSeparator: + BoxLayout: + orientation: "vertical" + size_hint_y: None + height: '70dp' + ScalingLabel: + text: "Collect logs periodically using configured time settings." + BoxLayout: + MDRaisedButton: + id: run_continuous_button + disabled: True + padding: 10, 0 + text: "Run continuously" + on_press: app.run_continuous() + ScalingLabel: + padding: 20, 0 + text: "Next run in:" + ScalingLabel: + id: next_run_label + text: "-" + MDSeparator: + BoxLayout: + size_hint_y: None + height: '30dp' + ScalingLabel: + id: status_label + text: "Status: idle" + BoxLayout + size_hint_y: None + height: '200dp' + orientation: "vertical" + BoxLayout: + ScalingLabel: + id: run_time + text: "-" + BoxLayout: + ScalingLabel: + id: retrieved_label + size_hint_x: 0.2 + text: "0" + ScalingLabel: + text: "logs retrieved" + BoxLayout: + ScalingLabel: + id: azure_sent_label + size_hint_x: 0.2 + text: "0" + ScalingLabel: + text: "logs successfully sent to Azure Log Analytics" + BoxLayout: + ScalingLabel: + id: azure_error_label + size_hint_x: 0.2 + text: "0" + ScalingLabel: + text: "logs unsuccessfully sent to Azure Log Analytics" + BoxLayout: + ScalingLabel: + id: graylog_sent_label + size_hint_x: 0.2 + text: "0" + ScalingLabel: + text: "logs successfully sent to Graylog" + BoxLayout: + ScalingLabel: + id: graylog_error_label + size_hint_x: 0.2 + text: "0" + ScalingLabel: + text: "logs unsuccessfully sent to Graylog" + +: + text_size: root.width, None + size: self.texture_size + markup: True diff --git a/Source/UX/CollectorWidget.py b/Source/UX/CollectorWidget.py new file mode 100644 index 0000000..a1c28f8 --- /dev/null +++ b/Source/UX/CollectorWidget.py @@ -0,0 +1,9 @@ +from kivy.uix import stacklayout +from kivymd.uix import tab + + +class CollectorWidget(tab.MDTabsBase, stacklayout.StackLayout): + + def __init__(self, **kwargs): + + super().__init__(**kwargs) \ No newline at end of file diff --git a/Source/UX/ConfigWidget.kv b/Source/UX/ConfigWidget.kv new file mode 100644 index 0000000..1e1985b --- /dev/null +++ b/Source/UX/ConfigWidget.kv @@ -0,0 +1,209 @@ +: + do_scroll_x: False + do_scroll_y: True + always_overscroll: False + effect_cls: 'ScrollEffect' + StackLayout: + size_hint_y: None + height: self.minimum_height + padding: 10, 10 + MDLabel: + text: "Save settings:" + halign: "center" + size_hint_y: None + height: '10dp' + BoxLayout: + id: save_settings + padding: 5, 10 + size_hint_y: None + height: '100dp' + orientation: "vertical" + BoxLayout: + orientation: "horizontal" + MDSwitch: + id: include_secret_key_switch + active: False + size_hint_y: 1 + ScalingLabel: + size_hint_y: 1 + padding: 25, 0 + text: "Include secret key" + MDRaisedButton: + text: "Save settings" + on_press: app.save_settings() + MDSeparator: + MDLabel: + text: "Cache settings:" + halign: "center" + size_hint_y: None + height: '10dp' + BoxLayout: + id: cache_settings + size_hint_y: None + height: '280dp' + orientation: "vertical" + BoxLayout: + orientation: "vertical" + ScalingLabel: + size_hint_y: 1 + text: "The known content cache contains the IDs of all previously retrieved content. Known content is skipped when encountered." + MDRaisedButton: + id: clear_known_content + text: "Clear known content" + on_press: app.clear_known_content() + BoxLayout: + orientation: "vertical" + ScalingLabel: + size_hint_y: 1 + text: 'The last run times cache contains the last run time of each content type. On the next run content is retrieved started from the last run time. If there is no last run time, the "Period to retrieve" setting is used' + MDRaisedButton: + id: clear_last_run_times + text: "Clear last run times" + on_press: app.clear_last_run_times() + MDSeparator: + MDLabel: + text: "Time settings:" + halign: "center" + size_hint_y: None + height: '10dp' + BoxLayout: + id: time_settings + padding: 5, 10 + size_hint_y: None + height: '150dp' + orientation: "vertical" + BoxLayout: + orientation: "horizontal" + MDSwitch: + id: resume_switch + active: True + size_hint_y: 1 + ScalingLabel: + size_hint_y: 1 + padding: 25, 0 + text: "Resume from last run time" + BoxLayout: + ScalingLabel: + id: run_time_description + text: "Time between runs:" + size_hint_x: 0.4 + MDLabel: + id: run_time_label + text: str(run_time_slider.value) + " hours" + size_hint_x: 0.2 + padding: 1, 10 + MDSlider: + id: run_time_slider + hint: False + size_hint_x: 0.4 + step: 1 + min: 1 + max: 48 + value: 1 + BoxLayout: + ScalingLabel: + text: "Period to retrieve:" + size_hint_x: None + width: run_time_description.width + MDLabel: + id: collect_time_label + size_hint_x: None + width: run_time_label.width + text: str(collect_time_slider.value) + " hours" + padding: 1, 10 + MDSlider: + id: collect_time_slider + size_hint_x: None + width: run_time_slider.width + hint: False + step: 1 + min: 1 + max: 48 + value: 1 + MDSeparator: + MDLabel: + padding: 5, 5 + text: "File output:" + halign: "center" + size_hint_y: None + height: '20dp' + BoxLayout: + id: file_output + orientation: "vertical" + size_hint_y: None + height: "120dp" + MDSwitch: + id: file_output_switch + active: False + size_hint_y: 1 + MDTextField: + id: file_output_path + padding: 5, 25 + hint_text: "Output file path" + text: 'Output.txt' + MDLabel: + text: "Azure Log Analytics output:" + halign: "center" + size_hint_y: None + height: '10dp' + BoxLayout: + id: oms_output + size_hint_y: None + height: "140dp" + orientation: "vertical" + MDSwitch: + id: oms_output_switch + active: False + size_hint_y: 1 + MDTextField: + id: oms_id + padding: 5, 20 + hint_text: "Workspace ID" + text: app.guid_example + MDTextField: + id: oms_key + padding: 5, 25 + hint_text: "Workspace shared key" + text: app.guid_example + MDLabel: + text: "Graylog output:" + halign: "center" + size_hint_y: None + height: '10dp' + BoxLayout: + id: graylog_output + size_hint_y: None + height: "120dp" + orientation: "vertical" + MDSwitch: + id: graylog_output_switch + active: False + size_hint_y: 1 + StackLayout: + orientation: 'lr-tb' + MDTextField: + id: graylog_ip + size_hint_x: 0.5 + padding: 5, 1 + hint_text: "Graylog address" + text: '0.0.0.0' + MDTextField: + id: graylog_port + size_hint_x: 0.5 + padding: 5, 1 + hint_text: "Graylog port" + text: '5000' + MDLabel: + text: "Logging settings:" + halign: "center" + size_hint_y: None + height: '10dp' + BoxLayout: + id: logging + orientation: "vertical" + size_hint_y: None + height: "50dp" + MDSwitch: + id: log_switch + active: False + size_hint_y: 1 \ No newline at end of file diff --git a/Source/UX/ConfigWidget.py b/Source/UX/ConfigWidget.py new file mode 100644 index 0000000..6b6ce9a --- /dev/null +++ b/Source/UX/ConfigWidget.py @@ -0,0 +1,10 @@ +from kivymd.uix import stacklayout, tab +from kivy.uix import scrollview +import os + + +class ConfigWidget(scrollview.ScrollView, tab.MDTabsBase): + + def __init__(self, **kwargs): + + super().__init__(**kwargs) diff --git a/Source/UX/ConnectionWidget.kv b/Source/UX/ConnectionWidget.kv new file mode 100644 index 0000000..ad29efc --- /dev/null +++ b/Source/UX/ConnectionWidget.kv @@ -0,0 +1,36 @@ +: + StackLayout: + padding: 5, 20 + orientation: "tb-lr" + StackLayout: + size_hint_y: 0.2 + orientation: 'tb-lr' + ScalingLabel: + text: "Log in to an Azure AD App Registration. The registered app must have the proper permissions, and your tenant must have audit logs enabled. Refer to the docs for more info." + MDTextField: + id: tenant_id + hint_text: "Tenant ID" + text: app.tenant_id + MDTextField: + id: client_key + hint_text: "Client key" + text: app.client_key + MDTextField: + id: secret_key + hint_text: "Secret key" + password_mask: "*" + password: True + text: app.secret_key + MDTextField: + id: publisher_id + hint_text: "Publisher ID" + text: app.publisher_id + MDRaisedButton: + id: login_button + text: "Login" + on_press: app.login(tenant_id.text, client_key.text, secret_key.text, publisher_id.text) + +: + text_size: root.width, None + size: self.texture_size + markup: True diff --git a/Source/UX/ConnectionWidget.py b/Source/UX/ConnectionWidget.py new file mode 100644 index 0000000..fd0d77d --- /dev/null +++ b/Source/UX/ConnectionWidget.py @@ -0,0 +1,13 @@ +from kivy.uix import boxlayout +from kivymd.uix import tab +from kivy.properties import ListProperty + + +class ConnectionWidget(tab.MDTabsBase, boxlayout.BoxLayout): + + status_color = ListProperty() + + def __init__(self, **kwargs): + + self.status_color = [1, 1, 0] + super().__init__(**kwargs) \ No newline at end of file diff --git a/Source/UX/MainWidget.kv b/Source/UX/MainWidget.kv new file mode 100644 index 0000000..73509f4 --- /dev/null +++ b/Source/UX/MainWidget.kv @@ -0,0 +1,34 @@ +: + orientation: "vertical" + TabWidget: + id: tab_widget + size_hint: 1, 0.9 + BoxLayout: + orientation: "vertical" + size_hint_y: 0.1 + MDSeparator: + size_hint: 1, 0.05 + ScalingLabel: + id: status_label + valign: "middle" + halign: "center" + text: "[color=#ffff00]Not logged in.[/color]" + +: + lock_swiping: True + ConnectionWidget: + id: connection_widget + title: "1. Connect" + SubscriberWidget: + id: subscriber_widget + title: "2. Subscribe" + ConfigWidget: + id: config_widget + title: "3. Configure" + CollectorWidget: + id: collector_widget + title: "4. Collect" + +: + text_size: root.width, None + size: self.texture_size diff --git a/Source/UX/MainWidget.py b/Source/UX/MainWidget.py new file mode 100644 index 0000000..a13d6ca --- /dev/null +++ b/Source/UX/MainWidget.py @@ -0,0 +1,21 @@ +from kivy.uix import boxlayout +from kivymd.uix import tab +from kivy.lang.builder import Builder +from . import ConfigWidget, ConnectionWidget, CollectorWidget, SubscriberWidget +import os + +root_dir = os.path.split(__file__)[0] +Builder.load_file(os.path.join(root_dir, 'ConnectionWidget.kv')) +Builder.load_file(os.path.join(root_dir, 'SubscriberWidget.kv')) +Builder.load_file(os.path.join(root_dir, 'ConfigWidget.kv')) +Builder.load_file(os.path.join(root_dir, 'CollectorWidget.kv')) + + +class MainWidget(boxlayout.BoxLayout): + + pass + + +class TabWidget(tab.MDTabs): + + pass diff --git a/Source/UX/SubscriberWidget.kv b/Source/UX/SubscriberWidget.kv new file mode 100644 index 0000000..94184da --- /dev/null +++ b/Source/UX/SubscriberWidget.kv @@ -0,0 +1,80 @@ +: + orientation: "tb-lr" + StackLayout: + padding: 5, 5 + size_hint_y: 1/6 + ScalingLabel: + text: "In order to retrieve Audit Logs, you must subscribe your tenant to the relevant feeds. Enable the feeds you wish to retrieve below." + StackLayout: + padding: 5, 5 + orientation: "tb-lr" + size_hint_y: 1/6 + ScalingLabel: + text: "Audit.AzureActiveDirectory" + size_hint_y: 0.1 + MDSwitch: + valign: 'top' + active: False + size_hint_x: 0.1 + id: Audit.AzureActiveDirectory + on_release: root.on_switch_press(*args, name="Audit.AzureActiveDirectory") + disabled: True + StackLayout: + padding: 5, 5 + orientation: "tb-lr" + size_hint_y: 1/6 + ScalingLabel: + text: "Audit.General" + size_hint_y: 0.1 + MDSwitch: + valign: 'top' + active: False + size_hint_x: 0.1 + id: Audit.General + on_release: root.on_switch_press(*args, name="Audit.General") + disabled: True + StackLayout: + padding: 5, 5 + orientation: "tb-lr" + size_hint_y: 1/6 + ScalingLabel: + text: "Audit.Exchange" + size_hint_y: 0.1 + MDSwitch: + valign: 'top' + active: False + size_hint_x: 0.1 + id: Audit.Exchange + on_release: root.on_switch_press(*args, name="Audit.Exchange") + disabled: True + StackLayout: + padding: 5, 5 + orientation: "tb-lr" + size_hint_y: 1/6 + ScalingLabel: + text: "Audit.SharePoint" + size_hint_y: 0.1 + MDSwitch: + valign: 'top' + active: False + size_hint_x: 0.1 + id: Audit.SharePoint + on_release: root.on_switch_press(*args, name="Audit.Sharepoint") + disabled: True + StackLayout: + padding: 5, 5 + orientation: "tb-lr" + size_hint_y: 1/6 + ScalingLabel: + text: "DLP.All" + size_hint_y: 0.1 + MDSwitch: + valign: 'top' + active: False + size_hint_x: 0.1 + id: DLP.All + on_release: root.on_switch_press(*args, name="DLP.All") + disabled: True +: + text_size: root.width, None + size: self.texture_size diff --git a/Source/UX/SubscriberWidget.py b/Source/UX/SubscriberWidget.py new file mode 100644 index 0000000..0bd36dc --- /dev/null +++ b/Source/UX/SubscriberWidget.py @@ -0,0 +1,51 @@ +from kivy.uix import stacklayout +from kivymd.uix import tab +from kivy.app import App + + +class SubscriberWidget(tab.MDTabsBase, stacklayout.StackLayout): + + def __init__(self, **kwargs): + + super().__init__(**kwargs) + + @property + def content_types(self): + + return ['Audit.AzureActiveDirectory', 'Audit.General', 'Audit.Exchange', 'Audit.SharePoint', 'DLP.All'] + + @property + def enabled_content_types(self): + + return [x for x in self.content_types if self.ids[x].active] + + def activate_switches(self): + + for content_type in self.content_types: + self.ids[content_type].disabled = False + + def deactivate_switches(self, reset_value=False): + + for content_type in self.content_types: + self.ids[content_type].disabled = True + if reset_value: + self.ids[content_type].active = False + + def on_switch_press(self, *args, name): + + App.get_running_app().subscriber.set_sub_status(content_type=name, action='start' if args[0].active else 'stop') + self.set_switches() + + def set_switches(self): + + status = App.get_running_app().subscriber.get_sub_status() + if status == '': + return App.get_running_app().disconnect() + disabled_content_types = self.content_types.copy() + for s in status: + if s['status'].lower() == 'enabled': + disabled_content_types.remove(s['contentType']) + for disabled_content_type in disabled_content_types: + self.ids[disabled_content_type].active = False + for enabled_content_type in [x for x in self.content_types if x not in disabled_content_types]: + self.ids[enabled_content_type].active = True \ No newline at end of file diff --git a/Windows/GUI - Office Audit Log Collector.exe b/Windows/GUI - Office Audit Log Collector.exe new file mode 100644 index 0000000..bffa194 Binary files /dev/null and b/Windows/GUI - Office Audit Log Collector.exe differ diff --git a/Windows/Office Audit Log Collector.exe b/Windows/Office Audit Log Collector.exe new file mode 100644 index 0000000..c752b96 Binary files /dev/null and b/Windows/Office Audit Log Collector.exe differ diff --git a/Windows/Office Audit Log Subscriber.exe b/Windows/Office Audit Log Subscriber.exe new file mode 100644 index 0000000..7decd73 Binary files /dev/null and b/Windows/Office Audit Log Subscriber.exe differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..afc2373 Binary files /dev/null and b/requirements.txt differ