From c200cd0369c4c197b5d85b9e38fc37db4b384b38 Mon Sep 17 00:00:00 2001 From: Srishti Negi Date: Wed, 3 Jun 2026 16:03:00 +0530 Subject: [PATCH] Add Purview eDiscovery Cases tracking to Data Security & Governance --- core/powershell/ediscovery.py | 42 ++++ .../scripts/get_compliance_cases.ps1 | 85 +++++++ telemetry/data_security_governance.py | 234 +++++++++++++++++- 3 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 core/powershell/ediscovery.py create mode 100644 core/powershell/scripts/get_compliance_cases.ps1 diff --git a/core/powershell/ediscovery.py b/core/powershell/ediscovery.py new file mode 100644 index 0000000..95aa8c8 --- /dev/null +++ b/core/powershell/ediscovery.py @@ -0,0 +1,42 @@ +import json +import logging +from core.powershell.client import PowerShellClient + +logger = logging.getLogger("EDiscoveryService") + +class EDiscoveryService: + def __init__(self, client: PowerShellClient): + self.client = client + + def fetch_ediscovery_cases(self) -> list: + """Locates certificate, triggers powershell execution, and parses eDiscovery cases.""" + try: + cert_path = self.client.locate_certificate() + except Exception as e: + raise RuntimeError(f"Failed to locate certificate for authentication: {str(e)}") + + args = [ + "-AppId", self.client.client_id, + "-Organization", self.client.tenant_id, + "-CertificatePath", cert_path + ] + if self.client.cert_password: + args += ["-CertificatePassword", self.client.cert_password] + + raw_output = self.client.execute_script("scripts/get_compliance_cases.ps1", args) + + if not raw_output or not raw_output.strip(): + return [] + + try: + return json.loads(raw_output) + except json.JSONDecodeError: + # Parse JSON block in case of warning/header lines outputted by powershell environment + lines = raw_output.strip().split('\n') + json_str = "" + for line in lines: + if line.startswith("[") or line.startswith("{") or json_str: + json_str += line + if json_str: + return json.loads(json_str) + raise RuntimeError(f"PowerShell returned non-JSON format: {raw_output}") diff --git a/core/powershell/scripts/get_compliance_cases.ps1 b/core/powershell/scripts/get_compliance_cases.ps1 new file mode 100644 index 0000000..e02c82e --- /dev/null +++ b/core/powershell/scripts/get_compliance_cases.ps1 @@ -0,0 +1,85 @@ +param( + [Parameter(Mandatory=$true)] + [string]$AppId, + [Parameter(Mandatory=$true)] + [string]$Organization, + [Parameter(Mandatory=$true)] + [string]$CertificatePath, + [Parameter(Mandatory=$false)] + [string]$CertificatePassword +) + +$ErrorActionPreference = "Stop" + +# Check if ExchangeOnlineManagement module is installed beforehand +if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { + throw "ExchangeOnlineManagement PowerShell module is not installed. Please install it beforehand by running: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser" +} + +Import-Module ExchangeOnlineManagement + +# Connect to Security & Compliance PowerShell using App-Only Cert Auth +if ($CertificatePassword) { + $secPassword = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force + Connect-IPPSSession -CertificateFilePath $CertificatePath -CertificatePassword $secPassword -AppId $AppId -Organization $Organization -ShowBanner:$false -WarningAction SilentlyContinue +} else { + Connect-IPPSSession -CertificateFilePath $CertificatePath -AppId $AppId -Organization $Organization -ShowBanner:$false -WarningAction SilentlyContinue +} + +try { + $output = @() + $standardError = $null + $premiumError = $null + + # Get Standard eDiscovery cases + try { + $standardCases = Get-ComplianceCase -CaseType eDiscovery -WarningAction SilentlyContinue + if ($standardCases) { + foreach ($case in @($standardCases)) { + $caseObj = [PSCustomObject]@{ + Name = $case.Name + Status = $case.Status + CaseType = "eDiscovery (Standard)" + CreatedBy = $case.CreatedBy + WhenCreated = $case.WhenCreated + } + $output += $caseObj + } + } + } catch { + $standardError = $_.Exception.Message + } + + # Get Premium (Advanced) eDiscovery cases + try { + $premiumCases = Get-ComplianceCase -CaseType AdvancedEdiscovery -WarningAction SilentlyContinue + if ($premiumCases) { + foreach ($case in @($premiumCases)) { + $caseObj = [PSCustomObject]@{ + Name = $case.Name + Status = $case.Status + CaseType = "eDiscovery (Premium)" + CreatedBy = $case.CreatedBy + WhenCreated = $case.WhenCreated + } + $output += $caseObj + } + } + } catch { + $premiumError = $_.Exception.Message + } + + # If both failed, throw a combined exception + if ($standardError -and $premiumError) { + throw "Failed to fetch eDiscovery cases. eDiscovery (Standard) error: $standardError; eDiscovery (Premium) error: $premiumError" + } + + if ($output) { + $output | ConvertTo-Json -Depth 5 + } else { + "[]" + } +} +finally { + Disconnect-ExchangeOnline -Confirm:$false +} diff --git a/telemetry/data_security_governance.py b/telemetry/data_security_governance.py index dc124d0..2c6cf2f 100644 --- a/telemetry/data_security_governance.py +++ b/telemetry/data_security_governance.py @@ -68,30 +68,55 @@ def run_security_governance_pipeline(client_id, client_secret, tenant_id) -> dic client.close() - # Fetch Retention Policies via PowerShell client + # Fetch Retention Policies & eDiscovery Cases via PowerShell client policies = None policies_error = None + ediscovery_cases = None + ediscovery_error = None + + ps_client = None try: from core.powershell.client import PowerShellClient - from core.powershell.retention import RetentionService - ps_client = PowerShellClient(tenant_id=tenant_domain, client_id=client_id, client_secret=client_secret) - retention_service = RetentionService(ps_client) - policies = retention_service.fetch_retention_policies() except Exception as e: - usage_logger.error("Failed to fetch retention policies via PowerShell", exc_info=True) - policies_error = str(e) + usage_logger.error("Failed to initialize PowerShell client", exc_info=True) + policies_error = f"PowerShell initialization failed: {str(e)}" + ediscovery_error = f"PowerShell initialization failed: {str(e)}" - # Raise ConnectionError only if BOTH failed - if labels_error and policies_error: - raise ConnectionError(f"Security governance fetch failed.\nLabels Error: {labels_error}\nPolicies Error: {policies_error}") + if ps_client: + try: + from core.powershell.retention import RetentionService + retention_service = RetentionService(ps_client) + policies = retention_service.fetch_retention_policies() + except Exception as e: + usage_logger.error("Failed to fetch retention policies via PowerShell", exc_info=True) + policies_error = str(e) + + try: + from core.powershell.ediscovery import EDiscoveryService + ediscovery_service = EDiscoveryService(ps_client) + ediscovery_cases = ediscovery_service.fetch_ediscovery_cases() + except Exception as e: + usage_logger.error("Failed to fetch eDiscovery cases via PowerShell", exc_info=True) + ediscovery_error = str(e) + + # Raise ConnectionError only if ALL failed + if labels_error and policies_error and ediscovery_error: + raise ConnectionError( + f"Security governance fetch failed.\n" + f"Labels Error: {labels_error}\n" + f"Policies Error: {policies_error}\n" + f"eDiscovery Error: {ediscovery_error}" + ) usage_logger.info("Data Security & Governance Pipeline completed successfully.") return { "labels": labels, "labels_error": labels_error, "policies": policies, - "policies_error": policies_error + "policies_error": policies_error, + "ediscovery_cases": ediscovery_cases, + "ediscovery_error": ediscovery_error } @@ -110,6 +135,7 @@ def __init__(self, master, log_callback, credentials_callback, status_change_cal self.ITEMS_PER_PAGE = 8 self.last_labels_data = None self.last_policies_data = None + self.last_ediscovery_data = None self.build_ui() @@ -245,6 +271,52 @@ def build_ui(self): corner_radius=8 ) + # Purview eDiscovery Cases section + self.ediscovery_header_frame = ctk.CTkFrame(self.inner_pad, fg_color="transparent") + self.ediscovery_title = ctk.CTkLabel( + self.ediscovery_header_frame, + text="Purview eDiscovery Cases", + font=FONT_HEADER_SMALL, + text_color=COLOR_TEXT_MAIN + ) + self.ediscovery_title.pack(side="left", anchor="w") + + self.ediscovery_link = ctk.CTkLabel( + self.ediscovery_header_frame, + text="Open Microsoft Purview eDiscovery ↗", + font=FONT_BODY_BOLD, + text_color=COLOR_PRIMARY, + cursor="hand2" + ) + self.ediscovery_link.pack(side="left", anchor="w", padx=(15, 0)) + self.ediscovery_link.bind("", lambda e: webbrowser.open("https://purview.microsoft.com/ediscovery/standard")) + self.ediscovery_link.bind("", lambda e: self.ediscovery_link.configure(text_color=COLOR_PRIMARY_HOVER)) + self.ediscovery_link.bind("", lambda e: self.ediscovery_link.configure(text_color=COLOR_PRIMARY)) + + self.btn_export_ediscovery = ctk.CTkButton( + self.ediscovery_header_frame, + text="Export eDiscovery Cases", + font=FONT_BODY_BOLD, + fg_color="transparent", + text_color=COLOR_PRIMARY, + border_width=1, + border_color=COLOR_OUTLINE, + hover_color=COLOR_SECONDARY_HOVER, + width=180, + height=32, + corner_radius=16, + command=self.export_ediscovery_csv, + state="disabled" + ) + self.btn_export_ediscovery.pack(side="right", anchor="e") + self.ediscovery_grid = ctk.CTkFrame( + self.inner_pad, + fg_color=COLOR_SURFACE, + border_color=COLOR_OUTLINE_LIGHT, + border_width=1, + corner_radius=8 + ) + self.reset_view() def reset_view(self): @@ -256,6 +328,8 @@ def reset_view(self): self.labels_header_frame.pack_forget() self.retention_header_frame.pack_forget() self.retention_grid.pack_forget() + self.ediscovery_header_frame.pack_forget() + self.ediscovery_grid.pack_forget() for w in self.state_frame.winfo_children(): w.destroy() @@ -263,6 +337,8 @@ def reset_view(self): w.destroy() for w in self.retention_grid.winfo_children(): w.destroy() + for w in self.ediscovery_grid.winfo_children(): + w.destroy() self.flattened_rows = [] self.current_page = 0 @@ -271,6 +347,8 @@ def reset_view(self): self.btn_export_labels.configure(state="disabled") if hasattr(self, "btn_export_retention"): self.btn_export_retention.configure(state="disabled") + if hasattr(self, "btn_export_ediscovery"): + self.btn_export_ediscovery.configure(state="disabled") def _set_state_loading(self, msg="Loading..."): for w in self.state_frame.winfo_children(): @@ -306,11 +384,14 @@ def trigger_fetch(self, tenant, client_id, client_secret): self.labels_header_frame.pack_forget() self.retention_header_frame.pack_forget() self.retention_grid.pack_forget() + self.ediscovery_header_frame.pack_forget() + self.ediscovery_grid.pack_forget() self._set_state_loading("Retrieving tenant Security & Compliance policies...") self.btn_export_labels.configure(state="disabled") self.btn_export_retention.configure(state="disabled") + self.btn_export_ediscovery.configure(state="disabled") threading.Thread( target=self._execute_worker, @@ -334,14 +415,19 @@ def _render_success(self, data: dict): w.destroy() for w in self.retention_grid.winfo_children(): w.destroy() + for w in self.ediscovery_grid.winfo_children(): + w.destroy() labels = data.get("labels") labels_error = data.get("labels_error") policies = data.get("policies") policies_error = data.get("policies_error") + ediscovery_cases = data.get("ediscovery_cases") + ediscovery_error = data.get("ediscovery_error") self.last_labels_data = labels self.last_policies_data = policies + self.last_ediscovery_data = ediscovery_cases usage_logger.info(f"Sensitivity Labels fetched successfully. Total labels to render: {len(labels) if labels else 0}") self.status = "success" @@ -418,6 +504,11 @@ def _render_success(self, data: dict): self.retention_grid.pack(fill="x", expand=True, pady=(0, 15)) self._render_retention_policies(policies, policies_error) + # 3. Render eDiscovery Cases Grid + self.ediscovery_header_frame.pack(fill="x", pady=(20, 10)) + self.ediscovery_grid.pack(fill="x", expand=True, pady=(0, 15)) + self._render_ediscovery_cases(ediscovery_cases, ediscovery_error) + self.on_status_change() def _display_current_page(self): @@ -528,6 +619,7 @@ def _render_error(self, err_msg): self.on_status_change() self.btn_export_labels.configure(state="disabled") self.btn_export_retention.configure(state="disabled") + self.btn_export_ediscovery.configure(state="disabled") def _render_retention_policies(self, policies, policies_error): if policies_error: @@ -779,4 +871,124 @@ def export_retention_csv(self): except Exception as e: messagebox.showerror("Export Failed", f"Failed to export CSV: {e}", parent=self) + def _render_ediscovery_cases(self, cases, cases_error): + if cases_error: + msg = cases_error + # Provide helpful, friendly advice if pwsh or dependency issue + if "powershell" in cases_error.lower() or "pwsh" in cases_error.lower(): + msg = "PowerShell Core ('pwsh') is not installed or configured on this machine.\nPlease refer to the Prerequisites in the README to configure it." + elif "exchangeonlinemanagement" in cases_error.lower(): + msg = "ExchangeOnlineManagement PowerShell module is missing.\nPlease run: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser" + + ctk.CTkLabel( + self.ediscovery_grid, + text=f"✖ {msg}", + font=FONT_BODY_MEDIUM, + text_color=COLOR_ERROR, + justify="center" + ).pack(padx=20, pady=20) + self.btn_export_ediscovery.configure(state="disabled") + elif cases is None or not cases: + ctk.CTkLabel( + self.ediscovery_grid, + text="No Active Purview eDiscovery Cases/Matters found in this tenant.", + font=FONT_BODY_MEDIUM, + text_color=COLOR_TEXT_SUB + ).pack(padx=20, pady=20) + self.btn_export_ediscovery.configure(state="disabled") + else: + self.btn_export_ediscovery.configure(state="normal") + + # Configure grid columns + self.ediscovery_grid.grid_columnconfigure(0, weight=3) # Case Name + self.ediscovery_grid.grid_columnconfigure(1, weight=2) # Type + self.ediscovery_grid.grid_columnconfigure(2, weight=1) # Status + self.ediscovery_grid.grid_columnconfigure(3, weight=2) # Created By + self.ediscovery_grid.grid_columnconfigure(4, weight=2) # Created Date + + headers = ["Case Name", "Case Type", "Status", "Created By", "Created Date"] + for col_idx, head_text in enumerate(headers): + cell = ctk.CTkFrame(self.ediscovery_grid, fg_color=COLOR_TONAL_BG, corner_radius=0) + cell.grid(row=0, column=col_idx, sticky="nsew", padx=1, pady=1) + ctk.CTkLabel(cell, text=head_text, font=FONT_BODY_BOLD, text_color=COLOR_TONAL_TEXT).pack(padx=10, pady=8, anchor="w") + + cases_list = cases if isinstance(cases, list) else [cases] + + for r_idx, case in enumerate(cases_list, start=1): + bg_style = "transparent" if r_idx % 2 != 0 else COLOR_SURFACE_VARIANT + + name = case.get("Name", "N/A") + case_type = case.get("CaseType", "N/A") + status_val = case.get("Status", "Active") + created_by = case.get("CreatedBy", "N/A") + when_created = case.get("WhenCreated", "N/A") + + # Format Status nicely + status_str = f"🟢 {status_val}" if status_val.lower() == "active" else f"🔴 {status_val}" + + c0 = ctk.CTkFrame(self.ediscovery_grid, fg_color=bg_style, corner_radius=0) + c0.grid(row=r_idx, column=0, sticky="nsew", padx=1, pady=1) + lbl_name = ctk.CTkLabel(c0, text=name, font=FONT_BODY_BOLD, text_color=COLOR_TEXT_MAIN) + lbl_name.pack(padx=10, pady=6, anchor="w") + c0.bind("", lambda e, l=lbl_name: l.configure(wraplength=e.width - 20)) + + c1 = ctk.CTkFrame(self.ediscovery_grid, fg_color=bg_style, corner_radius=0) + c1.grid(row=r_idx, column=1, sticky="nsew", padx=1, pady=1) + ctk.CTkLabel(c1, text=case_type, font=FONT_BODY_MEDIUM, text_color=COLOR_TEXT_MAIN).pack(padx=10, pady=6, anchor="w") + + c2 = ctk.CTkFrame(self.ediscovery_grid, fg_color=bg_style, corner_radius=0) + c2.grid(row=r_idx, column=2, sticky="nsew", padx=1, pady=1) + ctk.CTkLabel(c2, text=status_str, font=FONT_BODY_BOLD, text_color=COLOR_TEXT_MAIN).pack(padx=10, pady=6, anchor="w") + + c3 = ctk.CTkFrame(self.ediscovery_grid, fg_color=bg_style, corner_radius=0) + c3.grid(row=r_idx, column=3, sticky="nsew", padx=1, pady=1) + lbl_created_by = ctk.CTkLabel(c3, text=created_by, font=FONT_BODY_MEDIUM, text_color=COLOR_TEXT_MAIN) + lbl_created_by.pack(padx=10, pady=6, anchor="w") + c3.bind("", lambda e, l=lbl_created_by: l.configure(wraplength=e.width - 20)) + + c4 = ctk.CTkFrame(self.ediscovery_grid, fg_color=bg_style, corner_radius=0) + c4.grid(row=r_idx, column=4, sticky="nsew", padx=1, pady=1) + ctk.CTkLabel(c4, text=when_created, font=FONT_BODY_MEDIUM, text_color=COLOR_TEXT_MAIN).pack(padx=10, pady=6, anchor="w") + + def export_ediscovery_csv(self): + """Prompts the user to save eDiscovery cases as a detailed CSV file.""" + if not hasattr(self, "last_ediscovery_data") or not self.last_ediscovery_data: + from tkinter import messagebox + messagebox.showinfo("No Data", "There is no eDiscovery cases data to export. Please run a scan first.", parent=self) + return + + from tkinter import filedialog, messagebox + from datetime import datetime + import pandas as pd + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + f = filedialog.asksaveasfilename( + initialfile=f"ediscovery_cases_{ts}.csv", + defaultextension=".csv", + filetypes=[("CSV Files", "*.csv"), ("All Files", "*.*")], + parent=self + ) + if not f: + return + + cases_list = self.last_ediscovery_data if isinstance(self.last_ediscovery_data, list) else [self.last_ediscovery_data] + + rows = [] + for case in cases_list: + rows.append({ + "Case Name": case.get("Name", "N/A"), + "Case Type": case.get("CaseType", "N/A"), + "Status": case.get("Status", "N/A"), + "Created By": case.get("CreatedBy", "N/A"), + "When Created": case.get("WhenCreated", "N/A") + }) + + df = pd.DataFrame(rows) + try: + df.to_csv(f, index=False) + messagebox.showinfo("Export Successful", f"eDiscovery cases exported successfully to:\n{f}", parent=self) + except Exception as e: + messagebox.showerror("Export Failed", f"Failed to export CSV: {e}", parent=self) + +