This preview uses sample data so you can see how your branding lands before running a report against a real tenant.
"
+ },
+ {
+ "type": "infobox",
+ "title": "Why this matters",
+ "content": "Identity is the perimeter: **every** control in this report starts with who can sign in, and from where.",
+ "static": true
+ },
+ {
+ "type": "richbullets",
+ "title": "Key points",
+ "items": [
+ {
+ "label": "MFA is enforced.",
+ "text": "All 128 licensed users are covered by a Conditional Access policy."
+ },
+ {
+ "label": "Legacy authentication is blocked.",
+ "text": "No sign-ins over IMAP, POP or SMTP AUTH in the last 30 days."
+ }
+ ],
+ "static": true
+ },
+ {
+ "type": "note",
+ "content": "Figures are taken from the last completed sync, not read live.",
+ "static": true
+ },
+ {
+ "type": "page",
+ "title": "Findings in detail",
+ "subtitle": "What we found, and what to do about it",
+ "static": true
+ },
+ {
+ "id": "sample-2",
+ "type": "scorecard",
+ "title": "Environment Overview",
+ "static": true,
+ "stats": [
+ {
+ "label": "Licensed Users",
+ "value": "128"
+ },
+ {
+ "label": "Devices",
+ "value": "96"
+ },
+ {
+ "label": "Global Admins",
+ "value": "3",
+ "caption": "Target: 2–4"
+ },
+ {
+ "label": "Guests",
+ "value": "9"
+ }
+ ]
+ },
+ {
+ "id": "sample-3",
+ "type": "chart",
+ "title": "Device Compliance",
+ "static": true,
+ "chartKind": "donut",
+ "chartCentreLabel": "Devices",
+ "chartData": [
+ {
+ "label": "Compliant",
+ "value": 78
+ },
+ {
+ "label": "Non-compliant",
+ "value": 14
+ },
+ {
+ "label": "Not evaluated",
+ "value": 4
+ }
+ ]
+ },
+ {
+ "id": "sample-4",
+ "type": "chart",
+ "title": "Secure Score Trend",
+ "static": true,
+ "chartKind": "trend",
+ "chartMax": 100,
+ "chartCaption": "Current: 61 / 100 (61%)",
+ "chartData": [
+ {
+ "label": "Jul 29",
+ "value": 50
+ },
+ {
+ "label": "Jul 31",
+ "value": 55
+ },
+ {
+ "label": "Aug 2",
+ "value": 58
+ },
+ {
+ "label": "Aug 4",
+ "value": 61
+ }
+ ]
+ },
+ {
+ "id": "sample-5",
+ "type": "progress",
+ "title": "Control Coverage",
+ "static": true,
+ "items": [
+ {
+ "label": "MFA enforced",
+ "value": 92,
+ "max": 100
+ },
+ {
+ "label": "Disk encryption",
+ "value": 78,
+ "max": 100
+ },
+ {
+ "label": "Defender onboarded",
+ "value": 64,
+ "max": 100
+ }
+ ]
+ },
+ {
+ "type": "richtable",
+ "title": "Top findings",
+ "columns": [
+ {
+ "header": "Finding",
+ "key": "c1"
+ },
+ {
+ "header": "Impact",
+ "key": "c2"
+ },
+ {
+ "header": "Owner",
+ "key": "c3"
+ }
+ ],
+ "rows": [
+ {
+ "c1": "3 stale guest accounts",
+ "c2": "Medium",
+ "c3": "IT"
+ },
+ {
+ "c1": "2 devices out of compliance",
+ "c2": "Low",
+ "c3": "Service desk"
+ }
+ ],
+ "static": true
+ },
+ {
+ "type": "infoboxcolumns",
+ "columns": 2,
+ "items": [
+ {
+ "title": "Done this quarter",
+ "content": "Passwordless sign-in rolled out to 40% of staff."
+ },
+ {
+ "title": "Next quarter",
+ "content": "Extend it to the remaining sites and the shared mailboxes."
+ }
+ ],
+ "static": true
+ },
+ {
+ "id": "sample-6",
+ "type": "pagebreak",
+ "title": "",
+ "static": true
+ },
+ {
+ "id": "sample-7",
+ "type": "hero",
+ "title": "seconds",
+ "static": true,
+ "heroHighlight": "39",
+ "heroSubText": "a business falls victim to ransomware",
+ "heroFooterText": "Proactive defense beats reactive recovery",
+ "heroImage": "/reportImages/working.jpg"
+ },
+ {
+ "id": "sample-8",
+ "type": "test",
+ "title": "Multi-factor authentication",
+ "status": "Failed",
+ "static": false,
+ "content": "14 of 128 accounts can still sign in without a second factor.\n\n## Results\n\n| Account | Method | Last sign-in |\n| --- | --- | --- |\n| sample.one@example.com | None | 2 days ago |\n| sample.two@example.com | None | 9 days ago |\n"
+ },
+ {
+ "id": "sample-9",
+ "type": "chart",
+ "title": "Devices by Platform",
+ "static": true,
+ "chartKind": "bar",
+ "chartData": [
+ {
+ "label": "Windows",
+ "value": 62
+ },
+ {
+ "label": "macOS",
+ "value": 18
+ },
+ {
+ "label": "iOS",
+ "value": 12
+ },
+ {
+ "label": "Android",
+ "value": 4
+ }
+ ]
+ },
+ {
+ "id": "sample-10",
+ "type": "database",
+ "title": "Query Results",
+ "static": true,
+ "format": "text",
+ "content": "| Policy | Identifier | State |\n| --- | --- | --- |\n| Baseline | 8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f | Enabled |\n| Hardened | Microsoft_Defender_for_Business_Servers | Report only |\n"
+ },
+ {
+ "id": "sample-11",
+ "type": "database",
+ "title": "Raw Response",
+ "static": true,
+ "format": "json",
+ "content": "{\n \"tenant\": \"contoso.com\",\n \"policies\": 191,\n \"enabled\": 5\n}"
+ }
+ ]
+}
diff --git a/backend/Config/ReportSamples/shadowAI.json b/backend/Config/ReportSamples/shadowAI.json
new file mode 100644
index 0000000000..c5a4f9f6fc
--- /dev/null
+++ b/backend/Config/ReportSamples/shadowAI.json
@@ -0,0 +1,103 @@
+{
+ "summary": {
+ "aiToolsDetected": 18,
+ "deviceInstalls": 36,
+ "consentedAiApps": 5,
+ "highRiskTools": 3,
+ "sanctionedTools": 2
+ },
+ "byRisk": [
+ {
+ "risk": "High",
+ "tools": 3
+ },
+ {
+ "risk": "Medium",
+ "tools": 7
+ },
+ {
+ "risk": "Low",
+ "tools": 6
+ },
+ {
+ "risk": "Informational",
+ "tools": 2
+ }
+ ],
+ "topTools": [
+ {
+ "tool": "Sample AI Assistant",
+ "category": "Chat",
+ "status": "Unsanctioned",
+ "devices": 22,
+ "users": 18
+ },
+ {
+ "tool": "Sample Code Helper",
+ "category": "Development",
+ "status": "Unsanctioned",
+ "devices": 14,
+ "users": 9
+ },
+ {
+ "tool": "Sample Notetaker",
+ "category": "Meetings",
+ "status": "Sanctioned",
+ "devices": 11,
+ "users": 24
+ }
+ ],
+ "detectedApps": [
+ {
+ "application": "Sample AI Assistant Desktop",
+ "aiTool": "Sample AI Assistant",
+ "vendor": "Example Corp",
+ "category": "Chat",
+ "risk": "High",
+ "status": "Unsanctioned",
+ "deviceCount": 22
+ },
+ {
+ "application": "Sample Code Helper",
+ "aiTool": "Sample Code Helper",
+ "vendor": "Example Labs",
+ "category": "Development",
+ "risk": "Medium",
+ "status": "Unsanctioned",
+ "deviceCount": 14
+ },
+ {
+ "application": "Sample Notetaker",
+ "aiTool": "Sample Notetaker",
+ "vendor": "Example Corp",
+ "category": "Meetings",
+ "risk": "Informational",
+ "status": "Sanctioned",
+ "deviceCount": 11
+ }
+ ],
+ "consentedApps": [
+ {
+ "applicationId": "00000000-0000-0000-0000-000000000001",
+ "application": "Sample AI Connector",
+ "aiTool": "Sample AI Assistant",
+ "vendor": "Example Corp",
+ "category": "Chat",
+ "risk": "High",
+ "status": "Unsanctioned",
+ "activeUsersLast7Days": 18,
+ "firstConsentedDateTime": "2026-06-11T10:22:00Z"
+ },
+ {
+ "applicationId": "00000000-0000-0000-0000-000000000002",
+ "application": "Sample Meeting Notes",
+ "aiTool": "Sample Notetaker",
+ "vendor": "Example Corp",
+ "category": "Meetings",
+ "risk": "Informational",
+ "status": "Sanctioned",
+ "activeUsersLast7Days": 24,
+ "firstConsentedDateTime": "2026-03-02T14:05:00Z"
+ }
+ ]
+}
diff --git a/backend/Config/ReportSamples/sharing.json b/backend/Config/ReportSamples/sharing.json
new file mode 100644
index 0000000000..d7f89977f7
--- /dev/null
+++ b/backend/Config/ReportSamples/sharing.json
@@ -0,0 +1,53 @@
+{
+ "summary": {
+ "totalLinks": 24,
+ "itemsShared": 18,
+ "externalRecipients": 6,
+ "anonymousLinks": 4,
+ "anonymousEditLinks": 1,
+ "neverExpiringAnonymous": 2,
+ "folderShares": 3,
+ "externalLinks": 6,
+ "sharePointSites": 5,
+ "teamsSites": 3,
+ "oneDriveAccounts": 12
+ },
+ "links": [
+ {
+ "itemName": "Sample Proposal.docx",
+ "siteName": "Sample Marketing",
+ "linkType": "Anonymous",
+ "scope": "Edit",
+ "expires": "Never",
+ "recipients": "Anyone with the link"
+ },
+ {
+ "itemName": "Sample Budget.xlsx",
+ "siteName": "Sample Finance",
+ "linkType": "External",
+ "scope": "View",
+ "expires": "2026-12-31",
+ "recipients": "partner@example.com"
+ }
+ ],
+ "topRecipients": [
+ {
+ "recipient": "partner@example.com",
+ "links": 5
+ },
+ {
+ "recipient": "supplier@example.net",
+ "links": 3
+ }
+ ],
+ "topLibraries": [
+ {
+ "library": "Sample Marketing / Documents",
+ "links": 9
+ },
+ {
+ "library": "Sample Finance / Documents",
+ "links": 6
+ }
+ ]
+}
diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json
index 0ef4f0d2de..c2d605c85b 100644
--- a/backend/Config/openapi.json
+++ b/backend/Config/openapi.json
@@ -15255,7 +15255,7 @@
"tags": [
"Identity > Administration > Users"
],
- "description": "Collates the run's stored results, one CSV per finding set, the score, the containment history, every logbook entry stamped with the case id and the browser-rendered PDF reports (pdfBase64, pdfSummaryBase64) into a ZIP. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save. Metadata only - nothing in the package is message content.",
+ "description": "Collates the run's stored results, one CSV per finding set, the score, the containment history, every logbook entry stamped with the case id and the full and C-suite-summary report PDFs (rendered server-side) into a ZIP. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save. Metadata only - nothing in the package is message content.",
"requestBody": {
"required": true,
"content": {
@@ -15266,13 +15266,6 @@
"caseId": {
"type": "string"
},
- "pdfBase64": {
- "type": "string",
- "description": "optional: the report PDFs rendered in the browser, base64-encoded (full report + C-suite summary)"
- },
- "pdfSummaryBase64": {
- "type": "string"
- },
"tenantFilter": {
"type": "string"
}
@@ -15693,7 +15686,8 @@
"type": "string"
},
"kind": {
- "type": "string"
+ "type": "string",
+ "description": "A name for a gallery image, so it can be picked by name where the image is offered elsewhere: the report builder lists uploaded covers as Infographic page backgrounds."
},
"logoImageId": {
"type": "string"
@@ -15720,6 +15714,10 @@
"showPageNumbers": {
"type": "boolean"
},
+ "tenantLabel": {
+ "type": "string",
+ "description": "Which of the tenant's names the preset's reports print; see the Set action."
+ },
"watermarkEnabled": {
"type": "boolean"
},
@@ -22745,46 +22743,7 @@
"application/json": {
"schema": {
"type": "object",
- "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.",
- "properties": {
- "Blocks": {
- "type": "string",
- "x-cipp-field-source": "storage"
- },
- "ETag": {
- "type": "string",
- "x-cipp-field-source": "storage"
- },
- "GeneratedAt": {
- "type": "string",
- "x-cipp-field-source": "storage"
- },
- "PartitionKey": {
- "x-cipp-field-source": "storage"
- },
- "RowKey": {
- "type": "string",
- "x-cipp-field-source": "storage"
- },
- "Settings": {
- "x-cipp-field-source": "storage"
- },
- "Status": {
- "x-cipp-field-source": "storage"
- },
- "TemplateName": {
- "type": "string",
- "x-cipp-field-source": "storage"
- },
- "TenantFilter": {
- "type": "string",
- "x-cipp-field-source": "storage"
- },
- "Timestamp": {
- "type": "string",
- "x-cipp-field-source": "storage"
- }
- }
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
}
}
}
@@ -22868,6 +22827,315 @@
"x-cipp-any-tenant": true
}
},
+ "/api/ExecGetBaselineWhatIfReportPdf": {
+ "post": {
+ "summary": "ExecGetBaselineWhatIfReportPdf",
+ "operationId": "ExecGetBaselineWhatIfReportPdf",
+ "tags": [
+ "Tenant > Standards"
+ ],
+ "description": "Server-renders the Baseline What-If report as application/pdf bytes: what applying the\nconfigured standards would change for a tenant today, what each planned stage will change,\noptionally what assigning one more baseline would roll out, and the agreed exceptions. Reads\nthe same alignment payload the Baselines page shows (Get-CIPPBaselineAlignment), composes it\nthrough the shared CIPPSharp component kit (Build-CippBaselineWhatIfReportTree) and returns\nthe finished PDF. Nothing is changed by producing it.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "simulatedTemplateId": {
+ "type": "string",
+ "description": "Optional: the GUID of a baseline not assigned to the tenant, to preview what assigning it would roll out stage by stage."
+ },
+ "tenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "simulatedTemplateId",
+ "in": "query",
+ "description": "Optional: the GUID of a baseline not assigned to the tenant, to preview what assigning it would roll out stage by stage.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Tenant.Baselines.Read"
+ }
+ },
+ "/api/ExecGetBecReportPdf": {
+ "post": {
+ "summary": "ExecGetBecReportPdf",
+ "operationId": "ExecGetBecReportPdf",
+ "tags": [
+ "Identity > Administration > Users"
+ ],
+ "description": "Server-renders a stored Business Email Compromise (BEC) run as application/pdf bytes. Reads the\nrun through Get-CIPPBecReport (the BecReports metadata row plus its BecResults payload) and\ncomposes it through the shared CIPPSharp component kit (Build-CippBecReportTree) - the server-side\nreplacement for the client react-pdf BECRemediationReportButton. A run is named by its caseId;\npass userId instead to render the user's newest completed run. The run must have completed - the\nreport reads its stored result, it does not trigger a new investigation.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "caseId": {
+ "type": "string",
+ "description": "The stored run to render. A caseId names it directly; a userId picks the user's newest completed run."
+ },
+ "tenantFilter": {
+ "type": "string"
+ },
+ "userDisplayName": {
+ "type": "string",
+ "description": "The investigated user labels the cover and footer; the run stored who it was for."
+ },
+ "userId": {
+ "type": "string"
+ },
+ "userName": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "caseId",
+ "in": "query",
+ "description": "The stored run to render. A caseId names it directly; a userId picks the user's newest completed run.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ },
+ {
+ "name": "userDisplayName",
+ "in": "query",
+ "description": "The investigated user labels the cover and footer; the run stored who it was for.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "userId",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "userName",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "404": {
+ "description": "Not found"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Identity.User.Read"
+ }
+ },
+ "/api/ExecGetExecutiveReportPdf": {
+ "post": {
+ "summary": "ExecGetExecutiveReportPdf",
+ "operationId": "ExecGetExecutiveReportPdf",
+ "tags": [
+ "Tenant > Standards"
+ ],
+ "description": "Server-renders the Executive Summary report as application/pdf bytes. Every figure is read from the\nnightly Reporting DB cache via New-CIPPDbRequest (Users/Guests/Roles for the environment overview,\nLicenseOverview, ManagedDevices, ConditionalAccessPolicies and SecureScore) plus the standards\ncomparison from the CippStandardsReports table resolved against the standards catalog - the same\ncached data the rest of CIPP reports from, with no live Graph or cross-endpoint HTTP calls. The\nshaped data is composed through the shared CIPPSharp component kit (Build-CippExecutiveReportTree),\nthe server-side replacement for the client react-pdf ExecutiveReportButton. Each source is gathered\ndefensively: a source with no cached data simply drops its section.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "brandingPresetId": {
+ "type": "string",
+ "description": "The branding preset a caller picked for this render, else the global branding settings."
+ },
+ "sectionConfig": {
+ "type": "string"
+ },
+ "tenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "brandingPresetId",
+ "in": "query",
+ "description": "The branding preset a caller picked for this render, else the global branding settings.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.",
+ "properties": {
+ "CurrentValue": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "ETag": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "ExpectedValue": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "LicenseAvailable": {
+ "type": "boolean",
+ "x-cipp-field-source": "storage"
+ },
+ "PartitionKey": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "RowKey": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "TemplateId": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "Timestamp": {
+ "type": "string",
+ "x-cipp-field-source": "storage"
+ },
+ "Value": {
+ "x-cipp-field-source": "storage"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Tenant.Standards.Read"
+ }
+ },
"/api/ExecGetLocalAdminPassword": {
"post": {
"summary": "ExecGetLocalAdminPassword",
@@ -22929,6 +23197,143 @@
"x-cipp-role": "Endpoint.Device.Read"
}
},
+ "/api/ExecGetMailFlowReportPdf": {
+ "post": {
+ "summary": "ExecGetMailFlowReportPdf",
+ "operationId": "ExecGetMailFlowReportPdf",
+ "tags": [
+ "Email-Exchange > Reports"
+ ],
+ "description": "Server-renders the Exchange mail flow report as application/pdf bytes. Reads the same three Exchange\nreports the Mail Flow page uses (Get-MailFlowStatusReport plus the TopMailSender and\nTopSpamRecipient traffic summaries), aggregates the daily disposition rows the way the page does,\nand composes them through the shared CIPPSharp kit (Build-CippMailFlowReportTree) - the server-side\nreplacement for the client react-pdf MailFlowReportButton.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "days",
+ "in": "query",
+ "description": "Reporting window in days (1-90).",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Exchange.Mailbox.Read"
+ }
+ },
+ "/api/ExecGetPermissionsReportPdf": {
+ "post": {
+ "summary": "ExecGetPermissionsReportPdf",
+ "operationId": "ExecGetPermissionsReportPdf",
+ "tags": [
+ "Teams-Sharepoint"
+ ],
+ "description": "Server-renders the SharePoint Permissions report as application/pdf bytes. Gathers the same shaped\ndata the Permissions page uses (ListSharePointPermissions, from the CIPP reporting cache), composes\nit through the shared CIPPSharp component kit (Build-CippPermissionsReportTree) and returns the\nfinished PDF - the server-side replacement for the client react-pdf PermissionsReportButton.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Sharepoint.Site.Read"
+ }
+ },
"/api/ExecGetRecoveryKey": {
"post": {
"summary": "ExecGetRecoveryKey",
@@ -23009,6 +23414,205 @@
"x-cipp-role": "Endpoint.Device.Read"
}
},
+ "/api/ExecGetReportBuilderPdf": {
+ "get": {
+ "summary": "ExecGetReportBuilderPdf",
+ "operationId": "ExecGetReportBuilderPdf",
+ "tags": [
+ "Tools > Report-Builder"
+ ],
+ "description": "Returns the server-rendered PDF for a generated Report Builder report as application/pdf bytes.\nBacks both the in-app preview (shown in an iframe) and the download button on the view page.\n404 when the report has no rendered PDF (generated before server-side rendering, or its render\nfailed).",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "query",
+ "description": "The generated report's GUID.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "ReportGUID",
+ "in": "query",
+ "description": "The generated report's GUID.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "404": {
+ "description": "Not found"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "CIPP.Core.Read",
+ "x-cipp-any-tenant": true
+ }
+ },
+ "/api/ExecGetShadowAIReportPdf": {
+ "post": {
+ "summary": "ExecGetShadowAIReportPdf",
+ "operationId": "ExecGetShadowAIReportPdf",
+ "tags": [
+ "Tenant > Standards"
+ ],
+ "description": "Server-renders the Shadow AI report as application/pdf bytes. Gathers the same shaped data the\nShadow AI page uses (ListShadowAI) and composes it through the shared CIPPSharp component kit\n(Build-CippShadowAIReportTree) - the server-side replacement for the client react-pdf\nShadowAIReportButton.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "sectionConfig": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "tenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Tenant.Standards.Read"
+ }
+ },
+ "/api/ExecGetSharingReportPdf": {
+ "post": {
+ "summary": "ExecGetSharingReportPdf",
+ "operationId": "ExecGetSharingReportPdf",
+ "tags": [
+ "Teams-Sharepoint"
+ ],
+ "description": "Server-renders the SharePoint & OneDrive Sharing report as application/pdf bytes. Gathers the same\nshaped data the Sharing page uses (ListSharePointSharing, from the CIPP reporting cache), composes\nit through the shared CIPPSharp component kit (Build-CippSharingReportTree) and returns the finished\nPDF - the server-side replacement for the client react-pdf SharingReportButton.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "Sharepoint.Site.Read"
+ }
+ },
"/api/ExecGitHubAction": {
"post": {
"summary": "Invoke GitHub Action",
@@ -29428,6 +30032,151 @@
"x-cipp-role": "Identity.Role.ReadWrite"
}
},
+ "/api/ExecPreviewBrandingReportPdf": {
+ "post": {
+ "summary": "ExecPreviewBrandingReportPdf",
+ "operationId": "ExecPreviewBrandingReportPdf",
+ "tags": [
+ "CIPP > Settings"
+ ],
+ "description": "Renders a sample report against a branding configuration - normally the unsaved state of the\nbranding editor - and returns it as application/pdf bytes, so a colour, logo, cover, footer or\nwatermark can be judged on every page of a real report before it is saved. Each report type has\na fixed sample of the data its builder needs (Config/ReportSamples/.json); nothing is read\nfrom a tenant.",
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "branding": {
+ "type": "object",
+ "properties": {
+ "tenantLabel": {
+ "type": "string",
+ "description": "The sample tenant, named the way the branding under edit would name a real one."
+ }
+ },
+ "description": "The branding to render against, in the shape Get-CIPPBrandingSettings returns: colours (flat or under roleColours), a data-URL logo and cover or a coverStock path, footer, watermark and tenantLabel. Omitted -> the saved branding settings."
+ },
+ "reportType": {
+ "type": "string",
+ "description": "Which report to preview: executive, reportBuilder, shadowAI, bec, sharing, permissions or mailFlow."
+ },
+ "tenantFilter": {
+ "type": "string",
+ "description": "Optional: the tenant whose %variables% (%cippurl%, custom variables) resolve in the footer and cover."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request - missing required field or invalid input"
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "CIPP.AppSettings.Read",
+ "x-cipp-any-tenant": true
+ }
+ },
+ "/api/ExecPreviewReportBuilderPdf": {
+ "post": {
+ "summary": "ExecPreviewReportBuilderPdf",
+ "operationId": "ExecPreviewReportBuilderPdf",
+ "tags": [
+ "Tools > Report-Builder"
+ ],
+ "description": "Renders the current (unsaved) Report Builder state to a PDF and returns it as application/pdf\nbytes without persisting a generated-report row. Powers the builder's live preview and download.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "Blocks": {
+ "type": "string"
+ },
+ "Settings": {
+ "type": "string"
+ },
+ "TemplateGUID": {
+ "type": "string"
+ },
+ "TemplateName": {
+ "type": "string"
+ },
+ "TenantFilter": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "TenantFilter"
+ ]
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/tenantFilter"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing bearer token"
+ },
+ "403": {
+ "description": "Forbidden - caller lacks the required RBAC role"
+ },
+ "500": {
+ "description": "Internal server error"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "x-cipp-role": "CIPP.Core.ReadWrite"
+ }
+ },
"/api/ExecQuarantineManagement": {
"post": {
"summary": "ExecQuarantineManagement",
@@ -44231,7 +44980,7 @@
"tags": [
"CIPP > Core"
],
- "description": "Retrieves cached tenant data from the CIPP reporting database (CippReportingDB). This is the fastest\nand most efficient way to query tenant data across single or multiple tenants. The database is populated\nnightly by background cache jobs, so data is typically at most 24 hours old.\n\nRequired query parameters:\n - tenantFilter: The tenant domain or 'AllTenants' to query all managed tenants.\n - type: The cache collection to retrieve (e.g. Users, Groups, Mailboxes, Devices, etc.).\n Not required when countsOnly=true.\n\nOptional query parameters:\n - countsOnly: When 'true', returns one row per tenant per collection containing only the record\n count and the time that collection was last cached. This reads the pre-computed\n '-Count' rows, so it is a single table query regardless of tenant count and\n never materializes the underlying records. Combine with tenantFilter=AllTenants to\n get an estate-wide inventory and per-tenant cache freshness in one call. Pass a\n type alongside it to restrict the result to a single collection.\n\nUse type=_availableTypes to discover which cache collections exist for a given tenant. Omitting the\ntype parameter also returns the available types.\n\nPERFORMANCE GUIDANCE: For AllTenants queries or any bulk/cross-tenant data retrieval, prefer\nListDBCache over calling individual endpoints (e.g. ListUsers, ListGroups, ListMailboxes) directly.\nIndividual endpoints make live API calls per tenant which is significantly slower and may hit\nthrottling limits. ListDBCache reads pre-cached data from Azure Table Storage and returns results\nin seconds regardless of tenant count.\n\nNote that tenantFilter=AllTenants WITH a type performs a cross-partition scan and materializes every\nrecord of that collection across every tenant, so it grows with the size of the estate. Where only\ntotals are needed, countsOnly=true is dramatically cheaper and should always be preferred.\n\nRecommended workflow for MCP tool selection:\n 1. Call ListDBCache with type=_availableTypes to discover available cache collections.\n 2. If the data you need exists as a cache type, use ListDBCache with that type.\n 3. Only fall back to individual List* endpoints when you need real-time data for a single tenant\n or when the data is not available in the cache.\n\nCommon cache types include: Users, Groups, Mailboxes, Devices, ConditionalAccess, Applications,\nIntunePolicy, CompliancePolicy, and many more. The exact set depends on what has been configured.",
+ "description": "Retrieves cached tenant data from the CIPP reporting database (CippReportingDB). This is the fastest\nand most efficient way to query tenant data across single or multiple tenants. The database is populated\nnightly by background cache jobs, so data is typically at most 24 hours old.\n\nRequired query parameters:\n - tenantFilter: The tenant domain or 'AllTenants' to query all managed tenants.\n - type: The cache collection to retrieve (e.g. Users, Groups, Mailboxes, Devices, etc.).\n Not required when countsOnly=true.\n\nOptional query parameters:\n - countsOnly: When 'true', returns one row per tenant per collection containing only the record\n count and the time that collection was last cached. This reads the pre-computed\n '-Count' rows, so it is a single table query regardless of tenant count and\n never materializes the underlying records. Combine with tenantFilter=AllTenants to\n get an estate-wide inventory and per-tenant cache freshness in one call. Pass a\n type alongside it to restrict the result to a single collection.\n\nUse type=_shape for every collection's row count and fields (recorded when the cache was written).\nUse type=_availableTypes to discover which cache collections exist for a given tenant. Omitting the\ntype parameter also returns the available types.\n\nPERFORMANCE GUIDANCE: For AllTenants queries or any bulk/cross-tenant data retrieval, prefer\nListDBCache over calling individual endpoints (e.g. ListUsers, ListGroups, ListMailboxes) directly.\nIndividual endpoints make live API calls per tenant which is significantly slower and may hit\nthrottling limits. ListDBCache reads pre-cached data from Azure Table Storage and returns results\nin seconds regardless of tenant count.\n\nNote that tenantFilter=AllTenants WITH a type performs a cross-partition scan and materializes every\nrecord of that collection across every tenant, so it grows with the size of the estate. Where only\ntotals are needed, countsOnly=true is dramatically cheaper and should always be preferred.\n\nRecommended workflow for MCP tool selection:\n 1. Call ListDBCache with type=_availableTypes to discover available cache collections.\n 2. If the data you need exists as a cache type, use ListDBCache with that type.\n 3. Only fall back to individual List* endpoints when you need real-time data for a single tenant\n or when the data is not available in the cache.\n\nCommon cache types include: Users, Groups, Mailboxes, Devices, ConditionalAccess, Applications,\nIntunePolicy, CompliancePolicy, and many more. The exact set depends on what has been configured.",
"parameters": [
{
"name": "countsOnly",
@@ -56181,359 +56930,7 @@
"type": "array",
"items": {
"type": "object",
- "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.",
- "properties": {
- "agent": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "aiToolsDetected": {
- "x-cipp-field-source": "backend"
- },
- "appDisplayName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "appId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "appliedConditionalAccessPolicies": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "appliedEventListeners": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "appOwnerTenantId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "appTokenProtectionStatus": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationAppDeviceDetails": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationAppPolicyEvaluationDetails": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationContextClassReferences": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationDetails": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationMethodsUsed": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationProcessingDetails": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationProtocol": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationRequirement": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "authenticationRequirementPolicies": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "autonomousSystemNumber": {
- "type": "integer",
- "x-cipp-field-source": "graph-entity"
- },
- "azureResourceId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "byCategory": {
- "x-cipp-field-source": "backend"
- },
- "byRisk": {
- "x-cipp-field-source": "backend"
- },
- "clientAppUsed": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "clientCredentialType": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "clientSessionId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "conditionalAccessAudiences": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "conditionalAccessStatus": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "consentedAiApps": {
- "x-cipp-field-source": "backend"
- },
- "consentedApps": {
- "x-cipp-field-source": "backend"
- },
- "correlationId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "createdDateTime": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "crossTenantAccessType": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "detectedApps": {
- "x-cipp-field-source": "backend"
- },
- "deviceDetail": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "deviceInstalls": {
- "x-cipp-field-source": "backend"
- },
- "entraSynced": {
- "x-cipp-field-source": "backend"
- },
- "federatedCredentialId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "flaggedForReview": {
- "type": "boolean",
- "x-cipp-field-source": "graph-entity"
- },
- "globalSecureAccessIpAddress": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "highRiskTools": {
- "x-cipp-field-source": "backend"
- },
- "homeTenantId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "homeTenantName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "id": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "incomingTokenType": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "intuneSynced": {
- "x-cipp-field-source": "backend"
- },
- "ipAddress": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "ipAddressFromResourceProvider": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "isInteractive": {
- "type": "boolean",
- "x-cipp-field-source": "graph-entity"
- },
- "isTenantRestricted": {
- "type": "boolean",
- "x-cipp-field-source": "graph-entity"
- },
- "isThroughGlobalSecureAccess": {
- "type": "boolean",
- "x-cipp-field-source": "graph-entity"
- },
- "lastDataRefresh": {
- "x-cipp-field-source": "backend"
- },
- "location": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "managedServiceIdentity": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "mfaDetail": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "networkLocationDetails": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "originalRequestId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "originalTransferMethod": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "privateLinkDetails": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "processingTimeInMilliseconds": {
- "type": "integer",
- "x-cipp-field-source": "graph-entity"
- },
- "resourceDisplayName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "resourceId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "resourceOwnerTenantId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "resourceServicePrincipalId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "resourceTenantId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "riskDetail": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "riskEventTypes_v2": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "riskLevelAggregated": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "riskLevelDuringSignIn": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "riskState": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "sanctionedTools": {
- "x-cipp-field-source": "backend"
- },
- "servicePrincipalCredentialKeyId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "servicePrincipalCredentialThumbprint": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "servicePrincipalId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "servicePrincipalName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "sessionId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "sessionLifetimePolicies": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "signInEventTypes": {
- "type": "array",
- "x-cipp-field-source": "graph-entity"
- },
- "signInIdentifier": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "signInIdentifierType": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "signInTokenProtectionStatus": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "status": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "summary": {
- "x-cipp-field-source": "backend"
- },
- "tokenIssuerName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "tokenIssuerType": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "tokenProtectionStatusDetails": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- },
- "topTools": {
- "x-cipp-field-source": "backend"
- },
- "uniqueTokenIdentifier": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "userAgent": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "userDisplayName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "userId": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "userPrincipalName": {
- "type": "string",
- "x-cipp-field-source": "graph-entity"
- },
- "userType": {
- "type": "object",
- "x-cipp-field-source": "graph-entity"
- }
- }
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
}
}
}
@@ -56813,78 +57210,7 @@
"type": "array",
"items": {
"type": "object",
- "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.",
- "properties": {
- "assignments": {
- "x-cipp-field-source": "backend"
- },
- "broadClaimGrants": {
- "x-cipp-field-source": "backend"
- },
- "byBroadClaim": {
- "x-cipp-field-source": "backend"
- },
- "byPermissionLevel": {
- "x-cipp-field-source": "backend"
- },
- "byPrincipalType": {
- "x-cipp-field-source": "backend"
- },
- "claim": {
- "x-cipp-field-source": "backend"
- },
- "directFullControlGrants": {
- "x-cipp-field-source": "backend"
- },
- "externalGrants": {
- "x-cipp-field-source": "backend"
- },
- "grants": {
- "x-cipp-field-source": "backend"
- },
- "lastDataRefresh": {
- "x-cipp-field-source": "backend"
- },
- "level": {
- "x-cipp-field-source": "backend"
- },
- "libraries": {
- "x-cipp-field-source": "backend"
- },
- "librariesScanned": {
- "x-cipp-field-source": "backend"
- },
- "permissionsSynced": {
- "x-cipp-field-source": "backend"
- },
- "site": {
- "x-cipp-field-source": "backend"
- },
- "sitesScanned": {
- "x-cipp-field-source": "backend"
- },
- "sitesSkipped": {
- "x-cipp-field-source": "backend"
- },
- "skippedSites": {
- "x-cipp-field-source": "backend"
- },
- "summary": {
- "x-cipp-field-source": "backend"
- },
- "topSitesByUniqueLibraries": {
- "x-cipp-field-source": "backend"
- },
- "totalAssignments": {
- "x-cipp-field-source": "backend"
- },
- "type": {
- "x-cipp-field-source": "backend"
- },
- "uniquePermissionLibraries": {
- "x-cipp-field-source": "backend"
- }
- }
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
}
}
}
@@ -57135,111 +57461,7 @@
"type": "array",
"items": {
"type": "object",
- "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.",
- "properties": {
- "anonymousEditLinks": {
- "x-cipp-field-source": "backend"
- },
- "anonymousLinks": {
- "x-cipp-field-source": "backend"
- },
- "byLinkType": {
- "x-cipp-field-source": "backend"
- },
- "byScope": {
- "x-cipp-field-source": "backend"
- },
- "externalLinks": {
- "x-cipp-field-source": "backend"
- },
- "externalRecipients": {
- "x-cipp-field-source": "backend"
- },
- "folderShares": {
- "x-cipp-field-source": "backend"
- },
- "internalLinks": {
- "x-cipp-field-source": "backend"
- },
- "itemsShared": {
- "x-cipp-field-source": "backend"
- },
- "lastDataRefresh": {
- "x-cipp-field-source": "backend"
- },
- "library": {
- "x-cipp-field-source": "backend"
- },
- "links": {
- "x-cipp-field-source": "backend"
- },
- "linksSynced": {
- "x-cipp-field-source": "backend"
- },
- "neverExpiringAnonymous": {
- "x-cipp-field-source": "backend"
- },
- "oneDriveAccounts": {
- "x-cipp-field-source": "backend"
- },
- "oneDriveFiles": {
- "x-cipp-field-source": "backend"
- },
- "oneDriveStorageUsedGB": {
- "x-cipp-field-source": "backend"
- },
- "passwordProtectedLinks": {
- "x-cipp-field-source": "backend"
- },
- "recipient": {
- "x-cipp-field-source": "backend"
- },
- "scope": {
- "x-cipp-field-source": "backend"
- },
- "sharePointFiles": {
- "x-cipp-field-source": "backend"
- },
- "sharePointSites": {
- "x-cipp-field-source": "backend"
- },
- "sharePointStorageUsedGB": {
- "x-cipp-field-source": "backend"
- },
- "site": {
- "x-cipp-field-source": "backend"
- },
- "summary": {
- "x-cipp-field-source": "backend"
- },
- "teamsFiles": {
- "x-cipp-field-source": "backend"
- },
- "teamsSites": {
- "x-cipp-field-source": "backend"
- },
- "teamsStorageUsedGB": {
- "x-cipp-field-source": "backend"
- },
- "topLibraries": {
- "x-cipp-field-source": "backend"
- },
- "topRecipients": {
- "x-cipp-field-source": "backend"
- },
- "topSites": {
- "x-cipp-field-source": "backend"
- },
- "totalLinks": {
- "x-cipp-field-source": "backend"
- },
- "type": {
- "x-cipp-field-source": "backend"
- },
- "usageSynced": {
- "x-cipp-field-source": "backend"
- }
- }
+ "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides."
}
}
}
diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1
index 597a479adb..53cd4d1343 100644
--- a/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1
+++ b/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1
@@ -57,6 +57,52 @@ function Add-CIPPDbItem {
$RunStartUtc = [DateTimeOffset]::UtcNow.AddMinutes(-$SkewMarginMinutes)
$TotalProcessed = 0
+
+ # The collection's shape - its fields and their types, one nested level deep - sampled from the
+ # first rows written and stored on the '-Count' row (Shape), so the report builder can
+ # offer a collection's fields without reading the collection. Sampled, not exhaustive: a field
+ # only some rows carry may be missed, which the picker tolerates by accepting a typed name.
+ $ShapeFields = [ordered]@{}
+ $ShapeSampled = 0
+ $ShapeSampleSize = 50
+ $ShapeMaxFields = 300
+ $TypeOf = {
+ param($Value)
+ if ($null -eq $Value) { return 'null' }
+ if ($Value -is [bool]) { return 'boolean' }
+ if ($Value -is [datetime] -or $Value -is [DateTimeOffset]) { return 'date' }
+ if ($Value -is [string]) { return $(if ($Value -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}') { 'date' } else { 'string' }) }
+ if ($Value.GetType().IsPrimitive -or $Value -is [decimal]) { return 'number' }
+ if ($Value -is [System.Collections.IDictionary] -or $Value -is [System.Management.Automation.PSCustomObject]) { return 'object' }
+ if ($Value -is [System.Collections.IEnumerable]) { return 'array' }
+ 'string'
+ }
+ $PropertiesOf = {
+ param($Value)
+ if ($Value -is [System.Collections.IDictionary]) { foreach ($Key in $Value.Keys) { @{ Name = "$Key"; Value = $Value[$Key] } } }
+ elseif ($Value -is [System.Management.Automation.PSCustomObject]) { foreach ($Property in $Value.PSObject.Properties) { @{ Name = $Property.Name; Value = $Property.Value } } }
+ }
+ $NoteField = {
+ param([string]$Name, $Value)
+ if ($ShapeFields.Count -ge $ShapeMaxFields -and -not $ShapeFields.Contains($Name)) { return }
+ $Kind = & $TypeOf $Value
+ if (-not $ShapeFields.Contains($Name) -or $ShapeFields[$Name] -eq 'null') { $ShapeFields[$Name] = $Kind }
+ $Kind
+ }
+ $NoteShape = {
+ param($Item)
+ if ($ShapeSampled -ge $ShapeSampleSize) { return }
+ $ShapeSampled++
+ foreach ($Property in @(& $PropertiesOf $Item)) {
+ $Kind = & $NoteField $Property.Name $Property.Value
+ # one level in: an object's own fields, or the fields of an array's first object
+ $Inner = if ($Kind -eq 'object') { $Property.Value } elseif ($Kind -eq 'array') { @($Property.Value | Select-Object -First 1)[0] }
+ if ($null -ne $Inner -and (& $TypeOf $Inner) -eq 'object') {
+ foreach ($Child in @(& $PropertiesOf $Inner)) { $null = & $NoteField "$($Property.Name).$($Child.Name)" $Child.Value }
+ }
+ }
+ }
+
# Cache regex instances so each row pays only the match cost, not regex compilation.
# Two passes preserve the original semantics: path/wildcard chars → '_', control chars → stripped.
$RowKeyPathRegex = [regex]::new('[/\\#?]')
@@ -83,6 +129,7 @@ function Add-CIPPDbItem {
$ItemId = $Item.ExternalDirectoryObjectId ?? $Item.id ?? $Item.Identity ?? $Item.skuId ?? $Item.userPrincipalName ?? [guid]::NewGuid().ToString()
$RowKey = $RowKeyControlRegex.Replace($RowKeyPathRegex.Replace("$Type-$ItemId", '_'), '')
if ($SeenInBatch.Add($RowKey)) {
+ & $NoteShape $Item
$Batch.Add(@{
PartitionKey = $TenantFilter
RowKey = $RowKey
@@ -154,16 +201,32 @@ function Add-CIPPDbItem {
if ($Count.IsPresent -or $AddCount.IsPresent) {
$NewCount = $TotalProcessed
- if ($Append.IsPresent) {
+ # The existing count row is read when appending (to add to its count) and when this call
+ # sampled no rows (a count-only call after the rows went in separately), so a shape
+ # already recorded is kept rather than wiped.
+ $ExistingCount = $null
+ if ($Append.IsPresent -or $ShapeFields.Count -eq 0) {
$Filter = "PartitionKey eq '{0}' and RowKey eq '{1}-Count'" -f $TenantFilter, $Type
$ExistingCount = Get-CIPPAzDataTableEntity @Table -Filter $Filter
- if ($ExistingCount.DataCount) { $NewCount += [int]$ExistingCount.DataCount }
}
+ if ($Append.IsPresent -and $ExistingCount.DataCount) { $NewCount += [int]$ExistingCount.DataCount }
+ if (($Append.IsPresent -or $ShapeFields.Count -eq 0) -and $ExistingCount.Shape) {
+ try {
+ foreach ($Known in @((ConvertFrom-Json -InputObject "$($ExistingCount.Shape)").fields)) {
+ if ($Known.name -and -not $ShapeFields.Contains([string]$Known.name)) { $ShapeFields[[string]$Known.name] = [string]$Known.type }
+ }
+ } catch { Write-Verbose "Unreadable shape on $Type-Count; recording afresh." }
+ }
+ $ShapeJson = ConvertTo-Json -InputObject @{
+ fields = @(foreach ($Name in $ShapeFields.Keys) { @{ name = $Name; type = $ShapeFields[$Name] } })
+ sampledAt = (Get-Date).ToUniversalTime().ToString('o')
+ } -Depth 5 -Compress
$null = Add-CIPPAzDataTableEntity @Table -Entity @{
PartitionKey = $TenantFilter
RowKey = "$Type-Count"
DataCount = [int]$NewCount
Type = $Type
+ Shape = $ShapeJson
} -Force
}
diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPImage.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPImage.ps1
index 2168999eb7..804fb7581f 100644
--- a/backend/Modules/CIPPCore/Public/Add-CIPPImage.ps1
+++ b/backend/Modules/CIPPCore/Public/Add-CIPPImage.ps1
@@ -28,8 +28,17 @@ function Add-CIPPImage {
if ($Data -notmatch '^data:image\/([^;]+);base64,') {
throw 'Invalid image format. Expected a data URL image (data:image/...;base64,...).'
}
+ $Subtype = $Matches[1].ToLowerInvariant()
- $ContentType = "image/$($Matches[1])"
+ # The formats the report engine draws (ReportComponents.NormaliseImage: rasters handed to OfficeIMO
+ # as-is, SVG rasterised once). Anything else would be stored, sent down on every page load, and then
+ # dropped at render time. The branding page enforces the same list before uploading.
+ $SupportedTypes = @('png', 'jpeg', 'jpg', 'gif', 'bmp', 'tiff', 'webp', 'svg+xml')
+ if ($Subtype -notin $SupportedTypes) {
+ throw "Unsupported image format 'image/$Subtype'. Use PNG, JPEG, GIF, BMP, TIFF, WebP or SVG."
+ }
+
+ $ContentType = "image/$Subtype"
$Base64Data = $Data -replace '^data:image\/[^;]+;base64,', ''
try {
$ImageBytes = [Convert]::FromBase64String($Base64Data)
diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
index a77fabb7ed..76f7fe6a58 100644
--- a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
+++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1
@@ -5,18 +5,14 @@ function New-CIPPBecEvidencePackage {
.DESCRIPTION
Collates everything CIPP holds about a case into one ZIP: the results payload as JSON, one
CSV per finding set, the score, the containment history, every logbook line stamped with the
- case id, and the browser-rendered PDF reports when supplied. Nothing is stored: the ZIP is
- returned to the caller to hand to the browser. Everything inside is metadata the run already
- collected; passwords were redacted before they were stored and are scrubbed from the logbook
- copy again here.
+ case id, and the full and C-suite-summary report PDFs rendered server-side. Nothing is stored:
+ the ZIP is returned to the caller to hand to the browser. Everything inside is metadata the run
+ already collected; passwords were redacted before they were stored and are scrubbed from the
+ logbook copy again here.
.PARAMETER TenantFilter
Tenant default domain name.
.PARAMETER CaseId
The run to package.
- .PARAMETER PdfBase64
- Optional base64-encoded full PDF report rendered by the frontend.
- .PARAMETER PdfSummaryBase64
- Optional base64-encoded C-suite summary PDF rendered by the frontend.
.PARAMETER Headers
CIPP request headers, for logging.
.PARAMETER APIName
@@ -28,8 +24,6 @@ function New-CIPPBecEvidencePackage {
param(
[Parameter(Mandatory = $true)][string]$TenantFilter,
[Parameter(Mandatory = $true)][string]$CaseId,
- [string]$PdfBase64,
- [string]$PdfSummaryBase64,
$Headers,
[string]$APIName = 'BECEvidenceExport'
)
@@ -97,18 +91,31 @@ function New-CIPPBecEvidencePackage {
}
$Files['logbook.json'] = $Utf8.GetBytes((ConvertTo-Json -InputObject @($LogRows) -Depth 10))
- # The frontend renders the reports client-side (react-pdf), so it hands the PDFs in. Validate and
- # add each supplied one - the full report and the C-suite summary.
- $AddPdf = {
- param([string]$Base64, [string]$Name)
- if ([string]::IsNullOrWhiteSpace($Base64)) { return }
- $PdfBytes = [System.Convert]::FromBase64String(($Base64 -replace '^data:application/pdf;base64,', ''))
- if ($PdfBytes.Length -gt 25MB) { throw "The PDF report ($Name) exceeds 25 MB" }
- if ($PdfBytes.Length -lt 4 -or [System.Text.Encoding]::ASCII.GetString($PdfBytes, 0, 4) -ne '%PDF') { throw "The supplied $Name report is not a PDF" }
- $Files[$Name] = $PdfBytes
+ # Render the report PDFs server-side (the full report and the C-suite summary) through the same
+ # builder and kit the report endpoint uses. The builder reads containment off becData.Run, so
+ # attach the run block exactly as ExecGetBecReportPdf does. A render failure must not lose the rest
+ # of the evidence, so it is caught and the package ships without the PDFs.
+ try {
+ $BecData = $Results
+ $RunBlock = [pscustomobject]@{
+ CaseId = $Run.CaseId
+ Status = $Run.Status
+ ExtractedAt = $Run.ExtractedAt
+ RequestedAt = $Run.RequestedAt
+ RequestedBy = $Run.RequestedBy
+ Containment = $Run.Containment
+ }
+ $BecData | Add-Member -NotePropertyName 'Run' -NotePropertyValue $RunBlock -Force
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+ $DisplayName = @($Run.DisplayName, $Run.UserPrincipalName, $Run.UserId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
+ $UserData = [pscustomobject]@{ displayName = $DisplayName; userPrincipalName = $Run.UserPrincipalName; id = $Run.UserId }
+ $Full = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName
+ $Files['report-full.pdf'] = ConvertTo-CippReportPdf -Blocks $Full.Blocks -Variables $Full.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report'
+ $Summary = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName -Variant summary
+ $Files['report-summary.pdf'] = ConvertTo-CippReportPdf -Blocks $Summary.Blocks -Variables $Summary.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report'
+ } catch {
+ Write-Information "BEC evidence: server-side PDF render failed for $CaseId`: $($_.Exception.Message)"
}
- & $AddPdf $PdfBase64 'report-full.pdf'
- & $AddPdf $PdfSummaryBase64 'report-summary.pdf'
$Stream = [System.IO.MemoryStream]::new()
$Archive = [System.IO.Compression.ZipArchive]::new($Stream, [System.IO.Compression.ZipArchiveMode]::Create, $true)
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPBrandingPreset.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPBrandingPreset.ps1
index 76de098da4..b32b3e60a7 100644
--- a/backend/Modules/CIPPCore/Public/Get-CIPPBrandingPreset.ps1
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPBrandingPreset.ps1
@@ -90,6 +90,7 @@ function Get-CIPPBrandingPreset {
showPageNumbers = if ($null -eq $Entity.showPageNumbers) { $true } else { [bool]$Entity.showPageNumbers }
watermarkText = if ($Entity.watermarkText) { "$($Entity.watermarkText)" } else { '' }
watermarkEnabled = if ($null -eq $Entity.watermarkEnabled) { $true } else { [bool]$Entity.watermarkEnabled }
+ tenantLabel = if (@('alias', 'name', 'domain') -contains "$($Entity.tenantLabel)") { "$($Entity.tenantLabel)" } else { 'alias' }
}
}
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPBrandingSettings.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPBrandingSettings.ps1
index 3548ad5333..db5590381b 100644
--- a/backend/Modules/CIPPCore/Public/Get-CIPPBrandingSettings.ps1
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPBrandingSettings.ps1
@@ -265,6 +265,13 @@ function Get-CIPPBrandingSettings {
$ShowPageNumbers = if ($null -eq $BrandingConfig.showPageNumbers) { $true } else { [bool]$BrandingConfig.showPageNumbers }
$WatermarkEnabled = if ($null -eq $BrandingConfig.watermarkEnabled) { $true } else { [bool]$BrandingConfig.watermarkEnabled }
+ # The gallery covers by id and name, without their data: what a picker elsewhere (an Infographic
+ # page's background in the report builder) needs to offer them.
+ $CoverNames = Get-CIPPImageNameMap -PartitionKey 'brandingCover'
+ $CoverImages = @(foreach ($Cid in $CoverImageIds) {
+ [pscustomobject]@{ id = [string]$Cid; name = if ($CoverNames.ContainsKey([string]$Cid)) { $CoverNames[[string]$Cid] } else { '' } }
+ })
+
return [pscustomobject]@{
colour = if ($BrandingConfig.colour) { $BrandingConfig.colour } else { '#F77F00' }
secondaryColour = $SecondaryColour
@@ -277,6 +284,7 @@ function Get-CIPPBrandingSettings {
logoUploads = [string[]]@($LogoUploads)
coverImage = $CoverImageData
coverUploads = [string[]]@($CoverUploads)
+ coverImages = $CoverImages
footerText = $FooterText
coverFooterText = $CoverFooterText
showFooter = $ShowFooter
@@ -285,5 +293,7 @@ function Get-CIPPBrandingSettings {
watermarkEnabled = $WatermarkEnabled
reportDefaults = $ReportDefaults
roleColours = $RoleColours
+ # Which of the tenant's names a report prints; 'alias' is the name CIPP shows, the old behaviour.
+ tenantLabel = if (@('alias', 'name', 'domain') -contains "$($BrandingConfig.tenantLabel)") { "$($BrandingConfig.tenantLabel)" } else { 'alias' }
}
}
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPDbItem.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPDbItem.ps1
index ba4f2d4a5d..3fd2708e64 100644
--- a/backend/Modules/CIPPCore/Public/Get-CIPPDbItem.ps1
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPDbItem.ps1
@@ -30,7 +30,11 @@ function Get-CIPPDbItem {
[string]$Type,
[Parameter(Mandatory = $false)]
- [switch]$CountsOnly
+ [switch]$CountsOnly,
+
+ # With -CountsOnly: also return each collection's recorded Shape (fields and types).
+ [Parameter(Mandatory = $false)]
+ [switch]$IncludeShape
)
try {
@@ -64,7 +68,8 @@ function Get-CIPPDbItem {
$Filter = [string]::Join(' and ', $Conditions)
# -Property does the projection server-side; the trailing Select-Object was
# redundant (and rebuilt every row as a NoteProperty bag, slowing later filters).
- $Results = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property 'PartitionKey', 'RowKey', 'DataCount', 'Timestamp'
+ $Properties = @('PartitionKey', 'RowKey', 'DataCount', 'Timestamp'; if ($IncludeShape) { 'Shape' })
+ $Results = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property $Properties
} else {
if (-not $Type) {
throw 'Type parameter is required when CountsOnly is not specified'
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPImageNameMap.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPImageNameMap.ps1
new file mode 100644
index 0000000000..90448b8520
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPImageNameMap.ps1
@@ -0,0 +1,32 @@
+function Get-CIPPImageNameMap {
+ <#
+ .FUNCTIONALITY
+ Internal
+ .SYNOPSIS
+ The names given to the gallery images of one kind, as a hashtable of image id -> name.
+ .DESCRIPTION
+ A name lives on a small row of its own (PartitionKey imageMeta, RowKey the image id) rather
+ than on the image row: an image payload is split across several entities, and renaming it
+ must not mean rewriting them. Images that were never named are simply absent from the map.
+ .PARAMETER PartitionKey
+ The image kind the names belong to (logo, brandingCover).
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$PartitionKey
+ )
+
+ $Map = @{}
+ try {
+ $Table = Get-CIPPTable -TableName 'Images'
+ $Rows = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'imageMeta' and kind eq '$($PartitionKey.Replace("'", "''"))'"
+ foreach ($Row in @($Rows)) {
+ if ($Row.RowKey -and -not [string]::IsNullOrWhiteSpace([string]$Row.name)) { $Map[[string]$Row.RowKey] = [string]$Row.name }
+ }
+ } catch {
+ Write-Warning "Failed to read image names for '$PartitionKey': $($_.Exception.Message)"
+ }
+ return $Map
+}
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPShadowAIReport.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPShadowAIReport.ps1
new file mode 100644
index 0000000000..c121c58351
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPShadowAIReport.ps1
@@ -0,0 +1,247 @@
+function Get-CIPPShadowAIReport {
+ <#
+ .FUNCTIONALITY
+ Internal
+ .SYNOPSIS
+ The Shadow AI overview for a tenant, compiled from the CIPP reporting cache.
+ .DESCRIPTION
+ Matches the cached DetectedApps, ServicePrincipals and OAuth2PermissionGrants datasets against
+ the curated AI catalog (Config/ShadowAI.json) into the shape the Shadow AI page (ListShadowAI)
+ and the Shadow AI PDF consume: a summary, tools by category and risk, the top tools across both
+ sources, the AI software found on managed devices and the AI applications in Entra. No live
+ Graph enumeration is performed (refresh via ExecCIPPDBCache); the only live call is a bounded,
+ best-effort 7-day sign-in lookup for the matched AI applications.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$TenantFilter
+ )
+
+ # Curated, PR-editable catalog of known AI tools/apps.
+ try {
+ $Catalog = @(Get-Content (Join-Path $env:CIPPRootPath 'Config\ShadowAI.json') -ErrorAction Stop | ConvertFrom-Json)
+ } catch {
+ Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load Shadow AI catalog. Error: $($_.Exception.Message)" -Sev 'Error'
+ $Catalog = @()
+ }
+
+ # Returns the first catalog entry whose matchNames appear (case-insensitive substring) in $Text.
+ function Get-AiMatch {
+ param($Text, $Catalog)
+ if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
+ $Haystack = $Text.ToLower()
+ foreach ($Entry in $Catalog) {
+ foreach ($Match in $Entry.matchNames) {
+ if ($Match -and $Haystack.Contains($Match.ToLower())) { return $Entry }
+ }
+ }
+ return $null
+ }
+
+ $SanctionedTools = @{}
+ try {
+ $SanctionTable = Get-CIPPTable -TableName 'ShadowAIConfig'
+ $EscapedTenant = $TenantFilter -replace "'", "''"
+ foreach ($Row in @(Get-CIPPAzDataTableEntity @SanctionTable -Filter "PartitionKey eq '$EscapedTenant'")) {
+ $ToolName = if ($Row.Tool) { $Row.Tool } else { $Row.RowKey }
+ if ($ToolName) { $SanctionedTools[$ToolName.ToLower()] = $true }
+ }
+ } catch {
+ Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load sanctioned AI tools: $($_.Exception.Message)" -Sev 'Warning'
+ }
+
+ # --- Cached datasets from the CIPP reporting database (no live Graph enumeration) ---
+ $CacheData = @{}
+ $CacheTimestamps = [System.Collections.Generic.List[object]]::new()
+ foreach ($Type in @('DetectedApps', 'ServicePrincipals', 'OAuth2PermissionGrants')) {
+ $CacheData[$Type] = try { @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type) } catch { @() }
+ $CountRow = try { Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1 } catch { $null }
+ if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) }
+ }
+ $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1
+
+ # 1) Installed AI tools from the cached Intune detected apps. The inventory reports a separate
+ # application entry per version (and per install flavor, e.g. 'Copilot' vs 'Microsoft.Copilot'),
+ # so merge everything that matches the same catalog tool into ONE row: distinct devices only,
+ # with the observed application names, versions and platforms combined.
+ $DetectedAppMap = [ordered]@{}
+ foreach ($App in $CacheData['DetectedApps']) {
+ $Match = Get-AiMatch -Text "$($App.displayName) $($App.publisher)" -Catalog $Catalog
+ if (-not $Match) { continue }
+ if (-not $DetectedAppMap.Contains($Match.name)) {
+ $DetectedAppMap[$Match.name] = [PSCustomObject]@{
+ Match = $Match
+ Sanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower())
+ Applications = [System.Collections.Generic.List[string]]::new()
+ Publishers = [System.Collections.Generic.List[string]]::new()
+ Versions = [System.Collections.Generic.List[string]]::new()
+ Platforms = [System.Collections.Generic.List[string]]::new()
+ Devices = [ordered]@{}
+ }
+ }
+ $Entry = $DetectedAppMap[$Match.name]
+ if ($App.displayName -and $Entry.Applications -notcontains [string]$App.displayName) { $Entry.Applications.Add([string]$App.displayName) }
+ if ($App.publisher -and $Entry.Publishers -notcontains [string]$App.publisher) { $Entry.Publishers.Add([string]$App.publisher) }
+ if ($App.version -and $Entry.Versions -notcontains [string]$App.version) { $Entry.Versions.Add([string]$App.version) }
+ $Platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { [string]$App.platform }
+ if ($Entry.Platforms -notcontains $Platform) { $Entry.Platforms.Add($Platform) }
+ foreach ($Device in @($App.managedDevices ?? @())) {
+ $DeviceKey = if ($Device.id) { [string]$Device.id } else { [string]$Device.deviceName }
+ if ($DeviceKey -and -not $Entry.Devices.Contains($DeviceKey)) { $Entry.Devices[$DeviceKey] = $Device }
+ }
+ }
+
+ $DetectedApps = @(foreach ($Entry in $DetectedAppMap.Values) {
+ $Match = $Entry.Match
+ [PSCustomObject]@{
+ application = ($Entry.Applications | Sort-Object) -join ', '
+ aiTool = $Match.name
+ vendor = $Match.vendor
+ category = $Match.category
+ risk = if ($Entry.Sanctioned) { 'Informational' } else { $Match.risk }
+ catalogRisk = $Match.risk
+ status = if ($Entry.Sanctioned) { 'Sanctioned' } else { 'Unsanctioned' }
+ toolDescription = $Match.description
+ riskReason = $Match.riskReason
+ # Inventory rows mix clean publisher names with full certificate subjects - show the shortest.
+ publisher = $Entry.Publishers | Sort-Object -Property Length | Select-Object -First 1
+ version = ($Entry.Versions | Sort-Object) -join ', '
+ platform = ($Entry.Platforms | Sort-Object) -join ', '
+ deviceCount = $Entry.Devices.Count
+ managedDevices = @($Entry.Devices.Values)
+ }
+ })
+
+ # 2) AI applications in Entra: match ALL cached service principals (not only those with
+ # delegated grants), then attach any granted permissions. First consented = when the
+ # service principal was created in the tenant (the oauth2 grant startTime is unreliable).
+ $GrantsBySp = @{}
+ foreach ($Group in ($CacheData['OAuth2PermissionGrants'] | Where-Object { $_.clientId } | Group-Object clientId)) {
+ $GrantsBySp[$Group.Name] = $Group.Group
+ }
+
+ $ConsentedApps = [System.Collections.Generic.List[object]]::new()
+ $SeenApps = @{}
+ foreach ($Sp in $CacheData['ServicePrincipals']) {
+ $Match = Get-AiMatch -Text $Sp.displayName -Catalog $Catalog
+ if (-not $Match) { continue }
+ $Key = [string]($Sp.appId ?? $Sp.id)
+ if ($SeenApps.ContainsKey($Key)) { continue }
+ # Individual scopes as a string array so the frontend renders them as chips.
+ $Permissions = if ($GrantsBySp.ContainsKey($Sp.id)) {
+ @((@($GrantsBySp[$Sp.id].scope) -join ' ') -split '\s+' | Where-Object { $_ } | Sort-Object -Unique)
+ } else {
+ @()
+ }
+ $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower())
+ $Consent = [PSCustomObject]@{
+ application = $Sp.displayName
+ aiTool = $Match.name
+ vendor = $Match.vendor
+ category = $Match.category
+ risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk }
+ catalogRisk = $Match.risk
+ status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' }
+ toolDescription = $Match.description
+ riskReason = $Match.riskReason
+ applicationId = $Sp.appId
+ approvedPermissions = @($Permissions)
+ firstConsentedDateTime = $Sp.createdDateTime
+ signInsLast7Days = 0
+ activeUsersLast7Days = 0
+ applicationUsers = @()
+ }
+ $SeenApps[$Key] = $Consent
+ $ConsentedApps.Add($Consent)
+ }
+
+ # 2b) Best-effort: recent sign-in usage (last 7 days) for the matched AI apps. This is the only
+ # live Graph call: a single bounded query, skipped gracefully when unavailable (needs P1).
+ $AiAppIds = @($ConsentedApps.applicationId | Where-Object { $_ } | Select-Object -Unique -First 15)
+ if ($AiAppIds.Count -gt 0) {
+ try {
+ $StartDate = (Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $AppFilter = ($AiAppIds | ForEach-Object { "appId eq '$_'" }) -join ' or '
+ $SignInFilter = "createdDateTime ge $StartDate and ($AppFilter)"
+ $SignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=$SignInFilter" -tenantid $TenantFilter
+ $SignInGroups = $SignIns | Group-Object appId
+ foreach ($Consent in $ConsentedApps) {
+ $Group = $SignInGroups | Where-Object { $_.Name -eq $Consent.applicationId }
+ if ($Group) {
+ $Consent.signInsLast7Days = $Group.Count
+ $Consent.activeUsersLast7Days = @($Group.Group.userId | Select-Object -Unique).Count
+ $Consent.applicationUsers = @($Group.Group | Group-Object userPrincipalName | ForEach-Object {
+ [PSCustomObject]@{
+ userPrincipalName = $_.Name
+ userDisplayName = ($_.Group | Select-Object -First 1).userDisplayName
+ signIns = $_.Count
+ lastSignInDateTime = ($_.Group.createdDateTime | Sort-Object -Descending | Select-Object -First 1)
+ }
+ })
+ }
+ }
+ } catch {
+ Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Sign-in usage enrichment skipped (requires Entra ID P1). Error: $($_.Exception.Message)" -Sev 'Info'
+ }
+ }
+
+ # --- Roll up distinct AI tools across BOTH sources for the summary and charts: device installs
+ # from the Intune rows, active users (last 7 days) from the Entra rows, the first row seen
+ # (Intune first) naming the tool's category, risk and status. ---
+ $Tools = @($DetectedApps + @($ConsentedApps) | Group-Object aiTool | ForEach-Object {
+ [PSCustomObject]@{
+ Tool = $_.Name
+ Category = $_.Group[0].category
+ Risk = $_.Group[0].risk
+ Status = $_.Group[0].status
+ Devices = [int](($_.Group | ForEach-Object { [int]($_.deviceCount ?? 0) } | Measure-Object -Sum).Sum)
+ Users = [int](($_.Group | ForEach-Object { [int]($_.activeUsersLast7Days ?? 0) } | Measure-Object -Sum).Sum)
+ }
+ })
+
+ # Built as @(...) so a tenant with no AI tools gets empty lists, not a single null entry.
+ $ByCategory = @(foreach ($Group in ($Tools | Group-Object Category)) {
+ [PSCustomObject]@{
+ category = $Group.Name
+ tools = $Group.Count
+ devices = [int](($Group.Group | Measure-Object -Property Devices -Sum).Sum)
+ }
+ })
+ $ByRisk = @(foreach ($Group in ($Tools | Group-Object Risk)) {
+ [PSCustomObject]@{
+ risk = $Group.Name
+ tools = $Group.Count
+ }
+ })
+ # Top tools across BOTH sources: device installs (Intune) + active users (Entra, last 7 days).
+ $TopTools = @($Tools | Sort-Object -Property { $_.Devices + $_.Users } -Descending | Select-Object -First 8 | ForEach-Object {
+ [PSCustomObject]@{
+ tool = $_.Tool
+ devices = $_.Devices
+ users = $_.Users
+ footprint = $_.Devices + $_.Users
+ category = $_.Category
+ status = $_.Status
+ }
+ })
+
+ $Body = [PSCustomObject]@{
+ summary = [PSCustomObject]@{
+ aiToolsDetected = $Tools.Count
+ deviceInstalls = [int](($DetectedApps | Measure-Object -Property deviceCount -Sum).Sum)
+ consentedAiApps = $ConsentedApps.Count
+ highRiskTools = @($Tools | Where-Object { $_.Risk -eq 'High' }).Count
+ sanctionedTools = @($Tools | Where-Object { $_.Status -eq 'Sanctioned' }).Count
+ intuneSynced = $CacheData['DetectedApps'].Count -gt 0
+ entraSynced = $CacheData['ServicePrincipals'].Count -gt 0
+ lastDataRefresh = $LastDataRefresh
+ }
+ byCategory = @($ByCategory)
+ byRisk = @($ByRisk)
+ topTools = @($TopTools)
+ detectedApps = @($DetectedApps)
+ consentedApps = @($ConsentedApps)
+ }
+ return $Body
+}
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPSharePointPermissionsReport.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPSharePointPermissionsReport.ps1
new file mode 100644
index 0000000000..b142255987
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPSharePointPermissionsReport.ps1
@@ -0,0 +1,111 @@
+function Get-CIPPSharePointPermissionsReport {
+ <#
+ .FUNCTIONALITY
+ Internal
+ .SYNOPSIS
+ The SharePoint permissions report for a tenant, compiled from the CIPP reporting cache.
+ .DESCRIPTION
+ Rolls up the cached SharePointPermissions dataset into the shape the Permissions page
+ (ListSharePointPermissions) and the permissions PDF consume: the scan summary, the oversharing
+ signals worth acting on, chart datasets and the individual permission assignments. No live
+ enumeration is performed; refresh the data by syncing that cache (ExecCIPPDBCache).
+
+ Signals reported:
+ - Broad claims: grants to Everyone, Everyone except external users, or All Users. A library
+ carrying one of these is reachable by the whole tenant regardless of who was meant to
+ have it, which is the classic oversharing footgun.
+ - External grants: permissions held by guest or external identities.
+ - Direct Full Control: Full Control held by something other than a SharePoint group, i.e.
+ granted to a user or directory group rather than through the site's Owners group.
+ - Unique permission libraries: libraries that no longer inherit from their site, so site
+ level permission changes no longer reach them.
+
+ Limited Access assignments (isSystemManaged) are excluded from every signal - SharePoint
+ creates them itself so a user can traverse to an item, and they grant nothing on their own.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$TenantFilter
+ )
+
+ # A readable label for a site that has no display name, taken from the last path segment of
+ # its URL: '.../sites/AllCompany' becomes 'AllCompany', '.../search' becomes 'search'.
+ function Get-CIPPSiteLabel {
+ param([string]$SiteUrl)
+ if ([string]::IsNullOrWhiteSpace($SiteUrl)) { return 'Unnamed site' }
+ try {
+ $Path = ([System.Uri]$SiteUrl).AbsolutePath.Trim('/')
+ if ($Path) { return ($Path -split '/')[-1] }
+ return 'Root site'
+ } catch {
+ return 'Unnamed site'
+ }
+ }
+ # Counts per key, largest first, as { ; } rows for the charts and tables.
+ function Get-RankedCount($Rows, [scriptblock]$Key, [string]$KeyName, [string]$ValueName) {
+ @($Rows | Group-Object $Key | Where-Object { $_.Name } | Sort-Object -Property Count -Descending |
+ ForEach-Object { [PSCustomObject]@{ $KeyName = $_.Name; $ValueName = $_.Count } })
+ }
+
+ # --- Cached dataset from the CIPP reporting database ---
+ $CacheRows = try { @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SharePointPermissions') } catch { @() }
+ $CountRow = try { Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointPermissions' -CountsOnly | Select-Object -First 1 } catch { $null }
+
+ $SiteRows = @($CacheRows | Where-Object { $_.rowType -eq 'Site' })
+ $Assignments = @($CacheRows | Where-Object { $_.rowType -eq 'Assignment' })
+ $SkippedSites = @($SiteRows | Where-Object { $_.collectionStatus -eq 'Skipped' } | ForEach-Object {
+ [PSCustomObject]@{ siteName = $_.siteName; siteUrl = $_.siteUrl; error = $_.collectionError }
+ })
+ # Placeholder rows for a unique-permission library with nothing granted carry no principal, and
+ # SharePoint maintains Limited Access itself; neither grants anything on its own.
+ $RealAssignments = @($Assignments | Where-Object { $_.principalId -and $_.isSystemManaged -ne $true })
+ $Level = { [string]($_.permissionLevel ?? 'Unknown') }
+
+ $Body = [PSCustomObject]@{
+ summary = [PSCustomObject]@{
+ sitesScanned = $SiteRows.Count
+ sitesSkipped = $SkippedSites.Count
+ librariesScanned = [int](($SiteRows | ForEach-Object { [int]($_.librariesScanned ?? 0) } | Measure-Object -Sum).Sum)
+ uniquePermissionLibraries = [int](($SiteRows | ForEach-Object { [int]($_.librariesWithUniquePermissions ?? 0) } | Measure-Object -Sum).Sum)
+ totalAssignments = $RealAssignments.Count
+ broadClaimGrants = @($RealAssignments | Where-Object { $_.broadClaim }).Count
+ externalGrants = @($RealAssignments | Where-Object { $_.isGuest -eq $true }).Count
+ # Full Control held by anything other than a SharePoint group was granted directly rather
+ # than through the site's Owners group, which every site has by default.
+ directFullControlGrants = @($RealAssignments | Where-Object { (& $Level) -eq 'Full Control' -and $_.principalType -ne 'SharePoint Group' }).Count
+ permissionsSynced = [bool]$CountRow
+ lastDataRefresh = $CountRow.Timestamp
+ }
+ byPermissionLevel = @(Get-RankedCount $RealAssignments $Level 'level' 'grants')
+ byPrincipalType = @(Get-RankedCount $RealAssignments { [string]($_.principalType ?? 'Other') } 'type' 'grants')
+ byBroadClaim = @(Get-RankedCount @($RealAssignments | Where-Object { $_.broadClaim }) { [string]$_.broadClaim } 'claim' 'grants')
+ # Libraries that no longer inherit, counted per site for the chart.
+ topSitesByUniqueLibraries = @($SiteRows | Where-Object { [int]($_.librariesWithUniquePermissions ?? 0) -gt 0 } |
+ Group-Object { [string]($_.siteName ?? $_.siteUrl) } | Where-Object { $_.Name } |
+ ForEach-Object { [PSCustomObject]@{ site = $_.Name; libraries = [int](($_.Group | ForEach-Object { [int]$_.librariesWithUniquePermissions } | Measure-Object -Sum).Sum) } } |
+ Sort-Object -Property libraries -Descending | Select-Object -First 10)
+ skippedSites = @($SkippedSites)
+ # Display fields are derived here rather than stored, so existing cached data gains them
+ # without waiting for a re-scan.
+ #
+ # appliesTo spells out what scope means for a reader scanning the table. Every Library row
+ # is by definition a library that stopped inheriting - libraries that still inherit are not
+ # collected, because their permissions are the site's repeated.
+ #
+ # siteName falls back to a label built from the URL for the handful of system sites that
+ # have no name. The URL itself is not used: the tables render any value starting with http
+ # as a link, and a column of links where names should be is worse than a plain label.
+ assignments = @($Assignments | ForEach-Object {
+ $AppliesTo = if ($_.scope -eq 'Library') { 'This library only' } else { 'Whole site' }
+ $_ | Add-Member -NotePropertyName 'appliesTo' -NotePropertyValue $AppliesTo -Force
+
+ $SiteName = [string]$_.siteName
+ if ([string]::IsNullOrWhiteSpace($SiteName) -or $SiteName -like 'http*') {
+ $_ | Add-Member -NotePropertyName 'siteName' -NotePropertyValue (Get-CIPPSiteLabel -SiteUrl $_.siteUrl) -Force
+ }
+ $_
+ })
+ }
+ return $Body
+}
diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPSharePointSharingReport.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPSharePointSharingReport.ps1
new file mode 100644
index 0000000000..af022696ac
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Get-CIPPSharePointSharingReport.ps1
@@ -0,0 +1,103 @@
+function Get-CIPPSharePointSharingReport {
+ <#
+ .FUNCTIONALITY
+ Internal
+ .SYNOPSIS
+ The SharePoint & OneDrive sharing report for a tenant, compiled from the CIPP reporting cache.
+ .DESCRIPTION
+ Rolls up the cached SharePointSharingLinks, SharePointSiteUsage and OneDriveUsage datasets into
+ the shape the Sharing page (ListSharePointSharing) and the sharing PDF consume: environment,
+ file and storage summaries per workload, link counts by classification, the sharing sprawl
+ signals (anonymous links that allow editing, anonymous links with no expiry, folder-level
+ external shares, password-protected links), the busiest sites, libraries and external
+ recipients, and the individual link rows. No live Graph enumeration is performed; refresh the
+ data by syncing those caches (ExecCIPPDBCache).
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$TenantFilter
+ )
+
+ # Usage report values can arrive as numbers, strings or empty strings depending on the tenant.
+ function ConvertTo-SafeDouble {
+ param($Value)
+ $Parsed = [double]0
+ if ($null -ne $Value -and [double]::TryParse("$Value", [ref]$Parsed)) { return $Parsed }
+ return [double]0
+ }
+ function Get-Total($Rows, [string]$Property) {
+ ($Rows | ForEach-Object { ConvertTo-SafeDouble -Value $_.$Property } | Measure-Object -Sum).Sum
+ }
+ # Counts per key, largest first, as { ; } rows for the charts and tables.
+ function Get-RankedCount($Rows, [scriptblock]$Key, [string]$KeyName, [string]$ValueName, [int]$First = 0) {
+ $Groups = @($Rows | Group-Object $Key | Where-Object { $_.Name } | Sort-Object -Property Count -Descending)
+ if ($First -gt 0) { $Groups = @($Groups | Select-Object -First $First) }
+ @($Groups | ForEach-Object { [PSCustomObject]@{ $KeyName = $_.Name; $ValueName = $_.Count } })
+ }
+
+ # --- Cached datasets, whether each has ever synced, and the oldest refresh across them ---
+ $CacheData = @{}
+ $CacheSynced = @{}
+ $CacheTimestamps = [System.Collections.Generic.List[object]]::new()
+ foreach ($Type in @('SharePointSharingLinks', 'SharePointSiteUsage', 'OneDriveUsage')) {
+ $CacheData[$Type] = try { @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type) } catch { @() }
+ $CountRow = try { Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1 } catch { $null }
+ $CacheSynced[$Type] = [bool]$CountRow
+ if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) }
+ }
+ $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1
+
+ # --- Environment summaries per workload. Teams-connected sites (rootWebTemplate 'Group') are
+ # reported separately from the remaining SharePoint sites; OneDrive is per account. ---
+ $TeamsSiteRows = @($CacheData['SharePointSiteUsage'] | Where-Object { $_.rootWebTemplate -eq 'Group' })
+ $SharePointSiteRows = @($CacheData['SharePointSiteUsage'] | Where-Object { $_.rootWebTemplate -ne 'Group' })
+ $OneDriveRows = $CacheData['OneDriveUsage']
+
+ # --- Sharing link rollups ---
+ $Links = $CacheData['SharePointSharingLinks']
+ $AnonymousLinks = @($Links | Where-Object { $_.classification -eq 'Anonymous' })
+ $ExternalLinks = @($Links | Where-Object { $_.classification -eq 'External' })
+ # 'write' and 'owner' both mean the recipient can change the content.
+ $CanEdit = { param($Link) @($Link.roles) -contains 'write' -or @($Link.roles) -contains 'owner' }
+ $SiteOf = { param($Link) [string]($Link.siteName ?? $Link.siteUrl) }
+ # Who the tenant is sharing with, counted from named external recipients only: anonymous links
+ # have no recipient and internal ones are not sprawl.
+ $ExternalRecipients = @($ExternalLinks | ForEach-Object { @($_.sharedWith) } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { [string]$_ })
+ $TopRecipients = @(Get-RankedCount $ExternalRecipients { $_ } 'recipient' 'links')
+
+ $Body = [PSCustomObject]@{
+ summary = [PSCustomObject]@{
+ sharePointSites = $SharePointSiteRows.Count
+ sharePointFiles = [int64](Get-Total $SharePointSiteRows 'fileCount')
+ sharePointStorageUsedGB = [math]::Round((Get-Total $SharePointSiteRows 'storageUsedInBytes') / 1GB, 2)
+ teamsSites = $TeamsSiteRows.Count
+ teamsFiles = [int64](Get-Total $TeamsSiteRows 'fileCount')
+ teamsStorageUsedGB = [math]::Round((Get-Total $TeamsSiteRows 'storageUsedInBytes') / 1GB, 2)
+ oneDriveAccounts = $OneDriveRows.Count
+ oneDriveFiles = [int64](Get-Total $OneDriveRows 'fileCount')
+ oneDriveStorageUsedGB = [math]::Round((Get-Total $OneDriveRows 'storageUsedInBytes') / 1GB, 2)
+ totalLinks = $Links.Count
+ anonymousLinks = $AnonymousLinks.Count
+ externalLinks = $ExternalLinks.Count
+ internalLinks = $Links.Count - $AnonymousLinks.Count - $ExternalLinks.Count
+ itemsShared = @($Links | Where-Object { $_.driveId -and $_.itemId } | ForEach-Object { "$($_.driveId)|$($_.itemId)" } | Sort-Object -Unique).Count
+ anonymousEditLinks = @($AnonymousLinks | Where-Object { & $CanEdit $_ }).Count
+ neverExpiringAnonymous = @($AnonymousLinks | Where-Object { -not $_.expirationDateTime }).Count
+ # A share on a folder exposes everything below it, so it counts differently to a file share.
+ folderShares = @($Links | Where-Object { $_.itemType -eq 'Folder' -and $_.classification -in @('Anonymous', 'External') }).Count
+ passwordProtectedLinks = @($Links | Where-Object { $_.hasPassword -eq $true }).Count
+ externalRecipients = $TopRecipients.Count
+ linksSynced = $CacheSynced['SharePointSharingLinks']
+ usageSynced = ($CacheSynced['SharePointSiteUsage'] -or $CacheSynced['OneDriveUsage'])
+ lastDataRefresh = $LastDataRefresh
+ }
+ byScope = @(Get-RankedCount $Links { [string]($_.classification ?? 'Internal') } 'scope' 'links')
+ byLinkType = @(Get-RankedCount $Links { [string]($_.linkType ?? 'link') } 'type' 'links')
+ topSites = @(Get-RankedCount $Links { & $SiteOf $_ } 'site' 'links' 10)
+ topLibraries = @(Get-RankedCount @($Links | Where-Object { $_.driveName }) { $Site = & $SiteOf $_; if ($Site) { "$Site / $($_.driveName)" } else { [string]$_.driveName } } 'library' 'links' 10)
+ topRecipients = @($TopRecipients | Select-Object -First 10)
+ links = @($Links)
+ }
+ return $Body
+}
diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1
index a81a95e7c0..7402eebb70 100644
--- a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1
+++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1
@@ -126,6 +126,7 @@ function Invoke-CIPPDBCacheCollection {
ExchangeData = @(
'CASMailboxes'
'MailboxUsage'
+ 'MailTrafficSummary'
'OfficeActivations'
'HVEAccounts'
)
diff --git a/backend/Modules/CIPPCore/Public/Remove-CIPPImage.ps1 b/backend/Modules/CIPPCore/Public/Remove-CIPPImage.ps1
index fed55f8957..0e2dbac54e 100644
--- a/backend/Modules/CIPPCore/Public/Remove-CIPPImage.ps1
+++ b/backend/Modules/CIPPCore/Public/Remove-CIPPImage.ps1
@@ -36,5 +36,7 @@ function Remove-CIPPImage {
} catch {
Write-Warning "Failed to remove image '$ImageId' in partition '$PartitionKey': $($_.Exception.Message)"
}
+ # Its name row (see Set-CIPPImageName) goes with it; there may not be one.
+ try { Remove-CIPPAzDataTableEntity @Table -Entity @{ PartitionKey = 'imageMeta'; RowKey = $ImageId } -Force | Out-Null } catch { Write-Verbose "No name row for image '$ImageId'." }
}
}
diff --git a/backend/Modules/CIPPCore/Public/Set-CIPPImageName.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPImageName.ps1
new file mode 100644
index 0000000000..9feef21904
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Set-CIPPImageName.ps1
@@ -0,0 +1,44 @@
+function Set-CIPPImageName {
+ <#
+ .FUNCTIONALITY
+ Internal
+ .SYNOPSIS
+ Names a gallery image, so it can be picked by name wherever the image is offered.
+ .DESCRIPTION
+ Writes the name row Get-CIPPImageNameMap reads (PartitionKey imageMeta, RowKey the image id).
+ An empty name removes the row, which reads back as "unnamed".
+ .PARAMETER PartitionKey
+ The image kind (logo, brandingCover).
+ .PARAMETER Id
+ The image's RowKey GUID.
+ .PARAMETER Name
+ The name to give it; blank to clear.
+ #>
+ [CmdletBinding(SupportsShouldProcess = $true)]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$PartitionKey,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$Id,
+
+ [AllowEmptyString()]
+ [string]$Name = ''
+ )
+
+ $Table = Get-CIPPTable -TableName 'Images'
+ $Entity = @{
+ PartitionKey = 'imageMeta'
+ RowKey = $Id.Trim()
+ kind = $PartitionKey
+ name = $Name.Trim()
+ }
+ if (-not $PSCmdlet.ShouldProcess($Id, "Name image '$($Entity.name)'")) { return }
+ if ([string]::IsNullOrWhiteSpace($Entity.name)) {
+ try { Remove-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null } catch { Write-Warning "Failed to clear the name of image '$Id': $($_.Exception.Message)" }
+ return
+ }
+ Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/ConvertTo-CippReportPdf.ps1 b/backend/Modules/CIPPCore/Public/Tools/ConvertTo-CippReportPdf.ps1
new file mode 100644
index 0000000000..2963e229d0
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/ConvertTo-CippReportPdf.ps1
@@ -0,0 +1,92 @@
+function ConvertTo-CippReportPdf {
+ <#
+ .SYNOPSIS
+ Render a report component tree to PDF bytes server-side.
+ .DESCRIPTION
+ Thin wrapper over the CIPPSharp component kit ([CIPP.Reporting.ReportPdf]::Render), which is
+ loaded with CIPPCore via RequiredAssemblies. Takes the declarative component tree (Report
+ Builder blocks, or a fixed report's composed component nodes), the resolved branding, and the
+ %variable% values, and returns the finished PDF as a byte array. All layout lives in the shared
+ component kit - callers never touch OfficeIMO.
+ .PARAMETER Blocks
+ The component tree: an array of block/component nodes, or a JSON string of the same.
+ .PARAMETER Branding
+ Branding settings as an object or JSON string. Omit it to render against the tenant/global
+ branding settings, or name a preset with -BrandingPresetId (a missing preset falls back to the
+ settings, so a report is always branded).
+ .PARAMETER Variables
+ %variable% values for footer/watermark/cover text, as a hashtable or JSON string.
+ .PARAMETER TenantName
+ Client name shown on the cover and available as %tenantname%.
+ .PARAMETER ReportName
+ Report title shown on the cover and in the page header.
+ .PARAMETER GeneratedOn
+ Human-readable generation date shown on the cover / available as %reportdate%.
+ #>
+ [CmdletBinding()]
+ [OutputType([byte[]])]
+ param(
+ [Parameter(Mandatory = $true)]$Blocks,
+ $Branding,
+ [string]$BrandingPresetId,
+ $Variables,
+ [string]$TenantName = 'Organization',
+ [string]$ReportName = 'Report',
+ [string]$GeneratedOn = ((Get-Date).ToString('MMMM d, yyyy')),
+ [string]$PageSize = 'A4',
+ [switch]$Landscape,
+ # The tenant whose %variables% (global + tenant custom vars + built-ins like %cippurl%) resolve
+ # the branding footer/watermark and cover text. Omit to skip variable replacement.
+ [string]$TenantFilter
+ )
+
+ if ($null -eq $Branding) {
+ $Branding = try {
+ $Preset = if ($BrandingPresetId) { Get-CIPPBrandingPreset -Id $BrandingPresetId | Select-Object -First 1 }
+ if ($Preset) { $Preset } else { Get-CIPPBrandingSettings }
+ } catch { @{} }
+ }
+
+ # An Infographic page can use a cover from the branding gallery ('gallery:', as the report
+ # builder stores it); the renderer takes image bytes, so the image is read here. An image that is
+ # gone leaves the page's plain background rather than failing the report.
+ if ($Blocks -isnot [string]) {
+ foreach ($Block in @($Blocks)) {
+ $HeroImage = [string]$Block.heroImage
+ if ($Block.type -ne 'hero' -or $HeroImage -notlike 'gallery:*') { continue }
+ $Image = try { Get-CIPPImage -PartitionKey 'brandingCover' -Id $HeroImage.Substring(8) } catch { $null }
+ $Resolved = if ($Image.data) { [string]$Image.data } else { '' }
+ if ($Block -is [System.Collections.IDictionary]) { $Block['heroImage'] = $Resolved }
+ else { $Block | Add-Member -NotePropertyName 'heroImage' -NotePropertyValue $Resolved -Force }
+ }
+ }
+
+ # Accept objects or pre-serialised JSON for each structured input.
+ $BlocksJson = if ($Blocks -is [string]) { $Blocks } else { ConvertTo-Json -InputObject @($Blocks) -Depth 20 -Compress }
+ $BrandingJson = if ($null -eq $Branding) { '{}' } elseif ($Branding -is [string]) { $Branding } else { ConvertTo-Json -InputObject $Branding -Depth 10 -Compress }
+ $VariablesJson = if ($null -eq $Variables) { '{}' } elseif ($Variables -is [string]) { $Variables } else { ConvertTo-Json -InputObject $Variables -Depth 5 -Compress }
+
+ # Resolve CIPP %variables% (%cippurl%, %tenantname%, custom CippReplacemap vars, ...) through the one
+ # central replacement script, JSON-escaped so a value splices safely into these serialized strings.
+ # The branding footer/watermark and cover text carry these tokens; unknown tokens are left as written
+ # for the component kit to substitute (report-specific vars like %footerlabel%). No-op without a tenant.
+ if (-not [string]::IsNullOrWhiteSpace($TenantFilter)) {
+ # %tenantname% names the tenant the way the report does (the branding's tenantLabel, resolved
+ # by the caller into -TenantName) rather than the way the tenant cache does, so a footer and
+ # the cover agree. Substituted before the general replacement, which would use the cache.
+ if ($PSBoundParameters.ContainsKey('TenantName')) {
+ $EscapedName = (ConvertTo-Json -InputObject $TenantName -Compress).Trim('"')
+ $BrandingJson = [regex]::Replace($BrandingJson, '%tenantname%', $EscapedName, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
+ }
+ $BrandingJson = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $BrandingJson -EscapeForJson
+ $VariablesJson = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $VariablesJson -EscapeForJson
+ }
+
+ # Unary comma: return the byte[] as a single object so PowerShell does not unroll it into a stream
+ # of bytes (which would reach callers as object[] and only work by implicit coercion).
+ return , [CIPP.Reporting.ReportPdf]::Render(
+ $BlocksJson, $BrandingJson, $VariablesJson,
+ $TenantName, $ReportName, $GeneratedOn,
+ $PageSize, [bool]$Landscape
+ )
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Get-CippReportTenantName.ps1 b/backend/Modules/CIPPCore/Public/Tools/Get-CippReportTenantName.ps1
new file mode 100644
index 0000000000..7c8441f5d8
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Get-CippReportTenantName.ps1
@@ -0,0 +1,50 @@
+function Get-CippReportTenantName {
+ <#
+ .SYNOPSIS
+ How a report names its tenant, as the branding decides.
+ .DESCRIPTION
+ Every server-rendered report puts the tenant's name on its cover, in its prose and, through
+ %tenantname%, in its footer. The branding's tenantLabel says which name that is:
+
+ alias - the name CIPP shows for the tenant: its alias when one is set, else its name (default)
+ name - the Microsoft 365 organisation name, alias or not
+ domain - the tenant's default domain
+
+ A preset's choice wins over the global setting when a preset id is given, exactly as the
+ rest of the branding resolves. Anything that cannot be read falls back to the next best
+ thing, and finally to the tenant filter itself, so a report always has something to say.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$TenantFilter,
+ [string]$BrandingPresetId,
+ # Already-resolved branding, when the caller has it; otherwise the preset or global settings are read.
+ $Branding
+ )
+
+ $Tenant = try { Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 } catch { $null }
+ $Shown = if ($Tenant.displayName) { [string]$Tenant.displayName } else { $TenantFilter }
+
+ if ($null -eq $Branding) {
+ $Branding = try {
+ $Preset = if ($BrandingPresetId) { Get-CIPPBrandingPreset -Id $BrandingPresetId -SkipImageData | Select-Object -First 1 }
+ if ($Preset) { $Preset } else { Get-CIPPBrandingSettings }
+ } catch { $null }
+ }
+
+ switch ([string]$Branding.tenantLabel) {
+ 'domain' {
+ if ($Tenant.defaultDomainName) { [string]$Tenant.defaultDomainName } else { $TenantFilter }
+ }
+ 'name' {
+ # The organisation's own name, which the tenant cache overwrites with an alias when one is set.
+ $Organisation = try {
+ New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/organization?$select=displayName' -tenantid $TenantFilter -AsApp $true | Select-Object -First 1
+ } catch { $null }
+ if ($Organisation.displayName) { [string]$Organisation.displayName } else { $Shown }
+ }
+ default { $Shown }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1
index 6235acfa63..ae16edbf70 100644
--- a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1
+++ b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1
@@ -13,7 +13,10 @@ function Push-ExecGenerateReportBuilderReport {
$Blocks,
$TemplateGUID,
$IncludeRawAttachments,
- $Settings
+ $Settings,
+ # Render and return the PDF without persisting a generated-report row - powers the builder's
+ # live preview/download from the current unsaved state.
+ [switch]$PreviewOnly
)
try {
@@ -21,9 +24,8 @@ function Push-ExecGenerateReportBuilderReport {
throw 'TenantFilter is required'
}
- # Page setup and branding for this report — page size, orientation, cover, footer,
- # watermark and which branding preset to render against. Carried through untouched: the
- # PDF is rendered in the browser, so this side only has to store what it was given.
+ # Page setup and branding for this report: page size, orientation, cover, footer,
+ # watermark and which branding preset to render against.
$ParsedSettings = $null
if ($Settings) {
if ($Settings -is [string]) {
@@ -190,21 +192,65 @@ function Push-ExecGenerateReportBuilderReport {
$Block
})
+ # Data tokens (&Users&, &Devices.complianceState=compliant&, a chart or table's data source)
+ # resolve against the reporting database here, on the server, so a scheduled run and a
+ # preview read the same data.
+ $EnrichedBlocks = @(Resolve-CippReportDataToken -Blocks $EnrichedBlocks -TenantFilter $TenantFilter)
+
+ # -- Render the PDF server-side via the shared CIPPSharp component kit --
+ # A render failure must not lose the report: the enriched blocks are still stored so the report
+ # exists and can be re-rendered, and the failure is logged rather than thrown.
+ $PdfBytes = $null
+ try {
+ # The tenant's name for the cover, as the branding names it (alias, organisation name or
+ # domain). The template's chosen preset wins, else the global branding settings - the same
+ # resolution ConvertTo-CippReportPdf applies to the rest of the branding.
+ $TenantDisplayName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId ([string]$ParsedSettings.brandingPresetId)
+ $PageSizePref = if ($ParsedSettings -and $ParsedSettings.size) { [string]$ParsedSettings.size } else { 'A4' }
+ $IsLandscape = ($ParsedSettings -and "$($ParsedSettings.orientation)" -eq 'landscape')
+
+ $PdfBytes = ConvertTo-CippReportPdf -Blocks $EnrichedBlocks -BrandingPresetId ([string]$ParsedSettings.brandingPresetId) `
+ -TenantName $TenantDisplayName -TenantFilter $TenantFilter -ReportName ($TemplateName ?? 'Report') `
+ -PageSize $PageSizePref -Landscape:$IsLandscape
+ } catch {
+ $PdfError = Get-CippException -Exception $_
+ Write-LogMessage -API 'ReportBuilder' -tenant $TenantFilter -message "PDF render failed, storing report without a PDF: $($PdfError.NormalizedError)" -Sev 'Warning' -LogData $PdfError
+ }
+
+ # Preview: hand the freshly rendered bytes straight back without persisting a report row.
+ if ($PreviewOnly) {
+ return @{ PdfBytes = $PdfBytes }
+ }
+ $PdfBase64 = if ($PdfBytes) { [Convert]::ToBase64String($PdfBytes) } else { '' }
+ $PdfFileName = ("$($TemplateName ?? 'Report')_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+
# Store the generated report
$ReportGUID = (New-Guid).GUID
$ReportTable = Get-CippTable -tablename 'ReportBuilderReports'
$ReportEntity = @{
- PartitionKey = $TenantFilter
- RowKey = [string]$ReportGUID
- TemplateName = [string]($TemplateName ?? 'Scheduled Report')
- TenantFilter = [string]$TenantFilter
- Blocks = [string](ConvertTo-Json -InputObject @($EnrichedBlocks) -Depth 20 -Compress)
- GeneratedAt = [string](Get-Date).ToString('o')
- Status = 'Completed'
- Settings = if ($ParsedSettings) { [string](ConvertTo-Json -InputObject $ParsedSettings -Depth 10 -Compress) } else { '' }
+ PartitionKey = $TenantFilter
+ RowKey = [string]$ReportGUID
+ TemplateName = [string]($TemplateName ?? 'Scheduled Report')
+ TenantFilter = [string]$TenantFilter
+ Blocks = [string](ConvertTo-Json -InputObject @($EnrichedBlocks) -Depth 20 -Compress)
+ GeneratedAt = [string](Get-Date).ToString('o')
+ Status = 'Completed'
+ Settings = if ($ParsedSettings) { [string](ConvertTo-Json -InputObject $ParsedSettings -Depth 10 -Compress) } else { '' }
}
-
Add-CIPPAzDataTableEntity @ReportTable -Entity $ReportEntity -Force
+
+ # The finished PDF goes in its own table, keyed by the report GUID, so listing reports never pulls
+ # the base64. Add-CIPPAzDataTableEntity auto-splits the oversized property across part rows, so a
+ # multi-MB report survives the Azure Table 64KB/property limit.
+ if ($PdfBase64) {
+ $PdfTable = Get-CippTable -tablename 'ReportBuilderPdfs'
+ Add-CIPPAzDataTableEntity @PdfTable -Force -Entity @{
+ PartitionKey = $TenantFilter
+ RowKey = [string]$ReportGUID
+ FileName = $PdfFileName
+ Pdf = $PdfBase64
+ }
+ }
Write-LogMessage -API 'ReportBuilder' -tenant $TenantFilter -message "Generated report builder report '$TemplateName' with GUID $ReportGUID" -Sev 'Info'
# Build result message with direct link
@@ -218,9 +264,20 @@ function Push-ExecGenerateReportBuilderReport {
$ResultMessage += ". View report: $ReportLink"
}
- # Build file attachments for database blocks if requested
+ # Attachments for the scheduled-email path (Send-CIPPScheduledTaskAlert forwards TaskAttachments).
+ # The rendered PDF always attaches - that is the whole point of rendering server-side; the raw
+ # CSV/JSON data files stay behind the IncludeRawAttachments toggle.
+ $TaskAttachments = [System.Collections.Generic.List[object]]::new()
+
+ if ($PdfBase64) {
+ $TaskAttachments.Add(@{
+ Name = $PdfFileName
+ ContentType = 'application/pdf'
+ ContentBytes = $PdfBase64
+ })
+ }
+
if ($IncludeRawAttachments -eq 'true') {
- $TaskAttachments = @()
foreach ($Block in $EnrichedBlocks) {
if ($Block.type -ne 'database' -or -not $Block.dbType) { continue }
$Format = if ($Block.format) { $Block.format } else { 'csv' }
@@ -260,18 +317,18 @@ function Push-ExecGenerateReportBuilderReport {
}
}
$Bytes = [System.Text.Encoding]::UTF8.GetBytes($FileContent)
- $Base64 = [Convert]::ToBase64String($Bytes)
- $TaskAttachments += @{
- Name = $FileName
- ContentType = $ContentType
- ContentBytes = $Base64
- }
+ $TaskAttachments.Add(@{
+ Name = $FileName
+ ContentType = $ContentType
+ ContentBytes = [Convert]::ToBase64String($Bytes)
+ })
}
- if ($TaskAttachments.Count -gt 0) {
- return @{
- TaskAttachments = $TaskAttachments
- Results = $ResultMessage
- }
+ }
+
+ if ($TaskAttachments.Count -gt 0) {
+ return @{
+ TaskAttachments = @($TaskAttachments)
+ Results = $ResultMessage
}
}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippBaselineWhatIfReportTree.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippBaselineWhatIfReportTree.ps1
new file mode 100644
index 0000000000..b0c4c59f64
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippBaselineWhatIfReportTree.ps1
@@ -0,0 +1,154 @@
+function Build-CippBaselineWhatIfReportTree {
+ <#
+ .SYNOPSIS
+ Compose the Baseline What-If report as a component tree (server port of CippBaselineWhatIfReport.jsx).
+ .DESCRIPTION
+ Pure composition from an already-gathered alignment payload: what applying the configured
+ standards would change today, what each planned stage will change, optionally what assigning
+ one more baseline would roll out, and the deviations that have been agreed and will be left
+ alone. Nothing is changed by producing it. Returns @{ Blocks; Variables }.
+ .PARAMETER Data
+ TenantName, TenantFilter, summary (alignedPercentage, verifiedPercentage), rows[] (the
+ resolved standards rows), stageStates[] (per assigned baseline), simulatedTemplate (a baseline
+ not assigned to the tenant, or $null) and catalog[] (the definition catalog: name, label,
+ executiveText).
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][hashtable]$Data)
+
+ $summary = if ($Data.summary) { $Data.summary } else { @{} }
+ # `?? @()` so a missing list is empty rather than @($null), which would render as one blank row.
+ $rows = @($Data.rows ?? @())
+ $stageStates = @($Data.stageStates ?? @())
+ $simulated = $Data.simulatedTemplate
+ $catalog = @{}
+ foreach ($entry in @($Data.catalog ?? @())) { if ($entry.name) { $catalog[[string]$entry.name] = $entry } }
+ function nz($v) { if ($null -eq $v) { 0 } else { [int]$v } }
+ function plural($c, $s, $p) { "$c $(if ($c -eq 1) { $s } else { if ($p) { $p } else { "${s}s" } })" }
+ function html($s) { [System.Net.WebUtility]::HtmlEncode([string]$s) }
+
+ # A standard's plain-language line for an executive reader, falling back to its label.
+ $textFor = {
+ param($name, $fallback)
+ $entry = $catalog[[string]$name]
+ if ($entry -and $entry.executiveText) { [string]$entry.executiveText } else { [string]$fallback }
+ }
+ $labelFor = {
+ param($name)
+ $entry = $catalog[[string]$name]
+ if ($entry -and $entry.label) { [string]$entry.label } else { [string]$name }
+ }
+
+ # A stage's graduation conditions in words (client describeStageConditions).
+ $operatorLabels = @{ eq = 'equals'; ne = 'does not equal'; startsWith = 'starts with'; notStartsWith = 'does not start with' }
+ $describeConditions = {
+ param($stage)
+ $conditions = @($stage.conditions ?? @())
+ if ($conditions.Count -eq 0) { return 'no conditions configured' }
+ $parts = foreach ($c in $conditions) {
+ switch ([string]$c.type) {
+ 'time' { "$($c.days) $($c.unit ?? 'days') in the previous stage" }
+ 'variable' { "$($c.variable) $($operatorLabels[[string]$c.operator] ?? $c.operator) '$($c.value)'" }
+ 'success' { 'all previous stage items applied successfully' }
+ 'manual' { 'manual approval by an operator' }
+ default { [string]$c.type }
+ }
+ }
+ $parts -join $(if ($stage.logic -eq 'or') { ' OR ' } else { ' AND ' })
+ }
+ # When a time condition will be met, from the day the tenant entered its current stage.
+ $estimatedAt = {
+ param($state)
+ $time = @($state.nextStage.conditions ?? @()) | Where-Object { $_.type -eq 'time' } | Select-Object -First 1
+ if (-not $time) { return $null }
+ $entered = if ($state.enteredStageAt -is [DateTimeOffset]) { $state.enteredStageAt.UtcDateTime } else { $state.enteredStageAt -as [datetime] }
+ if (-not $entered) { return $null }
+ $days = ([double]($time.days ?? 0)) * $(if ($time.unit -eq 'weeks') { 7 } else { 1 })
+ $entered.AddDays($days).ToString('MMMM d, yyyy')
+ }
+
+ $changesNow = @($rows | Where-Object { @('Drift', 'Partially Accepted', 'Denied - Remediate Pending', 'Denied - Delete Pending') -contains $_.status })
+ $accepted = @($rows | Where-Object { $_.status -eq 'Accepted' })
+ $planned = @($stageStates | Where-Object { $_.nextStage })
+
+ $blocks = [System.Collections.Generic.List[object]]::new()
+
+ # -- Where you stand today --
+ $blocks.Add((New-CippReportPage -Title 'Baseline What-If Report' -Subtitle 'What applying the configured standards would change'))
+ $blocks.Add((New-CippReportParagraph -Html ('
This report previews what applying the standards configured for {0} would change: what would change today, what each planned stage will change when it is reached, and the deviations that have been agreed and will be left alone. No changes have been made by producing it.
' -f (html $Data.TenantName))))
+ $blocks.Add((New-CippReportHeading -Title 'Where you stand today'))
+ $blocks.Add((New-CippReportStatRow -Stats @(
+ @{ value = "$(nz $summary.alignedPercentage)%"; label = 'Compliant incl. accepted deviations' }
+ @{ value = "$(nz $summary.verifiedPercentage)%"; label = 'Compliant with baseline' }
+ @{ value = $changesNow.Count; label = 'Changes to make' }
+ @{ value = $accepted.Count; label = 'Agreed exceptions' }
+ )))
+
+ # -- Changes we would make now --
+ $blocks.Add((New-CippReportHeading -Title "Changes we would make now ($($changesNow.Count))"))
+ if ($changesNow.Count -eq 0) {
+ $blocks.Add((New-CippReportClearBox -Title 'Nothing to change' -Content 'Every enforced standard is already in its expected state.'))
+ } else {
+ $blocks.Add((New-CippReportBullets -Items @($changesNow | ForEach-Object {
+ $meta = @(
+ if ($_.impact) { [string]$_.impact }
+ if ((nz $_.secureScoreImpact) -gt 0) { "increases Secure Score by up to $(nz $_.secureScoreImpact) points" }
+ if ([string]$_.status -like 'Denied*') { 'deviation denied, fix pending' }
+ ) -join ' - '
+ @{ label = [string]$_.standardLabel; text = "$(& $textFor $_.standardName $_.standardLabel)$(if ($meta) { " ($meta)" })" }
+ })))
+ }
+
+ # -- Planned future changes --
+ $blocks.Add((New-CippReportHeading -Title 'Planned future changes (staged rollout)'))
+ if ($planned.Count -eq 0) {
+ $blocks.Add((New-CippReportClearBox -Title 'No further staged changes' -Content 'This tenant is in the final stage of every assigned baseline.'))
+ }
+ foreach ($state in $planned) {
+ $when = & $estimatedAt $state
+ $blocks.Add((New-CippReportInfoBox -Title ([string]$state.templateName) -Content ('Currently in Stage {0} of {1} ({2}). Next: Stage {3} ({4}) - advances when {5}{6}.' -f (nz $state.currentStage), (nz $state.totalStages), $state.stageName, ((nz $state.currentStage) + 1), $state.nextStageName, (& $describeConditions $state.nextStage), $(if ($when) { ", estimated around $when" }))))
+ $standards = @($state.nextStage.standards ?? @()) | ForEach-Object { ([string]$_).Split('#')[0] } | Select-Object -Unique
+ $items = @($standards | Where-Object { $catalog.ContainsKey([string]$_) } | ForEach-Object { @{ label = (& $labelFor $_); text = (& $textFor $_ $_) } })
+ if ($items.Count -gt 0) { $blocks.Add((New-CippReportBullets -Items $items)) }
+ }
+
+ # -- What-if: one more baseline --
+ if ($simulated) {
+ $blocks.Add((New-CippReportPage -Title "What-if: assigning the $($simulated.templateName) baseline" -Subtitle 'What one more baseline would roll out, stage by stage'))
+ $blocks.Add((New-CippReportParagraph -Text ("$(if ($simulated.description) { "$($simulated.description). " })This baseline is not assigned to the tenant today - below is what assigning it would roll out, stage by stage.")))
+ $stageIndex = 0
+ foreach ($stage in @($simulated.stages ?? @())) {
+ $stageIndex++
+ $timing = if ($stageIndex -eq 1) { 'applies immediately' } else { "advances when $(& $describeConditions $stage)" }
+ $blocks.Add((New-CippReportHeading -Title "Stage ${stageIndex}: $($stage.name) - $timing"))
+ $standards = @($stage.standards ?? @()) | ForEach-Object { ([string]$_).Split('#')[0] } | Select-Object -Unique
+ $items = @($standards | Where-Object { $catalog.ContainsKey([string]$_) } | ForEach-Object {
+ $name = [string]$_
+ $current = $rows | Where-Object { $_.standardName -eq $name } | Select-Object -First 1
+ $effect = if ($current -and $current.status -eq 'Compliant') { "No change - already aligned today (configured by $($current.sourceTemplate))." } else { 'Would change this tenant when the stage applies.' }
+ @{ label = (& $labelFor $name); text = "$(& $textFor $name $name) $effect" }
+ })
+ if ($items.Count -gt 0) { $blocks.Add((New-CippReportBullets -Items $items)) }
+ else { $blocks.Add((New-CippReportNote -Text 'No catalogued standards in this stage.')) }
+ }
+ }
+
+ # -- Agreed exceptions --
+ if ($accepted.Count -gt 0) {
+ $blocks.Add((New-CippReportHeading -Title "Agreed exceptions we will not change ($($accepted.Count))"))
+ $blocks.Add((New-CippReportBullets -Items @($accepted | ForEach-Object { @{ label = [string]$_.standardLabel; text = $(if ($_.deviationReason) { [string]$_.deviationReason } else { 'Accepted deviation.' }) } })))
+ }
+
+ @{
+ Blocks = @($blocks)
+ Variables = @{
+ coverlabel = 'Baseline What-If'
+ coversubtitle = "What applying the configured standards would change at $($Data.TenantName), today and at each planned stage. No changes have been made."
+ covermeta = ('{0} to make / {1} / {2}' -f (plural $changesNow.Count 'change'), (plural $planned.Count 'staged rollout'), (plural $accepted.Count 'agreed exception'))
+ covermetanote = "Compliant with baseline: $(nz $summary.verifiedPercentage)%"
+ coverfooternote = 'What-if preview - no changes were made'
+ coverfallbackimage = '/reportImages/working.jpg'
+ footerlabel = "$($Data.TenantName) - Baseline What-If"
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippBecReportTree.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippBecReportTree.ps1
new file mode 100644
index 0000000000..b0d511294b
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippBecReportTree.ps1
@@ -0,0 +1,1063 @@
+function Build-CippBecReportTree {
+ <#
+ .SYNOPSIS
+ Compose the BEC (Business Email Compromise) analysis report as a component tree - the server
+ port of BECRemediationReportButton's BECRemediationReportDocument.
+ .DESCRIPTION
+ Returns @{ Blocks; Variables }: the content blocks plus the cover/footer report variables. The
+ cover names the compromised user rather than the tenant. Detail callouts use -Lines so each
+ label/value line is a tight line break.
+
+ The report leads with an executive intelligence section (results roll-up, findings by attacker
+ objective, evidence-driven priority actions, an order-of-events timeline and any containment
+ already run), then educational context, the per-check detail (Checks 1-21), and recommendations
+ and compliance. Every derived value mirrors the client renderer and the case workspace helpers
+ (bec-objectives / bec-timeline) so the PDF and the on-screen case agree. The threat level is read
+ from the server-computed Score on the run; the analysis window is AnalysisWindowDays.
+ .PARAMETER UserData
+ The investigated user: displayName, userPrincipalName.
+ .PARAMETER BecData
+ The completed BEC results payload (from BecResults), with a .Run block attached carrying the
+ run's CaseId and containment history (as the client receives it from execBECCheck).
+ .PARAMETER TenantName
+ The tenant the user belongs to.
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)]$UserData, [Parameter(Mandatory)]$BecData, [string]$TenantName, [ValidateSet('full', 'summary')][string]$Variant = 'full')
+
+ # 'summary' = the executive pages only (cover + Executive Summary), for a C-suite reader; 'full' =
+ # every page. Mirrors the client BECRemediationReportDocument variant.
+ $isSummary = $Variant -eq 'summary'
+ $bec = $BecData
+ $loc = $bec.LocationAnalysis
+ $ana = $bec.SentMessageAnalysis
+ $windowDays = if ($bec.AnalysisWindowDays) { [int]$bec.AnalysisWindowDays } else { 7 }
+
+ # -- formatting/utility helpers --
+ function Cnt($x) { if ($null -eq $x) { return 0 }; @($x).Count }
+ function AsInt($v) { if ($null -eq $v) { return 0 }; try { [int]$v } catch { 0 } }
+ function ToDate($v) { if (-not $v) { return $null }; try { [datetime]$v } catch { $null } }
+ function FmtDate($d) {
+ if (-not $d) { return 'N/A' }
+ try { return ([datetime]$d).ToString('MMM d, yyyy, hh:mm tt', [Globalization.CultureInfo]::InvariantCulture) } catch { return "$d" }
+ }
+ function FmtSafelist($v) {
+ if (-not $v) { return 'unchanged' }
+ if ($v -is [array]) { $j = ($v -join ', '); if ($j) { return $j } else { return 'unchanged' } }
+ return "$v"
+ }
+ function CleanStr($v) { $t = "$v".Trim(); if ($t.Length -gt 0) { $t } else { $null } }
+ function JoinDetail { param([object[]]$Parts) (@($Parts | ForEach-Object { CleanStr $_ } | Where-Object { $_ })) -join ' - ' }
+
+ # analysis window: windowDays before extraction (mirrors becWindowStart)
+ $extractedAt = ToDate $bec.ExtractedAt
+ if (-not $extractedAt) { $extractedAt = Get-Date }
+ $windowStart = $extractedAt.AddDays(-$windowDays)
+
+ # -- statistics (mirrors the client stats object) --
+ $stats = @{
+ newRules = Cnt $bec.NewRules
+ ruleChanges = Cnt $bec.InboxRuleChanges
+ newUsers = Cnt $bec.NewUsers
+ newApps = Cnt $bec.AddedApps
+ permissionChanges = Cnt $bec.MailboxPermissionChanges
+ permissionChangesTargetingUser = Cnt @($bec.MailboxPermissionChanges | Where-Object { $_.TargetsSuspect -eq $true })
+ mfaDevices = Cnt $bec.MFADevices
+ passwordChanges = Cnt $bec.ChangedPasswords
+ sentMessages = Cnt $bec.SentMessages
+ trustedSenders = Cnt $bec.TrustedSenders
+ blockedSenders = Cnt $bec.BlockedSenders
+ safelistChanges = Cnt $bec.SafelistChanges
+ sharingChanges = Cnt $bec.SharingChanges
+ anonymousLinks = Cnt @($bec.SharingChanges | Where-Object { "$($_.Operation)".StartsWith('AnonymousLink') })
+ intuneDevices = Cnt $bec.IntuneDevices
+ signIns = Cnt $bec.SuspectUserSignIns
+ sentTotalMessages = AsInt $ana.TotalMessages
+ sentTotalRecipients = AsInt $ana.TotalRecipients
+ repeatedSubjects = AsInt $ana.FlaggedSubjectCount
+ sendBursts = Cnt $ana.Bursts
+ massMailFlagged = ($ana.Flagged -eq $true)
+ maliciousApps = (Cnt @($bec.AddedApps | Where-Object { $_.MaliciousMatch })) + (Cnt $bec.MaliciousSPs)
+ foreignSignIns = AsInt $loc.ForeignSignInCount
+ foreignSuccessfulSignIns = AsInt $loc.ForeignSuccessfulSignInCount
+ foreignSentMessages = AsInt $loc.ForeignSentMessageCount
+ }
+ $stats.foreignActivity = (AsInt $loc.ForeignRuleChangeCount) + (AsInt $loc.ForeignSafelistChangeCount) +
+ (AsInt $loc.ForeignSharingChangeCount) + (AsInt $loc.ForeignSentMessageCount)
+ $stats.recentIntuneDevices = Cnt @($bec.IntuneDevices | Where-Object { $d = ToDate $_.enrolledDateTime; $d -and $d -ge $windowStart })
+ $stats.recentMfaDevices = Cnt @($bec.MFADevices | Where-Object { $d = ToDate $_.createdDateTime; $d -and $d -ge $windowStart })
+
+ # successful foreign sign-ins first
+ $foreignSignInList = @($bec.SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true } |
+ Sort-Object -Property @{ Expression = { $_.Status -eq 'Success' }; Descending = $true })
+ $sortedIntune = @($bec.IntuneDevices | Sort-Object -Property @{ Expression = { $d = ToDate $_.enrolledDateTime; if ($d) { $d } else { [datetime]0 } }; Descending = $true })
+
+ # -- threat level (server-computed, read from the run's Score) --
+ $threatLevel = if ($bec.Score.Level) { "$($bec.Score.Level)" } else { 'Low' }
+ $threatValue = AsInt $bec.Score.Value
+ $threatColour = @{ High = '#742A2A'; Medium = '#744210'; Low = '#22543D' }[$threatLevel]
+ if (-not $threatColour) { $threatColour = '#22543D' }
+ $appliedSignals = @($bec.Score.Breakdown | Where-Object { $_.Applied })
+
+ # -- completeness (skipped / partial checks) --
+ $completeness = $bec.Completeness
+ $completenessEntries = if ($completeness) { @($completeness.PSObject.Properties) } else { @() }
+ $skippedCollectors = @($completenessEntries | Where-Object { $_.Value -and $_.Value.Skipped })
+ $incompleteCollectors = @($completenessEntries | Where-Object { $_.Value -and $_.Value.Complete -eq $false -and -not $_.Value.Skipped })
+
+ # -- flagged subsets used by the executive section and the deep checks --
+ $flaggedDelegations = @($bec.Delegations | Where-Object { $_.Flagged })
+ $flaggedGrants = @($bec.UserGrants | Where-Object { $_.Flagged })
+ $flaggedTransportChanges = @($bec.TransportRuleChanges | Where-Object { $_.Flagged })
+ $flaggedTransportRules = @($bec.TransportRulesFlagged)
+ $flaggedAddIns = @($bec.MailboxAddIns | Where-Object { $_.Flagged })
+ $receivedFindings = @($bec.ReceivedMailFindings)
+ $deliveredThreats = @($bec.DefenderDetections | Where-Object { $_.Delivered })
+ $flaggedAudits = @($bec.DirectoryAudits | Where-Object { $_.Flagged })
+ $recentRegisteredDevices = @($bec.RegisteredDevices | Where-Object { $_.RegisteredInWindow })
+ $foreignNonInteractive = @($bec.NonInteractiveSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' })
+ $mailActivitySummary = $bec.MailActivitySummary
+ $riskState = $bec.RiskState
+
+ $forwardingAddress = if ($bec.MailboxState.ForwardingSmtpAddress) { "$($bec.MailboxState.ForwardingSmtpAddress)" } elseif ($bec.MailboxState.ForwardingAddress) { "$($bec.MailboxState.ForwardingAddress)" } else { $null }
+ $hasForwarding = [bool]($bec.MailboxState.HasForwarding -or $forwardingAddress)
+
+ $upn = $UserData.userPrincipalName
+ $usageLoc = $loc.UsageLocation
+
+ # ============================================================================================
+ # Findings by attacker objective (the same five-objective lens and flag predicates the case
+ # workspace uses - becFindingFlags / becGroupFlagged). Only the per-finding counts feed the bars.
+ # ============================================================================================
+ $flagCounts = @{
+ SuspectUserSignIns = AsInt $loc.ForeignSuccessfulSignInCount
+ NonInteractiveSignIns = Cnt @($bec.NonInteractiveSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' })
+ MFADevices = $stats.recentMfaDevices
+ RiskState = $(if ($riskState.Listed) { 1 } else { 0 })
+ RegisteredDevices = $recentRegisteredDevices.Count
+ IntuneDevices = $stats.recentIntuneDevices
+ NewRules = Cnt @($bec.NewRules | Where-Object { $_.Suspicious -or (@($_.RiskReasons).Count -gt 0) })
+ Delegations = $flaggedDelegations.Count
+ UserGrants = $flaggedGrants.Count
+ MailboxAddIns = $flaggedAddIns.Count
+ AddedApps = (Cnt @($bec.AddedApps | Where-Object { $_.MaliciousMatch })) + (Cnt $bec.MaliciousSPs)
+ MailboxState = @(if ($bec.MailboxState.HasForwarding) { 1 }; if ($bec.MailboxState.AutoReplyState -and $bec.MailboxState.AutoReplyState -ne 'Disabled') { 1 }).Count
+ TrustedSenders = $stats.safelistChanges
+ TransportRuleChanges = $flaggedTransportChanges.Count
+ MailboxPermissionChanges = $stats.permissionChangesTargetingUser
+ SentMessages = $(if ($stats.massMailFlagged) { 1 } else { 0 })
+ SharingChanges = $stats.anonymousLinks
+ MailActivity = $(if ($mailActivitySummary.HardDeleteExceeded) { 1 } else { 0 })
+ ReceivedMailFindings = $receivedFindings.Count + $deliveredThreats.Count
+ NewUsers = $stats.newUsers
+ DirectoryAudits = $flaggedAudits.Count
+ }
+ $groupKeys = [ordered]@{
+ access = @('SuspectUserSignIns', 'NonInteractiveSignIns', 'MFADevices', 'RiskState', 'RegisteredDevices', 'IntuneDevices')
+ persistence = @('NewRules', 'Delegations', 'UserGrants', 'MailboxAddIns', 'AddedApps')
+ mailflow = @('MailboxState', 'TrustedSenders', 'TransportRuleChanges', 'MailboxPermissionChanges')
+ exfil = @('SentMessages', 'SharingChanges', 'MailActivity', 'ReceivedMailFindings')
+ blast = @('PartnerActions', 'NewUsers', 'ChangedPasswords', 'DirectoryAudits')
+ }
+ $objectiveMeta = @{
+ access = @{ label = 'Access'; colour = '#3182CE' }
+ persistence = @{ label = 'Persistence'; colour = '#805AD5' }
+ mailflow = @{ label = 'Mail flow'; colour = '#DD6B20' }
+ exfil = @{ label = 'Exfiltration'; colour = '#E53E3E' }
+ blast = @{ label = 'Blast radius'; colour = '#718096' }
+ }
+ $objectiveData = @(foreach ($id in $groupKeys.Keys) {
+ $sum = (@($groupKeys[$id] | ForEach-Object { AsInt $flagCounts[$_] }) | Measure-Object -Sum).Sum
+ @{ label = $objectiveMeta[$id].label; value = [int]$sum; colour = $objectiveMeta[$id].colour }
+ })
+ $totalFindings = (@($objectiveData | ForEach-Object { $_.value }) | Measure-Object -Sum).Sum
+ $objectiveMax = [Math]::Max((@($objectiveData | ForEach-Object { $_.value }) | Measure-Object -Maximum).Maximum, 1)
+
+ # ============================================================================================
+ # Results roll-up: every check as one row, flagged (with a high-risk sub-count) or clear.
+ # ============================================================================================
+ $summarySource = @(
+ @{ area = 'Inbox rules & changes'; count = ($stats.newRules + $stats.ruleChanges) }
+ @{ area = 'Mailbox delegations'; count = $flaggedDelegations.Count }
+ @{ area = 'Application consents'; count = $flaggedGrants.Count; danger = $flaggedGrants.Count }
+ @{ area = 'New / rogue applications'; count = $stats.newApps; danger = $stats.maliciousApps }
+ @{ area = 'Mailbox permission changes'; count = $stats.permissionChanges }
+ @{ area = 'Transport rules'; count = ($flaggedTransportRules.Count + $flaggedTransportChanges.Count) }
+ @{ area = 'Mailbox add-ins'; count = $flaggedAddIns.Count }
+ @{ area = 'Forwarding & auto-reply'; count = $(if ($hasForwarding) { 1 } else { 0 }) }
+ @{ area = 'Trusted / blocked sender changes'; count = $stats.safelistChanges }
+ @{ area = 'Sent mail / mass-mail'; count = $(if ($stats.massMailFlagged) { 1 } else { 0 }) }
+ @{ area = 'Received phishing & threats'; count = ($receivedFindings.Count + $deliveredThreats.Count); danger = $deliveredThreats.Count }
+ @{ area = 'Sharing links'; count = $stats.sharingChanges; danger = $stats.anonymousLinks }
+ @{ area = 'MFA methods (new in window)'; count = $stats.recentMfaDevices }
+ @{ area = 'Registered devices (new in window)'; count = $recentRegisteredDevices.Count }
+ @{ area = 'Intune devices (new in window)'; count = $stats.recentIntuneDevices }
+ @{ area = 'Foreign successful sign-ins'; count = $stats.foreignSuccessfulSignIns; danger = $stats.foreignSuccessfulSignIns }
+ @{ area = 'Directory audit events'; count = $flaggedAudits.Count }
+ @{ area = 'Identity Protection risk'; count = $(if ($riskState.Listed) { 1 } else { 0 }) }
+ )
+ $summaryData = @($summarySource | ForEach-Object {
+ $danger = AsInt $_.danger
+ $flagged = AsInt $_.count
+ $result = if ($danger -gt 0) { "$flagged flagged - $danger high-risk" } elseif ($flagged -gt 0) { "$flagged flagged" } else { 'Clear' }
+ $tone = if ($danger -gt 0) { 'fail' } elseif ($flagged -gt 0) { 'warn' } else { 'pass' }
+ @{ area = $_.area; result = $result; tone = $tone }
+ })
+ $flaggedAreaCount = @($summaryData | Where-Object { $_.result -ne 'Clear' }).Count
+
+ # ============================================================================================
+ # Priority remediation actions, written from what was actually found (mirrors tailoredActions).
+ # ============================================================================================
+ $isHighOrMed = ($threatLevel -eq 'High' -or $threatLevel -eq 'Medium')
+ $ruleNames = (@($bec.NewRules | ForEach-Object { $_.Name } | Where-Object { $_ } | Select-Object -First 3)) -join ', '
+ $rogueAppNames = (@(
+ @($bec.AddedApps | Where-Object { $_.MaliciousMatch } | ForEach-Object { if ($_.DisplayName) { $_.DisplayName } else { $_.AppId } })
+ @($bec.MaliciousSPs | ForEach-Object { if ($_.DisplayName) { $_.DisplayName } else { $_.AppId } })
+ ) | Where-Object { $_ } | Select-Object -First 3) -join ', '
+ $consentNames = (@($flaggedGrants | ForEach-Object { if ($_.ClientDisplayName) { $_.ClientDisplayName } else { $_.ClientAppId } } | Where-Object { $_ } | Select-Object -First 3)) -join ', '
+
+ $tailoredActions = @(
+ if ($isHighOrMed) { @{ tag = 'Critical'; text = "Reset $(if ($upn) { $upn } else { 'the user' })'s password and revoke all active sessions to cut off any current attacker access." } }
+ if ($threatLevel -eq 'High') { @{ tag = 'Critical'; text = 'Block sign-in for the account until the mailbox and identity are confirmed clean.' } }
+ if ($flaggedGrants.Count -gt 0 -or $stats.maliciousApps -gt 0) {
+ $names = if ($consentNames) { $consentNames } elseif ($rogueAppNames) { $rogueAppNames } else { '' }
+ @{ tag = 'Critical'; text = "Revoke $($flaggedGrants.Count + $stats.maliciousApps) risky application consent(s)$(if ($names) { " ($names)" }) - consent survives a password reset." }
+ }
+ if ($stats.maliciousApps -gt 0) { @{ tag = 'Critical'; text = "Disable the catalog-matched rogue application(s)$(if ($rogueAppNames) { " ($rogueAppNames)" }) tenant-wide." } }
+ if ($stats.newRules -gt 0 -or $stats.ruleChanges -gt 0) { @{ tag = 'High'; text = "Disable the $($stats.newRules + $stats.ruleChanges) suspicious inbox rule(s)/change(s)$(if ($ruleNames) { " ($ruleNames)" }) that hide replies or auto-forward mail." } }
+ if ($hasForwarding) { @{ tag = 'High'; text = "Clear mailbox forwarding$(if ($forwardingAddress) { " to $forwardingAddress" }), which silently copies mail out of the tenant." } }
+ if ($flaggedDelegations.Count -gt 0) { @{ tag = 'High'; text = "Remove $($flaggedDelegations.Count) flagged mailbox delegation(s) - a delegate keeps access after a reset." } }
+ if ($stats.anonymousLinks -gt 0 -or $stats.sharingChanges -gt 0) { @{ tag = 'High'; text = "Remove the $($stats.sharingChanges) sharing-link change(s)$(if ($stats.anonymousLinks) { ", including $($stats.anonymousLinks) 'anyone' link(s)" }) and disable OneDrive sharing - anonymous links expose data past any reset." } }
+ if ($stats.massMailFlagged) { @{ tag = 'High'; text = "The mailbox sent a mass-mail campaign ($($stats.sentTotalMessages) message(s) to $($stats.sentTotalRecipients) recipient(s)). Scope the wave and warn recipients before anything is purged." } }
+ if ($stats.foreignSuccessfulSignIns -gt 0) { @{ tag = 'High'; text = "$($stats.foreignSuccessfulSignIns) successful sign-in(s) from outside the assigned usage location confirm access - treat the account as compromised." } }
+ if (($flaggedTransportRules.Count + $flaggedTransportChanges.Count) -gt 0) { @{ tag = 'High'; text = "Review and disable $($flaggedTransportRules.Count + $flaggedTransportChanges.Count) tenant transport rule(s) changed in the window - these affect every mailbox." } }
+ if ($stats.recentMfaDevices -gt 0) { @{ tag = 'Medium'; text = "Remove $($stats.recentMfaDevices) MFA method(s) registered during the window, then re-register trusted ones." } }
+ if ($recentRegisteredDevices.Count -gt 0) { @{ tag = 'Medium'; text = "Disable $($recentRegisteredDevices.Count) device(s) registered during the window so they cannot satisfy device-based Conditional Access." } }
+ if ($stats.safelistChanges -gt 0) { @{ tag = 'Medium'; text = "Review $($stats.safelistChanges) trusted-sender / safelist change(s) that would let an attacker's future mail skip filtering." } }
+ if ($flaggedAddIns.Count -gt 0) { @{ tag = 'Medium'; text = "Disable $($flaggedAddIns.Count) flagged mailbox add-in(s)." } }
+ )
+ $priorityActions = if ($tailoredActions.Count -gt 0) { $tailoredActions } else {
+ @(@{ tag = 'Monitor'; text = 'No specific indicators require remediation. Continue monitoring the account for 30 days and keep MFA enforced as a precaution.' })
+ }
+ $priorityRows = @($priorityActions | ForEach-Object {
+ $tone = switch ($_.tag) { 'Critical' { 'fail' } 'High' { 'fail' } 'Medium' { 'warn' } default { 'pass' } }
+ @{ tag = $_.tag; text = $_.text; tone = $tone }
+ })
+
+ # -- impact findings (the plain-terms outcome; mirrors impactFindings) --
+ $impactFindings = @(
+ if ($stats.foreignSuccessfulSignIns -gt 0 -or $foreignNonInteractive.Count -gt 0) { "Unauthorized access is confirmed - $($stats.foreignSuccessfulSignIns + $foreignNonInteractive.Count) successful sign-in(s) came from outside the account's assigned location." }
+ if ($riskState.Listed) { "Microsoft Identity Protection currently flags this account as at risk$(if ($riskState.RiskLevel) { " ($($riskState.RiskLevel) risk)" })." }
+ if ($hasForwarding) { "Incoming mail is being copied out of the organization$(if ($forwardingAddress) { " to $forwardingAddress" }), so the attacker keeps reading it even after a reset." }
+ if ($stats.newRules -gt 0 -or $stats.ruleChanges -gt 0) { "$($stats.newRules + $stats.ruleChanges) inbox rule(s) or change(s) hide, delete or redirect the user's mail." }
+ if ($stats.anonymousLinks -gt 0) { "$($stats.anonymousLinks) `"anyone with the link`" sharing link(s) expose files to anyone holding the URL, past any later reset." }
+ if ($stats.massMailFlagged) { "The mailbox sent a mass-mail wave - $($stats.sentTotalMessages) message(s) to $($stats.sentTotalRecipients) recipient(s) - so it is now being used to attack others." }
+ if ($flaggedGrants.Count -gt 0 -or $stats.maliciousApps -gt 0) { "$($flaggedGrants.Count + $stats.maliciousApps) risky application consent(s) or app(s) retain access to data independently of the password." }
+ if ($stats.recentMfaDevices -gt 0 -or $recentRegisteredDevices.Count -gt 0) { "New sign-in persistence was added - $($stats.recentMfaDevices) MFA method(s) and $($recentRegisteredDevices.Count) device(s) registered during the window." }
+ if ($flaggedDelegations.Count -gt 0) { "$($flaggedDelegations.Count) mailbox delegation(s) let another account read this mailbox." }
+ )
+
+ # ============================================================================================
+ # Order of events - the correlated timeline (mirrors buildBecTimeline), collapsed per minute.
+ # ============================================================================================
+ $partnerKinds = @('Partner', 'OtherPartner', 'CIPP')
+ $rawEvents = [System.Collections.Generic.List[object]]::new()
+ foreach ($s in @($bec.SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true })) {
+ $rawEvents.Add(@{
+ date = ToDate (@($s.CreatedDateTime, $s.createdDateTime, $s.Timestamp) | Where-Object { $_ } | Select-Object -First 1)
+ label = "Sign-in $(if ($s.Status -eq 'Success') { 'success' } else { "($(if ($s.Status) { $s.Status } else { 'attempt' }))" })"
+ ip = CleanStr (@($s.IPAddress, $s.ClientIP) | Where-Object { $_ } | Select-Object -First 1)
+ app = CleanStr (@($s.AppDisplayName, $s.ClientAppUsed) | Where-Object { $_ } | Select-Object -First 1)
+ location = CleanStr ((@($s.City, $s.Country) | Where-Object { $_ }) -join ', ')
+ })
+ }
+ foreach ($a in @($bec.DirectoryAudits | Where-Object { $_.Flagged })) {
+ $rawEvents.Add(@{
+ date = ToDate $a.ActivityDateTime
+ label = if ($a.Activity) { "$($a.Activity)" } else { 'Directory change' }
+ ip = CleanStr $a.ClientIP
+ actor = CleanStr (@($a.ActorResolved, $a.InitiatedBy) | Where-Object { $_ } | Select-Object -First 1)
+ partner = ($partnerKinds -contains "$($a.ActorKind)")
+ })
+ }
+ foreach ($c in @($bec.InboxRuleChanges)) {
+ $rawEvents.Add(@{
+ date = ToDate $c.Date
+ label = if ($c.Operation) { "$($c.Operation)" } else { 'Inbox rule change' }
+ ip = CleanStr $c.ClientIP
+ target = CleanStr $c.RuleName
+ foreign = ($c.ForeignLocation -eq $true)
+ partner = ($partnerKinds -contains "$($c.ActorKind)")
+ })
+ }
+ foreach ($c in @($bec.MailboxPermissionChanges)) {
+ $rawEvents.Add(@{
+ date = ToDate $c.Date
+ label = if ($c.Operation) { "$($c.Operation)" } else { 'Mailbox permission change' }
+ ip = CleanStr $c.ClientIP
+ targetsSuspect = [bool]$c.TargetsSuspect
+ partner = ($partnerKinds -contains "$($c.ActorKind)")
+ })
+ }
+ foreach ($c in @($bec.SafelistChanges)) {
+ $rawEvents.Add(@{
+ date = ToDate $c.Date
+ label = if ($c.Operation) { "$($c.Operation)" } else { 'Safelist change' }
+ ip = CleanStr $c.ClientIP
+ partner = ($partnerKinds -contains "$($c.ActorKind)")
+ })
+ }
+ foreach ($c in @($bec.SharingChanges)) {
+ $rawEvents.Add(@{
+ date = ToDate $c.Date
+ label = if ($c.Operation) { "$($c.Operation)" } else { 'Sharing change' }
+ ip = CleanStr $c.ClientIP
+ target = CleanStr $c.FileName
+ partner = ($partnerKinds -contains "$($c.ActorKind)")
+ })
+ }
+ # Sent mail: one event each for a handful, else folded into one event per hour and source IP.
+ $sentMsgs = @($bec.SentMessages)
+ if ($sentMsgs.Count -le 25) {
+ foreach ($m in $sentMsgs) {
+ $rawEvents.Add(@{
+ date = ToDate $m.Received
+ label = 'Sent mail'
+ ip = CleanStr $m.FromIP
+ target = CleanStr $m.Subject
+ recipient = CleanStr $m.RecipientAddress
+ })
+ }
+ } else {
+ $buckets = @{}
+ foreach ($m in $sentMsgs) {
+ $d = ToDate $m.Received
+ if (-not $d) { continue }
+ $hour = $d.Date.AddHours($d.Hour)
+ $ip = CleanStr $m.FromIP
+ $key = '{0:o}|{1}' -f $hour, $ip
+ if (-not $buckets.ContainsKey($key)) { $buckets[$key] = @{ date = $hour; ip = $ip; rows = [System.Collections.Generic.List[object]]::new() } }
+ $buckets[$key].rows.Add($m)
+ }
+ foreach ($bucket in $buckets.Values) {
+ $rows = @($bucket.rows)
+ $emails = (@($rows | ForEach-Object { $_.MessageTraceId } | Where-Object { $_ } | Select-Object -Unique)).Count
+ if ($emails -eq 0) { $emails = $rows.Count }
+ $recipients = (@($rows | ForEach-Object { CleanStr $_.RecipientAddress } | Where-Object { $_ } | Select-Object -Unique)).Count
+ $subjectGroups = $rows | Group-Object -Property { $s = CleanStr $_.Subject; if ($s) { $s } else { '(no subject)' } } | Sort-Object -Property Count -Descending
+ $topSubject = ($subjectGroups | Select-Object -First 1).Name
+ $rawEvents.Add(@{
+ date = $bucket.date
+ label = "$emails email$(if ($emails -eq 1) { '' } else { 's' }) sent"
+ ip = $bucket.ip
+ target = JoinDetail @("to $recipients recipient$(if ($recipients -eq 1) { '' } else { 's' })", $(if ($topSubject) { "`"$topSubject`"" }))
+ })
+ }
+ }
+ foreach ($f in @($bec.ReceivedMailFindings)) {
+ $rawEvents.Add(@{
+ date = ToDate $f.Received
+ label = "Received: $(if ($f.FindingType) { $f.FindingType } else { 'finding' })"
+ target = CleanStr $f.Subject
+ sender = CleanStr $f.SenderAddress
+ })
+ }
+ foreach ($t in @($bec.DefenderDetections | Where-Object { $_.Delivered })) {
+ $rawEvents.Add(@{
+ date = ToDate $t.ReceivedDateTime
+ label = 'Threat delivered'
+ target = CleanStr $t.Subject
+ sender = CleanStr $t.SenderAddress
+ })
+ }
+ foreach ($u in @($bec.NewUsers)) {
+ $rawEvents.Add(@{ date = ToDate $u.createdDateTime; label = 'User created'; target = CleanStr $u.displayName })
+ }
+ foreach ($m in @($bec.MFADevices | Where-Object { $d = ToDate $_.createdDateTime; $d -and $d -ge $windowStart })) {
+ $rawEvents.Add(@{ date = ToDate $m.createdDateTime; label = 'MFA method registered'; target = ("$($m.'@odata.type')" -replace '#microsoft\.graph\.', '') })
+ }
+ foreach ($dev in @($bec.RegisteredDevices | Where-Object { $_.RegisteredInWindow })) {
+ $rawEvents.Add(@{ date = ToDate (@($dev.registrationDateTime, $dev.createdDateTime) | Where-Object { $_ } | Select-Object -First 1); label = 'Device registered'; target = CleanStr (@($dev.displayName, $dev.deviceId) | Where-Object { $_ } | Select-Object -First 1) })
+ }
+ foreach ($dev in @($bec.IntuneDevices | Where-Object { $d = ToDate $_.enrolledDateTime; $d -and $d -ge $windowStart })) {
+ $rawEvents.Add(@{ date = ToDate $dev.enrolledDateTime; label = 'Intune device enrolled'; target = CleanStr (@($dev.deviceName, $dev.model) | Where-Object { $_ } | Select-Object -First 1) })
+ }
+ foreach ($u in @($bec.ChangedPasswords)) {
+ $rawEvents.Add(@{ date = ToDate $u.lastPasswordChangeDateTime; label = 'Password changed'; target = CleanStr (@($u.displayName, $u.userPrincipalName) | Where-Object { $_ } | Select-Object -First 1) })
+ }
+
+ $sortedEvents = @($rawEvents | Where-Object { $_.date } | Sort-Object -Property date)
+ $timelineEvents = [System.Collections.Generic.List[object]]::new()
+ $prev = $null
+ foreach ($e in $sortedEvents) {
+ $detail = JoinDetail @(
+ $e.location, $e.app, $e.actor, $e.target,
+ $(if ($e.recipient) { "to $($e.recipient)" }),
+ $e.sender,
+ $(if ($e.foreign) { 'foreign' }),
+ $(if ($e.partner) { 'partner action' }),
+ $(if ($e.targetsSuspect) { 'targets this mailbox' }),
+ $e.ip
+ )
+ $minute = $e.date.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm')
+ if ($prev -and $prev.minute -eq $minute -and $prev.label -eq $e.label -and $prev.detail -eq $detail) {
+ $prev.count++
+ continue
+ }
+ $prev = @{ minute = $minute; label = $e.label; detail = $detail; date = $e.date; count = 1 }
+ $timelineEvents.Add($prev)
+ }
+ $timelineRows = @($timelineEvents | ForEach-Object {
+ @{ when = (FmtDate $_.date); event = $(if ($_.count -gt 1) { "$($_.label) (x$($_.count))" } else { $_.label }); detail = $_.detail }
+ })
+
+ # -- remediation already run (from the run's stored containment history) --
+ $remediationRows = @(@($bec.Run.Containment) | Where-Object { $_ } | Sort-Object -Property { ToDate $_.At } -Descending | ForEach-Object {
+ $entry = $_
+ foreach ($row in @($entry.Results)) {
+ $state = "$($row.state)"
+ $tone = switch ($state) { 'success' { 'pass' } 'error' { 'fail' } 'warning' { 'warn' } default { '' } }
+ @{
+ when = (FmtDate $entry.At)
+ action = (("$($row.Action)" -replace '([a-z0-9])([A-Z])', '$1 $2').Trim())
+ target = "$($row.Target)"
+ result = "$($row.resultText)"
+ state = $state
+ tone = $tone
+ }
+ }
+ })
+
+ $b = [System.Collections.Generic.List[object]]::new()
+
+ # === PAGE 1: EXECUTIVE SUMMARY ===
+ $b.Add((New-CippReportPage -Title 'Executive Summary' -Subtitle 'Overview of Business Email Compromise investigation findings'))
+ $b.Add((New-CippReportParagraph -Html ('
This report documents the findings of a Business Email Compromise (BEC) investigation performed for the user account {0} within {1}. The investigation analyzed suspicious activity indicators including mailbox rules, permission changes, new applications, authentication patterns, and sign-in locations over a {2}-day period.
' -f [System.Net.WebUtility]::HtmlEncode([string]$upn), [System.Net.WebUtility]::HtmlEncode([string]$TenantName), $windowDays)))
+ $b.Add((New-CippReportParagraph -Text 'Business Email Compromise is a sophisticated scam targeting organizations that regularly perform wire transfers or have established relationships with foreign suppliers. Attackers compromise legitimate email accounts through social engineering or computer intrusion techniques to conduct unauthorized fund transfers, steal sensitive information, or impersonate executives.'))
+
+ $b.Add((New-CippReportHeading -Title 'Investigation Overview'))
+ $b.Add((New-CippReportStatRow -Stats @(
+ @{ value = $stats.newRules; label = 'Mailbox Rules' }
+ @{ value = $stats.permissionChanges; label = 'Permission Changes' }
+ @{ value = $stats.foreignSignIns; label = 'Foreign Sign-ins' }
+ @{ value = $stats.maliciousApps; label = 'Malicious Apps' }
+ )))
+ $threatText = switch ($threatLevel) {
+ 'High' { 'HIGH RISK: Multiple indicators of compromise detected. Immediate remediation actions are strongly recommended. This account shows patterns consistent with active Business Email Compromise attacks.' }
+ 'Medium' { 'MEDIUM RISK: Suspicious activity patterns detected. Review findings and consider implementing recommended security measures. Some indicators suggest potential unauthorized access.' }
+ default { 'LOW RISK: Minimal suspicious activity detected. The findings show standard user behavior with no significant indicators of compromise. Continue monitoring as a precautionary measure.' }
+ }
+ $b.Add((New-CippReportAlertBox -Colour $threatColour -Title "Threat Assessment: $threatLevel (score $threatValue)" -Content $threatText))
+ if ($appliedSignals.Count -gt 0) {
+ $b.Add((New-CippReportInfoBox -Lines -Title 'Signals that contributed to the score' -Content ((@($appliedSignals | ForEach-Object { "+$(AsInt $_.Weight) $($_.Description) ($(AsInt $_.Count))" })) -join "`n")))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'What We Found'))
+ if ($impactFindings.Count -gt 0) {
+ $b.Add((New-CippReportParagraph -Html ('
In plain terms, this is what the investigation established about {0}:
' -f [System.Net.WebUtility]::HtmlEncode([string]$upn))))
+ $b.Add((New-CippReportBullets -Items @($impactFindings | ForEach-Object { @{ text = $_ } })))
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No indicators of account compromise' -Content "None of the investigation's checks returned evidence that this account was accessed, altered or misused during the analysis window."))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Findings at a Glance'))
+ $b.Add((New-CippReportParagraph -Text ("Every check in this investigation and its result. $(if (-not $isSummary) { 'Flagged rows are expanded in the detailed findings later in this report. ' })$flaggedAreaCount of $($summaryData.Count) checks returned something to review.")))
+ if ($totalFindings -gt 0) {
+ $b.Add((New-CippReportParagraph -Html '
Findings by attacker objective - grouped by what each finding would let an attacker do:
'))
+ $b.Add((New-CippReportProgress -Items @($objectiveData | ForEach-Object { @{ label = $_.label; value = $_.value; max = $objectiveMax; display = "$($_.value)"; colour = $_.colour } })))
+ }
+ $b.Add((New-CippReportTable -Columns @(
+ @{ header = 'Check'; key = 'area'; width = 3; bold = $true }
+ @{ header = 'Result'; key = 'result'; width = 2; toneField = 'tone' }
+ ) -Rows @($summaryData) -Limit $summaryData.Count))
+ if (-not $isSummary) {
+ $b.Add((New-CippReportNote -Text 'Checks that could not run (missing a licence, permission, mailbox or service) are itemised under Data Source Information below - a check that did not run is not a pass.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Priority Remediation Actions'))
+ $b.Add((New-CippReportParagraph -Text ("Actions specific to what this investigation found, most urgent first. Your IT or security team should carry these out without delay$(if (-not $isSummary) { '; the strategic and preventative measures follow later in this report.' } else { '.' })")))
+ $b.Add((New-CippReportTable -Columns @(
+ @{ header = 'Priority'; key = 'tag'; width = 1; toneField = 'tone' }
+ @{ header = 'Action'; key = 'text'; width = 5 }
+ ) -Rows @($priorityRows) -Limit $priorityRows.Count))
+
+ $b.Add((New-CippReportHeading -Title 'Order of Events'))
+ if ($timelineRows.Count -gt 0) {
+ $b.Add((New-CippReportParagraph -Text 'Every timestamped signal - sign-ins, directory and mailbox changes, sharing, and the mail itself - in the order it happened, with who and where where known. Read it as the shape of the intrusion over time, not as isolated findings.'))
+ $b.Add((New-CippReportTable -Columns @(
+ @{ header = 'When'; key = 'when'; width = 2; bold = $true }
+ @{ header = 'Event'; key = 'event'; width = 2 }
+ @{ header = 'Detail'; key = 'detail'; width = 3 }
+ ) -Rows @($timelineRows) -Limit 40))
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No timestamped events in the window' -Content 'None of the checks returned a dated event inside the analysis window. This usually means no changes were made to the account in the period, not that data was missing.'))
+ }
+
+ if ($remediationRows.Count -gt 0) {
+ $b.Add((New-CippReportHeading -Title 'Remediation Taken'))
+ $b.Add((New-CippReportParagraph -Text 'The containment actions already run for this account during the investigation, and their result for each target.'))
+ $b.Add((New-CippReportTable -Columns @(
+ @{ header = 'When'; key = 'when'; width = 2; bold = $true }
+ @{ header = 'Action'; key = 'action'; width = 2 }
+ @{ header = 'Target'; key = 'target'; width = 2 }
+ @{ header = 'Result'; key = 'result'; width = 3 }
+ @{ header = 'State'; key = 'state'; width = 1; toneField = 'tone' }
+ ) -Rows @($remediationRows) -Limit $remediationRows.Count))
+ }
+
+ # Data Source Information and every detail page (Understanding BEC, Checks 1-21, Recommendations,
+ # Compliance) are the full report only; the summary variant stops after the executive lead.
+ if (-not $isSummary) {
+ $b.Add((New-CippReportHeading -Title 'Data Source Information'))
+ $b.Add((New-CippReportInfoBox -Title 'Audit Log Status' -Content $(if ($bec.ExtractResult) { "$($bec.ExtractResult)" } else { 'Unknown' })))
+ $b.Add((New-CippReportInfoBox -Title 'Analysis Period' -Content ("Last $windowDays days ending {0}" -f (FmtDate $bec.ExtractedAt))))
+ if ($bec.Run.CaseId -or $bec.CaseId) {
+ $caseId = if ($bec.Run.CaseId) { $bec.Run.CaseId } else { $bec.CaseId }
+ $b.Add((New-CippReportInfoBox -Title 'Case' -Content "$caseId - full investigation. Metadata only: audit records, sign-ins, trace headers, permissions, rules and devices were collected; no message content was read."))
+ }
+ if ($skippedCollectors.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Lines -Title "[!] $($skippedCollectors.Count) check(s) could not run (not applicable to this tenant or user)" -Content ((@($skippedCollectors | ForEach-Object { "$($_.Name): $(if ($_.Value.Requirement) { $_.Value.Requirement } elseif ($_.Value.Error) { $_.Value.Error } else { 'not checked' })" })) -join "`n")))
+ }
+ if ($incompleteCollectors.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Lines -Title "[!] $($incompleteCollectors.Count) check(s) returned partial data" -Content ((@($incompleteCollectors | ForEach-Object { "$($_.Name): $(if ($_.Value.Error) { $_.Value.Error } else { "capped at $($_.Value.Cap)" })" })) -join "`n")))
+ }
+ $b.Add((New-CippReportInfoBox -Title 'Assigned Usage Location' -Content $(if ($usageLoc) { "$usageLoc" } else { 'Not assigned - sign-ins and activity could not be compared against an expected country' })))
+
+ # === PAGE 2: UNDERSTANDING BEC ===
+ $b.Add((New-CippReportPage -Title 'Understanding Business Email Compromise' -Subtitle 'What is BEC and why does it matter?'))
+ $b.Add((New-CippReportParagraph -Title 'What is Business Email Compromise?' -Text 'Business Email Compromise (BEC) is a type of cyberattack where criminals gain unauthorized access to a business email account. Once inside, attackers can:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ label = 'Monitor communications:'; text = 'Read sensitive emails to learn about business operations, financial processes, and key relationships.' }
+ @{ label = 'Impersonate executives:'; text = 'Send fraudulent emails appearing to come from company leadership requesting wire transfers or sensitive data.' }
+ @{ label = 'Manipulate transactions:'; text = 'Intercept legitimate invoices and alter payment information to redirect funds to attacker-controlled accounts.' }
+ @{ label = 'Hide their tracks:'; text = 'Create email rules to automatically delete or hide messages, preventing detection.' }
+ )))
+ $b.Add((New-CippReportParagraph -Title 'Common Attack Methods' -Text 'Attackers typically gain access to email accounts through:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ label = 'Phishing:'; text = 'Deceptive emails that trick users into providing their login credentials on fake websites.' }
+ @{ label = 'Password Spraying:'; text = 'Automated attempts to log in using common passwords across many accounts.' }
+ @{ label = 'Credential Stuffing:'; text = 'Using usernames and passwords leaked from other breached websites.' }
+ @{ label = 'Malware:'; text = 'Software that captures keystrokes or steals stored passwords from compromised devices.' }
+ )))
+ $b.Add((New-CippReportParagraph -Title 'Why This Investigation Was Performed' -Text 'This analysis was initiated because suspicious activity was detected or reported for this user account. The investigation examines multiple indicators that might suggest account compromise, including unusual mailbox rules, unexpected permission changes, new application authorizations, and abnormal sign-in patterns. Early detection is critical to minimize potential damage and prevent financial loss or data theft.'))
+
+ # === PAGE 3: DETAILED FINDINGS - Check 1 ===
+ $b.Add((New-CippReportPage -Title 'Detailed Findings' -Subtitle 'Investigation results and analysis'))
+ $b.Add((New-CippReportHeading -Title 'Check 1: Mailbox Rules'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Attackers often create email rules to automatically forward, delete, or hide messages so victims never see evidence of fraudulent activity. A rule is flagged when it forwards or redirects mail (especially to an external address), deletes messages, moves them to a low-visibility folder (RSS, Archive, Deleted Items), stops processing other rules, targets financial keywords, or takes any of these actions on all incoming mail with no condition.'))
+ if ($stats.newRules -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.newRules) Mailbox Rule(s) Found" -Content 'The following mailbox rules were detected. Review each rule carefully to determine if it was created by the user or by an attacker. Rules that forward emails or move them to unusual folders are particularly suspicious.'))
+ foreach ($rule in @($bec.NewRules | Select-Object -First 10)) {
+ $lines = @(
+ if ($rule.MoveToFolder) { "Moves mail to: $($rule.MoveToFolder)" }
+ if ($rule.ForwardTo) { "Forwards to: $($rule.ForwardTo)" }
+ if ($rule.ForwardAsAttachmentTo) { "Forwards as attachment to: $($rule.ForwardAsAttachmentTo)" }
+ if ($rule.RedirectTo) { "Redirects to: $($rule.RedirectTo)" }
+ if ($rule.DeleteMessage) { 'Deletes messages' }
+ if ($rule.MarkAsRead) { 'Marks messages read' }
+ if ($rule.StopProcessingRules) { 'Stops processing further rules' }
+ if ($rule.SubjectContainsWords) { "On subject words: $(if ($rule.SubjectContainsWords -is [array]) { $rule.SubjectContainsWords -join ', ' } else { $rule.SubjectContainsWords })" }
+ if ($rule.RecentlyChanged) { 'Created or changed in the window' }
+ if ($rule.Enabled -eq $false) { 'Currently disabled' }
+ )
+ $content = if ($lines.Count -gt 0) { $lines -join "`n" } elseif ($rule.Description) { "$($rule.Description)" } else { 'No actions recorded on this rule' }
+ $b.Add((New-CippReportInfoBox -Lines -Title "Rule: $(if ($rule.Name) { $rule.Name } else { 'Unnamed Rule' })" -Content $content))
+ }
+ if ($stats.newRules -gt 10) { $b.Add((New-CippReportNote -Text "... and $($stats.newRules - 10) more rules (in the retained investigation record)")) }
+ }
+ if ($stats.ruleChanges -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.ruleChanges) Rule Change(s) in the Last $windowDays Days" -Content 'The audit log recorded inbox rules being created, changed or removed on this mailbox. Rules that were removed after use are a common way for attackers to cover their tracks.'))
+ foreach ($change in @($bec.InboxRuleChanges | Select-Object -First 10)) {
+ $lines = @(
+ "Date: $(if ($change.Date) { $change.Date } else { 'Unknown' })"
+ "By: $(if ($change.UserKey) { $change.UserKey } else { 'Unknown' })"
+ if ($change.ClientIP) { "From: $($change.ClientIP)$(if ($change.Country) { " ($($change.Country))" })" }
+ if ($change.ForeignLocation -eq $true) { '[!] Originated outside the assigned usage location' }
+ if ($change.Parameters) { "Parameters: $($change.Parameters)" }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($change.Operation) { $change.Operation } else { 'Rule Change' }): $(if ($change.RuleName) { $change.RuleName } else { 'Unnamed Rule' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.ruleChanges -gt 10) { $b.Add((New-CippReportNote -Text "... and $($stats.ruleChanges - 10) more changes (see the retained investigation record for the full list)")) }
+ }
+ if ($stats.newRules -eq 0 -and $stats.ruleChanges -eq 0) {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Suspicious Rules Found' -Content 'No mailbox rules were detected that match suspicious patterns. This is a positive indicator.'))
+ }
+
+ # === PAGE 4: DETAILED FINDINGS (Continued) - Check 2, 3 ===
+ $b.Add((New-CippReportPage -Title 'Detailed Findings (Continued)' -Subtitle 'Investigation results and analysis'))
+ $b.Add((New-CippReportHeading -Title 'Check 2: Recently Created Users'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Attackers sometimes create new user accounts to maintain persistent access or to use as staging accounts for fraudulent activities. Reviewing recently created users helps identify unauthorized account creation.'))
+ if ($stats.newUsers -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[i] $($stats.newUsers) New User(s) Found" -Content "The following users were created in the last $windowDays days. Verify that each account creation was authorized and legitimate."))
+ foreach ($u in @($bec.NewUsers | Select-Object -First 8)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($u.displayName) { $u.displayName } else { 'Unknown' })" -Content ("Email: $(if ($u.userPrincipalName) { $u.userPrincipalName } else { 'N/A' })`nCreated: $(FmtDate $u.createdDateTime)")))
+ }
+ if ($stats.newUsers -gt 8) { $b.Add((New-CippReportNote -Text "... and $($stats.newUsers - 8) more users (see the retained investigation record for the full list)")) }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No New Users Found' -Content 'No new user accounts were created during the analysis period.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 3: New Applications'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content "Attackers may authorize malicious or suspicious third-party applications to access your email and data. These applications can read emails, send messages, and access files without the user's explicit knowledge."))
+ if ($stats.maliciousApps -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.maliciousApps) Known-Malicious Application(s) Detected" -Content 'One or more applications in this tenant match the CIPP known-malicious application catalog. Consent-based access survives a password reset, so these applications should be removed unless their presence is explained.'))
+ }
+ if ($stats.newApps -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.newApps) New Application(s) Found" -Content 'New applications were granted access during the analysis period. Review each application to ensure it was authorized and is from a trusted publisher.'))
+ foreach ($app in @($bec.AddedApps | Select-Object -First 6)) {
+ $lines = @(
+ "Publisher: $(if ($app.publisher) { $app.publisher } else { 'Unknown' })"
+ "App ID: $(if ($app.appId) { $app.appId } else { 'N/A' })"
+ "Created: $(FmtDate $app.createdDateTime)"
+ if ($app.MaliciousMatch) {
+ $cats = if ($app.MaliciousMatch.Categories) { " ($($app.MaliciousMatch.Categories -join ', '))" } else { '' }
+ "[!] Matches known-malicious catalog entry `"$($app.MaliciousMatch.Name)`"$cats"
+ }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($app.displayName) { $app.displayName } elseif ($app.appDisplayName) { $app.appDisplayName } else { 'Unknown' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.newApps -gt 6) { $b.Add((New-CippReportNote -Text "... and $($stats.newApps - 6) more apps (see the retained investigation record for the full list)")) }
+ } elseif ((Cnt $bec.MaliciousSPs) -eq 0) {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No New Applications Found' -Content 'No new applications were authorized during the analysis period, and no known malicious applications are present in the tenant.'))
+ }
+ if ((Cnt $bec.MaliciousSPs) -gt 0) {
+ foreach ($app in @($bec.MaliciousSPs | Select-Object -First 6)) {
+ $lines = @(
+ "Catalog entry: $(if ($app.CatalogName) { $app.CatalogName } else { 'Unknown' })"
+ "App ID: $(if ($app.appId) { $app.appId } else { 'N/A' })"
+ "Categories: $(if ($app.Categories) { $app.Categories -join ', ' } else { 'N/A' })"
+ "Enabled: $(if ($null -ne $app.accountEnabled) { $app.accountEnabled } else { 'Unknown' })"
+ "First seen: $(FmtDate $app.createdDateTime)"
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "[!] $(if ($app.displayName) { $app.displayName } else { 'Unknown' }) (present in tenant)" -Content ($lines -join "`n")))
+ }
+ if ((Cnt $bec.MaliciousSPs) -gt 6) { $b.Add((New-CippReportNote -Text "... and $((Cnt $bec.MaliciousSPs) - 6) more (see the retained investigation record for the full list)")) }
+ }
+
+ # === PAGE 5: ADDITIONAL SECURITY CHECKS - Check 4,5,6,7 ===
+ $b.Add((New-CippReportPage -Title 'Additional Security Checks' -Subtitle 'Permissions, outbound mail, authentication, and access patterns'))
+ $b.Add((New-CippReportHeading -Title 'Check 4: Mailbox Permission Changes'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Unauthorized changes to mailbox permissions can allow attackers to grant themselves or accomplices access to read, send, or manage emails. This is a common technique to maintain persistent access.'))
+ if ($stats.permissionChanges -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.permissionChanges) Permission Change(s) Found" -Content 'Mailbox permission changes were detected. Verify that each change was authorized and necessary for legitimate business purposes.'))
+ foreach ($change in @($bec.MailboxPermissionChanges | Select-Object -First 5)) {
+ $lines = @(
+ "User: $(if ($change.UserKey) { $change.UserKey } else { 'Unknown' })"
+ "Target: $(if ($change.ObjectId) { $change.ObjectId } else { 'N/A' })"
+ "Permissions: $(if ($change.Permissions) { $change.Permissions } else { 'Unknown' })"
+ if ($change.TargetsSuspect -eq $true) { '[!] Targets the investigated mailbox' }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($change.Operation) { $change.Operation } else { 'Permission Change' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.permissionChanges -gt 5) { $b.Add((New-CippReportNote -Text "... and $($stats.permissionChanges - 5) more changes")) }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Permission Changes Found' -Content 'No mailbox permission changes were detected during the analysis period.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 5: Sent Messages'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Attackers use a compromised mailbox to send fraudulent invoices, phishing, or internal impersonation mail. The message trace shows what actually left the mailbox during the analysis period, including the IP address it was sent from.'))
+ if ($stats.sentMessages -gt 0) {
+ $totMsg = if ($stats.sentTotalMessages) { $stats.sentTotalMessages } else { $stats.sentMessages }
+ $totRcp = if ($stats.sentTotalRecipients) { $stats.sentTotalRecipients } else { $stats.sentMessages }
+ $foreignTail = if ($stats.foreignSentMessages -gt 0) { ", including $($stats.foreignSentMessages) from an IP outside the user's assigned usage location." } else { '.' }
+ $b.Add((New-CippReportParagraph -Indent -Text "[i] $totMsg message(s) to $totRcp recipient(s) were sent by this mailbox during the analysis period$foreignTail"))
+ if ($stats.massMailFlagged) {
+ $mm = -join @(
+ if ($stats.repeatedSubjects -gt 0) { "$($stats.repeatedSubjects) subject(s) were sent as many separate messages or to many recipients. " }
+ if ($stats.sendBursts -gt 0) { "$($stats.sendBursts) short burst(s) of high-volume sending were detected. " }
+ 'Identical-subject mass mail and send bursts are how a compromised mailbox spreads phishing or fraudulent invoices. Review the campaigns below and warn the recipients if the content was malicious.'
+ )
+ $b.Add((New-CippReportAlertBox -Title '[!] Mass-Mail Pattern Detected' -Content $mm))
+ }
+ foreach ($g in @($ana.RepeatedSubjects | Select-Object -First 5)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($g.Flagged) { '[!] ' })Repeated subject: $(if ($g.Subject) { $g.Subject } else { '(no subject)' })" -Content ("Messages: $($g.MessageCount)`nRecipients: $($g.RecipientCount)`nFirst sent: $(if ($g.FirstSent) { $g.FirstSent } else { 'N/A' })`nLast sent: $(if ($g.LastSent) { $g.LastSent } else { 'N/A' })")))
+ }
+ if ((Cnt $ana.RepeatedSubjects) -gt 5) { $b.Add((New-CippReportNote -Text "... and $((Cnt $ana.RepeatedSubjects) - 5) more repeated subjects (see the retained investigation record for the full list)")) }
+ foreach ($burst in @($ana.Bursts | Select-Object -First 5)) {
+ $win = if ($burst.WindowMinutes) { $burst.WindowMinutes } else { 10 }
+ $content = @(
+ "Starting: $(if ($burst.WindowStart) { $burst.WindowStart } else { 'N/A' })"
+ if ($burst.TopSubject) { "Most common subject: $($burst.TopSubject)" }
+ ) -join "`n"
+ $b.Add((New-CippReportInfoBox -Lines -Title "[!] Send burst: $($burst.MessageCount) message(s) to $($burst.RecipientCount) recipient(s) in $win minutes" -Content $content))
+ }
+ if ((Cnt $ana.Bursts) -gt 5) { $b.Add((New-CippReportNote -Text "... and $((Cnt $ana.Bursts) - 5) more bursts (see the retained investigation record for the full list)")) }
+ foreach ($msg in @($bec.SentMessages | Select-Object -First 10)) {
+ $lines = @(
+ "To: $(if ($msg.RecipientAddress) { $msg.RecipientAddress } else { 'N/A' })"
+ "Status: $(if ($msg.Status) { $msg.Status } else { 'N/A' })"
+ "Received: $(if ($msg.Received) { $msg.Received } else { 'N/A' })"
+ if ($msg.FromIP) { "From IP: $($msg.FromIP)$(if ($msg.Country) { " ($($msg.Country))" })" }
+ if ($msg.ForeignLocation -eq $true) { '[!] Sent from outside the assigned usage location' }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($msg.Subject) { $msg.Subject } else { '(no subject)' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.sentMessages -gt 10) { $b.Add((New-CippReportNote -Text "... and $($stats.sentMessages - 10) more messages (see the retained investigation record for the full list)")) }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Sent Messages Found' -Content 'No messages were sent by this mailbox during the analysis period.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 6: MFA Devices'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Multi-factor authentication (MFA) devices provide an additional layer of security. Reviewing registered MFA methods helps identify if attackers have added unauthorized devices to bypass security controls.'))
+ if ($stats.mfaDevices -gt 0) {
+ $mfaTail = if ($stats.recentMfaDevices -gt 0) { ", including $($stats.recentMfaDevices) registered in the last $windowDays days. Verify the recent registrations were made by the user - attackers register their own method to keep access after a password reset." } else { '. Verify each device belongs to the user.' }
+ $b.Add((New-CippReportParagraph -Indent -Text "[i] $($stats.mfaDevices) MFA device(s) registered$mfaTail"))
+ $sortedMfa = @($bec.MFADevices | Sort-Object -Property @{ Expression = { $d = ToDate $_.createdDateTime; if ($d) { $d } else { [datetime]0 } }; Descending = $true } | Select-Object -First 5)
+ foreach ($device in $sortedMfa) {
+ $type = "$($device.'@odata.type')".Replace('#microsoft.graph.', '').Replace('AuthenticationMethod', '')
+ $lines = @(
+ "Display Name: $(if ($device.displayName) { $device.displayName } else { 'N/A' })"
+ "Registered: $(FmtDate $device.createdDateTime)"
+ $(if (($d = ToDate $device.createdDateTime) -and $d -ge $windowStart) { '[!] Registered in the last 7 days' })
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($type) { $type } else { 'Unknown' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.mfaDevices -gt 5) { $b.Add((New-CippReportNote -Text "... and $($stats.mfaDevices - 5) more methods (see the retained investigation record for the full list)")) }
+ } else {
+ $b.Add((New-CippReportInfoBox -Tone warn -Title '[!] No MFA Devices Found' -Content 'No multi-factor authentication devices are registered. MFA is highly recommended to prevent unauthorized access.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 7: Recent Password Changes'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content "Attackers often change passwords to lock out legitimate users. Reviewing recent password changes in the tenant helps identify if the compromised account's password was changed or if other accounts were affected."))
+ if ($stats.passwordChanges -gt 0) {
+ $b.Add((New-CippReportParagraph -Indent -Text "[i] $($stats.passwordChanges) password change(s) detected in the tenant during the analysis period."))
+ foreach ($u in @($bec.ChangedPasswords | Select-Object -First 5)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($u.displayName) { $u.displayName } else { 'Unknown' })" -Content ("Email: $(if ($u.userPrincipalName) { $u.userPrincipalName } else { 'N/A' })`nLast Password Change: $(FmtDate $u.lastPasswordChangeDateTime)")))
+ }
+ if ($stats.passwordChanges -gt 5) { $b.Add((New-CippReportNote -Text "... and $($stats.passwordChanges - 5) more (see the retained investigation record for the full list)")) }
+ } else {
+ $b.Add((New-CippReportParagraph -Indent -Text '[i] No password changes detected during the analysis period.'))
+ }
+
+ # === PAGE 6: MAILBOX LISTS, DEVICES & LOCATIONS - Check 8,9,10,11 ===
+ $b.Add((New-CippReportPage -Title 'Mailbox Lists, Devices & Locations' -Subtitle 'Sender lists, managed devices, and sign-in origins'))
+ $b.Add((New-CippReportHeading -Title 'Check 8: Trusted & Blocked Senders'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Attackers may add their own domain to the Trusted Senders list so their fraudulent messages bypass spam filtering, or add finance/security domains to the Blocked Senders list so warnings and alerts are hidden from the victim in the Junk Email folder.'))
+ if ($bec.SafelistError) {
+ $b.Add((New-CippReportAlertBox -Lines -Title '[!] Could Not Retrieve Sender Lists' -Content ("$($bec.SafelistError)`nAn empty list here does not mean the mailbox has no trusted or blocked senders.")))
+ }
+ if ($stats.safelistChanges -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.safelistChanges) Safelist Change(s) in the Last $windowDays Days" -Content 'The audit log recorded changes to the Trusted/Blocked Senders and Domains list on this mailbox. Review each change carefully.'))
+ foreach ($change in @($bec.SafelistChanges | Select-Object -First 10)) {
+ $lines = @(
+ "Date: $(FmtDate $change.Date)"
+ if ($change.ClientIP) { "From: $($change.ClientIP)$(if ($change.Country) { " ($($change.Country))" })" }
+ if ($change.ForeignLocation -eq $true) { '[!] Originated outside the assigned usage location' }
+ "Trusted: $(FmtSafelist $change.Trusted)"
+ "Blocked: $(FmtSafelist $change.Blocked)"
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($change.Operation) { $change.Operation } else { 'Safelist Change' }) by $(if ($change.UserKey) { $change.UserKey } else { 'Unknown' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.safelistChanges -gt 10) { $b.Add((New-CippReportNote -Text "... and $($stats.safelistChanges - 10) more changes (see the retained investigation record for the full list)")) }
+ }
+ if ($stats.trustedSenders -gt 0) {
+ $b.Add((New-CippReportInfoBox -Title "Trusted Senders/Domains ($($stats.trustedSenders))" -Content (@($bec.TrustedSenders | Select-Object -First 15) -join ', ')))
+ }
+ if ($stats.trustedSenders -gt 15) { $b.Add((New-CippReportNote -Text "... and $($stats.trustedSenders - 15) more trusted entries (see the retained investigation record for the full list)")) }
+ if ($stats.blockedSenders -gt 0) {
+ $b.Add((New-CippReportInfoBox -Title "Blocked Senders/Domains ($($stats.blockedSenders))" -Content (@($bec.BlockedSenders | Select-Object -First 15) -join ', ')))
+ }
+ if ($stats.blockedSenders -gt 15) { $b.Add((New-CippReportNote -Text "... and $($stats.blockedSenders - 15) more blocked entries (see the retained investigation record for the full list)")) }
+ if (-not $bec.SafelistError -and $stats.trustedSenders -eq 0 -and $stats.blockedSenders -eq 0 -and $stats.safelistChanges -eq 0) {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Trusted or Blocked Senders Found' -Content 'No trusted or blocked sender/domain entries were found on this mailbox.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 9: Intune Devices'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD endpoint under the compromised identity, including paths that re-register Windows Hello for Business. Review devices enrolled during the analysis window first.'))
+ if ($completeness.IntuneDevices.Skipped) {
+ $b.Add((New-CippReportAlertBox -Lines -Title '[!] Intune Not Checked' -Content $(if ($completeness.IntuneDevices.Requirement) { "Not checked - $($completeness.IntuneDevices.Requirement). This is not a pass; the result is unknown." } else { "$($completeness.IntuneDevices.Error)" })))
+ } elseif ($bec.IntuneDevicesError) {
+ $b.Add((New-CippReportAlertBox -Lines -Title '[!] Could Not Retrieve Intune Devices' -Content ("$(if ($completeness.IntuneDevices.Error) { $completeness.IntuneDevices.Error } else { $bec.IntuneDevicesError })`nAn empty device list here does not mean the user has no Intune devices.")))
+ } elseif ($stats.intuneDevices -gt 0) {
+ $intuneTail = if ($stats.recentIntuneDevices -gt 0) { ", including $($stats.recentIntuneDevices) enrolled in the last $windowDays days." } else { ". None were enrolled in the last $windowDays days." }
+ $b.Add((New-CippReportParagraph -Indent -Text "[i] $($stats.intuneDevices) Intune-managed device(s) associated with this user$intuneTail"))
+ foreach ($device in @($sortedIntune | Select-Object -First 5)) {
+ $lines = @(
+ "OS: $(if ($device.operatingSystem) { $device.operatingSystem } else { 'N/A' })$(if ($device.osVersion) { " $($device.osVersion)" })"
+ "Enrolled: $(FmtDate $device.enrolledDateTime)"
+ "Compliance: $(if ($device.complianceState) { $device.complianceState } else { 'N/A' })"
+ "Enrollment Type: $(if ($device.deviceEnrollmentType) { $device.deviceEnrollmentType } else { 'N/A' })"
+ if ($device.serialNumber) { "Serial: $($device.serialNumber)" }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($device.deviceName) { $device.deviceName } else { 'Unknown device' })" -Content ($lines -join "`n")))
+ }
+ if ((Cnt $sortedIntune) -gt 5) { $b.Add((New-CippReportNote -Text "... and $((Cnt $sortedIntune) - 5) more devices (see the retained investigation record for the full list)")) }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Intune Devices Found' -Content 'No Intune-managed devices were found for this user.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 10: Sign-in Locations'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content ("Sign-ins from countries the user does not work from are one of the strongest compromise indicators. Each sign-in is compared against the user's assigned usage location in Entra ID$(if ($usageLoc) { " ($usageLoc)" }), and the client IPs behind rule changes, safelist changes, sharing changes, and sent mail are geo-located and compared the same way.")))
+ if ($bec.SuspectUserSignInsError) {
+ $b.Add((New-CippReportAlertBox -Lines -Title '[!] Could Not Retrieve Sign-in Logs' -Content ("$($bec.SuspectUserSignInsError)`nAn empty list here does not mean the user has not signed in.")))
+ } else {
+ if (-not $usageLoc) {
+ $b.Add((New-CippReportInfoBox -Tone warn -Title '[!] No Usage Location Assigned' -Content $(if ($loc.Note) { "$($loc.Note)" } else { 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.' })))
+ }
+ if ((Cnt $loc.SignInCountries) -gt 0) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "Sign-in Countries Observed (last $($stats.signIns) sign-ins)" -Content ((@($loc.SignInCountries | ForEach-Object { "$($_.Country): $($_.Count) sign-in(s)" })) -join "`n")))
+ }
+ if ($stats.foreignSignIns -gt 0 -or $stats.foreignActivity -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title '[!] Activity Outside the Assigned Usage Location' -Content ("$($stats.foreignSignIns) sign-in(s) (of which $($stats.foreignSuccessfulSignIns) succeeded), $(AsInt $loc.ForeignRuleChangeCount) inbox rule change(s), $(AsInt $loc.ForeignSafelistChangeCount) safelist change(s), $(AsInt $loc.ForeignSharingChangeCount) sharing change(s), and $(AsInt $loc.ForeignSentMessageCount) sent message(s) originated outside $usageLoc. Failed foreign sign-ins are mostly password-spray noise; the successful ones prove access. Review each carefully - a single legitimate trip can explain some of this, but rule, safelist, or sharing changes from a foreign IP rarely have an innocent explanation.")))
+ foreach ($signIn in @($foreignSignInList | Select-Object -First 10)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(FmtDate $signIn.CreatedDateTime) - $(if ($signIn.Country) { $signIn.Country } else { 'Unknown' })" -Content ("Application: $(if ($signIn.AppDisplayName) { $signIn.AppDisplayName } else { 'N/A' })`nIP Address: $(if ($signIn.IPAddress) { $signIn.IPAddress } else { 'N/A' })`nCity: $(if ($signIn.City) { $signIn.City } else { 'N/A' })`nResult: $(if ($signIn.Status) { $signIn.Status } else { 'N/A' })")))
+ }
+ if ((Cnt $foreignSignInList) -gt 10) { $b.Add((New-CippReportNote -Text "... and $((Cnt $foreignSignInList) - 10) more foreign sign-ins (see the retained investigation record for the full list)")) }
+ } elseif ($usageLoc) {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Foreign Activity Detected' -Content "All located sign-ins and activity match the user's assigned usage location ($usageLoc)."))
+ }
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 11: Sharing Links'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Attackers share OneDrive and SharePoint folders to give themselves a data feed that survives a password reset, and anonymous links expose the content to anyone holding the URL. This check lists every sharing link the account created or changed during the analysis period, including the IP address it was done from.'))
+ if ($stats.sharingChanges -gt 0) {
+ $anon = if ($stats.anonymousLinks -gt 0) { "$($stats.anonymousLinks) of these involve anonymous links, which anyone with the URL can open. " } else { '' }
+ $b.Add((New-CippReportAlertBox -Title "[!] $($stats.sharingChanges) Sharing Change(s) in the Last $windowDays Days" -Content ("${anon}Review each link and remove any that are not explained, even if the account has since been remediated.")))
+ foreach ($change in @($bec.SharingChanges | Select-Object -First 10)) {
+ $lines = @(
+ "Date: $(FmtDate $change.Date)"
+ "Workload: $(if ($change.Workload) { $change.Workload } else { 'N/A' })"
+ if ($change.Target) { "Shared with: $($change.Target)" }
+ if ($change.ClientIP) { "From: $($change.ClientIP)$(if ($change.Country) { " ($($change.Country))" })" }
+ if ($change.ForeignLocation -eq $true) { '[!] Originated outside the assigned usage location' }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($change.Operation) { $change.Operation } else { 'Sharing Change' }): $(if ($change.FileName) { $change.FileName } elseif ($change.ItemUrl) { $change.ItemUrl } else { 'Unknown item' })" -Content ($lines -join "`n")))
+ }
+ if ($stats.sharingChanges -gt 10) { $b.Add((New-CippReportNote -Text "... and $($stats.sharingChanges - 10) more changes (see the retained investigation record for the full list)")) }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Sharing Changes Found' -Content 'No sharing links were created or changed by this account during the analysis period.'))
+ }
+
+ # === PAGE 7: FULL INVESTIGATION FINDINGS - Check 12-21 ===
+ $b.Add((New-CippReportPage -Title 'Full Investigation Findings' -Subtitle 'Delegations, consents, transport rules, received mail, directory audit, devices and risk state'))
+
+ $b.Add((New-CippReportHeading -Title 'Check 12: Mailbox Delegations and State'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'A delegate with FullAccess or SendAs, a forwarding address, or an automatic reply lets an attacker keep reading and impersonating after the password is changed.'))
+ if ($bec.MailboxState.HasForwarding) {
+ $fwd = if ($bec.MailboxState.ForwardingSmtpAddress) { $bec.MailboxState.ForwardingSmtpAddress } else { $bec.MailboxState.ForwardingAddress }
+ $b.Add((New-CippReportAlertBox -Title '[!] Mail forwarding is configured' -Content ("Mail is forwarded to $fwd$(if ($bec.MailboxState.DeliverToMailboxAndForward) { ' (a copy stays in the mailbox)' }).")))
+ }
+ if ($flaggedDelegations.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($flaggedDelegations.Count) Flagged Delegation(s)" -Content 'External, guest or catch-all principals hold rights on this mailbox. Remove any the user cannot explain.'))
+ foreach ($d in @($flaggedDelegations | Select-Object -First 10)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "$($d.PermissionType): $($d.Trustee)" -Content ("Rights: $($d.AccessRights)`nResource: $($d.Resource)")))
+ }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Flagged Delegations' -Content "$(Cnt $bec.Delegations) delegation(s) exist, none to an external, guest or catch-all principal."))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 13: Application Consents'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'Applications the user consented to keep their access after a password reset. A rogue-catalog match or a high-risk scope from an unverified publisher is how mailboxes are synchronised out of the tenant.'))
+ if ($flaggedGrants.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($flaggedGrants.Count) Flagged Consent(s)" -Content 'Revoke the grants below unless the user can explain them.'))
+ foreach ($g in @($flaggedGrants | Select-Object -First 10)) {
+ $lines = @(
+ "Scopes: $(if ($g.Scope) { $g.Scope } else { 'N/A' })"
+ "Publisher: $(if ($g.Publisher) { $g.Publisher } else { 'Unknown' }) $(if ($g.PublisherVerified) { '(verified)' } else { '(not verified)' })"
+ $(if ($g.CatalogMatch.Name) { "Catalog: $($g.CatalogMatch.Name) ($($g.CatalogMatch.Source))" })
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$(if ($g.ClientDisplayName) { $g.ClientDisplayName } else { $g.ClientAppId }) ($($g.Risk))" -Content ($lines -join "`n")))
+ }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Flagged Consents' -Content "$(Cnt $bec.UserGrants) consent(s) and role assignment(s) exist, none matching the rogue-app catalogs or carrying a high-risk scope from an unverified publisher."))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 14: Transport Rules'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'A tenant-wide transport rule that BCCs, redirects, deletes or quarantines mail keeps a feed open after the mailbox itself is cleaned.'))
+ if ($flaggedTransportChanges.Count -gt 0 -or $flaggedTransportRules.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($flaggedTransportChanges.Count) risky change(s) in the window, $($flaggedTransportRules.Count) current rule(s) with diversion or suppression actions" -Content 'Review each rule; disable any that cannot be explained.'))
+ foreach ($c in @($flaggedTransportChanges | Select-Object -First 5)) {
+ $lines = @(
+ "Date: $(FmtDate $c.Date)"
+ "By: $(if ($c.Actor) { $c.Actor } else { 'Unknown' })"
+ if ($c.ClientIP) { "From: $($c.ClientIP)$(if ($c.Country) { " ($($c.Country))" })" }
+ "Risky parameters: $(if ($c.RiskyParameters -is [array]) { $c.RiskyParameters -join ', ' } else { $c.RiskyParameters })"
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$($c.Operation): $($c.RuleName)" -Content ($lines -join "`n")))
+ }
+ foreach ($r in @($flaggedTransportRules | Select-Object -First 5)) {
+ $reasons = if ($r.RiskReasons -is [array]) { $r.RiskReasons -join "`n" } else { "$($r.RiskReasons)" }
+ $b.Add((New-CippReportInfoBox -Lines -Title "Rule: $($r.Name) ($($r.State), $($r.Mode))" -Content $reasons))
+ }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Risky Transport Rules' -Content 'No transport rule with a diversion or suppression action was changed in the window or exists in the tenant.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 15: Mailbox Add-ins'))
+ if ($flaggedAddIns.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Lines -Title "[!] $($flaggedAddIns.Count) user-installed non-Microsoft add-in(s)" -Content ((@($flaggedAddIns | ForEach-Object { "$($_.DisplayName) ($(if ($_.ProviderName) { $_.ProviderName } else { 'unknown provider' }))" })) -join "`n")))
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Flagged Add-ins' -Content 'No enabled user-installed add-in from a non-Microsoft provider was found.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 16: Received Mail'))
+ $b.Add((New-CippReportInfoBox -Title 'Why We Check This' -Content 'The message that started the compromise usually arrived in the window. Trace metadata is checked for phishing-shaped subjects and look-alike sender domains; Defender for Office 365 verdicts are included where licensed. No message content is read.'))
+ if ($bec.ReceivedMailSummary) {
+ $b.Add((New-CippReportParagraph -Indent -Text "[i] $($bec.ReceivedMailSummary.TotalMessages) message(s) from $($bec.ReceivedMailSummary.UniqueSenders) sender(s) were received in the window."))
+ }
+ if ($receivedFindings.Count -gt 0 -or $deliveredThreats.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($receivedFindings.Count) finding(s), $($deliveredThreats.Count) Defender-classified threat(s) delivered" -Content 'Look-alike sender domains are the strongest signal; subject patterns are leads for review, not verdicts.'))
+ foreach ($f in @($receivedFindings | Select-Object -First 8)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "$($f.FindingType): $($f.SenderAddress)" -Content ("Subject: $(if ($f.Subject) { $f.Subject } else { '(no subject)' })`nReason: $($f.Reason)`nReceived: $(if ($f.Received) { $f.Received } else { 'N/A' }) - $(if ($f.Status) { $f.Status } else { 'N/A' })")))
+ }
+ foreach ($d in @($deliveredThreats | Select-Object -First 5)) {
+ $b.Add((New-CippReportInfoBox -Lines -Title "Defender: $(if ($d.ThreatTypes -is [array]) { $d.ThreatTypes -join ', ' } else { $d.ThreatTypes })" -Content ("From: $(if ($d.SenderAddress) { $d.SenderAddress } else { 'Unknown' })`nSubject: $(if ($d.Subject) { $d.Subject } else { '(no subject)' })`nDelivery: $(if ($d.DeliveryAction) { $d.DeliveryAction } else { 'N/A' }) / $(if ($d.LatestDeliveryLocation) { $d.LatestDeliveryLocation } else { 'N/A' })")))
+ }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Received-mail Findings' -Content 'No phishing-shaped subjects, look-alike sender domains or delivered Defender detections were found.'))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 17: Entra Directory Audit'))
+ if ($flaggedAudits.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Title "[!] $($flaggedAudits.Count) flagged directory event(s)" -Content 'Security-info registration, consent, service principal, device, password, token or role events involving this user.'))
+ foreach ($a in @($flaggedAudits | Select-Object -First 8)) {
+ $lines = @(
+ "Date: $(FmtDate $a.ActivityDateTime)"
+ "By: $(if ($a.InitiatedBy) { $a.InitiatedBy } else { 'Unknown' })"
+ if ($a.ClientIP) { "From: $($a.ClientIP)$(if ($a.Country) { " ($($a.Country))" })" }
+ if ($a.Targets) { "Targets: $($a.Targets)" }
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Title "$($a.Activity) ($($a.Result))" -Content ($lines -join "`n")))
+ }
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Flagged Directory Events' -Content "$(Cnt $bec.DirectoryAudits) directory event(s) involved this user in the window, none of the flagged kinds."))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 18 and 19: Registered Devices and Non-interactive Sign-ins'))
+ if ($recentRegisteredDevices.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Lines -Title "[!] $($recentRegisteredDevices.Count) Entra device(s) registered in the window" -Content ((@($recentRegisteredDevices | ForEach-Object { "$(if ($_.displayName) { $_.displayName } else { $_.deviceId }) ($(if ($_.operatingSystem) { $_.operatingSystem } else { 'unknown OS' }), $(if ($_.trustType) { $_.trustType } else { 'unknown trust' })) registered $(FmtDate $_.registrationDateTime)" })) -join "`n")))
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Devices Registered in the Window' -Content "$(Cnt $bec.RegisteredDevices) registered device(s), none new."))
+ }
+ if ($foreignNonInteractive.Count -gt 0) {
+ $b.Add((New-CippReportAlertBox -Lines -Title "[!] $($foreignNonInteractive.Count) successful non-interactive sign-in(s) from outside the usage location" -Content ((@($foreignNonInteractive | Select-Object -First 8 | ForEach-Object { "$(FmtDate $_.CreatedDateTime) - $(if ($_.AppDisplayName) { $_.AppDisplayName } else { 'N/A' }) from $(if ($_.IPAddress) { $_.IPAddress } else { 'N/A' }) ($(if ($_.Country) { $_.Country } else { 'Unknown' }))" })) -join "`n")))
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] No Foreign Non-interactive Sign-ins' -Content "$(Cnt $bec.NonInteractiveSignIns) recent non-interactive sign-in(s), none successful from outside the usage location."))
+ }
+
+ $b.Add((New-CippReportHeading -Title 'Check 20 and 21: Mailbox Activity and Identity Protection'))
+ if ($mailActivitySummary) {
+ $lines = @(
+ "Item accesses: $($mailActivitySummary.MailItemsAccessedCount)"
+ "Hard deletes: $($mailActivitySummary.HardDeleteCount)$(if ($mailActivitySummary.HardDeleteExceeded) { " [!] exceeds the $($mailActivitySummary.HardDeleteThreshold) threshold" })"
+ "Soft deletes: $($mailActivitySummary.SoftDeleteCount)"
+ "Sends: $($mailActivitySummary.SendCount)"
+ "Distinct client IPs: $($mailActivitySummary.DistinctClientIPs)"
+ $(if ($mailActivitySummary.SendAsByOthersCount -gt 0) { "Sent as/on behalf by others: $($mailActivitySummary.SendAsByOthersCount)" })
+ 'Counts only - no items were read.'
+ )
+ $b.Add((New-CippReportInfoBox -Lines -Tone $(if ($mailActivitySummary.HardDeleteExceeded) { 'warn' } else { '' }) -Title 'Mailbox activity counts' -Content ($lines -join "`n")))
+ } else {
+ $b.Add((New-CippReportNote -Text 'Mailbox activity counts were not available for this run.'))
+ }
+ if ($completeness.RiskState.Skipped) {
+ $b.Add((New-CippReportAlertBox -Lines -Title '[!] Identity Protection Not Checked' -Content $(if ($completeness.RiskState.Requirement) { "Not checked - $($completeness.RiskState.Requirement). This is not a pass; whether the account is flagged as risky is unknown." } else { "$($completeness.RiskState.Error)" })))
+ } elseif ($riskState.Listed) {
+ $b.Add((New-CippReportAlertBox -Title "[!] Identity Protection: $($riskState.RiskState) at $($riskState.RiskLevel) risk" -Content ("$(if ($riskState.RiskDetail) { $riskState.RiskDetail } else { 'No detail' }) - last updated $(FmtDate $riskState.RiskLastUpdatedDateTime).$(if ((Cnt $riskState.Detections) -gt 0) { " $(Cnt $riskState.Detections) risk detection(s) in the window." })")))
+ } else {
+ $b.Add((New-CippReportClearBox -Title '[Pass] Not Listed as Risky' -Content 'Identity Protection does not list this user as risky.'))
+ }
+
+ # === PAGE 8: RECOMMENDATIONS ===
+ $b.Add((New-CippReportPage -Title 'Recommendations' -Subtitle 'Actions to take and prevention best practices'))
+ $b.Add((New-CippReportParagraph -Title 'Immediate Actions Required' -Text 'Based on the investigation findings, the following actions should be taken immediately:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ marker = '1.'; label = 'Reset Password:'; text = "Change the user's password immediately to prevent further unauthorized access." }
+ @{ marker = '2.'; label = 'Revoke Sessions:'; text = 'Sign out the user from all active sessions to terminate any attacker access.' }
+ @{ marker = '3.'; label = 'Remove Suspicious Rules:'; text = 'Delete any mailbox rules that forward, redirect, or hide emails, especially those moving messages to unusual folders.' }
+ @{ marker = '4.'; label = 'Review MFA Devices:'; text = "Remove any MFA devices that the user doesn't recognize and re-register legitimate devices." }
+ @{ marker = '5.'; label = 'Audit Permissions:'; text = 'Review and revoke any unauthorized mailbox permissions or application consents.' }
+ @{ marker = '6.'; label = 'Monitor Account:'; text = 'Continue monitoring the account for suspicious activity for at least 30 days.' }
+ )))
+ $b.Add((New-CippReportParagraph -Title 'Long-Term Prevention Strategies' -Text 'To prevent future Business Email Compromise attacks, implement these security best practices:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ label = 'Enforce Multi-Factor Authentication (MFA):'; text = 'Require MFA for all users, especially those with administrative privileges or access to financial systems.' }
+ @{ label = 'Implement Security Awareness Training:'; text = 'Educate employees about phishing, social engineering, and how to identify suspicious emails. Regular training significantly reduces successful attacks.' }
+ @{ label = 'Enable Advanced Threat Protection:'; text = 'Use email security solutions that detect and block phishing, malware, and suspicious attachments.' }
+ @{ label = 'Configure Conditional Access Policies:'; text = 'Restrict access based on location, device compliance, and risk level to prevent unauthorized sign-ins.' }
+ @{ label = 'Monitor Audit Logs:'; text = 'Regularly review audit logs for suspicious activities such as unusual sign-in patterns, rule creation, or permission changes.' }
+ @{ label = 'Establish Financial Controls:'; text = 'Implement multi-person approval processes for wire transfers and payment changes to prevent fraudulent transactions.' }
+ )))
+ $b.Add((New-CippReportParagraph -Title 'User Education Points' -Text 'Share these key points with the affected user to help prevent future compromises:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ text = 'Never click on links or open attachments in unexpected emails, even if they appear to come from known contacts.' }
+ @{ text = 'Always verify unusual requests for money transfers or sensitive information through a separate communication channel (phone call, in person).' }
+ @{ text = 'Use strong, unique passwords for each account and consider using a password manager.' }
+ @{ text = 'Be cautious when authorizing new applications or granting permissions to third-party services.' }
+ @{ text = 'Report suspicious emails or activities to your IT security team immediately.' }
+ )))
+
+ # === PAGE 9: COMPLIANCE & DOCUMENTATION ===
+ $b.Add((New-CippReportPage -Title 'Compliance & Documentation' -Subtitle 'Meeting regulatory and audit requirements'))
+ $b.Add((New-CippReportParagraph -Title 'Compliance Considerations' -Text 'This report supports compliance and documentation requirements for various security frameworks and regulatory standards:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ label = 'ISO 27001:'; text = 'Demonstrates incident detection, analysis, and response procedures (Controls A.16.1.1 - A.16.1.7).' }
+ @{ label = 'CMMC Level 2:'; text = 'Provides evidence of security incident monitoring, analysis, and documentation (AC.L2-3.1.12, AU.L2-3.3.1).' }
+ @{ label = 'SOC 2 Type II:'; text = 'Documents detective and responsive controls for security incidents (CC7.3, CC7.4).' }
+ @{ label = 'NIST CSF:'; text = 'Aligns with Detect (DE.AE, DE.CM) and Respond (RS.AN, RS.MI) functions.' }
+ @{ label = 'GDPR:'; text = 'Demonstrates security breach detection and potential data breach assessment (Articles 32, 33).' }
+ )))
+ $b.Add((New-CippReportParagraph -Title 'Audit Trail' -Text 'This investigation and resulting documentation provide an audit trail for security incident response:'))
+ $b.Add((New-CippReportInfoBox -Lines -Title 'Investigation Details' -Content (@(
+ "Investigation Date: $(FmtDate $bec.ExtractedAt)"
+ "Analyzed User: $upn"
+ "Organization: $TenantName"
+ "Analysis Period: $windowDays days"
+ "Assigned Usage Location: $(if ($usageLoc) { $usageLoc } else { 'Not assigned' })"
+ "Audit Log Status: $(if ($bec.ExtractResult) { $bec.ExtractResult } else { 'Unknown' })"
+ ) -join "`n")))
+ $b.Add((New-CippReportInfoBox -Lines -Title 'Findings Summary' -Content (@(
+ "Threat Level: $threatLevel (score $threatValue)"
+ "Mailbox Rules Found: $($stats.newRules)"
+ "Rule Changes: $($stats.ruleChanges)"
+ "Permission Changes: $($stats.permissionChanges) ($($stats.permissionChangesTargetingUser) targeting this mailbox)"
+ "New Applications: $($stats.newApps)"
+ "Known-Malicious Applications: $($stats.maliciousApps)"
+ "Flagged Application Consents: $($flaggedGrants.Count)"
+ "Flagged Mailbox Delegations: $($flaggedDelegations.Count)"
+ "Flagged Transport Rules/Changes: $($flaggedTransportRules.Count + $flaggedTransportChanges.Count)"
+ "New Users: $($stats.newUsers)"
+ "Sent Messages: $(if ($stats.sentTotalMessages) { $stats.sentTotalMessages } else { $stats.sentMessages })"
+ "Repeated Subject Campaigns: $($stats.repeatedSubjects)"
+ "Send Bursts: $($stats.sendBursts)"
+ "MFA Devices: $($stats.mfaDevices)"
+ "Recent MFA Registrations ($windowDays d): $($stats.recentMfaDevices)"
+ "Password Changes: $($stats.passwordChanges)"
+ "Trusted Senders: $($stats.trustedSenders)"
+ "Blocked Senders: $($stats.blockedSenders)"
+ "Safelist Changes: $($stats.safelistChanges)"
+ "Sharing Changes: $($stats.sharingChanges)"
+ "Anonymous Links: $($stats.anonymousLinks)"
+ "Intune Devices: $($stats.intuneDevices)"
+ "Recent Intune Enrollments ($windowDays d): $($stats.recentIntuneDevices)"
+ "Received-mail Findings: $($receivedFindings.Count)"
+ "Delivered Threats: $($deliveredThreats.Count)"
+ "Foreign Sign-ins: $($stats.foreignSignIns) ($($stats.foreignSuccessfulSignIns) successful)"
+ "Foreign Non-interactive Sign-ins: $($foreignNonInteractive.Count)"
+ "Identity Protection Listed: $(if ($riskState.Listed) { "Yes ($($riskState.RiskLevel))" } else { 'No' })"
+ ) -join "`n")))
+ $b.Add((New-CippReportParagraph -Title 'Document Retention' -Text "This report should be retained according to your organization's document retention policy and regulatory requirements. Typical retention periods range from 3-7 years depending on applicable compliance frameworks. Store this document securely with restricted access as it contains sensitive security information."))
+ $b.Add((New-CippReportParagraph -Title 'Additional Resources' -Text 'For more information about Business Email Compromise and cybersecurity best practices:'))
+ $b.Add((New-CippReportBullets -Items @(
+ @{ text = 'FBI IC3: Internet Crime Complaint Center (ic3.gov)' }
+ @{ text = 'CISA: Cybersecurity & Infrastructure Security Agency (cisa.gov)' }
+ @{ text = 'Microsoft Security: Business Email Compromise resources' }
+ )))
+ }
+
+ @{
+ Blocks = @($b)
+ Variables = @{
+ coverlabel = 'Security Incident Report'
+ covertitle = 'BEC Compromise'
+ coveraccent = 'Analysis'
+ covertenant = [string]$UserData.displayName
+ coversubtitle = "Business Email Compromise Investigation Report for $TenantName"
+ covermeta = [string]$upn
+ covermetanote = "Analysis Date: $(FmtDate $bec.ExtractedAt)"
+ coverfallbackimage = '/reportImages/soc.jpg'
+ coverfooternote = 'Confidential & Proprietary - For Internal Use Only'
+ footerlabel = "$TenantName - BEC Analysis Report for $($UserData.displayName)"
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippExecutiveReportTree.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippExecutiveReportTree.ps1
new file mode 100644
index 0000000000..c82a422ad0
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippExecutiveReportTree.ps1
@@ -0,0 +1,206 @@
+function Build-CippExecutiveReportTree {
+ <#
+ .SYNOPSIS
+ Compose the Executive report as a component tree (server port of ExecutiveReportButton.jsx).
+ .DESCRIPTION
+ Pure composition: takes the already-gathered/shaped report data and returns @{ Blocks; Variables }
+ - the component nodes for ConvertTo-CippReportPdf and the cover/footer report variables. Holds no
+ data gathering, so it is unit-testable with sample data and drives the same layout the client
+ ExecutiveReportDocument produces.
+ .PARAMETER Data
+ Hashtable of the report's data:
+ TenantName, UserStats, SecureScore (currentScore/maxScore/percentageCurrent/percentageVsSimilar/
+ percentageVsAllTenants/trend), Licenses[], Devices[], CAPolicies[] (raw state), SecurityControls[]
+ (name/description/tags/status).
+ .PARAMETER SectionConfig
+ Which sections to include (executiveSummary/securityStandards/secureScore/licenseManagement/
+ deviceManagement/conditionalAccess/infographics).
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][hashtable]$Data,
+ [hashtable]$SectionConfig = @{}
+ )
+
+ $cfg = @{
+ executiveSummary = $true; securityStandards = $true; secureScore = $true
+ licenseManagement = $true; deviceManagement = $true; conditionalAccess = $true; infographics = $true
+ }
+ foreach ($k in $SectionConfig.Keys) { $cfg[$k] = $SectionConfig[$k] }
+
+ $tenant = $Data.TenantName
+ $blocks = [System.Collections.Generic.List[object]]::new()
+ # Chapter dividers over the bundled stock photos (the renderer resolves /reportImages/ paths).
+ function Hero($image, $ov, $hi, $hl, $sub, $ft) {
+ if ($cfg.infographics) {
+ $blocks.Add((New-CippReportHero -Image "/reportImages/$image.jpg" -Overtitle $ov -Highlight $hi -Headline $hl -SubText $sub -FooterText $ft))
+ }
+ }
+
+ # -- Executive Summary --
+ if ($cfg.executiveSummary) {
+ $blocks.Add((New-CippReportPage -Title 'Executive Summary' -Subtitle 'Strategic overview of your Microsoft 365 security posture'))
+ $blocks.Add((New-CippReportParagraph -Html ("
This security assessment for {0} provides a clear picture of your organization's cybersecurity posture and readiness against modern threats. We've evaluated your current security measures against industry best practices to identify strengths and opportunities for improvement.
Our assessment follows globally recognized security standards to ensure your organization meets regulatory requirements and industry benchmarks. This approach helps protect your business assets, maintain customer trust, and reduce operational risks from cyber threats.
Your security standards have been carefully evaluated against industry best practices to protect your business from cyber threats while ensuring smooth daily operations. These standards help maintain business continuity, protect sensitive data, and meet regulatory requirements that are essential for your industry.
Microsoft Secure Score measures how well your organization is protected against cyber threats. This score reflects the effectiveness of your current security measures and helps identify areas where additional protection could strengthen your business resilience.
'))
+ $blocks.Add((New-CippReportStatRow -Title 'Score Comparison' -Stats @(
+ @{ value = "$(if ($null -ne $ss.currentScore) { $ss.currentScore } else { 'N/A' })"; label = 'Current Score' }
+ @{ value = "$(if ($null -ne $ss.maxScore) { $ss.maxScore } else { 'N/A' })"; label = 'Max Score' }
+ @{ value = "$(if ($null -ne $ss.percentageVsSimilar) { $ss.percentageVsSimilar } else { 'N/A' })%"; label = 'vs Similar Orgs' }
+ @{ value = "$(if ($null -ne $ss.percentageVsAllTenants) { $ss.percentageVsAllTenants } else { 'N/A' })%"; label = 'vs All Orgs' }
+ )))
+ $blocks.Add((New-CippReportHeading -Title '7-Day Score Trend'))
+ $blocks.Add((New-CippReportChart -Title 'Secure Score Progress' -Kind trend -Max ([double]$ss.maxScore) -Caption ("Current: {0} / {1} ({2}%)" -f $ss.currentScore, $ss.maxScore, $ss.percentageCurrent) -Data @($ss.trend)))
+ $blocks.Add((New-CippReportInfoBox -Title 'What Your Score Means' -Content ("Your current score of {0} represents {1}% of the maximum protection level available. This indicates how well your organization is currently defended against common cyber threats and data breaches." -f $ss.currentScore, $ss.percentageCurrent)))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why Scores Change' -Content "- Business growth and new employees may temporarily lower scores until security measures are applied`n- Changes in software licenses can affect available security features`n- New security threats require updated protections, which may impact scores`n- Regular security improvements help maintain and increase your protection level"))
+ }
+
+ Hero 'working' 'Every' '39' 'seconds' "a business falls victim to`nransomware attacks" "Proactive defense beats`nreactive recovery"
+
+ # -- License Management --
+ if ($cfg.licenseManagement -and $Data.Licenses -and @($Data.Licenses).Count -gt 0) {
+ $blocks.Add((New-CippReportPage -Title 'License Management' -Subtitle 'Microsoft 365 license allocation and utilization analysis'))
+ $blocks.Add((New-CippReportParagraph -Html '
Smart license management helps control costs while ensuring your team has the tools they need to be productive. This analysis shows how your current licenses are being used and identifies opportunities to optimize spending without compromising business operations.
'))
+ $blocks.Add((New-CippReportTable -Title 'License Allocation Summary' -Limit @($Data.Licenses).Count -Columns @(
+ @{ header = 'License Type'; key = 'name'; width = 5; bold = $true }
+ @{ header = 'Used'; key = 'used'; width = 1.5; align = 'center'; bold = $true }
+ @{ header = 'Available'; key = 'available'; width = 1.5; align = 'center'; bold = $true }
+ @{ header = 'Total'; key = 'total'; width = 1.5; align = 'center'; bold = $true }
+ ) -Rows @($Data.Licenses)))
+ $blocks.Add((New-CippReportBullets -Title 'License Optimization Recommendations' -Items @(
+ @{ label = 'Usage Monitoring:'; text = 'Track how licenses are being used to identify cost-saving opportunities' }
+ @{ label = 'Cost Control:'; text = 'Review unused licenses to reduce unnecessary spending' }
+ @{ label = 'Growth Planning:'; text = 'Ensure you have enough licenses for business expansion without overspending' }
+ @{ label = 'Regular Reviews:'; text = 'Conduct quarterly reviews to maintain cost-effective license allocation' }
+ )))
+ }
+
+ Hero 'laptop' $null '$4.45M' $null "average cost of a`ndata breach in 2024" "Investment in security`nsaves millions in recovery"
+
+ # -- Device Management --
+ if ($cfg.deviceManagement -and $Data.Devices -and @($Data.Devices).Count -gt 0) {
+ $devices = @($Data.Devices)
+ $compliant = @($devices | Where-Object { $_.compliant }).Count
+ $blocks.Add((New-CippReportPage -Title 'Device Management' -Subtitle 'Device compliance status and management overview'))
+ $blocks.Add((New-CippReportParagraph -Html '
Managing employee devices is essential for protecting your business data and maintaining productivity. This analysis shows which devices meet your security standards and identifies any that may need attention to prevent data breaches or operational disruptions.
Access control policies help protect your business by ensuring only the right people can access sensitive information under appropriate circumstances. These smart security measures automatically evaluate each access request and apply additional verification when needed, balancing security with employee productivity.
These policies work like intelligent security guards, making decisions based on who is trying to access what, from where, and when. For example, accessing email from the office might be seamless, but accessing it from an unusual location might require additional verification. This approach protects your data while minimizing disruption to daily work.
Every message entering or leaving the organisation is given a disposition - delivered, held by a transport rule, filtered as spam, or blocked as phishing or malware. Those dispositions are the clearest single measure of what the mail environment is being asked to handle, because they count what the filters actually did rather than what they are configured to do. This report covers the last {0} days of mail flow at {1}.
' -f $days, [System.Net.WebUtility]::HtmlEncode([string]$Data.TenantName))))
+ $blocks.Add((New-CippReportStatRow -Stats @(
+ @{ value = (num $totalMail); label = 'Total Messages' }
+ @{ value = "$goodPct%"; label = 'Delivered Clean' }
+ @{ value = (num $phish); label = 'Phish Blocked'; colour = $(if ($phish -gt 0) { $warnC }) }
+ @{ value = (num $malware); label = 'Malware Blocked'; colour = $(if ($malware -gt 0) { $dangerC }) }
+ )))
+ $hygText = switch ($hygiene) {
+ 'Attention Needed' { 'Threat traffic is a material share of total mail. At this rate the organisation is being targeted rather than incidentally caught by bulk campaigns, and the filters are absorbing volume that protection policy and user awareness should be reducing at source. Treat the recommendations as current work.' }
+ 'Fair' { 'Threats are being caught at a level that is normal for an organisation of this profile, but not negligible. The filtering is working; the value now is in checking which users absorb most of it and whether their protection matches their exposure.' }
+ default { 'Threat traffic is a small fraction of total mail and is being stopped before delivery. Nothing here needs action beyond keeping the review cadence, since a change in this profile is usually the first visible sign of a campaign starting.' }
+ }
+ $blocks.Add((New-CippReportAlertBox -Title "Mail Hygiene: $hygiene" -Colour $sevColour -Content $hygText))
+ $blocks.Add((New-CippReportInfoBox -Title 'What this data is' -Content "Figures come from Microsoft's mail flow status report for the tenant, aggregated as daily counts per disposition and direction. It is a count of messages, not a record of them: individual senders, subjects and recipients are not part of this data set, and a message appears once under the disposition that was applied to it."))
+ $blocks.Add((New-CippReportInfoBox -Title 'What it does not show' -Content 'A blocked message is a filter working, not an incident. Nothing here indicates that a threat reached a user or that an account was compromised - that requires message trace and sign-in data, which are reviewed separately. Equally, a clean result does not prove nothing got through; it proves nothing was recognised.'))
+
+ # -- Volume & Dispositions --
+ $blocks.Add((New-CippReportPage -Title 'Volume & Dispositions' -Subtitle 'How much mail, and what happened to it'))
+ $blocks.Add((New-CippReportHeading -Title 'Daily Volume'))
+ $blocks.Add((New-CippReportParagraph -Text 'Total messages handled per day across every disposition. Steady volume with occasional peaks is normal; a sustained step change usually reflects a business event - a campaign, an onboarding, a new integration - and is worth being able to explain.'))
+ $blocks.Add((New-CippReportChart -Kind trend -Title 'Messages per day' -Caption ("{0} messages over {1} days" -f (num $totalMail), $days) -Data @($volumeSeries)))
+ $blocks.Add((New-CippReportHeading -Title 'Dispositions'))
+ $blocks.Add((New-CippReportParagraph -Text 'The share each disposition accounts for matters more than the counts. Good mail should dominate; anything else growing as a proportion is the signal.'))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Disposition'; key = 'disposition'; width = 2.4 }, @{ header = 'Messages'; key = 'messages'; width = 1.2 }, @{ header = '% of Total'; key = 'share'; width = 1 }) -Rows @($dispositionRows)))
+ $blocks.Add((New-CippReportHeading -Title 'Direction'))
+ $blocks.Add((New-CippReportParagraph -Text 'Inbound, outbound and internal traffic in proportion. An unusual outbound share is the one to watch: mail leaving in volume that the business did not generate is how a compromised mailbox or an unsecured relay first shows up in these figures.'))
+ $blocks.Add((New-CippReportChart -Kind donut -Title 'Messages by direction' -CentreLabel 'messages' -Data @($directionSeries)))
+
+ # -- Senders & Spam Targets --
+ $blocks.Add((New-CippReportPage -Title 'Senders & Spam Targets' -Subtitle 'Who sends the most, and who is targeted'))
+ $blocks.Add((New-CippReportHeading -Title 'Top Mail Senders'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'The heaviest senders are normally the ones you would expect - shared mailboxes, ticketing systems, scan-to-email devices, marketing platforms. What is worth a second look is a name that does not belong on that list. A user account sending at machine volume is either an unmanaged automation nobody documented, or a mailbox someone else is using.'))
+ if ($topSenders.Count -gt 0) {
+ $blocks.Add((New-CippReportTable -Limit 10 -Columns @(@{ header = 'Sender'; key = 'name'; width = 3 }, @{ header = 'Messages'; key = 'count'; width = 1 }) -Rows @($topSenders | ForEach-Object { @{ name = ($_.Name ?? $_.name ?? 'Unknown'); count = (num ($_.Count ?? $_.count)) } })))
+ } else { $blocks.Add((New-CippReportClearBox -Title 'No sender data' -Content 'Microsoft returned no top-sender breakdown for this window.')) }
+ $blocks.Add((New-CippReportHeading -Title 'Top Spam Recipients'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'Unwanted mail does not spread evenly. A handful of addresses - usually the published ones, and the people whose names appear on the website - absorb most of it, and those same addresses are the ones a targeted attempt will use. Concentration here identifies exactly who benefits most from stricter policy and from being asked to be careful.'))
+ if ($topSpam.Count -gt 0) {
+ $blocks.Add((New-CippReportTable -Limit 10 -Columns @(@{ header = 'Recipient'; key = 'name'; width = 3 }, @{ header = 'Messages'; key = 'count'; width = 1 }) -Rows @($topSpam | ForEach-Object { @{ name = ($_.Name ?? $_.name ?? 'Unknown'); count = (num ($_.Count ?? $_.count)) } })))
+ } else { $blocks.Add((New-CippReportClearBox -Title 'No concentrated spam targets' -Content 'No recipient stands out as absorbing spam over this window.')) }
+
+ # -- Recommendations --
+ $blocks.Add((New-CippReportPage -Title 'Recommendations' -Subtitle 'What to do with these figures'))
+ $blocks.Add((New-CippReportHeading -Title 'Priority Actions'))
+ $blocks.Add((New-CippReportParagraph -Text "Ordered by what this window's data actually shows, rather than by a generic checklist."))
+ $priority = [System.Collections.Generic.List[object]]::new()
+ if ($threats -gt 0) { $priority.Add(@{ label = 'Review anti-phishing and anti-malware policy strength.'; text = "$(num $threats) messages were blocked as phishing or malware in this window. Confirm the tenant is on the current preset security policies, that impersonation protection lists the people who would actually be impersonated, and that Safe Links and Safe Attachments cover every mailbox rather than a pilot group." }) }
+ if ($topSpam.Count -gt 0) { $priority.Add(@{ label = 'Give the most-targeted users stronger protection.'; text = 'The recipients listed in this report absorb a disproportionate share of unwanted mail. Priority accounts, tighter quarantine policy and a short conversation about what they are receiving cost little and are aimed exactly where the traffic is going.' }) }
+ $priority.Add(@{ label = 'Verify SPF, DKIM and DMARC are published and enforcing.'; text = 'These records decide whether mail claiming to be from the domain is accepted elsewhere. A DMARC policy left at p=none reports abuse without stopping it, which means the organisation can be impersonated to its own customers regardless of how well inbound filtering performs.' })
+ if ($transportRules -gt 0) { $priority.Add(@{ label = 'Audit the transport rules acting on mail.'; text = "Transport rules handled $(num $transportRules) messages here. Rules accumulate, outlive the reason they were written, and silently override filtering decisions - confirm each one is still wanted and that none bypasses protection for a sender that no longer needs the exception." }) }
+ $i = 0; $priorityItems = foreach ($p in $priority) { $i++; @{ marker = "$i."; label = $p.label; text = $p.text } }
+ $blocks.Add((New-CippReportBullets -Items @($priorityItems)))
+ $blocks.Add((New-CippReportHeading -Title 'Keeping It That Way'))
+ $blocks.Add((New-CippReportBullets -Items @(
+ @{ label = 'Review mail flow on a fixed cadence.'; text = 'These figures are only meaningful against previous ones. A monthly look establishes the normal shape of the traffic, which is what makes an abnormal month visible at a glance.' }
+ @{ label = 'Watch the outbound share, not just the inbound.'; text = 'Inbound threat volume reflects the internet. Outbound volume reflects the organisation, so a change there is far more likely to mean something has gone wrong inside it.' }
+ @{ label = 'Alert on the conditions, not the counts.'; text = 'Configure alert policies for outbound spam and unusual sending volume. A report read monthly finds a compromised mailbox weeks late; an alert finds it the same day.' }
+ @{ label = 'Keep quarantine reviewed and released promptly.'; text = 'Filtering only holds if people trust it. Where legitimate mail sits in quarantine unattended, users route around the controls - and that habit costs more than the filtering saves.' }
+ )))
+
+ @{
+ Blocks = @($blocks)
+ Variables = @{
+ coverlabel = 'Email Traffic Review'
+ coversubtitle = "Where email at $($Data.TenantName) came from over the last $days days, how much of it was delivered, and what was stopped before it reached a mailbox."
+ covermeta = ('{0:N0} messages / {1}% delivered / {2:N0} threats caught' -f $totalMail, $goodPct, $threats)
+ covermetanote = "Mail hygiene: $hygiene"
+ coverfooternote = 'Confidential - For Internal Use Only'
+ coverfallbackimage = '/reportImages/city.jpg'
+ footerlabel = "$($Data.TenantName) - Mail Flow"
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippPermissionsReportTree.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippPermissionsReportTree.ps1
new file mode 100644
index 0000000000..92fef6e42f
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippPermissionsReportTree.ps1
@@ -0,0 +1,125 @@
+function Build-CippPermissionsReportTree {
+ <#
+ .SYNOPSIS
+ Compose the SharePoint Permissions report as a component tree (server port of
+ PermissionsReportButton.jsx).
+ .PARAMETER Data
+ Permissions data: summary (counts), assignments[], skippedSites[]. Returns @{ Blocks; Variables }.
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][hashtable]$Data)
+
+ $summary = if ($Data.summary) { $Data.summary } else { @{} }
+ # `?? @()` so a missing list is empty rather than @($null), which would count as one row.
+ $assignments = @($Data.assignments ?? @())
+ $skipped = @($Data.skippedSites ?? @())
+ function nz($v) { if ($null -eq $v) { 0 } else { [int]$v } }
+ function plural($c, $s, $p) { "$c $(if ($c -eq 1) { $s } else { if ($p) { $p } else { "${s}s" } })" }
+
+ $score = (@(
+ if ((nz $summary.broadClaimGrants) -gt 0) { 5 }
+ if ((nz $summary.externalGrants) -gt 0) { 3 }
+ if ((nz $summary.directFullControlGrants) -gt 0) { 2 }
+ if ((nz $summary.uniquePermissionLibraries) -gt 0) { 1 }
+ ) | Measure-Object -Sum).Sum
+ $exposure = if ($score -ge 7) { 'High' } elseif ($score -ge 3) { 'Medium' } else { 'Low' }
+ $dangerC = '#742A2A'; $warnC = '#744210'
+ $sevColour = @{ High = $dangerC; Medium = $warnC; Low = '#22543D' }[$exposure]
+ $claimLabels = @{ Everyone = 'Everyone (includes external users)'; EveryoneExceptExternal = 'Everyone except external users'; AllUsers = 'All Users' }
+
+ $real = @($assignments | Where-Object { $_.principalId -and -not $_.isSystemManaged })
+ $broad = @($real | Where-Object { $_.broadClaim })
+ $external = @($real | Where-Object { $_.isGuest -eq $true })
+ $fullCtl = @($real | Where-Object { $_.permissionLevel -eq 'Full Control' -and $_.principalType -ne 'SharePoint Group' })
+ $libRows = @($real | Where-Object { $_.scope -eq 'Library' })
+ $siteLabel = { param($r) if ($r.siteName) { $r.siteName } elseif ($r.siteUrl) { $r.siteUrl } else { 'Unnamed site' } }
+ $scopeLabel = { param($r) if ($r.scope -eq 'Library') { "$(& $siteLabel $r) / $($r.libraryTitle)" } else { (& $siteLabel $r) } }
+
+ $blocks = [System.Collections.Generic.List[object]]::new()
+
+ # -- Executive Summary --
+ $blocks.Add((New-CippReportPage -Title 'Executive Summary' -Subtitle 'Who is allowed in, and how widely'))
+ $blocks.Add((New-CippReportParagraph -Html ('
Permissions are set by administrators on a site or document library and decide who is structurally allowed in. They change rarely, which is what makes them worth auditing: a permission granted for one project stays in place indefinitely, and a permission granted to the whole organisation looks identical to one granted to a single team until somebody reads it. This report covers {0}.
' -f [System.Net.WebUtility]::HtmlEncode([string]$Data.TenantName))))
+ $blocks.Add((New-CippReportStatRow -Stats @(
+ @{ value = (nz $summary.broadClaimGrants); label = 'Tenant-Wide Grants'; colour = $(if ((nz $summary.broadClaimGrants) -gt 0) { $dangerC }) }
+ @{ value = (nz $summary.externalGrants); label = 'External Grants'; colour = $(if ((nz $summary.externalGrants) -gt 0) { $warnC }) }
+ @{ value = (nz $summary.directFullControlGrants); label = 'Direct Full Control'; colour = $(if ((nz $summary.directFullControlGrants) -gt 0) { $warnC }) }
+ @{ value = (nz $summary.uniquePermissionLibraries); label = 'Detached Libraries' }
+ )))
+ $expText = switch ($exposure) {
+ 'High' { 'Content is reachable by people it was never meant for. A tenant-wide grant is present, which opens the content to the entire organisation regardless of who the site membership says should have it - and it is the most common reason material turns up unexpectedly in search results and AI assistant answers. Treat the findings below as immediate remediation work.' }
+ 'Medium' { 'Access extends past the intended audience in places. Each finding below is individually manageable, but each one widens what a single compromised account reaches.' }
+ default { 'Permissions broadly match what the structure intends. No tenant-wide grants were found. Continue reviewing periodically, particularly after site or library changes.' }
+ }
+ $blocks.Add((New-CippReportAlertBox -Title "Permission Exposure: $exposure" -Colour $sevColour -Content $expText))
+ $blocks.Add((New-CippReportInfoBox -Title 'What was examined' -Content ("{0} SharePoint sites and {1} document libraries were read, producing {2} permission assignments. Data is taken from the last completed sync, not read live." -f (nz $summary.sitesScanned), (nz $summary.librariesScanned), (nz $summary.totalAssignments))))
+ $blocks.Add((New-CippReportInfoBox -Title 'What is not covered' -Content 'Permissions are reported as grant paths, not effective access - a group holding a permission is one entry and its members are not expanded, so a person may hold access that shows here only via their group. Permissions on individual folders and files are not enumerated. OneDrive personal sites are out of scope. Access handed out by sharing link is a separate path, covered by the Sharing Report.'))
+ if ($skipped.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title ("$(plural $skipped.Count 'site') could not be read") -Colour $warnC -Content 'These sites could not be read on the most recent scan. Where a site was read successfully before, its earlier results are still shown and are as old as that scan; a site never read successfully contributes nothing. Either way, an absence of findings for these sites is not evidence of good configuration.'))
+ }
+
+ # -- Findings --
+ $blocks.Add((New-CippReportPage -Title 'Findings' -Subtitle 'Permissions worth reviewing, most urgent first'))
+ $blocks.Add((New-CippReportHeading -Title 'Finding 1: Tenant-Wide Grants'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content "SharePoint offers a handful of special audiences - Everyone, Everyone except external users, and All Users - that resolve to the whole organisation rather than to named people. A library carrying one is readable by every employee no matter what the site's membership says, and it is the single most common cause of data appearing in search results or AI assistant answers where it was not expected."))
+ if ($broad.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title ("$(plural $broad.Count 'tenant-wide grant') found") -Colour $dangerC -Content 'Confirm the content is genuinely meant to be organisation-wide. If not, replace the grant with a specific group - one edit removes access for everyone who was never meant to have it.'))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Location'; key = 'location'; width = 2.4 }, @{ header = 'Audience'; key = 'audience'; width = 2 }, @{ header = 'Permission'; key = 'level'; width = 1.2 }) -Rows @($broad | ForEach-Object { @{ location = (& $scopeLabel $_); audience = ($claimLabels[$_.broadClaim] ?? $_.broadClaim); level = $_.permissionLevel } })))
+ } else { $blocks.Add((New-CippReportClearBox -Title 'No tenant-wide grants found' -Content 'No site or library grants access to Everyone, Everyone except external users, or All Users.')) }
+ $blocks.Add((New-CippReportHeading -Title 'Finding 2: External and Guest Access'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'Guest accounts holding permissions retain that access until somebody removes it - unlike a sharing link, nothing expires it. Guests from finished projects are a common source of standing access nobody is reviewing.'))
+ if ($external.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title ("$(plural $external.Count 'grant') held by external identities") -Colour $warnC -Content 'Verify each guest still needs access and that the relationship is current.'))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Location'; key = 'location'; width = 2.2 }, @{ header = 'Identity'; key = 'identity'; width = 2.4 }, @{ header = 'Permission'; key = 'level'; width = 1.2 }) -Rows @($external | ForEach-Object { @{ location = (& $scopeLabel $_); identity = ($_.email ?? $_.title ?? $_.loginName); level = $_.permissionLevel } })))
+ } else { $blocks.Add((New-CippReportClearBox -Title 'No external grants found' -Content 'No guest or external identity holds a permission on a scanned site or library.')) }
+
+ # -- Findings continued --
+ $blocks.Add((New-CippReportPage -Title 'Findings (continued)' -Subtitle 'Elevated rights and inheritance'))
+ $blocks.Add((New-CippReportHeading -Title 'Finding 3: Directly Granted Full Control'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'Every site has an Owners group that holds Full Control by design, and that is expected. Full Control granted straight to a person or a directory group is different: it sits outside the membership structure, so it is not removed when someone leaves a team and it is easy to overlook when reviewing who administers a site.'))
+ if ($fullCtl.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title (plural $fullCtl.Count 'direct Full Control grant') -Colour $warnC -Content "Move these into the site's Owners group where the access is legitimate, so membership changes take effect automatically."))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Location'; key = 'location'; width = 2.2 }, @{ header = 'Principal'; key = 'principal'; width = 2.2 }, @{ header = 'Type'; key = 'type'; width = 1.2 }) -Rows @($fullCtl | ForEach-Object { @{ location = (& $scopeLabel $_); principal = ($_.title ?? $_.email ?? $_.loginName); type = $_.principalType } })))
+ } else { $blocks.Add((New-CippReportClearBox -Title 'Full Control is held through Owners groups' -Content "No user or directory group holds Full Control outside a site's Owners group.")) }
+ $blocks.Add((New-CippReportHeading -Title 'Finding 4: Libraries With Their Own Permissions'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'A library normally inherits from its site, so managing the site manages everything in it. A detached library keeps its own permissions and later site-level changes no longer reach it. That is legitimate when deliberate and a blind spot when not - removing somebody from the site does not remove them here.'))
+ if ((nz $summary.uniquePermissionLibraries) -gt 0) {
+ $blocks.Add((New-CippReportParagraph -Text ("{0} of {1} libraries no longer inherit from their site. Their assignments are listed in the appendix. Review whether each detachment was intentional and is still needed." -f (nz $summary.uniquePermissionLibraries), (nz $summary.librariesScanned))))
+ } else { $blocks.Add((New-CippReportClearBox -Title 'All libraries inherit from their site' -Content 'Every scanned library takes its permissions from its site, so site-level access management covers them all.')) }
+
+ # -- Recommendations --
+ $blocks.Add((New-CippReportPage -Title 'Recommendations' -Subtitle 'What to do about the findings'))
+ $blocks.Add((New-CippReportHeading -Title 'Priority Actions'))
+ $blocks.Add((New-CippReportParagraph -Text 'Ordered by how much access each removes relative to the effort involved.'))
+ $blocks.Add((New-CippReportBullets -Items @(
+ @{ marker = '1.'; label = 'Replace tenant-wide grants.'; text = 'Swap Everyone and All Users grants for a specific group. This is the highest-value change available: a single edit removes access for everyone who was never meant to have it.' }
+ @{ marker = '2.'; label = 'Review guest permissions.'; text = 'Guests keep permissions indefinitely. Remove those whose projects have ended, and prefer time-boxed sharing for new external work.' }
+ @{ marker = '3.'; label = 'Move direct Full Control into Owners groups.'; text = 'Administrative rights held through the Owners group follow joiners and leavers. Held directly, they have to be remembered.' }
+ @{ marker = '4.'; label = 'Re-inherit libraries detached without reason.'; text = "Restoring inheritance brings a library back under site-level management. Only do this where the detachment was not deliberate - it discards the library's own permissions." }
+ @{ marker = '5.'; label = 'Prefer groups over individual grants.'; text = 'A permission held by a group updates itself as people join and leave. One held by a person does not.' }
+ )))
+ $blocks.Add((New-CippReportHeading -Title 'Keeping It That Way'))
+ $blocks.Add((New-CippReportBullets -Items @(
+ @{ label = 'Re-run this review regularly.'; text = 'Permissions drift as projects start and end. A periodic review catches drift while it is still small.' }
+ @{ label = 'Manage access at the site, not the library.'; text = 'Leaving libraries inherited keeps one place to look when somebody joins or leaves.' }
+ @{ label = 'Check effective access, not just the lists.'; text = 'A group holding Edit says nothing about who is in it. Use the access check on a site to confirm what a specific person can actually reach.' }
+ @{ label = 'Avoid tenant-wide audiences by default.'; text = 'Where content genuinely is organisation-wide, say so deliberately and review it, rather than reaching for Everyone because it is convenient.' }
+ )))
+
+ # -- Appendix --
+ $blocks.Add((New-CippReportPage -Title 'Appendix: Detached Library Permissions' -Subtitle 'Assignments on libraries that no longer inherit from their site'))
+ $blocks.Add((New-CippReportTable -Limit 40 -Columns @(@{ header = 'Site'; key = 'site'; width = 1.8 }, @{ header = 'Library'; key = 'library'; width = 1.6 }, @{ header = 'Principal'; key = 'principal'; width = 2.2 }, @{ header = 'Permission'; key = 'level'; width = 1.2 }) -Rows @($libRows | ForEach-Object { @{ site = (& $siteLabel $_); library = $_.libraryTitle; principal = ($_.title ?? $_.email ?? $_.loginName); level = $_.permissionLevel } })))
+
+ @{
+ Blocks = @($blocks)
+ Variables = @{
+ coverlabel = 'Access Review'
+ coversubtitle = "Who is structurally allowed into SharePoint sites and document libraries at $($Data.TenantName), and where that access reaches further than intended."
+ covermeta = ('{0} sites / {1} libraries / {2} permission assignments' -f (nz $summary.sitesScanned), (nz $summary.librariesScanned), (nz $summary.totalAssignments))
+ covermetanote = "Permission exposure: $exposure"
+ coverfooternote = 'Confidential - For Internal Use Only'
+ coverfallbackimage = '/reportImages/soc.jpg'
+ footerlabel = "$($Data.TenantName) - SharePoint Permissions"
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippShadowAIReportTree.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippShadowAIReportTree.ps1
new file mode 100644
index 0000000000..d018285942
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippShadowAIReportTree.ps1
@@ -0,0 +1,162 @@
+function Build-CippShadowAIReportTree {
+ <#
+ .SYNOPSIS
+ Compose the Shadow AI report as a component tree (server port of ShadowAIReportButton's
+ ShadowAIReportPages).
+ .PARAMETER Data
+ Shadow AI data: summary, detectedApps[], consentedApps[], topTools[], byRisk[]. Returns
+ @{ Blocks; Variables }.
+ .PARAMETER SectionConfig
+ Which sections to include, keyed executiveSummary/infographics/background/riskLevels/
+ sanctionedTools/detectedSoftware/entraApplications/recommendations. Empty (the default) includes
+ every section; a populated map includes only the keys set to $true (mirrors the client's section
+ toggles).
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][hashtable]$Data, [hashtable]$SectionConfig = @{})
+
+ $summary = if ($Data.summary) { $Data.summary } else { @{} }
+ # `?? @()` so a missing list is empty rather than @($null), which would render as one blank row.
+ $detected = @($Data.detectedApps ?? @())
+ $consented = @($Data.consentedApps ?? @())
+ $topTools = @($Data.topTools ?? @())
+ $byRisk = @($Data.byRisk ?? @())
+ $riskColours = @{ high = '#EF4444'; medium = '#F59E0B'; low = '#3B82F6'; informational = '#10B981' }
+ function RiskColour($r) { $k = "$r".ToLower(); if ($riskColours.ContainsKey($k)) { $riskColours[$k] } else { '#A0AEC0' } }
+ function nz($v) { if ($null -eq $v) { 0 } else { $v } }
+ # No config supplied -> full report; a supplied config includes only the keys explicitly set true.
+ $cfg = $SectionConfig
+ $allOn = ($null -eq $cfg -or $cfg.Count -eq 0)
+ function on($k) { if ($allOn) { $true } else { [bool]$cfg[$k] } }
+
+ $riskAreas = @(
+ @{ title = 'Data Leakage'; colour = '#EF4444'; text = 'Customer records, credentials, source code and financials pasted into consumer AI tools may be retained by the provider and used to train future models, permanently placing them outside your control. Every prompt is a data transfer to a third party.' }
+ @{ title = 'Compliance & Legal Exposure'; colour = '#F59E0B'; text = 'Processing personal data through unvetted AI services can breach GDPR, HIPAA and industry-specific regulations, and undermines contractual confidentiality commitments made to customers.' }
+ @{ title = 'Excessive Application Permissions'; colour = '#D69E2E'; text = 'AI meeting assistants and productivity plugins often request broad access to mailboxes, calendars and files. A single user consent can expose organization-wide data to a third-party service, and that access persists until the consent is revoked.' }
+ @{ title = 'Unreliable Output in Business Processes'; colour = '#3B82F6'; text = 'AI-generated content flows into quotes, contracts and customer communication without review. Errors produced by unmanaged tools are difficult to trace because the organization does not know the tools are in use.' }
+ )
+ $riskLevels = @(
+ @{ name = 'High'; text = 'Consumer tools that train on submitted data, retain prompts indefinitely, or operate from jurisdictions without adequate data protection. Content pasted into these tools should be treated as disclosed to an unvetted third party.' }
+ @{ name = 'Medium'; text = 'Tools with business-grade privacy options that are typically used through personal, unmanaged accounts. Risk depends heavily on the plan and account type in use.' }
+ @{ name = 'Low'; text = 'Tools with enterprise controls, contractual data-processing terms and no training on customer data when configured correctly.' }
+ @{ name = 'Informational'; text = 'Company sanctioned tools that have been explicitly approved for use in this tenant. They remain in the report for visibility but no longer contribute to the risk figures.' }
+ )
+
+ # Distinct sanctioned tools across both sources: device installs come from the Intune inventory
+ # rows, 7-day users from the Entra consent rows, summed per tool.
+ $sanctionedList = @($detected + $consented | Where-Object { $_.status -eq 'Sanctioned' } | Group-Object { $_.aiTool } | ForEach-Object {
+ @{
+ tool = $_.Name
+ vendor = $_.Group[0].vendor
+ category = $_.Group[0].category
+ devices = ($_.Group | ForEach-Object { nz $_.deviceCount } | Measure-Object -Sum).Sum
+ users = ($_.Group | ForEach-Object { nz $_.activeUsersLast7Days } | Measure-Object -Sum).Sum
+ }
+ } | Select-Object -First 18)
+ $detectedRows = @($detected | Select-Object -First 18)
+ $consentedRows = @($consented | Select-Object -First 18)
+
+ $blocks = [System.Collections.Generic.List[object]]::new()
+
+ # -- AI Summary --
+ if (on 'executiveSummary') {
+ $blocks.Add((New-CippReportPage -Title 'AI Summary' -Subtitle 'Strategic overview of AI usage in your Microsoft 365 environment'))
+ $blocks.Add((New-CippReportParagraph -Html ('
This report identifies the artificial intelligence tools discovered in the {0} environment, combining software inventory from managed devices (Intune) with cloud application consent data from Entra ID. Each tool is matched against a curated catalog of known AI services and assigned a risk level based on its data handling practices.
Tools that have been explicitly approved are marked as company sanctioned and report the Informational risk level. Everything else represents shadow AI: tools adopted by employees without review or approval, whose handling of company data is unknown.
'))
+ $blocks.Add((New-CippReportStatRow -Title 'AI Usage Overview' -Stats @(
+ @{ value = (nz $summary.aiToolsDetected); label = 'AI Tools' }
+ @{ value = (nz $summary.deviceInstalls); label = 'Device Installs' }
+ @{ value = (nz $summary.consentedAiApps); label = 'Entra AI Apps' }
+ @{ value = (nz $summary.highRiskTools); label = 'High Risk'; colour = (RiskColour 'high') }
+ @{ value = (nz $summary.sanctionedTools); label = 'Sanctioned'; colour = (RiskColour 'informational') }
+ )))
+ if ($topTools.Count -gt 0) {
+ $blocks.Add((New-CippReportTable -Title 'Most Used AI Tools' -Limit $topTools.Count -Columns @(
+ @{ header = 'Tool'; key = 'tool'; width = 3; bold = $true }, @{ header = 'Category'; key = 'category'; width = 3 }
+ @{ header = 'Status'; key = 'status'; width = 2 }, @{ header = 'Devices'; key = 'devices'; width = 1.5 }, @{ header = 'Users (7d)'; key = 'users'; width = 1.5 }
+ ) -Rows @($topTools | ForEach-Object { @{ tool = $_.tool; category = $_.category; status = ($_.status ?? 'Unsanctioned'); devices = "$($_.devices)"; users = "$($_.users)" } })))
+ }
+ }
+
+ if (on 'infographics') { $blocks.Add((New-CippReportHero -Image '/reportImages/laptop.jpg' -Highlight '75%' -SubText "of knowledge workers already`nuse generative AI at work -`nmost without their employer knowing" -FooterText "Visibility is the first step`nto control")) }
+
+ # -- Understanding Shadow AI --
+ if (on 'background') {
+ $blocks.Add((New-CippReportPage -Title 'Understanding Shadow AI' -Subtitle 'What unmanaged AI usage means for your organization'))
+ $blocks.Add((New-CippReportParagraph -Text 'Shadow AI is the use of artificial intelligence tools by employees without the knowledge or approval of the organization - the AI-era equivalent of shadow IT. Because most AI tools are free, browser-based and immediately useful, adoption happens quietly and quickly: an employee pastes a customer email into a chatbot to draft a reply, uploads a spreadsheet for analysis, or installs an AI notetaker that joins every meeting.'))
+ $blocks.Add((New-CippReportParagraph -Text 'The goal of a shadow AI program is not zero AI usage, but zero unsanctioned usage. Every tool in this report should end up either approved and managed, or replaced and blocked. The four risk areas below explain why unmanaged usage deserves attention.'))
+ $blocks.Add((New-CippReportHeading -Title 'Key Risk Areas'))
+ foreach ($area in $riskAreas) { $blocks.Add((New-CippReportInfoBox -Title $area.title -Colour $area.colour -Content $area.text)) }
+ }
+
+ # -- Risk Levels & Distribution --
+ if (on 'riskLevels') {
+ $blocks.Add((New-CippReportPage -Title 'AI Tool Risk Levels' -Subtitle 'How risk is assigned and how it is distributed in this tenant'))
+ $blocks.Add((New-CippReportParagraph -Text 'Detected tools are matched against a curated catalog of known AI services, each carrying a risk classification based on its data handling practices, account model and enterprise controls. Marking a tool as company sanctioned overrides its catalog risk with the Informational level, so the figures below reflect only unapproved use.'))
+ $blocks.Add((New-CippReportChart -Kind donut -Title 'AI Tool Risk Distribution' -CentreLabel 'Tools' -Data @($byRisk | ForEach-Object { @{ label = $_.risk; value = $_.tools; colour = (RiskColour $_.risk) } })))
+ $blocks.Add((New-CippReportInfoBoxColumns -Columns 2 -Items @($riskLevels | ForEach-Object { @{ title = $_.name; content = $_.text; colour = (RiskColour $_.name); tintTitle = $true } })))
+ }
+
+ # -- Sanctioned Tools --
+ if ((on 'sanctionedTools') -and $sanctionedList.Count -gt 0) {
+ $blocks.Add((New-CippReportPage -Title 'Company Sanctioned AI Tools' -Subtitle 'AI tools that are approved for use in this organization'))
+ $blocks.Add((New-CippReportParagraph -Text 'The tools listed below are permitted in this environment. They are allowed either because a business justification exists for their use, or because the system administrator has explicitly approved these tools for deployment. Sanctioned tools report the Informational risk level and are excluded from the shadow AI risk figures in this report; they remain listed for visibility into where AI is used across the organization.'))
+ $blocks.Add((New-CippReportParagraph -Text "Approval is not permanent: sanctioned tools should be reviewed periodically to confirm that the plan in use, the vendor's data handling terms and the business justification still hold."))
+ $blocks.Add((New-CippReportTable -Limit $sanctionedList.Count -Columns @(
+ @{ header = 'Tool'; key = 'tool'; width = 3; bold = $true }, @{ header = 'Vendor'; key = 'vendor'; width = 3 }, @{ header = 'Category'; key = 'category'; width = 3 }
+ @{ header = 'Devices'; key = 'devices'; width = 2 }, @{ header = 'Users (7d)'; key = 'users'; width = 2 }
+ ) -Rows @($sanctionedList | ForEach-Object { @{ tool = $_.tool; vendor = $_.vendor; category = $_.category; devices = "$($_.devices)"; users = "$($_.users)" } })))
+ }
+
+ # -- Detected Software --
+ if (on 'detectedSoftware') {
+ $blocks.Add((New-CippReportPage -Title 'AI Software on Managed Devices' -Subtitle 'AI applications found in the Intune software inventory'))
+ $detNote = if ($detected.Count -gt $detectedRows.Count) { ", showing the top $($detectedRows.Count) of $($detected.Count) entries" } else { '' }
+ $blocks.Add((New-CippReportParagraph -Text "The following AI applications were detected in the software inventory of managed devices$detNote. Device counts indicate how widely each application has spread through the environment."))
+ $blocks.Add((New-CippReportTable -Limit $detectedRows.Count -Columns @(
+ @{ header = 'Application'; key = 'application'; width = 4; bold = $true }, @{ header = 'AI Tool'; key = 'aiTool'; width = 3 }, @{ header = 'Category'; key = 'category'; width = 3 }
+ @{ header = 'Risk'; key = 'risk'; width = 2; colourField = 'riskColour' }, @{ header = 'Status'; key = 'status'; width = 2.5 }, @{ header = 'Devices'; key = 'deviceCount'; width = 1.5 }
+ ) -Rows @($detectedRows | ForEach-Object { @{ application = $_.application; aiTool = $_.aiTool; category = $_.category; risk = $_.risk; riskColour = (RiskColour $_.risk); status = $_.status; deviceCount = "$($_.deviceCount)" } })))
+ }
+
+ # -- Entra Applications --
+ if (on 'entraApplications') {
+ $blocks.Add((New-CippReportPage -Title 'AI Applications in Entra ID' -Subtitle 'AI services with a footprint in your identity platform'))
+ $conNote = if ($consented.Count -gt $consentedRows.Count) { ", showing the top $($consentedRows.Count) of $($consented.Count) entries" } else { '' }
+ $blocks.Add((New-CippReportParagraph -Text "The following AI services are registered as applications in the tenant, including any permissions users have consented to$conNote. The consent date shows when each service first gained a foothold in the environment."))
+ $blocks.Add((New-CippReportTable -Limit $consentedRows.Count -Columns @(
+ @{ header = 'Application'; key = 'application'; width = 4; bold = $true }, @{ header = 'AI Tool'; key = 'aiTool'; width = 3 }
+ @{ header = 'Risk'; key = 'risk'; width = 2; colourField = 'riskColour' }, @{ header = 'Status'; key = 'status'; width = 2.5 }, @{ header = 'Users (7d)'; key = 'activeUsersLast7Days'; width = 2 }, @{ header = 'First Consented'; key = 'firstConsented'; width = 2.5 }
+ ) -Rows @($consentedRows | ForEach-Object { @{ application = $_.application; aiTool = $_.aiTool; risk = $_.risk; riskColour = (RiskColour $_.risk); status = $_.status; activeUsersLast7Days = "$($_.activeUsersLast7Days)"; firstConsented = $(if ($_.firstConsentedDateTime) { ([datetime]$_.firstConsentedDateTime).ToString('M/d/yyyy') } else { 'Unknown' }) } })))
+ }
+
+ if (on 'infographics') { $blocks.Add((New-CippReportHero -Image '/reportImages/working.jpg' -Highlight '1 in 3' -SubText "employees shares sensitive work data`nwith AI tools without approval" -FooterText "Sanctioned alternatives keep`nyour data under contract")) }
+
+ # -- Recommendations --
+ if (on 'recommendations') {
+ $blocks.Add((New-CippReportPage -Title 'Recommendations' -Subtitle 'A structured response to shadow AI in your environment'))
+ $blocks.Add((New-CippReportParagraph -Text 'A structured response to shadow AI combines approval of useful tools with controls on the rest. The following actions are recommended based on the findings in this report:'))
+ $blocks.Add((New-CippReportBullets -Title 'Action Plan' -Items @(
+ @{ label = 'Review & Decide:'; text = 'Evaluate each detected tool with stakeholders and decide whether it should be sanctioned, replaced with an approved alternative, or blocked.' }
+ @{ label = 'Sanction Approved Tools:'; text = 'Maintain a list of company sanctioned tools so future reports separate approved AI use from true shadow AI.' }
+ @{ label = 'Offer an Alternative:'; text = 'Provide a sanctioned option such as Microsoft 365 Copilot before blocking popular tools - blocking without an alternative drives usage to personal devices.' }
+ @{ label = 'Restrict Consent:'; text = 'Require admin approval for unverified applications in Entra ID so new AI services cannot access company data through user consent.' }
+ @{ label = 'Block & Monitor:'; text = 'Deploy Conditional Access and Defender for Cloud Apps policies to block or monitor unsanctioned AI web services.' }
+ @{ label = 'Extend DLP:'; text = 'Cover generative AI endpoints with data loss prevention policies to stop sensitive data from being pasted into chat prompts.' }
+ @{ label = 'Train Users:'; text = 'Publish an acceptable AI use policy and train users on what data may never be shared with AI tools.' }
+ @{ label = 'Review Monthly:'; text = 'Re-run this report on a regular cadence - new AI tools appear in tenants within days of release.' }
+ )))
+ $blocks.Add((New-CippReportInfoBox -Title 'Next Review' -Content 'The AI tool landscape changes quickly and new tools appear in tenants within days of release. We recommend re-running this assessment monthly and reviewing newly detected tools against your acceptable AI use policy.'))
+ }
+
+ @{
+ Blocks = @($blocks)
+ Variables = @{
+ coverlabel = 'AI Risk Assessment'
+ coversubtitle = 'Discovery and risk assessment of AI tools in use across managed devices and cloud applications.'
+ coverfooternote = 'Confidential - For Internal Use Only'
+ coverfallbackimage = '/reportImages/city.jpg'
+ footerlabel = "$($Data.TenantName) - Shadow AI Report"
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippSharingReportTree.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippSharingReportTree.ps1
new file mode 100644
index 0000000000..1d2516a86f
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Build-CippSharingReportTree.ps1
@@ -0,0 +1,138 @@
+function Build-CippSharingReportTree {
+ <#
+ .SYNOPSIS
+ Compose the SharePoint/OneDrive Sharing report as a component tree (server port of
+ SharingReportButton.jsx).
+ .DESCRIPTION
+ Pure composition from already-gathered sharing data. Returns @{ Blocks; Variables }: the
+ component nodes for ConvertTo-CippReportPdf and the cover/footer report variables.
+ .PARAMETER Data
+ Sharing data: summary (counts), links[], topRecipients[], topLibraries[].
+ #>
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][hashtable]$Data)
+
+ $summary = if ($Data.summary) { $Data.summary } else { @{} }
+ # `?? @()` so a missing list is empty rather than @($null), which would render as one blank row.
+ $links = @($Data.links ?? @())
+ $topRecipients = @($Data.topRecipients ?? @())
+ $topLibraries = @($Data.topLibraries ?? @())
+ function nz($v) { if ($null -eq $v) { 0 } else { [int]$v } }
+ function plural($c, $s, $p) { "$c $(if ($c -eq 1) { $s } else { if ($p) { $p } else { "${s}s" } })" }
+ function joinList($v) { if ($v -is [array]) { $v -join ', ' } else { [string]$v } }
+
+ # Exposure grade (client assessExposure).
+ $score = (@(
+ if ((nz $summary.anonymousEditLinks) -gt 0) { 5 }
+ if ((nz $summary.neverExpiringAnonymous) -gt 0) { 3 }
+ if ((nz $summary.anonymousLinks) -gt 0) { 2 }
+ if ((nz $summary.folderShares) -gt 0) { 2 }
+ if ((nz $summary.externalLinks) -gt 0) { 1 }
+ ) | Measure-Object -Sum).Sum
+ $exposure = if ($score -ge 7) { 'High' } elseif ($score -ge 3) { 'Medium' } else { 'Low' }
+ $dangerC = '#742A2A'; $warnC = '#744210'
+ $sevColour = @{ High = $dangerC; Medium = $warnC; Low = '#22543D' }[$exposure]
+
+ $canEdit = { param($r) (joinList $r.roles) -match 'write|owner' }
+ $anonEdit = @($links | Where-Object { $_.classification -eq 'Anonymous' -and (& $canEdit $_) })
+ $neverExp = @($links | Where-Object { $_.classification -eq 'Anonymous' -and -not $_.expirationDateTime })
+ $folderShares = @($links | Where-Object { $_.itemType -eq 'Folder' -and @('Anonymous', 'External') -contains $_.classification })
+ $externalRows = @($links | Where-Object { $_.classification -eq 'External' })
+ $locationOf = { param($r) "$(if ($r.siteName) { $r.siteName } elseif ($r.siteUrl) { $r.siteUrl } else { 'Unknown site' })$(if ($r.driveName) { " / $($r.driveName)" })" }
+ $expiryOf = { param($r) if ($r.expirationDateTime) { ([datetime]$r.expirationDateTime).ToString('M/d/yyyy') } else { 'Never' } }
+
+ $blocks = [System.Collections.Generic.List[object]]::new()
+
+ # -- Executive Summary --
+ $blocks.Add((New-CippReportPage -Title 'Executive Summary' -Subtitle 'What has been shared, and how far it reaches'))
+ $blocks.Add((New-CippReportParagraph -Html ('
Sharing links are created by users on individual files and folders. They hand out access outside the permission structure an administrator sets on a site or library, they accumulate quietly as people work, and nothing prompts anyone to review them. This report covers what exists today across SharePoint and OneDrive in {0}.
' -f [System.Net.WebUtility]::HtmlEncode([string]$Data.TenantName))))
+ $blocks.Add((New-CippReportStatRow -Stats @(
+ @{ value = (nz $summary.anonymousEditLinks); label = 'Anonymous & Editable'; colour = $(if ((nz $summary.anonymousEditLinks) -gt 0) { $dangerC }) }
+ @{ value = (nz $summary.neverExpiringAnonymous); label = 'Anonymous, No Expiry'; colour = $(if ((nz $summary.neverExpiringAnonymous) -gt 0) { $dangerC }) }
+ @{ value = (nz $summary.folderShares); label = 'Shared Folders'; colour = $(if ((nz $summary.folderShares) -gt 0) { $warnC }) }
+ @{ value = (nz $summary.externalRecipients); label = 'External Recipients'; colour = $(if ((nz $summary.externalRecipients) -gt 0) { $warnC }) }
+ )))
+ $expText = switch ($exposure) {
+ 'High' { 'Content is reachable by people who cannot be identified. Anonymous links work for anyone holding them, with no sign-in and no record of use - and where those links also allow editing, changes are attributed to nobody. Treat the findings below as immediate remediation work.' }
+ 'Medium' { 'Sharing extends beyond the intended audience in places. Each finding below is individually manageable, but every open link widens what a single forwarded message can expose.' }
+ default { 'No high-risk sharing was found. Links are scoped and time-bounded. Continue reviewing periodically, since sharing accumulates as projects come and go.' }
+ }
+ $blocks.Add((New-CippReportAlertBox -Title "Sharing Exposure: $exposure" -Colour $sevColour -Content $expText))
+ $blocks.Add((New-CippReportInfoBox -Title 'What was examined' -Content ("{0} sharing links and external shares across {1} SharePoint sites, {2} Teams-connected sites and {3} OneDrive accounts, covering {4} distinct shared items. Data is taken from the last completed sync, not read live." -f (nz $summary.totalLinks), (nz $summary.sharePointSites), (nz $summary.teamsSites), (nz $summary.oneDriveAccounts), (nz $summary.itemsShared))))
+ $blocks.Add((New-CippReportInfoBox -Title 'What is not covered' -Content 'This report covers sharing links only. Permissions granted on a site or document library are a separate access path, governed differently, and are covered by the Permissions Report. A clean result here does not mean access is restricted - it means nothing has been shared out by link.'))
+
+ # -- Findings --
+ $blocks.Add((New-CippReportPage -Title 'Findings' -Subtitle 'Shares worth reviewing, most urgent first'))
+ $blocks.Add((New-CippReportHeading -Title 'Finding 1: Anonymous Links That Allow Editing'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'An anonymous link works for anyone who holds it - no sign-in, no record of who used it. When that link also grants editing, anyone it has been forwarded to can change or delete the content, and the change is attributed to nobody. This is the only combination that allows untraceable modification.'))
+ if ($anonEdit.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title (plural $anonEdit.Count 'anonymous editable link') -Colour $dangerC -Content 'Revoke these, or downgrade them to view-only where the sharing is still needed.'))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Item'; key = 'item'; width = 2.4 }, @{ header = 'Location'; key = 'location'; width = 2 }, @{ header = 'Expires'; key = 'expires'; width = 1 }) -Rows @($anonEdit | ForEach-Object { @{ item = $_.fileName; location = (& $locationOf $_); expires = (& $expiryOf $_) } })))
+ } else {
+ $blocks.Add((New-CippReportClearBox -Title 'No anonymous editable links' -Content 'No anonymous link grants write access.'))
+ }
+ $blocks.Add((New-CippReportHeading -Title 'Finding 2: Anonymous Links That Never Expire'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'A link with no expiry date stays live indefinitely, long after the reason for sharing has passed. Expiry is the only control that withdraws this access without somebody remembering to do it.'))
+ if ($neverExp.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title ("$(plural $neverExp.Count 'anonymous link') with no expiry") -Colour $warnC -Content 'Set a tenant-level default expiry so this cannot recur, then revoke the existing links that are no longer needed.'))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Item'; key = 'item'; width = 2.4 }, @{ header = 'Location'; key = 'location'; width = 2 }, @{ header = 'Permission'; key = 'roles'; width = 1 }) -Rows @($neverExp | ForEach-Object { @{ item = $_.fileName; location = (& $locationOf $_); roles = (joinList $_.roles) } })))
+ } else {
+ $blocks.Add((New-CippReportClearBox -Title 'All anonymous links expire' -Content 'Every anonymous link has an expiry date set.'))
+ }
+
+ # -- Findings continued --
+ $blocks.Add((New-CippReportPage -Title 'Findings (continued)' -Subtitle 'Reach and recipients'))
+ $blocks.Add((New-CippReportHeading -Title 'Finding 3: Shared Folders'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content "Sharing a folder shares everything inside it, including anything added later. The recipient's access grows over time without anyone re-approving it, which is the main way a small share quietly becomes a large one."))
+ if ($folderShares.Count -gt 0) {
+ $blocks.Add((New-CippReportAlertBox -Title ("$(plural $folderShares.Count 'folder') shared externally or anonymously") -Colour $warnC -Content 'Check what each folder holds now, not what it held when it was shared.'))
+ $blocks.Add((New-CippReportTable -Columns @(@{ header = 'Folder'; key = 'item'; width = 2.2 }, @{ header = 'Location'; key = 'location'; width = 2 }, @{ header = 'Audience'; key = 'audience'; width = 1.2 }) -Rows @($folderShares | ForEach-Object { @{ item = $_.fileName; location = (& $locationOf $_); audience = $_.classification } })))
+ } else {
+ $blocks.Add((New-CippReportClearBox -Title 'No externally shared folders' -Content 'External and anonymous shares point at individual files rather than folders.'))
+ }
+ $blocks.Add((New-CippReportHeading -Title 'Finding 4: External Recipients'))
+ $blocks.Add((New-CippReportInfoBox -Title 'Why this matters' -Content 'Every external recipient is a person outside the organisation holding access that was granted individually, usually for a specific piece of work. Nothing withdraws it when that work ends.'))
+ if ($topRecipients.Count -gt 0) {
+ $blocks.Add((New-CippReportParagraph -Text ("{0} hold shared content, across {1}. The most frequent are listed below." -f (plural (nz $summary.externalRecipients) 'external identity' 'external identities'), (plural $externalRows.Count 'share'))))
+ $blocks.Add((New-CippReportTable -Limit 15 -Columns @(@{ header = 'Recipient'; key = 'recipient'; width = 3 }, @{ header = 'Shares'; key = 'shares'; width = 1 }) -Rows @($topRecipients | ForEach-Object { @{ recipient = $_.recipient; shares = "$($_.links)" } })))
+ } else {
+ $blocks.Add((New-CippReportClearBox -Title 'No external recipients' -Content 'Nothing has been shared with an identity outside the organisation.'))
+ }
+ if ($topLibraries.Count -gt 0) {
+ $blocks.Add((New-CippReportHeading -Title 'Where Sharing Concentrates'))
+ $blocks.Add((New-CippReportParagraph -Text 'The libraries below account for the most sharing links. Concentration is not a problem in itself, but it shows where a review will have the most effect.'))
+ $blocks.Add((New-CippReportTable -Limit 10 -Columns @(@{ header = 'Library'; key = 'library'; width = 3 }, @{ header = 'Links'; key = 'links'; width = 1 }) -Rows @($topLibraries | ForEach-Object { @{ library = $_.library; links = "$($_.links)" } })))
+ }
+
+ # -- Recommendations --
+ $blocks.Add((New-CippReportPage -Title 'Recommendations' -Subtitle 'What to do about the findings'))
+ $blocks.Add((New-CippReportHeading -Title 'Priority Actions'))
+ $blocks.Add((New-CippReportParagraph -Text 'Ordered by how much exposure each removes relative to the effort involved.'))
+ $blocks.Add((New-CippReportBullets -Items @(
+ @{ marker = '1.'; label = 'Revoke or downgrade anonymous editable links.'; text = 'Anonymous plus editable is the only combination allowing untracked changes. Switching to view-only keeps the sharing working while removing the ability to alter content.' }
+ @{ marker = '2.'; label = 'Set a default expiry for anonymous links.'; text = 'A tenant-level expiry policy stops never-expiring links being created again. This fixes the cause rather than the instances, so the problem does not rebuild.' }
+ @{ marker = '3.'; label = 'Review folder-level external shares.'; text = 'Share specific files where practical. A folder share keeps granting access to content that did not exist when it was approved.' }
+ @{ marker = '4.'; label = 'Review long-standing external recipients.'; text = 'Revoke shares belonging to finished engagements. External access has no natural end unless someone gives it one.' }
+ @{ marker = '5.'; label = 'Require sign-in where the audience is known.'; text = 'A link scoped to specific people records who opened it. Anonymous links cannot be attributed to anyone.' }
+ )))
+ $blocks.Add((New-CippReportHeading -Title 'Keeping It That Way'))
+ $blocks.Add((New-CippReportBullets -Items @(
+ @{ label = 'Re-run this review regularly.'; text = 'Sharing accumulates continuously. A periodic review catches it while the list is still short.' }
+ @{ label = 'Set sharing defaults at tenant and site level.'; text = 'Default link type, expiry and permitted domains stop the riskiest shares being created at all, which is far cheaper than finding them later.' }
+ @{ label = 'Restrict anonymous links to view-only.'; text = 'If anonymous sharing is needed at all, removing the edit option eliminates untraceable changes without blocking the sharing itself.' }
+ @{ label = 'Password-protect sensitive shares.'; text = 'A password meaningfully narrows who can use a link that has been forwarded on.' }
+ )))
+
+ @{
+ Blocks = @($blocks)
+ Variables = @{
+ coverlabel = 'Data Sharing Review'
+ coversubtitle = "What has been shared out of SharePoint and OneDrive at $($Data.TenantName), who it reaches, and which of those shares are worth acting on."
+ covermeta = ('{0} sharing links / {1} items / {2} external recipients' -f (nz $summary.totalLinks), (nz $summary.itemsShared), (nz $summary.externalRecipients))
+ covermetanote = "Sharing exposure: $exposure"
+ coverfooternote = 'Confidential - For Internal Use Only'
+ coverfallbackimage = '/reportImages/glasses.jpg'
+ footerlabel = "$($Data.TenantName) - SharePoint & OneDrive Sharing"
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1
new file mode 100644
index 0000000000..0cc80cad73
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Convert-CippExecStandardsToControls.ps1
@@ -0,0 +1,136 @@
+function Convert-CippExecStandardsToControls {
+ <#
+ .SYNOPSIS
+ Resolve the standards-comparison result into the Executive report's SecurityControls rows.
+ .DESCRIPTION
+ Server port of processStandardsData() from ExecutiveReportButton.jsx. Walks the first tenant's
+ standards.* entries in a ListStandardsCompare result, decides Compliant vs Review the same way the
+ client does (CurrentValue/ExpectedValue deep-equal with top-level keys sorted, else Value -eq $true),
+ and resolves each standard's display name / description / tags from the standards catalog
+ (Config\standards.json). Standards with no catalog entry fall back to a template display-name lookup
+ or a formatted key, exactly as the client does. Pure transform - no data gathering - so it is
+ unit-testable with sample data.
+ .PARAMETER Compare
+ The ListStandardsCompare Body: an array whose first element is the tenant's standards object.
+ .PARAMETER Templates
+ The listStandardTemplates Body, used only to resolve Intune/CA template GUIDs to display names.
+ .PARAMETER Catalog
+ The parsed Config\standards.json catalog (array of standard definitions).
+ #>
+ [CmdletBinding()]
+ param(
+ $Compare,
+ $Templates = @(),
+ $Catalog = @()
+ )
+
+ # JS parity: JSON.stringify of an object whose top-level keys are sorted. Nested objects keep their
+ # order, which ConvertTo-Json also does, so structurally-equal values serialize identically on both
+ # sides of the comparison.
+ function ConvertTo-CanonicalJson($Value) {
+ if ($null -eq $Value) { return 'null' }
+ if ($Value -is [hashtable] -or $Value -is [System.Collections.Specialized.OrderedDictionary]) {
+ $o = [ordered]@{}
+ foreach ($k in ($Value.Keys | Sort-Object)) { $o[$k] = $Value[$k] }
+ return ($o | ConvertTo-Json -Depth 20 -Compress)
+ }
+ if ($Value -is [System.Management.Automation.PSCustomObject]) {
+ $o = [ordered]@{}
+ foreach ($n in ($Value.PSObject.Properties.Name | Sort-Object)) { $o[$n] = $Value.$n }
+ return ($o | ConvertTo-Json -Depth 20 -Compress)
+ }
+ return ($Value | ConvertTo-Json -Depth 20 -Compress)
+ }
+
+ $CompareArr = @($Compare)
+ if ($CompareArr.Count -eq 0 -or $null -eq $CompareArr[0]) { return @() }
+ $TenantData = $CompareArr[0]
+
+ # Template GUID -> display name (Intune + Conditional Access templates, incl. Tags expansion).
+ $TemplateMap = @{}
+ foreach ($Template in @($Templates)) {
+ $Std = $Template.standards
+ if (-not $Std) { continue }
+ foreach ($ListName in @('IntuneTemplate', 'ConditionalAccessTemplate')) {
+ $Items = $Std.$ListName
+ if (-not $Items) { continue }
+ foreach ($Item in @($Items)) {
+ $Tl = $Item.TemplateList
+ if ($Tl -and $Tl.value -and $Tl.label) { $TemplateMap[([string]$Tl.value).ToLower()] = [string]$Tl.label }
+ $Tags = $Item.'TemplateList-Tags'
+ $TagTemplates = $null
+ if ($Tags) {
+ $TagTemplates = $Tags.addedFields.templates
+ if (-not $TagTemplates) { $TagTemplates = $Tags.rawData.templates }
+ }
+ foreach ($Et in @($TagTemplates)) {
+ if ($Et.GUID -and ($Et.displayName -or $Et.name)) {
+ $TemplateMap[([string]$Et.GUID).ToLower()] = [string]($Et.displayName ?? $Et.name)
+ }
+ }
+ }
+ }
+ }
+
+ # Catalog by full standard name (e.g. 'standards.CopilotSettings').
+ $CatalogMap = @{}
+ foreach ($C in @($Catalog)) { if ($C.name) { $CatalogMap[[string]$C.name] = $C } }
+
+ $PropNames = if ($TenantData -is [hashtable]) { @($TenantData.Keys) } else { @($TenantData.PSObject.Properties.Name) }
+ $Out = [System.Collections.Generic.List[object]]::new()
+
+ foreach ($Key in $PropNames) {
+ $KeyStr = [string]$Key
+ if (-not $KeyStr.StartsWith('standards.') -or $KeyStr -eq 'tenantFilter') { continue }
+ $Val = if ($TenantData -is [hashtable]) { $TenantData[$Key] } else { $TenantData.$Key }
+
+ # Compliance: CurrentValue/ExpectedValue deep-equal (defined -> compared, null counts as defined),
+ # else Value -eq $true.
+ $HasCV = $false; $HasEV = $false; $CV = $null; $EV = $null; $ValueTrue = $false
+ if ($Val -is [hashtable]) {
+ $HasCV = $Val.ContainsKey('CurrentValue'); if ($HasCV) { $CV = $Val['CurrentValue'] }
+ $HasEV = $Val.ContainsKey('ExpectedValue'); if ($HasEV) { $EV = $Val['ExpectedValue'] }
+ if ($Val.ContainsKey('Value')) { $ValueTrue = ($Val['Value'] -eq $true) }
+ } elseif ($Val -is [System.Management.Automation.PSCustomObject]) {
+ $Props = $Val.PSObject.Properties.Name
+ $HasCV = $Props -contains 'CurrentValue'; if ($HasCV) { $CV = $Val.CurrentValue }
+ $HasEV = $Props -contains 'ExpectedValue'; if ($HasEV) { $EV = $Val.ExpectedValue }
+ if ($Props -contains 'Value') { $ValueTrue = ($Val.Value -eq $true) }
+ }
+
+ $IsCompliant = $false
+ if ($HasCV -and $HasEV) {
+ $IsCompliant = ((ConvertTo-CanonicalJson $CV) -eq (ConvertTo-CanonicalJson $EV))
+ } elseif ($ValueTrue) {
+ $IsCompliant = $true
+ }
+ $Status = if ($IsCompliant) { 'Compliant' } else { 'Review' }
+
+ $Def = $CatalogMap[$KeyStr]
+ if ($Def) {
+ $Tags = if ($Def.tag -and @($Def.tag).Count -gt 0) { (@($Def.tag) | Select-Object -First 2) -join ', ' } else { 'No tags' }
+ $Name = [string]$Def.label
+ $Desc = if (-not [string]::IsNullOrWhiteSpace([string]$Def.executiveText)) { [string]$Def.executiveText }
+ elseif (-not [string]::IsNullOrWhiteSpace([string]$Def.helpText)) { [string]$Def.helpText }
+ else { 'No description available' }
+ } else {
+ $Tags = 'No tags'
+ $Desc = 'Security standard implementation'
+ if ($KeyStr -match '^standards\.IntuneTemplate\.([0-9a-fA-F-]+)') {
+ $Guid = $Matches[1]
+ $Name = $TemplateMap[$Guid.ToLower()] ?? ('Intune Template - ' + $Guid.Substring(0, [Math]::Min(8, $Guid.Length)))
+ } elseif ($KeyStr -match '^standards\.ConditionalAccessTemplate\.([0-9a-fA-F-]+)') {
+ $Guid = $Matches[1]
+ $Name = $TemplateMap[$Guid.ToLower()] ?? ('CA Template - ' + $Guid.Substring(0, [Math]::Min(8, $Guid.Length)))
+ } else {
+ $N = $KeyStr -replace '^standards\.', ''
+ $N = ([regex]::Replace($N, '([A-Z])', ' $1')).Trim()
+ $Name = if ($N.Length -gt 0) { $N.Substring(0, 1).ToUpper() + $N.Substring(1) } else { $N }
+ }
+ }
+
+ $Out.Add(@{ name = $Name; description = $Desc; status = $Status; tags = $Tags })
+ }
+
+ return @($Out)
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Get-CippReportSankeyData.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Get-CippReportSankeyData.ps1
new file mode 100644
index 0000000000..6de5a9a5d3
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Get-CippReportSankeyData.ps1
@@ -0,0 +1,194 @@
+function Get-CippReportSankeyData {
+ <#
+ .SYNOPSIS
+ Build the { nodes, links } for a dashboard-style sankey from its source collection rows.
+ .DESCRIPTION
+ Faithful server-side ports of the dashboard cards' sankey computations so a report renders the
+ SAME sankey a user sees on the dashboard. Each preset reads the raw collection rows and returns
+ @{ nodes = @(@{ id; label; nodeColor }, ...); links = @(@{ source; target; value }, ...) }.
+
+ Ported one-to-one from the frontend cards (keep them in step):
+ mfaCoverage <- MFACard.jsx (MFAState) enabled -> registered/not -> enforcement
+ authMethods <- AuthMethodCard.jsx (MFAState) users -> factor class -> method breakdown
+ licenseAllocation <- LicenseCard.jsx (LicenseOverview) top-5 licence -> assigned/available
+ deviceCompliance <- managed devices (ManagedDevices) devices -> OS -> compliance state
+
+ Nodes with no link are dropped by the renderer, so declaring the full node set is safe.
+ .PARAMETER Preset
+ Which dashboard sankey to build.
+ .PARAMETER Rows
+ The raw collection rows for that sankey's source (unfiltered; each builder filters as the card does).
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)][string]$Preset,
+ [AllowEmptyCollection()][object[]]$Rows = @()
+ )
+
+ $Nodes = [System.Collections.Generic.List[object]]::new()
+ $Links = [System.Collections.Generic.List[object]]::new()
+ $AddNode = { param($Id, $Label, $Colour) $Nodes.Add([ordered]@{ id = $Id; label = $Label; nodeColor = $Colour }) }
+ $AddLink = { param($Source, $Target, $Value) if ($Value -gt 0) { $Links.Add([ordered]@{ source = $Source; target = $Target; value = $Value }) } }
+
+ switch ($Preset) {
+ 'mfaCoverage' {
+ # MFACard.jsx: enabled users -> MFA registered / Not registered -> how it is enforced.
+ $Enabled = @($Rows | Where-Object { $_.AccountEnabled -eq $true })
+ $RegisteredUsers = 0; $NotRegisteredUsers = 0
+ $RegCA = 0; $RegSD = 0; $RegPerUser = 0; $RegNone = 0
+ $NotCA = 0; $NotSD = 0; $NotNone = 0
+ foreach ($User in $Enabled) {
+ $HasRegistered = $User.MFARegistration -eq $true
+ $CoveredByCA = "$($User.CoveredByCA)".StartsWith('Enforced')
+ $CoveredBySD = $User.CoveredBySD -eq $true
+ $PerUser = "$($User.PerUser)"
+ $PerUserOn = $PerUser -eq 'enforced' -or $PerUser -eq 'enabled'
+ if ($HasRegistered -or $PerUserOn) {
+ $RegisteredUsers++
+ if ($PerUserOn) { $RegPerUser++ }
+ elseif ($CoveredByCA) { $RegCA++ }
+ elseif ($CoveredBySD) { $RegSD++ }
+ else { $RegNone++ }
+ } else {
+ $NotRegisteredUsers++
+ if ($CoveredByCA) { $NotCA++ }
+ elseif ($CoveredBySD) { $NotSD++ }
+ else { $NotNone++ }
+ }
+ }
+ & $AddNode 'Enabled users' 'Enabled users' 'hsl(28, 100%, 53%)'
+ & $AddNode 'MFA registered' 'MFA registered' 'hsl(99, 70%, 50%)'
+ & $AddNode 'Not registered' 'Not registered' 'hsl(39, 100%, 50%)'
+ & $AddNode 'CA policy' 'CA policy' 'hsl(99, 70%, 50%)'
+ & $AddNode 'Security defaults' 'Security defaults' 'hsl(140, 70%, 50%)'
+ & $AddNode 'Per-user MFA' 'Per-user MFA' 'hsl(200, 70%, 50%)'
+ & $AddNode 'No enforcement' 'No enforcement' 'hsl(0, 100%, 50%)'
+ & $AddLink 'Enabled users' 'MFA registered' $RegisteredUsers
+ & $AddLink 'Enabled users' 'Not registered' $NotRegisteredUsers
+ & $AddLink 'MFA registered' 'CA policy' $RegCA
+ & $AddLink 'MFA registered' 'Security defaults' $RegSD
+ & $AddLink 'MFA registered' 'Per-user MFA' $RegPerUser
+ & $AddLink 'MFA registered' 'No enforcement' $RegNone
+ & $AddLink 'Not registered' 'CA policy' $NotCA
+ & $AddLink 'Not registered' 'Security defaults' $NotSD
+ & $AddLink 'Not registered' 'No enforcement' $NotNone
+ }
+ 'authMethods' {
+ # AuthMethodCard.jsx: users -> single/multi factor, phishable vs phish-resistant, + breakdown.
+ $Enabled = @($Rows | Where-Object { $_.AccountEnabled -eq $true })
+ $Phishable = @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'email', 'microsoftAuthenticatorPush', 'softwareOneTimePasscode', 'hardwareOneTimePasscode')
+ $Passkey = @('fido2SecurityKey', 'passKeyDeviceBound', 'passKeyDeviceBoundAuthenticator', 'passKeyDeviceBoundWindowsHello', 'x509Certificate')
+ $PhishResistant = @($Passkey + 'windowsHelloForBusiness')
+ $SingleFactor = 0; $PhishableCount = 0; $PhishResistantCount = 0; $PerUserMFA = 0
+ $PhoneCount = 0; $AuthenticatorCount = 0; $PasskeyCount = 0; $WhfbCount = 0
+ foreach ($User in $Enabled) {
+ $Methods = @($User.MFAMethods | Where-Object { $null -ne $_ -and "$_" -ne '' })
+ $PerUser = "$($User.PerUser)"
+ $PerUserOn = $PerUser -eq 'enforced' -or $PerUser -eq 'enabled'
+ $HasRegistered = $User.MFARegistration -eq $true
+ if ($PerUserOn -and -not $HasRegistered -and $Methods.Count -eq 0) { $PerUserMFA++; continue }
+ if (-not $HasRegistered -or $Methods.Count -eq 0) { $SingleFactor++; continue }
+ $HasPR = @($Methods | Where-Object { $PhishResistant -contains $_ }).Count -gt 0
+ $HasPh = @($Methods | Where-Object { $Phishable -contains $_ }).Count -gt 0
+ if ($HasPR) {
+ $PhishResistantCount++
+ if (@($Methods | Where-Object { $Passkey -contains $_ }).Count -gt 0) { $PasskeyCount++ }
+ if ($Methods -contains 'windowsHelloForBusiness') { $WhfbCount++ }
+ } elseif ($HasPh) {
+ $PhishableCount++
+ if (($Methods -contains 'mobilePhone') -or ($Methods -contains 'alternateMobilePhone') -or ($Methods -contains 'officePhone') -or ($Methods -contains 'email')) { $PhoneCount++ }
+ if (($Methods -contains 'microsoftAuthenticatorPush') -or ($Methods -contains 'softwareOneTimePasscode') -or ($Methods -contains 'hardwareOneTimePasscode')) { $AuthenticatorCount++ }
+ } else {
+ $PhishableCount++; $AuthenticatorCount++
+ }
+ }
+ & $AddNode 'Users' 'Users' 'hsl(28, 100%, 53%)'
+ & $AddNode 'Single factor' 'Single factor' 'hsl(0, 100%, 50%)'
+ & $AddNode 'Multi factor' 'Multi factor' 'hsl(200, 70%, 50%)'
+ & $AddNode 'Phishable' 'Phishable' 'hsl(39, 100%, 50%)'
+ & $AddNode 'Phone' 'Phone' 'hsl(39, 100%, 45%)'
+ & $AddNode 'Authenticator' 'Authenticator' 'hsl(39, 100%, 55%)'
+ & $AddNode 'Phish resistant' 'Phish resistant' 'hsl(99, 70%, 50%)'
+ & $AddNode 'Passkey' 'Passkey' 'hsl(140, 70%, 50%)'
+ & $AddNode 'WHfB' 'WHfB' 'hsl(160, 70%, 50%)'
+ & $AddLink 'Users' 'Single factor' $SingleFactor
+ & $AddLink 'Users' 'Multi factor' $PerUserMFA
+ & $AddLink 'Users' 'Phishable' $PhishableCount
+ & $AddLink 'Users' 'Phish resistant' $PhishResistantCount
+ & $AddLink 'Phishable' 'Phone' $PhoneCount
+ & $AddLink 'Phishable' 'Authenticator' $AuthenticatorCount
+ & $AddLink 'Phish resistant' 'Passkey' $PasskeyCount
+ & $AddLink 'Phish resistant' 'WHfB' $WhfbCount
+ }
+ 'licenseAllocation' {
+ # LicenseCard.jsx: the top-5 licences by total, each fanning out to its own assigned/available.
+ $Top = @($Rows | Where-Object { ($_.TotalLicenses -as [int]) -gt 0 } |
+ Sort-Object -Property @{ Expression = { $_.TotalLicenses -as [int] }; Descending = $true } |
+ Select-Object -First 5)
+ $Index = 0
+ foreach ($Lic in $Top) {
+ $Name = if ($Lic.License) { "$($Lic.License)" } elseif ($Lic.skuPartNumber) { "$($Lic.skuPartNumber)" } elseif ($Lic.SkuPartNumber) { "$($Lic.SkuPartNumber)" } else { 'Unknown License' }
+ $Short = if ($Name.Length -gt 30) { $Name.Substring(0, 27) + '...' } else { $Name }
+ $Assigned = ($Lic.CountUsed -as [int]); if ($null -eq $Assigned) { $Assigned = 0 }
+ $Available = ($Lic.CountAvailable -as [int]); if ($null -eq $Available) { $Available = 0 }
+ $NodeId = "$Index-$Short"
+ & $AddNode $NodeId $Short ('hsl({0}, 70%, 50%)' -f (210 + $Index * 30))
+ if ($Assigned -gt 0) {
+ & $AddNode "$NodeId - Assigned" "$Short - Assigned" 'hsl(99, 70%, 50%)'
+ & $AddLink $NodeId "$NodeId - Assigned" $Assigned
+ }
+ if ($Available -gt 0) {
+ & $AddNode "$NodeId - Available" "$Short - Available" 'hsl(28, 100%, 53%)'
+ & $AddLink $NodeId "$NodeId - Available" $Available
+ }
+ $Index++
+ }
+ }
+ 'deviceCompliance' {
+ # Managed devices -> operating system -> compliance state. Compliant green, non-compliant red,
+ # in-grace amber, anything else (unknown/error/conflict) grey.
+ $StateColour = {
+ param($State)
+ switch -Regex ("$State".ToLowerInvariant()) {
+ '^compliant$' { 'hsl(99, 70%, 50%)' }
+ '^noncompliant$' { 'hsl(0, 100%, 50%)' }
+ '^ingraceperiod$' { 'hsl(39, 100%, 50%)' }
+ default { 'hsl(220, 10%, 60%)' }
+ }
+ }
+ $OsOrder = [System.Collections.Generic.List[string]]::new()
+ $StateSeen = @{}
+ $OsTotals = @{}
+ $PairCounts = [ordered]@{}
+ foreach ($Device in $Rows) {
+ $Os = "$($Device.operatingSystem)"; if (-not $Os) { $Os = 'Unknown' }
+ $State = "$($Device.complianceState)"; if (-not $State) { $State = 'unknown' }
+ if (-not $OsOrder.Contains($Os)) { $OsOrder.Add($Os) }
+ $OsTotals[$Os] = ([int]$OsTotals[$Os]) + 1
+ $Key = "$Os`n$State"
+ $PairCounts[$Key] = ([int]$PairCounts[$Key]) + 1
+ }
+ if ($OsOrder.Count -gt 0) {
+ & $AddNode 'Managed devices' 'Managed devices' 'hsl(28, 100%, 53%)'
+ $OsIndex = 0
+ foreach ($Os in $OsOrder) {
+ & $AddNode "os:$Os" $Os ('hsl({0}, 70%, 50%)' -f ((200 + $OsIndex * 35) % 360))
+ & $AddLink 'Managed devices' "os:$Os" ([int]$OsTotals[$Os])
+ $OsIndex++
+ }
+ foreach ($Key in $PairCounts.Keys) {
+ $Parts = $Key -split "`n", 2
+ $Os = $Parts[0]; $State = $Parts[1]
+ $StateId = "state:$State"
+ if (-not $StateSeen.ContainsKey($StateId)) { & $AddNode $StateId $State (& $StateColour $State); $StateSeen[$StateId] = $true }
+ & $AddLink "os:$Os" $StateId ([int]$PairCounts[$Key])
+ }
+ }
+ }
+ default { }
+ }
+
+ return @{ nodes = @($Nodes); links = @($Links) }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/Get-CippReportTableData.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/Get-CippReportTableData.ps1
new file mode 100644
index 0000000000..623e4cdec1
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/Get-CippReportTableData.ps1
@@ -0,0 +1,69 @@
+function Get-CippReportTableData {
+ <#
+ .SYNOPSIS
+ Build flat table rows for a pre-built report table whose data lives in a nested shape a generic
+ single-collection dataSource cannot read.
+ .DESCRIPTION
+ Some report tables need data that is not one flat row per record - for example the Secure Score
+ controls, which live in a nested controlScores array inside a handful of daily snapshot rows. A
+ preset here reads the raw collection rows and returns an ordered list of flat rows (hashtables),
+ each key a field a table column can name. The report resolver feeds these rows through the same
+ column mapping every hand-built table uses, so the columns read their fields exactly as normal.
+
+ Presets:
+ secureScoreFailing <- SecureScore the latest snapshot's controls that are not fully achieved
+ (scoreInPercentage < 100), worst first.
+ .PARAMETER Preset
+ Which table to build.
+ .PARAMETER Rows
+ The raw collection rows for that table's source.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)][string]$Preset,
+ [AllowEmptyCollection()][object[]]$Rows = @()
+ )
+
+ $AsDouble = { param($Value) $n = "$Value" -as [double]; if ($null -ne $n) { $n } else { $null } }
+ $Pct = { param($Value) $n = & $AsDouble $Value; if ($null -eq $n) { '' } elseif ([math]::Round($n) -eq $n) { "$([int]$n)%" } else { "$([math]::Round($n, 1))%" } }
+ # Some control statuses arrive as HTML (implementationStatus); flatten to a short line of plain text.
+ $Plain = {
+ param([string]$Text)
+ if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
+ $Clean = [regex]::Replace($Text, '<[^>]+>', ' ')
+ $Clean = [System.Net.WebUtility]::HtmlDecode($Clean)
+ $Clean = [regex]::Replace($Clean, '\s+', ' ').Trim()
+ if ($Clean.Length -gt 160) { $Clean = $Clean.Substring(0, 159).TrimEnd() + [char]0x2026 }
+ $Clean
+ }
+
+ switch ($Preset) {
+ 'secureScoreFailing' {
+ # SecureScore is a series of daily snapshots; take the most recent one and read its per-control
+ # scores. A control that is not fully achieved (below 100%) is one worth listing.
+ if (@($Rows).Count -eq 0) { return @() }
+ $Latest = @($Rows | Sort-Object -Property @{ Expression = { [datetime]("$($_.createdDateTime)" -as [datetime]) } } -Descending | Select-Object -First 1)
+ if (-not $Latest) { $Latest = @($Rows)[-1] }
+ $Controls = @($Latest.controlScores)
+ if (@($Controls).Count -eq 0) { return @() }
+ $Failing = @($Controls | Where-Object {
+ $p = & $AsDouble $_.scoreInPercentage
+ $null -ne $p -and $p -lt 100
+ })
+ $Ordered = @($Failing | Sort-Object -Property @{ Expression = { [double](& $AsDouble $_.scoreInPercentage) } }, @{ Expression = { "$($_.controlName)" } })
+ return @($Ordered | ForEach-Object {
+ [ordered]@{
+ control = "$($_.controlName)"
+ category = "$($_.controlCategory)"
+ status = $(if ($_.implementationStatus) { (& $Plain "$($_.implementationStatus)") } else { "$($_.on)" })
+ percent = (& $Pct $_.scoreInPercentage)
+ }
+ })
+ }
+ default {
+ throw "Unknown report table preset '$Preset'."
+ }
+ }
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Reporting/New-CippReportComponents.ps1 b/backend/Modules/CIPPCore/Public/Tools/Reporting/New-CippReportComponents.ps1
new file mode 100644
index 0000000000..9e9f84f6d7
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Reporting/New-CippReportComponents.ps1
@@ -0,0 +1,159 @@
+#
+# Report component builders - the PowerShell authoring layer for server-side reports.
+#
+# Each function returns one declarative component node (a hashtable) that the CIPPSharp component kit
+# renders (see ConvertTo-CippReportPdf / [CIPP.Reporting.ReportPdf]). A report is composed of these
+# and never hand-writes OfficeIMO or block JSON: gather data, map it to components, hand the array to
+# ConvertTo-CippReportPdf. The node shapes mirror the block types the kit understands
+# (page/blank/scorecard/richtable/richbullets/chart/infobox/alertbox/clearbox/hero/pagebreak).
+#
+
+function New-CippReportPage {
+ # Opens a titled content page (a fixed report's ContentPage): its own header title + subtitle.
+ param([Parameter(Mandatory)][string]$Title, [string]$Subtitle)
+ $n = [ordered]@{ type = 'page'; title = $Title }
+ if ($Subtitle) { $n.subtitle = $Subtitle }
+ $n
+}
+
+function New-CippReportPageBreak {
+ @{ type = 'pagebreak' }
+}
+
+function New-CippReportHeading {
+ # A section heading on its own (renders the section title, no body).
+ param([Parameter(Mandatory)][string]$Title)
+ [ordered]@{ type = 'blank'; title = $Title; content = '' }
+}
+
+function New-CippReportParagraph {
+ # Body copy. -Html passes raw HTML (bold/links); -Text wraps plain text.
+ # -Title adds a section heading above the paragraph. -Indent steps plain -Text in under a heading.
+ param([string]$Text, [string]$Html, [string]$Title, [switch]$Indent)
+ if ($Indent) {
+ $n = [ordered]@{ type = 'paragraphindent'; content = [string]$Text }
+ if ($Title) { $n.title = $Title }
+ return $n
+ }
+ if ($Html) {
+ $n = [ordered]@{ type = 'blank'; content = $Html }
+ } else {
+ $n = [ordered]@{ type = 'blank'; content = ('
{0}
' -f [System.Net.WebUtility]::HtmlEncode([string]$Text)) }
+ }
+ if ($Title) { $n.title = $Title }
+ $n
+}
+
+function New-CippReportStatRow {
+ # A row of stat cards. -Stats: @( @{ value; label; caption; colour }, ... ). An empty row renders nothing.
+ param([string]$Title, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Stats)
+ $n = [ordered]@{ type = 'scorecard'; stats = @($Stats) }
+ if ($Title) { $n.title = $Title }
+ $n
+}
+
+function New-CippReportTable {
+ # A data table. -Columns: @( @{ header; key; width; bold; align; toneField }, ... ). -Rows: row objects.
+ param([string]$Title, [Parameter(Mandatory)][object[]]$Columns, [object[]]$Rows = @(), [int]$Limit = 25)
+ $n = [ordered]@{ type = 'richtable'; columns = @($Columns); rows = @($Rows); limit = $Limit }
+ if ($Title) { $n.title = $Title }
+ $n
+}
+
+function New-CippReportBullets {
+ # Rich bullets. -Items: @( @{ label; text }, ... ) (label is optional bold prefix).
+ param([string]$Title, [Parameter(Mandatory)][object[]]$Items)
+ $n = [ordered]@{ type = 'richbullets'; items = @($Items) }
+ if ($Title) { $n.title = $Title }
+ $n
+}
+
+function New-CippReportNote {
+ # A small italic aside (client Note / truncation line): "... and N more".
+ param([Parameter(Mandatory)][string]$Text)
+ [ordered]@{ type = 'note'; content = $Text }
+}
+
+function New-CippReportChart {
+ # A chart: -Kind bar|donut|trend, -Data @( @{ label; value; colour }, ... ). Title shows in the frame.
+ # Empty data is allowed: the kit draws a "No data available" frame (a tenant with no mail, no risks...).
+ param([string]$Title, [ValidateSet('bar', 'donut', 'trend')][string]$Kind = 'bar', [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Data, [double]$Max, [string]$Caption, [string]$CentreLabel)
+ $n = [ordered]@{ type = 'chart'; chartKind = $Kind; chartData = @($Data) }
+ if ($Title) { $n.title = $Title }
+ if ($PSBoundParameters.ContainsKey('Max')) { $n.max = $Max }
+ if ($Caption) { $n.caption = $Caption }
+ if ($CentreLabel) { $n.centreLabel = $CentreLabel }
+ $n
+}
+
+function New-CippReportProgress {
+ # A list of labelled progress bars (the client ProgressList). -Items: @( @{ label; value; max; display; colour }, ... ).
+ # Each renders a value/max data bar over a grey track - zero-safe (a 0 value draws an empty track), unlike a
+ # bar chart whose rounded bars collapse at zero. -Display overrides the "N%" label (e.g. a raw count).
+ param([string]$Title, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Items)
+ $n = [ordered]@{ type = 'progress'; items = @($Items) }
+ if ($Title) { $n.title = $Title }
+ $n
+}
+
+function New-CippReportInfoBox {
+ # A callout with a left accent stripe. -Tone ok|warn tints it; -Content is markdown, unless -Lines is
+ # set, in which case each '\n' line is kept as a tight line break (label/value detail lists).
+ param([Parameter(Mandatory)][string]$Title, [string]$Content, [ValidateSet('', 'ok', 'warn')][string]$Tone = '', [string]$Colour, [switch]$TintTitle, [switch]$Lines)
+ $n = [ordered]@{ type = 'infobox'; title = $Title; content = $Content }
+ if ($Tone) { $n.tone = $Tone }
+ if ($Colour) { $n.colour = $Colour }
+ if ($TintTitle) { $n.tintTitle = $true }
+ if ($Lines) { $n.lines = $true }
+ $n
+}
+
+function New-CippReportInfoBoxColumns {
+ # A grid of callouts laid out -Columns per row (client Columns of InfoBoxes). -Items:
+ # @( @{ title; content; colour; tone; tintTitle }, ... ). Content is plain prose.
+ param([Parameter(Mandatory)][object[]]$Items, [int]$Columns = 2)
+ [ordered]@{ type = 'infoboxcolumns'; items = @($Items); columns = $Columns }
+}
+
+function New-CippReportAlertBox {
+ # A warning callout. -Content is markdown, unless -Lines keeps each '\n' line as a tight line break.
+ param([Parameter(Mandatory)][string]$Title, [string]$Content, [string]$Colour, [switch]$Lines)
+ $n = [ordered]@{ type = 'alertbox'; title = $Title; content = $Content }
+ if ($Colour) { $n.colour = $Colour }
+ if ($Lines) { $n.lines = $true }
+ $n
+}
+
+function New-CippReportClearBox {
+ param([Parameter(Mandatory)][string]$Title, [string]$Content, [switch]$Lines)
+ $n = [ordered]@{ type = 'clearbox'; title = $Title; content = $Content }
+ if ($Lines) { $n.lines = $true }
+ $n
+}
+
+function New-CippReportHero {
+ # A full-bleed chapter divider. -Image is a data-URL cover photo; the big -Highlight sits over it.
+ param([string]$Image, [string]$Overtitle, [string]$Highlight, [string]$Headline, [string]$SubText, [string]$FooterText)
+ $n = [ordered]@{ type = 'hero' }
+ if ($Image) { $n.heroImage = $Image }
+ if ($Overtitle) { $n.overtitle = $Overtitle }
+ if ($Highlight) { $n.highlight = $Highlight }
+ if ($Headline) { $n.headline = $Headline }
+ if ($SubText) { $n.subText = $SubText }
+ if ($FooterText) { $n.footerText = $FooterText }
+ $n
+}
+
+function New-CippReportSankey {
+ # A flow diagram (the dashboard CippSankey). -Nodes: @( @{ id; nodeColor; label }, ... ) - nodeColor
+ # accepts hex or hsl(); -Links: @( @{ source; target; value }, ... ) referencing node ids. Node columns
+ # and heights are derived from the link flow. -Height sets the plot height (default 240pt). Empty data
+ # draws a "No data available" frame.
+ param([string]$Title, [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Nodes,
+ [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Links, [string]$Caption, [double]$Height)
+ $n = [ordered]@{ type = 'sankey'; nodes = @($Nodes); links = @($Links) }
+ if ($Title) { $n.title = $Title }
+ if ($Caption) { $n.caption = $Caption }
+ if ($PSBoundParameters.ContainsKey('Height')) { $n.height = $Height }
+ $n
+}
diff --git a/backend/Modules/CIPPCore/Public/Tools/Resolve-CippReportDataToken.ps1 b/backend/Modules/CIPPCore/Public/Tools/Resolve-CippReportDataToken.ps1
new file mode 100644
index 0000000000..0f04fbaca2
--- /dev/null
+++ b/backend/Modules/CIPPCore/Public/Tools/Resolve-CippReportDataToken.ps1
@@ -0,0 +1,447 @@
+function Resolve-CippReportDataToken {
+ <#
+ .SYNOPSIS
+ Resolve &data tokens& in report builder blocks against the reporting database.
+ .DESCRIPTION
+ A block's text can name reporting-database data with a token, and the value is read here on
+ the server when the report renders, so a scheduled run and a preview read the same data:
+
+ &Users& the number of rows in that collection
+ &Users.displayName& the field's distinct values, comma-separated (the first 25)
+ &Devices.complianceState=compliant& the number of rows whose field has that value (* wildcards; != for the rest)
+ &Mailboxes.TotalItemSize:sum& a numeric field's sum, avg, min, max or count of rows carrying it
+
+ Collection names are the reporting database's types, the same names the Database Data block
+ offers as sources; fields are case-insensitive and may reach into nested objects with dots.
+ A chart with a chartSource of &Devices.operatingSystem& gets one slice per value of that field;
+ a table with a dataSource of &Mailboxes& (a filter token works too) gets the rows, each column
+ reading the field it names (its `field`, else its header). A score card block with a statsSource
+ gets one card per value of the field (a count, or an aggregate of a numeric field) and a progress
+ block with an itemsSource one bar each, filled by its share of the total. A token that names
+ nothing is left as written, so the mistake shows in the report instead of silently blanking.
+ .PARAMETER Blocks
+ The enriched blocks, as objects or hashtables. Returned with the tokens replaced in place.
+ .PARAMETER TenantFilter
+ The tenant whose reporting database answers.
+ .FUNCTIONALITY
+ Internal
+ #>
+ [CmdletBinding()]
+ param(
+ [AllowEmptyCollection()][object[]]$Blocks = @(),
+ [Parameter(Mandatory = $true)][string]$TenantFilter
+ )
+
+ $Pattern = '(?:&|&)(?[A-Za-z0-9_-]+)(?:\.(?[A-Za-z0-9_.-]+))?(?:(?!=|=)(?[^&]*?))?(?::(?sum|avg|min|max|count))?(?:&|&)'
+ $MaxListed = 25
+ $MaxSlices = 8
+ $MaxPoints = 30
+ $MaxRows = 200
+
+ # One read per collection per render; a collection the database does not hold reads as $null. The
+ # reserved collection 'TestResults' reads the in-app compliance test results (CippTestResults) instead
+ # of the reporting database, so a chart/table/flow can be driven by test data (count by Status,
+ # Category, Risk...) the same way it is driven by reporting collections.
+ $Cache = @{}
+ $RowsOf = {
+ param([string]$Type)
+ $Key = $Type.ToLowerInvariant()
+ if (-not $Cache.ContainsKey($Key)) {
+ $Rows = try {
+ if ($Key -eq 'testresults') {
+ @((Get-CIPPTestResults -TenantFilter $TenantFilter).TestResults) | Where-Object { $null -ne $_ }
+ } else {
+ @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type) | Where-Object { $null -ne $_ -and $_ -ne $false }
+ }
+ } catch { $null }
+ $Cache[$Key] = if ($null -eq $Rows) { $null } else { @($Rows) }
+ }
+ $Cache[$Key]
+ }
+
+ # A field's values in one row, following a dotted path and flattening arrays along the way.
+ $ValueOf = {
+ param($Row, [string]$Path)
+ $Current = @($Row)
+ foreach ($Segment in $Path.Split('.')) {
+ $Current = @(foreach ($Item in $Current) {
+ if ($null -eq $Item) { continue }
+ if ($Item -is [System.Collections.IDictionary]) {
+ $Name = @($Item.Keys) | Where-Object { "$_" -ieq $Segment } | Select-Object -First 1
+ if ($null -ne $Name) { $Item[$Name] }
+ } else {
+ $Property = $Item.PSObject.Properties | Where-Object { $_.Name -ieq $Segment } | Select-Object -First 1
+ if ($Property) { $Property.Value }
+ }
+ })
+ }
+ @($Current | Where-Object { $null -ne $_ -and "$_" -ne '' })
+ }
+
+ $RowMatches = {
+ param($Row, [string]$Field, [string]$Op, [string]$Wanted)
+ $Values = @(& $ValueOf $Row $Field | ForEach-Object { "$_" })
+ $Hit = if ($Wanted.Contains('*')) { @($Values | Where-Object { $_ -like $Wanted }).Count -gt 0 } else { @($Values | Where-Object { $_ -ieq $Wanted }).Count -gt 0 }
+ if ($Op -eq '!=') { -not $Hit } else { $Hit }
+ }
+
+ $FormatNumber = { param([double]$n) if ([math]::Round($n) -eq $n) { "$([long]$n)" } else { "$([math]::Round($n, 2))" } }
+
+ # The text a token stands for; $null when its collection is unknown, so the token stays as written.
+ $Evaluate = {
+ param($Match)
+ $Type = $Match.Groups['type'].Value
+ $Field = $Match.Groups['field'].Value
+ $Op = $Match.Groups['op'].Value
+ $Wanted = $Match.Groups['value'].Value
+ $Agg = $Match.Groups['agg'].Value
+ $Rows = & $RowsOf $Type
+ if ($null -eq $Rows) { return $null }
+ if (-not $Field) { return "$($Rows.Count)" }
+ if ($Op) { return "$(@($Rows | Where-Object { & $RowMatches $_ $Field $Op $Wanted }).Count)" }
+ $Values = @(foreach ($Row in $Rows) { & $ValueOf $Row $Field })
+ if ($Agg) {
+ if ($Agg -eq 'count') { return "$($Values.Count)" }
+ $Numbers = @($Values | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ })
+ if ($Numbers.Count -eq 0) { return '0' }
+ $Measured = $Numbers | Measure-Object -Sum -Average -Minimum -Maximum
+ $Aggregate = switch ($Agg) { 'sum' { $Measured.Sum } 'avg' { $Measured.Average } 'min' { $Measured.Minimum } default { $Measured.Maximum } }
+ return (& $FormatNumber $Aggregate)
+ }
+ $Distinct = @($Values | ForEach-Object { "$_" } | Sort-Object -Unique)
+ if ($Distinct.Count -le $MaxListed) { return ($Distinct -join ', ') }
+ return (($Distinct | Select-Object -First $MaxListed) -join ', ') + " and $($Distinct.Count - $MaxListed) more"
+ }
+
+ # Replace every token in a string. A string that is one token and resolves to a number becomes a
+ # number, so a progress bar's value or a chart point can be a token too.
+ $ReplaceIn = {
+ param([string]$Text)
+ if ($Text -notmatch '&') { return $Text }
+ $Whole = [regex]::Match($Text.Trim(), "^$Pattern$")
+ $Result = [regex]::Replace($Text, $Pattern, [System.Text.RegularExpressions.MatchEvaluator] {
+ param($m)
+ $Value = & $Evaluate $m
+ if ($null -eq $Value) { $m.Value } else { $Value }
+ })
+ if ($Whole.Success -and $Result.Trim() -match '^-?\d+(\.\d+)?$') { return [double]$Result.Trim() }
+ $Result
+ }
+
+ $SetProperty = {
+ param($Target, [string]$Name, $Value)
+ if ($Target -is [System.Collections.IDictionary]) { $Target[$Name] = $Value } else { $Target | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force }
+ }
+
+ # Walk a value: strings are resolved, lists and objects walked, everything else kept.
+ $Walk = $null
+ $Walk = {
+ param($Value)
+ if ($Value -is [string]) { return (& $ReplaceIn $Value) }
+ if ($Value -is [System.Collections.IDictionary]) {
+ foreach ($Name in @($Value.Keys)) { $Value[$Name] = & $Walk $Value[$Name] }
+ return $Value
+ }
+ if ($Value -is [array] -or $Value -is [System.Collections.IList]) {
+ return , @(foreach ($Item in $Value) { & $Walk $Item })
+ }
+ if ($Value -is [System.Management.Automation.PSCustomObject]) {
+ foreach ($Property in @($Value.PSObject.Properties)) { $Value.($Property.Name) = & $Walk $Property.Value }
+ return $Value
+ }
+ $Value
+ }
+
+ $ParseToken = { param([string]$Text) $m = [regex]::Match("$Text".Trim(), "^$Pattern$"); if ($m.Success) { $m } }
+
+ # A chart or table source as the builder's picker saves it - { type; field; filter = { field; op;
+ # value } } - or as a token; either way @{ type; field; filter }, with filter $null when there is none.
+ $SourceOf = {
+ param($Source)
+ if ($null -eq $Source) { return $null }
+ if ($Source -is [string]) {
+ $m = & $ParseToken $Source
+ if (-not $m) { return $null }
+ $Field = $m.Groups['field'].Value
+ if ($m.Groups['op'].Value) {
+ return @{ type = $m.Groups['type'].Value; field = $null; filter = @{ field = $Field; op = $m.Groups['op'].Value; value = $m.Groups['value'].Value } }
+ }
+ return @{ type = $m.Groups['type'].Value; field = $(if ($Field) { $Field }); filter = $null }
+ }
+ if (-not $Source.type) { return $null }
+ $Filter = $Source.filter
+ $Spec = if ($Filter -and $Filter.field -and $Filter.op) { @{ field = "$($Filter.field)"; op = "$($Filter.op)"; value = "$($Filter.value)" } }
+ @{
+ type = "$($Source.type)"
+ field = $(if ($Source.field) { "$($Source.field)" })
+ # a numeric field to plot instead of counting rows; aggregate combines rows sharing a label
+ valueField = $(if ($Source.valueField -and "$($Source.valueField)" -ne '__count') { "$($Source.valueField)" })
+ aggregate = $(if ($Source.aggregate -and @('sum', 'avg', 'max', 'min') -contains "$($Source.aggregate)") { "$($Source.aggregate)" })
+ # divide plotted values by this before drawing, so bytes can be shown as GB and so on
+ scale = $(if (($Source.scale -as [double]) -gt 0) { [double]$Source.scale })
+ filter = $Spec
+ }
+ }
+ $RowsFor = {
+ param($Spec)
+ $Rows = & $RowsOf $Spec.type
+ if ($null -eq $Rows) { return $null }
+ if ($Spec.filter) { $Rows = @($Rows | Where-Object { & $RowMatches $_ $Spec.filter.field $Spec.filter.op $Spec.filter.value }) }
+ , @($Rows)
+ }
+
+ # Label/value pairs from a source, for score cards and progress bars: one entry per distinct value
+ # of the field (a count of rows, or an aggregate of a numeric field), or a single figure when no
+ # field is named. Sorted by value and capped so a row of cards or bars stays readable.
+ $CardsFrom = {
+ param($Spec, $Rows, [string]$SingleLabel)
+ $Field = $Spec.field
+ $Aggregate = { param($Numbers) if (@($Numbers).Count -eq 0) { return 0 }
+ $Nums = if ($Spec.scale) { @($Numbers | ForEach-Object { $_ / $Spec.scale }) } else { @($Numbers) }
+ $Measured = $Nums | Measure-Object -Sum -Average -Minimum -Maximum
+ switch ($Spec.aggregate) { 'avg' { [math]::Round($Measured.Average, 2) } 'min' { $Measured.Minimum } 'max' { $Measured.Maximum } default { $Measured.Sum } } }
+ $Pairs = if ($Spec.valueField) {
+ if ($Field) {
+ @($Rows | Group-Object { @(& $ValueOf $_ $Field | ForEach-Object { "$_" }) | Select-Object -First 1 } | ForEach-Object {
+ $Label = "$($_.Name)"
+ if (-not $Label) { return }
+ $Numbers = @($_.Group | ForEach-Object { @(& $ValueOf $_ $Spec.valueField | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ }) | Select-Object -First 1 } | Where-Object { $null -ne $_ })
+ if ($Numbers.Count -eq 0) { return }
+ @{ label = $Label; value = [double](& $Aggregate $Numbers) }
+ })
+ } else {
+ $Numbers = @($Rows | ForEach-Object { @(& $ValueOf $_ $Spec.valueField | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ }) | Select-Object -First 1 } | Where-Object { $null -ne $_ })
+ @(@{ label = $(if ($SingleLabel) { $SingleLabel } else { $Spec.type }); value = [double](& $Aggregate $Numbers) })
+ }
+ } elseif ($Field) {
+ $Groups = @(foreach ($Row in $Rows) { @(& $ValueOf $Row $Field | ForEach-Object { "$_" }) }) | Group-Object { $_.ToLowerInvariant() }
+ @($Groups | ForEach-Object { @{ label = "$($_.Group[0])"; value = [double]$_.Count } })
+ } else {
+ @(@{ label = $(if ($SingleLabel) { $SingleLabel } else { $Spec.type }); value = [double]$Rows.Count })
+ }
+ @($Pairs | Sort-Object -Property @{ Expression = { $_.value }; Descending = $true }, @{ Expression = { $_.label } } | Select-Object -First $MaxSlices)
+ }
+
+ foreach ($Block in @($Blocks)) {
+ if ($null -eq $Block) { continue }
+ $Type = "$($Block.type)"
+
+ # A chart drawn from the data. Counting rows: one slice per distinct value of the field, the long
+ # tail as Other, or a single counted slice when no field was picked. Plotting a field's value:
+ # one point per row labelled by the field - chronological when the labels are dates, which is
+ # how a Secure Score trend reads - or, with an aggregate, one point per label.
+ if ($Type -eq 'chart' -and $Block.chartSource) {
+ $Spec = & $SourceOf $Block.chartSource
+ $Rows = if ($Spec) { & $RowsFor $Spec }
+ if ($Spec -and $null -ne $Rows) {
+ $Field = $Spec.field
+ $Points = if ($Spec.valueField) {
+ $Series = @(foreach ($Row in $Rows) {
+ $Number = @(& $ValueOf $Row $Spec.valueField | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ }) | Select-Object -First 1
+ if ($null -eq $Number) { continue }
+ if ($Spec.scale) { $Number = [double]$Number / $Spec.scale }
+ $Raw = if ($Field) { @(& $ValueOf $Row $Field | ForEach-Object { "$_" }) | Select-Object -First 1 } else { $null }
+ $Date = [datetime]::MinValue
+ $IsDate = $null -ne $Raw -and [datetime]::TryParse("$Raw", [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$Date)
+ @{ raw = $Raw; value = [double]$Number; date = $(if ($IsDate) { $Date }) }
+ })
+ if ($Spec.aggregate) {
+ $Combined = @($Series | Group-Object { "$($_.raw)".ToLowerInvariant() } | ForEach-Object {
+ $Measured = @($_.Group | ForEach-Object { $_.value }) | Measure-Object -Sum -Average -Maximum -Minimum
+ $Figure = switch ($Spec.aggregate) { 'avg' { [math]::Round($Measured.Average, 2) } 'max' { $Measured.Maximum } 'min' { $Measured.Minimum } default { $Measured.Sum } }
+ @{ label = "$($_.Group[0].raw)"; value = [double]$Figure }
+ })
+ @($Combined | Sort-Object -Property @{ Expression = { $_.value }; Descending = $true }, @{ Expression = { $_.label } } | Select-Object -First $MaxSlices)
+ } elseif ($Series.Count -gt 0 -and @($Series | Where-Object { $null -ne $_.date }).Count -eq $Series.Count) {
+ $Ordered = @($Series | Sort-Object -Property { $_.date } | Select-Object -Last $MaxPoints)
+ $Format = if (@($Ordered | ForEach-Object { $_.date.Year } | Select-Object -Unique).Count -gt 1) { 'MMM d yyyy' } else { 'MMM d' }
+ @($Ordered | ForEach-Object { @{ label = $_.date.ToString($Format, [cultureinfo]::InvariantCulture); value = $_.value } })
+ } else {
+ @($Series | Select-Object -Last $MaxPoints | ForEach-Object { @{ label = $(if ($null -ne $_.raw) { "$($_.raw)" } else { '' }); value = $_.value } })
+ }
+ } elseif ($Field) {
+ $Groups = @(foreach ($Row in $Rows) { @(& $ValueOf $Row $Field | ForEach-Object { "$_" }) }) | Group-Object { $_.ToLowerInvariant() } | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, @{ Expression = 'Name'; Descending = $false }
+ $Blank = @($Rows | Where-Object { @(& $ValueOf $_ $Field).Count -eq 0 }).Count
+ $Top = @($Groups | Select-Object -First $MaxSlices | ForEach-Object { @{ label = $_.Group[0]; value = $_.Count } })
+ $Rest = @($Groups | Select-Object -Skip $MaxSlices | Measure-Object -Property Count -Sum).Sum
+ @($Top; if ($Rest -gt 0) { @{ label = 'Other'; value = [int]$Rest } }; if ($Blank -gt 0) { @{ label = '(blank)'; value = $Blank } })
+ } else {
+ @(@{ label = $(if ($Block.title) { "$($Block.title)" } else { $Spec.type }); value = $Rows.Count })
+ }
+ & $SetProperty $Block 'chartData' @($Points)
+ }
+ }
+
+ # A table filled from the data: the rows (the ones the condition keeps), each column reading
+ # the field it names. A dataSource with a preset instead calls a derived builder for data that a
+ # flat single-collection read cannot express (e.g. the Secure Score controls, nested per snapshot);
+ # either way the same column mapping fills the table.
+ if ($Type -eq 'richtable' -and $Block.dataSource) {
+ $TablePreset = "$($Block.dataSource.preset)"
+ $Rows = if ($TablePreset) {
+ $Source = & $RowsOf "$($Block.dataSource.type)"
+ if ($null -ne $Source) { try { @(Get-CippReportTableData -Preset $TablePreset -Rows @($Source)) } catch { $null } }
+ } else {
+ $Spec = & $SourceOf $Block.dataSource
+ if ($Spec) { & $RowsFor $Spec }
+ }
+ if ($null -ne $Rows) {
+ $Columns = @($Block.columns)
+ $TableRows = @(foreach ($Row in (@($Rows) | Select-Object -First $MaxRows)) {
+ $Cells = [ordered]@{}
+ foreach ($Column in $Columns) {
+ $Key = "$($Column.key)"
+ $From = if ($Column.field) { "$($Column.field)" } else { "$($Column.header)" }
+ $Cells[$Key] = (@(& $ValueOf $Row $From | ForEach-Object { "$_" }) -join ', ')
+ }
+ $Cells
+ })
+ & $SetProperty $Block 'rows' @($TableRows)
+ if (-not $Block.limit) { & $SetProperty $Block 'limit' $MaxRows }
+ }
+ }
+
+ # Score cards drawn from the data: one card per distinct value of the field, the figure a count
+ # of rows (or an aggregate of a numeric field). Manual stats stay hand-typed and can use tokens.
+ if ($Type -eq 'scorecard' -and $Block.statsSource) {
+ $Spec = & $SourceOf $Block.statsSource
+ $Rows = if ($Spec) { & $RowsFor $Spec }
+ if ($Spec -and $null -ne $Rows) {
+ $Cards = @(& $CardsFrom $Spec @($Rows) "$($Block.title)" | ForEach-Object { @{ value = (& $FormatNumber $_.value); label = $_.label } })
+ if ($Cards.Count -gt 0) { & $SetProperty $Block 'stats' @($Cards) }
+ }
+ }
+
+ # Progress bars drawn from the data: one bar per distinct value of the field, filled by its share
+ # of the total (counting rows) or of the largest bar (aggregating a numeric field).
+ if ($Type -eq 'progress' -and $Block.itemsSource) {
+ $Spec = & $SourceOf $Block.itemsSource
+ $Rows = if ($Spec) { & $RowsFor $Spec }
+ if ($Spec -and $null -ne $Rows) {
+ $Bars = @(& $CardsFrom $Spec @($Rows) "$($Block.title)")
+ if ($Bars.Count -gt 0) {
+ $Max = if ($Spec.valueField) { [double](@($Bars | ForEach-Object { $_.value }) | Measure-Object -Maximum).Maximum } else { [double]@($Rows).Count }
+ if ($Max -le 0) { $Max = [double](@($Bars | ForEach-Object { $_.value }) | Measure-Object -Maximum).Maximum }
+ $Items = @($Bars | ForEach-Object { @{ label = $_.label; value = $_.value; max = $Max } })
+ & $SetProperty $Block 'items' @($Items)
+ }
+ }
+ }
+
+ # A sankey (flow diagram) drawn from the data. Three shapes:
+ # preset - a faithful server-side port of a dashboard sankey (MFA coverage, auth methods,
+ # licence allocation, device compliance); Get-CippReportSankeyData does the exact
+ # per-sankey computation the dashboard card does, so the report matches the dashboard.
+ # measures - one category field on the left, plus numeric measure fields, each a right-hand
+ # node; every row adds category -> measureLabel weighted by that field.
+ # flow - an ordered list of categorical fields; each adjacent pair (a, b) becomes a link
+ # a -> b weighted by the number of rows (or the sum of valueField). Node ids are
+ # namespaced by stage so a value shared between two columns does not fold/cycle.
+ if ($Type -eq 'sankey' -and $Block.sankeySource) {
+ $Sankey = $Block.sankeySource
+ $CollectionType = "$($Sankey.type)"
+ $Rows = if ($CollectionType) { & $RowsOf $CollectionType }
+ if ($null -ne $Rows -and $Sankey.preset) {
+ # Faithful dashboard sankey - the ported card logic owns the whole {nodes, links}.
+ $Built = try { Get-CippReportSankeyData -Preset "$($Sankey.preset)" -Rows @($Rows) } catch { $null }
+ if ($Built -and @($Built.links).Count -gt 0) {
+ & $SetProperty $Block 'nodes' @($Built.nodes)
+ & $SetProperty $Block 'links' @($Built.links)
+ }
+ } elseif ($null -ne $Rows) {
+ $SankeyFilter = $Sankey.filter
+ if ($SankeyFilter -and $SankeyFilter.field -and $SankeyFilter.op) {
+ $Rows = @($Rows | Where-Object { & $RowMatches $_ "$($SankeyFilter.field)" "$($SankeyFilter.op)" "$($SankeyFilter.value)" })
+ }
+ $NodeOrder = [System.Collections.Generic.List[string]]::new()
+ $NodeLabel = @{}
+ $NodeColour = @{}
+ $LinkValue = [ordered]@{}
+ $Palette = { param([int]$Index) 'hsl({0}, 70%, 50%)' -f ((205 + $Index * 37) % 360) }
+ $AddNode = {
+ param([string]$Id, [string]$Label, [string]$Colour)
+ if (-not $NodeLabel.ContainsKey($Id)) {
+ $NodeOrder.Add($Id)
+ $NodeLabel[$Id] = $Label
+ $NodeColour[$Id] = if ($Colour) { $Colour } else { & $Palette ($NodeOrder.Count - 1) }
+ }
+ }
+ $AddLink = {
+ param([string]$Source, [string]$Target, [double]$Value)
+ $Key = "$Source`n$Target"
+ $LinkValue[$Key] = ([double]($LinkValue[$Key]) + $Value)
+ }
+
+ if ("$($Sankey.mode)" -eq 'measures') {
+ $CategoryField = "$($Sankey.field)"
+ $Measures = @($Sankey.measures)
+ $Limit = if (($Sankey.limit -as [int]) -gt 0) { [int]$Sankey.limit } else { 8 }
+ # Rank categories by their total across all measures, keep the top N.
+ $CategoryTotals = @{}
+ foreach ($Row in $Rows) {
+ $Category = @(& $ValueOf $Row $CategoryField | ForEach-Object { "$_" }) | Select-Object -First 1
+ if (-not $Category) { continue }
+ foreach ($Measure in $Measures) {
+ $Number = @(& $ValueOf $Row "$($Measure.field)" | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ }) | Select-Object -First 1
+ if ($null -ne $Number) { $CategoryTotals[$Category] = ([double]($CategoryTotals[$Category]) + [double]$Number) }
+ }
+ }
+ $KeepCategories = @($CategoryTotals.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First $Limit -ExpandProperty Key)
+ foreach ($Row in $Rows) {
+ $Category = @(& $ValueOf $Row $CategoryField | ForEach-Object { "$_" }) | Select-Object -First 1
+ if (-not $Category -or $Category -notin $KeepCategories) { continue }
+ & $AddNode $Category $Category $null
+ foreach ($Measure in $Measures) {
+ $Label = if ($Measure.label) { "$($Measure.label)" } else { "$($Measure.field)" }
+ $Number = @(& $ValueOf $Row "$($Measure.field)" | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ }) | Select-Object -First 1
+ if ($null -eq $Number -or $Number -le 0) { continue }
+ & $AddNode "measure:$Label" $Label "$($Measure.colour)"
+ & $AddLink $Category "measure:$Label" ([double]$Number)
+ }
+ }
+ } else {
+ # flow: an ordered list of categorical fields; adjacent pairs become links. Node ids are
+ # namespaced by stage index so the same value in two columns stays two nodes.
+ $Fields = @($Sankey.fields | Where-Object { $_ } | ForEach-Object { "$_" })
+ $ValueField = if ($Sankey.valueField) { "$($Sankey.valueField)" }
+ if ($Fields.Count -ge 2) {
+ foreach ($Row in $Rows) {
+ $Weight = if ($ValueField) {
+ @(& $ValueOf $Row $ValueField | ForEach-Object { $_ -as [double] } | Where-Object { $null -ne $_ }) | Select-Object -First 1
+ } else { 1 }
+ if ($null -eq $Weight -or $Weight -le 0) { continue }
+ $Stages = @(for ($si = 0; $si -lt $Fields.Count; $si++) {
+ $Value = @(& $ValueOf $Row $Fields[$si] | ForEach-Object { "$_" }) | Select-Object -First 1
+ if (-not $Value) { $Value = '(blank)' }
+ [pscustomobject]@{ Id = ('{0}:{1}' -f $si, $Value); Label = "$Value" }
+ })
+ foreach ($Stage in $Stages) { & $AddNode $Stage.Id $Stage.Label $null }
+ for ($i = 0; $i -lt $Stages.Count - 1; $i++) { & $AddLink $Stages[$i].Id $Stages[$i + 1].Id ([double]$Weight) }
+ }
+ }
+ }
+
+ if ($NodeOrder.Count -gt 0 -and $LinkValue.Count -gt 0) {
+ $Nodes = @(foreach ($Id in $NodeOrder) { @{ id = $Id; label = $NodeLabel[$Id]; nodeColor = $NodeColour[$Id] } })
+ $Links = @(foreach ($Key in $LinkValue.Keys) {
+ $Parts = $Key -split "`n", 2
+ @{ source = $Parts[0]; target = $Parts[1]; value = $LinkValue[$Key] }
+ })
+ & $SetProperty $Block 'nodes' @($Nodes)
+ & $SetProperty $Block 'links' @($Links)
+ }
+ }
+ }
+
+ # Every other string on the block, its rows and its items.
+ if ($Block -is [System.Collections.IDictionary]) {
+ foreach ($Name in @($Block.Keys)) { if ($Name -notin 'chartSource', 'dataSource', 'sankeySource', 'statsSource', 'itemsSource') { $Block[$Name] = & $Walk $Block[$Name] } }
+ } else {
+ foreach ($Property in @($Block.PSObject.Properties)) { if ($Property.Name -notin 'chartSource', 'dataSource', 'sankeySource', 'statsSource', 'itemsSource') { $Block.($Property.Name) = & $Walk $Property.Value } }
+ }
+ }
+
+ # Unrolled, not wrapped: callers collect with @(), and a wrapped array would reach them as one
+ # element holding every block - which the renderer then draws as nothing at all.
+ return $Blocks
+}
diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailTrafficSummary.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailTrafficSummary.ps1
new file mode 100644
index 0000000000..1fd59d8c71
--- /dev/null
+++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailTrafficSummary.ps1
@@ -0,0 +1,60 @@
+function Set-CIPPDBCacheMailTrafficSummary {
+ <#
+ .SYNOPSIS
+ Caches Exchange Online mail traffic summary (top senders and recipients by message volume)
+
+ .DESCRIPTION
+ Runs Get-MailTrafficSummaryReport for the TopMailSender and TopMailRecipient categories over a
+ 30-day window and stores one row per address as { category, name, count, windowDays } under the
+ MailTrafficSummary type. This is the same data the live Mail Flow Statistics page reads, cached so
+ a scheduled or pre-built report (mailboxes by messaging volume) can use it without a live EXO call.
+
+ .PARAMETER TenantFilter
+ The tenant to cache mail traffic summary for
+
+ .PARAMETER QueueId
+ The queue ID to update with total tasks (optional)
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$TenantFilter,
+ [string]$QueueId
+ )
+
+ try {
+ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching mail traffic summary' -sev Debug
+
+ $WindowDays = 30
+ $StartDate = (Get-Date).AddDays(-$WindowDays).ToUniversalTime().ToString('s')
+ $EndDate = (Get-Date).ToUniversalTime().ToString('s')
+
+ # Both categories in one EXO batch; OperationGuid tags each returned row with the category it
+ # came from (the report rows do not otherwise say). C1 is the address/name, C2 the message count.
+ $Batch = @('TopMailSender', 'TopMailRecipient') | ForEach-Object {
+ @{
+ CmdletInput = @{ CmdletName = 'Get-MailTrafficSummaryReport'; Parameters = @{ Category = $_; StartDate = $StartDate; EndDate = $EndDate } }
+ OperationGuid = $_
+ }
+ }
+ $Summary = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Batch) -useSystemMailbox $true
+
+ $Rows = @(foreach ($Entry in @($Summary)) {
+ if ($Entry.error -or [string]::IsNullOrWhiteSpace("$($Entry.C1)")) { continue }
+ [PSCustomObject]@{
+ category = "$($Entry.OperationGuid)"
+ name = "$($Entry.C1)"
+ count = [int]($Entry.C2)
+ windowDays = $WindowDays
+ }
+ })
+
+ if ($Rows.Count -gt 0) {
+ $Rows | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailTrafficSummary' -AddCount
+ }
+ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached mail traffic summary successfully ($($Rows.Count) rows)" -sev Debug
+
+ } catch {
+ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache mail traffic summary: $($_.Exception.Message)" -sev Error
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDBCache.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDBCache.ps1
index 5e9407e172..2d6191661c 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDBCache.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDBCache.ps1
@@ -22,6 +22,7 @@ function Invoke-ListDBCache {
get an estate-wide inventory and per-tenant cache freshness in one call. Pass a
type alongside it to restrict the result to a single collection.
+ Use type=_shape for every collection's row count and fields (recorded when the cache was written).
Use type=_availableTypes to discover which cache collections exist for a given tenant. Omitting the
type parameter also returns the available types.
@@ -120,6 +121,27 @@ function Invoke-ListDBCache {
})
}
+ # type=_shape: every collection with its row count and the fields (and types) its rows were seen
+ # to carry, as recorded when the cache was written. What the report builder offers to pick from.
+ if ($Type -eq '_shape') {
+ $ShapeRows = @(Get-CIPPDbItem -CountsOnly -IncludeShape -TenantFilter $Tenant)
+ if ($null -ne $AllowedDomains) {
+ $ShapeRows = @($ShapeRows | Where-Object { $AllowedDomains.Contains([string]$_.PartitionKey) })
+ }
+ $Shapes = @($ShapeRows | Sort-Object -Property RowKey | ForEach-Object {
+ $Fields = try { @((ConvertFrom-Json -InputObject "$($_.Shape)" -ErrorAction Stop).fields) } catch { @() }
+ [PSCustomObject]@{
+ Type = $_.RowKey -replace '-Count$', ''
+ Count = $_.DataCount
+ Fields = @($Fields | Where-Object { $_.name } | ForEach-Object { [PSCustomObject]@{ name = [string]$_.name; type = [string]$_.type } })
+ }
+ })
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ Body = @{ Results = $Shapes }
+ })
+ }
+
if (-not $Type -or $Type -eq '_availableTypes') {
$TypeRows = @(Get-CIPPDbItem -CountsOnly -TenantFilter $Tenant)
if ($null -ne $AllowedDomains) {
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1
index 886a547bcd..fb53d8ef32 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1
@@ -240,6 +240,19 @@ Function Invoke-ExecBrandingSettings {
}
}
+ # Which of the tenant's names a report prints: alias (the name CIPP shows), name (the
+ # Microsoft 365 organisation name) or domain (the default domain).
+ if (-not $ErrorMessage -and $Request.Body.PSObject.Properties.Name -contains 'tenantLabel') {
+ $TenantLabel = "$($Request.Body.tenantLabel)"
+ if (@('alias', 'name', 'domain') -notcontains $TenantLabel) {
+ $StatusCode = [HttpStatusCode]::BadRequest
+ $ErrorMessage = 'Error: tenantLabel must be alias, name or domain.'
+ } else {
+ $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'tenantLabel' -Value $TenantLabel -Force
+ $Updated = $true
+ }
+ }
+
# Show the branded footer text on report pages.
if (-not $ErrorMessage -and $Request.Body.PSObject.Properties.Name -contains 'showFooter') {
$BrandingConfig | Add-Member -MemberType NoteProperty -Name 'showFooter' -Value ([bool]$Request.Body.showFooter) -Force
@@ -438,6 +451,32 @@ Function Invoke-ExecBrandingSettings {
Write-LogMessage -API $APIName -tenant 'Global' -headers $Request.Headers -message 'Reset branding settings to defaults' -Sev 'Info'
'Successfully reset branding settings to defaults'
}
+ 'RenameImage' {
+ # A name for a gallery image, so it can be picked by name where the image is offered
+ # elsewhere: the report builder lists uploaded covers as Infographic page backgrounds.
+ $Kind = "$($Request.Body.kind)".ToLowerInvariant()
+ $ImageId = "$($Request.Body.id)".Trim()
+ $ImageName = "$($Request.Body.name)".Trim()
+ if (-not $ImageId) {
+ $StatusCode = [HttpStatusCode]::BadRequest
+ 'Error: id is required.'
+ break
+ }
+ if ($ImageName.Length -gt 64) {
+ $StatusCode = [HttpStatusCode]::BadRequest
+ 'Error: Image name must be 64 characters or fewer.'
+ break
+ }
+ $PartitionKey = switch ($Kind) { 'logo' { 'logo' } 'cover' { 'brandingCover' } default { $null } }
+ if (-not $PartitionKey) {
+ $StatusCode = [HttpStatusCode]::BadRequest
+ 'Error: kind must be logo or cover.'
+ break
+ }
+ Set-CIPPImageName -PartitionKey $PartitionKey -Id $ImageId -Name $ImageName
+ Write-LogMessage -API $APIName -tenant 'Global' -headers $Request.Headers -message "Named branding $Kind image $ImageId '$ImageName'" -Sev 'Info'
+ 'Successfully named image'
+ }
'ListPresets' {
Get-CIPPBrandingPreset
}
@@ -513,6 +552,14 @@ Function Invoke-ExecBrandingSettings {
break
}
+ # Which of the tenant's names the preset's reports print; see the Set action.
+ $PresetTenantLabel = if ($Request.Body.tenantLabel) { "$($Request.Body.tenantLabel)" } else { 'alias' }
+ if (@('alias', 'name', 'domain') -notcontains $PresetTenantLabel) {
+ $StatusCode = [HttpStatusCode]::BadRequest
+ 'Error: tenantLabel must be alias, name or domain.'
+ break
+ }
+
$PresetId = if ($Request.Body.id) { "$($Request.Body.id)" } else { (New-Guid).Guid }
Add-CIPPAzDataTableEntity @Table -Force -Entity @{
@@ -530,6 +577,7 @@ Function Invoke-ExecBrandingSettings {
showPageNumbers = [bool]$Request.Body.showPageNumbers
watermarkText = $PresetWatermark
watermarkEnabled = [bool]$Request.Body.watermarkEnabled
+ tenantLabel = $PresetTenantLabel
} | Out-Null
Write-LogMessage -API $APIName -tenant 'Global' -headers $Request.Headers -message "Saved branding preset '$PresetName'" -Sev 'Info'
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPreviewBrandingReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPreviewBrandingReportPdf.ps1
new file mode 100644
index 0000000000..10ab3e55e7
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPreviewBrandingReportPdf.ps1
@@ -0,0 +1,68 @@
+function Invoke-ExecPreviewBrandingReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint,AnyTenant
+ .ROLE
+ CIPP.AppSettings.Read
+ .DESCRIPTION
+ Renders a sample report against a branding configuration - normally the unsaved state of the
+ branding editor - and returns it as application/pdf bytes, so a colour, logo, cover, footer or
+ watermark can be judged on every page of a real report before it is saved. Each report type has
+ a fixed sample of the data its builder needs (Config/ReportSamples/.json); nothing is read
+ from a tenant.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ $ReportNames = @{
+ executive = 'Executive Summary'; reportBuilder = 'Quarterly Security Review'; shadowAI = 'Shadow AI Report'
+ bec = 'BEC Analysis Report'; sharing = 'Sharing Report'; permissions = 'Permissions Report'; mailFlow = 'Mail Flow Report'
+ }
+
+ try {
+ # Which report to preview: executive, reportBuilder, shadowAI, bec, sharing, permissions or mailFlow.
+ $ReportType = [string]($Request.Body.reportType ?? 'executive')
+ if (-not $ReportNames.ContainsKey($ReportType)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = "Unknown report type '$ReportType'. Use one of: $($ReportNames.Keys -join ', ')." })
+ }
+ $Sample = Get-Content (Join-Path $env:CIPPRootPath "Config\ReportSamples\$ReportType.json") -Raw | ConvertFrom-Json -AsHashtable
+ # The branding to render against, in the shape Get-CIPPBrandingSettings returns: colours (flat or
+ # under roleColours), a data-URL logo and cover or a coverStock path, footer, watermark and
+ # tenantLabel. Omitted -> the saved branding settings.
+ $Branding = $Request.Body.branding
+ # The sample tenant, named the way the branding under edit would name a real one.
+ $TenantName = switch ([string]$Branding.tenantLabel) {
+ 'domain' { 'contoso.onmicrosoft.com' }
+ 'name' { 'Contoso Ltd (sample data)' }
+ default { 'Contoso (sample data)' }
+ }
+ $Data = @{ TenantName = $TenantName } + $Sample
+ $Report = switch ($ReportType) {
+ 'reportBuilder' { @{ Blocks = @($Sample.blocks); Variables = @{} } }
+ 'shadowAI' { Build-CippShadowAIReportTree -Data $Data }
+ 'bec' { Build-CippBecReportTree -UserData $Sample.userData -BecData $Sample.becData -TenantName $TenantName }
+ 'sharing' { Build-CippSharingReportTree -Data $Data }
+ 'permissions' { Build-CippPermissionsReportTree -Data $Data }
+ 'mailFlow' { Build-CippMailFlowReportTree -Data $Data }
+ default { Build-CippExecutiveReportTree -Data $Data }
+ }
+
+ # Optional: the tenant whose %variables% (%cippurl%, custom variables) resolve in the footer and cover.
+ $TenantFilter = [string]$Request.Body.tenantFilter
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -Branding $Branding -TenantName $TenantName -TenantFilter $TenantFilter -ReportName $ReportNames[$ReportType]
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = 'inline; filename="Branding_Preview.pdf"' }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render the branding preview: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1
new file mode 100644
index 0000000000..b94e26a6ee
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ExecGetMailFlowReportPdf.ps1
@@ -0,0 +1,74 @@
+function Invoke-ExecGetMailFlowReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Exchange.Mailbox.Read
+ .DESCRIPTION
+ Server-renders the Exchange mail flow report as application/pdf bytes. Reads the same three Exchange
+ reports the Mail Flow page uses (Get-MailFlowStatusReport plus the TopMailSender and
+ TopSpamRecipient traffic summaries), aggregates the daily disposition rows the way the page does,
+ and composes them through the shared CIPPSharp kit (Build-CippMailFlowReportTree) - the server-side
+ replacement for the client react-pdf MailFlowReportButton.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
+ }
+ # Reporting window in days (1-90).
+ $Days = [Math]::Min([Math]::Max([int]($Request.Query.days ?? 14), 1), 90)
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+
+ $Window = @{
+ StartDate = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString('s')
+ EndDate = (Get-Date).ToUniversalTime().ToString('s')
+ }
+ $FlowRows = @(New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MailFlowStatusReport' -CmdParams $Window)
+ # For the Top* traffic summary categories C1 is the address and C2 the message count.
+ function Get-TopList($Category) {
+ @(New-ExoRequest -TenantId $TenantFilter -Cmdlet 'Get-MailTrafficSummaryReport' -CmdParams ($Window + @{ Category = $Category }) |
+ ForEach-Object { @{ name = $_.C1; count = $_.C2 } })
+ }
+
+ # Message counts summed per event type, per direction and per day (the page's useMemo aggregation).
+ $Totals = @{}
+ foreach ($g in ($FlowRows | Group-Object EventType)) { $Totals[$g.Name] = ($g.Group | Measure-Object -Property MessageCount -Sum).Sum }
+ $DirectionTotals = @{}
+ foreach ($g in ($FlowRows | Group-Object Direction)) { $DirectionTotals[$g.Name] = ($g.Group | Measure-Object -Property MessageCount -Sum).Sum }
+ $Daily = @($FlowRows | Group-Object { ([datetime]$_.Date).ToString('yyyy-MM-dd') } | Sort-Object Name | ForEach-Object {
+ $Day = @{ date = $_.Name }
+ foreach ($t in ($_.Group | Group-Object EventType)) { $Day[$t.Name] = ($t.Group | Measure-Object -Property MessageCount -Sum).Sum }
+ $Day
+ })
+
+ $Report = Build-CippMailFlowReportTree -Data @{
+ TenantName = $TenantName
+ days = $Days
+ totals = $Totals
+ directionTotals = $DirectionTotals
+ daily = $Daily
+ topSenders = Get-TopList 'TopMailSender'
+ topSpamRecipients = Get-TopList 'TopSpamRecipient'
+ }
+
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Mail Flow Report'
+ $FileName = ("Mail_Flow_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render Mail Flow report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1
index e8973960f7..6855623067 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveAssignmentFilterTemplate.ps1
@@ -15,11 +15,9 @@ Function Invoke-RemoveAssignmentFilterTemplate {
$ID = $request.Query.ID ?? $Request.Body.ID
try {
$Table = Get-CippTable -tablename 'templates'
- Write-Host $ID
$SafeID = ConvertTo-CIPPODataFilterValue -Value $ID -Type Guid
$Filter = "PartitionKey eq 'AssignmentFilterTemplate' and RowKey eq '$SafeID'"
- Write-Host $Filter
$ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey
Remove-CIPPAzDataTableEntity -Force @Table -Entity $ClearRow
$Result = "Removed Assignment Filter Template with ID $ID"
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1
index 011c25569f..0c387af612 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1
@@ -7,7 +7,7 @@ function Invoke-ExecBECEvidenceExport {
.SYNOPSIS
Builds the evidence package for a Business Email Compromise run and returns it.
.DESCRIPTION
- Collates the run's stored results, one CSV per finding set, the score, the containment history, every logbook entry stamped with the case id and the browser-rendered PDF reports (pdfBase64, pdfSummaryBase64) into a ZIP. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save. Metadata only - nothing in the package is message content.
+ Collates the run's stored results, one CSV per finding set, the score, the containment history, every logbook entry stamped with the case id and the full and C-suite-summary report PDFs (rendered server-side) into a ZIP. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save. Metadata only - nothing in the package is message content.
#>
[CmdletBinding()]
param($Request, $TriggerMetadata)
@@ -16,15 +16,12 @@ function Invoke-ExecBECEvidenceExport {
$Headers = $Request.Headers
$TenantFilter = $Request.Body.tenantFilter
$CaseId = [string]$Request.Body.caseId
- # optional: the report PDFs rendered in the browser, base64-encoded (full report + C-suite summary)
- $PdfBase64 = [string]$Request.Body.pdfBase64
- $PdfSummaryBase64 = [string]$Request.Body.pdfSummaryBase64
Set-CippBecCaseContext -CaseId $CaseId
try {
if (-not $TenantFilter) { throw 'tenantFilter is required' }
if (-not $CaseId) { throw 'caseId is required' }
- $Package = New-CIPPBecEvidencePackage -TenantFilter $TenantFilter -CaseId $CaseId -PdfBase64 $PdfBase64 -PdfSummaryBase64 $PdfSummaryBase64 -Headers $Headers -APIName $APIName
+ $Package = New-CIPPBecEvidencePackage -TenantFilter $TenantFilter -CaseId $CaseId -Headers $Headers -APIName $APIName
$Body = @{
Results = "Evidence package for case $CaseId created: $($Package.FileCount) files, $([math]::Round($Package.Bytes / 1KB)) KB"
Evidence = [pscustomobject]@{
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1
new file mode 100644
index 0000000000..d12000d3d1
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecGetBecReportPdf.ps1
@@ -0,0 +1,87 @@
+function Invoke-ExecGetBecReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Identity.User.Read
+ .DESCRIPTION
+ Server-renders a stored Business Email Compromise (BEC) run as application/pdf bytes. Reads the
+ run through Get-CIPPBecReport (the BecReports metadata row plus its BecResults payload) and
+ composes it through the shared CIPPSharp component kit (Build-CippBecReportTree) - the server-side
+ replacement for the client react-pdf BECRemediationReportButton. A run is named by its caseId;
+ pass userId instead to render the user's newest completed run. The run must have completed - the
+ report reads its stored result, it does not trigger a new investigation.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ # The stored run to render. A caseId names it directly; a userId picks the user's newest completed run.
+ $CaseId = $Request.Query.caseId ?? $Request.Body.caseId
+ $UserId = $Request.Query.userId ?? $Request.Body.userId
+ if ([string]::IsNullOrWhiteSpace($TenantFilter) -or ([string]::IsNullOrWhiteSpace($CaseId) -and [string]::IsNullOrWhiteSpace($UserId))) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter and either a caseId or a userId are required' })
+ }
+
+ # Without a caseId, fall back to the user's most recent completed run (the list is newest-first).
+ if ([string]::IsNullOrWhiteSpace($CaseId)) {
+ $Runs = @(Get-CIPPBecReport -TenantFilter $TenantFilter -UserId $UserId)
+ $CaseId = ($Runs | Where-Object { $_.Status -eq 'Completed' } | Select-Object -First 1).CaseId
+ if ([string]::IsNullOrWhiteSpace($CaseId)) {
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::NotFound
+ Body = 'No completed BEC analysis is stored for this user. Run the BEC check first, then generate the report.'
+ })
+ }
+ }
+
+ $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -IncludeResults
+ if (-not $Run -or $Run.Status -ne 'Completed' -or -not $Run.Results) {
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::NotFound
+ Body = "No completed BEC analysis is stored for case '$CaseId'. Run the BEC check first, then generate the report."
+ })
+ }
+
+ # The results payload is what the check pages render. The containment history and the run's own
+ # identifiers live on the metadata row, and the client renderer reads them off becData.Run - so
+ # attach the same .Run block here, and the builder reads it exactly as the client does.
+ $BecData = $Run.Results
+ $RunBlock = [pscustomobject]@{
+ CaseId = $Run.CaseId
+ Status = $Run.Status
+ ExtractedAt = $Run.ExtractedAt
+ RequestedAt = $Run.RequestedAt
+ RequestedBy = $Run.RequestedBy
+ Containment = $Run.Containment
+ }
+ $BecData | Add-Member -NotePropertyName 'Run' -NotePropertyValue $RunBlock -Force
+
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+ # The investigated user labels the cover and footer; the run stored who it was for.
+ $DisplayName = @($Run.DisplayName, $Run.UserPrincipalName, $Request.Query.userDisplayName, $Request.Body.userDisplayName, $Run.UserId) |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
+ $UserName = @($Run.UserPrincipalName, $Request.Query.userName, $Request.Body.userName) |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
+ $UserData = [pscustomobject]@{ displayName = $DisplayName; userPrincipalName = $UserName; id = $Run.UserId }
+
+ $Report = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName
+
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report'
+ $FileName = ("BEC_Report_$DisplayName" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render BEC report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1
new file mode 100644
index 0000000000..af1c283c1e
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetPermissionsReportPdf.ps1
@@ -0,0 +1,48 @@
+function Invoke-ExecGetPermissionsReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Sharepoint.Site.Read
+ .DESCRIPTION
+ Server-renders the SharePoint Permissions report as application/pdf bytes. Gathers the same shaped
+ data the Permissions page uses (ListSharePointPermissions, from the CIPP reporting cache), composes
+ it through the shared CIPPSharp component kit (Build-CippPermissionsReportTree) and returns the
+ finished PDF - the server-side replacement for the client react-pdf PermissionsReportButton.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
+ }
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+
+ # The same shaped data the Permissions page reads (from the reporting cache).
+ $Raw = Get-CIPPSharePointPermissionsReport -TenantFilter $TenantFilter
+ $Report = Build-CippPermissionsReportTree -Data @{
+ TenantName = $TenantName
+ summary = $Raw.summary
+ assignments = $Raw.assignments
+ skippedSites = $Raw.skippedSites
+ }
+
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Permissions Report'
+ $FileName = ("Permissions_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render Permissions report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1
new file mode 100644
index 0000000000..892954f9b6
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecGetSharingReportPdf.ps1
@@ -0,0 +1,49 @@
+function Invoke-ExecGetSharingReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Sharepoint.Site.Read
+ .DESCRIPTION
+ Server-renders the SharePoint & OneDrive Sharing report as application/pdf bytes. Gathers the same
+ shaped data the Sharing page uses (ListSharePointSharing, from the CIPP reporting cache), composes
+ it through the shared CIPPSharp component kit (Build-CippSharingReportTree) and returns the finished
+ PDF - the server-side replacement for the client react-pdf SharingReportButton.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
+ }
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+
+ # The same shaped data the Sharing page reads (from the reporting cache; no live Graph enumeration).
+ $Raw = Get-CIPPSharePointSharingReport -TenantFilter $TenantFilter
+ $Report = Build-CippSharingReportTree -Data @{
+ TenantName = $TenantName
+ summary = $Raw.summary
+ links = $Raw.links
+ topRecipients = $Raw.topRecipients
+ topLibraries = $Raw.topLibraries
+ }
+
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Sharing Report'
+ $FileName = ("Sharing_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render Sharing report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointPermissions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointPermissions.ps1
index a6b7f59a4a..938c32b37f 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointPermissions.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointPermissions.ps1
@@ -27,135 +27,7 @@ function Invoke-ListSharePointPermissions {
param($Request, $TriggerMetadata)
$TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
-
- # A readable label for a site that has no display name, taken from the last path segment of
- # its URL: '.../sites/AllCompany' becomes 'AllCompany', '.../search' becomes 'search'.
- function Get-CIPPSiteLabel {
- param([string]$SiteUrl)
- if ([string]::IsNullOrWhiteSpace($SiteUrl)) { return 'Unnamed site' }
- try {
- $Path = ([System.Uri]$SiteUrl).AbsolutePath.Trim('/')
- if ($Path) { return ($Path -split '/')[-1] }
- return 'Root site'
- } catch {
- return 'Unnamed site'
- }
- }
-
- # --- Cached dataset from the CIPP reporting database ---
- try {
- $CacheRows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SharePointPermissions')
- } catch {
- $CacheRows = @()
- }
-
- $PermissionsSynced = $false
- $LastDataRefresh = $null
- try {
- $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointPermissions' -CountsOnly | Select-Object -First 1
- if ($CountRow) { $PermissionsSynced = $true }
- if ($CountRow.Timestamp) { $LastDataRefresh = $CountRow.Timestamp }
- } catch {}
-
- $SiteRows = @($CacheRows | Where-Object { $_.rowType -eq 'Site' })
- $Assignments = @($CacheRows | Where-Object { $_.rowType -eq 'Assignment' })
-
- # --- Scan coverage ---
- $SitesScanned = 0; $LibrariesScanned = 0; $UniquePermissionLibraries = 0
- $SkippedSites = [System.Collections.Generic.List[object]]::new()
- foreach ($Site in $SiteRows) {
- $SitesScanned++
- $LibrariesScanned += [int]($Site.librariesScanned ?? 0)
- $UniquePermissionLibraries += [int]($Site.librariesWithUniquePermissions ?? 0)
- if ($Site.collectionStatus -eq 'Skipped') {
- $SkippedSites.Add([PSCustomObject]@{
- siteName = $Site.siteName
- siteUrl = $Site.siteUrl
- error = $Site.collectionError
- })
- }
- }
-
- # --- Assignment rollups ---
- $BroadClaimGrants = 0; $ExternalGrants = 0; $DirectFullControlGrants = 0
- $ByPermissionLevel = @{}
- $ByPrincipalType = @{}
- $ByBroadClaim = @{}
- $BySiteUnique = @{}
- $RealAssignments = 0
- foreach ($Assignment in $Assignments) {
- # Placeholder rows for a unique-permission library with nothing granted carry no principal.
- if (-not $Assignment.principalId) { continue }
- # SharePoint maintains Limited Access itself; it grants nothing on its own.
- if ($Assignment.isSystemManaged -eq $true) { continue }
- $RealAssignments++
-
- $Level = [string]($Assignment.permissionLevel ?? 'Unknown')
- $ByPermissionLevel[$Level] = [int]($ByPermissionLevel[$Level] ?? 0) + 1
- $Type = [string]($Assignment.principalType ?? 'Other')
- $ByPrincipalType[$Type] = [int]($ByPrincipalType[$Type] ?? 0) + 1
-
- if ($Assignment.broadClaim) {
- $BroadClaimGrants++
- $Claim = [string]$Assignment.broadClaim
- $ByBroadClaim[$Claim] = [int]($ByBroadClaim[$Claim] ?? 0) + 1
- }
- if ($Assignment.isGuest -eq $true) { $ExternalGrants++ }
- # Full Control held by anything other than a SharePoint group means it was granted
- # directly rather than through the site's Owners group, which every site has by default.
- if ($Level -eq 'Full Control' -and $Assignment.principalType -ne 'SharePoint Group') {
- $DirectFullControlGrants++
- }
- }
-
- # Libraries that no longer inherit, counted per site for the chart.
- foreach ($Site in $SiteRows) {
- $Unique = [int]($Site.librariesWithUniquePermissions ?? 0)
- if ($Unique -gt 0) {
- $SiteName = [string]($Site.siteName ?? $Site.siteUrl)
- if ($SiteName) { $BySiteUnique[$SiteName] = [int]($BySiteUnique[$SiteName] ?? 0) + $Unique }
- }
- }
-
- $Body = [PSCustomObject]@{
- summary = [PSCustomObject]@{
- sitesScanned = $SitesScanned
- sitesSkipped = $SkippedSites.Count
- librariesScanned = $LibrariesScanned
- uniquePermissionLibraries = $UniquePermissionLibraries
- totalAssignments = $RealAssignments
- broadClaimGrants = $BroadClaimGrants
- externalGrants = $ExternalGrants
- directFullControlGrants = $DirectFullControlGrants
- permissionsSynced = $PermissionsSynced
- lastDataRefresh = $LastDataRefresh
- }
- byPermissionLevel = @($ByPermissionLevel.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ level = $_.Key; grants = $_.Value } })
- byPrincipalType = @($ByPrincipalType.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ type = $_.Key; grants = $_.Value } })
- byBroadClaim = @($ByBroadClaim.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ claim = $_.Key; grants = $_.Value } })
- topSitesByUniqueLibraries = @($BySiteUnique.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ site = $_.Key; libraries = $_.Value } })
- skippedSites = @($SkippedSites)
- # Display fields are derived here rather than stored, so existing cached data gains them
- # without waiting for a re-scan.
- #
- # appliesTo spells out what scope means for a reader scanning the table. Every Library row
- # is by definition a library that stopped inheriting - libraries that still inherit are not
- # collected, because their permissions are the site's repeated.
- #
- # siteName falls back to a label built from the URL for the handful of system sites that
- # have no name. The URL itself is not used: the tables render any value starting with http
- # as a link, and a column of links where names should be is worse than a plain label.
- assignments = @($Assignments | ForEach-Object {
- $AppliesTo = if ($_.scope -eq 'Library') { 'This library only' } else { 'Whole site' }
- $_ | Add-Member -NotePropertyName 'appliesTo' -NotePropertyValue $AppliesTo -Force
-
- $SiteName = [string]$_.siteName
- if ([string]::IsNullOrWhiteSpace($SiteName) -or $SiteName -like 'http*') {
- $_ | Add-Member -NotePropertyName 'siteName' -NotePropertyValue (Get-CIPPSiteLabel -SiteUrl $_.siteUrl) -Force
- }
- $_
- })
- }
+ $Body = Get-CIPPSharePointPermissionsReport -TenantFilter $TenantFilter
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1
index 7faa7e9172..220821ebb9 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1
@@ -19,137 +19,7 @@ function Invoke-ListSharePointSharing {
param($Request, $TriggerMetadata)
$TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
-
- # Usage report values can arrive as numbers, strings or empty strings depending on the tenant.
- function ConvertTo-SafeDouble {
- param($Value)
- $Parsed = [double]0
- if ($null -ne $Value -and [double]::TryParse("$Value", [ref]$Parsed)) { return $Parsed }
- return [double]0
- }
-
- # --- Cached datasets from the CIPP reporting database ---
- $CacheTypes = @('SharePointSharingLinks', 'SharePointSiteUsage', 'OneDriveUsage')
- $CacheData = @{}
- $CacheSynced = @{}
- $CacheTimestamps = [System.Collections.Generic.List[object]]::new()
- foreach ($Type in $CacheTypes) {
- try {
- $CacheData[$Type] = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type)
- } catch {
- $CacheData[$Type] = @()
- }
- $CacheSynced[$Type] = $false
- try {
- $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1
- if ($CountRow) { $CacheSynced[$Type] = $true }
- if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) }
- } catch {}
- }
- $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1
-
- # --- Environment summaries per workload. Teams-connected sites (rootWebTemplate 'Group')
- # are reported separately from the remaining SharePoint sites; OneDrive is per account. ---
- $SharePointSites = 0; $SharePointFiles = [int64]0; $SharePointStorage = [double]0
- $TeamsSites = 0; $TeamsFiles = [int64]0; $TeamsStorage = [double]0
- foreach ($Site in $CacheData['SharePointSiteUsage']) {
- $Files = [int64](ConvertTo-SafeDouble -Value $Site.fileCount)
- $Storage = ConvertTo-SafeDouble -Value $Site.storageUsedInBytes
- if ($Site.rootWebTemplate -eq 'Group') {
- $TeamsSites++; $TeamsFiles += $Files; $TeamsStorage += $Storage
- } else {
- $SharePointSites++; $SharePointFiles += $Files; $SharePointStorage += $Storage
- }
- }
-
- $OneDriveAccounts = 0; $OneDriveFiles = [int64]0; $OneDriveStorage = [double]0
- foreach ($Account in $CacheData['OneDriveUsage']) {
- $OneDriveAccounts++
- $OneDriveFiles += [int64](ConvertTo-SafeDouble -Value $Account.fileCount)
- $OneDriveStorage += ConvertTo-SafeDouble -Value $Account.storageUsedInBytes
- }
-
- # --- Sharing link rollups ---
- $Links = $CacheData['SharePointSharingLinks']
- $AnonymousLinks = 0; $ExternalLinks = 0; $InternalLinks = 0
- $ByScope = @{}
- $ByLinkType = @{}
- $BySite = @{}
- $ByLibrary = @{}
- $ByRecipient = @{}
- $SharedItemIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
- # Sprawl risk signals, derived from the same cached rows.
- $AnonymousEditLinks = 0; $NeverExpiringAnonymousLinks = 0; $FolderShares = 0; $PasswordProtectedLinks = 0
- foreach ($Link in $Links) {
- switch ($Link.classification) {
- 'Anonymous' { $AnonymousLinks++ }
- 'External' { $ExternalLinks++ }
- default { $InternalLinks++ }
- }
- $Scope = [string]($Link.classification ?? 'Internal')
- $ByScope[$Scope] = [int]($ByScope[$Scope] ?? 0) + 1
- $Type = [string]($Link.linkType ?? 'link')
- $ByLinkType[$Type] = [int]($ByLinkType[$Type] ?? 0) + 1
- $SiteName = [string]($Link.siteName ?? $Link.siteUrl)
- if ($SiteName) { $BySite[$SiteName] = [int]($BySite[$SiteName] ?? 0) + 1 }
- if ($Link.driveName) {
- $LibraryLabel = if ($SiteName) { "$SiteName / $($Link.driveName)" } else { [string]$Link.driveName }
- $ByLibrary[$LibraryLabel] = [int]($ByLibrary[$LibraryLabel] ?? 0) + 1
- }
- if ($Link.driveId -and $Link.itemId) { [void]$SharedItemIds.Add("$($Link.driveId)|$($Link.itemId)") }
-
- # 'write' and 'owner' both mean the recipient can change the content.
- $CanEdit = @($Link.roles) -contains 'write' -or @($Link.roles) -contains 'owner'
- if ($Link.classification -eq 'Anonymous') {
- if ($CanEdit) { $AnonymousEditLinks++ }
- if (-not $Link.expirationDateTime) { $NeverExpiringAnonymousLinks++ }
- }
- # A share on a folder exposes everything below it, so it counts differently to a file share.
- if ($Link.itemType -eq 'Folder' -and $Link.classification -in @('Anonymous', 'External')) { $FolderShares++ }
- if ($Link.hasPassword -eq $true) { $PasswordProtectedLinks++ }
-
- # Who the tenant is sharing with, counted from named external recipients only:
- # anonymous links have no recipient and internal ones are not sprawl.
- if ($Link.classification -eq 'External') {
- foreach ($Recipient in @($Link.sharedWith)) {
- if ([string]::IsNullOrWhiteSpace($Recipient)) { continue }
- $ByRecipient[[string]$Recipient] = [int]($ByRecipient[[string]$Recipient] ?? 0) + 1
- }
- }
- }
-
- $Body = [PSCustomObject]@{
- summary = [PSCustomObject]@{
- sharePointSites = $SharePointSites
- sharePointFiles = $SharePointFiles
- sharePointStorageUsedGB = [math]::Round($SharePointStorage / 1GB, 2)
- teamsSites = $TeamsSites
- teamsFiles = $TeamsFiles
- teamsStorageUsedGB = [math]::Round($TeamsStorage / 1GB, 2)
- oneDriveAccounts = $OneDriveAccounts
- oneDriveFiles = $OneDriveFiles
- oneDriveStorageUsedGB = [math]::Round($OneDriveStorage / 1GB, 2)
- totalLinks = @($Links).Count
- anonymousLinks = $AnonymousLinks
- externalLinks = $ExternalLinks
- internalLinks = $InternalLinks
- itemsShared = $SharedItemIds.Count
- anonymousEditLinks = $AnonymousEditLinks
- neverExpiringAnonymous = $NeverExpiringAnonymousLinks
- folderShares = $FolderShares
- passwordProtectedLinks = $PasswordProtectedLinks
- externalRecipients = $ByRecipient.Count
- linksSynced = $CacheSynced['SharePointSharingLinks']
- usageSynced = ($CacheSynced['SharePointSiteUsage'] -or $CacheSynced['OneDriveUsage'])
- lastDataRefresh = $LastDataRefresh
- }
- byScope = @($ByScope.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ scope = $_.Key; links = $_.Value } })
- byLinkType = @($ByLinkType.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ type = $_.Key; links = $_.Value } })
- topSites = @($BySite.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ site = $_.Key; links = $_.Value } })
- topLibraries = @($ByLibrary.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ library = $_.Key; links = $_.Value } })
- topRecipients = @($ByRecipient.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ recipient = $_.Key; links = $_.Value } })
- links = @($Links)
- }
+ $Body = Get-CIPPSharePointSharingReport -TenantFilter $TenantFilter
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetBaselineWhatIfReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetBaselineWhatIfReportPdf.ps1
new file mode 100644
index 0000000000..1b1ed9bdba
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetBaselineWhatIfReportPdf.ps1
@@ -0,0 +1,55 @@
+function Invoke-ExecGetBaselineWhatIfReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Tenant.Baselines.Read
+ .DESCRIPTION
+ Server-renders the Baseline What-If report as application/pdf bytes: what applying the
+ configured standards would change for a tenant today, what each planned stage will change,
+ optionally what assigning one more baseline would roll out, and the agreed exceptions. Reads
+ the same alignment payload the Baselines page shows (Get-CIPPBaselineAlignment), composes it
+ through the shared CIPPSharp component kit (Build-CippBaselineWhatIfReportTree) and returns
+ the finished PDF. Nothing is changed by producing it.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
+ }
+ # Optional: the GUID of a baseline not assigned to the tenant, to preview what assigning it would roll out stage by stage.
+ $SimulatedTemplateId = [string]($Request.Query.simulatedTemplateId ?? $Request.Body.simulatedTemplateId)
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+
+ $Alignment = Get-CIPPBaselineAlignment -TenantFilter $TenantFilter
+ $Simulated = if ($SimulatedTemplateId) { @(Get-CIPPBaseline) | Where-Object { $_.GUID -eq $SimulatedTemplateId } | Select-Object -First 1 }
+ $Report = Build-CippBaselineWhatIfReportTree -Data @{
+ TenantName = $TenantName
+ TenantFilter = $TenantFilter
+ summary = $Alignment.summary
+ rows = @($Alignment.rows)
+ stageStates = @($Alignment.stageStates)
+ simulatedTemplate = $Simulated
+ catalog = @(Get-CIPPBaselineDefinition)
+ }
+
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Baseline What-If Report'
+ $FileName = ("Baseline_WhatIf_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render the Baseline What-If report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1
new file mode 100644
index 0000000000..e5eb3bfb09
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetExecutiveReportPdf.ps1
@@ -0,0 +1,198 @@
+function Invoke-ExecGetExecutiveReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Tenant.Standards.Read
+ .DESCRIPTION
+ Server-renders the Executive Summary report as application/pdf bytes. Every figure is read from the
+ nightly Reporting DB cache via New-CIPPDbRequest (Users/Guests/Roles for the environment overview,
+ LicenseOverview, ManagedDevices, ConditionalAccessPolicies and SecureScore) plus the standards
+ comparison from the CippStandardsReports table resolved against the standards catalog - the same
+ cached data the rest of CIPP reports from, with no live Graph or cross-endpoint HTTP calls. The
+ shaped data is composed through the shared CIPPSharp component kit (Build-CippExecutiveReportTree),
+ the server-side replacement for the client react-pdf ExecutiveReportButton. Each source is gathered
+ defensively: a source with no cached data simply drops its section.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
+ }
+
+ # The branding preset a caller picked for this render, else the global branding settings.
+ $BrandingPresetId = [string]($Request.Body.brandingPresetId ?? $Request.Query.brandingPresetId)
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter -BrandingPresetId $BrandingPresetId
+
+ # GA directory role template id, used to pull the Global Administrator count from the Roles cache.
+ $GaTemplateId = '62e90394-69f5-4237-9190-012177145e10'
+
+ # -- User stats (Users / Guests cache + the cached privileged-role getter for GAs) --
+ $UserStats = @{ licensedUsers = 0; unlicensedUsers = 0; guests = 0; globalAdmins = 0; permanentGlobalAdmins = 0; eligibleGlobalAdmins = 0; pimCapable = $false }
+ try {
+ $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' -Fields 'assignedLicenses')
+ $LicUsers = @($Users | Where-Object { @($_.assignedLicenses).Count -gt 0 }).Count
+ $GuestCount = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Guests' -Fields 'id').Count
+ # Get-CippDbRoleMembers merges cached PIM (Active/Eligible) + direct role membership for the role.
+ $GaMembers = @(Get-CippDbRoleMembers -TenantFilter $TenantFilter -RoleTemplateId $GaTemplateId)
+ $EligibleGas = @($GaMembers | Where-Object { $_.AssignmentType -eq 'Eligible' }).Count
+ $PimActive = @($GaMembers | Where-Object { $_.AssignmentType -in @('Active', 'Eligible') }).Count
+ $UserStats = @{
+ licensedUsers = $LicUsers
+ unlicensedUsers = [Math]::Max(0, $Users.Count - $LicUsers)
+ guests = $GuestCount
+ globalAdmins = $GaMembers.Count
+ permanentGlobalAdmins = ($GaMembers.Count - $EligibleGas)
+ eligibleGlobalAdmins = $EligibleGas
+ # PIM is in use for GA when any assignment is a PIM Active/Eligible one (not plain Direct).
+ pimCapable = ($PimActive -gt 0)
+ }
+ } catch { Write-Information "Executive report: user stats unavailable - $($_.Exception.Message)" }
+
+ # -- Licences (LicenseOverview cache) --
+ $Licenses = @()
+ try {
+ $Licenses = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'LicenseOverview' | ForEach-Object {
+ @{ name = ($_.License ?? 'N/A'); used = "$($_.CountUsed ?? 0)"; available = "$($_.CountAvailable ?? 0)"; total = "$($_.TotalLicenses ?? 0)" }
+ })
+ } catch { Write-Information "Executive report: licences unavailable - $($_.Exception.Message)" }
+
+ # -- Managed devices (ManagedDevices cache) --
+ $Devices = @()
+ try {
+ $Devices = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ManagedDevices' | ForEach-Object {
+ $d = $_
+ $model = [string]$d.model
+ $mfr = [string]$d.manufacturer
+ $isCloudPc = ($d.isCloudPC -eq $true) -or ($d.deviceType -eq 'cloudPC') -or ($d.chassisType -eq 'cloudPC') -or ($model.ToLower().StartsWith('cloud pc') -and $mfr.ToLower() -eq 'microsoft corporation')
+ $compState = [string]$d.complianceState
+ @{
+ name = ($d.deviceName ?? 'N/A')
+ os = ($d.operatingSystem ?? 'N/A')
+ compliance = $(if ([string]::IsNullOrWhiteSpace($compState)) { 'Unknown' } else { $compState })
+ compliant = ($compState.ToLower() -eq 'compliant')
+ lastSync = $(if ($d.lastSyncDateTime) { try { ([datetime]$d.lastSyncDateTime).ToString('MMM d, yyyy') } catch { [string]$d.lastSyncDateTime } } else { 'N/A' })
+ encrypted = (($d.isEncrypted -eq $true) -or $isCloudPc)
+ }
+ })
+ } catch { Write-Information "Executive report: managed devices unavailable - $($_.Exception.Message)" }
+
+ # -- Conditional Access policies (ConditionalAccessPolicies cache, raw Graph shape) --
+ # 'controls' is the builtInControls array (drives the tree's MFA count via -contains 'mfa');
+ # 'controlsText' is the human label; 'applications' summarises includeApplications.
+ $CAPolicies = @()
+ try {
+ $ControlLabels = [ordered]@{ mfa = 'MFA'; block = 'Block'; compliantDevice = 'Compliant Device' }
+ $CAPolicies = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ConditionalAccessPolicies' | Where-Object { $_.displayName } | ForEach-Object {
+ $p = $_
+ $bic = @($p.grantControls.builtInControls)
+ $apps = @($p.conditions.applications.includeApplications)
+ $labels = @(foreach ($k in $ControlLabels.Keys) { if ($bic -contains $k) { $ControlLabels[$k] } })
+ @{
+ name = ($p.displayName ?? 'N/A')
+ state = ([string]$p.state)
+ controls = $bic
+ controlsText = $(if ($labels.Count -gt 0) { $labels -join ', ' } else { 'Custom' })
+ applications = $(if ($apps -contains 'All') { 'All' } elseif ($apps.Count -gt 0) { "$($apps.Count) app$(if ($apps.Count -ne 1) { 's' })" } else { 'None' })
+ }
+ })
+ } catch { Write-Information "Executive report: conditional access unavailable - $($_.Exception.Message)" }
+
+ # -- Microsoft Secure Score (SecureScore cache, one record per day) --
+ $SecureScore = $null
+ try {
+ $Scores = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SecureScore' | Sort-Object -Property @{ Expression = { [datetime]$_.createdDateTime } } -Descending)
+ if ($Scores.Count -gt 0) {
+ $Latest = $Scores[0]
+ $Cur = [double]($Latest.currentScore ?? 0)
+ $Max = [double]($Latest.maxScore ?? 0)
+ $Acs = @($Latest.averageComparativeScores)
+ $VsAll = ($Acs | Where-Object { $_.basis -eq 'AllTenants' } | Select-Object -First 1).averageScore
+ $VsSim = ($Acs | Where-Object { $_.basis -eq 'TotalSeats' } | Select-Object -First 1).averageScore
+ # Trend: the 7 most recent points, drawn oldest-first.
+ $Trend = @($Scores | Select-Object -First 7 | Sort-Object -Property @{ Expression = { [datetime]$_.createdDateTime } } | ForEach-Object {
+ @{ label = ([datetime]$_.createdDateTime).ToString('MMM d'); value = [math]::Round([double]($_.currentScore ?? 0), 2) }
+ })
+ $SecureScore = @{
+ currentScore = [math]::Round($Cur, 2)
+ maxScore = $Max
+ percentageCurrent = $(if ($Max -gt 0) { [math]::Round(($Cur / $Max) * 100) } else { 0 })
+ percentageVsSimilar = $(if ($null -ne $VsSim) { [math]::Round([double]$VsSim) } else { $null })
+ percentageVsAllTenants = $(if ($null -ne $VsAll) { [math]::Round([double]$VsAll) } else { $null })
+ trend = $Trend
+ }
+ }
+ } catch { Write-Information "Executive report: secure score unavailable - $($_.Exception.Message)" }
+
+ # -- Security standards (CippStandardsReports table + the standards catalog) --
+ $SecurityControls = @()
+ try {
+ $StdTable = Get-CIPPTable -TableName 'CippStandardsReports'
+ $StdRows = @(Get-CIPPAzDataTableEntity @StdTable -Filter "PartitionKey eq '$TenantFilter'")
+ if ($StdRows.Count -gt 0) {
+ $TenantStd = [ordered]@{ tenantFilter = $TenantFilter }
+ foreach ($Row in $StdRows) {
+ $FieldName = [string]$Row.RowKey
+ # Quarantine template rows hex-encode the display name in the key; decode it as the page does.
+ if ($FieldName -match '^(standards\.QuarantineTemplate\.)([0-9a-fA-F]+)$') {
+ $Prefix = $Matches[1]; $Hex = $Matches[2]
+ $Chars = for ($i = 0; $i -lt $Hex.Length; $i += 2) { [char][Convert]::ToInt32($Hex.Substring($i, 2), 16) }
+ $FieldName = "$Prefix$(-join $Chars)"
+ }
+ $Cv = if (-not [string]::IsNullOrWhiteSpace([string]$Row.CurrentValue) -and (Test-Json -Json ([string]$Row.CurrentValue) -ErrorAction SilentlyContinue)) { $Row.CurrentValue | ConvertFrom-Json -ErrorAction SilentlyContinue } else { $Row.CurrentValue }
+ $Ev = if (-not [string]::IsNullOrWhiteSpace([string]$Row.ExpectedValue) -and (Test-Json -Json ([string]$Row.ExpectedValue) -ErrorAction SilentlyContinue)) { $Row.ExpectedValue | ConvertFrom-Json -ErrorAction SilentlyContinue } else { $Row.ExpectedValue }
+ $Val = $Row.Value
+ if ($Val -is [string] -and -not [string]::IsNullOrWhiteSpace($Val) -and (Test-Json -Json $Val -ErrorAction SilentlyContinue)) { $Val = $Val | ConvertFrom-Json -ErrorAction SilentlyContinue }
+ $TenantStd[$FieldName] = @{ Value = $Val; CurrentValue = $Cv; ExpectedValue = $Ev }
+ }
+ $Catalog = @()
+ try {
+ $CatPath = Join-Path $env:CIPPRootPath 'Config\standards.json'
+ if (Test-Path $CatPath) { $Catalog = @(Get-Content $CatPath -Raw | ConvertFrom-Json -Depth 20) }
+ } catch { $Catalog = @() }
+ $SecurityControls = @(Convert-CippExecStandardsToControls -Compare @([pscustomobject]$TenantStd) -Catalog $Catalog)
+ }
+ } catch { Write-Information "Executive report: standards unavailable - $($_.Exception.Message)" }
+
+ $Data = @{
+ TenantName = $TenantName
+ UserStats = $UserStats
+ SecureScore = $SecureScore
+ Licenses = $Licenses
+ Devices = $Devices
+ CAPolicies = $CAPolicies
+ SecurityControls = $SecurityControls
+ }
+
+ # Optional per-section toggles from the client's section panel (POST body). Absent -> full report.
+ $SectionConfig = @{}
+ $RawCfg = $Request.Body.sectionConfig
+ if ($RawCfg -is [hashtable]) { $SectionConfig = $RawCfg }
+ elseif ($RawCfg) { foreach ($p in $RawCfg.PSObject.Properties) { $SectionConfig[$p.Name] = [bool]$p.Value } }
+
+ $Report = Build-CippExecutiveReportTree -Data $Data -SectionConfig $SectionConfig
+
+ # Branding: a named preset if the client selected one (the report's Branding dropdown), else
+ # the tenant/global default.
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter `
+ -ReportName 'Executive Summary' -BrandingPresetId $BrandingPresetId
+
+ $FileName = ("Executive_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render Executive report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetShadowAIReportPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetShadowAIReportPdf.ps1
new file mode 100644
index 0000000000..a88ca43c51
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecGetShadowAIReportPdf.ps1
@@ -0,0 +1,56 @@
+function Invoke-ExecGetShadowAIReportPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ Tenant.Standards.Read
+ .DESCRIPTION
+ Server-renders the Shadow AI report as application/pdf bytes. Gathers the same shaped data the
+ Shadow AI page uses (ListShadowAI) and composes it through the shared CIPPSharp component kit
+ (Build-CippShadowAIReportTree) - the server-side replacement for the client react-pdf
+ ShadowAIReportButton.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
+ if ([string]::IsNullOrWhiteSpace($TenantFilter)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A tenantFilter is required' })
+ }
+ $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter
+
+ # Optional per-section toggles from the client's section panel (POST body). Absent -> full report.
+ $SectionConfig = @{}
+ $RawCfg = $Request.Body.sectionConfig
+ if ($RawCfg -is [hashtable]) { $SectionConfig = $RawCfg }
+ elseif ($RawCfg) { foreach ($p in $RawCfg.PSObject.Properties) { $SectionConfig[$p.Name] = [bool]$p.Value } }
+
+ # The same shaped data the Shadow AI page reads (from the reporting cache).
+ $Raw = Get-CIPPShadowAIReport -TenantFilter $TenantFilter
+ $Report = Build-CippShadowAIReportTree -SectionConfig $SectionConfig -Data @{
+ TenantName = $TenantName
+ summary = $Raw.summary
+ detectedApps = $Raw.detectedApps
+ consentedApps = $Raw.consentedApps
+ topTools = $Raw.topTools
+ byRisk = $Raw.byRisk
+ }
+
+ $Bytes = ConvertTo-CippReportPdf -Blocks $Report.Blocks -Variables $Report.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'Shadow AI Report'
+ $FileName = ("Shadow_AI_Report_$TenantFilter" -replace '[^a-zA-Z0-9_\-]', '_') + '.pdf'
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$FileName`"" }
+ Body = $Bytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to render Shadow AI report: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1
index ff806f1c5a..405ea85f30 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1
@@ -15,247 +15,7 @@ function Invoke-ListShadowAI {
param($Request, $TriggerMetadata)
$TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter
-
- # Curated, PR-editable catalog of known AI tools/apps.
- try {
- $Catalog = @(Get-Content (Join-Path $env:CIPPRootPath 'Config\ShadowAI.json') -ErrorAction Stop | ConvertFrom-Json)
- } catch {
- Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load Shadow AI catalog. Error: $($_.Exception.Message)" -Sev 'Error'
- $Catalog = @()
- }
-
- # Returns the first catalog entry whose matchNames appear (case-insensitive substring) in $Text.
- function Get-AiMatch {
- param($Text, $Catalog)
- if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
- $Haystack = $Text.ToLower()
- foreach ($Entry in $Catalog) {
- foreach ($Match in $Entry.matchNames) {
- if ($Match -and $Haystack.Contains($Match.ToLower())) { return $Entry }
- }
- }
- return $null
- }
-
- $SanctionedTools = @{}
- try {
- $SanctionTable = Get-CIPPTable -TableName 'ShadowAIConfig'
- $EscapedTenant = $TenantFilter -replace "'", "''"
- foreach ($Row in @(Get-CIPPAzDataTableEntity @SanctionTable -Filter "PartitionKey eq '$EscapedTenant'")) {
- $ToolName = if ($Row.Tool) { $Row.Tool } else { $Row.RowKey }
- if ($ToolName) { $SanctionedTools[$ToolName.ToLower()] = $true }
- }
- } catch {
- Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load sanctioned AI tools: $($_.Exception.Message)" -Sev 'Warning'
- }
-
- # --- Cached datasets from the CIPP reporting database (no live Graph enumeration) ---
- $CacheTypes = @('DetectedApps', 'ServicePrincipals', 'OAuth2PermissionGrants')
- $CacheData = @{}
- $CacheTimestamps = [System.Collections.Generic.List[object]]::new()
- foreach ($Type in $CacheTypes) {
- try {
- $CacheData[$Type] = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type)
- } catch {
- $CacheData[$Type] = @()
- }
- try {
- $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1
- if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) }
- } catch {}
- }
- $IntuneSynced = $CacheData['DetectedApps'].Count -gt 0
- $EntraSynced = $CacheData['ServicePrincipals'].Count -gt 0
- $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1
-
- # 1) Installed AI tools from the cached Intune detected apps. The inventory reports a separate
- # application entry per version (and per install flavor, e.g. 'Copilot' vs 'Microsoft.Copilot'),
- # so merge everything that matches the same catalog tool into ONE row: distinct devices only,
- # with the observed application names, versions and platforms combined.
- $DetectedAppMap = [ordered]@{}
- foreach ($App in $CacheData['DetectedApps']) {
- $Match = Get-AiMatch -Text "$($App.displayName) $($App.publisher)" -Catalog $Catalog
- if (-not $Match) { continue }
- if (-not $DetectedAppMap.Contains($Match.name)) {
- $DetectedAppMap[$Match.name] = [PSCustomObject]@{
- Match = $Match
- Sanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower())
- Applications = [System.Collections.Generic.List[string]]::new()
- Publishers = [System.Collections.Generic.List[string]]::new()
- Versions = [System.Collections.Generic.List[string]]::new()
- Platforms = [System.Collections.Generic.List[string]]::new()
- Devices = [ordered]@{}
- }
- }
- $Entry = $DetectedAppMap[$Match.name]
- if ($App.displayName -and $Entry.Applications -notcontains [string]$App.displayName) { $Entry.Applications.Add([string]$App.displayName) }
- if ($App.publisher -and $Entry.Publishers -notcontains [string]$App.publisher) { $Entry.Publishers.Add([string]$App.publisher) }
- if ($App.version -and $Entry.Versions -notcontains [string]$App.version) { $Entry.Versions.Add([string]$App.version) }
- $Platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { [string]$App.platform }
- if ($Entry.Platforms -notcontains $Platform) { $Entry.Platforms.Add($Platform) }
- foreach ($Device in @($App.managedDevices ?? @())) {
- $DeviceKey = if ($Device.id) { [string]$Device.id } else { [string]$Device.deviceName }
- if ($DeviceKey -and -not $Entry.Devices.Contains($DeviceKey)) { $Entry.Devices[$DeviceKey] = $Device }
- }
- }
-
- $DetectedApps = [System.Collections.Generic.List[object]]::new()
- foreach ($Entry in $DetectedAppMap.Values) {
- $Match = $Entry.Match
- # Inventory rows mix clean publisher names with full certificate subjects - show the shortest
- $Publisher = $Entry.Publishers | Sort-Object -Property Length | Select-Object -First 1
- $DetectedApps.Add([PSCustomObject]@{
- application = ($Entry.Applications | Sort-Object) -join ', '
- aiTool = $Match.name
- vendor = $Match.vendor
- category = $Match.category
- risk = if ($Entry.Sanctioned) { 'Informational' } else { $Match.risk }
- catalogRisk = $Match.risk
- status = if ($Entry.Sanctioned) { 'Sanctioned' } else { 'Unsanctioned' }
- toolDescription = $Match.description
- riskReason = $Match.riskReason
- publisher = $Publisher
- version = ($Entry.Versions | Sort-Object) -join ', '
- platform = ($Entry.Platforms | Sort-Object) -join ', '
- deviceCount = $Entry.Devices.Count
- managedDevices = @($Entry.Devices.Values)
- })
- }
-
- # 2) AI applications in Entra: match ALL cached service principals (not only those with
- # delegated grants), then attach any granted permissions. First consented = when the
- # service principal was created in the tenant (the oauth2 grant startTime is unreliable).
- $GrantsBySp = @{}
- foreach ($Grant in $CacheData['OAuth2PermissionGrants']) {
- if (-not $Grant.clientId) { continue }
- if (-not $GrantsBySp.ContainsKey($Grant.clientId)) {
- $GrantsBySp[$Grant.clientId] = [System.Collections.Generic.List[object]]::new()
- }
- $GrantsBySp[$Grant.clientId].Add($Grant)
- }
-
- $ConsentedApps = [System.Collections.Generic.List[object]]::new()
- $SeenApps = @{}
- foreach ($Sp in $CacheData['ServicePrincipals']) {
- $Match = Get-AiMatch -Text $Sp.displayName -Catalog $Catalog
- if (-not $Match) { continue }
- $Key = [string]($Sp.appId ?? $Sp.id)
- if ($SeenApps.ContainsKey($Key)) { continue }
- # Individual scopes as a string array so the frontend renders them as chips.
- $Permissions = if ($GrantsBySp.ContainsKey($Sp.id)) {
- @((@($GrantsBySp[$Sp.id].scope) -join ' ') -split '\s+' | Where-Object { $_ } | Sort-Object -Unique)
- } else {
- @()
- }
- $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower())
- $Consent = [PSCustomObject]@{
- application = $Sp.displayName
- aiTool = $Match.name
- vendor = $Match.vendor
- category = $Match.category
- risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk }
- catalogRisk = $Match.risk
- status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' }
- toolDescription = $Match.description
- riskReason = $Match.riskReason
- applicationId = $Sp.appId
- approvedPermissions = @($Permissions)
- firstConsentedDateTime = $Sp.createdDateTime
- signInsLast7Days = 0
- activeUsersLast7Days = 0
- applicationUsers = @()
- }
- $SeenApps[$Key] = $Consent
- $ConsentedApps.Add($Consent)
- }
-
- # 2b) Best-effort: recent sign-in usage (last 7 days) for the matched AI apps. This is the only
- # live Graph call: a single bounded query, skipped gracefully when unavailable (needs P1).
- $AiAppIds = @($ConsentedApps.applicationId | Where-Object { $_ } | Select-Object -Unique -First 15)
- if ($AiAppIds.Count -gt 0) {
- try {
- $StartDate = (Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
- $AppFilter = ($AiAppIds | ForEach-Object { "appId eq '$_'" }) -join ' or '
- $SignInFilter = "createdDateTime ge $StartDate and ($AppFilter)"
- $SignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=$SignInFilter" -tenantid $TenantFilter
- $SignInGroups = $SignIns | Group-Object appId
- foreach ($Consent in $ConsentedApps) {
- $Group = $SignInGroups | Where-Object { $_.Name -eq $Consent.applicationId }
- if ($Group) {
- $Consent.signInsLast7Days = $Group.Count
- $Consent.activeUsersLast7Days = @($Group.Group.userId | Select-Object -Unique).Count
- $Consent.applicationUsers = @($Group.Group | Group-Object userPrincipalName | ForEach-Object {
- [PSCustomObject]@{
- userPrincipalName = $_.Name
- userDisplayName = ($_.Group | Select-Object -First 1).userDisplayName
- signIns = $_.Count
- lastSignInDateTime = ($_.Group.createdDateTime | Sort-Object -Descending | Select-Object -First 1)
- }
- })
- }
- }
- } catch {
- Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Sign-in usage enrichment skipped (requires Entra ID P1). Error: $($_.Exception.Message)" -Sev 'Info'
- }
- }
-
- # --- Roll up distinct AI tools across BOTH sources for the summary and charts ---
- $ToolMap = @{}
- foreach ($App in $DetectedApps) {
- if (-not $ToolMap.ContainsKey($App.aiTool)) {
- $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Status = $App.status; Devices = 0; Users = 0 }
- }
- $ToolMap[$App.aiTool].Devices += $App.deviceCount
- }
- foreach ($App in $ConsentedApps) {
- if (-not $ToolMap.ContainsKey($App.aiTool)) {
- $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Status = $App.status; Devices = 0; Users = 0 }
- }
- $ToolMap[$App.aiTool].Users += [int]$App.activeUsersLast7Days
- }
-
- $ByCategory = foreach ($Group in ($ToolMap.Values | Group-Object Category)) {
- [PSCustomObject]@{
- category = $Group.Name
- tools = $Group.Count
- devices = [int](($Group.Group | Measure-Object -Property Devices -Sum).Sum)
- }
- }
- $ByRisk = foreach ($Group in ($ToolMap.Values | Group-Object Risk)) {
- [PSCustomObject]@{
- risk = $Group.Name
- tools = $Group.Count
- }
- }
- # Top tools across BOTH sources: device installs (Intune) + active users (Entra, last 7 days).
- $TopTools = $ToolMap.Values | Sort-Object -Property { $_.Devices + $_.Users } -Descending | Select-Object -First 8 | ForEach-Object {
- [PSCustomObject]@{
- tool = $_.Tool
- devices = $_.Devices
- users = $_.Users
- footprint = $_.Devices + $_.Users
- category = $_.Category
- status = $_.Status
- }
- }
-
- $Body = [PSCustomObject]@{
- summary = [PSCustomObject]@{
- aiToolsDetected = $ToolMap.Count
- deviceInstalls = [int](($DetectedApps | Measure-Object -Property deviceCount -Sum).Sum)
- consentedAiApps = $ConsentedApps.Count
- highRiskTools = @($ToolMap.Values | Where-Object { $_.Risk -eq 'High' }).Count
- sanctionedTools = @($ToolMap.Values | Where-Object { $_.Status -eq 'Sanctioned' }).Count
- intuneSynced = $IntuneSynced
- entraSynced = $EntraSynced
- lastDataRefresh = $LastDataRefresh
- }
- byCategory = @($ByCategory)
- byRisk = @($ByRisk)
- topTools = @($TopTools)
- detectedApps = @($DetectedApps)
- consentedApps = @($ConsentedApps)
- }
+ $Body = Get-CIPPShadowAIReport -TenantFilter $TenantFilter
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1
index 50a73242f5..890b2ceb8f 100644
--- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGenerateReportBuilderReport.ps1
@@ -23,6 +23,11 @@ function Invoke-ExecGenerateReportBuilderReport {
$ExistingEntity = Get-CIPPAzDataTableEntity @ReportTable -Filter "RowKey eq '$($Body.ReportGUID)'"
if ($ExistingEntity) {
Remove-CIPPAzDataTableEntity @ReportTable -Entity $ExistingEntity
+ # The rendered PDF sits in its own table; a large one is split across part rows, so fetch
+ # the raw head + part rows (keys only, no split markers) and hand them all to the remover.
+ $PdfTable = Get-CippTable -tablename 'ReportBuilderPdfs'
+ $PdfRows = @(Get-CIPPAzDataTableEntity @PdfTable -Filter "RowKey eq '$($Body.ReportGUID)' or OriginalEntityId eq '$($Body.ReportGUID)'" -Property PartitionKey, RowKey)
+ if ($PdfRows.Count -gt 0) { Remove-CIPPAzDataTableEntity @PdfTable -Entity $PdfRows }
Write-LogMessage -headers $Headers -API $APIName -message "Deleted generated report '$($Body.ReportGUID)'" -Sev 'Info'
$Result = @{ Results = 'Successfully deleted generated report' }
} else {
@@ -53,8 +58,12 @@ function Invoke-ExecGenerateReportBuilderReport {
$GenerateResult = Push-ExecGenerateReportBuilderReport @GenerateParams
Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Generated report builder report '$TemplateName'" -Sev 'Info'
+ # Push-* returns either the plain result string, or an envelope carrying base64 email
+ # attachments for the scheduled path. The interactive HTTP response only needs the message -
+ # the finished PDF is fetched from ExecGetReportBuilderPdf, not echoed here as base64.
+ $ResultText = if ($GenerateResult -is [System.Collections.IDictionary] -and $GenerateResult['Results']) { $GenerateResult['Results'] } else { $GenerateResult }
$Result = @{
- Results = $GenerateResult
+ Results = $ResultText
}
$StatusCode = [HttpStatusCode]::OK
@@ -68,6 +77,6 @@ function Invoke-ExecGenerateReportBuilderReport {
return ([HttpResponseContext]@{
StatusCode = $StatusCode
- Body = ConvertTo-Json -InputObject $Result -Depth 20
+ Body = $Result
})
}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGetReportBuilderPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGetReportBuilderPdf.ps1
new file mode 100644
index 0000000000..c3a7b1502b
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecGetReportBuilderPdf.ps1
@@ -0,0 +1,46 @@
+function Invoke-ExecGetReportBuilderPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint,AnyTenant
+ .ROLE
+ CIPP.Core.Read
+ .DESCRIPTION
+ Returns the server-rendered PDF for a generated Report Builder report as application/pdf bytes.
+ Backs both the in-app preview (shown in an iframe) and the download button on the view page.
+ 404 when the report has no rendered PDF (generated before server-side rendering, or its render
+ failed).
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug'
+
+ try {
+ # The generated report's GUID.
+ $ReportGUID = $Request.Query.id ?? $Request.Query.ReportGUID
+ if ([string]::IsNullOrEmpty($ReportGUID)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest; Body = 'A report id is required' })
+ }
+ $ReportGUID = ConvertTo-CIPPODataFilterValue -Value $ReportGUID -Type 'Guid'
+
+ # The PDF lives in its own table (keyed by the report GUID) so listing reports never pulls the
+ # base64. No -Property projection: the merge-aware read reassembles a PDF split across part rows.
+ $Table = Get-CippTable -tablename 'ReportBuilderPdfs'
+ $Row = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$ReportGUID'" | Select-Object -First 1
+ if ([string]::IsNullOrEmpty($Row.Pdf)) {
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::NotFound; Body = 'This report has no rendered PDF. Regenerate it to produce one.' })
+ }
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Headers = @{ 'Content-Disposition' = "inline; filename=`"$($Row.FileName ?? "Report_$ReportGUID.pdf")`"" }
+ Body = [Convert]::FromBase64String($Row.Pdf)
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -Headers $Request.Headers -API $APIName -message "Failed to fetch report PDF: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError; Body = "Error: $($ErrorMessage.NormalizedError)" })
+ }
+}
diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecPreviewReportBuilderPdf.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecPreviewReportBuilderPdf.ps1
new file mode 100644
index 0000000000..7cabd8e1e9
--- /dev/null
+++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Report-Builder/Invoke-ExecPreviewReportBuilderPdf.ps1
@@ -0,0 +1,57 @@
+function Invoke-ExecPreviewReportBuilderPdf {
+ <#
+ .FUNCTIONALITY
+ Entrypoint
+ .ROLE
+ CIPP.Core.ReadWrite
+ .DESCRIPTION
+ Renders the current (unsaved) Report Builder state to a PDF and returns it as application/pdf
+ bytes without persisting a generated-report row. Powers the builder's live preview and download.
+ #>
+ [CmdletBinding()]
+ param($Request, $TriggerMetadata)
+
+ $APIName = $TriggerMetadata.FunctionName
+ $Headers = $Request.Headers
+
+ try {
+ $Body = $Request.Body
+ $TenantFilter = $Body.TenantFilter ?? $Request.Query.TenantFilter
+ if ([string]::IsNullOrEmpty($TenantFilter)) { throw 'TenantFilter is required' }
+
+ $GenerateParams = @{
+ TenantFilter = $TenantFilter
+ TemplateName = $Body.TemplateName ?? 'Report'
+ PreviewOnly = $true
+ }
+ if ($Body.Blocks) {
+ $GenerateParams.Blocks = if ($Body.Blocks -is [string]) { $Body.Blocks } else { ConvertTo-Json -InputObject @($Body.Blocks) -Depth 20 -Compress }
+ }
+ if ($Body.TemplateGUID) { $GenerateParams.TemplateGUID = $Body.TemplateGUID }
+ if ($Body.Settings) {
+ $GenerateParams.Settings = if ($Body.Settings -is [string]) { $Body.Settings } else { ConvertTo-Json -InputObject $Body.Settings -Depth 10 -Compress }
+ }
+
+ $Preview = Push-ExecGenerateReportBuilderReport @GenerateParams
+
+ if (-not $Preview.PdfBytes) {
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::InternalServerError
+ Body = 'Failed to render the report preview.'
+ })
+ }
+
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::OK
+ ContentType = 'application/pdf'
+ Body = $Preview.PdfBytes
+ })
+ } catch {
+ $ErrorMessage = Get-CippException -Exception $_
+ Write-LogMessage -headers $Headers -API $APIName -message "Report preview error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
+ return ([HttpResponseContext]@{
+ StatusCode = [HttpStatusCode]::InternalServerError
+ Body = "Error: $($ErrorMessage.NormalizedError)"
+ })
+ }
+}
diff --git a/backend/Shared/CIPPSharp/CIPPSharp.csproj b/backend/Shared/CIPPSharp/CIPPSharp.csproj
index 6c7a3f192b..d2b375fcb0 100644
--- a/backend/Shared/CIPPSharp/CIPPSharp.csproj
+++ b/backend/Shared/CIPPSharp/CIPPSharp.csproj
@@ -13,8 +13,17 @@
falsebin\false
+
+ true
-
+
+
+
+
diff --git a/backend/Shared/CIPPSharp/Reporting/FontCmap.cs b/backend/Shared/CIPPSharp/Reporting/FontCmap.cs
new file mode 100644
index 0000000000..b322e49711
--- /dev/null
+++ b/backend/Shared/CIPPSharp/Reporting/FontCmap.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+using System.Text;
+
+namespace CIPP.Reporting
+{
+ ///
+ /// A minimal read-only TrueType/OpenType cmap reader: enough to enumerate the Unicode code
+ /// points a font can draw, so the bundled emoji fallback is the single source of truth for both what
+ /// keeps and which ranges OfficeIMO routes to it. Reads the
+ /// Windows Unicode BMP subtable (format 4) and the full-repertoire subtable (format 12, which carries
+ /// the astral emoji); all multi-byte fields are big-endian per the sfnt spec.
+ ///
+ internal static class FontCmap
+ {
+ public static IEnumerable ReadCodepoints(byte[] f)
+ {
+ var result = new List();
+ if (f.Length < 12) return result;
+
+ var numTables = U16(f, 4);
+ var cmapOffset = -1;
+ for (var i = 0; i < numTables; i++)
+ {
+ var rec = 12 + i * 16;
+ if (rec + 16 > f.Length) break;
+ if (Encoding.ASCII.GetString(f, rec, 4) == "cmap") { cmapOffset = (int)U32(f, rec + 8); break; }
+ }
+ if (cmapOffset < 0 || cmapOffset + 4 > f.Length) return result;
+
+ var subCount = U16(f, cmapOffset + 2);
+ var best4 = -1;
+ var best12 = -1;
+ for (var i = 0; i < subCount; i++)
+ {
+ var p = cmapOffset + 4 + i * 8;
+ if (p + 8 > f.Length) break;
+ var platform = U16(f, p);
+ var encoding = U16(f, p + 2);
+ var subOffset = cmapOffset + (int)U32(f, p + 4);
+ if (subOffset + 2 > f.Length) continue;
+ var format = U16(f, subOffset);
+ if (format == 12 && (platform == 3 && encoding == 10 || platform == 0)) best12 = subOffset;
+ else if (format == 4 && best4 < 0 && (platform == 3 && encoding == 1 || platform == 0)) best4 = subOffset;
+ }
+
+ if (best12 >= 0) ReadFormat12(f, best12, result);
+ if (best4 >= 0) ReadFormat4(f, best4, result);
+ return result;
+ }
+
+ private static void ReadFormat4(byte[] f, int o, List outp)
+ {
+ var segX2 = U16(f, o + 6);
+ var segCount = segX2 / 2;
+ var endO = o + 14;
+ var startO = endO + segX2 + 2;
+ var deltaO = startO + segX2;
+ var rangeO = deltaO + segX2;
+ for (var s = 0; s < segCount; s++)
+ {
+ var end = U16(f, endO + s * 2);
+ var start = U16(f, startO + s * 2);
+ if (start == 0xFFFF) continue;
+ var delta = U16(f, deltaO + s * 2);
+ var ro = U16(f, rangeO + s * 2);
+ for (var c = start; c <= end; c++)
+ {
+ int g;
+ if (ro == 0) g = (c + delta) & 0xFFFF;
+ else
+ {
+ var gi = rangeO + s * 2 + ro + (c - start) * 2;
+ if (gi + 2 > f.Length) continue;
+ g = U16(f, gi);
+ if (g != 0) g = (g + delta) & 0xFFFF;
+ }
+ if (g != 0) outp.Add(c);
+ }
+ }
+ }
+
+ private static void ReadFormat12(byte[] f, int o, List outp)
+ {
+ var nGroups = U32(f, o + 12);
+ for (long i = 0; i < nGroups; i++)
+ {
+ var p = o + 16 + (int)i * 12;
+ if (p + 12 > f.Length) break;
+ var startChar = U32(f, p);
+ var endChar = U32(f, p + 4);
+ for (var c = startChar; c <= endChar && c <= 0x10FFFF; c++) outp.Add((int)c);
+ }
+ }
+
+ private static int U16(byte[] f, int o) => (f[o] << 8) | f[o + 1];
+ private static long U32(byte[] f, int o) => ((long)f[o] << 24) | ((long)f[o + 1] << 16) | ((long)f[o + 2] << 8) | f[o + 3];
+ }
+}
diff --git a/backend/Shared/CIPPSharp/Reporting/ReportComponents.cs b/backend/Shared/CIPPSharp/Reporting/ReportComponents.cs
new file mode 100644
index 0000000000..e422cc67d5
--- /dev/null
+++ b/backend/Shared/CIPPSharp/Reporting/ReportComponents.cs
@@ -0,0 +1,1710 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using OfficeIMO.Drawing;
+using OfficeIMO.Pdf;
+
+namespace CIPP.Reporting
+{
+ ///
+ /// The reusable component kit - the server port of reportPdfPrimitives.jsx. Every component takes the
+ /// (theme/styles/variables) and the current OfficeIMO ,
+ /// and encapsulates all OfficeIMO calls. Reports compose these; no report inlines a raw OfficeIMO call.
+ ///
+ public static class ReportComponents
+ {
+ private const double CodeParagraphSize = ReportStyles.CodeBlock;
+
+ // Make any raw string safe for the PDF standard fonts (strips/maps emoji etc.).
+ private static string San(string? s) => ReportMarkdown.Sanitize(s);
+
+ // -- colour bridge --
+ public static PdfColor Pdf(string hex)
+ {
+ var (r, g, b) = ColourMath.ToRgb(hex);
+ return PdfColor.FromRgb((byte)r, (byte)g, (byte)b);
+ }
+
+ // -- images --
+ /// Decode a data-URL/base64 image (or resolve a bundled /reportImages/ path) into bytes OfficeIMO can draw anywhere, or null.
+ public static byte[]? DecodeImage(string? dataUrl)
+ {
+ if (string.IsNullOrWhiteSpace(dataUrl)) return null;
+ var s = dataUrl.Trim();
+
+ // A bundled stock image referenced by the frontend path it has always used, e.g.
+ // "/reportImages/board.jpg". The browser used to fetch these from public/reportImages; the
+ // server ships the same files beside the assembly under reportImages/, so resolve the path to
+ // those bytes. Only the file name is honoured (no traversal), and only files that ship return.
+ var stock = ReportImagesPathPattern.Match(s);
+ if (stock.Success) return ReportImage(stock.Groups[1].Value);
+
+ var comma = s.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
+ if (comma >= 0) s = s.Substring(comma + "base64,".Length);
+ byte[] bytes;
+ try { bytes = Convert.FromBase64String(s.Trim()); } catch { return null; }
+ return NormaliseImage(bytes);
+ }
+
+ // Every raster OfficeIMO decodes (PNG, JPEG, GIF, BMP, TIFF, WebP) is handed to it as-is. An SVG
+ // is only accepted by the drawing API, not by flow images or page backgrounds, so it is rasterised
+ // once here through OfficeIMO's own SVG reader - transparent background, ~1200px on the long side,
+ // crisp at logo and cover sizes and a few KB for a typical logo - and then behaves like a PNG.
+ // Anything OfficeIMO cannot identify is dropped here rather than left to fail at serialisation.
+ // Add-CIPPImage enforces the same list at upload time.
+ private const double SvgRasterSize = 1200;
+ private static byte[]? NormaliseImage(byte[] bytes)
+ {
+ try
+ {
+ if (!OfficeImageReader.TryIdentifyByContent(bytes, null, out var info)) return null;
+ if (info.Format != OfficeImageFormat.Svg) return bytes;
+ if (!OfficeSvgDrawingReader.TryRead(bytes, out var drawing) || drawing.Width <= 0 || drawing.Height <= 0) return null;
+ return OfficeDrawingRasterRenderer.ToPng(drawing, SvgRasterSize / Math.Max(drawing.Width, drawing.Height), null);
+ }
+ catch { return null; }
+ }
+
+ private static readonly Regex ReportImagesPathPattern =
+ new(@"(?:^|/)reportImages/([A-Za-z0-9_.\-]+\.(?:jpg|jpeg|png|gif|webp))$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ // Stock report images (cover/hero photos) ship beside the assembly under reportImages/, mirroring
+ // how the twemoji PNG set and the fallback font are placed. Loaded once per file and cached.
+ private static readonly Lazy ReportImageDir = new(() =>
+ {
+ try
+ {
+ var d = Path.GetDirectoryName(typeof(ReportComponents).Assembly.Location);
+ if (string.IsNullOrEmpty(d)) return null;
+ var dir = Path.Combine(d, "reportImages");
+ return Directory.Exists(dir) ? dir : null;
+ }
+ catch { return null; }
+ });
+
+ private static readonly ConcurrentDictionary ReportImageCache = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Load a bundled stock report image by file name (no path segments), or null if it is not shipped.
+ public static byte[]? ReportImage(string? fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName)) return null;
+ var key = Path.GetFileName(fileName); // defence in depth: strip any path
+ return ReportImageCache.GetOrAdd(key, k =>
+ {
+ var dir = ReportImageDir.Value;
+ if (dir is null) return null;
+ try
+ {
+ var path = Path.Combine(dir, k);
+ return File.Exists(path) ? File.ReadAllBytes(path) : null;
+ }
+ catch { return null; }
+ });
+ }
+
+ ///
+ /// The box a branding logo is drawn in: the client sets a fixed height (cover 100pt, page header
+ /// 30pt) and lets the width follow the image's aspect ratio; the width cap keeps a banner-shaped
+ /// logo off the cover date and out of the page-header title column.
+ ///
+ public static (double w, double h) LogoBox(byte[] bytes, double height, double maxWidth)
+ {
+ var (pxW, pxH) = ImageSize(bytes);
+ var w = pxW * (height / pxH);
+ if (w > maxWidth) { height *= maxWidth / w; w = maxWidth; }
+ return (Math.Round(w), Math.Round(height));
+ }
+
+ /// The MIME type a drawing needs beside the bytes, from the content itself; null when OfficeIMO cannot identify it (skipped rather than failing).
+ public static string? ImageContentType(byte[] b)
+ {
+ try { return OfficeImageReader.TryIdentifyByContent(b, null, out var info) ? info.MimeType : null; }
+ catch { return null; }
+ }
+
+ /// Pixel size of any image OfficeIMO identifies (a 4:3 guess for anything it cannot).
+ public static (int w, int h) ImageSize(byte[] b)
+ {
+ try
+ {
+ if (OfficeImageReader.TryIdentifyByContent(b, null, out var info) && info.Width > 0 && info.Height > 0)
+ return (info.Width, info.Height);
+ }
+ catch { /* fall through to the guess */ }
+ return (800, 600);
+ }
+
+ // -- inline runs --
+ // `size` is the base font size for the paragraph's runs (markdown has no size marks, so every run
+ // in a paragraph shares it). Without an explicit size OfficeIMO falls back to its ~12pt default,
+ // which is why body copy rendered far larger than the client's 9pt.
+ private static void ApplyRuns(PdfParagraphBuilder b, IReadOnlyList runs, string bodyColour, double size)
+ {
+ if (runs.Count == 0) { b.Text(" "); return; }
+ b.Color(Pdf(bodyColour)).FontSize(size);
+ foreach (var r in runs)
+ {
+ // Emoji split out to inline colour images (or a monochrome glyph) so the surrounding copy
+ // keeps its font/weight and an emoji never lands in a bold/italic run the fallback can't draw.
+ foreach (var seg in SegmentEmoji(r.Text))
+ {
+ switch (seg.Kind)
+ {
+ case EmojiSegKind.Image:
+ b.InlineImage(seg.Image!, size, size, seg.Text, OfficeImageFit.Contain, EmojiOffset(size));
+ break;
+ case EmojiSegKind.Mono:
+ b.Bold(false).Italic(false).Underline(false).Strike(false).Font(PdfStandardFont.Helvetica).Color(Pdf(seg.Tint ?? bodyColour)).Text(seg.Text);
+ break;
+ default:
+ b.Bold(r.Bold).Italic(r.Italic).Underline(r.Underline).Strike(r.Strike)
+ .Font(r.Code ? PdfStandardFont.Courier : PdfStandardFont.Helvetica).Color(Pdf(bodyColour)).Text(seg.Text);
+ break;
+ }
+ }
+ }
+ }
+
+ // -- primitives --
+ public static void Heading(ReportContext ctx, PdfContentBuilder item, int level, IReadOnlyList runs)
+ {
+ var text = RunsToPlain(runs);
+ var colour = level switch { 1 => ReportColours.Ink, 2 => ctx.Theme.Palette["heading"], _ => ReportColours.Body };
+ var size = level switch { 1 => ReportStyles.Heading1, 2 => ReportStyles.Heading2, _ => ReportStyles.Heading3 };
+ // A markdown heading is drawn as a bold paragraph (rather than item.H1/H2/H3) so an emoji in the
+ // heading renders as an inline colour image instead of a monochrome font glyph.
+ item.Paragraph(b => { b.FontSize(size); EmitInline(b, text, colour, size, bold: true); },
+ PdfAlign.Left, null, new PdfParagraphStyle { LineHeight = 1.15, SpacingAfter = 6 });
+ }
+
+ // The text style a run of copy is drawn with. Threaded from the enclosing component so body copy,
+ // callout text and captions each render at their own size/colour/alignment - the server mirror of
+ // the client's context-driven styles.
+ public readonly struct TextStyle
+ {
+ public double Size { get; init; }
+ public string Colour { get; init; }
+ public PdfAlign Align { get; init; }
+ public double LineHeight { get; init; }
+ public double SpacingAfter { get; init; }
+ }
+
+ // Body copy: 9pt, justified. The client uses lineHeight 1.5 / 8pt after, but OfficeIMO's leading
+ // renders looser at the same numbers, so a tighter 1.35 / 6pt gives the client's visual density.
+ public static TextStyle BodyStyle(ReportContext ctx) => new()
+ {
+ Size = ReportStyles.Body, Colour = ctx.Theme.Palette["body"], Align = PdfAlign.Justify, LineHeight = 1.35, SpacingAfter = 6,
+ };
+
+ public static void Paragraph(ReportContext ctx, PdfContentBuilder item, IReadOnlyList runs, TextStyle? style = null)
+ {
+ var st = style ?? BodyStyle(ctx);
+ item.Paragraph(b => ApplyRuns(b, runs, st.Colour, st.Size), st.Align, null,
+ new PdfParagraphStyle { LineHeight = st.LineHeight, SpacingAfter = st.SpacingAfter });
+ }
+
+ // Section title: 14pt bold heading colour (client styles.sectionTitle marginBottom 8, tightened to
+ // 5 to offset OfficeIMO's looser leading around the heading).
+ public static void SectionTitle(ReportContext ctx, PdfContentBuilder item, string title)
+ => item.Paragraph(b => { b.FontSize(ReportStyles.SectionTitle); EmitInline(b, title, ctx.Theme.Palette["heading"], ReportStyles.SectionTitle, bold: true); },
+ PdfAlign.Left, null, new PdfParagraphStyle { LineHeight = 1.1, SpacingAfter = 5 });
+
+ // A callout's list rendered as one paragraph, a line break between items (a panel ignores list
+ // styling, so this matches the client's single-text-block callout bullets). `marker(i)` prefixes
+ // each line (a bullet dot, or "N. " for numbered).
+ private static void BulletLines(ReportContext ctx, PdfContentBuilder item, IReadOnlyList items, TextStyle ts, Func marker)
+ {
+ if (items.Count == 0) return;
+ item.Paragraph(b =>
+ {
+ b.FontSize(ts.Size).Color(Pdf(ts.Colour));
+ for (var i = 0; i < items.Count; i++)
+ {
+ if (i > 0) b.LineBreak();
+ b.Color(Pdf(ts.Colour)).Text(marker(i));
+ EmitToBuilder(b, San(items[i]), ts.Colour, ts.Size);
+ }
+ }, PdfAlign.Left, null, new PdfParagraphStyle { LeftIndent = 12, SpacingAfter = ts.SpacingAfter, LineHeight = ts.LineHeight });
+ }
+
+ public static void Bullets(ReportContext ctx, PdfContentBuilder item, IEnumerable items, double? size = null)
+ => BulletParagraphs(ctx, item, new List(items), size ?? ReportStyles.BulletText, _ => "• ");
+
+ // A bullet/numbered list drawn as one paragraph per item (marker + emoji-aware text) rather than
+ // item.Bullets, so an emoji in a list item renders as an inline colour image. Marker in the heading
+ // colour; matches the old ListStyle (indent 12, 4pt item spacing, 1.3 line height).
+ private static void BulletParagraphs(ReportContext ctx, PdfContentBuilder item, IReadOnlyList items, double size, Func marker)
+ {
+ var body = ctx.Theme.Palette["body"];
+ var markerColour = ctx.Theme.Palette["heading"];
+ for (var i = 0; i < items.Count; i++)
+ {
+ var idx = i;
+ item.Paragraph(b =>
+ {
+ b.FontSize(size);
+ b.Bold(true).Color(Pdf(markerColour)).Text(marker(idx));
+ EmitToBuilder(b, San(items[idx]), body, size);
+ }, PdfAlign.Left, null, new PdfParagraphStyle { LeftIndent = 12, SpacingAfter = 4, LineHeight = 1.3 });
+ }
+ }
+
+ public static void Numbered(ReportContext ctx, PdfContentBuilder item, IEnumerable items, int start, double? size = null)
+ => BulletParagraphs(ctx, item, new List(items), size ?? ReportStyles.BulletText, i => (start + i) + ". ");
+
+ // The branded cover, drawn as a page-sized OfficeDrawing over an optional full-bleed cover photo
+ // (client CoverPage): date top-right, a rounded pill label chip, the two-tone title, the subtitle
+ // and tenant vertically placed, and the confidential note at the bottom. A drawing lets the chip
+ // be a real rounded pill and the confidential note sit at the page foot - neither is possible in
+ // the plain content flow.
+ public static void RenderCoverDrawing(ReportContext ctx, PdfContentBuilder item)
+ {
+ var w = ctx.ContentWidth - 2;
+ // A drawing exactly the content height is rejected as too tall (as with width): landscape trips
+ // this because the A4 long edge is a hair over the page height, so shave 2pt off both here.
+ var h = ctx.ContentHeight - 2;
+ const double leftPad = 28;
+ var coverText = ctx.Theme.Palette["coverText"];
+ var subtitleC = ctx.Theme.Palette["subtitle"];
+ var primary = ctx.Theme.Primary;
+ var dw = new OfficeDrawing(w, h);
+
+ // Vertical anchors mirror the client cover's fixed paddings (page pad 60, a header row of the
+ // logo and date with a 40pt margin under it, hero paddingTop 24) rather than a proportion of
+ // page height, so the block lands where the react-pdf cover puts it. The drawing origin already
+ // sits at the page margin, so each client "from page top" figure is offset by PagePadding here.
+ const double coverPad = 60, headerGap = 40, heroPad = 24, dateLine = 11, logoHeight = 100;
+ var headerTop = coverPad - ReportStyles.PagePadding;
+
+ // Client coverHeader: the branding logo on the left at 100pt (width by aspect ratio, capped so
+ // a banner never reaches the date), the date on the right, the two vertically centred on each
+ // other. Without a logo the row is just the date line, which is where the hero's 135pt top came
+ // from (60 + 11 + 40 + 24).
+ var logoType = ctx.Logo is { Length: > 0 } ? ImageContentType(ctx.Logo) : null;
+ var logoBox = logoType is null ? (w: 0.0, h: 0.0) : LogoBox(ctx.Logo!, logoHeight, 260);
+ var headerH = Math.Max(dateLine, logoBox.h);
+ if (logoType is not null)
+ dw.AddImage(ctx.Logo!, logoType, new OfficeImageProjection(
+ new OfficeImagePlacement(leftPad, headerTop, logoBox.w, logoBox.h), new OfficeImageSourceCrop(0, 0, 0, 0), 0, null, null, false, false));
+ if (!string.IsNullOrEmpty(ctx.GeneratedOn))
+ AddT(dw, San(ctx.GeneratedOn).ToUpperInvariant(), 0, headerTop + Math.Max(0, (headerH - 14) / 2), w, 14, 9, subtitleC, OfficeTextAlignment.Right);
+
+ var coverLabel = (ctx.Variables.TryGetValue("coverlabel", out var cl) && !string.IsNullOrWhiteSpace(cl)) ? cl : "ASSESSMENT REPORT";
+ coverLabel = San(coverLabel).ToUpperInvariant();
+ var chipW = Math.Min(w - leftPad * 2, 26 + coverLabel.Length * 6.4);
+ var y = coverPad + headerH + headerGap + heroPad - ReportStyles.PagePadding; // client coverHero content top (chip)
+ var chip = OfficeShape.RoundedRectangle(chipW, 26, 13); chip.FillColor = OC(primary);
+ dw.AddShape(chip, leftPad, y);
+ // AddText seats the glyph near the top of its box, so a box that merely matches the pill leaves
+ // the label riding high; drop it so the 10pt label sits centred in the 26pt pill.
+ AddT(dw, coverLabel, leftPad, y + 9, chipW, 12, ReportStyles.CoverLabel, ctx.Theme.OnPrimary, OfficeTextAlignment.Center, true);
+ y += 58; // chip height (~28) + client marginBottom 30
+
+ // Cover title: an explicit covertitle/coveraccent override (client coverTitle/coverAccent, e.g.
+ // the BEC report's "BEC Compromise" / "Analysis") else the report name split on its last word.
+ string lead, accent;
+ if (ctx.Variables.TryGetValue("covertitle", out var ctv) && !string.IsNullOrWhiteSpace(ctv))
+ {
+ lead = San(ctv).ToUpperInvariant();
+ accent = (ctx.Variables.TryGetValue("coveraccent", out var cav) && !string.IsNullOrWhiteSpace(cav)) ? San(cav).ToUpperInvariant() : string.Empty;
+ }
+ else
+ {
+ var words = San(ctx.ReportName).ToUpperInvariant().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ lead = words.Length > 1 ? string.Join(" ", words[..^1]) : (words.Length == 1 ? words[0] : string.Empty);
+ accent = words.Length > 1 ? words[^1] : string.Empty;
+ }
+ // A long line shrinks to fit rather than running off the page ("QUARTERLY SECURITY" did):
+ // Helvetica Bold capitals average about 0.7em, so the size that fits is the box over that.
+ double FitTitle(string s) => Math.Min(ReportStyles.CoverTitle, (w - leftPad) / Math.Max(1, s.Length * 0.7));
+ var titleSize = Math.Min(FitTitle(lead), FitTitle(accent));
+ if (!string.IsNullOrEmpty(lead)) { AddT(dw, lead, leftPad, y, w - leftPad, 56, titleSize, coverText, OfficeTextAlignment.Left, true); y += 53; }
+ if (!string.IsNullOrEmpty(accent)) { AddT(dw, accent, leftPad, y, w - leftPad, 56, titleSize, primary, OfficeTextAlignment.Left, true); y += 53; }
+ if (!string.IsNullOrEmpty(lead) || !string.IsNullOrEmpty(accent)) y += 20; // client title marginBottom 20
+
+ if (ctx.Variables.TryGetValue("coversubtitle", out var cs) && !string.IsNullOrWhiteSpace(cs))
+ {
+ // Wraps within a fixed-width box like the client subtitle (maxWidth 400); advance by the
+ // wrapped height (14pt at lineHeight 1.5 ~= 21/line) plus the client subtitle marginBottom 40.
+ const double subW = 400, subLine = 21;
+ var subLines = Math.Max(1, Math.Ceiling(San(cs).Length / 57.0));
+ AddT(dw, San(cs), leftPad, y, subW, subLine * subLines + 4, ReportStyles.CoverSubtitle, subtitleC, OfficeTextAlignment.Left, false, true);
+ y += subLine * subLines + 40;
+ }
+
+ // The subject line under the title: usually the tenant, but a covertenant override names a
+ // different subject (the BEC report puts the compromised user here instead of the tenant).
+ var coverTenant = (ctx.Variables.TryGetValue("covertenant", out var cvt) && !string.IsNullOrWhiteSpace(cvt)) ? cvt : ctx.TenantName;
+ if (!string.IsNullOrEmpty(coverTenant))
+ {
+ AddT(dw, San(coverTenant), leftPad, y, w - leftPad, 24, 18, coverText, OfficeTextAlignment.Left, true);
+ y += 26;
+ }
+ // Optional cover meta (client CoverMeta): extra detail lines under the tenant, then a note.
+ if (ctx.Variables.TryGetValue("covermeta", out var cm) && !string.IsNullOrWhiteSpace(cm))
+ foreach (var line in cm.Replace("\r", "").Split('\n'))
+ { AddT(dw, San(line), leftPad, y, w - leftPad * 2, 16, 12, subtitleC, OfficeTextAlignment.Left); y += 16; }
+ if (ctx.Variables.TryGetValue("covermetanote", out var cmn) && !string.IsNullOrWhiteSpace(cmn))
+ AddT(dw, San(cmn), leftPad, y + 6, w - leftPad * 2, 16, 11, subtitleC, OfficeTextAlignment.Left);
+
+ var note = "CONFIDENTIAL & PROPRIETARY";
+ if (ctx.Variables.TryGetValue("coverfooternote", out var cfn) && !string.IsNullOrWhiteSpace(cfn)) note = cfn;
+ else if (!string.IsNullOrEmpty(ctx.Theme.CoverFooterText)) note = ctx.Theme.CoverFooterText;
+ note = San(ReportTheme.ApplyVariables(note, ctx.Variables)).ToUpperInvariant();
+ AddT(dw, note, 0, h - 16, w, 14, 9, ctx.Theme.Palette["footer"], OfficeTextAlignment.Center);
+
+ item.Drawing(dw, PdfAlign.Left);
+ }
+
+ // Full-bleed hero content (client HeroPage overlay): the big highlight figure plus overtitle/
+ // headline/subtext block, vertically centred and left-aligned, with the footer note bottom-right.
+ // Drawn as one page-sized OfficeDrawing (transparent) over the section's background image, because
+ // flow layout can neither vertically centre nor pin the footer to the bottom-right corner.
+ public static void RenderHeroDrawing(ReportContext ctx, PdfContentBuilder item, ReportNode block)
+ {
+ var w = ctx.ContentWidth - 2;
+ // See RenderCoverDrawing: a page-tall drawing is rejected in landscape without this 2pt shave.
+ var h = ctx.ContentHeight - 2;
+ var highlightColour = ctx.Theme.Palette["infographic"];
+ var onDark = ctx.Theme.OnInfographic;
+ var overtitle = block.Str("overtitle") ?? block.Str("heroOvertitle");
+ var highlight = block.Str("highlight") ?? block.Str("heroHighlight");
+ var headline = block.Str("headline") ?? block.Str("heroHeadline") ?? block.Str("title");
+ var subText = block.Str("subText") ?? block.Str("heroSubText");
+ var footerText = block.Str("footerText") ?? block.Str("heroFooterText");
+ const double leftPad = 28, textW = 440;
+ var subLines = string.IsNullOrEmpty(subText) ? Array.Empty() : subText.Replace("\r", "").Split('\n');
+
+ var blockH = (string.IsNullOrEmpty(overtitle) ? 0 : 24) + (string.IsNullOrEmpty(highlight) ? 0 : 84)
+ + (string.IsNullOrEmpty(headline) ? 0 : 24) + subLines.Length * 19;
+ var y = Math.Max(40, (h - blockH) / 2);
+
+ var dw = new OfficeDrawing(w, h);
+ if (!string.IsNullOrEmpty(overtitle)) { AddT(dw, San(overtitle), leftPad, y, textW, 22, 18, onDark, OfficeTextAlignment.Left, true); y += 24; }
+ if (!string.IsNullOrEmpty(highlight)) { AddT(dw, San(highlight), leftPad, y, textW, 82, 72, highlightColour, OfficeTextAlignment.Left, true); y += 84; }
+ if (!string.IsNullOrEmpty(headline)) { AddT(dw, San(headline), leftPad, y, textW, 22, 18, onDark, OfficeTextAlignment.Left, true); y += 24; }
+ foreach (var line in subLines) { AddT(dw, San(line), leftPad, y, textW, 18, 14, onDark, OfficeTextAlignment.Left, true); y += 19; }
+
+ if (!string.IsNullOrEmpty(footerText))
+ {
+ var fLines = footerText.Replace("\r", "").Split('\n');
+ var fy = h - 40 - (fLines.Length - 1) * 16;
+ foreach (var line in fLines) { AddT(dw, San(line), 0, fy, w - leftPad, 16, 12, onDark, OfficeTextAlignment.Right, true); fy += 16; }
+ }
+ item.Drawing(dw, PdfAlign.Left);
+ }
+
+ /// Rich bullets (client BulletList with {label, text}): an orange marker, a bold label, then
+ /// body text - each an item.Paragraph with per-run colour so the marker and label differ from the text.
+ public static void RichBullets(ReportContext ctx, PdfContentBuilder item, List