diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index b03f2070fa0b..63203c303ab8 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -241,7 +241,7 @@ "Id": "5e8a9b4c-2d6f-4a3e-b7c1-9d0e5f3a8b2c", "Command": "Start-IntuneReportExportOrchestrator", "Description": "Submit Intune report-export jobs ahead of nightly DB cache run", - "Cron": "0 0 2 * * *", + "Cron": "0 0 1 * * *", "Priority": 22, "RunOnProcessor": true, "TZOffset": true, diff --git a/Config/ConversionTable.csv b/Config/ConversionTable.csv index 92775e6da97b..9c354ea9db05 100644 --- a/Config/ConversionTable.csv +++ b/Config/ConversionTable.csv @@ -5855,3 +5855,126 @@ Windows Store for Business,WINDOWS_STORE,6470687e-a428-4b7a-bef2-8a291ad947c9,WI Windows Store for Business EDU Faculty,WSFB_EDU_FACULTY,c7e9d9e6-1981-4bf3-bb50-a5bdfaa06fb2,Windows Store for Business EDU Store_faculty,aaa2cd24-5519-450f-a1a0-160750710ca1,Windows Store for Business EDU Store_faculty Workload Identities Premium,Workload_Identities_Premium_CN,73fa80b5-689f-4db9-bbe4-bd414bc41e44,AAD_WRKLDID_P2,7dc0e92d-bf15-401d-907e-0884efe7c760,Azure Active Directory workload identities P2 Workload Identities Premium,Workload_Identities_Premium_CN,73fa80b5-689f-4db9-bbe4-bd414bc41e44,AAD_WRKLDID_P1,84c289f0-efcb-486f-8581-07f44fc9efad,Azure Active Directory workload identities P1 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS,27e196a4-8b80-4930-bd65-53fd28581878,Microsoft Entra Network Controls for Assistive Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ENTRA_ID_GOV_FOR_ASSISTIVE_AGENTS,a9e85e05-1687-4958-8a4c-bdacda2943db,Microsoft Entra ID Governance for Assistive Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIDER_RISK_MANAGEMENT_FOR_AGENTS,004ddfc0-c92f-4b0a-90c5-c60646299d71,Microsoft Purview Insider Risk Management for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INFORMATION_PROTECTION_FOR_AGENTS,48478b49-91a1-4ded-94f0-066db80035ca,Microsoft Purview Information Protection for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EDISCOVERY_FOR_AGENTS,92cedcb2-3fb2-40b4-9df4-f9a5603d9631,Microsoft Purview eDiscovery for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DEFENDER_FOR_AI,a1c15058-5559-4c1a-ba05-8040847f91bb,Microsoft Defender for AI +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DATA_LOSS_PREVENTION_FOR_AGENTS,46d3c309-0ba7-461f-9d0e-eaca165794c2,Microsoft Purview Data Loss Prevention for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DATA_LIFECYCLE_MANAGEMENT_FOR_AGENTS,30d56d35-9be2-41c1-b4b8-7a8d6f073152,Microsoft Purview Data Lifecycle Management for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMPLIANCE_MANAGER_FOR_AGENTS,d1f65d05-a302-4861-bd35-da8933ba7655,Microsoft Purview Compliance Manager for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMMUNICATION_COMPLIANCE_FOR_AGENTS,135fe762-031a-4e79-bd3c-ac376addddec,Microsoft Purview Communication Compliance for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AUDIT_FOR_AGENTS,6d9b0ae5-e6a0-4f04-991a-e5700fd84930,Microsoft Purview Audit for Agents +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AGENT_365,d0ce5ebb-9db0-491f-b780-8973a1d815fe,Agent 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Defender_for_Iot_Enterprise,99cd49a9-0e54-4e07-aea1-d8d9f5f704f5,Defender for IoT - Enterprise IoT Security +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,DYN365_CDS_O365_P3,28b0fa46-c39a-4188-89e2-58e979a6b014,Common Data Service +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RMS_S_PREMIUM2,5689bec4-755d-4753-8b61-40975025187c,AZURE INFORMATION PROTECTION PREMIUM P2 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RMS_S_PREMIUM,6c57d4b6-3b23-47a5-9bc9-69f17b4947b3,Microsoft Entra RIGHTS +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Verifiable_Credentials_Service_Request,aae826b7-14cd-4691-8178-2b312f7072ea,Verifiable Credentials Service Request +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,POWER_VIRTUAL_AGENTS_O365_P3,ded3d325-1bdc-453e-8432-5bac26d7a014,Power Virtual Agents for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_CONNECTORS,89f1c4c8-0878-40f7-804d-869c9128ab5d,Power Platform Connectors in Microsoft 365 Copilot +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,FLOW_O365_P3,07699545-9485-468e-95b6-2fca3738be01,Power Automate for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WORKPLACE_ANALYTICS_INSIGHTS_BACKEND,ff7b261f-d98b-415b-827c-42a3fdf015af,Microsoft Viva Insights Backend +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WORKPLACE_ANALYTICS_INSIGHTS_USER,b622badb-1b45-48d5-920f-4b27a2c0996c,Microsoft Viva Insights +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INTUNE_A,c1ec4a95-1f05-45b3-a911-aa3fa01094f5,Microsoft Intune +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Entra_Premium_Private_Access,f057aab1-b184-49b2-85c0-881b02a405c5,Microsoft Entra Private Access +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Entra_Premium_Internet_Access,8d23cb83-ab07-418f-8517-d7aca77307dc,Microsoft Entra Internet Access +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AAD_PREMIUM_P2,eec0eb4f-6444-4f95-aba0-50c24d67f998,Microsoft Entra ID P2 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,AAD_PREMIUM,41781fb2-bc02-4b7c-bd55-b576c07bb09d,Microsoft Entra ID P1 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ATA,14ab5db5-e6c4-4b20-b4bc-13e36fd2227f,MICROSOFT DEFENDER FOR IDENTITY +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ADALLOM_S_STANDALONE,2e2ddb96-6af9-4b1d-a3f0-d6ecfd22edb2,MICROSOFT CLOUD APP SECURITY +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_BUSINESS_CHAT,3f30311c-6b1e-48a4-ab79-725b469da960,Microsoft Copilot with Graph-grounded chat +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MFA_PREMIUM,8a256a2b-b617-496d-b51b-e76466e88db0,Microsoft Azure Multi-Factor Authentication +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_APPS,a62f8878-de10-42f3-b68f-6149a25ceb97,Microsoft 365 Copilot in Productivity Apps +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_TEAMS,b95945de-b3bd-46db-8437-f2beb6ea2347,Microsoft 365 Copilot in Microsoft Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_SHAREPOINT,0aedf20c-091d-420b-aadf-30c042609612,Microsoft 365 Copilot for SharePoint +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_COPILOT_INTELLIGENT_SEARCH,931e4a88-a67f-48b5-814f-16a5f1e6028d,Intelligent Search +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,GRAPH_CONNECTORS_COPILOT,82d30987-df9b-4486-b146-198b21d164c7,Graph Connectors in Microsoft 365 Copilot +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Entra_Identity_Governance,e866a266-3cff-43a3-acca-0c90a7e00c8b,Entra Identity Governance +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COPILOT_STUDIO_IN_COPILOT_FOR_M365,fe6c28b3-d468-44ea-bbd0-a10a5167435c,Copilot Studio in Copilot for M365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE,7bf960f6-2cd9-443a-8046-5dbff9558365,Windows Update for Business Deployment Service +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Windows_Autopatch,9a6eeb79-0b4b-4bf0-9808-39d99a2cd5a3,Windows Autopatch +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WIN10_PRO_ENT_SUB,21b439ba-a0ca-424f-a6cc-52f954a5b111,Windows 10/11 Enterprise (Original) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,UNIVERSAL_PRINT_01,795f6fe0-cc4d-4773-b050-5dde4dc704c9,Universal Print +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFTENDPOINTDLP,64bfac92-2b17-4482-b5e5-a0304429de3e,Microsoft Endpoint DLP +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WINDEFATP,871d91ec-ec1a-452b-a83f-bd76c7d770ef,Microsoft Defender for Endpoint +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,YAMMER_ENTERPRISE +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,WHITEBOARD_PLAN3,4a51bca5-1eff-43f5-878c-177680f191af,Whiteboard (Plan 3) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,VIVA_LEARNING_SEEDED,b76fb638-6ba6-402a-b9f9-83d28acb3d86,Viva Learning Seeded +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,VIVAENGAGE_CORE,a82fbf69-b4d7-49f4-83a6-915b2cf354f4,Viva Engage Core +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,BPOS_S_TODO_3,3fb82609-8c27-4f7b-bd51-30634711ee67,To-Do (Plan 3) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SWAY,a23b959c-7ce8-4e57-9140-b90eb88a9e97,Sway +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint (Plan 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PURVIEW_DISCOVERY,c948ea65-2053-4a5a-8a62-9eaaaf11b522,Purview Discovery +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PROJECT_O365_P3,b21a6b06-1988-436e-a07b-51ec6d9f52ad,Project for Office (Plan E5) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PREMIUM_ENCRYPTION,617b097b-4b93-4ede-83de-5f075bb5fb2f,Premium Encryption in Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,POWERAPPS_O365_P3,9c0dab89-a30c-4117-86e7-97bda240acd2,Power Apps for Office 365 (Plan 3) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PEOPLE_SKILLS_FOUNDATION,13b6da2c-0d84-450e-9f69-a33e221387ca,People Skills - Foundation +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office for the Web +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,SAFEDOCS,bf6f5520-59e3-4f82-974b-7dbbc4fd27c7,Office 365 SafeDocs +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PAM_ENTERPRISE,b1188c4c-1b36-4018-b48b-ee07604f6feb,Office 365 Privileged Access Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ADALLOM_S_O365,8c098270-9dd4-4350-9b30-ba4703f3b36b,Office 365 Cloud App Security +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EQUIVIO_ANALYTICS,4de31727-a228-4ec3-a5bf-8e45b5ca48cc,Office 365 Advanced eDiscovery +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Nucleus,db4d623d-b514-490b-b7ef-8885eee514de,Nucleus +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INTUNE_O365,882e1d05-acd1-4ccb-8708-6ee03664b117,Mobile Device Management for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,STREAM_O365_E5,6c6042f5-6f01-4d67-b8c1-eb99d36eed3e,Microsoft Stream for Office 365 E5 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Deskless,8c7d2df8-86f0-4902-b2ed-a0458298f3b3,Microsoft StaffHub +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFT_SEARCH,94065c59-bc8e-4e8b-89e5-5138d471eaff,Microsoft Search +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RECORDS_MANAGEMENT,65cc641f-cccd-4643-97e0-a17e3045e541,Microsoft Records Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,PROJECTWORKMANAGEMENT,b737dad2-2f6c-4c65-90e3-ca563267e8b9,Microsoft Planner +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EXCHANGE_ANALYTICS,34c0d7a0-a70f-4668-9238-47f9fc208882,Microsoft MyAnalytics (Full) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ML_CLASSIFICATION,d2d51368-76c9-4317-ada2-a12c004c432f,Microsoft ML-Based Classification +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFT_LOOP,c4b8c31a-fb44-4c65-9837-a21f55fcabda,Microsoft Loop +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIDER_RISK_MANAGEMENT,9d0c4ee5-e4a1-4625-ab39-d82b619b1a34,Microsoft Insider Risk Management - Exchange +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIDER_RISK,d587c7a3-bda9-4f99-8776-9bcf59c84f75,Microsoft Insider Risk Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INFO_GOVERNANCE,e26c2fcc-ab91-4a61-b35c-03cdc8dddf66,Microsoft Information Governance +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,FORMS_PLAN_E5,e212cbc7-0961-4c40-9825-01117710dcb1,Microsoft Forms (Plan E5) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EXCEL_PREMIUM,531ee2f8-b1cb-453b-9c21-d2180d014ca5,Microsoft Excel Advanced Analytics +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,THREAT_INTELLIGENCE,8e0c0a52-6a6c-4d40-8370-dd62790dcd70,Microsoft Defender for Office 365 (Plan 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CUSTOMER_KEY,6db1f1db-2b46-403f-be40-e39395f08dbb,Microsoft Customer Key +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMMUNICATIONS_DLP,6dc145d6-95dd-4191-b9c3-185575ee6f6b,Microsoft Communications DLP +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CLIPCHAMP,a1ace008-72f3-4ea0-8dac-33b3a23a2472,Microsoft Clipchamp +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFTBOOKINGS,199a5c09-e0ca-4e37-8f7c-b05d533e1ea2,Microsoft Bookings +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MCOEV,4828c8ec-dc2e-4779-b502-87ac9ce28ab7,Microsoft 365 Phone System +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_LIGHTHOUSE_CUSTOMER_PLAN1,6f23d6a9-adbf-481c-8538-b4c095654487,Microsoft 365 Lighthouse (Plan 1) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MTP,bf28f719-7844-4079-9c78-c1307898e192,Microsoft 365 Defender +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MICROSOFT_COMMUNICATION_COMPLIANCE,a413a9ff-720c-4822-98ef-2f37c2a21f4c,Microsoft 365 Communication Compliance +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_AUDIT_PLATFORM,f6de4823-28fa-440b-b886-4783fa86ddba,Microsoft 365 Audit Platform +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MCOMEETADV,3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40,Microsoft 365 Audio Conferencing +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,OFFICESUBSCRIPTION,43de0ff5-c92c-492b-9116-175376d08c38,Microsoft 365 Apps for enterprise +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,M365_ADVANCED_AUDITING,2f442157-a11c-46b9-ae5b-6e39ff4e5849,Microsoft 365 Advanced Auditing +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INSIGHTS_BY_MYANALYTICS,b088306e-925b-44ab-baa0-63291c629a91,Insights by MyAnalytics Backend +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MYANALYTICS_P2,33c4f319-9bdd-48d6-9c4d-410b750a4a5a,Insights by MyAnalytics +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MIP_S_CLP1,5136a095-5cf0-4aff-bec3-e84448b38ea5,Information Protection for Office 365 - Standard +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MIP_S_CLP2,efb0351d-3b08-4503-993d-383af8de41e3,Information Protection for Office 365 - Premium +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,ContentExplorer_Standard,2b815d45-56e4-4e3a-b65c-66cb9175b560,Information Protection and Governance Analytics – Standard +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,Content_Explorer,d9fa6af4-e046-4c89-9226-729a0786685d,Information Protection and Governance Analytics - Premium +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,INFORMATION_BARRIERS,c4801e8a-cb58-4c35-aca6-f2dcc106f287,Information Barriers +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MESH_IMMERSIVE_FOR_TEAMS,f0ff6ac6-297d-49cd-be34-6dfef97f0c28,Immersive spaces for Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,GRAPH_CONNECTORS_SEARCH_INDEX,a6520331-d7d4-4276-95f5-15c0933bc757,Graph Connectors Search with Index +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,EXCHANGE_S_ENTERPRISE,efb87545-963c-4e0d-99df-69c6916d9eb0,EXCHANGE ONLINE (PLAN 2) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,COMMON_DEFENDER_PLATFORM_FOR_OFFICE,a312bdeb-1e21-40d0-84b1-0e73f128144f,Defender Platform for Office 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MIP_S_Exchange,cd31b152-6326-4d1b-ae1b-997b625182e6,Data Classification in Microsoft 365 +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CustomerLockboxA_Enterprise,3ec18638-bd4c-4d3b-8905-479ed636b83e,Customer Lockbox (A) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,LOCKBOX_ENTERPRISE,9f431833-0334-42de-a7dc-70aa40db46db,Customer Lockbox +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,CDS_O365_P3,afa73018-811e-46e9-988f-f75d2b1b8430,Common Data Service for Teams +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Azure Rights Management +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MESH_AVATARS_ADDITIONAL_FOR_TEAMS,3efbd4ed-8958-4824-8389-1321f8730af8,Avatars for Teams (additional) +Microsoft 365 E7,MICROSOFT_365_E7,9a18296a-025f-4e37-9ffa-30bf8d1ce775,MESH_AVATARS_FOR_TEAMS,dcf9d2f4-772e-4434-b757-77a453cfbc02,Avatars for Teams +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,AGENT_365,d0ce5ebb-9db0-491f-b780-8973a1d815fe,Agent 365 +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,AUDIT_FOR_AGENTS,6d9b0ae5-e6a0-4f04-991a-e5700fd84930,Microsoft Purview Audit for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,COMMUNICATION_COMPLIANCE_FOR_AGENTS,135fe762-031a-4e79-bd3c-ac376addddec,Microsoft Purview Communication Compliance for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,COMPLIANCE_MANAGER_FOR_AGENTS,d1f65d05-a302-4861-bd35-da8933ba7655,Microsoft Purview Compliance Manager for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,DATA_LIFECYCLE_MANAGEMENT_FOR_AGENTS,30d56d35-9be2-41c1-b4b8-7a8d6f073152,Microsoft Purview Data Lifecycle Management for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,DATA_LOSS_PREVENTION_FOR_AGENTS,46d3c309-0ba7-461f-9d0e-eaca165794c2,Microsoft Purview Data Loss Prevention for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,DEFENDER_FOR_AI,a1c15058-5559-4c1a-ba05-8040847f91bb,Microsoft Defender for AI +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,EDISCOVERY_FOR_AGENTS,92cedcb2-3fb2-40b4-9df4-f9a5603d9631,Microsoft Purview eDiscovery for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,ENTRA_ID_GOV_FOR_ASSISTIVE_AGENTS,a9e85e05-1687-4958-8a4c-bdacda2943db,Microsoft Entra ID Governance for Assistive Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS,27e196a4-8b80-4930-bd65-53fd28581878,Microsoft Entra Network Controls for Assistive Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,INFORMATION_PROTECTION_FOR_AGENTS,48478b49-91a1-4ded-94f0-066db80035ca,Microsoft Purview Information Protection for Agents +Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,INSIDER_RISK_MANAGEMENT_FOR_AGENTS,004ddfc0-c92f-4b0a-90c5-c60646299d71,Microsoft Purview Insider Risk Management for Agents diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index bd8fe152ce5e..e90b238e94be 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -81,5 +81,20 @@ ], "Pages": [], "Hidden": false + }, + { + "Id": "AppInsights", + "Name": "App Insights", + "Description": "App Insights page not used in NG", + "Enabled": true, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ExecAppInsightsQuery" + ], + "Pages": [ + "/cipp/advanced/diagnostics" + ], + "Hidden": true } ] diff --git a/openapi.json b/Config/openapi.json similarity index 100% rename from openapi.json rename to Config/openapi.json diff --git a/Config/standards.json b/Config/standards.json index 3d27a41de832..6552e8000429 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -378,8 +378,8 @@ "cat": "Global Standards", "tag": ["CIS M365 7.0.0 (1.3.6)", "CustomerLockBoxEnabled"], "appliesToTest": ["CIS_1_3_6"], - "helpText": "**Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", - "docsDescription": "**Requires Entra ID P2.** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", + "helpText": "**Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", + "docsDescription": "**Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", "executiveText": "Requires explicit organizational approval before Microsoft support staff can access company data for service operations. This provides an additional layer of data protection and ensures the organization maintains control over who can access sensitive business information, even during technical support scenarios.", "addedComponent": [], "label": "Enable Customer Lockbox", @@ -4157,12 +4157,10 @@ "defaultValue": false }, { - "type": "autoComplete", - "multiple": true, - "creatable": true, + "type": "LanguageCodeMultiSelect", "required": false, "name": "standards.SpamFilterPolicy.LanguageBlockList", - "label": "Languages to block (uppercase ISO 639-1 two-letter)", + "label": "Languages to block (ISO 639-1 two-letter)", "condition": { "field": "standards.SpamFilterPolicy.EnableLanguageBlockList", "compareType": "is", @@ -4176,12 +4174,10 @@ "defaultValue": false }, { - "type": "autoComplete", - "multiple": true, - "creatable": true, + "type": "CountryCodeMultiSelect", "required": false, "name": "standards.SpamFilterPolicy.RegionBlockList", - "label": "Regions to block (uppercase ISO 3166-1 two-letter)", + "label": "Regions to block (ISO 3166-1 two-letter)", "condition": { "field": "standards.SpamFilterPolicy.EnableRegionBlockList", "compareType": "is", @@ -5865,7 +5861,7 @@ { "type": "switch", "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", - "label": "Allow users to communicate with other organizations" + "label": "Allow users to communicate with consumer Teams accounts" }, { "type": "autoComplete", @@ -7255,6 +7251,12 @@ "label": "Block Android if partner data unavailable", "defaultValue": false }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.grantMobileThreatDefensePartnerRole", + "label": "Grant MTD role to MDE on enrolled Android COBO/COPE devices", + "defaultValue": false + }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectIos", @@ -7264,13 +7266,19 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectIosCompliance", - "label": "Connect iOS 13.0+ (App-based MAM)", + "label": "Connect iOS/iPadOS devices for app protection policy evaluation (MAM)", "defaultValue": false }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.appSync", - "label": "Enable App Sync for iOS", + "label": "Enable App Sync (sending application inventory) for iOS/iPadOS devices", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalApplicationMetadata", + "label": "Send full application inventory data on personally-owned iOS/iPadOS devices", "defaultValue": false }, { @@ -7282,13 +7290,13 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata", - "label": "Collect certificate metadata from iOS", + "label": "Enable Certificate Sync for iOS/iPadOS devices", "defaultValue": false }, { "type": "switch", "name": "standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata", - "label": "Collect personal certificate metadata from iOS", + "label": "Send full certificate inventory data on personally-owned iOS/iPadOS devices", "defaultValue": false }, { diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 index 3f7ceb6009e5..2391968a94f7 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 @@ -16,7 +16,7 @@ function Push-TableCleanupTask { if ($Table) { Write-Information "Deleting table $($Table.Context.TableName)" try { - Remove-AzDataTable -Context $Table.Context -Force + Remove-AzDataTable -Context $Table.Context } catch { #Write-LogMessage -API 'TableCleanup' -message "Failed to delete table $($Table.Context.TableName)" -sev Error -LogData (Get-CippException -Exception $_) } diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 index 3422175d1233..4a457e98b240 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1 @@ -72,6 +72,21 @@ function New-CIPPAuditLogSearchResultsCache { Add-CIPPAzDataTableEntity @CacheWebhooksTable -Entity $cacheEntity -Force } Write-Information "Successfully cached search ID: $($SearchId) for tenant: $TenantFilter" + + try { + $PrefetchIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($sr in $searchResults) { + $cip = $sr.auditData.clientip + if (![string]::IsNullOrWhiteSpace($cip)) { $null = $PrefetchIPs.Add(([string]$cip).Trim()) } + } + if ($PrefetchIPs.Count -gt 0) { + $null = Get-CIPPGeoIPLocationBatch -IPs @($PrefetchIPs) + Write-Information "Geo prefetch: warmed cache for $($PrefetchIPs.Count) distinct IP(s) (search $SearchId)" + } + } catch { + Write-Information "Geo prefetch during ingestion failed for search ${SearchId}: $($_.Exception.Message)" + } + try { $FailedDownloadsTable = Get-CippTable -TableName 'FailedAuditLogDownloads' $failedEntities = Get-CIPPAzDataTableEntity @FailedDownloadsTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'" diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 new file mode 100644 index 000000000000..312dbc83f8cd --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPManagedIdentityResourceId.ps1 @@ -0,0 +1,51 @@ +function Get-CIPPManagedIdentityResourceId { + <# + .SYNOPSIS + Get the Azure resource ID that the Function App's managed identity belongs to. + .DESCRIPTION + Reads the 'xms_mirid' claim from a managed identity access token. For a system-assigned + identity (which CIPP uses), this claim is the ARM resource ID of the host resource itself + - i.e. the Function App site, including its resource group: + + /subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Web/sites/{site} + + This is the most reliable in-process source for the site's resource group because it is + present in every managed identity token, requires no extra ARM/Graph call, and - unlike + parsing WEBSITE_OWNER_NAME - always names the site's RG rather than the App Service Plan's + webspace RG. + + Note: for a user-assigned identity, xms_mirid points at the userAssignedIdentities resource + instead, which may live in a different RG. Callers that need the site's RG should validate + the returned ID against the expected site (see Get-CIPPFunctionAppResourceGroup). + .PARAMETER ResourceUrl + The Azure resource URL to request the token for. Defaults to Azure Resource Manager. + .EXAMPLE + Get-CIPPManagedIdentityResourceId + Returns e.g. /subscriptions/.../resourcegroups/CIPP-myinstance/providers/Microsoft.Web/sites/cippabcde + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$ResourceUrl = 'https://management.azure.com/' + ) + + $Token = Get-CIPPAzIdentityToken -ResourceUrl $ResourceUrl + if (-not $Token) { + throw 'Could not acquire a managed identity token to read the xms_mirid claim.' + } + + # JWT payload is the second dot-delimited segment, base64url-encoded. + $Parts = $Token.Split('.') + if ($Parts.Count -lt 2) { + throw 'Managed identity token is not a well-formed JWT.' + } + + $Payload = $Parts[1].Replace('-', '+').Replace('_', '/') + switch ($Payload.Length % 4) { + 2 { $Payload += '==' } + 3 { $Payload += '=' } + } + + $Claims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload)) | ConvertFrom-Json + return $Claims.xms_mirid +} diff --git a/Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 new file mode 100644 index 000000000000..eaa6c76bee65 --- /dev/null +++ b/Modules/CIPPCore/Public/Compare-CIPPDlpCompliancePolicy.ps1 @@ -0,0 +1,165 @@ +function ConvertTo-CIPPComparableString { + <# + .SYNOPSIS + Produce an order-independent canonical string for a value, for equality comparison. + .DESCRIPTION + Recursively serializes scalars, dictionaries/objects (keys sorted), and arrays (elements sorted) + into a deterministic string. Two values are equal iff their canonical strings match - independent + of property order or array order, which is the right semantics for DLP locations and the set of + sensitive information types (order is not meaningful for matching). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Value) + + if ($null -eq $Value) { return 'null' } + if ($Value -is [string]) { return '"' + $Value + '"' } + if ($Value -is [bool] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) { + return [string]$Value + } + if ($Value -is [System.Collections.IDictionary]) { + $parts = foreach ($k in (@($Value.Keys) | Sort-Object)) { '"' + $k + '":' + (ConvertTo-CIPPComparableString -Value $Value[$k]) } + return '{' + ($parts -join ',') + '}' + } + if ($Value -is [System.Management.Automation.PSCustomObject]) { + $parts = foreach ($p in ($Value.PSObject.Properties | Sort-Object Name)) { '"' + $p.Name + '":' + (ConvertTo-CIPPComparableString -Value $p.Value) } + return '{' + ($parts -join ',') + '}' + } + if ($Value -is [System.Collections.IEnumerable]) { + $items = @(foreach ($item in $Value) { ConvertTo-CIPPComparableString -Value $item }) | Sort-Object + return '[' + ($items -join ',') + ']' + } + return '"' + ([string]$Value) + '"' +} + +function ConvertTo-CIPPDlpComparable { + <# + .SYNOPSIS + Normalize a DLP policy source (template or live policy) + its rules into a comparable param map. + .DESCRIPTION + Runs the source through the exact same normalization the deploy path uses - allowlist filtering, + location normalization, sensitive-information-type conversion (which also strips output-only + rulePackId), and IncidentReportContent string->array - so a template and the live policy it was + deployed from collapse to identical structures when nothing has actually drifted. + .PARAMETER PolicySource + The policy-level object (a stored template, or a Get-DlpCompliancePolicy result). + .PARAMETER RuleSource + The rule collection (template RuleParams, or Get-DlpComplianceRule results). + .OUTPUTS + PSCustomObject with Policy (hashtable of normalized policy params) and Rules (ordered map of + rule name -> hashtable of normalized rule params). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($PolicySource, $RuleSource) + + $Fields = Get-CIPPDlpComplianceFieldList + + $Policy = Format-CIPPCompliancePolicyParams -Source $PolicySource -AllowedFields $Fields.Policy -LocationFields $Fields.Location + $Policy.Remove('Name') | Out-Null # identity, not a comparable setting + # Mirror deploy: an invalid/transient Mode (e.g. PendingDeletion) is never deployed, so it must not + # register as drift either. + if ($Policy.ContainsKey('Mode') -and $Policy['Mode'] -notin $Fields.ValidPolicyModes) { + $Policy.Remove('Mode') | Out-Null + } + + $Rules = [ordered]@{} + foreach ($Rule in @($RuleSource) | Where-Object { $_ }) { + $RuleParams = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $Fields.Rule + $RuleName = [string]$RuleParams['Name'] + $RuleParams.Remove('Policy') | Out-Null + $RuleParams.Remove('Name') | Out-Null + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { + if ($RuleParams.ContainsKey($SitField)) { + $RuleParams[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleParams[$SitField]) + } + } + if ($RuleParams.ContainsKey('IncidentReportContent') -and $RuleParams['IncidentReportContent'] -is [string]) { + $RuleParams['IncidentReportContent'] = @($RuleParams['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + if (-not [string]::IsNullOrWhiteSpace($RuleName)) { $Rules[$RuleName] = $RuleParams } + } + + return [pscustomobject]@{ Policy = $Policy; Rules = $Rules } +} + +function Compare-CIPPDlpCompliancePolicy { + <# + .SYNOPSIS + Compare a stored DLP template against the live policy + rules in a tenant and report drift. + .DESCRIPTION + Normalizes both sides through ConvertTo-CIPPDlpComparable and diffs them field by field + (policy-level and per-rule, matched by rule name). Returns the overall state and the specific + differing fields with their expected (template) and current (tenant) values, so callers can + decide whether to remediate and can surface exactly what differs. + .PARAMETER TenantFilter + Target tenant. + .PARAMETER Template + The stored template object (already ConvertFrom-Json'd). + .OUTPUTS + PSCustomObject: Name, State ('Missing' | 'PendingDeletion' | 'InSync' | 'Drift'), and Differences + (array of { Scope, Field, Expected, Current }). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantFilter, + [Parameter(Mandatory)] $Template + ) + + $PolicyName = $Template.Name ?? $Template.name + + $LivePolicy = try { + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | + Where-Object { $_.Name -eq $PolicyName } | Select-Object -First 1 + } catch { $null } + + if (-not $LivePolicy) { + return [pscustomobject]@{ Name = $PolicyName; State = 'Missing'; Differences = @() } + } + if ($LivePolicy.Mode -eq 'PendingDeletion') { + return [pscustomobject]@{ Name = $PolicyName; State = 'PendingDeletion'; Differences = @() } + } + + $LiveRules = try { + @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | + Where-Object { $_.ParentPolicyName -eq $PolicyName }) + } catch { @() } + + $Want = ConvertTo-CIPPDlpComparable -PolicySource $Template -RuleSource $Template.RuleParams + $Have = ConvertTo-CIPPDlpComparable -PolicySource $LivePolicy -RuleSource $LiveRules + + $Differences = [System.Collections.Generic.List[object]]::new() + + # Policy-level diff + foreach ($Key in (@($Want.Policy.Keys) + @($Have.Policy.Keys) | Select-Object -Unique)) { + $Expected = if ($Want.Policy.ContainsKey($Key)) { $Want.Policy[$Key] } else { $null } + $Current = if ($Have.Policy.ContainsKey($Key)) { $Have.Policy[$Key] } else { $null } + if ((ConvertTo-CIPPComparableString -Value $Expected) -ne (ConvertTo-CIPPComparableString -Value $Current)) { + $Differences.Add([pscustomobject]@{ Scope = 'Policy'; Field = $Key; Expected = $Expected; Current = $Current }) + } + } + + # Rule-level diff (only rules the template defines; matched by name) + foreach ($RuleName in @($Want.Rules.Keys)) { + if (@($Have.Rules.Keys) -notcontains $RuleName) { + $Differences.Add([pscustomobject]@{ Scope = "Rule '$RuleName'"; Field = '(entire rule)'; Expected = 'present'; Current = 'missing' }) + continue + } + $WantRule = $Want.Rules[$RuleName] + $HaveRule = $Have.Rules[$RuleName] + foreach ($Key in (@($WantRule.Keys) + @($HaveRule.Keys) | Select-Object -Unique)) { + $Expected = if ($WantRule.ContainsKey($Key)) { $WantRule[$Key] } else { $null } + $Current = if ($HaveRule.ContainsKey($Key)) { $HaveRule[$Key] } else { $null } + if ((ConvertTo-CIPPComparableString -Value $Expected) -ne (ConvertTo-CIPPComparableString -Value $Current)) { + $Differences.Add([pscustomobject]@{ Scope = "Rule '$RuleName'"; Field = $Key; Expected = $Expected; Current = $Current }) + } + } + } + + $State = if ($Differences.Count -eq 0) { 'InSync' } else { 'Drift' } + return [pscustomobject]@{ Name = $PolicyName; State = $State; Differences = @($Differences) } +} diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 new file mode 100644 index 000000000000..9294fee20d6f --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitiveInformationType.ps1 @@ -0,0 +1,103 @@ +function ConvertTo-CIPPSensitiveInformationType { + <# + .SYNOPSIS + Normalize a DLP rule's ContentContainsSensitiveInformation value into clean input objects. + .DESCRIPTION + Get-DlpComplianceRule returns ContentContainsSensitiveInformation (and the ExceptIf variant) + in an output-only @odata serialization that New-/Set-DlpComplianceRule will not accept as input. + Two shapes occur: + + - Flat list: an array of SITs, each SIT being an array of '{ _key, _value }' GenericHashTable + pairs - e.g. { _key = 'name'; _value = 'Credit Card Number' }. + + - Grouped: an array containing a single wrapper '{ groups = (...); operator = 'And' }', where + each group is an array of pairs carrying 'name', 'operator', and a nested 'sensitivetypes' + value (itself a flat list of SITs). Used by templates that AND/OR several named groups + together (e.g. HIPAA Enhanced). NOTE the wrapper is delivered inside an array, so the + top-level value is an array in BOTH shapes. + + This collapses every '{ _key, _value }' pair group into a single flat object and recurses through + the grouped / groups / sensitivetypes nesting, producing a structure the New-/Set-* cmdlets accept. + + The function is idempotent: a value already in the clean shape (no '_key' pairs) is returned + unchanged, so it is safe to call at both template-build time and deploy time. + .PARAMETER SensitiveInformation + The ContentContainsSensitiveInformation value to normalize. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($SensitiveInformation) + + if ($null -eq $SensitiveInformation) { return $null } + + # Output-only SIT properties that Get-DlpComplianceRule emits but New-/Set-DlpComplianceRule reject + # as input (matched case-insensitively against the lower-cased key). + $script:InvalidSitProperties = @('rulepackid') + + # Recursively normalize a single entry. An entry is one of: + # - a raw array of { _key, _value } pairs (a SIT, or a group) -> collapse to a flat object + # - a grouped wrapper object { groups, operator } -> recurse each group + # - an already-clean object with a nested sensitivetypes list -> recurse that list + # - anything else -> pass through unchanged + # 'groups' and 'sensitivetypes' are recursed on BOTH the raw and the already-clean paths so the + # conversion is correct whether the wrapper arrives bare or (as the cmdlets deliver it) array-wrapped, + # and so re-running on an already-converted value is a no-op. + function Convert-Entry { + param($Entry) + + if ($null -eq $Entry) { return $null } + + $first = @($Entry) | Where-Object { $null -ne $_ } | Select-Object -First 1 + $isRawPairs = ($Entry -isnot [string]) -and $null -ne $first -and ($first.PSObject.Properties.Name -contains '_key') + + if ($isRawPairs) { + $ht = [ordered]@{} + foreach ($pair in @($Entry)) { + if ($null -eq $pair -or ($pair.PSObject.Properties.Name -notcontains '_key')) { continue } + $key = [string]$pair._key + # Skip output-only properties the New-/Set-* cmdlets reject as input (e.g. rulePackId, + # which Get-DlpComplianceRule emits on every SIT). + if ($key -in $script:InvalidSitProperties) { continue } + if ($key -in @('sensitivetypes', 'groups')) { + $ht[$key] = @(foreach ($child in @($pair._value)) { Convert-Entry -Entry $child }) + } else { + $ht[$key] = $pair._value + } + } + return [pscustomobject]$ht + } + + # Already clean (or partially clean) object - recurse the nested collections, strip invalid + # properties, pass the rest through. Rebuild when there is anything to recurse or strip. + $propNames = @($Entry.PSObject.Properties.Name) + $needsRebuild = ($propNames | Where-Object { $_ -in @('groups', 'sensitivetypes') -or $_ -in $script:InvalidSitProperties }).Count -gt 0 + if ($needsRebuild) { + $clone = [ordered]@{} + foreach ($prop in $Entry.PSObject.Properties) { + if ($prop.Name -in $script:InvalidSitProperties) { continue } + if ($prop.Name -in @('groups', 'sensitivetypes')) { + $clone[$prop.Name] = @(foreach ($child in @($prop.Value)) { Convert-Entry -Entry $child }) + } else { + $clone[$prop.Name] = $prop.Value + } + } + return [pscustomobject]$clone + } + + return $Entry + } + + # Grouped form: a bare wrapper object exposing a 'groups' collection. (When array-wrapped, the + # branch below handles it via Convert-Entry on each element.) + if ($SensitiveInformation -isnot [System.Collections.IEnumerable] -and + ($SensitiveInformation.PSObject.Properties.Name -contains 'groups')) { + # Callers MUST wrap the result in @(...) so this lands as a PswsHashtable[] array on the wire - + # PowerShell unwraps a single-element return to a bare object, which is rejected server-side. + return @(Convert-Entry -Entry $SensitiveInformation) + } + + # Array form (the normal case): flat list of SITs, OR an array carrying the grouped wrapper. + # Callers must wrap in @(...) - see the note above. + return @(foreach ($entry in @($SensitiveInformation)) { Convert-Entry -Entry $entry }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 index 9a171e606982..bd3b2a1338a3 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 @@ -86,12 +86,11 @@ function Start-ContainerUpdateCheck { # Resolve ARM site details $Subscription = Get-CIPPAzFunctionAppSubId $SiteName = $env:WEBSITE_SITE_NAME - $RGName = $env:WEBSITE_RESOURCE_GROUP - if (-not $RGName) { - $Owner = $env:WEBSITE_OWNER_NAME - if ($Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } + try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName + } catch { + Write-Information "Could not determine resource group: $($_.Exception.Message)" + $RGName = $null } $ImageTag = $env:IMAGE_TAG ?? 'unknown' diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 60bce20dc1b0..69879bc6bc84 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -105,6 +105,16 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'knownlocationdbv2' + DataTableProps = @{ + Filter = "PartitionKey eq 'ip' and Timestamp lt datetime'$((Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'" + First = 10000 + Property = @('PartitionKey', 'RowKey', 'ETag') + } + } @{ FunctionName = 'TableCleanupTask' Type = 'DeleteTable' diff --git a/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 b/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 index 8a3196b268a3..383c91d65656 100644 --- a/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 +++ b/Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1 @@ -19,13 +19,7 @@ function Request-CIPPRestart { try { $Subscription = Get-CIPPAzFunctionAppSubId $SiteName = $env:WEBSITE_SITE_NAME - $RGName = $env:WEBSITE_RESOURCE_GROUP - if (-not $RGName) { - $Owner = $env:WEBSITE_OWNER_NAME - if ($Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } - } + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName if (-not ($Subscription -and $RGName -and $SiteName)) { throw 'Azure App Service details could not be determined from environment' } diff --git a/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 b/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 index 3284012a7a4a..669cf204bcde 100644 --- a/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 +++ b/Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1 @@ -10,11 +10,7 @@ function Get-ApplicationInsightsQuery { } $SubscriptionId = Get-CIPPAzFunctionAppSubId - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } + $RGName = Get-CIPPFunctionAppResourceGroup $AppInsightsName = $env:WEBSITE_SITE_NAME $Body = @{ diff --git a/Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 b/Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 new file mode 100644 index 000000000000..d73119667d10 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPDlpComplianceFieldList.ps1 @@ -0,0 +1,65 @@ +function Get-CIPPDlpComplianceFieldList { + <# + .SYNOPSIS + Single source of truth for the DLP compliance policy/rule cmdlet parameter allowlists. + .DESCRIPTION + The New-/Set-DlpCompliancePolicy and New-/Set-DlpComplianceRule cmdlets accept only a subset of + the (much larger) set of properties Get-* returns. These allowlists are shared by every code path + that builds or compares DLP policy params - template creation, deploy, and drift comparison - so + the accepted fields never diverge between them (divergence here previously caused 'Mode'/'Priority' + being sent where invalid, etc.). + + Priority is intentionally excluded: Microsoft assigns it per tenant from existing policy ordering, + so it varies between tenants and must not be captured into, deployed from, or drift-compared. + .OUTPUTS + PSCustomObject with Policy, Rule, and Location (subset of Policy) string arrays. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Policy = @( + 'Name', 'Comment', 'Mode', + 'ExchangeLocation', 'ExchangeLocationException', + 'SharePointLocation', 'SharePointLocationException', + 'OneDriveLocation', 'OneDriveLocationException', + 'TeamsLocation', 'TeamsLocationException', + 'EndpointDlpLocation', 'EndpointDlpLocationException', + 'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException', + 'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException', + 'PowerBIDlpLocation', 'PowerBIDlpLocationException', + 'ModernGroupLocation', 'ModernGroupLocationException' + ) + + # Note: DLP rules have no 'Mode' parameter (that is policy-level). 'Policy' is the parent reference + # added at deploy time; it is not a comparable setting. + $Rule = @( + 'Name', 'Policy', 'Comment', 'Disabled', + 'ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation', + 'ContentPropertyContainsWords', 'BlockAccess', 'BlockAccessScope', + 'NotifyUser', 'NotifyEmailCustomText', 'NotifyEmailCustomSubject', + 'NotifyPolicyTipCustomText', 'GenerateAlert', 'AlertProperties', + 'GenerateIncidentReport', 'IncidentReportContent', + 'AccessScope', 'From', 'FromMemberOf', 'FromAddressContainsWords', + 'FromAddressMatchesPatterns', 'SentTo', 'SentToMemberOf', + 'RecipientDomainIs', 'AnyOfRecipientAddressContainsWords', + 'AnyOfRecipientAddressMatchesPatterns', 'AnyOfRecipientAddressDomainIs', + 'ExceptIfFrom', 'ExceptIfFromMemberOf', 'ExceptIfFromAddressContainsWords', + 'ExceptIfFromAddressMatchesPatterns', + 'AddRecipients', 'BlockMessage', 'GenerateAlertOn', 'IncidentReportTo', + 'ReportSeverityLevel', 'RuleErrorAction', + 'ContentExtensionMatchesWords', 'DocumentNameMatchesPatterns', + 'DocumentNameMatchesWords', 'DocumentSizeOver', + 'ContentCharacterSetContainsWords', 'ContentFileTypeMatches' + ) + + return [pscustomobject]@{ + Policy = $Policy + Rule = $Rule + Location = @($Policy | Where-Object { $_ -like '*Location*' }) + # Valid -Mode input values for New-/Set-DlpCompliancePolicy. Transient/output-only states such as + # 'PendingDeletion' are NOT accepted as input and must be dropped before deploy. + ValidPolicyModes = @('Enable', 'TestWithNotifications', 'TestWithoutNotifications', 'Disable') + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 new file mode 100644 index 000000000000..6d60d71fe1aa --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 @@ -0,0 +1,149 @@ +function Get-CIPPGeoIPLocationBatch { + <# + .SYNOPSIS + Resolve many IPs to geo-location in one pass, warming the knownlocationdbv2 cache. + .DESCRIPTION + Normalizes + de-dupes the input IPs and drops redacted / reserved / private / link-local + addresses (never geolocatable). Remaining IPs are seeded from knownlocationdbv2 (fresh + entries only); cache misses are resolved in bulk via the geoipdb /GetIPInfoBatch endpoint + (which proxies ip-api's batch API, 100 IPs per upstream request). Successful results are + written back to knownlocationdbv2 and cachegeoip so later processing is a cache hit. + + Returns a hashtable keyed by normalized IP -> flattened location object + @{ CountryOrRegion; City; Proxy; Hosting; ASName }. Failed/unknown lookups are NOT cached + (no poisoning) and are absent from the returned hashtable. + + Used both at ingestion (warm the cache up front) and as a per-batch prefetch in the audit + log processor (so the per-record loop is a pure in-memory lookup). + .PARAMETER IPs + IP addresses to resolve. Duplicates, reserved IPs and ports are handled automatically. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [string[]]$IPs + ) + + # 20s timeout, up to 3 attempts for the geoip HTTP calls. The short timeout stops a single + # hung IP from stalling the whole batch; the retries ride out transient blips before we give up. + function Invoke-GeoRetry { + param([string]$Uri, [string]$Method = 'GET', $Body, [string]$ContentType, [int]$Retries = 3, [int]$TimeoutSec = 20) + $lastErr = $null + for ($attempt = 1; $attempt -le $Retries; $attempt++) { + try { + if ($PSBoundParameters.ContainsKey('Body')) { + return Invoke-CIPPRestMethod -Uri $Uri -Method $Method -Body $Body -ContentType $ContentType -TimeoutSec $TimeoutSec + } else { + return Invoke-CIPPRestMethod -Uri $Uri -Method $Method -TimeoutSec $TimeoutSec + } + } catch { + $lastErr = $_ + if ($attempt -lt $Retries) { Start-Sleep -Milliseconds (300 * $attempt) } + } + } + throw $lastErr + } + + $ClientIpRegex = [regex]'^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' + $ReservedIpRegex = [regex]::new( + '^(?:10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2[0-9]|3[01])\.|100\.(?:6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(?:22[4-9]|23[0-9]|24[0-9]|25[0-5])\.|::1?$|fe[89ab]|f[cd]|ff)', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + + $Result = @{} + + # Normalize (strip :port / brackets), drop redacted + reserved, de-dupe + $Distinct = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($ip in $IPs) { + if ([string]::IsNullOrWhiteSpace($ip)) { continue } + $clean = $ClientIpRegex.Replace(([string]$ip).Trim(), '$1') -replace '[\[\]]', '' + if ([string]::IsNullOrWhiteSpace($clean) -or $clean -match '[X]+') { continue } + if ($ReservedIpRegex.IsMatch($clean)) { continue } + $null = $Distinct.Add($clean) + } + if ($Distinct.Count -eq 0) { return $Result } + + $LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2' + $ValidAfter = (Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + + # 1) Seed from knownlocationdbv2 (fresh, non-Unknown entries); collect the misses + $ToResolve = [System.Collections.Generic.List[string]]::new() + foreach ($ip in $Distinct) { + $cached = Get-CIPPAzDataTableEntity @LocationTable -Filter "PartitionKey eq 'ip' and RowKey eq '$ip' and Timestamp ge datetime'$ValidAfter'" + if ($cached -and $cached.CountryOrRegion -and $cached.CountryOrRegion -ne 'Unknown') { + $Result[$ip] = [pscustomobject]@{ + CountryOrRegion = $cached.CountryOrRegion + City = $cached.City + Proxy = $cached.Proxy + Hosting = $cached.Hosting + ASName = $cached.ASName + } + } else { + $ToResolve.Add($ip) + } + } + if ($ToResolve.Count -eq 0) { return $Result } + + # 2) Bulk-resolve the misses via geoipdb /GetIPInfoBatch (chunk to 100 to bound payloads) + $CacheGeoIPTable = Get-CippTable -TableName 'cachegeoip' + $KnownEntities = [System.Collections.Generic.List[object]]::new() + $CacheGeoEntities = [System.Collections.Generic.List[object]]::new() + + for ($i = 0; $i -lt $ToResolve.Count; $i += 100) { + $chunk = @($ToResolve[$i..([Math]::Min($i + 99, $ToResolve.Count - 1))]) + $payload = '[' + (($chunk | ForEach-Object { $_ | ConvertTo-Json }) -join ',') + ']' + $resp = $null + try { + $resp = Invoke-GeoRetry -Uri 'https://geoipdb.azurewebsites.net/api/GetIPInfoBatch' -Method POST -Body $payload -ContentType 'application/json' + } catch { + #Write-LogMessage -API GeoIPLocation -message "Bulk geoip lookup failed, falling back to single lookups for $($chunk.Count) IP(s): $($_.Exception.Message)" -sev Warning + $fb = [System.Collections.Generic.List[object]]::new() + foreach ($ip in $chunk) { + try { + $s = Invoke-GeoRetry -Uri "https://geoipdb.azurewebsites.net/api/GetIPInfo?IP=$ip" + if ($s -and $s.status -ne 'fail') { $fb.Add([pscustomobject]@{ query = $ip; status = 'success'; countryCode = $s.countryCode; city = $s.city; proxy = $s.proxy; hosting = $s.hosting; asname = $s.asname }) } + } catch { } + } + $resp = $fb + } + foreach ($r in $resp) { + $ip = [string]$r.query + if ([string]::IsNullOrWhiteSpace($ip) -or $r.status -ne 'success') { continue } + $loc = [pscustomobject]@{ + CountryOrRegion = if ($r.countryCode) { $r.countryCode } else { 'Unknown' } + City = if ($r.city) { $r.city } else { 'Unknown' } + Proxy = if ($null -ne $r.proxy) { $r.proxy } else { 'Unknown' } + Hosting = if ($null -ne $r.hosting) { $r.hosting } else { 'Unknown' } + ASName = if ($r.asname) { $r.asname } else { 'Unknown' } + } + $Result[$ip] = $loc + # Only cache real results - never persist Unknown (no poisoning, matches single path) + if ($loc.CountryOrRegion -ne 'Unknown') { + $KnownEntities.Add(@{ + PartitionKey = 'ip' + RowKey = $ip + CountryOrRegion = "$($loc.CountryOrRegion)" + City = "$($loc.City)" + Proxy = "$($loc.Proxy)" + Hosting = "$($loc.Hosting)" + ASName = "$($loc.ASName)" + }) + $CacheGeoEntities.Add(@{ + PartitionKey = 'IP' + RowKey = $ip + Data = [string]($r | ConvertTo-Json -Compress) + }) + } + } + } + + # 3) Batch-write the caches + if ($KnownEntities.Count -gt 0) { + try { $null = Add-CIPPAzDataTableEntity @LocationTable -Entity @($KnownEntities) -Force } + catch { Write-LogMessage -API GeoIPLocation -message "Failed to cache $($KnownEntities.Count) bulk geo results: $($_.Exception.Message)" -sev Warning } + } + if ($CacheGeoEntities.Count -gt 0) { + try { $null = Add-AzDataTableEntity @CacheGeoIPTable -Entity @($CacheGeoEntities) -Force } catch {} + } + + return $Result +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 new file mode 100644 index 000000000000..fbf664a6261b --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPFunctionAppResourceGroup.ps1 @@ -0,0 +1,50 @@ +function Get-CIPPFunctionAppResourceGroup { + <# + .SYNOPSIS + Resolve the resource group that the CIPP Function App site lives in. + .DESCRIPTION + Returns the resource group of the running Function App, using authoritative sources only: + + 1. WEBSITE_RESOURCE_GROUP - platform-injected, the site's actual RG. Free, no decode. + 2. xms_mirid claim from the managed identity token - the site's own ARM resource ID, + present even when WEBSITE_RESOURCE_GROUP is empty, needs no extra call or permission. + + The legacy approach of parsing WEBSITE_OWNER_NAME is intentionally NOT used: that string + encodes the App Service Plan's webspace RG, which is frequently different from the site's RG + (e.g. it returns 'DefaultResourceGroup-WEU' or '-m01' for sites whose plan was created + in an auto-generated/other resource group). Writing auth settings, restarting, or querying + the wrong RG is worse than failing, so this throws when no reliable source is available. + .PARAMETER SiteName + The Function App site name to resolve. Defaults to WEBSITE_SITE_NAME. + .EXAMPLE + Get-CIPPFunctionAppResourceGroup + Returns e.g. 'CIPP-myinstance' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$SiteName = $env:WEBSITE_SITE_NAME + ) + + # 1. Platform-injected site resource group - authoritative, zero cost. + if ($env:WEBSITE_RESOURCE_GROUP) { + return $env:WEBSITE_RESOURCE_GROUP + } + + # 2. The managed identity's own token names this site's resource ID (incl. RG). Only trust it + # when it actually points at this Microsoft.Web/sites resource, so a user-assigned identity + # (whose xms_mirid is a userAssignedIdentities resource) falls through rather than returning + # the identity's RG. + try { + $MiRid = Get-CIPPManagedIdentityResourceId + if ($SiteName -and $MiRid -match "(?i)/resourcegroups/(?[^/]+)/providers/Microsoft\.Web/sites/$([regex]::Escape($SiteName))(/|$)") { + return $Matches.RG + } + Write-Information "xms_mirid did not match site '$SiteName': $MiRid" + } catch { + Write-Warning "Could not read resource group from managed identity token: $($_.Exception.Message)" + } + + # 3. No reliable source - fail loudly rather than guess from WEBSITE_OWNER_NAME. + throw "Could not determine the function app resource group for site '$SiteName'. WEBSITE_RESOURCE_GROUP is empty and the managed identity resource ID was unavailable." +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 620f36a0c210..39b3e2272150 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -4,11 +4,21 @@ function Get-CippSamPermissions { This script retrieves the CIPP-SAM permissions. .DESCRIPTION - The Get-CippSamManifest function is used to retrieve the CIPP-SAM permissions either from the manifest files or table. + Retrieves the CIPP-SAM permissions as a layered set: the permissions defined in the SAM manifest + files (SAMManifest.json + AdditionalPermissions.json) are ALWAYS treated as the required base and + can never be removed. Any permissions saved in the AppPermissions table are treated as EXTRAS that + are layered on top of (not instead of) the manifest base. + + The effective set returned in .Permissions is therefore always manifest ∪ extras. Each permission + is annotated with a 'required' boolean so the UI can lock the manifest-defined defaults. + + Unless -NoDiff is used, the function also pulls the live CIPP-SAM application registration from the + partner tenant and diffs its requiredResourceAccess against the effective set, surfacing + permissions that need to be added to (MissingPermissions) and removed from (PartnerAppDiff) the app. .EXAMPLE - Get-CippSamManifest - Retrieves the CIPP SAM manifest located in the module root + Get-CippSamPermissions + Returns the effective permission set plus the partner app drift diff. .FUNCTIONALITY Internal @@ -23,6 +33,8 @@ function Get-CippSamPermissions { [switch]$NoDiff ) + $GuidRegex = '^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$' + if (!$SavedOnly.IsPresent) { # Return cached result if available and less than 5 minutes old (avoids duplicate partner-tenant Graph calls within same invocation) if ($NoDiff.IsPresent -and $script:CippSamPermissionsCache -and @@ -32,12 +44,12 @@ function Get-CippSamPermissions { } $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') - $AdditionalPermissions = Get-Item (Join-Path $env:CIPPRootPath 'Config\AdditionalPermissions.json') + $AdditionalPermissionsFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\AdditionalPermissions.json') $ServicePrincipalList = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/servicePrincipals?$top=999&$select=id,appId,displayName' -tenantid $env:TenantID -NoAuthCheck $true $SAMManifest = Get-Content -Path $SamManifestFile.FullName | ConvertFrom-Json - $AdditionalPermissions = Get-Content -Path $AdditionalPermissions.FullName | ConvertFrom-Json + $AdditionalPermissions = Get-Content -Path $AdditionalPermissionsFile.FullName | ConvertFrom-Json $RequiredResources = $SAMManifest.requiredResourceAccess @@ -59,14 +71,15 @@ function Get-CippSamPermissions { $_.body } - $Permissions = @{} + # Build the manifest (required / default) permission set. These are immutable and always required. + $ManifestPermissions = @{} foreach ($AppId in $AppIds) { $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId $AppPermissions = [System.Collections.Generic.List[object]]@() - $ManifestPermissions = ($RequiredResources | Where-Object -Property resourceAppId -EQ $AppId).resourceAccess + $ManifestResourceAccess = ($RequiredResources | Where-Object -Property resourceAppId -EQ $AppId).resourceAccess $UnpublishedPermissions = ($AdditionalPermissions | Where-Object -Property resourceAppId -EQ $AppId).resourceAccess - foreach ($Permission in $ManifestPermissions) { + foreach ($Permission in $ManifestResourceAccess) { $AppPermissions.Add($Permission) } if ($UnpublishedPermissions) { @@ -75,10 +88,10 @@ function Get-CippSamPermissions { } } - $ApplicationPermissions = [system.collections.generic.list[object]]@() - $DelegatedPermissions = [system.collections.generic.list[object]]@() + $ApplicationPermissions = [System.Collections.Generic.List[object]]@() + $DelegatedPermissions = [System.Collections.Generic.List[object]]@() foreach ($Permission in $AppPermissions) { - if ($Permission.id -match '^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$') { + if ($Permission.id -match $GuidRegex) { if ($Permission.type -eq 'Role') { $PermissionName = ($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Permission.id).value } else { @@ -88,112 +101,175 @@ function Get-CippSamPermissions { $PermissionName = $Permission.id } + $Entry = [PSCustomObject]@{ + id = $Permission.id + value = $PermissionName + required = $true + } if ($Permission.type -eq 'Role') { - $ApplicationPermissions.Add([PSCustomObject]@{ - id = $Permission.id - value = $PermissionName - - }) + $ApplicationPermissions.Add($Entry) } else { - $DelegatedPermissions.Add([PSCustomObject]@{ - id = $Permission.id - value = $PermissionName - }) + $DelegatedPermissions.Add($Entry) } } - $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $Permissions.$AppId = @{ - applicationPermissions = @($ApplicationPermissions | Sort-Object -Property label) - delegatedPermissions = @($DelegatedPermissions | Sort-Object -Property label) + $ManifestPermissions.$AppId = @{ + applicationPermissions = @($ApplicationPermissions | Sort-Object -Property value) + delegatedPermissions = @($DelegatedPermissions | Sort-Object -Property value) } } } + if ($ManifestOnly) { return [PSCustomObject]@{ - Permissions = $Permissions + Permissions = [PSCustomObject]$ManifestPermissions Type = 'Manifest' } } + # Load the saved EXTRA permissions (layered on top of the manifest base) $Table = Get-CippTable -tablename 'AppPermissions' - $SavedPermissions = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" - if ($SavedPermissions.Permissions) { + $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if ($SavedRow.Permissions) { try { - $SavedPermissions.Permissions = $SavedPermissions.Permissions | ConvertFrom-Json -ErrorAction Stop + $SavedPermissions = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop } catch { - $SavedPermissions.Permissions = [PSCustomObject]@{} + $SavedPermissions = [PSCustomObject]@{} } } else { - $SavedPermissions = @{ - Permissions = [PSCustomObject]@{} - } + $SavedPermissions = [PSCustomObject]@{} } if ($SavedOnly.IsPresent) { - $SavedPermissions | Add-Member -MemberType NoteProperty -Name Type -Value 'Table' - return $SavedPermissions + return [PSCustomObject]@{ + Permissions = $SavedPermissions + Type = 'Table' + } } - if (!$NoDiff.IsPresent -and $SavedPermissions.Permissions) { - $DiffPermissions = @{} - foreach ($AppId in $AppIds) { - $ManifestSpPermissions = $Permissions.$AppId - $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $SavedSpPermission = $SavedPermissions.Permissions.$AppId - $MissingApp = [System.Collections.Generic.List[object]]::new() - $MissingDelegated = [System.Collections.Generic.List[object]]::new() - foreach ($Permission in $ManifestSpPermissions.applicationPermissions) { - if ($SavedSpPermission.applicationPermissions.id -notcontains $Permission.id) { - $AppRole = $ServicePrincipal.appRoles | Where-Object -Property id -EQ $Permission.id | Select-Object id, value - $MissingApp.Add($AppRole ?? $Permission) - } + # Build the effective set = manifest (required) ∪ saved extras (required = false). + # Manifest permissions are always present, so a stale/edited saved set can never drop a required scope. + $EffectivePermissions = @{} + $AdditionalOnly = @{} + $AllAppIds = @(@($ManifestPermissions.Keys) + @($SavedPermissions.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique + + foreach ($AppId in $AllAppIds) { + $ManifestApp = $ManifestPermissions.$AppId + $SavedApp = $SavedPermissions.$AppId + + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $EffApp = [System.Collections.Generic.List[object]]::new() + $EffDel = [System.Collections.Generic.List[object]]::new() + $ExtraApp = [System.Collections.Generic.List[object]]::new() + $ExtraDel = [System.Collections.Generic.List[object]]::new() + + foreach ($Permission in $ManifestApp.applicationPermissions) { $EffApp.Add($Permission) } + foreach ($Permission in $ManifestApp.delegatedPermissions) { $EffDel.Add($Permission) } + + foreach ($Permission in $SavedApp.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $Extra = [PSCustomObject]@{ id = $Permission.id; value = $Permission.value; required = $false } + $EffApp.Add($Extra) + $ExtraApp.Add($Extra) } - foreach ($Permission in $ManifestSpPermissions.delegatedPermissions) { - if ($SavedSpPermission.delegatedPermissions.id -notcontains $Permission.id) { - $PermissionScope = $ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Permission.id | Select-Object id, value - $MissingDelegated.Add($PermissionScope ?? $Permission) - } + } + foreach ($Permission in $SavedApp.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $Extra = [PSCustomObject]@{ id = $Permission.id; value = $Permission.value; required = $false } + $EffDel.Add($Extra) + $ExtraDel.Add($Extra) } - if ($MissingApp -or $MissingDelegated) { - $DiffPermissions.$AppId = @{ - applicationPermissions = $MissingApp - delegatedPermissions = $MissingDelegated - } + } + + $EffectivePermissions.$AppId = @{ + applicationPermissions = @($EffApp | Sort-Object -Property value) + delegatedPermissions = @($EffDel | Sort-Object -Property value) + } + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $AdditionalOnly.$AppId = @{ + applicationPermissions = @($ExtraApp) + delegatedPermissions = @($ExtraDel) } } } - $SamAppPermissions = @{} - if (($SavedPermissions.Permissions.PSObject.Properties.Name | Measure-Object).Count -gt 0) { - $SamAppPermissions.Permissions = $SavedPermissions.Permissions - $SamAppPermissions.UsedServicePrincipals = $UsedServicePrincipals - $SamAppPermissions.UpdatedBy = $SavedPermissions.UpdatedBy - $SamAppPermissions.Timestamp = $SavedPermissions.Timestamp.DateTime.ToString('yyyy-MM-ddTHH:mm:ssZ') - $SamAppPermissions.Type = 'Table' - } else { - $SamAppPermissions.Permissions = $Permissions - $SamAppPermissions.UsedServicePrincipals = $UsedServicePrincipals - $SamAppPermissions.Type = 'Manifest' - $SamAppPermissions.UpdatedBy = 'CIPP' - $SamAppPermissions.Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - - $Entity = @{ - 'PartitionKey' = 'CIPP-SAM' - 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$Permissions | ConvertTo-Json -Depth 10 -Compress) - 'UpdatedBy' = 'CIPP' - } - $Table = Get-CIPPTable -TableName 'AppPermissions' + # Diff the effective set against the live CIPP-SAM application registration in the partner tenant. + # MissingPermissions = effective perms not yet on the app (need to be added). + # PartnerAppDiff also surfaces extra perms on the app that are not in the effective set (need to be removed). + $MissingPermissions = @{} + $PartnerAppDiff = @{} + if (!$NoDiff.IsPresent) { try { - $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true + foreach ($AppId in $AllAppIds) { + $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId + $AppRegResource = $PartnerApp.requiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId + $AppRegRoleIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Role' }).id) + $AppRegScopeIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Scope' }).id) + + # Only GUID-based permissions live in the app registration's requiredResourceAccess. + # String-named scopes (e.g. the .Sdp AdditionalPermissions) are applied as direct grants, + # so excluding them here avoids permanent false-positive "missing" entries. + $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) + $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions | Where-Object { $_.id -match $GuidRegex }) + $EffAppIds = @($EffApp.id) + $EffDelIds = @($EffDel.id) + + $MissingApp = @(foreach ($Permission in $EffApp) { if ($AppRegRoleIds -notcontains $Permission.id) { $Permission } }) + $MissingDel = @(foreach ($Permission in $EffDel) { if ($AppRegScopeIds -notcontains $Permission.id) { $Permission } }) + $ExtraApp = @(foreach ($Id in $AppRegRoleIds) { + if ($EffAppIds -notcontains $Id) { + [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } + } + }) + $ExtraDel = @(foreach ($Id in $AppRegScopeIds) { + if ($EffDelIds -notcontains $Id) { + [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Id).value) ?? $Id } + } + }) + + if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) { + $MissingPermissions.$AppId = @{ + applicationPermissions = $MissingApp + delegatedPermissions = $MissingDel + } + } + if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0 -or $ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $PartnerAppDiff.$AppId = @{ + missingApplicationPermissions = $MissingApp + missingDelegatedPermissions = $MissingDel + extraApplicationPermissions = $ExtraApp + extraDelegatedPermissions = $ExtraDel + } + } + } } catch { - Write-Error "Failed to save the CIPP-SAM permissions: $($_.Exception.Message)" + Write-Information "Failed to retrieve partner app registration for permission diff: $($_.Exception.Message)" } } - if (!$NoDiff.IsPresent -and $SamAppPermissions.Type -eq 'Table') { - $SamAppPermissions.MissingPermissions = $DiffPermissions + $Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime() + if ($SavedRow.Timestamp) { + $SavedTimestamp = $SavedRow.Timestamp.DateTime.ToUniversalTime() + if ($SavedTimestamp -gt $Timestamp) { + $Timestamp = $SavedTimestamp + } + } + + $HasSaved = ($SavedPermissions.PSObject.Properties.Name | Measure-Object).Count -gt 0 + + $SamAppPermissions = [PSCustomObject]@{ + Permissions = [PSCustomObject]$EffectivePermissions + DefaultPermissions = [PSCustomObject]$ManifestPermissions + AdditionalPermissions = [PSCustomObject]$AdditionalOnly + MissingPermissions = [PSCustomObject]$MissingPermissions + PartnerAppDiff = [PSCustomObject]$PartnerAppDiff + UsedServicePrincipals = $UsedServicePrincipals + Type = if ($HasSaved) { 'Table' } else { 'Manifest' } + UpdatedBy = $SavedRow.UpdatedBy ?? 'CIPP' + Timestamp = $Timestamp.ToString('yyyy-MM-ddTHH:mm:ssZ') } $SamAppPermissions = $SamAppPermissions | ConvertTo-Json -Depth 10 -Compress | ConvertFrom-Json @@ -205,4 +281,3 @@ function Get-CippSamPermissions { return $SamAppPermissions } - diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index 632fd0e7c6d6..f562d32c2eed 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -48,7 +48,7 @@ function New-ExoRequest { } else { $Params = @{} } - $ExoBody = ConvertTo-Json -Depth 5 -Compress -InputObject @{ + $ExoBody = ConvertTo-Json -Depth 20 -Compress -InputObject @{ CmdletInput = @{ CmdletName = $cmdlet Parameters = $Params diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 index ad9995541c28..5b3ed653fe58 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 @@ -38,16 +38,7 @@ function Set-CIPPOffloadFunctionTriggers { } # Determine resource group - if ($env:WEBSITE_RESOURCE_GROUP) { - $ResourceGroupName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $ResourceGroupName = $Matches.RGName - } else { - throw 'Could not determine resource group. Please provide ResourceGroupName parameter.' - } - } + $ResourceGroupName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName # Define the triggers to disable when offloading is enabled $TargetedTriggers = @( diff --git a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index 2724877e0557..c4f40e526453 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 @@ -306,8 +306,9 @@ function Invoke-CIPPOffboardingJob { } if ($Batch.Count -eq 0) { - Write-LogMessage -API $APIName -tenant $TenantFilter -message "No offboarding tasks selected for user $Username" -sev Warning - return "No offboarding tasks were selected for $Username" + $NoTasksMessage = "No offboarding tasks were selected for $Username. The offboarding job was not executed - check that at least one action was enabled." + Write-LogMessage -API $APIName -tenant $TenantFilter -message $NoTasksMessage -sev Error + throw $NoTasksMessage } Write-Information "Built batch of $($Batch.Count) offboarding tasks for $Username" diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index e03b78f77a3f..e03fd8ee901f 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -104,6 +104,7 @@ function Invoke-CIPPTestCollection { $Table = Get-CippTable -tablename 'CippTestResults' $ResultBatch = [System.Collections.Generic.List[hashtable]]::new() + $AlertBatch = [System.Collections.Generic.List[object]]::new() foreach ($Guid in $EnabledGuids) { $ItemStopwatch = [System.Diagnostics.Stopwatch]::StartNew() @@ -113,6 +114,8 @@ function Invoke-CIPPTestCollection { foreach ($Entity in $TestOutput) { if ($Entity -is [hashtable] -and $Entity.PartitionKey -and $Entity.RowKey) { $ResultBatch.Add($Entity) + } elseif ($Entity -isnot [hashtable] -and $Entity.PSObject.Properties['CippCustomTestAlert']) { + $AlertBatch.Add($Entity) } } if ($ResultBatch.Count -ge 100) { @@ -141,6 +144,12 @@ function Invoke-CIPPTestCollection { Write-Information " [Custom] Flushed final $($ResultBatch.Count) results to table" } + # Ship a single aggregated alert for the tenant covering all alert-worthy results. + if ($AlertBatch.Count -gt 0) { + Write-Information " [Custom] Shipping $($AlertBatch.Count) custom test alert(s) for $TenantFilter" + Send-CIPPCustomTestAlert -TenantFilter $TenantFilter -Alerts @($AlertBatch) + } + $SuiteStopwatch.Stop() $TotalElapsed = '{0:N3}' -f $SuiteStopwatch.Elapsed.TotalSeconds $Summary = "Custom suite for $TenantFilter completed in ${TotalElapsed}s — $SuccessCount/$($EnabledGuids.Count) ran, $FailedCount errored" diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 index 341f7eb08f9f..1dddcca1b4cd 100644 --- a/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpSpec.ps1 @@ -16,19 +16,9 @@ function Get-CippMcpSpec { return $script:CippMcpSpec } - $Root = $env:CIPPRootPath - if (-not $Root -or -not (Test-Path (Join-Path $Root 'openapi.json'))) { - # Fallback: walk up from this module until openapi.json is found. - $Root = $PSScriptRoot - while ($Root -and -not (Test-Path (Join-Path $Root 'openapi.json'))) { - $Parent = Split-Path $Root -Parent - if (-not $Parent -or $Parent -eq $Root) { $Root = $null; break } - $Root = $Parent - } - } + $SpecPath = Join-Path -Path $env:CIPPRootPath -ChildPath 'Config\openapi.json' - $SpecPath = if ($Root) { Join-Path $Root 'openapi.json' } else { $null } - if (-not $SpecPath -or -not (Test-Path $SpecPath)) { + if (-not (Test-Path $SpecPath)) { throw [pscustomobject]@{ code = -32603; message = 'OpenAPI spec (openapi.json) not found; cannot project MCP tools.' } } diff --git a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 index db1d5e455da1..063718e85705 100644 --- a/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPAlertTemplate.ps1 @@ -87,6 +87,43 @@ function New-CIPPAlertTemplate { $ButtonUrl = "$CIPPURL/standards/list-standards" $ButtonText = 'Check Standards configuration' } + if ($InputObject -eq 'customScript') { + # $Data is an array of custom-test alert records (one per failing test for this tenant). + $Alerts = @($Data) + $Count = $Alerts.Count + $Title = if ($Count -eq 1) { + "$($Tenant) - Custom test '$($Alerts[0].ScriptName)' returned status '$($Alerts[0].Status)'" + } else { + "$($Tenant) - $Count custom tests need attention" + } + + $SummaryRows = foreach ($Alert in $Alerts) { + [PSCustomObject]@{ + Test = $Alert.ScriptName + Status = $Alert.Status + Risk = if ($Alert.Risk) { $Alert.Risk } else { 'Medium' } + } + } + $SummaryHTML = ($SummaryRows | ConvertTo-Html -Fragment | Out-String).Replace('', '
') + $IntroText = "

You're receiving this because you enabled failure alerts for one or more custom tests. The following custom test(s) on tenant $($Tenant) need attention:

$SummaryHTML" + + foreach ($Alert in $Alerts) { + $IntroText += "

$($Alert.ScriptName) — $($Alert.Status)

" + if (![string]::IsNullOrWhiteSpace($Alert.ErrorMessage)) { + $IntroText += "

The test failed to execute: $($Alert.ErrorMessage)

" + } elseif (![string]::IsNullOrWhiteSpace($Alert.ResultMarkdown)) { + $IntroText += "
$($Alert.ResultMarkdown)
" + } elseif ($Alert.FailedRows) { + # Normalize string rows to objects so ConvertTo-Html renders a message column + # instead of the string's Length property. + $Rows = foreach ($r in @($Alert.FailedRows)) { if ($r -is [string]) { [PSCustomObject]@{ message = $r } } else { $r } } + $DetailHTML = ($Rows | Select-Object * -ExcludeProperty Etag, PartitionKey, TimeStamp | ConvertTo-Html -Fragment | Out-String).Replace('
', '
') + $IntroText += "

Results:

$DetailHTML" + } + } + $ButtonUrl = "$CIPPURL/tools/custom-tests" + $ButtonText = 'View custom test results' + } if ($InputObject -eq 'auditlog') { $ButtonUrl = "$CIPPURL/identity/administration/users/user/bec?userId=$($data.ObjectId)&tenantFilter=$Tenant" $ButtonText = 'User Management' diff --git a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 index fb995c5e2986..d76b15ade594 100644 --- a/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 +++ b/Modules/CIPPCore/Public/New-CippCustomScriptExecution.ps1 @@ -59,8 +59,6 @@ function New-CippCustomScriptExecution { # Get script content $ScriptContent = $Script.ScriptContent - Write-LogMessage -API 'CustomScript' -tenant $TenantFilter -message "Executing custom script: $($Script.ScriptName) (Version: $($Script.Version))" -sev Info - # Convert Parameters to hashtable if it's a PSCustomObject (from JSON) if ($Parameters -is [PSCustomObject]) { $ParamsHash = @{} diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index c63ff4872094..226e9b994542 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,9 +1,15 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Updates CIPP-SAM app permissions by merging missing permissions. + Repairs the CIPP-SAM app registration permissions in the partner tenant. .DESCRIPTION - Retrieves current SAM permissions, merges any missing permissions, and updates the AppPermissions table. + Diffs the effective CIPP-SAM permission set (manifest defaults + saved extras) against the live + CIPP-SAM application registration in the partner tenant and ADDS any missing permissions to the + app registration's requiredResourceAccess. This is additive only: it never removes permissions, + so it cannot strip a legitimately-configured entry. Extra permissions found on the app that are + not part of the effective set are reported back so an admin can review/remove them manually. + + Pushing these permissions out to customer tenants is handled separately by the CPV refresh. .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -17,62 +23,85 @@ function Update-CippSamPermissions { try { $CurrentPermissions = Get-CippSamPermissions + $PartnerAppDiff = $CurrentPermissions.PartnerAppDiff + $MissingPermissions = $CurrentPermissions.MissingPermissions - if (($CurrentPermissions.MissingPermissions | Measure-Object).Count -eq 0) { + $MissingAppIds = @($MissingPermissions.PSObject.Properties.Name) + $ExtraAppIds = @($PartnerAppDiff.PSObject.Properties.Name | Where-Object { + ($PartnerAppDiff.$_.extraApplicationPermissions | Measure-Object).Count -gt 0 -or + ($PartnerAppDiff.$_.extraDelegatedPermissions | Measure-Object).Count -gt 0 + }) + + if ($MissingAppIds.Count -eq 0) { + if ($ExtraAppIds.Count -gt 0) { + $ExtraSummary = foreach ($AppId in $ExtraAppIds) { + $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) + "$AppId ($($Names -join ', '))" + } + return "No missing permissions to add. The following extra permissions are present on the app and should be reviewed/removed manually: $($ExtraSummary -join '; ')" + } return 'No permissions to update' } - Write-Information 'Missing permissions found' - $MissingPermissions = $CurrentPermissions.MissingPermissions - $Permissions = $CurrentPermissions.Permissions - - $AppIds = @($Permissions.PSObject.Properties.Name + $MissingPermissions.PSObject.Properties.Name) - $NewPermissions = @{} - - foreach ($AppId in $AppIds) { - if (!$AppId) { continue } - $ApplicationPermissions = [system.collections.generic.list[object]]::new() - $DelegatedPermissions = [system.collections.generic.list[object]]::new() + # Retrieve the live CIPP-SAM application registration in the partner tenant. + $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=id,requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true - foreach ($Permission in $Permissions.$AppId.applicationPermissions) { - $ApplicationPermissions.Add($Permission) + $RequiredResourceAccess = [System.Collections.Generic.List[object]]::new() + foreach ($Resource in $PartnerApp.requiredResourceAccess) { + $ResourceAccess = [System.Collections.Generic.List[object]]::new() + foreach ($Access in $Resource.resourceAccess) { + $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) } - if (($MissingPermissions.$AppId.applicationPermissions | Measure-Object).Count -gt 0) { - foreach ($MissingPermission in $MissingPermissions.$AppId.applicationPermissions) { - Write-Host "Adding missing permission: $MissingPermission" - $ApplicationPermissions.Add($MissingPermission) + $RequiredResourceAccess.Add([PSCustomObject]@{ + resourceAppId = $Resource.resourceAppId + resourceAccess = $ResourceAccess + }) + } + + $AddedPermissions = [System.Collections.Generic.List[string]]::new() + foreach ($AppId in $MissingAppIds) { + $Resource = $RequiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId | Select-Object -First 1 + if (!$Resource) { + $Resource = [PSCustomObject]@{ + resourceAppId = $AppId + resourceAccess = [System.Collections.Generic.List[object]]::new() } + $RequiredResourceAccess.Add($Resource) } + $ExistingIds = @($Resource.resourceAccess.id) - foreach ($Permission in $Permissions.$AppId.delegatedPermissions) { - $DelegatedPermissions.Add($Permission) - } - if (($MissingPermissions.$AppId.delegatedPermissions | Measure-Object).Count -gt 0) { - foreach ($MissingPermission in $MissingPermissions.$AppId.delegatedPermissions) { - Write-Host "Adding missing permission: $MissingPermission" - $DelegatedPermissions.Add($MissingPermission) + foreach ($Permission in $MissingPermissions.$AppId.applicationPermissions) { + if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { + $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Role' }) + $AddedPermissions.Add("$($Permission.value) (Application)") } } - - $NewPermissions.$AppId = @{ - applicationPermissions = @($ApplicationPermissions | Sort-Object -Property label) - delegatedPermissions = @($DelegatedPermissions | Sort-Object -Property label) + foreach ($Permission in $MissingPermissions.$AppId.delegatedPermissions) { + if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { + $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Scope' }) + $AddedPermissions.Add("$($Permission.value) (Delegated)") + } } } - $Entity = @{ - 'PartitionKey' = 'CIPP-SAM' - 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$NewPermissions | ConvertTo-Json -Depth 10 -Compress) - 'UpdatedBy' = $UpdatedBy + if ($AddedPermissions.Count -eq 0) { + return 'No permissions to update' } - $Table = Get-CIPPTable -TableName 'AppPermissions' - $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + $PatchBody = @{ requiredResourceAccess = @($RequiredResourceAccess) } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/applications/$($PartnerApp.id)" -tenantid $env:TenantID -body $PatchBody -type PATCH -NoAuthCheck $true - Write-LogMessage -API 'UpdateCippSamPermissions' -message 'CIPP-SAM Permissions Updated' -Sev 'Info' -LogData $NewPermissions + Write-LogMessage -API 'UpdateCippSamPermissions' -message "CIPP-SAM app registration permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Added = $AddedPermissions } - return 'Permissions Updated' + $Result = "Added $($AddedPermissions.Count) missing permission(s) to the CIPP-SAM app registration: $($AddedPermissions -join ', '). Run a CPV refresh to apply these to customer tenants." + if ($ExtraAppIds.Count -gt 0) { + $ExtraSummary = foreach ($AppId in $ExtraAppIds) { + $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) + "$AppId ($($Names -join ', '))" + } + $Result += " Extra permissions present on the app that should be reviewed/removed manually: $($ExtraSummary -join '; ')." + } + return $Result } catch { throw "Failed to update permissions: $($_.Exception.Message)" } diff --git a/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 new file mode 100644 index 000000000000..172fc5011140 --- /dev/null +++ b/Modules/CIPPCore/Public/Send-CIPPCustomTestAlert.ps1 @@ -0,0 +1,94 @@ +function Send-CIPPCustomTestAlert { + <# + .SYNOPSIS + Ship an aggregated notification for one or more custom script test results for a tenant. + + .DESCRIPTION + Builds a single email/PSA HTML body (via New-CIPPAlertTemplate) and a single webhook + JSON payload covering all alert-worthy custom test results for a tenant, then ships them + through Send-CIPPAlert for the email, webhook and PSA channels. Each channel self-gates + inside Send-CIPPAlert on the global CippNotifications configuration, so channels that + aren't configured are simply skipped. + + This is the "post all the tests" shipping action — Invoke-CIPPTestCollection collects the + alert records emitted by Invoke-CippTestCustomScripts across every enabled script for a + tenant and calls this once after the suite has run, so a tenant receives a single + notification per run rather than one per failing script. + + Routing (recipients / webhook URL / PSA) comes entirely from the instance-wide + CippNotifications config, the same source used by the audit-log alert engine. + + .PARAMETER TenantFilter + The tenant the tests ran against. + + .PARAMETER Alerts + One or more custom-test alert records (as emitted by Invoke-CippTestCustomScripts). Each + record carries TestId, ScriptGuid, ScriptName, Status, Risk, Pillar, FailedRows, + ResultMarkdown and (on execution failure) ErrorMessage. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + $Alerts + ) + + try { + $Alerts = @($Alerts) + if ($Alerts.Count -eq 0) { return } + + # UseStandardizedSchema flag comes from the global CippNotifications config, matching + # how Push-SchedulerCIPPNotifications resolves it for webhook delivery. + $ConfigTable = Get-CIPPTable -TableName SchedulerConfig + $Config = [pscustomobject](Get-CIPPAzDataTableEntity @ConfigTable -Filter "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'") + + # CIPP URL for the email button link. + $CippConfigTable = Get-CippTable -tablename Config + $CippConfig = Get-CIPPAzDataTableEntity @CippConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $CIPPURL = 'https://{0}' -f $CippConfig.Value + + # Email / PSA HTML + $Template = New-CIPPAlertTemplate -Format 'html' -InputObject 'customScript' -Data $Alerts -CIPPURL $CIPPURL -Tenant $TenantFilter + $Title = $Template.title + + # Email — Send-CIPPAlert no-ops if no notification email is configured. + $null = Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' + + # PSA — Send-CIPPAlert no-ops unless config.sendtoIntegration is set. + $null = Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $Template.htmlcontent -TenantFilter $TenantFilter -APIName 'CustomTests' + + # Webhook — hand-built payload, Send-CIPPAlert no-ops if no webhook is configured. + $WebhookData = [PSCustomObject]@{ + Title = $Title + Tenant = $TenantFilter + AlertCount = $Alerts.Count + Tests = @($Alerts | ForEach-Object { + [PSCustomObject]@{ + TestId = $_.TestId + ScriptGuid = $_.ScriptGuid + ScriptName = $_.ScriptName + Status = $_.Status + Risk = if ($_.Risk) { $_.Risk } else { 'Medium' } + Pillar = $_.Pillar + Category = 'Custom Script' + FailedRowCount = @($_.FailedRows).Count + Results = $_.FailedRows + ResultMarkdown = $_.ResultMarkdown + ErrorMessage = $_.ErrorMessage + } + }) + } | ConvertTo-Json -Depth 10 -Compress + + $null = Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $WebhookData -TenantFilter $TenantFilter ` + -APIName 'CustomTests' -SchemaSource 'Custom Test Notification' -InvokingCommand 'Invoke-CippTestCustomScripts' ` + -UseStandardizedSchema:$([boolean]$Config.UseStandardizedSchema) + } catch { + $Err = Get-CippException -Exception $_ + Write-LogMessage -API 'CustomTests' -tenant $TenantFilter -message "Failed to send custom test alerts: $($Err.NormalizedError)" -sev Error -LogData $Err + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 index 9ffa6ab75743..5384b63995be 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefenderCompliancePolicy.ps1 @@ -27,17 +27,18 @@ function Set-CIPPDefenderCompliancePolicy { macEnabled = [bool]$Compliance.ConnectMac partnerUnsupportedOsVersionBlocked = [bool]$Compliance.BlockunsupportedOS partnerUnresponsivenessThresholdInDays = 7 - allowPartnerToCollectIOSApplicationMetadata = [bool]$Compliance.ConnectIosCompliance - allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Compliance.ConnectIosCompliance + allowPartnerToCollectIOSApplicationMetadata = [bool]$Compliance.AppSync + allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Compliance.allowPartnerToCollectIosPersonalApplicationMetadata androidDeviceBlockedOnMissingPartnerData = [bool]$Compliance.androidDeviceBlockedOnMissingPartnerData iosDeviceBlockedOnMissingPartnerData = [bool]$Compliance.iosDeviceBlockedOnMissingPartnerData windowsDeviceBlockedOnMissingPartnerData = [bool]$Compliance.windowsDeviceBlockedOnMissingPartnerData macDeviceBlockedOnMissingPartnerData = [bool]$Compliance.macDeviceBlockedOnMissingPartnerData androidMobileApplicationManagementEnabled = [bool]$Compliance.ConnectAndroidCompliance - iosMobileApplicationManagementEnabled = [bool]$Compliance.appSync + iosMobileApplicationManagementEnabled = [bool]$Compliance.ConnectIosCompliance windowsMobileApplicationManagementEnabled = [bool]$Compliance.windowsMobileApplicationManagementEnabled allowPartnerToCollectIosCertificateMetadata = [bool]$Compliance.allowPartnerToCollectIosCertificateMetadata allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$Compliance.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$Compliance.grantMobileThreatDefensePartnerRole microsoftDefenderForEndpointAttachEnabled = [bool]$true } $SettingsObj = $SettingsObject | ConvertTo-Json -Compress diff --git a/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 index fb8587ad03f0..e963f40db0e0 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDlpCompliancePolicy.ps1 @@ -29,46 +29,25 @@ function Set-CIPPDlpCompliancePolicy { $Headers ) - $PolicyAllowedFields = @( - 'Name', 'Comment', 'Mode', 'Priority', - 'ExchangeLocation', 'ExchangeLocationException', - 'SharePointLocation', 'SharePointLocationException', - 'OneDriveLocation', 'OneDriveLocationException', - 'TeamsLocation', 'TeamsLocationException', - 'EndpointDlpLocation', 'EndpointDlpLocationException', - 'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException', - 'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException', - 'PowerBIDlpLocation', 'PowerBIDlpLocationException', - 'ModernGroupLocation', 'ModernGroupLocationException' - ) - $RuleAllowedFields = @( - 'Name', 'Policy', 'Comment', 'Disabled', 'Mode', 'Priority', - 'ContentContainsSensitiveInformation', - 'ContentPropertyContainsWords', 'BlockAccess', 'BlockAccessScope', - 'NotifyUser', 'NotifyEmailCustomText', 'NotifyEmailCustomSubject', - 'NotifyPolicyTipCustomText', 'GenerateAlert', 'AlertProperties', - 'GenerateIncidentReport', 'IncidentReportContent', - 'ExceptIfContentContainsSensitiveInformation', - 'AccessScope', 'From', 'FromMemberOf', 'FromAddressContainsWords', - 'FromAddressMatchesPatterns', 'SentTo', 'SentToMemberOf', - 'RecipientDomainIs', 'AnyOfRecipientAddressContainsWords', - 'AnyOfRecipientAddressMatchesPatterns', 'AnyOfRecipientAddressDomainIs', - 'ExceptIfFrom', 'ExceptIfFromMemberOf', 'ExceptIfFromAddressContainsWords', - 'ExceptIfFromAddressMatchesPatterns', - 'AddRecipients', 'BlockMessage', 'GenerateAlertOn', 'IncidentReportTo', - 'ReportSeverityLevel', 'RuleErrorAction', - 'ContentExtensionMatchesWords', 'DocumentNameMatchesPatterns', - 'DocumentNameMatchesWords', 'DocumentSizeOver', - 'ContentCharacterSetContainsWords', 'ContentFileTypeMatches' - ) - $LocationFields = $PolicyAllowedFields | Where-Object { $_ -like '*Location*' } + # Allowlists come from the single shared source so deploy, template creation, and drift comparison + # never diverge. Priority is excluded there (per-tenant), and rules carry no 'Mode' (policy-level). + $Fields = Get-CIPPDlpComplianceFieldList + $PolicyAllowedFields = $Fields.Policy + $RuleAllowedFields = $Fields.Rule + $LocationFields = $Fields.Location $PolicyParams = Format-CIPPCompliancePolicyParams -Source $Template -AllowedFields $PolicyAllowedFields -LocationFields $LocationFields + # Drop a Mode the cmdlets won't accept as input (e.g. 'PendingDeletion' captured from a policy that + # was mid-deletion); New-/Set-* would otherwise throw InvalidCompliancePolicyMode. + if ($PolicyParams.ContainsKey('Mode') -and $PolicyParams['Mode'] -notin $Fields.ValidPolicyModes) { + $PolicyParams.Remove('Mode') | Out-Null + } $RuleSource = $Template.RuleParams $PolicyName = $PolicyParams.Name try { - $ExistingPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object Name, IsDefault } catch { @() } + # Pull the location fields too so re-deploys can diff against what the policy already has. + $ExistingPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object (@('Name', 'IsDefault') + $LocationFields) } catch { @() } $ExistingRules = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | Select-Object Name, ParentPolicyName } catch { @() } $ExistingPolicy = $ExistingPolicies | Where-Object { $_.Name -eq $PolicyName } | Select-Object -First 1 @@ -79,7 +58,23 @@ function Set-CIPPDlpCompliancePolicy { } if ($ExistingPolicy) { - $SetParams = ConvertTo-CIPPComplianceSetParams -Params $PolicyParams -Identity $PolicyName -AddPrefixFields $LocationFields + # Location params are Add-prefixed on Set (incremental), so re-adding a location the policy + # already has (e.g. 'All') throws LocationAlreadyExistsException and aborts the entire Set. + # Diff each location field against the existing policy and only Add what's genuinely new. + $DeltaParams = @{} + foreach ($key in $PolicyParams.Keys) { + if ($key -notin $LocationFields) { $DeltaParams[$key] = $PolicyParams[$key]; continue } + $existingLocs = @($ExistingPolicy.$key) | ForEach-Object { + if ($null -eq $_) { return } + if ($_ -is [string]) { $_ } + elseif ($_.Name) { $_.Name } + elseif ($_.DisplayName) { $_.DisplayName } + elseif ($_.PrimarySmtpAddress) { $_.PrimarySmtpAddress } + } + $newLocs = @($PolicyParams[$key]) | Where-Object { $_ -and $_ -notin $existingLocs } + if ($newLocs.Count -gt 0) { $DeltaParams[$key] = $newLocs } + } + $SetParams = ConvertTo-CIPPComplianceSetParams -Params $DeltaParams -Identity $PolicyName -AddPrefixFields $LocationFields $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpCompliancePolicy' -cmdParams $SetParams -Compliance -useSystemMailbox $true $PolicyAction = "Updated DLP compliance policy '$PolicyName' in $TenantFilter." } else { @@ -87,29 +82,60 @@ function Set-CIPPDlpCompliancePolicy { $PolicyAction = "Created DLP compliance policy '$PolicyName' in $TenantFilter." } - if ($RuleSource) { - $RuleHash = Format-CIPPCompliancePolicyParams -Source $RuleSource -AllowedFields $RuleAllowedFields + # RuleParams may be a single rule object (legacy templates) or an array of rules - a DLP + # policy can carry several (e.g. low- vs high-volume detection). Normalize to an array. + $RuleList = @($RuleSource) | Where-Object { $_ } + $RuleActions = @() + $RuleIndex = 0 + foreach ($Rule in $RuleList) { + $RuleIndex++ + $RuleHash = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $RuleAllowedFields + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { + if ($RuleHash.ContainsKey($SitField)) { + $RuleHash[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleHash[$SitField]) + } + } + # Get-* returns IncidentReportContent as a single comma-joined string, but the New-/Set-* + # cmdlets expect a ReportContentOption[] array - split it back out. + if ($RuleHash.ContainsKey('IncidentReportContent') -and $RuleHash['IncidentReportContent'] -is [string]) { + $RuleHash['IncidentReportContent'] = @($RuleHash['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } $RuleHash['Policy'] = $PolicyName $RuleName = if ($RuleHash.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace([string]$RuleHash['Name'])) { $RuleHash['Name'] + } elseif ($RuleList.Count -gt 1) { + "$PolicyName Rule $RuleIndex" } else { "$PolicyName Rule" } $RuleHash['Name'] = $RuleName - $RuleExists = [bool]($ExistingRules | Where-Object { $_.Name -eq $RuleName -or $_.ParentPolicyName -eq $PolicyName }) + # DLP rule names are unique tenant-wide, so match on BOTH name and parent policy: + # - same name under THIS policy -> update it (idempotent re-deploy) + # - same name under a DIFFERENT policy -> conflict; skip rather than clobber that policy's + # rule (the name must be made unique to deploy here) + # - name free -> create it + $RuleUnderThisPolicy = $ExistingRules | Where-Object { $_.Name -eq $RuleName -and $_.ParentPolicyName -eq $PolicyName } + $RuleNameOwnedElsewhere = $ExistingRules | Where-Object { $_.Name -eq $RuleName -and $_.ParentPolicyName -ne $PolicyName } | Select-Object -First 1 - if ($RuleExists) { + if ($RuleUnderThisPolicy) { $SetRuleHash = ConvertTo-CIPPComplianceSetParams -Params $RuleHash -Identity $RuleName $SetRuleHash.Remove('Policy') | Out-Null $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DlpComplianceRule' -cmdParams $SetRuleHash -Compliance -useSystemMailbox $true + $RuleActions += "updated rule '$RuleName'" + } elseif ($RuleNameOwnedElsewhere) { + $Warn = "rule '$RuleName' already exists under policy '$($RuleNameOwnedElsewhere.ParentPolicyName)' - rule names must be unique tenant-wide, so it was NOT created for '$PolicyName'" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Warn -sev Warning + $RuleActions += $Warn } else { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DlpComplianceRule' -cmdParams $RuleHash -Compliance -useSystemMailbox $true + $RuleActions += "created rule '$RuleName'" } } - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $PolicyAction -sev Info - return $PolicyAction + $Result = if ($RuleActions.Count -gt 0) { "$PolicyAction Rules: $($RuleActions -join '; ')." } else { $PolicyAction } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + return $Result } catch { $ErrorMessage = Get-CippException -Exception $_ $msg = "Could not deploy DLP compliance policy '$PolicyName' to $($TenantFilter): $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 index 5ea7b643fcbc..baf65914962d 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 @@ -82,13 +82,25 @@ function Set-CIPPSAMAdminRoles { $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -Compliance -tenantid $TenantFilter -useSystemMailbox $true -AsApp $ActionLogs.Add('Added Service Principal to Compliance Center') } catch { - $ActionLogs.Add('Service Principal already added to Compliance Center') + $SpError = $_.Exception.Message + if ($SpError -match 'already exist') { + $ActionLogs.Add('Service Principal already added to Compliance Center') + } else { + $ActionLogs.Add("Failed to add Service Principal to Compliance Center: $SpError") + $HasFailures = $true + } } try { $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp $ActionLogs.Add('Added Service Principal to Exchange Online') } catch { - $ActionLogs.Add('Service Principal already added to Exchange Online') + $SpError = $_.Exception.Message + if ($SpError -match 'already exist') { + $ActionLogs.Add('Service Principal already added to Exchange Online') + } else { + $ActionLogs.Add("Failed to add Service Principal to Exchange Online: $SpError") + $HasFailures = $true + } } Write-Verbose ($Requests | ConvertTo-Json -Depth 5) diff --git a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 index ab01757ac4e6..2aff76ce9ac1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 @@ -37,19 +37,72 @@ function Set-CIPPStandardsCompareField { return $JsonString } - if ($CurrentValue -and $CurrentValue -isnot [string]) { - $CurrentValue = [string](ConvertTo-Json -InputObject $CurrentValue -Depth 10 -Compress) + function ConvertTo-SortedObject { + param($Value) + + if ($null -eq $Value) { return $null } + + if ($Value -is [string] -or $Value -is [bool] -or $Value -is [System.ValueType]) { + return $Value + } + + if ($Value -is [System.Collections.IDictionary]) { + $Sorted = [ordered]@{} + foreach ($Key in ($Value.Keys | Sort-Object)) { + $Sorted[$Key] = ConvertTo-SortedObject -Value $Value[$Key] + } + return $Sorted + } + + if ($Value -is [System.Management.Automation.PSCustomObject]) { + $Sorted = [ordered]@{} + foreach ($Name in ($Value.PSObject.Properties.Name | Sort-Object)) { + $Sorted[$Name] = ConvertTo-SortedObject -Value $Value.$Name + } + return $Sorted + } + + if ($Value -is [System.Collections.IEnumerable]) { + $Carriers = foreach ($Item in $Value) { + $SortedItem = ConvertTo-SortedObject -Value $Item + [PSCustomObject]@{ + SortKey = [string](ConvertTo-Json -InputObject $SortedItem -Depth 10 -Compress) + Item = $SortedItem + } + } + $Result = [System.Collections.Generic.List[object]]::new() + foreach ($Carrier in @($Carriers | Sort-Object -Property SortKey)) { + $Result.Add($Carrier.Item) + } + return , $Result.ToArray() + } + + return $Value } - if ($ExpectedValue -and $ExpectedValue -isnot [string]) { - $ExpectedValue = [string](ConvertTo-Json -InputObject $ExpectedValue -Depth 10 -Compress) + + function ConvertTo-CanonicalJsonString { + param($Value) + + if ($null -eq $Value) { return $Value } + + $ToSort = $Value + if ($Value -is [string]) { + try { + $ToSort = ConvertFrom-Json -InputObject $Value -ErrorAction Stop + } catch { + return $Value + } + } + + $Json = [string](ConvertTo-Json -InputObject (ConvertTo-SortedObject -Value $ToSort) -Depth 10 -Compress) + return ConvertTo-NormalizedJson -JsonString $Json } - # Normalize both values for consistent comparison (handle quoted numbers) if ($CurrentValue) { - $CurrentValue = ConvertTo-NormalizedJson -JsonString $CurrentValue + $CurrentValue = ConvertTo-CanonicalJsonString -Value $CurrentValue } if ($ExpectedValue) { - $ExpectedValue = ConvertTo-NormalizedJson -JsonString $ExpectedValue + $ExpectedValue = ConvertTo-CanonicalJsonString -Value $ExpectedValue } # Handle bulk operations diff --git a/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 b/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 index 6a86545741f5..1cdafbdaff96 100644 --- a/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 +++ b/Modules/CIPPCore/Public/Standards/Get-CIPPStandards.ps1 @@ -319,7 +319,7 @@ function Get-CIPPStandards { $Actions = $CurrentStandard.action.value if ($Actions -contains 'Remediate' -or $Actions -contains 'warn' -or $Actions -contains 'Report') { # Key by StandardName + TemplateList.value (if present) - $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } else { '' } + $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } elseif ($CurrentStandard.displayName.value) { $CurrentStandard.displayName.value } elseif ($CurrentStandard.displayName) { $CurrentStandard.displayName } else { [guid]::NewGuid().ToString() } $Key = "$StandardName|$TemplateKey" $ComputedStandards[$Key] = $CurrentStandard @@ -388,7 +388,7 @@ function Get-CIPPStandards { $Actions = $CurrentStandard.action.value if ($Actions -contains 'Remediate' -or $Actions -contains 'warn' -or $Actions -contains 'Report') { - $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } else { '' } + $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } elseif ($CurrentStandard.displayName.value) { $CurrentStandard.displayName.value } elseif ($CurrentStandard.displayName) { $CurrentStandard.displayName } else { [guid]::NewGuid().ToString() } $Key = "$StandardName|$TemplateKey" if ($ComputedStandards.ContainsKey($Key)) { @@ -468,7 +468,7 @@ function Get-CIPPStandards { $Actions = $CurrentStandard.action.value if ($Actions -contains 'Remediate' -or $Actions -contains 'warn' -or $Actions -contains 'Report') { - $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } else { '' } + $TemplateKey = if ($CurrentStandard.TemplateList.value) { $CurrentStandard.TemplateList.value } elseif ($CurrentStandard.displayName.value) { $CurrentStandard.displayName.value } elseif ($CurrentStandard.displayName) { $CurrentStandard.displayName } else { [guid]::NewGuid().ToString() } $Key = "$StandardName|$TemplateKey" if ($ComputedStandards.ContainsKey($Key)) { diff --git a/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 new file mode 100644 index 000000000000..4938feef3dca --- /dev/null +++ b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 @@ -0,0 +1,101 @@ +function Test-CIPPOffboardingRequest { + <# + .SYNOPSIS + Validates the shape of an ExecOffboardUser request body before a scheduled task is queued. + .DESCRIPTION + Invoke-ExecOffboardUser queues an asynchronous scheduled task and returns 200 the instant the + task is created - it never waits for, or reports on, the actual offboarding result. That means a + malformed payload silently "succeeds": it reports OK, queues nothing useful, runs no actions, and + never appears in the Offboarding view. + + The common failure modes this catches: + - 'user' sent as bare UPN strings instead of { value = '' } objects, so the backend's + $Request.Body.user.value resolves to nothing and no task is created. + - 'tenantFilter' missing or not resolvable to a domain. + - 'Scheduled.enabled' true with a missing/invalid date. + - No offboarding actions selected, which produces an empty batch that completes as a no-op. + + Returns a structured result. The endpoint rejects the request with a 400 when IsValid is false, + and reuses the normalized TenantFilter/Users so the extraction logic matches what was validated. + .PARAMETER Body + The $Request.Body of the ExecOffboardUser call. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Body + ) + + $Errors = [System.Collections.Generic.List[string]]::new() + + # tenantFilter: required, must resolve to a non-empty domain string (accepts a string or { value }) + $TenantFilter = $Body.tenantFilter.value ?? $Body.tenantFilter + if ([string]::IsNullOrWhiteSpace([string]$TenantFilter)) { + $Errors.Add("'tenantFilter' is required and must resolve to a tenant domain (a string, or an object with a non-empty 'value' property).") + } + + # user: required, >= 1 entry, each resolving to a non-empty userPrincipalName. + # Accepts the UI shape ([{ value = '' }]) and bare UPN strings (['']). + # Only string values are accepted - an object without a 'value' must NOT fall back to the object + # itself (its string form is "@{...}", which would otherwise sneak past the UPN '@' check). + $Users = @( + $Body.user | ForEach-Object { + $UserValue = $_.value ?? $_ + if ($UserValue -is [string]) { $UserValue } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if (-not $Body.user -or @($Body.user).Count -eq 0) { + $Errors.Add("'user' is required and must be a non-empty array of users (objects with a 'value' property, or userPrincipalName strings).") + } elseif ($Users.Count -eq 0) { + $Errors.Add("'user' did not resolve to any usable userPrincipalName. Each entry must be a UPN string or an object with a non-empty 'value' property.") + } else { + $InvalidUsers = @($Users | Where-Object { [string]$_ -notmatch '@' }) + if ($InvalidUsers.Count -gt 0) { + $Errors.Add("These user values do not look like userPrincipalNames (missing '@'): $($InvalidUsers -join ', ').") + } + } + + # Scheduled: when enabled, date must be a valid Unix timestamp + if ($Body.Scheduled.enabled) { + $Epoch = [int64]0 + if ($null -eq $Body.Scheduled.date -or -not [int64]::TryParse([string]$Body.Scheduled.date, [ref]$Epoch) -or $Epoch -le 0) { + $Errors.Add("'Scheduled.enabled' is true but 'Scheduled.date' is not a valid Unix timestamp.") + } + } + + # At least one offboarding action must be selected, otherwise the job builds an empty batch and no-ops. + # Keep this list in sync with the conditions in Invoke-CIPPOffboardingJob. + $BooleanActions = @( + 'ConvertToShared', 'HideFromGAL', 'removeCalendarInvites', 'removePermissions', 'removeCalendarPermissions', + 'RemoveRules', 'RemoveMobile', 'RemoveGroups', 'RemoveLicenses', 'RevokeSessions', 'DisableSignIn', + 'ClearImmutableId', 'ResetPass', 'RemoveMFADevices', 'RemoveTeamsPhoneDID', 'DeleteUser', + 'DisableOneDriveSharing', 'disableForwarding' + ) + $CollectionActions = @('AccessNoAutomap', 'AccessAutomap', 'OnedriveAccess') + + $HasAction = $false + foreach ($Key in $BooleanActions) { + if ($Body.$Key -eq $true) { $HasAction = $true; break } + } + if (-not $HasAction) { + foreach ($Key in $CollectionActions) { + if (@($Body.$Key | Where-Object { $null -ne $_ }).Count -gt 0) { $HasAction = $true; break } + } + } + if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]($Body.forward.value ?? $Body.forward))) { + $HasAction = $true + } + if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]$Body.OOO)) { + $HasAction = $true + } + if (-not $HasAction) { + $Errors.Add('No offboarding actions were selected. Enable at least one action (e.g. RemoveLicenses, DisableSignIn, RevokeSessions) before submitting.') + } + + return [PSCustomObject]@{ + IsValid = ($Errors.Count -eq 0) + Errors = @($Errors) + TenantFilter = $TenantFilter + Users = $Users + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index de2282b91f52..8e16d05cc436 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -10,6 +10,11 @@ function Test-CIPPAuditLogRules { try { # Pre-compiled regex patterns for GUID matching (performance optimization) $script:StandardGuidRegex = [regex]'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + $script:ClientIpRegex = [regex]'^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' + $script:ReservedIpRegex = [regex]::new( + '^(?:10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2[0-9]|3[01])\.|100\.(?:6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(?:22[4-9]|23[0-9]|24[0-9]|25[0-5])\.|::1?$|fe[89ab]|f[cd]|ff)', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) $script:PartnerUpnRegex = [regex]'user_([0-9a-f]{32})@([^@]+\.onmicrosoft\.com)' $script:PartnerExchangeRegex = [regex]'([^\\]+\.onmicrosoft\.com)\\tenant:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}),\s*object:\s*([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})' @@ -419,7 +424,32 @@ function Test-CIPPAuditLogRules { $ExcludedUsers = Get-CIPPAzDataTableEntity @AuditLogUserExclusions -Filter "PartitionKey eq '$TenantFilter'" if ($LogCount -gt 0) { - $LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2' + $TrustedIPEntries = Get-CIPPAzDataTableEntity @TrustedIPTable -Filter "((PartitionKey eq '$TenantFilter') or (PartitionKey eq 'AllTenants')) and state eq 'Trusted'" + $TrustedIPLookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($TrustedEntry in $TrustedIPEntries) { + if (![string]::IsNullOrEmpty($TrustedEntry.RowKey)) { + $null = $TrustedIPLookup.Add([string]$TrustedEntry.RowKey) + } + } + + $GeoPrefetchIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($AuditRecord in $SearchResults) { + $cip = $AuditRecord.auditData.clientip + if ([string]::IsNullOrEmpty($cip) -or $cip -match '[X]+') { continue } + $cip = $script:ClientIpRegex.Replace([string]$cip, '$1') -replace '[\[\]]', '' + if ($TrustedIPLookup.Contains($cip) -or $script:ReservedIpRegex.IsMatch($cip)) { continue } + $null = $GeoPrefetchIPs.Add($cip) + } + $GeoLookup = @{} + if ($GeoPrefetchIPs.Count -gt 0) { + try { + $GeoLookup = Get-CIPPGeoIPLocationBatch -IPs @($GeoPrefetchIPs) + Write-Information "Geo prefetch: $($GeoLookup.Count)/$($GeoPrefetchIPs.Count) distinct IPs resolved" + } catch { + #Write-Warning "Geo prefetch failed, falling back to per-record lookup: $($_.Exception.Message)" + } + } + $ProcessedData = foreach ($AuditRecord in $SearchResults) { $RecordStartTime = Get-Date Write-Information "Processing RowKey $($AuditRecord.id) - $($TenantFilter)." @@ -471,68 +501,35 @@ function Test-CIPPAuditLogRules { if (![string]::IsNullOrEmpty($Data.clientip) -and $Data.clientip -notmatch '[X]+') { # Ignore IP addresses that have been redacted - $IPRegex = '^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' - $Data.clientip = $Data.clientip -replace $IPRegex, '$1' -replace '[\[\]]', '' - - # Check if IP is on trusted IP list - $TrustedIP = Get-CIPPAzDataTableEntity @TrustedIPTable -Filter "((PartitionKey eq '$TenantFilter') or (PartitionKey eq 'AllTenants')) and RowKey eq '$($Data.clientip)' and state eq 'Trusted'" - if ($TrustedIP) { - #write-warning "IP $($Data.clientip) is trusted" - $Trusted = $true - } + $Data.clientip = $script:ClientIpRegex.Replace([string]$Data.clientip, '$1') -replace '[\[\]]', '' + $Trusted = $TrustedIPLookup.Contains([string]$Data.clientip) + $IsReserved = $script:ReservedIpRegex.IsMatch([string]$Data.clientip) if (!$Trusted) { - $CacheLookupStartTime = Get-Date - $Location = Get-AzDataTableEntity @LocationTable -Filter "PartitionKey eq 'ip' and RowKey eq '$($Data.clientIp)'" | Select-Object -ExcludeProperty Tenant - $CacheLookupEndTime = Get-Date - $CacheLookupSeconds = ($CacheLookupEndTime - $CacheLookupStartTime).TotalSeconds - Write-Warning "Cache lookup for IP $($Data.clientip) took $CacheLookupSeconds seconds" - - if ($Location) { - $Country = $Location.CountryOrRegion - $City = $Location.City - $Proxy = $Location.Proxy - $hosting = $Location.Hosting - $ASName = $Location.ASName + if ($IsReserved) { + $Data.CIPPGeoLocation = 'Unknown' + $Data.CIPPBadRepIP = 'Unknown' + $Data.CIPPHostedIP = 'Unknown' + $Data.CIPPIPDetected = [string]$Data.clientip + $Data.CIPPLocationInfo = $null + $HasLocationData = $true } else { - try { - $IPLookupStartTime = Get-Date - $Location = Get-CIPPGeoIPLocation -IP $Data.clientip - $IPLookupEndTime = Get-Date - $IPLookupSeconds = ($IPLookupEndTime - $IPLookupStartTime).TotalSeconds - Write-Warning "IP lookup for $($Data.clientip) took $IPLookupSeconds seconds" - } catch { - #write-warning "Unable to get IP location for $($Data.clientip): $($_.Exception.Message)" - } - $Country = if ($Location.countryCode) { $Location.countryCode } else { 'Unknown' } - $City = if ($Location.city) { $Location.city } else { 'Unknown' } - $Proxy = if ($Location.proxy -ne $null) { $Location.proxy } else { 'Unknown' } - $hosting = if ($Location.hosting -ne $null) { $Location.hosting } else { 'Unknown' } - $ASName = if ($Location.asname) { $Location.asname } else { 'Unknown' } - $IP = $Data.ClientIP - $LocationInfo = @{ - RowKey = [string]$Data.clientip - PartitionKey = 'ip' - Tenant = [string]$TenantFilter - CountryOrRegion = "$Country" - City = "$City" - Proxy = "$Proxy" - Hosting = "$hosting" - ASName = "$ASName" - } - - try { - $null = Add-CIPPAzDataTableEntity @LocationTable -Entity $LocationInfo -Force - } catch { - #write-warning "Failed to add location info for $($Data.clientip) to cache: $($_.Exception.Message)" - + $Loc = $GeoLookup[[string]$Data.clientip] + if ($Loc) { + $Data.CIPPGeoLocation = $Loc.CountryOrRegion + $Data.CIPPBadRepIP = $Loc.Proxy + $Data.CIPPHostedIP = $Loc.Hosting + $Data.CIPPIPDetected = [string]$Data.clientip + $Data.CIPPLocationInfo = ($Loc | ConvertTo-Json -Compress -Depth 10) + $HasLocationData = $true + } else { + $Data.CIPPGeoLocation = 'Unknown' + $Data.CIPPBadRepIP = 'Unknown' + $Data.CIPPHostedIP = 'Unknown' + $Data.CIPPIPDetected = [string]$Data.clientip + $Data.CIPPLocationInfo = $null + $HasLocationData = $false } } - $Data.CIPPGeoLocation = $Country - $Data.CIPPBadRepIP = $Proxy - $Data.CIPPHostedIP = $hosting - $Data.CIPPIPDetected = $IP - $Data.CIPPLocationInfo = ($Location | ConvertTo-Json -Compress -Depth 10) - $HasLocationData = $true } } $Data.AuditRecord = [string]($RootProperties | ConvertTo-Json -Compress -Depth 10) @@ -543,19 +540,17 @@ function Test-CIPPAuditLogRules { Write-LogMessage -API 'Webhooks' -message 'Error Processing Audit Log Data' -LogData (Get-CippException -Exception $_) -sev Error -tenant $TenantFilter } - Write-Information "Removing row $($AuditRecord.id) from cache" try { - Write-Information 'Removing processed rows from cache' - $RowEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$($AuditRecord.id)'" - Remove-AzDataTableEntity @CacheWebhooksTable -Entity $RowEntity -Force - Write-Information "Removed row $($AuditRecord.id) from cache" + $null = Remove-AzDataTableEntity -Force @CacheWebhooksTable -Entity ([pscustomobject]@{ + PartitionKey = $TenantFilter + RowKey = [string]$AuditRecord.id + }) } catch { - Write-Information "Error removing rows from cache: $($_.Exception.Message)" - } finally { - $RecordEndTime = Get-Date - $RecordSeconds = ($RecordEndTime - $RecordStartTime).TotalSeconds - Write-Warning "Task took $RecordSeconds seconds for RowKey $($AuditRecord.id)" + Write-Information "Error removing row $($AuditRecord.id) from cache: $($_.Exception.Message)" } + $RecordEndTime = Get-Date + $RecordSeconds = ($RecordEndTime - $RecordStartTime).TotalSeconds + Write-Warning "Task took $RecordSeconds seconds for RowKey $($AuditRecord.id)" } #write-warning "Processed Data: $(($ProcessedData | Measure-Object).Count) - This should be higher than 0 in many cases, because the where object has not run yet." #write-warning "Creating filters - $(($ProcessedData.operation | Sort-Object -Unique) -join ',') - $($TenantFilter)" @@ -688,14 +683,13 @@ function Test-CIPPAuditLogRules { } try { - Write-Information 'Removing processed rows from cache' - foreach ($Row in $Rows) { - if ($Row.id) { - $RowEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$($Row.id)'" - if ($RowEntity) { - Remove-AzDataTableEntity @CacheWebhooksTable -Entity $RowEntity -Force - Write-Information "Removed row $($Row.id) from cache at final pass." - } + $RowIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Rows.id | Where-Object { $_ })) + if ($RowIds.Count -gt 0) { + $CachedRows = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter'" + $RowsToRemove = @($CachedRows | Where-Object { $RowIds.Contains([string]$_.RowKey) }) + if ($RowsToRemove.Count -gt 0) { + Remove-AzDataTableEntity @CacheWebhooksTable -Entity $RowsToRemove -Force + Write-Information "Removed $($RowsToRemove.Count) processed rows from cache" } } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 index 6abdfd46ff95..a2590aa4d2f9 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRiskDetections.ps1 @@ -20,7 +20,7 @@ function Set-CIPPDBCacheRiskDetections { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching risk detections from Identity Protection' -sev Debug # Requires P2 licensing - $RiskDetections = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/identityProtection/riskDetections' -tenantid $TenantFilter + $RiskDetections = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/identityProtection/riskDetections?$top=500' -tenantid $TenantFilter if ($RiskDetections) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RiskDetections' -Data $RiskDetections -AddCount diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 index a5a2b1b74bd3..d4d924f20683 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 @@ -21,6 +21,9 @@ function Invoke-ListFeatureFlags { if ($Flag.Id -eq 'SuperAdminNG') { $Flag.Enabled = $true } + elseIf ($Flag.Id -eq 'AppInsights') { + $Flag.Enabled = $false + } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 index 2f6652f7bd7a..ddbda5d5132b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecApiClient.ps1 @@ -193,19 +193,9 @@ function Invoke-ExecApiClient { } } 'GetAzureConfiguration' { - if ($env:WEBSITE_RESOURCE_GROUP) { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - Write-Information "Could not determine resource group from environment variables. Owner: $Owner" - $RGName = $null - } - } $FunctionAppName = $env:WEBSITE_SITE_NAME try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName $APIClients = Get-CippApiAuth -RGName $RGName -FunctionAppName $FunctionAppName $Results = $ApiClients } catch { @@ -220,17 +210,6 @@ function Invoke-ExecApiClient { } 'SaveToAzure' { $TenantId = $env:TenantID - if ($env:WEBSITE_RESOURCE_GROUP) { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - Write-Information "Could not determine resource group from environment variables. Owner: $Owner" - $RGName = $null - } - } $FunctionAppName = $env:WEBSITE_SITE_NAME $AllClients = Get-CIPPAzDataTableEntity @Table -Filter 'Enabled eq true' | Where-Object { ![string]::IsNullOrEmpty($_.RowKey) } $ClientIds = $AllClients.RowKey @@ -238,6 +217,7 @@ function Invoke-ExecApiClient { $McpClientIds = @($AllClients | Where-Object { "$($_.MCPAllowed)" -eq 'True' } | ForEach-Object { $_.RowKey }) Write-Information "[ExecApiClient] MCP clients resolved for audiences/scope: $($McpClientIds -join ', ')" try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName Set-CippApiAuth -RGName $RGName -FunctionAppName $FunctionAppName -TenantId $TenantId -ClientIds $ClientIds -McpClientIds $McpClientIds # Advertise the MCP resource scope via App Service PRM so the Claude connector requests diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 index aa2f5733533a..eed62dcc076c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackendURLs.ps1 @@ -13,16 +13,11 @@ function Invoke-ExecBackendURLs { # Write to the Azure Functions log stream. Write-Host 'PowerShell HTTP trigger function processed a request.' - if ($env:WEBSITE_RESOURCE_GROUP) { - $RGName = $env:WEBSITE_RESOURCE_GROUP - } else { - $Owner = $env:WEBSITE_OWNER_NAME - if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $RGName = $Matches.RGName - } else { - Write-Information "Could not determine resource group from environment variables. Owner: $Owner" - $RGName = $null - } + try { + $RGName = Get-CIPPFunctionAppResourceGroup + } catch { + Write-Information "Could not determine resource group: $($_.Exception.Message)" + $RGName = $null } $results = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 index 06075ff14998..5d6e347fc6b1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 @@ -17,18 +17,18 @@ function Invoke-ExecContainerManagement { # Helper: resolve ARM site details function Get-ContainerSiteInfo { - $info = @{ - Subscription = Get-CIPPAzFunctionAppSubId - SiteName = $env:WEBSITE_SITE_NAME - RGName = $env:WEBSITE_RESOURCE_GROUP + $SiteName = $env:WEBSITE_SITE_NAME + try { + $RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName + } catch { + Write-Information "Could not determine resource group: $($_.Exception.Message)" + $RGName = $null } - if (-not $info.RGName) { - $Owner = $env:WEBSITE_OWNER_NAME - if ($Owner -match '^(?[^+]+)\+(?[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') { - $info.RGName = $Matches.RGName - } + return @{ + Subscription = Get-CIPPAzFunctionAppSubId + SiteName = $SiteName + RGName = $RGName } - return $info } # Helper: query GHCR for the image at $Tag and return its digest + version label. diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 index 1421a7260301..9c7885ab908a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 @@ -13,19 +13,63 @@ function Invoke-ExecSAMAppPermissions { switch ($Request.Query.Action) { 'Update' { try { - $Permissions = $Request.Body.Permissions + $Submitted = $Request.Body.Permissions + $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions + + $Extras = @{} + foreach ($AppId in $Submitted.PSObject.Properties.Name) { + $ManifestApp = $ManifestPermissions.$AppId + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $ExtraApp = @(foreach ($Permission in $Submitted.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } + } + }) + $ExtraDel = @(foreach ($Permission in $Submitted.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } + } + }) + + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $Extras.$AppId = @{ + applicationPermissions = $ExtraApp + delegatedPermissions = $ExtraDel + } + } + } + $Entity = @{ 'PartitionKey' = 'CIPP-SAM' 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]($Permissions | ConvertTo-Json -Depth 10 -Compress) + 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) 'UpdatedBy' = $User.UserDetails ?? 'CIPP-API' } $Table = Get-CIPPTable -TableName 'AppPermissions' $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force $Body = @{ - 'Results' = 'Permissions Updated' + 'Results' = 'Additional permissions updated. Default CIPP permissions are always applied and cannot be removed. Please run a Permissions check and CPV refresh to finalise the changes.' + } + Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM additional permissions updated' -Sev 'Info' -LogData $Extras + } catch { + $Body = @{ + 'Results' = $_.Exception.Message + } + } + } + 'Reset' { + try { + $Table = Get-CIPPTable -TableName 'AppPermissions' + $Existing = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if ($Existing) { + $null = Remove-AzDataTableEntity @Table -Entity $Existing -Force + } + $Body = @{ + 'Results' = 'Permissions reset to CIPP defaults.' } - Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM Permissions Updated' -Sev 'Info' -LogData $Permissions + Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM permissions reset to CIPP defaults' -Sev 'Info' } catch { $Body = @{ 'Results' = $_.Exception.Message diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 index efbec290e6aa..068c95ec1c3b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecOffboardUser.ps1 @@ -7,8 +7,17 @@ function Invoke-ExecOffboardUser { #> [CmdletBinding()] param($Request, $TriggerMetadata) - $AllUsers = $Request.Body.user.value - $TenantFilter = $request.Body.tenantFilter.value ? $request.Body.tenantFilter.value : $request.Body.tenantFilter + + $Validation = Test-CIPPOffboardingRequest -Body $Request.Body + if (-not $Validation.IsValid) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = [pscustomobject]@{ Results = @($Validation.Errors) } + }) + } + + $AllUsers = $Validation.Users + $TenantFilter = $Validation.TenantFilter $OffboardingOptions = $Request.Body | Select-Object * -ExcludeProperty user, tenantFilter, Scheduled $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 index 696f307ca965..c080efaa0935 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecTestRun.ps1 @@ -72,9 +72,6 @@ function Invoke-ExecTestRun { $StatusCode = [HttpStatusCode]::OK $Body = [PSCustomObject]@{ Results = $ResultMessage } - - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Mode '$Mode' orchestration started. Instance ID: $InstanceId" -sev Info - } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to start data collection/test run: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 index acb6deb0c703..12ac2b9900f7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListKnownIPDb.ps1 @@ -9,16 +9,9 @@ function Invoke-ListKnownIPDb { #> [CmdletBinding()] param($Request, $TriggerMetadata) - # Interact with query parameters or the body of the request. - $TenantFilter = $Request.Query.tenantFilter - if (-not [string]::IsNullOrEmpty($TenantFilter)) { - $TenantFilter = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String - } - $Table = Get-CIPPTable -TableName 'knownlocationdbv2' - $Filter = "Tenant eq '$($TenantFilter)'" - $KnownIPDb = Get-CIPPAzDataTableEntity @Table -Filter $Filter + $KnownIPDb = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ip'" return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 index acc974c464a0..e40c721b030a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-DLP/Invoke-AddDlpCompliancePolicyTemplate.ps1 @@ -11,21 +11,13 @@ Function Invoke-AddDlpCompliancePolicyTemplate { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - $AllowedFields = @( - 'Name', 'Comment', 'Mode', 'Priority', - 'ExchangeLocation', 'ExchangeLocationException', - 'SharePointLocation', 'SharePointLocationException', - 'OneDriveLocation', 'OneDriveLocationException', - 'TeamsLocation', 'TeamsLocationException', - 'EndpointDlpLocation', 'EndpointDlpLocationException', - 'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException', - 'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException', - 'PowerBIDlpLocation', 'PowerBIDlpLocationException', - 'ModernGroupLocation', 'ModernGroupLocationException', - 'RuleParams' - ) - - $LocationFields = $AllowedFields | Where-Object { $_ -like '*Location*' } + # Allowlists come from the single shared source so template creation, deploy, and drift comparison + # never diverge. 'RuleParams' is template-only (added so a PowerShellCommand body that already carries + # RuleParams passes through). 'Policy' on rules is captured then stripped below (added at deploy time). + $Fields = Get-CIPPDlpComplianceFieldList + $AllowedFields = @($Fields.Policy) + 'RuleParams' + $RuleAllowedFields = $Fields.Rule + $LocationFields = $Fields.Location try { $GUID = (New-Guid).GUID @@ -36,8 +28,44 @@ Function Invoke-AddDlpCompliancePolicyTemplate { [pscustomobject]$Request.Body } + # A policy that is pending deletion can't be redeployed, so templating it would only capture an + # undeployable snapshot - reject rather than store it. + if (($Source.Mode ?? '') -eq 'PendingDeletion') { + throw "DLP policy '$($Source.Name ?? $Source.name)' is pending deletion and cannot be saved as a template." + } + $Clean = Format-CIPPCompliancePolicyParams -Source $Source -AllowedFields $AllowedFields -LocationFields $LocationFields + # Defensive: drop any other Mode the cmdlets won't accept as input. + if ($Clean.ContainsKey('Mode') -and $Clean['Mode'] -notin $Fields.ValidPolicyModes) { + $Clean.Remove('Mode') | Out-Null + } + + # Capture the policy's detection rules into RuleParams so the template carries the actual + # DLP logic (sensitive info types, severity, notifications) rather than just the policy shell. + # The list endpoint surfaces these as AssociatedRules; a policy can have more than one. + $AssociatedRules = @($Source.AssociatedRules) | Where-Object { $_ } + if ($AssociatedRules.Count -gt 0) { + $RuleParams = foreach ($Rule in $AssociatedRules) { + $RuleClean = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $RuleAllowedFields + $RuleClean.Remove('Policy') | Out-Null # added at deploy time, not stored + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { + if ($RuleClean.ContainsKey($SitField)) { + $RuleClean[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleClean[$SitField]) + } + } + # Get-* returns IncidentReportContent as a comma-joined string; store it as the array + # the New-/Set-* cmdlets expect (a ReportContentOption[]). + if ($RuleClean.ContainsKey('IncidentReportContent') -and $RuleClean['IncidentReportContent'] -is [string]) { + $RuleClean['IncidentReportContent'] = @($RuleClean['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + [pscustomobject]$RuleClean + } + $Clean['RuleParams'] = @($RuleParams) + } elseif ($Source.RuleParams) { + $Clean['RuleParams'] = $Source.RuleParams + } + $Ordered = [ordered]@{ name = $Clean['Name'] ?? $Source.Name ?? $Source.name comments = $Source.Comment ?? $Source.comments diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 index bbf7e50cc628..ce30603163f6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/Custom-Scripts/Invoke-AddCustomScript.ps1 @@ -31,12 +31,10 @@ function Invoke-AddCustomScript { $LatestVersion = $ExistingVersions | Sort-Object -Property Version -Descending | Select-Object -First 1 $CurrentEnabled = if ($LatestVersion.PSObject.Properties['Enabled']) { [bool]$LatestVersion.Enabled } else { $true } $CurrentAlertOnFailure = if ($LatestVersion.PSObject.Properties['AlertOnFailure']) { [bool]$LatestVersion.AlertOnFailure } else { $false } - $CurrentAlertStatuses = if ($LatestVersion.PSObject.Properties['AlertStatuses'] -and -not [string]::IsNullOrWhiteSpace($LatestVersion.AlertStatuses)) { $LatestVersion.AlertStatuses } else { '[]' } $CurrentResultMode = if ($LatestVersion.PSObject.Properties['ResultMode'] -and -not [string]::IsNullOrWhiteSpace($LatestVersion.ResultMode)) { $LatestVersion.ResultMode } else { 'Auto' } $NewEnabled = $CurrentEnabled $NewAlertOnFailure = $CurrentAlertOnFailure - $NewAlertStatuses = $CurrentAlertStatuses $NewResultMode = $CurrentResultMode switch ($Action) { @@ -48,13 +46,9 @@ function Invoke-AddCustomScript { } 'EnableAlerts' { $NewAlertOnFailure = $true - if ($NewAlertStatuses -eq '[]') { - $NewAlertStatuses = @('Failed') | ConvertTo-Json -Compress - } } 'DisableAlerts' { $NewAlertOnFailure = $false - $NewAlertStatuses = '[]' } 'SetResultMode' { $RequestedMode = $Request.Body.ResultMode @@ -71,7 +65,6 @@ function Invoke-AddCustomScript { RowKey = $LatestVersion.RowKey Enabled = $NewEnabled AlertOnFailure = $NewAlertOnFailure - AlertStatuses = $NewAlertStatuses ResultMode = $NewResultMode } @@ -122,7 +115,7 @@ function Invoke-AddCustomScript { $UserImpact = $Request.Body.UserImpact $Enabled = $Request.Body.Enabled $AlertOnFailure = $Request.Body.AlertOnFailure - $AlertStatuses = if ($Request.Body.AlertStatuses) { $Request.Body.AlertStatuses | ConvertTo-Json -Compress } else { '[]' } + $AlertStatuses = $Request.Body.AlertStatuses $ReturnType = $Request.Body.ReturnType $MarkdownTemplate = $Request.Body.MarkdownTemplate $ResultSchema = $Request.Body.ResultSchema diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 index 34ca096ac19a..2deb77bff0de 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 @@ -46,9 +46,9 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { param($Tenant, $Settings) $TestResult = Test-CIPPStandardLicense -StandardName 'ColleagueImpersonationAlert' -TenantFilter $Tenant -Preset Exchange - + if ($TestResult -eq $false) { - return $true + return $true } #we're done. $ruleHtml = $Settings.disclaimerHtml @@ -135,8 +135,8 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { $range = $entry.Key $pattern = $entry.Value $ruleName = "($range) Colleague Impersonation Alert" - $names = @($displayNames | Where-Object { $_ -match $pattern }) - if ($names.Count -eq 0) { $names = @("($range)") } + $names = @($displayNames | Where-Object { $_ -match $pattern } | ForEach-Object { [regex]::Escape($_) }) + if ($names.Count -eq 0) { $names = @([regex]::Escape("($range)")) } $existing = $Rules | Where-Object { $_.Name -eq $ruleName } | Select-Object -First 1 $namesMatch = $false diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 index 86c475fa28b5..c5f73aab4236 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDefenderCompliancePolicy.ps1 @@ -21,12 +21,14 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectAndroid","label":"Connect Android devices to MDE","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectAndroidCompliance","label":"Connect Android 6.0.0+ (App-based MAM)","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.androidDeviceBlockedOnMissingPartnerData","label":"Block Android if partner data unavailable","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.grantMobileThreatDefensePartnerRole","label":"Grant MTD role to MDE on enrolled Android COBO/COPE devices","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectIos","label":"Connect iOS/iPadOS devices to MDE","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectIosCompliance","label":"Connect iOS 13.0+ (App-based MAM)","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.appSync","label":"Enable App Sync for iOS","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectIosCompliance","label":"Connect iOS/iPadOS devices for app protection policy evaluation (MAM)","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.appSync","label":"Enable App Sync (sending application inventory) for iOS/iPadOS devices","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalApplicationMetadata","label":"Send full application inventory data on personally-owned iOS/iPadOS devices","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.iosDeviceBlockedOnMissingPartnerData","label":"Block iOS if partner data unavailable","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata","label":"Collect certificate metadata from iOS","defaultValue":false} - {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata","label":"Collect personal certificate metadata from iOS","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosCertificateMetadata","label":"Enable Certificate Sync for iOS/iPadOS devices","defaultValue":false} + {"type":"switch","name":"standards.DefenderCompliancePolicy.allowPartnerToCollectIosPersonalCertificateMetadata","label":"Send full certificate inventory data on personally-owned iOS/iPadOS devices","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectMac","label":"Connect macOS devices to MDE","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.macDeviceBlockedOnMissingPartnerData","label":"Block macOS if partner data unavailable","defaultValue":false} {"type":"switch","name":"standards.DefenderCompliancePolicy.ConnectWindows","label":"Connect Windows 10.0.15063+ to MDE (Note: enabling this forces 'Block Windows if partner data unavailable' to on)","defaultValue":false} @@ -58,17 +60,18 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { windowsEnabled = [bool]$Settings.ConnectWindows macEnabled = [bool]$Settings.ConnectMac partnerUnsupportedOsVersionBlocked = [bool]$Settings.BlockunsupportedOS - allowPartnerToCollectIOSApplicationMetadata = [bool]$Settings.ConnectIosCompliance - allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Settings.ConnectIosCompliance + allowPartnerToCollectIOSApplicationMetadata = [bool]$Settings.appSync + allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Settings.allowPartnerToCollectIosPersonalApplicationMetadata androidDeviceBlockedOnMissingPartnerData = [bool]$Settings.androidDeviceBlockedOnMissingPartnerData iosDeviceBlockedOnMissingPartnerData = [bool]$Settings.iosDeviceBlockedOnMissingPartnerData windowsDeviceBlockedOnMissingPartnerData = if ([bool]$Settings.ConnectWindows) { $true } else { [bool]$Settings.windowsDeviceBlockedOnMissingPartnerData } macDeviceBlockedOnMissingPartnerData = [bool]$Settings.macDeviceBlockedOnMissingPartnerData androidMobileApplicationManagementEnabled = [bool]$Settings.ConnectAndroidCompliance - iosMobileApplicationManagementEnabled = [bool]$Settings.appSync + iosMobileApplicationManagementEnabled = [bool]$Settings.ConnectIosCompliance windowsMobileApplicationManagementEnabled = [bool]$Settings.windowsMobileApplicationManagementEnabled allowPartnerToCollectIosCertificateMetadata = [bool]$Settings.allowPartnerToCollectIosCertificateMetadata allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$Settings.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$Settings.grantMobileThreatDefensePartnerRole microsoftDefenderForEndpointAttachEnabled = $true } @@ -112,6 +115,7 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { windowsMobileApplicationManagementEnabled = [bool]$CurrentState.windowsMobileApplicationManagementEnabled allowPartnerToCollectIosCertificateMetadata = [bool]$CurrentState.allowPartnerToCollectIosCertificateMetadata allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$CurrentState.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$CurrentState.grantMobileThreatDefensePartnerRole microsoftDefenderForEndpointAttachEnabled = [bool]$CurrentState.microsoftDefenderForEndpointAttachEnabled } } else { @@ -132,6 +136,7 @@ function Invoke-CIPPStandardDefenderCompliancePolicy { windowsMobileApplicationManagementEnabled = $false allowPartnerToCollectIosCertificateMetadata = $false allowPartnerToCollectIosPersonalCertificateMetadata = $false + grantMobileThreatDefensePartnerRole = $false microsoftDefenderForEndpointAttachEnabled = $false } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 index b6813487ee95..75154d84641e 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployContactTemplates.ps1 @@ -147,7 +147,7 @@ function Invoke-CIPPStandardDeployContactTemplates { @{ Template = 'hidefromGAL'; Current = $ExistingContact.HiddenFromAddressListsEnabled; IsBool = $true } @{ Template = 'companyName'; Current = $ExtendedContact.Company } @{ Template = 'state'; Current = $ExtendedContact.StateOrProvince } - @{ Template = 'streetAddress'; Current = $ExtendedContact.Office } + @{ Template = 'streetAddress'; Current = $ExtendedContact.StreetAddress } @{ Template = 'businessPhone'; Current = $ExtendedContact.Phone } @{ Template = 'website'; Current = $ExtendedContact.WebPage } @{ Template = 'jobTitle'; Current = $ExtendedContact.Title } @@ -338,7 +338,7 @@ function Invoke-CIPPStandardDeployContactTemplates { $PropertyMap = @{ 'Company' = $Template.companyName 'StateOrProvince' = $Template.state - 'Office' = $Template.streetAddress + 'StreetAddress' = $Template.streetAddress 'Phone' = $Template.businessPhone 'WebPage' = $Template.website 'Title' = $Template.jobTitle diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 index 6c3f4b2d7b0c..08587a91f1cd 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDlpCompliancePolicyTemplate.ps1 @@ -50,37 +50,53 @@ function Invoke-CIPPStandardDlpCompliancePolicyTemplate { return } - if ($Settings.remediate -eq $true) { - foreach ($Template in @($Templates)) { - $null = Set-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template -APIName 'Standards' + # Compare each template against the live policy + rules. Remediate only what actually drifts (or is + # missing) - an in-sync policy is left untouched. After a successful remediation we re-compare so the + # report/alert reflect the corrected state. A PendingDeletion policy can't be modified, so it is + # surfaced as non-compliant rather than redeployed (the deploy would just fail). + $Comparisons = foreach ($Template in @($Templates)) { + $Comparison = Compare-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template + + if ($Settings.remediate -eq $true -and $Comparison.State -in @('Missing', 'Drift')) { + $DeployResult = Set-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template -APIName 'Standards' + if ($DeployResult -match '^(Could not deploy|Failed)') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message $DeployResult -sev Error + $Comparison | Add-Member -NotePropertyName DeployError -NotePropertyValue "$DeployResult" -Force + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Remediated DLP policy '$($Comparison.Name)' ($($Comparison.State)): $DeployResult" -sev Info + $Comparison = Compare-CIPPDlpCompliancePolicy -TenantFilter $Tenant -Template $Template + } } + $Comparison } - # Determine which templated policies are present in the tenant for alert/report modes - $ExistingPolicyNames = try { - @(New-ExoRequest -tenantid $Tenant -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object -ExpandProperty Name) - } catch { @() } - - $MissingPolicies = @(foreach ($Template in @($Templates)) { - $TemplateName = $Template.Name ?? $Template.name - if ($ExistingPolicyNames -notcontains $TemplateName) { $TemplateName } - }) + $NonCompliant = @($Comparisons | Where-Object { $_.State -ne 'InSync' }) if ($Settings.alert -eq $true) { - if ($MissingPolicies.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected DLP compliance policy templates are deployed.' -sev Info + if ($NonCompliant.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected DLP compliance policy templates are deployed and in sync.' -sev Info } else { - $AlertMessage = "DLP compliance policies not deployed in tenant: $($MissingPolicies -join ', ')" - Write-StandardsAlert -message $AlertMessage -object @{ MissingPolicies = $MissingPolicies } -tenant $Tenant -standardName 'DlpCompliancePolicyTemplate' -standardId $Settings.standardId + $Summary = $NonCompliant | ForEach-Object { + if ($_.State -eq 'Drift') { + $Fields = @($_.Differences | ForEach-Object { "$($_.Scope)/$($_.Field)" }) -join ', ' + "$($_.Name): drift in $Fields" + } else { + "$($_.Name): $($_.State)" + } + } + $AlertMessage = "DLP compliance policy templates not in sync: $($Summary -join '; ')" + Write-StandardsAlert -message $AlertMessage -object @{ NonCompliantPolicies = $NonCompliant } -tenant $Tenant -standardName 'DlpCompliancePolicyTemplate' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } } if ($Settings.report -eq $true) { - $CurrentValue = @{ MissingPolicies = $MissingPolicies } - $ExpectedValue = @{ MissingPolicies = @() } + # Expose the actual drift (per policy: state + the differing fields with expected vs current + # values) rather than just a list of missing names. + $CurrentValue = @{ NonCompliantPolicies = $NonCompliant } + $ExpectedValue = @{ NonCompliantPolicies = @() } Set-CIPPStandardsCompareField -FieldName 'standards.DlpCompliancePolicyTemplate' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant - Add-CIPPBPAField -FieldName 'DlpCompliancePolicyTemplate' -FieldValue ($MissingPolicies.Count -eq 0) -StoreAs bool -Tenant $Tenant + Add-CIPPBPAField -FieldName 'DlpCompliancePolicyTemplate' -FieldValue ($NonCompliant.Count -eq 0) -StoreAs bool -Tenant $Tenant } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 index 956b9398e12c..c73256442798 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardEnableCustomerLockbox { .SYNOPSIS (Label) Enable Customer Lockbox .DESCRIPTION - (Helptext) **Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data - (DocsDescription) \*\*Requires Entra ID P2.\*\* Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data. + (Helptext) **Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data + (DocsDescription) \*\*Requires CustomerLockbox (E5, E7, A5, Purview Addon for BP, EDU or FL)\*\* Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data. .NOTES CAT Global Standards diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 index 8ef3ef2cc58f..2fe663123420 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 @@ -111,10 +111,10 @@ function Invoke-CIPPStandardMalwareFilterPolicy { $DefaultFileTypes = @('ace', 'ani', 'apk', 'app', 'appx', 'arj', 'bat', 'cab', 'cmd', 'com', 'deb', 'dex', 'dll', 'docm', 'elf', 'exe', 'hta', 'img', 'iso', 'jar', 'jnlp', 'kext', 'lha', 'lib', 'library', 'lnk', 'lzh', 'macho', 'msc', 'msi', 'msix', 'msp', 'mst', 'pif', 'ppa', 'ppam', 'reg', 'rev', 'scf', 'scr', 'sct', 'sys', 'uif', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', 'xll', 'xz', 'z') - if ($null -eq $Settings.OptionalFileTypes) { + if ([string]::IsNullOrWhiteSpace($Settings.OptionalFileTypes)) { $ExpectedFileTypes = $DefaultFileTypes } else { - $ExpectedFileTypes = $DefaultFileTypes + @($Settings.OptionalFileTypes.Split(',').Trim()) + $ExpectedFileTypes = $DefaultFileTypes + @($Settings.OptionalFileTypes.Split(',').Trim() | Where-Object { $_ }) } $FileTypeAction = $Settings.FileTypeAction.value ?? $Settings.FileTypeAction ?? 'Quarantine' diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 index a305b1510973..6be274bd9079 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 @@ -51,9 +51,9 @@ function Invoke-CIPPStandardSpamFilterPolicy { {"type":"switch","name":"standards.SpamFilterPolicy.MarkAsSpamWebBugsInHtml","label":"Mark as spam if message contains web bugs (also known as web beacons)","defaultValue":false} {"type":"switch","name":"standards.SpamFilterPolicy.MarkAsSpamSensitiveWordList","label":"Mark as spam if message contains words from the sensitive words list","defaultValue":false} {"type":"switch","name":"standards.SpamFilterPolicy.EnableLanguageBlockList","label":"Enable language block list","defaultValue":false} - {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.SpamFilterPolicy.LanguageBlockList","label":"Languages to block (uppercase ISO 639-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableLanguageBlockList","compareType":"is","compareValue":true}} + {"type":"LanguageCodeMultiSelect","required":false,"name":"standards.SpamFilterPolicy.LanguageBlockList","label":"Languages to block (ISO 639-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableLanguageBlockList","compareType":"is","compareValue":true}} {"type":"switch","name":"standards.SpamFilterPolicy.EnableRegionBlockList","label":"Enable region block list","defaultValue":false} - {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.SpamFilterPolicy.RegionBlockList","label":"Regions to block (uppercase ISO 3166-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableRegionBlockList","compareType":"is","compareValue":true}} + {"type":"CountryCodeMultiSelect","required":false,"name":"standards.SpamFilterPolicy.RegionBlockList","label":"Regions to block (ISO 3166-1 two-letter)","condition":{"field":"standards.SpamFilterPolicy.EnableRegionBlockList","compareType":"is","compareValue":true}} {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"name":"standards.SpamFilterPolicy.AllowedSenderDomains","label":"Allowed sender domains"} IMPACT Medium Impact @@ -82,17 +82,35 @@ function Invoke-CIPPStandardSpamFilterPolicy { } #we're done. # Use custom name if provided, otherwise use default for backward compatibility - $PolicyName = if ($Settings.name) { $Settings.name } else { 'CIPP Default Spam Filter Policy' } + $DefaultPolicyName = 'CIPP Default Spam Filter Policy' + $PolicyName = if ($Settings.name) { $Settings.name } else { $DefaultPolicyName } try { - $CurrentState = New-ExoRequest -TenantId $Tenant -cmdlet 'Get-HostedContentFilterPolicy' | - Where-Object -Property Name -EQ $PolicyName + $AllSpamFilterPolicies = New-ExoRequest -TenantId $Tenant -cmdlet 'Get-HostedContentFilterPolicy' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SpamFilterPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error return } + # Only match against legacy/default names when no custom name is provided. When a custom name is + # set, deploy it as a new policy instead of reusing an existing default-named one. 'Default' is + # Microsoft's built-in inbound anti-spam policy ("Anti-spam inbound policy" in the portal); it + # cannot be renamed and has no associated rule. + if ($PolicyName -eq $DefaultPolicyName) { + $PolicyList = @($PolicyName, 'Default Spam Filter Policy', 'Default') + $ExistingPolicy = $AllSpamFilterPolicies | Where-Object -Property Name -In $PolicyList | Select-Object -First 1 + if ($null -ne $ExistingPolicy.Name) { + # Use existing policy name if found + $PolicyName = $ExistingPolicy.Name + } + } + + # The built-in default policy cannot have a HostedContentFilterRule, so rule remediation is skipped for it. + $IsDefaultPolicy = $PolicyName -eq 'Default' + + $CurrentState = $AllSpamFilterPolicies | Where-Object -Property Name -EQ $PolicyName + $SpamAction = $Settings.SpamAction.value ?? $Settings.SpamAction $SpamQuarantineTag = $Settings.SpamQuarantineTag.value ?? $Settings.SpamQuarantineTag $HighConfidenceSpamAction = $Settings.HighConfidenceSpamAction.value ?? $Settings.HighConfidenceSpamAction @@ -103,6 +121,21 @@ function Invoke-CIPPStandardSpamFilterPolicy { $PhishQuarantineTag = $Settings.PhishQuarantineTag.value ?? $Settings.PhishQuarantineTag $HighConfidencePhishQuarantineTag = $Settings.HighConfidencePhishQuarantineTag.value ?? $Settings.HighConfidencePhishQuarantineTag + # Normalize list settings to clean string arrays. Values may arrive as a proper array or as a + # single comma-delimited string; splitting and trimming makes Compare-Object and remediation reliable. + # Case is folded to match what EXO stores and validates: ISO 3166-1 regions uppercase, ISO 639-1 languages lowercase. + $LanguageBlockList = @(@($Settings.LanguageBlockList.value) | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }) + $RegionBlockList = @(@($Settings.RegionBlockList.value) | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ }) + $AllowedSenderDomains = @(@($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains) | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + # Block lists only matter when their Enable* toggle is on; when off, the list is ignored entirely. + $CurrentLanguageBlockList = @($CurrentState.LanguageBlockList) + $CurrentRegionBlockList = @($CurrentState.RegionBlockList) + $LanguageBlockListCorrect = ($Settings.EnableLanguageBlockList -ne $true) -or + (($CurrentLanguageBlockList.Count -eq $LanguageBlockList.Count) -and (($LanguageBlockList.Count -eq 0) -or !(Compare-Object -ReferenceObject $CurrentLanguageBlockList -DifferenceObject $LanguageBlockList))) + $RegionBlockListCorrect = ($Settings.EnableRegionBlockList -ne $true) -or + (($CurrentRegionBlockList.Count -eq $RegionBlockList.Count) -and (($RegionBlockList.Count -eq 0) -or !(Compare-Object -ReferenceObject $CurrentRegionBlockList -DifferenceObject $RegionBlockList))) + $IncreaseScoreWithImageLinks = if ($Settings.IncreaseScoreWithImageLinks) { 'On' } else { 'Off' } $IncreaseScoreWithBizOrInfoUrls = if ($Settings.IncreaseScoreWithBizOrInfoUrls) { 'On' } else { 'Off' } $MarkAsSpamFramesInHtml = if ($Settings.MarkAsSpamFramesInHtml) { 'On' } else { 'Off' } @@ -146,10 +179,10 @@ function Invoke-CIPPStandardSpamFilterPolicy { ($CurrentState.PhishZapEnabled -eq $true) -and ($CurrentState.SpamZapEnabled -eq $true) -and ($CurrentState.EnableLanguageBlockList -eq $Settings.EnableLanguageBlockList) -and - ((($null -eq $CurrentState.LanguageBlockList -or $CurrentState.LanguageBlockList.Count -eq 0) -and ($null -eq $Settings.LanguageBlockList.value)) -or ($null -ne $CurrentState.LanguageBlockList -and $CurrentState.LanguageBlockList.Count -gt 0 -and $null -ne $Settings.LanguageBlockList.value -and !(Compare-Object -ReferenceObject $CurrentState.LanguageBlockList -DifferenceObject $Settings.LanguageBlockList.value))) -and + $LanguageBlockListCorrect -and ($CurrentState.EnableRegionBlockList -eq $Settings.EnableRegionBlockList) -and - ((($null -eq $CurrentState.RegionBlockList -or $CurrentState.RegionBlockList.Count -eq 0) -and ($null -eq $Settings.RegionBlockList.value)) -or ($null -ne $CurrentState.RegionBlockList -and $CurrentState.RegionBlockList.Count -gt 0 -and $null -ne $Settings.RegionBlockList.value -and !(Compare-Object -ReferenceObject $CurrentState.RegionBlockList -DifferenceObject $Settings.RegionBlockList.value))) -and - ((($null -eq $CurrentState.AllowedSenderDomains -or $CurrentState.AllowedSenderDomains.Count -eq 0) -and ($null -eq ($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains))) -or ($null -ne $CurrentState.AllowedSenderDomains -and $CurrentState.AllowedSenderDomains.Count -gt 0 -and $null -ne ($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains) -and !(Compare-Object -ReferenceObject $CurrentState.AllowedSenderDomains -DifferenceObject ($Settings.AllowedSenderDomains.value ?? $Settings.AllowedSenderDomains)))) + $RegionBlockListCorrect -and + ((($null -eq $CurrentState.AllowedSenderDomains -or $CurrentState.AllowedSenderDomains.Count -eq 0) -and ($AllowedSenderDomains.Count -eq 0)) -or ($null -ne $CurrentState.AllowedSenderDomains -and $CurrentState.AllowedSenderDomains.Count -gt 0 -and $AllowedSenderDomains.Count -gt 0 -and !(Compare-Object -ReferenceObject $CurrentState.AllowedSenderDomains -DifferenceObject $AllowedSenderDomains))) } catch { $StateIsCorrect = $false } @@ -201,19 +234,19 @@ function Invoke-CIPPStandardSpamFilterPolicy { InlineSafetyTipsEnabled = $true PhishZapEnabled = $true SpamZapEnabled = $true - AllowedSenderDomains = $Settings.AllowedSenderDomains.value ?? @{'@odata.type' = '#Exchange.GenericHashTable' } + AllowedSenderDomains = $AllowedSenderDomains.Count -gt 0 ? $AllowedSenderDomains : @{'@odata.type' = '#Exchange.GenericHashTable' } } # Remove optional block lists if not configured - if ($Settings.EnableLanguageBlockList -eq $true -and $Settings.LanguageBlockList.value) { + if ($Settings.EnableLanguageBlockList -eq $true -and $LanguageBlockList.Count -gt 0) { $cmdParams.Add('EnableLanguageBlockList', $Settings.EnableLanguageBlockList) - $cmdParams.Add('LanguageBlockList', $Settings.LanguageBlockList.value) + $cmdParams.Add('LanguageBlockList', $LanguageBlockList) } else { $cmdParams.Add('EnableLanguageBlockList', $false) } - if ($Settings.EnableRegionBlockList -eq $true -and $Settings.RegionBlockList.value) { + if ($Settings.EnableRegionBlockList -eq $true -and $RegionBlockList.Count -gt 0) { $cmdParams.Add('EnableRegionBlockList', $Settings.EnableRegionBlockList) - $cmdParams.Add('RegionBlockList', $Settings.RegionBlockList.value) + $cmdParams.Add('RegionBlockList', $RegionBlockList) } else { $cmdParams.Add('EnableRegionBlockList', $false) } @@ -238,7 +271,7 @@ function Invoke-CIPPStandardSpamFilterPolicy { } } - if ($RuleStateIsCorrect -eq $false) { + if ($RuleStateIsCorrect -eq $false -and -not $IsDefaultPolicy) { $cmdParams = @{ Priority = 0 RecipientDomainIs = ConvertTo-SafeArray -Field $AcceptedDomains.Name @@ -309,9 +342,7 @@ function Invoke-CIPPStandardSpamFilterPolicy { MarkAsSpamWebBugsInHtml = $CurrentState.MarkAsSpamWebBugsInHtml MarkAsSpamSensitiveWordList = $CurrentState.MarkAsSpamSensitiveWordList EnableLanguageBlockList = $CurrentState.EnableLanguageBlockList - LanguageBlockList = $CurrentState.LanguageBlockList EnableRegionBlockList = $CurrentState.EnableRegionBlockList - RegionBlockList = $CurrentState.RegionBlockList AllowedSenderDomains = $CurrentState.AllowedSenderDomains } $ExpectedValue = @{ @@ -335,11 +366,20 @@ function Invoke-CIPPStandardSpamFilterPolicy { MarkAsSpamWebBugsInHtml = $MarkAsSpamWebBugsInHtml MarkAsSpamSensitiveWordList = $MarkAsSpamSensitiveWordList EnableLanguageBlockList = $Settings.EnableLanguageBlockList - LanguageBlockList = $Settings.EnableLanguageBlockList ? @($Settings.EnableLanguageBlockList) : @() EnableRegionBlockList = $Settings.EnableRegionBlockList - RegionBlockList = $Settings.RegionBlockList.value ? @($Settings.RegionBlockList.value) : @() - AllowedSenderDomains = $Settings.AllowedSenderDomains.value ? @($Settings.AllowedSenderDomains.value) : @() + AllowedSenderDomains = $AllowedSenderDomains + } + + # Only include the block lists in the comparison when their toggle is enabled; otherwise they are ignored. + if ($Settings.EnableLanguageBlockList) { + $CurrentValue['LanguageBlockList'] = $CurrentState.LanguageBlockList + $ExpectedValue['LanguageBlockList'] = $LanguageBlockList } + if ($Settings.EnableRegionBlockList) { + $CurrentValue['RegionBlockList'] = $CurrentState.RegionBlockList + $ExpectedValue['RegionBlockList'] = $RegionBlockList + } + Set-CIPPStandardsCompareField -FieldName 'standards.SpamFilterPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } diff --git a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 index c64a9b1d03ce..f22c62b234a6 100644 --- a/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 +++ b/Modules/CIPPTests/Public/Tests/Custom/Invoke-CippTestCustomScripts.ps1 @@ -56,9 +56,19 @@ function Invoke-CippTestCustomScripts { $TestId = "CustomScript-$($Script.ScriptGuid)" $ScriptName = if ([string]::IsNullOrWhiteSpace($Script.ScriptName)) { $TestId } else { $Script.ScriptName } + $AllStatuses = @('Passed', 'Failed', 'Info', 'Investigate') $AlertStatuses = @('Failed') if ($AlertStatusesProp -and -not [string]::IsNullOrWhiteSpace($AlertStatusesProp.Value)) { - $AlertStatuses = $AlertStatusesProp.Value | ConvertFrom-Json + $RawAlertStatuses = [string]$AlertStatusesProp.Value + if ($RawAlertStatuses.TrimStart().StartsWith('[')) { + $AlertStatuses = @($RawAlertStatuses | ConvertFrom-Json) + } else { + $AlertStatuses = @($RawAlertStatuses) + } + } + # 'All' alerts on every result status. + if ($AlertStatuses -contains 'All') { + $AlertStatuses = $AllStatuses } try { @@ -105,7 +115,24 @@ function Invoke-CippTestCustomScripts { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultDataJson $ResultDataJson -ResultMarkdown $ResultMarkdown -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' if ($ShouldAlert -and $FinalStatus -in $AlertStatuses) { - Write-AlertMessage -tenant $Tenant -message "Custom script test failed: $ScriptName ($($Script.ScriptGuid))" + # Logbook entry for the UI/audit trail. Uses API 'CustomTests' + a non-alert + # severity so Push-SchedulerCIPPNotifications does not re-ship it. + Write-LogMessage -API 'CustomTests' -tenant $Tenant -message "Custom script test '$ScriptName' returned status '$FinalStatus' ($($Script.ScriptGuid))" -sev Info + # Emit an alert record. Delivery is batched per-tenant by Invoke-CIPPTestCollection + # after the whole suite runs (Send-CIPPCustomTestAlert). Manual single-test runs + # via Push-CIPPTest discard this, so they intentionally do not ship an alert. + [PSCustomObject]@{ + CippCustomTestAlert = $true + TestId = $TestId + ScriptGuid = $Script.ScriptGuid + ScriptName = $ScriptName + Status = $FinalStatus + Risk = $Script.Risk ?? 'Medium' + Pillar = $Script.Pillar + FailedRows = $FailedRows + ResultMarkdown = $ResultMarkdown + ErrorMessage = $null + } } } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -117,7 +144,19 @@ function Invoke-CippTestCustomScripts { } Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Custom' -Status $FinalStatus -ResultMarkdown "Custom script execution failed: $($ErrorMessage.NormalizedError)" -Risk ($Script.Risk ?? 'Medium') -Name $ScriptName -Pillar $Script.Pillar -UserImpact $Script.UserImpact -ImplementationEffort $Script.ImplementationEffort -Category 'Custom Script' if ($ShouldAlert -and $FinalStatus -in $AlertStatuses) { - Write-AlertMessage -tenant $Tenant -message "Custom script execution failed: $ScriptName ($($Script.ScriptGuid)) - $($ErrorMessage.NormalizedError)" + Write-LogMessage -API 'CustomTests' -tenant $Tenant -message "Custom script execution failed: $ScriptName ($($Script.ScriptGuid)) - $($ErrorMessage.NormalizedError)" -sev Warning + [PSCustomObject]@{ + CippCustomTestAlert = $true + TestId = $TestId + ScriptGuid = $Script.ScriptGuid + ScriptName = $ScriptName + Status = $FinalStatus + Risk = $Script.Risk ?? 'Medium' + Pillar = $Script.Pillar + FailedRows = @() + ResultMarkdown = '' + ErrorMessage = $ErrorMessage.NormalizedError + } } } } diff --git a/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 b/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 index 6e61d59f7e3a..c82202da5200 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Get-NinjaOneFieldMapping.ps1 @@ -3,6 +3,13 @@ function Get-NinjaOneFieldMapping { param ( $CIPPMapping ) + + $Unset = [PSCustomObject]@{ + name = '--- Do not synchronize ---' + value = $null + type = 'unset' + } + try { #Get available mappings $Mappings = [pscustomobject]@{} @@ -94,13 +101,9 @@ function Get-NinjaOneFieldMapping { if ($Null -eq $NinjaCustomFieldsOrg) { [System.Collections.Generic.List[object]]$NinjaCustomFieldsOrg = @() } - $Unset = [PSCustomObject]@{ - name = '--- Do not synchronize ---' - value = $null - type = 'unset' - } } catch { + Write-Information "Get-NinjaOneFieldMapping: failed to retrieve NinjaOne custom fields: $($_.Exception.Message)" [System.Collections.Generic.List[object]]$NinjaCustomFieldsNode = @() [System.Collections.Generic.List[object]]$NinjaCustomFieldsOrg = @() } diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 5c6753fc6de9..a4200a6a7f44 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -1945,95 +1945,75 @@ function Invoke-NinjaOneTenantSync { [System.Collections.Generic.List[PSCustomObject]]$WidgetData = @() - ### Fetch BPA Data - $Table = get-cipptable 'cachebpav2' - $BPAData = (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$($Customer.customerId)'") - - if ($Null -ne $BPAData.Timestamp) { - ## BPA Data Widgets - # Shared Mailboxes with Enabled Users - #$WidgetData.add([PSCustomObject]@{ - # Value = $( - # $SharedSendMailboxCount = ($BpaData.SharedMailboxeswithenabledusers | ConvertFrom-Json | Measure-Object).count - # if ($SharedSendMailboxCount -ne 0) { - # $ResultColour = '#D53948' - # } else { - # $ResultColour = '#26A644' - # } - # $SharedSendMailboxCount - # ) - # Description = 'Shared Mailboxes with enabled users' - # Colour = $ResultColour - # Link = "https://$CIPPUrl/tenant/standards/bpa-report?SearchNow=true&Report=CIPP+Best+Practices+v1.0+-+Tenant+view&tenantFilter=$($Customer.customerId)" - # }) - - # Unused Licenses - $WidgetData.add([PSCustomObject]@{ - Value = $( - try { - $BPAUnusedLicenses = (($BpaData.Unusedlicenses | ConvertFrom-Json -ErrorAction SilentlyContinue).availableUnits | Measure-Object -Sum).sum - } catch { - $BPAUnusedLicenses = 'Failed to retrieve unused licenses' - } - if ($BPAUnusedLicenses -ne 0) { - $ResultColour = '#D53948' - } else { - $ResultColour = '#26A644' - } - $BPAUnusedLicenses - ) - Description = 'Unused Licenses' - Colour = $ResultColour - Link = "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" - }) + ### Tenant Posture Widgets (CIPP Reporting DB) + $PostureTenant = $Customer.defaultDomainName + # Reads a reporting DB type and returns the deserialized data objects (count rows excluded). + $GetDbData = { + param($Tenant, $Type) + try { + Get-CIPPDbItem -TenantFilter $Tenant -Type $Type | Where-Object { $_.RowKey -notlike '*-Count' } | ForEach-Object { $_.Data | ConvertFrom-Json -ErrorAction SilentlyContinue } + } catch { + Write-Information "NinjaOne: failed to read '$Type' from reporting DB for $Tenant : $($_.Exception.Message)" + } + } - # Unified Audit Log - $WidgetData.add([PSCustomObject]@{ - Value = $(if ($BPAData.UnifiedAuditLog -eq $True) { - $ResultColour = '#26A644' - '' - } else { - $ResultColour = '#D53948' - '' - } - ) - Description = 'Unified Audit Log' - Colour = $ResultColour - Link = "https://security.microsoft.com/auditlogsearch?viewid=Async%20Search&tid=$($Customer.customerId)" - }) + # OAuth App Consent - user consent restricted (legacy open-consent policy not assigned). + $AuthPolicy = (& $GetDbData -Tenant $PostureTenant -Type 'AuthorizationPolicy') | Select-Object -First 1 + $HasAuthPolicy = $null -ne $AuthPolicy + $OAuthConsentRestricted = 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy' -notin $AuthPolicy.permissionGrantPolicyIdsAssignedToDefaultUserRole + + # Unified Audit Log - ingestion enabled + $AuditConfig = (& $GetDbData -Tenant $PostureTenant -Type 'ExoAdminAuditLogConfig') | Select-Object -First 1 + $HasAuditConfig = $null -ne $AuditConfig + $UnifiedAuditLogEnabled = $AuditConfig.UnifiedAuditLogIngestionEnabled -eq $true + + # Password Never Expires - any domain with password validity set to never (2147483647) + $DomainData = & $GetDbData -Tenant $PostureTenant -Type 'Domains' + $HasDomainData = ($DomainData | Measure-Object).Count -gt 0 + $PasswordNeverExpires = [bool]($DomainData | Where-Object { $_.passwordValidityPeriodInDays -eq 2147483647 }) + + # Unused Licenses - sum of available units across SKUs with spare licenses + $LicenseData = & $GetDbData -Tenant $PostureTenant -Type 'LicenseOverview' + $HasLicenseData = ($LicenseData | Measure-Object).Count -gt 0 + $UnusedLicenseCount = (($LicenseData | Where-Object { $_.availableUnits -gt 0 }).availableUnits | Measure-Object -Sum).Sum + if ($null -eq $UnusedLicenseCount) { $UnusedLicenseCount = 0 } + + Write-Information "Tenant posture (reporting DB) - AuthPolicy:$HasAuthPolicy AuditConfig:$HasAuditConfig Domains:$HasDomainData Licenses:$HasLicenseData" + + # Renders a boolean posture widget, with a neutral state when no cached data is available. + $NewPostureWidget = { + param($Description, $Link, $HasData, $State) + if (-not $HasData) { + [PSCustomObject]@{ Value = ''; Description = $Description; Colour = '#CCCCCC'; Link = $Link } + } elseif ($State) { + [PSCustomObject]@{ Value = ''; Description = $Description; Colour = '#26A644'; Link = $Link } + } else { + [PSCustomObject]@{ Value = ''; Description = $Description; Colour = '#D53948'; Link = $Link } + } + } - # Passwords Never Expire + # Unused Licenses + $UnusedLicenseLink = "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" + if (-not $HasLicenseData) { + $WidgetData.add([PSCustomObject]@{ Value = 'No data'; Description = 'Unused Licenses'; Colour = '#CCCCCC'; Link = $UnusedLicenseLink }) + } else { $WidgetData.add([PSCustomObject]@{ - Value = $(if ($BPAData.PasswordNeverExpires -eq $True) { - $ResultColour = '#26A644' - '' - } else { - $ResultColour = '#D53948' - '' - } - ) - Description = 'Password Never Expires' - Colour = $ResultColour - Link = "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" + Value = $UnusedLicenseCount + Description = 'Unused Licenses' + Colour = $(if ($UnusedLicenseCount -ne 0) { '#D53948' } else { '#26A644' }) + Link = $UnusedLicenseLink }) + } - # oAuth App Consent - $WidgetData.add([PSCustomObject]@{ - Value = $(if ($BPAData.OAuthAppConsent -eq $True) { - $ResultColour = '#26A644' - '' - } else { - $ResultColour = '#D53948' - '' - } - ) - Description = 'OAuth App Consent' - Colour = $ResultColour - Link = "https://entra.microsoft.com/$($Customer.defaultDomainName)/#view/Microsoft_AAD_IAM/ConsentPoliciesMenuBlade/~/UserSettings" - }) + # Unified Audit Log + $WidgetData.add((& $NewPostureWidget -Description 'Unified Audit Log' -Link "https://security.microsoft.com/auditlogsearch?viewid=Async%20Search&tid=$($Customer.customerId)" -HasData $HasAuditConfig -State $UnifiedAuditLogEnabled)) - } + # Password Never Expires + $WidgetData.add((& $NewPostureWidget -Description 'Password Never Expires' -Link "https://$CIPPUrl/tenant/standards/bpa-report?tenantFilter=$($Customer.defaultDomainName)" -HasData $HasDomainData -State $PasswordNeverExpires)) + + # OAuth App Consent + $WidgetData.add((& $NewPostureWidget -Description 'OAuth App Consent' -Link "https://entra.microsoft.com/$($Customer.defaultDomainName)/#view/Microsoft_AAD_IAM/ConsentPoliciesMenuBlade/~/UserSettings" -HasData $HasAuthPolicy -State $OAuthConsentRestricted)) # Blocked Senders $BlockedSenderCount = ($BlockedSenders | Measure-Object).count diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 70a610110c12..08c5a60fde37 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; @@ -609,9 +610,33 @@ public static async Task SendAsync( using (response) { var statusCode = (int)response.StatusCode; - var content = response.Content is not null - ? await response.Content.ReadAsStringAsync(token).ConfigureAwait(false) - : string.Empty; + + // Read the body defensively. With AutomaticDecompression enabled, ReadAsStringAsync can throw + // (InvalidDataException "...unsupported compression method", IOException, ...) when a response + // carries a Content-Encoding the handler cannot decode or a malformed/mislabeled body. Such a + // read failure must NOT mask the HTTP status: for an error response we surface the status code + // (the actionable signal - e.g. a 403 from EXO), and for a success response we surface a clear, + // attributable error instead of an opaque decompression exception. Callers that skip the error + // check (e.g. redirect / compliance-URL discovery) keep their headers and fall back to an empty body. + string content; + try + { + content = response.Content is not null + ? await response.Content.ReadAsStringAsync(token).ConfigureAwait(false) + : string.Empty; + } + catch (Exception ex) when (ex is InvalidDataException || ex is IOException) + { + if (!(skipErrorCheck || noRedirect)) + { + TrackPoolResult(selection.Pool, response.IsSuccessStatusCode, statusCode); + var readFailMessage = !response.IsSuccessStatusCode + ? $"Response status code does not indicate success: {statusCode}" + : $"Failed to read response body (status {statusCode}): {ex.Message}"; + throw new HttpRequestException(readFailMessage, ex, response.StatusCode); + } + content = string.Empty; + } // ---------------------------------------------------------- // Response headers diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index b5ad6c396c0c..242e6458a803 100644 Binary files a/Shared/CIPPSharp/bin/CIPPSharp.dll and b/Shared/CIPPSharp/bin/CIPPSharp.dll differ diff --git a/host.json b/host.json index 3d53421632b0..67e853b0f3cf 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.4", + "defaultVersion": "10.5.5", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} \ No newline at end of file +} diff --git a/version_latest.txt b/version_latest.txt index 927fa80836fb..23b7528bc208 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.4 +10.5.5