diff --git a/Config/ExcludeSkuList.JSON b/Config/ExcludeSkuList.JSON index 7409a30962dd..1dcaadfa2f69 100644 --- a/Config/ExcludeSkuList.JSON +++ b/Config/ExcludeSkuList.JSON @@ -59,6 +59,10 @@ "GUID": "8c4ce438-32a7-4ac5-91a6-e22ae08d9c8b", "Product_Display_Name": "Rights Management Adhoc" }, + { + "GUID": "093e8d14-a334-43d9-93e3-30589a8b47d0", + "Product_Display_Name": "Rights Management Service Basic Content Protection" + }, { "GUID": "5b631642-bd26-49fe-bd20-1daaa972ef80", "Product_Display_Name": "Microsoft Power Apps for Developer" diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index ba991b67302a..59ffac3d4b91 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -44,6 +44,31 @@ ], "Hidden": true }, + { + "Id": "CopilotAI", + "Name": "Copilot & AI", + "Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.", + "Enabled": false, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ListCopilotSettings", + "ExecCopilotSettings", + "ListCopilotUsage", + "ListAgent365Packages", + "ListAgent365PackageDetail", + "ListShadowAI" + ], + "Pages": [ + "/copilot/settings", + "/copilot/shadow-ai", + "/copilot/agent365/packages", + "/copilot/reports/copilot-adoption", + "/copilot/reports/copilot-usage", + "/copilot/reports/copilot-trend" + ], + "Hidden": true + }, { "Id": "MCPServer", "Name": "MCP Server", @@ -57,4 +82,4 @@ "Pages": [], "Hidden": false } -] +] \ No newline at end of file diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index bcba2d5ec5b2..8868687473b2 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -23,6 +23,18 @@ { "resourceAppId": "00000003-0000-0000-c000-000000000000", "resourceAccess": [ + { + "id": "ed31732f-9495-47ed-ba3b-4ed0948c1c64", + "type": "Role" + }, + { + "id": "72f0655d-6228-4ddc-8e1b-164973b9213b", + "type": "Role" + }, + { + "id": "556d5e2e-1081-4452-8147-26c3a1b06f58", + "type": "Role" + }, { "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", "type": "Role" diff --git a/Config/ShadowAI.json b/Config/ShadowAI.json new file mode 100644 index 000000000000..081ecc500456 --- /dev/null +++ b/Config/ShadowAI.json @@ -0,0 +1,176 @@ +[ + { "name": "Azure OpenAI", "vendor": "Microsoft", "category": "AI Platform & API", "risk": "Low", "matchNames": ["azure openai"], "appIds": [] }, + { "name": "GitHub Copilot", "vendor": "GitHub", "category": "AI Coding", "risk": "Medium", "matchNames": ["github copilot"], "appIds": [] }, + { "name": "OpenAI Codex", "vendor": "OpenAI", "category": "AI Coding", "risk": "High", "matchNames": ["codex"], "appIds": [] }, + { "name": "OpenAI Sora", "vendor": "OpenAI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["sora"], "appIds": [] }, + { "name": "ChatGPT", "vendor": "OpenAI", "category": "AI Assistant", "risk": "High", "matchNames": ["chatgpt", "openai"], "appIds": [] }, + { "name": "Claude", "vendor": "Anthropic", "category": "AI Assistant", "risk": "Medium", "matchNames": ["claude", "anthropic"], "appIds": [] }, + { "name": "NotebookLM", "vendor": "Google", "category": "AI Search & Research", "risk": "Low", "matchNames": ["notebooklm"], "appIds": [] }, + { "name": "Google Gemini", "vendor": "Google", "category": "AI Assistant", "risk": "Medium", "matchNames": ["gemini"], "appIds": [] }, + { "name": "Google Antigravity", "vendor": "Google", "category": "AI Coding", "risk": "Medium", "matchNames": ["antigravity"], "appIds": [] }, + { "name": "Vertex AI", "vendor": "Google", "category": "AI Platform & API", "risk": "Low", "matchNames": ["vertex ai"], "appIds": [] }, + { "name": "Microsoft Copilot", "vendor": "Microsoft", "category": "AI Assistant", "risk": "Low", "matchNames": ["copilot"], "appIds": [] }, + { "name": "Perplexity", "vendor": "Perplexity AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["perplexity"], "appIds": [] }, + { "name": "DeepSeek", "vendor": "DeepSeek", "category": "AI Assistant", "risk": "High", "matchNames": ["deepseek"], "appIds": [] }, + { "name": "Grok", "vendor": "xAI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["grok", "x.ai"], "appIds": [] }, + { "name": "Mistral Le Chat", "vendor": "Mistral AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["mistral"], "appIds": [] }, + { "name": "Meta AI", "vendor": "Meta", "category": "AI Assistant", "risk": "Medium", "matchNames": ["meta ai", "llama.cpp"], "appIds": [] }, + { "name": "Qwen", "vendor": "Alibaba", "category": "AI Assistant", "risk": "High", "matchNames": ["qwen"], "appIds": [] }, + { "name": "Kimi", "vendor": "Moonshot AI", "category": "AI Assistant", "risk": "High", "matchNames": ["kimi", "moonshot ai"], "appIds": [] }, + { "name": "Poe", "vendor": "Quora", "category": "AI Assistant", "risk": "Medium", "matchNames": ["poe by quora", "quora"], "appIds": [] }, + { "name": "You.com", "vendor": "You.com", "category": "AI Assistant", "risk": "Medium", "matchNames": ["you.com"], "appIds": [] }, + { "name": "Pi", "vendor": "Inflection AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["inflection"], "appIds": [] }, + { "name": "Character.AI", "vendor": "Character Technologies", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["character.ai", "character ai"], "appIds": [] }, + { "name": "Candy.AI", "vendor": "Candy.AI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["candy.ai"], "appIds": [] }, + { "name": "Janitor AI", "vendor": "JanitorAI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["janitor ai", "janitorai"], "appIds": [] }, + { "name": "Crushon AI", "vendor": "Crushon", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["crushon"], "appIds": [] }, + { "name": "FlowGPT", "vendor": "FlowGPT", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["flowgpt"], "appIds": [] }, + { "name": "Cursor", "vendor": "Anysphere", "category": "AI Coding", "risk": "Medium", "matchNames": ["cursor"], "appIds": [] }, + { "name": "Codeium / Windsurf", "vendor": "Codeium", "category": "AI Coding", "risk": "Medium", "matchNames": ["codeium", "windsurf"], "appIds": [] }, + { "name": "Tabnine", "vendor": "Tabnine", "category": "AI Coding", "risk": "Medium", "matchNames": ["tabnine"], "appIds": [] }, + { "name": "Sourcegraph Cody", "vendor": "Sourcegraph", "category": "AI Coding", "risk": "Medium", "matchNames": ["sourcegraph", "cody"], "appIds": [] }, + { "name": "Aider", "vendor": "Aider", "category": "AI Coding", "risk": "Medium", "matchNames": ["aider"], "appIds": [] }, + { "name": "Amazon Q / CodeWhisperer", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["codewhisperer", "amazon q"], "appIds": [] }, + { "name": "Amazon Bedrock", "vendor": "Amazon", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["bedrock"], "appIds": [] }, + { "name": "Amazon Kiro", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["kiro"], "appIds": [] }, + { "name": "Replit", "vendor": "Replit", "category": "AI Coding", "risk": "High", "matchNames": ["replit"], "appIds": [] }, + { "name": "Lovable", "vendor": "Lovable", "category": "AI Coding", "risk": "High", "matchNames": ["lovable"], "appIds": [] }, + { "name": "Bolt.new", "vendor": "StackBlitz", "category": "AI Coding", "risk": "High", "matchNames": ["bolt.new", "stackblitz"], "appIds": [] }, + { "name": "v0", "vendor": "Vercel", "category": "AI Coding", "risk": "High", "matchNames": ["v0.dev"], "appIds": [] }, + { "name": "Devin", "vendor": "Cognition", "category": "AI Coding", "risk": "High", "matchNames": ["devin ai", "cognition labs"], "appIds": [] }, + { "name": "Continue", "vendor": "Continue", "category": "AI Coding", "risk": "Medium", "matchNames": ["continue.dev"], "appIds": [] }, + { "name": "Cline", "vendor": "Cline", "category": "AI Coding", "risk": "Medium", "matchNames": ["cline"], "appIds": [] }, + { "name": "Roo Code", "vendor": "Roo Code", "category": "AI Coding", "risk": "Medium", "matchNames": ["roo code"], "appIds": [] }, + { "name": "Qodo", "vendor": "Qodo", "category": "AI Coding", "risk": "Medium", "matchNames": ["qodo"], "appIds": [] }, + { "name": "Warp", "vendor": "Warp", "category": "AI Coding", "risk": "Medium", "matchNames": ["warp.dev"], "appIds": [] }, + { "name": "Visual Studio Code", "vendor": "Microsoft", "category": "AI-Capable Editor", "risk": "Informational", "matchNames": ["visual studio code", "vscode"], "appIds": [] }, + { "name": "JetBrains AI", "vendor": "JetBrains", "category": "AI-Capable Editor", "risk": "Low", "matchNames": ["jetbrains ai"], "appIds": [] }, + { "name": "Ollama", "vendor": "Ollama", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["ollama"], "appIds": [] }, + { "name": "LM Studio", "vendor": "LM Studio", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["lm studio", "lmstudio"], "appIds": [] }, + { "name": "GPT4All", "vendor": "Nomic AI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["gpt4all"], "appIds": [] }, + { "name": "Jan", "vendor": "Jan", "category": "Local AI Runtime", "risk": "Low", "matchNames": ["jan.ai"], "appIds": [] }, + { "name": "Open WebUI", "vendor": "Open WebUI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["open webui"], "appIds": [] }, + { "name": "AnythingLLM", "vendor": "Mintplex Labs", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["anythingllm"], "appIds": [] }, + { "name": "Oobabooga", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["oobabooga"], "appIds": [] }, + { "name": "KoboldCpp", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["koboldcpp"], "appIds": [] }, + { "name": "LocalAI", "vendor": "LocalAI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["localai"], "appIds": [] }, + { "name": "Msty", "vendor": "Msty", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["msty"], "appIds": [] }, + { "name": "OpenClaw / Claw", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["claw"], "appIds": [] }, + { "name": "AutoGPT", "vendor": "Significant Gravitas", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["autogpt"], "appIds": [] }, + { "name": "AgentGPT", "vendor": "Reworkd", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["agentgpt"], "appIds": [] }, + { "name": "BabyAGI", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["babyagi"], "appIds": [] }, + { "name": "Manus", "vendor": "Monica", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["manus ai", "manus.im"], "appIds": [] }, + { "name": "LangChain", "vendor": "LangChain", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["langchain"], "appIds": [] }, + { "name": "LlamaIndex", "vendor": "LlamaIndex", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["llamaindex"], "appIds": [] }, + { "name": "CrewAI", "vendor": "CrewAI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["crewai"], "appIds": [] }, + { "name": "n8n", "vendor": "n8n", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["n8n"], "appIds": [] }, + { "name": "Zapier", "vendor": "Zapier", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["zapier"], "appIds": [] }, + { "name": "Make", "vendor": "Make", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["make.com"], "appIds": [] }, + { "name": "Lindy", "vendor": "Lindy", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["lindy"], "appIds": [] }, + { "name": "Relevance AI", "vendor": "Relevance AI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["relevance ai"], "appIds": [] }, + { "name": "Dust", "vendor": "Dust", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["dust.tt"], "appIds": [] }, + { "name": "Bardeen", "vendor": "Bardeen", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["bardeen"], "appIds": [] }, + { "name": "Gumloop", "vendor": "Gumloop", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["gumloop"], "appIds": [] }, + { "name": "Browse AI", "vendor": "Browse AI", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["browse ai"], "appIds": [] }, + { "name": "Midjourney", "vendor": "Midjourney", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["midjourney"], "appIds": [] }, + { "name": "Stable Diffusion", "vendor": "Stability AI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["stable diffusion", "stability ai"], "appIds": [] }, + { "name": "DALL-E", "vendor": "OpenAI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["dall-e", "dalle"], "appIds": [] }, + { "name": "Adobe Firefly", "vendor": "Adobe", "category": "AI Image & Design", "risk": "Low", "matchNames": ["firefly"], "appIds": [] }, + { "name": "Canva", "vendor": "Canva", "category": "AI Image & Design", "risk": "Informational", "matchNames": ["canva"], "appIds": [] }, + { "name": "Leonardo AI", "vendor": "Leonardo.Ai", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["leonardo.ai", "leonardo ai"], "appIds": [] }, + { "name": "Ideogram", "vendor": "Ideogram", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["ideogram"], "appIds": [] }, + { "name": "Krea", "vendor": "Krea", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["krea.ai"], "appIds": [] }, + { "name": "ComfyUI", "vendor": "Comfy Org", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["comfyui"], "appIds": [] }, + { "name": "Automatic1111", "vendor": "Community", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["automatic1111"], "appIds": [] }, + { "name": "InvokeAI", "vendor": "Invoke", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["invokeai"], "appIds": [] }, + { "name": "Remove.bg", "vendor": "Kaleido", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["remove.bg"], "appIds": [] }, + { "name": "Photoroom", "vendor": "Photoroom", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["photoroom"], "appIds": [] }, + { "name": "Synthesia", "vendor": "Synthesia", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["synthesia"], "appIds": [] }, + { "name": "HeyGen", "vendor": "HeyGen", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["heygen"], "appIds": [] }, + { "name": "RunwayML", "vendor": "Runway", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["runwayml", "runway ml"], "appIds": [] }, + { "name": "Descript", "vendor": "Descript", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["descript"], "appIds": [] }, + { "name": "OpusClip", "vendor": "OpusClip", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["opusclip", "opus clip"], "appIds": [] }, + { "name": "Veed", "vendor": "Veed", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["veed.io"], "appIds": [] }, + { "name": "Pika", "vendor": "Pika Labs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pika labs"], "appIds": [] }, + { "name": "Luma", "vendor": "Luma AI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["luma ai", "lumalabs"], "appIds": [] }, + { "name": "Pictory", "vendor": "Pictory", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pictory"], "appIds": [] }, + { "name": "InVideo", "vendor": "InVideo", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["invideo"], "appIds": [] }, + { "name": "CapCut", "vendor": "ByteDance", "category": "AI Video & Audio", "risk": "High", "matchNames": ["capcut"], "appIds": [] }, + { "name": "ElevenLabs", "vendor": "ElevenLabs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["elevenlabs"], "appIds": [] }, + { "name": "Murf", "vendor": "Murf", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["murf"], "appIds": [] }, + { "name": "Play.ht", "vendor": "PlayHT", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["play.ht"], "appIds": [] }, + { "name": "Speechify", "vendor": "Speechify", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["speechify"], "appIds": [] }, + { "name": "Suno", "vendor": "Suno", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["suno"], "appIds": [] }, + { "name": "Otter.ai", "vendor": "Otter", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["otter.ai"], "appIds": [] }, + { "name": "Fireflies.ai", "vendor": "Fireflies", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["fireflies"], "appIds": [] }, + { "name": "Fathom", "vendor": "Fathom", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["fathom"], "appIds": [] }, + { "name": "tl;dv", "vendor": "tldv", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["tldv", "tl;dv"], "appIds": [] }, + { "name": "Read AI", "vendor": "Read", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["read.ai"], "appIds": [] }, + { "name": "Krisp", "vendor": "Krisp", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["krisp"], "appIds": [] }, + { "name": "Sembly", "vendor": "Sembly AI", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["sembly"], "appIds": [] }, + { "name": "Avoma", "vendor": "Avoma", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["avoma"], "appIds": [] }, + { "name": "MeetGeek", "vendor": "MeetGeek", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["meetgeek"], "appIds": [] }, + { "name": "Supernormal", "vendor": "Supernormal", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["supernormal"], "appIds": [] }, + { "name": "Granola", "vendor": "Granola", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["granola"], "appIds": [] }, + { "name": "Grammarly", "vendor": "Grammarly", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["grammarly"], "appIds": [] }, + { "name": "Jasper", "vendor": "Jasper", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["jasper.ai"], "appIds": [] }, + { "name": "QuillBot", "vendor": "QuillBot", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["quillbot"], "appIds": [] }, + { "name": "Wordtune", "vendor": "AI21 Labs", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["wordtune"], "appIds": [] }, + { "name": "Copy.ai", "vendor": "Copy.ai", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["copy.ai"], "appIds": [] }, + { "name": "Writesonic", "vendor": "Writesonic", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["writesonic"], "appIds": [] }, + { "name": "Rytr", "vendor": "Rytr", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["rytr"], "appIds": [] }, + { "name": "Sudowrite", "vendor": "Sudowrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["sudowrite"], "appIds": [] }, + { "name": "HyperWrite", "vendor": "HyperWrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["hyperwrite"], "appIds": [] }, + { "name": "Writer", "vendor": "Writer", "category": "AI Writing & Translation", "risk": "Low", "matchNames": ["writer.com"], "appIds": [] }, + { "name": "DeepL", "vendor": "DeepL", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["deepl"], "appIds": [] }, + { "name": "Fyxer", "vendor": "Fyxer AI", "category": "AI Email", "risk": "High", "matchNames": ["fyxer"], "appIds": [] }, + { "name": "Superhuman", "vendor": "Superhuman", "category": "AI Email", "risk": "Medium", "matchNames": ["superhuman"], "appIds": [] }, + { "name": "Shortwave", "vendor": "Shortwave", "category": "AI Email", "risk": "Medium", "matchNames": ["shortwave"], "appIds": [] }, + { "name": "Lavender", "vendor": "Lavender", "category": "AI Email", "risk": "Medium", "matchNames": ["lavender"], "appIds": [] }, + { "name": "Glean", "vendor": "Glean", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["glean"], "appIds": [] }, + { "name": "Phind", "vendor": "Phind", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["phind"], "appIds": [] }, + { "name": "ChatPDF", "vendor": "ChatPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["chatpdf"], "appIds": [] }, + { "name": "AskYourPDF", "vendor": "AskYourPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["askyourpdf"], "appIds": [] }, + { "name": "SciSpace", "vendor": "SciSpace", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["scispace"], "appIds": [] }, + { "name": "Gamma", "vendor": "Gamma", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["gamma"], "appIds": [] }, + { "name": "Tome", "vendor": "Tome", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["tome.app", "tome ai"], "appIds": [] }, + { "name": "Beautiful.ai", "vendor": "Beautiful.ai", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["beautiful.ai"], "appIds": [] }, + { "name": "Notion", "vendor": "Notion", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["notion"], "appIds": [] }, + { "name": "Coda", "vendor": "Coda", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["coda"], "appIds": [] }, + { "name": "Mem", "vendor": "Mem", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["mem.ai"], "appIds": [] }, + { "name": "Monica", "vendor": "Monica", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["monica"], "appIds": [] }, + { "name": "Sider", "vendor": "Sider", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["sider.ai"], "appIds": [] }, + { "name": "Merlin", "vendor": "Merlin", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["merlin"], "appIds": [] }, + { "name": "MaxAI", "vendor": "MaxAI", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["maxai"], "appIds": [] }, + { "name": "Hugging Face", "vendor": "Hugging Face", "category": "AI Platform & API", "risk": "Low", "matchNames": ["hugging face", "huggingface"], "appIds": [] }, + { "name": "Replicate", "vendor": "Replicate", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["replicate"], "appIds": [] }, + { "name": "OpenRouter", "vendor": "OpenRouter", "category": "AI Platform & API", "risk": "High", "matchNames": ["openrouter"], "appIds": [] }, + { "name": "Together AI", "vendor": "Together AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["together ai", "together.ai"], "appIds": [] }, + { "name": "Groq", "vendor": "Groq", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["groq"], "appIds": [] }, + { "name": "Fireworks AI", "vendor": "Fireworks AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["fireworks ai"], "appIds": [] }, + { "name": "Cohere", "vendor": "Cohere", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["cohere"], "appIds": [] }, + { "name": "AI21 Labs", "vendor": "AI21 Labs", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["ai21"], "appIds": [] }, + { "name": "Databricks", "vendor": "Databricks", "category": "AI Platform & API", "risk": "Informational", "matchNames": ["databricks"], "appIds": [] }, + { "name": "Weights & Biases", "vendor": "Weights & Biases", "category": "AI Platform & API", "risk": "Low", "matchNames": ["weights & biases", "wandb"], "appIds": [] }, + { "name": "Gong", "vendor": "Gong", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["gong"], "appIds": [] }, + { "name": "Apollo.io", "vendor": "Apollo", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["apollo.io"], "appIds": [] }, + { "name": "Regie.ai", "vendor": "Regie.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["regie"], "appIds": [] }, + { "name": "Intercom Fin", "vendor": "Intercom", "category": "AI Business Apps", "risk": "Low", "matchNames": ["intercom"], "appIds": [] }, + { "name": "Salesforce Einstein", "vendor": "Salesforce", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["einstein"], "appIds": [] }, + { "name": "ServiceNow Now Assist", "vendor": "ServiceNow", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["now assist"], "appIds": [] }, + { "name": "Atlassian Rovo", "vendor": "Atlassian", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["rovo"], "appIds": [] }, + { "name": "Moveworks", "vendor": "Moveworks", "category": "AI Business Apps", "risk": "Low", "matchNames": ["moveworks"], "appIds": [] }, + { "name": "HireVue", "vendor": "HireVue", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["hirevue"], "appIds": [] }, + { "name": "Eightfold", "vendor": "Eightfold AI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["eightfold"], "appIds": [] }, + { "name": "Harvey", "vendor": "Harvey", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["harvey.ai", "harvey ai"], "appIds": [] }, + { "name": "Spellbook", "vendor": "Spellbook", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["spellbook"], "appIds": [] }, + { "name": "DataRobot", "vendor": "DataRobot", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["datarobot"], "appIds": [] }, + { "name": "H2O.ai", "vendor": "H2O.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["h2o.ai", "h2o ai"], "appIds": [] }, + { "name": "Julius AI", "vendor": "Julius", "category": "AI Business Apps", "risk": "High", "matchNames": ["julius ai"], "appIds": [] }, + { "name": "Vapi", "vendor": "Vapi", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["vapi"], "appIds": [] }, + { "name": "Retell AI", "vendor": "Retell", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["retell ai"], "appIds": [] }, + { "name": "Voiceflow", "vendor": "Voiceflow", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["voiceflow"], "appIds": [] }, + { "name": "Botpress", "vendor": "Botpress", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["botpress"], "appIds": [] }, + { "name": "Chatbase", "vendor": "Chatbase", "category": "AI Business Apps", "risk": "High", "matchNames": ["chatbase"], "appIds": [] }, + { "name": "StackAI", "vendor": "StackAI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["stackai", "stack ai"], "appIds": [] } +] diff --git a/Config/standards.json b/Config/standards.json index 1e2ceba142dc..3d27a41de832 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1,4 +1,113 @@ [ + { + "name": "standards.CopilotSettings", + "cat": "Copilot (M365) Standards", + "tag": [], + "helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.", + "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.", + "executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Pin Microsoft 365 Copilot Chat", + "name": "standards.CopilotSettings.copilotChatPinning", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Copilot Access to Open Content", + "name": "standards.CopilotSettings.blockAccessToOpenFiles", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Block open content", "value": "1" }, + { "label": "Allow open content", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Designer Image Generation", + "name": "standards.CopilotSettings.imageGeneration", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Web Search in Copilot", + "name": "standards.CopilotSettings.allowWebSearch", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Admin Copilot in Microsoft 365 Admin Center", + "name": "standards.CopilotSettings.allowInAdminCenters", + "options": [ + { "label": "Do not configure", "value": "donotconfigure" }, + { "label": "Enabled", "value": "1" }, + { "label": "Disabled", "value": "0" } + ] + } + ], + "label": "Configure Microsoft 365 Copilot policy settings", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-09", + "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/policySettings/{id}", + "recommendedBy": [] + }, + { + "name": "standards.CopilotLimitedMode", + "cat": "Copilot (M365) Standards", + "tag": [], + "helpText": "Controls Microsoft 365 Copilot Limited Mode for Teams meetings. When enabled for a group, Copilot in Teams meetings does not respond to sentiment-related prompts (inferring emotions, behavior, or judgments) for members of the selected group. A target group is required when enabling. Managed via the Copilot admin settings Graph API.", + "docsDescription": "Configures the `copilotAdminLimitedMode` setting through the `/copilot/admin/settings/limitedMode` Microsoft Graph API (beta). When enabled, `isEnabledForGroup` is set to true and applied to the resolved target group; when disabled, `isEnabledForGroup` is set to false. NOTE: this API currently requires delegated authentication and the acting identity must be Global Administrator to write the setting.", + "executiveText": "Limits Microsoft 365 Copilot in Teams meetings so it does not provide opinions on sentiment, emotions, or judgments for a selected group of users. This helps organizations meet workplace policy, privacy, and works-council requirements while still allowing Copilot to summarize and answer factual questions grounded in the meeting.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.CopilotLimitedMode.LimitedModeEnabled", + "label": "Enable Copilot Limited Mode for a group (Teams meetings)", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.CopilotLimitedMode.GroupName", + "label": "Target Group Name (wildcard match; required when enabled)", + "required": false, + "condition": { + "field": "standards.CopilotLimitedMode.LimitedModeEnabled", + "compareType": "is", + "compareValue": true + } + } + ], + "label": "Configure Microsoft 365 Copilot Limited Mode (Teams meetings)", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-09", + "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/settings/limitedMode", + "recommendedBy": [] + }, { "name": "standards.MailContacts", "cat": "Global Standards", @@ -77,7 +186,14 @@ "impactColour": "info", "addedDate": "2024-03-19", "powershellEquivalent": "New-MailContact", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DeployContactTemplates", @@ -102,27 +218,25 @@ } ], "label": "Deploy Mail Contact Template", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Low Impact", "impactColour": "info", "addedDate": "2025-05-31", "powershellEquivalent": "New-MailContact", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (3.1.1)", - "mip_search_auditlog", - "NIST CSF 2.0 (DE.CM-09)", - "CISAMSEXO171", - "CISAMSEXO173" - ], + "tag": ["CIS M365 7.0.0 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CISAMSEXO171", "CISAMSEXO173", "CIS_3_1_1"], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", "addedComponent": [], @@ -131,12 +245,20 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Enable-OrganizationCustomization", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.RestrictThirdPartyStorageServices", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (1.3.7)"], + "tag": ["CIS M365 7.0.0 (1.3.7)"], + "appliesToTest": ["CIS_1_3_7"], "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, Box, and other third-party storage providers.", "docsDescription": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. This standard ensures Microsoft 365 on the web third-party storage services are restricted by creating and disabling the Microsoft 365 on the web service principal (appId: c1f33bc0-bdb4-4248-ba9b-096807ddb43e). By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security. Impact is highly dependent upon current practices - if users do not use other storage providers, then minimal impact is likely. However, if users regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", "executiveText": "Prevents employees from using external cloud storage services like Dropbox, Google Drive, and Box within Microsoft 365, reducing data security risks and ensuring all company data remains within controlled corporate systems. This helps maintain data governance and prevents potential data leaks to unauthorized platforms.", @@ -146,7 +268,15 @@ "impactColour": "warning", "addedDate": "2025-06-06", "powershellEquivalent": "New-MgServicePrincipal and Update-MgServicePrincipal", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.ProfilePhotos", @@ -163,14 +293,8 @@ "label": "Select value", "name": "standards.ProfilePhotos.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -179,7 +303,14 @@ "impactColour": "info", "addedDate": "2025-01-19", "powershellEquivalent": "Set-OrganizationConfig -ProfilePhotoOptions EnablePhotos and Update-MgBetaAdminPeople", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.PhishProtection", @@ -192,13 +323,10 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-01-22", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "powershellEquivalent": "Portal only", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2", "OFFICE_BUSINESS"] }, { "name": "standards.Branding", @@ -230,38 +358,26 @@ "label": "Visual Template", "name": "standards.Branding.layoutTemplateType", "options": [ - { - "label": "Full-screen background", - "value": "default" - }, - { - "label": "Partial-screen background", - "value": "verticalSplit" - } + { "label": "Full-screen background", "value": "default" }, + { "label": "Partial-screen background", "value": "verticalSplit" } ] }, - { - "type": "switch", - "name": "standards.Branding.isHeaderShown", - "label": "Show header" - }, - { - "type": "switch", - "name": "standards.Branding.isFooterShown", - "label": "Show footer" - } + { "type": "switch", "name": "standards.Branding.isHeaderShown", "label": "Show header" }, + { "type": "switch", "name": "standards.Branding.isFooterShown", "label": "Show footer" } ], "label": "Set branding for the tenant", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-05-13", "powershellEquivalent": "Portal only", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2", "OFFICE_BUSINESS"] }, { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (1.3.6)", "CustomerLockBoxEnabled"], + "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.", "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.", @@ -271,7 +387,8 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -CustomerLockBoxEnabled $true", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["CustomerLockbox"] }, { "name": "standards.EnablePronouns", @@ -320,15 +437,22 @@ "name": "standards.DisableGuestDirectory", "cat": "Global Standards", "tag": [ - "CIS M365 5.0 (5.1.6.2)", + "CIS M365 7.0.0 (5.1.6.2)", "CISA (MS.AAD.5.1v1)", "EIDSCA.AP14", "EIDSCA.ST08", "EIDSCA.ST09", "NIST CSF 2.0 (PR.AA-05)", + "SMB1001 (2.8)" + ], + "appliesToTest": [ + "CIS_5_1_6_2", "EIDSCAAP07", + "EIDSCAAP14", "EIDSCAST08", - "EIDSCAST09" + "EIDSCAST09", + "SMB1001_2_8", + "ZTNA21792" ], "helpText": "Disables Guest access to enumerate directory objects. This prevents guest users from seeing other users or guests in the directory.", "docsDescription": "Sets it so guests can view only their own user profile. Permission to view other users isn't allowed. Also restricts guest users from seeing the membership of groups they're in. See exactly what get locked down in the [Microsoft documentation.](https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions)", @@ -344,12 +468,8 @@ { "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (6.5.4)", - "NIST CSF 2.0 (PR.IR-01)", - "ZTNA21799", - "CISAMSEXO51" - ], + "tag": ["CIS M365 7.0.0 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], + "appliesToTest": ["CISAMSEXO51", "CIS_6_5_4", "ZTNA21799"], "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", @@ -359,19 +479,20 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": [ - "CIS M365 5.0 (1.3.2)", - "spo_idle_session_timeout", - "NIST CSF 2.0 (PR.AA-03)", - "ZTNA21813", - "ZTNA21814", - "ZTNA21815" - ], + "tag": ["CIS M365 7.0.0 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], + "appliesToTest": ["CIS_1_3_2", "ZTNA21813", "ZTNA21814", "ZTNA21815"], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", "addedComponent": [ @@ -382,26 +503,11 @@ "label": "Select value", "name": "standards.ActivityBasedTimeout.timeout", "options": [ - { - "label": "1 Hour", - "value": "01:00:00" - }, - { - "label": "3 Hours", - "value": "03:00:00" - }, - { - "label": "6 Hours", - "value": "06:00:00" - }, - { - "label": "12 Hours", - "value": "12:00:00" - }, - { - "label": "24 Hours", - "value": "1.00:00:00" - } + { "label": "1 Hour", "value": "01:00:00" }, + { "label": "3 Hours", "value": "03:00:00" }, + { "label": "6 Hours", "value": "06:00:00" }, + { "label": "12 Hours", "value": "12:00:00" }, + { "label": "24 Hours", "value": "1.00:00:00" } ] } ], @@ -416,11 +522,19 @@ "name": "standards.AuthMethodsSettings", "cat": "Entra (AAD) Standards", "tag": [ + "CIS M365 7.0.0 (5.2.3.6)", "EIDSCA.AG01", "EIDSCA.AG02", "EIDSCA.AG03", + "SMB1001 (2.8)" + ], + "appliesToTest": [ + "CIS_5_2_3_6", + "EIDSCAAG01", "EIDSCAAG02", - "EIDSCAAG03" + "EIDSCAAG03", + "SMB1001_2_8", + "ZTNA21841" ], "helpText": "Configures the report suspicious activity settings and system credential preferences in the authentication methods policy.", "docsDescription": "Controls the authentication methods policy settings for reporting suspicious activity and system credential preferences. These settings help enhance the security of authentication in your organization.", @@ -434,18 +548,9 @@ "name": "standards.AuthMethodsSettings.ReportSuspiciousActivity", "label": "Report Suspicious Activity Settings", "options": [ - { - "label": "Microsoft managed", - "value": "default" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -456,18 +561,9 @@ "name": "standards.AuthMethodsSettings.SystemCredential", "label": "System Credential Preferences", "options": [ - { - "label": "Microsoft managed", - "value": "default" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -479,79 +575,447 @@ "recommendedBy": [] }, { - "name": "standards.AuthMethodsPolicyMigration", - "cat": "Entra (AAD) Standards", - "tag": ["EIDSCAAG01"], - "helpText": "Completes the migration of authentication methods policy to the new format", - "docsDescription": "Sets the authentication methods policy migration state to complete. This is required when migrating from legacy authentication policies to the new unified authentication methods policy.", - "executiveText": "Completes the transition from legacy authentication policies to Microsoft's modern unified authentication methods policy, ensuring the organization benefits from the latest security features and management capabilities. This migration enables enhanced security controls and simplified policy management.", - "addedComponent": [], - "label": "Complete Authentication Methods Policy Migration", - "impact": "Medium Impact", - "impactColour": "warning", - "addedDate": "2025-07-07", - "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", - "recommendedBy": ["CIPP"] - }, - { - "name": "standards.AppDeploy", + "name": "standards.AuthenticationMethods", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Deploys selected applications to the tenant. Use a comma separated list of application IDs to deploy multiple applications. Permissions will be copied from the source application.", - "docsDescription": "Uses the CIPP functionality that deploys applications across an entire tenant base as a standard.", - "executiveText": "Automatically deploys approved business applications across all company locations and users, ensuring consistent access to essential tools and maintaining standardized software configurations. This streamlines application management and reduces IT deployment overhead.", + "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups.", + "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", + "executiveText": "Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control.", "addedComponent": [ { - "type": "select", + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "label": "Microsoft Authenticator", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorSoftwareOath", + "label": "Enable Software OTP in Authenticator", + "defaultValue": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", "multiple": false, "creatable": false, - "label": "App Approval Mode", - "name": "standards.AppDeploy.mode", + "label": "Number Matching", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorNumberMatching", "options": [ - { - "label": "Template", - "value": "template" - }, - { - "label": "Copy Permissions", - "value": "copy" - } - ] + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } }, { "type": "autoComplete", - "multiple": true, + "multiple": false, "creatable": false, - "label": "Select Applications", - "name": "standards.AppDeploy.templateIds", - "api": { - "url": "/api/ListAppApprovalTemplates", - "labelField": "TemplateName", - "valueField": "TemplateId", - "queryKey": "StdAppApprovalTemplateList", - "addedField": { - "AppId": "AppId" - } - }, + "label": "Show Application Name in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayAppInfo", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], "condition": { - "field": "standards.AppDeploy.mode", + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", "compareType": "is", - "compareValue": "template" + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Show Geographic Location in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayLocation", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Companion App (Authenticator Lite)", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorCompanionApp", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true } }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.FIDO2Enabled", + "label": "FIDO2 Security Keys", + "defaultValue": false + }, { "type": "textField", - "name": "standards.AppDeploy.appids", - "label": "Application IDs, comma separated", + "name": "standards.AuthenticationMethods.FIDO2Group", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, "condition": { - "field": "standards.AppDeploy.mode", - "compareType": "isNot", - "compareValue": "template" + "field": "standards.AuthenticationMethods.FIDO2Enabled", + "compareType": "is", + "compareValue": true } - } - ], - "label": "Deploy Application", - "impact": "Low Impact", + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.TAPEnabled", + "label": "Temporary Access Pass", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.TAPGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "TAP Usage Mode", + "name": "standards.AuthenticationMethods.TAPUsableOnce", + "options": [ + { "label": "Only Once", "value": "true" }, + { "label": "Multiple Logons", "value": "false" } + ], + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLifetime", + "label": "TAP Default Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMinLifetime", + "label": "TAP Minimum Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMaxLifetime", + "label": "TAP Maximum Lifetime (minutes)", + "defaultValue": 480, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLength", + "label": "TAP Length (characters)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SoftwareOathEnabled", + "label": "Third-Party Software OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SoftwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SoftwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.HardwareOathEnabled", + "label": "Hardware OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.HardwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.HardwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SMSEnabled", + "label": "SMS", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SMSGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SMSEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.VoiceEnabled", + "label": "Voice Call", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.VoiceGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.VoiceEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.EmailEnabled", + "label": "Email OTP", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.EmailGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.EmailEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.x509CertificateEnabled", + "label": "Certificate-Based Authentication", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.x509CertificateGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.x509CertificateEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.QRCodePinEnabled", + "label": "QR Code Pin", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.QRCodePinGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodeLifetimeInDays", + "label": "QR Code Lifetime (days, 1-395)", + "defaultValue": 365, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodePinLength", + "label": "QR Code PIN Length (8-20)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + } + ], + "label": "Configure Authentication Methods", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-28", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.AdminSSPR", + "cat": "Entra (AAD) Standards", + "tag": ["EIDSCA.AP01"], + "appliesToTest": ["EIDSCAAP01", "ZTNA21842"], + "helpText": "Controls whether administrators are allowed to use Self-Service Password Reset through the Microsoft Entra authorization policy.", + "docsDescription": "Configures the allowedToUseSSPR property on the Microsoft Entra authorization policy. Microsoft documents this property as controlling whether administrators of the tenant can use Self-Service Password Reset. Use this standard to explicitly enable or disable administrator SSPR based on your security policy.", + "executiveText": "Controls whether tenant administrators can reset their own passwords through Self-Service Password Reset. Disabling this capability forces privileged accounts through more controlled recovery processes and reduces the risk of self-service recovery being misused on administrative identities.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.AdminSSPR.state", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ] + } + ], + "label": "Set administrator Self-Service Password Reset state", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-21", + "powershellEquivalent": "Update-MgBetaPolicyAuthorizationPolicy", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.AuthMethodsPolicyMigration", + "cat": "Entra (AAD) Standards", + "tag": [], + "appliesToTest": ["EIDSCAAG01"], + "helpText": "Completes the migration of authentication methods policy to the new format", + "docsDescription": "Sets the authentication methods policy migration state to complete. This is required when migrating from legacy authentication policies to the new unified authentication methods policy.", + "executiveText": "Completes the transition from legacy authentication policies to Microsoft's modern unified authentication methods policy, ensuring the organization benefits from the latest security features and management capabilities. This migration enables enhanced security controls and simplified policy management.", + "addedComponent": [], + "label": "Complete Authentication Methods Policy Migration", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-07-07", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.AppDeploy", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Deploys selected applications to the tenant. Use a comma separated list of application IDs to deploy multiple applications. Permissions will be copied from the source application.", + "docsDescription": "Uses the CIPP functionality that deploys applications across an entire tenant base as a standard.", + "executiveText": "Automatically deploys approved business applications across all company locations and users, ensuring consistent access to essential tools and maintaining standardized software configurations. This streamlines application management and reduces IT deployment overhead.", + "addedComponent": [ + { + "type": "select", + "multiple": false, + "creatable": false, + "label": "App Approval Mode", + "name": "standards.AppDeploy.mode", + "options": [ + { "label": "Template", "value": "template" }, + { "label": "Copy Permissions", "value": "copy" } + ] + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Applications", + "name": "standards.AppDeploy.templateIds", + "api": { + "url": "/api/ListAppApprovalTemplates", + "labelField": "TemplateName", + "valueField": "TemplateId", + "queryKey": "StdAppApprovalTemplateList", + "addedField": { "AppId": "AppId" } + }, + "condition": { + "field": "standards.AppDeploy.mode", + "compareType": "is", + "compareValue": "template" + } + }, + { + "type": "textField", + "name": "standards.AppDeploy.appids", + "label": "Application IDs, comma separated", + "condition": { + "field": "standards.AppDeploy.mode", + "compareType": "isNot", + "compareValue": "template" + } + } + ], + "label": "Deploy Application", + "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-07-07", "powershellEquivalent": "Portal or Graph API", @@ -560,7 +1024,8 @@ { "name": "standards.laps", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21953", "ZTNA21955", "ZTNA24560"], + "tag": ["CIS M365 7.0.0 (5.1.4.5)", "SMB1001 (2.2)"], + "appliesToTest": ["CIS_5_1_4_5", "SMB1001_2_2", "ZTNA21953", "ZTNA21955", "ZTNA24560"], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", @@ -576,14 +1041,17 @@ "name": "standards.PWdisplayAppInformationRequiredState", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (2.3.1)", + "CIS M365 7.0.0 (5.2.3.1)", "EIDSCA.AM03", "EIDSCA.AM04", "EIDSCA.AM06", "EIDSCA.AM07", "EIDSCA.AM09", "EIDSCA.AM10", - "NIST CSF 2.0 (PR.AA-03)", + "NIST CSF 2.0 (PR.AA-03)" + ], + "appliesToTest": [ + "CIS_5_2_3_1", "EIDSCAAM01", "EIDSCAAM03", "EIDSCAAM04", @@ -606,7 +1074,8 @@ { "name": "standards.allowOTPTokens", "cat": "Entra (AAD) Standards", - "tag": ["EIDSCA.AM02", "EIDSCAAM02"], + "tag": ["EIDSCA.AM02"], + "appliesToTest": ["EIDSCAAM02"], "helpText": "Allows you to use MS authenticator OTP token generator", "docsDescription": "Allows you to use Microsoft Authenticator OTP token generator. Useful for using the NPS extension as MFA on VPN clients.", "executiveText": "Enables one-time password generation through Microsoft Authenticator app, providing an additional secure authentication method for employees. This is particularly useful for secure VPN access and other systems requiring multi-factor authentication.", @@ -621,7 +1090,8 @@ { "name": "standards.PWcompanionAppAllowedState", "cat": "Entra (AAD) Standards", - "tag": ["EIDSCA.AM01"], + "tag": ["CIS M365 7.0.0 (5.2.3.10)", "EIDSCA.AM01"], + "appliesToTest": ["CIS_5_2_3_10", "EIDSCAAM01"], "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", @@ -633,18 +1103,9 @@ "label": "Select value", "name": "standards.PWcompanionAppAllowedState.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - }, - { - "label": "Microsoft managed", - "value": "default" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] } ], @@ -666,12 +1127,22 @@ "EIDSCA.AF05", "EIDSCA.AF06", "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ "EIDSCAAF01", "EIDSCAAF02", "EIDSCAAF03", "EIDSCAAF04", "EIDSCAAF05", - "EIDSCAAF06" + "EIDSCAAF06", + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_9", + "ZTNA21838", + "ZTNA21839" ], "helpText": "Enables the FIDO2 authenticationMethod for the tenant", "docsDescription": "Enables FIDO2 capabilities for the tenant. This allows users to use FIDO2 keys like a Yubikey for authentication.", @@ -687,7 +1158,8 @@ { "name": "standards.EnableHardwareOAuth", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "Enables the HardwareOath authenticationMethod for the tenant. This allows you to use hardware tokens for generating 6 digit MFA codes.", "docsDescription": "Enables Hardware OAuth tokens for the tenant. This allows users to use hardware tokens like a Yubikey for authentication.", "executiveText": "Enables physical hardware tokens that generate secure authentication codes, providing an alternative to smartphone-based authentication. This is particularly valuable for employees who cannot use mobile devices or require the highest security standards for accessing sensitive systems.", @@ -703,6 +1175,7 @@ "name": "standards.allowOAuthTokens", "cat": "Entra (AAD) Standards", "tag": ["EIDSCA.AT01", "EIDSCA.AT02"], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02"], "helpText": "Allows you to use any software OAuth token generator", "docsDescription": "Enables OTP Software OAuth tokens for the tenant. This allows users to use OTP codes generated via software, like a password manager to be used as an authentication method.", "executiveText": "Allows employees to use third-party authentication apps and password managers to generate secure login codes, providing flexibility in authentication methods while maintaining security standards. This accommodates diverse user preferences and existing security tools.", @@ -717,7 +1190,8 @@ { "name": "standards.FormsPhishingProtection", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (1.3.5)", "Security", "PhishingProtection"], + "tag": ["CIS M365 7.0.0 (1.3.5)", "Security", "PhishingProtection"], + "appliesToTest": ["CIS_1_3_5"], "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.", @@ -732,10 +1206,11 @@ { "name": "standards.TAP", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21845", "ZTNA21846", "EIDSCAAT01", "EIDSCAAT02"], + "tag": [], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", - "docsDescription": "Enables Temporary Password generation for the tenant.", - "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", + "docsDescription": "Enables Temporary Access Pass generation for the tenant.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ { "type": "autoComplete", @@ -744,18 +1219,12 @@ "label": "Select TAP Lifetime", "name": "standards.TAP.config", "options": [ - { - "label": "Only Once", - "value": "true" - }, - { - "label": "Multiple Logons", - "value": "false" - } + { "label": "Only Once", "value": "true" }, + { "label": "Multiple Logons", "value": "false" } ] } ], - "label": "Enable Temporary Access Passes", + "label": "Enable Temporary Access Passes (TAP)", "impact": "Low Impact", "impactColour": "info", "addedDate": "2022-03-15", @@ -765,7 +1234,8 @@ { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 5.0 (1.3.1)", "PWAgePolicyNew"], + "tag": ["CIS M365 7.0.0 (1.3.1)", "PWAgePolicyNew"], + "appliesToTest": ["CIS_1_3_1", "ZTNA21811"], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", "executiveText": "Eliminates mandatory password expiration requirements, allowing employees to keep strong passwords indefinitely rather than forcing frequent changes that often lead to weaker passwords. This modern security approach reduces help desk calls and improves overall password security when combined with multi-factor authentication.", @@ -780,16 +1250,18 @@ { "name": "standards.CustomBannedPasswordList", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (5.2.3.2)", - "ZTNA21848", - "ZTNA21849", - "ZTNA21850", + "tag": ["CIS M365 7.0.0 (5.2.3.2)", "SMB1001 (2.1)"], + "appliesToTest": [ + "CIS_5_2_3_2", "EIDSCAPR01", "EIDSCAPR02", "EIDSCAPR03", "EIDSCAPR05", - "EIDSCAPR06" + "EIDSCAPR06", + "SMB1001_2_1", + "ZTNA21848", + "ZTNA21849", + "ZTNA21850" ], "helpText": "**Requires Entra ID P1.** Updates and enables the Entra ID custom banned password list with the supplied words. Enter words separated by commas or semicolons. Each word must be 4-16 characters long. Maximum 1,000 words allowed.", "docsDescription": "Updates and enables the Entra ID custom banned password list with the supplied words. This supplements the global banned password list maintained by Microsoft. The custom list is limited to 1,000 key base terms of 4-16 characters each. Entra ID will [block variations and common substitutions](https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-configure-custom-password-protection#configure-custom-banned-passwords) of these words in user passwords. [How are passwords evaluated?](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad#score-calculation)", @@ -807,12 +1279,14 @@ "impactColour": "warning", "addedDate": "2025-06-28", "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, { "name": "standards.ExternalMFATrusted", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21803", "ZTNA21804"], + "tag": [], + "appliesToTest": ["ZTNA21803", "ZTNA21804"], "helpText": "Sets the state of the Cross-tenant access setting to trust external MFA. This allows guest users to use their home tenant MFA to access your tenant.", "executiveText": "Allows external partners and vendors to use their own organization's multi-factor authentication when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring they still meet authentication requirements.", "addedComponent": [ @@ -823,14 +1297,8 @@ "label": "Select value", "name": "standards.ExternalMFATrusted.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -844,12 +1312,8 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 5.0 (1.2.3)", - "CISA (MS.AAD.6.1v1)", - "ZTNA21772", - "ZTNA21787" - ], + "tag": ["CIS M365 7.0.0 (5.1.2.3)", "CISA (MS.AAD.6.1v1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_2_3", "SMB1001_2_8", "ZTNA21772", "ZTNA21787"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", "executiveText": "Prevents regular employees from creating new Microsoft 365 organizations, ensuring all new tenants are properly managed and controlled by IT administrators. This prevents unauthorized shadow IT environments and maintains centralized governance over Microsoft 365 resources.", @@ -865,7 +1329,7 @@ "name": "standards.EnableAppConsentRequests", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.5.2)", + "CIS M365 7.0.0 (5.1.5.2)", "CISA (MS.AAD.9.1v1)", "EIDSCA.CP04", "EIDSCA.CR01", @@ -873,12 +1337,17 @@ "EIDSCA.CR03", "EIDSCA.CR04", "Essential 8 (1507)", - "NIST CSF 2.0 (PR.AA-05)", - "ZTNA21869", + "NIST CSF 2.0 (PR.AA-05)" + ], + "appliesToTest": [ + "CIS_5_1_5_2", + "EIDSCACP04", "EIDSCACR01", "EIDSCACR02", "EIDSCACR03", - "EIDSCACR04" + "EIDSCACR04", + "ZTNA21809", + "ZTNA21869" ], "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", @@ -900,7 +1369,8 @@ { "name": "standards.NudgeMFA", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21889"], + "tag": ["SMB1001 (2.5)"], + "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], "helpText": "Sets the state of the registration campaign for the tenant", "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", @@ -912,14 +1382,8 @@ "label": "Select value", "name": "standards.NudgeMFA.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -943,7 +1407,8 @@ { "name": "standards.DisableM365GroupUsers", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.21.1v1)", "ZTNA21868"], + "tag": ["CISA (MS.AAD.21.1v1)", "SMB1001 (2.8)"], + "appliesToTest": ["SMB1001_2_8", "ZTNA21868"], "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.", @@ -953,19 +1418,28 @@ "impactColour": "info", "addedDate": "2022-07-17", "powershellEquivalent": "Update-MgBetaDirectorySetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableAppCreation", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.2.2)", + "CIS M365 7.0.0 (5.1.2.2)", "CISA (MS.AAD.4.1v1)", "EIDSCA.AP10", "Essential 8 (1175)", "NIST CSF 2.0 (PR.AA-05)", - "EIDSCAAP10" + "SMB1001 (2.8)" ], + "appliesToTest": ["CIS_5_1_2_2", "EIDSCAAP10", "SMB1001_2_8"], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", "executiveText": "Prevents regular employees from creating application registrations that could be used to maintain unauthorized access to company systems. This security measure ensures that only authorized IT personnel can create applications, reducing the risk of persistent security breaches through malicious applications.", @@ -980,7 +1454,8 @@ { "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21954"], + "tag": ["CIS M365 7.0.0 (5.1.4.6)"], + "appliesToTest": ["CIS_5_1_4_6", "ZTNA21954"], "helpText": "Controls whether standard users can recover BitLocker keys for devices they own.", "docsDescription": "Updates the Microsoft Entra authorization policy that controls whether standard users can read BitLocker recovery keys for devices they own. Choose to restrict access for tighter security or allow self-service recovery when operational needs require it.", "executiveText": "Gives administrators centralized control over BitLocker recovery secrets—restrict access to ensure IT-assisted recovery flows, or allow self-service when rapid device unlocks are a priority.", @@ -992,14 +1467,8 @@ "label": "Select state", "name": "standards.BitLockerKeysForOwnedDevice.state", "options": [ - { - "label": "Restrict", - "value": "restrict" - }, - { - "label": "Allow", - "value": "allow" - } + { "label": "Restrict", "value": "restrict" }, + { "label": "Allow", "value": "allow" } ] } ], @@ -1013,7 +1482,13 @@ { "name": "standards.DisableSecurityGroupUsers", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.20.1v1)", "NIST CSF 2.0 (PR.AA-05)", "ZTNA21868"], + "tag": [ + "CIS M365 7.0.0 (5.1.3.1)", + "CISA (MS.AAD.20.1v1)", + "NIST CSF 2.0 (PR.AA-05)", + "SMB1001 (2.8)" + ], + "appliesToTest": ["CIS_5_1_3_1", "SMB1001_2_8", "ZTNA21868"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], @@ -1041,7 +1516,8 @@ { "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (1.3.4)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_1_3_4", "EIDSCAAP05", "SMB1001_2_8"], "helpText": "**Requires 'Billing Administrator' GDAP role.** This standard disables all self service licenses and enables all exclusions", "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", "addedComponent": [ @@ -1050,6 +1526,11 @@ "name": "standards.DisableSelfServiceLicenses.Exclusions", "label": "License Ids to exclude from this standard", "required": false + }, + { + "type": "switch", + "name": "standards.DisableSelfServiceLicenses.DisableTrials", + "label": "Disable starting trials on behalf of your organization" } ], "label": "Disable Self Service Licensing", @@ -1062,7 +1543,8 @@ { "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", - "tag": ["ZTNA21858"], + "tag": ["SMB1001 (2.8)"], + "appliesToTest": ["SMB1001_2_8", "ZTNA21858"], "helpText": "Blocks login for guest users that have not logged in for a number of days", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ @@ -1079,26 +1561,31 @@ "impactColour": "warning", "addedDate": "2022-10-20", "powershellEquivalent": "Graph API", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, { "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.5.1)", + "CIS M365 7.0.0 (5.1.5.1)", "CISA (MS.AAD.4.2v1)", "EIDSCA.AP08", "EIDSCA.AP09", "Essential 8 (1175)", - "NIST CSF 2.0 (PR.AA-05)", - "ZTNA21772", - "ZTNA21774", - "ZTNA21807", + "NIST CSF 2.0 (PR.AA-05)" + ], + "appliesToTest": [ + "CIS_5_1_5_1", "EIDSCAAP08", "EIDSCAAP09", "EIDSCACP01", "EIDSCACP03", - "EIDSCACP04" + "EIDSCACP04", + "ZTNA21772", + "ZTNA21774", + "ZTNA21807", + "ZTNA21810" ], "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", @@ -1135,7 +1622,8 @@ { "name": "standards.GuestInvite", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "EIDSCAAP04"], + "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_6_3", "EIDSCAAP04", "EIDSCAAP07", "SMB1001_2_8", "ZTNA21791"], "helpText": "This setting controls who can invite guests to your directory to collaborate on resources secured by your company, such as SharePoint sites or Azure resources.", "executiveText": "Controls who within the organization can invite external partners and vendors to access company resources, ensuring proper oversight of external access while enabling necessary business collaboration. This helps maintain security while supporting partnership and vendor relationships.", "addedComponent": [ @@ -1147,22 +1635,13 @@ "label": "Who can send invites?", "name": "standards.GuestInvite.allowInvitesFrom", "options": [ - { - "label": "Everyone", - "value": "everyone" - }, + { "label": "Everyone", "value": "everyone" }, { "label": "Admins, Guest inviters and All Members", "value": "adminsGuestInvitersAndAllMembers" }, - { - "label": "Admins and Guest inviters", - "value": "adminsAndGuestInviters" - }, - { - "label": "None", - "value": "none" - } + { "label": "Admins and Guest inviters", "value": "adminsAndGuestInviters" }, + { "label": "None", "value": "none" } ] } ], @@ -1176,11 +1655,7 @@ { "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", - "tag": [ - "Essential 8 (1501)", - "NIST CSF 2.0 (ID.AM-08)", - "NIST CSF 2.0 (PR.PS-03)" - ], + "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], "helpText": "**Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days.", "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", @@ -1194,17 +1669,14 @@ } } ], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": true - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": true }, "label": "Cleanup stale Entra devices", "impact": "High Impact", "impactColour": "danger", "addedDate": "2025-01-19", "powershellEquivalent": "Remove-MgDevice, Update-MgDevice or Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.UndoOauth", @@ -1223,7 +1695,8 @@ { "name": "standards.SecurityDefaults", "cat": "Entra (AAD) Standards", - "tag": ["CISA (MS.AAD.11.1v1)", "ZTNA21843"], + "tag": ["CISA (MS.AAD.11.1v1)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9", "ZTNA21843"], "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", @@ -1239,10 +1712,20 @@ "name": "standards.DisableSMS", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AS04", "NIST CSF 2.0 (PR.AA-03)", - "EIDSCAAS04" + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "CIS_5_2_3_5", + "EIDSCAAS04", + "SMB1001_2_5", + "SMB1001_2_5_L4", + "SMB1001_2_6", + "SMB1001_2_9" ], "helpText": "This blocks users from using SMS as an MFA method. If a user only has SMS as a MFA method, they will be unable to log in.", "docsDescription": "Disables SMS as an MFA method for the tenant. If a user only has SMS as a MFA method, they will be unable to sign in.", @@ -1259,10 +1742,20 @@ "name": "standards.DisableVoice", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AV01", "NIST CSF 2.0 (PR.AA-03)", - "EIDSCAAV01" + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "CIS_5_2_3_5", + "EIDSCAAV01", + "SMB1001_2_5", + "SMB1001_2_5_L4", + "SMB1001_2_6", + "SMB1001_2_9" ], "helpText": "This blocks users from using Voice call as an MFA method. If a user only has Voice as a MFA method, they will be unable to log in.", "docsDescription": "Disables Voice call as an MFA method for the tenant. If a user only has Voice call as a MFA method, they will be unable to sign in.", @@ -1278,7 +1771,14 @@ { "name": "standards.DisableEmail", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 5.0 (2.3.5)", "NIST CSF 2.0 (PR.AA-03)"], + "tag": [ + "CIS M365 7.0.0 (5.2.3.7)", + "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": ["CIS_5_2_3_7", "SMB1001_2_5", "SMB1001_2_5_L4", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "This blocks users from using email as an MFA method. This disables the email OTP option for guest users, and instead prompts them to create a Microsoft account.", "executiveText": "Disables email-based authentication codes due to security concerns with email interception and account compromise. This forces users to adopt more secure authentication methods, particularly affecting guest users who must use stronger verification methods.", "addedComponent": [], @@ -1289,6 +1789,28 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", "recommendedBy": [] }, + { + "name": "standards.EmailAsAlternateLoginId", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures the tenant-wide Email as alternate login ID setting in Home Realm Discovery policy. Enabling this can help during migrations, if users are changing UPN.", + "docsDescription": "Sets the Home Realm Discovery policy AlternateIdLogin setting to enable or disable using email as an alternate sign-in ID.", + "executiveText": "Controls whether users can sign in with email as an alternate identifier, allowing organizations to align sign-in behavior with their identity strategy and reduce authentication ambiguity.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.EmailAsAlternateLoginId.Enabled", + "label": "Enable Email as Alternate Login ID", + "defaultValue": true + } + ], + "label": "Configure Email as alternate login ID", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-03", + "powershellEquivalent": "Invoke-MgGraphRequest https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies/", + "recommendedBy": ["CIPP"] + }, { "name": "standards.Disablex509Certificate", "cat": "Entra (AAD) Standards", @@ -1323,15 +1845,20 @@ "name": "standards.PerUserMFA", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 5.0 (1.2.1)", - "CIS M365 5.0 (1.1.1)", - "CIS M365 5.0 (1.1.2)", "CISA (MS.AAD.1.1v1)", "CISA (MS.AAD.1.2v1)", "Essential 8 (1504)", "Essential 8 (1173)", "Essential 8 (1401)", "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_9", "ZTNA21780", "ZTNA21782", "ZTNA21796" @@ -1359,11 +1886,7 @@ "creatable": false, "name": "standards.UserPreferredLanguage.preferredLanguage", "label": "Preferred Language", - "api": { - "url": "/languageList.json", - "labelField": "tag", - "valueField": "tag" - } + "api": { "url": "/languageList.json", "labelField": "tag", "valueField": "tag" } } ], "label": "Preferred language for all users", @@ -1376,7 +1899,21 @@ { "name": "standards.AppManagementPolicy", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": [ + "CIS M365 7.0.0 (5.1.5.3)", + "CIS M365 7.0.0 (5.1.5.4)", + "CIS M365 7.0.0 (5.1.5.5)", + "CIS M365 7.0.0 (5.1.5.6)" + ], + "appliesToTest": [ + "CIS_5_1_5_3", + "CIS_5_1_5_4", + "CIS_5_1_5_5", + "CIS_5_1_5_6", + "ZTNA21773", + "ZTNA21896", + "ZTNA21992" + ], "helpText": "Configures the default app management policy to control application and service principal credential restrictions such as password and key credential lifetimes.", "docsDescription": "Configures the default app management policy to control application and service principal credential restrictions. This includes password addition restrictions, custom password addition, symmetric key addition, and credential lifetime limits for both applications and service principals.", "executiveText": "Enforces credential restrictions on application registrations and service principals to limit how secrets and certificates are created and how long they remain valid. This reduces the risk of long-lived or unmanaged credentials being used to access your tenant.", @@ -1389,14 +1926,8 @@ "name": "standards.AppManagementPolicy.passwordCredentialsPasswordAddition", "label": "Disable Password Addition", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -1407,14 +1938,8 @@ "name": "standards.AppManagementPolicy.passwordCredentialsCustomPasswordAddition", "label": "Disable Custom Password", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -1440,7 +1965,8 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.6)"], + "tag": ["CIS M365 7.0.0 (2.1.6)"], + "appliesToTest": ["CIS_2_1_6"], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", "addedComponent": [ @@ -1455,7 +1981,14 @@ "impactColour": "info", "addedDate": "2023-05-03", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.MessageExpiration", @@ -1469,7 +2002,14 @@ "impactColour": "info", "addedDate": "2024-02-23", "powershellEquivalent": "Set-TransportConfig -MessageExpirationTimeout 12.00:00:00", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.GlobalQuarantineNotifications", @@ -1484,18 +2024,9 @@ "label": "Select value", "name": "standards.GlobalQuarantineNotifications.NotificationInterval", "options": [ - { - "label": "4 hours", - "value": "04:00:00" - }, - { - "label": "1 day/Daily", - "value": "1.00:00:00" - }, - { - "label": "7 days/Weekly", - "value": "7.00:00:00" - } + { "label": "4 hours", "value": "04:00:00" }, + { "label": "1 day/Daily", "value": "1.00:00:00" }, + { "label": "7 days/Weekly", "value": "7.00:00:00" } ] } ], @@ -1504,7 +2035,14 @@ "impactColour": "info", "addedDate": "2024-05-03", "powershellEquivalent": "Set-QuarantinePolicy -EndUserSpamNotificationFrequency", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableTNEF", @@ -1519,7 +2057,14 @@ "impactColour": "info", "addedDate": "2024-04-26", "powershellEquivalent": "Set-RemoteDomain -Identity 'Default' -TNEFEnabled $false", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.FocusedInbox", @@ -1535,14 +2080,8 @@ "label": "Select value", "name": "standards.FocusedInbox.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -1551,7 +2090,14 @@ "impactColour": "info", "addedDate": "2024-04-26", "powershellEquivalent": "Set-OrganizationConfig -FocusedInboxOn $true or $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.CloudMessageRecall", @@ -1567,14 +2113,8 @@ "label": "Select value", "name": "standards.CloudMessageRecall.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -1583,7 +2123,14 @@ "impactColour": "info", "addedDate": "2024-05-31", "powershellEquivalent": "Set-OrganizationConfig -MessageRecallEnabled", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoExpandArchive", @@ -1598,7 +2145,14 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Set-OrganizationConfig -AutoExpandingArchive", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.TwoClickEmailProtection", @@ -1615,14 +2169,8 @@ "label": "Select value", "name": "standards.TwoClickEmailProtection.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -1631,12 +2179,20 @@ "impactColour": "info", "addedDate": "2025-06-13", "powershellEquivalent": "Set-OrganizationConfig -TwoClickMailPreviewEnabled $true | $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EnableOnlineArchiving", "cat": "Exchange Standards", - "tag": ["Essential 8 (1511)", "NIST CSF 2.0 (PR.DS-11)"], + "tag": ["Essential 8 (1511)", "NIST CSF 2.0 (PR.DS-11)", "SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Enables the In-Place Online Archive for all UserMailboxes with a valid license.", "executiveText": "Automatically enables online email archiving for all licensed employees, providing additional storage for older emails while maintaining easy access. This helps manage mailbox sizes, improves email performance, and supports compliance with data retention requirements.", "addedComponent": [], @@ -1645,12 +2201,20 @@ "impactColour": "info", "addedDate": "2024-01-20", "powershellEquivalent": "Enable-Mailbox -Archive $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EnableLitigationHold", "cat": "Exchange Standards", - "tag": [], + "tag": ["SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Enables litigation hold for all UserMailboxes with a valid license.", "executiveText": "Preserves all email content for legal and compliance purposes by preventing permanent deletion of emails, even when users attempt to delete them. This is essential for organizations subject to legal discovery requirements or regulatory compliance mandates.", "addedComponent": [ @@ -1667,12 +2231,20 @@ "impactColour": "info", "addedDate": "2024-06-25", "powershellEquivalent": "Set-Mailbox -LitigationHoldEnabled $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.2.3)", "ORCA111", "ORCA240", "CISAMSEXO71"], + "tag": ["CIS M365 7.0.0 (6.2.3)"], + "appliesToTest": ["CISAMSEXO71", "CIS_6_2_3", "ORCA111", "ORCA240"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", @@ -1683,14 +2255,8 @@ "label": "Select value", "name": "standards.SpoofWarn.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -1706,13 +2272,21 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2021-11-16", - "powershellEquivalent": "Set-ExternalInOutlook \u2013Enabled $true or $false", - "recommendedBy": ["CIS", "CIPP"] + "powershellEquivalent": "Set-ExternalInOutlook –Enabled $true or $false", + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.5.2)", "exo_mailtipsenabled"], + "tag": ["CIS M365 7.0.0 (6.5.2)", "exo_mailtipsenabled"], + "appliesToTest": ["CIS_6_5_2"], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", "executiveText": "Enables helpful notifications in Outlook that warn users about potential email issues, such as sending to large groups, external recipients, or invalid addresses. This reduces email mistakes and improves communication efficiency by providing real-time guidance to employees.", "addedComponent": [ @@ -1729,7 +2303,14 @@ "impactColour": "info", "addedDate": "2024-01-14", "powershellEquivalent": "Set-OrganizationConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.TeamsMeetingsByDefault", @@ -1745,14 +2326,8 @@ "label": "Select value", "name": "standards.TeamsMeetingsByDefault.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -1761,7 +2336,14 @@ "impactColour": "info", "addedDate": "2024-05-31", "powershellEquivalent": "Set-OrganizationConfig -OnlineMeetingsByDefaultEnabled", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableViva", @@ -1781,7 +2363,8 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.9)"], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], + "appliesToTest": ["CIS_2_1_9", "SMB1001_2_12"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", "addedComponent": [], @@ -1790,12 +2373,52 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "Rotate-DkimSigningConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.EnableExchangeCloudManagement", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures cloud-based management of Exchange attributes for directory-synced users with remote mailboxes in Exchange Online. This allows you to enable or disable management of Exchange attributes directly in the cloud without requiring an on-premises Exchange server. More information can be found [here](https://learn.microsoft.com/da-dk/exchange/hybrid-deployment/enable-exchange-attributes-cloud-management).", + "docsDescription": "Configures the IsExchangeCloudManaged property for mailboxes, allowing Exchange attributes (aliases, mailbox flags, custom attributes, etc.) to be managed directly in Exchange Online or revert back to on-premises management. This feature helps organizations retire their last on-premises Exchange server in hybrid deployments while maintaining the ability to manage recipient attributes. Identity attributes (names, UPN) remain managed on-premises via Active Directory. More information can be found [here](https://learn.microsoft.com/da-dk/exchange/hybrid-deployment/enable-exchange-attributes-cloud-management).", + "executiveText": "Configures cloud-based management of Exchange mailbox attributes for hybrid organizations. When enabled, eliminates the dependency on on-premises Exchange servers for attribute management. This modernizes email administration, reduces infrastructure complexity, and allows direct management of mailbox properties through cloud portals and PowerShell. When disabled, returns management to on-premises Exchange servers.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "name": "standards.EnableExchangeCloudManagement.state", + "label": "Cloud Management State", + "options": [ + { "label": "Cloud Management", "value": true }, + { "label": "On-Premises Management", "value": false } + ] + } + ], + "label": "Configure Exchange Cloud Management for Remote/On-Premises Mailboxes", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-03-28", + "powershellEquivalent": "Set-Mailbox -Identity user@domain.com -IsExchangeCloudManaged $true or $false", + "recommendedBy": ["Microsoft", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV" + ] }, { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.9)", "ORCA108", "CISAMSEXO31"], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], + "appliesToTest": ["CISAMSEXO31", "CIS_2_1_9", "ORCA108", "ORCA108_1", "SMB1001_2_12"], "helpText": "Enables DKIM for all domains that currently support it", "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", "addedComponent": [], @@ -1804,12 +2427,20 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "New-DkimSigningConfig and Set-DkimSigningConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AddDMARCToMOERA", "cat": "Global Standards", - "tag": ["CIS M365 5.0 (2.1.10)", "Security", "PhishingProtection"], + "tag": ["CIS M365 7.0.0 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], + "appliesToTest": ["CIS_2_1_10", "SMB1001_2_12"], "helpText": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "docsDescription": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", @@ -1823,10 +2454,7 @@ "label": "Value", "name": "standards.AddDMARCToMOERA.RecordValue", "options": [ - { - "label": "v=DMARC1; p=reject; (recommended)", - "value": "v=DMARC1; p=reject;" - } + { "label": "v=DMARC1; p=reject; (recommended)", "value": "v=DMARC1; p=reject;" } ] } ], @@ -1836,23 +2464,21 @@ "addedDate": "2025-06-16", "powershellEquivalent": "Portal only", "recommendedBy": ["CIS", "Microsoft"], - "disabledFeatures": { - "remediate": true - } + "disabledFeatures": { "remediate": true } }, { "name": "standards.EnableMailboxAuditing", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (6.1.1)", - "CIS M365 5.0 (6.1.2)", - "CIS M365 5.0 (6.1.3)", + "CIS M365 7.0.0 (6.1.1)", + "CIS M365 7.0.0 (6.1.2)", + "CIS M365 7.0.0 (6.1.3)", "exo_mailboxaudit", "Essential 8 (1509)", "Essential 8 (1683)", - "NIST CSF 2.0 (DE.CM-09)", - "CISAMSEXO131" + "NIST CSF 2.0 (DE.CM-09)" ], + "appliesToTest": ["CISAMSEXO131", "CIS_6_1_1", "CIS_6_1_2", "CIS_6_1_3"], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "executiveText": "Enables comprehensive logging of all email access and modifications across all employee mailboxes, providing detailed audit trails for security investigations and compliance requirements. This helps detect unauthorized access, data breaches, and supports regulatory compliance efforts.", @@ -1862,7 +2488,14 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -AuditDisabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoArchive", @@ -1888,7 +2521,14 @@ "impactColour": "info", "addedDate": "2025-12-11", "powershellEquivalent": "Set-OrganizationConfig -AutoArchivingThresholdPercentage 80-100", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoArchiveMailbox", @@ -1905,14 +2545,8 @@ "label": "Select value", "name": "standards.AutoArchiveMailbox.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -1921,7 +2555,14 @@ "impactColour": "info", "addedDate": "2026-01-16", "powershellEquivalent": "Set-OrganizationConfig -AutoEnableArchiveMailbox $true|$false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SendReceiveLimitTenant", @@ -1956,7 +2597,14 @@ "impactColour": "info", "addedDate": "2023-11-16", "powershellEquivalent": "Set-MailboxPlan", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.calDefault", @@ -1965,11 +2613,7 @@ "helpText": "Sets the default sharing level for the default calendar, for all users", "docsDescription": "Sets the default sharing level for the default calendar for all users in the tenant. You can read about the different sharing levels [here.](https://learn.microsoft.com/en-us/powershell/module/exchange/set-mailboxfolderpermission?view=exchange-ps#-accessrights)", "executiveText": "Configures how much calendar information employees share by default with colleagues, balancing collaboration needs with privacy. This setting determines whether others can see meeting details, free/busy times, or just availability, helping optimize scheduling while protecting sensitive meeting information.", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "addedComponent": [ { "type": "autoComplete", @@ -2001,10 +2645,7 @@ "label": "Non Editing Author - The user has full read access and create items. Can can delete only own items.", "value": "NonEditingAuthor" }, - { - "label": "Reviewer - The user can read all items in the folder.", - "value": "Reviewer" - }, + { "label": "Reviewer - The user can read all items in the folder.", "value": "Reviewer" }, { "label": "Contributor - The user can create items and folders.", "value": "Contributor" @@ -2017,10 +2658,7 @@ "label": "Limited Details - The user can view free/busy time within the calendar and the subject and location of appointments.", "value": "LimitedDetails" }, - { - "label": "None - The user has no permissions on the folder.", - "value": "none" - } + { "label": "None - The user has no permissions on the folder.", "value": "none" } ] } ], @@ -2029,12 +2667,20 @@ "impactColour": "info", "addedDate": "2023-04-27", "powershellEquivalent": "Set-MailboxFolderPermission", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (2.1.6)"], + "tag": ["CIS M365 7.0.0 (2.1.15)"], + "appliesToTest": ["CIS_2_1_15"], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", "executiveText": "Sets limits on how many emails employees can send per hour and per day to prevent spam and protect the organization's email reputation. When limits are exceeded, the system can alert administrators or temporarily block the user, helping detect compromised accounts or prevent abuse.", @@ -2076,14 +2722,8 @@ "name": "standards.EXOOutboundSpamLimits.ActionWhenThresholdReached", "label": "Action When Threshold Reached", "options": [ - { - "label": "Alert", - "value": "Alert" - }, - { - "label": "Block User", - "value": "BlockUser" - }, + { "label": "Alert", "value": "Alert" }, + { "label": "Block User", "value": "BlockUser" }, { "label": "Block user from sending mail for the rest of the day", "value": "BlockUserForToday" @@ -2096,17 +2736,20 @@ "impactColour": "info", "addedDate": "2025-05-13", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": ["CIPP", "CIS"] + "recommendedBy": ["CIPP", "CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (1.3.3)", - "exo_individualsharing", - "ZTNA21803", - "CISAMSEXO62" - ], + "tag": ["CIS M365 7.0.0 (1.3.3)", "exo_individualsharing"], + "appliesToTest": ["CISAMSEXO62", "CIS_1_3_3", "ZTNA21803"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", @@ -2116,7 +2759,14 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Get-SharingPolicy | Set-SharingPolicy -Enabled $False", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AutoAddProxy", @@ -2132,20 +2782,13 @@ "addedDate": "2025-02-07", "powershellEquivalent": "Set-Mailbox -EmailAddresses @{add=$EmailAddress}", "recommendedBy": [], - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - } + "disabledFeatures": { "report": false, "warn": true, "remediate": false } }, { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": [ - "CIS M365 5.0 (6.5.3)", - "exo_storageproviderrestricted", - "ZTNA21817" - ], + "tag": ["CIS M365 7.0.0 (6.5.3)", "exo_storageproviderrestricted"], + "appliesToTest": ["CIS_6_5_3", "ZTNA21817"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", "executiveText": "Prevents employees from accessing personal cloud storage services like Dropbox or Google Drive through Outlook on the web, reducing data security risks and ensuring company information stays within approved corporate systems. This helps maintain data governance and prevents accidental data leaks.", @@ -2155,12 +2798,20 @@ "impactColour": "info", "addedDate": "2024-01-17", "powershellEquivalent": "Get-OwaMailboxPolicy | Set-OwaMailboxPolicy -AdditionalStorageProvidersEnabled $False", - "recommendedBy": ["CIS"] - }, + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": ["CIS M365 5.0 (2.1.13)"], + "tag": ["CIS M365 7.0.0 (2.1.13)"], + "appliesToTest": ["CIS_2_1_13"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", "executiveText": "Enables Microsoft's pre-approved list of trusted email servers to improve email delivery from legitimate sources while maintaining spam protection. This reduces false positives where legitimate emails might be blocked while still protecting against spam and malicious emails.", @@ -2176,7 +2827,14 @@ "impactColour": "info", "addedDate": "2025-02-15", "powershellEquivalent": "Set-HostedConnectionFilterPolicy \"Default\" -EnableSafeList $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.ShortenMeetings", @@ -2191,18 +2849,9 @@ "label": "Select value", "name": "standards.ShortenMeetings.ShortenEventScopeDefault", "options": [ - { - "label": "Disabled/None", - "value": "None" - }, - { - "label": "End early", - "value": "EndEarly" - }, - { - "label": "Start late", - "value": "StartLate" - } + { "label": "Disabled/None", "value": "None" }, + { "label": "End early", "value": "EndEarly" }, + { "label": "Start late", "value": "StartLate" } ] }, { @@ -2231,12 +2880,20 @@ "impactColour": "warning", "addedDate": "2024-05-27", "powershellEquivalent": "Set-OrganizationConfig -ShortenEventScopeDefault -DefaultMinutesToReduceShortEventsBy -DefaultMinutesToReduceLongEventsBy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.Bookings", "cat": "Exchange Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (1.3.9)"], + "appliesToTest": ["CIS_1_3_9"], "helpText": "Sets the state of Bookings on the tenant. Bookings is a scheduling tool that allows users to book appointments with others both internal and external.", "docsDescription": "", "executiveText": "Controls whether employees can use Microsoft Bookings to create online appointment scheduling pages for internal and external clients. This feature can improve customer service and streamline appointment management, but may need to be controlled for security or business process reasons.", @@ -2247,14 +2904,8 @@ "label": "Select value", "name": "standards.Bookings.state", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ] } ], @@ -2263,12 +2914,20 @@ "impactColour": "warning", "addedDate": "2024-05-31", "powershellEquivalent": "Set-OrganizationConfig -BookingsEnabled", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EXODirectSend", "cat": "Exchange Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (6.5.5)"], + "appliesToTest": ["CIS_6_5_5"], "helpText": "Sets the state of Direct Send in Exchange Online. Direct Send allows applications to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication.", "docsDescription": "Controls whether applications can use Direct Send to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication. A detailed explanation from Microsoft can be found [here.](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365)", "executiveText": "Controls whether business applications and devices (like printers or scanners) can send emails through the company's email system without authentication. While this enables convenient features like scan-to-email, it may pose security risks and should be carefully managed.", @@ -2280,14 +2939,8 @@ "label": "Select value", "name": "standards.EXODirectSend.state", "options": [ - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] } ], @@ -2302,12 +2955,12 @@ "name": "standards.DisableOutlookAddins", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (6.3.1)", + "CIS M365 7.0.0 (6.3.1)", "exo_outlookaddins", "NIST CSF 2.0 (PR.AA-05)", - "NIST CSF 2.0 (PR.PS-05)", - "ZTNA21817" + "NIST CSF 2.0 (PR.PS-05)" ], + "appliesToTest": ["CIS_6_3_1", "ZTNA21817"], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", "executiveText": "Prevents employees from installing third-party add-ins in Outlook without administrative approval, reducing security risks from potentially malicious extensions. This ensures only vetted and approved tools can access company email data while maintaining centralized control over email functionality.", @@ -2317,7 +2970,14 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Get-ManagementRoleAssignment | Remove-ManagementRoleAssignment", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SafeSendersDisable", @@ -2326,17 +2986,20 @@ "helpText": "Loops through all users and removes the Safe Senders list. This is to prevent SPF bypass attacks, as the Safe Senders list is not checked by SPF.", "executiveText": "Removes user-defined safe sender lists to prevent security bypasses where malicious emails could avoid spam filtering. This ensures all emails go through proper security screening, even if users have previously marked senders as 'safe', improving overall email security.", "addedComponent": [], - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "label": "Remove Safe Senders to prevent SPF bypass", "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2023-10-26", "powershellEquivalent": "Set-MailboxJunkEmailConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DelegateSentItems", @@ -2357,7 +3020,14 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SendFromAlias", @@ -2384,12 +3054,54 @@ "impactColour": "warning", "addedDate": "2022-05-25", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { - "name": "standards.UserSubmissions", + "name": "standards.DlpViaDcsEnabled", "cat": "Exchange Standards", "tag": [], + "helpText": "Sets whether Outlook on the web uses Data Classification Services for DLP evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "docsDescription": "Configures whether Outlook on the web uses Data Classification Services (DCS)-based Data Loss Prevention (DLP) policy evaluation instead of Exchange-based evaluation. Review DLP policies before enabling this setting, as some legacy Exchange-based predicates are not supported with DCS-based evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "executiveText": "Improves how Outlook on the web applies Data Loss Prevention policies, giving users clearer guidance when sensitive information may be shared and helping reduce accidental data exposure.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.DlpViaDcsEnabled.state", + "options": [ + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } + ] + } + ], + "label": "Set OWA DLP evaluation via DCS", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-20", + "powershellEquivalent": "Set-OrganizationConfig -DlpViaDcsEnabled", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.UserSubmissions", + "cat": "Exchange Standards", + "tag": ["CIS M365 7.0.0 (8.6.1)"], + "appliesToTest": ["CIS_8_6_1"], "helpText": "Set the state of the spam submission button in Outlook", "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", "executiveText": "Enables employees to easily report suspicious emails directly from Outlook, helping improve the organization's spam and phishing detection systems. This crowdsourced approach to security allows users to contribute to threat detection while providing valuable feedback to enhance email security filters.", @@ -2400,14 +3112,8 @@ "label": "Select value", "name": "standards.UserSubmissions.state", "options": [ - { - "label": "Enabled", - "value": "enable" - }, - { - "label": "Disabled", - "value": "disable" - } + { "label": "Enabled", "value": "enable" }, + { "label": "Disabled", "value": "disable" } ] }, { @@ -2422,16 +3128,25 @@ "impactColour": "warning", "addedDate": "2024-06-28", "powershellEquivalent": "New-ReportSubmissionPolicy or Set-ReportSubmissionPolicy and New-ReportSubmissionRule or Set-ReportSubmissionRule", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (1.2.2)", + "CIS M365 7.0.0 (1.2.2)", "CISA (MS.AAD.10.1v1)", - "NIST CSF 2.0 (PR.AA-01)" + "NIST CSF 2.0 (PR.AA-01)", + "SMB1001 (2.3)" ], + "appliesToTest": ["CIS_1_2_2", "SMB1001_2_3"], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users' accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", @@ -2446,7 +3161,8 @@ { "name": "standards.DisableResourceMailbox", "cat": "Exchange Standards", - "tag": ["NIST CSF 2.0 (PR.AA-01)"], + "tag": ["NIST CSF 2.0 (PR.AA-01)", "SMB1001 (2.3)"], + "appliesToTest": ["SMB1001_2_3"], "helpText": "Blocks login for all accounts that are marked as a resource mailbox and does not have a license assigned. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "docsDescription": "Resource mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for resource mailboxes. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "executiveText": "Prevents direct login to resource mailbox accounts (like conference rooms or equipment), ensuring they can only be managed through proper administrative channels. This security measure eliminates potential unauthorized access to resource scheduling systems while maintaining proper booking functionality.", @@ -2456,18 +3172,26 @@ "impactColour": "warning", "addedDate": "2025-06-01", "powershellEquivalent": "Get-Mailbox & Update-MgUser", - "recommendedBy": ["Microsoft", "CIPP"] + "recommendedBy": ["Microsoft", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.EXODisableAutoForwarding", "cat": "Exchange Standards", "tag": [ - "CIS M365 5.0 (6.2.1)", + "CIS M365 7.0.0 (6.2.1)", "mdo_autoforwardingmode", "mdo_blockmailforward", "CISA (MS.EXO.4.1v1)", "NIST CSF 2.0 (PR.DS-02)" ], + "appliesToTest": ["CIS_6_2_1"], "helpText": "Disables the ability for users to automatically forward e-mails to external recipients.", "docsDescription": "Disables the ability for users to automatically forward e-mails to external recipients. This is to prevent data exfiltration. Please check if there are any legitimate use cases for this feature before implementing, like forwarding invoices and such.", "executiveText": "Prevents employees from automatically forwarding company emails to external addresses, protecting against data leaks and unauthorized information sharing. This security measure helps maintain control over sensitive business communications while preventing both accidental and intentional data exfiltration.", @@ -2477,12 +3201,20 @@ "impactColour": "danger", "addedDate": "2024-07-26", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode 'Off'", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.RetentionPolicyTag", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.4.1)"], + "tag": ["SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "docsDescription": "Creates a CIPP - Deleted Items retention policy tag that permanently deletes items in the Deleted Items folder after X days.", "executiveText": "Automatically and permanently removes deleted emails after a specified number of days, helping manage storage costs and ensuring compliance with data retention policies. This prevents accumulation of unnecessary deleted items while maintaining a reasonable recovery window for accidentally deleted emails.", @@ -2499,7 +3231,14 @@ "impactColour": "danger", "addedDate": "2025-02-02", "powershellEquivalent": "Set-RetentionPolicyTag", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.QuarantineRequestAlert", @@ -2520,7 +3259,14 @@ "impactColour": "info", "addedDate": "2024-07-15", "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SharePointMassDeletionAlert", @@ -2556,18 +3302,15 @@ "impactColour": "info", "addedDate": "2025-04-07", "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["RMS_S_PREMIUM2"] }, { "name": "standards.SafeLinksTemplatePolicy", "label": "SafeLinks Policy Template", "cat": "Templates", "multiple": false, - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2025-04-29", "helpText": "Deploy and manage SafeLinks policy templates to protect against malicious URLs in emails and Office documents.", @@ -2586,16 +3329,29 @@ "queryKey": "ListSafeLinksPolicyTemplates" } } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 5.0 (2.1.1)", + "CIS M365 7.0.0 (2.1.1)", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps", - "NIST CSF 2.0 (DE.CM-09)", + "NIST CSF 2.0 (DE.CM-09)" + ], + "appliesToTest": [ + "CISAMSEXO151", + "CISAMSEXO152", + "CISAMSEXO153", + "CIS_2_1_1", "ORCA105", "ORCA106", "ORCA107", @@ -2606,13 +3362,11 @@ "ORCA119", "ORCA156", "ORCA179", + "ORCA189_2", "ORCA226", "ORCA236", "ORCA237", - "ORCA238", - "CISAMSEXO151", - "CISAMSEXO152", - "CISAMSEXO153" + "ORCA238" ], "helpText": "This creates a Safe Links policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", "addedComponent": [ @@ -2652,7 +3406,14 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeLinksPolicy or New-SafeLinksPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AntiPhishPolicy", @@ -2665,8 +3426,14 @@ "mdo_spam_notifications_only_for_admins", "mdo_antiphishingpolicies", "mdo_phishthresholdlevel", - "CIS M365 5.0 (2.1.7)", - "NIST CSF 2.0 (DE.CM-09)", + "CIS M365 7.0.0 (2.1.7)", + "NIST CSF 2.0 (DE.CM-09)" + ], + "appliesToTest": [ + "CISAMSEXO111", + "CISAMSEXO112", + "CISAMSEXO113", + "CIS_2_1_7", "ORCA104", "ORCA115", "ORCA180", @@ -2686,10 +3453,7 @@ "ORCA244", "ZTNA21784", "ZTNA21817", - "ZTNA21819", - "CISAMSEXO111", - "CISAMSEXO112", - "CISAMSEXO113" + "ZTNA21819" ], "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mail tips.", "addedComponent": [ @@ -2740,14 +3504,8 @@ "label": "If the message is detected as spoof by spoof intelligence", "name": "standards.AntiPhishPolicy.AuthenticationFailAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move to Junk Folder", "value": "MoveToJmf" } ] }, { @@ -2757,14 +3515,8 @@ "label": "Quarantine policy for Spoof", "name": "standards.AntiPhishPolicy.SpoofQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -2777,18 +3529,9 @@ "label": "If a message is detected as user impersonation", "name": "standards.AntiPhishPolicy.TargetedUserProtectionAction", "options": [ - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - }, - { - "label": "Delete the message before its delivered", - "value": "Delete" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Move to Junk Folder", "value": "MoveToJmf" }, + { "label": "Delete the message before its delivered", "value": "Delete" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -2798,14 +3541,8 @@ "label": "Quarantine policy for user impersonation", "name": "standards.AntiPhishPolicy.TargetedUserQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -2818,18 +3555,9 @@ "label": "If a message is detected as domain impersonation", "name": "standards.AntiPhishPolicy.TargetedDomainProtectionAction", "options": [ - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - }, - { - "label": "Delete the message before its delivered", - "value": "Delete" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Move to Junk Folder", "value": "MoveToJmf" }, + { "label": "Delete the message before its delivered", "value": "Delete" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -2843,14 +3571,8 @@ "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" }, - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - } + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" } ] }, { @@ -2859,18 +3581,9 @@ "label": "If Mailbox Intelligence detects an impersonated user", "name": "standards.AntiPhishPolicy.MailboxIntelligenceProtectionAction", "options": [ - { - "label": "Move to Junk Folder", - "value": "MoveToJmf" - }, - { - "label": "Delete the message before its delivered", - "value": "Delete" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Move to Junk Folder", "value": "MoveToJmf" }, + { "label": "Delete the message before its delivered", "value": "Delete" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -2880,14 +3593,8 @@ "label": "Apply quarantine policy", "name": "standards.AntiPhishPolicy.MailboxIntelligenceQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -2900,20 +3607,26 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AntiPhishPolicy or New-AntiPhishPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SafeAttachmentPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 5.0 (2.1.4)", + "CIS M365 7.0.0 (2.1.4)", "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy", - "NIST CSF 2.0 (DE.CM-09)", - "ORCA158", - "ORCA227" + "NIST CSF 2.0 (DE.CM-09)" ], + "appliesToTest": ["CIS_2_1_4", "ORCA158", "ORCA189", "ORCA227"], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ { @@ -2929,18 +3642,9 @@ "label": "Safe Attachment Action", "name": "standards.SafeAttachmentPolicy.SafeAttachmentAction", "options": [ - { - "label": "Allow", - "value": "Allow" - }, - { - "label": "Block", - "value": "Block" - }, - { - "label": "DynamicDelivery", - "value": "DynamicDelivery" - } + { "label": "Allow", "value": "Allow" }, + { "label": "Block", "value": "Block" }, + { "label": "DynamicDelivery", "value": "DynamicDelivery" } ] }, { @@ -2950,25 +3654,15 @@ "label": "QuarantineTag", "name": "standards.SafeAttachmentPolicy.QuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" } ] }, - { - "type": "switch", - "label": "Redirect", - "name": "standards.SafeAttachmentPolicy.Redirect" - }, + { "type": "switch", "label": "Redirect", "name": "standards.SafeAttachmentPolicy.Redirect" }, { "type": "textField", "name": "standards.SafeAttachmentPolicy.RedirectAddress", @@ -2986,12 +3680,20 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeAttachmentPolicy or New-SafeAttachmentPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": ["CIS M365 5.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], + "tag": ["CIS M365 7.0.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CIS_2_1_5", "ORCA225"], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ { @@ -3007,12 +3709,21 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AtpPolicyForO365", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.PhishingSimulations", "cat": "Defender Standards", - "tag": [], + "tag": ["SMB1001 (1.11)", "SMB1001 (5.1)"], + "appliesToTest": ["SMB1001_1_11", "SMB1001_5_1"], "helpText": "This creates a phishing simulation policy that enables phishing simulations for the entire tenant.", "addedComponent": [ { @@ -3052,27 +3763,42 @@ "impactColour": "info", "addedDate": "2025-03-27", "powershellEquivalent": "New-TenantAllowBlockListItems, New-PhishSimOverridePolicy and New-ExoPhishSimOverrideRule", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 5.0 (2.1.2)", - "CIS M365 5.0 (2.1.3)", + "CIS M365 7.0.0 (2.1.2)", + "CIS M365 7.0.0 (2.1.3)", + "CIS M365 7.0.0 (2.1.11)", "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware", - "NIST CSF 2.0 (DE.CM-09)", + "NIST CSF 2.0 (DE.CM-09)" + ], + "appliesToTest": [ + "CISAMSEXO101", + "CISAMSEXO102", + "CISAMSEXO103", + "CISAMSEXO95", + "CIS_2_1_11", + "CIS_2_1_2", + "CIS_2_1_3", + "ORCA120_malware", "ORCA121", "ORCA124", + "ORCA205", "ORCA232", "ZTNA21817", - "ZTNA21819", - "CISAMSEXO95", - "CISAMSEXO101", - "CISAMSEXO102", - "CISAMSEXO103" + "ZTNA21819" ], "helpText": "This creates a Malware filter policy that enables the default File filter and Zero-hour auto purge for malware.", "addedComponent": [ @@ -3089,14 +3815,8 @@ "label": "FileTypeAction", "name": "standards.MalwareFilterPolicy.FileTypeAction", "options": [ - { - "label": "Reject", - "value": "Reject" - }, - { - "label": "Quarantine the message", - "value": "Quarantine" - } + { "label": "Reject", "value": "Reject" }, + { "label": "Quarantine the message", "value": "Quarantine" } ] }, { @@ -3112,14 +3832,8 @@ "label": "QuarantineTag", "name": "standards.MalwareFilterPolicy.QuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3166,7 +3880,14 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-MalwareFilterPolicy or New-MalwareFilterPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.PhishSimSpoofIntelligence", @@ -3195,17 +3916,34 @@ "impactColour": "info", "addedDate": "2025-03-28", "powershellEquivalent": "New-TenantAllowBlockListSpoofItems", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.SpamFilterPolicy", "cat": "Defender Standards", - "tag": [ + "tag": [], + "appliesToTest": [ + "CISAMSEXO141", + "CISAMSEXO142", + "CISAMSEXO143", "ORCA100", "ORCA101", "ORCA102", "ORCA103", "ORCA104", + "ORCA109", + "ORCA110", + "ORCA118_1", + "ORCA118_3", + "ORCA120_phish", + "ORCA120_spam", "ORCA123", "ORCA139", "ORCA140", @@ -3214,10 +3952,7 @@ "ORCA143", "ORCA224", "ORCA231", - "ORCA241", - "CISAMSEXO141", - "CISAMSEXO142", - "CISAMSEXO143" + "ORCA241" ], "helpText": "This standard creates a Spam filter policy similar to the default strict policy.", "docsDescription": "This standard creates a Spam filter policy similar to the default strict policy, the following settings are configured to on by default: IncreaseScoreWithNumericIps, IncreaseScoreWithRedirectToOtherPort, MarkAsSpamEmptyMessages, MarkAsSpamJavaScriptInHtml, MarkAsSpamSpfRecordHardFail, MarkAsSpamFromAddressAuthFail, MarkAsSpamNdrBackscatter, MarkAsSpamBulkMail, InlineSafetyTipsEnabled, PhishZapEnabled, SpamZapEnabled", @@ -3247,14 +3982,8 @@ "label": "Spam Action", "name": "standards.SpamFilterPolicy.SpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3265,14 +3994,8 @@ "label": "Spam Quarantine Tag", "name": "standards.SpamFilterPolicy.SpamQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3287,14 +4010,8 @@ "label": "High Confidence Spam Action", "name": "standards.SpamFilterPolicy.HighConfidenceSpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3305,14 +4022,8 @@ "label": "High Confidence Spam Quarantine Tag", "name": "standards.SpamFilterPolicy.HighConfidenceSpamQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3327,14 +4038,8 @@ "label": "Bulk Spam Action", "name": "standards.SpamFilterPolicy.BulkSpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3345,14 +4050,8 @@ "label": "Bulk Quarantine Tag", "name": "standards.SpamFilterPolicy.BulkQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3367,14 +4066,8 @@ "label": "Phish Spam Action", "name": "standards.SpamFilterPolicy.PhishSpamAction", "options": [ - { - "label": "Quarantine the message", - "value": "Quarantine" - }, - { - "label": "Move message to Junk Email folder", - "value": "MoveToJmf" - } + { "label": "Quarantine the message", "value": "Quarantine" }, + { "label": "Move message to Junk Email folder", "value": "MoveToJmf" } ] }, { @@ -3385,14 +4078,8 @@ "label": "Phish Quarantine Tag", "name": "standards.SpamFilterPolicy.PhishQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3407,14 +4094,8 @@ "label": "High Confidence Phish Quarantine Tag", "name": "standards.SpamFilterPolicy.HighConfidencePhishQuarantineTag", "options": [ - { - "label": "AdminOnlyAccessPolicy", - "value": "AdminOnlyAccessPolicy" - }, - { - "label": "DefaultFullAccessPolicy", - "value": "DefaultFullAccessPolicy" - }, + { "label": "AdminOnlyAccessPolicy", "value": "AdminOnlyAccessPolicy" }, + { "label": "DefaultFullAccessPolicy", "value": "DefaultFullAccessPolicy" }, { "label": "DefaultFullAccessWithNotificationPolicy", "value": "DefaultFullAccessWithNotificationPolicy" @@ -3521,16 +4202,19 @@ "impactColour": "warning", "addedDate": "2024-07-15", "powershellEquivalent": "New-HostedContentFilterPolicy or Set-HostedContentFilterPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.QuarantineTemplate", "cat": "Defender Standards", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "tag": [], "helpText": "This standard creates a Custom Quarantine Policies that can be used in Anti-Spam and all MDO365 policies. Quarantine Policies can be used to specify recipients permissions, enable end-user spam notifications, and specify the release action preference", "executiveText": "Creates standardized quarantine policies that define how employees can interact with quarantined emails, including permissions to release, delete, or preview suspicious messages. This ensures consistent security handling across the organization while providing appropriate user access to manage quarantined content.", @@ -3608,7 +4292,14 @@ "impactColour": "info", "addedDate": "2025-05-16", "powershellEquivalent": "Set-QuarantinePolicy or New-QuarantinePolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.IntuneWindowsDiagnostic", @@ -3636,7 +4327,8 @@ "impactColour": "info", "addedDate": "2026-01-27", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.WindowsBackupRestore", @@ -3664,7 +4356,8 @@ "impactColour": "info", "addedDate": "2026-02-26", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneDeviceRetirementDays", @@ -3684,7 +4377,8 @@ "impactColour": "info", "addedDate": "2023-05-19", "powershellEquivalent": "Graph API", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneBrandingProfile", @@ -3758,12 +4452,14 @@ "impactColour": "info", "addedDate": "2024-06-20", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.IntuneComplianceSettings", "cat": "Intune Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (4.1)"], + "appliesToTest": ["CIS_4_1"], "helpText": "Sets the mark devices with no compliance policy assigned as compliance/non compliant and Compliance status validity period.", "executiveText": "Configures how the system treats devices that don't have specific compliance policies and sets how often devices must check in to maintain their compliance status. This ensures proper security oversight of all corporate devices and maintains current compliance information.", "addedComponent": [ @@ -3775,14 +4471,8 @@ "name": "standards.IntuneComplianceSettings.secureByDefault", "label": "Mark devices with no compliance policy as", "options": [ - { - "label": "Compliant", - "value": "false" - }, - { - "label": "Non-Compliant", - "value": "true" - } + { "label": "Compliant", "value": "false" }, + { "label": "Non-Compliant", "value": "true" } ] }, { @@ -3801,7 +4491,8 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.MDMScope", @@ -3816,18 +4507,9 @@ "label": "MDM User Scope?", "type": "radio", "options": [ - { - "label": "All", - "value": "all" - }, - { - "label": "None", - "value": "none" - }, - { - "label": "Custom Group", - "value": "selected" - } + { "label": "All", "value": "all" }, + { "label": "None", "value": "none" }, + { "label": "Custom Group", "value": "selected" } ] }, { @@ -3842,12 +4524,14 @@ "impactColour": "info", "addedDate": "2025-02-18", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.DefaultPlatformRestrictions", "cat": "Intune Standards", - "tag": ["CISA (MS.AAD.19.1v1)"], + "tag": ["CIS M365 7.0.0 (4.2)", "CISA (MS.AAD.19.1v1)"], + "appliesToTest": ["CIS_4_2"], "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", "addedComponent": [ @@ -3917,7 +4601,8 @@ "impactColour": "info", "addedDate": "2025-04-01", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.MDMEnrollmentDuringRegistration", @@ -3938,7 +4623,8 @@ "impactColour": "warning", "addedDate": "2025-12-15", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration", @@ -3953,18 +4639,9 @@ "label": "Configure Windows Hello for Business", "multiple": false, "options": [ - { - "label": "Not configured", - "value": "notConfigured" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -3999,18 +4676,9 @@ "label": "Lowercase letters in PIN", "multiple": false, "options": [ - { - "label": "Not allowed", - "value": "disallowed" - }, - { - "label": "Allowed", - "value": "allowed" - }, - { - "label": "Required", - "value": "required" - } + { "label": "Not allowed", "value": "disallowed" }, + { "label": "Allowed", "value": "allowed" }, + { "label": "Required", "value": "required" } ] }, { @@ -4019,18 +4687,9 @@ "label": "Uppercase letters in PIN", "multiple": false, "options": [ - { - "label": "Not allowed", - "value": "disallowed" - }, - { - "label": "Allowed", - "value": "allowed" - }, - { - "label": "Required", - "value": "required" - } + { "label": "Not allowed", "value": "disallowed" }, + { "label": "Allowed", "value": "allowed" }, + { "label": "Required", "value": "required" } ] }, { @@ -4039,18 +4698,9 @@ "label": "Special characters in PIN", "multiple": false, "options": [ - { - "label": "Not allowed", - "value": "disallowed" - }, - { - "label": "Allowed", - "value": "allowed" - }, - { - "label": "Required", - "value": "required" - } + { "label": "Not allowed", "value": "disallowed" }, + { "label": "Allowed", "value": "allowed" }, + { "label": "Required", "value": "required" } ] }, { @@ -4077,18 +4727,9 @@ "label": "Use enhanced anti-spoofing when available", "multiple": false, "options": [ - { - "label": "Not configured", - "value": "notConfigured" - }, - { - "label": "Enabled", - "value": "enabled" - }, - { - "label": "Disabled", - "value": "disabled" - } + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } ] }, { @@ -4096,6 +4737,28 @@ "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.remotePassportEnabled", "label": "Allow phone sign-in", "default": true + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.enhancedSignInSecurity", + "label": "Enable enhanced sign-in security", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "0" }, + { "label": "Enabled on capable hardware", "value": "1" }, + { "label": "Disabled on all systems", "value": "2" } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.securityKeyForSignIn", + "label": "Use security keys for sign-in", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ] } ], "label": "Windows Hello for Business enrollment configuration", @@ -4103,12 +4766,14 @@ "impactColour": "info", "addedDate": "2025-09-25", "powershellEquivalent": "Graph API", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", - "tag": ["CISA (MS.AAD.17.1v1)", "ZTNA21801", "ZTNA21802"], + "tag": ["CIS M365 7.0.0 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], + "appliesToTest": ["CIS_5_1_4_2", "ZTNA21801", "ZTNA21802", "ZTNA21837"], "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", "addedComponent": [ @@ -4124,12 +4789,14 @@ "impactColour": "warning", "addedDate": "2023-03-27", "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.intuneDeviceRegLocalAdmins", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (5.1.4.3)", "CIS M365 7.0.0 (5.1.4.4)", "SMB1001 (2.2)"], + "appliesToTest": ["CIS_5_1_4_3", "CIS_5_1_4_4", "SMB1001_2_2"], "helpText": "Controls whether users who register Microsoft Entra joined devices are granted local administrator rights on those devices and if Global Administrators are added as local admins.", "docsDescription": "Configures the Device Registration Policy local administrator behavior for registering users. When enabled, users who register devices are not granted local administrator rights, you can also configure if Global Administrators are added as local admins.", "executiveText": "Controls whether employees who enroll devices automatically receive local administrator access. Disabling registering-user admin rights follows least-privilege principles and reduces security risk from over-privileged endpoints.", @@ -4158,6 +4825,7 @@ "name": "standards.intuneRestrictUserDeviceRegistration", "cat": "Entra (AAD) Standards", "tag": [], + "appliesToTest": [], "helpText": "Controls whether users can register devices with Entra.", "docsDescription": "Configures whether users can register devices with Entra. When disabled, users are unable to register devices with Entra.", "executiveText": "Controls whether employees can register their devices for corporate access. Disabling user device registration prevents unauthorized or unmanaged devices from connecting to company resources, enhancing overall security posture.", @@ -4176,10 +4844,34 @@ "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", "recommendedBy": [] }, + { + "name": "standards.intuneRestrictUserDeviceJoin", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.1.4.1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_4_1", "SMB1001_2_8"], + "helpText": "Controls whether users can join devices to Entra.", + "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", + "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.intuneRestrictUserDeviceJoin.disableUserDeviceJoin", + "label": "Disable users from joining devices", + "defaultValue": true + } + ], + "label": "Configure user restriction for Entra device join", + "impact": "High Impact", + "impactColour": "warning", + "addedDate": "2026-05-15", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, { "name": "standards.intuneRequireMFA", "cat": "Intune Standards", - "tag": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], + "tag": [], + "appliesToTest": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", @@ -4192,7 +4884,8 @@ { "name": "standards.DeletedUserRentention", "cat": "SharePoint Standards", - "tag": [], + "tag": ["SMB1001 (3.1)"], + "appliesToTest": ["SMB1001_3_1"], "helpText": "Sets the retention period for deleted users OneDrive to the specified period of time. The default is 30 days.", "docsDescription": "When a OneDrive user gets deleted, the personal SharePoint site is saved for selected amount of time that data can be retrieved from it.", "executiveText": "Preserves departed employees' OneDrive files for a specified period, allowing time to recover important business documents before permanent deletion. This helps prevent data loss while managing storage costs and maintaining compliance with data retention policies.", @@ -4203,54 +4896,18 @@ "name": "standards.DeletedUserRentention.Days", "label": "Retention time (Default 30 days)", "options": [ - { - "label": "30 days", - "value": "30" - }, - { - "label": "90 days", - "value": "90" - }, - { - "label": "1 year", - "value": "365" - }, - { - "label": "2 years", - "value": "730" - }, - { - "label": "3 years", - "value": "1095" - }, - { - "label": "4 years", - "value": "1460" - }, - { - "label": "5 years", - "value": "1825" - }, - { - "label": "6 years", - "value": "2190" - }, - { - "label": "7 years", - "value": "2555" - }, - { - "label": "8 years", - "value": "2920" - }, - { - "label": "9 years", - "value": "3285" - }, - { - "label": "10 years", - "value": "3650" - } + { "label": "30 days", "value": "30" }, + { "label": "90 days", "value": "90" }, + { "label": "1 year", "value": "365" }, + { "label": "2 years", "value": "730" }, + { "label": "3 years", "value": "1095" }, + { "label": "4 years", "value": "1460" }, + { "label": "5 years", "value": "1825" }, + { "label": "6 years", "value": "2190" }, + { "label": "7 years", "value": "2555" }, + { "label": "8 years", "value": "2920" }, + { "label": "9 years", "value": "3285" }, + { "label": "10 years", "value": "3650" } ] } ], @@ -4259,7 +4916,15 @@ "impactColour": "info", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPFileRequests", @@ -4290,7 +4955,15 @@ "impactColour": "warning", "addedDate": "2025-07-30", "powershellEquivalent": "Set-SPOTenant -CoreRequestFilesLinkEnabled $true -OneDriveRequestFilesLinkEnabled $true -CoreRequestFilesLinkExpirationInDays 30 -OneDriveRequestFilesLinkExpirationInDays 30", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.TenantDefaultTimezone", @@ -4310,12 +4983,21 @@ "impactColour": "info", "addedDate": "2024-04-20", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": ["CIS M365 5.0 (7.2.2)"], + "tag": ["CIS M365 7.0.0 (7.2.2)"], + "appliesToTest": ["CIS_7_2_2"], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", "executiveText": "Enables secure collaboration with external partners through SharePoint and OneDrive by integrating with Azure B2B guest access. This allows controlled sharing with external organizations while maintaining security oversight and proper access management.", "addedComponent": [], @@ -4324,17 +5006,21 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EnableAzureADB2BIntegration $true", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.3.1)", - "CISA (MS.SPO.3.1v1)", - "NIST CSF 2.0 (DE.CM-09)", - "ZTNA21817" - ], + "tag": ["CIS M365 7.0.0 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CIS_7_3_1", "ZTNA21817"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", "addedComponent": [], @@ -4343,7 +5029,15 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DisallowInfectedFileDownload $true", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPDisableLegacyWorkflows", @@ -4357,7 +5051,15 @@ "impactColour": "info", "addedDate": "2024-07-15", "powershellEquivalent": "Set-SPOTenant -DisableWorkflow2010 $true -DisableWorkflow2013 $true -DisableBackToClassic $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPDirectSharing", @@ -4371,18 +5073,21 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType Direct", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.9)", - "CISA (MS.SPO.1.5v1)", - "ZTNA21803", - "ZTNA21804", - "ZTNA21858" - ], + "tag": ["CIS M365 7.0.0 (7.2.9)", "CISA (MS.SPO.1.5v1)"], + "appliesToTest": ["CIS_7_2_9", "ZTNA21803", "ZTNA21804", "ZTNA21858"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", "addedComponent": [ @@ -4402,17 +5107,21 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.10)", - "CISA (MS.SPO.1.6v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.10)", "CISA (MS.SPO.1.6v1)"], + "appliesToTest": ["CIS_7_2_10", "ZTNA21803", "ZTNA21804"], "helpText": "Ensure re-authentication with verification code is restricted", "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", "addedComponent": [ @@ -4432,18 +5141,21 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DefaultSharingLink", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.7)", - "CIS M365 5.0 (7.2.11)", - "CISA (MS.SPO.1.4v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.7)", "CIS M365 7.0.0 (7.2.11)", "CISA (MS.SPO.1.4v1)"], + "appliesToTest": ["CIS_7_2_11", "CIS_7_2_7", "ZTNA21803", "ZTNA21804"], "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", "executiveText": "Configures SharePoint default sharing links to implement the principle of least privilege for document sharing. This security measure reduces the risk of accidental data modification while maintaining collaboration functionality, requiring users to explicitly select Edit permissions when necessary. The sharing type setting controls whether links are restricted to specific recipients or available to the entire organization. This reduces the risk of accidental data exposure through link sharing.", @@ -4456,14 +5168,8 @@ "label": "Default Sharing Link Type", "name": "standards.DefaultSharingLink.SharingLinkType", "options": [ - { - "label": "Direct - Only the people the user specifies", - "value": "Direct" - }, - { - "label": "Internal - Only people in your organization", - "value": "Internal" - } + { "label": "Direct - Only the people the user specifies", "value": "Direct" }, + { "label": "Internal - Only people in your organization", "value": "Internal" } ] } ], @@ -4472,7 +5178,15 @@ "impactColour": "info", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType [Direct|Internal] -DefaultLinkPermission View", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableAddShortcutsToOneDrive", @@ -4488,14 +5202,8 @@ "label": "Add Shortcuts To OneDrive button state", "name": "standards.DisableAddShortcutsToOneDrive.state", "options": [ - { - "label": "Disabled", - "value": "true" - }, - { - "label": "Enabled", - "value": "false" - } + { "label": "Disabled", "value": "true" }, + { "label": "Enabled", "value": "false" } ] } ], @@ -4504,7 +5212,15 @@ "impactColour": "warning", "addedDate": "2023-07-25", "powershellEquivalent": "Set-SPOTenant -DisableAddShortcutsToOneDrive $true or $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.SPSyncButtonState", @@ -4520,14 +5236,8 @@ "label": "SharePoint Sync Button state", "name": "standards.SPSyncButtonState.state", "options": [ - { - "label": "Disabled", - "value": "true" - }, - { - "label": "Enabled", - "value": "false" - } + { "label": "Disabled", "value": "true" }, + { "label": "Enabled", "value": "false" } ] } ], @@ -4536,20 +5246,26 @@ "impactColour": "warning", "addedDate": "2024-07-26", "powershellEquivalent": "Set-SPOTenant -HideSyncButtonOnTeamSite $true or $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableSharePointLegacyAuth", "cat": "SharePoint Standards", "tag": [ - "CIS M365 5.0 (6.5.1)", - "CIS M365 5.0 (7.2.1)", + "CIS M365 7.0.0 (7.2.1)", "spo_legacy_auth", "CISA (MS.AAD.3.1v1)", - "NIST CSF 2.0 (PR.IR-01)", - "ZTNA21776", - "ZTNA21797" + "NIST CSF 2.0 (PR.IR-01)" ], + "appliesToTest": ["CIS_7_2_1", "ZTNA21776", "ZTNA21797"], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", "executiveText": "Disables outdated authentication methods for SharePoint access, forcing applications and users to use modern, more secure authentication protocols. This significantly improves security by eliminating vulnerable authentication pathways while requiring updates to older applications.", @@ -4559,18 +5275,26 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Set-SPOTenant -LegacyAuthProtocolsEnabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.sharingCapability", "cat": "SharePoint Standards", "tag": [ - "CIS M365 5.0 (7.2.3)", + "CIS M365 7.0.0 (7.2.3)", + "CIS M365 7.0.0 (7.2.4)", "CISA (MS.AAD.14.1v1)", - "CISA (MS.SPO.1.1v1)", - "ZTNA21803", - "ZTNA21804" + "CISA (MS.SPO.1.1v1)" ], + "appliesToTest": ["CIS_7_2_3", "CIS_7_2_4", "ZTNA21803", "ZTNA21804"], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Defines the organization's default policy for sharing files and folders in SharePoint and OneDrive, balancing collaboration needs with security requirements. This fundamental setting determines whether employees can share with external users, anonymous links, or only internal colleagues.", "addedComponent": [ @@ -4604,18 +5328,21 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.5)", - "CISA (MS.AAD.14.2v1)", - "CISA (MS.SPO.1.2v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], + "appliesToTest": ["CIS_7_2_5", "ZTNA21803", "ZTNA21804"], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Prevents external users from sharing company documents with additional people, maintaining control over document distribution and preventing unauthorized access expansion. This security measure ensures that external sharing remains within intended boundaries set by internal employees.", @@ -4625,12 +5352,21 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.DisableUserSiteCreate", "cat": "SharePoint Standards", - "tag": [], + "tag": ["SMB1001 (2.8)"], + "appliesToTest": ["SMB1001_2_8"], "helpText": "Disables users from creating new SharePoint sites", "docsDescription": "Disables standard users from creating SharePoint sites, also disables the ability to fully create teams", "executiveText": "Restricts the creation of new SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces and ensuring proper governance. This maintains organized information architecture while requiring approval for new collaborative environments.", @@ -4640,7 +5376,15 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.ExcludedfileExt", @@ -4660,7 +5404,15 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.disableMacSync", @@ -4674,17 +5426,21 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.3)", - "CISA (MS.SPO.2.1v1)", - "NIST CSF 2.0 (PR.AA-05)", - "ZTNA24824" - ], + "tag": ["CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], + "appliesToTest": ["ZTNA24824"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -4696,14 +5452,8 @@ "name": "standards.unmanagedSync.state", "label": "State", "options": [ - { - "label": "Allow limited, web-only access", - "value": "1" - }, - { - "label": "Block access", - "value": "2" - } + { "label": "Allow limited, web-only access", "value": "1" }, + { "label": "Block access", "value": "2" } ], "required": false } @@ -4713,18 +5463,14 @@ "impactColour": "danger", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -ConditionalAccessPolicy AllowFullAccess | AllowLimitedAccess | BlockAccess", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 5.0 (7.2.6)", - "CISA (MS.AAD.14.3v1)", - "CISA (MS.SPO.1.3v1)", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], + "appliesToTest": ["CIS_7_2_6", "ZTNA21803", "ZTNA21804"], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", "addedComponent": [ @@ -4734,18 +5480,9 @@ "name": "standards.sharingDomainRestriction.Mode", "label": "Limit external sharing by domains", "options": [ - { - "label": "Off", - "value": "none" - }, - { - "label": "Restrict sharing to specific domains", - "value": "allowList" - }, - { - "label": "Block sharing to specific domains", - "value": "blockList" - } + { "label": "Off", "value": "none" }, + { "label": "Restrict sharing to specific domains", "value": "allowList" }, + { "label": "Block sharing to specific domains", "value": "blockList" } ] }, { @@ -4760,18 +5497,40 @@ "impactColour": "danger", "addedDate": "2024-06-20", "powershellEquivalent": "Update-MgAdminSharePointSetting", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] }, { "name": "standards.TeamsGlobalMeetingPolicy", "cat": "Teams Standards", "tag": [ - "CIS M365 5.0 (8.5.1)", - "CIS M365 5.0 (8.5.2)", - "CIS M365 5.0 (8.5.3)", - "CIS M365 5.0 (8.5.4)", - "CIS M365 5.0 (8.5.5)", - "CIS M365 5.0 (8.5.6)" + "CIS M365 7.0.0 (8.5.1)", + "CIS M365 7.0.0 (8.5.2)", + "CIS M365 7.0.0 (8.5.3)", + "CIS M365 7.0.0 (8.5.4)", + "CIS M365 7.0.0 (8.5.5)", + "CIS M365 7.0.0 (8.5.6)", + "CIS M365 7.0.0 (8.5.7)", + "CIS M365 7.0.0 (8.5.8)", + "CIS M365 7.0.0 (8.5.9)" + ], + "appliesToTest": [ + "CIS_8_5_1", + "CIS_8_5_2", + "CIS_8_5_3", + "CIS_8_5_4", + "CIS_8_5_5", + "CIS_8_5_6", + "CIS_8_5_7", + "CIS_8_5_8", + "CIS_8_5_9" ], "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl", "executiveText": "Establishes security-focused default settings for Teams meetings, controlling who can join meetings, present content, and participate in chats. These policies balance collaboration needs with security requirements, ensuring meetings remain productive while protecting against unauthorized access and disruption.", @@ -4784,22 +5543,13 @@ "name": "standards.TeamsGlobalMeetingPolicy.DesignatedPresenterRoleMode", "label": "Default value of the `Who can present?`", "options": [ - { - "label": "Everyone", - "value": "EveryoneUserOverride" - }, - { - "label": "People in my organization", - "value": "EveryoneInCompanyUserOverride" - }, + { "label": "Everyone", "value": "EveryoneUserOverride" }, + { "label": "People in my organization", "value": "EveryoneInCompanyUserOverride" }, { "label": "People in my organization and trusted organizations", "value": "EveryoneInSameAndFederatedCompanyUserOverride" }, - { - "label": "Only organizer", - "value": "OrganizerOnlyUserOverride" - } + { "label": "Only organizer", "value": "OrganizerOnlyUserOverride" } ] }, { @@ -4821,10 +5571,7 @@ "label": "Who can bypass the lobby?", "helperText": "If left blank, the current value will not be changed.", "options": [ - { - "label": "Only organizers and co-organizers", - "value": "OrganizerOnly" - }, + { "label": "Only organizers and co-organizers", "value": "OrganizerOnly" }, { "label": "People in organization excluding guests", "value": "EveryoneInCompanyExcludingGuests" @@ -4833,14 +5580,8 @@ "label": "People in same or federated organizations", "value": "EveryoneInSameAndFederatedCompany" }, - { - "label": "People who were invited", - "value": "InvitedUsers" - }, - { - "label": "Everyone", - "value": "Everyone" - } + { "label": "People who were invited", "value": "InvitedUsers" }, + { "label": "Everyone", "value": "Everyone" } ] }, { @@ -4856,18 +5597,9 @@ "name": "standards.TeamsGlobalMeetingPolicy.MeetingChatEnabledType", "label": "Meeting chat policy", "options": [ - { - "label": "On for everyone", - "value": "Enabled" - }, - { - "label": "On for everyone but anonymous users", - "value": "EnabledExceptAnonymous" - }, - { - "label": "Off for everyone", - "value": "Disabled" - } + { "label": "On for everyone", "value": "Enabled" }, + { "label": "On for everyone but anonymous users", "value": "EnabledExceptAnonymous" }, + { "label": "Off for everyone", "value": "Disabled" } ] }, { @@ -4886,7 +5618,8 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsChatProtection", @@ -4914,12 +5647,14 @@ "impactColour": "info", "addedDate": "2025-10-02", "powershellEquivalent": "Set-CsTeamsMessagingConfiguration -FileTypeCheck 'Enabled' -UrlReputationCheck 'Enabled' -ReportIncorrectSecurityDetections 'Enabled'", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsExternalChatWithAnyone", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.2.3)"], + "appliesToTest": ["CIS_8_2_3"], "helpText": "Controls whether users can start Teams chats with any email address, inviting external recipients as guests via email.", "docsDescription": "Manages the Teams messaging policy setting UseB2BInvitesToAddExternalUsers. When enabled, users can start chats with any email address and recipients receive an invitation to join the chat as guests. Disabling the setting prevents these external email chats from being created, keeping conversations limited to internal users and approved guests.", "executiveText": "Allows organizations to decide if employees can launch Microsoft Teams chats with anyone on the internet using just an email address. Disabling the feature keeps conversations inside trusted boundaries and helps prevent accidental data exposure through unexpected external invitations.", @@ -4929,14 +5664,8 @@ "name": "standards.TeamsExternalChatWithAnyone.UseB2BInvitesToAddExternalUsers", "label": "Allow chatting with anyone via email", "options": [ - { - "label": "Enabled", - "value": "true" - }, - { - "label": "Disabled", - "value": "false" - } + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } ], "defaultValue": "Disabled" } @@ -4946,7 +5675,8 @@ "impactColour": "info", "addedDate": "2025-11-03", "powershellEquivalent": "Set-CsTeamsMessagingPolicy -Identity Global -UseB2BInvitesToAddExternalUsers $false/$true", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsEmailIntegration", @@ -4967,7 +5697,9 @@ "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", "recommendedBy": ["CIS"], - "tag": ["CIS M365 5.0 (8.1.2)"] + "tag": ["CIS M365 7.0.0 (8.1.2)"], + "appliesToTest": ["CIS_8_1_2"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsGuestAccess", @@ -4988,7 +5720,8 @@ "impactColour": "info", "addedDate": "2025-06-03", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGuestUser $true", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsMeetingVerification", @@ -5005,10 +5738,7 @@ "label": "CAPTCHA Verification Setting", "name": "standards.TeamsMeetingVerification.CaptchaVerificationForMeetingJoin", "options": [ - { - "label": "Not Required", - "value": "NotRequired" - }, + { "label": "Not Required", "value": "NotRequired" }, { "label": "Anonymous Users and Untrusted Organizations", "value": "AnonymousUsersAndUntrustedOrganizations" @@ -5021,12 +5751,14 @@ "impactColour": "info", "addedDate": "2025-06-14", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -CaptchaVerificationForMeetingJoin", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsExternalFileSharing", "cat": "Teams Standards", - "tag": ["CIS M365 5.0 (8.4.1)"], + "tag": ["CIS M365 7.0.0 (8.1.1)"], + "appliesToTest": ["CIS_8_1_1"], "helpText": "Ensure external file sharing in Teams is enabled for only approved cloud storage services.", "executiveText": "Controls which external cloud storage services (like Google Drive, Dropbox, Box) employees can access through Teams, ensuring file sharing occurs only through approved and secure platforms. This helps maintain data governance while supporting necessary business integrations.", "addedComponent": [ @@ -5061,7 +5793,8 @@ "impactColour": "info", "addedDate": "2024-07-28", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", - "recommendedBy": ["CIS"] + "recommendedBy": ["CIS"], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsEnrollUser", @@ -5079,14 +5812,8 @@ "name": "standards.TeamsEnrollUser.EnrollUserOverride", "label": "Voice and Face Enrollment", "options": [ - { - "label": "Disabled", - "value": "Disabled" - }, - { - "label": "Enabled", - "value": "Enabled" - } + { "label": "Disabled", "value": "Disabled" }, + { "label": "Enabled", "value": "Enabled" } ] } ], @@ -5095,12 +5822,14 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -Identity Global -EnrollUserOverride $false", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsExternalAccessPolicy", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.2.1)", "CIS M365 7.0.0 (8.2.2)"], + "appliesToTest": ["CIS_8_2_1", "CIS_8_2_2"], "helpText": "Sets the properties of the Global external access policy.", "docsDescription": "Sets the properties of the Global external access policy. External access policies determine whether or not your users can: 1) communicate with users who have Session Initiation Protocol (SIP) accounts with a federated organization; 2) communicate with users who are using custom applications built with Azure Communication Services; 3) access Skype for Business Server over the Internet, without having to log on to your internal network; 4) communicate with users who have SIP accounts with a public instant messaging (IM) provider such as Skype; and, 5) communicate with people who are using Teams with an account that's not managed by an organization.", "executiveText": "Defines the organization's policy for communicating with external users through Teams, including other organizations, Skype users, and unmanaged accounts. This fundamental setting determines the scope of external collaboration while maintaining security boundaries for business communications.", @@ -5121,12 +5850,14 @@ "impactColour": "warning", "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsExternalAccessPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsFederationConfiguration", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.2.1)"], + "appliesToTest": ["CIS_8_2_1", "CIS_8_2_4"], "helpText": "Sets the properties of the Global federation configuration.", "docsDescription": "Sets the properties of the Global federation configuration. Federation configuration settings determine whether or not your users can communicate with users who have SIP accounts with a federated organization.", "executiveText": "Configures how the organization federates with external organizations for Teams communication, controlling whether employees can communicate with specific external domains or all external organizations. This setting enables secure inter-organizational collaboration while maintaining control over external communications.", @@ -5144,22 +5875,10 @@ "name": "standards.TeamsFederationConfiguration.DomainControl", "label": "Communication Mode", "options": [ - { - "label": "Allow all external domains", - "value": "AllowAllExternal" - }, - { - "label": "Block all external domains", - "value": "BlockAllExternal" - }, - { - "label": "Allow specific external domains", - "value": "AllowSpecificExternal" - }, - { - "label": "Block specific external domains", - "value": "BlockSpecificExternal" - } + { "label": "Allow all external domains", "value": "AllowAllExternal" }, + { "label": "Block all external domains", "value": "BlockAllExternal" }, + { "label": "Allow specific external domains", "value": "AllowSpecificExternal" }, + { "label": "Block specific external domains", "value": "BlockSpecificExternal" } ] }, { @@ -5179,7 +5898,8 @@ "impactColour": "warning", "addedDate": "2024-07-31", "powershellEquivalent": "Set-CsTenantFederationConfiguration", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsMeetingRecordingExpiration", @@ -5206,12 +5926,14 @@ "impactColour": "warning", "addedDate": "2025-04-17", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -Identity Global -MeetingRecordingExpirationDays ", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.TeamsMessagingPolicy", "cat": "Teams Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (8.6.1)"], + "appliesToTest": ["CIS_8_6_1"], "helpText": "Sets the properties of the Global messaging policy.", "docsDescription": "Sets the properties of the Global messaging policy. Messaging policies control which chat and channel messaging features are available to users in Teams.", "executiveText": "Defines what messaging capabilities employees have in Teams, including the ability to edit or delete messages, create custom emojis, and report inappropriate content. These policies help maintain professional communication standards while enabling necessary collaboration features.", @@ -5248,18 +5970,9 @@ "name": "standards.TeamsMessagingPolicy.ReadReceiptsEnabledType", "label": "Read Receipts Enabled Type", "options": [ - { - "label": "User controlled", - "value": "UserPreference" - }, - { - "label": "Turned on for everyone", - "value": "Everyone" - }, - { - "label": "Turned off for everyone", - "value": "None" - } + { "label": "User controlled", "value": "UserPreference" }, + { "label": "Turned on for everyone", "value": "Everyone" }, + { "label": "Turned off for everyone", "value": "None" } ] }, { @@ -5292,17 +6005,14 @@ "impactColour": "warning", "addedDate": "2025-01-10", "powershellEquivalent": "Set-CsTeamsMessagingPolicy", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, { "name": "standards.AutopilotStatusPage", "cat": "Device Management Standards", "tag": [], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "helpText": "Deploy the Autopilot Status Page, which shows progress during device setup through Autopilot.", "docsDescription": "This standard allows configuration of the Autopilot Status Page, providing users with a visual representation of the progress during device setup. It includes options like timeout, logging, and retry settings.", "executiveText": "Provides employees with a visual progress indicator during automated device setup, improving the user experience when receiving new computers. This reduces IT support calls and helps ensure successful device deployment by guiding users through the setup process.", @@ -5370,17 +6080,15 @@ "impact": "Low Impact", "addedDate": "2023-12-30", "impactColour": "info", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.AutopilotProfile", "cat": "Device Management Standards", - "tag": [], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "tag": ["SMB1001 (2.2)"], + "appliesToTest": ["SMB1001_2_2"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "helpText": "Assign the appropriate Autopilot profile to streamline device deployment.", "docsDescription": "This standard allows the deployment of Autopilot profiles to devices, including settings such as unique name templates, language options, and local admin privileges.", "addedComponent": [ @@ -5407,11 +6115,7 @@ "required": false, "name": "standards.AutopilotProfile.Languages", "label": "Languages", - "api": { - "url": "/languageList.json", - "labelField": "languageTag", - "valueField": "tag" - } + "api": { "url": "/languageList.json", "labelField": "languageTag", "valueField": "tag" } }, { "type": "switch", @@ -5472,20 +6176,165 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2023-12-30", - "recommendedBy": [] + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, + { + "name": "standards.DevicePrepProfile", + "cat": "Device Management Standards", + "tag": ["autopilot", "device_prep", "enrollment"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages a Windows Autopilot Device Preparation profile for streamlined device enrollment.", + "docsDescription": "Deploys a Windows Autopilot Device Preparation profile through Intune configuration policies. This standard manages deployment mode, join type, account type, timeout, error messages, and optional device security group assignment. Optionally creates a new security group with the Intune Provisioning Client as owner.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.DevicePrepProfile.ProfileName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.ProfileDescription", + "label": "Profile Description", + "required": false + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.DeploymentType", + "label": "Deployment Type", + "options": [ + { "label": "Single user", "value": "0" }, + { "label": "Shared", "value": "1" } + ] + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.JoinType", + "label": "Join Type", + "options": [ + { "label": "Microsoft Entra join", "value": "0" }, + { "label": "Microsoft Entra hybrid join", "value": "1" } + ] + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.AccountType", + "label": "Account Type", + "options": [ + { "label": "Standard user", "value": "0" }, + { "label": "Administrator", "value": "1" } + ] + }, + { + "type": "number", + "name": "standards.DevicePrepProfile.Timeout", + "label": "Timeout (minutes)", + "defaultValue": 60 + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.CustomErrorMessage", + "label": "Custom Error Message", + "required": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.AllowSkip", + "label": "Allow users to skip setup after failure", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.AllowDiagnostics", + "label": "Allow users to collect diagnostics", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.DeviceGroupName", + "label": "Device Security Group Name (wildcard match)", + "required": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.CreateNewGroup", + "label": "Create new group if group is not found", + "defaultValue": false + }, + { + "type": "radio", + "name": "standards.DevicePrepProfile.AssignTo", + "label": "Policy Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "All devices", "value": "AllDevices" }, + { "label": "All users and devices", "value": "AllDevicesAndUsers" } + ] + } + ], + "label": "Deploy Device Prep Profile", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-25", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.IntuneTemplate", "cat": "Templates", "label": "Intune Template", "multiple": true, - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", + "tag": [ + "SMB1001 (1.2)", + "SMB1001 (1.3)", + "SMB1001 (1.4)", + "SMB1001 (1.8)", + "SMB1001 (1.9)", + "SMB1001 (1.10)", + "SMB1001 (1.12)", + "SMB1001 (2.2)", + "SMB1001 (4.7)" + ], + "appliesToTest": [ + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "SMB1001_2_2", + "SMB1001_4_7", + "ZTNA24540", + "ZTNA24541", + "ZTNA24542", + "ZTNA24543", + "ZTNA24545", + "ZTNA24547", + "ZTNA24548", + "ZTNA24549", + "ZTNA24550", + "ZTNA24552", + "ZTNA24553", + "ZTNA24564", + "ZTNA24568", + "ZTNA24569", + "ZTNA24572", + "ZTNA24574", + "ZTNA24575", + "ZTNA24576", + "ZTNA24784", + "ZTNA24839", + "ZTNA24840", + "ZTNA24870" + ], "helpText": "Deploy and manage Intune templates across devices.", "executiveText": "Deploys standardized device management configurations across all corporate devices, ensuring consistent security policies, application settings, and compliance requirements. This template-based approach streamlines device management while maintaining uniform security standards across the organization.", "addedComponent": [ @@ -5502,11 +6351,7 @@ "labelField": "Displayname", "valueField": "GUID", "showRefresh": true, - "templateView": { - "title": "Intune Template", - "property": "RAWJson", - "type": "intune" - } + "templateView": { "title": "Intune Template", "property": "RAWJson", "type": "intune" } } }, { @@ -5528,26 +6373,11 @@ "label": "Who should this template be assigned to?", "type": "radio", "options": [ - { - "label": "Do not assign", - "value": "On" - }, - { - "label": "Assign to all users", - "value": "allLicensedUsers" - }, - { - "label": "Assign to all devices", - "value": "AllDevices" - }, - { - "label": "Assign to all users and devices", - "value": "AllDevicesAndUsers" - }, - { - "label": "Assign to Custom Group", - "value": "customGroup" - } + { "label": "Do not assign", "value": "On" }, + { "label": "Assign to all users", "value": "allLicensedUsers" }, + { "label": "Assign to all devices", "value": "AllDevices" }, + { "label": "Assign to all users and devices", "value": "AllDevicesAndUsers" }, + { "label": "Assign to Custom Group", "value": "customGroup" } ] }, { @@ -5556,11 +6386,7 @@ "name": "customGroup", "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." }, - { - "type": "switch", - "name": "verifyAssignments", - "label": "Verify policy assignments" - }, + { "type": "switch", "name": "verifyAssignments", "label": "Verify policy assignments" }, { "name": "excludeGroup", "label": "Exclude Groups", @@ -5582,15 +6408,21 @@ "required": false, "helpText": "Choose whether to include or exclude devices matching the filter. Only applies if you specified a filter name above. Defaults to Include if not specified.", "options": [ - { - "label": "Include - Assign to devices matching the filter", - "value": "include" - }, - { - "label": "Exclude - Assign to devices NOT matching the filter", - "value": "exclude" - } + { "label": "Include - Assign to devices matching the filter", "value": "include" }, + { "label": "Exclude - Assign to devices NOT matching the filter", "value": "exclude" } ] + }, + { + "type": "number", + "required": false, + "name": "levenshteinDistance", + "label": "Fuzzy Match Distance (0 = exact name match only, higher values allow replacing policies with similar names based on Levenshtein distance)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + }, + "warningThreshold": 5, + "warningMessage": "Warning: values above 5 can match unrelated policies. Use with caution." } ] }, @@ -5599,14 +6431,34 @@ "cat": "Templates", "label": "Reusable Settings Template", "multiple": true, - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "impactColour": "info", "addedDate": "2026-01-02", + "tag": [ + "SMB1001 (1.2)", + "SMB1001 (1.3)", + "SMB1001 (1.4)", + "SMB1001 (1.8)", + "SMB1001 (1.9)", + "SMB1001 (1.10)", + "SMB1001 (1.12)" + ], + "appliesToTest": [ + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "ZTNA24540", + "ZTNA24550", + "ZTNA24552", + "ZTNA24574", + "ZTNA24575", + "ZTNA24784" + ], "helpText": "Deploy and maintain Intune reusable settings templates that can be referenced by multiple policies.", "executiveText": "Creates and keeps reusable Intune settings templates consistent so common firewall and configuration blocks can be reused across many policies.", "addedComponent": [ @@ -5623,11 +6475,7 @@ "labelField": "displayName", "valueField": "GUID", "showRefresh": true, - "templateView": { - "title": "Reusable Settings", - "property": "RawJSON", - "type": "intune" - } + "templateView": { "title": "Reusable Settings", "property": "RawJSON", "type": "intune" } } } ], @@ -5638,11 +6486,7 @@ "name": "standards.TransportRuleTemplate", "label": "Transport Rule Template", "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy transport rules to manage email flow.", @@ -5653,7 +6497,7 @@ "name": "transportRuleTemplate", "label": "Select Transport Rule Template", "api": { - "url": "/api/ListTransportRulesTemplates", + "url": "/api/ListTransportRulesTemplates?noJson=true", "labelField": "name", "valueField": "GUID", "queryKey": "ListTransportRulesTemplates" @@ -5665,6 +6509,13 @@ "name": "overwrite", "defaultValue": true } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { @@ -5672,13 +6523,57 @@ "label": "Conditional Access Template", "cat": "Templates", "multiple": true, - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", + "tag": [ + "CIS M365 7.0.0 (5.2.2.1)", + "CIS M365 7.0.0 (5.2.2.2)", + "CIS M365 7.0.0 (5.2.2.3)", + "CIS M365 7.0.0 (5.2.2.4)", + "CIS M365 7.0.0 (5.2.2.5)", + "CIS M365 7.0.0 (5.2.2.6)", + "CIS M365 7.0.0 (5.2.2.7)", + "CIS M365 7.0.0 (5.2.2.8)", + "CIS M365 7.0.0 (5.2.2.9)", + "CIS M365 7.0.0 (5.2.2.10)", + "CIS M365 7.0.0 (5.2.2.11)", + "CIS M365 7.0.0 (5.2.2.12)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.8)", + "SMB1001 (2.9)" + ], + "appliesToTest": [ + "CIS_5_2_2_1", + "CIS_5_2_2_10", + "CIS_5_2_2_11", + "CIS_5_2_2_12", + "CIS_5_2_2_2", + "CIS_5_2_2_3", + "CIS_5_2_2_4", + "CIS_5_2_2_5", + "CIS_5_2_2_6", + "CIS_5_2_2_7", + "CIS_5_2_2_8", + "CIS_5_2_2_9", + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_8", + "SMB1001_2_9", + "ZTNA21783", + "ZTNA21786", + "ZTNA21806", + "ZTNA21808", + "ZTNA21824", + "ZTNA21825", + "ZTNA21828", + "ZTNA21830", + "ZTNA21883", + "ZTNA21892", + "ZTNA21941", + "ZTNA24827" + ], "helpText": "Manage conditional access policies for better security.", "executiveText": "Deploys standardized conditional access policies that automatically enforce security requirements based on user location, device compliance, and risk factors. These templates ensure consistent security controls across the organization while enabling secure access to business resources.", "addedComponent": [ @@ -5686,6 +6581,8 @@ "type": "autoComplete", "name": "TemplateList", "multiple": false, + "required": false, + "creatable": false, "label": "Select Conditional Access Template", "api": { "url": "/api/ListCATemplates", @@ -5693,8 +6590,23 @@ "valueField": "GUID", "queryKey": "ListCATemplates", "showRefresh": true, - "templateView": { - "title": "Conditional Access Policy" + "templateView": { "title": "Conditional Access Policy" } + } + }, + { + "type": "autoComplete", + "multiple": false, + "required": false, + "creatable": false, + "name": "TemplateList-Tags", + "label": "Or select a package of CA Templates", + "api": { + "queryKey": "ListCATemplates-tag-autocomplete", + "url": "/api/ListCATemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "addedField": { + "templates": "templates" } } }, @@ -5703,22 +6615,10 @@ "label": "What state should we deploy this template in?", "type": "radio", "options": [ - { - "value": "donotchange", - "label": "Do not change state" - }, - { - "value": "Enabled", - "label": "Set to enabled" - }, - { - "value": "Disabled", - "label": "Set to disabled" - }, - { - "value": "enabledForReportingButNotEnforced", - "label": "Set to report only" - } + { "value": "donotchange", "label": "Do not change state" }, + { "value": "Enabled", "label": "Set to enabled" }, + { "value": "Disabled", "label": "Set to disabled" }, + { "value": "enabledForReportingButNotEnforced", "label": "Set to report only" } ] }, { @@ -5726,22 +6626,15 @@ "name": "DisableSD", "label": "Disable Security Defaults when deploying policy" }, - { - "type": "switch", - "name": "CreateGroups", - "label": "Create groups if they do not exist" - } - ] + { "type": "switch", "name": "CreateGroups", "label": "Create groups if they do not exist" } + ], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] }, { "name": "standards.ExchangeConnectorTemplate", "label": "Exchange Connector Template", "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage Exchange connectors.", @@ -5758,6 +6651,13 @@ "queryKey": "ListExConnectorTemplates" } } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { @@ -5765,11 +6665,9 @@ "label": "Group Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "tag": [], + "appliesToTest": [], + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", "helpText": "Deploy and manage group templates.", @@ -5787,21 +6685,18 @@ "queryKey": "ListGroupTemplates" } } - ] + ], + "requiredCapabilities": ["EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", "EXCHANGE_LITE"] }, { "name": "standards.DlpCompliancePolicyTemplate", "label": "DLP Compliance Policy Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", - "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates. Existing policies are overwritten in place.", + "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates.", "executiveText": "Deploys Data Loss Prevention policies from a standardized template library. Ensures consistent DLP coverage across tenants for sensitive data such as financial, identity, and regulated content.", "addedComponent": [ { @@ -5824,11 +6719,7 @@ "label": "Retention Compliance Policy Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview retention compliance policies from CIPP templates.", @@ -5854,11 +6745,7 @@ "label": "Sensitivity Label Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview sensitivity labels from CIPP templates.", @@ -5884,11 +6771,7 @@ "label": "Sensitive Information Type Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Low Impact", "addedDate": "2026-05-10", "helpText": "Deploy custom Microsoft Purview Sensitive Information Types from CIPP templates.", @@ -5914,11 +6797,7 @@ "label": "Assignment Filter Template", "multi": true, "cat": "Templates", - "disabledFeatures": { - "report": true, - "warn": true, - "remediate": false - }, + "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2025-10-04", "helpText": "Deploy and manage assignment filter templates.", @@ -5936,6 +6815,40 @@ "queryKey": "ListAssignmentFilterTemplates" } } + ], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, + { + "name": "standards.TenantAllowBlockListTemplate", + "label": "Tenant Allow/Block List Template", + "cat": "Exchange Standards", + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-04-02", + "helpText": "Deploy tenant allow/block list entries from a saved template.", + "executiveText": "Deploys standardized tenant allow/block list entries across tenants. These templates ensure consistent email filtering rules are applied, managing which senders, URLs, file hashes, and IP addresses are allowed or blocked across the organization.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "TenantAllowBlockListTemplate", + "required": false, + "multiple": true, + "label": "Select Tenant Allow/Block List Template", + "api": { + "url": "/api/ListTenantAllowBlockListTemplates", + "labelField": "templateName", + "valueField": "GUID", + "queryKey": "ListTenantAllowBlockListTemplates", + "showRefresh": true + } + } + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" ] }, { @@ -5962,12 +6875,19 @@ "impactColour": "info", "addedDate": "2025-05-28", "powershellEquivalent": "Set-Mailbox -RecipientLimits", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.DisableExchangeOnlinePowerShell", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.1.1)", "Security", "NIST CSF 2.0 (PR.AA-05)"], + "tag": ["Security", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This helps prevent attackers from using PowerShell to run malicious commands, access file systems, registry, and distribute ransomware throughout networks. Users with admin roles are automatically excluded.", "docsDescription": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This security measure follows a least privileged access approach, preventing potential attackers from using PowerShell to execute malicious commands, access sensitive systems, or distribute malware. Users with management roles containing 'Admin' are automatically excluded to ensure administrators retain PowerShell access to perform necessary management tasks.", "executiveText": "Restricts PowerShell access to Exchange Online for regular employees while maintaining access for administrators, significantly reducing security risks from compromised accounts. This prevents attackers from using PowerShell to execute malicious commands or distribute ransomware while preserving necessary administrative capabilities.", @@ -5976,12 +6896,19 @@ "impactColour": "warning", "addedDate": "2025-06-19", "powershellEquivalent": "Set-User -Identity $user -RemotePowerShellEnabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["CIS", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.OWAAttachmentRestrictions", "cat": "Exchange Standards", - "tag": ["CIS M365 5.0 (6.1.2)", "Security", "NIST CSF 2.0 (PR.AA-05)"], + "tag": ["Security", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Restricts how users on unmanaged devices can interact with email attachments in Outlook on the web and new Outlook for Windows. Prevents downloading attachments or blocks viewing them entirely.", "docsDescription": "This standard configures the OWA mailbox policy to restrict access to email attachments on unmanaged devices. Users can be prevented from downloading attachments (but can view/edit via Office Online) or blocked from seeing attachments entirely. This helps prevent data exfiltration through email attachments on devices not managed by the organization.", "executiveText": "Restricts access to email attachments on personal or unmanaged devices while allowing full functionality on corporate-managed devices. This security measure prevents data theft through email attachments while maintaining productivity for employees using approved company devices.", @@ -5991,10 +6918,7 @@ "name": "standards.OWAAttachmentRestrictions.ConditionalAccessPolicy", "label": "Attachment Restriction Policy", "options": [ - { - "label": "Read Only (View/Edit via Office Online, no download)", - "value": "ReadOnly" - }, + { "label": "Read Only (View/Edit via Office Online, no download)", "value": "ReadOnly" }, { "label": "Read Only Plus Attachments Blocked (Cannot see attachments)", "value": "ReadOnlyPlusAttachmentsBlocked" @@ -6008,7 +6932,14 @@ "impactColour": "warning", "addedDate": "2025-08-22", "powershellEquivalent": "Set-OwaMailboxPolicy -Identity \"OwaMailboxPolicy-Default\" -ConditionalAccessPolicy ReadOnlyPlusAttachmentsBlocked", - "recommendedBy": ["Microsoft Zero Trust", "CIPP"] + "recommendedBy": ["Microsoft Zero Trust", "CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] }, { "name": "standards.LegacyEmailReportAddins", @@ -6115,6 +7046,12 @@ "placeholder": "e.g. https://example.com/*", "helperText": "Enter URLs to allowlist in the extension. Press enter to add each URL. Wildcards are allowed. This should be used for sites that are being blocked by the extension but are known to be safe." }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.domainSquattingEnabled", + "label": "Enable domain squatting detection", + "defaultValue": true + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.companyName", @@ -6122,13 +7059,6 @@ "placeholder": "YOUR-COMPANY", "required": false }, - { - "type": "textField", - "name": "standards.DeployCheckChromeExtension.companyURL", - "label": "Company URL", - "placeholder": "https://yourcompany.com", - "required": false - }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.productName", @@ -6143,6 +7073,27 @@ "placeholder": "support@yourcompany.com", "required": false }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.supportUrl", + "label": "Support URL", + "placeholder": "https://support.yourcompany.com", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.privacyPolicyUrl", + "label": "Privacy Policy URL", + "placeholder": "https://yourcompany.com/privacy", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.aboutUrl", + "label": "About URL", + "placeholder": "https://yourcompany.com/about", + "required": false + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.primaryColor", @@ -6162,26 +7113,11 @@ "label": "Who should this app be assigned to?", "type": "radio", "options": [ - { - "label": "Do not assign", - "value": "On" - }, - { - "label": "Assign to all users", - "value": "allLicensedUsers" - }, - { - "label": "Assign to all devices", - "value": "AllDevices" - }, - { - "label": "Assign to all users and devices", - "value": "AllDevicesAndUsers" - }, - { - "label": "Assign to Custom Group", - "value": "customGroup" - } + { "label": "Do not assign", "value": "On" }, + { "label": "Assign to all users", "value": "allLicensedUsers" }, + { "label": "Assign to all devices", "value": "AllDevices" }, + { "label": "Assign to all users and devices", "value": "AllDevicesAndUsers" }, + { "label": "Assign to Custom Group", "value": "customGroup" } ] }, { @@ -6196,7 +7132,8 @@ "impactColour": "info", "addedDate": "2025-09-18", "powershellEquivalent": "Add-CIPPW32ScriptApplication", - "recommendedBy": ["CIPP"] + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, { "name": "standards.SecureScoreRemediation", @@ -6211,11 +7148,7 @@ "required": false, "name": "standards.SecureScoreRemediation.Default", "label": "Controls to set to Default", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } }, { "type": "autoComplete", @@ -6224,11 +7157,7 @@ "required": false, "name": "standards.SecureScoreRemediation.Ignored", "label": "Controls to set to Ignored", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } }, { "type": "autoComplete", @@ -6237,11 +7166,7 @@ "required": false, "name": "standards.SecureScoreRemediation.ThirdParty", "label": "Controls to set to Third-Party", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } }, { "type": "autoComplete", @@ -6250,11 +7175,7 @@ "creatable": true, "name": "standards.SecureScoreRemediation.Reviewed", "label": "Controls to set to Reviewed", - "api": { - "url": "/secureScore.json", - "labelField": "title", - "valueField": "id" - } + "api": { "url": "/secureScore.json", "labelField": "title", "valueField": "id" } } ], "label": "Update Secure Score Control Profiles", @@ -6271,22 +7192,12 @@ "docsDescription": "Creates five Exchange Online transport rules grouped by the first letter of user display names (A-E, F-J, K-O, P-T, U-Z). Each rule fires when an external sender's From header matches a display name in that group, prepends a configurable HTML warning banner, and skips emails from accepted organisational domains. Any manually configured sender or domain exemptions on existing rules are preserved when the standard runs. The disclaimer HTML is fully customisable via the standard settings.", "executiveText": "Protects staff from display-name impersonation attacks by injecting a visible warning banner on emails that appear to come from a colleague but originate externally. Rules are maintained automatically across all letter groups and updated whenever the standard runs.", "addedComponent": [ - { - "type": "heading", - "label": "Alert Banner (HTML)", - "required": false - }, { "type": "textField", "name": "standards.ColleagueImpersonationAlert.disclaimerHtml", "label": "Disclaimer HTML – Paste the full HTML for the warning banner", "required": true }, - { - "type": "heading", - "label": "Keyword Exclusions (Exclude certain users by keywords)", - "required": false - }, { "type": "autoComplete", "name": "standards.ColleagueImpersonationAlert.excludedMailboxes", @@ -6295,11 +7206,6 @@ "creatable": true, "required": false }, - { - "type": "heading", - "label": "Exempt Senders (Email Accounts)", - "required": false - }, { "type": "autoComplete", "name": "standards.ColleagueImpersonationAlert.additionalExemptSenders", @@ -6314,6 +7220,894 @@ "impactColour": "warning", "addedDate": "2026-03-22", "powershellEquivalent": "New-TransportRule / Set-TransportRule", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.DefenderCompliancePolicy", + "cat": "Defender Standards", + "tag": ["defender_mde_connector", "defender_intune_compliance"], + "helpText": "Configures the Microsoft Defender for Endpoint connector with Intune, enabling compliance evaluation for mobile and desktop platforms (Android, iOS, macOS, Windows). Controls which platforms connect to MDE and whether devices are blocked when partner data is missing.", + "docsDescription": "Configures the Microsoft Defender for Endpoint mobile threat defense connector with Intune. This enables compliance evaluation across platforms (Android, iOS/iPadOS, macOS, Windows) and controls settings like blocking unsupported OS versions, requiring partner data for compliance, and enabling mobile application management. The connector must be enabled before platform-specific compliance policies can evaluate device risk from MDE.", + "executiveText": "Establishes the critical link between Microsoft Defender for Endpoint and Intune, enabling security risk data from MDE to be used in device compliance policies. This ensures that only devices meeting your organization's security standards can access corporate resources, providing a foundational layer of Zero Trust security across all platforms.", + "addedComponent": [ + { + "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.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.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.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 + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.windowsMobileApplicationManagementEnabled", + "label": "Connect Windows (MAM)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.windowsDeviceBlockedOnMissingPartnerData", + "label": "Block Windows if partner data unavailable (Note: Microsoft enforces this to on when Connect Windows 10.0.15063+ to MDE is on)", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.BlockunsupportedOS", + "label": "Block unsupported OS versions", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DefenderCompliancePolicy.AllowMEMEnforceCompliance", + "label": "Allow MEM enforcement of compliance", + "defaultValue": false + } + ], + "label": "Defender for Endpoint - Intune Compliance Connector", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-04-02", + "powershellEquivalent": "Graph API - deviceManagement/mobileThreatDefenseConnectors", + "recommendedBy": [] + }, + { + "name": "standards.GlobalQuarantineSettings", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures the Global Quarantine Policy settings including sender name, custom subject, disclaimer, from address, and org branding.", + "docsDescription": "Configures the Global Quarantine Policy branding and notification settings for the tenant. This includes the quarantine notification sender display name, custom subject line, disclaimer text, the from address used for notifications, and whether to use org branding. Notification frequency is managed separately by the GlobalQuarantineNotifications standard.", + "executiveText": "Ensures quarantine notification emails are branded and configured consistently, so end users receive clear, professional alerts about quarantined messages and know how to request release.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.SenderName", + "label": "Sender Display Name (e.g. Contoso-Office365Alerts)", + "helperText": "Will be overridden if an active sender address with an existing display name is used.", + "required": false + }, + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.CustomSubject", + "label": "Subject", + "required": false + }, + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.CustomDisclaimer", + "label": "Disclaimer (max 200 characters)", + "required": false + }, + { + "type": "textField", + "name": "standards.GlobalQuarantineSettings.FromAddress", + "label": "Specify Sender Address (must be an internal mailbox)", + "required": false + }, + { + "type": "switch", + "name": "standards.GlobalQuarantineSettings.OrganizationBrandingEnabled", + "label": "Use Organization Branding (logo)", + "helperText": "Requires branding to be configured in the Microsoft 365 admin centre." + } + ], + "label": "Configure Global Quarantine Notification Settings", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-02", + "powershellEquivalent": "Set-QuarantinePolicy (GlobalQuarantinePolicy)", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.SPDisableCustomScripts", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Prevents users from running custom scripts on SharePoint and OneDrive sites. Custom scripts can modify site behaviors and bypass governance controls.", + "docsDescription": "Disables the ability to add and run custom scripts on SharePoint and OneDrive sites at the tenant level. When custom scripts are allowed, governance cannot be enforced, and the capabilities of inserted code cannot be scoped or blocked. Microsoft recommends using the SharePoint Framework instead of custom scripts.", + "executiveText": "Blocks custom scripts from being added to SharePoint and OneDrive sites, enforcing governance controls and preventing unscoped code execution. This aligns with Microsoft's Baseline Security Mode recommendation to permanently remove the ability to add new custom scripts, directing organizations to use the SharePoint Framework instead.", + "addedComponent": [], + "label": "Disable custom scripts on SharePoint sites", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-04-28", + "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, + { + "name": "standards.SPDisableStoreAccess", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Disables end users from installing applications from the Microsoft Store into SharePoint sites.", + "docsDescription": "Removes the ability for end users to install applications directly from the Microsoft Store into SharePoint. This prevents uncontrolled app installations that can increase governance costs and go against organizational policies.", + "executiveText": "Prevents end users from installing applications from the Microsoft Store into SharePoint sites, ensuring that only approved applications are available. This reduces governance overhead and aligns with Microsoft's Baseline Security Mode recommendations.", + "addedComponent": [], + "label": "Disable SharePoint Store access", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-28", + "powershellEquivalent": "Set-SPOTenant -DisableSharePointStoreAccess $true", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, + { + "name": "standards.DisableEWS", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Disables Exchange Web Services (EWS) organization-wide. This reduces the attack surface by blocking legacy API access to mailbox data. Warning: This may break Office web add-ins on builds older than 16.0.19127.", + "docsDescription": "Disables Exchange Web Services (EWS) at the organization level to reduce attack surface. EWS provides cross-platform API access to sensitive Exchange Online data such as emails, meetings, and contacts. If compromised, attackers can access confidential data, send phishing emails, or spoof identities. Disabling EWS also reduces legacy app usage and minimizes exploitable endpoints. Note that this may break first-party features including web add-ins for Word, Excel, PowerPoint, and Outlook on builds older than 16.0.19127.", + "executiveText": "Disables Exchange Web Services (EWS) across the organization to reduce attack surface and prevent legacy API access to sensitive mailbox data. This aligns with Microsoft's Baseline Security Mode recommendation to minimize exploitable endpoints while requiring updates to applications that depend on EWS.", + "addedComponent": [], + "label": "Disable Exchange Web Services", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-04-28", + "powershellEquivalent": "Set-OrganizationConfig -EwsEnabled $false", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.OMEBranding", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Configures the branding applied to Microsoft Purview (OME) encrypted emails, including the logo, background color, and the text recipients see when viewing a protected message. [Read more](https://learn.microsoft.com/en-us/purview/add-your-organization-brand-to-encrypted-messages)", + "docsDescription": "Configures Office Message Encryption (OME) branding settings for the tenant default configuration. Allows organizations to apply a custom logo (via URL), background color, button text, and portal text to encrypted emails viewed by external recipients.", + "executiveText": "Applies organizational branding to encrypted emails so recipients see a professional, on-brand experience when viewing protected messages. Reinforces brand identity while preserving security compliance.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.OMEBranding.BackgroundColor", + "label": "Background Color - Optional", + "placeholder": "#ffffff", + "helpText": "The background color of the encrypted message wrapper. Enter an HTML hex color code (e.g. #ffffff) or a named color value (e.g. white).", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.LogoUrl", + "label": "Logo Image URL - Optional (Less than 40kb 170x70 pixels)", + "placeholder": "https://example.com/logo.png or %CustomVarable%", + "helpText": "URL to your organization's logo displayed in the encrypted email and the reading portal. Supported formats: PNG, JPG, BMP, TIFF. Optimal size: 170x70 px, max 40 KB.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.IntroductionText", + "label": "Text next to the sender's name and email address - Optional", + "placeholder": "has sent you a secure message.", + "helpText": "Text that appears next to the sender's name and email address. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.ReadButtonText", + "label": "Read Button Text - Optional", + "placeholder": "Read Secure Message.", + "helpText": "Text that appears on the 'Read Message' button. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.EmailText", + "label": "Email Text below the button - Optional", + "placeholder": "Encrypted message from Contoso secure messaging system.", + "helpText": "Text that appears below the 'Read Message' button. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.PrivacyStatementUrl", + "label": "Privacy Statement URL - Optional", + "placeholder": "https://contoso.com/privacystatement.html", + "helpText": "URL for the Privacy Statement link in the encrypted email notification. Leave blank to use Microsoft's default privacy statement.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.DisclaimerText", + "label": "Disclaimer Statement - Optional", + "placeholder": "This message is confidential for the use of the addressee only.", + "helpText": "Disclaimer statement shown in the email that contains the encrypted message. Maximum 1024 characters.", + "required": false + }, + { + "type": "textField", + "name": "standards.OMEBranding.PortalText", + "label": "Text appears at the top of the encrypted mail viewing portal - Optional", + "placeholder": "Contoso secure email portal.", + "helpText": "Text that appears at the top of the encrypted mail viewing portal. Maximum 128 characters.", + "required": false + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.OMEBranding.OTPEnabled", + "label": "One-Time Pass Code - Required", + "helpText": "Enable or disable authentication with a one-time pass code. When enabled, recipients without a Microsoft account can verify their identity via a code sent to their email.", + "options": [ + { + "label": "Enabled", + "value": true + }, + { + "label": "Disabled", + "value": false + } + ] + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.OMEBranding.SocialIdSignIn", + "label": "Social ID Sign-In - Required", + "helpText": "Enable or disable authentication with Microsoft, Google, or Yahoo identities. When enabled, recipients can sign in with an existing social account to view the encrypted message.", + "options": [ + { + "label": "Enabled", + "value": true + }, + { + "label": "Disabled", + "value": false + } + ] + } + ], + "label": "Configure Encrypted Message Branding (OME)", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-04-25", + "powershellEquivalent": "Set-OMEConfiguration", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.EnforcePrivateGroups", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (1.2.1)"], + "appliesToTest": ["CIS_1_2_1"], + "helpText": "Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword.", + "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 7.0.0 benchmark control 1.2.1.", + "executiveText": "Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Exclude groups by display name keyword", + "name": "standards.EnforcePrivateGroups.ExcludedGroupNames" + } + ], + "label": "Enforce Private M365 Groups", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Update-MgGroup -GroupId -Visibility Private", + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, + { + "name": "standards.EmptyFilterIPAllowList", + "cat": "Defender Standards", + "tag": ["CIS M365 7.0.0 (2.1.12)"], + "appliesToTest": ["CIS_2_1_12"], + "helpText": "Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks.", + "docsDescription": "IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it.", + "executiveText": "Ensures the Exchange Online connection filter IP allow list is empty, preventing any IP addresses from bypassing spam filtering, spoofing checks, and sender authentication. Keeping this list empty ensures all inbound email undergoes full security scanning, reducing the risk of phishing and malware delivery through trusted-but-compromised sources.", + "addedComponent": [], + "label": "Ensure connection filter IP allow list is empty", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @()", + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.TeamsZAP", + "cat": "Defender Standards", + "tag": ["CIS M365 7.0.0 (2.4.4)"], + "appliesToTest": ["CIS_2_4_4"], + "helpText": "Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery.", + "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 7.0.0 benchmark control 2.4.4.", + "executiveText": "Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users.", + "addedComponent": [], + "label": "Ensure Zero-hour auto purge for Microsoft Teams is on", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-05-06", + "powershellEquivalent": "Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true", + "recommendedBy": ["CIS"], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.CollaborationDomainRestriction", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.1.6.1)"], + "appliesToTest": ["CIS_5_1_6_1"], + "helpText": "Restricts B2B collaboration invitations to a specified list of allowed domains. If no domains are provided, the standard will alert and report on whether any domain restrictions are currently configured.", + "docsDescription": "By default, Microsoft Entra ID allows collaboration invitations to be sent to any external domain. CIS recommends restricting B2B collaboration invitations to only approved domains to reduce the risk of data exfiltration and unauthorized access. This standard checks the B2B management policy for an allow list of domains and can remediate by setting the allowed domains list.", + "executiveText": "Restricts external collaboration invitations to approved domains only, preventing users from sharing data with unapproved external organizations. This reduces the risk of data exfiltration and ensures that collaboration occurs only with trusted business partners.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.CollaborationDomainRestriction.allowedDomains", + "label": "Allowed domains (comma separated)", + "required": false, + "placeholder": "contoso.com, fabrikam.com" + } + ], + "label": "Restrict collaboration invitations to allowed domains only", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Graph API PATCH https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default", + "recommendedBy": ["CIS"] + }, + { + "name": "standards.IntuneAppTemplateDeploy", + "cat": "Intune Standards", + "tag": [], + "helpText": "Deploys selected Intune application templates to the tenant. Supports WinGet/Store apps, Office apps, Chocolatey apps, Win32 script apps, and MSP apps.", + "docsDescription": "Uses CIPP Intune Application Templates to deploy applications across tenants as a standard. Each template can contain multiple applications of different types which will be queued for deployment.", + "executiveText": "Automatically deploys approved Intune applications across all managed tenants, ensuring consistent software availability and reducing manual deployment overhead. Supports WinGet, Office, Chocolatey, Win32, and MSP application types.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Application Templates", + "name": "standards.IntuneAppTemplateDeploy.templateIds", + "api": { + "url": "/api/ListAppTemplates", + "labelField": "displayName", + "valueField": "GUID", + "queryKey": "StdIntuneAppTemplateList" + } + } + ], + "label": "Deploy Intune Application Template", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-23", + "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", + "recommendedBy": [] + }, + { + "name": "standards.AutopatchGroup", + "cat": "Intune Standards", + "tag": [], + "beta": true, + "deprecated": true, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Deploys a Windows Autopatch group with configurable deployment ring settings for quality updates, feature updates, Edge, and Office.", + "docsDescription": "Creates or updates a Windows Autopatch deployment group with Test and Last deployment rings. Configures quality update deferrals, feature update targeting, Edge and Office update channels per ring. Uses the Autopatch API proxy to manage the group configuration.", + "executiveText": "Configures Windows Autopatch deployment groups to manage update delivery across devices. Autopatch automates Windows quality updates, feature updates, Edge, and Office updates using deployment rings with configurable deferrals and deadlines.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AutopatchGroup.GroupName", + "label": "Group Name", + "required": true, + "defaultValue": "Autopatch default group" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TargetOSVersion", + "label": "Target OS Version", + "required": true, + "options": [ + { "label": "Windows 11, version 24H2", "value": "Windows 11, version 24H2" }, + { "label": "Windows 11, version 25H2", "value": "Windows 11, version 25H2" } + ], + "defaultValue": "Windows 11, version 25H2" + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.EnableDriverUpdate", + "label": "Enable Driver Updates", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.InstallWin10OnWin11Ineligible", + "label": "Install latest Windows 10 on Windows 11 ineligible devices", + "defaultValue": false + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeferral", + "label": "Test Ring - Quality Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeadline", + "label": "Test Ring - Quality Update Deadline (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityGracePeriod", + "label": "Test Ring - Quality Update Grace Period (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeferral", + "label": "Test Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeadline", + "label": "Test Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestEdgeChannel", + "label": "Test Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Beta" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestOfficeChannel", + "label": "Test Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestDnfDeferral", + "label": "Test Ring - Driver & Firmware Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeferral", + "label": "Last Ring - Quality Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeadline", + "label": "Last Ring - Quality Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityGracePeriod", + "label": "Last Ring - Quality Update Grace Period (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeferral", + "label": "Last Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeadline", + "label": "Last Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastEdgeChannel", + "label": "Last Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Stable" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastOfficeChannel", + "label": "Last Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeferral", + "label": "Last Ring - Office Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeadline", + "label": "Last Ring - Office Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastDnfDeferral", + "label": "Last Ring - Driver & Firmware Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + } + ], + "label": "Deploy Windows Autopatch Group", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Autopatch API - POST /api/autoPatch", "recommendedBy": [] + }, + { + "name": "standards.FIDO2PasskeyProfiles", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures FIDO2 passkey profiles including AAGUID allowlists, attestation enforcement, and passkey types for the tenant.", + "docsDescription": "Manages FIDO2 passkey profiles on the tenant authentication methods policy. Allows defining passkey profiles that control which authenticators (hardware keys, password managers, Microsoft Authenticator) are permitted via AAGUID allowlists, whether attestation is enforced, and which passkey types (device-bound, synced, or both) are allowed. This enables MSPs to centrally deploy phishing-resistant MFA configurations across tenants.", + "executiveText": "Configures passkey (FIDO2) profiles that control which authenticators users can register for phishing-resistant MFA. Supports allowlisting specific hardware keys (e.g., YubiKey models), password managers (e.g., 1Password), and Microsoft Authenticator by AAGUID, with control over attestation enforcement and passkey types.", + "addedComponent": [ + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.PasskeyTypes", + "label": "Allowed Passkey Types", + "options": [ + { "label": "Device-bound only", "value": "deviceBound" }, + { "label": "Synced only", "value": "synced" }, + { "label": "Both device-bound and synced", "value": "deviceBound,synced" } + ], + "required": true + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.AttestationEnforcement", + "label": "Attestation Enforcement", + "options": [ + { "label": "Disabled (required for synced passkeys)", "value": "disabled" }, + { "label": "Registration only", "value": "registrationOnly" } + ], + "required": true + }, + { + "type": "switch", + "name": "standards.FIDO2PasskeyProfiles.EnforceKeyRestrictions", + "label": "Enforce AAGUID Key Restrictions" + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.EnforcementType", + "label": "Key Restriction Type", + "options": [ + { "label": "Allow listed AAGUIDs only", "value": "allow" }, + { "label": "Block listed AAGUIDs", "value": "block" } + ], + "required": false + }, + { + "type": "textField", + "name": "standards.FIDO2PasskeyProfiles.AAGUIDs", + "label": "AAGUIDs (comma-separated list of authenticator AAGUIDs)", + "required": false + } + ], + "label": "Configure FIDO2 Passkey Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-25", + "powershellEquivalent": "Graph API PATCH /policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.SmartLockout", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], + "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], + "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "addedComponent": [ + { + "type": "number", + "name": "standards.SmartLockout.LockoutDurationInSeconds", + "label": "Lockout Duration (seconds)", + "default": 60, + "required": true + }, + { + "type": "number", + "name": "standards.SmartLockout.LockoutThreshold", + "label": "Lockout Threshold (failed attempts)", + "default": 10, + "required": true + }, + { + "type": "switch", + "name": "standards.SmartLockout.EnableBannedPasswordCheckOnPremises", + "label": "Enable On-Premises Password Protection" + }, + { + "type": "radio", + "name": "standards.SmartLockout.BannedPasswordCheckOnPremisesMode", + "label": "On-Premises Mode", + "options": [ + { "label": "Audit", "value": "Audit" }, + { "label": "Enforced", "value": "Enforced" } + ] + } + ], + "label": "Configure Entra ID Smart Lockout", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", + "recommendedBy": ["CIS"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] + }, + { + "name": "standards.SPOVersionControl", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Configures SharePoint Online file versioning to either use automatic version trimming managed by Microsoft, or enforce a fixed major version limit with optional version expiration.", + "docsDescription": "Configures the SharePoint Online tenant-level file versioning policy. When automatic version trimming is enabled, Microsoft intelligently manages version cleanup. When disabled, you can set a fixed maximum number of major versions to retain and optionally expire versions after a specified number of days. This helps manage storage consumption while preserving version history as needed.", + "executiveText": "Controls how SharePoint Online manages file version history at the tenant level. Automatic trimming lets Microsoft optimize storage by cleaning up old versions intelligently. Manual limits give administrators precise control over the maximum number of versions retained and their expiration, ensuring predictable storage usage and compliance with data retention policies.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.SPOVersionControl.EnableAutoTrim", + "label": "Enable Automatic Version Trimming (Microsoft managed)" + }, + { + "type": "number", + "name": "standards.SPOVersionControl.MajorVersionLimit", + "label": "Maximum Major Versions (when auto trim is off)", + "default": 50 + }, + { + "type": "number", + "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", + "label": "Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)", + "default": 0, + "validators": { + "min": { "value": 0, "message": "Use 0 for never, or 30 or more days" }, + "max": { "value": 36500, "message": "Maximum value is 36500" } + } + }, + { + "type": "switch", + "name": "standards.SPOVersionControl.ApplyToExistingSites", + "label": "Apply to all existing sites and document libraries" + } + ], + "label": "Set SharePoint File Version Limits", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-27", + "powershellEquivalent": "Set-SPOTenant -EnableAutoExpirationVersionTrim $true or Set-SPOTenant -EnableAutoExpirationVersionTrim $false -MajorVersionLimit 50 -ExpireVersionsAfterDays 365", + "recommendedBy": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + } } ] diff --git a/Config/words.txt b/Config/words.txt index ad92336870f8..6acd4124ca31 100644 --- a/Config/words.txt +++ b/Config/words.txt @@ -1,4 +1,9 @@ Abacus +Abandon +Abandoned +Abatement +Abbey +Abbot Abdomen Abdominal Abide @@ -7,68 +12,247 @@ Ability Ablaze Able Abnormal +Abode +Abolish +Abolished +Abolition +Abound +Above Abrasion Abrasive Abreast Abridge Abroad +Abrupt Abruptly Absence +Absent Absentee Absently Absinthe Absolute Absolve +Absorb +Absorbed +Absorbing +Absorbs Abstain Abstract +Abstracts Absurd +Absurdity +Abundance +Abundant +Abyss +Acacia +Academia +Academic +Academics +Academies +Academy Accent +Accents +Accept +Accepted +Accepting +Accepts +Access +Accessed +Accessing +Accession +Accessory +Accident +Accidents Acclaim +Acclaimed Acclimate +Accolades Accompany +Accord +Accorded +According +Accordion +Accords Account +Accounted +Accounts +Accretion +Accrual +Accrue +Accrued Accuracy Accurate +Accuse +Accused +Accuses +Accusing Accustom +Acetate +Acetic Acetone +Achieve +Achieved +Achieves +Achieving Achiness Aching Acid +Acidic +Acidity +Acids +Acne Acorn +Acoustic Acquaint Acquire +Acquired +Acquires +Acquiring +Acquitted Acre +Acreage Acrobat Acronym +Across +Acrylic Acting Action Activate +Activates Activator Active +Actively Activism Activist +Activists Activity Actress +Actresses Acts +Actuality +Actually +Acuity +Acute Acutely Acuteness +Adamant +Adapt +Adaptable +Adapted +Adapter +Adapting +Adaptive +Add +Adding +Addition +Additions +Additive +Additives +Address +Addressed +Addresses +Adds +Adept +Adhere +Adhered +Adherence +Adherents +Adhering +Adhesion +Adhesive +Adjacent +Adjective +Adjoining +Adjunct +Adjust +Adjusted +Adjusting +Admin +Admirable +Admirably +Admiral +Admirals +Admiralty +Admire +Admired +Admirer +Admirers +Admiring +Admission +Admit +Admits +Admitted +Admitting +Adobe +Adopt +Adopted +Adopting +Adoption +Adoptive +Adopts +Adoration +Adored +Adorned +Adrenal +Adult +Adulthood +Adults +Advance +Advanced +Advances +Advancing +Advantage +Advent +Adventure +Adverb +Adverbs +Adversary +Adverse +Adversely +Adversity +Advertise +Advice +Advisable +Advise +Advised +Adviser +Advisers +Advises +Advising +Advisory +Advocacy +Advocate +Advocated +Advocates +Aegis Aeration +Aerial Aerobics Aerosol Aerospace Afar Affair +Affairs +Affect Affected Affecting Affection +Affects Affidavit Affiliate +Affinity Affirm +Affirming +Affirms Affix Afflicted +Affluence Affluent Afford +Afforded +Affords Affront +Afghan Aflame Afloat Aflutter @@ -79,100 +263,225 @@ Afterlife Aftermath Aftermost Afternoon +Afterward +Again +Against Aged Ageless +Agencies Agency Agenda +Agendas Agent Aggregate Aghast Agile Agility Aging +Agitated +Agitation Agnostic Agonize Agonizing Agony +Agrarian +Agree Agreeable Agreeably Agreed Agreeing Agreement +Agrees Aground Ahead Ahoy Aide -Aids +Aides +Ailments Aim +Aimed +Aiming +Aims +Airborne +Aircraft +Airfield +Airfields +Airlift +Airline +Airlines +Airmen +Airplane +Airplanes +Airport +Airports +Airship +Airspace +Airstrip +Airways +Aisle +Aisles Ajar +Akin Alabaster Alarm +Alarmed +Alarming +Alarms +Alas Albatross +Albeit Album +Albumin +Albums +Alchemy +Alcohol +Alcoholic +Alder +Alderman +Aldermen +Alert +Alerted Alfalfa +Algae Algebra +Algebraic +Algebras Algorithm Alias Alibi +Alien Alienable Alienate +Alienated Aliens +Align +Aligned +Alignment Alike Alive +Alkali Alkaline Alkalize +Alleged +Allegedly +Alleging +Allegory +Allergic +Allergies +Allergy +Alleviate +Alliance +Alliances +Alligator +Allocate +Allocated +Allotment +Allotted +Allowable +Allowance +Allowed +Allowing +Allows +Alloy +Alloys +Alluded +Alludes +Allusion +Allusions +Alluvial Almanac Almighty +Almond Almost Aloe Aloft Aloha Alone +Along Alongside Aloof +Aloud +Alpha Alphabet +Alpine +Already Alright +Altar +Altars +Alter +Altered +Altering +Alternate +Alters Although Altitude +Altitudes Alto +Altruism Aluminum Alumni Always +Amalgam Amaretto +Amassed +Amateur +Amateurs Amaze +Amazed +Amazement +Amazing Amazingly +Amazon Amber Ambiance +Ambient Ambiguity Ambiguous Ambition +Ambitions Ambitious Ambulance Ambush +Ambushed +Amenable +Amend Amendable +Amended +Amending Amendment Amends +Amenities Amenity Amiable Amicably Amid +Amidst Amigo Amino Amiss Ammonia Ammonium +Amnesia Amnesty Amniotic Among +Amongst +Amorphous Amount +Amounted +Amounting +Amounts Amperage Ample +Amplified Amplifier Amplify +Amplitude Amply Amuck Amulet Amusable +Amuse Amused Amusement Amuser @@ -180,18 +489,47 @@ Amusing Anaconda Anaerobic Anagram +Analog +Analogies +Analogous +Analogy +Analysis +Analyst +Analysts +Analytic +Analyze +Analyzed +Analyzer +Analyzes +Analyzing +Anarchy +Anatomic Anatomist Anatomy +Ancestor +Ancestors +Ancestral +Ancestry Anchor +Anchorage +Anchored +Anchors Anchovy Ancient +Ancients +Ancillary +Androgen Android +Anecdotal +Anecdote +Anecdotes Anemia Anemic Aneurism Anew Angelfish Angelic +Angels Anger Angled Angler @@ -199,37 +537,70 @@ Angles Angling Angrily Angriness +Angry +Anguish Anguished Angular Animal +Animals Animate +Animated Animating Animation Animator Anime Animosity +Anion Ankle +Ankles +Annals +Annealing Annex +Annexed Annotate +Annotated +Announce +Announced Announcer +Announces +Annoyance +Annoyed Annoying +Annual Annually Annuity +Annulled +Anode +Anointed Anointer +Anomalies +Anomalous +Anomaly +Anonymity +Anonymous Another +Answer Answering +Answers Antacid Antarctic +Ante Anteater Antelope +Antenna Antennae +Antennas +Anterior Anthem Anthill Anthology Antibody Antics Antidote +Antigen +Antigens Antihero +Antique Antiquely Antiques Antiquity @@ -242,6 +613,10 @@ Antler Antonym Antsy Anvil +Anxieties +Anxiety +Anxious +Anxiously Anybody Anyhow Anymore @@ -253,164 +628,410 @@ Anyway Anywhere Aorta Apache +Apart +Apartment +Apathy +Aperture +Apex +Apologies +Apologize +Apology Apostle +Apostles +App +Appalled +Appalling +Apparatus +Apparel +Apparent +Appeal +Appealed Appealing +Appeals Appear +Appearing +Appears Appease Appeasing +Appellant +Appellate Appendage +Appended Appendix Appetite +Appetites Appetizer Applaud +Applauded Applause Apple +Apples Appliance Applicant Applied +Applies Apply +Applying +Appoint +Appointed Appointee +Appoints Appraisal Appraiser Apprehend Approach Approval Approve +Approved +Approving +Apps Apricot April Apron Aptitude Aptly Aqua +Aquarium +Aquatic +Aquatics Aqueduct +Aquifer Arbitrary Arbitrate +Arboretum +Arc +Arcade +Arcades +Archaic +Archangel +Archduke +Archer +Archers +Archery +Archetype +Architect +Archive +Archived +Archives +Arcs +Ardent Ardently +Arduous Area +Areas Arena +Arenas +Argon Arguable Arguably Argue +Argued +Argues +Arguing +Argument +Arguments +Argyle +Arid Arise +Arisen +Arises +Arising +Armada Armadillo +Armament +Armaments Armband Armchair Armed Armful Armhole +Armies Arming +Armistice Armless Armoire +Armor Armored Armory Armrest Army Aroma +Aromatic Arose Around -Arousal Arrange +Arranged +Arranger +Arranges +Arranging Array +Arrays +Arrears Arrest +Arrested +Arresting +Arrests Arrival +Arrivals Arrive +Arrived +Arrives +Arriving Arrogance Arrogant -Arson +Arroyo +Arsenal Art +Arteries +Artery +Arthritis +Artifact +Artifacts +Artillery +Artist +Artistic +Artistry +Artists +Artwork +Artworks +Asbestos Ascend +Ascended +Ascending Ascension Ascent Ascertain +Ascetic +Ascribe +Ascribed Ashamed Ashen Ashes +Ashore Ashy Aside Askew Asleep Asparagus Aspect +Aspects +Aspen +Asphalt Aspirate Aspire Aspirin +Aspiring +Assay +Assays +Assemble +Assembled +Assembly +Assent +Assert +Asserted +Asserting +Assertion +Assertive +Asserts +Assess +Assessed +Assesses +Assessing +Asset +Assets +Assign +Assigning +Assigns +Assistant +Assisted +Assisting +Assists +Associate +Assorted +Assume +Assumed +Assumes +Assuming +Assuredly +Assures +Asteroid +Asteroids +Asthma Astonish Astound +Astray Astride Astrology Astronaut Astronomy Astute +Asymmetry +Athlete +Athletes +Athletic +Athletics Atlantic Atlas +Atoll Atom +Atoms Atonable +Atonement Atop Atrium Atrocious Atrophy Attach +Attached +Attaching +Attacked +Attacker +Attackers +Attacking +Attacks Attain +Attained +Attaining +Attains Attempt +Attempted +Attempts +Attend Attendant +Attended Attendee +Attending +Attends Attention Attentive Attest +Attested Attic Attire Attitude +Attitudes +Attorney +Attorneys +Attract +Attracted Attractor +Attracts Attribute +Attrition +Attuned Atypical +Auburn Auction +Auctioned +Auctions Audacious Audacity Audible Audibly Audience +Audiences Audio +Audit +Auditing Audition +Auditions +Auditor +Auditors +Auditory +Audits +Augment Augmented August +Aura +Auspices +Austere +Austerity Authentic Author -Autism -Autistic +Authored +Authority +Authorize +Authors +Auto Autograph Automaker Automated Automatic +Autonomy Autopilot +Autumn +Auxiliary +Avail Available Avalanche Avatar Avenge +Avengers Avenging Avenue +Avenues Average +Averaged +Averages +Averaging +Averse Aversion Avert +Averted +Avian Aviation Aviator Avid Avoid +Avoidance +Avoided +Avoiding +Avoids Await +Awaited +Awaiting +Awaits +Awake Awaken +Awakened +Awakening +Awakens Award +Awarded +Awarding +Awards Aware +Awareness +Awe +Awesome +Awfully Awhile Awkward +Awkwardly Awning Awoke Awry +Axe +Axial +Axiom +Axioms Axis +Axle +Axles +Axon +Axons +Aye +Azure Babble Babbling Babied +Babies Baboon +Baby +Bachelor Backache Backboard +Backbone Backboned Backdrop Backed @@ -436,23 +1057,33 @@ Backspace Backspin Backstab Backstage +Backstory Backtalk Backtrack Backup Backward +Backwards Backwash Backwater Backyard Bacon Bacteria +Bacterial Bacterium -Badass +Bad +Bade Badge +Badger +Badgers +Badges Badland Badly +Badminton Badness Baffle +Baffled Baffling +Bag Bagel Bagful Baggage @@ -462,165 +1093,545 @@ Bagginess Bagging Baggy Bagpipe +Bags Baguette +Bail +Bait +Bake Baked +Baker Bakery Bakeshop Baking Balance Balancing Balcony +Bald +Bales +Ballad +Ballads +Ballast +Ballet +Ballets +Ballistic +Balloon +Balloons +Ballot +Ballots +Ballpark +Ballroom Balmy Balsamic Bamboo Banana +Bananas +Bandage +Banded +Bandit +Bandits +Bands +Bandwidth +Bang Banish +Banished Banister Banjo +Bank Bankable Bankbook Banked Banker +Bankers Banking Banknote +Banknotes Bankroll +Bankrupt +Banks +Banned Banner +Banners +Banning Bannister +Banquet +Bans Banshee +Bantam Banter +Barbarian +Barbarous Barbecue Barbed Barbell Barber Barcode +Bard +Bare +Barefoot +Barely +Bargain Barge +Barges Bargraph Barista Baritone +Barium +Barker Barley Barmaid Barman Barn +Barney +Barns Barometer +Baron +Baroness +Baronet +Baronets +Barons +Baroque Barrack +Barracks Barracuda +Barrage +Barred Barrel +Barrels +Barren Barrette Barricade Barrier +Barriers +Barring +Barrister +Barrow +Bars Barstool Bartender +Barter Barterer +Basal +Basalt +Baseball +Based +Baseline +Baseman +Basement Bash +Basic Basically Basics Basil +Basilica Basin +Basing +Basins Basis Basket +Baskets +Bass +Bassist +Bastion Batboy Batch Bath +Bathe +Bathed +Bathing +Bathroom +Bathrooms +Baths Baton Bats +Batsmen Battalion +Batted +Batter Battered +Batteries Battering +Batters Battery Batting Battle +Battled +Battles +Battling Bauble +Bay +Bayou +Bays +Bazaar Bazooka +Beach +Beaches +Beacon +Bead +Beads +Beak +Beam +Beamed +Beams +Beans +Bear +Beard +Bearded +Bearer +Bearers +Bearings +Bears +Beast +Beasts +Beating +Beats +Beau +Beauties +Beautiful +Beauty +Beaver +Beavers +Became +Beck +Become +Becomes +Becoming +Bedrock +Bedroom +Bedrooms +Beds +Bedside +Bedtime +Bee +Beech +Beef +Beer +Beers +Bees +Beet +Beetle +Beetles +Befriends +Beg +Began +Beggar +Beggars +Begged +Begging +Begin +Beginner +Beginners +Beginning +Begins +Begs +Begun +Behalf +Behave +Behaved +Behaves +Behaving +Behavior +Beheld +Behest +Behind +Behold +Being +Beings +Belief +Beliefs +Believe +Believed +Believer +Believers +Believes +Believing +Bell +Belle +Bells +Belly +Belong +Belonged +Belonging +Belongs +Beloved +Below +Belt +Belts +Bench +Benches +Benchmark +Bend +Bender +Bending +Bends +Beneath +Benefit +Benefited +Benefits +Benign +Bent +Benzene +Berg +Berth +Berths +Beset +Beside +Besides +Besieged +Best +Bestow +Bestowed +Beta +Betray +Betrayal +Betrayed +Bets +Better +Betting +Beverage +Beverages +Beware +Beyond +Bias +Biases +Biathlon +Bicycle +Bicycles +Bidder +Bidding +Bids +Biennial +Big +Bigger +Biggest +Bike +Bikes +Biking +Bikini +Bilateral +Bilingual +Bill +Billboard +Billed +Billiards +Billing +Billings +Billion +Billions +Bills +Binary +Bind +Binder +Binding +Binds +Bingo +Binomial +Biologist +Biology +Biopsy +Biosphere +Biplane +Birch +Bird +Birds +Birthday +Births +Biscuits +Bishopric +Bishops +Bison +Bite +Bites +Bitten +Bitter +Bitterly +Bizarre Blabber +Black +Blackened +Blackish +Blackness +Blackout Bladder Blade +Blades Blah Blame +Blamed +Blames Blaming Blanching +Bland Blandness Blank +Blanket +Blankets +Blanks Blaspheme Blasphemy Blast +Blasted +Blasting Blatancy +Blatant Blatantly +Blaze Blazer +Blazers Blazing Bleach Bleak Bleep Blemish Blend +Blended +Blending +Blends Bless +Blessed +Blessing +Blessings +Blew +Blight Blighted Blimp +Blinding +Blindly +Blinds Bling +Blink Blinked Blinker Blinking Blinks Blip +Bliss Blissful Blitz Blizzard Bloated Bloating Blob +Bloc +Block +Blockade +Blocked +Blocking +Blocks Blog +Blogger +Blogs +Blond +Blonde +Bloom Bloomers Blooming +Blooms Blooper +Blossom +Blossoms Blot Blouse +Blow +Blowing +Blown +Blows Blubber +Blue +Bluegrass +Blueprint +Blues Bluff +Bluffs Bluish Blunderer Blunt +Bluntly +Blur Blurb Blurred +Blurring Blurry Blurt Blush +Blushed Blustery +Boa +Boar +Boarded +Boarding +Boardwalk +Boast +Boasted Boaster Boastful Boasting +Boasts Boat +Boating Bobbed Bobbing Bobble +Bobby Bobcat +Bobcats Bobsled Bobtail Bodacious +Bodily Body +Bodyguard +Bog Bogged Boggle Bogus +Bohemian Boil +Boiled +Boiler +Boilers +Boiling Bok +Bold +Boldly +Boldness Bolster Bolt +Bolted +Bolts Bonanza +Bond Bonded Bonding Bondless +Bonds Boned Bonehead Boneless Bonelike +Bones Boney Bonfire Bonnet Bonsai Bonus +Bonuses Bony Boogeyman +Boogie Boogieman Book +Booked +Booking +Booklet +Bookstore +Boom +Booming +Boon Boondocks +Boost +Boosted +Booster +Boot Booted Booth Bootie @@ -630,53 +1641,166 @@ Bootleg Boots Boozy Borax +Border +Bordered +Bordering +Borders +Bore +Boredom Boring +Boron Borough +Boroughs +Borrow +Borrowed Borrower +Borrowers Borrowing Boss +Bosses Botanical Botanist Botany Botch Both +Bother +Bothered +Bothering Bottle +Bottled +Bottles Bottling Bottom +Bottoms +Bought +Boulder +Boulders +Boulevard Bounce +Bounced Bouncing Bouncy +Boundary +Bounded Bounding Boundless Bountiful +Bounty +Bouquet +Bourbon +Bout +Boutique +Bouts Bovine +Bowed +Bower +Bowing +Bowl +Bowled +Bowler +Bowlers +Bowling +Bowls +Bowman Boxcar +Boxed Boxer +Boxers +Boxes Boxing Boxlike Boxy +Boycott +Boycotted +Boyfriend +Boyhood +Bracket +Brackets +Braille +Brain +Brains +Brake +Brakes +Braking +Bran +Branch +Branched +Branches +Branching +Brand +Branded +Branding +Brands +Brandy +Brass +Brave +Bravely +Bravery +Braves +Bravo +Brawl +Bray Breach +Breached +Breaches +Bread +Breadth +Breakdown +Breaker +Breakers +Breakfast +Breakup Breath +Breathe +Breathed +Breathing +Breaths Breeches Breeching +Breed Breeder +Breeders Breeding +Breeds Breeze Breezy Brethren +Brevity +Brew +Brewer +Breweries +Brewers Brewery Brewing Briar Bribe +Bribery +Bribes Brick +Bricks Bride Bridged +Bridges +Bridging +Brief +Briefcase +Briefing +Briefly +Briefs +Brig Brigade +Brigades Bright +Brighter +Brightest +Brightly Brilliant Brim +Brine Bring +Brings Brink +Brisk Brisket Briskly Briskness @@ -685,231 +1809,433 @@ Brittle Broadband Broadcast Broaden +Broadened +Broader +Broadest Broadly Broadness Broadside Broadways +Broccoli +Brochure +Brochures Broiler Broiling +Broke Broken Broker +Brokerage +Brokers +Bromide Bronchial Bronco +Broncos Bronze Bronzing +Brood +Brooding Brook +Brooks Broom +Broth +Brother +Brothers Brought Browbeat -Brownnose +Brown +Browning +Brownish +Browns Browse +Browser +Browsers Browsing +Bruins +Bruised +Bruises Bruising Brunch Brunette Brunt Brush +Brushed +Brushes +Brushing Brussels Brute Brutishly Bubble +Bubbles Bubbling Bubbly Buccaneer +Buck Bucked Bucket +Buckets +Buckeyes Buckle +Buckling +Bucks Buckshot Buckskin Bucktooth Buckwheat +Bud Buddhism Buddhist +Buddies Budding Buddy Budget +Budgetary +Budgeting +Budgets +Buds +Buff Buffalo Buffed Buffer +Buffers +Buffet Buffing Buffoon Buggy +Bugs +Builder +Builders +Builds +Buildup Bulb +Bulbs Bulge Bulginess +Bulging Bulgur Bulk +Bulky +Bull Bulldog +Bulldogs Bulldozer +Bulletin Bullfight Bullfrog Bullhorn Bullion Bullish +Bullock Bullpen Bullring +Bulls Bullseye Bullwhip Bully +Bump +Bumped +Bumper +Bumps +Bun Bunch Bundle +Bundled +Bundles +Bungalow Bungee Bunion +Bunk Bunkbed +Bunker +Bunkers Bunkhouse Bunkmate Bunny Bunt +Burden +Burdened +Burdens +Bureau +Bureaus +Burglary +Burned +Burner +Burning +Burns +Burnt +Burrow +Burrows +Bursting +Bus Busboy Bush +Bushes +Busiest Busily +Business Busload Bust +Busy Busybody +Butcher +Butler +Butter +Butterfly +Button +Buttons +Buy +Buyer +Buyers +Buying +Buyout +Buys Buzz +Bypass +Bypassed +Bypassing +Byte +Bytes +Cab Cabana +Cabaret Cabbage Cabbie Cabdriver +Cabin +Cabinet +Cabinets +Cabins Cable +Cables Caboose Cache Cackle Cacti Cactus +Cad Caddie Caddy +Cadence Cadet +Cadets Cadillac Cadmium +Cadre +Cadres +Cafeteria +Caffeine Cage +Cages Cahoots Cake +Cakes Calamari Calamity Calcium Calculate Calculus +Calendar +Calf Caliber Calibrate +Caller Calm +Calmed +Calmly Caloric Calorie +Calories +Calves Calzone Camcorder +Camel +Camels Cameo Camera +Cameras Camisole +Camp +Campaign +Campaigns +Camped Camper Campfire Camping +Camps Campsite Campus +Campuses Canal +Canals Canary Cancel +Canceled +Candid +Candidacy +Candidate Candied Candle +Candles Candy Cane Canine Canister -Cannabis Canned Canning Cannon +Cannons Cannot +Canoe +Canoeing +Canoes Canola Canon +Canonical +Canons Canopener Canopy +Cantata Canteen +Canto +Canvas Canyon +Cap Capable Capably +Capacitor Capacity Cape Capillary Capital +Capitals Capitol Capped Capricorn +Caps Capsize Capsule +Capsules +Captain +Captaincy +Captained +Captains Caption Captivate Captive +Captives Captivity Capture +Captures +Capturing Caramel Carat Caravan Carbon +Carbonate Cardboard Carded Cardiac Cardigan Cardinal +Cardinals Cardstock +Career +Careers +Careful Carefully Caregiver Careless +Cares Caress Caretaker Cargo +Caribou Caring Carless Carload Carmaker -Carnage Carnation Carnival Carnivore Carol +Carousel +Carp Carpenter Carpentry +Carpet +Carpets Carpool Carport +Carriage +Carriages Carried +Carrier +Carriers +Carries Carrot +Carrots Carrousel Carry +Carrying +Cart Cartel +Cartilage Cartload Carton Cartoon +Cartoons Cartridge +Carts Cartwheel Carve +Carved +Carver Carving +Carvings Carwash Cascade +Cascades Case Cash Casing Casino +Casinos Casket Cassette +Caste +Castes +Castle +Castles +Casual Casually Casualty Catacomb Catalog +Catalogs Catalyst +Catalysts +Catalytic Catalyze +Catalyzed +Catalyzes Catapult Cataract Catatonic Catcall +Catch Catchable Catcher +Catches Catching Catchy +Category +Cater Caterer Catering Catfight Catfish Cathedral -Cathouse +Catheters +Cathode Catlike Catnap Catnip @@ -919,105 +2245,231 @@ Cattishly Cattle Catty Catwalk -Caucasian Caucus +Caught Causal +Causality Causation +Causative Cause +Caused +Causes +Causeway Causing +Caustic Cauterize Caution +Cautioned +Cautions Cautious Cavalier +Cavaliers Cavalry +Caveat +Cavern +Caves Caviar +Cavities Cavity +Cease +Ceasefire +Ceases Cedar +Ceiling +Ceilings +Celebrate +Celebrity Celery Celestial Celibacy Celibate +Cell +Cellar +Cellist +Cello +Cells +Cellular +Cellulose Celtic Cement +Cemented +Censor +Censored +Censure Census +Censuses +Centenary +Center +Centered +Centering +Centers +Central +Centrally +Centuries +Century +Ceramic Ceramics +Cereal +Cereals +Cerebral Ceremony Certainly Certainty Certified Certify Cesarean +Cessation Cesspool Chafe Chaffing Chain +Chained +Chains Chair +Chaired +Chairman +Chairs Chalice +Chalk Challenge Chamber +Chambers Chamomile +Champ +Champagne Champion +Champions Chance +Chancery +Chances +Chandler Change Channel +Channels Chant +Chanting Chaos +Chaotic +Chap +Chapel +Chapels Chaperone Chaplain Chapped Chaps Chapter +Chapters +Char Character Charbroil Charcoal +Charge +Charged Charger +Chargers +Charges Charging Chariot +Charisma +Charities Charity Charm +Charmed +Charming +Charms Charred +Chart +Charted Charter +Chartered +Charters Charting +Charts Chase Chasing +Chassis Chaste Chastise Chastity +Chat Chatroom Chatter Chatting Chatty +Cheap +Cheaper +Cheapest +Cheaply +Cheat +Cheated Cheating +Check +Checked +Checking +Checklist +Checks Cheddar Cheek +Cheeks Cheer +Cheered +Cheerful +Cheering +Cheers Cheese Cheesy Chef +Chefs +Chemical Chemicals Chemist -Chemo +Chemistry +Chemists +Cherish +Cherished Cherisher +Cherry Cherub Chess Chest +Chestnut +Chests Chevron Chevy +Chew Chewable +Chewed Chewer Chewing Chewy +Chick +Chicken +Chickens +Chicks Chief +Chiefly +Chiefs +Chieftain Chihuahua +Child Childcare Childhood Childish Childless Childlike +Chile Chili Chill +Chilled +Chilling +Chilly +Chimney +Chimneys Chimp +Chin +China Chip +Chips Chirping Chirpy Chitchat @@ -1025,125 +2477,278 @@ Chivalry Chive Chloride Chlorine +Chocolate Choice -Chokehold +Choices +Choir +Choirs +Choke +Choked Choking Chomp +Choose Chooser +Chooses Choosing Choosy Chop +Chopped +Choral +Chorale +Chord +Chords +Chores +Chorus +Chose Chosen +Chow Chowder Chowtime +Chromatic Chrome +Chromium +Chronic +Chronicle Chubby Chuck +Chuckled Chug Chummy Chump Chunk +Chunks +Churches Churn Chute Cider +Cigar +Cigarette +Cigars Cilantro Cinch Cinema +Cinemas +Cinematic Cinnamon +Cipher +Circa +Circadian Circle +Circles Circling +Circuit +Circuitry +Circuits Circular Circulate Circus Citable Citadel Citation +Citations +Cites +Cities Citizen +Citizenry +Citizens Citric Citrus City Civic Civil +Civilian +Civilians +Civilized Clad Claim +Claimant +Claimants Clambake Clammy Clamor Clamp +Clamped Clamshell +Clan Clang Clanking +Clans Clapped Clapper Clapping +Clarified Clarify Clarinet Clarity Clash +Clashed +Clashes Clasp +Clasped Class +Classed +Classes +Classic +Classical +Classics +Classify +Classmate +Classroom Clatter Clause +Clauses Clavicle Claw +Claws Clay Clean +Cleaned +Cleaner +Cleaners +Cleaning +Cleansing +Cleanup Clear +Clearance +Cleared +Clearer +Clearest +Clearing +Clearly Cleat Cleaver Cleft +Clement Clench +Clenched +Clergy Clergyman +Clergymen +Cleric Clerical +Clerics Clerk +Clerks Clever +Click +Clicked Clicker +Clicking +Clicks Client +Clients +Cliff +Cliffs Climate +Climates Climatic +Climb +Climbed +Climber +Climbers +Climbing +Climbs +Clinch +Clinched Cling +Clinging Clinic +Clinical +Clinician +Clinics Clinking Clip +Clipboard +Clipped +Clipper +Clippers +Clipping +Clips Clique Cloak Clobber Clock +Clocks +Clockwise +Cloister Clone +Cloned Cloning Closable +Closely +Closeness +Closer +Closes +Closest +Closet Closure +Clot +Cloth +Clothed Clothes Clothing +Cloths +Clotting Cloud +Clouded +Clouds +Cloudy Clover +Cloves +Clown Clubbed Clubbing Clubhouse +Clue +Clues Clump +Clumps Clumsily Clumsy +Clung Clunky +Cluster Clustered +Clusters Clutch +Clutched +Clutching Clutter Coach +Coached +Coaches +Coaching Coagulant +Coalition +Coals +Coarse +Coast Coastal Coaster Coasting Coastland Coastline +Coasts Coat +Coated +Coating +Coatings +Coats Coauthor Cobalt Cobbler +Cobra Cobweb +Cockpit Cocoa Coconut Cod +Codex +Codified Coeditor Coerce +Coercion +Coercive Coexist Coffee Cofounder @@ -1152,45 +2757,127 @@ Cognitive Cogwheel Coherence Coherent +Cohesion Cohesive +Cohort +Cohorts Coil +Coiled +Coils +Coin +Coinage +Coincide +Coincided +Coincides +Coined +Coins Coke Cola Cold +Colder +Coldest +Coldly Coleslaw Coliseum Collage Collapse +Collapsed +Collapses Collar +Colleague Collected Collector +Collects +College +Colleges Collide +Collided Collie +Collier +Colliery Collision +Collusion +Cologne +Colon +Colonel +Colonels Colonial +Colonies Colonist +Colonists Colonize +Colonized Colony +Colorful +Coloring +Colors Colossal Colt +Colts +Column +Columnist +Columns Coma +Combat +Combating +Combine +Combined +Combines +Combining +Combo +Combs Come +Comeback +Comedian +Comedians +Comedic +Comedies +Comedy +Comet +Comets Comfort +Comforted +Comforts Comfy Comic +Comics Coming Comma +Command +Commanded +Commander +Commando +Commandos +Commands +Commas Commence +Commenced Commend Comment +Commented +Comments Commerce +Commit +Commits +Committed Commode Commodity Commodore Common +Commonest +Commonly +Commons Commotion +Communal +Commune +Communes +Community Commute +Commuted +Commuter +Commuters Commuting +Compact Compacted Compacter Compactly @@ -1198,55 +2885,126 @@ Compactor Companion Company Compare +Compared +Compares +Comparing Compel +Compelled +Compete +Competed +Competes +Competing Compile +Compiled +Compiler +Compiling +Complain +Complains +Complaint +Completed +Completes +Complex +Complexes +Compliant +Complied Comply +Complying Component +Compose Composed Composer +Composers +Composing Composite Compost Composure Compound +Compounds Compress +Comprise Comprised +Comprises +Compute +Computed Computer +Computers Computing Comrade +Comrades Concave Conceal +Concealed +Concede Conceded +Conceding +Conceive +Conceived Concept +Concepts +Concern Concerned +Concerns Concert +Concerted +Concerto +Concertos +Concerts Conch Concierge Concise +Conclave Conclude +Concluded +Concludes +Concord +Concourse Concrete Concur +Condemn +Condemned Condense +Condensed +Condenser Condiment Condition Condone +Condor Conducive +Conduct +Conducted Conductor +Conducts Conduit Cone +Cones +Confer +Conferred +Confers Confess +Confessed +Confesses Confetti Confidant +Confided Confident Confider Confiding Configure +Confine Confined +Confines Confining Confirm +Confirmed +Confirms Conflict +Conflicts Conform +Conforms Confound Confront +Confronts +Confuse Confused Confusing Confusion @@ -1254,124 +3012,308 @@ Congenial Congested Congrats Congress +Congruent Conical Conjoined +Conjugate Conjure Conjuror +Connect Connected Connector +Connects +Conquer +Conquered +Conqueror +Conquest +Conquests Consensus Consent +Consented +Conserve +Conserved +Considers +Consist +Consisted +Consists Console +Consoles Consoling Consonant +Consort Constable +Constancy Constant +Constants Constrain Constrict Construct +Construed +Consul +Consular +Consulate Consult +Consulted +Consume +Consumed Consumer +Consumers +Consumes Consuming Contact +Contacted +Contacts +Contain +Contained Container +Contains Contempt Contend +Contended +Contender +Contends +Content Contented Contently Contents Contest +Contested +Contests Context +Contexts +Continual +Continue +Continued +Continues +Continuum Contort Contour +Contours +Contract +Contracts +Contrary +Contrast +Contrasts Contrite +Contrived Control +Controls Contusion Convene +Convened Convent +Converge +Converse +Convert +Converted +Converter +Converts +Convex +Convey +Conveyed +Conveying +Conveyor +Conveys +Convict +Convicted +Convicts +Convince +Convinced +Convinces +Convoy +Convoys +Cook +Cookbook +Cooked +Cookie +Cookies +Cooking +Cooks +Cool +Cooled +Cooler +Cooling +Cooper +Cooperate +Cop Copartner Cope Copied Copier +Copies Copilot Coping Copious Copper +Cops Copy +Copyright Coral +Corals +Cordial Cork Cornball Cornbread Corncob Cornea +Corneal Corned Corner +Corners Cornfield Cornflake Cornhusk +Cornice Cornmeal Cornstalk Corny +Corolla +Corollary +Corona Coronary Coroner Corporal Corporate +Corporeal +Corps +Corpus Corral Correct +Corrected +Correlate Corridor +Corridors Corrode Corroding +Corrosion Corrosive +Corrupt +Corrupted Corsage Corset Cortex Cosigner +Cosmetic Cosmetics Cosmic +Cosmology Cosmos Cosponsor Cost +Costing +Costly +Costs +Costume +Costumes Cottage +Cottages Cotton Couch +Cougars Cough +Coughing Could +Council +Councilor +Councils +Counsel +Counselor Countable Countdown +Countess +Counties Counting Countless +Countries Country County +Coup +Coupe +Couple +Coupled +Couples +Coupling +Coupon +Coupons Courier +Courses +Court +Courteous +Courtesy +Courtiers +Courtly +Courtroom +Courts +Courtship +Courtyard +Cousin +Cousins +Cove Covenant +Covenants Cover +Coverage +Covert Coveted Coveting +Cow +Coward +Cowardice +Cowardly +Cowboy +Cowboys +Coworkers +Cows Coyness +Coyote +Coyotes Cozily Coziness Cozy +Crab Crabbing Crabgrass Crablike Crabmeat +Crack +Crackdown +Cracked +Crackers +Cracking +Cracks Cradle Cradling +Crafted Crafter Craftily +Crafts Craftsman +Craftsmen Craftwork Crafty +Crammed Cramp +Cramped +Cramps Cranberry Crane +Cranes Cranial Cranium Crank +Crash +Crashed +Crashes +Crashing Crate +Crater +Craters Crave +Craven Craving Crawfish +Crawl +Crawled Crawlers Crawling Crayfish @@ -1383,64 +3325,125 @@ Crazy Creamed Creamer Creamlike +Creamy Crease Creasing Creatable Create +Creates +Creating Creation +Creations Creative +Creator +Creators Creature +Creatures +Credence Credible Credibly Credit +Creditor +Creditors +Credits Creed +Creek +Creeks +Creep +Creeping Creme Creole Crepe Crept Crescent +Crest Crested Cresting Crestless Crevice +Crewed Crewless Crewman Crewmate Crib Cricket +Cricketer Cried Crier +Cries +Crime +Crimes +Criminal +Criminals Crimp Crimson Cringe Cringing Crinkle Crinkly +Crises +Crisis +Crisp Crisped Crisping Crisply Crispness Crispy Criteria +Criterion +Critic +Critical +Criticism +Criticize +Critics +Critique +Critiques Critter Croak Crock +Crocodile Crook +Crooked Croon Crop +Cropped +Cropping Cross +Crossed +Crosses +Crossing +Crossings +Crossover Crouch +Crouched Crouton +Crow Crowbar Crowd +Crowded +Crowds Crown +Crowned +Crowns +Crows Crucial +Crucially +Crucible +Crude Crudely Crudeness +Cruel Cruelly Cruelness Cruelty +Cruise +Cruiser +Cruisers +Cruises +Cruising Crumb +Crumbling +Crumbs Crummiest Crummy Crumpet @@ -1448,7 +3451,11 @@ Crumpled Cruncher Crunching Crunchy +Crusade Crusader +Crusaders +Crusades +Crush Crushable Crushed Crusher @@ -1458,14 +3465,21 @@ Crux Crying Cryptic Crystal +Crystals +Cub Cubbyhole Cube +Cubes +Cubic Cubical Cubicle +Cubs Cucumber Cuddle Cuddly +Cuff Cufflink +Cuisine Culinary Culminate Culpable @@ -1473,53 +3487,98 @@ Culprit Cultivate Cultural Culture +Cultured +Cultures +Cunning +Cup Cupbearer +Cupboard Cupcake Cupid +Cupola Cupped Cupping +Cups Curable +Curative Curator +Curb Curdle Cure +Cures Curfew Curing +Curiosity +Curious +Curiously +Curl Curled Curler Curliness Curling +Curls Curly +Currents +Curricula Curry Curse +Cursed +Curses +Cursing Cursive Cursor +Curt +Curtailed Curtain +Curtains Curtly Curtsy Curvature Curve +Curved +Curves +Curving Curvy +Cushion +Cushions Cushy Cusp Cussed Custard +Custodial Custodian Custody +Custom Customary Customer +Customers Customize Customs Cut +Cutoff +Cuts +Cutter +Cutting +Cuttings Cycle Cyclic +Cyclical Cycling Cyclist +Cyclists +Cyclone +Cyclones Cylinder +Cylinders Cymbal +Cynical +Cynicism +Cypress Cytoplasm Cytoplast Dab Dad +Daddy Daffodil Dagger Daily @@ -1527,37 +3586,66 @@ Daintily Dainty Dairy Daisy +Dale Dallying +Damage +Damaged +Damages +Damaging +Damp +Dams Dance +Danced +Dancer +Dancers +Dances Dancing Dandelion Dander Dandruff Dandy Danger +Dangerous +Dangers Dangle Dangling +Dare +Dared Daredevil Dares +Daring Daringly +Dark Darkened Darkening +Darker +Darkest Darkish Darkness Darkroom Darling Darn Dart +Darted +Darts Darwinism Dash +Dashed +Dashing Dastardly Data +Database +Databases Datebook Dating +Datum Daughter +Daughters Daunting +Davenport Dawdler Dawn +Dawned Daybed Daybreak Daycare @@ -1566,32 +3654,54 @@ Daylight Daylong Dayroom Daytime +Dazed Dazzler Dazzling Deacon +Deadline +Deadlines +Deadlock Deafening Deafness Dealer +Dealers Dealing +Dealings Dealmaker Dealt Dean +Dear +Dearest +Dearly Debatable Debate +Debated +Debates Debating Debit Debrief +Debris +Debt Debtless Debtor +Debtors +Debts Debug +Debugging Debunk +Debut +Debuts Decade +Decades Decaf Decal Decathlon Decay +Decaying Deceased Deceit +Deceive +Deceived Deceiver Deceiving December @@ -1601,46 +3711,100 @@ Deception Deceptive Decibel Decidable +Decide +Decided +Decidedly +Decides +Deciding +Deciduous Decimal Decimeter Decipher +Decision +Decisions +Decisive Deck +Decks +Declare Declared +Declares +Declaring Decline +Declined +Declines +Declining Decode +Decoding Decompose +Decor +Decorate Decorated Decorator Decoy Decrease +Decreased +Decreases Decree +Decreed +Decrees Dedicate +Dedicated Dedicator Deduce +Deduced Deduct +Deducted +Deduction +Deductive Deed +Deeds Deem +Deep Deepen +Deepened +Deepening +Deeper +Deepest Deeply Deepness Deface Defacing Defame Default +Defaults Defeat +Defeating +Defeats +Defect +Defected Defection Defective +Defects +Defend Defendant +Defended Defender +Defenders +Defending +Defends Defense +Defenses Defensive +Defer +Deference Deferral Deferred Defiance Defiant +Deficient +Deficit +Deficits +Defied Defile Defiling Define +Defines +Defining Definite Deflate Deflation @@ -1652,156 +3816,334 @@ Deforest Defraud Defrost Deftly +Defunct Defuse Defy +Degrade Degraded Degrading Degrease Degree +Degrees Dehydrate +Deities Deity Dejected Delay +Delayed +Delaying +Delays Delegate +Delegated +Delegates Delegator Delete +Deleted +Deleting Deletion Delicacy Delicate Delicious +Delight Delighted +Delights +Delineate Delirious Delirium +Deliver +Delivered Deliverer +Delivers Delivery Delouse Delta Deluge Delusion +Delusions Deluxe +Demand +Demanded Demanding +Demands Demeaning Demeanor Demise +Demo Democracy Democrat +Demolish +Demon +Demonic +Demons +Demos Demote +Demoted Demotion Demystify Denatured Deniable Denial +Denied +Denies Denim Denote +Denoted +Denotes +Denoting +Denounce +Denounced Dense +Densely +Densities Density Dental Dentist +Dentistry +Dentists Denture Deny +Denying Deodorant Deodorize +Depart Departed +Departing +Departs Departure +Depend +Depended +Depending +Depends Depict +Depicted +Depicting +Depiction +Depicts Deplete +Depleted Depletion Deplored Deploy +Deployed +Deploying Deport +Deported Depose +Deposed +Deposit +Deposited +Deposits +Depot +Depots Depraved Depravity Deprecate Depress +Depressed Deprive +Deprived +Depriving Depth +Depths +Deputies Deputize Deputy Derail -Deranged Derby +Derelict +Derive Derived +Derives +Deriving +Derrick +Descend +Descended +Descends +Descent +Describe +Described +Describes Desecrate +Desert +Deserted +Desertion +Deserts Deserve +Deserved +Deserves Deserving Designate Designed Designer +Designers Designing +Designs +Desire +Desired +Desires +Desiring +Desirous +Desk Deskbound +Desks Desktop Deskwork Desolate Despair +Despatch +Desperate Despise +Despised Despite +Despotism +Dessert +Destined Destiny Destitute +Destroy +Destroyed +Destroyer +Destroys Destruct Detached Detail +Detailed +Detailing +Details +Detained +Detainees +Detect +Detected +Detecting Detection Detective Detector +Detectors +Detects Detention +Deter Detergent +Determine +Deterrent Detest Detonate Detonator Detoxify Detract +Detriment Deuce Devalue +Develop +Developer +Develops +Deviance Deviancy Deviant Deviate Deviation Deviator Device +Devices +Devil +Devils Devious +Devise +Devised +Devising +Devoid +Devote +Devoted Devotedly Devotee +Devotees Devotion +Devoured Devourer Devouring +Devout Devoutly +Dew Dexterity Dexterous -Diabetes -Diabetic +Dharma Diabolic +Diagnose +Diagnosed Diagnoses Diagnosis +Diagonal Diagram +Diagrams Dial +Dialect +Dialectic +Dialects +Dialog Diameter +Diameters +Diamond +Diamonds Diaper Diaphragm +Diaries Diary Dice +Dichotomy Dicing Dictate +Dictated +Dictates Dictation Dictator +Dictum +Didactic +Diesel +Diet +Dietary +Diets +Differ +Differed +Differing +Differs Difficult +Diffuse Diffused Diffuser Diffusion Diffusive Dig +Digest +Digested +Digestion +Digestive +Digging +Digit +Digital +Digitally +Digits +Dignified +Dignity +Dilated Dilation +Dilemma +Dilemmas Diligence Diligent Dill Dilute +Diluted +Dilution +Dim Dime +Dimension Diminish Dimly Dimmed Dimmer Dimness Dimple +Din +Dined Diner Dingbat Dinghy @@ -1810,92 +4152,184 @@ Dingo Dingy Dining Dinner +Dinners +Dinosaur +Dinosaurs Diocese +Diode +Diodes Dioxide +Dip Diploma +Diplomacy +Diplomat +Diplomats +Dipole Dipped Dipper Dipping +Dire Directed +Directing Direction Directive Directly +Director +Directors Directory +Directs Direness +Dirk +Dirt Dirtiness +Dirty +Disable Disabled +Disabling Disagree +Disagreed Disallow +Disappear Disarm Disarray Disaster +Disasters Disband +Disbanded Disbelief Disburse Discard +Discarded Discern +Discerned Discharge +Disciple +Disciples Disclose +Disco Discolor +Discord Discount +Discounts Discourse Discover +Discovers +Discovery +Discredit +Discreet +Discrete Discuss +Discussed +Discusses Disdain Disengage Disfigure Disgrace +Disguise +Disguised +Disgust +Disgusted Dish +Dishes +Dishonest Disinfect Disjoin Disk +Disks Dislike +Disliked Disliking Dislocate Dislodge Disloyal +Dismal Dismantle Dismay Dismiss +Dismissal +Dismissed Dismount Disobey Disorder +Disorders Disown Disparate Disparity Dispatch +Dispel Dispense +Dispensed Dispersal +Disperse Dispersed Disperser Displace +Displaced Display +Displayed +Displays Displease Disposal Dispose +Disposed Disprove Dispute +Disputes Disregard +Disrepair Disrupt +Disrupted +Diss +Dissent +Dissident +Dissolve +Dissolved +Dissolves Dissuade Distance +Distanced +Distances Distant Distaste Distill +Distilled Distinct Distort +Distorted Distract Distress District +Districts Distrust +Disturb +Disused Ditch +Ditches Ditto Ditzy +Diuretics +Diurnal +Diva +Dive +Diver +Diverged +Divergent +Divers +Diverse +Diversion +Diversity +Divert +Diverted +Dives Dividable +Divide Divided Dividend +Dividends Dividers +Divides Dividing +Divine Divinely Diving Divinity @@ -1903,35 +4337,73 @@ Divisible Divisibly Division Divisive +Divorce +Divorced Divorcee Dizziness Dizzy Doable Docile Dock +Docked +Docking +Docks +Dockyard +Doctor +Doctorate +Doctors +Doctrinal Doctrine +Doctrines Document +Documents Dodge +Dodgers Dodgy +Doe +Dogma +Dogmatic Doily Doing Dole +Doll Dollar +Dollars Dollhouse Dollop +Dolls Dolly Dolphin +Dolphins Domain +Domains +Dome +Domed Domelike +Domes Domestic +Dominance +Dominant +Dominate +Dominated +Dominates Dominion +Dominions +Domino Dominoes +Donate Donated +Donating Donation +Donations Donator +Donkey Donor +Donors Donut Doodle +Doom +Doomed Doorbell Doorframe Doorknob @@ -1944,100 +4416,209 @@ Doorstop Doorway Doozy Dork +Dormant Dormitory Dorsal Dosage Dose +Doses +Dosing +Dot +Doth +Dots Dotted +Double +Doubled +Doubles Doubling -Douche +Doubly +Doubt +Doubted +Doubtful +Doubting +Doubtless +Doubts +Dough Dove +Dowager Down +Downed +Downfall +Downhill +Downing +Download +Downloads +Downright +Downtown +Downturn +Downward +Downwards Dowry Doze +Dozen +Dozens Drab +Draft +Drafted +Drafting +Drafts +Drag +Dragged Dragging +Dragon Dragonfly Dragonish +Dragons +Dragoons Dragster +Drain Drainable Drainage Drained Drainer +Draining Drainpipe +Drains +Drama +Dramas Dramatic +Dramatist Dramatize Drank +Draped Drapery Drastic Draw +Drawback +Drawbacks +Drawer +Drawers +Drawings +Draws +Dread Dreaded Dreadful Dreadlock +Dream Dreamboat +Dreamed +Dreamer Dreamily +Dreaming Dreamland Dreamless Dreamlike +Dreams Dreamt Dreamy Drearily Dreary Drench Dress +Dresser Drew Dribble Dried Drier Drift +Drifted +Drifting +Drill +Drilled Driller Drilling +Drills +Drink Drinkable +Drinkers Drinking +Drinks +Drip Dripping Drippy Drivable +Drive Driven Driver +Drivers +Drives Driveway Driving Drizzle Drizzly Drone +Drones Drool Droop Drop-down Dropbox Dropkick Droplet +Droplets Dropout +Dropped Dropper +Dropping +Drops +Drought Drove Drown Drowsily Drudge Drum +Drummer +Drummers +Drumming +Drums Dry +Dryer +Drying +Dual +Dualism +Duality +Dub Dubbed +Dubbing +Dubious Dubiously Duchess +Duck Duckbill +Ducked Ducking Duckling +Ducks Ducktail Ducky Duct Dude +Duel +Dues +Duet +Duets +Duff Duffel +Dug Dugout Duh Duke +Dukes +Dull Duller Dullness Duly +Dummies +Dump +Dumped Dumping Dumpling Dumpster +Dun +Dune +Dunes +Dung +Dungeon +Dungeons Duo Dupe Duplex @@ -2050,35 +4631,58 @@ Duress During Dusk Dust +Dusty +Duties Dutiful Duty Duvet Dwarf Dweeb +Dwell Dwelled Dweller +Dwellers Dwelling +Dwellings +Dwells Dwindle Dwindling +Dye +Dyed +Dyer +Dyes Dynamic +Dynamical +Dynamics +Dynamism Dynamite +Dynamo +Dynastic +Dynasties Dynasty -Dyslexia -Dyslexic Each +Eagerly +Eagerness Eagle +Eagles Earache Eardrum Earflap Earful +Earlier +Earliest Earlobe Early Earmark Earmuff +Earnest +Earnestly +Earnings Earphone Earpiece Earplugs Earring +Earrings Earshot Earthen Earthlike @@ -2089,6 +4693,7 @@ Earthy Earwig Easeful Easel +Easier Easiest Easily Easiness @@ -2097,72 +4702,134 @@ Eastbound Eastcoast Easter Eastward +Eastwards Eatable Eaten Eatery Eating Eats Ebay +Ebb Ebony Ebook Ecard Eccentric +Echelon Echo +Echoed +Echoes +Echoing Eclair +Eclectic Eclipse Ecologist Ecology Economic +Economics +Economies Economist Economy Ecosphere Ecosystem +Ecstatic +Eddy Edge +Edged Edginess Edging Edgy +Edible +Edict +Edifice +Edit +Edited +Editing Edition +Editions Editor +Editorial +Editors +Educate Educated +Educating Education Educator +Educators Eel +Eerie +Effect +Effected +Effecting Effective Effects +Efficacy Efficient Effort +Efforts +Effusion +Egg Eggbeater Egging Eggnog Eggplant +Eggs Eggshell +Ego Egomaniac Egotism Egotistic +Eighteen +Eighth +Eighties +Eighty Either Eject Elaborate +Elapsed Elastic Elated Elbow +Elbows +Elder Eldercare Elderly +Elders Eldest Electable Election Elective +Electoral +Electors +Electric +Electrode +Electron +Electrons +Elegance +Elegant +Element +Elemental +Elements Elephant +Elephants Elevate +Elevated Elevating Elevation Elevator +Elevators Eleven +Eleventh Elf +Elicit +Elicited +Eliciting Eligible Eligibly Eliminate Elite +Elites Elitism +Elitist Elixir Elk Ellipse @@ -2172,228 +4839,461 @@ Elongated Elope Eloquence Eloquent +Else Elsewhere +Elucidate Elude Elusive Elves Email +Emails +Emanating Embargo Embark +Embarked +Embarking +Embassies Embassy Embattled +Embedded +Embedding Embellish Ember Embezzle Emblaze Emblem +Embodied +Embodies Embody +Embodying Embolism Emboss +Embrace +Embraced +Embraces +Embracing Embroider +Embroiled +Embryo +Embryonic +Embryos Emcee Emerald +Emerge +Emerged +Emergence Emergency +Emergent +Emerges +Emerging +Emeritus +Emery +Emigrants +Emigrate +Emigrated +Eminence +Eminent +Eminently +Emir +Emirates Emission +Emissions Emit +Emitted +Emitting Emote Emoticon Emotion +Emotional +Emotions Empathic Empathy Emperor +Emperors Emphases Emphasis Emphasize Emphatic +Empire +Empires Empirical +Employ Employed Employee +Employees Employer +Employers +Employing +Employs Emporium Empower +Empowered +Empress +Emptied Emptier +Empties Emptiness Empty +Emptying Emu +Emulate +Emulation +Emulsion Enable +Enabled +Enables +Enabling +Enact +Enacted +Enacting Enactment Enamel Enchanted Enchilada Encircle +Encircled +Enclave Enclose +Enclosed +Enclosing Enclosure Encode +Encoded +Encodes +Encoding +Encompass Encore Encounter Encourage Encroach Encrust Encrypt +Encrypted Endanger Endeared Endearing +Endeavor +Endeavors Ended +Endemic Ending +Endings Endless +Endlessly Endnote Endocrine Endorphin Endorse +Endorsed Endowment Endpoint Endurable Endurance +Endure +Endured Enduring +Enemies +Enemy Energetic +Energies Energize Energy +Enforce Enforced Enforcer +Enforcing +Engage Engaged +Engages Engaging Engine -Engorge +Engineer +Engineers +Engines Engraved Engraver Engraving Engross Engulf +Engulfed Enhance +Enhanced +Enhances +Enhancing +Enigma Enigmatic +Enjoined +Enjoy Enjoyable Enjoyably +Enjoyed Enjoyer Enjoying Enjoyment +Enjoys +Enlarge Enlarged Enlarging Enlighten +Enlist Enlisted +Enmity +Enormous +Enough Enquirer +Enquiries +Enquiry Enrage +Enraged Enrich +Enriched Enroll -Enslave +Enrolled +Enrolling +Ensemble +Ensembles +Enshrined +Ensign Ensnare +Ensued +Ensues +Ensuing Ensure +Ensured +Ensures +Ensuring Entail +Entailed +Entails Entangled Entering Entertain Enticing Entire +Entirely +Entirety Entitle +Entitled Entity Entomb Entourage +Entrance +Entrances +Entrants Entrap Entree Entrench +Entries +Entropy Entrust +Entrusted Entryway Entwine Enunciate Envelope +Enveloped +Envelopes Enviable Enviably Envious +Envisaged Envision Envoy +Envoys Envy Enzyme +Enzymes +Ephemeral Epic Epidemic +Epidemics Epidermal Epidermis Epidural -Epilepsy -Epileptic Epilogue Epiphany Episode +Episodes +Episodic +Epistle +Epistles +Epithet +Epoch +Epoxy +Epsilon Equal +Equally +Equals Equate +Equated Equation +Equations Equator Equinox +Equip Equipment +Equipped +Equitable Equity Equivocal Eradicate Erasable +Erase Erased Eraser Erasure Ergonomic +Eroded +Erosion +Err Errand Errant Erratic +Erroneous Error +Errors +Erstwhile Erupt +Erupted +Eruption +Eruptions Escalate +Escalated Escalator Escapable Escapade +Escape +Escaped +Escapes +Escaping Escapist Escargot -Eskimo Esophagus +Esoteric Espionage +Espoused Espresso Esquire Essay +Essayist +Essays Essence Essential Establish Estate +Estates +Esteem Esteemed Estimate +Estimates Estimator Estranged Estrogen +Estuary Etching Eternal +Eternally Eternity Ethanol Ether +Ethic Ethically Ethics +Ethos +Etiquette +Etymology Euphemism +Euphoria +Eureka +Euro +Euros Evacuate +Evacuated Evacuee Evade Evaluate +Evaluated +Evaluates Evaluator Evaporate Evasion Evasive Even +Evening +Evenings +Evenly +Event +Events +Eventual Everglade Evergreen +Every Everybody Everyday Everyone Evict +Evicted +Eviction Evidence +Evidenced +Evidences Evident +Evidently Evil Evoke +Evokes Evolution Evolve +Evolved Exact +Exacting +Exactly Exalted +Exam +Examine +Examined +Examiner +Examiners +Examines +Examining Example +Examples +Exams Excavate +Excavated Excavator +Exceed +Exceeded Exceeding +Exceeds +Excel +Excelled +Excellent +Except +Excepting Exception +Excerpt +Excerpts Excess +Excesses +Excessive Exchange +Exchanged +Exchanges +Exchequer +Excise +Excised +Excision Excitable +Excite +Excited +Excitedly Exciting Exclaim +Exclaimed Exclude +Excluded +Excludes Excluding Exclusion Exclusive @@ -2403,271 +5303,679 @@ Excursion Excusable Excusably Excuse +Excused +Excuses +Executive +Executor +Exegesis Exemplary Exemplify +Exempt +Exempted Exemption +Exercise +Exercised Exerciser +Exercises Exert +Exerted +Exertion +Exerts Exes Exfoliate Exhale Exhaust +Exhausted +Exhibit +Exhibited +Exhibits Exhume Exile +Exiled +Exiles +Existed +Existent Existing +Exists Exit +Exited +Exiting +Exits Exodus Exonerate Exorcism Exorcist +Exotic Expand +Expanded +Expanding +Expands Expanse Expansion Expansive +Expect Expectant +Expecting +Expects +Expedient Expedited Expediter Expel +Expelled Expend +Expended +Expense Expenses Expensive Expert +Expertise +Experts Expire +Expired Expiring Explain +Explains Expletive Explicit Explode +Exploded +Exploding Exploit +Exploited +Exploits Explore +Explored +Explorer +Explorers +Explores Exploring +Explosion +Explosive +Expo Exponent +Exponents +Export +Exported Exporter +Exporters +Exporting +Exports +Expos Exposable Expose +Exposed +Exposes +Exposing Exposure +Exposures +Expounded Express +Expressed +Expresses +Expressly Expulsion Exquisite +Extant +Extend Extended Extending +Extends +Extension +Extensive Extent Extenuate Exterior External Extinct Extortion +Extra +Extract +Extracted +Extracts Extradite Extras +Extreme +Extremely +Extremes +Extremity +Extrinsic Extrovert Extrude Extruding +Extrusion Exuberant +Eye +Eyebrow +Eyebrows +Eyelid +Eyelids Fable +Fables Fabric +Fabrics Fabulous +Facade +Facades Facebook Facecloth +Faced Facedown Faceless Facelift Faceplate +Facet Faceted +Facets Facial Facility Facing Facsimile +Fact Faction +Factions Factoid Factor +Factories +Factors +Facts Factsheet Factual +Faculties Faculty Fade +Faded +Fades Fading +Fail +Failed Failing +Fails +Failure +Failures +Faint +Faintly +Fairies +Fairness +Fairy +Faith +Faithful +Faiths +Fake Falcon +Falcons Fall +Fallacy +Fallen +Falling +Fallout +Fallow False +Falsehood +Falsely Falsify Fame +Famed +Familial Familiar +Families Family Famine Famished +Famously +Fan Fanatic Fancied +Fanciful Fanciness Fancy Fanfare Fang Fanning +Fans +Fantasies Fantasize Fantastic Fantasy -Fascism +Farce +Fared +Fares +Farewell +Farm +Farmed +Farmer +Farmers +Farmhouse +Farming +Farmland +Farms +Farther +Farthest +Fashion +Fashioned +Fashions Fastball +Fastened Faster +Fastest Fasting Fastness +Fated +Fateful +Fathered +Fatigue +Fats Faucet +Faulty +Fauna +Favor Favorable Favorably Favored Favoring Favorite +Favorites +Favors Fax +Fear +Feared +Fearful +Fearing +Fearless +Fears +Feasible Feast +Feasts +Feather +Feathers +Feature +Featured +Features +Featuring +Fed Federal +Federally +Federated Fedora +Fee Feeble Feed +Feedback +Feeder +Feeding +Feeds Feel +Feeling +Feelings +Feels +Fees +Feet Feisty +Felicity Feline +Fell +Fellow +Fellows +Felony +Felt Felt-tip +Female +Females Feminine Feminism Feminist Feminize +Femoral Femur Fence +Fencer +Fences Fencing Fender +Feral Ferment +Fern Fernlike +Ferns Ferocious Ferocity Ferret +Ferries Ferris +Ferrous Ferry +Fertile +Fervent Fervor Fester Festival +Festivals Festive Festivity Fetal Fetch +Fetched +Feud +Feudal +Feudalism Fever +Feverish +Fewer +Fewest +Fiat Fiber +Fibers +Fibrous Fiction +Fictional +Fictions Fiddle Fiddling Fidelity Fidgeting Fidgety +Fiduciary +Fief +Fielded +Fielding +Fieldwork +Fierce +Fiercely +Fiery +Fiesta +Fife Fifteen +Fifteenth Fifth +Fifths +Fifties Fiftieth Fifty +Fig +Fight +Fighter +Fighting +Fights Figment +Figs Figure +Figures Figurine +Figurines +Filament +Filaments +Filial Filing Filled Filler Filling +Fills Film +Filmed +Filming +Filmmaker +Films Filter +Filtered +Filtering +Filters Filth +Filthy Filtrate +Fin Finale Finalist +Finalists +Finality Finalize +Finalized Finally Finance +Financed +Finances Financial +Financier +Financing Finch +Find +Finder +Finding +Findings +Finds +Finely Fineness Finer +Finest +Finger +Fingers Finicky +Finish Finished Finisher +Finishers +Finishes Finishing Finite Finless Finlike +Fins +Fir +Fired +Fireplace +Fires +Firewall +Firewood +Fireworks +Firing +Firmly +Firmness +Firsthand +Firstly +Firth +Fiscal Fiscally +Fished +Fisher +Fisheries +Fisherman +Fishermen +Fishery +Fishes +Fishing +Fission +Fissure +Fist +Fists Fit +Fitness +Fitted +Fitting +Fittings Five +Fix +Fixation +Fixed +Fixes +Fixing +Fixture +Fixtures +Fjord Flaccid +Flag Flagman Flagpole +Flags Flagship Flagstick Flagstone Flail +Flair +Flakes Flakily Flaky Flame +Flamenco +Flames +Flaming Flammable +Flank Flanked Flanking +Flanks Flannels Flap +Flaps +Flare +Flared Flaring +Flash Flashback Flashbulb Flashcard +Flashed +Flashes Flashily Flashing Flashy Flask +Flat Flatbed Flatfoot Flatly Flatness +Flats Flatten +Flattened +Flatter Flattered Flatterer Flattery Flattop Flatware Flatworm +Flavor Flavored Flavorful Flavoring +Flavors +Flaw +Flawed +Flaws +Flax Flaxseed +Flea Fled +Fledged +Fledgling +Flee +Fleeing +Flees +Fleet +Fleeting +Fleets +Flesh Fleshed Fleshy +Flew Flick +Flicker Flier Flight +Flights Flinch Fling Flint Flip +Flipped Flirt Float +Floated +Floating +Floats Flock +Flocks Flogging +Flood +Flooded +Flooding +Floods +Floor +Floors Flop +Floppy +Flora Floral Florist Floss +Flotilla Flounder +Flour +Flourish +Flowed +Flowering +Flowers +Flown +Flows +Flu +Fluency +Fluid +Fluidity +Fluids +Flung +Fluoride +Flush +Flushed +Flushing +Flute +Flutter +Fluxes Flyable Flyaway Flyer +Flyers Flying Flyover Flypaper +Flyweight Foam +Focal +Focus +Focused +Focuses +Focusing +Fodder Foe +Foes Fog Foil +Folder +Folders +Foliage Folic Folk +Folklore +Folks Follicle +Follicles Follow -Fondling +Followed +Follower +Followers +Following +Follows +Folly +Fond Fondly Fondness Fondue Font +Fonts +Foo Food +Foods Fool +Fooled +Foolish +Fools Footage Football Footbath @@ -2675,87 +5983,211 @@ Footboard Footer Footgear Foothill +Foothills Foothold Footing Footless Footman Footnote +Footnotes Footpad Footpath Footprint Footrest -Footsie Footsore +Footsteps Footwear Footwork +Forage +Foraging +Foray +Forbade +Forbid +Forbidden +Forbids +Forceful +Forceps +Forcibly +Forearm +Forecast +Forecasts +Forefront +Foregoing +Forehead +Foreign +Foreigner +Foreman +Foremost +Forensic +Foresee +Foresight +Forested +Forestry +Forests +Forever +Foreword +Forfeited +Forge +Forged +Forgery +Forget +Forgets +Forging +Forgive +Forgiven +Forgiving +Forgot +Forgotten +Forks +Formalism +Formality +Format +Formats +Formatted +Formerly +Formula +Formulas +Formulate +Forte +Forthwith +Forties +Fortified +Fortnight +Fortress +Fortune +Fortunes +Forty +Forum +Forums +Forwarded +Forwards Fossil +Fossils Foster +Fostered +Fostering +Fought +Foul Founder +Founders Founding +Foundry Fountain +Fountains +Four +Fourteen +Fourth +Fowl Fox +Foxes Foyer +Fractal Fraction +Fractions Fracture +Fractured +Fractures Fragile Fragility Fragment +Fragments Fragrance Fragrant Frail Frame +Framed +Frames +Framework Framing +Franchise +Frank +Frankly +Franks Frantic Fraternal +Fraud +Fraught +Fray Frayed Fraying Frays +Freak Freckled Freckles -Freebase +Free Freebee Freebie +Freed +Freedman Freedom +Freedoms Freefall Freehand Freeing +Freelance Freeload Freely +Freeman Freemason Freeness +Freer Freestyle Freeware Freeway Freewill Freezable +Freeze +Freezer Freezing Freight +Freighter French Frenzied Frenzy Frequency Frequent +Fresco +Frescoes Fresh +Freshly +Freshman +Freshmen +Freshness Fretful Fretted +Friar +Friars Friction Friday Fridge Fried Friend +Fries +Frieze +Frigate +Frigates +Fright Frighten Frightful Frigidity Frigidly Frill Fringe +Fringes Frisbee Frisk Fritter Frivolous +Frog +Frogs Frolic From Front +Frontage +Frontal +Frontier +Frontiers +Frost Frostbite Frosted Frostily @@ -2764,72 +6196,202 @@ Frostlike Frosty Froth Frown +Frowned +Frowning +Froze Frozen Fructose Frugality Frugally Fruit +Fruitful +Fruition +Fruitless +Fruits Frustrate +Fry Frying +Fuel +Fueled +Fuels +Fugitive +Fulfill +Fulfilled +Full +Fuller +Fullest +Fullness +Fumble +Fumbles +Fumes +Fun +Function +Functions +Funded +Funding +Funds +Fungal +Fungi +Fungus +Funk +Funky +Funnel +Funny +Furious +Furiously +Furlongs +Furnace +Furnaces +Furnish +Furnished +Furniture +Furs +Further +Fury +Fuselage +Fuss +Futile +Futility +Future +Futures +Fuzzy Gab +Gable +Gables Gaffe Gag Gainfully Gaining Gains +Gait Gala +Galactic +Galaxies +Galaxy +Gale +Gall +Gallant Gallantly +Gallantry Galleria +Galleries Gallery Galley Gallon -Gallows +Gallons +Gallop Gallstone Galore Galvanize +Gamble Gambling Game +Gamer +Games Gaming Gamma Gander +Gang Gangly Gangrene +Gangs +Gangster Gangway Gap +Gaping +Gaps Garage +Garb Garbage Garden +Gardener +Gardeners +Gardening +Gardens Gargle Garland Garlic Garment +Garments +Garner +Garnered +Garnering Garnet Garnish +Garrison +Garrisons Garter Gas +Gaseous +Gasoline +Gasp +Gasped +Gasping +Gastric +Gate +Gates +Gateway +Gather +Gathered Gatherer Gathering +Gathers Gating +Gauge Gauging +Gaunt Gauntlet Gauze Gave Gawk +Gaze +Gazed +Gazette +Gazetted Gazing Gear +Gearbox +Geared +Gears Gecko Geek +Geese Geiger +Gelatin Gem +Gems Gender +Gene +Genealogy +Genera +General +Generally +Generals +Generated +Generates +Generator Generic Generous +Genes +Genesis +Genetic Genetics +Genie +Genius +Genome +Genomes Genre +Genres Gentile +Gentle Gentleman +Gentlemen Gently +Gentry Gents +Genuine +Genuinely +Genus Geography Geologic Geologist @@ -2839,67 +6401,110 @@ Geometry Geranium Gerbil Geriatric +Germ Germicide Germinate Germless Germproof +Germs Gestate Gestation Gesture +Gestured +Gestures Getaway Getting Getup +Ghastly +Ghost +Ghostly +Ghosts Giant +Giants Gibberish +Gibbon +Gibbons Giblet Giddily Giddiness Giddy Gift +Gifted +Gifts +Gig Gigabyte Gigahertz Gigantic Giggle +Giggled Giggling Giggly -Gigolo +Gigs +Gilded +Gill Gilled Gills Gimmick +Ginger Girdle +Girl +Girls +Give Giveaway Given Giver +Gives Giving Gizmo Gizzard Glacial Glacier +Glaciers +Glad Glade Gladiator Gladly Glamorous Glamour Glance +Glanced +Glances Glancing +Gland +Glands Glandular Glare +Glared Glaring Glass +Glasses Glaucoma +Glaze +Glazed Glazing +Gleam Gleaming +Gleaned +Glee Gleeful +Glen +Glide Glider +Gliders Gliding Glimmer Glimpse +Glimpses Glisten Glitch Glitter Glitzy Gloater Gloating +Global +Globally +Globe +Gloom Gloomily Gloomy Glorified @@ -2908,80 +6513,142 @@ Glorify Glorious Glory Gloss +Glossary +Glossy Glove +Gloves +Glow +Glowed Glowing Glowworm Glucose Glue +Glued Gluten Glutinous Glutton Gnarly Gnat Goal +Goals +Goat +Goats Goatskin +God +Goddess +Goddesses +Godfather +Godly +Gods Goes Goggles Going +Gold +Golden Goldfish Goldmine Goldsmith Golf +Golfer Goliath -Gonad Gondola Gone Gong Good +Goodbye +Goodness +Goods +Goodwill Gooey Goofball Goofiness Goofy Google Goon +Goose Gopher -Gore +Gorge Gorged Gorgeous -Gory +Gorilla Gosling +Gospel +Gospels Gossip Gothic Gotten Gout +Govern +Governed +Governing +Governor +Governors +Governs Gown +Gowns Grab +Grabbed +Grabbing +Grabs +Grace Graceful Graceless +Graces Gracious Gradation Graded Grader +Graders Gradient +Gradients Grading +Gradual Gradually Graduate +Graduated Graffiti +Graft Grafted Grafting +Grafts +Grail Grain +Grains +Grammar +Grammars +Grand Granddad +Grandeur +Grandiose Grandkid Grandly Grandma Grandpa Grandson +Grange Granite Granny Granola Grant +Granted +Granting Granular +Granules Grape +Grapes Graph +Graphical +Graphite Grapple Grappling Grasp +Grasped +Grasping Grass +Grasses +Grassland +Grassy +Grateful Gratified Gratify Grating @@ -2995,47 +6662,97 @@ Gravitate Gravity Gravy Gray +Grays Grazing +Grease Greasily +Greasy +Great +Greater +Greatest +Greatly +Greatness Greedily Greedless Greedy Green +Greenish +Greens +Greet +Greeted Greeter Greeting +Greetings +Grenadier Grew Greyhound Grid +Grids Grief Grievance +Grieve Grieving Grievous +Griffin Grill +Grille +Grilled +Grim Grimace Grimacing Grime +Grimes Griminess +Grimly Grimy +Grin Grinch +Grind +Grinding +Grinned Grinning Grip +Gripped +Gripping +Grips Gristle Grit +Grizzlies +Groan +Groaned +Groceries +Grocery Groggily Groggy -Groin Groom +Grooming Groove +Grooves Grooving Groovy -Grope +Gross +Grossed +Grossing +Grossly +Grotesque Ground +Grounded +Grounding Grouped +Grouping +Groupings Grout Grove +Groves +Grow Grower +Growers Growing Growl +Growled +Grown +Grows +Growth Grub Grudge Grudging @@ -3047,54 +6764,110 @@ Grumbly Grumpily Grunge Grunt +Grunted Guacamole +Guarantee +Guarded +Guardian +Guardians +Guarding +Guerrilla +Guess +Guessed +Guessing +Guest +Guests Guidable Guidance Guide +Guided +Guideline +Guides Guiding +Guild +Guilds Guileless +Guilt +Guilty +Guinea Guise +Guitar +Guitarist +Guitars Gulf +Gull Gullible Gully Gulp +Gum Gumball Gumdrop Gumminess Gumming Gummy +Gums +Gunboat +Gunboats +Gunner +Gunners +Gunnery +Gunpowder Gurgle Gurgling Guru Gush Gusto Gusty +Gut Gutless Guts Gutter Guy +Guys Guzzler +Gym +Gymnasium +Gymnast +Gypsum Gyration Habitable Habitant Habitat +Habitats Habitual Hacked Hacker Hacking +Hackney Hacksaw Had Haggler Haiku +Hail +Hailed +Hails +Hairy Half +Halftime +Halfway +Hallmark +Halls +Hallway +Halo Halogen Halt +Halted +Halting Halved Halves Hamburger Hamlet +Hamlets +Hammer +Hammered Hammock Hamper +Hampered Hamster Hamstring Handbag @@ -3112,108 +6885,161 @@ Handgrip Handgun Handheld Handiness +Handing Handiwork +Handle Handlebar Handled Handler +Handles Handling Handmade Handoff Handpick Handprint Handrail +Hands Handsaw Handset Handsfree Handshake +Handsome Handstand Handwash Handwork Handwoven Handwrite +Handy Handyman +Hang +Hangar Hangnail Hangout Hangover +Hangs Hangup Hankering Hankie Hanky Haphazard +Happen +Happened Happening +Happens Happier Happiest Happily Happiness Happy Harbor +Hard Hardcopy -Hardcore Hardcover Harddisk +Harden Hardened Hardener Hardening +Harder +Hardest Hardhat Hardhead Hardiness Hardly Hardness Hardship +Hardships Hardware Hardwired Hardwood Hardy Harmful Harmless +Harmonic Harmonica Harmonics +Harmonies Harmonize Harmony Harness Harpist +Harrow +Harry Harsh +Harshly Harvest +Harvested +Harvests Hash Hassle Haste +Hasten +Hastened Hastily Hastiness Hasty Hatbox +Hatch Hatchback +Hatched Hatchery Hatchet Hatching Hatchling Hate +Hated +Hates Hatless Hatred +Hats +Hauled +Hauling Haunt +Haunted +Haunting +Haunts Haven +Havoc +Hawk +Hawker +Hawks +Hawthorn +Hay +Hays Hazard +Hazardous +Hazards +Haze +Hazel Hazelnut Hazily Haziness Hazing Hazy Headache +Headaches Headband Headboard Headcount Headdress Headed Header +Headers Headfirst Headgear Heading +Headings Headlamp Headless +Headline +Headlined +Headlines Headlock Headphone Headpiece Headrest Headroom +Heads Headscarf Headset Headsman @@ -3221,98 +7047,349 @@ Headstand Headstone Headway Headwear +Heal +Healed +Healer +Healers +Healing +Health +Healthier Heap +Heaped +Heaps +Hearer +Hearings +Hears +Hearsay +Heartbeat +Hearth +Heartily +Heartland +Hearts +Hearty Heat +Heather +Heats Heave +Heaven +Heavenly +Heavens +Heavier +Heaviest Heavily Heaviness Heaving +Heavy +Hectare +Hectares +Hector Hedge +Hedgehog +Hedges Hedging +Heed Heftiness Hefty +Hegemony +Height +Heights +Heir +Heiress +Heirs Helium +Helix +Hello +Helm Helmet +Helmets +Help +Helped Helper +Helpers Helpful Helping Helpless Helpline +Helps Hemlock +Hemp Hemstitch Hence Henchman +Henchmen Henna +Hens Herald +Heralded +Heraldic +Herb Herbal Herbicide Herbs +Herd +Herder +Herds +Hereafter +Heredity Heritage Hermit +Hermitage +Hero +Heroes +Heroic Heroics +Heroine Heroism +Heron Herring Herself Hertz Hesitancy Hesitant Hesitate +Hesitated +Heuristic Hexagon +Hexagonal Hexagram +Heyday +Hiatus +Hickory +Hid +Hidden +Hide +Hideous +Hideout +Hides +Hiding +Hierarchy +Higher +Highest +Highland +Highlands +Highlight +Highly +Highness +Highway +Highways +Hike +Hiking +Hillside +Hilltop +Hinder +Hindered +Hindrance +Hindsight +Hinge +Hinges +Hint +Hinted +Hints +Hired +Hires +Hiring +Hiss +Hissed +Histamine +Histogram +Historian +Historic +Histories +History +Hit +Hitch +Hitherto +Hits +Hitter +Hitting +Hoard +Hoarse +Hoax +Hobbies +Hobby +Hockey +Hogan +Holdings +Holes +Holiday +Holidays +Holiness +Holistic +Hollow +Holy +Homage +Home +Homeland +Homemade +Homer +Homes +Homestead +Hometown +Homework +Honest +Honestly +Honesty +Honey +Honeycomb +Honeymoon +Honor +Honorable +Honorary +Honored +Honorific +Honoring +Honors +Hooked +Hooks +Hoop +Hope +Hoped +Hopeful +Hopefully +Hopeless +Hopes +Hoping +Horde +Horizon +Horizons +Hormonal +Hormone +Hormones +Horned +Hornet +Hornets +Horrible +Horrid +Horrified +Horror +Horrors +Horseback +Horsemen +Horses +Horseshoe +Hospital +Hospitals +Hosted +Hostel +Hostess +Hostile +Hostility +Hosting +Hotel +Hotels +Hottest +Hound +Hounds +Hour +Hourly +Hours +Housed +Household +Housewife +Housework +Housing +Hovered +Hovering +However +Howitzer +Howitzers +Howl +Howling +Hub Hubcap +Hubs Huddle +Huddled Huddling +Hue Huff Hug +Huge +Hugely +Hugged +Hugging +Huh Hula Hulk Hull +Hum Human +Humane +Humanism +Humanist +Humanity +Humankind +Humans Humble Humbling Humbly Humid +Humidity Humiliate Humility Humming Hummus Humongous +Humor Humorist Humorless Humorous Humpback Humped Humvee -Hunchback +Hundred +Hundreds Hundredth +Hung Hunger Hungrily Hungry Hunk +Hunted Hunter +Hunters Hunting Huntress +Hunts Huntsman Hurdle +Hurdles Hurled Hurler Hurling Hurray Hurricane Hurried +Hurriedly Hurry +Hurrying Hurt +Hurting +Hurts Husband +Husbandry +Husbands Hush +Hushed Husked +Huskies Huskiness +Hussars Hut +Huts Hybrid +Hybrids +Hydra Hydrant Hydrated Hydration +Hydraulic Hydrogen Hydroxide +Hygiene +Hymn +Hymns +Hyper Hyperlink Hypertext Hyphen @@ -3324,154 +7401,574 @@ Hypnotist Hypnotize Hypocrisy Hypocrite +Hysteria Ibuprofen Ice Iciness Icing Icky Icon +Icons Icy +Idea +Ideal Idealism Idealist Idealize +Idealized Ideally Idealness +Ideals +Ideas Identical Identify Identity Ideology Idiocy Idiom +Idle +Idleness Idly +Idol +Idols Igloo +Igneous +Ignited Ignition +Ignorance +Ignorant Ignore +Ignored +Ignores +Ignoring Iguana +Illegal +Illegally +Illicit Illicitly +Illnesses Illusion +Illusions Illusive +Illusory Image +Imagery +Images Imaginary +Imagine +Imagined Imagines Imaging -Imbecile +Imagining +Imam +Imbalance +Imbued Imitate +Imitated +Imitating Imitation +Immanent Immature +Immediacy +Immediate +Immense +Immensely Immerse +Immersed Immersion Imminent Immobile Immodest +Immoral Immorally Immortal Immovable Immovably Immunity Immunize +Immutable +Impact +Impacted +Impacts +Impair Impaired -Impale Impart +Imparted +Impartial +Impasse Impatient Impeach +Impedance +Impede +Impeded Impeding +Impelled Impending Imperfect Imperial +Impetus Impish Implant +Implanted +Implants Implement Implicate Implicit +Implied +Implies Implode Implosion Implosive Imply +Implying Impolite +Import Important +Imported Importer +Importing +Imports Impose +Imposes Imposing -Impotence -Impotency -Impotent Impound Imprecise +Impress +Impressed Imprint Imprison Impromptu Improper Improve +Improved +Improves Improving Improvise Imprudent Impulse +Impulses Impulsive Impure Impurity +Imputed +Inability +Inaction +Inactive +Inanimate +Inasmuch +Inaugural +Incapable +Incarnate +Incense +Incentive +Inception +Incessant +Inches +Incident +Incidents +Incipient +Incised +Incision +Incline +Inclined +Include +Included +Includes +Including +Inclusion +Inclusive +Income +Incomes +Incoming +Incorrect +Increase +Increased +Increases +Increment +Incubated +Incumbent +Incur +Incurred +Indebted +Indeed +Indemnity +Index +Indexed +Indexes +Indexing +Indicate +Indicated +Indicates +Indicator +Indices +Indicted +Indignant +Indigo +Indirect +Indoor +Indoors +Induce +Induced +Induces +Inducing +Inducted +Induction +Inductive +Indulge +Indulged +Industry +Inelastic +Inert +Inertia +Inertial +Infamous +Infancy +Infant +Infantile +Infantry +Infants +Infect +Infected +Infection +Infer +Inference +Inferior +Inferno +Inferred +Infested +Infinite +Infinity +Infirmary +Inflamed +Inflated +Inflation +Inflict +Inflicted +Inflow +Influence +Influenza +Influx +Info +Inform +Informal +Informant +Informed +Informing +Informs +Infrared +Infused +Infusion +Ingenious +Ingenuity +Ingested +Ingestion +Ingrained +Inhabit +Inhabits +Inhaled +Inherent +Inherit +Inherited +Inhibit +Inhibited +Inhibits +Inhuman +Initial +Initially +Initials +Initiate +Initiated +Initiates +Inject +Injected +Injecting +Injection +Injure +Injured +Injuries +Injuring +Injurious +Injury +Injustice +Inlet +Inmate +Inmates +Inn +Innate +Innermost +Innocence +Innocent +Inns +Inorganic +Inpatient +Input +Inputs +Inquest +Inquire +Inquired +Inquiries +Inquiring +Inquiry +Inscribed +Insect +Insects +Insecure +Insert +Inserted +Inserting +Insertion +Inserts +Inset +Inside +Insider +Insiders +Insidious +Insight +Insights +Insignia +Insist +Insisted +Insistent +Insisting +Insists +Insoluble +Insomnia +Inspect +Inspected +Inspector +Inspire +Inspired +Inspires +Inspiring +Install +Installed +Instance +Instances +Instant +Instantly +Instead +Instinct +Instincts +Institute +Instruct +Insulated +Insulin +Insult +Insulted +Insulting +Insults +Insurance +Insure +Insured +Insurer +Insurers +Intact +Intake +Integer +Integers +Integral +Integrate +Integrity +Intel +Intellect +Intend +Intending +Intends +Intense +Intensely +Intensify +Intensity +Intensive +Intent +Intention +Intently +Interact +Interacts +Intercept +Interest +Interests +Interface +Interfere +Interim +Interior +Interiors +Intern +Internal +Interned +Internet +Interplay +Interpret +Interred +Interrupt +Intersect +Interval +Intervals +Intervene +Interview +Intestine +Intricate +Intrigue +Intrigued +Intrinsic +Intro +Introduce +Intruder +Intrusion +Intrusive +Intuition +Intuitive +Invade +Invaded +Invaders +Invading +Invalid +Invariant +Invasion +Invasions +Invasive +Invent +Invented +Inventing +Invention +Inventive +Inventor +Inventors +Inventory +Inverse +Inversely +Inversion +Inverted +Invest +Invested +Investing +Investor +Investors +Invisible +Invite +Invited +Invites +Inviting +Invoice +Invoke +Invoked +Invokes +Invoking +Involve +Involved +Involves +Involving +Inward +Inwardly Iodine Iodize Ion +Ionized Ipad Iphone Ipod Irate +Iris Irk Iron +Ironic +Irons +Irony Irregular Irrigate +Irrigated Irritable Irritably Irritant Irritate -Islamic -Islamist +Irritated +Island +Islander +Islanders +Islands +Islet +Isolate Isolated +Isolates Isolating Isolation Isotope +Isotopes +Isotopic +Isotropic Issue Issuing +Isthmus Italicize Italics Item +Items +Iteration +Iterative +Itinerant Itinerary Itunes Ivory Ivy Jab +Jack Jackal Jacket +Jackets Jackknife Jackpot +Jade +Jagged +Jaguar +Jaguars +Jail Jailbird Jailbreak +Jailed Jailer Jailhouse Jalapeno Jam +Jammed +Jams Janitor January +Japan +Jar Jargon Jarring +Jars Jasmine +Jasper Jaundice Jaunt Java +Javelin +Jaw Jawed Jawless Jawline Jaws Jaybird +Jays Jaywalker Jazz +Jealous +Jealousy +Jeans Jeep Jeeringly Jellied Jelly +Jeopardy Jersey +Jerseys +Jest Jester Jet +Jets +Jewel +Jewelry +Jewels Jiffy Jigsaw Jimmy @@ -3481,19 +7978,35 @@ Jinx Jitters Jittery Job +Jobs +Jock Jockey Jockstrap Jogger Jogging John Joining +Joins +Joint +Jointly +Joints +Joke +Joker +Jokes Jokester +Joking Jokingly Jolliness Jolly Jolt +Josh Jot +Journal +Journals +Journey +Journeys Jovial +Joyful Joyfully Joylessly Joyous @@ -3501,15 +8014,24 @@ Joyride Joystick Jubilance Jubilant +Jubilee Judge +Judged +Judges +Judging Judgingly +Judgment +Judgments Judicial Judiciary +Judicious Judo +Jug Juggle Juggling Jugular Juice +Juices Juiciness Juicy Jujitsu @@ -3518,43 +8040,78 @@ July Jumble Jumbo Jump +Jumped +Jumper +Jumping +Jumps Junction Juncture June +Jungle Junior +Juniors Juniper -Junkie +Junk Junkman Junkyard +Junta +Juridical Jurist +Jurists Juror +Jurors Jury Justice +Justified Justifier +Justifies Justify Justly Justness Juvenile +Juveniles Kabob Kangaroo Karaoke Karate Karma Kebab +Keel +Keen Keenly Keenness Keep +Keeps Keg Kelp Kennel Kept Kerchief +Kernel Kerosene Kettle +Keyboard +Keyboards +Keynote +Keystone +Keyword +Keywords +Kibbutz Kick +Kicked +Kicker +Kicking +Kickoff +Kicks +Kid +Kidding +Kidney +Kidneys +Kids Kiln Kilobyte Kilogram +Kilograms Kilometer Kilowatt Kilt @@ -3564,57 +8121,114 @@ Kindling Kindly Kindness Kindred +Kinds Kinetic Kinfolk King +Kingdom +Kingdoms +Kingship Kinship Kinsman Kinswoman Kissable Kisser Kissing +Kit Kitchen +Kitchens Kite +Kits Kitten Kitty Kiwi Kleenex Knapsack Knee +Kneeling +Knees Knelt +Knew Knickers +Knife +Knight +Knighted +Knights +Knit +Knitting +Knives +Knock +Knocked +Knocking +Knockout +Knocks Knoll +Knot +Knots +Knowing +Knowingly +Knows +Knuckles Koala Kooky Kosher Krypton Kudos Kung +Label +Labeled +Labeling +Labels +Labor Labored Laborer +Laborers Laboring Laborious +Labors Labrador +Labyrinth +Lacked +Lacking +Lacks +Lacrosse +Lactate +Lactose +Lacy Ladder +Laden Ladies Ladle +Lady Ladybug Ladylike Lagged Lagging Lagoon +Laid Lair Lake +Lamb +Lambda +Lambs +Lament +Lamented +Lamps Lance +Lancers +Lancet Landed Landfall Landfill Landing +Landings Landlady Landless Landline Landlord +Landlords Landmark +Landmarks Landmass Landmine Landowner @@ -3622,6 +8236,7 @@ Landscape Landside Landslide Language +Languages Lankiness Lanky Lantern @@ -3632,107 +8247,289 @@ Lapping Laptop Lard Large +Largely +Larger +Largest Lark +Larva +Larvae +Larval +Larynx +Laser +Lasers Lash Lasso Last +Lastly +Lasts Latch Late +Lately +Latency +Latent +Later +Laterally +Latest +Latex Lather Latitude +Latitudes Latrine Latter +Lattice Latticed +Laugh +Laughed +Laughing +Laughs Launch +Launched +Launcher +Launchers +Launches +Launching Launder Laundry +Laureate Laurel +Lava Lavender Lavish +Lawmakers +Lawn +Lawns +Lawsuit +Lawsuits +Lawyer +Lawyers Laxative +Layered +Layman +Layout Lazily Laziness Lazy +Leach +Leaching +Leader +Leaders +Leads +Leaf +Leaflet +Leaflets +Leafs +Leafy +League +Leagues +Leakage +Leaked +Leaking +Leaks +Leans +Leap +Leaped +Leaping +Leaps +Leapt +Learn +Learned +Learner +Learners +Learning +Learns +Learnt +Least +Leather +Leave +Leaves +Leaving +Lecture +Lectured Lecturer +Lectures +Lecturing +Ledger Left +Leg Legacy Legal +Legality Legend +Legendary +Legends Legged Leggings Legible Legibly +Legion +Legions Legislate Lego Legroom +Legs Legume +Legumes Legwarmer Legwork +Leisure +Leisurely +Lemma Lemon Lend +Lenders Length +Lengthy Lens +Lenses Lent +Leopard +Leopards Leotard +Lessee +Lessen +Lessened Lesser +Lesson +Lessons +Lessor Letdown Lethargic Lethargy Letter +Lettering +Letters +Letting Lettuce Level +Leveled +Levels Leverage Levers +Levied Levitate Levitator +Levy +Lexical +Lexicon Liability Liable +Liaison +Liar +Libel +Liberated +Liberties Liberty Librarian +Libraries Library +Libretto +License +Licensed +Licensee +Licenses +Licensing +Lichen Licking Licorice Lid Life +Lifeboat +Lifeless +Lifelong +Lifespan +Lifestyle +Lifetime +Lifetimes +Lifted Lifter Lifting Liftoff +Lifts Ligament +Ligaments +Lighter +Lightness +Lightning +Liked Likely +Likened Likeness +Likes Likewise Liking Lilac +Lilies Lilly Lily Limb Limeade Limelight +Limerick Limes +Limestone Limit +Limiting +Limitless +Limits Limping Limpness Line +Lineage +Lineages +Linearly +Linen +Liner +Lineup +Linger +Lingered +Lingering Lingo +Lingual Linguini Linguist +Linguists Lining +Linkage +Linkages Linked +Links Linoleum Linseed Lint Lion Lip +Lipid +Lipids +Lipstick Liquefy Liqueur Liquid +Liquidity +Liquids +Liquor Lisp List +Listen +Listened +Listener +Listeners +Listens +Listing +Listings +Lists +Liter +Literal +Literally +Literary +Liters +Lithium Litigate Litigator Litmus Litter Little +Littoral +Liturgy Livable Lived Lively @@ -3741,17 +8538,97 @@ Livestock Lividly Living Lizard +Lizards +Loaf +Loan +Loaned +Loans +Lobbied +Lobby +Lobbying +Lobes +Lobster +Local +Locale +Locality +Localized +Locally +Locals +Locates +Locker +Loco +Locus +Locust +Lodge +Lodged +Lodges +Lodging +Lodgings +Lofty +Logged +Logging +Logic +Login +Logistic +Logistics +Logo +Logos +Lonely +Longer +Longest +Longevity +Longhorns +Longitude +Longtime +Lookout +Loomed +Loops +Loose +Loosely +Loosen +Loosened +Loosening +Loot +Looted +Looting +Lopes +Lordship +Losers +Losses +Lost +Lottery +Lotus +Louder +Loudly +Lounge +Lovely +Lovers +Loving +Lovingly +Lowered +Lowest +Lowland +Lowlands +Loyal +Loyalist +Loyalists +Loyalties +Loyalty Lubricant Lubricate Lucid +Luck Luckily Luckiness Luckless Lucrative Ludicrous +Luggage Lugged Lukewarm +Lull Lullaby +Lumbar Lumber Luminance Luminous @@ -3760,26 +8637,33 @@ Lumping Lumpish Lunacy Lunar +Lunch Lunchbox Luncheon Lunchroom Lunchtime Lung +Lungs Lurch Lure +Lured Luridness Lurk +Lurking Lushly Lushness Luster -Lustfully -Lustily -Lustiness Lustrous -Lusty +Luxuries Luxurious Luxury +Lyceum Lying +Lymph +Lymphatic +Lynx +Lyric +Lyrical Lyrically Lyricism Lyricist @@ -3789,55 +8673,117 @@ Macaroni Macaw Mace Machine +Machinery +Machines +Machining Machinist +Macintosh +Macro +Macros +Mad +Madam +Madame +Madden +Madness +Maestro Magazine +Magazines Magenta Maggot +Magic Magical Magician Magma +Magnate Magnesium +Magnet Magnetic Magnetism Magnetize +Magnets +Magnified Magnifier Magnify Magnitude Magnolia +Magnum +Mahatma Mahogany -Maimed +Maiden +Maids +Mailbox +Mailed +Mailing +Mainframe +Mainland +Mainline +Mainly +Mainstay +Maintain +Maintains +Maize Majestic Majesty +Major +Majored Majorette +Majoring Majority +Majors Makeover Maker +Makes Makeshift +Makeup Making +Malaise Malformed +Malice +Malicious +Malls Malt Mama +Mamma Mammal -Mammary +Mammalian +Mammals Mammogram +Mammoth +Manage +Managed Manager +Managers +Manages Managing Manatee Mandarin Mandate +Mandated +Mandates Mandatory +Mandible Mandolin +Maneuver +Maneuvers +Manga +Manganese Manger Mangle Mango +Mangrove Mangy Manhandle Manhole Manhood Manhunt +Mania +Manic Manicotti Manicure +Manifest Manifesto +Manifests +Manifold Manila Mankind Manlike @@ -3845,113 +8791,464 @@ Manliness Manly Manmade Manned +Manner +Manners +Manning Mannish Manor +Manors Manpower +Mansion +Mansions Mantis +Mantle Mantra Manual +Manually +Manuals +Manure Many Map +Maple +Mapped +Mapping +Maps Marathon Marauding +Marble Marbled Marbles Marbling March +Marched +Marches +Marching Mardi Margarine Margarita Margin +Marginal +Margins +Maria Marigold Marina Marine +Mariner +Mariners Marital Maritime +Markedly +Marker +Markers +Marketed +Marketers +Marketing +Markings +Markup Marlin +Marlins Marmalade Maroon +Maroons +Marquess +Marred +Marriage +Marriages Married +Marries Marrow Marry +Marrying +Marsh +Marshal +Marshals +Marshes Marshland Marshy Marsupial +Martial +Martian +Martini +Martins +Marvel Marvelous Marxism Mascot Masculine Mashed Mashing +Mask +Masked +Masking +Masks +Mason +Masonic +Masonry +Masons +Mass +Massage Massager Masses Massive +Mast +Mastered +Mastering +Masters +Mastery Mastiff +Masts +Mat Matador Matchbook Matchbox +Matched Matcher +Matches Matching Matchless Material +Materials Maternal Maternity Math -Mating Matriarch +Matrices Matrimony Matrix Matron +Mats Matted Matter +Mattered +Matters +Mattress +Matured Maturely Maturing Maturity Mauve Maverick +Mavericks +Max +Maxillary +Maxim +Maxima +Maximal Maximize +Maxims Maximum Maybe Mayday Mayflower -Moaner -Moaning +Mayhem +Mayo +Mayor +Mayoral +Mayors +Maze +Mead +Meadow +Meadows +Meager +Meals +Mean +Meaning +Meanings +Means +Meant +Meantime +Meanwhile +Measles +Measure +Measured +Measures +Measuring +Meat +Meats +Mechanic +Mechanics +Mechanism +Medal +Medalists +Medallion +Medals +Mediated +Mediating +Mediation +Mediator +Mediators +Medical +Medically +Medicinal +Medicine +Medicines +Medieval +Mediocre +Meditate +Medium +Medley +Meek +Meet +Meeting +Meetings +Meets +Melodic +Melodies +Melodrama +Melody +Melt +Melted +Melting +Melts +Membrane +Membranes +Memo +Memoir +Memoirs +Memorable +Memoranda +Memorial +Memorials +Memories +Memory +Menace +Menacing +Mentality +Mention +Mentions +Mentor +Mentored +Mentoring +Mentors +Menu +Menus +Mercenary +Merchant +Merchants +Merciful +Mercury +Mercy +Mere +Merely +Merger +Mergers +Meridian +Merit +Merits +Mermaid +Merry +Mesa +Mesh +Mess +Message +Messages +Messenger +Messy +Meta +Metabolic +Metal +Metallic +Metals +Metaphor +Metaphors +Meteor +Meteorite +Methane +Methanol +Method +Methods +Metric +Metrics +Metro +Mezzanine +Mica +Mice +Mickey +Microbes +Microfilm +Microwave +Midday +Middle +Midland +Midlands +Midnight +Midpoint +Midsummer +Midtown +Midway +Midwife +Midwives +Might +Migraine +Migrating +Migratory +Mild +Milder +Mildly +Mileage +Milestone +Milieu +Military +Militia +Militias +Milk +Milky +Millennia +Miller +Millet +Milling +Million +Millions +Mills +Mime +Mimic +Minced +Mindful +Miner +Mineral +Minerals +Miners +Mingled +Mini +Miniature +Minimal +Minimally +Minimize +Minimized +Minimizes +Minimum +Ministry +Minor +Minority +Minors +Mint +Minted +Minuscule +Minute +Minutes +Miracle +Miracles +Mirage +Mirror +Mirrored +Mirrors +Mischief +Miserable +Misery +Misguided +Misled +Mismatch +Misplaced +Miss +Missed +Misses +Missing +Mistake +Mistaken +Mistakes +Mister +Mistrust +Misty +Misuse +Mitigate +Mix +Mixed +Mixer +Mixes +Mixing +Mixture +Mixtures +Moat +Mob Mobile Mobility Mobilize Mobster Mocha +Mock +Mocked Mocker +Mockery +Mocking Mockup +Mod +Modal +Mode +Model +Models +Modem +Moderate +Moderator +Modernism +Modernist +Modernity +Modes +Modest +Modestly +Modesty Modified +Modifier +Modifiers +Modifies Modify +Modifying Modular +Modulated Modulator Module +Modules +Modulus +Moist Moisten Moistness Moisture Molar Molasses Mold +Molded +Molding +Molds +Mole Molecular Molecule +Molecules Molehill Mollusk +Mollusks +Molten Mom +Moment +Momentary +Momentous +Moments +Momentum +Mommy +Monarch +Monarchs +Monarchy Monastery +Monastic Monday Monetary Monetize +Money Moneybags Moneyless Moneywise Mongoose -Mongrel +Moniker Monitor +Monitored +Monitors +Monk +Monkey +Monkeys Monkhood +Monks +Mono Monogamy Monogram +Monograph Monologue Monopoly Monorail @@ -3960,11 +9257,18 @@ Monotype Monoxide Monsieur Monsoon +Monster +Monsters Monstrous +Month Monthly +Months Monument +Monuments Moocher +Mood Moodiness +Moods Moody Mooing Moonbeam @@ -3973,68 +9277,115 @@ Moonlight Moonlike Moonlit Moonrise +Moons Moonscape Moonshine Moonstone Moonwalk +Moors +Moose Mop Morale Morality Morally +Morals +Moray +Morbid Morbidity Morbidly -Morphine +Moreover +Mores +Morning +Mornings +Morocco Morphing Morse Mortality Mortally +Mortals +Mortar +Mortars +Mortgage +Mortgages Mortician Mortified Mortify Mortuary Mosaic +Mosaics +Mosque +Mosques +Mosquito +Moss Mossy Most +Mostly +Motel Mothball +Mothers Mothproof +Moths +Motif +Motifs Motion +Motioned Motivate +Motivated Motivator Motive Motocross Motor +Motorized +Motors +Motorway Motto +Mound +Mounds Mountable Mountain +Mountains Mounted Mounting +Mourn Mourner Mournful +Mourning Mouse Mousiness Moustache Mousy Mouth +Mouths Movable Move +Movement +Movements +Mover Movie +Movies Moving Mower Mowing Much Muck Mud +Muddy +Muffled Mug Mulberry Mulch Mule +Mules Mulled Mullets +Multi Multiple +Multiples Multiply Multitask Multitude Mumble +Mumbled Mumbling Mumbo Mummified @@ -4044,45 +9395,81 @@ Mumps Munchkin Mundane Municipal +Munitions Muppet Mural +Murals Murkiness Murky +Murmur +Murmured Murmuring +Muscle +Muscles Muscular Museum +Museums Mushily Mushiness Mushroom +Mushrooms Mushy Music +Musical +Musically +Musicals +Musician +Musicians +Musk Musket Muskiness Musky +Mustache Mustang +Mustangs Mustard Muster +Mustered Mustiness Musty Mutable +Mutant +Mutants Mutate Mutation +Mutations Mute -Mutilated -Mutilator Mutiny Mutt +Muttered +Muttering Mutual +Mutually Muzzle +Myriad Myself Myspace +Mysteries +Mystery +Mystic +Mystical +Mysticism +Mystics Mystified Mystify Myth +Mythic +Mythical +Mythology +Myths Nacho Nag Nail +Naive Name +Namely +Names +Namesake Naming Nanny Nanometer @@ -4091,86 +9478,214 @@ Napkin Napped Napping Nappy +Narrated +Narrates +Narration +Narrative +Narrator Narrow +Narrowed +Narrower +Narrowly +Narrows +Nasal Nastily Nastiness National Native Nativity Natural +Naturally Nature -Naturist +Nausea Nautical +Naval +Navigable Navigate Navigator Navy Nearby +Nearer Nearest +Nearing Nearly Nearness Neatly Neatness Nebula Nebulizer +Necessity +Neck +Necklace +Necks Nectar +Need +Needed +Needing +Needle +Needles +Needs Negate Negation Negative +Neglect +Neglected Neglector -Negligee Negligent Negotiate +Neighbor +Neighbors +Neither Nemeses Nemesis Neon Nephew +Nephews Nerd +Nerve +Nerves Nervous Nervy Nest +Nesting +Nests Net +Netted +Netting +Network +Networks Neurology Neuron +Neurons Neurosis Neurotic Neuter +Neutral Neutron +Neutrons Never +Newborn +Newcomer +Newcomers +Newer +Newest +Newly +News +Newscast +Newscasts +Newspaper Next Nibble +Niche +Niches +Nickel Nickname +Nicknamed +Nicknames Nicotine Niece Nifty +Nightclub +Nightly +Nightmare +Nighttime Nimble Nimbly Nineteen Ninetieth +Ninety Ninja Nintendo Ninth +Nirvana +Nitrate +Nitrogen +Nobility +Noble +Nobleman +Nobles +Nobody +Nocturnal +Nodes +Noise +Noises +Noisy +Nomadic +Nomads +Nominal +Nominally +Nominate +Nominated +Nominee +Nominees +None +Nonlinear +Nonprofit +Nonsense +Nonstop +Noodles +Norm +Norms +North +Northeast +Northerly +Northern +Northward +Northwest +Nose +Nostalgia +Notable +Notably +Notch +Notebook +Nothing +Notice +Notices +Notified +Notion +Notions +Notoriety +Notorious +Novel +Novelist +Novella +Novels +Novelty +Novice +Nowadays +Nowhere Nuclear Nuclei Nucleus Nugget +Nuggets +Null Nullify Number +Numbering +Numbers Numbing Numbly Numbness Numeral +Numerals Numerate Numerator Numeric +Numerical Numerous +Nun +Nuns Nuptials +Nurse Nursery +Nurses Nursing Nurture -Nutcase Nutlike Nutmeg Nutrient +Nutrients Nutshell Nuttiness Nutty @@ -4178,64 +9693,146 @@ Nuzzle Nylon Oaf Oak +Oaks Oasis Oat +Oath +Oaths Obedience Obedient +Obelisk +Obey +Obeyed +Obeying Obituary Object +Objected +Objection +Objective +Objects Obligate +Obligated +Oblige Obliged +Oblique Oblivion Oblivious Oblong Obnoxious Oboe Obscure +Obscured Obscurity Observant +Observe +Observed Observer +Observers +Observes Observing Obsessed Obsession Obsessive Obsolete Obstacle +Obstacles Obstinate Obstruct Obtain +Obtained +Obtaining +Obtains Obtrusive Obtuse Obvious +Obviously +Occasion +Occasions +Occlusion +Occult Occultist Occupancy Occupant +Occupants +Occupied Occupier +Occupies Occupy +Occupying +Occur +Occurred +Occurring +Occurs Ocean +Oceanic +Oceans Ocelot Octagon +Octagonal Octane +Octave October Octopus +Ocular +Odd +Oddly +Odds +Odor +Odors +Odyssey +Offend +Offended +Offender +Offenders +Offending +Offense +Offenses +Offensive +Offer +Offered +Offering +Offerings +Offers +Office +Officer +Officers +Offices +Officials +Offline +Offset +Offshoot +Offshore +Offspring Ogle Oil +Oily Oink Ointment Okay Old +Oldies +Olfactory Olive +Olives Olympics +Ombudsman Omega Omen Ominous Omission +Omissions Omit +Omitted +Omitting +Omnibus Omnivore Onboard Oncoming +Oneness +Oneself Ongoing Onion +Onions Online Onlooker Only @@ -4246,58 +9843,154 @@ Onslaught Onstage Onto Onward +Onwards Onyx Oops Ooze Oozy Opacity Opal +Opaque Open +Opener +Openings +Openly +Openness +Opens +Opera Operable +Operand +Operas Operate +Operates +Operatic Operating Operation Operative Operator -Opium +Operators +Opined +Opinion +Opinions Opossum Opponent +Opponents Oppose +Opposes Opposing Opposite +Opposites Oppressed Oppressor Opt +Optic +Optical +Optics +Optimal +Optimism +Optimize +Optimized +Optimum +Optional +Optioned +Options Opulently +Opus +Oracle +Orange +Oranges +Orator +Orbit +Orbital +Orbitals +Orbiting +Orbits +Orchard +Orchards +Orchestra +Orchid +Orchids +Ordained +Ordeal +Orderly +Ordinal +Ordinance +Ordnance +Organ +Organism +Organisms +Organist +Organize +Organizer +Organizes +Organs +Orient +Oriented +Origin +Originals +Originate +Origins +Orioles +Ornament +Ornaments +Ornate +Orphan +Orphanage +Orphaned +Orphans +Orthodoxy Osmosis +Osmotic Other +Otherwise Otter +Ottoman +Ottomans Ouch Ought Ounce +Ounces +Ourselves +Ousted Outage Outback Outbid Outboard Outbound Outbreak +Outbreaks Outburst +Outbursts Outcast Outclass Outcome +Outcomes +Outcrops +Outcry Outdated +Outdoor Outdoors Outer Outfield Outfit +Outfits Outflank Outgoing Outgrow Outhouse Outing Outlast +Outlaw +Outlawed +Outlaws +Outlay +Outlays Outlet +Outlets Outline +Outlined +Outlines +Outlining Outlook Outlying Outmatch @@ -4305,17 +9998,23 @@ Outmost Outnumber Outplayed Outpost +Outposts Outpour Output +Outputs Outrage +Outraged Outrank Outreach Outright Outscore Outsell +Outset Outshine Outshoot +Outside Outsider +Outsiders Outskirts Outsmart Outsource @@ -4323,6 +10022,7 @@ Outspoken Outtakes Outthink Outward +Outwardly Outweigh Outwit Oval @@ -4338,6 +10038,7 @@ Overblown Overboard Overbook Overbuilt +Overcame Overcast Overcoat Overcome @@ -4362,17 +10063,22 @@ Overhang Overhaul Overhead Overhear +Overheard Overheat Overhung Overjoyed Overkill Overlabor Overlaid +Overland Overlap +Overlaps Overlay Overload Overlook +Overlooks Overlord +Overly Overlying Overnight Overpass @@ -4388,6 +10094,11 @@ Override Overripe Overrule Overrun +Oversaw +Overseas +Oversee +Overseen +Oversees Overshoot Overshot Oversight @@ -4402,25 +10113,36 @@ Overstock Overstuff Oversweet Overtake +Overtaken Overthrow Overtime Overtly Overtone +Overtones +Overtook Overture Overturn Overuse Overvalue Overview +Overwhelm Overwrite +Owe +Owes Owl +Ownership +Oxen Oxford Oxidant Oxidation +Oxides Oxidize +Oxidized Oxidizing Oxygen Oxymoron Oyster +Oysters Ozone Paced Pacemaker @@ -4429,18 +10151,56 @@ Pacifier Pacifism Pacifist Pacify +Pack +Package +Packaged +Packages +Packaging +Packed +Packer +Packers +Packet +Packets +Packing +Packs +Pad Padded Padding Paddle Paddling Padlock +Padre +Padres +Pads Pagan +Pageant Pager +Pages Paging +Pagoda +Pain +Painful +Painfully +Pains +Paint +Painted +Painter +Painters +Painting +Paintings +Paints +Paisley Pajamas Palace +Palaces Palatable +Palazzo +Pale +Paler +Palette +Palladium Palm +Palms Palpable Palpitate Paltry @@ -4448,106 +10208,200 @@ Pampered Pamperer Pampers Pamphlet +Pamphlets Panama Pancake Pancreas Panda Pandemic +Pane +Panel +Panels Pang Panhandle Panic Panning Panorama Panoramic +Pantheon Panther +Panthers +Panting Pantomime Pantry Pants Pantyhose +Pap +Papa +Papacy +Papal Paparazzi Papaya Paper +Paperback +Paperwork Paprika Papyrus +Par +Parables Parabola Parachute Parade +Parades +Paradigm +Paradigms +Paradise Paradox +Paradoxes Paragraph Parakeet Paralegal +Parallax +Parallel +Parallels Paralyses Paralysis Paralyze Paramedic Parameter Paramount +Parapet Parasail Parasite +Parasites Parasitic Parcel +Parcels Parched Parchment Pardon +Pardoned +Parental +Parenting Parish +Parishes +Parity Parka Parking Parkway Parlor Parmesan +Parochial +Parodies +Parody Parole Parrot +Parry +Pars Parsley Parsnip +Parson +Parsons Partake Parted +Partially +Particle +Particles +Parties Parting +Partisan +Partisans Partition +Partizan Partly Partner +Partnered +Partners Partridge Party Passable Passably Passage +Passages Passcode Passenger Passerby +Passes Passing Passion +Passions Passive +Passively Passivism +Passivity Passover Passport +Passports Password +Passwords +Past Pasta +Paste Pasted Pastel Pastime Pastor +Pastoral +Pastors Pastrami +Pastry Pasture +Pastures Pasty +Patches Patchwork Patchy +Patent +Patented +Patents Paternal Paternity Path +Pathetic +Pathogen +Pathogens +Pathology +Pathos +Paths +Pathway +Pathways Patience Patient +Patients Patio Patriarch Patriot +Patriotic +Patriots Patrol +Patrolled +Patrols +Patron Patronage Patronize +Patrons +Patsy +Patted +Pattern +Patterned +Patterns +Patty +Paucity Pauper +Pause +Paused +Pauses +Pausing +Paved Pavement Paver Pavestone Pavilion +Pavilions Paving +Paw Pawing +Paws Payable Payback Paycheck @@ -4555,110 +10409,327 @@ Payday Payee Payer Paying +Payload Payment +Payments +Payoff Payphone Payroll +Pays +Pea +Peace +Peaceful +Peacetime +Peach +Peaches +Peacock +Peaked +Peanut +Peanuts +Pearl +Pearls +Peas +Peasant +Peasantry +Peasants Pebble +Pebbles Pebbly Pecan Pectin +Pectoral Peculiar +Pecuniary +Pedagogy +Pedal Peddling +Pedestal Pediatric Pedicure Pedigree Pedometer +Peek +Peel +Peeled +Peeling +Peep +Peer +Peerage +Peered +Peering +Peers +Peg Pegboard Pelican Pellet +Pellets Pelt +Pelvic Pelvis Penalize +Penalties Penalty +Penance +Pence Pencil +Pencils Pendant Pending +Pendulum +Penguin +Penguins Penholder +Peninsula Penknife Pennant +Penned Penniless Penny Penpal Pension Pentagon Pentagram +Peoples Pep +Pepper +Peppers Perceive +Perceived +Perceives Percent Perch +Perched Percolate Perennial Perfected Perfectly +Perform +Performed +Performer +Performs Perfume +Perhaps +Peril +Perilous +Perils +Perimeter +Period +Periodic +Periods +Periphery Periscope Perish +Perished Perjurer Perjury Perkiness Perky Perm +Permanent +Permeable +Permeated +Permit +Permits +Permitted Peroxide Perpetual Perplexed Persecute Persevere +Persist +Persisted +Persists +Persona +Personnel +Persons +Persuade Persuaded Persuader +Persuades +Pertain +Pertains +Pertinent +Pervasive Pesky Peso +Pesos Pessimism Pessimist Pester Pesticide +Pests Petal +Petals Petite Petition Petri +Petrol Petroleum Petted Petticoat Pettiness Petty Petunia +Pew +Phage Phantom +Pharaoh +Pharmacy +Phase +Phased +Phases +Phenomena +Phenotype +Philology Phobia +Phoebe Phoenix Phonebook +Phoned +Phonetic Phoney Phonics Phoniness +Phonology Phony Phosphate Photo +Photocopy +Photon +Photons +Photos Phrase +Phrases Phrasing +Physician +Physicist +Physique +Pianist +Piano +Pianos +Piazza +Pick +Picked +Picket +Picking +Picks +Pickup +Picnic +Pictorial +Picture +Pictured +Pictures +Pie +Piecemeal +Pieces +Pierce +Pierced +Piercing +Piers +Piety +Pig +Pigeon +Pigeons +Pigment +Pigments +Pigs +Pilasters +Piles +Pilgrim +Pilgrims +Pillars +Pillow +Pillows +Pilot +Piloted +Pilots +Pinch +Pinched +Pineapple +Pink +Pinnacle +Pinned +Pinpoint +Pint +Pinto +Pioneer +Pioneered +Pioneers +Pipe +Pipeline +Pipelines +Piper +Pipes +Piping +Pirate +Pirates +Piston +Pistons +Pitch +Pitched +Pitcher +Pitchers +Pitches +Pitchfork +Pitching +Pitfalls +Pitiful +Pits +Pitted +Pituitary +Pity +Pivot +Pivotal +Pixel +Pixels +Pizza Placard Placate +Placebo Placidly +Plainly +Plaintiff +Plan +Planar +Planet +Planetary +Planets Plank +Planks +Planned Planner +Planners +Planning +Plans Plant +Planter +Planters +Planting +Plaque +Plaques Plasma Plaster Plastic +Plastics +Plateau Plated +Platelet +Platelets Platform +Platforms Plating Platinum Platonic +Platoon Platter Platypus Plausible Plausibly Playable Playback +Played Player +Players Playful Playgroup Playhouse @@ -4667,85 +10738,196 @@ Playlist Playmaker Playmate Playoff +Playoffs Playpen Playroom +Plays Playset Plaything Playtime Plaza +Plea +Plead +Pleaded Pleading +Pleas +Pleased +Pleases +Pleasing +Pleasure +Pleasures Pleat Pledge +Pledged +Pledges +Plenary Plentiful Plenty Plethora Plexiglas +Plexus Pliable +Plight Plod Plop Plot +Plots +Plotted +Plotting Plow Ploy Pluck +Plucked Plug +Plugged +Plugs +Plum +Plumage +Plumbing +Plume +Plump Plunder +Plundered +Plunge +Plunged Plunging Plural +Pluralism +Plurality Plus Plutonium Plywood +Pneumatic Poach +Pocket +Pockets Pod +Podcast +Podcasts +Podium +Pods Poem +Poems Poet +Poetic +Poetical +Poetry +Poets Pogo +Poignant Pointed Pointer +Pointers Pointing Pointless Pointy Poise +Poised Poison +Poked Poker Poking Polar +Polarity +Polarized +Pole +Poles Police +Policeman +Policemen +Policies +Policing Policy Polio Polish +Polished +Polishing +Polite Politely +Politic +Political +Politics +Polity Polka +Poll +Polled +Pollen +Polling +Polls +Pollutant +Polluted +Pollution Polo Polyester Polygon Polygraph Polymer +Polymeric +Polymers Poncho Pond +Ponder +Pondered Pony +Pool +Pooled +Pooling +Pools +Poor +Poorer +Poorest +Poorly +Pop Popcorn Pope +Popes Poplar +Popped Popper +Popping Poppy +Pops Popsicle Populace Popular +Popularly Populate +Populated +Populist +Populous +Porcelain +Porch Porcupine +Pore Pork Porous Porridge Portable +Portage Portal +Portals Portfolio Porthole +Portico Portion Portly +Portrait +Portraits +Portray +Portrayal +Portrayed +Portrays Portside Poser Posh Posing +Positive +Positron +Posse +Possess +Possessed +Possesses +Possessor Possible Possibly Possum @@ -4753,24 +10935,55 @@ Postage Postal Postbox Postcard +Postcards Posted Poster +Posterior +Posterity +Posters Posting Postnasal +Postnatal +Postpone +Postponed +Postulate Posture +Postures Postwar +Potassium +Potato +Potatoes +Potency +Potential +Potter +Pottery Pouch +Poultry Pounce Pouncing Pound +Pour +Poured Pouring Pout +Poverty Powdered Powdering +Powders Powdery Power +Powerful +Powerless +Powers Powwow Pox +Practiced +Practices +Pragmatic +Prairie +Praise +Praised +Praises Praising Prance Prancing @@ -4778,51 +10991,105 @@ Pranker Prankish Prankster Prayer +Prayers Praying +Preach +Preached Preacher +Preachers Preaching Preachy Preamble +Precede +Preceded +Precedent +Precedes +Preceding +Precepts Precinct +Precious Precise +Precisely Precision +Preclude +Precluded +Precludes Precook +Precursor Precut Predator +Predators +Predatory Predefine +Predicate Predict +Predicted +Predictor +Predicts Preface +Prefect +Prefer +Preferred +Prefers Prefix +Prefixes Preflight Preformed Pregame Pregnancy Pregnant +Preheat Preheated +Prejudice +Prelate Prelaunch Prelaw Prelude +Premier Premiere +Premiered +Premieres +Premiers +Premise Premises Premium +Premiums Prenatal Preoccupy Preorder +Prep Prepaid +Prepare +Prepares +Preparing Prepay Preplan Preppy +Prequel Preschool Prescribe Preseason +Presence +Presenter +Presently +Preserve +Preserved +Preserves Preset Preshow +Presided President +Presiding Presoak Press +Pressure +Pressured +Pressures +Prestige Presume +Presumed Presuming -Preteen +Pretend Pretended Pretender Pretense @@ -4830,314 +11097,649 @@ Pretext Pretty Pretzel Prevail +Prevailed +Prevails Prevalent Prevent +Prevented +Prevents Preview +Previews Previous Prewar Prewashed +Prey +Price +Priced +Prices +Pricing +Pride Prideful Pried +Priest +Priestly +Priests +Primacy Primal +Primaries Primarily Primary Primate +Primates +Prime Primer +Primers +Priming +Primitive Primp +Prince +Princely +Princes Princess +Principal +Principle Print +Printers +Printing Prior +Priority +Priory Prism Prison +Prisoner +Prisoners +Prisons Prissy Pristine Privacy Private +Privateer +Privately Privatize +Privilege +Privy Prize +Prized +Prizes +Pro Proactive Probable Probably +Probate Probation Probe +Probed +Probes Probing Probiotic Problem +Problems Procedure +Proceed +Proceeded +Proceeds Process +Processed +Processes +Processor Proclaim -Procreate -Procurer +Proclaims +Proctor +Procure +Procured +Prod Prodigal Prodigy Produce +Producer +Producers Product +Products Profane Profanity +Profess Professed Professor Profile +Profiled +Profiles +Profiling +Profits Profound Profusely +Profusion Progeny Prognosis Program +Programs Progress +Prohibit +Prohibits +Project +Projected Projector +Projects +Prolific Prologue +Prolong Prolonged +Prom Promenade Prominent +Promo +Promote +Promoted Promoter +Promoters +Promotes +Promoting Promotion +Prompt +Prompted Prompter +Prompting Promptly +Prompts Prone Prong +Pronoun Pronounce +Pronouns Pronto +Proof Proofing Proofread Proofs +Prop +Propagate +Propelled Propeller Properly Property +Prophecy +Prophet +Prophetic +Prophets Proponent Proposal +Proposals Propose +Proposed +Proposes +Proposing +Propped +Propriety Props Prorate +Prose +Prosecute +Prospect +Prospects +Prosper +Prospered +Protect Protector +Protects Protegee +Protein +Proteins +Protest +Protested +Protests +Protocol +Protocols Proton +Protons Prototype Protozoan Protract Protrude Proud +Proudly Provable Proved Proven +Proverb +Proverbs +Provide Provided Provider +Providers +Provides Providing Province +Provinces Proving +Provision Provoke +Provoked Provoking Provolone +Provost Prowess Prowler Prowling Proximity Proxy -Prozac Prude +Prudence +Prudent Prudishly Prune Pruning Pry +Psalm +Psalms +Pseudo +Pseudonym +Psyche Psychic +Pub Public +Publicity +Publicly +Publish Publisher +Publishes +Pubs +Puck Pucker +Pudding Pueblo +Puff Pug Pull +Pulled +Pulling +Pulls Pulmonary Pulp +Pulpit Pulsate Pulse Pulverize Puma Pumice Pummel +Pump +Pumped +Pumping +Pumpkin +Pumps Punch +Punched +Punches Punctual Punctuate +Puncture Punctured Pungent +Punish +Punished Punisher +Punishing +Punitive Punk +Punt Pupil +Pupils Puppet +Puppets Puppy Purchase -Pureblood +Purchased +Purchaser +Purchases Purebred Purely Pureness +Purest Purgatory Purge Purging +Purified Purifier Purify Purist Puritan +Puritans Purity Purple Purplish +Purported +Purpose Purposely +Purposes Purr Purse Pursuable Pursuant +Pursue +Pursued +Pursues +Pursuing Pursuit +Pursuits Purveyor +Push Pushcart Pushchair +Pushed Pusher +Pushes Pushiness Pushing Pushover Pushpin Pushup Pushy +Putative Putdown Putt +Putting Puzzle +Puzzled +Puzzles Puzzling Pyramid +Pyramidal +Pyramids Pyromania Python Quack Quadrant +Quadratic +Quadruple Quail +Quaint Quaintly Quake Quaking Qualified Qualifier +Qualifies Qualify Quality Qualm +Quantify +Quantity Quantum +Quark Quarrel +Quarrels +Quarries Quarry +Quart Quartered Quarterly Quarters Quartet +Quartz +Quasi +Quay +Queen +Queens Quench +Quenching +Queries Query +Question +Questions +Queue +Quick Quicken +Quicker Quickly Quickness Quicksand Quickstep Quiet +Quietly Quill Quilt Quintet Quintuple Quirk Quit +Quite +Quitting Quiver +Quivering +Quiz Quizzical +Quorum +Quota Quotable +Quotas Quotation Quote +Quoted +Quotes +Quotient +Quoting +Rabbis +Rabbit +Rabbits Rabid Race +Racehorse +Racers +Racetrack Racing -Racism Rack Racoon Radar +Radars Radial Radiance +Radiant Radiantly Radiated +Radiating Radiation Radiator +Radical +Radically +Radicals Radio +Radiology +Radios Radish +Radius Raffle Raft Rage Ragged Raging +Rags Ragweed +Raided Raider +Raiders +Raiding +Raids Railcar Railing Railroad +Railroads Railway +Railways +Rainbow +Rainfall +Rainy Raisin Rake Raking +Rallied +Rallies Rally +Rallying Ramble Rambling Ramp +Rampage +Rampant Ramrod Ranch Rancidity Random +Randomly Ranged Ranger Ranging Ranked Ranking +Rankings Ransack +Ransom Ranting Rants +Rapid +Rapidity +Rapidly +Rapids +Rapper +Rappers +Rapport +Rapture Rare +Rarely Rarity Rascal Rash Rasping +Rat +Rather +Ratings +Ratio +Rationale +Rationing +Ratios +Rats +Rattle +Rattled +Rattling Ravage +Ravaged Raven +Ravens Ravine Raving Ravioli Ravishing +Rayon +Razed +Razor Reabsorb Reach Reacquire +React +Reacted +Reacting Reaction +Reactions Reactive Reactor +Reactors +Reacts +Readable +Reader +Readers +Readily +Readiness +Readings Reaffirm +Reagent +Reagents +Realism +Realist +Realities +Reality +Realize +Realized +Realizes +Realizing +Realm +Realms Ream Reanalyze +Reap Reappear Reapply Reappoint Reapprove +Rear +Reared +Rearing Rearrange Rearview Reason +Reasoned +Reasoning +Reasons Reassign Reassure +Reassured Reattach Reawake Rebalance Rebate Rebel +Rebelled +Rebellion +Rebels Rebirth Reboot Reborn Rebound +Rebounds Rebuff Rebuild Rebuilt +Rebuke Reburial Rebuttal Recall +Recalled +Recalling +Recalls Recant Recapture Recast Recede +Receipt +Receipts +Receive +Received +Receiver +Receivers +Receives +Receiving Recent +Recently +Reception +Receptive +Receptor +Receptors Recess +Recessed +Recession +Recessive Recharger +Recipe +Recipes Recipient Recital +Recitals Recite +Recited +Reciting Reckless +Reckon +Reckoned +Reckoning Reclaim +Reclaimed Recliner Reclining Recluse @@ -5146,241 +11748,498 @@ Recognize Recoil Recollect Recolor +Recommend Reconcile Reconfirm Reconvene Recopy Record +Recorded +Recorder +Recorders +Recording +Records Recount +Recounted +Recounts Recoup +Recourse +Recover +Recovered +Recovers Recovery Recreate -Rectal +Recreated +Recruit +Recruited +Recruits Rectangle Rectified Rectify +Rector +Rectory +Recur +Recurrent +Recurring +Recursive +Recycle Recycled Recycler Recycling +Reddish +Redeem +Redeemed +Redefine +Redesign +Redress +Reds +Reduce +Reduced +Reduces +Reducing +Reduction +Redundant +Redwood +Reef +Reefs +Reel +Reelected Reemerge Reenact Reenter Reentry +Reeve Reexamine Referable Referee +Referees Reference +Referral +Referrals Refill Refinance +Refine Refined Refinery Refining Refinish +Reflect Reflected Reflector +Reflects Reflex +Reflexes +Reflexive Reflux Refocus Refold Reforest +Reform Reformat Reformed Reformer +Reformers +Reforming Reformist +Reforms Refract Refrain Refreeze Refresh Refried +Refs Refueling +Refuge +Refugee +Refugees Refund Refurbish Refurnish Refusal Refuse +Refused +Refuses Refusing Refutable Refute Regain +Regained +Regaining +Regal Regalia Regally +Regard +Regarded +Regarding +Regards +Regatta +Regency +Regent +Regents Reggae Regime +Regimen +Regimens +Regiment +Regiments +Regimes Region +Regional +Regions Register +Registers Registrar Registry Regress +Regret Regretful +Regrets +Regretted Regroup Regular +Regulars Regulate +Regulated +Regulates Regulator Rehab +Rehearsal +Rehearsed Reheat Rehire Rehydrate +Reigned +Reigning +Reigns Reimburse +Reindeer +Reinforce +Reins Reissue +Reissued Reiterate +Reject +Rejected +Rejecting +Rejection +Rejects Rejoice +Rejoiced Rejoicing Rejoin +Rejoined Rekindle Relapse Relapsing Relatable Related +Relating Relation Relative +Relatives Relax +Relaxed +Relaxing Relay +Relays Relearn Release +Releases +Releasing +Relegated Relenting +Relevance Reliable Reliably Reliance Reliant Relic +Relics +Relied +Relief +Reliefs +Relies Relieve +Relieved Relieving Relight +Religion +Religions +Religious Relish Relive Reload Relocate +Relocated Relock Reluctant Rely +Relying +Remade +Remain +Remainder +Remained +Remaining +Remains Remake Remark +Remarked +Remarking +Remarks +Remarried Remarry Rematch Remedial +Remedies Remedy Remember +Remembers +Remind +Reminded Reminder +Reminders Remindful +Reminding +Reminds Remission Remix Remnant +Remnants +Remodeled Remodeler Remold Remorse Remote +Remotely Removable Removal +Remove Removed Remover +Removes Removing Rename +Renamed +Renaming +Render +Rendered Renderer Rendering +Renders Rendition Renegade +Renew Renewable Renewably Renewal Renewed +Renewing Renounce +Renounced Renovate +Renovated Renovator +Renown +Renowned Rentable Rental +Rentals Rented Renter Reoccupy Reoccur Reopen +Reopened +Reopening Reorder Repackage Repacking Repaint Repair +Repaired +Repairing +Repairs Repave +Repay Repaying Repayment Repeal +Repealed +Repeat Repeated Repeater +Repeating +Repeats +Repelled Repent +Repertory Rephrase Replace +Replaced +Replaces +Replacing Replay +Replete Replica +Replicas +Replicate +Replied +Replies Reply +Report +Reported Reporter +Reporters +Reporting +Reports Repose Repossess Repost +Represent Repressed Reprimand Reprint +Reprinted +Reprints Reprise +Reprising Reproach Reprocess Reproduce Reprogram Reps Reptile +Reptiles Reptilian +Republic +Republics Repugnant +Repulsed Repulsion Repulsive Repurpose Reputable Reputably +Reputed Request +Requested +Requests +Requiem Require +Required +Requires +Requiring Requisite +Reread Reroute +Rerouted Rerun +Reruns Resale Resample +Rescinded +Rescue +Rescued Rescuer +Rescues +Rescuing Reseal Research Reselect Reseller Resemble +Resembled +Resembles Resend Resent +Resentful +Reservoir Reset +Resettled Reshape Reshoot Reshuffle +Reside Residence Residency Resident +Resides Residual +Residuals Residue +Residues +Resign Resigned +Resigning Resilient +Resin +Resins +Resist Resistant +Resisted Resisting +Resistor +Resistors +Resists Resize Resolute +Resolve Resolved +Resolves +Resolving +Resonance Resonant Resonate Resort +Resorted +Resorting +Resorts Resource +Resources Respect +Respected +Respects +Respite +Response +Responses +Restart +Restarted +Restless +Restore +Restored +Restoring +Restrain +Restraint +Restrict +Restricts Resubmit Result +Resultant +Resulted +Resulting +Results Resume +Resumes +Resuming Resupply Resurface Resurrect Retail +Retailer +Retailers +Retailing +Retain +Retained Retainer Retaining +Retains Retake Retaliate Retention Rethink +Retina Retinal +Retire Retired Retiree Retiring @@ -5390,72 +12249,133 @@ Retorted Retouch Retrace Retract +Retracted Retrain Retread Retreat +Retreated +Retreats Retrial Retrieval +Retrieve +Retrieved Retriever Retry Return +Returned +Returning +Returns Retying Retype Reunion Reunite +Reunited +Reuniting Reusable Reuse +Reused +Revamped Reveal +Revealed +Revealing +Reveals Reveler Revenge Revenue +Revenues Reverb Revered Reverence Reverend Reversal Reverse +Reversed +Reverses Reversing Reversion Revert +Reverted +Reviewed +Reviewer +Reviewers +Reviewing Revisable Revise +Revised +Revising Revision +Revisions Revisit +Revisited Revivable Revival +Revive +Revived Reviver Reviving Revocable Revoke +Revoked Revolt +Revolts +Revolve Revolver +Revolves Revolving Reward +Rewarded +Rewarding +Rewards Rewash Rewind Rewire Reword Rework +Reworked Rewrap Rewrite +Rewriting +Rewritten +Rhetoric +Rheumatic +Rhino +Rhinos Rhyme +Rhymes +Rhythm +Rhythmic +Rhythms Ribbon +Ribbons Ribcage +Ribs Rice +Richer Riches +Richest Richly Richness Rickety Ricotta Riddance Ridden +Riddle Ride +Rider +Riders +Ridicule +Ridiculed Riding Rifling Rift Rigging +Righteous +Rights Rigid +Rigidity +Rigidly Rigor +Rigorous Rimless Rimmed Rind @@ -5463,6 +12383,7 @@ Rink Rinse Rinsing Riot +Rioting Ripcord Ripeness Ripening @@ -5473,89 +12394,193 @@ Riptide Rise Rising Risk +Risked +Risking +Risks +Risky Risotto -Ritalin +Ritual +Rituals Ritzy Rival +Rivalries +Rivalry Riverbank Riverbed Riverboat Riverside Riveter Riveting +Roadside +Roadway +Roadways +Roam Roamer Roaming +Roar +Roared +Roaring Roast +Roasted +Rob +Robbed +Robber +Robbers +Robbery Robbing Robe Robin +Robins +Robot +Robotic Robotics +Robots Robust Rockband +Rocked Rocker Rocket +Rockets Rockfish Rockiness Rocking Rocklike +Rocks Rockslide Rockstar Rocky +Rodent +Rodents +Rodeo +Rods +Roe Rogue +Roles +Roller +Rollers +Rolls Roman +Romance +Romances +Romantic Romp +Rooftop +Rookie +Roommate +Rooster +Roosters +Root +Rooted +Roots Rope +Ropes Roping +Rosary +Rosemary +Roses Roster +Rosters Rosy +Rotary +Rotate +Rotated +Rotates +Rotating +Rotation +Rotations +Rotor Rotten Rotting Rotunda +Rouge +Roughly +Roughness Roulette Rounding Roundish Roundness Roundup Roundworm +Roused +Route +Router +Routes Routine +Routinely +Routines Routing Rover +Rovers Roving Royal +Royalist +Royalists +Royals +Royalties +Royalty Rubbed Rubber Rubbing +Rubbish Rubble Rubdown +Rubles +Rubric Ruby Ruckus Rudder Rug +Rugby +Ruin Ruined Rule +Ruled +Ruler +Rulers +Rules +Ruling +Rulings Rumble Rumbling Rummage Rumor +Rumored +Rumors Runaround +Runaway Rundown Runner +Runners Running Runny +Runoff Runt Runway +Runways +Rupees Rupture +Ruptured Rural Ruse Rush Rust +Rustic +Rusty Rut +Ruthless +Rye Sabbath Sabotage +Sack +Sacked +Sacking +Sacks Sacrament Sacred Sacrifice +Sad Sadden +Saddle Saddlebag Saddled Saddling @@ -5566,55 +12591,93 @@ Safeguard Safehouse Safely Safeness +Safer +Safest +Safety Saffron Saga Sage Sagging Saggy Said +Sail +Sailed +Sailing +Sailor +Sailors +Sails Saint +Saints +Saith Sake Salad +Salads Salami Salaried +Salaries Salary +Sales +Salesman +Salience +Salient Saline +Salinity +Saliva +Sally +Salmon Salon Saloon Salsa Salt +Salts +Salty Salutary Salute Salvage +Salvaged Salvaging Salvation +Samba Same Sample +Sampled +Samples Sampling +Samurai Sanction +Sanctions Sanctity Sanctuary +Sand Sandal +Sandals Sandbag Sandbank Sandbar Sandblast Sandbox Sanded +Sanders Sandfish Sanding Sandlot Sandpaper Sandpit +Sands Sandstone Sandstorm +Sandwich Sandworm Sandy +Sang Sanitary Sanitizer Sank +Sans Santa +Sap Sapling +Sapphire Sappiness Sappy Sarcasm @@ -5623,61 +12686,111 @@ Sardine Sash Sasquatch Sassy +Sat Satchel +Satellite Satiable Satin +Satire Satirical Satisfied +Satisfies Satisfy Saturate Saturday +Sauce +Saucepan Sauciness Saucy Sauna +Sausage Savage Savanna +Savannah +Save Saved +Saves +Saving Savings Savior Savor +Sawmill +Sawyer +Sax Saxophone Say +Saying +Sayings Scabbed Scabby +Scalar Scalded Scalding Scale +Scaled +Scales Scaling Scallion Scallop +Scalp Scalping Scam +Scan Scandal +Scandals +Scanned Scanner Scanning +Scans Scant Scapegoat +Scar Scarce +Scarcely Scarcity +Scare Scarecrow Scared Scarf Scarily Scariness +Scarlet +Scarred Scarring +Scars Scary +Scatter +Scattered Scavenger +Scenario +Scenarios +Scenery +Scenes Scenic +Scented Schedule +Schedules +Schema Schematic Scheme +Schemes Scheming Schilling +Schism Schnapps Scholar +Scholarly +Scholars +School +Schoolboy +Schooling +Schools +Schooner Science +Sciences Scientist Scion +Scissors Scoff Scolding Scone @@ -5690,6 +12803,7 @@ Scorecard Scored Scoreless Scorer +Scorers Scoring Scorn Scorpion @@ -5697,25 +12811,47 @@ Scotch Scoundrel Scoured Scouring +Scout Scouting Scouts Scowling Scrabble Scraggly +Scramble Scrambled Scrambler Scrap +Scrape +Scraped +Scraping +Scrapped +Scrapping +Scraps Scratch +Scratched Scrawny +Scream +Screamed +Screaming +Screams Screen +Screened +Screening +Screens +Screw +Screws Scribble Scribe Scribing Scrimmage Script +Scripted +Scripture Scroll +Scrolls Scrooge Scrounger +Scrub Scrubbed Scrubber Scruffy @@ -5723,164 +12859,390 @@ Scrunch Scrutiny Scuba Scuff +Sculls +Sculpted Sculptor +Sculptors Sculpture Scurvy Scuttle +Sea +Seaboard +Seafood +Seal +Sealed +Sealing +Seals +Seam +Seaman +Seamen +Seams +Seaplane +Seaport +Sears +Seaside +Season +Seasonal +Seasoned +Seasons +Seat +Seated +Seating +Seats +Secession Secluded Secluding Seclusion Second +Secondary +Seconded +Secondly +Seconds Secrecy Secret +Secretary +Secrete +Secreted +Secretion +Secretly +Secrets +Sectarian Sectional Sector +Sectors Secular +Secured Securely +Securing Security Sedan Sedate Sedation Sedative +Sedentary +Sedge Sediment -Seduce -Seducing +Sediments +Seed +Seeded +Seeding +Seedlings +Seeds +Seek +Seeker +Seekers +Seeking +Seeks +Seem +Seemed +Seeming +Seemingly +Seems +Seer Segment +Segmented +Segments Seismic +Seize +Seized Seizing Seldom +Select Selected +Selecting Selection Selective Selector +Selects +Selenium Self +Selfish +Sell +Sellers +Selling +Sells Seltzer Semantic +Semantics Semester +Semi Semicolon Semifinal Seminar +Seminars +Seminary +Semiotics Semisoft Semisweet Senate Senator +Senators Send +Sender +Sending +Sends Senior +Seniority +Seniors Senorita Sensation +Sensed +Senseless +Senses +Sensible +Sensing Sensitive Sensitize -Sensually -Sensuous +Sensor +Sensors +Sensory +Sentence +Sentenced +Sentences +Sentiment +Sentinel +Sepals +Separate +Separated +Separates Sepia +Septa September Septic Septum Sequel +Sequels Sequence Sequester +Serene +Serenity +Serge +Sergeant +Serial +Serials Series +Serious +Seriously Sermon +Sermons Serotonin Serpent Serrated +Serum +Servant +Servants Serve Service +Serviced +Services +Servicing Serving +Servings Sesame Sessions Setback +Setbacks Setting +Settings Settle +Settler +Settlers +Settles Settling Setup +Seven Sevenfold +Sevens Seventeen Seventh +Seventies Seventy +Several +Severe +Severed +Severely Severity +Sew +Sewage +Sewer +Sewing +Sewn Shabby Shack +Shade Shaded +Shades Shadily Shadiness Shading Shadow +Shadows +Shadowy Shady Shaft +Shafts Shakable +Shake +Shaken +Shaker +Shakes Shakily Shakiness Shaking Shaky Shale +Shall Shallot Shallow +Shalt +Sham +Shaman Shame +Shameful Shampoo Shamrock +Shanghai Shank Shanty Shape +Shaped +Shapes Shaping Share +Shared +Shares +Sharing +Shark +Sharks +Sharp +Sharpened Sharpener Sharper Sharpie Sharply Sharpness +Shattered +Shave +Shaved +Shaving Shawl +Shear +Shearing Sheath Shed +Shedding +Sheds +Sheen Sheep +Sheer Sheet +Sheets Shelf Shell +Shellfish +Shelling +Shells Shelter +Sheltered +Shelters Shelve +Shelved +Shelves Shelving +Shepherd +Shepherds +Sheriff Sherry Shield +Shielded +Shielding +Shields +Shifted Shifter Shifting Shiftless +Shifts Shifty +Shillings Shimmer Shimmy +Shin Shindig Shine +Shines Shingle Shininess Shining Shiny Ship +Shipment +Shipments +Shipped +Shipping +Shipwreck +Shipyard +Shipyards +Shire Shirt +Shirts +Shiver +Shivered Shivering +Shoals Shock +Shocked +Shocking +Shocks +Shoemaker +Shoes Shone +Shook +Shootout +Shoots Shoplift Shopper +Shoppers Shopping Shoptalk Shore +Shoreline +Shores +Short Shortage +Shortages Shortcake Shortcut Shorten +Shortened Shorter +Shortest Shorthand Shortlist Shortly Shortness Shorts +Shortstop Shortwave Shorty +Shots +Shoulder +Shoulders Shout +Shouted +Shouting +Shouts Shove +Shoved +Shovel +Show Showbiz Showcase +Showcased +Showcases Showdown +Showed Shower +Showers Showgirl Showing Showman @@ -5889,118 +13251,221 @@ Showoff Showpiece Showplace Showroom +Shows Showy Shrank Shrapnel +Shredded Shredder Shredding +Shrew +Shrewd Shrewdly Shriek Shrill Shrimp Shrine +Shrines Shrink +Shrinkage +Shrinking Shrivel Shrouded +Shrub Shrubbery Shrubs Shrug +Shrugged Shrunk Shucking Shudder +Shuddered Shuffle +Shuffled Shuffling Shun +Shunt Shush Shut +Shutdown +Shutout +Shutouts +Shutter +Shutters +Shutting +Shuttle Shy Siamese Siberian Sibling +Siblings +Sick +Sickle +Sickly +Sickness +Sideline +Sidelined +Sidewalk +Sideways Siding +Sidings +Siege Sierra Siesta +Sieve Sift +Sigh +Sighed Sighing +Sighs +Sighted +Sighting +Sightings +Sigma +Signal +Signaled +Signaling +Signals +Signature +Signified +Signifies +Signify +Signings +Silence Silenced Silencer Silent +Silently Silica +Silicate Silicon +Silicone Silk +Sill Silliness Silly Silo Silt Silver +Similar Similarly Simile +Simmer Simmering Simple +Simpler +Simplest +Simplex Simplify Simply +Sims +Simulate +Simulated +Simulator +Simulcast +Since Sincere +Sincerely Sincerity +Sinful Singer +Singers Singing Single +Singled +Singles +Singleton +Singly Singular Sinister +Sink +Sinking +Sinks Sinless Sinner +Sinners Sinuous +Sinus +Sinuses Sip +Sipped +Sipping +Sir Siren Sister +Sisters Sitcom Sitter Sitting Situated Situation +Six Sixfold Sixteen +Sixteenth Sixth Sixties Sixtieth +Sixty Sixtyfold Sizable Sizably Size +Sizeable +Sized +Sizes Sizing Sizzle Sizzling +Skate Skater +Skaters Skating Skedaddle Skeletal Skeleton +Skeletons Skeptic +Skeptical Sketch +Sketched +Sketches Skewed Skewer +Ski Skid Skied Skier Skies Skiing +Skill Skilled Skillet Skillful +Skills +Skim Skimmed Skimmer Skimming Skimpily +Skin Skincare -Skinhead Skinless +Skinned Skinning Skinny +Skins Skintight +Skip +Skipped Skipper Skipping Skirmish Skirt Skittle +Skull +Skulls +Sky Skydiver Skylight Skyline @@ -6008,71 +13473,124 @@ Skype Skyrocket Skyward Slab +Slabs +Slack Slacked Slacker Slacking Slackness Slacks -Slain Slam +Slammed Slander Slang +Slant +Slap +Slapped Slapping Slapstick +Slash Slashed Slashing Slate Slather Slaw +Slayer Sled Sleek Sleep +Sleeper +Sleeping +Sleeps +Sleepy Sleet Sleeve +Sleeves +Slender Slept +Slice Sliceable Sliced Slicer +Slices Slicing Slick +Slid Slider Slideshow Sliding +Slight Slighted +Slightest Slighting Slightly +Slim Slimness Slimy +Sling Slinging Slingshot Slinky Slip +Slipped +Slippers +Slippery +Slipping +Slips Slit Sliver Slobbery Slogan +Slogans +Sloop +Slope Sloped Sloping Sloppily Sloppy Slot +Slots Slouching Slouchy +Slough +Slow +Slowed +Slower +Slowing +Slowly +Slows Sludge Slug +Sluggish Slum +Slump +Slumped +Slums +Slung Slurp Slush Sly Small +Smaller +Smallest +Smart Smartly Smartness +Smash +Smashed Smasher Smashing Smashup +Smear Smell +Smelled +Smelling +Smells Smelting Smile +Smiled +Smiles +Smiling Smilingly Smirk Smite @@ -6080,26 +13598,41 @@ Smith Smitten Smock Smog +Smoke Smoked Smokeless +Smokers Smokiness Smoking Smoky Smolder Smooth +Smoothed +Smoothing +Smoothly Smother Smudge Smudgy +Smuggled Smuggler Smuggling Smugly Smugness Snack +Snacks Snagged +Snail +Snails +Snake +Snakes Snaking Snap +Snapped +Snapping +Snapshot Snare Snarl +Snatched Snazzy Sneak Sneer @@ -6107,16 +13640,20 @@ Sneeze Sneezing Snide Sniff +Sniffed Snippet Snipping Snitch +Snooker Snooper Snooze Snore Snoring Snorkel Snort +Snorted Snout +Snow Snowbird Snowboard Snowbound @@ -6139,51 +13676,240 @@ Snuff Snuggle Snugly Snugness +Soak +Soaked +Soaking +Soap +Soared +Soaring +Sob +Sobbing +Sober +Sobs +Soccer +Social +Socially +Societal +Societies +Society +Sociology +Socket +Sockets +Socks +Sod +Soda +Sodium +Sofa +Soft +Softball +Soften +Softened +Softening +Softer +Softly +Softness +Software +Soil +Soils +Sojourn +Solace +Solar +Sold +Solder +Soldier +Soldiers +Solely +Solemn +Solemnly +Solicit +Solicited +Solicitor +Solid +Solidly +Solids +Solitary +Solitude +Solo +Soloist +Soloists +Solos +Solvent +Solvents +Somber +Somebody +Someday +Somehow +Someone +Something +Sometime +Sometimes +Somewhat +Somewhere +Sonar +Sonata +Song +Songs +Sonnet +Sonnets +Sonny +Sooner +Soot +Soothe +Soothing +Sophomore +Soprano +Sore +Sores +Sorghum +Sorority +Sorrow +Sorrows +Sorry +Sorties +Sought +Soul +Souls +Sounded +Sounding +Sounds +Soup +Sour +Sourced +South +Southeast +Southern +Southward +Southwest +Souvenir +Sovereign +Soviet +Soviets +Sow +Sowing +Sown +Soy +Soybean +Spa +Spaced +Spaces +Spaceship +Spacing +Spacious +Spade +Spaghetti +Spanned +Spanning +Spans +Spare +Spared +Sparing +Spark +Sparked +Sparkling +Sparks +Sparrow +Sparse +Sparsely +Spartan +Spasm +Spat +Spatial +Spatially +Spawn +Spawned +Spawning Speak +Speaker +Speakers +Speaking +Speaks +Spear Spearfish Spearhead Spearman Spearmint +Spears +Spec +Special +Specials +Specialty Species +Specific +Specifics +Specifies +Specify Specimen +Specimens Specked Speckled Specks Spectacle Spectator +Spectra +Spectral Spectrum Speculate Speech +Speeches Speed +Speedily +Speeding +Speeds +Speedway +Speedy +Spell Spellbind +Spelled Speller Spelling +Spellings +Spells +Spelt +Spend Spendable Spender Spending +Spends Spent Spew Sphere +Spheres Spherical Sphinx +Spicy Spider +Spiders Spied +Spies Spiffy +Spike +Spikes Spill +Spilled +Spilling +Spills Spilt +Spin Spinach Spinal Spindle +Spine +Spines Spinner Spinning Spinout +Spins Spinster Spiny Spiral +Spirit Spirited Spiritism Spirits Spiritual +Spit +Spitfire +Splash Splashed Splashing Splashy @@ -6194,60 +13920,95 @@ Splendor Splice Splicing Splinter +Split +Splits +Splitting Splotchy Splurge +Spoil Spoilage Spoiled Spoiler Spoiling Spoils +Spoke Spoken Spokesman Sponge Spongy Sponsor +Sponsored +Sponsors Spoof Spookily Spooky Spool Spoon +Sporadic Spore +Spores Sporting Sports +Sportsman Sporty +Spot Spotless Spotlight +Spots Spotted Spotter Spotting Spotty Spousal Spouse +Spouses Spout Sprain Sprang Sprawl +Sprawling Spray +Sprayed +Spraying +Spreading +Spreads Spree Sprig Spring +Springing +Springs +Sprinkle Sprinkled Sprinkler Sprint +Sprinter Sprite Sprout Spruce Sprung Spry Spud +Spun Spur +Spurious +Spurred +Spurs Sputter +Spy Spyglass Squabble Squad +Squadron +Squadrons +Squads Squall Squander +Square +Squared +Squarely +Squares Squash +Squat Squatted Squatter Squatting @@ -6257,22 +14018,35 @@ Squealing Squeamish Squeegee Squeeze +Squeezed Squeezing Squid Squiggle Squiggly Squint Squire +Squirrel +Squirrels Squirt Squishier Squishy Stability Stabilize Stable +Stables Stack +Stacked +Stacking +Stacks Stadium +Stadiums Staff +Staffed +Staffing +Staffs Stage +Staged +Staggered Staging Stagnant Stagnate @@ -6280,21 +14054,44 @@ Stainable Stained Staining Stainless +Stains +Stair +Staircase +Stairway +Stale Stalemate Staleness +Stalk +Stalks Stalling Stallion +Stalls +Stamens Stamina Stammer Stamp +Stamped +Stampede +Stamping +Stamps Stand +Standard +Standards +Standby +Standout Stank +Stanza +Stanzas Staple +Staples Stapling Starboard Starch Stardom Stardust +Stare +Stared +Stares Starfish Stargazer Staring @@ -6303,58 +14100,110 @@ Starless Starlet Starlight Starlit +Starred Starring Starry +Stars Starship Starter +Starters Starting Startle +Startled Startling +Starts Startup +Starve Starved Starving Stash +Stat State +Statehood +Stately +Statesman +Statesmen +Statewide Static +Stating +Stationed Statistic +Stats Statue +Statues Stature Status Statute +Statutes Statutory Staunch +Stayed +Staying Stays Steadfast Steadier Steadily Steadying +Steak +Steal +Stealing +Steals +Stealth Steam +Steamboat +Steamed +Steamer +Steamers +Steaming +Steamship Steed +Steel +Steels Steep +Steeply +Steer Steerable +Steered Steering Steersman Stegosaur +Stein Stellar Stem +Stemmed +Stemming Stench Stencil +Stent Step +Steppe +Stepped +Stepping Stereo Sterile Sterility Sterilize Sterling +Sternly Sternness Sternum Stew +Steward Stick +Sticking +Sticks +Sticky +Stiff Stiffen +Stiffened Stiffly Stiffness Stifle Stifling +Stigma +Still Stillness +Stills Stilt Stimulant Stimulate @@ -6367,14 +14216,26 @@ Stingray Stingy Stinking Stinky +Stint +Stints Stipend Stipulate Stir +Stirred +Stirring Stitch +Stitches Stock +Stocked +Stocking +Stockings +Stocks Stoic Stoke +Stokes Stole +Stolen +Stomach Stomp Stonewall Stoneware @@ -6384,6 +14245,7 @@ Stony Stood Stooge Stool +Stools Stoop Stoplight Stoppable @@ -6391,56 +14253,111 @@ Stoppage Stopped Stopper Stopping +Stops Stopwatch Storable Storage Storeroom +Stores Storewide +Stories Storm +Stormed +Stormy Stout Stove Stowaway Stowing Straddle Straggler +Straight Strained Strainer Straining +Strains +Strait +Straits +Strand +Stranded +Strands +Strange Strangely Stranger -Strangle +Strangers +Strap +Strapped +Straps +Strata Strategic Strategy +Stratum Stratus Straw Stray Streak +Streaks Stream +Streamed +Streaming +Streams Street +Streetcar +Streets Strength +Strengths Strenuous Strep Stress +Stressed +Stresses +Stressful +Stressing Stretch +Stretches Strewn Stricken Strict +Stricter +Strictly Stride +Strides Strife Strike +Striker +Strikers +Strikes Striking +Stringent +Strings +Stripe +Striped +Stripes +Stripped +Stripping +Strips Strive +Strives Striving Strobe Strode +Stroked +Strokes +Stroll +Strolled Stroller +Strong Strongbox +Stronger +Strongest Strongly Strongman +Strove Struck Structure Strudel Struggle +Struggled +Struggles Strum Strung Strut @@ -6450,14 +14367,21 @@ Stubbly Stubborn Stucco Stuck +Stud Student +Students Studied +Studies Studio +Studios Study +Studying +Stuff Stuffed Stuffing Stuffy Stumble +Stumbled Stumbling Stump Stung @@ -6465,15 +14389,20 @@ Stunned Stunner Stunning Stunt +Stunts Stupor Sturdily Sturdy +Sturgeon +Styled Styling Stylishly Stylist +Stylistic Stylized Stylus Suave +Sub Subarctic Subatomic Subdivide @@ -6481,15 +14410,21 @@ Subdued Subduing Subfloor Subgroup +Subgroups Subheader Subject +Subjected +Subjects Sublease Sublet Sublevel Sublime Submarine Submerge +Submerged Submersed +Submit +Submitted Submitter Subpanel Subpar @@ -6498,42 +14433,75 @@ Subprime Subscribe Subscript Subsector +Subset +Subsets Subside +Subsided +Subsidies Subsiding Subsidize Subsidy Subsoil Subsonic Substance +Substrate +Subsumed Subsystem Subtext Subtitle +Subtitled +Subtitles +Subtle +Subtlety Subtly Subtotal Subtract Subtype Suburb +Suburban +Suburbs Subway Subwoofer Subzero +Succeed +Succeeded +Succeeds +Success +Successes +Successor Succulent +Succumb +Succumbed Such +Sucrose Suction Sudden +Suddenly Sudoku Suds +Suffer +Suffered Sufferer Suffering +Suffers Suffice Suffix -Suffocate +Suffixes +Suffragan Suffrage Sugar +Sugarcane +Sugars Suggest +Suggested +Suggests Suing Suitable Suitably Suitcase +Suite +Suited +Suites Suitor Sulfate Sulfide @@ -6543,115 +14511,252 @@ Sulk Sullen Sulphate Sulphuric +Sultan +Sultanate Sultry +Sum +Summaries +Summarize +Summary +Summed +Summers +Summing +Summit +Summits +Summon +Summoned +Summons +Sumo +Sums +Sun +Sundry +Sunflower +Sung +Sunk +Sunken +Sunlight +Sunny +Sunrise +Suns +Sunset +Sunshine +Sup +Super +Superb Superbowl Superglue Superhero Superior +Superiors Superjet Superman Supermom Supernova +Superstar Supervise +Supine Supper +Supplied Supplier +Suppliers +Supplies Supply +Supplying Support -Supremacy +Supported +Supporter +Supports +Suppose +Supposed +Supposing +Suppress Supreme Surcharge Surely Sureness +Surf Surface +Surfaced +Surfaces Surfacing Surfboard Surfer +Surfing +Surge +Surgeon +Surgeons +Surgeries Surgery Surgical Surging Surname +Surnames Surpass +Surpassed Surplus +Surpluses Surprise +Surprised +Surprises Surreal Surrender +Surrey Surrogate Surround +Surrounds Survey +Surveyed +Surveying +Surveyor +Surveys Survival Survive +Survived +Survives Surviving Survivor +Survivors Sushi Suspect +Suspected +Suspects Suspend +Suspended Suspense +Suspicion +Sustain Sustained Sustainer +Sustains +Suture +Sutures Swab Swaddling Swagger +Swallow +Swallowed +Swam +Swamp Swampland +Swamps Swan +Swans +Swap +Swapped Swapping Swarm Sway +Swayed +Swaying Swear +Swearing Sweat +Sweater +Sweating Sweep +Sweeping +Sweeps +Sweet +Sweetly +Sweets Swell +Swelled +Swelling Swept Swerve +Swift Swifter Swiftly Swiftness +Swim Swimmable Swimmer +Swimmers Swimming Swimsuit Swimwear -Swinger +Swine +Swing Swinging +Swings Swipe Swirl +Swirling Switch +Switched +Switches +Switching Swivel Swizzle +Swollen Swooned Swoop Swoosh +Sword +Swords Swore Sworn Swung Sycamore +Syllable +Syllables +Syllabus +Symbiotic +Symbol +Symbolic +Symbolism +Symbolize +Symbols Sympathy Symphonic Symphony +Symposium Symptom +Symptoms +Synagogue Synapse +Synapses +Sync +Syndicate Syndrome +Syndromes Synergy +Synod +Synonym +Synonyms Synopses Synopsis +Syntactic +Syntax Synthesis Synthetic +Syringe Syrup System +Systemic T-shirt Tabasco Tabby Tableful Tables Tablet +Tablets Tableware Tabloid +Taboos +Tabs +Tabulated +Tacit Tackiness Tacking Tackle +Tackled +Tackles Tackling Tacky Taco Tactful +Tactic Tactical Tactics Tactile @@ -6659,31 +14764,64 @@ Tactless Tadpole Taekwondo Tag +Tagged +Tags +Tailor +Tailored Tainted Take +Takeoff +Takeover Taking Talcum +Talent +Talented +Talents +Tales Talisman +Talked +Talking Tall +Taller +Tallest Talon Tamale +Tame Tameness Tamer Tamper +Tandem +Tangent +Tangle +Tango Tank +Tanker +Tankers +Tanks Tanned +Tanner Tannery Tanning Tantrum +Tap +Taped Tapeless +Taper Tapered Tapering +Tapes Tapestry Tapioca +Tapped Tapping Taps Tarantula Target +Targeted +Targeting +Targets +Tariff +Tariffs Tarmac Tarnish Tarot @@ -6691,8 +14829,12 @@ Tartar Tartly Tartness Task +Tasked +Tasks Tassel Taste +Tasted +Tastes Tastiness Tasting Tasty @@ -6700,78 +14842,255 @@ Tattered Tattle Tattling Tattoo +Taught Taunt +Taut Tavern +Tax +Taxable +Taxation +Taxed +Taxes +Taxi +Taxing +Taxis +Taxonomic +Taxonomy +Taxpayer +Taxpayers +Tea +Teach +Teachers +Teaches +Teaching +Teachings +Teammate +Teammates +Teams +Teamwork +Tear +Tearing +Tears +Tease +Teased +Teaser +Teasing +Teaspoon +Teaspoons +Technical +Technique +Techno +Tedious +Teenage +Teenager +Teenagers +Teens +Teeth +Telegram +Telegraph +Telephone +Telescope +Televised +Telex +Tell +Telling +Tells +Temp +Temper +Temperate +Tempered +Tempest +Templates +Temple +Temples +Tempo +Temporal +Temps +Tendency +Tenderly +Tendon +Tendons +Tenets +Tennis +Tenor +Tens +Tensile +Tensor +Tentacles +Tentative +Tenth +Tenuous +Tenure +Term +Termed +Terminal +Terminals +Terminus +Terms +Terrace +Terraces +Terrain +Terrible +Terribly +Terrific +Terrified +Territory +Terry +Tertiary +Testament +Testified +Testifies +Testify +Testimony +Textbook +Textbooks +Textile +Textiles +Texture +Textured +Textures Thank +Thanked +Thankful +Thanking +Thanks That +Thatcher Thaw Theater +Theaters Theatrics Thee Theft +Thematic Theme +Themes +Thence Theology +Theorem +Theorems +Theoretic +Theories +Theorist +Theorists Theorize +Theorized +Theory +Therapies +Therapist +Therein +Thereupon Thermal Thermos Thesaurus These +Theses Thesis Thespian +Thick Thicken +Thickened +Thicker Thicket +Thickly Thickness +Thief +Thieves Thieving Thievish Thigh +Thighs Thimble +Thin Thing +Things Think +Thinker +Thinkers +Thinks Thinly Thinner Thinness Thinning +Third +Thirdly +Thirds +Thirst Thirstily Thirsting Thirsty Thirteen +Thirties Thirty -Thong +Thistle Thorn +Thorns +Thorough Those +Thought +Thoughts Thousand +Thousands Thrash Thread +Threaded +Threads +Threat Threaten +Threatens +Threats +Three Threefold +Threshold +Threw Thrift Thrill +Thrilled +Thriller +Thrilling Thrive Thriving Throat -Throbbing +Throats +Throne Throng Throttle Throwaway Throwback Thrower Throwing +Throws Thud Thumb +Thumbs Thumping +Thunder Thursday Thus +Thwarted Thwarting +Thyroid Thyself Tiara Tibia +Ticket +Tickets Tidal Tidbit +Tide +Tides Tidiness Tidings Tidy +Tie +Tier +Tiers Tiger +Tigers +Tight Tighten +Tightened +Tighter Tightly Tightness Tightrope @@ -6781,6 +15100,17 @@ Tile Tiling Till Tilt +Tilted +Tilting +Timber +Timbers +Timed +Timeless +Timeline +Timely +Timer +Timers +Timetable Timid Timing Timothy @@ -6796,33 +15126,165 @@ Tinsmith Tint Tinwork Tiny +Tip Tipoff Tipped Tipper Tipping Tiptoeing Tiptop +Tires Tiring Tissue +Tissues +Titan +Titanic +Titanium +Titans +Title +Titular +Toad +Toast +Tobacco +Today +Toddler +Toddlers +Toe +Toes +Toil +Toilet +Toilets +Token +Tokens +Told +Tolerant +Tolerate +Tolerated +Tolls +Tomato +Tomatoes +Tomorrow +Tonal +Tong +Tongue +Tongues +Tonic +Tonight +Tonnage +Toolbar +Tooth +Topic +Topical +Topics +Topology +Torch +Torment +Tormented +Torn +Tornado +Tornadoes +Torpedo +Torpedoed +Torpedoes +Torque +Torrent +Torsion +Torso +Tort +Tortoise +Torts +Toss +Tossed +Tossing +Tot +Total +Totaled +Totaling +Totality +Totalling +Totally +Totals +Touch +Touchdown +Touches +Touching +Tough +Tougher +Toughness +Toured +Touring +Tourism +Tourist +Tourists +Touted +Toward +Towards +Towel +Towels +Tower +Towering +Towers +Towing +Towns +Township +Townships +Toxic +Toxicity +Toxin +Toxins +Toy +Toys Trace +Traced +Tracer +Traces +Trachea Tracing Track +Tracked +Tracking Traction Tractor Trade +Traded +Trademark +Trader +Traders +Trades Trading Tradition Traffic +Tragedies Tragedy +Tragic +Trail +Trailed +Trailer +Trailers Trailing +Trails Trailside Train +Trainee +Trainees +Trainer +Trainers Traitor +Traitors +Tram +Trams Trance Tranquil +Transcend +Transept Transfer +Transfers Transform +Transient +Transit Translate +Transmit +Transmits Transpire Transport Transpose @@ -6834,37 +15296,72 @@ Trapper Trapping Traps Trash +Trauma +Traumatic +Travail Travel +Traveled +Traveler +Travelers +Traveling +Travels Traverse +Traversed +Traverses Travesty Tray Treachery +Tread Treading Treadmill Treason +Treasure +Treasurer +Treasures +Treasury Treat +Treaties +Treatise +Treatises +Treatment +Treaty Treble Tree +Trees +Trek Trekker Tremble +Trembled Trembling Tremor Trench +Trenches Trend +Trends Trespass +Triad Triage Trial +Trials Triangle +Triangles +Triathlon +Tribal +Tribe +Tribes Tribesman Tribunal +Tribunals Tribune Tributary Tribute Triceps +Trick Trickery Trickily Tricking Trickle +Tricks Trickster Tricky Tricolor @@ -6873,72 +15370,139 @@ Trident Tried Trifle Trifocals +Trigger +Triggered +Triggers Trillion Trilogy +Trim Trimester +Trimmed Trimmer Trimming Trimness Trinity Trio +Triple +Triples +Triplet Tripod Tripping Triumph +Triumphs +Trivia Trivial Trodden +Trolley Trolling Trombone +Troop +Troopers +Troops +Trophies Trophy Tropical Tropics +Trot Trouble +Troubled +Troubles Troubling Trough +Troupe Trousers Trout Trowel Truce Truck +Trucks Truffle +Truly Trump +Trumpet +Trumpeter +Trumpets +Truncated +Trunk Trunks +Truss +Trust Trustable Trustee +Trustees Trustful Trusting Trustless +Trusts Truth +Truthful +Truths Try +Trying +Tsar +Tsunami +Tub Tubby +Tube Tubeless +Tubes +Tubing Tubular +Tucked +Tucker Tucking Tuesday +Tufts Tug +Tugged Tuition Tulip Tumble Tumbling Tummy +Tuna +Tundra +Tune +Tunes +Tungsten +Tunic +Tuning +Tunnel +Tunneling +Tunnels Turban Turbine +Turbines Turbofan Turbojet Turbulent Turf Turkey Turmoil +Turner +Turnout +Turnover +Turnpike Turret +Turrets Turtle +Turtles Tusk +Tutelage Tutor +Tutorial +Tutoring +Tutors Tutu Tux Tweak Tweed Tweet +Tweeted Tweezers +Twelfth Twelve +Twenties Twentieth Twenty Twerp @@ -6946,27 +15510,41 @@ Twice Twiddle Twiddling Twig +Twigs Twilight +Twin Twine +Twinned Twins Twirl +Twist Twistable Twisted Twister Twisting +Twists Twisty Twitch Twitter +Twofold Tycoon Tying Tyke +Typeface +Typhoon +Typically +Tyranny +Tyrant Udder +Ulcer +Ulcers Ultimate Ultimatum Ultra Umbilical Umbrella Umpire +Umpires Unabashed Unable Unadorned @@ -6975,6 +15553,9 @@ Unafraid Unaired Unaligned Unaltered +Unanimity +Unanimous +Unarmed Unarmored Unashamed Unaudited @@ -7014,6 +15595,9 @@ Unclaimed Unclamped Unclasp Uncle +Unclean +Unclear +Uncles Unclip Uncloak Unclog @@ -7030,6 +15614,7 @@ Uncounted Uncouple Uncouth Uncover +Uncovered Uncross Uncrown Uncrushed @@ -7043,7 +15628,6 @@ Undaunted Undead Undecided Undefined -Underage Underarm Undercoat Undercook @@ -7054,8 +15638,12 @@ Underfed Underfeed Underfoot Undergo +Undergoes +Undergone Undergrad Underhand +Underlie +Underlies Underline Underling Undermine @@ -7064,6 +15652,7 @@ Underpaid Underpass Underpay Underrate +Underside Undertake Undertone Undertook @@ -7075,16 +15664,20 @@ Underwire Undesired Undiluted Undivided +Undo Undocked Undoing Undone Undrafted Undress Undrilled +Undue +Unduly Undusted Undying Unearned Unearth +Unearthed Unease Uneasily Uneasy @@ -7102,11 +15695,13 @@ Unexpired Unexposed Unfailing Unfair +Unfairly Unfasten Unfazed Unfeeling Unfiled Unfilled +Unfit Unfitted Unfitting Unfixable @@ -7114,6 +15709,9 @@ Unfixed Unflawed Unfocused Unfold +Unfolded +Unfolding +Unfolds Unfounded Unframed Unfreeze @@ -7145,22 +15743,31 @@ Unicorn Unicycle Unified Unifier +Uniform Uniformed Uniformly +Uniforms Unify +Unifying Unimpeded Uninjured Uninstall Uninsured Uninvited Union +Unions +Unique Uniquely Unisexual Unison Unissued Unit +Unitary +Unites +Units Universal Universe +Unjust Unjustly Unkempt Unkind @@ -7173,10 +15780,13 @@ Unlawful Unleaded Unlearned Unleash +Unleashed Unless Unleveled Unlighted Unlikable +Unlike +Unlikely Unlimited Unlined Unlinked @@ -7185,6 +15795,8 @@ Unlit Unlivable Unloaded Unloader +Unloading +Unlock Unlocked Unlocking Unlovable @@ -7198,6 +15810,7 @@ Unmanaged Unmanned Unmapped Unmarked +Unmarried Unmasked Unmasking Unmatched @@ -7320,6 +15933,7 @@ Untamed Untangled Untapped Untaxed +Untenable Unthawed Unthread Untidy @@ -7344,6 +15958,7 @@ Untying Unusable Unused Unusual +Unusually Unvalued Unvaried Unvarying @@ -7366,6 +15981,7 @@ Unwieldy Unwilling Unwind Unwired +Unwise Unwitting Unwomanly Unworldly @@ -7382,23 +15998,39 @@ Upchuck Upcoming Upcountry Update +Updated +Updates +Updating Upfront Upgrade +Upgraded +Upgrades +Upgrading Upheaval Upheld Uphill Uphold +Upholding +Upkeep +Upland +Uplands +Uplift Uplifted Uplifting Upload +Uploaded Upon Upper +Uppermost Upright Uprising +Uprisings Upriver Uproar Uproot Upscale +Upset +Upsetting Upside Upstage Upstairs @@ -7412,164 +16044,337 @@ Uptight Uptown Upturned Upward +Upwards Upwind Uranium Urban Urchin +Urea Urethane +Urged Urgency Urgent +Urgently +Urges Urging Urologist Urology +Usability Usable Usage +Usages Useable Used +Useful +Usefully +Useless Uselessly User Usher +Ushered Usual Utensil +Utensils +Utilities Utility Utilize +Utilized +Utilizes +Utilizing Utmost Utopia +Utopian Utter +Utterance +Utterly +Vacancies Vacancy Vacant Vacate +Vacated Vacation +Vacations +Vaccine +Vaccines +Vacuum Vagabond Vagrancy Vagrantly +Vague Vaguely Vagueness +Vain +Valentine Valiant Valid -Valium +Validate +Validated +Validity Valley +Valleys +Valor Valuables Value +Valued +Values +Valuing +Valve +Valves +Vampire +Vampires +Van +Vandalism +Vandals +Vanguard Vanilla Vanish +Vanished +Vanishes +Vanishing Vanity Vanquish +Vans Vantage +Vapor Vaporizer Variable +Variables Variably +Variance +Variances +Variants +Variation Varied +Varieties Variety Various +Variously Varmint Varnish Varsity Varying Vascular +Vase Vaseline +Vases +Vassal +Vassals +Vast Vastly Vastness +Vat +Vault +Vaulted +Vaults Veal +Vector +Vectors Vegan +Vegetable Veggie +Vehicle +Vehicles Vehicular +Veil +Vein +Veins Velcro Velocity Velvet Vendetta Vending Vendor +Vendors +Veneer Veneering +Venerable +Venerated +Vengeance Vengeful +Venom Venomous +Venous +Ventral Ventricle Venture +Ventured Venue Venus +Verbal Verbalize Verbally +Verbatim Verbose Verdict +Verified Verify +Verifying +Veritable +Versatile Verse Version Versus Vertebrae +Vertebral +Vertex Vertical +Vertices Vertigo Very Vessel +Vessels Vest Veteran +Veterans Veto +Vetoed Vexingly +Via Viability Viable +Viaduct +Vibe Vibes +Vibrant +Vibrating +Vibration +Vicar +Vicarious Vice +Viceroy Vicinity +Vicious +Victim +Victims +Victor +Victories Victory Video +Videos +Videotape Viewable Viewer Viewing Viewless Viewpoint +Vigil +Vigilance +Vigilant +Vigor Vigorous +Vile +Villa Village +Villagers +Villages Villain +Villains +Villas Vindicate +Vinegar +Vines Vineyard +Vineyards Vintage +Vinyl +Viola Violate +Violated +Violates +Violating Violation Violator Violet Violin +Violinist +Violins Viper Viral Virtual +Virtually +Virtue +Virtues Virtuous +Virulent Virus +Viruses Visa +Visas +Visceral Viscosity +Viscount Viscous Viselike Visible Visibly Vision +Visionary +Visit Visiting Visitor +Visitors +Visits Visor Vista +Visual +Visualize +Visually +Visuals +Vital Vitality Vitalize Vitally +Vitamin Vitamins Vivacious +Vivid Vividly Vividness Vixen +Vocal Vocalist +Vocalists Vocalize Vocally +Vocals Vocation +Vodka +Vogue Voice +Voiced +Voices Voicing Void Volatile +Volcanic +Volcano +Volcanoes +Volition Volley +Vols Voltage +Voltages +Volume Volumes +Volunteer +Vortex Voter +Voters +Votes Voting Voucher +Vow Vowed Vowel +Vowels +Vows Voyage +Voyager +Voyages +Vulture Wackiness Wad +Wade Wafer Waffle Waged @@ -7577,18 +16382,76 @@ Wager Wages Waggle Wagon +Wagons +Wailing +Waist +Waiter +Waitress +Waived +Waiver +Waivers Wake +Wakes Waking +Waldo +Wales Walk +Walked +Walker +Walkers +Walking +Walks +Walkway +Walled +Wallet +Wallpaper +Walls Walmart Walnut Walrus Waltz Wand +Wander +Wandered +Wanderers +Wandering +Waning Wannabe Wanted Wanting +Wants +Warbler +Warden +Wardrobe +Warehouse +Wares +Warfare +Warlord +Warmed +Warmer +Warming +Warmly +Warmth +Warn +Warned +Warning +Warnings +Warns +Warp +Warrant +Warrants +Warranty +Warren +Warring +Warrior +Warriors +Wars +Warship +Warships +Wartime +Wary Wasabi +Wash Washable Washbasin Washboard @@ -7597,6 +16460,7 @@ Washcloth Washday Washed Washer +Washes Washhouse Washing Washout @@ -7604,43 +16468,178 @@ Washroom Washstand Washtub Wasp +Wasps +Waste +Wasted +Wasteful +Wastes Wasting Watch +Watched +Watches +Watchful +Watching Water +Watered +Waterfall +Watering +Watershed +Waterway +Waterways +Watery +Watt +Watts +Wave +Waved +Waveform +Waves Waviness Waving Wavy +Wax +Weak +Weaken +Weakened +Weakening +Weaker +Weakest +Weakly +Weakness +Wealthy +Weariness +Wears +Weary +Weather +Weathered +Weave +Weaver +Weavers +Weaving +Web +Webs +Website +Websites +Wedding +Weddings +Wedge +Weeds +Week +Weekday +Weekdays +Weekend +Weekends +Weekly +Weeks +Weighed +Weighing +Weighs +Weighted +Weighting +Weights +Weird +Welcomed +Welcoming +Weld +Welded +Welding +Welfare +Welsh +Welt +Westbound +Westward +Westwards +Wet +Wetland +Wetlands +Wettest +Wetting Whacking Whacky +Whale +Whalers +Whales +Whaling Wham Wharf +Whatever Wheat +Wheel +Wheelbase +Wheeled +Wheeler +Wheels +Whence Whenever +Whereas +Wherefore +Wherein +Whereupon +Wherever +Whether +Whichever Whiff +Whilst +Whim Whimsical Whinny Whiny +Whip +Whipped +Whipping +Whirled +Whirling +Whiskey Whisking +Whisper +Whispered +Whispers +Whistle +Whistler +Whistling +White +Whiteness +Whither +Whiting +Whitish Whoever Whole +Wholeness +Wholesale +Wholesome +Wholly Whomever Whoopee Whooping Whoops +Whorl Why Wick +Wicked +Wicket +Wickets Widely Widen +Widened +Widening +Wider +Widest Widget Widow +Widowed +Widows Width +Widths Wieldable Wielder Wife Wifi +Wig +Wight Wikipedia +Wild Wildcard Wildcat +Wildcats Wilder Wildfire Wildfowl @@ -7649,118 +16648,253 @@ Wildlife Wildly Wildness Willed +Willful Willfully Willing +Willingly Willow Willpower +Wills Wilt Wimp Wince Wincing Wind +Winding +Windmill +Window +Windows +Winds +Windy +Winery +Wines Wing +Winged +Winger +Wingspan +Wink +Winked Winking Winner +Winners +Winning Winnings Winter +Winters Wipe +Wiped +Wiping +Wire Wired Wireless +Wires Wiring Wiry Wisdom Wise +Wisely +Wiser Wish +Wished +Wishes +Wishing Wisplike Wispy Wistful +Wit +Withdraw +Withdrawn +Withdrew +Withered +Withheld +Withhold +Withstand +Witnessed +Witnesses +Wits +Witty Wizard +Wizards Wobble Wobbling Wobbly +Woe Wok Wolf Wolverine +Wolves Womanhood Womankind Womanless Womanlike Womanly Womb +Women +Won +Wonder +Wondered +Wonderful +Wondering +Wonders +Wondrous +Woo +Wooded +Wooden +Woodland +Woodlands +Woods Woof Wooing Wool Woozy Word +Wording Work +Workable +Workbook +Worker +Workflow +Workforce +Workings +Workload +Workman +Workmen +Workout +Workplace +Worksheet +Workshop +Workshops +Worldly +Worlds +Worldwide +Worm +Worms Worried Worrier +Worries Worrisome Worry +Worrying +Worse +Worsened Worsening +Worship Worshiper Worst +Worth +Worthless Wound +Wounded +Wounding +Wounds Woven Wow Wrangle +Wrap +Wrapped +Wrapping Wrath Wreath Wreckage +Wrecked Wrecker Wrecking +Wren Wrench +Wrestle +Wrestled +Wrestler +Wrestlers +Wrestling +Wretched Wriggle Wriggly Wrinkle +Wrinkled +Wrinkles Wrinkly Wrist +Wrists +Writ +Writes Writing +Writings Written +Wrong Wrongdoer Wronged Wrongful Wrongly Wrongness +Wrongs +Wrote Wrought Xbox Xerox +Yacht +Yachts Yahoo Yam +Yanked Yanking Yapping Yard Yarn Yeah +Year Yearbook Yearling Yearly Yearning +Years Yeast +Yell +Yelled Yelling +Yellow +Yellowish Yelp Yen Yesterday +Yet Yiddish Yield +Yielded +Yielding +Yields Yin Yippee Yo-yo Yodel Yoga Yogurt +Yoke +Yolk Yonder +Young +Younger +Youngest +Youngster +Youth +Youthful +Youths Yoyo Yummy Zap +Zeal Zealous Zebra Zen +Zenith Zeppelin Zero +Zeros Zestfully Zesty +Zeta Zigzagged +Zinc +Zip Zipfile Zipping Zippy @@ -7768,8 +16902,12 @@ Zips Zit Zodiac Zombie +Zombies Zone +Zoned +Zones Zoning +Zoo Zookeeper Zoologist Zoology diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index ead11b0ca7c0..8b4dddb20c1e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -7,6 +7,7 @@ function Push-ExecOnboardTenantQueue { param($Item) try { $Id = $Item.id + Write-Information "Onboarding: Starting for relationship $Id" $Start = Get-Date $Logs = [System.Collections.Generic.List[object]]::new() $OnboardTable = Get-CIPPTable -TableName 'TenantOnboarding' @@ -61,6 +62,7 @@ function Push-ExecOnboardTenantQueue { $x++ Start-Sleep -Seconds 30 } while ($Relationship.status -ne 'active' -and $x -lt 6) + Write-Information "Onboarding: Step1 poll completed - status=$($Relationship.status) attempts=$x" if ($Relationship.status -eq 'active') { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'GDAP Invite Accepted' }) @@ -118,9 +120,34 @@ function Push-ExecOnboardTenantQueue { $OnboardingSteps.Step2.Status = 'succeeded' $OnboardingSteps.Step2.Message = 'Your GDAP relationship has the required roles' } + + # Validate (and correct) that the mapped security groups still exist in the partner tenant before + # Step 3 tries to POST the access assignments - a missing group surfaces as a raw Graph + # "access container does not exist" error otherwise. + if ($OnboardingSteps.Step2.Status -ne 'failed' -and ($Item.Roles | Measure-Object).Count -gt 0) { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Validating GDAP security group mappings against the partner tenant' }) + $GroupCheck = Test-CIPPGDAPGroupMappings -RoleMappings $Item.Roles -CreateMissing:([bool]$Item.AddMissingGroups) -WriteBack + foreach ($GroupResult in $GroupCheck.Results) { + if ($GroupResult.Status -in @('Stale', 'Created', 'Missing')) { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $GroupResult.Message }) + } + } + # Use the corrected mappings for the remainder of the onboarding (group mapping, SAM membership, retries) + $Item.Roles = @($GroupCheck.RoleMappings) + + if (-not $GroupCheck.Valid) { + $MissingGroupNames = ($GroupCheck.MissingGroups.Name | Sort-Object -Unique) -join ', ' + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Missing GDAP security groups in the partner tenant: $MissingGroupNames" }) + $TenantOnboarding.Status = 'failed' + $OnboardingSteps.Step2.Status = 'failed' + $OnboardingSteps.Step2.Message = "The following GDAP security groups are missing in the partner tenant, recreate the GDAP roles and retry: $MissingGroupNames" + } + } + $TenantOnboarding.OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) $TenantOnboarding.Logs = [string](ConvertTo-Json -InputObject @($Logs) -Compress) Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop + Write-Information "Onboarding: Step2 completed - status=$($OnboardingSteps.Step2.Status) missingRoles=$($MissingRoles -join ',')" } if ($OnboardingSteps.Step2.Status -eq 'succeeded') { @@ -304,43 +331,57 @@ function Push-ExecOnboardTenantQueue { $TenantOnboarding.OnboardingSteps = [string](ConvertTo-Json -InputObject $OnboardingSteps -Compress) $TenantOnboarding.Logs = [string](ConvertTo-Json -InputObject @($Logs) -Compress) Add-CIPPAzDataTableEntity @OnboardTable -Entity $TenantOnboarding -Force -ErrorAction Stop + Write-Information "Onboarding: Step3 completed - status=$($OnboardingSteps.Step3.Status)" } if ($OnboardingSteps.Step3.Status -eq 'succeeded') { # Check if the relationship was recently activated — Microsoft propagation may not have settled yet if ($Relationship.activatedDateTime) { + $MinutesSinceActivation = $null try { $ActivatedTimeUtc = ([DateTimeOffset]$Relationship.activatedDateTime).UtcDateTime $MinutesSinceActivation = ([datetime]::UtcNow - $ActivatedTimeUtc).TotalMinutes - if ($MinutesSinceActivation -lt 15) { - $RetryAtUtc = [Cronos.CronExpression]::Parse('* * * * *').GetNextOccurrence([DateTime]::UtcNow.AddMinutes(15), [TimeZoneInfo]::Utc) - $RetryEpoch = ([DateTimeOffset]$RetryAtUtc).ToUnixTimeSeconds() - $RetryDelayMinutes = ($RetryAtUtc - [DateTime]::UtcNow).TotalMinutes - $MinutesSinceActivationDisplay = ('{0:N1}' -f $MinutesSinceActivation) - $RetryDelayMinutesDisplay = ('{0:N1}' -f $RetryDelayMinutes) - $RetryLogMessage = "GDAP relationship was activated $MinutesSinceActivationDisplay minutes ago. Rescheduling onboarding in $RetryDelayMinutesDisplay minutes to allow Microsoft propagation to settle." - $Logs.Add([PSCustomObject]@{ - Date = (Get-Date).ToUniversalTime() - Log = $RetryLogMessage - }) - $RetryParams = [PSCustomObject]@{ - Item = [PSCustomObject]@{ - id = $Item.id - Roles = $Item.Roles - AutoMapRoles = $Item.AutoMapRoles - IgnoreMissingRoles = $Item.IgnoreMissingRoles - StandardsExcludeAllTenants = $Item.StandardsExcludeAllTenants - } - } - $RetryTask = [PSCustomObject]@{ - Name = "GDAP Onboarding retry: $($Relationship.customer.displayName)" - Command = [PSCustomObject]@{ value = 'Push-ExecOnboardTenantQueue' } - Parameters = $RetryParams - TenantFilter = $env:TenantID - Recurrence = '' - ScheduledTime = $RetryEpoch + } catch { + Write-Warning "Failed to parse activatedDateTime for relationship ${Id}: $($_.Exception.Message)" + } + Write-Information "Onboarding: activatedDateTime=$($Relationship.activatedDateTime) minutesSinceActivation=$MinutesSinceActivation" + if ($null -ne $MinutesSinceActivation -and $MinutesSinceActivation -lt 15) { + $RetryAtUtc = [Cronos.CronExpression]::Parse('* * * * *').GetNextOccurrence([DateTime]::UtcNow.AddMinutes(15), [TimeZoneInfo]::Utc) + $RetryEpoch = ([DateTimeOffset]$RetryAtUtc).ToUnixTimeSeconds() + $RetryDelayMinutes = ($RetryAtUtc - [DateTime]::UtcNow).TotalMinutes + $MinutesSinceActivationDisplay = ('{0:N1}' -f $MinutesSinceActivation) + $RetryDelayMinutesDisplay = ('{0:N1}' -f $RetryDelayMinutes) + $RetryParams = [PSCustomObject]@{ + Item = [PSCustomObject]@{ + id = $Item.id + Roles = $Item.Roles + AutoMapRoles = $Item.AutoMapRoles + IgnoreMissingRoles = $Item.IgnoreMissingRoles + AddMissingGroups = $Item.AddMissingGroups + StandardsExcludeAllTenants = $Item.StandardsExcludeAllTenants } - $null = Add-CIPPScheduledTask -Task $RetryTask -DesiredStartTime ([string]$RetryEpoch) + } + $RetryTask = [PSCustomObject]@{ + Name = "GDAP Onboarding retry: $($Relationship.customer.displayName)" + Command = [PSCustomObject]@{ value = 'Push-ExecOnboardTenantQueue' } + Parameters = $RetryParams + TenantFilter = $env:TenantID + Recurrence = '' + ScheduledTime = $RetryEpoch + } + try { + $ScheduleResult = Add-CIPPScheduledTask -Task $RetryTask -DesiredStartTime ([string]$RetryEpoch) + } catch { + $ScheduleResult = "Error - $($_.Exception.Message)" + } + Write-Information "Onboarding: Add-CIPPScheduledTask result=$ScheduleResult" + if ($ScheduleResult -match '^Error') { + $FailMessage = "Failed to schedule onboarding retry for $($Relationship.customer.displayName): $ScheduleResult" + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $FailMessage }) + Write-LogMessage -API 'Onboarding' -message $FailMessage -Sev 'Error' + } else { + $RetryLogMessage = "GDAP relationship was activated $MinutesSinceActivationDisplay minutes ago. Rescheduling onboarding in $RetryDelayMinutesDisplay minutes to allow Microsoft propagation to settle." + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = $RetryLogMessage }) $RetryMessage = "Rescheduled: GDAP relationship was activated $MinutesSinceActivationDisplay minutes ago. Retrying in $RetryDelayMinutesDisplay minutes to allow Microsoft propagation to settle." $OnboardingSteps.Step4.Status = 'pending' $OnboardingSteps.Step4.Message = $RetryMessage @@ -351,8 +392,6 @@ function Push-ExecOnboardTenantQueue { Write-LogMessage -API 'Onboarding' -message $RetryMessage -Sev 'Info' return } - } catch { - Write-Warning "Failed to check activatedDateTime for relationship ${Id}: $($_.Exception.Message)" } } @@ -421,6 +460,7 @@ function Push-ExecOnboardTenantQueue { } } while ($Refreshing -and (Get-Date) -lt $Start.AddMinutes(8)) + Write-Information "Onboarding: CPV refresh loop completed - success=$CPVSuccess lastError=$LastCPVError" if ($CPVSuccess) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'CPV permissions refreshed' }) $OnboardingSteps.Step4.Status = 'succeeded' @@ -534,6 +574,7 @@ function Push-ExecOnboardTenantQueue { $ApiException = $_ } + Write-Information "Onboarding: Step5 API test completed - userCount=$UserCount apiError=$ApiError" if ($UserCount -gt 0) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'API test successful' }) $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Onboarding complete' }) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index 505570ec1ae5..8a358101773e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -11,10 +11,12 @@ function Push-ExecScheduledCommand { $OrchestratorBasedCommands = @('Invoke-CIPPOffboardingJob') # Initialize AsyncLocal storage for thread-safe per-invocation context - if (-not $global:CippScheduledTaskIdStorage) { - $global:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new() - } - $global:CippScheduledTaskIdStorage.Value = $Item.TaskInfo.RowKey + # Store task id in CIPPCore module-scoped AsyncLocal storage so Write-LogMessage can read it - + # global vars are unreliable in Azure Functions + Set-CippScheduledTaskContext -TaskId $Item.TaskInfo.RowKey + + # Store action source + creating user identity (from stored headers) for outbound User-Agent attribution + Set-CippUserAgentContext -Headers $Item.Parameters.Headers -Source 'scheduled-task' -TaskId $Item.TaskInfo.RowKey $Table = Get-CippTable -tablename 'ScheduledTasks' $task = $Item.TaskInfo @@ -179,7 +181,7 @@ function Push-ExecScheduledCommand { return } - if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB', 'CippExtensions')) { + if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB', 'CippExtensions', 'CIPPActivityTriggers')) { $State = 'Failed' Write-LogMessage -headers $Headers -API 'ScheduledTask' -message "Blocked attempt to schedule command from unauthorized module: $($Command.ModuleName)\$($Item.Command)" -Sev 'Warning' $Results = "Task blocked: The command '$($Item.Command)' is not permitted to run as a scheduled task." diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 index 6138066755c9..6d8cba6a8cb2 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 @@ -46,12 +46,12 @@ function Push-CIPPStandard { return } - # Initialize AsyncLocal storage for thread-safe per-invocation context - # Uses $global: so Write-LogMessage (CIPPCore module) can read it across module boundaries - if (-not $global:CippStandardInfoStorage) { - $global:CippStandardInfoStorage = [System.Threading.AsyncLocal[object]]::new() - } - $global:CippStandardInfoStorage.Value = $StandardInfo + # Store standard info in CIPPCore module-scoped AsyncLocal storage so Write-LogMessage and + # Set-CIPPStandardsCompareField can read it - global vars are unreliable in Azure Functions + Set-CippStandardInfoContext -StandardInfo $StandardInfo + + # Store action source + template id for outbound User-Agent attribution + Set-CippUserAgentContext -Source 'standard' -TemplateId $Item.TemplateId # ---- Standard execution telemetry ---- $runId = [guid]::NewGuid().ToString() @@ -131,8 +131,6 @@ function Push-CIPPStandard { Error = $err } | ConvertTo-Json -Compress) - if ($global:CippStandardInfoStorage) { - $global:CippStandardInfoStorage.Value = $null - } + Set-CippStandardInfoContext -StandardInfo $null } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 index 9f969037d7b8..7c8efa8cdf76 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 @@ -25,12 +25,14 @@ function Get-CIPPAlertSharepointQuota { } catch { $Value = 90 } - $UsedStoragePercentage = [int](($sharepointQuota.GeoUsedStorageMB / $sharepointQuota.TenantStorageMB) * 100) + $GeoUsedStorageMB = ($sharepointQuota.GeoUsedStorageMB | Measure-Object -Sum).Sum + $TenantStorageMB = $sharepointQuota.TenantStorageMB | Select-Object -First 1 + $UsedStoragePercentage = [int](($GeoUsedStorageMB / $TenantStorageMB) * 100) if ($UsedStoragePercentage -gt $Value) { $AlertData = [PSCustomObject]@{ UsedStoragePercentage = $UsedStoragePercentage - StorageUsed = ([math]::Round($sharepointQuota.GeoUsedStorageMB / 1024, 2)) - StorageQuota = ([math]::Round($sharepointQuota.TenantStorageMB / 1024, 2)) + StorageUsed = ([math]::Round($GeoUsedStorageMB / 1024, 2)) + StorageQuota = ([math]::Round($TenantStorageMB / 1024, 2)) AlertQuotaThreshold = $Value Tenant = $TenantFilter } diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 576c2bcca127..7837363b430d 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -70,7 +70,7 @@ function Add-CIPPScheduledTask { $ImportedModules = [System.Collections.Generic.List[string]]::new() if (-not $Command) { try { - foreach ($SiblingModule in @('CIPPStandards', 'CIPPAlerts', 'CIPPTests', 'CIPPDB', 'CippExtensions')) { + foreach ($SiblingModule in @('CIPPStandards', 'CIPPAlerts', 'CIPPTests', 'CIPPDB', 'CippExtensions', 'CIPPActivityTriggers')) { if (-not (Get-Module -Name $SiblingModule)) { Import-Module $SiblingModule -ErrorAction SilentlyContinue if (Get-Module -Name $SiblingModule) { @@ -91,7 +91,7 @@ function Add-CIPPScheduledTask { return "Error - The command '$RequestedCommand' does not exist and cannot be scheduled." } - if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB', 'CippExtensions')) { + if ($Command.Module -notin @('CIPPCore', 'CIPPAlerts', 'CIPPStandards', 'CIPPTests', 'CIPPDB', 'CippExtensions', 'CIPPActivityTriggers')) { Write-LogMessage -headers $Headers -API 'ScheduledTask' -message "Blocked attempt to schedule command from unauthorized module: $($Command.ModuleName)\$RequestedCommand" -Sev 'Warning' return "Error - The command '$RequestedCommand' is not permitted to run as a scheduled task." } diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 index fac6880e178b..befd7cd16496 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 @@ -163,7 +163,24 @@ function New-CippAuditLogSearch { } } - if (($null -ne $AuditLogError) -and $AuditLogError.Status -eq 'AuditingDisabledTenant') { + # The AuditingDisabledTenant status can appear either at the top level or nested + # inside error.message as a JSON-encoded string (e.g. when Microsoft wraps it in an + # UnknownError envelope), so resolve the status from both locations. + $AuditStatus = $AuditLogError.Status + if (-not $AuditStatus) { + $InnerMessage = $AuditLogError.error.message ?? $AuditLogError.message + if ($InnerMessage -is [string]) { + $TrimmedInnerMessage = $InnerMessage.TrimStart() + if ($TrimmedInnerMessage.StartsWith('{') -or $TrimmedInnerMessage.StartsWith('[')) { + $InnerParsed = $InnerMessage | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($InnerParsed) { + $AuditStatus = $InnerParsed.Status + } + } + } + } + + if (($null -ne $AuditLogError) -and $AuditStatus -eq 'AuditingDisabledTenant') { try { $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' $DisabledEntity = [PSCustomObject]@{ @@ -182,7 +199,7 @@ function New-CippAuditLogSearch { return [PSCustomObject]@{ id = $null displayName = [string]$DisplayName - status = [string]$AuditLogError.Status + status = [string]$AuditStatus cippStatus = [string]'Skipped' message = [string]'Unified auditing is disabled for this tenant.' } diff --git a/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 b/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 index 7d40ec88282f..ffb9018b0e88 100644 --- a/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Add-CIPPSSOAppSecret.ps1 @@ -3,16 +3,24 @@ function Add-CIPPSSOAppSecret { .SYNOPSIS Creates a client secret on the CIPP-SSO app registration with retry. .DESCRIPTION - Adds a new password credential to the given app object via Graph. Retries up to - MaxRetries times with backoff because Entra propagation can take a few seconds - after the app is freshly created or its app-management-policy exemption is set. - Throws on final failure so callers can persist Status=error + LastError. + Adds a new password credential to the given app object via Graph. Before adding the + secret it ensures the app is exempt from the tenant default app-management policy (so a + 'passwordAddition' restriction can't block the secret) via Update-AppManagementPolicy, + and honours any 'passwordLifetime' restriction when building the credential body. + Retries up to MaxRetries times with backoff because Entra propagation can take a few + seconds after the app is freshly created or its app-management-policy exemption is set: + replication misses back off 3s, and credential-policy blocks back off min(30, 5*attempt)s + while the exemption propagates. Throws on final failure so callers can persist + Status=error + LastError. .PARAMETER ObjectId Graph object ID of the application (NOT the appId/clientId). + .PARAMETER AppId + AppId/clientId of the application, used to target the app-management-policy exemption. + Resolved from ObjectId when not supplied. .PARAMETER DisplayName Display name to set on the password credential. Defaults to 'CIPP-SSO-Secret'. .PARAMETER MaxRetries - Number of secret-creation attempts before giving up. Defaults to 5. + Number of secret-creation attempts before giving up. Defaults to 6. #> [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] [CmdletBinding()] @@ -20,32 +28,78 @@ function Add-CIPPSSOAppSecret { [Parameter(Mandatory = $true)] [string]$ObjectId, + [Parameter(Mandatory = $false)] + [string]$AppId, + [Parameter(Mandatory = $false)] [string]$DisplayName = 'CIPP-SSO-Secret', [Parameter(Mandatory = $false)] - [int]$MaxRetries = 5 + [int]$MaxRetries = 6 ) + # Update-AppManagementPolicy targets the app by appId/clientId; resolve it from the object id when not supplied. + if (-not $AppId) { + try { + $SSOApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId`?`$select=id,appId" -NoAuthCheck $true -AsApp $true + $AppId = $SSOApp.appId + } catch { + Write-Warning "[SSO-Secret] Failed to resolve appId for objectId $ObjectId : $($_.Exception.Message)" + } + } + + # Ensure the app is exempt from any credential-addition restriction before adding the secret. + if ($AppId) { + try { + $PolicyUpdate = Update-AppManagementPolicy -ApplicationId $AppId + Write-Information "[SSO-Secret] App management policy: $($PolicyUpdate.PolicyAction)" + } catch { + Write-Information "[SSO-Secret] Failed to update app management policy: $($_.Exception.Message)" + } + } + + # Honour the tenant password-lifetime restriction (if enforced) when building the credential body. + $AppManagementPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -AsApp $true -NoAuthCheck $true + $PasswordExpirationPolicy = $AppManagementPolicy.applicationRestrictions.passwordcredentials | + Where-Object { $_.restrictionType -eq 'passwordLifetime' } + if (-not ($PasswordExpirationPolicy.state -eq 'disabled' -or $null -eq $PasswordExpirationPolicy.state)) { + $TimeToExpiration = [System.Xml.XmlConvert]::ToTimeSpan($PasswordExpirationPolicy.maxLifetime) + $ExpirationDate = (Get-Date).AddDays($TimeToExpiration.Days).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`",`"endDateTime`":`"$ExpirationDate`"}}" + } else { + $PasswordBody = "{`"passwordCredential`":{`"displayName`":`"$DisplayName`"}}" + } + $SecretText = $null - $SecretAttempt = 0 - $BackoffSchedule = @(2, 5, 10, 15, 30) $LastException = $null - - while ($SecretAttempt -lt $MaxRetries -and -not $SecretText) { + for ($Attempt = 1; $Attempt -le $MaxRetries; $Attempt++) { try { - $PasswordBody = @{ passwordCredential = @{ displayName = $DisplayName } } | ConvertTo-Json -Compress - $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -body $PasswordBody -type POST -NoAuthCheck $true -AsApp $true + $PasswordResult = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ObjectId/addPassword" -AsApp $true -NoAuthCheck $true -type POST -body $PasswordBody -maxRetries 3 $SecretText = $PasswordResult.secretText Write-Information "[SSO-Secret] Client secret created on objectId $ObjectId" + break } catch { - $SecretAttempt++ $LastException = $_ - Write-Warning "[SSO-Secret] Secret creation attempt $SecretAttempt/$MaxRetries failed: $($_.Exception.Message)" - if ($SecretAttempt -lt $MaxRetries) { - $Delay = $BackoffSchedule[[Math]::Min($SecretAttempt - 1, $BackoffSchedule.Count - 1)] - Start-Sleep -Seconds $Delay + $ExceptionMessage = $_.Exception.Message + $IsNotReplicatedYet = $ExceptionMessage -match "Resource '.*' does not exist or one of its queried reference-property objects are not present" + $IsCredentialPolicyBlocked = $ExceptionMessage -match 'Credential type not allowed as per assigned policy' + Write-Warning "[SSO-Secret] Secret creation attempt $Attempt/$MaxRetries failed: $ExceptionMessage" + + if ($IsNotReplicatedYet -and $Attempt -lt $MaxRetries) { + $DelaySeconds = 3 + Write-Information "[SSO-Secret] Application object not yet replicated for addPassword (attempt $Attempt of $MaxRetries). Retrying in $DelaySeconds second(s)." + Start-Sleep -Seconds $DelaySeconds + continue + } + + if ($IsCredentialPolicyBlocked -and $Attempt -lt $MaxRetries) { + $DelaySeconds = [Math]::Min(30, 5 * $Attempt) + Write-Information "[SSO-Secret] Credential policy still blocks addPassword (attempt $Attempt of $MaxRetries). Waiting for policy propagation and retrying in $DelaySeconds second(s)." + Start-Sleep -Seconds $DelaySeconds + continue } + + throw } } diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 index 5a16b3c70a4e..0fd04bd57e07 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 @@ -23,7 +23,11 @@ function Get-CippAllowedPermissions { # Get all available permissions and base roles configuration - $Version = (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).trim() + $Version = if ($env:CIPPNG -eq 'true') { + $env:APP_VERSION + } else { + (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).Trim() + } $BaseRoles = Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\cipp-roles.json') | ConvertFrom-Json $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly', 'anonymous', 'authenticated') diff --git a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 index 78319c3a05b8..127af216dcc5 100644 --- a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 @@ -145,6 +145,80 @@ function Initialize-CIPPAuth { Write-Information "[Auth-Init] EasyAuth issuer reconciliation failed (non-fatal): $_" } } + + # 3c. Reconcile EasyAuth policy (UnauthenticatedClientAction, ExcludedPaths) with appsettings configuration + if ($AuthState.HasSAMCredentials -and -not $env:CIPP_SSO_MIGRATION_APPID) { + try { + $PolicyReconciled = [Craft.Services.AppLifecycleBridge]::ReconcileAuthPolicy('CIPP warmup') + if ($PolicyReconciled) { + Write-Information '[Auth-Init] EasyAuth policy reconciled from Craft appsettings (drift detected and corrected)' + } else { + Write-Information '[Auth-Init] EasyAuth policy matches appsettings — no update needed' + } + } catch { + Write-Information "[Auth-Init] EasyAuth policy reconcile failed (non-fatal): $_" + } + } + + # 3d. Reconcile API clients — ensure the EasyAuth config matches what the + # "Save to Azure" action (Set-CippApiAuth) would produce for the currently + # enabled API clients. That means BOTH lists must be checked, not just apps: + # allowedApplications = SSO app + every enabled client + # allowedAudiences = api:// for each of the above, plus the MCP host + # URIs and bare client IDs for MCP-enabled clients + # Config drifts when a client is enabled but "Save to Azure" was never run (or a + # prior save partially applied — e.g. apps set but audiences missing), which + # silently breaks API authentication for that client. + if ($AuthState.HasSAMCredentials -and -not $env:CIPP_SSO_MIGRATION_APPID -and $env:WEBSITE_AUTH_V2_CONFIG_JSON) { + try { + $ApiClientsTable = Get-CippTable -tablename 'ApiClients' + $EnabledClients = @(Get-CIPPAzDataTableEntity @ApiClientsTable -Filter 'Enabled eq true' | Where-Object { ![string]::IsNullOrEmpty($_.RowKey) }) + + if ($EnabledClients.Count -gt 0) { + $EnabledClientIds = @($EnabledClients.RowKey) + # MCPAllowed can round-trip as a bool or string; compare on string form (matches SaveToAzure) + $McpClientIds = @($EnabledClients | Where-Object { "$($_.MCPAllowed)" -eq 'True' } | ForEach-Object { $_.RowKey }) + + $ApiAuthConfig = $env:WEBSITE_AUTH_V2_CONFIG_JSON | ConvertFrom-Json -ErrorAction Stop + $AADConfig = $ApiAuthConfig.identityProviders.azureActiveDirectory + + # Desired state — keep in sync with Set-CippApiAuth's CIPPNG branch. + $DesiredApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($AADConfig.registration.clientId) { [void]$DesiredApps.Add($AADConfig.registration.clientId) } + foreach ($Id in $EnabledClientIds) { if (-not [string]::IsNullOrEmpty($Id)) { [void]$DesiredApps.Add($Id) } } + + $DesiredAudiences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Id in $DesiredApps) { [void]$DesiredAudiences.Add("api://$Id") } + if ($McpClientIds.Count -gt 0 -and $env:WEBSITE_HOSTNAME) { + [void]$DesiredAudiences.Add("https://$($env:WEBSITE_HOSTNAME)") + [void]$DesiredAudiences.Add("https://$($env:WEBSITE_HOSTNAME)/api/ExecMcp") + foreach ($McpId in $McpClientIds) { if (-not [string]::IsNullOrEmpty($McpId)) { [void]$DesiredAudiences.Add($McpId) } } + } + + # Current state from the platform-injected config + $CurrentApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($App in @($AADConfig.validation.defaultAuthorizationPolicy.allowedApplications)) { if ($App) { [void]$CurrentApps.Add($App) } } + $CurrentAudiences = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Aud in @($AADConfig.validation.allowedAudiences)) { if ($Aud) { [void]$CurrentAudiences.Add($Aud) } } + + # Drift when anything the endpoint would set is missing from the live config + $AppsOk = $DesiredApps.IsSubsetOf($CurrentApps) + $AudiencesOk = $DesiredAudiences.IsSubsetOf($CurrentAudiences) + + if (-not $AppsOk -or -not $AudiencesOk) { + $MissingApps = @($DesiredApps | Where-Object { -not $CurrentApps.Contains($_) }) + $MissingAudiences = @($DesiredAudiences | Where-Object { -not $CurrentAudiences.Contains($_) }) + Write-Information "[Auth-Init] API client drift detected — missing apps: [$($MissingApps -join ', ')]; missing audiences: [$($MissingAudiences -join ', ')] — reconciling EasyAuth" + Set-CippApiAuth -TenantId $env:TenantID -ClientIds $EnabledClientIds -McpClientIds $McpClientIds + Write-Information '[Auth-Init] EasyAuth allowedApplications + allowedAudiences reconciled with enabled API clients' + } else { + Write-Information "[Auth-Init] EasyAuth already matches $($EnabledClients.Count) enabled API client(s) — no update needed" + } + } + } catch { + Write-Information "[Auth-Init] API client reconcile failed (non-fatal): $_" + } + } } elseif ($AuthState.HasSAMCredentials) { # EasyAuth NOT configured but we DO have SAM credentials — try to auto-configure Write-Information '[Auth-Init] EasyAuth not configured but SAM credentials available — attempting auto-configuration' diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 new file mode 100644 index 000000000000..7c3894d872e4 --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 @@ -0,0 +1,110 @@ +function ConvertTo-CIPPSensitivityLabelParams { + <# + .SYNOPSIS + Normalize a sensitivity label template/object into the flat parameter shape that New-Label/Set-Label expect. + .DESCRIPTION + Get-Label (the read shape) does not expose flat Encryption*/Apply* properties. Instead it encodes + encryption, content marking and watermarking inside the 'LabelActions' array, e.g. + + { "Type":"encrypt", "SubType":null, "Settings":[ {"Key":"protectiontype","Value":"userdefined"}, ... ] } + { "Type":"applycontentmarking", "SubType":"footer", "Settings":[ {"Key":"text","Value":"..."}, ... ] } + + New-Label/Set-Label (the write shape) instead take flat 'Apply*'/'Encryption*' parameters. This + function bridges the two: when a label object carries 'LabelActions' it expands those actions into + the flat parameters and drops the read-only 'LabelActions'/'Settings'/'LocaleSettings'/'Conditions' + arrays (which are not valid input in their read form). A flat object (manual JSON authored against + the deploy schema) has no 'LabelActions' and passes through unchanged. + + Deploy-time validation/allowlisting still happens in Set-CIPPSensitivityLabel via + Get-CIPPSensitivityLabelField; this function only reshapes. + .PARAMETER Label + The label template/object to normalize (a Get-Label object, a stored template, or flat manual JSON). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Label + ) + + # A captured Get-Label object always has a LabelActions property (even if empty); flat manual JSON does not. + $HasActions = [bool]$Label.PSObject.Properties['LabelActions'] + # Read-shape arrays that are not valid New-/Set-Label input - dropped when reshaping a captured label. + $ReadShapeArrays = @('LabelActions', 'Settings', 'LocaleSettings', 'Conditions') + + $Flat = [ordered]@{} + foreach ($Prop in $Label.PSObject.Properties) { + if ($HasActions -and $Prop.Name -in $ReadShapeArrays) { continue } + $Flat[$Prop.Name] = $Prop.Value + } + + if (-not $HasActions) { + return [pscustomobject]$Flat + } + + foreach ($Raw in @($Label.LabelActions)) { + if ($null -eq $Raw) { continue } + $Action = if ($Raw -is [string]) { $Raw | ConvertFrom-Json } else { $Raw } + + $Set = @{} + foreach ($KV in $Action.Settings) { $Set[$KV.Key] = $KV.Value } + $Enabled = ($Set['disabled'] -ne 'true') + + switch ($Action.Type) { + 'encrypt' { + $Flat['EncryptionEnabled'] = $Enabled + if (-not $Enabled) { break } + + $ProtectionType = "$($Set['protectiontype'])".ToLower() + if ($ProtectionType -eq 'template') { + $Flat['EncryptionProtectionType'] = 'Template' + if ($Set['templateid']) { $Flat['EncryptionTemplateId'] = $Set['templateid'] } + if ($Set.ContainsKey('contentexpiredondateindaysornever')) { $Flat['EncryptionContentExpiredOnDateInDaysOrNever'] = $Set['contentexpiredondateindaysornever'] } + if ($Set.ContainsKey('offlineaccessdays')) { $Flat['EncryptionOfflineAccessDays'] = [int]$Set['offlineaccessdays'] } + } else { + $Flat['EncryptionProtectionType'] = 'UserDefined' + if ($Set.ContainsKey('donotforward')) { $Flat['EncryptionDoNotForward'] = ($Set['donotforward'] -eq 'true') } + if ($Set.ContainsKey('encryptonly')) { $Flat['EncryptionEncryptOnly'] = ($Set['encryptonly'] -eq 'true') } + if ($Set.ContainsKey('promptuser')) { $Flat['EncryptionPromptUser'] = ($Set['promptuser'] -eq 'true') } + } + } + 'applycontentmarking' { + $Prefix = switch ("$($Action.SubType)".ToLower()) { + 'header' { 'ApplyContentMarkingHeader' } + 'footer' { 'ApplyContentMarkingFooter' } + 'watermark' { 'ApplyWaterMarking' } + default { $null } + } + if (-not $Prefix) { break } + + $Flat["${Prefix}Enabled"] = $Enabled + if ($Set['text']) { $Flat["${Prefix}Text"] = $Set['text'] } + if ($Set['fontcolor']) { $Flat["${Prefix}FontColor"] = $Set['fontcolor'] } + if ($Set['fontname']) { $Flat["${Prefix}FontName"] = $Set['fontname'] } + if ($Set.ContainsKey('fontsize') -and "$($Set['fontsize'])".Trim()) { $Flat["${Prefix}FontSize"] = [int]$Set['fontsize'] } + if ($Prefix -eq 'ApplyWaterMarking') { + if ($Set['layout']) { $Flat['ApplyWaterMarkingLayout'] = $Set['layout'] } + } else { + if ($Set['alignment']) { $Flat["${Prefix}Alignment"] = $Set['alignment'] } + if ($Action.SubType -eq 'footer' -and $Set.ContainsKey('margin') -and "$($Set['margin'])".Trim()) { $Flat["${Prefix}Margin"] = [int]$Set['margin'] } + } + } + 'applywatermarking' { + $Flat['ApplyWaterMarkingEnabled'] = $Enabled + if ($Set['text']) { $Flat['ApplyWaterMarkingText'] = $Set['text'] } + if ($Set['fontcolor']) { $Flat['ApplyWaterMarkingFontColor'] = $Set['fontcolor'] } + if ($Set['fontname']) { $Flat['ApplyWaterMarkingFontName'] = $Set['fontname'] } + if ($Set.ContainsKey('fontsize') -and "$($Set['fontsize'])".Trim()) { $Flat['ApplyWaterMarkingFontSize'] = [int]$Set['fontsize'] } + if ($Set['layout']) { $Flat['ApplyWaterMarkingLayout'] = $Set['layout'] } + } + 'protectgroup' { + $Flat['SiteAndGroupProtectionEnabled'] = $Enabled + if ($Set['privacy']) { $Flat['SiteAndGroupProtectionPrivacy'] = $Set['privacy'] } + if ($Set.ContainsKey('allowaccesstoguestusers')) { $Flat['SiteAndGroupProtectionAllowAccessToGuestUsers'] = ($Set['allowaccesstoguestusers'] -eq 'true') } + if ($Set.ContainsKey('allowemailfromguestusers')) { $Flat['SiteAndGroupProtectionAllowEmailFromGuestUsers'] = ($Set['allowemailfromguestusers'] -eq 'true') } + } + } + } + + return [pscustomobject]$Flat +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 index 97dd96f5bf7d..0b382a26e431 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 @@ -61,8 +61,21 @@ function New-CippCoreRequest { } } + # Store action source + acting identity for outbound User-Agent attribution + Set-CippUserAgentContext -Headers $Request.Headers + # Check if endpoint is disabled via feature flags $FeatureFlags = Get-CIPPFeatureFlag + + # In CIPP-NG, force-enable SuperAdminNG regardless of the stored flag state. + if ($env:CIPPNG -eq 'true') { + foreach ($Flag in $FeatureFlags) { + if ($Flag.Id -eq 'SuperAdminNG') { + $Flag.Enabled = $true + } + } + } + $DisabledEndpoint = $FeatureFlags | Where-Object { $_.Enabled -eq $false -and $_.Endpoints -contains $Request.Params.CIPPEndpoint } | Select-Object -First 1 diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index 7df1c6fa0b46..4484f851a9fe 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -5,6 +5,7 @@ function Start-CIPPStatsTimer { #> [CmdletBinding(SupportsShouldProcess = $true)] param() + #These stats are sent to a central server to help us understand how many tenants are using the product, and how many are using the latest version, this information allows the CIPP team to make decisions about what features to support, and what features to deprecate. @@ -15,7 +16,11 @@ function Start-CIPPStatsTimer { $TenantCount = (Get-Tenants -IncludeAll).count - $APIVersion = Get-Content (Join-Path $env:CIPPRootPath 'version_latest.txt') | Out-String + $APIVersion = if ($env:CIPPNG -eq 'true') { + $env:APP_VERSION + } else { + Get-Content (Join-Path $env:CIPPRootPath 'version_latest.txt') | Out-String + } $Table = Get-CIPPTable -TableName Extensionsconfig try { $RawExt = (Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -Depth 10 -ErrorAction Stop @@ -27,6 +32,7 @@ function Start-CIPPStatsTimer { $FunctionOffloading = (Get-CIPPAzDataTableEntity @ConfigTable -Filter "RowKey eq 'OffloadFunctions' and PartitionKey eq 'OffloadFunctions'").state $OffloadingEnabled = $false [bool]::TryParse($FunctionOffloading, [ref]$OffloadingEnabled) | Out-Null + $CIPPNG = $env:CIPPNG -eq 'true' # Get counts of various entities across all tenants $counts = Get-CIPPDbItem -TenantFilter AllTenants -CountsOnly @@ -35,30 +41,42 @@ function Start-CIPPStatsTimer { $groupsCount = ($counts | Where-Object { $_.RowKey -eq 'Groups-Count' } | Measure-Object -Property DataCount -Sum).Sum $managedDevicesCount = ($counts | Where-Object { $_.RowKey -eq 'ManagedDevices-Count' } | Measure-Object -Property DataCount -Sum).Sum $policyCount = ($counts | Where-Object { $_.RowKey -match 'Intune' -and $_.RowKey -match 'Policies|Policy' } | Measure-Object -Property DataCount -Sum).Sum + $deployedApps = ($counts | Where-Object { $_.RowKey -eq 'IntuneApplications-Count' } | Measure-Object -Property DataCount -Sum).Sum + $ReportsTable = Get-CippTable -tablename 'ReportBuilderTemplates' + $CustomReportCount = (Get-CIPPAzDataTableEntity @ReportsTable -Filter "PartitionKey eq 'ReportBuilderTemplates'").count + $uniqueStandardsApplied = Get-CIPPStatsUniqueStandardsApplied + $driftStandardsCount = Get-CIPPStatsDriftStandardsCount + $mobileEnrollment = Get-CIPPStatsMobileEnrollment $SendingObject = [PSCustomObject]@{ - rgid = $env:WEBSITE_SITE_NAME - SetupComplete = $SetupComplete - Hosted = $env:CIPP_HOSTED -eq 'true' - OffloadingEnabled = $OffloadingEnabled - RunningVersionAPI = $APIVersion.trim() - CountOfTotalTenants = $TenantCount - uid = $env:TenantID - UserCount = $userCount - DeviceCount = $deviceCount - GroupsCount = $groupsCount - ManagedDevicesCount = $managedDevicesCount - PolicyCount = $policyCount - CIPPAPI = $RawExt.CIPPAPI.Enabled - Hudu = $RawExt.Hudu.Enabled - Sherweb = $RawExt.Sherweb.Enabled - Gradient = $RawExt.Gradient.Enabled - NinjaOne = $RawExt.NinjaOne.Enabled - haloPSA = $RawExt.haloPSA.Enabled - HIBP = $RawExt.HIBP.Enabled - PWPush = $RawExt.PWPush.Enabled - CFZTNA = $RawExt.CFZTNA.Enabled - GitHub = $RawExt.GitHub.Enabled + rgid = $env:WEBSITE_SITE_NAME + SetupComplete = $SetupComplete + Hosted = $env:CIPP_HOSTED -eq 'true' + CIPPNG = $CIPPNG + OffloadingEnabled = $OffloadingEnabled + RunningVersionAPI = $APIVersion.trim() + CountOfTotalTenants = $TenantCount + uid = $env:TenantID + UserCount = $userCount + DeviceCount = $deviceCount + GroupsCount = $groupsCount + ManagedDevicesCount = $managedDevicesCount + PolicyCount = $policyCount + UniqueStandardsApplied = $uniqueStandardsApplied + DriftStandardsCount = $driftStandardsCount + MobileEnrollment = $mobileEnrollment + DeployedApps = $deployedApps + CustomReportCount = $CustomReportCount + CIPPAPI = $RawExt.CIPPAPI.Enabled + Hudu = $RawExt.Hudu.Enabled + Sherweb = $RawExt.Sherweb.Enabled + Gradient = $RawExt.Gradient.Enabled + NinjaOne = $RawExt.NinjaOne.Enabled + haloPSA = $RawExt.haloPSA.Enabled + HIBP = $RawExt.HIBP.Enabled + PWPush = $RawExt.PWPush.Enabled + CFZTNA = $RawExt.CFZTNA.Enabled + GitHub = $RawExt.GitHub.Enabled } | ConvertTo-Json try { diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 index 82fe3c739d2b..9a171e606982 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1 @@ -120,46 +120,55 @@ function Start-ContainerUpdateCheck { $CheckTag = if ($CurrentImage -match ':([^:]+)$') { $Matches[1] } else { $ImageTag } - # Parse image path for GHCR $imagePath = $CurrentImage -replace '^ghcr\.io/', '' -replace ':.*$', '' if (-not $imagePath) { Write-LogMessage -API 'ContainerUpdateCheck' -message 'Could not parse image path from reference' -sev Warning return } - # Get anonymous GHCR token - $tokenUri = "https://ghcr.io/token?scope=repository:${imagePath}:pull" - $tokenResp = Invoke-RestMethod -Uri $tokenUri -Method GET -ErrorAction Stop - $token = $tokenResp.token + $tokenResp = Invoke-RestMethod -Uri "https://ghcr.io/token?scope=repository:${imagePath}:pull" -Method GET -ErrorAction Stop + $authHeader = @{ Authorization = "Bearer $($tokenResp.token)" } + $manifestAccept = 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' - $digestHeaders = @{ - Authorization = "Bearer $token" - Accept = 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' + # PS7's Invoke-WebRequest returns .Content as byte[] when the response lacks a charset + # (GHCR manifest media types omit it), so piping straight to ConvertFrom-Json yields + # an int array — leaving RemoteVersion null and UpdateAvailable always false. Decode bytes first. + function ConvertFrom-RawJson($Content) { + if ($Content -is [byte[]]) { $Content = [System.Text.Encoding]::UTF8.GetString($Content) } + return $Content | ConvertFrom-Json } - # Get remote digest for the configured channel tag - $manifestUri = "https://ghcr.io/v2/$imagePath/manifests/$CheckTag" - $resp = Invoke-WebRequest -Uri $manifestUri -Method HEAD -Headers $digestHeaders -ErrorAction Stop + # GET the channel tag's manifest — extract digest + version label set by CI + # (org.opencontainers.image.version mirrors $env:APP_VERSION in the running container). + $manifestHeaders = $authHeader + @{ Accept = $manifestAccept } + $resp = Invoke-WebRequest -Uri "https://ghcr.io/v2/$imagePath/manifests/$CheckTag" -Method GET -Headers $manifestHeaders -ErrorAction Stop $RemoteDigest = $resp.Headers['Docker-Content-Digest'] if ($RemoteDigest -is [array]) { $RemoteDigest = $RemoteDigest[0] } + $manifest = ConvertFrom-RawJson $resp.Content - # Get running digest for the baked-in image tag - $RunningDigest = $null - try { - $runningUri = "https://ghcr.io/v2/$imagePath/manifests/$ImageTag" - $runResp = Invoke-WebRequest -Uri $runningUri -Method HEAD -Headers $digestHeaders -ErrorAction Stop - $RunningDigest = $runResp.Headers['Docker-Content-Digest'] - if ($RunningDigest -is [array]) { $RunningDigest = $RunningDigest[0] } - } catch { - Write-Information "Could not get running digest for tag $ImageTag" + if ($manifest.manifests) { + $child = $manifest.manifests | Where-Object { $_.platform.architecture -eq 'amd64' -and $_.platform.os -eq 'linux' } | Select-Object -First 1 + if (-not $child) { $child = $manifest.manifests | Select-Object -First 1 } + $childResp = Invoke-WebRequest -Uri "https://ghcr.io/v2/$imagePath/manifests/$($child.digest)" -Method GET -Headers $manifestHeaders -ErrorAction Stop + $manifest = ConvertFrom-RawJson $childResp.Content + } + + $RemoteVersion = $manifest.annotations.'org.opencontainers.image.version' + if (-not $RemoteVersion -and $manifest.config.digest) { + try { + $config = Invoke-RestMethod -Uri "https://ghcr.io/v2/$imagePath/blobs/$($manifest.config.digest)" -Method GET -Headers $authHeader -ErrorAction Stop + $RemoteVersion = $config.config.Labels.'org.opencontainers.image.version' + } catch { + Write-Information "Could not read image config labels: $($_.Exception.Message)" + } } + $RunningVersion = $env:APP_VERSION $UpdateAvailable = $false - if ($RemoteDigest -and $RunningDigest -and $RemoteDigest -ne $RunningDigest) { + if ($RemoteVersion -and $RunningVersion -and $RemoteVersion -ne $RunningVersion) { $UpdateAvailable = $true } - # Update the settings row with results (preserve user settings) $UpdateEntity = @{ PartitionKey = 'Settings' RowKey = 'UpdateConfig' @@ -168,22 +177,23 @@ function Start-ContainerUpdateCheck { CheckTime = [string]($Settings.CheckTime ?? '') LastCheck = [string][int64](([DateTimeOffset]::UtcNow).ToUnixTimeSeconds()) UpdateAvailable = [string]$UpdateAvailable - RunningDigest = [string]($RunningDigest ?? '') + RunningVersion = [string]($RunningVersion ?? '') + RemoteVersion = [string]($RemoteVersion ?? '') RemoteDigest = [string]($RemoteDigest ?? '') } Add-CIPPAzDataTableEntity @SettingsTable -Entity $UpdateEntity -Force | Out-Null if ($UpdateAvailable -and $Settings.AutoUpdate -eq 'true') { - Write-LogMessage -API 'ContainerUpdateCheck' -message "Auto-update: new container image detected (running: $RunningDigest, remote: $RemoteDigest). Restarting." -sev Info + Write-LogMessage -API 'ContainerUpdateCheck' -message "Auto-update: new container version detected (running: $RunningVersion, remote: $RemoteVersion). Restarting." -sev Info try { - Request-CIPPRestart -Reason 'Auto-update: new container image available' + Request-CIPPRestart -Reason 'Auto-update: new container version available' } catch { - Write-LogMessage -API 'ContainerUpdateCheck' -message 'Auto-restart requested but AppLifecycleBridge is not available' -sev Warning + Write-LogMessage -API 'ContainerUpdateCheck' -message 'Auto-restart requested but restart bridge is not available' -sev Warning } } elseif ($UpdateAvailable) { - Write-LogMessage -API 'ContainerUpdateCheck' -message "Container update available (running: $RunningDigest, remote: $RemoteDigest)" -sev Info + Write-LogMessage -API 'ContainerUpdateCheck' -message "Container update available (running: $RunningVersion, remote: $RemoteVersion)" -sev Info } else { - Write-Information "Container is up to date. Digest: $RunningDigest" + Write-Information "Container is up to date. Version: $RunningVersion" } } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 index f4578bccecdf..435d3a3d4a01 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 @@ -57,7 +57,7 @@ function Start-UserSyncTimer { $Upn = $Upn.Trim().ToLower() if (-not $UserRoleMap.ContainsKey($Upn)) { - $UserRoleMap[$Upn] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $UserRoleMap[$Upn] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) } foreach ($Role in $RolesForGroup) { [void]$UserRoleMap[$Upn].Add($Role) @@ -79,54 +79,56 @@ function Start-UserSyncTimer { $UsersTable = Get-CippTable -tablename 'allowedUsers' $ExistingUsers = @(Get-CIPPAzDataTableEntity @UsersTable | Where-Object { -not $_.RowKey.StartsWith('_') }) - # Build lookup of existing users + # Group existing rows by lowercased UPN so case-variant duplicate rows + # are reconciled into one canonical row. $ExistingLookup = @{} foreach ($Existing in $ExistingUsers) { - $ExistingLookup[$Existing.RowKey.ToLower()] = $Existing + $Key = $Existing.RowKey.ToLower() + if (-not $ExistingLookup.ContainsKey($Key)) { + $ExistingLookup[$Key] = [System.Collections.Generic.List[object]]::new() + } + $ExistingLookup[$Key].Add($Existing) } $Now = (Get-Date).ToUniversalTime().ToString('o') $UpsertCount = 0 $RemoveCount = 0 $EntitiesToUpsert = [System.Collections.Generic.List[object]]::new() + $EntitiesToRemove = [System.Collections.Generic.List[object]]::new() - # Upsert users from Graph + # Upsert users that are members of a mapped role group foreach ($Upn in $UserRoleMap.Keys) { $AutoRoles = @($UserRoleMap[$Upn] | Sort-Object) - $ManualRoles = @() - $Source = 'Auto' - + # Merge manual roles from every case-variant of this user (case-sensitive dedupe) + $ManualRoles = [System.Collections.Generic.List[string]]::new() if ($ExistingLookup.ContainsKey($Upn)) { - $Existing = $ExistingLookup[$Upn] - - # Preserve manual roles if they exist - if ($Existing.ManualRoles) { - try { - $ManualRoles = @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop) - } catch { - $ManualRoles = @() + foreach ($Existing in $ExistingLookup[$Upn]) { + if ($Existing.ManualRoles) { + try { + foreach ($R in @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)) { + if (-not $ManualRoles.Contains($R)) { $ManualRoles.Add($R) } + } + } catch {} } - } - - # If user was previously manual-only and now also auto, mark as Both - if ($ManualRoles.Count -gt 0) { - $Source = 'Both' + # Any row that isn't the canonical lowercase key is a duplicate to remove + if ($Existing.RowKey -cne $Upn) { $EntitiesToRemove.Add($Existing) } } } + $Source = if ($ManualRoles.Count -gt 0) { 'Both' } else { 'Auto' } - # Compute effective roles = union of auto + manual - $EffectiveRoles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Role in $AutoRoles) { [void]$EffectiveRoles.Add($Role) } - foreach ($Role in $ManualRoles) { [void]$EffectiveRoles.Add($Role) } + # Compute effective roles = auto ∪ manual (case-sensitive dedupe) + $EffectiveRoles = [System.Collections.Generic.List[string]]::new() + foreach ($Role in $AutoRoles) { if (-not $EffectiveRoles.Contains($Role)) { $EffectiveRoles.Add($Role) } } + foreach ($Role in $ManualRoles) { if (-not $EffectiveRoles.Contains($Role)) { $EffectiveRoles.Add($Role) } } $EffectiveRolesArray = @($EffectiveRoles | Sort-Object) $Entity = @{ PartitionKey = 'User' RowKey = $Upn Roles = [string]($EffectiveRolesArray | ConvertTo-Json -Compress -AsArray) - AutoRoles = [string]($AutoRoles | ConvertTo-Json -Compress -AsArray) - ManualRoles = [string](($ManualRoles.Count -gt 0 ? $ManualRoles : @()) | ConvertTo-Json -Compress -AsArray) + AutoRoles = [string](@($AutoRoles) | ConvertTo-Json -Compress -AsArray) + ManualRoles = [string]((($ManualRoles.Count -gt 0) ? @($ManualRoles) : @()) | ConvertTo-Json -Compress -AsArray) Source = $Source LastSync = $Now } @@ -135,57 +137,87 @@ function Start-UserSyncTimer { $UpsertCount++ } - # Handle users that were auto-provisioned but are no longer in any role group - foreach ($Existing in $ExistingUsers) { - $ExistingUpn = $Existing.RowKey.ToLower() - if ($UserRoleMap.ContainsKey($ExistingUpn)) { continue } # Still in a group, already handled - - if ($Existing.Source -eq 'Auto') { - # Purely auto-provisioned user no longer in any group — remove - Remove-AzDataTableEntity -Force @UsersTable -Entity $Existing - $RemoveCount++ - } elseif ($Existing.Source -eq 'Both') { - # Was both auto + manual — clear auto roles, keep manual only - $ManualRoles = @() + # Reconcile existing users that are NOT in any mapped role group + foreach ($Key in $ExistingLookup.Keys) { + if ($UserRoleMap.ContainsKey($Key)) { continue } # Still in a group, already handled + + $Variants = $ExistingLookup[$Key] + $NeedsNormalize = ($Variants.Count -gt 1) -or ($Variants[0].RowKey -cne $Key) + + # Merge manual roles across all case-variants (case-sensitive dedupe) + $ManualRoles = [System.Collections.Generic.List[string]]::new() + foreach ($Existing in $Variants) { if ($Existing.ManualRoles) { try { - $ManualRoles = @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop) - } catch { - $ManualRoles = @() - } + foreach ($R in @($Existing.ManualRoles | ConvertFrom-Json -ErrorAction Stop)) { + if (-not $ManualRoles.Contains($R)) { $ManualRoles.Add($R) } + } + } catch {} } + } - if ($ManualRoles.Count -gt 0) { - $Entity = @{ - PartitionKey = 'User' - RowKey = $Existing.RowKey - Roles = [string]($ManualRoles | ConvertTo-Json -Compress -AsArray) - AutoRoles = '[]' - ManualRoles = [string]($ManualRoles | ConvertTo-Json -Compress -AsArray) - Source = 'Manual' - LastSync = $Now + if (-not $NeedsNormalize) { + # Single clean lowercase row — apply the original cleanup rules + $Existing = $Variants[0] + if ($Existing.Source -eq 'Auto') { + # Purely auto-provisioned user no longer in any group — remove + $EntitiesToRemove.Add($Existing) + } elseif ($Existing.Source -eq 'Both') { + if ($ManualRoles.Count -gt 0) { + # Was both auto + manual — clear auto roles, keep manual only + $ManualArray = @($ManualRoles | Sort-Object) + $EntitiesToUpsert.Add(@{ + PartitionKey = 'User' + RowKey = $Key + Roles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + AutoRoles = '[]' + ManualRoles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + Source = 'Manual' + LastSync = $Now + }) + } else { + $EntitiesToRemove.Add($Existing) } - $EntitiesToUpsert.Add($Entity) - } else { - # No manual roles either — remove - Remove-AzDataTableEntity -Force @UsersTable -Entity $Existing - $RemoveCount++ } + # Source = 'Manual' (or unset) — leave untouched, these are purely manual entries + continue } - # Source = 'Manual' (or unset) — leave untouched, these are purely manual entries - } - # Batch upsert - if ($EntitiesToUpsert.Count -gt 0) { - foreach ($Entity in $EntitiesToUpsert) { - Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force + # Duplicates or non-lowercase casing present — collapse to one canonical lowercase row + if ($ManualRoles.Count -gt 0) { + $ManualArray = @($ManualRoles | Sort-Object) + $EntitiesToUpsert.Add(@{ + PartitionKey = 'User' + RowKey = $Key + Roles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + AutoRoles = '[]' + ManualRoles = [string]($ManualArray | ConvertTo-Json -Compress -AsArray) + Source = 'Manual' + LastSync = $Now + }) + # Remove every case-variant except the canonical one (overwritten by the upsert) + foreach ($Existing in $Variants) { + if ($Existing.RowKey -cne $Key) { $EntitiesToRemove.Add($Existing) } + } + } else { + # No manual roles anywhere — purely auto-provisioned; remove all variants + foreach ($Existing in $Variants) { $EntitiesToRemove.Add($Existing) } } } + # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows) + foreach ($Entity in $EntitiesToUpsert) { + Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force + } + foreach ($Entity in $EntitiesToRemove) { + Remove-AzDataTableEntity -Force @UsersTable -Entity $Entity + $RemoveCount++ + } + # Invalidate CRAFT's in-memory user cache so changes apply try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} - Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount auto-only users removed." -sev Info + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount duplicate/stale rows removed." -sev Info } catch { $ErrorData = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 183325edfe0d..f877a2a4af80 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -409,12 +409,22 @@ function Get-CIPPDrift { # Persist newly detected deviations to the tenantDrift table so the summary page can count them $NewDriftEntities = [System.Collections.Generic.List[object]]::new() foreach ($Deviation in $AllDeviations) { - if (-not $ExistingDriftStates.ContainsKey($Deviation.standardName)) { - $RowKey = $Deviation.standardName -replace '\.', '_' + # Diagnostic: standardName must be a scalar string. Azure Tables cannot store a PSObject, + # so a non-string here is what causes "Unsupported property types found: StandardName". + # Log the offending value (with tenant) so the producing standard can be identified. + if ($Deviation.standardName -isnot [string]) { + Write-Warning "Drift deviation for tenant '$TenantFilter' has a non-string standardName (type $($Deviation.standardName.GetType().FullName)): $(ConvertTo-Json -InputObject $Deviation.standardName -Depth 5 -Compress -ErrorAction SilentlyContinue)" + } + # Coerce to string so the table write never fails on this property. RowKey already + # coerces via -replace; this makes the stored StandardName column match. + $StandardNameValue = [string]$Deviation.standardName + if ([string]::IsNullOrWhiteSpace($StandardNameValue)) { continue } + if (-not $ExistingDriftStates.ContainsKey($StandardNameValue)) { + $RowKey = $StandardNameValue -replace '\.', '_' $NewDriftEntities.Add(@{ PartitionKey = $TenantFilter RowKey = $RowKey - StandardName = $Deviation.standardName + StandardName = $StandardNameValue Status = 'New' LastModified = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }) diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index 4260633df330..7e7c32e5285e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -5,7 +5,8 @@ function Get-CIPPLicenseOverview { $TenantFilter, $APIName = 'Get License Overview', $Headers, - [switch]$AlertMode + [switch]$AlertMode, + [switch]$IncludeExcluded ) $Requests = @( @@ -72,6 +73,7 @@ function Get-CIPPLicenseOverview { $null -eq $_.ExcludedEverywhere -or $_.ExcludedEverywhere -eq $true } | ForEach-Object { $_.GUID }) } + $DropdownVisibleGuids = @($ExcludedSkuList | Where-Object { $_.ShowInLicenseDropdown -eq $true } | ForEach-Object { $_.GUID }) $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) | Sort-Object -Property displayName $UsersBySku = @{} @@ -120,7 +122,9 @@ function Get-CIPPLicenseOverview { $GraphRequest = foreach ($singleReq in $RawGraphRequest) { $skuId = $singleReq.Licenses foreach ($sku in $skuId) { - if ($sku.skuId -in $EffectiveExcludedGuids) { continue } + if ($sku.skuId -in $EffectiveExcludedGuids) { + if (!$IncludeExcluded -or $sku.skuId -notin $DropdownVisibleGuids) { continue } + } $PrettyNameAdmin = $AdminPortalLicenses | Where-Object { $_.aadSkuId -eq $sku.skuId } | Select-Object -ExpandProperty displayName -First 1 $PrettyNameCSV = ($ConvertTable | Where-Object { $_.guid -eq $sku.skuid }).'Product_Display_Name' | Select-Object -Last 1 $PrettyName = $PrettyNameAdmin ?? $PrettyNameCSV ?? $sku.skuPartNumber @@ -172,4 +176,3 @@ function Get-CIPPLicenseOverview { } return ($GraphRequest | Sort-Object -Property License) } - diff --git a/Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 b/Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 new file mode 100644 index 000000000000..61f280d3429f --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSensitivityLabelField.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPSensitivityLabelField { + <# + .SYNOPSIS + Returns the valid New-Label / Set-Label parameter names CIPP supports for sensitivity label deployment. + .DESCRIPTION + Single source of truth for the sensitivity label field allowlist, shared by Set-CIPPSensitivityLabel + (deploy) and Invoke-AddSensitivityLabelTemplate (capture keep-list) so the two cannot drift. + + Names match the Microsoft Purview New-Label/Set-Label cmdlet parameters exactly. Note the content + marking and watermark parameters are all 'Apply'-prefixed (ApplyContentMarkingHeaderText, + ApplyWaterMarkingText, ...) - the bare 'ContentMarking*' names do not exist and cause an + AmbiguousParameterSetException. + + 'Priority' is included here but is only valid on Set-Label, not New-Label - Set-CIPPSensitivityLabel + applies it via a dedicated Set-Label call. 'Disabled' is intentionally absent because it is not a + valid parameter on either cmdlet. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + return @( + # Core + 'Name', 'DisplayName', 'Comment', 'Tooltip', 'ParentId', 'ContentType', 'Priority', + 'Conditions', 'LocaleSettings', 'Settings', 'AdvancedSettings', + + # Encryption + 'EncryptionEnabled', 'EncryptionProtectionType', + 'EncryptionTemplateId', 'EncryptionLinkedTemplateId', 'EncryptionAipTemplateScopes', + 'EncryptionRightsDefinitions', 'EncryptionContentExpiredOnDateInDaysOrNever', + 'EncryptionDoNotForward', 'EncryptionEncryptOnly', 'EncryptionPromptUser', + 'EncryptionOfflineAccessDays', + + # Content marking - header + 'ApplyContentMarkingHeaderEnabled', 'ApplyContentMarkingHeaderText', + 'ApplyContentMarkingHeaderFontSize', 'ApplyContentMarkingHeaderFontColor', + 'ApplyContentMarkingHeaderFontName', 'ApplyContentMarkingHeaderAlignment', + 'ApplyContentMarkingHeaderMargin', + + # Content marking - footer + 'ApplyContentMarkingFooterEnabled', 'ApplyContentMarkingFooterText', + 'ApplyContentMarkingFooterFontSize', 'ApplyContentMarkingFooterFontColor', + 'ApplyContentMarkingFooterFontName', 'ApplyContentMarkingFooterAlignment', + 'ApplyContentMarkingFooterMargin', + + # Watermark + 'ApplyWaterMarkingEnabled', 'ApplyWaterMarkingText', + 'ApplyWaterMarkingFontSize', 'ApplyWaterMarkingFontColor', + 'ApplyWaterMarkingFontName', 'ApplyWaterMarkingLayout', + + # Site & group protection + 'SiteAndGroupProtectionEnabled', 'SiteAndGroupProtectionPrivacy', + 'SiteAndGroupProtectionLevel', + 'SiteAndGroupProtectionAllowAccessToGuestUsers', + 'SiteAndGroupProtectionAllowEmailFromGuestUsers', + 'SiteAndGroupProtectionAllowFullAccess', + 'SiteAndGroupProtectionAllowLimitedAccess', + 'SiteAndGroupProtectionBlockAccess' + ) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 b/Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 new file mode 100644 index 000000000000..f8bf40cae001 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSiteVersionCleanupStatus.ps1 @@ -0,0 +1,83 @@ +function Get-CIPPSiteVersionCleanupStatus { + <# + .SYNOPSIS + Get the progress of a file version batch delete (trim) job for a SharePoint site + + .DESCRIPTION + Queries the progress of the file version batch delete job for a SharePoint site via the + CSOM GetFileVersionBatchDeleteJobProgress method on the Tenant object, using the same + ProcessQuery channel as Start-CIPPSiteVersionCleanup. Reports the status of a cleanup + previously started with Start-CIPPSiteVersionCleanup. + + Unlike NewFileVersionBatchDeleteJob / RemoveFileVersionBatchDeleteJob (which return an + SpoOperation object that the client serialises with a ), + GetFileVersionBatchDeleteJobProgress returns a plain String whose content is a JSON blob. + It is therefore invoked as a bare inside with no wrapper - asking + for SelectAllProperties on a String fails server-side with "Cannot find stub for type + System.String". The ProcessQuery response is an array whose only String element is the JSON + progress payload, which this function parses and returns. (Confirmed against a captured + Get-SPOSiteFileVersionBatchDeleteJobProgress request.) + + .PARAMETER TenantFilter + Tenant to query + + .PARAMETER SiteUrl + Full URL of the SharePoint site to query + + .EXAMPLE + Get-CIPPSiteVersionCleanupStatus -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/MySite' + + .FUNCTIONALITY + Internal + + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$SiteUrl + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $EscapedSiteUrl = [System.Security.SecurityElement]::Escape($SiteUrl) + + # CSOM pattern: Tenant Constructor -> GetFileVersionBatchDeleteJobProgress(siteUrl). + # The method returns a String (JSON), so it is called directly in with no . + $XML = @" +$EscapedSiteUrl +"@ + + $AdditionalHeaders = @{ + 'Accept' = 'application/json;odata=verbose' + } + + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + # ProcessQuery returns a JSON array; if it came back as raw text, parse it first. + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + + # The first array element carries ErrorInfo for the whole request. + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint returned an error querying version cleanup status for $SiteUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + # GetFileVersionBatchDeleteJobProgress returns its payload as the only String element. + $ProgressJson = $Response | Where-Object { $_ -is [string] } | Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($ProgressJson)) { + return [PSCustomObject]@{ + SiteUrl = $SiteUrl + Status = 'NoJob' + Message = 'No file version batch delete job found for this site.' + } + } + + $Progress = $ProgressJson | ConvertFrom-Json + Add-Member -InputObject $Progress -MemberType NoteProperty -Name 'SiteUrl' -Value $SiteUrl -Force + return $Progress +} diff --git a/Modules/CIPPCore/Public/Get-CIPPStatsDriftStandardsCount.ps1 b/Modules/CIPPCore/Public/Get-CIPPStatsDriftStandardsCount.ps1 new file mode 100644 index 000000000000..19ad8335be57 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPStatsDriftStandardsCount.ps1 @@ -0,0 +1,55 @@ +function Get-CIPPStatsDriftStandardsCount { + [CmdletBinding()] + param() + + try { + $Standards = @(Get-CIPPStandards -TenantFilter allTenants) + $TemplateTable = Get-CippTable -tablename 'templates' + $TemplateRows = @(Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq 'StandardsTemplateV2'") + + $TemplateTypeByGuid = @{} + foreach ($TemplateRow in $TemplateRows) { + if ([string]::IsNullOrWhiteSpace($TemplateRow.JSON)) { continue } + + try { + $TemplateData = $TemplateRow.JSON | ConvertFrom-Json -Depth 30 -ErrorAction Stop + } catch { + continue + } + + $TemplateGuid = [string]($TemplateData.GUID ?? $TemplateRow.GUID) + if ([string]::IsNullOrWhiteSpace($TemplateGuid)) { continue } + + $TemplateTypeByGuid[$TemplateGuid] = [string]$TemplateData.type + } + + $DriftStandardIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Standard in $Standards) { + if ([string]::IsNullOrWhiteSpace($Standard.TemplateId)) { continue } + if ([string]::IsNullOrWhiteSpace($Standard.Standard)) { continue } + if (-not $TemplateTypeByGuid.ContainsKey([string]$Standard.TemplateId)) { continue } + if ($TemplateTypeByGuid[[string]$Standard.TemplateId] -ne 'drift') { continue } + + $TemplateValue = $null + if ($Standard.Settings -and $Standard.Settings.PSObject.Properties['TemplateList']) { + if ($Standard.Settings.TemplateList -and $Standard.Settings.TemplateList.PSObject.Properties['value']) { + $TemplateValue = [string]$Standard.Settings.TemplateList.value + } + } + + $Id = if ([string]::IsNullOrWhiteSpace($TemplateValue)) { + [string]$Standard.Standard + } else { + "{0}|{1}" -f $Standard.Standard, $TemplateValue + } + + if ([string]::IsNullOrWhiteSpace($Id)) { continue } + [void]$DriftStandardIds.Add($Id) + } + + return $DriftStandardIds.Count + } catch { + Write-LogMessage -API 'CIPPStatsTimer' -tenant $env:TenantID -message "Failed to calculate DriftStandardsCount: $($_.Exception.Message)" -sev Warning + return 0 + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPStatsMobileEnrollment.ps1 b/Modules/CIPPCore/Public/Get-CIPPStatsMobileEnrollment.ps1 new file mode 100644 index 000000000000..5932bd806b54 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPStatsMobileEnrollment.ps1 @@ -0,0 +1,28 @@ +function Get-CIPPStatsMobileEnrollment { + [CmdletBinding()] + param() + + try { + $MobileEnrollment = 0 + $ManagedDeviceRows = Get-CIPPDbItem -TenantFilter allTenants -Type 'ManagedDevices' + foreach ($ManagedDeviceRow in $ManagedDeviceRows) { + if (-not $ManagedDeviceRow.Data) { continue } + + try { + $ManagedDevice = $ManagedDeviceRow.Data | ConvertFrom-Json -Depth 20 -ErrorAction Stop + } catch { + continue + } + + $OperatingSystem = [string]$ManagedDevice.operatingSystem + if ($OperatingSystem -match 'iOS|iPadOS|Android') { + $MobileEnrollment++ + } + } + + return $MobileEnrollment + } catch { + Write-LogMessage -API 'CIPPStatsTimer' -tenant $env:TenantID -message "Failed to calculate MobileEnrollment: $($_.Exception.Message)" -sev Warning + return 0 + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPStatsUniqueStandardsApplied.ps1 b/Modules/CIPPCore/Public/Get-CIPPStatsUniqueStandardsApplied.ps1 new file mode 100644 index 000000000000..8cf15fdc88ee --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPStatsUniqueStandardsApplied.ps1 @@ -0,0 +1,34 @@ +function Get-CIPPStatsUniqueStandardsApplied { + [CmdletBinding()] + param() + + try { + $Standards = @(Get-CIPPStandards -TenantFilter allTenants) + $DistinctStandards = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($Standard in $Standards) { + if (-not $Standard.Standard) { continue } + + $TemplateValue = $null + if ($Standard.Settings -and $Standard.Settings.PSObject.Properties['TemplateList']) { + if ($Standard.Settings.TemplateList -and $Standard.Settings.TemplateList.PSObject.Properties['value']) { + $TemplateValue = [string]$Standard.Settings.TemplateList.value + } + } + + $Id = if ([string]::IsNullOrWhiteSpace($TemplateValue)) { + [string]$Standard.Standard + } else { + '{0}|{1}' -f $Standard.Standard, $TemplateValue + } + + if ([string]::IsNullOrWhiteSpace($Id)) { continue } + [void]$DistinctStandards.Add($Id) + } + + return $DistinctStandards.Count + } catch { + Write-LogMessage -API 'CIPPStatsTimer' -tenant $env:TenantID -message "Failed to calculate UniqueStandardsApplied: $($_.Exception.Message)" -sev Warning + return 0 + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippUserAgent.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippUserAgent.ps1 new file mode 100644 index 000000000000..2b2590492265 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippUserAgent.ps1 @@ -0,0 +1,34 @@ +function Get-CippUserAgent { + <# + .SYNOPSIS + Builds the User-Agent string for outbound M365 API requests. + .DESCRIPTION + Returns 'CIPP/' optionally suffixed with semicolon-delimited 'key:value' context segments + set via Set-CippUserAgentContext, e.g. 'CIPP/8.2.0 (user:john@msp.com)' or + 'CIPP/8.2.0 (scheduled-task:; user:john@msp.com)'. + This allows MDR/security teams to attribute CIPP activity in M365 audit logs. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Version = $env:CippVersion ?? $env:APP_VERSION ?? '1.0' + $Context = $script:CippUserAgentContextStorage.Value + + if ($Context.Source) { + $Segments = [System.Collections.Generic.List[string]]::new() + if ($Context.Source -in @('user', 'api')) { + # Identity belongs to the source itself, e.g. user:john@msp.com or api: + $Segments.Add($Context.Identity ? ('{0}:{1}' -f $Context.Source, $Context.Identity) : $Context.Source) + } else { + $Id = @($Context.TaskId, $Context.TemplateId) | Where-Object { $_ } | Select-Object -First 1 + $Segments.Add($Id ? ('{0}:{1}' -f $Context.Source, $Id) : $Context.Source) + if ($Context.Identity) { + $Segments.Add('user:{0}' -f $Context.Identity) + } + } + return ('CIPP/{0} ({1})' -f $Version, ($Segments -join '; ')) + } + return ('CIPP/{0}' -f $Version) +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index b00000f7f90a..a943f97e3c74 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -18,6 +18,7 @@ function New-GraphGetRequest { [switch]$IncludeResponseHeaders, [hashtable]$extraHeaders, [switch]$ReturnRawResponse, + [switch]$SkipValueExtraction, $Headers ) @@ -53,7 +54,7 @@ function New-GraphGetRequest { } if (!$headers['User-Agent']) { - $headers['User-Agent'] = "CIPP/$($global:CippVersion ?? '1.0')" + $headers['User-Agent'] = Get-CippUserAgent } @@ -105,7 +106,8 @@ function New-GraphGetRequest { $Data.'@odata.count' $NextURL = $null } else { - if ($Data.PSObject.Properties.Name -contains 'value') { $data.value } else { $Data } + + if (!$SkipValueExtraction -and $Data.PSObject.Properties.Name -contains 'value') { $data.value } else { $Data } if ($noPagination -eq $true) { if ($Caller -eq 'Get-GraphRequestList' -and $data.'@odata.nextLink') { @{ 'nextLink' = $data.'@odata.nextLink' } diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index 4bdcf468ad9c..c69c34636766 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -27,7 +27,6 @@ function New-GraphPOSTRequest { $Headers = $Headers } else { $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache - $body = Get-CIPPTextReplacement -TenantFilter $tenantid -Text $body -EscapeForJson } if ($AddedHeaders) { foreach ($header in $AddedHeaders.GetEnumerator()) { @@ -35,8 +34,10 @@ function New-GraphPOSTRequest { } } + $body = Get-CIPPTextReplacement -TenantFilter $tenantid -Text $body -EscapeForJson + if (!$headers['User-Agent']) { - $headers['User-Agent'] = "CIPP/$($env:CippVersion ?? '1.0')" + $headers['User-Agent'] = Get-CippUserAgent } if (!$contentType) { @@ -48,7 +49,7 @@ function New-GraphPOSTRequest { $RawErrorBody = $null do { try { - Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | attempt: $($RetryCount + 1) of $maxRetries" + Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | user-agent: $($headers['User-Agent']) | attempt: $($RetryCount + 1) of $maxRetries" $ReturnedData = (Invoke-CIPPRestMethod -Uri $($uri) -Method $TYPE -Body $body -Headers $headers -ContentType $contentType -SkipHttpErrorCheck:$IgnoreErrors -ResponseHeadersVariable responseHeaders) $RequestSuccessful = $true } catch { diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CippUserAgentContext.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CippUserAgentContext.ps1 new file mode 100644 index 000000000000..bde5dbe62005 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CippUserAgentContext.ps1 @@ -0,0 +1,76 @@ +function Set-CippUserAgentContext { + <# + .SYNOPSIS + Stores the execution source and user identity for the current invocation, for inclusion in outbound User-Agent strings. + .DESCRIPTION + Resolves the acting identity from the client principal headers (UPN when available, falling back to + the Entra object id claim, SWA userId, or the API client AppId) and stores it with the action source in + AsyncLocal storage so Get-CippUserAgent can build an attributable User-Agent for Graph requests. + .PARAMETER Headers + The request headers (live or stored snapshot) containing x-ms-client-principal* values. + .PARAMETER Source + The action source label, e.g. 'scheduled-task'. When omitted, 'api' is inferred for AAD API clients and 'user' otherwise. + .PARAMETER TaskId + Optional task identifier (e.g. the scheduled task RowKey) included in the User-Agent for cross-referencing. + .PARAMETER TemplateId + Optional standard template identifier included in the User-Agent for cross-referencing. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Headers, + [string]$Source, + [string]$TaskId, + [string]$TemplateId + ) + + if (-not $script:CippUserAgentContextStorage) { + $script:CippUserAgentContextStorage = [System.Threading.AsyncLocal[hashtable]]::new() + } + + $Identity = $null + $GuidRegex = '^[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}$' + + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match $GuidRegex) { + # Direct API client - principal name is the AppId + $Identity = $Headers.'x-ms-client-principal-name' + if (-not $Source) { $Source = 'api' } + } elseif ($Headers.'x-ms-client-principal') { + try { + $Principal = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json + # Prefer the UPN - human readable and meaningful to MDR/security teams reviewing M365 audit logs + $Upn = $Principal.userDetails + if ([string]::IsNullOrWhiteSpace($Upn)) { + $Upn = ($Principal.claims | Where-Object { $_.typ -in @('preferred_username', 'upn', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', 'email', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress') } | Select-Object -First 1).val + } + if ($Upn) { + $Identity = $Upn + if (-not $Source) { $Source = 'user' } + } else { + $AppId = ($Principal.claims | Where-Object { $_.typ -in @('azp', 'appid') } | Select-Object -First 1).val + if ($AppId) { + # App-only token - identify by AppId + $Identity = $AppId + if (-not $Source) { $Source = 'api' } + } else { + # Fall back to the Entra object id claim or the SWA userId + $Oid = ($Principal.claims | Where-Object { $_.typ -in @('http://schemas.microsoft.com/identity/claims/objectidentifier', 'oid') } | Select-Object -First 1).val + $Identity = $Oid ?? $Principal.userId + if (-not $Source) { $Source = 'user' } + } + } + } catch { + Write-Verbose "Failed to resolve identity from client principal: $($_.Exception.Message)" + } + } + + if ($Source) { + $script:CippUserAgentContextStorage.Value = @{ + Source = $Source + Identity = $Identity + TaskId = $TaskId + TemplateId = $TemplateId + } + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 b/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 index 904e25454d49..5a4ca5d6a9fd 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 @@ -68,18 +68,19 @@ function Write-LogMessage { if ($tenantId) { $TableRow.Add('TenantID', [string]$tenantId) } - if ($global:CippStandardInfoStorage -and $global:CippStandardInfoStorage.Value) { - $TableRow.Standard = [string]$global:CippStandardInfoStorage.Value.Standard - $TableRow.StandardTemplateId = [string]$global:CippStandardInfoStorage.Value.StandardTemplateId - if ($global:CippStandardInfoStorage.Value.IntuneTemplateId) { - $TableRow.IntuneTemplateId = [string]$global:CippStandardInfoStorage.Value.IntuneTemplateId + $StandardInfo = $script:CippStandardInfoStorage.Value + if ($StandardInfo) { + $TableRow.Standard = [string]$StandardInfo.Standard + $TableRow.StandardTemplateId = [string]$StandardInfo.StandardTemplateId + if ($StandardInfo.IntuneTemplateId) { + $TableRow.IntuneTemplateId = [string]$StandardInfo.IntuneTemplateId } - if ($global:CippStandardInfoStorage.Value.ConditionalAccessTemplateId) { - $TableRow.ConditionalAccessTemplateId = [string]$global:CippStandardInfoStorage.Value.ConditionalAccessTemplateId + if ($StandardInfo.ConditionalAccessTemplateId) { + $TableRow.ConditionalAccessTemplateId = [string]$StandardInfo.ConditionalAccessTemplateId } } - if ($global:CippScheduledTaskIdStorage -and $global:CippScheduledTaskIdStorage.Value) { - $TableRow.ScheduledTaskId = [string]$global:CippScheduledTaskIdStorage.Value + if ($script:CippScheduledTaskIdStorage.Value) { + $TableRow.ScheduledTaskId = [string]$script:CippScheduledTaskIdStorage.Value } $Table.Entity = $TableRow diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 478bd5e79bf9..f2b236fdf9b5 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -112,7 +112,7 @@ function New-CIPPCAPolicy { $JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.PSObject.Properties.Remove('@odata.context') } if ($State -and $State -ne 'donotchange') { - $JSONobj.state = $State + $JSONobj | Add-Member -NotePropertyName 'state' -NotePropertyValue $State -Force } } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -543,7 +543,7 @@ function New-CIPPCAPolicy { return $false } else { if ($State -eq 'donotchange') { - $JSONobj.state = $CheckExisting.state + $JSONobj | Add-Member -NotePropertyName 'state' -NotePropertyValue $CheckExisting.state -Force $RawJSON = ConvertTo-Json -InputObject $JSONobj -Depth 10 -Compress } # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index 767ce5747c3f..cd6d7d7d8c18 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -16,6 +16,8 @@ function Set-CIPPAuthenticationPolicy { [Parameter()][string[]]$GroupIds, [Parameter()][ValidateRange(1, 395)]$QRCodeLifetimeInDays = 365, [Parameter()][ValidateRange(8, 20)]$QRCodePinLength = 8, + [Parameter()][ValidateSet('default', 'enabled', 'disabled')]$EmailAllowExternalIdToUseEmailOtp, + [Parameter()][string[]]$EmailExcludeGroupIds, $APIName = 'Set Authentication Policy', $Headers ) @@ -99,7 +101,23 @@ function Set-CIPPAuthenticationPolicy { # Email OTP 'Email' { - # No special configuration needed + if ($State -eq 'enabled') { + if ($EmailAllowExternalIdToUseEmailOtp) { + $CurrentInfo.allowExternalIdToUseEmailOtp = $EmailAllowExternalIdToUseEmailOtp + $OptionalLogMessage = "with allowExternalIdToUseEmailOtp set to $EmailAllowExternalIdToUseEmailOtp" + } + if ($EmailExcludeGroupIds) { + $CurrentInfo.excludeTargets = @( + foreach ($id in $EmailExcludeGroupIds) { + [pscustomobject]@{ + targetType = 'group' + id = $id + } + } + ) + $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + } + } } # Certificate-based authentication diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 index ed8fdd7060e8..f575d9fc0241 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxType.ps1 @@ -14,6 +14,35 @@ function Set-CIPPMailboxType { if ([string]::IsNullOrWhiteSpace($Username)) { $Username = $UserID } $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $UserID; Type = $MailboxType } -Anchor $Username $Message = "Successfully converted $Username to a $MailboxType mailbox" + + # When converting to a shared mailbox, surface the cached mailbox size if it exceeds the + # unlicensed shared-mailbox limit (50 GiB; we warn at 49 GiB). This is best-effort: any + # lookup failure or unexpected response shape falls through to the standard success message. + if ($MailboxType -eq 'Shared') { + try { + # 49 GiB warning threshold (shared mailboxes are capped at 50 GiB without a license) + $SharedMailboxWarnBytes = 49GB + # Resolve the partition key (defaultDomainName) the reporting DB is keyed on + $PartitionKey = (Get-Tenants -TenantFilter $TenantFilter).defaultDomainName + if ($PartitionKey) { + # Server-side point lookup for this specific mailbox only. + # Cached mailbox rows are keyed RowKey = 'Mailboxes-'. + $Table = Get-CippTable -tablename 'CippReportingDB' + $Filter = "PartitionKey eq '{0}' and RowKey eq 'Mailboxes-{1}'" -f $PartitionKey, $UserID + $CachedMailbox = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Select-Object -First 1 + if ($CachedMailbox.Data) { + $StorageBytes = [int64]([string]($CachedMailbox.Data | ConvertFrom-Json).storageUsedInBytes) + if ($StorageBytes -ge $SharedMailboxWarnBytes) { + $StorageGB = [math]::Round($StorageBytes / 1GB, 1) + $Message = "$Message. Warning: detected mailbox size is $StorageGB GB, which exceeds the 50 GB shared mailbox limit. The mailbox may stop receiving mail unless an Exchange Online Plan 2 license is retained." + } + } + } + } catch { + # Best-effort size check only; ignore lookup/parse errors and return the standard message. + } + } + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter return $Message } catch { diff --git a/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 b/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 index fdad1639753d..1fb407a28228 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 @@ -16,29 +16,8 @@ function Set-CIPPSensitivityLabel { $Headers ) - $LabelAllowedFields = @( - 'Name', 'DisplayName', 'Comment', 'Tooltip', 'ParentId', - 'Disabled', 'ContentType', 'Priority', - 'EncryptionEnabled', 'EncryptionProtectionType', 'EncryptionRightsDefinitions', - 'EncryptionContentExpiredOnDateInDaysOrNever', 'EncryptionDoNotForward', - 'EncryptionEncryptOnly', 'EncryptionOfflineAccessDays', - 'EncryptionPromptUser', 'EncryptionAESKeySize', - 'ContentMarkingHeaderEnabled', 'ContentMarkingHeaderText', - 'ContentMarkingHeaderFontSize', 'ContentMarkingHeaderFontColor', 'ContentMarkingHeaderAlignment', - 'ContentMarkingFooterEnabled', 'ContentMarkingFooterText', - 'ContentMarkingFooterFontSize', 'ContentMarkingFooterFontColor', 'ContentMarkingFooterAlignment', - 'ContentMarkingFooterMargin', - 'ContentMarkingWatermarkEnabled', 'ContentMarkingWatermarkText', - 'ContentMarkingWatermarkFontSize', 'ContentMarkingWatermarkFontColor', 'ContentMarkingWatermarkLayout', - 'ApplyContentMarkingHeaderEnabled', 'ApplyContentMarkingFooterEnabled', 'ApplyWaterMarkingEnabled', - 'SiteAndGroupProtectionEnabled', 'SiteAndGroupProtectionPrivacy', - 'SiteAndGroupProtectionAllowAccessToGuestUsers', - 'SiteAndGroupProtectionAllowEmailFromGuestUsers', - 'SiteAndGroupProtectionAllowFullAccess', - 'SiteAndGroupProtectionAllowLimitedAccess', - 'SiteAndGroupProtectionBlockAccess', - 'Conditions', 'AdvancedSettings', 'Settings', 'LocaleSettings' - ) + # Valid New-Label/Set-Label parameter names (single source of truth, shared with the template endpoint). + $LabelAllowedFields = Get-CIPPSensitivityLabelField $PolicyAllowedFields = @( 'Name', 'Comment', 'Labels', 'AdvancedSettings', 'Settings', 'ExchangeLocation', 'ExchangeLocationException', @@ -50,10 +29,20 @@ function Set-CIPPSensitivityLabel { $PolicyLocationFields = $PolicyAllowedFields | Where-Object { $_ -like '*Location*' } $LabelPolicyAddPrefixed = @('Labels') + $PolicyLocationFields - $LabelParams = Format-CIPPCompliancePolicyParams -Source $Template -AllowedFields $LabelAllowedFields + # Normalize the read shape (Get-Label LabelActions) into the flat New-/Set-Label parameter shape. + # Flat manual JSON authored against the deploy schema passes through unchanged. + $NormalizedLabel = ConvertTo-CIPPSensitivityLabelParams -Label $Template + $LabelParams = Format-CIPPCompliancePolicyParams -Source $NormalizedLabel -AllowedFields $LabelAllowedFields $PolicySource = $Template.PolicyParams $LabelName = $LabelParams.Name + # Priority is valid on Set-Label but not New-Label, so it is applied via a dedicated Set-Label call below. + $LabelPriority = $null + if ($LabelParams.ContainsKey('Priority')) { + $LabelPriority = $LabelParams['Priority'] + $LabelParams.Remove('Priority') + } + try { $ExistingLabels = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Label' -Compliance | Select-Object Name, DisplayName } catch { @() } $ExistingLabelPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-LabelPolicy' -Compliance | Select-Object Name } catch { @() } @@ -69,6 +58,17 @@ function Set-CIPPSensitivityLabel { $LabelAction = "Created sensitivity label '$LabelName' in $TenantFilter." } + # Priority is Set-Label only (not a New-Label parameter) and is tenant-relative: a value valid in the + # source tenant can be out of range in the target. Apply it best-effort so an invalid priority never + # masks an otherwise successful label deployment. + if ($null -ne $LabelPriority) { + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Label' -cmdParams @{ Identity = $LabelName; Priority = $LabelPriority } -Compliance -useSystemMailbox $true + } catch { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deployed sensitivity label '$LabelName' but could not set priority $LabelPriority in $($TenantFilter): $($_.Exception.Message)" -sev Warning + } + } + if ($PolicySource) { $PolicyHash = Format-CIPPCompliancePolicyParams -Source $PolicySource -AllowedFields $PolicyAllowedFields if (-not $PolicyHash.ContainsKey('Labels') -or -not $PolicyHash['Labels']) { diff --git a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 index f22252fc4165..ab01757ac4e6 100644 --- a/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPStandardsCompareField.ps1 @@ -70,7 +70,7 @@ function Set-CIPPStandardsCompareField { if ($ExistingHash.ContainsKey($Field.FieldName)) { $Entity = $ExistingHash[$Field.FieldName] $Entity.Value = $NormalizedValue - $Entity | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$global:CippStandardInfoStorage.Value.StandardTemplateId) -Force + $Entity | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$script:CippStandardInfoStorage.Value.StandardTemplateId) -Force $Entity | Add-Member -NotePropertyName LicenseAvailable -NotePropertyValue ([bool]$Field.LicenseAvailable) -Force $Entity | Add-Member -NotePropertyName CurrentValue -NotePropertyValue ([string]$Field.CurrentValue) -Force $Entity | Add-Member -NotePropertyName ExpectedValue -NotePropertyValue ([string]$Field.ExpectedValue) -Force @@ -79,7 +79,7 @@ function Set-CIPPStandardsCompareField { PartitionKey = [string]$TenantName.defaultDomainName RowKey = [string]$Field.FieldName Value = $NormalizedValue - TemplateId = [string]$global:CippStandardInfoStorage.Value.StandardTemplateId + TemplateId = [string]$script:CippStandardInfoStorage.Value.StandardTemplateId LicenseAvailable = [bool]$Field.LicenseAvailable CurrentValue = [string]$Field.CurrentValue ExpectedValue = [string]$Field.ExpectedValue @@ -106,7 +106,7 @@ function Set-CIPPStandardsCompareField { try { if ($Existing) { $Existing.Value = $NormalizedValue - $Existing | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$global:CippStandardInfoStorage.Value.StandardTemplateId) -Force + $Existing | Add-Member -NotePropertyName TemplateId -NotePropertyValue ([string]$script:CippStandardInfoStorage.Value.StandardTemplateId) -Force $Existing | Add-Member -NotePropertyName LicenseAvailable -NotePropertyValue ([bool]$LicenseAvailable) -Force $Existing | Add-Member -NotePropertyName CurrentValue -NotePropertyValue ([string]$CurrentValue) -Force $Existing | Add-Member -NotePropertyName ExpectedValue -NotePropertyValue ([string]$ExpectedValue) -Force @@ -116,7 +116,7 @@ function Set-CIPPStandardsCompareField { PartitionKey = [string]$TenantName.defaultDomainName RowKey = [string]$FieldName Value = $NormalizedValue - TemplateId = [string]$global:CippStandardInfoStorage.Value.StandardTemplateId + TemplateId = [string]$script:CippStandardInfoStorage.Value.StandardTemplateId LicenseAvailable = [bool]$LicenseAvailable CurrentValue = [string]$CurrentValue ExpectedValue = [string]$ExpectedValue diff --git a/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 b/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 new file mode 100644 index 000000000000..60fff712e700 --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 @@ -0,0 +1,23 @@ +function Set-CippScheduledTaskContext { + <# + .SYNOPSIS + Stores the scheduled task id in CIPPCore module-scoped AsyncLocal storage for the current invocation. + .DESCRIPTION + Used by the scheduler engine (Push-ExecScheduledCommand in CIPPActivityTriggers) so that CIPPCore functions + like Write-LogMessage can attribute log entries to the running scheduled task. Module script scope + is used instead of global scope, which is not reliable in Azure Functions. + .PARAMETER TaskId + The scheduled task RowKey. Pass $null or empty to clear. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [string]$TaskId + ) + + if (-not $script:CippScheduledTaskIdStorage) { + $script:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new() + } + $script:CippScheduledTaskIdStorage.Value = $TaskId +} diff --git a/Modules/CIPPCore/Public/Set-CippStandardInfoContext.ps1 b/Modules/CIPPCore/Public/Set-CippStandardInfoContext.ps1 new file mode 100644 index 000000000000..007a59443b07 --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CippStandardInfoContext.ps1 @@ -0,0 +1,23 @@ +function Set-CippStandardInfoContext { + <# + .SYNOPSIS + Stores standard execution info in CIPPCore module-scoped AsyncLocal storage for the current invocation. + .DESCRIPTION + Used by standards entrypoints (e.g. Push-CIPPStandard in CIPPActivityTriggers) so that CIPPCore functions + like Write-LogMessage and Set-CIPPStandardsCompareField can read the standard context. Module script scope + is used instead of global scope, which is not reliable in Azure Functions. + .PARAMETER StandardInfo + Hashtable with Standard, StandardTemplateId, and optional IntuneTemplateId/ConditionalAccessTemplateId. Pass $null to clear. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $StandardInfo + ) + + if (-not $script:CippStandardInfoStorage) { + $script:CippStandardInfoStorage = [System.Threading.AsyncLocal[object]]::new() + } + $script:CippStandardInfoStorage.Value = $StandardInfo +} diff --git a/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 b/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 index dcea014c0def..7f231faf55ff 100644 --- a/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 +++ b/Modules/CIPPCore/Public/Standards/Merge-CippStandards.ps1 @@ -11,14 +11,34 @@ function Merge-CippStandards { # If the standard name ends with 'Template', we treat them as arrays to merge. if ($StandardName -like '*Template') { - $ExistingIsArray = $Existing -is [System.Collections.IEnumerable] -and -not ($Existing -is [string]) - $NewIsArray = $New -is [System.Collections.IEnumerable] -and -not ($New -is [string]) + # Combine both tiers, then collapse duplicates that target the same template + # (same TemplateList.value). Without this, the same Intune/CA template configured + # in more than one tier (or in more than one standard) for a tenant gets + # concatenated into a multi-element array, which downstream stringifies into a + # doubled GUID ("Failed to find template ") that matches no RowKey. + # + # The standards engine already keys each template instance by TemplateList.value, + # so when this function runs the items share a template GUID and should resolve to + # a single deployment. Items without a TemplateList.value can't be keyed, so they + # are always kept (preserves the additive behaviour for those). + $Combined = @($Existing) + @($New) - # Make sure both are arrays - if (-not $ExistingIsArray) { $Existing = @($Existing) } - if (-not $NewIsArray) { $New = @($New) } + $Deduped = [System.Collections.Generic.List[object]]::new() + $SeenValues = [System.Collections.Generic.HashSet[string]]::new() + # Walk newest-first so the most-specific tier wins for a given template, while + # Insert(0, ...) keeps the overall ordering stable. + for ($i = $Combined.Count - 1; $i -ge 0; $i--) { + $Item = $Combined[$i] + $TemplateValue = $Item.TemplateList.value + if ([string]::IsNullOrEmpty($TemplateValue)) { + $Deduped.Insert(0, $Item) + } elseif ($SeenValues.Add([string]$TemplateValue)) { + $Deduped.Insert(0, $Item) + } + } - return $Existing + $New + if ($Deduped.Count -eq 1) { return $Deduped[0] } + return $Deduped.ToArray() } else { # Single‐value standard: override the old with the new return $New diff --git a/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 b/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 index ef9e032aa9f6..0f48ad09c656 100644 --- a/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 +++ b/Modules/CIPPCore/Public/Start-CIPPSiteVersionCleanup.ps1 @@ -75,6 +75,18 @@ function Start-CIPPSiteVersionCleanup { } if ($PSCmdlet.ShouldProcess($SiteUrl, 'Start file version batch delete job')) { - return New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + # CSOM reports validation failures as HTTP 200 with a populated ErrorInfo on the first + # array element. Surface it instead of returning a silent "success" the caller can't see. + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint rejected the version cleanup job for $SiteUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + return $Response } } diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 new file mode 100644 index 000000000000..ec23d6ba8fb1 --- /dev/null +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPGroupMappings.ps1 @@ -0,0 +1,226 @@ +function Test-CIPPGDAPGroupMappings { + <# + .SYNOPSIS + Validate (and optionally repair) the security groups referenced by GDAP role mappings in the partner tenant. + + .DESCRIPTION + GDAP access assignments link a security group in the partner (CSP) tenant to a unified role. If the GroupId + stored in a role mapping no longer points at a real group, Graph rejects the access assignment with an + "access container does not exist" error. This helper fetches the partner tenant security groups once and, for + each mapping: + + 1. GroupId still resolves to a group -> kept as-is (Valid). + 2. GroupId is gone but a group with the expected name ("M365 GDAP " / the stored GroupName) exists + -> the mapping is resolved to that group's id (Stale - the stored id was stale). + 3. Neither exists -> recreated via the standard "M365 GDAP" group when -CreateMissing is set (Created), + otherwise reported as Missing with an actionable message instead of letting the raw Graph error surface. + + Corrections/creations can be persisted back to the GDAPRoles table (-WriteBack), a GDAP role template + (-TemplateId) and/or a GDAP invite entry (-InviteRowKey) so subsequent syncs use the corrected GroupIds. + + Returns the corrected mapping set, a per-mapping result list, the still-missing groups and an overall Valid flag. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + $RoleMappings, + $PartnerGroups, + [switch]$CreateMissing, + [switch]$WriteBack, + $TemplateId, + $InviteRowKey, + $APIName = 'GDAP Group Check', + $Headers + ) + + # Normalise input into a mutable copy so we never mutate the caller's objects in place + $Mappings = @(foreach ($Mapping in $RoleMappings) { + [PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $Mapping.GroupName + GroupId = $Mapping.GroupId + roleDefinitionId = $Mapping.roleDefinitionId + } + }) + + if (($Mappings | Measure-Object).Count -eq 0) { + return [PSCustomObject]@{ + RoleMappings = @() + Results = @() + Valid = $true + MissingGroups = @() + } + } + + # Fetch partner tenant security groups once if the caller did not already hand them to us + if (-not $PartnerGroups) { + $PartnerGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + } + + $Results = [System.Collections.Generic.List[object]]::new() + $MissingGroups = [System.Collections.Generic.List[object]]::new() + $Corrections = [System.Collections.Generic.List[object]]::new() + $CreateRequests = [System.Collections.Generic.List[object]]::new() + $CreateLookup = @{} + + foreach ($Mapping in $Mappings) { + $ExpectedName = if ($Mapping.GroupName) { $Mapping.GroupName } else { "M365 GDAP $($Mapping.RoleName)" } + + # 1. GroupId still valid in the partner tenant + if ($Mapping.GroupId -and $PartnerGroups.id -contains $Mapping.GroupId) { + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $ExpectedName + GroupId = $Mapping.GroupId + Status = 'Valid' + Message = '' + OldGroupId = $null + }) + continue + } + + # 2. Remap to an existing group that matches the expected name + $MatchByName = $PartnerGroups | Where-Object { $_.displayName -eq $ExpectedName } | Select-Object -First 1 + if ($MatchByName) { + $OldGroupId = $Mapping.GroupId + $Mapping.GroupId = $MatchByName.id + $Mapping.GroupName = $MatchByName.displayName + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $MatchByName.displayName + GroupId = $MatchByName.id + Status = 'Stale' + Message = "Group '$ExpectedName' exists but the stored group id '$OldGroupId' is stale; the correct group id is '$($MatchByName.id)'" + OldGroupId = $OldGroupId + }) + $Corrections.Add([PSCustomObject]@{ OldGroupId = $OldGroupId; Mapping = $Mapping }) + continue + } + + # 3. Neither the id nor a matching group exists - recreate or report as missing + if ($CreateMissing) { + $MailNickname = 'M365GDAP{0}' -f (($ExpectedName -replace '^M365 GDAP ', '') -replace '[^a-zA-Z0-9]', '') + $RequestId = "create-$($Mapping.roleDefinitionId)" + $CreateLookup[$RequestId] = $Mapping + $CreateRequests.Add(@{ + id = $RequestId + url = '/groups' + method = 'POST' + headers = @{ 'Content-Type' = 'application/json' } + body = @{ + displayName = $ExpectedName + description = "This group is used to manage M365 partner tenants at the $($Mapping.RoleName) level." + securityEnabled = $true + mailEnabled = $false + mailNickname = $MailNickname + } + }) + } else { + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $ExpectedName + GroupId = $Mapping.GroupId + Status = 'Missing' + Message = "Group '$ExpectedName' is missing in the partner tenant, recreate the GDAP roles before retrying" + OldGroupId = $Mapping.GroupId + }) + $MissingGroups.Add([PSCustomObject]@{ Name = $ExpectedName; Type = 'Role Mapping' }) + } + } + + # Execute any group recreations and fold the new ids back into the mappings + if ($CreateRequests.Count -gt 0 -and $PSCmdlet.ShouldProcess('Partner tenant', "Recreate $($CreateRequests.Count) missing GDAP group(s)")) { + $CreateResults = New-GraphBulkRequest -Requests @($CreateRequests) -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + foreach ($Result in $CreateResults) { + $Mapping = $CreateLookup[$Result.id] + if (-not $Mapping) { continue } + $ExpectedName = if ($Mapping.GroupName) { $Mapping.GroupName } else { "M365 GDAP $($Mapping.RoleName)" } + if ($Result.body.error) { + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $ExpectedName + GroupId = $Mapping.GroupId + Status = 'Missing' + Message = "Failed to recreate group '$ExpectedName': $($Result.body.error.message)" + OldGroupId = $Mapping.GroupId + }) + $MissingGroups.Add([PSCustomObject]@{ Name = $ExpectedName; Type = 'Role Mapping' }) + } else { + $OldGroupId = $Mapping.GroupId + $Mapping.GroupId = $Result.body.id + $Mapping.GroupName = $Result.body.displayName + $Results.Add([PSCustomObject]@{ + RoleName = $Mapping.RoleName + GroupName = $Result.body.displayName + GroupId = $Result.body.id + Status = 'Created' + Message = "Recreated missing group '$($Result.body.displayName)' as '$($Result.body.id)'" + OldGroupId = $OldGroupId + }) + $Corrections.Add([PSCustomObject]@{ OldGroupId = $OldGroupId; Mapping = $Mapping }) + } + } + } + + # Persist corrected/created GroupIds back to the GDAPRoles registry (RowKey is the GroupId) + if ($WriteBack -and $Corrections.Count -gt 0) { + try { + $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' + foreach ($Correction in $Corrections) { + $Mapping = $Correction.Mapping + if ($Correction.OldGroupId -and $Correction.OldGroupId -ne $Mapping.GroupId) { + $OldEntity = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles' and RowKey eq '$($Correction.OldGroupId)'" + if ($OldEntity) { + Remove-AzDataTableEntity -Force @RolesTable -Entity $OldEntity + } + } + Add-CIPPAzDataTableEntity @RolesTable -Entity @{ + PartitionKey = 'Roles' + RowKey = [string]$Mapping.GroupId + RoleName = [string]$Mapping.RoleName + GroupName = [string]$Mapping.GroupName + GroupId = [string]$Mapping.GroupId + roleDefinitionId = [string]$Mapping.roleDefinitionId + } -Force + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to GDAPRoles: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + + # Optionally push the corrected mappings back to the source template/invite + if ($Corrections.Count -gt 0) { + if ($TemplateId) { + try { + Add-CIPPGDAPRoleTemplate -TemplateId $TemplateId -RoleMappings ($Mappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) -Overwrite + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to template '$TemplateId': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + if ($InviteRowKey) { + try { + $InviteTable = Get-CIPPTable -TableName 'GDAPInvites' + $Invite = Get-CIPPAzDataTableEntity @InviteTable -Filter "RowKey eq '$InviteRowKey'" + if ($Invite) { + $Invite.RoleMappings = [string](@($Mappings | Select-Object -Property RoleName, GroupName, GroupId, roleDefinitionId) | ConvertTo-Json -Depth 10 -Compress) + Add-CIPPAzDataTableEntity @InviteTable -Entity $Invite -Force + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to write corrected GDAP group mappings to invite '$InviteRowKey': $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + } + + return [PSCustomObject]@{ + RoleMappings = @($Mappings) + Results = @($Results) + Valid = (($MissingGroups | Measure-Object).Count -eq 0) + MissingGroups = @($MissingGroups) + } +} diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 index 590fef5f8ddf..d0c801560be6 100644 --- a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 @@ -8,6 +8,7 @@ function Test-CIPPGDAPRelationships { $GDAPissues = [System.Collections.Generic.List[object]]@() $MissingGroups = [System.Collections.Generic.List[object]]@() + $RoleMappingResults = [System.Collections.Generic.List[object]]@() try { #Get graph request to list all relationships. $Relationships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships?`$filter=status eq 'active'" -tenantid $env:TenantID -NoAuthCheck $true @@ -99,16 +100,51 @@ function Test-CIPPGDAPRelationships { }) | Out-Null } + # Validate that every stored GDAP role mapping still points at a group that exists in the partner tenant. + # A drifted/deleted GroupId is what causes the "access container does not exist" error during onboarding. + # Problems are added to GDAPIssues as errors (so they count toward the Errors total) and tagged with + # Category 'RoleMapping' so the frontend can keep them out of the GDAP Issues table (the detail/repair view + # lives in RoleMappingResults). + $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' + $StoredRoleMappings = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles'" + if (($StoredRoleMappings | Measure-Object).Count -gt 0) { + # Read-only check: do not write back or recreate groups from the access check card + $MappingCheck = Test-CIPPGDAPGroupMappings -RoleMappings $StoredRoleMappings -Headers $Headers + $RoleMappingResults.AddRange(@($MappingCheck.Results)) + foreach ($MappingResult in $MappingCheck.Results) { + if ($MappingResult.Status -eq 'Missing') { + $GDAPissues.add([PSCustomObject]@{ + Type = 'Error' + Category = 'RoleMapping' + Issue = "The GDAP role mapping for '$($MappingResult.GroupName)' references a security group that no longer exists in the partner tenant. Onboarding group mapping will fail until the GDAP roles are recreated." + Tenant = '*Partner Tenant' + Relationship = 'None' + Link = 'https://docs.cipp.app/setup/installation/recommended-roles' + }) | Out-Null + } elseif ($MappingResult.Status -eq 'Stale') { + $GDAPissues.add([PSCustomObject]@{ + Type = 'Error' + Category = 'RoleMapping' + Issue = "The GDAP role mapping for '$($MappingResult.GroupName)' points at a stale group id but a matching group still exists. Use 'Repair Role Mappings' under Details to correct the stored group id." + Tenant = '*Partner Tenant' + Relationship = 'None' + Link = 'https://docs.cipp.app/setup/installation/recommended-roles' + }) | Out-Null + } + } + } + } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Failed to run GDAP check for $($TenantFilter): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } $GDAPRelationships = [PSCustomObject]@{ - GDAPIssues = @($GDAPissues) - MissingGroups = @($MissingGroups) - Memberships = @($SAMUserMemberships + $NestedGroups) - CIPPGroupCount = $CIPPGroupCount + GDAPIssues = @($GDAPissues) + MissingGroups = @($MissingGroups) + Memberships = @($SAMUserMemberships + $NestedGroups) + CIPPGroupCount = $CIPPGroupCount + RoleMappingResults = @($RoleMappingResults) } $Table = Get-CIPPTable -TableName AccessChecks diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index 56d3c5843e44..48e7a2fa2bd2 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -84,15 +84,6 @@ function Import-CommunityTemplate { switch -Wildcard ($Type) { '*Group*' { - $RawJsonObj = [PSCustomObject]@{ - Displayname = $Template.displayName - Description = $Template.Description - MembershipRules = $Template.membershipRule - username = $Template.mailNickname - GUID = $id - groupType = 'generic' - } | ConvertTo-Json -Depth 100 - # Check for duplicate template $DuplicateFilter = "PartitionKey eq 'GroupTemplate'" $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue @@ -115,6 +106,19 @@ function Import-CommunityTemplate { break } + # On update, reuse the existing GUID so the JSON-embedded GUID stays in + # sync with the table RowKey (see the Intune path below for the full rationale). + $TemplateGuid = if ($Duplicate) { $Duplicate.GUID } else { $id } + + $RawJsonObj = [PSCustomObject]@{ + Displayname = $Template.displayName + Description = $Template.Description + MembershipRules = $Template.membershipRule + username = $Template.mailNickname + GUID = $TemplateGuid + groupType = 'generic' + } | ConvertTo-Json -Depth 100 + if ($Duplicate) { $StatusMessage = "Updating Group template '$($Template.displayName)' from source '$Source' (SHA changed)." Write-Information $StatusMessage @@ -126,7 +130,7 @@ function Import-CommunityTemplate { JSON = "$RawJsonObj" PartitionKey = 'GroupTemplate' SHA = $SHA - GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + GUID = $TemplateGuid RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } @@ -239,14 +243,6 @@ function Import-CommunityTemplate { #create a new template $DisplayName = $Template.displayName ?? $template.Name - $RawJsonObj = [PSCustomObject]@{ - Displayname = $DisplayName - Description = $Template.Description - RAWJson = $RawJson - Type = $URLName - GUID = $id - } | ConvertTo-Json -Depth 100 -Compress - # Check for duplicate template $DuplicateFilter = "PartitionKey eq 'IntuneTemplate'" $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue @@ -263,6 +259,21 @@ function Import-CommunityTemplate { } } | Select-Object -First 1 + # On update, reuse the existing template's GUID so the GUID embedded + # in the JSON blob stays in sync with the table RowKey. Minting a fresh + # GUID here desyncs the two: the standards engine resolves templates by + # RowKey, while the template picker surfaces the JSON GUID, so the drift + # would point at a GUID that no longer matches any RowKey. + $TemplateGuid = if ($Duplicate) { $Duplicate.GUID } else { $id } + + $RawJsonObj = [PSCustomObject]@{ + Displayname = $DisplayName + Description = $Template.Description + RAWJson = $RawJson + Type = $URLName + GUID = $TemplateGuid + } | ConvertTo-Json -Depth 100 -Compress + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { $StatusMessage = "Intune template '$DisplayName' from source '$Source' is already up to date. Skipping import." Write-Information $StatusMessage @@ -280,7 +291,7 @@ function Import-CommunityTemplate { JSON = "$RawJsonObj" PartitionKey = 'IntuneTemplate' SHA = $SHA - GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + GUID = $TemplateGuid RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } diff --git a/Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 new file mode 100644 index 000000000000..e1f59d93e3d0 --- /dev/null +++ b/Modules/CIPPCore/Public/Tools/Repair-CippStandardsTemplate.ps1 @@ -0,0 +1,247 @@ +function Repair-CippStandardsTemplate { + <# + .SYNOPSIS + Recovers a standards template whose JSON failed to parse because of case-insensitive + duplicate property names, using the standards catalog to decide which key is real, then + marks the repaired template as safe-by-default. Returns the repaired JSON string, or THROWS + a descriptive error if it cannot be safely recovered. + .DESCRIPTION + PowerShell's ConvertFrom-Json treats property names case-insensitively and throws + ("...keys with different casing" / "...duplicated keys") when a single object contains two + names that differ only by case. The known offender is the legacy 'calDefault' standard, + which was saved with both 'permissionlevel' and 'permissionLevel'. + + This is a targeted recovery routine - call it ONLY from a ConvertFrom-Json catch block. It + reparses with System.Text.Json (which tolerates duplicate property names) and, for every + object that has case-colliding keys, consults the standards catalog (Config\standards.json) + for the owning standard. The colliding key whose exact casing matches a real catalog field + is kept; the unrecognised duplicate is dropped. So calDefault keeps 'permissionLevel' + (a real field) and drops the corrupt 'permissionlevel' - rather than blindly guessing. + + Because the repair makes a best-effort choice about corrupt data, the recovered template is + also neutered so it cannot silently start remediating from an auto-fixed config: + - templateName is prefixed with "(repaired) ". + - Drift templates (type -eq 'drift'): autoRemediate is forced to $false on every standard. + - Regular templates: runManually is forced to $true (the schedule is disabled). + + Non-colliding fields are otherwise untouched, so there is no risk of dropping legitimately + stored data that the catalog does not enumerate. Arrays, numbers, nulls and nesting are + preserved exactly (single-element arrays are NOT collapsed). + + If a collision cannot be resolved from the catalog (unknown standard / neither casing is a + known field), or the JSON is malformed beyond duplicate keys, this function THROWS a + descriptive terminating error rather than guessing. The caller is expected to log it and + omit the whole template from the response. + .PARAMETER Json + The raw JSON string that failed to parse. + .PARAMETER Reference + Optional identifier (e.g. RowKey or template name) included in error/log context. + .EXAMPLE + try { + $Data = $JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + } catch { + try { $RepairedJson = Repair-CippStandardsTemplate -Json $JSON -Reference $RowKey } + catch { Write-LogMessage ... -message "Template $RowKey omitted: $($_.Exception.Message)" -Sev Error; return } + $Data = $RepairedJson | ConvertFrom-Json -Depth 100 + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Json, + [string]$Reference + ) + + if ([string]::IsNullOrWhiteSpace($Json)) { + throw 'Template JSON is empty.' + } + + # Tolerant reparse. System.Text.Json permits duplicate property names; if even this fails the + # record is malformed beyond the known duplicate-key issue and is genuinely unrecoverable. + try { + $doc = [System.Text.Json.JsonDocument]::Parse($Json) + } catch { + throw "Malformed JSON, not recoverable: $($_.Exception.Message)" + } + + $Schema = Get-CippStandardFieldSchema + + try { + # Determine drift vs regular (matches CIPP: $Template.type -eq 'drift') to decide how to + # neuter the repaired template. + $IsDrift = $false + if ($doc.RootElement.ValueKind -eq 'Object') { + foreach ($p in $doc.RootElement.EnumerateObject()) { + if ($p.Name -eq 'type' -and $p.Value.ValueKind -eq 'String' -and $p.Value.GetString() -eq 'drift') { + $IsDrift = $true; break + } + } + } + + $stream = [System.IO.MemoryStream]::new() + $writer = [System.Text.Json.Utf8JsonWriter]::new($stream) + try { + # Write-CippCleanJsonElement throws if it finds a collision it cannot resolve. + Write-CippCleanJsonElement -Element $doc.RootElement -Writer $writer -Schema $Schema -IsDrift $IsDrift -Context 'root' + $writer.Flush() + $clean = [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) + } finally { $writer.Dispose() } + } finally { $doc.Dispose() } + + # Validate the repaired JSON parses cleanly before handing it back; if not, it's unrecoverable. + try { + $null = $clean | ConvertFrom-Json -Depth 100 -ErrorAction Stop + } catch { + throw "Still unreadable after de-duplicating keys: $($_.Exception.Message)" + } + return $clean +} + +function Get-CippStandardFieldSchema { + # Builds (and caches) a map of standard name -> set of valid field names (canonical casing), + # derived from the addedComponent definitions in Config\standards.json. Used only to decide + # which member of a case-colliding key pair is the legitimate one. + if ($script:CippStandardFieldSchema) { return $script:CippStandardFieldSchema } + + $map = @{} + try { + $Path = Join-Path $env:CIPPRootPath 'Config\standards.json' + if (Test-Path $Path) { + $Catalog = Get-Content $Path -Raw | ConvertFrom-Json -Depth 20 + foreach ($Std in $Catalog) { + if (-not $Std.name -or $Std.name -notlike 'standards.*') { continue } + $StandardKey = $Std.name.Substring('standards.'.Length).ToLowerInvariant() + $Fields = [System.Collections.Generic.HashSet[string]]::new() + $Prefix = "$($Std.name)." + foreach ($Component in @($Std.addedComponent)) { + if (-not $Component.name) { continue } + if ($Component.name -like "$Prefix*") { + foreach ($Segment in ($Component.name.Substring($Prefix.Length) -split '\.')) { + if ($Segment) { [void]$Fields.Add($Segment) } + } + } + } + $map[$StandardKey] = $Fields + } + } else { + Write-Host "Get-CippStandardFieldSchema: standards catalog not found at $Path" + } + } catch { + Write-Host "Get-CippStandardFieldSchema: failed to build schema: $($_.Exception.Message)" + } + + $script:CippStandardFieldSchema = $map + return $map +} + +function Write-CippCleanJsonElement { + # Internal helper for Repair-CippStandardsTemplate. Recursively rewrites a JsonElement, + # resolving case-insensitive duplicate property names by keeping the catalog-valid casing + # (throws if it can't), and neutering the repaired template so it won't auto-remediate: + # renames templateName, forces runManually (regular) or autoRemediate=false (drift). + param( + [System.Text.Json.JsonElement]$Element, + [System.Text.Json.Utf8JsonWriter]$Writer, + [hashtable]$Schema, + [bool]$IsDrift = $false, + [System.Collections.Generic.HashSet[string]]$ValidFields = $null, + [string]$StandardName = $null, + [string]$Context = 'root' + ) + switch ($Element.ValueKind) { + 'Object' { + $Writer.WriteStartObject() + $props = @($Element.EnumerateObject()) + + # Group property indices by case-insensitive name to detect collisions. + $byCi = @{} + for ($i = 0; $i -lt $props.Count; $i++) { + $ci = $props[$i].Name.ToLowerInvariant() + if (-not $byCi.ContainsKey($ci)) { $byCi[$ci] = [System.Collections.Generic.List[int]]::new() } + [void]$byCi[$ci].Add($i) + } + + # For each collision keep the catalog-valid casing; throw if it can't be resolved. + $keep = @{} + foreach ($ci in $byCi.Keys) { + $indices = $byCi[$ci] + if ($indices.Count -eq 1) { $keep[$ci] = $indices[0]; continue } + $chosen = $null + if ($ValidFields) { + foreach ($idx in $indices) { + if ($ValidFields.Contains($props[$idx].Name)) { $chosen = $idx; break } + } + } + if ($null -eq $chosen) { + $where = if ($StandardName) { "standard '$StandardName'" } else { 'the template root' } + $variants = ($indices | ForEach-Object { "'$($props[$_].Name)'" }) -join ', ' + throw "Unresolvable duplicate property '$ci' ($variants) in $where - no matching field in the standards catalog to determine the correct value." + } + $keep[$ci] = $chosen + } + + # Safety-neutering overrides to apply to THIS object. + $forceBool = @{} # canonical name -> bool value to force + if ($Context -eq 'root' -and -not $IsDrift) { $forceBool['runManually'] = $true } + elseif ($Context -eq 'standardEntry' -and $IsDrift) { $forceBool['autoRemediate'] = $false } + $forceLower = @{} + foreach ($k in $forceBool.Keys) { $forceLower[$k.ToLowerInvariant()] = $k } + $pending = [System.Collections.Generic.List[string]]@($forceBool.Keys) + + for ($i = 0; $i -lt $props.Count; $i++) { + $ci = $props[$i].Name.ToLowerInvariant() + if ($keep[$ci] -ne $i) { continue } + + $name = $props[$i].Name + + # Force a boolean value (e.g. runManually / autoRemediate) over the stored one. + if ($forceLower.ContainsKey($ci)) { + $Writer.WriteBoolean($name, [bool]$forceBool[$forceLower[$ci]]) + [void]$pending.Remove($forceLower[$ci]) + continue + } + + # Prefix the template name so it's obvious it was auto-repaired. + if ($Context -eq 'root' -and $ci -eq 'templatename' -and $props[$i].Value.ValueKind -eq 'String') { + $orig = $props[$i].Value.GetString() + $newName = if ($orig.StartsWith('(repaired) ')) { $orig } else { "(repaired) $orig" } + $Writer.WriteString($name, $newName) + continue + } + + $Writer.WritePropertyName($name) + + # Track which standard we are inside so deeper collisions can be resolved/reported + # and so per-standard neutering can be applied at the standard entry object. + $childValid = $ValidFields + $childStandard = $StandardName + $childContext = 'inside' + if ($Context -eq 'root' -and $name -eq 'standards') { + $childContext = 'container' + $childValid = $null + } elseif ($Context -eq 'container') { + $childValid = $Schema[$ci] + $childStandard = $name + $childContext = 'standardEntry' + } + Write-CippCleanJsonElement -Element $props[$i].Value -Writer $Writer -Schema $Schema -IsDrift $IsDrift -ValidFields $childValid -StandardName $childStandard -Context $childContext + } + + # Inject any forced property that wasn't present in the stored object. + foreach ($missing in $pending) { + $Writer.WriteBoolean($missing, [bool]$forceBool[$missing]) + } + + $Writer.WriteEndObject() + } + 'Array' { + $Writer.WriteStartArray() + foreach ($item in $Element.EnumerateArray()) { + Write-CippCleanJsonElement -Element $item -Writer $Writer -Schema $Schema -IsDrift $IsDrift -ValidFields $ValidFields -StandardName $StandardName -Context 'inside' + } + $Writer.WriteEndArray() + } + default { $Element.WriteTo($Writer) } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 index dfc78cd1a542..2e1ca1931df5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCIPPUsers.ps1 @@ -13,6 +13,18 @@ function Invoke-ExecCIPPUsers { $Action = $Request.Query.Action ?? $Request.Body.Action $Table = Get-CippTable -tablename 'allowedUsers' + # Returns $true if a row carries a manually-assigned 'superadmin' role. + # Superadmin granted via Entra group sync (AutoRoles) does NOT count — group + # membership can change, so it must never be the sole source of superadmin. + # Match is case-sensitive: the built-in role is exactly 'superadmin'; a custom + # role like 'SuperAdmin' is a different role and must not trip this protection. + $HasManualSuperAdmin = { + param($Entity) + if (-not $Entity.ManualRoles) { return $false } + try { return (@($Entity.ManualRoles | ConvertFrom-Json -ErrorAction Stop) -ccontains 'superadmin') } + catch { return $false } + } + switch ($Action) { 'AddUpdate' { try { @@ -20,7 +32,8 @@ function Invoke-ExecCIPPUsers { if ([string]::IsNullOrWhiteSpace($UPN)) { throw 'UPN (email) is required' } - $UPN = $UPN.Trim() + # Squash casing so the RowKey is canonical and case-variant duplicates can't form + $UPN = $UPN.Trim().ToLower() $Roles = @($Request.Body.Roles) if ($Roles.Count -eq 0) { @@ -45,26 +58,41 @@ function Invoke-ExecCIPPUsers { } } - # Check if user already exists to preserve auto-synced roles - $ExistingEntity = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$UPN'" - $AutoRoles = @() - $Source = 'Manual' - - if ($ExistingEntity -and $ExistingEntity.AutoRoles) { - try { - $AutoRoles = @($ExistingEntity.AutoRoles | ConvertFrom-Json -ErrorAction Stop) - } catch { - $AutoRoles = @() + # Find every existing row for this user (case-insensitive) so auto-synced + # roles are preserved and any case-variant duplicates collapse into one + # canonical lowercase row. + $AllUsers = @(Get-CIPPAzDataTableEntity @Table | Where-Object { -not $_.RowKey.StartsWith('_') }) + $MatchingEntities = @($AllUsers | Where-Object { $_.RowKey -and $_.RowKey.ToLower() -eq $UPN }) + + # Invariant: at least one user must always keep a manually-assigned superadmin. + # Block an update that would strip the last manual superadmin. + if (@($Roles) -cnotcontains 'superadmin') { + $TargetHadManualSuperAdmin = @($MatchingEntities | Where-Object { & $HasManualSuperAdmin $_ }).Count -gt 0 + if ($TargetHadManualSuperAdmin) { + $OtherManualSuperAdmins = @($AllUsers | Where-Object { $_.RowKey.ToLower() -ne $UPN -and (& $HasManualSuperAdmin $_) }) + if ($OtherManualSuperAdmins.Count -eq 0) { + throw 'Cannot remove the superadmin role from the last user that has it manually assigned. Grant superadmin manually to another user first (superadmin from Entra group sync does not count).' + } } - if ($AutoRoles.Count -gt 0) { - $Source = 'Both' + } + + # Preserve + merge auto roles across all case-variants (case-sensitive dedupe) + $AutoRoles = [System.Collections.Generic.List[string]]::new() + foreach ($Existing in $MatchingEntities) { + if ($Existing.AutoRoles) { + try { + foreach ($R in @($Existing.AutoRoles | ConvertFrom-Json -ErrorAction Stop)) { + if (-not $AutoRoles.Contains($R)) { $AutoRoles.Add($R) } + } + } catch {} } } + $Source = if ($AutoRoles.Count -gt 0) { 'Both' } else { 'Manual' } - # Compute effective roles = union of manual + auto - $EffectiveRoles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($R in $Roles) { [void]$EffectiveRoles.Add($R) } - foreach ($R in $AutoRoles) { [void]$EffectiveRoles.Add($R) } + # Compute effective roles = manual ∪ auto (case-sensitive dedupe) + $EffectiveRoles = [System.Collections.Generic.List[string]]::new() + foreach ($R in $Roles) { if (-not $EffectiveRoles.Contains($R)) { $EffectiveRoles.Add($R) } } + foreach ($R in $AutoRoles) { if (-not $EffectiveRoles.Contains($R)) { $EffectiveRoles.Add($R) } } $EffectiveRolesArray = @($EffectiveRoles | Sort-Object) $Entity = @{ @@ -72,11 +100,18 @@ function Invoke-ExecCIPPUsers { RowKey = $UPN Roles = [string]($EffectiveRolesArray | ConvertTo-Json -Compress -AsArray) ManualRoles = [string](@($Roles) | ConvertTo-Json -Compress -AsArray) - AutoRoles = [string]($AutoRoles | ConvertTo-Json -Compress -AsArray) + AutoRoles = [string](@($AutoRoles) | ConvertTo-Json -Compress -AsArray) Source = $Source } Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + # Remove any case-variant duplicate rows now merged into the canonical row + foreach ($Existing in $MatchingEntities) { + if ($Existing.RowKey -cne $UPN) { + Remove-AzDataTableEntity -Force @Table -Entity $Existing + } + } + # Trigger a user sync to reconcile auto + manual roles try { Start-UserSyncTimer } catch {} @@ -100,7 +135,7 @@ function Invoke-ExecCIPPUsers { if ([string]::IsNullOrWhiteSpace($UPN)) { throw 'UPN (email) is required' } - $UPN = $UPN.Trim() + $UPN = $UPN.Trim().ToLower() # Self-lockout protection: prevent removing yourself $CurrentUser = $Request.Headers.'x-ms-client-principal-name' @@ -108,12 +143,28 @@ function Invoke-ExecCIPPUsers { throw 'Cannot remove your own user account. This would lock you out.' } - $ExistingEntity = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$UPN'" - if (-not $ExistingEntity) { + # Fetch all users once so we can locate the target (case-insensitively) + # and enforce the "at least one manual superadmin" invariant. + $AllUsers = @(Get-CIPPAzDataTableEntity @Table | Where-Object { -not $_.RowKey.StartsWith('_') }) + $MatchingEntities = @($AllUsers | Where-Object { $_.RowKey -and $_.RowKey.ToLower() -eq $UPN }) + if ($MatchingEntities.Count -eq 0) { throw "User $UPN not found in the allowed users table" } - Remove-AzDataTableEntity -Force @Table -Entity $ExistingEntity + # Invariant: don't remove the last user holding a manually-assigned superadmin. + # (Superadmin granted via Entra group sync does not count — it can disappear + # when group membership changes.) + $TargetHasManualSuperAdmin = @($MatchingEntities | Where-Object { & $HasManualSuperAdmin $_ }).Count -gt 0 + if ($TargetHasManualSuperAdmin) { + $OtherManualSuperAdmins = @($AllUsers | Where-Object { $_.RowKey.ToLower() -ne $UPN -and (& $HasManualSuperAdmin $_) }) + if ($OtherManualSuperAdmins.Count -eq 0) { + throw 'Cannot remove the last user with a manually assigned superadmin role. Grant superadmin manually to another user first (superadmin from Entra group sync does not count).' + } + } + + foreach ($Existing in $MatchingEntities) { + Remove-AzDataTableEntity -Force @Table -Entity $Existing + } try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} $Result = "Successfully removed user $UPN" 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 556307f2fc22..06075ff14998 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 @@ -31,29 +31,56 @@ function Invoke-ExecContainerManagement { return $info } - # Helper: query GHCR for the image digest of a given tag - function Get-GHCRImageDigest { + # Helper: query GHCR for the image at $Tag and return its digest + version label. + # The version label is set by the CI build (org.opencontainers.image.version) and matches + # $env:APP_VERSION in the running container — comparing them tells us whether the channel + # tag has been republished to a different build. + function Get-GHCRImageInfo { param([string]$ImageRef, [string]$Tag) - # Parse image reference: ghcr.io/owner/repo or owner/repo $imagePath = $ImageRef -replace '^ghcr\.io/', '' -replace ':.*$', '' if (-not $imagePath) { throw 'Could not parse image path from reference' } - # Get anonymous token for GHCR (public packages) - $tokenUri = "https://ghcr.io/token?scope=repository:${imagePath}:pull" - $tokenResp = Invoke-RestMethod -Uri $tokenUri -Method GET -ErrorAction Stop - $token = $tokenResp.token + # PS7's Invoke-WebRequest returns .Content as byte[] when the response lacks a charset + # (GHCR manifest media types omit it), so piping straight to ConvertFrom-Json yields + # an int array. Decode bytes first. + function ConvertFrom-RawJson($Content) { + if ($Content -is [byte[]]) { $Content = [System.Text.Encoding]::UTF8.GetString($Content) } + return $Content | ConvertFrom-Json + } + + $tokenResp = Invoke-RestMethod -Uri "https://ghcr.io/token?scope=repository:${imagePath}:pull" -Method GET -ErrorAction Stop + $authHeader = @{ Authorization = "Bearer $($tokenResp.token)" } + $manifestAccept = 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' - # Get manifest digest via HEAD request $manifestUri = "https://ghcr.io/v2/$imagePath/manifests/$Tag" - $digestHeaders = @{ - Authorization = "Bearer $token" - Accept = 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' - } - $resp = Invoke-WebRequest -Uri $manifestUri -Method HEAD -Headers $digestHeaders -ErrorAction Stop + $manifestHeaders = $authHeader + @{ Accept = $manifestAccept } + $resp = Invoke-WebRequest -Uri $manifestUri -Method GET -Headers $manifestHeaders -ErrorAction Stop $digest = $resp.Headers['Docker-Content-Digest'] if ($digest -is [array]) { $digest = $digest[0] } - return [string]$digest + $manifest = ConvertFrom-RawJson $resp.Content + + if ($manifest.manifests) { + $child = $manifest.manifests | Where-Object { $_.platform.architecture -eq 'amd64' -and $_.platform.os -eq 'linux' } | Select-Object -First 1 + if (-not $child) { $child = $manifest.manifests | Select-Object -First 1 } + $childResp = Invoke-WebRequest -Uri "https://ghcr.io/v2/$imagePath/manifests/$($child.digest)" -Method GET -Headers $manifestHeaders -ErrorAction Stop + $manifest = ConvertFrom-RawJson $childResp.Content + } + + $version = $manifest.annotations.'org.opencontainers.image.version' + if (-not $version -and $manifest.config.digest) { + try { + $config = Invoke-RestMethod -Uri "https://ghcr.io/v2/$imagePath/blobs/$($manifest.config.digest)" -Method GET -Headers $authHeader -ErrorAction Stop + $version = $config.config.Labels.'org.opencontainers.image.version' + } catch { + Write-Information "Could not read image config labels for $($imagePath):$Tag — $($_.Exception.Message)" + } + } + + return [pscustomobject]@{ + Digest = [string]$digest + Version = [string]$version + } } switch ($Action) { @@ -88,13 +115,14 @@ function Invoke-ExecContainerManagement { # Read update settings and last check result $Settings = Get-CIPPAzDataTableEntity @SettingsTable -Filter "PartitionKey eq 'Settings' and RowKey eq 'UpdateConfig'" | Select-Object -First 1 $UpdateInfo = @{ - AutoUpdate = $false - CheckInterval = '0' - CheckTime = $null - LastCheck = $null - UpdateAvailable = $false - RunningDigest = $null - RemoteDigest = $null + AutoUpdate = $false + CheckInterval = '0' + CheckTime = $null + LastCheck = $null + UpdateAvailable = $false + RunningVersion = $null + RemoteVersion = $null + RemoteDigest = $null } if ($Settings) { $UpdateInfo.AutoUpdate = $Settings.AutoUpdate -eq 'true' @@ -102,7 +130,8 @@ function Invoke-ExecContainerManagement { $UpdateInfo.CheckTime = $Settings.CheckTime ?? $null $UpdateInfo.LastCheck = if ($Settings.LastCheck) { [int64]$Settings.LastCheck } else { $null } $UpdateInfo.UpdateAvailable = $Settings.UpdateAvailable -eq 'true' - $UpdateInfo.RunningDigest = $Settings.RunningDigest ?? $null + $UpdateInfo.RunningVersion = $Settings.RunningVersion ?? $null + $UpdateInfo.RemoteVersion = $Settings.RemoteVersion ?? $null $UpdateInfo.RemoteDigest = $Settings.RemoteDigest ?? $null } @@ -162,35 +191,30 @@ function Invoke-ExecContainerManagement { break } - # Determine the tag to check + # Determine the channel tag to check (parsed from the configured image ref) $CheckTag = if ($CurrentImage -match ':([^:]+)$') { $Matches[1] } else { $ImageTag } - # Query GHCR for the remote digest - $RemoteDigest = Get-GHCRImageDigest -ImageRef $CurrentImage -Tag $CheckTag - - # Get the running container's digest — query for the baked-in tag to get what we're running - $RunningDigest = $null - try { - $RunningDigest = Get-GHCRImageDigest -ImageRef $CurrentImage -Tag $ImageTag - } catch { - Write-Information "Could not get running digest for tag $ImageTag — may be first check" - } + # Query GHCR for the channel tag's manifest — gives us both the digest and + # the version label that the CI baked in (org.opencontainers.image.version). + $RemoteInfo = Get-GHCRImageInfo -ImageRef $CurrentImage -Tag $CheckTag + $RemoteVersion = $RemoteInfo.Version + $RemoteDigest = $RemoteInfo.Digest + $RunningVersion = $env:APP_VERSION $UpdateAvailable = $false - if ($RemoteDigest -and $RunningDigest -and $RemoteDigest -ne $RunningDigest) { + if ($RemoteVersion -and $RunningVersion -and $RemoteVersion -ne $RunningVersion) { $UpdateAvailable = $true } - # Store result $Entity = @{ PartitionKey = 'Settings' RowKey = 'UpdateConfig' LastCheck = [string][int64](([DateTimeOffset]::UtcNow).ToUnixTimeSeconds()) UpdateAvailable = [string]$UpdateAvailable - RunningDigest = [string]($RunningDigest ?? '') + RunningVersion = [string]($RunningVersion ?? '') + RemoteVersion = [string]($RemoteVersion ?? '') RemoteDigest = [string]($RemoteDigest ?? '') } - # Merge with existing settings (preserve AutoUpdate, CheckInterval, CheckTime) $Existing = Get-CIPPAzDataTableEntity @SettingsTable -Filter "PartitionKey eq 'Settings' and RowKey eq 'UpdateConfig'" | Select-Object -First 1 if ($Existing) { $Entity.AutoUpdate = $Existing.AutoUpdate ?? 'false' @@ -199,23 +223,23 @@ function Invoke-ExecContainerManagement { } Add-CIPPAzDataTableEntity @SettingsTable -Entity $Entity -Force | Out-Null - # Auto-restart if enabled and update is available $Settings = Get-CIPPAzDataTableEntity @SettingsTable -Filter "PartitionKey eq 'Settings' and RowKey eq 'UpdateConfig'" | Select-Object -First 1 if ($UpdateAvailable -and $Settings.AutoUpdate -eq 'true') { - Write-LogMessage -API $APIName -headers $Headers -message "Auto-update: new container image detected (running: $RunningDigest, remote: $RemoteDigest). Restarting." -sev Info - try { Request-CIPPRestart -Reason 'Auto-update: new container image available' } catch {} - $Result = "Update available — container restart initiated (auto-update enabled). Running digest: $RunningDigest, Remote digest: $RemoteDigest" + Write-LogMessage -API $APIName -headers $Headers -message "Auto-update: new container version detected (running: $RunningVersion, remote: $RemoteVersion). Restarting." -sev Info + try { Request-CIPPRestart -Reason 'Auto-update: new container version available' } catch {} + $Result = "Update available — container restart initiated (auto-update enabled). Running: $RunningVersion, Remote: $RemoteVersion" } elseif ($UpdateAvailable) { - $Result = "Update available. Running digest: $RunningDigest, Remote digest: $RemoteDigest. Restart the container to apply." - Write-LogMessage -API $APIName -headers $Headers -message "Container update available (running: $RunningDigest, remote: $RemoteDigest)" -sev Info + $Result = "Update available. Running: $RunningVersion, Remote: $RemoteVersion. Restart the container to apply." + Write-LogMessage -API $APIName -headers $Headers -message "Container update available (running: $RunningVersion, remote: $RemoteVersion)" -sev Info } else { - $Result = "Container is up to date. Digest: $RunningDigest" + $Result = "Container is up to date. Version: $RunningVersion" } $Body = @{ Results = @{ Message = $Result UpdateAvailable = $UpdateAvailable - RunningDigest = $RunningDigest + RunningVersion = $RunningVersion + RemoteVersion = $RemoteVersion RemoteDigest = $RemoteDigest CheckedTag = $CheckTag } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecExcludeLicenses.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecExcludeLicenses.ps1 index 40a2a968a690..98b1b07ab8ca 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecExcludeLicenses.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecExcludeLicenses.ps1 @@ -44,6 +44,24 @@ function Invoke-ExecExcludeLicenses { $Result = "Success. $DisplayName($GUID) will now only be excluded from alerts." Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + } + 'SetShowInDropdown' { + $ShowInDropdown = $Request.Body.ShowInDropdown -eq $true + $Filter = "RowKey eq '{0}' and PartitionKey eq 'License'" -f $GUID + $Entity = Get-CIPPAzDataTableEntity @Table -Filter $Filter + if (!$Entity) { throw "Excluded license not found: $GUID" } + if (!$DisplayName) { $DisplayName = $Entity.Product_Display_Name } + + $Entity | Add-Member -NotePropertyName 'ShowInLicenseDropdown' -NotePropertyValue $ShowInDropdown -Force + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + + $Result = if ($ShowInDropdown) { + "Success. $DisplayName($GUID) will now be shown in license dropdowns." + } else { + "Success. $DisplayName($GUID) will now be hidden from license dropdowns." + } + Write-LogMessage -API $APIName -headers $Headers -message $Result -Sev 'Info' + } 'RemoveExclusion' { $Filter = "RowKey eq '{0}' and PartitionKey eq 'License'" -f $GUID diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 index 00494bb4c2c0..49c19af75838 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListExcludedLicenses.ps1 @@ -29,6 +29,9 @@ function Invoke-ListExcludedLicenses { if ($null -eq $_.ExcludedEverywhere) { $_ | Add-Member -NotePropertyName 'ExcludedEverywhere' -NotePropertyValue $true -Force } + if ($null -eq $_.ShowInLicenseDropdown) { + $_ | Add-Member -NotePropertyName 'ShowInLicenseDropdown' -NotePropertyValue $false -Force + } $ExclusionType = if ($_.ExcludedEverywhere -eq $true) { 'Excluded Everywhere' } else { 'Excluded from Alerts Only' } $_ | Add-Member -NotePropertyName 'ExclusionType' -NotePropertyValue $ExclusionType -Force $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContact.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContact.ps1 index 4559c8ed8558..dad60290c7da 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContact.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContact.ps1 @@ -36,8 +36,6 @@ Function Invoke-EditContact { 'StateOrProvince' = $contactInfo.State 'CountryOrRegion' = $contactInfo.CountryOrRegion 'Company' = $contactInfo.Company - 'MobilePhone' = $contactInfo.mobilePhone - 'Phone' = $contactInfo.phone 'WebPage' = $contactInfo.website } @@ -48,6 +46,14 @@ Function Invoke-EditContact { } } + $BodyProperties = $contactInfo.PSObject.Properties.Name + if ($BodyProperties -contains 'mobilePhone') { + $bodyForSetContact['MobilePhone'] = if ([string]::IsNullOrWhiteSpace($contactInfo.mobilePhone)) { $null } else { $contactInfo.mobilePhone } + } + if ($BodyProperties -contains 'phone') { + $bodyForSetContact['Phone'] = if ([string]::IsNullOrWhiteSpace($contactInfo.phone)) { $null } else { $contactInfo.phone } + } + # Update contact only if we have properties to set beyond Identity if ($bodyForSetContact.Count -gt 1) { $null = New-ExoRequest -tenantid $TenantID -cmdlet 'Set-Contact' -cmdParams $bodyForSetContact -UseSystemMailbox $true diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntunePolicyClone.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntunePolicyClone.ps1 new file mode 100644 index 000000000000..b82a7b1829c1 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntunePolicyClone.ps1 @@ -0,0 +1,63 @@ +function Invoke-AddIntunePolicyClone { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.MEM.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # Interact with the body of the request. + $TenantFilter = $Request.Body.tenantFilter + $ID = $Request.Body.ID + $URLName = $Request.Body.URLName + $ODataType = $Request.Body.ODataType + $NewDisplayName = $Request.Body.newDisplayName + $NewDescription = $Request.Body.newDescription + + try { + if ([string]::IsNullOrWhiteSpace($NewDisplayName)) { throw 'You must enter a display name for the cloned policy' } + + # Export the source policy to template JSON; this strips read-only properties per policy type. + $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName $URLName -ID $ID -ODataType $ODataType + if (-not $Template.TemplateJson) { throw "Policy type '$($URLName ?? $ODataType)' is not supported for cloning" } + + # Set-CIPPIntunePolicy updates an existing policy when the display name matches, so a clone + # that keeps the source name would overwrite the source policy instead of creating a copy. + if ($NewDisplayName -eq $Template.DisplayName) { throw 'The new display name must be different from the name of the policy you are cloning' } + + $Description = $NewDescription ?? $Template.Description + + # Several policy types take their name and description from the JSON payload rather than the + # parameters, so rewrite them in the payload too. Admin (groupPolicyConfigurations) template + # JSON holds definition values rather than the policy object and must stay untouched. + $RawJSON = $Template.TemplateJson + if ($Template.Type -ne 'Admin') { + $PolicyObject = $RawJSON | ConvertFrom-Json + $NameProperty = if ($Template.Type -eq 'Catalog') { 'name' } else { 'displayName' } + $PolicyObject | Add-Member -MemberType NoteProperty -Name $NameProperty -Value $NewDisplayName -Force + $PolicyObject | Add-Member -MemberType NoteProperty -Name 'description' -Value $Description -Force + $RawJSON = ConvertTo-Json -InputObject $PolicyObject -Depth 100 -Compress + } + + $null = Set-CIPPIntunePolicy -TemplateType $Template.Type -Description $Description -DisplayName $NewDisplayName -RawJSON $RawJSON -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName + + $Result = "Successfully cloned Intune policy '$($Template.DisplayName)' to '$($NewDisplayName)'" + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to clone Intune policy $($ID): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntunePolicy.ps1 index a7844764f852..361e27600985 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-EditIntunePolicy.ps1 @@ -13,24 +13,52 @@ function Invoke-EditIntunePolicy { # Interact with query parameters or the body of the request. - $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter - $ID = $Request.Query.ID ?? $Request.Body.ID - $DisplayName = $Request.Query.newDisplayName ?? $Request.Body.newDisplayName - $PolicyType = $Request.Query.policyType ?? $Request.Body.policyType + $TenantFilter = $Request.Body.tenantFilter + $ID = $Request.Body.ID + $DisplayName = $Request.Body.newDisplayName + $PolicyType = $Request.Body.policyType + $PlatformType = $Request.Body.platformType ?? 'deviceManagement' + + # The description is optional and may be sent as an empty string to clear it, + # so track whether the caller actually supplied the key. + $DescriptionProvided = $Request.Body.PSObject.Properties.Name -contains 'description' + $Description = $Request.Body.description try { + # App protection policy lists expose the singular @odata.type as the URLName, but a + # Graph PATCH needs the plural collection segment. Normalize the known types here. + $PolicyType = switch ($PolicyType) { + 'androidManagedAppProtection' { 'androidManagedAppProtections' } + 'iosManagedAppProtection' { 'iosManagedAppProtections' } + 'windowsManagedAppProtection' { 'windowsManagedAppProtections' } + 'mdmWindowsInformationProtectionPolicy' { 'mdmWindowsInformationProtectionPolicies' } + 'windowsInformationProtectionPolicy' { 'windowsInformationProtectionPolicies' } + 'targetedManagedAppConfiguration' { 'targetedManagedAppConfigurations' } + default { $PolicyType } + } + $properties = @{} - # Only add displayName if it's provided + # Settings catalog policies (configurationPolicies) store the name in the 'name' + # property rather than 'displayName'. + $NameProperty = if ($PolicyType -ieq 'configurationPolicies') { 'name' } else { 'displayName' } + + # Only add the name if it's provided if ($DisplayName) { - $properties['displayName'] = $DisplayName + $properties[$NameProperty] = $DisplayName + } + + # Only add description if the caller supplied it (empty string clears it) + if ($DescriptionProvided) { + $properties['description'] = $Description } # Update the policy - $Request = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/$PolicyType/$ID" -tenantid $TenantFilter -type PATCH -body ($properties | ConvertTo-Json) -asapp $true + $Request = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$PolicyType/$ID" -tenantid $TenantFilter -type PATCH -body ($properties | ConvertTo-Json) -asapp $true $Result = "Successfully updated Intune policy $($ID)" if ($DisplayName) { $Result += " name to '$($DisplayName)'" } + if ($DescriptionProvided) { $Result += ' and description' } Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 index 4944d2f2f7cb..f258a21892b1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 @@ -137,7 +137,16 @@ function Invoke-ListIntuneTemplates { } } | Sort-Object -Property label) } else { - $Templates = $RawTemplates.JSON | ForEach-Object { try { ConvertFrom-Json -InputObject $_ -Depth 100 -ErrorAction SilentlyContinue } catch {} } + # Force GUID to the table RowKey (the authoritative key the standards engine + # resolves against). The JSON-embedded GUID can drift out of sync with the + # RowKey after a community-repo re-sync, so never surface it as the selectable value. + $Templates = $RawTemplates | ForEach-Object { + try { + $Parsed = ConvertFrom-Json -InputObject $_.JSON -Depth 100 -ErrorAction SilentlyContinue + if ($Parsed) { $Parsed | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.RowKey -Force } + $Parsed + } catch {} + } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 index 2c93e87596c1..69a66fb92bcd 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 @@ -91,7 +91,7 @@ function Invoke-ExecListAppId { redirectUris = $RedirectUris } } | ConvertTo-Json -Depth 10 - Invoke-GraphRequest -Method PATCH -Url "https://graph.microsoft.com/v1.0/applications/$($AppResponse.body.id)" -Body $AppUpdateBody -tenantid $env:TenantID -NoAuthCheck $true + $null = New-GraphPOSTRequest -type PATCH -Uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.body.id)" -Body $AppUpdateBody -tenantid $env:TenantID -NoAuthCheck $true Write-LogMessage -message "Updated redirect URIs for application $($env:ApplicationID) to include $NewRedirectUri" -Sev 'Info' } catch { Write-LogMessage -message "Failed to update redirect URIs for application $($env:ApplicationID)" -LogData (Get-CippException -Exception $_) -sev 'Warning' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 index 9be3623fb381..fc949d857239 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListLicenses.ps1 @@ -11,8 +11,9 @@ function Invoke-ListLicenses { param($Request, $TriggerMetadata) # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter + $IncludeExcluded = $Request.Query.IncludeExcluded -eq 'true' if ($TenantFilter -ne 'AllTenants') { - $GraphRequest = Get-CIPPLicenseOverview -TenantFilter $TenantFilter | ForEach-Object { + $GraphRequest = Get-CIPPLicenseOverview -TenantFilter $TenantFilter -IncludeExcluded:$IncludeExcluded | ForEach-Object { $_ } } else { diff --git a/Modules/CIPPHTTP/Public/Invoke-ListObjectHistory.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 similarity index 100% rename from Modules/CIPPHTTP/Public/Invoke-ListObjectHistory.ps1 rename to Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListObjectHistory.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 index 868e317a3a2e..9db57279cd68 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-AddSensitivityLabelTemplate.ps1 @@ -11,30 +11,10 @@ Function Invoke-AddSensitivityLabelTemplate { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - $AllowedFields = @( - 'Name', 'DisplayName', 'Comment', 'Tooltip', 'ParentId', - 'Disabled', 'ContentType', 'Priority', - 'EncryptionEnabled', 'EncryptionProtectionType', 'EncryptionRightsDefinitions', - 'EncryptionContentExpiredOnDateInDaysOrNever', 'EncryptionDoNotForward', - 'EncryptionEncryptOnly', 'EncryptionOfflineAccessDays', - 'EncryptionPromptUser', 'EncryptionAESKeySize', - 'ContentMarkingHeaderEnabled', 'ContentMarkingHeaderText', - 'ContentMarkingHeaderFontSize', 'ContentMarkingHeaderFontColor', 'ContentMarkingHeaderAlignment', - 'ContentMarkingFooterEnabled', 'ContentMarkingFooterText', - 'ContentMarkingFooterFontSize', 'ContentMarkingFooterFontColor', 'ContentMarkingFooterAlignment', - 'ContentMarkingFooterMargin', - 'ContentMarkingWatermarkEnabled', 'ContentMarkingWatermarkText', - 'ContentMarkingWatermarkFontSize', 'ContentMarkingWatermarkFontColor', 'ContentMarkingWatermarkLayout', - 'ApplyContentMarkingHeaderEnabled', 'ApplyContentMarkingFooterEnabled', 'ApplyWaterMarkingEnabled', - 'SiteAndGroupProtectionEnabled', 'SiteAndGroupProtectionPrivacy', - 'SiteAndGroupProtectionAllowAccessToGuestUsers', - 'SiteAndGroupProtectionAllowEmailFromGuestUsers', - 'SiteAndGroupProtectionAllowFullAccess', - 'SiteAndGroupProtectionAllowLimitedAccess', - 'SiteAndGroupProtectionBlockAccess', - 'Conditions', 'AdvancedSettings', 'Settings', 'LocaleSettings', - 'PolicyParams' - ) + # Captured labels (Get-Label output) and manual JSON are stored as-is; the read shape (LabelActions etc.) + # is normalized to deploy parameters at deploy time by Set-CIPPSensitivityLabel. We only keep the fields + # that matter for re-deployment and drop read-only Get-Label metadata (Guid, ImmutableId, WhenCreated...). + $KeepFields = @(Get-CIPPSensitivityLabelField) + @('LabelActions', 'PolicyParams', 'Disabled', 'comments') try { $GUID = (New-Guid).GUID @@ -45,15 +25,16 @@ Function Invoke-AddSensitivityLabelTemplate { [pscustomobject]$Request.Body } - $Clean = Format-CIPPCompliancePolicyParams -Source $Source -AllowedFields $AllowedFields - + $DisplayName = $Source.DisplayName ?? $Source.Name ?? $Source.name $Ordered = [ordered]@{ - name = $Clean['Name'] ?? $Source.Name ?? $Source.name - comments = $Source.Comment ?? $Source.comments + DisplayName = $DisplayName + Name = $Source.Name ?? $Source.name + Comment = $Source.Comment ?? $Source.comments } - foreach ($k in $Clean.Keys) { - if ($Ordered.Contains($k)) { continue } - $Ordered[$k] = $Clean[$k] + foreach ($Prop in $Source.PSObject.Properties) { + if ($Prop.Name -notin $KeepFields) { continue } + if ($Ordered.Contains($Prop.Name)) { continue } + $Ordered[$Prop.Name] = $Prop.Value } $JSON = ([pscustomobject]$Ordered | ConvertTo-Json -Depth 10) @@ -64,7 +45,7 @@ Function Invoke-AddSensitivityLabelTemplate { RowKey = "$GUID" PartitionKey = 'SensitivityLabelTemplate' } - $Result = "Successfully created Sensitivity Label Template: $($Ordered['name']) with GUID $GUID" + $Result = "Successfully created Sensitivity Label Template: $DisplayName with GUID $GUID" Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Debug' $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 new file mode 100644 index 000000000000..fc8713f8abeb --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSPOVersionCleanup.ps1 @@ -0,0 +1,31 @@ +function Invoke-ListSPOVersionCleanup { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + + try { + $Result = Get-CIPPSiteVersionCleanupStatus -TenantFilter $TenantFilter -SiteUrl $SiteUrl + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Retrieved version cleanup status for $SiteUrl" -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to retrieve version cleanup status for $SiteUrl : $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Result = "Failed to retrieve version cleanup status: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 index 5410025042b9..8f6edf979b7b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 @@ -20,10 +20,12 @@ Function Invoke-ListSharepointQuota { $extraHeaders = @{ 'Accept' = 'application/json' } - $SharePointQuota = (New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2") | Sort-Object -Property GeoUsedStorageMB -Descending | Select-Object -First 1 + $SharePointQuota = New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2" + $GeoUsedStorageMB = ($SharePointQuota.GeoUsedStorageMB | Measure-Object -Sum).Sum + $TenantStorageMB = $SharePointQuota.TenantStorageMB | Select-Object -First 1 - if ($SharePointQuota) { - $UsedStoragePercentage = [int](($SharePointQuota.GeoUsedStorageMB / $SharePointQuota.TenantStorageMB) * 100) + if ($TenantStorageMB) { + $UsedStoragePercentage = [int](($GeoUsedStorageMB / $TenantStorageMB) * 100) } } catch { $UsedStoragePercentage = 'Not available' @@ -31,8 +33,8 @@ Function Invoke-ListSharepointQuota { } $SharePointQuotaDetails = @{ - GeoUsedStorageMB = $SharePointQuota.GeoUsedStorageMB - TenantStorageMB = $SharePointQuota.TenantStorageMB + GeoUsedStorageMB = $GeoUsedStorageMB + TenantStorageMB = $TenantStorageMB Percentage = $UsedStoragePercentage Dashboard = "$($UsedStoragePercentage) / 100" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 index b2e3803ea41f..41155b7e5d81 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPAccessAssignment.ps1 @@ -48,9 +48,29 @@ function Invoke-ExecGDAPAccessAssignment { $Groups = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups?`$top=999&`$select=id,displayName&`$filter=securityEnabled eq true" -asApp $true -NoAuthCheck $true + # Validate/correct the template's group mappings against the partner tenant before creating any + # access assignments - a stale GroupId would otherwise fail with "access container does not exist". + $GroupCheck = Test-CIPPGDAPGroupMappings -RoleMappings $Mappings -PartnerGroups $Groups -WriteBack -TemplateId $RoleTemplateId -Headers $Request.Headers + $Mappings = $GroupCheck.RoleMappings + $Requests = [System.Collections.Generic.List[object]]::new() $Messages = [System.Collections.Generic.List[object]]::new() + $MappingResults = [System.Collections.Generic.List[object]]::new() + foreach ($GroupResult in $GroupCheck.Results) { + if ($GroupResult.Status -eq 'Stale') { + $MappingResults.Add(@{ resultText = $GroupResult.Message; state = 'success' }) + } elseif ($GroupResult.Status -eq 'Missing') { + $MappingResults.Add(@{ resultText = $GroupResult.Message; state = 'error' }) + } + } + + # Drop mappings whose group could not be resolved so we never POST a non-existent access container + $MissingGroupIds = @($GroupCheck.Results | Where-Object { $_.Status -eq 'Missing' } | Select-Object -ExpandProperty GroupId) + if ($MissingGroupIds.Count -gt 0) { + $Mappings = @($Mappings | Where-Object { $_.GroupId -notin $MissingGroupIds }) + } + foreach ($AccessAssignment in $AccessAssignments) { $RoleCount = ($AccessAssignment.accessDetails.unifiedRoles | Measure-Object).Count if ($Mappings.GroupId -notcontains $AccessAssignment.accessContainer.accessContainerId -and $AccessAssignment.status -notin @('deleting', 'deleted', 'error')) { @@ -159,11 +179,18 @@ function Invoke-ExecGDAPAccessAssignment { } } - } else { + } elseif ($MappingResults.Count -eq 0) { $Results = @{ resultText = 'This relationship already has the correct access assignments' state = 'success' } + } else { + $Results = @() + } + + # Surface any group mapping corrections / missing groups alongside the assignment changes + if ($MappingResults.Count -gt 0) { + $Results = @($MappingResults) + @($Results) } $Body = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 new file mode 100644 index 000000000000..2421e3a14218 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPRepairRoleMappings.ps1 @@ -0,0 +1,74 @@ +function Invoke-ExecGDAPRepairRoleMappings { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Relationship.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + Write-LogMessage -headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' + + $Results = [System.Collections.Generic.List[object]]::new() + + try { + # Fetch the partner tenant security groups once and reuse them for every store we repair + $PartnerGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$filter=securityEnabled eq true&$select=id,displayName&$top=999' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + + # Repair the GDAPRoles registry (stale group ids are remapped to the existing "M365 GDAP" group) + $RolesTable = Get-CIPPTable -TableName 'GDAPRoles' + $StoredRoles = Get-CIPPAzDataTableEntity @RolesTable -Filter "PartitionKey eq 'Roles'" + if (($StoredRoles | Measure-Object).Count -gt 0) { + $RoleCheck = Test-CIPPGDAPGroupMappings -RoleMappings $StoredRoles -PartnerGroups $PartnerGroups -WriteBack -APIName $APIName -Headers $Headers + foreach ($Result in $RoleCheck.Results) { + if ($Result.Status -eq 'Stale') { + $Results.Add(@{ resultText = "GDAP Roles: $($Result.Message)"; state = 'success' }) + } elseif ($Result.Status -eq 'Missing') { + $Results.Add(@{ resultText = "GDAP Roles: $($Result.Message)"; state = 'error' }) + } + } + } + + # Repair every saved role template so onboarding/reset use the corrected group ids + $TemplatesTable = Get-CIPPTable -TableName 'GDAPRoleTemplates' + $Templates = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'RoleTemplate'" + foreach ($Template in $Templates) { + try { + $TemplateMappings = $Template.RoleMappings | ConvertFrom-Json + } catch { + $TemplateMappings = @() + } + if (($TemplateMappings | Measure-Object).Count -eq 0) { continue } + + $TemplateCheck = Test-CIPPGDAPGroupMappings -RoleMappings $TemplateMappings -PartnerGroups $PartnerGroups -TemplateId $Template.RowKey -APIName $APIName -Headers $Headers + foreach ($Result in $TemplateCheck.Results) { + if ($Result.Status -eq 'Stale') { + $Results.Add(@{ resultText = "Template '$($Template.RowKey)': $($Result.Message)"; state = 'success' }) + } elseif ($Result.Status -eq 'Missing') { + $Results.Add(@{ resultText = "Template '$($Template.RowKey)': $($Result.Message)"; state = 'error' }) + } + } + } + + if ($Results.Count -eq 0) { + $Results.Add(@{ resultText = 'All GDAP role mappings already reference existing security groups'; state = 'success' }) + } + + # Refresh the cached GDAP access check so the card reflects the repair immediately + $null = Test-CIPPGDAPRelationships -Headers $Headers + + Write-LogMessage -headers $Headers -API $APIName -message 'Repaired GDAP role mappings' -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results.Add(@{ resultText = "Failed to repair GDAP role mappings: $($ErrorMessage.NormalizedError)"; state = 'error' }) + Write-LogMessage -headers $Headers -API $APIName -message "Failed to repair GDAP role mappings: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = @($Results) } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensesReport.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensesReport.ps1 index 479ebeb135af..a3814f6ab833 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensesReport.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Reports/Invoke-ListLicensesReport.ps1 @@ -66,6 +66,13 @@ function Invoke-ListLicensesReport { } } + # Strip the Term property from each TermInfo entry before returning + foreach ($Result in $GraphRequest) { + if ($Result.TermInfo) { + $Result.TermInfo = @($Result.TermInfo | Select-Object -Property * -ExcludeProperty Term) + } + } + $Body = [PSCustomObject]@{ Results = @($GraphRequest) Metadata = $Metadata diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 new file mode 100644 index 000000000000..7ba11d2e1c2a --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 @@ -0,0 +1,60 @@ +function Invoke-ExecCopilotSettings { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.ReadWrite + .DESCRIPTION + Sets a single Microsoft 365 Copilot policy setting to Enabled (1), Disabled (0) or Not configured (cleared). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $SettingId = $Request.Body.settingId.value ?? $Request.Body.settingId + $Value = $Request.Body.value.value ?? $Request.Body.value + + $AllowedSettings = @( + 'microsoft.copilot.copilotchatpinning' + 'microsoft.copilot.blockaccesstoopenfiles' + 'microsoft.copilot.imagegeneration' + 'microsoft.copilot.allowwebsearch' + 'microsoft.copilot.allowinadmincenters' + ) + + if ($SettingId -notin $AllowedSettings) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = [pscustomobject]@{ Results = "Unsupported Copilot setting: $SettingId" } + }) + } + + # 'clear'/'notconfigured'/blank -> remove the value (Not configured); otherwise set the string value. + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -in @('clear', 'notconfigured')) { + $PatchBody = [pscustomobject]@{ value = $null } | ConvertTo-Json -Compress + $StateText = 'Not configured' + } else { + $PatchBody = [pscustomobject]@{ value = [string]$Value } | ConvertTo-Json -Compress + $StateText = if ($Value -eq '1') { 'Enabled' } elseif ($Value -eq '0') { 'Disabled' } else { "value '$Value'" } + } + + # The Copilot admin APIs currently require delegated auth, so use the default delegated token. + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$SettingId" -tenantid $TenantFilter -type PATCH -body $PatchBody -ContentType 'application/json' + $Results = "Set '$SettingId' to $StateText" + Write-LogMessage -headers $Headers -tenant $TenantFilter -API $APIName -message $Results -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to set '$SettingId' to ${StateText}: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -tenant $TenantFilter -API $APIName -message $Results -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::OK + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = [pscustomobject]@{ Results = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 index 70f8593e74f1..4c5e977b8dc0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecStandardsRun.ps1 @@ -29,7 +29,9 @@ function Invoke-ExecStandardsRun { # Call the wrapper - it handles queuing internally via Start-CIPPOrchestrator try { $null = New-CIPPStandardsRun -TenantFilter $TenantFilter -TemplateID $TemplateId -runManually ([bool]$Templates.runManually) -Force - $Results = "Successfully started Standards Run for tenant: $TenantFilter" + $TemplateName = if ($TemplateId -eq '*') { 'All' } else { "$($Templates.templateName) ($($Templates.GUID))" } + $RunMode = if ([bool]$Templates.runManually) { ' (Manual Only)' } else { '' } + $Results = "Successfully started Standards Run for tenant: $TenantFilter - Template: $TemplateName$RunMode" Write-LogMessage -headers $Headers -tenant $TenantFilter -API $APIName -message $Results -Sev 'Info' } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListAgent365PackageDetail.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListAgent365PackageDetail.ps1 new file mode 100644 index 000000000000..308cce78ff47 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListAgent365PackageDetail.ps1 @@ -0,0 +1,32 @@ +function Invoke-ListAgent365PackageDetail { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.Read + .DESCRIPTION + Gets the full detail for a single Microsoft Agent 365 / Copilot package by id, including the + allowedUsersAndGroups, acquireUsersAndGroups and elementDetails that the list endpoint omits. + Uses delegated auth: this call returns 424 Failed Dependency under application context. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $PackageId = $Request.Query.id ?? $Request.Body.id + + try { + $Detail = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/copilot/admin/catalog/packages/$PackageId" -tenantid $TenantFilter + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Agent365Packages' -tenant $TenantFilter -message "Could not get Agent 365 package detail for $PackageId. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Detail = [pscustomobject]@{ error = $ErrorMessage.NormalizedError } + $StatusCode = [HttpStatusCode]::OK + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Detail + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListAgent365Packages.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListAgent365Packages.ps1 new file mode 100644 index 000000000000..56ead8286c87 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListAgent365Packages.ps1 @@ -0,0 +1,56 @@ +function Invoke-ListAgent365Packages { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.Read + .DESCRIPTION + Lists Microsoft Agent 365 / Copilot packages (agents and Microsoft 365 apps) in the tenant + catalog via the Package Management API. Requires a Microsoft Agent 365 license on the tenant. + Uses delegated auth: the Package Management API currently fails under application context + (424 Failed Dependency on GET, partial on LIST). Agents are NOT returned by the default list, + so this also queries supportedHosts=Copilot and merges the results (deduped by id). An explicit + OData $filter (Filter query parameter) overrides the default merge. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Filter = $Request.Query.Filter ?? $Request.Body.Filter + + $BaseUri = 'https://graph.microsoft.com/beta/copilot/admin/catalog/packages' + if (-not [string]::IsNullOrWhiteSpace($Filter)) { + $Uris = @("$BaseUri`?`$filter=$Filter") + } else { + # The default catalog list omits agents, so also pull agents (supportedHosts=Copilot) and merge. + $Uris = @( + $BaseUri + "$BaseUri`?`$filter=supportedHosts/any(x:x eq 'Copilot')" + ) + } + + $Packages = [System.Collections.Generic.List[object]]::new() + $Seen = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Uri in $Uris) { + try { + $Result = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter + foreach ($Package in $Result) { + if ($Package.id -and $Seen.Add([string]$Package.id)) { + $Packages.Add($Package) + } + } + $StatusCode = [HttpStatusCode]::OK + $Results = @($Packages) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = $ErrorMessage.Message + $statusCode = [HttpStatusCode]::InternalServerError + Write-LogMessage -API 'Agent365Packages' -tenant $TenantFilter -message "Could not list Agent 365 packages (a Microsoft Agent 365 license is required). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + + return ([HttpResponseContext]@{ + StatusCode = $statusCode + Body = $Results + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 new file mode 100644 index 000000000000..3300495bf9ba --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 @@ -0,0 +1,73 @@ +function Invoke-ListCopilotSettings { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.Read + .DESCRIPTION + Lists the Microsoft 365 Copilot admin policy settings for a tenant, one row per setting, + with the current raw value and a friendly state (Enabled / Disabled / Not configured). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + $PolicySettings = @( + @{ setting = 'Pin Microsoft 365 Copilot Chat'; id = 'microsoft.copilot.copilotchatpinning' } + @{ setting = 'Block Copilot Access to Open Content'; id = 'microsoft.copilot.blockaccesstoopenfiles' } + @{ setting = 'Designer Image Generation'; id = 'microsoft.copilot.imagegeneration' } + @{ setting = 'Allow web search in Copilot'; id = 'microsoft.copilot.allowwebsearch' } + @{ setting = 'Admin Copilot in Microsoft 365 Admin Center'; id = 'microsoft.copilot.allowinadmincenters' } + ) + + # One $batch call instead of five sequential GETs. The Copilot admin APIs currently require + # delegated auth (no -AsApp). Reading each item's body directly also sidesteps the single-request + # helper's collection unwrapping of the entity's scalar 'value' property. + $BulkRequests = foreach ($Setting in $PolicySettings) { + @{ + id = $Setting.id + method = 'GET' + url = "/copilot/admin/policySettings/$($Setting.id)" + } + } + + try { + $BulkResults = New-GraphBulkRequest -Requests @($BulkRequests) -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CopilotSettings' -tenant $TenantFilter -message "Could not read Copilot settings. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $BulkResults = @() + } + + $Results = foreach ($Setting in $PolicySettings) { + $Response = $BulkResults | Where-Object { $_.id -eq $Setting.id } + $Value = $null + if ($Response.status -ge 200 -and $Response.status -le 299) { + $Value = $Response.body.value + $StateText = if ([string]::IsNullOrEmpty($Value)) { + 'Not configured' + } elseif ($Value -eq '1') { + 'Enabled' + } elseif ($Value -eq '0') { + 'Disabled' + } else { + "Custom ($Value)" + } + } else { + Write-LogMessage -API 'CopilotSettings' -tenant $TenantFilter -message "Could not read Copilot setting $($Setting.id). Error: $($Response.body.error.message ?? 'No response')" -Sev 'Error' + $StateText = 'Unable to read' + } + [PSCustomObject]@{ + setting = $Setting.setting + state = $StateText + value = $Value + settingId = $Setting.id + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Results) + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotUsage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotUsage.ps1 new file mode 100644 index 000000000000..eaaad1abb558 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotUsage.ps1 @@ -0,0 +1,102 @@ +function Invoke-ListCopilotUsage { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.Read + .DESCRIPTION + Returns Microsoft 365 Copilot usage reports for a tenant, flattened into table rows. + Type=Adoption -> getMicrosoft365CopilotUserCountSummary (per-product enabled vs active users) + Type=Trend -> getMicrosoft365CopilotUserCountTrend (per-date active/enabled users) + Type=UserDetail-> getMicrosoft365CopilotUsageUserDetail (per-user last activity per app) + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Type = $Request.Query.Type ?? $Request.Body.Type ?? 'Adoption' + $Period = $Request.Query.period ?? $Request.Body.period ?? 'D30' + + # Copilot usage reports support delegated auth with Reports.Read.All (granted to the SAM app); + # CIPP's delegated identity carries the required usage-reports role via GDAP. + try { + switch ($Type) { + 'UserDetail' { + $Uri = "https://graph.microsoft.com/beta/copilot/reports/getMicrosoft365CopilotUsageUserDetail(period='$Period')?`$format=application/json" + $Report = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter + $Results = foreach ($User in $Report) { + [PSCustomObject]@{ + userPrincipalName = $User.userPrincipalName + displayName = $User.displayName + lastActivityDate = $User.lastActivityDate + copilotChat = $User.copilotChatLastActivityDate + teams = $User.microsoftTeamsCopilotLastActivityDate + word = $User.wordCopilotLastActivityDate + excel = $User.excelCopilotLastActivityDate + powerPoint = $User.powerPointCopilotLastActivityDate + outlook = $User.outlookCopilotLastActivityDate + oneNote = $User.oneNoteCopilotLastActivityDate + loop = $User.loopCopilotLastActivityDate + } + } + } + 'Trend' { + $Uri = "https://graph.microsoft.com/beta/copilot/reports/getMicrosoft365CopilotUserCountTrend(period='$Period')?`$format=application/json" + $Report = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter + $Results = foreach ($Entry in $Report) { + foreach ($Day in $Entry.adoptionByDate) { + [PSCustomObject]@{ + reportDate = $Day.reportDate + anyAppActive = $Day.anyAppActiveUsers + anyAppEnabled = $Day.anyAppEnabledUsers + teamsActive = $Day.microsoftTeamsActiveUsers + wordActive = $Day.wordActiveUsers + excelActive = $Day.excelActiveUsers + powerPointActive = $Day.powerPointActiveUsers + outlookActive = $Day.outlookActiveUsers + oneNoteActive = $Day.oneNoteActiveUsers + loopActive = $Day.loopActiveUsers + copilotChatActive = $Day.copilotChatActiveUsers + } + } + } + } + default { + # Adoption (by product) - getMicrosoft365CopilotUserCountSummary + $Uri = "https://graph.microsoft.com/beta/copilot/reports/getMicrosoft365CopilotUserCountSummary(period='$Period')?`$format=application/json" + $Report = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter + $Adoption = ($Report | Select-Object -First 1).adoptionByProduct | Select-Object -First 1 + $ProductMap = [ordered]@{ + 'Any App' = 'anyApp' + 'Microsoft Teams' = 'microsoftTeams' + 'Word' = 'word' + 'Excel' = 'excel' + 'PowerPoint' = 'powerPoint' + 'Outlook' = 'outlook' + 'OneNote' = 'oneNote' + 'Loop' = 'loop' + 'Copilot Chat' = 'copilotChat' + } + $Results = foreach ($Product in $ProductMap.Keys) { + $Prefix = $ProductMap[$Product] + [PSCustomObject]@{ + product = $Product + enabledUsers = $Adoption."$($Prefix)EnabledUsers" + activeUsers = $Adoption."$($Prefix)ActiveUsers" + } + } + } + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CopilotUsage' -tenant $TenantFilter -message "Failed to retrieve Copilot usage report ($Type). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::OK + $Results = @() + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($Results) + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 new file mode 100644 index 000000000000..c5ea0190d57d --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 @@ -0,0 +1,211 @@ +function Invoke-ListShadowAI { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.Read + .DESCRIPTION + Compiles a Shadow AI overview for a tenant by matching CACHED data from the CIPP reporting + database (DetectedApps, ServicePrincipals, OAuth2PermissionGrants) against the curated AI + catalog (Config/ShadowAI.json). No live Graph enumeration is performed - refresh the data by + syncing those caches (ExecCIPPDBCache). The only live call is a bounded, best-effort 7-day + sign-in lookup for the matched AI applications. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + # Curated, PR-editable catalog of known AI tools/apps. + try { + $Catalog = @(Get-Content (Join-Path $env:CIPPRootPath 'Config\ShadowAI.json') -ErrorAction Stop | ConvertFrom-Json) + } catch { + Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load Shadow AI catalog. Error: $($_.Exception.Message)" -Sev 'Error' + $Catalog = @() + } + + # Returns the first catalog entry whose matchNames appear (case-insensitive substring) in $Text. + function Get-AiMatch { + param($Text, $Catalog) + if ([string]::IsNullOrWhiteSpace($Text)) { return $null } + $Haystack = $Text.ToLower() + foreach ($Entry in $Catalog) { + foreach ($Match in $Entry.matchNames) { + if ($Match -and $Haystack.Contains($Match.ToLower())) { return $Entry } + } + } + return $null + } + + # --- Cached datasets from the CIPP reporting database (no live Graph enumeration) --- + $CacheTypes = @('DetectedApps', 'ServicePrincipals', 'OAuth2PermissionGrants') + $CacheData = @{} + $CacheTimestamps = [System.Collections.Generic.List[object]]::new() + foreach ($Type in $CacheTypes) { + try { + $CacheData[$Type] = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type) + } catch { + $CacheData[$Type] = @() + } + try { + $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1 + if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) } + } catch {} + } + $IntuneSynced = $CacheData['DetectedApps'].Count -gt 0 + $EntraSynced = $CacheData['ServicePrincipals'].Count -gt 0 + $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1 + + # 1) Installed AI tools from the cached Intune detected apps + $DetectedApps = [System.Collections.Generic.List[object]]::new() + foreach ($App in $CacheData['DetectedApps']) { + $Match = Get-AiMatch -Text "$($App.displayName) $($App.publisher)" -Catalog $Catalog + if (-not $Match) { continue } + $DeviceCount = [int]($App.deviceCount ?? 0) + if ($DeviceCount -eq 0 -and $App.managedDevices) { $DeviceCount = @($App.managedDevices).Count } + $DetectedApps.Add([PSCustomObject]@{ + application = $App.displayName + aiTool = $Match.name + vendor = $Match.vendor + category = $Match.category + risk = $Match.risk + publisher = $App.publisher + version = $App.version + platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } + deviceCount = $DeviceCount + }) + } + + # 2) AI applications in Entra: match ALL cached service principals (not only those with + # delegated grants), then attach any granted permissions. First consented = when the + # service principal was created in the tenant (the oauth2 grant startTime is unreliable). + $GrantsBySp = @{} + foreach ($Grant in $CacheData['OAuth2PermissionGrants']) { + if (-not $Grant.clientId) { continue } + if (-not $GrantsBySp.ContainsKey($Grant.clientId)) { + $GrantsBySp[$Grant.clientId] = [System.Collections.Generic.List[object]]::new() + } + $GrantsBySp[$Grant.clientId].Add($Grant) + } + + $ConsentedApps = [System.Collections.Generic.List[object]]::new() + $SeenApps = @{} + foreach ($Sp in $CacheData['ServicePrincipals']) { + $Match = Get-AiMatch -Text $Sp.displayName -Catalog $Catalog + if (-not $Match) { continue } + $Key = [string]($Sp.appId ?? $Sp.id) + if ($SeenApps.ContainsKey($Key)) { continue } + # Individual scopes as a string array so the frontend renders them as chips. + $Permissions = if ($GrantsBySp.ContainsKey($Sp.id)) { + @((@($GrantsBySp[$Sp.id].scope) -join ' ') -split '\s+' | Where-Object { $_ } | Sort-Object -Unique) + } else { + @() + } + $Consent = [PSCustomObject]@{ + application = $Sp.displayName + aiTool = $Match.name + vendor = $Match.vendor + category = $Match.category + risk = $Match.risk + applicationId = $Sp.appId + approvedPermissions = @($Permissions) + firstConsentedDateTime = $Sp.createdDateTime + signInsLast7Days = 0 + activeUsersLast7Days = 0 + applicationUsers = @() + } + $SeenApps[$Key] = $Consent + $ConsentedApps.Add($Consent) + } + + # 2b) Best-effort: recent sign-in usage (last 7 days) for the matched AI apps. This is the only + # live Graph call: a single bounded query, skipped gracefully when unavailable (needs P1). + $AiAppIds = @($ConsentedApps.applicationId | Where-Object { $_ } | Select-Object -Unique -First 15) + if ($AiAppIds.Count -gt 0) { + try { + $StartDate = (Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $AppFilter = ($AiAppIds | ForEach-Object { "appId eq '$_'" }) -join ' or ' + $SignInFilter = "createdDateTime ge $StartDate and ($AppFilter)" + $SignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=$SignInFilter" -tenantid $TenantFilter + $SignInGroups = $SignIns | Group-Object appId + foreach ($Consent in $ConsentedApps) { + $Group = $SignInGroups | Where-Object { $_.Name -eq $Consent.applicationId } + if ($Group) { + $Consent.signInsLast7Days = $Group.Count + $Consent.activeUsersLast7Days = @($Group.Group.userId | Select-Object -Unique).Count + $Consent.applicationUsers = @($Group.Group | Group-Object userPrincipalName | ForEach-Object { + [PSCustomObject]@{ + userPrincipalName = $_.Name + userDisplayName = ($_.Group | Select-Object -First 1).userDisplayName + signIns = $_.Count + lastSignInDateTime = ($_.Group.createdDateTime | Sort-Object -Descending | Select-Object -First 1) + } + }) + } + } + } catch { + Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Sign-in usage enrichment skipped (requires Entra ID P1). Error: $($_.Exception.Message)" -Sev 'Info' + } + } + + # --- Roll up distinct AI tools across BOTH sources for the summary and charts --- + $ToolMap = @{} + foreach ($App in $DetectedApps) { + if (-not $ToolMap.ContainsKey($App.aiTool)) { + $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Devices = 0; Users = 0 } + } + $ToolMap[$App.aiTool].Devices += $App.deviceCount + } + foreach ($App in $ConsentedApps) { + if (-not $ToolMap.ContainsKey($App.aiTool)) { + $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Devices = 0; Users = 0 } + } + $ToolMap[$App.aiTool].Users += [int]$App.activeUsersLast7Days + } + + $ByCategory = foreach ($Group in ($ToolMap.Values | Group-Object Category)) { + [PSCustomObject]@{ + category = $Group.Name + tools = $Group.Count + devices = [int](($Group.Group | Measure-Object -Property Devices -Sum).Sum) + } + } + $ByRisk = foreach ($Group in ($ToolMap.Values | Group-Object Risk)) { + [PSCustomObject]@{ + risk = $Group.Name + tools = $Group.Count + } + } + # Top tools across BOTH sources: device installs (Intune) + active users (Entra, last 7 days). + $TopTools = $ToolMap.Values | Sort-Object -Property { $_.Devices + $_.Users } -Descending | Select-Object -First 8 | ForEach-Object { + [PSCustomObject]@{ + tool = $_.Tool + devices = $_.Devices + users = $_.Users + footprint = $_.Devices + $_.Users + category = $_.Category + } + } + + $Body = [PSCustomObject]@{ + summary = [PSCustomObject]@{ + aiToolsDetected = $ToolMap.Count + deviceInstalls = [int](($DetectedApps | Measure-Object -Property deviceCount -Sum).Sum) + consentedAiApps = $ConsentedApps.Count + highRiskTools = @($ToolMap.Values | Where-Object { $_.Risk -eq 'High' }).Count + intuneSynced = $IntuneSynced + entraSynced = $EntraSynced + lastDataRefresh = $LastDataRefresh + } + byCategory = @($ByCategory) + byRisk = @($ByRisk) + topTools = @($TopTools) + detectedApps = @($DetectedApps) + consentedApps = @($ConsentedApps) + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index c7977465b7e0..8f844ddb9de7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -14,14 +14,29 @@ function Invoke-listStandardTemplates { $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'StandardsTemplateV2'" $Templates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) | ForEach-Object { + $RowKey = $_.RowKey $JSON = $_.JSON -replace '"Action":', '"action":' try { - $RowKey = $_.RowKey - $Data = $JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue - + $Data = $JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { - Write-Host "$($RowKey) standard could not be loaded: $($_.Exception.Message)" - return + try { + $RepairedJSON = Repair-CippStandardsTemplate -Json $JSON -Reference $RowKey + } catch { + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was omitted from the response: $($_.Exception.Message)" -Sev 'Error' + return + } + $Data = $RepairedJSON | ConvertFrom-Json -Depth 100 + try { + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$RepairedJSON" + RowKey = "$RowKey" + PartitionKey = 'StandardsTemplateV2' + GUID = "$RowKey" + } -Force + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' contained corrupt data (case-duplicate keys) and was automatically repaired and re-saved." -Sev 'Warning' + } catch { + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was repaired for this response but could not be re-saved: $($_.Exception.Message)" -Sev 'Warning' + } } if ($Data) { $Data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $_.GUID -Force diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index 90bfaef5e75b..816498651f49 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 @@ -160,6 +160,7 @@ function Invoke-ExecCommunityRepo { $Path = $Request.Body.Path $FullName = $Request.Body.FullName $Branch = $Request.Body.Branch + $Force = [bool]$Request.Body.Force try { $Template = Get-GitHubFileContents -FullName $FullName -Path $Path -Branch $Branch @@ -178,7 +179,7 @@ function Invoke-ExecCommunityRepo { (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $Location.path).content | ConvertFrom-Json } } - $ImportResult = Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $FullName + $ImportResult = Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $FullName -Force:$Force $Results = @{ resultText = $ImportResult ?? 'Template imported' diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 index 61e2423c7738..2d67f12c43b8 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAuthenticationMethods.ps1 @@ -8,7 +8,7 @@ function Invoke-CIPPStandardAuthenticationMethods { (Label) Configure Authentication Methods .DESCRIPTION (Helptext) Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups. - (DocsDescription) Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP. + (DocsDescription) Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, Authenticator software OTP, and Email OTP external user access with exclude group targeting. .NOTES CAT Entra (AAD) Standards @@ -41,6 +41,8 @@ function Invoke-CIPPStandardAuthenticationMethods { {"type":"textField","name":"standards.AuthenticationMethods.VoiceGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.VoiceEnabled","compareType":"is","compareValue":true}} {"type":"switch","name":"standards.AuthenticationMethods.EmailEnabled","label":"Email OTP","defaultValue":false} {"type":"textField","name":"standards.AuthenticationMethods.EmailGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"is","compareValue":true}} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Allow external users to use Email OTP","name":"standards.AuthenticationMethods.EmailAllowExternalIdToUseEmailOtp","options":[{"label":"Microsoft managed (default)","value":"default"},{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}],"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"is","compareValue":true}} + {"type":"textField","name":"standards.AuthenticationMethods.EmailExcludeGroup","label":"Exclude Group Name (wildcard supported, blank = no exclusions)","required":false,"condition":{"field":"standards.AuthenticationMethods.EmailEnabled","compareType":"is","compareValue":true}} {"type":"switch","name":"standards.AuthenticationMethods.x509CertificateEnabled","label":"Certificate-Based Authentication","defaultValue":false} {"type":"textField","name":"standards.AuthenticationMethods.x509CertificateGroup","label":"Target Group Name (wildcard supported, blank = All Users)","required":false,"condition":{"field":"standards.AuthenticationMethods.x509CertificateEnabled","compareType":"is","compareValue":true}} {"type":"switch","name":"standards.AuthenticationMethods.QRCodePinEnabled","label":"QR Code Pin","defaultValue":false} @@ -87,13 +89,15 @@ function Invoke-CIPPStandardAuthenticationMethods { $EnabledValue = $Settings.$EnabledKey if ($null -eq $EnabledValue) { continue } $GroupName = $Settings."$($Method.SettingKey)Group" + $ExcludeGroupName = $Settings."$($Method.SettingKey)ExcludeGroup" [PSCustomObject]@{ - Id = $Method.Id - RemediationId = $Method.RemediationId - Key = $Method.SettingKey - Label = $Method.Label - Enabled = [bool]$EnabledValue - GroupName = if ([string]::IsNullOrWhiteSpace($GroupName)) { $null } else { $GroupName } + Id = $Method.Id + RemediationId = $Method.RemediationId + Key = $Method.SettingKey + Label = $Method.Label + Enabled = [bool]$EnabledValue + GroupName = if ([string]::IsNullOrWhiteSpace($GroupName)) { $null } else { $GroupName } + ExcludeGroupName = if ([string]::IsNullOrWhiteSpace($ExcludeGroupName)) { $null } else { $ExcludeGroupName } } } @@ -139,6 +143,26 @@ function Invoke-CIPPStandardAuthenticationMethods { $GroupIdCache[$Method.GroupName] = $null } } + if ($Method.Enabled -and $Method.ExcludeGroupName -and -not $GroupIdCache.ContainsKey($Method.ExcludeGroupName)) { + try { + $EscapedName = $Method.ExcludeGroupName -replace "'", "''" + $GroupFilter = [System.Uri]::EscapeDataString("startsWith(displayName,'$EscapedName')") + $MatchedGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName&`$filter=$GroupFilter" -tenantid $Tenant) + if ($MatchedGroups.Count -gt 0) { + $GroupIdCache[$Method.ExcludeGroupName] = @($MatchedGroups | ForEach-Object { $_.id }) + if ($MatchedGroups.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AuthenticationMethods: Multiple exclude groups matched '$($Method.ExcludeGroupName)': $($MatchedGroups.displayName -join ', ')" -sev Info + } + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AuthenticationMethods: No exclude group found matching '$($Method.ExcludeGroupName)'" -sev Warning + $GroupIdCache[$Method.ExcludeGroupName] = $null + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AuthenticationMethods: Failed to resolve exclude group '$($Method.ExcludeGroupName)'. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $GroupIdCache[$Method.ExcludeGroupName] = $null + } + } } # --- Build expected vs current state and check compliance per method --- @@ -269,6 +293,31 @@ function Invoke-CIPPStandardAuthenticationMethods { } } } + 'Email' { + if ($Method.Enabled) { + $DesiredExternalOtp = $Settings.EmailAllowExternalIdToUseEmailOtp.value ?? $Settings.EmailAllowExternalIdToUseEmailOtp + if ($DesiredExternalOtp) { + $ExpectedConfig['allowExternalIdToUseEmailOtp'] = $DesiredExternalOtp + if ($CurrentConfig.allowExternalIdToUseEmailOtp -ne $DesiredExternalOtp) { + $Drifts.Add("allowExternalIdToUseEmailOtp: '$($CurrentConfig.allowExternalIdToUseEmailOtp)' -> '$DesiredExternalOtp'") + } + } + + # Exclude targets check + if ($Method.ExcludeGroupName) { + $ResolvedExcludeIds = $GroupIdCache[$Method.ExcludeGroupName] + if ($ResolvedExcludeIds) { + $NormalizedExcludeTargets = @($ResolvedExcludeIds | ForEach-Object { @{ targetType = 'group'; id = $_ } }) + $ExpectedConfig['excludeTargets'] = $NormalizedExcludeTargets + $CurrentExcludeIds = @($CurrentConfig.excludeTargets | ForEach-Object { $_.id }) + $ExcludeDiff = Compare-Object -ReferenceObject @($ResolvedExcludeIds | Sort-Object) -DifferenceObject @($CurrentExcludeIds | Sort-Object) -ErrorAction SilentlyContinue + if ($ExcludeDiff) { + $Drifts.Add("excludeTargets: current [$($CurrentExcludeIds -join ', ')] -> expected [$($ResolvedExcludeIds -join ', ')]") + } + } + } + } + } } [PSCustomObject]@{ @@ -333,6 +382,20 @@ function Invoke-CIPPStandardAuthenticationMethods { $Params['QRCodePinLength'] = [int]($Settings.QRCodePinLength ?? 8) } } + 'Email' { + if ($Result.Method.Enabled) { + $DesiredExternalOtp = $Settings.EmailAllowExternalIdToUseEmailOtp.value ?? $Settings.EmailAllowExternalIdToUseEmailOtp + if ($DesiredExternalOtp) { + $Params['EmailAllowExternalIdToUseEmailOtp'] = $DesiredExternalOtp + } + if ($Result.Method.ExcludeGroupName) { + $ResolvedExcludeIds = $GroupIdCache[$Result.Method.ExcludeGroupName] + if ($ResolvedExcludeIds) { + $Params['EmailExcludeGroupIds'] = $ResolvedExcludeIds + } + } + } + } } Set-CIPPAuthenticationPolicy @Params @@ -375,6 +438,10 @@ function Invoke-CIPPStandardAuthenticationMethods { $CurrentSnapshot[$Prop] = @($Result.CurrentConfig.includeTargets | ForEach-Object { @{ targetType = $_.targetType; id = $_.id } }) + } elseif ($Prop -eq 'excludeTargets') { + $CurrentSnapshot[$Prop] = @($Result.CurrentConfig.excludeTargets | ForEach-Object { + @{ targetType = $_.targetType; id = $_.id } + }) } else { $CurrentSnapshot[$Prop] = $Result.CurrentConfig.$Prop } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 047ddd19fc4e..bb7c1d6d1d2c 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -105,11 +105,17 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 + if ($null -eq $Policy) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + return + } + # Override the template's state with the Drift Standard's state if specified # This ensures drift detection compares against the desired state, not the original template state if ($Settings.state -and $Settings.state -ne 'donotchange') { Write-Information "Overriding template state from '$($Policy.state)' to '$($Settings.state)' for drift comparison" - $Policy.state = $Settings.state + $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force } $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotLimitedMode.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotLimitedMode.ps1 new file mode 100644 index 000000000000..13ffd2851040 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotLimitedMode.ps1 @@ -0,0 +1,134 @@ +function Invoke-CIPPStandardCopilotLimitedMode { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) CopilotLimitedMode + .SYNOPSIS + (Label) Configure Microsoft 365 Copilot Limited Mode (Teams meetings) + .DESCRIPTION + (Helptext) Controls Microsoft 365 Copilot Limited Mode for Teams meetings. When enabled for a group, Copilot in Teams meetings does not respond to sentiment-related prompts (inferring emotions, behavior, or judgments) for members of the selected group. A target group is required when enabling. Managed via the Copilot admin settings Graph API. + (DocsDescription) Configures the `copilotAdminLimitedMode` setting through the `/copilot/admin/settings/limitedMode` Microsoft Graph API (beta). When enabled, `isEnabledForGroup` is set to true and applied to the resolved target group; when disabled, `isEnabledForGroup` is set to false. NOTE: this API currently requires delegated authentication and the acting identity must be Global Administrator to write the setting. + .NOTES + CAT + Copilot (M365) Standards + TAG + EXECUTIVETEXT + Limits Microsoft 365 Copilot in Teams meetings so it does not provide opinions on sentiment, emotions, or judgments for a selected group of users. This helps organizations meet workplace policy, privacy, and works-council requirements while still allowing Copilot to summarize and answer factual questions grounded in the meeting. + ADDEDCOMPONENT + {"type":"switch","name":"standards.CopilotLimitedMode.LimitedModeEnabled","label":"Enable Copilot Limited Mode for a group (Teams meetings)","defaultValue":false} + {"type":"textField","name":"standards.CopilotLimitedMode.GroupName","label":"Target Group Name (wildcard match; required when enabled)","required":false,"condition":{"field":"standards.CopilotLimitedMode.LimitedModeEnabled","compareType":"is","compareValue":true}} + IMPACT + Medium Impact + ADDEDDATE + 2026-06-09 + POWERSHELLEQUIVALENT + Graph API: PATCH /beta/copilot/admin/settings/limitedMode + RECOMMENDEDBY + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + + $LimitedModeUri = 'https://graph.microsoft.com/beta/copilot/admin/settings/limitedMode' + $DesiredEnabled = [bool]$Settings.LimitedModeEnabled + $GroupName = $Settings.GroupName + + # Read current state. The Copilot admin settings API currently requires delegated auth, so this + # uses CIPP's default delegated token (no -AsApp). + try { + $CurrentState = New-GraphGetRequest -Uri $LimitedModeUri -tenantid $Tenant + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotLimitedMode: Could not retrieve the limited mode state. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + # When enabling, resolve the target group name (wildcard, first match) to a single group ID. + $ResolvedGroupId = $null + $GroupResolutionFailed = $false + if ($DesiredEnabled) { + if ([string]::IsNullOrWhiteSpace($GroupName)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotLimitedMode: A target group name is required when enabling limited mode.' -sev Error + $GroupResolutionFailed = $true + } else { + try { + $EscapedName = $GroupName -replace "'", "''" + $GroupFilter = [System.Uri]::EscapeDataString("startsWith(displayName,'$EscapedName')") + $MatchedGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName&`$filter=$GroupFilter" -tenantid $Tenant) + if ($MatchedGroups.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotLimitedMode: No group found matching '$GroupName'." -sev Warning + $GroupResolutionFailed = $true + } else { + $ResolvedGroupId = $MatchedGroups[0].id + if ($MatchedGroups.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotLimitedMode: Multiple groups matched '$GroupName', using '$($MatchedGroups[0].displayName)'." -sev Info + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotLimitedMode: Failed to resolve group '$GroupName'. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $GroupResolutionFailed = $true + } + } + } + + # Determine compliance + if ($DesiredEnabled) { + $StateIsCorrect = (-not $GroupResolutionFailed) -and ($CurrentState.isEnabledForGroup -eq $true) -and ($CurrentState.groupId -eq $ResolvedGroupId) + } else { + $StateIsCorrect = ($CurrentState.isEnabledForGroup -eq $false) + } + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotLimitedMode: Already in the desired state.' -sev Info + } elseif ($DesiredEnabled -and $GroupResolutionFailed) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotLimitedMode: Skipping remediation because the target group could not be resolved.' -sev Warning + } else { + try { + $BodyObject = [ordered]@{ + '@odata.type' = '#microsoft.graph.copilotAdminLimitedMode' + isEnabledForGroup = $DesiredEnabled + groupId = if ($DesiredEnabled) { $ResolvedGroupId } else { $null } + } + $Body = $BodyObject | ConvertTo-Json -Compress + $null = New-GraphPostRequest -uri $LimitedModeUri -tenantid $Tenant -type PATCH -body $Body -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotLimitedMode: Set limited mode to '$DesiredEnabled'$(if ($DesiredEnabled) { " for group '$GroupName'" })." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotLimitedMode: Failed to set limited mode. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotLimitedMode: Limited mode is in the desired state.' -sev Info + } else { + $AlertObject = [PSCustomObject]@{ + CurrentEnabled = $CurrentState.isEnabledForGroup + CurrentGroupId = $CurrentState.groupId + DesiredEnabled = $DesiredEnabled + } + Write-StandardsAlert -message 'CopilotLimitedMode: Limited mode is not in the desired state.' -object $AlertObject -tenant $Tenant -standardName 'CopilotLimitedMode' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotLimitedMode: Limited mode is not in the desired state.' -sev Info + } + } + + if ($Settings.report -eq $true) { + $CurrentValue = @{ + isEnabledForGroup = $CurrentState.isEnabledForGroup + groupId = $CurrentState.groupId + } + $ExpectedValue = @{ + isEnabledForGroup = $DesiredEnabled + groupId = if ($DesiredEnabled) { $ResolvedGroupId } else { $null } + } + Set-CIPPStandardsCompareField -FieldName 'standards.CopilotLimitedMode' -CurrentValue ([PSCustomObject]$CurrentValue) -ExpectedValue ([PSCustomObject]$ExpectedValue) -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'CopilotLimitedMode' -FieldValue ([bool]$StateIsCorrect) -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 new file mode 100644 index 000000000000..f47f0fa02bc9 --- /dev/null +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 @@ -0,0 +1,147 @@ +function Invoke-CIPPStandardCopilotSettings { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) CopilotSettings + .SYNOPSIS + (Label) Configure Microsoft 365 Copilot policy settings + .DESCRIPTION + (Helptext) Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level. + (DocsDescription) Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the "Do not configure" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly "1"/"0") and should be validated against a Copilot-licensed tenant. + .NOTES + CAT + Copilot (M365) Standards + TAG + EXECUTIVETEXT + Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements. + ADDEDCOMPONENT + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Pin Microsoft 365 Copilot Chat","name":"standards.CopilotSettings.copilotChatPinning","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Copilot Access to Open Content","name":"standards.CopilotSettings.blockAccessToOpenFiles","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Block open content","value":"1"},{"label":"Allow open content","value":"0"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Designer Image Generation","name":"standards.CopilotSettings.imageGeneration","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Web Search in Copilot","name":"standards.CopilotSettings.allowWebSearch","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Admin Copilot in Microsoft 365 Admin Center","name":"standards.CopilotSettings.allowInAdminCenters","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} + IMPACT + Medium Impact + ADDEDDATE + 2026-06-09 + POWERSHELLEQUIVALENT + Graph API: PATCH /beta/copilot/admin/policySettings/{id} + RECOMMENDEDBY + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + + # Supported Copilot policy settings. 'Id' is the Graph policySettings identifier; 'Key' is the CIPP setting field name. + $CopilotPolicySettings = @( + @{ Key = 'copilotChatPinning'; Id = 'microsoft.copilot.copilotchatpinning'; Label = 'Pin Microsoft 365 Copilot Chat' } + @{ Key = 'blockAccessToOpenFiles'; Id = 'microsoft.copilot.blockaccesstoopenfiles'; Label = 'Block Copilot Access to Open Content' } + @{ Key = 'imageGeneration'; Id = 'microsoft.copilot.imagegeneration'; Label = 'Designer Image Generation' } + @{ Key = 'allowWebSearch'; Id = 'microsoft.copilot.allowwebsearch'; Label = 'Allow web search in Copilot' } + @{ Key = 'allowInAdminCenters'; Id = 'microsoft.copilot.allowinadmincenters'; Label = 'Admin Copilot in Microsoft 365 Admin Center' } + ) + + # Determine which settings the admin explicitly configured (anything other than blank / 'donotconfigure') + $ConfiguredSettings = foreach ($Setting in $CopilotPolicySettings) { + $DesiredValue = $Settings.$($Setting.Key).value ?? $Settings.$($Setting.Key) + if ([string]::IsNullOrWhiteSpace($DesiredValue) -or $DesiredValue -eq 'donotconfigure') { continue } + [PSCustomObject]@{ + Key = $Setting.Key + Id = $Setting.Id + Label = $Setting.Label + Desired = [string]$DesiredValue + } + } + + if (-not $ConfiguredSettings -or @($ConfiguredSettings).Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotSettings: No Copilot settings configured, skipping.' -sev Info + return + } + + # Read current state for each configured setting. + # The Copilot policySettings API currently requires delegated auth (no -AsApp). The entity also + # carries a scalar 'value' property that is data rather than a collection envelope, so + # -SkipValueExtraction returns the entity intact. + $ComplianceResults = foreach ($Setting in $ConfiguredSettings) { + try { + $Current = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$($Setting.Id)" -tenantid $Tenant -SkipValueExtraction + [PSCustomObject]@{ + Key = $Setting.Key + Id = $Setting.Id + Label = $Setting.Label + Desired = $Setting.Desired + CurrentValue = $Current.value + PolicyId = $Current.policyId + IsCompliant = ([string]$Current.value -eq $Setting.Desired) + Error = $null + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotSettings: Could not retrieve '$($Setting.Label)'. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + [PSCustomObject]@{ + Key = $Setting.Key + Id = $Setting.Id + Label = $Setting.Label + Desired = $Setting.Desired + CurrentValue = $null + PolicyId = $null + IsCompliant = $false + Error = $ErrorMessage.NormalizedError + } + } + } + + if ($Settings.remediate -eq $true) { + foreach ($Result in $ComplianceResults) { + if ($Result.Error) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotSettings: Skipping remediation of '$($Result.Label)' due to a read error." -sev Warning + continue + } + if ($Result.IsCompliant) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotSettings: '$($Result.Label)' is already set to '$($Result.Desired)'." -sev Info + continue + } + try { + $Body = [pscustomobject]@{ value = $Result.Desired } | ConvertTo-Json -Compress + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$($Result.Id)" -tenantid $Tenant -type PATCH -body $Body -ContentType 'application/json' + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotSettings: Set '$($Result.Label)' to '$($Result.Desired)' (was '$($Result.CurrentValue)')." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotSettings: Failed to set '$($Result.Label)' to '$($Result.Desired)'. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + $NonCompliant = @($ComplianceResults | Where-Object { -not $_.IsCompliant }) + if ($NonCompliant.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'CopilotSettings: All configured Copilot settings are compliant.' -sev Info + } else { + $AlertDetails = foreach ($Result in $NonCompliant) { + [PSCustomObject]@{ + Setting = $Result.Label + Current = $Result.CurrentValue + Desired = $Result.Desired + } + } + Write-StandardsAlert -message "CopilotSettings: $($NonCompliant.Count) Copilot setting(s) not compliant: $(($NonCompliant.Label) -join ', ')" -object $AlertDetails -tenant $Tenant -standardName 'CopilotSettings' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "CopilotSettings: $($NonCompliant.Count) Copilot setting(s) not compliant." -sev Info + } + } + + if ($Settings.report -eq $true) { + $CurrentState = @{} + $ExpectedState = @{} + foreach ($Result in $ComplianceResults) { + $CurrentState[$Result.Key] = $Result.CurrentValue + $ExpectedState[$Result.Key] = $Result.Desired + } + Set-CIPPStandardsCompareField -FieldName 'standards.CopilotSettings' -CurrentValue ([PSCustomObject]$CurrentState) -ExpectedValue ([PSCustomObject]$ExpectedState) -TenantFilter $Tenant + $AllCompliant = -not ($ComplianceResults | Where-Object { -not $_.IsCompliant }) + Add-CIPPBPAField -FieldName 'CopilotSettings' -FieldValue ([bool]$AllCompliant) -StoreAs bool -Tenant $Tenant + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 index 37eb3e0402a3..4feaaec7e7ca 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardFIDO2PasskeyProfiles.ps1 @@ -16,7 +16,7 @@ function Invoke-CIPPStandardFIDO2PasskeyProfiles { EXECUTIVETEXT Configures the default passkey (FIDO2) profile that controls which authenticators users can register for phishing-resistant MFA. Supports allowlisting specific hardware keys (e.g., YubiKey models), password managers (e.g., 1Password), and Microsoft Authenticator by AAGUID, with control over attestation enforcement and passkey types. ADDEDCOMPONENT - [{"type":"select","multiple":false,"name":"standards.FIDO2PasskeyProfiles.PasskeyTypes","label":"Allowed Passkey Types","options":[{"label":"Device-bound only","value":"deviceBound"},{"label":"Synced only","value":"synced"},{"label":"Both device-bound and synced","value":"deviceBound,synced"}],"required":true},{"type":"select","multiple":false,"name":"standards.FIDO2PasskeyProfiles.AttestationEnforcement","label":"Attestation Enforcement","options":[{"label":"Disabled (required for synced passkeys)","value":"disabled"},{"label":"Registration only","value":"registrationOnly"}],"required":true},{"type":"switch","name":"standards.FIDO2PasskeyProfiles.EnforceKeyRestrictions","label":"Enforce AAGUID Key Restrictions"},{"type":"select","multiple":false,"name":"standards.FIDO2PasskeyProfiles.EnforcementType","label":"Key Restriction Type","options":[{"label":"Allow listed AAGUIDs only","value":"allow"},{"label":"Block listed AAGUIDs","value":"block"}]},{"type":"textField","name":"standards.FIDO2PasskeyProfiles.AAGUIDs","label":"AAGUIDs (comma-separated list of authenticator AAGUIDs)"}] + [{"type":"select","multiple":false,"name":"standards.FIDO2PasskeyProfiles.PasskeyTypes","label":"Allowed Passkey Types","options":[{"label":"Device-bound only","value":"deviceBound"},{"label":"Synced only","value":"synced"},{"label":"Both device-bound and synced","value":"deviceBound,synced"}],"required":true},{"type":"select","multiple":false,"name":"standards.FIDO2PasskeyProfiles.AttestationEnforcement","label":"Attestation Enforcement","options":[{"label":"Disabled (required for synced passkeys)","value":"disabled"},{"label":"Registration only","value":"registrationOnly"}],"required":true},{"type":"switch","name":"standards.FIDO2PasskeyProfiles.EnforceKeyRestrictions","label":"Enforce AAGUID Key Restrictions"},{"type":"select","multiple":false,"name":"standards.FIDO2PasskeyProfiles.EnforcementType","label":"Key Restriction Type","options":[{"label":"Allow listed AAGUIDs only","value":"allow"},{"label":"Block listed AAGUIDs","value":"block"}],"required":false},{"type":"textField","name":"standards.FIDO2PasskeyProfiles.AAGUIDs","label":"AAGUIDs (comma-separated list of authenticator AAGUIDs)","required":false}] IMPACT Medium Impact ADDEDDATE diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 index 49e6233afbf8..e2bea7df24a7 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMailContacts.ps1 @@ -46,9 +46,10 @@ function Invoke-CIPPStandardMailContacts { $contacts = $settings $TechAndSecurityContacts = @(@($contacts.SecurityContact, $contacts.TechContact) | Where-Object { $_ } | Select-Object -Unique) - $marketingMatch = @($CurrentInfo.marketingNotificationEmails) -contains $contacts.MarketingContact - $techMatch = -not (Compare-Object @($CurrentInfo.technicalNotificationMails) $TechAndSecurityContacts) - $generalMatch = $CurrentInfo.privacyProfile.contactEmail -eq $contacts.GeneralContact + # If an input value is null/empty, ignore the target tenant's current state and treat it as compliant. + $marketingMatch = [string]::IsNullOrWhiteSpace($contacts.MarketingContact) -or (@($CurrentInfo.marketingNotificationEmails) -contains $contacts.MarketingContact) + $techMatch = $TechAndSecurityContacts.Count -eq 0 -or (-not (Compare-Object @($CurrentInfo.technicalNotificationMails) $TechAndSecurityContacts)) + $generalMatch = [string]::IsNullOrWhiteSpace($contacts.GeneralContact) -or ($CurrentInfo.privacyProfile.contactEmail -eq $contacts.GeneralContact) $state = $marketingMatch -and $techMatch -and $generalMatch @@ -74,7 +75,7 @@ function Invoke-CIPPStandardMailContacts { if ($Settings.alert -eq $true) { - if ($CurrentInfo.marketingNotificationEmails -eq $Contacts.MarketingContact) { + if (!$Contacts.MarketingContact -or $CurrentInfo.marketingNotificationEmails -eq $Contacts.MarketingContact) { Write-LogMessage -API 'Standards' -tenant $tenant -message "Marketing contact email is set to $($Contacts.MarketingContact)" -sev Info } else { $Object = $CurrentInfo | Select-Object marketingNotificationEmails @@ -95,7 +96,7 @@ function Invoke-CIPPStandardMailContacts { Write-StandardsAlert -message "Technical contact email is not set to $($Contacts.TechContact)" -object $Object -tenant $tenant -standardName 'MailContacts' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $tenant -message "Technical contact email is not set to $($Contacts.TechContact)" -sev Info } - if ($CurrentInfo.privacyProfile.contactEmail -eq $Contacts.GeneralContact) { + if (!$Contacts.GeneralContact -or $CurrentInfo.privacyProfile.contactEmail -eq $Contacts.GeneralContact) { Write-LogMessage -API 'Standards' -tenant $tenant -message "General contact email is set to $($Contacts.GeneralContact)" -sev Info } else { $Object = $CurrentInfo | Select-Object privacyProfile @@ -110,10 +111,11 @@ function Invoke-CIPPStandardMailContacts { technicalNotificationMails = @($CurrentInfo.technicalNotificationMails | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) contactEmail = $CurrentInfo.privacyProfile.contactEmail } + # When an input value is null/empty, mirror the current state so the field is reported as compliant. $ExpectedValue = @{ - marketingNotificationEmails = @($Contacts.MarketingContact | Sort-Object -Unique) - technicalNotificationMails = @(@($Contacts.SecurityContact, $Contacts.TechContact) | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique) - contactEmail = $Contacts.GeneralContact + marketingNotificationEmails = @(if ([string]::IsNullOrWhiteSpace($Contacts.MarketingContact)) { $CurrentValue.marketingNotificationEmails } else { $Contacts.MarketingContact | Sort-Object -Unique }) + technicalNotificationMails = @(if ($TechAndSecurityContacts.Count -eq 0) { $CurrentValue.technicalNotificationMails } else { $TechAndSecurityContacts | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false } | Sort-Object -Unique }) + contactEmail = if ([string]::IsNullOrWhiteSpace($Contacts.GeneralContact)) { $CurrentValue.contactEmail } else { $Contacts.GeneralContact } } Set-CIPPStandardsCompareField -FieldName 'standards.MailContacts' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant Add-CIPPBPAField -FieldName 'MailContacts' -FieldValue $CurrentInfo -StoreAs json -Tenant $tenant diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 index 3d2e2ce054f2..8ef3ef2cc58f 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMalwareFilterPolicy.ps1 @@ -81,26 +81,28 @@ function Invoke-CIPPStandardMalwareFilterPolicy { } # Use custom name if provided, otherwise use default for backward compatibility - $PolicyName = if ($Settings.name) { $Settings.name } else { 'CIPP Default Malware Policy' } - $PolicyList = @($PolicyName, 'CIPP Default Malware Policy', 'Default Malware Policy') - $ExistingPolicy = $AllMalwareFilterPolicies | Where-Object -Property Name -In $PolicyList | Select-Object -First 1 - if ($null -eq $ExistingPolicy.Name) { - # No existing policy - use the configured/default name - $PolicyName = if ($Settings.name) { $Settings.name } else { 'CIPP Default Malware Policy' } - } else { - # Use existing policy name if found - $PolicyName = $ExistingPolicy.Name + $DefaultPolicyName = 'CIPP Default Malware Policy' + $PolicyName = if ($Settings.name) { $Settings.name } else { $DefaultPolicyName } + # 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. + if ($PolicyName -eq $DefaultPolicyName) { + $PolicyList = @($PolicyName, 'Default Malware Policy') + $ExistingPolicy = $AllMalwareFilterPolicies | Where-Object -Property Name -In $PolicyList | Select-Object -First 1 + if ($null -ne $ExistingPolicy.Name) { + # Use existing policy name if found + $PolicyName = $ExistingPolicy.Name + } } - # Derive rule name from policy name, but check for old names for backward compatibility + # Derive rule name from policy name. Only check legacy names when using the default policy name. $DesiredRuleName = "$PolicyName Rule" - $RuleList = @($DesiredRuleName, 'CIPP Default Malware Rule', 'CIPP Default Malware Policy') - $ExistingRule = $AllMalwareFilterRules | Where-Object -Property Name -In $RuleList | Select-Object -First 1 - if ($null -eq $ExistingRule.Name) { - # No existing rule - use the derived name - $RuleName = $DesiredRuleName - } else { - # Use existing rule name if found - $RuleName = $ExistingRule.Name + $RuleName = $DesiredRuleName + if ($PolicyName -eq $DefaultPolicyName) { + $RuleList = @($DesiredRuleName, 'CIPP Default Malware Rule', 'CIPP Default Malware Policy') + $ExistingRule = $AllMalwareFilterRules | Where-Object -Property Name -In $RuleList | Select-Object -First 1 + if ($null -ne $ExistingRule.Name) { + # Use existing rule name if found + $RuleName = $ExistingRule.Name + } } $CurrentState = $AllMalwareFilterPolicies | diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 index 54cffe780164..b1f147d96ce6 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSPOVersionControl.ps1 @@ -18,7 +18,7 @@ function Invoke-CIPPStandardSPOVersionControl { ADDEDCOMPONENT {"type":"switch","name":"standards.SPOVersionControl.EnableAutoTrim","label":"Enable Automatic Version Trimming (Microsoft managed)"} {"type":"number","name":"standards.SPOVersionControl.MajorVersionLimit","label":"Maximum Major Versions (when auto trim is off)","default":50} - {"type":"number","name":"standards.SPOVersionControl.ExpireVersionsAfterDays","label":"Expire Versions After Days (0 = never, when auto trim is off)","default":0} + {"type":"number","name":"standards.SPOVersionControl.ExpireVersionsAfterDays","label":"Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)","default":0,"validators":{"min":{"value":0,"message":"Use 0 for never, or 30 or more days"},"max":{"value":36500,"message":"Maximum value is 36500"}}} {"type":"switch","name":"standards.SPOVersionControl.ApplyToExistingSites","label":"Apply to all existing sites and document libraries"} IMPACT Medium Impact @@ -53,6 +53,15 @@ function Invoke-CIPPStandardSPOVersionControl { $DesiredMajorVersionLimit = [int]($Settings.MajorVersionLimit ?? 50) $DesiredExpireVersionsAfterDays = [int]($Settings.ExpireVersionsAfterDays ?? 0) + # SharePoint only accepts 0 (never expire) or 30-36500 days for version expiration. Reject + # anything in the 1-29 gap (or above the max) up front so we never send a value the tenant + # will refuse. This is the same 30-day floor the version cleanup (trim) job enforces. + if (-not $DesiredAutoTrim -and $DesiredExpireVersionsAfterDays -ne 0 -and + ($DesiredExpireVersionsAfterDays -lt 30 -or $DesiredExpireVersionsAfterDays -gt 36500)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "SPOVersionControl: ExpireVersionsAfterDays must be 0 (never) or between 30 and 36500 days. Received '$DesiredExpireVersionsAfterDays'. Skipping standard." -sev Error + return + } + try { $CurrentState = Get-CIPPSPOTenant -TenantFilter $Tenant | Select-Object -Property _ObjectIdentity_, TenantFilter, EnableAutoExpirationVersionTrim, MajorVersionLimit, ExpireVersionsAfterDays } catch { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSecureScoreRemediation.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSecureScoreRemediation.ps1 index 077f9f0699c1..f0697c0803e4 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSecureScoreRemediation.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSecureScoreRemediation.ps1 @@ -43,6 +43,14 @@ function Invoke-CIPPStandardSecureScoreRemediation { return } + # secureScoreControlProfile has no top-level 'state' property; the effective state is + # the most recent controlStateUpdates entry. No entries means the control is at default. + $CurrentStates = @{} + foreach ($ControlProfile in $CurrentControls) { + $LatestUpdate = $ControlProfile.controlStateUpdates | Sort-Object updatedDateTime | Select-Object -Last 1 + $CurrentStates[$ControlProfile.id] = [string]::IsNullOrEmpty($LatestUpdate.state) ? 'default' : $LatestUpdate.state + } + # Build list of controls with their desired states $ControlsToUpdate = [System.Collections.Generic.List[object]]::new() @@ -96,26 +104,27 @@ function Invoke-CIPPStandardSecureScoreRemediation { if ($Settings.remediate -eq $true) { $ControlsNeedingUpdate = [System.Collections.Generic.List[object]]::new() + $CompliantControls = [System.Collections.Generic.List[string]]::new() foreach ($Control in $ControlsToUpdate) { # Skip if this is a Defender control (starts with scid_) if ($Control.ControlName -match '^scid_') { - Write-Host 'scid' Write-LogMessage -API 'Standards' -tenant $tenant -message "Skipping Defender control $($Control.ControlName) - cannot be updated via this API" -sev Info continue } - $CurrentControl = $CurrentControls | Where-Object { $_.id -eq $Control.ControlName } - # Check if already in desired state - if ($CurrentControl.state -eq $Control.State) { - Write-Host 'Already in state' - Write-LogMessage -API 'Standards' -tenant $tenant -message "Control $($Control.ControlName) is already in state $($Control.State)" -sev Info + if ($CurrentStates[$Control.ControlName] -eq $Control.State) { + $CompliantControls.Add($Control.ControlName) } else { $ControlsNeedingUpdate.Add($Control) } } + if ($CompliantControls.Count -gt 0) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "$($CompliantControls.Count) Secure Score control(s) already in desired state: $($CompliantControls -join ', ')" -sev Info + } + # Build bulk requests for all controls that need updating if ($ControlsNeedingUpdate.Count -gt 0) { $int = 1 @@ -141,17 +150,22 @@ function Invoke-CIPPStandardSecureScoreRemediation { try { $BulkResults = New-GraphBulkRequest -tenantid $Tenant -Requests @($BulkRequests) + $UpdatedControls = [System.Collections.Generic.List[string]]::new() for ($i = 0; $i -lt $BulkResults.Count; $i++) { $result = $BulkResults[$i] $Control = $ControlsNeedingUpdate[$i] if ($result.status -eq 200 -or $result.status -eq 204) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Successfully set control $($Control.ControlName) to $($Control.State)" -sev Info + $UpdatedControls.Add("$($Control.ControlName) -> $($Control.State)") } else { $errorMsg = if ($result.body.error.message) { $result.body.error.message } else { "Unknown error (Status: $($result.status))" } Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set control $($Control.ControlName) to $($Control.State). Error: $errorMsg" -sev Error } } + + if ($UpdatedControls.Count -gt 0) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Updated $($UpdatedControls.Count) Secure Score control(s): $($UpdatedControls -join ', ')" -sev Info + } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to update secure score controls in bulk. Error: $ErrorMessage" -sev Error @@ -161,19 +175,18 @@ function Invoke-CIPPStandardSecureScoreRemediation { if ($Settings.alert -eq $true) { $AlertMessages = [System.Collections.Generic.List[string]]::new() + $ExpectedStateControls = [System.Collections.Generic.List[string]]::new() foreach ($Control in $ControlsToUpdate) { if ($Control.ControlName -match '^scid_') { continue } - $CurrentControl = $CurrentControls | Where-Object { $_.id -eq $Control.ControlName } - - if ($CurrentControl) { - if ($CurrentControl.state -eq $Control.State) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Control $($Control.ControlName) is in expected state: $($Control.State)" -sev Info + if ($CurrentStates.ContainsKey($Control.ControlName)) { + if ($CurrentStates[$Control.ControlName] -eq $Control.State) { + $ExpectedStateControls.Add($Control.ControlName) } else { - $AlertMessage = "Control $($Control.ControlName) is in state $($CurrentControl.state), expected $($Control.State)" + $AlertMessage = "Control $($Control.ControlName) is in state $($CurrentStates[$Control.ControlName]), expected $($Control.State)" $AlertMessages.Add($AlertMessage) Write-LogMessage -API 'Standards' -tenant $tenant -message $AlertMessage -sev Alert } @@ -184,6 +197,10 @@ function Invoke-CIPPStandardSecureScoreRemediation { } } + if ($ExpectedStateControls.Count -gt 0) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "$($ExpectedStateControls.Count) Secure Score control(s) in expected state: $($ExpectedStateControls -join ', ')" -sev Info + } + if ($AlertMessages.Count -gt 0) { Write-StandardsAlert -message 'Secure Score controls not in expected state' -object @{Issues = $AlertMessages.ToArray() } -tenant $Tenant -standardName 'SecureScoreRemediation' -standardId $Settings.standardId } @@ -197,8 +214,7 @@ function Invoke-CIPPStandardSecureScoreRemediation { continue } - $CurrentControl = $CurrentControls | Where-Object { $_.id -eq $Control.ControlName } - $LatestState = ($CurrentControl.controlStateUpdates | Select-Object -Last 1).state + $LatestState = $CurrentStates[$Control.ControlName] if ($LatestState -ne $Control.State) { $ReportData.Add(@{ ControlName = $Control.ControlName diff --git a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 index 816fe0b820de..ebcb738157ac 100644 --- a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 +++ b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 @@ -58,6 +58,8 @@ function Invoke-CippTestGenericTest011 { $ResolveDisplayName = { param($StandardName, $TemplateSettings) + if ([string]::IsNullOrWhiteSpace($StandardName)) { return $null } + # 1. Regular standards — look up in standards.json if ($StandardsLabelMap.ContainsKey($StandardName)) { return $StandardsLabelMap[$StandardName] diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21775.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21775.ps1 new file mode 100644 index 000000000000..80d26300e2e7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21775.ps1 @@ -0,0 +1,60 @@ +function Invoke-CippTestZTNA21775 { + <# + .SYNOPSIS + Tenant app management policy is configured + #> + param($Tenant) + + try { + $PolicyData = Get-CIPPTestData -TenantFilter $Tenant -Type 'DefaultAppManagementPolicy' + + if (-not $PolicyData) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21775' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Medium' -Name 'Tenant app management policy is configured' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Application Management' + return + } + + $Policy = if ($PolicyData -is [System.Collections.IList]) { $PolicyData[0] } else { $PolicyData } + + $Enabled = $Policy.isEnabled -eq $true + $AppRestrictions = $Policy.applicationRestrictions + $SpRestrictions = $Policy.servicePrincipalRestrictions + + $HasActiveRule = { + param($Restrictions) + if (-not $Restrictions) { return $false } + foreach ($Section in 'passwordCredentials', 'keyCredentials') { + $Rules = $Restrictions.$Section + if ($Rules -and ($Rules.Where({ $_.state -eq 'enabled' })).Count -gt 0) { + return $true + } + } + return $false + } + + $AppHasRule = & $HasActiveRule $AppRestrictions + $SpHasRule = & $HasActiveRule $SpRestrictions + $Passed = $Enabled -and ($AppHasRule -or $SpHasRule) + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($Passed) { + $Status = 'Passed' + $Lines.Add('Tenant default app management policy is enabled with active credential restrictions.') + } else { + $Status = 'Failed' + $Lines.Add('Tenant default app management policy is not properly configured.') + $Lines.Add('') + $Lines.Add("- **isEnabled:** $Enabled") + $Lines.Add("- **applicationRestrictions has active rule:** $AppHasRule") + $Lines.Add("- **servicePrincipalRestrictions has active rule:** $SpHasRule") + $Lines.Add('') + $Lines.Add('**Remediation:** Enable the default app management policy and configure credential restrictions to control how applications can use password and key credentials.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21775' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'Medium' -Name 'Tenant app management policy is configured' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Application Management' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21775' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Tenant app management policy is configured' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21777.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21777.ps1 new file mode 100644 index 000000000000..9e05d2832d60 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21777.ps1 @@ -0,0 +1,61 @@ +function Invoke-CippTestZTNA21777 { + <# + .SYNOPSIS + App instance property lock is configured for all multitenant applications + #> + param($Tenant) + + try { + $Apps = Get-CIPPTestData -TenantFilter $Tenant -Type 'Apps' + + if (-not $Apps) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21777' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'App instance property lock is configured for all multitenant applications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + return + } + + $MultitenantAudiences = 'AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount', 'PersonalMicrosoftAccount' + $MultitenantApps = $Apps.Where({ $_.signInAudience -in $MultitenantAudiences }) + + if ($MultitenantApps.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21777' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No multitenant applications found in the tenant.' -Risk 'High' -Name 'App instance property lock is configured for all multitenant applications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + return + } + + $NonCompliantApps = [System.Collections.Generic.List[object]]::new() + foreach ($App in $MultitenantApps) { + $Lock = $App.servicePrincipalLockConfiguration + $LockEnabled = $Lock.isEnabled -eq $true -and $Lock.allProperties -eq $true + if (-not $LockEnabled) { + $NonCompliantApps.Add($App) + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($NonCompliantApps.Count -eq 0) { + $Status = 'Passed' + $Lines.Add("All $($MultitenantApps.Count) multitenant application(s) have property lock configured.") + } else { + $Status = 'Failed' + $Lines.Add("$($NonCompliantApps.Count) of $($MultitenantApps.Count) multitenant application(s) are missing property lock configuration.") + $Lines.Add('') + $Lines.Add('| Display Name | App ID | Sign-In Audience |') + $Lines.Add('| :----------- | :----- | :--------------- |') + foreach ($App in ($NonCompliantApps | Select-Object -First 25)) { + $Lines.Add("| $($App.displayName) | $($App.appId) | $($App.signInAudience) |") + } + if ($NonCompliantApps.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($NonCompliantApps.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Configure `servicePrincipalLockConfiguration` with `isEnabled = true` and `allProperties = true` on each multitenant app to prevent unauthorized property modifications.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21777' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'High' -Name 'App instance property lock is configured for all multitenant applications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21777' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'App instance property lock is configured for all multitenant applications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.md index 6a740c4322e2..4d8fa7607490 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.md +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.md @@ -1,6 +1,10 @@ -... +Microsoft Entra ID Protection generates risk detections for sign-in and user-level anomalies, including unfamiliar locations, anonymous IP usage, leaked credentials, and impossible travel. When these detections remain in an untriaged state for an extended time, an organization loses the operational signal that an account may be compromised. Threat actors then have a longer window to extend persistence, move laterally, or stage further attacks before defenders are aware of the activity. + +Triaging each detection — by dismissing, marking the user as compromised, or confirming safe — closes the feedback loop with ID Protection's machine-learning models and ensures the security team has actioned every risk indicator the platform has surfaced. **Remediation action** +- [Investigate risk in Microsoft Entra ID Protection](https://learn.microsoft.com/entra/id-protection/howto-identity-protection-investigate-risk?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) +- [Remediate risks and unblock users](https://learn.microsoft.com/entra/id-protection/howto-identity-protection-remediate-unblock?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) %TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.ps1 new file mode 100644 index 000000000000..ebb72aaa3c40 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21864.ps1 @@ -0,0 +1,64 @@ +function Invoke-CippTestZTNA21864 { + <# + .SYNOPSIS + All risk detections are triaged + #> + param($Tenant) + + try { + $RiskDetections = Get-CIPPTestData -TenantFilter $Tenant -Type 'RiskDetections' + + if (-not $RiskDetections) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21864' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'All risk detections are triaged' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Access Control' + return + } + + # Risk detections that haven't been actioned. Anything still in atRisk/unknownFutureValue + # older than 30 days is untriaged. + $TriagedStates = 'remediated', 'dismissed', 'confirmedSafe', 'none' + $Threshold = (Get-Date).AddDays(-30) + + $Untriaged = [System.Collections.Generic.List[object]]::new() + foreach ($Detection in $RiskDetections) { + if ($Detection.riskState -in $TriagedStates) { continue } + $When = $Detection.detectedDateTime ?? $Detection.activityDateTime + if (-not $When) { continue } + try { + if (([DateTime]$When) -lt $Threshold) { $Untriaged.Add($Detection) } + } catch { } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($Untriaged.Count -eq 0) { + $Status = 'Passed' + $Lines.Add("All $($RiskDetections.Count) risk detection(s) have been triaged or are recent (within 30 days).") + } else { + $Status = 'Failed' + $Lines.Add("$($Untriaged.Count) risk detection(s) older than 30 days remain in an untriaged state.") + $Lines.Add('') + $Lines.Add("**Total detections:** $($RiskDetections.Count)") + $Lines.Add("**Untriaged (>30 days):** $($Untriaged.Count)") + $Lines.Add('') + $Lines.Add('| User | Risk Event | Risk Level | Risk State | Detected |') + $Lines.Add('| :--- | :--------- | :--------- | :--------- | :------- |') + $Top = $Untriaged | Sort-Object { [DateTime]($_.detectedDateTime ?? $_.activityDateTime) } | Select-Object -First 25 + foreach ($D in $Top) { + $When = ($D.detectedDateTime ?? $D.activityDateTime) + $Lines.Add("| $($D.userDisplayName ?? '-') | $($D.riskEventType ?? '-') | $($D.riskLevel ?? '-') | $($D.riskState ?? '-') | $When |") + } + if ($Untriaged.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($Untriaged.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Investigate and triage the listed risk detections through the Microsoft Entra ID Protection portal. Resolve each by marking the user as compromised, dismissing, or confirming safe.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21864' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'High' -Name 'All risk detections are triaged' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21864' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'All risk detections are triaged' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.md index 6a740c4322e2..0749e51d4bbf 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.md +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.md @@ -1,6 +1,10 @@ -... +Standing (permanent) assignments to privileged Microsoft Entra roles such as Global Administrator, Privileged Role Administrator, or Security Administrator expand the blast radius of a single account compromise. A threat actor who acquires credentials for an account with a permanent privileged assignment immediately inherits the full role, with no MFA challenge, approval workflow, or time bound on the access. + +Privileged Identity Management (PIM) replaces permanent assignments with just-in-time eligibility. Users must request and activate the role for a bounded duration, typically with MFA and optionally with approval. This shrinks the window of opportunity for an attacker and produces audit trails on every elevation. **Remediation action** +- [Convert standing privileged role assignments to eligible PIM assignments](https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-how-to-add-role-to-user?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) +- [Configure PIM role settings](https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) %TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.ps1 new file mode 100644 index 000000000000..3f9d35c722ff --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21876.ps1 @@ -0,0 +1,71 @@ +function Invoke-CippTestZTNA21876 { + <# + .SYNOPSIS + Use PIM for Microsoft Entra privileged roles + #> + param($Tenant) + + try { + $RoleAssignments = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $RoleAssignments) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21876' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Medium' -Name 'Use PIM for Microsoft Entra privileged roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + return + } + + # Well-known privileged role template IDs. + $PrivilegedRoleTemplateIds = @( + '62e90394-69f5-4237-9190-012177145e10' # Global Administrator + 'e8611ab8-c189-46e8-94e1-60213ab1f814' # Privileged Role Administrator + '194ae4cb-b126-40b2-bd5b-6091b380977d' # Security Administrator + 'fe930be7-5e62-47db-91af-98c3a49a38b1' # User Administrator + '729827e3-9c14-49f7-bb1b-9608f156bbb8' # Helpdesk Administrator + 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' # SharePoint Administrator + '29232cdf-9323-42fd-ade2-1d097af3e4de' # Exchange Administrator + '69091246-20e8-4a56-aa4d-066075b2a7a8' # Teams Administrator + '158c047a-c907-4556-b7ef-446551a6b5f7' # Cloud Application Administrator + '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' # Application Administrator + 'b0f54661-2d74-4c50-afa3-1ec803f12efe' # Billing Administrator + 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' # Conditional Access Administrator + '966707d0-3269-4727-9be2-8c3a10f19b9d' # Password Administrator + 'e3973bdf-4987-49ae-837a-ba8e231c7286' # Azure DevOps Administrator + '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' # Privileged Authentication Administrator + ) + + $PermanentToPrivileged = [System.Collections.Generic.List[object]]::new() + foreach ($A in $RoleAssignments) { + if ($A.roleDefinitionId -notin $PrivilegedRoleTemplateIds) { continue } + if ($A.assignmentType -eq 'Assigned' -and $A.memberType -in 'Direct', 'Group') { + $PermanentToPrivileged.Add($A) + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($PermanentToPrivileged.Count -eq 0) { + $Status = 'Passed' + $Lines.Add('No permanent (non-PIM) assignments found for privileged Microsoft Entra roles.') + } else { + $Status = 'Failed' + $Lines.Add("$($PermanentToPrivileged.Count) permanent assignment(s) found for privileged Microsoft Entra roles. These should be managed via PIM eligibility instead.") + $Lines.Add('') + $Lines.Add('| Principal | Role Definition ID | Assignment Type | Member Type |') + $Lines.Add('| :-------- | :----------------- | :-------------- | :---------- |') + foreach ($A in ($PermanentToPrivileged | Select-Object -First 25)) { + $Lines.Add("| $($A.principalId) | $($A.roleDefinitionId) | $($A.assignmentType) | $($A.memberType) |") + } + if ($PermanentToPrivileged.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($PermanentToPrivileged.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Move standing privileged role assignments into PIM as eligible assignments so users must activate the role just-in-time with MFA and approval.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21876' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'Medium' -Name 'Use PIM for Microsoft Entra privileged roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21876' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'Use PIM for Microsoft Entra privileged roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.md index 6a740c4322e2..7c9293034ddc 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.md +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.md @@ -1,6 +1,10 @@ -... +PIM for Groups extends just-in-time elevation to group membership, so users only become members of a role-assignable group for a bounded duration. When such groups contain other groups as members instead of direct user assignments, the PIM activation flow is bypassed for everyone in the nested group — they inherit membership at all times rather than going through PIM activation. + +Nested groups also obscure the effective access picture. Auditors can no longer determine, from the role-assignable group alone, which users actually hold the role at any given moment. **Remediation action** +- [Replace nested group memberships with direct user assignments on role-assignable groups](https://learn.microsoft.com/entra/id-governance/privileged-identity-management/concept-pim-for-groups?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) +- [Best practices for role-assignable groups](https://learn.microsoft.com/entra/identity/role-based-access-control/groups-concept?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) %TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.ps1 new file mode 100644 index 000000000000..3d70e3d93b27 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21882.ps1 @@ -0,0 +1,68 @@ +function Invoke-CippTestZTNA21882 { + <# + .SYNOPSIS + No nested groups in PIM for groups + #> + param($Tenant) + + try { + $Groups = Get-CIPPTestData -TenantFilter $Tenant -Type 'Groups' + + if (-not $Groups) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21882' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Medium' -Name 'No nested groups in PIM for groups' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' + return + } + + # Role-assignable groups are the ones eligible for PIM-for-Groups. + # A nested group member is one without a userPrincipalName (the Groups cache only + # selects id/displayName/userPrincipalName, so missing UPN strongly implies a group). + $RoleAssignableGroups = $Groups.Where({ $_.isAssignableToRole -eq $true }) + + if ($RoleAssignableGroups.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21882' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No role-assignable groups found in the tenant.' -Risk 'Medium' -Name 'No nested groups in PIM for groups' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' + return + } + + $NestedGroups = [System.Collections.Generic.List[object]]::new() + foreach ($G in $RoleAssignableGroups) { + $Members = $G.members + if (-not $Members) { continue } + $GroupMembers = $Members.Where({ [string]::IsNullOrEmpty($_.userPrincipalName) }) + if ($GroupMembers.Count -gt 0) { + $NestedGroups.Add([PSCustomObject]@{ + Group = $G + NestedCount = $GroupMembers.Count + NestedSample = ($GroupMembers | Select-Object -First 3).displayName -join ', ' + }) + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($NestedGroups.Count -eq 0) { + $Status = 'Passed' + $Lines.Add("All $($RoleAssignableGroups.Count) role-assignable group(s) contain only direct user members — no nested groups detected.") + } else { + $Status = 'Failed' + $Lines.Add("$($NestedGroups.Count) of $($RoleAssignableGroups.Count) role-assignable group(s) contain nested group members.") + $Lines.Add('') + $Lines.Add('| Group | Nested Members | Sample |') + $Lines.Add('| :---- | :------------- | :----- |') + foreach ($Entry in ($NestedGroups | Select-Object -First 25)) { + $Lines.Add("| $($Entry.Group.displayName) | $($Entry.NestedCount) | $($Entry.NestedSample) |") + } + if ($NestedGroups.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($NestedGroups.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Replace nested-group memberships in role-assignable / PIM-managed groups with direct user assignments. Nesting bypasses the PIM activation flow for users in the nested group.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21882' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'Medium' -Name 'No nested groups in PIM for groups' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21882' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'No nested groups in PIM for groups' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21884.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21884.ps1 new file mode 100644 index 000000000000..a1e9c5ea4e25 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21884.ps1 @@ -0,0 +1,56 @@ +function Invoke-CippTestZTNA21884 { + <# + .SYNOPSIS + Workload identities based on known networks are configured + #> + param($Tenant) + + try { + $CAPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $CAPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21884' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Workload identities based on known networks are configured' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'External collaboration' + return + } + + # A workload-identity CA policy targets service principals AND uses a location condition. + $WorkloadPolicies = [System.Collections.Generic.List[object]]::new() + foreach ($P in $CAPolicies) { + if ($P.state -ne 'enabled') { continue } + $TargetsWorkload = ($P.conditions.clientApplications.includeServicePrincipals.Count -gt 0) -or + ($P.conditions.clientApplications.includeServicePrincipals -contains 'All') -or + ($P.conditions.clientApplications.includeServicePrincipals -contains 'ServicePrincipalsInMyTenant') + + $HasLocation = ($P.conditions.locations.includeLocations.Count -gt 0) -or + ($P.conditions.locations.excludeLocations.Count -gt 0) + + if ($TargetsWorkload -and $HasLocation) { + $WorkloadPolicies.Add($P) + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($WorkloadPolicies.Count -gt 0) { + $Status = 'Passed' + $Lines.Add("Found $($WorkloadPolicies.Count) enabled Conditional Access policy(s) protecting workload identities with location conditions.") + $Lines.Add('') + $Lines.Add('| Policy Name | State |') + $Lines.Add('| :---------- | :---- |') + foreach ($P in ($WorkloadPolicies | Select-Object -First 25)) { + $Lines.Add("| $($P.displayName) | $($P.state) |") + } + } else { + $Status = 'Failed' + $Lines.Add('No enabled Conditional Access policies were found that target workload identities (service principals) and include a location condition.') + $Lines.Add('') + $Lines.Add('**Remediation:** Create a Conditional Access policy targeting service principals (`clientApplications.includeServicePrincipals`) with a trusted named-location condition. Requires Microsoft Entra Workload Identities license.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21884' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'High' -Name 'Workload identities based on known networks are configured' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'External collaboration' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21884' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Workload identities based on known networks are configured' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'External collaboration' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21885.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21885.ps1 new file mode 100644 index 000000000000..3cb559cb4284 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21885.ps1 @@ -0,0 +1,87 @@ +function Invoke-CippTestZTNA21885 { + <# + .SYNOPSIS + App registrations use safe redirect URIs + #> + param($Tenant) + + try { + $Apps = Get-CIPPTestData -TenantFilter $Tenant -Type 'Apps' + + if (-not $Apps) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21885' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'App registrations use safe redirect URIs' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Application Management' + return + } + + # Returns array of {Reason, Uri} entries for any unsafe URI on the app. + $TestUri = { + param([string]$Uri, [string]$Section) + $Issues = [System.Collections.Generic.List[hashtable]]::new() + if ([string]::IsNullOrWhiteSpace($Uri)) { return $Issues } + if ($Uri -match '\*') { $Issues.Add(@{ Reason = 'Wildcard URI'; Uri = $Uri; Section = $Section }) } + if ($Uri -match '^http://(?!localhost(?:[:/]|$))') { $Issues.Add(@{ Reason = 'Plain HTTP (non-localhost)'; Uri = $Uri; Section = $Section }) } + if ($Uri -match '\.azurewebsites\.net') { $Issues.Add(@{ Reason = 'Azure default *.azurewebsites.net domain (subject to subdomain takeover)'; Uri = $Uri; Section = $Section }) } + if ($Uri -match '\.cloudapp\.(?:net|azure\.com)') { $Issues.Add(@{ Reason = 'Azure default cloudapp domain'; Uri = $Uri; Section = $Section }) } + if ($Uri -match '^https?://(?:\d{1,3}\.){3}\d{1,3}') { $Issues.Add(@{ Reason = 'IP-address redirect URI'; Uri = $Uri; Section = $Section }) } + return $Issues + } + + $Failed = [System.Collections.Generic.List[object]]::new() + $Inspected = 0 + + foreach ($App in $Apps) { + $Inspected++ + $AllIssues = [System.Collections.Generic.List[hashtable]]::new() + + foreach ($Uri in @($App.web.redirectUris)) { + (& $TestUri $Uri 'web').ForEach({ $AllIssues.Add($_) }) + } + foreach ($Uri in @($App.spa.redirectUris)) { + (& $TestUri $Uri 'spa').ForEach({ $AllIssues.Add($_) }) + } + # publicClient is allowed to use localhost / loopback, so only flag wildcards / azurewebsites. + foreach ($Uri in @($App.publicClient.redirectUris)) { + if ([string]::IsNullOrWhiteSpace($Uri)) { continue } + if ($Uri -match '\*') { $AllIssues.Add(@{ Reason = 'Wildcard URI'; Uri = $Uri; Section = 'publicClient' }) } + if ($Uri -match '\.azurewebsites\.net') { $AllIssues.Add(@{ Reason = 'Azure default domain'; Uri = $Uri; Section = 'publicClient' }) } + } + + if ($AllIssues.Count -gt 0) { + $Failed.Add([PSCustomObject]@{ + App = $App + Issues = $AllIssues + }) + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($Failed.Count -eq 0) { + $Status = 'Passed' + $Lines.Add("All $Inspected application(s) use safe redirect URIs.") + } else { + $Status = 'Failed' + $Lines.Add("$($Failed.Count) of $Inspected application(s) have unsafe redirect URIs.") + $Lines.Add('') + $Lines.Add('| App | Section | Issue | URI |') + $Lines.Add('| :-- | :------ | :---- | :-- |') + $RowCount = 0 + foreach ($Entry in $Failed) { + foreach ($Issue in $Entry.Issues) { + if ($RowCount -ge 50) { break } + $Lines.Add("| $($Entry.App.displayName) | $($Issue.Section) | $($Issue.Reason) | $($Issue.Uri) |") + $RowCount++ + } + if ($RowCount -ge 50) { break } + } + $Lines.Add('') + $Lines.Add('**Remediation:** Use only HTTPS URIs that you own and that have proper DNS. Avoid wildcards, IP addresses, and shared Azure default domains (*.azurewebsites.net, *.cloudapp.net) which are vulnerable to subdomain takeover.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21885' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'High' -Name 'App registrations use safe redirect URIs' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Application Management' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21885' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'App registrations use safe redirect URIs' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.md index 6a740c4322e2..4cb326819633 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.md +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.md @@ -1,6 +1,10 @@ -... +Microsoft Entra PIM raises notifications when a role is activated, assigned, or approaching expiration. If a role management policy has no notification recipients configured, those alerts are effectively silenced — administrators are unaware of role activations, including activations carried out by a compromised account. + +Configuring a recipient (administrator, requestor, or approver) on every notification rule ensures that role activation is observable and that anomalous PIM activity raises an alert path. **Remediation action** +- [Configure PIM notification recipients](https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-email-notifications?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) +- [Customize PIM role settings and notifications](https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) %TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 new file mode 100644 index 000000000000..0d94a2fb06c4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 @@ -0,0 +1,68 @@ +function Invoke-CippTestZTNA21899 { + <# + .SYNOPSIS + All privileged role assignments have a recipient that can receive notifications + #> + param($Tenant) + + try { + $Policies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies' + + if (-not $Policies) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21899' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Medium' -Name 'All privileged role assignments have a recipient that can receive notifications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + return + } + + # Policies expose a `rules` collection. Notification rules have @odata.type + # '#microsoft.graph.unifiedRoleManagementPolicyNotificationRule' and a + # notificationRecipients collection. Empty notificationRecipients = nobody gets paged. + $MissingRecipients = [System.Collections.Generic.List[object]]::new() + + foreach ($Policy in $Policies) { + $Rules = $Policy.rules + if (-not $Rules) { continue } + $NotifRules = $Rules.Where({ $_.'@odata.type' -eq '#microsoft.graph.unifiedRoleManagementPolicyNotificationRule' }) + foreach ($R in $NotifRules) { + if (-not $R.notificationRecipients -or $R.notificationRecipients.Count -eq 0) { + $MissingRecipients.Add([PSCustomObject]@{ + PolicyId = $Policy.id + ScopeId = $Policy.scopeId + ScopeType = $Policy.scopeType + RuleId = $R.id + NotificationLevel = $R.notificationLevel + NotificationType = $R.notificationType + RecipientType = $R.recipientType + }) + } + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($MissingRecipients.Count -eq 0) { + $Status = 'Passed' + $Lines.Add("All $($Policies.Count) role management policy notification rule(s) have recipients configured.") + } else { + $Status = 'Failed' + $Lines.Add("$($MissingRecipients.Count) notification rule(s) across role management policies have no recipients configured.") + $Lines.Add('') + $Lines.Add('| Policy / Scope | Rule | Level | Type | Recipient Type |') + $Lines.Add('| :------------- | :--- | :---- | :--- | :------------- |') + foreach ($M in ($MissingRecipients | Select-Object -First 25)) { + $Lines.Add("| $($M.ScopeType):$($M.ScopeId) | $($M.RuleId) | $($M.NotificationLevel) | $($M.NotificationType) | $($M.RecipientType) |") + } + if ($MissingRecipients.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($MissingRecipients.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Add at least one notification recipient (admin, requestor, or approver group) to each PIM notification rule so role activations and approvals raise alerts.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21899' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'Medium' -Name 'All privileged role assignments have a recipient that can receive notifications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21899' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'All privileged role assignments have a recipient that can receive notifications' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.md index 6a740c4322e2..b0a9f73ff868 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.md +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.md @@ -1,6 +1,9 @@ -... +Microsoft Entra surfaces a list of recommendations covering security, governance, and reliability issues detected in the tenant. Medium-priority recommendations cover meaningful gaps — for example, deprecated authentication methods still in use, applications missing owners, or risky configuration patterns. Leaving these recommendations active for an extended period accumulates technical debt and security drift, increasing the chance a threat actor will find an unaddressed weakness to exploit. + +Triaging each medium-priority recommendation — applying the fix or formally postponing it with a documented reason — keeps the tenant's posture aligned with Microsoft's evolving guidance. **Remediation action** +- [Review and address Microsoft Entra recommendations](https://learn.microsoft.com/entra/identity/monitoring-health/overview-recommendations?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) %TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.ps1 new file mode 100644 index 000000000000..7585818e4695 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21983.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestZTNA21983 { + <# + .SYNOPSIS + No Active Medium priority Entra recommendations found + #> + param($Tenant) + + try { + $Recommendations = Get-CIPPTestData -TenantFilter $Tenant -Type 'DirectoryRecommendations' + + if (-not $Recommendations) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21983' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Medium' -Name 'No Active Medium priority Entra recommendations found' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + return + } + + $Active = $Recommendations.Where({ $_.status -eq 'active' -and $_.priority -eq 'medium' }) + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($Active.Count -eq 0) { + $Status = 'Passed' + $Lines.Add('No active medium-priority Microsoft Entra recommendations were found.') + } else { + $Status = 'Failed' + $Lines.Add("$($Active.Count) active medium-priority Microsoft Entra recommendation(s) found.") + $Lines.Add('') + $Lines.Add('| Recommendation | Impact | Last Action |') + $Lines.Add('| :------------- | :----- | :---------- |') + foreach ($R in ($Active | Select-Object -First 25)) { + $Lines.Add("| $($R.displayName) | $($R.impactType ?? '-') | $($R.lastModifiedDateTime ?? '-') |") + } + if ($Active.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($Active.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Review the recommendations in the Microsoft Entra admin center under Identity > Overview > Recommendations and apply or postpone each item.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21983' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'Medium' -Name 'No Active Medium priority Entra recommendations found' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21983' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name 'No Active Medium priority Entra recommendations found' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.md index 6a740c4322e2..3dd6d40a5ad1 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.md +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.md @@ -1,6 +1,7 @@ -... +Microsoft Entra surfaces a list of recommendations covering security, governance, and reliability issues detected in the tenant. Low-priority recommendations are typically operational improvements rather than urgent risks — for example, enabling optional features that streamline administration or tightening rarely-used settings. While individually low impact, they accumulate as drift over time, and addressing them keeps the tenant's posture aligned with Microsoft's evolving guidance. **Remediation action** +- [Review and address Microsoft Entra recommendations](https://learn.microsoft.com/entra/identity/monitoring-health/overview-recommendations?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) %TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.ps1 new file mode 100644 index 000000000000..e5405f6762fc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21984.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestZTNA21984 { + <# + .SYNOPSIS + No Active low priority Entra recommendations found + #> + param($Tenant) + + try { + $Recommendations = Get-CIPPTestData -TenantFilter $Tenant -Type 'DirectoryRecommendations' + + if (-not $Recommendations) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21984' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Low' -Name 'No Active low priority Entra recommendations found' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + return + } + + $Active = $Recommendations.Where({ $_.status -eq 'active' -and $_.priority -eq 'low' }) + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($Active.Count -eq 0) { + $Status = 'Passed' + $Lines.Add('No active low-priority Microsoft Entra recommendations were found.') + } else { + $Status = 'Failed' + $Lines.Add("$($Active.Count) active low-priority Microsoft Entra recommendation(s) found.") + $Lines.Add('') + $Lines.Add('| Recommendation | Impact | Last Action |') + $Lines.Add('| :------------- | :----- | :---------- |') + foreach ($R in ($Active | Select-Object -First 25)) { + $Lines.Add("| $($R.displayName) | $($R.impactType ?? '-') | $($R.lastModifiedDateTime ?? '-') |") + } + if ($Active.Count -gt 25) { + $Lines.Add('') + $Lines.Add("...and $($Active.Count - 25) more.") + } + $Lines.Add('') + $Lines.Add('**Remediation:** Review the recommendations in the Microsoft Entra admin center under Identity > Overview > Recommendations and apply or postpone each item.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21984' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'Low' -Name 'No Active low priority Entra recommendations found' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21984' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Low' -Name 'No Active low priority Entra recommendations found' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Access Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA23183.md b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA23183.md new file mode 100644 index 000000000000..504ce7f7dfb3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA23183.md @@ -0,0 +1,9 @@ +Non-Microsoft and multitenant applications configured with URLs that include wildcards, localhost, or URL shorteners increase the attack surface for threat actors. These insecure redirect URIs (reply URLs) might allow adversaries to manipulate authentication requests, hijack authorization codes, and intercept tokens by directing users to attacker-controlled endpoints. Wildcard entries expand the risk by permitting unintended domains to process authentication responses, while localhost and shortener URLs might facilitate phishing and token theft in uncontrolled environments. + +Without strict validation of redirect URIs, attackers can bypass security controls, impersonate legitimate applications, and escalate their privileges. This misconfiguration enables persistence, unauthorized access, and lateral movement, as adversaries exploit weak OAuth enforcement to infiltrate protected resources undetected. + +**Remediation action** + +- [Check the redirect URIs for your application registrations.](https://learn.microsoft.com/entra/identity-platform/reply-url?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci) Make sure the redirect URIs don't have localhost, *.azurewebsites.net, wildcards, or URL shorteners. + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA23183.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA23183.ps1 new file mode 100644 index 000000000000..1a9a620c90eb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA23183.ps1 @@ -0,0 +1,80 @@ +function Invoke-CippTestZTNA23183 { + <# + .SYNOPSIS + Service principals use safe redirect URIs + #> + param($Tenant) + + try { + $ServicePrincipals = Get-CIPPTestData -TenantFilter $Tenant -Type 'ServicePrincipals' + + if (-not $ServicePrincipals) { + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA23183' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Service principals use safe redirect URIs' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Application Management' + return + } + + $TestUri = { + param([string]$Uri) + $Issues = [System.Collections.Generic.List[string]]::new() + if ([string]::IsNullOrWhiteSpace($Uri)) { return $Issues } + if ($Uri -match '\*') { $Issues.Add('Wildcard URI') } + if ($Uri -match '^http://(?!localhost(?:[:/]|$))') { $Issues.Add('Plain HTTP (non-localhost)') } + if ($Uri -match '\.azurewebsites\.net') { $Issues.Add('Azure default *.azurewebsites.net domain') } + if ($Uri -match '\.cloudapp\.(?:net|azure\.com)') { $Issues.Add('Azure default cloudapp domain') } + if ($Uri -match '^https?://(?:\d{1,3}\.){3}\d{1,3}') { $Issues.Add('IP-address redirect URI') } + return $Issues + } + + # Skip Microsoft-published service principals — these are out of tenant control. + $TenantSPs = $ServicePrincipals.Where({ + $_.appOwnerOrganizationId -ne 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' -and + $_.servicePrincipalType -ne 'ManagedIdentity' + }) + + $Failed = [System.Collections.Generic.List[object]]::new() + foreach ($SP in $TenantSPs) { + $AllIssues = [System.Collections.Generic.List[hashtable]]::new() + foreach ($Uri in @($SP.replyUrls)) { + foreach ($Reason in (& $TestUri $Uri)) { + $AllIssues.Add(@{ Uri = $Uri; Reason = $Reason }) + } + } + if ($AllIssues.Count -gt 0) { + $Failed.Add([PSCustomObject]@{ + Sp = $SP + Issues = $AllIssues + }) + } + } + + $Lines = [System.Collections.Generic.List[string]]::new() + if ($Failed.Count -eq 0) { + $Status = 'Passed' + $Lines.Add("All $($TenantSPs.Count) tenant-owned service principal(s) use safe reply URLs.") + } else { + $Status = 'Failed' + $Lines.Add("$($Failed.Count) of $($TenantSPs.Count) service principal(s) have unsafe reply URLs.") + $Lines.Add('') + $Lines.Add('| Service Principal | Issue | URI |') + $Lines.Add('| :---------------- | :---- | :-- |') + $RowCount = 0 + foreach ($Entry in $Failed) { + foreach ($Issue in $Entry.Issues) { + if ($RowCount -ge 50) { break } + $Lines.Add("| $($Entry.Sp.displayName) | $($Issue.Reason) | $($Issue.Uri) |") + $RowCount++ + } + if ($RowCount -ge 50) { break } + } + $Lines.Add('') + $Lines.Add('**Remediation:** Remove unsafe reply URLs from each affected service principal. Use only HTTPS URIs that you own with proper DNS — avoid wildcards, IPs, and shared Azure default domains.') + } + + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA23183' -TestType 'Identity' -Status $Status -ResultMarkdown ($Lines -join "`n") -Risk 'High' -Name 'Service principals use safe redirect URIs' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Application Management' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Tests' -tenant $Tenant -message "Failed to run test: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA23183' -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name 'Service principals use safe redirect URIs' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'Application Management' + } +} diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/report.json b/Modules/CIPPTests/Public/Tests/ZTNA/report.json index 21d37a9b0dd3..41ea4d38936c 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/report.json +++ b/Modules/CIPPTests/Public/Tests/ZTNA/report.json @@ -6,7 +6,9 @@ "ZTNA21772", "ZTNA21773", "ZTNA21774", + "ZTNA21775", "ZTNA21776", + "ZTNA21777", "ZTNA21780", "ZTNA21783", "ZTNA21784", @@ -62,27 +64,36 @@ "ZTNA21861", "ZTNA21862", "ZTNA21863", + "ZTNA21864", "ZTNA21865", "ZTNA21866", "ZTNA21868", "ZTNA21869", "ZTNA21872", "ZTNA21874", + "ZTNA21876", "ZTNA21877", + "ZTNA21882", "ZTNA21883", + "ZTNA21884", + "ZTNA21885", "ZTNA21886", "ZTNA21889", "ZTNA21892", "ZTNA21896", + "ZTNA21899", "ZTNA21941", "ZTNA21953", "ZTNA21954", "ZTNA21955", "ZTNA21964", + "ZTNA21983", + "ZTNA21984", "ZTNA21992", "ZTNA22124", "ZTNA22128", "ZTNA22659", + "ZTNA23183", "ZTNA24570", "ZTNA24572", "ZTNA24824", diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index 5dbe009605e0..f184e81f493e 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -121,7 +121,7 @@ function Invoke-HuduExtensionSync { } $HuduRelations = Get-HuduRelations - $Links = @( + [System.Collections.ArrayList]$Links = @( @{ Title = 'M365 Admin Portal' URL = 'https://admin.cloud.microsoft?delegatedOrg={0}' -f $Tenant.initialDomainName @@ -153,6 +153,31 @@ function Invoke-HuduExtensionSync { Icon = 'fas fa-server' } ) + if($Configuration.IncludeDefenderLink) + { + $Links.Add(@{ + Title = 'Defender Portal' + URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-shield' + }) + } + if($Configuration.IncludeComplianceLink) + { + $Links.Add(@{ + Title = 'Compliance Portal' + URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-caret-up' + }) + } + if($Configuration.IncludeParterCenterLink) + { + $Links.Add(@{ + Title = 'Partner Center Portals' + URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId + Icon = 'fas fa-arrow-up-right-from-square' + }) + } + $FormattedLinks = foreach ($Link in $Links) { Get-HuduLinkBlock @Link } @@ -183,6 +208,11 @@ function Invoke-HuduExtensionSync { " $post = '' + + if($Configuration.HideEmptyRoles) { + $Roles = $Roles | Where-Object { $_.ParsedMembers } + } + $RolesHtml = $Roles | Select-Object DisplayName, Description, ParsedMembers | ConvertTo-Html -PreContent $pre -PostContent $post -Fragment | ForEach-Object { $tmp = $_ -replace '<', '<'; $tmp -replace '>', '>'; } | Out-String $AdminUsers = (($Roles | Where-Object { $_.displayName -match 'Administrator' }).Members | Where-Object { $null -ne $_.displayName } | Select-Object @{N = 'Name'; E = { "$($_.displayName) - $($_.userPrincipalName)" } } -Unique).name -join '
' diff --git a/Tools/Update-IntuneCollection.ps1 b/Tools/Update-IntuneCollection.ps1 index 9ad709bf0db2..e32d53ee8382 100644 --- a/Tools/Update-IntuneCollection.ps1 +++ b/Tools/Update-IntuneCollection.ps1 @@ -5,11 +5,12 @@ .DESCRIPTION Queries the Microsoft Graph beta endpoint for all Intune device management configuration setting definitions and writes the result to intuneCollection.json - in both the CIPP-API root and CIPP/src/data directories. + in both the CIPP-API Config directory (backend runtime) and the CIPP frontend + public/ directory (served as a static asset, fetched on demand by the UI). - The resulting file is used by Compare-CIPPIntuneObject.ps1 (backend) and - CippTemplateFieldRenderer.jsx / CippJSONView.jsx (frontend) to translate - raw settingDefinitionIds into human-readable display names. + The resulting file is used by Compare-CIPPIntuneObject.ps1 (backend) and the + frontend Intune setting renderers to translate raw settingDefinitionIds into + human-readable display names. Must be run from the "Tools" folder in the CIPP-API project, with Initialize-DevEnvironment.ps1 already dot-sourced (or it will be loaded @@ -93,19 +94,19 @@ Set-Location $PSScriptRoot $json = $collection | ConvertTo-Json -Depth 5 -Compress -# CIPP-API root (used by Compare-CIPPIntuneObject.ps1 at runtime) +# CIPP-API Config (used by Compare-CIPPIntuneObject.ps1 at runtime) $apiPath = Join-Path $PSScriptRoot '..\Config\intuneCollection.json' $json | Set-Content -Path $apiPath -Encoding utf8NoBOM Write-Host "Written: $(Resolve-Path $apiPath)" -ForegroundColor Green -# CIPP frontend src/data (used by the React UI) -$frontendPath = Join-Path $PSScriptRoot '..\..\CIPP\src\data\intuneCollection.json' +# CIPP frontend public/ (served as a static asset, fetched on demand by the React UI) +$frontendPath = Join-Path $PSScriptRoot '..\..\CIPP\public\intuneCollection.json' if (Test-Path (Split-Path $frontendPath)) { $json | Set-Content -Path $frontendPath -Encoding utf8NoBOM Write-Host "Written: $(Resolve-Path $frontendPath)" -ForegroundColor Green } else { Write-Host "CIPP frontend path not found — skipping: $frontendPath" -ForegroundColor Yellow - Write-Host "Copy $(Resolve-Path $apiPath) manually to your CIPP/src/data/ directory." -ForegroundColor Yellow + Write-Host "Copy $(Resolve-Path $apiPath) manually to your CIPP/public/ directory." -ForegroundColor Yellow } Write-Host "`nDone. $($collection.Count) settings written to intuneCollection.json." -ForegroundColor Green diff --git a/host.json b/host.json index 7648204274b0..964ab6d101ea 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.2", + "defaultVersion": "10.5.3", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index a39233be07ad..1e9c35fac856 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.2 +10.5.3