Microsoft 365 Security as Code — Part 3: Running Microsoft365DSC on Linux
Microsoft365DSC was built for Windows, but our pipeline runs on Linux. This is how I made the apply step work without the Windows engine: a small parser for the compiled configuration, a careful Exchange Online session, and the permission problem that looked like a code problem.
This is part 3 of a series on managing Microsoft 365 security as code, based on the CIS Microsoft 365 Foundations Benchmark. Each part can be read on its own. Part 1: Before you write any code · Part 2: One benchmark, four tools · Part 3: Running Microsoft365DSC on Linux (this post)

Microsoft365DSC is a good way to describe Exchange Online, SharePoint and Teams (basically, almost anything M365) settings as code. You write the settings you want, and the tool can apply them and later tell you when the tenant has drifted away from them. There is one catch: DSC was built for Windows. Our pipeline runs on Linux. This post is about what that gap looks like, how I closed it, and what it costs to keep it closed.
TLDR: on Linux you can still compile a DSC configuration, but you cannot apply it with the normal DSC engine, because that engine only exists on Windows. I parse the compiled file myself and apply each resource one by one. The parser was the easy part. Most of the work went into the Exchange Online connection, a permission model that does not behave like other Microsoft 365 permissions, and memory use.
Why Linux at all
The obvious question ofcourse is, why not just use Windows. The answer is operational, not technical.
Our standard for infrastructure is Terraform. Wherever a Terraform provider can manage a setting through an API, that is what we use. Exchange Online, and part of SharePoint and Teams, are not reachable that way (no GraphAPI), so for those we need something else. My rule for that "something else" is that it has to adjust to our platform, not the other way around.
The platform is GitLab runners on AKS, and those runners are Linux. Adding Windows nodes to the cluster, or running Windows virtual machines only for this workload, would add a lot of operating work: patching, images, another kind of node to monitor. There is a second reason. Windows skills are scarce in the team. Whatever I build must keep working with very little maintenance, and it should not need someone who is good at Windows administration to fix it. In practice this meant the solution had to "just work" on Linux, and getting that to be true took more effort than anything else in this whole security project.
What breaks on Linux
Microsoft365DSC is a set of DSC resources. DSC (Desired State Configuration) has two parts that matter here. First, you write a configuration, and compiling it produces a MOF file. That is a plain text file that lists every resource and every setting. Second, an engine called the Local Configuration Manager (LCM) reads that file and makes the machine match it. The LCM is Windows-only.
On PowerShell 7 on Linux, the module PSDesiredStateConfiguration 2.x has no Start-DscConfiguration and no Test-DscConfiguration. The commands that would apply and check your configuration are simply not there. What does exist is Invoke-DscResource, which runs one resource at a time.
Compiling still works, with one condition: the compiler needs a DSC schema store, found through the environment variable DSC_HOME. You can get one from OMI (the Open Management Infrastructure package that DSC for Linux uses), or from a PSDesiredStateConfiguration version below 3.0. Version 3.0 does not work for this. My container image includes OMI, and the first script in every job looks for the schema store and stops with a clear message if it cannot find one:
if (-not $env:DSC_HOME) {
$omiSchema = '/etc/opt/omi/conf/dsc/configuration'
if (Test-Path -LiteralPath (Join-Path $omiSchema 'BaseRegistration/BaseResource.schema.mof')) {
$env:DSC_HOME = $omiSchema
}
else {
# fall back to a PSDesiredStateConfiguration older than 3.0
}
}
if (-not $env:DSC_HOME -or -not (Test-Path -LiteralPath $env:DSC_HOME)) {
throw "DSC schema store not found. ..."
}
So the situation is: the configuration stays declarative and compiles on Linux, but the step that turns it into changes in the tenant has to be rebuilt.
The idea: compile with DSC, apply it yourself
The approach is simple to say. Compile the configuration as normal. Read the MOF file myself. For each resource in it, call Invoke-DscResource -Method Set with the settings from the file. The configuration language and the resources are the standard ones. Only the part that walks through the list is mine.
The part that reads the file is a parser of about 220 lines. My first version was wrong, and I replaced it about twenty minutes after I wrote it. I used one regular expression to find each instance of ... { ... }; block. That works on a toy file. It fails on a real tenant configuration, because values can contain semicolons and braces, for example a mail disclaimer written in HTML. The expression stopped in the middle of the value.
The second version walks through the text one character at a time and keeps track of two things: whether it is inside a quoted string, and how deep it is inside braces. This is the function that finds the end of a block:
function Read-DscMofBraceBlock {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string] $Text,
[Parameter(Mandatory = $true)][int] $StartIndex
)
$i = $StartIndex
$len = $Text.Length
if ($i -ge $len -or $Text[$i] -ne '{') {
return '', $i
}
$depth = 0
$start = $i
$inString = $false
while ($i -lt $len) {
$c = $Text[$i]
if ($inString) {
if ($c -eq '\' -and ($i + 1) -lt $len) { $i += 2; continue }
if ($c -eq '"') { $inString = $false }
$i++
continue
}
if ($c -eq '"') { $inString = $true; $i++; continue }
if ($c -eq '{') { $depth++ }
elseif ($c -eq '}') {
$depth--
if ($depth -eq 0) {
$i++
return $Text.Substring($start, $i - $start), $i
}
}
$i++
}
return $Text.Substring($start), $i
}
It is not clever, and that is on purpose. A small parser that does one thing and is easy to read is something a person without deep PowerShell skills can still maintain. The lesson I took from it is this: the first idea that looks right can fail on real data, so test a parser on your real configuration from the beginning, not on a sample.
What the apply loop has to do
With the parser in place, the apply script is a loop over the resources. Four details in that loop are what makes this work in a real pipeline.
First, the tokens. The MOF file can contain access tokens from compile time, and these are out of date by the time apply runs. In our pipeline the runner has no stored secrets; it gets its tokens through workload identity. So the loop removes every credential from the file and adds fresh tokens from the running process:
$props['AccessTokens'] = $context.AccessTokens
$props['TenantId'] = $context.TenantId
foreach ($drop in @('Credential', 'ApplicationId', 'ApplicationSecret', 'CertificateThumbprint',
'CertificatePath', 'CertificatePassword', 'ManagedIdentity')) {
$props.Remove($drop)
}
Second, the schema can be older than the module. The MOF file may contain a property that the resource's Set-TargetResource no longer accepts, and then the call fails. The loop reads the list of properties from the resource, removes the ones that are not on it, and prints a warning.
Third, Set only. After each Set I do not run a Test. On PowerShell 7 the test call reloads the nested module, and then fails with "the module could not be loaded", even though the change itself had worked. It also doubled the memory use. A successful Set is the signal I use. Checking is done by a separate job that only reads.
Fourth, failures. A failing resource does not stop the run. The loop collects every failure, goes on with the next resource, and at the end it prints the full list and exits with an error:
try {
$null = Invoke-DscResource `
-Name $resource.ResourceName `
-ModuleName Microsoft365DSC `
-Method Set `
-Property $props `
-ErrorAction Stop
Write-Output ' OK'
}
catch {
Write-Warning "Failed $label : $($_.Exception.Message)"
$failed.Add("$label :: $($_.Exception.Message)")
}
One failing setting in the middle of the list should not hide the results of all the others, but the run must never look green when something failed. You actually want to report on failures as that means there is drift.
Exchange Online, the hard part
The same parser and apply loop also drive SharePoint and Teams, where none of the problems below appear. Exchange Online took most of the time that I needed to get all of this working.
The session lost its commands halfway through. Exchange Online commands do not exist in the module you install. When you connect, PowerShell builds a temporary module with only the commands your session is allowed to use. Microsoft365DSC has its own login helper, and it got confused by this. It reported the workload as connected while the resource modules could not see the commands, or it reconnected with only a part of the commands and disconnected the full session in the middle of the apply. Resources then failed with "command not found". Importing the resource modules again with -Force made it worse, because it unloaded the nested modules that the Exchange commands came from. The fix is a separate script. It connects once with the token, tells the login helper that the workload is already connected with the full set of commands, and does not let anything reconnect during the apply. The same script handles a smaller problem: the module still sends some properties that the live Exchange commands no longer accept (old mail tips and mailbox move settings), so a thin wrapper removes those for the two commands that need it.
A permission problem that looked like a code problem. This one took several rounds to understand, and I think it is the most useful thing in this post. The apply kept failing because commands were missing from the session. I first thought it was the same session problem as above. It was not.
An application that connects to Exchange Online gets only the commands that its own management role assignments allow. Entra directory roles are not enough. Membership of a role group, even a very powerful one like Organization Management, does not count either. The application needs direct management role assignments, made with New-ManagementRoleAssignment -App. Terraform's Entra provider cannot create these, so they are a manual step in Exchange Online PowerShell. After you change them, Exchange caches the result, and the new commands can take 30 minutes to two hours to appear. A related surprise came later: a command can exist while one of its parameters is missing, because the role assignment behind it was too narrow.
What I did about it is not a clever fix, it is a check. When the apply script connects, it first tests that every Exchange command the configuration uses is available to this identity. If one is missing, the job stops with a list of the missing commands, before any setting is changed. The check looks at commands only, so it does not catch the missing-parameter case. I now test the permission model before I test the code.
Memory. The run was killed near 5 GiB. The cause was my own: before each resource I checked the Exchange connection and repaired it if needed, and that repair used more memory with every resource. The fix was to do much less. A cheap keep-alive runs for each resource, a light refresh of the wrapper runs every tenth resource, and the loop forces a memory cleanup between resources. I had also raised the memory request in the pipeline file, which did nothing at the time, because the runner configuration ignored it. The real change had to be made in the runner setup. Check that your memory settings actually arrive on the pod before you spend an evening tuning code.
The price of this approach: a restricted configuration style
The tool that compares the configuration to the tenant (the drift check) only understands plain, static resource blocks. If the configuration uses foreach, if, or a local variable inside the node, those parts are skipped without any message. The effect is subtle: the check sees fewer resources than you wrote, and reports drift that is not real.
In our configuration this affected a list of partner relationships that I had written as a loop over a data list. I rewrote them as repeated static blocks, with a comment saying that the list in the data file and the blocks must be kept in step. The check for duplicate values moved to the compile script, where normal PowerShell works. It is not elegant, but it is predictable.
How I keep it maintainable
I said at the start that this had to be very low maintenance. This is what I built to make that true, and what is still weak.
The code adds up about 220 lines for the parser, 140 for the apply script and 500 for the Exchange session. That is real code, and it follows changes in Microsoft365DSC and the Exchange modules. It is not nothing. What makes it safe to change:
- Offline tests. About 225 lines of tests cover the Exchange session script. They run in the lint stage of the pipeline, before anything touches the tenant, and they do not need a tenant. There are more tests for the other scripts.
- Failing loudly. The command check stops the job before any change if commands are missing, every failing resource is reported, and the job fails if any resource failed.
- Separate roles. Apply is a manual job on the main branch only. Checking for drift is a different, read-only job. It runs after every apply and on a schedule.
- A small surface. The parser has one job. The apply loop has one job. Neither contains tenant logic.
What is still weak is the step that drops properties. Today it only prints a warning. If a security setting is dropped because the module no longer knows about it, the run still succeeds. Because the drift check uses the same module, it would not see the missing setting either. I have not fixed this yet. The fix I have in mind is a short list of settings that must never be dropped, with a failing run if one of them is. Until that exists, read the warnings in the apply log.
The other honest point is where the code lives. The parser is copied into the repository that manages SharePoint and Teams. The other scripts are similar in name but have grown apart, and I have no plan to merge them. Each repository is maintained on its own. That costs some duplication, and it keeps each repository small and its permissions separate, which is the subject of part 4.
When this is worth doing
If you have Windows machines and Windows skills, use the normal DSC engine. It does everything above for you. If the setting you need can be reached through an API with a Terraform provider, use Terraform, and you will not need any of this.
If your platform is Linux and you have settings that only DSC reaches, this approach works. The conditions I would check first:
- Can you get the DSC schema store into your container image (OMI, or a PSDesiredStateConfiguration older than 3.0)?
- Does every application permission you need exist as a direct role assignment, including the manual Exchange ones, and is there a check for it?
- Is your configuration written as static blocks, so the drift check sees all of it?
- Do you have a test that runs without a tenant for the glue code you write?
Get in touch
I help teams that need Microsoft 365 security to be written down as code, reviewed like code, and provable to an auditor, especially in financial services and other regulated environments. If you are working on something similar, or if your Linux pipeline hit a different problem than mine, I would like to hear about it. Connect with me on LinkedIn, or reach out via ascode.nl.
This post is part of the series Microsoft 365 Security as Code, where you can find all the parts.