diff --git a/.gitignore b/.gitignore index d8505b1..f141c14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,16 @@ +.DS_Store __pycache__/ *.pyc *.pyo */__pycache__/ -__pycache__/ */*/__pycache__/ */*/test_* .env env/ outputs/ +output/ +reports/ +certificate/ data/ test_*.py diff --git a/README.md b/README.md index 236423b..ab74e6d 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Please follow the [official Microsoft guide](https://learn.microsoft.com/en-us/p To scan your tenant, you need to register an app in the Microsoft Entra ID (formerly Azure AD) portal. -> **💡 AUTOMATED SETUP AVAILABLE:** You can completely automate Steps 1-3 below (App Creation, API Permissions, Admin Consent, and Secret Generation) by running the provided PowerShell script `app_creation_script.ps1` after [downloading the tool from github repository](#running-the-tool). Simply execute `.\scripts\app_creation_script.ps1` from your PowerShell terminal or right click and select "Run with Powershell" and follow the prompts thereafter. Note that this requires the Microsoft Graph PowerShell module. If you prefer manual setup, follow the steps below. - +> **💡 AUTOMATED SETUP AVAILABLE:** You can completely automate Steps 1-3 below (App Creation, API Permissions, Admin Consent, and Secret Generation) by running the provided PowerShell script. Simply execute `.\scripts\app_creation_script.ps1` from your PowerShell terminal and follow the prompts. Note that this requires the Microsoft Graph PowerShell module. If you prefer manual setup, follow the steps below. +In order to execute the script right click and select "Run with Powershell" ### 1. Register the App 1. Go to [portal.azure.com](https://portal.azure.com/). @@ -194,18 +194,16 @@ To scan your tenant, you need to register an app in the Microsoft Entra ID (form 3. Name the app (e.g., "Deal Assistant Tool"). 4. Select **"Accounts in this organizational directory only"** (Single Tenant). 5. Click **Register**. -6. **(Required for Delegated Auth Flows)**: Go to **Manage > Authentication**. Under **Redirect URI configuration**, click **Add Redirect URI** -> **Web**. Add `http://localhost` as the redirect URI. +6. **(Required for Delegated Auth & eDiscovery)**: Go to **Authentication**. Under **Redirect URI configuration**, click **Add Redirect URI** -> **Web**. Add `http://localhost` as the redirect URI. ### 2. API Permissions In your new app, go to **API permissions > Add a permission**, and assign permissions based on the workloads you plan to scan. *Don't forget to click **"Grant admin consent"** after adding these permissions.* -#### 2.1. Deal Assistant Telemetry Permissions ([Usage and Adoption](#introduction)) +#### 2.1. Deal Assistant Telemetry Permissions (Usage and Adoption) The Usage and Adoption tab performs extensive tenant auditing. While the following permissions are recommended for a complete report, you may choose to grant only a subset. **NOTE: Be aware that any missing permissions will simply cause the tool to gracefully skip those specific telemetry sections.** -**2.1.1. Microsoft APIs > Microsoft Graph:** - -**Application permissions**: +**2.1.1. Microsoft Graph API Application permissions:** * `Reports.Read.All`: Used to retrieve active user trends, mailbox/SharePoint usage reports, M365 Apps, and Email Client usage. * `Directory.Read.All`: Used to read tenant organization configuration data, Domain, User, and Group summaries. * `Policy.Read.All`: Required for Conditional Access & Authentication mechanics. @@ -222,22 +220,18 @@ The Usage and Adoption tab performs extensive tenant auditing. While the followi * `SensitivityLabels.Read.All`: to read all sensitivity labels * `Application.Read.All`: Required to retrieve App Registrations directory details and Service Principal SSO configurations. -**Delegated permissions** +**2.1.2. Microsoft Graph API Delegated permissions:** * `eDiscovery.Read.All`: Required to retrieve active/closed Microsoft Purview eDiscovery cases on behalf of the user. * `Policy.Read.All`: Required to retrieve Mobile Device Management (MDM) Policies on behalf of the user. * `offline_access`: required to maintain access to data you have given the app access to -> **Note** : When [running the deal assistant tool](#running-the-tool) with [Delegated Authentication Flow enabled](#1-delegated-authentication-flow), the user who logs in must be an eDiscovery administrator for these permissions to work in the deal assistant app. - -**2.1.2. APIs my organization uses > Office 365 Exchange Online** - -**Application Permissions:** -* `Exchange.ManageAsApp`: required to read data governance and security policies (sensitive, information types, exchange connectors etc) -* `Exchange.ManageAsAppV2`: required to read data governance and security policies (sensitive, information types, exchange connectors etc) +> **Note** : The user who logs in must be an eDiscovery administrator. -#### 2.2. Migration Planner +**2.1.3. Office 365 Exchange Online Application Permissions (under `APIs my organization uses`):** +* `Exchange.ManageAsApp`: required to read data governance ans security policies (sensitive, information types, exchange connectors etc) +* `Exchange.ManageAsAppV2`: required to read data governance ans security policies (sensitive, information types, exchange connectors etc) -**Microsoft APIs > Microsoft Graph > Application Permissions** +#### 2.2. Migration Planner Permissions (Microsoft Graph Application Permissions) **2.2.1. Shared Core Permissions** * `User.Read.All` (To list users) @@ -311,7 +305,7 @@ You will need three values for the tool: ## Advanced Authentication & Setup (Deal Assistant) ### 1. Delegated Authentication Flow -Certain features like **eDiscovery** require **Delegated Authentication**. On the login screen of deal assistant, you can check the box to enable Delegated Authentication. +Certain features like **eDiscovery** require **Delegated Authentication**. On the login screen, you can check the box to enable Delegated Authentication. > **⚠️ IMPORTANT WARNING**: If you enable this, your Entra App Registration MUST have `http://localhost` registered as a redirect URI. Otherwise, the interactive browser login popup will fail! ### 2. PowerShell & Certificate-Based Authentication @@ -346,12 +340,10 @@ A standard Client Secret cannot authorize PowerShell modules—a certificate is Upon launching, you are greeted with the **Unified Login Screen**: * Enter your **Tenant ID**, **Client ID**, and **Client Secret**. -* Select if you wish to use [Delegated Authentication](#1-delegated-authentication-flow). -* Click **Connect & Continue**. Follow any [certificate upload instructions](#2-powershell--certificate-based-authentication) if prompted. +* Select if you wish to use **Delegated Authentication**. +* Click **Connect & Continue**. Follow any certificate upload instructions if prompted. -Once authenticated, the tool provides a left-hand navigation sidebar with three main tabs: -* **[Usage and Adoption](#tab-1-usage-and-adoption)** -* **[Migration Planner](#tab-2-migration-planner)** +Once authenticated, the tool provides a left-hand navigation sidebar with two main tabs: ## Tab 1: Usage and Adoption This tab loads the M365 Telemetry dashboard. diff --git a/core/graph/network_security/conditional_access.py b/core/graph/network_security/conditional_access.py index b23a782..f50ea12 100644 --- a/core/graph/network_security/conditional_access.py +++ b/core/graph/network_security/conditional_access.py @@ -55,12 +55,10 @@ def run_conditional_access_pipeline( if is_cancelled_callback and is_cancelled_callback(): break resp = session.get(url, headers=headers) if resp.status_code == 200: - data = resp.json() or {} + data = resp.json() value_list = data.get("value", []) for p in value_list: - if not p: - continue p["name"] = p.get("displayName") or p.get("name") or "N/A" p["state"] = p.get("state") or "N/A" diff --git a/core/graph/security/authentication.py b/core/graph/security/authentication.py index 52fd4a4..4c1e07d 100644 --- a/core/graph/security/authentication.py +++ b/core/graph/security/authentication.py @@ -61,12 +61,10 @@ def run_authentication_pipeline( if is_cancelled_callback and is_cancelled_callback(): break resp = session.get(url, headers=headers) if resp.status_code == 200: - data = resp.json() or {} + data = resp.json() value_list = data.get("value", []) if writer: for p in value_list: - if not p: - continue name = p.get("displayName", "N/A") state = p.get("state", "N/A") diff --git a/deal_assistant.py b/deal_assistant.py index bbe973b..574f74a 100644 --- a/deal_assistant.py +++ b/deal_assistant.py @@ -1,44 +1,5 @@ # Copyright 2026 Google LLC - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# https://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -The Deal Assistant is a comprehensive desktop application designed to help deployment partners and IT administrators assess a Microsoft 365 tenant before migration. It is split into two primary modules: - -Usage and Adoption: A deep telemetry and discovery module providing insights into a tenant's directory, license usage, security governance, and endpoint management. -Migration Planner: Independently assess Exchange Online (Emails, Contacts, Calendars), Files in OneDrive / SharePoint, or Microsoft Teams to provide volume metrics and generate optimized Migration Batch Plans with estimated completion times. - -DISCLAIMER -========== -The estimations provided by this tool are calculated projections intended for -preliminary planning only. -Actual migration timelines (ETAs) and batch execution may vary based on -real-time network conditions, source/target throttling policies, migration -configurations, and the volume of delta migrations. -The estimations do not constitute a performance guarantee or a binding service -level agreement (SLA). -Please note that this release is an experimental feature with limited and -evolving functionality. -Partners and/or Customers should NOT use this product for production projects -where business outcomes rely on the product functionality or timelines. -Partners and/or Customers should NOT commit hard migration timelines based on -this product. -By using this tool, you acknowledge that log files generated by this script -contain personally identifiable information and that you are solely responsible -for securing your local environment and these files. -Use of this tool is governed by the Apache 2.0 license. - -""" +"""Standalone application for the License Usage and Telemetry view.""" import os import pandas as pd diff --git a/docs/m365_telemetry_scaling_skill.md b/docs/m365_telemetry_scaling_skill.md index 21a4fac..b313dc4 100644 --- a/docs/m365_telemetry_scaling_skill.md +++ b/docs/m365_telemetry_scaling_skill.md @@ -92,14 +92,6 @@ graph TD backoff=backoff_val ) ``` -4. **API Payload Null Safety**: - - Never assume keys returned by Microsoft Graph API or PowerShell scripts are always non-null. If a key's value is explicitly set to `null` (None) in the API payload, `dict.get("key", {})` returns `None` (bypassing the default `{}` argument). - - Chaining lookups on this (e.g. `p.get("grantControls", {}).get("builtInControls")`) will crash with `AttributeError: 'NoneType' object has no attribute 'get'`. - - **Rule**: Always use the falsy check operator `or` to define defaults when chaining dictionary queries: - ```python - grant_controls = p.get("grantControls") or {} - controls = grant_controls.get("builtInControls") or [] - ``` --- @@ -314,23 +306,6 @@ Follow these rules to prevent missing data in the exported PDF: - Ensure the structure of data retrieved (e.g., list of dictionaries, flat rows, or tuples) matches exactly what the PDF builder (`telemetry/pdf_report.py`) expects. - If `pdf_report.py` expects nested objects (e.g. `closedBy` containing `user`), ensure the data returned by `.last_data` or `load_csv()` matches this format or parse it accordingly. -5. **XML/HTML Parsing Safety inside Paragraphs**: - - ReportLab `Paragraph` flowables attempt to parse content strings as basic XML/HTML markup. Raw HTML tags (like `
` or `` found in disclaimers/descriptions) or unescaped characters (like `&`, `<`, or `>`) will cause ReportLab to abruptly crash with `xml.parsers.expat.ExpatError` or `ValueError: findSpanStyle not implemented in this parser`. - - **Rule**: Always wrap **all** dynamic string variables retrieved from Graph or PowerShell inside `escape_text(val)` before constructing a `Paragraph`. - - **Important Pitfall**: When using Python f-strings, be sure to escape the variables inside the string (e.g. `Paragraph(f"Error: {escape_text(err)}")`), or escape the entire dynamically composed string if no static HTML is present. Static layout formatting (like `` or `
`) must remain outside the `escape_text()` call. - -6. **Isolated Section Rendering Fail-safes**: - - To prevent a single query or formatting error in one section from failing the entire report download, all major PDF sections must be wrapped in independent `try...except` blocks. - - If an exception occurs, use `logger.exception("Failed to format [Section Name] section in PDF")` to write the full traceback to the telemetry logs, and append a warning paragraph to the `story` using `section_err_style`: - ```python - try: - # Build section... - story.append(section_table) - except Exception as e: - logger.exception("Failed to format Feature section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Feature section: {escape_text(str(e))}", section_err_style)) - ``` - --- ## 8. Documentation Update Guidelines diff --git a/scripts/app_creation_script.ps1 b/scripts/app_creation_script.ps1 index a33b7d2..39ddf75 100644 --- a/scripts/app_creation_script.ps1 +++ b/scripts/app_creation_script.ps1 @@ -1,17 +1,17 @@ - <# +<# .SYNOPSIS -Automates the creation of a Single-Tenant Entra ID App for Workspace Migration. -Strictly forces account selection and verifies specific Admin roles. +Automates the creation of a Single-Tenant Entra ID App for Workspace Migration and Deal Assistant Telemetry. +Strictly forces account selection, verifies Admin roles, configures Graph & Exchange API permissions, grants admin consent, +and automatically assigns required Entra ID Directory Roles to the Service Principal. #> -# Check if the module is missing +# Check if the Microsoft Graph module is installed if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host "Microsoft Graph module is NOT installed." -ForegroundColor Yellow $UserResponse = Read-Host "Would you like to try installing Microsoft Graph? (Y/N)" if ($UserResponse -ieq "Y") { try { - # Use only native cmdlets, no .NET property setting Install-Module -Name Microsoft.Graph -Scope CurrentUser -Force -AllowClobber Write-Host "Installation complete!" -ForegroundColor Green } @@ -37,18 +37,17 @@ if (Test-Path $CachePath) { try { Remove-Item $CachePath -Recurse -Force -ErrorAction SilentlyContinue } catch {} } -Write-Host "Opening Microsoft Login... (Please select the correct account)" -ForegroundColor Cyan +Write-Host "Opening Microsoft Login... (Please select an administrative account)" -ForegroundColor Cyan $RequiredScopes = @( "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "Directory.Read.All", - "RoleManagement.Read.Directory" + "RoleManagement.Read.Directory", + "RoleManagement.ReadWrite.Directory" ) try { - # In v2, -ContextScope Process is the most reliable way to force account selection - # and prevent the session from saving to the machine permanently. Connect-MgGraph -Scopes $RequiredScopes -ContextScope Process $Context = Get-MgContext @@ -58,7 +57,7 @@ try { Write-Host "Logged in as: $UserPrincipal" -ForegroundColor Green # --- ROLE VALIDATION --- - Write-Host "Verifying Directory Roles..." -ForegroundColor Gray + Write-Host "Verifying Directory Roles of authenticating user..." -ForegroundColor Gray $UserRoles = Get-MgUserMemberOf -UserId $Context.Account -All | Where-Object { $_.AdditionalProperties.displayName -ne $null } $Authorized = $false @@ -110,6 +109,12 @@ $ExchangeAppRoles = @( "Exchange.ManageAsApp", "Exchange.ManageAsAppV2" ) +$DirectoryRolesToAssign = @( + "Global Reader", + "Compliance Administrator", + "Compliance Data Administrator" +) + $TenantId = $Context.TenantId try { @@ -126,10 +131,10 @@ try { # --- STEP 2: PREPARE SERVICE PRINCIPAL --- $NewServicePrincipal = New-MgServicePrincipal -BodyParameter @{ appId = $Application.AppId } - Write-Host "Waiting 10 seconds for replication..." -ForegroundColor DarkGray + Write-Host "Waiting 10 seconds for service principal replication..." -ForegroundColor DarkGray Start-Sleep -Seconds 10 - # --- STEP 3: CONFIGURE & GRANT PERMISSIONS --- + # --- STEP 3: CONFIGURE & GRANT API PERMISSIONS --- Write-Host "Configuring API Permissions & Granting Admin Consent..." -ForegroundColor Cyan $AllRequiredResourceAccess = @() @@ -198,8 +203,53 @@ try { Write-Host " - Admin Consent Granted for Delegated Scopes" -ForegroundColor Gray } - # --- STEP 4: CREATE CLIENT SECRET --- - Write-Host "Generating Client Secret..." -ForegroundColor Cyan + # --- STEP 4: AUTOMATED DIRECTORY ROLE ASSIGNMENTS --- + Write-Host "`nAssigning Directory Roles to Service Principal..." -ForegroundColor Cyan + + foreach ($RoleName in $DirectoryRolesToAssign) { + try { + # Check if directory role is enabled in tenant + $Role = Get-MgDirectoryRole -Filter "displayName eq '$RoleName'" -ErrorAction SilentlyContinue + if (-not $Role) { + # Enable role from template if not instantiated + $Template = Get-MgDirectoryRoleTemplate -Filter "displayName eq '$RoleName'" -ErrorAction SilentlyContinue + if ($Template) { + $Role = New-MgDirectoryRole -RoleTemplateId $Template.Id -ErrorAction SilentlyContinue + } + } + + if ($Role) { + # Add service principal as member + New-MgDirectoryRoleMemberByRef -DirectoryRoleId $Role.Id -OdataId "https://graph.microsoft.com/v1.0/directoryObjects/$($NewServicePrincipal.Id)" -ErrorAction Stop + Write-Host " - Successfully Assigned Directory Role: $RoleName" -ForegroundColor Green + } else { + # Fallback via REST if template resolution requires raw REST API call + $RoleRest = (Invoke-MgRestMethod -Uri "https://graph.microsoft.com/v1.0/directoryRoles?`$filter=displayName eq '$RoleName'").value[0] + if (-not $RoleRest) { + $TemplateRest = (Invoke-MgRestMethod -Uri "https://graph.microsoft.com/v1.0/directoryRoleTemplates?`$filter=displayName eq '$RoleName'").value[0] + if ($TemplateRest) { + $RoleRest = Invoke-MgRestMethod -Uri "https://graph.microsoft.com/v1.0/directoryRoles" -Method Post -Body @{ roleTemplateId = $TemplateRest.id } + } + } + if ($RoleRest) { + $MemberBody = @{ "`@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($NewServicePrincipal.Id)" } + Invoke-MgRestMethod -Uri "https://graph.microsoft.com/v1.0/directoryRoles/$($RoleRest.id)/members/`$ref" -Method Post -Body $MemberBody | Out-Null + Write-Host " - Successfully Assigned Directory Role (REST): $RoleName" -ForegroundColor Green + } else { + Write-Host " - Warning: Unable to resolve Directory Role template for '$RoleName'." -ForegroundColor Yellow + } + } + } catch { + if ($_.ToString() -match "already exists" -or $_.ToString() -match "Request_BadRequest") { + Write-Host " - Directory Role '$RoleName' is already assigned." -ForegroundColor DarkGreen + } else { + Write-Host " - Failed to assign Directory Role '$RoleName': $_" -ForegroundColor Yellow + } + } + } + + # --- STEP 5: CREATE CLIENT SECRET --- + Write-Host "`nGenerating Client Secret..." -ForegroundColor Cyan $ExpiryDate = (Get-Date).AddYears(2).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $PasswordCred = Add-MgApplicationPassword -ApplicationId $Application.Id -BodyParameter @{ passwordCredential = @{ @@ -216,6 +266,7 @@ try { Write-Host "Application (Client) ID : $($Application.AppId)" Write-Host "Client Secret Value : $($PasswordCred.SecretText)" Write-Host "Directory (Tenant) ID : $TenantId" + Write-Host "Directory Roles Assigned: $($DirectoryRolesToAssign -join ', ')" -ForegroundColor Green Write-Warning "IMPORTANT: Copy the Client Secret Value immediately." } @@ -223,7 +274,7 @@ catch { Write-Error "Operation failed: $_" } -# --- STEP 5: POWER PLATFORM MANAGEMENT APP (OPTIONAL) --- +# --- STEP 6: POWER PLATFORM MANAGEMENT APP (OPTIONAL) --- Write-Host "`n--- POWER AUTOMATE & DATAVERSE CONFIGURATION ---" -ForegroundColor Cyan $PromptPower = Read-Host "Would you like to register this app for Power Automate telemetry? (Y/N)" if ($PromptPower -ieq "Y") { @@ -237,6 +288,7 @@ if ($PromptPower -ieq "Y") { Write-Host "Registering App as Management App..." -ForegroundColor Gray New-PowerAppManagementApp -ApplicationId $Application.AppId -ErrorAction Stop Write-Host "Power Automate Management App Registration Complete!" -ForegroundColor Green + Write-Host "`nNote: For Desktop Flow scanning, ensure this App Registration is added as an Application User with the 'System Administrator' security role in your Dataverse environment(s)." -ForegroundColor Yellow } catch { Write-Warning "Power Automate registration failed: $_" Write-Host "You can safely ignore this error. The main App Registration was created successfully." -ForegroundColor Yellow @@ -247,4 +299,3 @@ if ($PromptPower -ieq "Y") { # --- FINAL DISCONNECT --- Disconnect-MgGraph Read-Host "`nPress Enter to close this window" - \ No newline at end of file diff --git a/telemetry/pdf_report.py b/telemetry/pdf_report.py index 8708c8c..d335066 100644 --- a/telemetry/pdf_report.py +++ b/telemetry/pdf_report.py @@ -16,10 +16,7 @@ import io import html -import logging from datetime import datetime - -logger = logging.getLogger("M365TelemetryAsyncLogger.PdfReport") from collections import Counter from matplotlib.figure import Figure from matplotlib.backends.backend_agg import FigureCanvasAgg @@ -30,14 +27,6 @@ from reportlab.pdfgen import canvas -def escape_text(val) -> str: - """Safely escapes XML/HTML special characters in dynamic text to prevent ReportLab Paragraph parsing errors.""" - if val is None: - return "-" - return html.escape(str(val)) - - - class NumberedCanvas(canvas.Canvas): """Custom canvas to compute total page count and draw running headers, footers and page numbers.""" @@ -353,16 +342,6 @@ def generate_pdf_report(data: dict, filepath: str): textColor=text_color ) - section_err_style = ParagraphStyle( - 'SectionErrTxt', - parent=styles['Normal'], - textColor=colors.HexColor("#DC2626"), - fontName="Helvetica-Bold", - fontSize=10, - spaceBefore=10, - spaceAfter=10 - ) - story = [] # ========================================================================= @@ -376,7 +355,7 @@ def generate_pdf_report(data: dict, filepath: str): # Metadata Table meta_data = [ - [Paragraph("Tenant Name / ID:", meta_label_style), Paragraph(escape_text(data.get("tenant_id", "N/A")), meta_val_style)], + [Paragraph("Tenant Name / ID:", meta_label_style), Paragraph(data.get("tenant_id", "N/A"), meta_val_style)], [Paragraph("Report Generated:", meta_label_style), Paragraph(datetime.now().strftime("%B %d, %Y at %I:%M %p"), meta_val_style)], [Paragraph("Assessment Status:", meta_label_style), Paragraph("🟢 Audit Completed Successfully", ParagraphStyle('StatusStyle', parent=meta_val_style, fontName='Helvetica-Bold', textColor=colors.HexColor("#15803D")))], [Paragraph("Report Context:", meta_label_style), Paragraph("Usage & Adoption Inventory", meta_val_style)] @@ -398,569 +377,131 @@ def generate_pdf_report(data: dict, filepath: str): story.append(Paragraph("This section outlines the licensing packages (SKUs) currently configured and active in your Microsoft Entra ID tenant scope, displaying total enabled vs. consumed license counts.", body_style)) story.append(Spacer(1, 8)) - try: - sku_list = data.get("skus", []) - if not sku_list: - story.append(Paragraph("No subscribed licensing data was discovered or available for this report.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - # Table columns: SKU, Units, Consumed - sku_table_data = [[ - Paragraph("SKU Part Number", table_cell_header), - Paragraph("Allocated Units Status", table_cell_header), - Paragraph("Consumed Units", table_cell_header) - ]] + sku_list = data.get("skus", []) + if not sku_list: + story.append(Paragraph("No subscribed licensing data was discovered or available for this report.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + # Table columns: SKU, Units, Consumed + sku_table_data = [[ + Paragraph("SKU Part Number", table_cell_header), + Paragraph("Allocated Units Status", table_cell_header), + Paragraph("Consumed Units", table_cell_header) + ]] + + for item in sku_list: + sku_table_data.append([ + Paragraph(item.get("skuPartNumber", "UNKNOWN_SKU"), table_cell_bold), + Paragraph(format_prepaid_units(item).replace("\n", "
"), table_cell_style), + Paragraph(f"{item.get('consumedUnits', 0):,}", table_cell_style) + ]) - for item in sku_list: - sku_table_data.append([ - Paragraph(escape_text(item.get("skuPartNumber", "UNKNOWN_SKU")), table_cell_bold), - Paragraph(format_prepaid_units(item).replace("\n", "
"), table_cell_style), - Paragraph(f"{item.get('consumedUnits', 0):,}", table_cell_style) - ]) - - sku_table = Table(sku_table_data, colWidths=[220, 160, 120]) - sku_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 6), - ('BOTTOMPADDING', (0, 0), (-1, -1), 6), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(sku_table) - except Exception as e: - logger.exception("Failed to format Subscribed SKUs section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Subscribed SKUs section: {escape_text(str(e))}", section_err_style)) + sku_table = Table(sku_table_data, colWidths=[220, 160, 120]) + sku_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 6), + ('BOTTOMPADDING', (0, 0), (-1, -1), 6), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(sku_table) story.append(Spacer(1, 15)) # ========================================================================= # SECTION 1b: DIRECTORY SUMMARY - try: - # ========================================================================= - story.append(Paragraph("1b. Directory Summary", h1_style)) - - dir_data = data.get("directory", {}) - if not dir_data: - story.append(Paragraph("No directory telemetry data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + # ========================================================================= + story.append(Paragraph("1b. Directory Summary", h1_style)) + + dir_data = data.get("directory", {}) + if not dir_data: + story.append(Paragraph("No directory telemetry data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + # 1. Organization Details Table + story.append(Paragraph("Organization Details", h2_style)) + story.append(Paragraph("This section outlines general configuration parameters, tenant types, sync properties, and active services/plans configured for the tenant organization.", body_style)) + story.append(Spacer(1, 8)) + + org_list = dir_data.get("organization", []) + if not org_list: + story.append(Paragraph("No organization configuration details were available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) else: - # 1. Organization Details Table - story.append(Paragraph("Organization Details", h2_style)) - story.append(Paragraph("This section outlines general configuration parameters, tenant types, sync properties, and active services/plans configured for the tenant organization.", body_style)) - story.append(Spacer(1, 8)) - - org_list = dir_data.get("organization", []) - if not org_list: - story.append(Paragraph("No organization configuration details were available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - org = org_list[0] if org_list else {} - plans = org.get("provisionedPlans", []) - plan_services = sorted(list(set(plan.get("service") for plan in plans if plan.get("service")))) - plan_services_str = ", ".join(plan_services) if plan_services else "null" - - def format_pdf_val(v): - return "null" if v is None else str(v) - - org_table_data = [ - [Paragraph("Property", table_cell_header), Paragraph("Value", table_cell_header)], - [Paragraph("displayName", table_cell_bold), Paragraph(escape_text(format_pdf_val(org.get("displayName"))), table_cell_style)], - [Paragraph("isMultipleDataLocationsForServicesEnabled", table_cell_bold), Paragraph(escape_text(format_pdf_val(org.get("isMultipleDataLocationsForServicesEnabled"))), table_cell_style)], - [Paragraph("onPremisesSyncEnabled", table_cell_bold), Paragraph(escape_text(format_pdf_val(org.get("onPremisesSyncEnabled"))), table_cell_style)], - [Paragraph("onPremisesLastSyncDateTime", table_cell_bold), Paragraph(escape_text(format_pdf_val(org.get("onPremisesLastSyncDateTime"))), table_cell_style)], - [Paragraph("partnerTenantType", table_cell_bold), Paragraph(escape_text(format_pdf_val(org.get("partnerTenantType"))), table_cell_style)], - [Paragraph("tenantType", table_cell_bold), Paragraph(escape_text(format_pdf_val(org.get("tenantType"))), table_cell_style)], - [Paragraph("provisionedPlans", table_cell_bold), Paragraph(escape_text(plan_services_str), table_cell_style)] - ] - - org_table = Table(org_table_data, colWidths=[200, 300]) - org_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(org_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* If OnPremisesSyncEnabled returns True, on-premises Active Directory is a primary source of truth. If it returns Null or False, the directory is cloud-managed or driven by a 3rd-party application.", body_style)) - - story.append(Spacer(1, 15)) - - # 2. Domains Table - story.append(Paragraph("Domains", h2_style)) - story.append(Paragraph("This section displays the configured internet domains associated with the tenant and their verified statuses.", body_style)) - story.append(Spacer(1, 8)) - - domains = dir_data.get("domains", []) - if not domains: - story.append(Paragraph("No domains discovered in directory scope.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - domains_table_data = [[ - Paragraph("Domain ID", table_cell_header), - Paragraph("Auth Type", table_cell_header), - Paragraph("Admin Managed", table_cell_header), - Paragraph("Default", table_cell_header), - Paragraph("Verified", table_cell_header), - Paragraph("Supported Services", table_cell_header), - Paragraph("Federation Display Name", table_cell_header), - Paragraph("Federation Issuer URI", table_cell_header) - ]] - for item in domains: - auth_type = item.get("authenticationType", "N/A") or "N/A" - admin_managed = "Yes" if item.get("isAdminManaged") else "No" - is_default = "Yes" if item.get("isDefault") else "No" - is_verified = "Yes" if item.get("isVerified") else "No" - services = item.get("supportedServices", []) - services_str = ", ".join(services) if services else "-" - fed_idp = item.get("federationDisplayName") or "-" - fed_issuer = item.get("federationIssuerUri") or "-" - - domains_table_data.append([ - Paragraph(escape_text(item.get("id", "-")), table_cell_bold), - Paragraph(escape_text(auth_type), table_cell_style), - Paragraph(escape_text(admin_managed), table_cell_style), - Paragraph(escape_text(is_default), table_cell_style), - Paragraph(escape_text(is_verified), table_cell_style), - Paragraph(escape_text(services_str), table_cell_style), - Paragraph(escape_text(fed_idp), table_cell_style), - Paragraph(escape_text(fed_issuer), table_cell_style) - ]) - domains_table = Table(domains_table_data, colWidths=[80, 50, 45, 35, 35, 95, 80, 80]) - domains_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(domains_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* AuthenticationType=Managed indicates a cloud managed domain where Microsoft Entra ID performs user authentication. Federated indicates authentication is federated with an identity provider (eg. AD FS, Okta etc.)", body_style)) - - story.append(Spacer(1, 15)) - - # 2b. User Creation/Deletion Logs Table - story.append(Paragraph("User Creation/Deletion Logs", h2_style)) - story.append(Paragraph("This section displays directory audit logs for user creation and deletion events, indicating who initiated the action and the associated details.", body_style)) - story.append(Spacer(1, 8)) - - user_creation_logs = dir_data.get("user_creation_logs", []) - if not user_creation_logs: - story.append(Paragraph("No user creation or deletion audit logs discovered.", body_style)) - elif user_creation_logs[0].get("activity") == "ERROR": - err_msg = user_creation_logs[0].get("initiatedBy") - story.append(Paragraph(f"Error: {escape_text(err_msg)}", ParagraphStyle('ErrTxtUserCreation', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - user_creation_table_data = [[ - Paragraph("Activity", table_cell_header), - Paragraph("Initiated By", table_cell_header) - ]] - - for log in user_creation_logs: - activity = log.get("activity") or "-" - init_by = log.get("initiatedBy") or "-" - - user_creation_table_data.append([ - Paragraph(escape_text(activity), table_cell_bold), - Paragraph(escape_text(init_by), table_cell_style) - ]) - - user_creation_table = Table(user_creation_table_data, colWidths=[124, 380]) - user_creation_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(user_creation_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* Based on sampled data collected from audit logs.", body_style)) + org = org_list[0] if org_list else {} + plans = org.get("provisionedPlans", []) + plan_services = sorted(list(set(plan.get("service") for plan in plans if plan.get("service")))) + plan_services_str = ", ".join(plan_services) if plan_services else "null" - story.append(Spacer(1, 15)) - - # 2c. Provisioning Logs Table - story.append(Paragraph("Provisioning Logs", h2_style)) - story.append(Paragraph("This section displays directory provisioning audit logs, indicating identity synchronization actions, status info, and target details.", body_style)) - story.append(Spacer(1, 8)) - - provisioning_logs = dir_data.get("provisioning_logs", []) - if not provisioning_logs: - story.append(Paragraph("No provisioning audit logs discovered.", body_style)) - elif provisioning_logs[0].get("initiatedBy") == "ERROR": - err_msg = provisioning_logs[0].get("provisioningAction") - story.append(Paragraph(f"Error: {escape_text(err_msg)}", ParagraphStyle('ErrTxtProvisioning', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - prov_table_data = [[ - Paragraph("Initiated By", small_table_cell_header), - Paragraph("Action", small_table_cell_header), - Paragraph("Steps", small_table_cell_header), - Paragraph("Service Principal", small_table_cell_header), - Paragraph("Source System", small_table_cell_header), - Paragraph("Target System", small_table_cell_header), - Paragraph("Tenant ID", small_table_cell_header), - Paragraph("Status Info", small_table_cell_header) - ]] + def format_pdf_val(v): + return "null" if v is None else str(v) - for log in provisioning_logs: - initiatedBy = log.get("initiatedBy") or "-" - action = log.get("provisioningAction") or "-" - steps = log.get("provisioningSteps") or "-" - sp = log.get("servicePrincipal") or "-" - src = log.get("sourceSystem") or "-" - tgt = log.get("targetSystem") or "-" - tenant = log.get("tenantId") or "-" - statusInfo = log.get("provisioningStatusInfo") or "-" - - prov_table_data.append([ - Paragraph(escape_text(initiatedBy), small_table_cell_style), - Paragraph(escape_text(action), small_table_cell_bold), - Paragraph(escape_text(steps), small_table_cell_style), - Paragraph(escape_text(sp), small_table_cell_style), - Paragraph(escape_text(src), small_table_cell_style), - Paragraph(escape_text(tgt), small_table_cell_style), - Paragraph(escape_text(tenant), small_table_cell_style), - Paragraph(escape_text(statusInfo), small_table_cell_style) - ]) - - prov_table = Table(prov_table_data, colWidths=[64, 60, 80, 60, 40, 40, 40, 120]) - prov_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'TOP'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('LEFTPADDING', (0, 0), (-1, -1), 3), - ('RIGHTPADDING', (0, 0), (-1, -1), 3), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(prov_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* Based on sampled data collected from audit logs.", body_style)) - - story.append(Spacer(1, 15)) - - # 3. Groups & Users Table - story.append(Paragraph("Groups & Users", h2_style)) - story.append(Paragraph("This section displays counts of different directory user and group categories configured in Microsoft Entra ID.", body_style)) - story.append(Spacer(1, 8)) - - group_counts = dir_data.get("group_counts", {}) - user_counts = dir_data.get("user_counts", {}) - - dir_table_data = [[ - Paragraph("Category", table_cell_header), - Paragraph("Count", table_cell_header) - ]] - - rows_spec = [ - # User statistics - ("Total Users", user_counts.get("total", 0), True), - ("Enabled Users", user_counts.get("enabled", 0), False), - ("Disabled Users", user_counts.get("disabled", 0), False), - ("Member Users", user_counts.get("member", 0), False), - ("Guest Users", user_counts.get("guest", 0), False), - # Spacing placeholder - ("", "", False), - # Group statistics - ("Total Groups", group_counts.get("total", 0), True), - ("Microsoft 365 Groups (Unified)", group_counts.get("m365", 0), False), - ("Security Groups (Static, non-mail-enabled)", group_counts.get("security", 0), False), - ("Mail-enabled Security Groups", group_counts.get("mail_enabled_security", 0), False), - ("Distribution Groups", group_counts.get("distribution", 0), False), - ("Dynamic Groups (Dynamic Membership)", group_counts.get("dynamic", 0), False) + org_table_data = [ + [Paragraph("Property", table_cell_header), Paragraph("Value", table_cell_header)], + [Paragraph("displayName", table_cell_bold), Paragraph(format_pdf_val(org.get("displayName")), table_cell_style)], + [Paragraph("isMultipleDataLocationsForServicesEnabled", table_cell_bold), Paragraph(format_pdf_val(org.get("isMultipleDataLocationsForServicesEnabled")), table_cell_style)], + [Paragraph("onPremisesSyncEnabled", table_cell_bold), Paragraph(format_pdf_val(org.get("onPremisesSyncEnabled")), table_cell_style)], + [Paragraph("onPremisesLastSyncDateTime", table_cell_bold), Paragraph(format_pdf_val(org.get("onPremisesLastSyncDateTime")), table_cell_style)], + [Paragraph("partnerTenantType", table_cell_bold), Paragraph(format_pdf_val(org.get("partnerTenantType")), table_cell_style)], + [Paragraph("tenantType", table_cell_bold), Paragraph(format_pdf_val(org.get("tenantType")), table_cell_style)], + [Paragraph("provisionedPlans", table_cell_bold), Paragraph(plan_services_str, table_cell_style)] ] - - row_backgrounds = [] - for idx, item in enumerate(rows_spec, start=1): - metric_name, val, is_bold = item - if metric_name == "": - dir_table_data.append([Paragraph("", table_cell_style), Paragraph("", table_cell_style)]) - # Divider background color - row_backgrounds.append((idx, colors.HexColor("#CBD5E1"))) - continue - - cell_bold = table_cell_bold if is_bold else table_cell_style - dir_table_data.append([ - Paragraph(escape_text(str(metric_name)), cell_bold), - Paragraph(f"{val:,}", table_cell_style) - ]) - # Alternate row background - bg = colors.white if idx % 2 == 0 else colors.HexColor("#F8FAFC") - row_backgrounds.append((idx, bg)) - - dir_table_style = [ + + org_table = Table(org_table_data, colWidths=[200, 300]) + org_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), primary_color), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('TOPPADDING', (0, 0), (-1, -1), 5), ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ] - - for r_idx, bg_color in row_backgrounds: - dir_table_style.append(('BACKGROUND', (0, r_idx), (-1, r_idx), bg_color)) - - dir_table = Table(dir_table_data, colWidths=[300, 200]) - dir_table.setStyle(TableStyle(dir_table_style)) - story.append(dir_table) - except Exception as e: - logger.exception("Failed to format Directory Summary section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Directory Summary section: {escape_text(str(e))}", section_err_style)) - story.append(Spacer(1, 15)) - - - # ========================================================================= - # SECTION 2: APP USAGE SUMMARY - # ========================================================================= - story.append(Paragraph("2. App Usage Summary", h1_style)) - story.append(Paragraph("Active Users Usage", h2_style)) - story.append(Paragraph("A breakdown of user activity across major Microsoft 365 services over the last 30, 90, and 180 days, representing actual adoption levels.", body_style)) - story.append(Spacer(1, 8)) - - try: - o365_usage = data.get("o365_usage", []) - if not o365_usage: - story.append(Paragraph("No active user usage report data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - usage_table_data = [[ - Paragraph("Service / License", table_cell_header), - Paragraph("30 Days Active", table_cell_header), - Paragraph("90 Days Active", table_cell_header), - Paragraph("180 Days Active", table_cell_header) - ]] - - for row in o365_usage: - usage_table_data.append([ - Paragraph(escape_text(str(str(row[0]))), table_cell_bold), - Paragraph(f"{row[1]:,}", table_cell_style), - Paragraph(f"{row[2]:,}", table_cell_style), - Paragraph(f"{row[3]:,}", table_cell_style) - ]) - - usage_table = Table(usage_table_data, colWidths=[200, 100, 100, 100]) - usage_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 6), - ('BOTTOMPADDING', (0, 0), (-1, -1), 6), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), ('GRID', (0, 0), (-1, -1), 0.5, outline_color), ])) - story.append(usage_table) - - # 30-Day Trend Chart - Generated on the fly - o365_trend = data.get("o365_trend", {}) - if o365_trend and o365_trend.get("dates"): - try: - chart_bytes = generate_trend_chart_bytes(o365_trend) - if chart_bytes: - story.append(Spacer(1, 15)) - story.append(Paragraph("O365 30-Day Active User Trend", h2_style)) - chart_flow = Image(chart_bytes, width=450, height=210) - story.append(chart_flow) - except Exception as chart_ex: - print(f"Failed to generate active user trend chart for PDF: {chart_ex}") - - story.append(PageBreak()) - - # M365 Apps Usage - story.append(Paragraph("Microsoft 365 Client Applications Usage (180 Days)", h2_style)) - story.append(Paragraph("Displays the unique counts of active users on client applications (Outlook, Word, Excel, PowerPoint, OneNote, Teams) segmented by system platforms.", body_style)) - story.append(Spacer(1, 8)) - - m365_apps = data.get("m365_apps", []) - if not m365_apps: - story.append(Paragraph("No client application telemetry data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - # Format 4-columns layout matching the UI table - app_table_data = [[ - Paragraph("App / Platform", table_cell_header), - Paragraph("Active Users", table_cell_header), - Paragraph("App / Platform", table_cell_header), - Paragraph("Active Users", table_cell_header) - ]] - - half = (len(m365_apps) + 1) // 2 - left_col = m365_apps[:half] - right_col = m365_apps[half:] - - for r_idx in range(half): - l_name = left_col[r_idx][0] if r_idx < len(left_col) else "" - l_val = f"{left_col[r_idx][1]:,}" if r_idx < len(left_col) else "" - r_name = right_col[r_idx][0] if r_idx < len(right_col) else "" - r_val = f"{right_col[r_idx][1]:,}" if r_idx < len(right_col) else "" - - app_table_data.append([ - Paragraph(escape_text(str(l_name)), table_cell_bold if l_name else table_cell_style), - Paragraph(escape_text(l_val), table_cell_style), - Paragraph(escape_text(str(r_name)), table_cell_bold if r_name else table_cell_style), - Paragraph(escape_text(r_val), table_cell_style) - ]) - - app_table = Table(app_table_data, colWidths=[150, 100, 150, 100]) - app_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(app_table) - - except Exception as e: - logger.exception("Failed to format App Usage Summary section in PDF") - story.append(Paragraph(f"⚠️ Error formatting App Usage Summary section: {escape_text(str(e))}", section_err_style)) - story.append(Spacer(1, 15)) - - # ========================================================================= - # SECTION 3: WORKLOAD STORAGE & METRICS - # ========================================================================= - story.append(Paragraph("3. Workload Storage & Environmental Telemetry", h1_style)) - story.append(Paragraph("A compiled summary of storage consumption, item counts, and device statistics across Exchange Online, SharePoint, and OneDrive workloads.", body_style)) - story.append(Spacer(1, 8)) - - # 3.1 Exchange Mailbox & Calendar Telemetry - story.append(Paragraph("Exchange Online Mailbox & Resource Configurations", h2_style)) - - try: - mailbox = data.get("mailbox", {}) - calendar = data.get("calendar", {}) - - # Let's check for warning/errors - pw_warn = [] - if mailbox.get("powershell_error"): - pw_warn.append(f"Mailbox: {mailbox['powershell_error']}") - if calendar.get("powershell_error"): - pw_warn.append(f"Calendar: {calendar['powershell_error']}") - - if pw_warn: - story.append(Paragraph(escape_text(f"⚠️ Warning: PowerShell metrics are restricted or incomplete ({'; '.join(pw_warn)})"), ParagraphStyle('WarnTxt', parent=body_style, textColor=colors.HexColor("#D97706"), fontName="Helvetica-Bold"))) + story.append(org_table) story.append(Spacer(1, 4)) + story.append(Paragraph("* If OnPremisesSyncEnabled returns True, on-premises Active Directory is a primary source of truth. If it returns Null or False, the directory is cloud-managed or driven by a 3rd-party application.", body_style)) - workload_table_data = [[ - Paragraph("Metric / Telemetry Property", table_cell_header), - Paragraph("Exchange Mailbox Value", table_cell_header) - ]] - - # Compile rows from mailbox & calendar - exchange_rows = [ - ("Total Mailboxes Analyzed", f"{mailbox.get('total_mailboxes', 0):,} Mailboxes"), - ("Total Size of All Mailboxes", mailbox.get("total_storage_formatted", "0.00 Bytes")), - ("Average Mailbox Size", mailbox.get("average_mailbox_size_formatted", "0.00 Bytes")), - ("Total Emails Volume", f"{mailbox.get('total_emails', 0):,} Emails"), - ("Average Emails per Mailbox", f"{mailbox.get('average_emails', 0.0):,.0f} Emails"), - ] - - s_count = mailbox.get('shared_mailboxes_count') - s_count_str = f"{s_count:,} Shared Mailboxes" if s_count is not None else "Error/Unavailable" - s_size_str = mailbox.get("shared_mailboxes_total_formatted", "Error/Unavailable") - - pf_count = mailbox.get('public_folders_count') - pf_count_str = f"{pf_count:,} Public Folders" if pf_count is not None else "Error/Unavailable" - - mail_pf_count = mailbox.get('mail_public_folders_count') - mail_pf_count_str = f"{mail_pf_count:,} Public Folders" if mail_pf_count is not None else "Error/Unavailable" - - pf_size_str = mailbox.get("public_folders_total_formatted", "Error/Unavailable") - - exchange_rows += [ - ("Shared Mailboxes Count", s_count_str), - ("Total Shared Mailbox Size", s_size_str), - ("Public Folders Count", pf_count_str), - ("Mail-enabled Public Folders Count", mail_pf_count_str), - ("Total Public Folder Size", pf_size_str), - ] - - # Add calendar properties - reserve_val = calendar.get("CanUsersReserveRooms") - if isinstance(reserve_val, bool): reserve_val = "Yes" if reserve_val else "No" - - att_val = calendar.get("CanShareAttachments") - if isinstance(att_val, bool): att_val = "Yes" if att_val else "No" - - exchange_rows += [ - ("Room & Resource Reservation Enabled", str(reserve_val)), - ("Calendar Resource Pools (Rooms/Devices)", calendar.get("NamingConvention") or "None found"), - ("Calendar Attachment Link Permissions", str(att_val)), - ] - - for label, val in exchange_rows: - workload_table_data.append([ - Paragraph(escape_text(label), table_cell_bold), - Paragraph(escape_text(val), table_cell_style) - ]) - - ex_table = Table(workload_table_data, colWidths=[260, 240]) - ex_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(ex_table) story.append(Spacer(1, 15)) - - # 3.1b Integrated Apps - story.append(Paragraph("Integrated Apps", h2_style)) - story.append(Paragraph("This section lists all organization-wide apps deployed in Exchange Online by administrators and their enabled status.", body_style)) + + # 2. Domains Table + story.append(Paragraph("Domains", h2_style)) + story.append(Paragraph("This section displays the configured internet domains associated with the tenant and their verified statuses.", body_style)) story.append(Spacer(1, 8)) - org_apps = calendar.get("OrganizationApps", []) - apps_error = calendar.get("AppsError") - - if apps_error: - story.append(Paragraph(f"Error querying organization apps: {escape_text(apps_error)}", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - elif not org_apps: - story.append(Paragraph("No organization-wide apps found in Exchange Online.", body_style)) + domains = dir_data.get("domains", []) + if not domains: + story.append(Paragraph("No domains discovered in directory scope.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) else: - apps_table_data = [[ - Paragraph("App Display Name", table_cell_header), - Paragraph("Status", table_cell_header), - Paragraph("App Display Name", table_cell_header), - Paragraph("Status", table_cell_header) + domains_table_data = [[ + Paragraph("Domain ID", table_cell_header), + Paragraph("Auth Type", table_cell_header), + Paragraph("Admin Managed", table_cell_header), + Paragraph("Default", table_cell_header), + Paragraph("Verified", table_cell_header), + Paragraph("Supported Services", table_cell_header), + Paragraph("Federation Display Name", table_cell_header), + Paragraph("Federation Issuer URI", table_cell_header) ]] - half = (len(org_apps) + 1) // 2 - left_col = org_apps[:half] - right_col = org_apps[half:] - - for r_idx in range(half): - row_items = [] - if r_idx < len(left_col): - app = left_col[r_idx] - enabled_str = "Enabled" if app.get("Enabled") else "Disabled" - row_items.extend([escape_text(app.get("DisplayName", "-")), enabled_str]) - else: - row_items.extend(["", ""]) - - if r_idx < len(right_col): - app = right_col[r_idx] - enabled_str = "Enabled" if app.get("Enabled") else "Disabled" - row_items.extend([escape_text(app.get("DisplayName", "-")), enabled_str]) - else: - row_items.extend(["", ""]) - - apps_table_data.append([ - Paragraph(escape_text(str(row_items[0])), table_cell_bold if row_items[0] else table_cell_style), - Paragraph(escape_text(str(row_items[1])), table_cell_style), - Paragraph(escape_text(str(row_items[2])), table_cell_bold if row_items[2] else table_cell_style), - Paragraph(escape_text(str(row_items[3])), table_cell_style) - ]) + for item in domains: + auth_type = item.get("authenticationType", "N/A") or "N/A" + admin_managed = "Yes" if item.get("isAdminManaged") else "No" + is_default = "Yes" if item.get("isDefault") else "No" + is_verified = "Yes" if item.get("isVerified") else "No" + services = item.get("supportedServices", []) + services_str = ", ".join(services) if services else "-" + fed_idp = item.get("federationDisplayName") or "-" + fed_issuer = item.get("federationIssuerUri") or "-" - apps_table = Table(apps_table_data, colWidths=[180, 70, 180, 70]) - apps_table.setStyle(TableStyle([ + domains_table_data.append([ + Paragraph(item.get("id", "-"), table_cell_bold), + Paragraph(auth_type, table_cell_style), + Paragraph(admin_managed, table_cell_style), + Paragraph(is_default, table_cell_style), + Paragraph(is_verified, table_cell_style), + Paragraph(services_str, table_cell_style), + Paragraph(fed_idp, table_cell_style), + Paragraph(fed_issuer, table_cell_style) + ]) + domains_table = Table(domains_table_data, colWidths=[80, 50, 45, 35, 35, 95, 80, 80]) + domains_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), primary_color), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), @@ -969,37 +510,40 @@ def format_pdf_val(v): ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), ('GRID', (0, 0), (-1, -1), 0.5, outline_color), ])) - story.append(apps_table) + story.append(domains_table) + story.append(Spacer(1, 4)) + story.append(Paragraph("* AuthenticationType=Managed indicates a cloud managed domain where Microsoft Entra ID performs user authentication. Federated indicates authentication is federated with an identity provider (eg. AD FS, Okta etc.)", body_style)) story.append(Spacer(1, 15)) - - # 3.1c Exchange Connectors - story.append(Paragraph("Exchange Connectors", h2_style)) - story.append(Paragraph("This section displays mail routing connectors configured in Exchange Online.", body_style)) + + # 2b. User Creation/Deletion Logs Table + story.append(Paragraph("User Creation/Deletion Logs", h2_style)) + story.append(Paragraph("This section displays directory audit logs for user creation and deletion events, indicating who initiated the action and the associated details.", body_style)) story.append(Spacer(1, 8)) - connectors = data.get("exchange_connectors", []) - if not connectors: - story.append(Paragraph("No Exchange connectors configured.", body_style)) + user_creation_logs = dir_data.get("user_creation_logs", []) + if not user_creation_logs: + story.append(Paragraph("No user creation or deletion audit logs discovered.", body_style)) + elif user_creation_logs[0].get("activity") == "ERROR": + err_msg = user_creation_logs[0].get("initiatedBy") + story.append(Paragraph(f"Error: {err_msg}", ParagraphStyle('ErrTxtUserCreation', parent=body_style, textColor=colors.HexColor("#DC2626")))) else: - conn_table_data = [[ - Paragraph("Direction", table_cell_header), - Paragraph("Connector Name", table_cell_header), - Paragraph("Status", table_cell_header), - Paragraph("Domains", table_cell_header), - Paragraph("Routing Config", table_cell_header) + user_creation_table_data = [[ + Paragraph("Activity", table_cell_header), + Paragraph("Initiated By", table_cell_header) ]] - for conn in connectors: - routing_txt = escape_text(conn.get("Routing", "-")).replace("\n", "
") - conn_table_data.append([ - Paragraph(escape_text(conn.get("Direction", "-")), table_cell_style), - Paragraph(escape_text(conn.get("Name", "-")), table_cell_bold), - Paragraph(escape_text(conn.get("Status", "-")), table_cell_style), - Paragraph(escape_text(conn.get("Domains", "-")), table_cell_style), - Paragraph(escape_text(routing_txt), table_cell_style) + + for log in user_creation_logs: + activity = log.get("activity") or "-" + init_by = log.get("initiatedBy") or "-" + + user_creation_table_data.append([ + Paragraph(activity, table_cell_bold), + Paragraph(init_by, table_cell_style) ]) - conn_table = Table(conn_table_data, colWidths=[70, 120, 60, 100, 154]) - conn_table.setStyle(TableStyle([ + + user_creation_table = Table(user_creation_table_data, colWidths=[124, 380]) + user_creation_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), primary_color), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), @@ -1008,127 +552,170 @@ def format_pdf_val(v): ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), ('GRID', (0, 0), (-1, -1), 0.5, outline_color), ])) - story.append(conn_table) + story.append(user_creation_table) + story.append(Spacer(1, 4)) + story.append(Paragraph("* Based on sampled data collected from audit logs.", body_style)) + story.append(Spacer(1, 15)) - - # 3.1d Email Clients & PST Files - story.append(Paragraph("Email Clients & PST Environment", h2_style)) - story.append(Paragraph("Overview of email client adoption and PST configuration.", body_style)) + + # 2c. Provisioning Logs Table + story.append(Paragraph("Provisioning Logs", h2_style)) + story.append(Paragraph("This section displays directory provisioning audit logs, indicating identity synchronization actions, status info, and target details.", body_style)) story.append(Spacer(1, 8)) - email_clients = data.get("email_clients", {}) - if not email_clients: - story.append(Paragraph("No email client telemetry data available.", body_style)) + provisioning_logs = dir_data.get("provisioning_logs", []) + if not provisioning_logs: + story.append(Paragraph("No provisioning audit logs discovered.", body_style)) + elif provisioning_logs[0].get("initiatedBy") == "ERROR": + err_msg = provisioning_logs[0].get("provisioningAction") + story.append(Paragraph(f"Error: {err_msg}", ParagraphStyle('ErrTxtProvisioning', parent=body_style, textColor=colors.HexColor("#DC2626")))) else: - ec_table_data = [[ - Paragraph("Client Type", table_cell_header), - Paragraph("Active Users", table_cell_header) + prov_table_data = [[ + Paragraph("Initiated By", small_table_cell_header), + Paragraph("Action", small_table_cell_header), + Paragraph("Steps", small_table_cell_header), + Paragraph("Service Principal", small_table_cell_header), + Paragraph("Source System", small_table_cell_header), + Paragraph("Target System", small_table_cell_header), + Paragraph("Tenant ID", small_table_cell_header), + Paragraph("Status Info", small_table_cell_header) ]] - rows = [ - ("Outlook on the Web (OWA)", email_clients.get("client_browser", 0)), - ("Outlook for Windows", email_clients.get("client_win_outlook", 0)), - ("Outlook for Mac", email_clients.get("client_mac_outlook", 0)), - ("Apple Mail (macOS)", email_clients.get("client_mac_mail", 0)), - ("Other Desktop Apps", email_clients.get("client_desktop_other", 0)), - ("Outlook Mobile (iOS/Android)", email_clients.get("client_mobile_outlook", 0)), - ("Native / Other Mobile Apps", email_clients.get("client_mobile_other", 0)), - ("IMAP4 Apps", email_clients.get("client_imap", 0)), - ("POP3 Apps", email_clients.get("client_pop", 0)), - ("SMTP Apps", email_clients.get("client_smtp", 0)) - ] - for label, val in rows: - ec_table_data.append([ - Paragraph(escape_text(label), table_cell_bold), - Paragraph(f"{val:,} Users" if 'SMTP' not in label else f"{val:,} Accounts", table_cell_style) + + for log in provisioning_logs: + initiatedBy = log.get("initiatedBy") or "-" + action = log.get("provisioningAction") or "-" + steps = log.get("provisioningSteps") or "-" + sp = log.get("servicePrincipal") or "-" + src = log.get("sourceSystem") or "-" + tgt = log.get("targetSystem") or "-" + tenant = log.get("tenantId") or "-" + statusInfo = log.get("provisioningStatusInfo") or "-" + + prov_table_data.append([ + Paragraph(initiatedBy, small_table_cell_style), + Paragraph(action, small_table_cell_bold), + Paragraph(steps, small_table_cell_style), + Paragraph(sp, small_table_cell_style), + Paragraph(src, small_table_cell_style), + Paragraph(tgt, small_table_cell_style), + Paragraph(tenant, small_table_cell_style), + Paragraph(statusInfo, small_table_cell_style) ]) - ec_table = Table(ec_table_data, colWidths=[250, 150]) - ec_table.setStyle(TableStyle([ + + prov_table = Table(prov_table_data, colWidths=[64, 60, 80, 60, 40, 40, 40, 120]) + prov_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), primary_color), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), ('GRID', (0, 0), (-1, -1), 0.5, outline_color), ])) - story.append(ec_table) - story.append(Spacer(1, 10)) + story.append(prov_table) + story.append(Spacer(1, 4)) + story.append(Paragraph("* Based on sampled data collected from audit logs.", body_style)) + + story.append(Spacer(1, 15)) + + # 3. Groups & Users Table + story.append(Paragraph("Groups & Users", h2_style)) + story.append(Paragraph("This section displays counts of different directory user and group categories configured in Microsoft Entra ID.", body_style)) + story.append(Spacer(1, 8)) + + group_counts = dir_data.get("group_counts", {}) + user_counts = dir_data.get("user_counts", {}) + + dir_table_data = [[ + Paragraph("Category", table_cell_header), + Paragraph("Count", table_cell_header) + ]] - pst_files = data.get("pst_files", {}) - if pst_files: - pst_table_data = [[Paragraph("PST Metric", table_cell_header), Paragraph("Value", table_cell_header)]] - pst_cloud = pst_files.get("pst_cloud_data", {}) - cloud_count = 0 - cloud_bytes = 0 - if pst_cloud and "value" in pst_cloud: - for item in pst_cloud.get("value", []): - for hc in item.get("hitsContainers", []): - cloud_count += hc.get("total", 0) - for hit in hc.get("hits", []): - cloud_bytes += int(hit.get("resource", {}).get("size", 0)) - - def format_bytes(size): - for unit in ['Bytes', 'KB', 'MB', 'GB', 'TB']: - if size < 1024.0: return f"{size:.2f} {unit}" - size /= 1024.0 - return f"{size:.2f} PB" - - cloud_size_str = f" ({format_bytes(cloud_bytes)})" if cloud_bytes > 0 else "" - cloud_str = f"{cloud_count:,} Files{cloud_size_str}" if cloud_count > 0 else "None Detected" - - pst_table_data.append([Paragraph("Cloud (SharePoint & OneDrive)", table_cell_bold), Paragraph(escape_text(cloud_str), table_cell_style)]) + rows_spec = [ + # User statistics + ("Total Users", user_counts.get("total", 0), True), + ("Enabled Users", user_counts.get("enabled", 0), False), + ("Disabled Users", user_counts.get("disabled", 0), False), + ("Member Users", user_counts.get("member", 0), False), + ("Guest Users", user_counts.get("guest", 0), False), + # Spacing placeholder + ("", "", False), + # Group statistics + ("Total Groups", group_counts.get("total", 0), True), + ("Microsoft 365 Groups (Unified)", group_counts.get("m365", 0), False), + ("Security Groups (Static, non-mail-enabled)", group_counts.get("security", 0), False), + ("Mail-enabled Security Groups", group_counts.get("mail_enabled_security", 0), False), + ("Distribution Groups", group_counts.get("distribution", 0), False), + ("Dynamic Groups (Dynamic Membership)", group_counts.get("dynamic", 0), False) + ] + + row_backgrounds = [] + for idx, item in enumerate(rows_spec, start=1): + metric_name, val, is_bold = item + if metric_name == "": + dir_table_data.append([Paragraph("", table_cell_style), Paragraph("", table_cell_style)]) + # Divider background color + row_backgrounds.append((idx, colors.HexColor("#CBD5E1"))) + continue + + cell_bold = table_cell_bold if is_bold else table_cell_style + dir_table_data.append([ + Paragraph(metric_name, cell_bold), + Paragraph(f"{val:,}", table_cell_style) + ]) + # Alternate row background + bg = colors.white if idx % 2 == 0 else colors.HexColor("#F8FAFC") + row_backgrounds.append((idx, bg)) - pst_table = Table(pst_table_data, colWidths=[250, 150]) - pst_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(pst_table) + dir_table_style = [ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ] - except Exception as e: - logger.exception("Failed to format Exchange Online Workload Telemetry section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Exchange Online Workload Telemetry section: {escape_text(str(e))}", section_err_style)) + for r_idx, bg_color in row_backgrounds: + dir_table_style.append(('BACKGROUND', (0, r_idx), (-1, r_idx), bg_color)) + + dir_table = Table(dir_table_data, colWidths=[300, 200]) + dir_table.setStyle(TableStyle(dir_table_style)) + story.append(dir_table) story.append(Spacer(1, 15)) - story.append(PageBreak()) - # 3.2 SharePoint & OneDrive Storage - story.append(Paragraph("SharePoint & OneDrive Environment Telemetry", h2_style)) - story.append(Paragraph("A comparison of file volume, storage consumption, site activity, and active synchronization clients.", body_style)) + + # ========================================================================= + # SECTION 2: APP USAGE SUMMARY + # ========================================================================= + story.append(Paragraph("2. App Usage Summary", h1_style)) + story.append(Paragraph("Active Users Usage", h2_style)) + story.append(Paragraph("A breakdown of user activity across major Microsoft 365 services over the last 30, 90, and 180 days, representing actual adoption levels.", body_style)) story.append(Spacer(1, 8)) - try: - sp = data.get("sharepoint", {}) - od = data.get("onedrive", {}) - - files_table_data = [[ - Paragraph("Metric Property Description", table_cell_header), - Paragraph("SharePoint Sites (180d)", table_cell_header), - Paragraph("OneDrive Personal (180d)", table_cell_header) + o365_usage = data.get("o365_usage", []) + if not o365_usage: + story.append(Paragraph("No active user usage report data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + usage_table_data = [[ + Paragraph("Service / License", table_cell_header), + Paragraph("30 Days Active", table_cell_header), + Paragraph("90 Days Active", table_cell_header), + Paragraph("180 Days Active", table_cell_header) ]] - files_rows = [ - ("Total Scope Count (Sites / Accounts)", f"{sp.get('total_sites', 0):,} Sites", f"{od.get('total_accounts', 0):,} Accounts"), - ("Total Storage Consumed", sp.get("total_storage_formatted", "0.00 Bytes"), od.get("total_storage_formatted", "0.00 Bytes")), - ("Total Stored File Count", f"{sp.get('total_files', 0):,} Files", f"{od.get('total_files', 0):,} Files"), - ("Active Files Count (Active %)", f"{sp.get('active_files', 0):,} ({sp.get('active_files_pct', 0.0):.1f}%)", f"{od.get('active_files', 0):,} ({od.get('active_files_pct', 0.0):.1f}%)"), - ("Users with Sync Client Active", "N/A (SharePoint level)", f"{od.get('sync_users', 0):,} Users ({od.get('sync_users_pct', 0.0):.1f}%)"), - ("Active OneNote Users", "N/A (SharePoint level)", f"{od.get('onenote_users', 0):,} Users"), - ] - - for label, sp_val, od_val in files_rows: - files_table_data.append([ - Paragraph(escape_text(label), table_cell_bold), - Paragraph(escape_text(sp_val), table_cell_style), - Paragraph(escape_text(od_val), table_cell_style) + for row in o365_usage: + usage_table_data.append([ + Paragraph(str(row[0]), table_cell_bold), + Paragraph(f"{row[1]:,}", table_cell_style), + Paragraph(f"{row[2]:,}", table_cell_style), + Paragraph(f"{row[3]:,}", table_cell_style) ]) - files_table = Table(files_table_data, colWidths=[200, 150, 150]) - files_table.setStyle(TableStyle([ + usage_table = Table(usage_table_data, colWidths=[200, 100, 100, 100]) + usage_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), primary_color), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), @@ -1137,237 +724,601 @@ def format_bytes(size): ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), ('GRID', (0, 0), (-1, -1), 0.5, outline_color), ])) - story.append(files_table) - story.append(Spacer(1, 15)) - - sp_data_types = data.get("sharepoint_data_types", {}) - if sp_data_types: - story.append(Paragraph("SharePoint Data Types (Tenant Wide)", h2_style)) - story.append(Paragraph("A global count of major SharePoint components across the tenant.", body_style)) - story.append(Spacer(1, 8)) - - sp_dt_table_data = [[ - Paragraph("Data Type", table_cell_header), - Paragraph("Count", table_cell_header) - ]] - - for k, v in [("Document Libraries", sp_data_types.get("Document Libraries", 0)), - ("Lists", sp_data_types.get("Lists", 0)), - ("Web Pages", sp_data_types.get("Web Pages", 0))]: - sp_dt_table_data.append([ - Paragraph(escape_text(k), table_cell_bold), - Paragraph(f"{v:,}", table_cell_style) - ]) - - sp_dt_table = Table(sp_dt_table_data, colWidths=[250, 250]) - sp_dt_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(sp_dt_table) - story.append(Spacer(1, 15)) - except Exception as e: - logger.exception("Failed to format SharePoint & OneDrive Environment Telemetry section in PDF") - story.append(Paragraph(f"⚠️ Error formatting SharePoint & OneDrive Environment Telemetry section: {escape_text(str(e))}", section_err_style)) + story.append(usage_table) + + # 30-Day Trend Chart - Generated on the fly + o365_trend = data.get("o365_trend", {}) + if o365_trend and o365_trend.get("dates"): + try: + chart_bytes = generate_trend_chart_bytes(o365_trend) + if chart_bytes: + story.append(Spacer(1, 15)) + story.append(Paragraph("O365 30-Day Active User Trend", h2_style)) + chart_flow = Image(chart_bytes, width=450, height=210) + story.append(chart_flow) + except Exception as chart_ex: + print(f"Failed to generate active user trend chart for PDF: {chart_ex}") + + story.append(PageBreak()) - # 3.2b Microsoft Teams Overview - story.append(Paragraph("Microsoft Teams Overview", h2_style)) - story.append(Paragraph("A summary of Microsoft Teams activity, including active users, guests, and meetings organized over the last 180 days.", body_style)) + # M365 Apps Usage + story.append(Paragraph("Microsoft 365 Client Applications Usage (180 Days)", h2_style)) + story.append(Paragraph("Displays the unique counts of active users on client applications (Outlook, Word, Excel, PowerPoint, OneNote, Teams) segmented by system platforms.", body_style)) story.append(Spacer(1, 8)) - try: - msteams_data = data.get("msteams_activity", []) - if not msteams_data: - story.append(Paragraph("No Microsoft Teams activity data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - teams_table_data = [[ - Paragraph("Team Name", table_cell_header), - Paragraph("Last Activity", table_cell_header), - Paragraph("Active Users", table_cell_header), - Paragraph("Guests", table_cell_header), - Paragraph("Meetings", table_cell_header), - Paragraph("Messages", table_cell_header) - ]] - - for row in msteams_data[:20]: - teams_table_data.append([ - Paragraph(escape_text(row.get("Team Name", "-")), table_cell_bold), - Paragraph(escape_text(row.get("Last Activity Date", "-")), table_cell_style), - Paragraph(escape_text(row.get("Active Users", "0")), table_cell_style), - Paragraph(escape_text(row.get("Guests", "0")), table_cell_style), - Paragraph(escape_text(row.get("Meetings Organized", "0")), table_cell_style), - Paragraph(escape_text(row.get("Channel Messages", "0")), table_cell_style) - ]) - - teams_table = Table(teams_table_data, colWidths=[120, 70, 70, 50, 70, 70]) - teams_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(teams_table) - except Exception as e: - logger.exception("Failed to format Microsoft Teams Overview section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Microsoft Teams Overview section: {escape_text(str(e))}", section_err_style)) - story.append(Spacer(1, 15)) + m365_apps = data.get("m365_apps", []) + if not m365_apps: + story.append(Paragraph("No client application telemetry data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + # Format 4-columns layout matching the UI table + app_table_data = [[ + Paragraph("App / Platform", table_cell_header), + Paragraph("Active Users", table_cell_header), + Paragraph("App / Platform", table_cell_header), + Paragraph("Active Users", table_cell_header) + ]] + + half = (len(m365_apps) + 1) // 2 + left_col = m365_apps[:half] + right_col = m365_apps[half:] + + for r_idx in range(half): + l_name = left_col[r_idx][0] if r_idx < len(left_col) else "" + l_val = f"{left_col[r_idx][1]:,}" if r_idx < len(left_col) else "" + r_name = right_col[r_idx][0] if r_idx < len(right_col) else "" + r_val = f"{right_col[r_idx][1]:,}" if r_idx < len(right_col) else "" + + app_table_data.append([ + Paragraph(l_name, table_cell_bold if l_name else table_cell_style), + Paragraph(l_val, table_cell_style), + Paragraph(r_name, table_cell_bold if r_name else table_cell_style), + Paragraph(r_val, table_cell_style) + ]) + + app_table = Table(app_table_data, colWidths=[150, 100, 150, 100]) + app_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(app_table) + + story.append(Spacer(1, 15)) - # 3.3 Microsoft Entra Data - story.append(Paragraph("Microsoft Entra Data", h2_style)) - story.append(Paragraph("This section outlines application sign-in metrics and authentication methods configuration summaries.", body_style)) + # ========================================================================= + # SECTION 3: WORKLOAD STORAGE & METRICS + # ========================================================================= + story.append(Paragraph("3. Workload Storage & Environmental Telemetry", h1_style)) + story.append(Paragraph("A compiled summary of storage consumption, item counts, and device statistics across Exchange Online, SharePoint, and OneDrive workloads.", body_style)) story.append(Spacer(1, 8)) - try: - entra_data = data.get("devices_apps", {}) + # 3.1 Exchange Mailbox & Calendar Telemetry + story.append(Paragraph("Exchange Online Mailbox & Resource Configurations", h2_style)) + + mailbox = data.get("mailbox", {}) + calendar = data.get("calendar", {}) + + # Let's check for warning/errors + pw_warn = [] + if mailbox.get("powershell_error"): + pw_warn.append(f"Mailbox: {mailbox['powershell_error']}") + if calendar.get("powershell_error"): + pw_warn.append(f"Calendar: {calendar['powershell_error']}") - # 3.3.2 App Sign Ins - story.append(Paragraph("App Sign Ins", body_style)) + if pw_warn: + story.append(Paragraph(f"⚠️ Warning: PowerShell metrics are restricted or incomplete ({'; '.join(pw_warn)})", ParagraphStyle('WarnTxt', parent=body_style, textColor=colors.HexColor("#D97706"), fontName="Helvetica-Bold"))) story.append(Spacer(1, 4)) - app_signins = entra_data.get("app_signins", []) - if not app_signins: - story.append(Paragraph("No Azure AD application sign-in logs were discovered or permission restricted.", ParagraphStyle('ErrTxtAppSignins', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - app_signins_table_data = [[ - Paragraph("App Name", table_cell_header), - Paragraph("Successful Sign Ins", table_cell_header) - ]] - - for app, success in app_signins: - app_signins_table_data.append([ - Paragraph(escape_text(app), table_cell_bold), - Paragraph(escape_text(success), table_cell_style) - ]) + workload_table_data = [[ + Paragraph("Metric / Telemetry Property", table_cell_header), + Paragraph("Exchange Mailbox Value", table_cell_header) + ]] + + # Compile rows from mailbox & calendar + exchange_rows = [ + ("Total Mailboxes Analyzed", f"{mailbox.get('total_mailboxes', 0):,} Mailboxes"), + ("Total Size of All Mailboxes", mailbox.get("total_storage_formatted", "0.00 Bytes")), + ("Average Mailbox Size", mailbox.get("average_mailbox_size_formatted", "0.00 Bytes")), + ("Total Emails Volume", f"{mailbox.get('total_emails', 0):,} Emails"), + ("Average Emails per Mailbox", f"{mailbox.get('average_emails', 0.0):,.0f} Emails"), + ] + + s_count = mailbox.get('shared_mailboxes_count') + s_count_str = f"{s_count:,} Shared Mailboxes" if s_count is not None else "Error/Unavailable" + s_size_str = mailbox.get("shared_mailboxes_total_formatted", "Error/Unavailable") + + pf_count = mailbox.get('public_folders_count') + pf_count_str = f"{pf_count:,} Public Folders" if pf_count is not None else "Error/Unavailable" + + mail_pf_count = mailbox.get('mail_public_folders_count') + mail_pf_count_str = f"{mail_pf_count:,} Public Folders" if mail_pf_count is not None else "Error/Unavailable" + + pf_size_str = mailbox.get("public_folders_total_formatted", "Error/Unavailable") + + exchange_rows += [ + ("Shared Mailboxes Count", s_count_str), + ("Total Shared Mailbox Size", s_size_str), + ("Public Folders Count", pf_count_str), + ("Mail-enabled Public Folders Count", mail_pf_count_str), + ("Total Public Folder Size", pf_size_str), + ] + + # Add calendar properties + reserve_val = calendar.get("CanUsersReserveRooms") + if isinstance(reserve_val, bool): reserve_val = "Yes" if reserve_val else "No" + + att_val = calendar.get("CanShareAttachments") + if isinstance(att_val, bool): att_val = "Yes" if att_val else "No" + + exchange_rows += [ + ("Room & Resource Reservation Enabled", str(reserve_val)), + ("Calendar Resource Pools (Rooms/Devices)", calendar.get("NamingConvention") or "None found"), + ("Calendar Attachment Link Permissions", str(att_val)), + ] + + for label, val in exchange_rows: + workload_table_data.append([ + Paragraph(label, table_cell_bold), + Paragraph(val, table_cell_style) + ]) + + ex_table = Table(workload_table_data, colWidths=[260, 240]) + ex_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(ex_table) + story.append(Spacer(1, 15)) + + # 3.1b Integrated Apps + story.append(Paragraph("Integrated Apps", h2_style)) + story.append(Paragraph("This section lists all organization-wide apps deployed in Exchange Online by administrators and their enabled status.", body_style)) + story.append(Spacer(1, 8)) + + org_apps = calendar.get("OrganizationApps", []) + apps_error = calendar.get("AppsError") + + if apps_error: + story.append(Paragraph(f"Error querying organization apps: {apps_error}", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + elif not org_apps: + story.append(Paragraph("No organization-wide apps found in Exchange Online.", body_style)) + else: + apps_table_data = [[ + Paragraph("App Display Name", table_cell_header), + Paragraph("Status", table_cell_header), + Paragraph("App Display Name", table_cell_header), + Paragraph("Status", table_cell_header) + ]] + half = (len(org_apps) + 1) // 2 + left_col = org_apps[:half] + right_col = org_apps[half:] + + for r_idx in range(half): + row_items = [] + if r_idx < len(left_col): + app = left_col[r_idx] + enabled_str = "Enabled" if app.get("Enabled") else "Disabled" + row_items.extend([app.get("DisplayName", "-"), enabled_str]) + else: + row_items.extend(["", ""]) - app_signins_table = Table(app_signins_table_data, colWidths=[250, 254]) - app_signins_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(app_signins_table) + if r_idx < len(right_col): + app = right_col[r_idx] + enabled_str = "Enabled" if app.get("Enabled") else "Disabled" + row_items.extend([app.get("DisplayName", "-"), enabled_str]) + else: + row_items.extend(["", ""]) + + apps_table_data.append([ + Paragraph(row_items[0], table_cell_bold if row_items[0] else table_cell_style), + Paragraph(row_items[1], table_cell_style), + Paragraph(row_items[2], table_cell_bold if row_items[2] else table_cell_style), + Paragraph(row_items[3], table_cell_style) + ]) - story.append(Spacer(1, 15)) + apps_table = Table(apps_table_data, colWidths=[180, 70, 180, 70]) + apps_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(apps_table) - # 3.3.3 App Registrations - story.append(Paragraph("App Registrations", body_style)) - story.append(Spacer(1, 4)) + story.append(Spacer(1, 15)) + + # 3.1c Exchange Connectors + story.append(Paragraph("Exchange Connectors", h2_style)) + story.append(Paragraph("This section displays mail routing connectors configured in Exchange Online.", body_style)) + story.append(Spacer(1, 8)) + + connectors = data.get("exchange_connectors", []) + if not connectors: + story.append(Paragraph("No Exchange connectors configured.", body_style)) + else: + conn_table_data = [[ + Paragraph("Direction", table_cell_header), + Paragraph("Connector Name", table_cell_header), + Paragraph("Status", table_cell_header), + Paragraph("Domains", table_cell_header), + Paragraph("Routing Config", table_cell_header) + ]] + for conn in connectors: + routing_txt = conn.get("Routing", "-").replace("\n", "
") + conn_table_data.append([ + Paragraph(conn.get("Direction", "-"), table_cell_style), + Paragraph(conn.get("Name", "-"), table_cell_bold), + Paragraph(conn.get("Status", "-"), table_cell_style), + Paragraph(conn.get("Domains", "-"), table_cell_style), + Paragraph(routing_txt, table_cell_style) + ]) + conn_table = Table(conn_table_data, colWidths=[70, 120, 60, 100, 154]) + conn_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(conn_table) + story.append(Spacer(1, 15)) + + # 3.1d Email Clients & PST Files + story.append(Paragraph("Email Clients & PST Environment", h2_style)) + story.append(Paragraph("Overview of email client adoption and PST configuration.", body_style)) + story.append(Spacer(1, 8)) + + email_clients = data.get("email_clients", {}) + if not email_clients: + story.append(Paragraph("No email client telemetry data available.", body_style)) + else: + ec_table_data = [[ + Paragraph("Client Type", table_cell_header), + Paragraph("Active Users", table_cell_header) + ]] + rows = [ + ("Outlook on the Web (OWA)", email_clients.get("client_browser", 0)), + ("Outlook for Windows", email_clients.get("client_win_outlook", 0)), + ("Outlook for Mac", email_clients.get("client_mac_outlook", 0)), + ("Apple Mail (macOS)", email_clients.get("client_mac_mail", 0)), + ("Other Desktop Apps", email_clients.get("client_desktop_other", 0)), + ("Outlook Mobile (iOS/Android)", email_clients.get("client_mobile_outlook", 0)), + ("Native / Other Mobile Apps", email_clients.get("client_mobile_other", 0)), + ("IMAP4 Apps", email_clients.get("client_imap", 0)), + ("POP3 Apps", email_clients.get("client_pop", 0)), + ("SMTP Apps", email_clients.get("client_smtp", 0)) + ] + for label, val in rows: + ec_table_data.append([ + Paragraph(label, table_cell_bold), + Paragraph(f"{val:,} Users" if 'SMTP' not in label else f"{val:,} Accounts", table_cell_style) + ]) + ec_table = Table(ec_table_data, colWidths=[250, 150]) + ec_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(ec_table) + story.append(Spacer(1, 10)) + + pst_files = data.get("pst_files", {}) + if pst_files: + pst_table_data = [[Paragraph("PST Metric", table_cell_header), Paragraph("Value", table_cell_header)]] + pst_cloud = pst_files.get("pst_cloud_data", {}) + cloud_count = 0 + cloud_bytes = 0 + if pst_cloud and "value" in pst_cloud: + for item in pst_cloud.get("value", []): + for hc in item.get("hitsContainers", []): + cloud_count += hc.get("total", 0) + for hit in hc.get("hits", []): + cloud_bytes += int(hit.get("resource", {}).get("size", 0)) + + def format_bytes(size): + for unit in ['Bytes', 'KB', 'MB', 'GB', 'TB']: + if size < 1024.0: return f"{size:.2f} {unit}" + size /= 1024.0 + return f"{size:.2f} PB" + + cloud_size_str = f" ({format_bytes(cloud_bytes)})" if cloud_bytes > 0 else "" + cloud_str = f"{cloud_count:,} Files{cloud_size_str}" if cloud_count > 0 else "None Detected" + + pst_table_data.append([Paragraph("Cloud (SharePoint & OneDrive)", table_cell_bold), Paragraph(cloud_str, table_cell_style)]) - app_registrations = entra_data.get("app_registrations", []) - if not app_registrations: - story.append(Paragraph("No Azure AD app registrations were discovered or permission restricted.", ParagraphStyle('ErrTxtAppRegs', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - app_regs_table_data = [[ - Paragraph("App Name", table_cell_header), - Paragraph("Application ID", table_cell_header), - Paragraph("Created Date", table_cell_header), - Paragraph("Sign In Audience", table_cell_header), - Paragraph("Credentials", table_cell_header) - ]] + pst_table = Table(pst_table_data, colWidths=[250, 150]) + pst_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(pst_table) + + story.append(Spacer(1, 15)) + story.append(PageBreak()) + + # 3.2 SharePoint & OneDrive Storage + story.append(Paragraph("SharePoint & OneDrive Environment Telemetry", h2_style)) + story.append(Paragraph("A comparison of file volume, storage consumption, site activity, and active synchronization clients.", body_style)) + story.append(Spacer(1, 8)) + + sp = data.get("sharepoint", {}) + od = data.get("onedrive", {}) + + files_table_data = [[ + Paragraph("Metric Property Description", table_cell_header), + Paragraph("SharePoint Sites (180d)", table_cell_header), + Paragraph("OneDrive Personal (180d)", table_cell_header) + ]] + + files_rows = [ + ("Total Scope Count (Sites / Accounts)", f"{sp.get('total_sites', 0):,} Sites", f"{od.get('total_accounts', 0):,} Accounts"), + ("Total Storage Consumed", sp.get("total_storage_formatted", "0.00 Bytes"), od.get("total_storage_formatted", "0.00 Bytes")), + ("Total Stored File Count", f"{sp.get('total_files', 0):,} Files", f"{od.get('total_files', 0):,} Files"), + ("Active Files Count (Active %)", f"{sp.get('active_files', 0):,} ({sp.get('active_files_pct', 0.0):.1f}%)", f"{od.get('active_files', 0):,} ({od.get('active_files_pct', 0.0):.1f}%)"), + ("Users with Sync Client Active", "N/A (SharePoint level)", f"{od.get('sync_users', 0):,} Users ({od.get('sync_users_pct', 0.0):.1f}%)"), + ("Active OneNote Users", "N/A (SharePoint level)", f"{od.get('onenote_users', 0):,} Users"), + ] + + for label, sp_val, od_val in files_rows: + files_table_data.append([ + Paragraph(label, table_cell_bold), + Paragraph(sp_val, table_cell_style), + Paragraph(od_val, table_cell_style) + ]) + + files_table = Table(files_table_data, colWidths=[200, 150, 150]) + files_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 6), + ('BOTTOMPADDING', (0, 0), (-1, -1), 6), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(files_table) + story.append(Spacer(1, 15)) + + sp_data_types = data.get("sharepoint_data_types", {}) + if sp_data_types: + story.append(Paragraph("SharePoint Data Types (Tenant Wide)", h2_style)) + story.append(Paragraph("A global count of major SharePoint components across the tenant.", body_style)) + story.append(Spacer(1, 8)) + + sp_dt_table_data = [[ + Paragraph("Data Type", table_cell_header), + Paragraph("Count", table_cell_header) + ]] + + for k, v in [("Document Libraries", sp_data_types.get("Document Libraries", 0)), + ("Lists", sp_data_types.get("Lists", 0)), + ("Web Pages", sp_data_types.get("Web Pages", 0))]: + sp_dt_table_data.append([ + Paragraph(k, table_cell_bold), + Paragraph(f"{v:,}", table_cell_style) + ]) - for name, app_id, created, audience, creds in app_registrations: - formatted_created = created[:10] if created else "" - app_regs_table_data.append([ - Paragraph(escape_text(name), table_cell_bold), - Paragraph(escape_text(app_id), table_cell_style), - Paragraph(escape_text(formatted_created), table_cell_style), - Paragraph(escape_text(audience), table_cell_style), - Paragraph(escape_text(creds), table_cell_style) - ]) - - app_regs_table = Table(app_regs_table_data, colWidths=[120, 110, 70, 110, 94]) - app_regs_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(app_regs_table) + sp_dt_table = Table(sp_dt_table_data, colWidths=[250, 250]) + sp_dt_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(sp_dt_table) + story.append(Spacer(1, 15)) + + # 3.2b Microsoft Teams Overview + story.append(Paragraph("Microsoft Teams Overview", h2_style)) + story.append(Paragraph("A summary of Microsoft Teams activity, including active users, guests, and meetings organized over the last 180 days.", body_style)) + story.append(Spacer(1, 8)) + + msteams_data = data.get("msteams_activity", []) + if not msteams_data: + story.append(Paragraph("No Microsoft Teams activity data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + teams_table_data = [[ + Paragraph("Team Name", table_cell_header), + Paragraph("Last Activity", table_cell_header), + Paragraph("Active Users", table_cell_header), + Paragraph("Guests", table_cell_header), + Paragraph("Meetings", table_cell_header), + Paragraph("Messages", table_cell_header) + ]] + + for row in msteams_data[:20]: + teams_table_data.append([ + Paragraph(row.get("Team Name", "-"), table_cell_bold), + Paragraph(row.get("Last Activity Date", "-"), table_cell_style), + Paragraph(row.get("Active Users", "0"), table_cell_style), + Paragraph(row.get("Guests", "0"), table_cell_style), + Paragraph(row.get("Meetings Organized", "0"), table_cell_style), + Paragraph(row.get("Channel Messages", "0"), table_cell_style) + ]) + teams_table = Table(teams_table_data, colWidths=[120, 70, 70, 50, 70, 70]) + teams_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(teams_table) story.append(Spacer(1, 15)) + + # 3.3 Microsoft Entra Data + story.append(Paragraph("Microsoft Entra Data", h2_style)) + story.append(Paragraph("This section outlines application sign-in metrics and authentication methods configuration summaries.", body_style)) + story.append(Spacer(1, 8)) + + entra_data = data.get("devices_apps", {}) + + # 3.3.2 App Sign Ins + story.append(Paragraph("App Sign Ins", body_style)) + story.append(Spacer(1, 4)) + + app_signins = entra_data.get("app_signins", []) + if not app_signins: + story.append(Paragraph("No Azure AD application sign-in logs were discovered or permission restricted.", ParagraphStyle('ErrTxtAppSignins', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + app_signins_table_data = [[ + Paragraph("App Name", table_cell_header), + Paragraph("Successful Sign Ins", table_cell_header) + ]] - # 3.3.4 User Sign-Ins - story.append(Paragraph("User Sign-Ins", body_style)) - story.append(Spacer(1, 4)) + for app, success in app_signins: + app_signins_table_data.append([ + Paragraph(app, table_cell_bold), + Paragraph(success, table_cell_style) + ]) + + app_signins_table = Table(app_signins_table_data, colWidths=[250, 254]) + app_signins_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(app_signins_table) - user_signins = entra_data.get("user_signins", {}) - if not user_signins or (not user_signins.get("apps") and not user_signins.get("os") and not user_signins.get("browsers")): - story.append(Paragraph("No successful user sign-in logs were discovered or permission restricted.", ParagraphStyle('ErrTxtUserSignins', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - user_signins_table_data = [ - [Paragraph("Sign-in Attribute", table_cell_header), Paragraph("Successful Unique Values", table_cell_header)], - [Paragraph("App Display Names", table_cell_bold), Paragraph(escape_text(", ".join(user_signins.get("apps", [])) or "None"), table_cell_style)], - [Paragraph("Operating Systems", table_cell_bold), Paragraph(escape_text(", ".join(user_signins.get("os", [])) or "None"), table_cell_style)], - [Paragraph("Browsers", table_cell_bold), Paragraph(escape_text(", ".join(user_signins.get("browsers", [])) or "None"), table_cell_style)] - ] - user_table = Table(user_signins_table_data, colWidths=[150, 354]) - user_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(user_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* Based on sample data collected from signins.", body_style)) + story.append(Spacer(1, 15)) + + # 3.3.3 App Registrations + story.append(Paragraph("App Registrations", body_style)) + story.append(Spacer(1, 4)) + + app_registrations = entra_data.get("app_registrations", []) + if not app_registrations: + story.append(Paragraph("No Azure AD app registrations were discovered or permission restricted.", ParagraphStyle('ErrTxtAppRegs', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + app_regs_table_data = [[ + Paragraph("App Name", table_cell_header), + Paragraph("Application ID", table_cell_header), + Paragraph("Created Date", table_cell_header), + Paragraph("Sign In Audience", table_cell_header), + Paragraph("Credentials", table_cell_header) + ]] + + for name, app_id, created, audience, creds in app_registrations: + formatted_created = created[:10] if created else "" + app_regs_table_data.append([ + Paragraph(name, table_cell_bold), + Paragraph(app_id, table_cell_style), + Paragraph(formatted_created, table_cell_style), + Paragraph(audience, table_cell_style), + Paragraph(creds, table_cell_style) + ]) - story.append(Spacer(1, 15)) + app_regs_table = Table(app_regs_table_data, colWidths=[120, 110, 70, 110, 94]) + app_regs_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(app_regs_table) - # 3.3.5 Authentication Methods - story.append(Paragraph("Authentication Methods", body_style)) + story.append(Spacer(1, 15)) + + # 3.3.4 User Sign-Ins + story.append(Paragraph("User Sign-Ins", body_style)) + story.append(Spacer(1, 4)) + + user_signins = entra_data.get("user_signins", {}) + if not user_signins or (not user_signins.get("apps") and not user_signins.get("os") and not user_signins.get("browsers")): + story.append(Paragraph("No successful user sign-in logs were discovered or permission restricted.", ParagraphStyle('ErrTxtUserSignins', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + user_signins_table_data = [ + [Paragraph("Sign-in Attribute", table_cell_header), Paragraph("Successful Unique Values", table_cell_header)], + [Paragraph("App Display Names", table_cell_bold), Paragraph(", ".join(user_signins.get("apps", [])) or "None", table_cell_style)], + [Paragraph("Operating Systems", table_cell_bold), Paragraph(", ".join(user_signins.get("os", [])) or "None", table_cell_style)], + [Paragraph("Browsers", table_cell_bold), Paragraph(", ".join(user_signins.get("browsers", [])) or "None", table_cell_style)] + ] + user_table = Table(user_signins_table_data, colWidths=[150, 354]) + user_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(user_table) story.append(Spacer(1, 4)) + story.append(Paragraph("* Based on sample data collected from signins.", body_style)) - auth_methods = entra_data.get("auth_methods", []) - if not auth_methods: - story.append(Paragraph("No authentication methods logs were discovered or permission restricted.", ParagraphStyle('ErrTxtAuthMethods', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - auth_period = entra_data.get("auth_methods_period", "D7") - period_str = auth_period - if period_str.startswith("D"): - period_str = f"{period_str[1:]} days" - auth_table_data = [[ - Paragraph("Authentication Method", table_cell_header), - Paragraph(f"Success Activity Count ({period_str})", table_cell_header) - ]] + story.append(Spacer(1, 15)) + + # 3.3.5 Authentication Methods + story.append(Paragraph("Authentication Methods", body_style)) + story.append(Spacer(1, 4)) + + auth_methods = entra_data.get("auth_methods", []) + if not auth_methods: + story.append(Paragraph("No authentication methods logs were discovered or permission restricted.", ParagraphStyle('ErrTxtAuthMethods', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + auth_period = entra_data.get("auth_methods_period", "D7") + period_str = auth_period + if period_str.startswith("D"): + period_str = f"{period_str[1:]} days" + auth_table_data = [[ + Paragraph("Authentication Method", table_cell_header), + Paragraph(f"Success Activity Count ({period_str})", table_cell_header) + ]] + + for method, activity in auth_methods: + auth_table_data.append([ + Paragraph(method, table_cell_bold), + Paragraph(activity, table_cell_style) + ]) - for method, activity in auth_methods: - auth_table_data.append([ - Paragraph(escape_text(method), table_cell_bold), - Paragraph(escape_text(activity), table_cell_style) - ]) - - auth_table = Table(auth_table_data, colWidths=[250, 254]) - auth_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(auth_table) - except Exception as e: - logger.exception("Failed to format Microsoft Entra Data section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Microsoft Entra Data section: {escape_text(str(e))}", section_err_style)) + auth_table = Table(auth_table_data, colWidths=[250, 254]) + auth_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(auth_table) story.append(Spacer(1, 15)) @@ -1376,327 +1327,323 @@ def format_bytes(size): story.append(Paragraph("This section contains mobile applications and device configuration policies managed and distributed via Microsoft Intune.", body_style)) story.append(Spacer(1, 8)) - try: - intune_data = data.get("intune", {}) - mobile_apps = intune_data.get("mobile_apps", []) - table_rows = intune_data.get("table_rows", []) + intune_data = data.get("intune", {}) + mobile_apps = intune_data.get("mobile_apps", []) + table_rows = intune_data.get("table_rows", []) + + # Render Mobile Apps + story.append(Paragraph("Managed Mobile Apps:", body_style)) + apps_text = ", ".join(mobile_apps) if mobile_apps else "No mobile apps discovered or permission restricted." + story.append(Paragraph(apps_text, body_style)) + story.append(Spacer(1, 10)) + + # Render Managed Devices Table (Top 10) + story.append(Paragraph("Managed Devices (Top 10)", body_style)) + story.append(Spacer(1, 6)) + + managed_devices = intune_data.get("managed_devices", []) + if not managed_devices: + story.append(Paragraph("No managed devices were discovered or permission restricted.", ParagraphStyle('ErrTxtMngDev', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + dev_table_data = [[ + Paragraph("User ID", table_cell_header), + Paragraph("Device Name", table_cell_header), + Paragraph("OS", table_cell_header), + Paragraph("Agent", table_cell_header), + Paragraph("State", table_cell_header), + Paragraph("Model", table_cell_header), + Paragraph("Manufacturer", table_cell_header) + ]] - # Render Mobile Apps - story.append(Paragraph("Managed Mobile Apps:", body_style)) - apps_text = ", ".join(mobile_apps) if mobile_apps else "No mobile apps discovered or permission restricted." - story.append(Paragraph(escape_text(apps_text), body_style)) + for dev in managed_devices[:10]: + dev_table_data.append([ + Paragraph(dev.get("userId", "N/A"), table_cell_bold), + Paragraph(dev.get("deviceName", "N/A"), table_cell_style), + Paragraph(dev.get("operatingSystem", "N/A"), table_cell_style), + Paragraph(dev.get("managementAgent", "unknown"), table_cell_style), + Paragraph(dev.get("deviceRegistrationState", "unknown"), table_cell_style), + Paragraph(dev.get("model", "N/A"), table_cell_style), + Paragraph(dev.get("manufacturer", "N/A"), table_cell_style) + ]) + + dev_table = Table(dev_table_data, colWidths=[90, 80, 60, 70, 70, 64, 70]) + dev_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(dev_table) story.append(Spacer(1, 10)) + + # Render VC Devices Table (Top 10) + story.append(Paragraph("Video Conferencing (VC) Devices (Top 10)", body_style)) + story.append(Spacer(1, 6)) + + vc_devices = intune_data.get("vc_devices", []) + if not vc_devices: + story.append(Paragraph("No Video Conferencing (VC) devices were discovered or matched against room mailboxes.", ParagraphStyle('ErrTxtVCDev', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + vc_table_data = [[ + Paragraph("User ID", table_cell_header), + Paragraph("Device Name", table_cell_header), + Paragraph("OS", table_cell_header), + Paragraph("Agent", table_cell_header), + Paragraph("State", table_cell_header), + Paragraph("Model", table_cell_header), + Paragraph("Manufacturer", table_cell_header) + ]] - # Render Managed Devices Table (Top 10) - story.append(Paragraph("Managed Devices (Top 10)", body_style)) - story.append(Spacer(1, 6)) + for dev in vc_devices[:10]: + vc_table_data.append([ + Paragraph(dev.get("userId", "N/A"), table_cell_bold), + Paragraph(dev.get("deviceName", "N/A"), table_cell_style), + Paragraph(dev.get("operatingSystem", "N/A"), table_cell_style), + Paragraph(dev.get("managementAgent", "unknown"), table_cell_style), + Paragraph(dev.get("deviceRegistrationState", "unknown"), table_cell_style), + Paragraph(dev.get("model", "N/A"), table_cell_style), + Paragraph(dev.get("manufacturer", "N/A"), table_cell_style) + ]) + + vc_table = Table(vc_table_data, colWidths=[90, 80, 60, 70, 70, 64, 70]) + vc_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(vc_table) + story.append(Spacer(1, 10)) + + # Render Device Configurations Table + story.append(Paragraph("Device Configurations", body_style)) + story.append(Spacer(1, 6)) + + if not table_rows: + story.append(Paragraph("No device configuration policies were discovered or permission restricted.", ParagraphStyle('ErrTxtIntune', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + intune_table_data = [[ + Paragraph("Platform", table_cell_header), + Paragraph("Policy Type", table_cell_header), + Paragraph("Number of Policies", table_cell_header) + ]] - managed_devices = intune_data.get("managed_devices", []) - if not managed_devices: - story.append(Paragraph("No managed devices were discovered or permission restricted.", ParagraphStyle('ErrTxtMngDev', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - dev_table_data = [[ - Paragraph("User ID", table_cell_header), - Paragraph("Device Name", table_cell_header), - Paragraph("OS", table_cell_header), - Paragraph("Agent", table_cell_header), - Paragraph("State", table_cell_header), - Paragraph("Model", table_cell_header), - Paragraph("Manufacturer", table_cell_header) - ]] + for platform, p_type, count in table_rows: + intune_table_data.append([ + Paragraph(platform, table_cell_bold), + Paragraph(p_type, table_cell_style), + Paragraph(count, table_cell_style) + ]) - for dev in managed_devices[:10]: - dev_table_data.append([ - Paragraph(escape_text(dev.get("userId", "N/A")), table_cell_bold), - Paragraph(escape_text(dev.get("deviceName", "N/A")), table_cell_style), - Paragraph(escape_text(dev.get("operatingSystem", "N/A")), table_cell_style), - Paragraph(escape_text(dev.get("managementAgent", "unknown")), table_cell_style), - Paragraph(escape_text(dev.get("deviceRegistrationState", "unknown")), table_cell_style), - Paragraph(escape_text(dev.get("model", "N/A")), table_cell_style), - Paragraph(escape_text(dev.get("manufacturer", "N/A")), table_cell_style) - ]) - - dev_table = Table(dev_table_data, colWidths=[90, 80, 60, 70, 70, 64, 70]) - dev_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(dev_table) - story.append(Spacer(1, 10)) - - # Render VC Devices Table (Top 10) - story.append(Paragraph("Video Conferencing (VC) Devices (Top 10)", body_style)) - story.append(Spacer(1, 6)) - - vc_devices = intune_data.get("vc_devices", []) - if not vc_devices: - story.append(Paragraph("No Video Conferencing (VC) devices were discovered or matched against room mailboxes.", ParagraphStyle('ErrTxtVCDev', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - vc_table_data = [[ - Paragraph("User ID", table_cell_header), - Paragraph("Device Name", table_cell_header), - Paragraph("OS", table_cell_header), - Paragraph("Agent", table_cell_header), - Paragraph("State", table_cell_header), - Paragraph("Model", table_cell_header), - Paragraph("Manufacturer", table_cell_header) - ]] - - for dev in vc_devices[:10]: - vc_table_data.append([ - Paragraph(escape_text(dev.get("userId", "N/A")), table_cell_bold), - Paragraph(escape_text(dev.get("deviceName", "N/A")), table_cell_style), - Paragraph(escape_text(dev.get("operatingSystem", "N/A")), table_cell_style), - Paragraph(escape_text(dev.get("managementAgent", "unknown")), table_cell_style), - Paragraph(escape_text(dev.get("deviceRegistrationState", "unknown")), table_cell_style), - Paragraph(escape_text(dev.get("model", "N/A")), table_cell_style), - Paragraph(escape_text(dev.get("manufacturer", "N/A")), table_cell_style) - ]) - - vc_table = Table(vc_table_data, colWidths=[90, 80, 60, 70, 70, 64, 70]) - vc_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(vc_table) - story.append(Spacer(1, 10)) - - # Render Device Configurations Table - story.append(Paragraph("Device Configurations", body_style)) - story.append(Spacer(1, 6)) - - if not table_rows: - story.append(Paragraph("No device configuration policies were discovered or permission restricted.", ParagraphStyle('ErrTxtIntune', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - intune_table_data = [[ - Paragraph("Platform", table_cell_header), - Paragraph("Policy Type", table_cell_header), - Paragraph("Number of Policies", table_cell_header) - ]] - - for platform, p_type, count in table_rows: - intune_table_data.append([ - Paragraph(escape_text(platform), table_cell_bold), - Paragraph(escape_text(p_type), table_cell_style), - Paragraph(escape_text(count), table_cell_style) - ]) - - intune_table = Table(intune_table_data, colWidths=[150, 200, 154]) - intune_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(intune_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* Based on sample data collected from Intune.", body_style)) - story.append(Spacer(1, 10)) - - # Render Mobile Device Compliance Policies Section - story.append(Paragraph("Mobile Device Compliance Policies", body_style)) - story.append(Spacer(1, 6)) - - # 1. Android Devices Table - story.append(Paragraph("Android Devices (Top 10)", body_style)) - story.append(Spacer(1, 4)) - android_compliance = intune_data.get("android_compliance", []) - if not android_compliance: - story.append(Paragraph("No Android device compliance policies were discovered or permission restricted.", ParagraphStyle('ErrTxtAndCompliance', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - compliance_table_data = [[ - Paragraph("Display Name", table_cell_header), - Paragraph("Description", table_cell_header), - Paragraph("Created Time", table_cell_header), - Paragraph("Last Modified", table_cell_header), - Paragraph("Version", table_cell_header) - ]] - for policy in android_compliance[:10]: - compliance_table_data.append([ - Paragraph(escape_text(policy.get("displayName", "N/A")), table_cell_bold), - Paragraph(escape_text(policy.get("description", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("createdDateTime", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("lastModifiedDateTime", "N/A")), table_cell_style), - Paragraph(escape_text(str(policy.get("version", 0))), table_cell_style) - ]) - compliance_table = Table(compliance_table_data, colWidths=[120, 150, 100, 100, 34]) - compliance_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(compliance_table) - story.append(Spacer(1, 10)) - - # 2. iOS Devices Table - story.append(Paragraph("iOS Devices (Top 10)", body_style)) - story.append(Spacer(1, 4)) - ios_compliance = intune_data.get("ios_compliance", []) - if not ios_compliance: - story.append(Paragraph("No iOS device compliance policies were discovered or permission restricted.", ParagraphStyle('ErrTxtIosCompliance', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - compliance_table_data = [[ - Paragraph("Display Name", table_cell_header), - Paragraph("Description", table_cell_header), - Paragraph("Created Time", table_cell_header), - Paragraph("Last Modified", table_cell_header), - Paragraph("Version", table_cell_header) - ]] - for policy in ios_compliance[:10]: - compliance_table_data.append([ - Paragraph(escape_text(policy.get("displayName", "N/A")), table_cell_bold), - Paragraph(escape_text(policy.get("description", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("createdDateTime", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("lastModifiedDateTime", "N/A")), table_cell_style), - Paragraph(escape_text(str(policy.get("version", 0))), table_cell_style) - ]) - compliance_table = Table(compliance_table_data, colWidths=[120, 150, 100, 100, 34]) - compliance_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(compliance_table) - story.append(Spacer(1, 10)) - - # Render Mobile BYOD Configurations (Top 10) - story.append(Paragraph("Mobile BYOD Configurations (Top 10)", body_style)) + intune_table = Table(intune_table_data, colWidths=[150, 200, 154]) + intune_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(intune_table) story.append(Spacer(1, 4)) - byod_configs = intune_data.get("byod_configs", []) - if not byod_configs: - story.append(Paragraph("No Mobile BYOD configurations were discovered or permission restricted.", ParagraphStyle('ErrTxtByod', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - byod_table_data = [[ - Paragraph("Display Name", table_cell_header), - Paragraph("Description", table_cell_header), - Paragraph("Priority", table_cell_header), - Paragraph("Last Modified", table_cell_header), - Paragraph("iOS Restrictions", table_cell_header), - Paragraph("Windows Mobile", table_cell_header), - Paragraph("Android Restrictions", table_cell_header) - ]] - for config in byod_configs[:10]: - byod_table_data.append([ - Paragraph(escape_text(config.get("displayName", "N/A")), table_cell_bold), - Paragraph(escape_text(config.get("description", "N/A")), table_cell_style), - Paragraph(escape_text(str(config.get("priority", 0))), table_cell_style), - Paragraph(escape_text(config.get("lastModifiedDateTime", "N/A")), table_cell_style), - Paragraph(escape_text(config.get("iosRestrictions", "N/A")), table_cell_style), - Paragraph(escape_text(config.get("windowsMobileRestrictions", "N/A")), table_cell_style), - Paragraph(escape_text(config.get("androidRestrictions", "N/A")), table_cell_style) - ]) - byod_table = Table(byod_table_data, colWidths=[70, 75, 34, 65, 90, 90, 90]) - byod_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(byod_table) + story.append(Paragraph("* Based on sample data collected from Intune.", body_style)) story.append(Spacer(1, 10)) - - # Render Mobile Device Management Policies (Top 10) - story.append(Paragraph("Mobile Device Management Policies (Top 10)", body_style)) - story.append(Spacer(1, 6)) - mdm_policies = intune_data.get("mdm_policies", []) - if not mdm_policies: - story.append(Paragraph("No Mobile Device Management (MDM) policies were discovered or permission restricted.", ParagraphStyle('ErrTxtMdmPolicies', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - mdm_table_data = [[ - Paragraph("Display Name", table_cell_header), - Paragraph("Description", table_cell_header), - Paragraph("Applies To", table_cell_header), - Paragraph("Discovery URL", table_cell_header), - Paragraph("Terms of Use", table_cell_header), - Paragraph("Compliance", table_cell_header) - ]] - for policy in mdm_policies[:10]: - mdm_table_data.append([ - Paragraph(escape_text(policy.get("displayName", "N/A")), table_cell_bold), - Paragraph(escape_text(policy.get("description", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("appliesTo", "None")), table_cell_style), - Paragraph(escape_text(policy.get("discoveryUrl", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("termsOfUseUrl", "N/A")), table_cell_style), - Paragraph(escape_text(policy.get("complianceUrl", "N/A")), table_cell_style) - ]) - mdm_table = Table(mdm_table_data, colWidths=[90, 100, 64, 86, 86, 86]) - mdm_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(mdm_table) - story.append(Spacer(1, 10)) - - # Render Detected Apps Table (first 10 items) - story.append(Spacer(1, 10)) - story.append(Paragraph("Detected Apps (Top 10 Discovered)", body_style)) - story.append(Spacer(1, 6)) + # Render Mobile Device Compliance Policies Section + story.append(Paragraph("Mobile Device Compliance Policies", body_style)) + story.append(Spacer(1, 6)) + + # 1. Android Devices Table + story.append(Paragraph("Android Devices (Top 10)", body_style)) + story.append(Spacer(1, 4)) + android_compliance = intune_data.get("android_compliance", []) + if not android_compliance: + story.append(Paragraph("No Android device compliance policies were discovered or permission restricted.", ParagraphStyle('ErrTxtAndCompliance', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + compliance_table_data = [[ + Paragraph("Display Name", table_cell_header), + Paragraph("Description", table_cell_header), + Paragraph("Created Time", table_cell_header), + Paragraph("Last Modified", table_cell_header), + Paragraph("Version", table_cell_header) + ]] + for policy in android_compliance[:10]: + compliance_table_data.append([ + Paragraph(policy.get("displayName", "N/A"), table_cell_bold), + Paragraph(policy.get("description", "N/A"), table_cell_style), + Paragraph(policy.get("createdDateTime", "N/A"), table_cell_style), + Paragraph(policy.get("lastModifiedDateTime", "N/A"), table_cell_style), + Paragraph(str(policy.get("version", 0)), table_cell_style) + ]) + compliance_table = Table(compliance_table_data, colWidths=[120, 150, 100, 100, 34]) + compliance_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(compliance_table) + story.append(Spacer(1, 10)) + + # 2. iOS Devices Table + story.append(Paragraph("iOS Devices (Top 10)", body_style)) + story.append(Spacer(1, 4)) + ios_compliance = intune_data.get("ios_compliance", []) + if not ios_compliance: + story.append(Paragraph("No iOS device compliance policies were discovered or permission restricted.", ParagraphStyle('ErrTxtIosCompliance', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + compliance_table_data = [[ + Paragraph("Display Name", table_cell_header), + Paragraph("Description", table_cell_header), + Paragraph("Created Time", table_cell_header), + Paragraph("Last Modified", table_cell_header), + Paragraph("Version", table_cell_header) + ]] + for policy in ios_compliance[:10]: + compliance_table_data.append([ + Paragraph(policy.get("displayName", "N/A"), table_cell_bold), + Paragraph(policy.get("description", "N/A"), table_cell_style), + Paragraph(policy.get("createdDateTime", "N/A"), table_cell_style), + Paragraph(policy.get("lastModifiedDateTime", "N/A"), table_cell_style), + Paragraph(str(policy.get("version", 0)), table_cell_style) + ]) + compliance_table = Table(compliance_table_data, colWidths=[120, 150, 100, 100, 34]) + compliance_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(compliance_table) + story.append(Spacer(1, 10)) + + # Render Mobile BYOD Configurations (Top 10) + story.append(Paragraph("Mobile BYOD Configurations (Top 10)", body_style)) + story.append(Spacer(1, 4)) + byod_configs = intune_data.get("byod_configs", []) + if not byod_configs: + story.append(Paragraph("No Mobile BYOD configurations were discovered or permission restricted.", ParagraphStyle('ErrTxtByod', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + byod_table_data = [[ + Paragraph("Display Name", table_cell_header), + Paragraph("Description", table_cell_header), + Paragraph("Priority", table_cell_header), + Paragraph("Last Modified", table_cell_header), + Paragraph("iOS Restrictions", table_cell_header), + Paragraph("Windows Mobile", table_cell_header), + Paragraph("Android Restrictions", table_cell_header) + ]] + for config in byod_configs[:10]: + byod_table_data.append([ + Paragraph(config.get("displayName", "N/A"), table_cell_bold), + Paragraph(config.get("description", "N/A"), table_cell_style), + Paragraph(str(config.get("priority", 0)), table_cell_style), + Paragraph(config.get("lastModifiedDateTime", "N/A"), table_cell_style), + Paragraph(config.get("iosRestrictions", "N/A"), table_cell_style), + Paragraph(config.get("windowsMobileRestrictions", "N/A"), table_cell_style), + Paragraph(config.get("androidRestrictions", "N/A"), table_cell_style) + ]) + byod_table = Table(byod_table_data, colWidths=[70, 75, 34, 65, 90, 90, 90]) + byod_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(byod_table) + story.append(Spacer(1, 10)) + + # Render Mobile Device Management Policies (Top 10) + story.append(Paragraph("Mobile Device Management Policies (Top 10)", body_style)) + story.append(Spacer(1, 6)) + + mdm_policies = intune_data.get("mdm_policies", []) + if not mdm_policies: + story.append(Paragraph("No Mobile Device Management (MDM) policies were discovered or permission restricted.", ParagraphStyle('ErrTxtMdmPolicies', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + mdm_table_data = [[ + Paragraph("Display Name", table_cell_header), + Paragraph("Description", table_cell_header), + Paragraph("Applies To", table_cell_header), + Paragraph("Discovery URL", table_cell_header), + Paragraph("Terms of Use", table_cell_header), + Paragraph("Compliance", table_cell_header) + ]] + for policy in mdm_policies[:10]: + mdm_table_data.append([ + Paragraph(policy.get("displayName", "N/A"), table_cell_bold), + Paragraph(policy.get("description", "N/A"), table_cell_style), + Paragraph(policy.get("appliesTo", "None"), table_cell_style), + Paragraph(policy.get("discoveryUrl", "N/A"), table_cell_style), + Paragraph(policy.get("termsOfUseUrl", "N/A"), table_cell_style), + Paragraph(policy.get("complianceUrl", "N/A"), table_cell_style) + ]) + mdm_table = Table(mdm_table_data, colWidths=[90, 100, 64, 86, 86, 86]) + mdm_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(mdm_table) + story.append(Spacer(1, 10)) - detected_apps = intune_data.get("detected_apps", []) - if not detected_apps: - story.append(Paragraph("No detected apps were discovered or permission restricted.", ParagraphStyle('ErrTxtDetApps', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - det_table_data = [[ - Paragraph("App Name", table_cell_header), - Paragraph("Version", table_cell_header), - Paragraph("Publisher", table_cell_header), - Paragraph("Platform", table_cell_header) - ]] + # Render Detected Apps Table (first 10 items) + story.append(Spacer(1, 10)) + story.append(Paragraph("Detected Apps (Top 10 Discovered)", body_style)) + story.append(Spacer(1, 6)) + + detected_apps = intune_data.get("detected_apps", []) + if not detected_apps: + story.append(Paragraph("No detected apps were discovered or permission restricted.", ParagraphStyle('ErrTxtDetApps', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + det_table_data = [[ + Paragraph("App Name", table_cell_header), + Paragraph("Version", table_cell_header), + Paragraph("Publisher", table_cell_header), + Paragraph("Platform", table_cell_header) + ]] + + for app in detected_apps[:10]: + det_table_data.append([ + Paragraph(app.get("displayName", "N/A"), table_cell_bold), + Paragraph(app.get("version", "N/A"), table_cell_style), + Paragraph(app.get("publisher", "N/A"), table_cell_style), + Paragraph(app.get("platform", "unknown"), table_cell_style) + ]) - for app in detected_apps[:10]: - det_table_data.append([ - Paragraph(escape_text(app.get("displayName", "N/A")), table_cell_bold), - Paragraph(escape_text(app.get("version", "N/A")), table_cell_style), - Paragraph(escape_text(app.get("publisher", "N/A")), table_cell_style), - Paragraph(escape_text(app.get("platform", "unknown")), table_cell_style) - ]) - - det_table = Table(det_table_data, colWidths=[150, 100, 150, 104]) - det_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(det_table) - story.append(Spacer(1, 4)) - story.append(Paragraph("* Showing top 10 detected apps. The full inventory list of up to 10,000 apps is available in the exported CSV report.", body_style)) - except Exception as e: - logger.exception("Failed to format Microsoft Intune Data section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Microsoft Intune Data section: {escape_text(str(e))}", section_err_style)) + det_table = Table(det_table_data, colWidths=[150, 100, 150, 104]) + det_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(det_table) + story.append(Spacer(1, 4)) + story.append(Paragraph("* Showing top 10 detected apps. The full inventory list of up to 10,000 apps is available in the exported CSV report.", body_style)) story.append(Spacer(1, 15)) @@ -1707,112 +1654,108 @@ def format_bytes(size): story.append(Paragraph("A summary of Entra Global Secure Access filtering policies, Conditional Access exclusions, and Intune Firewall and Proxy configurations.", body_style)) story.append(Spacer(1, 10)) - try: - net_sec = data.get("network_security", {}) - filtering_policies = net_sec.get("filtering_policies", []) - ca_policies = net_sec.get("conditional_access", []) - fw_policies = net_sec.get("firewall_policies", []) - - # 4.1 Filtering Policies (GSA) - story.append(Paragraph("Filtering Policies (Global Secure Access)", h2_style)) - if not filtering_policies: - story.append(Paragraph("No Global Secure Access filtering policies configured or permission restricted.", ParagraphStyle('ErrTxtNetSec', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - table_data = [[ - Paragraph("Policy Name", table_cell_header), - Paragraph("Description", table_cell_header), - Paragraph("Version", table_cell_header), - Paragraph("Action", table_cell_header), - Paragraph("Rules", table_cell_header) - ]] - for item in filtering_policies: - table_data.append([ - Paragraph(escape_text(item.get("name", "N/A")), table_cell_bold), - Paragraph(escape_text(item.get("description", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("version", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("action", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("rules_count", "0")), table_cell_style) - ]) - t = Table(table_data, colWidths=[120, 180, 70, 70, 64]) - t.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(t) - - story.append(Spacer(1, 12)) - - # 4.2 Conditional Access policies - story.append(Paragraph("Conditional Access (Network Exclusions & Scope)", h2_style)) - if not ca_policies: - story.append(Paragraph("No Conditional Access policies configured or permission restricted.", ParagraphStyle('ErrTxtNetSecCA', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - table_data = [[ - Paragraph("Policy Name", table_cell_header), - Paragraph("State", table_cell_header), - Paragraph("Target Users", table_cell_header), - Paragraph("Target Apps", table_cell_header), - Paragraph("Grant Controls", table_cell_header) - ]] - for item in ca_policies: - table_data.append([ - Paragraph(escape_text(item.get("name", "N/A")), table_cell_bold), - Paragraph(escape_text(item.get("state", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("target_users", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("target_apps", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("controls", "N/A")), table_cell_style) - ]) - t = Table(table_data, colWidths=[130, 60, 100, 100, 114]) - t.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(t) - - story.append(Spacer(1, 12)) - - # 4.3 Firewall/Proxy Policies - story.append(Paragraph("Firewall and Proxy Configurations", h2_style)) - if not fw_policies: - story.append(Paragraph("No Firewall or Proxy configurations discovered in Intune policies.", ParagraphStyle('ErrTxtNetSecFW', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - table_data = [[ - Paragraph("Configuration Name", table_cell_header), - Paragraph("Policy Type", table_cell_header), - Paragraph("Firewall Status", table_cell_header), - Paragraph("Proxy Status", table_cell_header) - ]] - for item in fw_policies: - table_data.append([ - Paragraph(escape_text(item.get("name", "N/A")), table_cell_bold), - Paragraph(escape_text(item.get("policy_type", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("firewall_status", "N/A")), table_cell_style), - Paragraph(escape_text(item.get("proxy_status", "N/A")), table_cell_style) - ]) - t = Table(table_data, colWidths=[150, 150, 100, 104]) - t.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 4), - ('BOTTOMPADDING', (0, 0), (-1, -1), 4), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(t) - except Exception as e: - logger.exception("Failed to format Network Security section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Network Security section: {escape_text(str(e))}", section_err_style)) + net_sec = data.get("network_security", {}) + filtering_policies = net_sec.get("filtering_policies", []) + ca_policies = net_sec.get("conditional_access", []) + fw_policies = net_sec.get("firewall_policies", []) + + # 4.1 Filtering Policies (GSA) + story.append(Paragraph("Filtering Policies (Global Secure Access)", h2_style)) + if not filtering_policies: + story.append(Paragraph("No Global Secure Access filtering policies configured or permission restricted.", ParagraphStyle('ErrTxtNetSec', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + table_data = [[ + Paragraph("Policy Name", table_cell_header), + Paragraph("Description", table_cell_header), + Paragraph("Version", table_cell_header), + Paragraph("Action", table_cell_header), + Paragraph("Rules", table_cell_header) + ]] + for item in filtering_policies: + table_data.append([ + Paragraph(item.get("name", "N/A"), table_cell_bold), + Paragraph(item.get("description", "N/A"), table_cell_style), + Paragraph(item.get("version", "N/A"), table_cell_style), + Paragraph(item.get("action", "N/A"), table_cell_style), + Paragraph(item.get("rules_count", "0"), table_cell_style) + ]) + t = Table(table_data, colWidths=[120, 180, 70, 70, 64]) + t.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(t) + + story.append(Spacer(1, 12)) + + # 4.2 Conditional Access policies + story.append(Paragraph("Conditional Access (Network Exclusions & Scope)", h2_style)) + if not ca_policies: + story.append(Paragraph("No Conditional Access policies configured or permission restricted.", ParagraphStyle('ErrTxtNetSecCA', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + table_data = [[ + Paragraph("Policy Name", table_cell_header), + Paragraph("State", table_cell_header), + Paragraph("Target Users", table_cell_header), + Paragraph("Target Apps", table_cell_header), + Paragraph("Grant Controls", table_cell_header) + ]] + for item in ca_policies: + table_data.append([ + Paragraph(item.get("name", "N/A"), table_cell_bold), + Paragraph(item.get("state", "N/A"), table_cell_style), + Paragraph(item.get("target_users", "N/A"), table_cell_style), + Paragraph(item.get("target_apps", "N/A"), table_cell_style), + Paragraph(item.get("controls", "N/A"), table_cell_style) + ]) + t = Table(table_data, colWidths=[130, 60, 100, 100, 114]) + t.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(t) + + story.append(Spacer(1, 12)) + + # 4.3 Firewall/Proxy Policies + story.append(Paragraph("Firewall and Proxy Configurations", h2_style)) + if not fw_policies: + story.append(Paragraph("No Firewall or Proxy configurations discovered in Intune policies.", ParagraphStyle('ErrTxtNetSecFW', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + table_data = [[ + Paragraph("Configuration Name", table_cell_header), + Paragraph("Policy Type", table_cell_header), + Paragraph("Firewall Status", table_cell_header), + Paragraph("Proxy Status", table_cell_header) + ]] + for item in fw_policies: + table_data.append([ + Paragraph(item.get("name", "N/A"), table_cell_bold), + Paragraph(item.get("policy_type", "N/A"), table_cell_style), + Paragraph(item.get("firewall_status", "N/A"), table_cell_style), + Paragraph(item.get("proxy_status", "N/A"), table_cell_style) + ]) + t = Table(table_data, colWidths=[150, 150, 100, 104]) + t.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(t) story.append(Spacer(1, 15)) story.append(PageBreak()) @@ -1825,405 +1768,403 @@ def format_bytes(size): # 4.1 Sensitivity Labels story.append(Paragraph("Microsoft Purview Sensitivity Labels", h2_style)) - try: - labels = data.get("security_labels", []) - if not labels: - story.append(Paragraph("No Purview Sensitivity Labels configured or permission restricted.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - labels_table_data = [[ - Paragraph("Sensitivity Label", table_cell_header), - Paragraph("Description", table_cell_header), - Paragraph("Shield", table_cell_header), - Paragraph("Mode", table_cell_header), - Paragraph("Priority", table_cell_header), - Paragraph("Status", table_cell_header) - ]] - - # Flatten parent labels and sublabels for the PDF table - flattened_labels = [] - for parent in labels: + labels = data.get("security_labels", []) + if not labels: + story.append(Paragraph("No Purview Sensitivity Labels configured or permission restricted.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + labels_table_data = [[ + Paragraph("Sensitivity Label", table_cell_header), + Paragraph("Description", table_cell_header), + Paragraph("Shield", table_cell_header), + Paragraph("Mode", table_cell_header), + Paragraph("Priority", table_cell_header), + Paragraph("Status", table_cell_header) + ]] + + # Flatten parent labels and sublabels for the PDF table + flattened_labels = [] + for parent in labels: + flattened_labels.append({ + "name": parent.get("name", "N/A"), + "description": parent.get("description", "") or parent.get("toolTip", "") or "N/A", + "hasProtection": parent.get("hasProtection", False), + "applicationMode": parent.get("applicationMode", "N/A") or "N/A", + "priority": parent.get("priority", 0), + "isEnabled": parent.get("isEnabled", True), + "is_sub": False + }) + for sub in parent.get("sublabels", []): flattened_labels.append({ - "name": parent.get("name", "N/A"), - "description": parent.get("description", "") or parent.get("toolTip", "") or "N/A", - "hasProtection": parent.get("hasProtection", False), - "applicationMode": parent.get("applicationMode", "N/A") or "N/A", - "priority": parent.get("priority", 0), - "isEnabled": parent.get("isEnabled", True), - "is_sub": False + "name": f" L_ {sub.get('name', 'N/A')}", + "description": sub.get("description", "") or sub.get("toolTip", "") or "N/A", + "hasProtection": sub.get("hasProtection", False), + "applicationMode": sub.get("applicationMode", "N/A") or "N/A", + "priority": sub.get("priority", 0), + "isEnabled": sub.get("isEnabled", True), + "is_sub": True }) - for sub in parent.get("sublabels", []): - flattened_labels.append({ - "name": f" L_ {sub.get('name', 'N/A')}", - "description": sub.get("description", "") or sub.get("toolTip", "") or "N/A", - "hasProtection": sub.get("hasProtection", False), - "applicationMode": sub.get("applicationMode", "N/A") or "N/A", - "priority": sub.get("priority", 0), - "isEnabled": sub.get("isEnabled", True), - "is_sub": True - }) - - for item in flattened_labels: - bg_bold_s = table_cell_bold if not item["is_sub"] else table_cell_style - protection_str = "Yes" if item["hasProtection"] else "No" - status_str = "Enabled" if item["isEnabled"] else "Disabled" - - labels_table_data.append([ - Paragraph(escape_text(item["name"]), bg_bold_s), - Paragraph(escape_text(item["description"]), table_cell_style), - Paragraph(escape_text(str(protection_str)), table_cell_style), - Paragraph(escape_text(str(item["applicationMode"]).capitalize()), table_cell_style), - Paragraph(escape_text(str(item["priority"])), table_cell_style), - Paragraph(escape_text(status_str), table_cell_style) - ]) - labels_table = Table(labels_table_data, colWidths=[120, 160, 50, 60, 50, 60]) - labels_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(labels_table) - - story.append(PageBreak()) - - # 4.2 Retention Policies - story.append(Paragraph("Microsoft Purview Retention Compliance Policies", h2_style)) - policies = data.get("retention_policies", []) - if not policies: - story.append(Paragraph("No Purview Retention compliance policies discovered or permission restricted.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - ret_table_data = [[ - Paragraph("Policy Name", table_cell_header), - Paragraph("Workloads Involved", table_cell_header), - Paragraph("Retention Duration Basis", table_cell_header), - Paragraph("Distribution Status", table_cell_header), - Paragraph("Status", table_cell_header) - ]] + for item in flattened_labels: + bg_bold_s = table_cell_bold if not item["is_sub"] else table_cell_style + protection_str = "Yes" if item["hasProtection"] else "No" + status_str = "Enabled" if item["isEnabled"] else "Disabled" - policies_list = policies if isinstance(policies, list) else [policies] - for policy in policies_list: - duration_val = str(policy.get("Duration", "N/A")) - duration_str = duration_val - if duration_val.lower() == "unlimited": - duration_str = "Keep Forever" - elif duration_val.isdigit(): - days = int(duration_val) - if days >= 365: - years = days / 365.0 - duration_str = f"{int(years)} Years ({days} days)" if years.is_integer() else f"{years:.1f} Years ({days} days)" - else: - duration_str = f"{days} days" - - trigger_val = policy.get("RetentionTrigger", "N/A") - if trigger_val and trigger_val != "N/A": - trigger_map = {"DateCreated": "created date", "DateModified": "last modified date", "DateLabeled": "labeled date"} - duration_str += f"
(from {trigger_map.get(trigger_val, trigger_val)})" - - enabled_val = policy.get("Enabled", True) - is_enabled = enabled_val.lower() == "true" if isinstance(enabled_val, str) else bool(enabled_val) - status_str = "Enabled" if is_enabled else "Disabled" - - ret_table_data.append([ - Paragraph(escape_text(policy.get("Name", "N/A")), table_cell_bold), - Paragraph(escape_text(policy.get("Workload", "N/A")), table_cell_style), - Paragraph(escape_text(str(duration_str)), table_cell_style), - Paragraph(escape_text(policy.get("DistributionStatus", "Success")), table_cell_style), - Paragraph(escape_text(status_str), table_cell_style) - ]) - - ret_table = Table(ret_table_data, colWidths=[130, 110, 110, 90, 60]) - ret_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(ret_table) - story.append(Spacer(1, 15)) + labels_table_data.append([ + Paragraph(item["name"], bg_bold_s), + Paragraph(item["description"], table_cell_style), + Paragraph(protection_str, table_cell_style), + Paragraph(str(item["applicationMode"]).capitalize(), table_cell_style), + Paragraph(str(item["priority"]), table_cell_style), + Paragraph(status_str, table_cell_style) + ]) + + labels_table = Table(labels_table_data, colWidths=[120, 160, 50, 60, 50, 60]) + labels_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(labels_table) - # 4.3 Data Loss Prevention Policies - story.append(Paragraph("4.3 Data Loss Prevention Policies", h2_style)) - story.append(Paragraph("This section outlines DLP policies configured in Microsoft Purview to prevent accidental data leaks.", body_style)) - story.append(Spacer(1, 8)) + story.append(PageBreak()) + + # 4.2 Retention Policies + story.append(Paragraph("Microsoft Purview Retention Compliance Policies", h2_style)) + policies = data.get("retention_policies", []) + if not policies: + story.append(Paragraph("No Purview Retention compliance policies discovered or permission restricted.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + ret_table_data = [[ + Paragraph("Policy Name", table_cell_header), + Paragraph("Workloads Involved", table_cell_header), + Paragraph("Retention Duration Basis", table_cell_header), + Paragraph("Distribution Status", table_cell_header), + Paragraph("Status", table_cell_header) + ]] - dlp_policies = data.get("dlp_policies", []) - if not dlp_policies: - story.append(Paragraph("No Purview Data Loss Prevention policies discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - dlp_table_data = [[ - Paragraph("Policy Name", table_cell_header), - Paragraph("Mode", table_cell_header), - Paragraph("Workload", table_cell_header), - Paragraph("State", table_cell_header), - Paragraph("Actions", table_cell_header), - Paragraph("Created By", table_cell_header) - ]] - for dlp in dlp_policies: - en_val = str(dlp.get("Enabled", "")).lower() - state_str = "Enabled" if en_val in ("true", "1", "yes") else "Disabled" + policies_list = policies if isinstance(policies, list) else [policies] + for policy in policies_list: + duration_val = str(policy.get("Duration", "N/A")) + duration_str = duration_val + if duration_val.lower() == "unlimited": + duration_str = "Keep Forever" + elif duration_val.isdigit(): + days = int(duration_val) + if days >= 365: + years = days / 365.0 + duration_str = f"{int(years)} Years ({days} days)" if years.is_integer() else f"{years:.1f} Years ({days} days)" + else: + duration_str = f"{days} days" + + trigger_val = policy.get("RetentionTrigger", "N/A") + if trigger_val and trigger_val != "N/A": + trigger_map = {"DateCreated": "created date", "DateModified": "last modified date", "DateLabeled": "labeled date"} + duration_str += f"
(from {trigger_map.get(trigger_val, trigger_val)})" - dlp_table_data.append([ - Paragraph(escape_text(dlp.get("Name", "-")), table_cell_bold), - Paragraph(escape_text(dlp.get("Mode", "-")), table_cell_style), - Paragraph(escape_text(dlp.get("Workload", "-")), table_cell_style), - Paragraph(escape_text(str(state_str)), table_cell_style), - Paragraph(escape_text(dlp.get("Actions", "-")), table_cell_style), - Paragraph(escape_text(dlp.get("CreatedBy", "-")), table_cell_style) - ]) - dlp_table = Table(dlp_table_data, colWidths=[110, 50, 110, 60, 90, 80]) - dlp_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(dlp_table) - story.append(Spacer(1, 15)) + enabled_val = policy.get("Enabled", True) + is_enabled = enabled_val.lower() == "true" if isinstance(enabled_val, str) else bool(enabled_val) + status_str = "Enabled" if is_enabled else "Disabled" + + ret_table_data.append([ + Paragraph(policy.get("Name", "N/A"), table_cell_bold), + Paragraph(policy.get("Workload", "N/A"), table_cell_style), + Paragraph(duration_str, table_cell_style), + Paragraph(policy.get("DistributionStatus", "Success"), table_cell_style), + Paragraph(status_str, table_cell_style) + ]) + + ret_table = Table(ret_table_data, colWidths=[130, 110, 110, 90, 60]) + ret_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(ret_table) + story.append(Spacer(1, 15)) + + # 4.3 Data Loss Prevention Policies + story.append(Paragraph("4.3 Data Loss Prevention Policies", h2_style)) + story.append(Paragraph("This section outlines DLP policies configured in Microsoft Purview to prevent accidental data leaks.", body_style)) + story.append(Spacer(1, 8)) - # 4.4 Sensitive Information Types - story.append(Paragraph("4.4 Sensitive Information Types", h2_style)) - story.append(Paragraph("This section outlines custom and built-in sensitive information types active in the environment.", body_style)) - story.append(Spacer(1, 8)) - - sit_types = data.get("sensitive_info_types", []) - if not sit_types: - story.append(Paragraph("No Sensitive Information Types discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - sit_table_data = [[ - Paragraph("Name", table_cell_header), - Paragraph("Type", table_cell_header), - Paragraph("Confidence", table_cell_header) - ]] - for sit in sit_types: - sit_table_data.append([ - Paragraph(escape_text(sit.get("Name", "-")), table_cell_bold), - Paragraph(escape_text(sit.get("Type", "-")), table_cell_style), - Paragraph(escape_text(str(sit.get("RecommendedConfidence", "-"))), table_cell_style) - ]) - sit_table = Table(sit_table_data, colWidths=[280, 100, 120]) - sit_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(sit_table) - story.append(Spacer(1, 15)) + dlp_policies = data.get("dlp_policies", []) + if not dlp_policies: + story.append(Paragraph("No Purview Data Loss Prevention policies discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + dlp_table_data = [[ + Paragraph("Policy Name", table_cell_header), + Paragraph("Mode", table_cell_header), + Paragraph("Workload", table_cell_header), + Paragraph("State", table_cell_header), + Paragraph("Actions", table_cell_header), + Paragraph("Created By", table_cell_header) + ]] + for dlp in dlp_policies: + en_val = str(dlp.get("Enabled", "")).lower() + state_str = "Enabled" if en_val in ("true", "1", "yes") else "Disabled" + + dlp_table_data.append([ + Paragraph(dlp.get("Name", "-"), table_cell_bold), + Paragraph(dlp.get("Mode", "-"), table_cell_style), + Paragraph(dlp.get("Workload", "-"), table_cell_style), + Paragraph(state_str, table_cell_style), + Paragraph(dlp.get("Actions", "-"), table_cell_style), + Paragraph(dlp.get("CreatedBy", "-"), table_cell_style) + ]) + dlp_table = Table(dlp_table_data, colWidths=[110, 50, 110, 60, 90, 80]) + dlp_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(dlp_table) + story.append(Spacer(1, 15)) + + # 4.4 Sensitive Information Types + story.append(Paragraph("4.4 Sensitive Information Types", h2_style)) + story.append(Paragraph("This section outlines custom and built-in sensitive information types active in the environment.", body_style)) + story.append(Spacer(1, 8)) - # ========================================================================= + sit_types = data.get("sensitive_info_types", []) + if not sit_types: + story.append(Paragraph("No Sensitive Information Types discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + sit_table_data = [[ + Paragraph("Name", table_cell_header), + Paragraph("Type", table_cell_header), + Paragraph("Confidence", table_cell_header) + ]] + for sit in sit_types: + sit_table_data.append([ + Paragraph(sit.get("Name", "-"), table_cell_bold), + Paragraph(sit.get("Type", "-"), table_cell_style), + Paragraph(str(sit.get("RecommendedConfidence", "-")), table_cell_style) + ]) + sit_table = Table(sit_table_data, colWidths=[280, 100, 120]) + sit_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(sit_table) + story.append(Spacer(1, 15)) + + # ========================================================================= + + # 4.5 Mail Security + story.append(Paragraph("4.5 Mail Security (Exchange)", h2_style)) + story.append(Paragraph("This section displays configured email filtering and threat protection policies.", body_style)) + story.append(Spacer(1, 8)) - # 4.5 Mail Security - story.append(Paragraph("4.5 Mail Security (Exchange)", h2_style)) - story.append(Paragraph("This section displays configured email filtering and threat protection policies.", body_style)) - story.append(Spacer(1, 8)) + mail_sec = data.get("mail_security", {}) + if not mail_sec or (not mail_sec.get("defender", {}).get("skus") and not mail_sec.get("eop", {}).get("skus")): + story.append(Paragraph("No mail security SKUs detected.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + ms_table_data = [[ + Paragraph("Mail Security Configuration", table_cell_header), + Paragraph("Detected SKUs", table_cell_header), + Paragraph("Affected Users", table_cell_header) + ]] - mail_sec = data.get("mail_security", {}) - if not mail_sec or (not mail_sec.get("defender", {}).get("skus") and not mail_sec.get("eop", {}).get("skus")): - story.append(Paragraph("No mail security SKUs detected.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - ms_table_data = [[ - Paragraph("Mail Security Configuration", table_cell_header), - Paragraph("Detected SKUs", table_cell_header), - Paragraph("Affected Users", table_cell_header) - ]] + defender_data = mail_sec.get("defender", {}) + eop_data = mail_sec.get("eop", {}) + + if defender_data.get("skus"): + ms_table_data.append([ + Paragraph("Microsoft Defender for Office 365", table_cell_bold), + Paragraph(", ".join(defender_data.get("skus", [])), table_cell_style), + Paragraph(f"{defender_data.get('users', 0):,} Users", table_cell_style) + ]) - defender_data = mail_sec.get("defender", {}) - eop_data = mail_sec.get("eop", {}) + if eop_data.get("skus"): + ms_table_data.append([ + Paragraph("Exchange Online Protection (Baseline)", table_cell_bold), + Paragraph(", ".join(eop_data.get("skus", [])), table_cell_style), + Paragraph(f"{eop_data.get('users', 0):,} Users", table_cell_style) + ]) - if defender_data.get("skus"): - ms_table_data.append([ - Paragraph("Microsoft Defender for Office 365", table_cell_bold), - Paragraph(escape_text(", ".join(defender_data.get("skus", []))), table_cell_style), - Paragraph(f"{defender_data.get('users', 0):,} Users", table_cell_style) - ]) - - if eop_data.get("skus"): - ms_table_data.append([ - Paragraph("Exchange Online Protection (Baseline)", table_cell_bold), - Paragraph(escape_text(", ".join(eop_data.get("skus", []))), table_cell_style), - Paragraph(f"{eop_data.get('users', 0):,} Users", table_cell_style) - ]) - - ms_table = Table(ms_table_data, colWidths=[200, 200, 100]) - ms_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(ms_table) - story.append(Spacer(1, 15)) + ms_table = Table(ms_table_data, colWidths=[200, 200, 100]) + ms_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(ms_table) + story.append(Spacer(1, 15)) + + # 4.6 Exchange Transport Rules + story.append(Paragraph("4.6 Exchange Transport Rules", h2_style)) + story.append(Paragraph("This section displays mail flow rules configured in Exchange Online.", body_style)) + story.append(Spacer(1, 8)) - # 4.6 Exchange Transport Rules - story.append(Paragraph("4.6 Exchange Transport Rules", h2_style)) - story.append(Paragraph("This section displays mail flow rules configured in Exchange Online.", body_style)) - story.append(Spacer(1, 8)) + transport_rules = data.get("transport_rules", []) + if not transport_rules: + story.append(Paragraph("No Exchange Transport Rules discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + rules_table_data = [[ + Paragraph("Rule Name", table_cell_header), + Paragraph("State", table_cell_header), + Paragraph("Priority", table_cell_header), + Paragraph("Mode", table_cell_header), + Paragraph("Rule Logic", table_cell_header) + ]] - transport_rules = data.get("transport_rules", []) - if not transport_rules: - story.append(Paragraph("No Exchange Transport Rules discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - rules_table_data = [[ - Paragraph("Rule Name", table_cell_header), - Paragraph("State", table_cell_header), - Paragraph("Priority", table_cell_header), - Paragraph("Mode", table_cell_header), - Paragraph("Rule Logic", table_cell_header) - ]] + display_rules = transport_rules + for rule in display_rules: + desc_text = rule.get("Description") or "N/A" + safe_desc = html.escape(str(desc_text)).replace('\n', '
') + safe_name = html.escape(str(rule.get("Name", "-"))) + rules_table_data.append([ + Paragraph(safe_name, table_cell_bold), + Paragraph(html.escape(str(rule.get("State", "-"))), table_cell_style), + Paragraph(html.escape(str(rule.get("Priority", "-"))), table_cell_style), + Paragraph(html.escape(str(rule.get("Mode", "-"))), table_cell_style), + Paragraph(safe_desc, small_table_cell_style) + ]) - display_rules = transport_rules - for rule in display_rules: - desc_text = rule.get("Description") or "N/A" - rules_table_data.append([ - Paragraph(escape_text(rule.get("Name", "-")), table_cell_bold), - Paragraph(escape_text(rule.get("State", "-")), table_cell_style), - Paragraph(escape_text(rule.get("Priority", "-")), table_cell_style), - Paragraph(escape_text(rule.get("Mode", "-")), table_cell_style), - Paragraph(escape_text(desc_text), small_table_cell_style) - ]) - - rules_table = Table(rules_table_data, colWidths=[120, 50, 40, 60, 234]) - rules_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(rules_table) - story.append(Spacer(1, 15)) + rules_table = Table(rules_table_data, colWidths=[120, 50, 40, 60, 234]) + rules_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(rules_table) + story.append(Spacer(1, 15)) + + # 4.7 SSO Service Principals + story.append(Paragraph("4.7 Enterprise SAML SSO Apps", h2_style)) + story.append(Paragraph("This section displays Enterprise Applications configured for SAML Single Sign-On.", body_style)) + story.append(Spacer(1, 8)) - # 4.7 SSO Service Principals - story.append(Paragraph("4.7 Enterprise SAML SSO Apps", h2_style)) - story.append(Paragraph("This section displays Enterprise Applications configured for SAML Single Sign-On.", body_style)) - story.append(Spacer(1, 8)) + sso_apps = data.get("service_principals_sso", []) + if not sso_apps: + story.append(Paragraph("No SAML SSO applications discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + mode_counts = Counter() + for app in sso_apps: + mode = app.get("preferredSingleSignOnMode", "").strip() or "None" + mode_counts[mode] += 1 + + sso_table_data = [[ + Paragraph("SSO Mode", table_cell_header), + Paragraph("Number of Applications", table_cell_header) + ]] - sso_apps = data.get("service_principals_sso", []) - if not sso_apps: - story.append(Paragraph("No SAML SSO applications discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - mode_counts = Counter() - for app in sso_apps: - mode = app.get("preferredSingleSignOnMode", "").strip() or "None" - mode_counts[mode] += 1 - - sso_table_data = [[ - Paragraph("SSO Mode", table_cell_header), - Paragraph("Number of Applications", table_cell_header) - ]] + for mode, count in sorted(mode_counts.items(), key=lambda x: x[1], reverse=True): + sso_table_data.append([ + Paragraph(mode, table_cell_bold), + Paragraph(f"{count:,} Apps", table_cell_style) + ]) - for mode, count in sorted(mode_counts.items(), key=lambda x: x[1], reverse=True): - sso_table_data.append([ - Paragraph(escape_text(mode), table_cell_bold), - Paragraph(f"{count:,} Apps", table_cell_style) - ]) - - sso_table = Table(sso_table_data, colWidths=[300, 200]) - sso_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(sso_table) - story.append(Spacer(1, 15)) + sso_table = Table(sso_table_data, colWidths=[300, 200]) + sso_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(sso_table) + story.append(Spacer(1, 15)) + + # 4.8 Conditional Access Policies + story.append(Paragraph("4.8 Conditional Access Policies", h2_style)) + story.append(Paragraph("This section displays Azure AD Auth Policies governing conditional access.", body_style)) + story.append(Spacer(1, 8)) - # 4.8 Conditional Access Policies - story.append(Paragraph("4.8 Conditional Access Policies", h2_style)) - story.append(Paragraph("This section displays Azure AD Auth Policies governing conditional access.", body_style)) - story.append(Spacer(1, 8)) - - ca_policies = data.get("conditional_access", []) - if not ca_policies: - story.append(Paragraph("No conditional access policies discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - ca_table_data = [[ - Paragraph("Policy Name", table_cell_header), - Paragraph("State", table_cell_header), - Paragraph("Controls", table_cell_header) - ]] - for cap in ca_policies: - ca_table_data.append([ - Paragraph(escape_text(cap.get("name", "-")), table_cell_bold), - Paragraph(escape_text(cap.get("state", "-")), table_cell_style), - Paragraph(escape_text(cap.get("controls", "-")), table_cell_style) - ]) - ca_table = Table(ca_table_data, colWidths=[250, 100, 150]) - ca_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(ca_table) - story.append(Spacer(1, 15)) - story.append(PageBreak()) + ca_policies = data.get("conditional_access", []) + if not ca_policies: + story.append(Paragraph("No conditional access policies discovered.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + ca_table_data = [[ + Paragraph("Policy Name", table_cell_header), + Paragraph("State", table_cell_header), + Paragraph("Controls", table_cell_header) + ]] + for cap in ca_policies: + ca_table_data.append([ + Paragraph(cap.get("name", "-"), table_cell_bold), + Paragraph(cap.get("state", "-"), table_cell_style), + Paragraph(cap.get("controls", "-"), table_cell_style) + ]) + ca_table = Table(ca_table_data, colWidths=[250, 100, 150]) + ca_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(ca_table) + story.append(Spacer(1, 15)) + story.append(PageBreak()) + + # 4.9 eDiscovery Cases + story.append(Paragraph("4.9 Microsoft Purview eDiscovery Cases", h2_style)) + story.append(Paragraph("This section lists the active and closed eDiscovery cases across the tenant, providing visibility into compliance and legal discovery workloads.", body_style)) + story.append(Spacer(1, 8)) - # 4.9 eDiscovery Cases - story.append(Paragraph("4.9 Microsoft Purview eDiscovery Cases", h2_style)) - story.append(Paragraph("This section lists the active and closed eDiscovery cases across the tenant, providing visibility into compliance and legal discovery workloads.", body_style)) - story.append(Spacer(1, 8)) - - ediscovery_cases = data.get("ediscovery_cases", []) - if not ediscovery_cases: - story.append(Paragraph("No eDiscovery cases were discovered or Delegated Authentication was not used.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - edisc_table_data = [[ - Paragraph("Display Name", table_cell_header), - Paragraph("Status", table_cell_header), - Paragraph("Created Date", table_cell_header), - Paragraph("Closed By", table_cell_header) - ]] - for case in ediscovery_cases[:10]: - created_date = str(case.get("createdDateTime", "-")).split("T")[0] - edisc_table_data.append([ - Paragraph(escape_text(case.get("displayName", "-")), table_cell_bold), - Paragraph(escape_text(case.get("status", "-")), table_cell_style), - Paragraph(escape_text(created_date), table_cell_style), - Paragraph(escape_text(case.get("closedBy", "-")), table_cell_style) - ]) - - edisc_table = Table(edisc_table_data, colWidths=[200, 80, 100, 120]) - edisc_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(edisc_table) - if len(ediscovery_cases) > 10: - story.append(Paragraph(f"...and {len(ediscovery_cases) - 10} more. See generated CSV reports for full details.", ParagraphStyle('Ital', parent=body_style, fontName='Helvetica-Oblique', textColor=secondary_color))) - story.append(Spacer(1, 15)) - except Exception as e: - logger.exception("Failed to format Data Security, Governance & Compliance section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Data Security, Governance & Compliance section: {escape_text(str(e))}", section_err_style)) + ediscovery_cases = data.get("ediscovery_cases", []) + if not ediscovery_cases: + story.append(Paragraph("No eDiscovery cases were discovered or Delegated Authentication was not used.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + edisc_table_data = [[ + Paragraph("Display Name", table_cell_header), + Paragraph("Status", table_cell_header), + Paragraph("Created Date", table_cell_header), + Paragraph("Closed By", table_cell_header) + ]] + for case in ediscovery_cases[:10]: + created_date = str(case.get("createdDateTime", "-")).split("T")[0] + edisc_table_data.append([ + Paragraph(case.get("displayName", "-"), table_cell_bold), + Paragraph(case.get("status", "-"), table_cell_style), + Paragraph(created_date, table_cell_style), + Paragraph(case.get("closedBy", "-"), table_cell_style) + ]) + + edisc_table = Table(edisc_table_data, colWidths=[200, 80, 100, 120]) + edisc_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(edisc_table) + if len(ediscovery_cases) > 10: + story.append(Paragraph(f"...and {len(ediscovery_cases) - 10} more. See generated CSV reports for full details.", ParagraphStyle('Ital', parent=body_style, fontName='Helvetica-Oblique', textColor=secondary_color))) + story.append(Spacer(1, 15)) story.append(PageBreak()) # SECTION 6: POWER AUTOMATE @@ -2232,66 +2173,62 @@ def format_bytes(size): story.append(Paragraph("An analysis of low-code cloud and desktop workflows configured inside the tenant environments, identifying complex workflows and premium connectors.", body_style)) story.append(Spacer(1, 8)) - try: - pa = data.get("power_automate", {}) - if not pa: - story.append(Paragraph("No Power Platform or Power Automate telemetry scan data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) - else: - counts = pa.get("counts", {}) - total_flows = counts.get("Cloud Flows", 0) + counts.get("Desktop Flows", 0) - premium_conns = pa.get("premium_connectors", []) - custom_conns = pa.get("custom_connectors", []) - - prem_str = ", ".join(premium_conns) if premium_conns else "0" - cust_str = ", ".join(custom_conns) if custom_conns else "0" - - pa_table_data = [[ - Paragraph("Power Platform Telemetry Property", table_cell_header), - Paragraph("Scanned Value", table_cell_header) - ]] - - pa_rows = [ - ("Total Environments Scanned", str(pa.get("total_environments", 0))), - ("Total Flows (Active + Inactive)", f"{total_flows:,} Flows"), - ("Active Cloud Flows Count", f"{pa.get('active_counts', {}).get('Cloud Flows', 0):,} Cloud Flows"), - ("Active Desktop Flows Count", f"{pa.get('active_counts', {}).get('Desktop Flows', 0):,} Desktop Flows"), - ("Premium Connectors In Use", prem_str), - ("Custom Connectors In Use", cust_str), - ("Complex Business-Logic Flows Identified", f"{len(pa.get('complex_logic_flows', [])):,} Flows"), - ] - - for label, val in pa_rows: - pa_table_data.append([ - Paragraph(escape_text(label), table_cell_bold), - Paragraph(escape_text(str(val)), table_cell_style) - ]) - - pa_table = Table(pa_table_data, colWidths=[220, 280]) - pa_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), primary_color), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ('TOPPADDING', (0, 0), (-1, -1), 5), - ('BOTTOMPADDING', (0, 0), (-1, -1), 5), - ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), - ('GRID', (0, 0), (-1, -1), 0.5, outline_color), - ])) - story.append(pa_table) + pa = data.get("power_automate", {}) + if not pa: + story.append(Paragraph("No Power Platform or Power Automate telemetry scan data was available.", ParagraphStyle('ErrTxt', parent=body_style, textColor=colors.HexColor("#DC2626")))) + else: + counts = pa.get("counts", {}) + total_flows = counts.get("Cloud Flows", 0) + counts.get("Desktop Flows", 0) + premium_conns = pa.get("premium_connectors", []) + custom_conns = pa.get("custom_connectors", []) + + prem_str = ", ".join(premium_conns) if premium_conns else "0" + cust_str = ", ".join(custom_conns) if custom_conns else "0" + + pa_table_data = [[ + Paragraph("Power Platform Telemetry Property", table_cell_header), + Paragraph("Scanned Value", table_cell_header) + ]] + + pa_rows = [ + ("Total Environments Scanned", str(pa.get("total_environments", 0))), + ("Total Flows (Active + Inactive)", f"{total_flows:,} Flows"), + ("Active Cloud Flows Count", f"{pa.get('active_counts', {}).get('Cloud Flows', 0):,} Cloud Flows"), + ("Active Desktop Flows Count", f"{pa.get('active_counts', {}).get('Desktop Flows', 0):,} Desktop Flows"), + ("Premium Connectors In Use", prem_str), + ("Custom Connectors In Use", cust_str), + ("Complex Business-Logic Flows Identified", f"{len(pa.get('complex_logic_flows', [])):,} Flows"), + ] + + for label, val in pa_rows: + pa_table_data.append([ + Paragraph(label, table_cell_bold), + Paragraph(val, table_cell_style) + ]) - # Power Automate Breakdown Chart - Generated on the fly - if counts: - try: - pa_chart_bytes = generate_pa_chart_bytes(pa) - if pa_chart_bytes: - story.append(Spacer(1, 15)) - story.append(Paragraph("Power Automate Flows Breakdown Chart", h2_style)) - pa_chart = Image(pa_chart_bytes, width=450, height=210) - story.append(pa_chart) - except Exception as chart_ex: - print(f"Failed to generate Power Automate chart for PDF: {chart_ex}") - except Exception as e: - logger.exception("Failed to format Power Platform & Automate Flows Analytics section in PDF") - story.append(Paragraph(f"⚠️ Error formatting Power Platform & Automate Flows Analytics section: {escape_text(str(e))}", section_err_style)) + pa_table = Table(pa_table_data, colWidths=[220, 280]) + pa_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), primary_color), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 5), + ('BOTTOMPADDING', (0, 0), (-1, -1), 5), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ('GRID', (0, 0), (-1, -1), 0.5, outline_color), + ])) + story.append(pa_table) + + # Power Automate Breakdown Chart - Generated on the fly + if counts: + try: + pa_chart_bytes = generate_pa_chart_bytes(pa) + if pa_chart_bytes: + story.append(Spacer(1, 15)) + story.append(Paragraph("Power Automate Flows Breakdown Chart", h2_style)) + pa_chart = Image(pa_chart_bytes, width=450, height=210) + story.append(pa_chart) + except Exception as chart_ex: + print(f"Failed to generate Power Automate chart for PDF: {chart_ex}") # 4. Build Document doc.build(story, canvasmaker=NumberedCanvas)