Abstract illustration of two overlapping directory structures rendered as dim steel-blue nodes on a deep slate-navy field, with a small handful of nodes lit in bright blue where the two layers fail to align, representing detected configuration drift.

Two changes to a Microsoft 365 tenant will not page anybody, will not appear in any dashboard your leadership looks at, and will not feel like an incident on the day they happen.

The first is an exclusion added to a conditional access policy. The policy is still there. It is still enabled. It still shows a green check. One account or one group is now outside it.

The second is an inbox rule that forwards mail to an address nobody recognizes, sitting underneath forty ordinary rules that move newsletters into folders.

Both are ordinary administrative actions. Both are also what the back half of an account compromise looks like. The difference between the two readings is not visible in the object itself. It is only visible in the fact that the object is different from what it was, and that is a question almost nobody can answer about their own tenant, because almost nobody wrote down what it was.

I have released the tool I use to answer it. It is a single PowerShell script, it is read-only by construction, and it is on GitHub at Resistor52/m365-identity-baseline.

Where it came from

This came out of a need in my consulting practice. I kept walking into tenants where the security-relevant state was undocumented, where nobody could say whether the current configuration was the intended one, and where the honest answer to “has anything changed since last quarter” was a shrug.

The obvious objection is that Microsoft already tells you. It does, and I want to deal with that head on rather than pretend otherwise, because both of my opening examples are recorded natively. Update conditional access policy is an Entra ID audit log event carrying modified properties. New-InboxRule lands in the unified audit log. Entra audit logs, Purview audit search, PIM alerts and Graph delta queries are all real answers to “what changed,” you already pay for them, and this tool reads two of those logs itself.

They answer a different question, though. An audit log tells you what happened, inside a retention window, if you already knew what to search for. A baseline tells you what is different from agreed normal, with no retention limit, and requires you to know nothing in advance beyond how to run a diff. An exclusion added eighteen months ago has aged out of every log you own. It has not aged out of the comparison. That gap is the whole reason this exists, and it turned out to be small enough to write.

What this is not

It is worth being precise about the boundary, because the category is crowded.

This is not a governance platform. It does not do access reviews, entitlement management, or approvals. It does not compete with Entra ID Governance, and if you own that, you should use it.

This is not a posture scoring tool. It will not give you a number out of one hundred or tell you that you are eighty-three percent secure. There is no benchmark, no CIS mapping, no letter grade.

It is a difference engine for one narrow question: what in this tenant’s privileged surface is not what we agreed it should be. That is the whole scope, and keeping the scope that small is what let the rest of the design be opinionated.

The product is the difference, not the dump

Most directory audit tooling delivers a snapshot. You run it, you get a large structured file or a forty-page PDF, and you look at it once. Nobody diffs last quarter’s against this quarter’s, because doing that by hand is miserable and doing it with a naive comparison produces a wall of false positives.

So the tool has two modes and the second one is the point.

./Invoke-M365IdentityBaseline.ps1 -Mode Baseline

That captures a dated snapshot. You read the human summary it writes, and when you agree that it represents normal, you accept it by dropping a marker file into the snapshot directory. From then on:

./Invoke-M365IdentityBaseline.ps1 -Mode Diff

Diff mode picks the most recent accepted baseline and writes a fresh snapshot alongside a drift report containing only what moved. A clean run exits zero and its drift report is empty. That is the intended experience: a detector you can run on a schedule, whose report is empty when nothing happened, and which you therefore actually read on the day it is not.

Worth knowing before you schedule it: Diff writes a full snapshot on every run, not just a diff. That is deliberate, because today’s Diff is tomorrow’s candidate baseline, but it means a nightly job accumulates complete directory captures wherever you pointed the output. More on why that location matters below.

Accepting a baseline is a deliberate human step, not an automatic one, and that is not friction for its own sake. An unreviewed baseline encodes today’s misconfiguration as normal, and every run afterwards then cheerfully agrees that everything is fine. The acceptance marker is where a person takes responsibility for the definition of normal.

Read-only, absolutely

There is no code path in this script that writes, resets, disables, removes, or rotates anything. Every Graph call is a GET. Every Exchange call is a Get- or a Search-.

That constraint is not modesty about scope. It is what makes the tool safe to hand to somebody else and safe to run against a production tenant on a Tuesday afternoon. A remediation feature would double the value and multiply the risk by considerably more than two, and the person who has to approve running this in their environment should be able to satisfy themselves of its safety by reading it, not by trusting me.

Least privilege, measured rather than assumed

The read-only constraint is a claim about behavior. The permission set is the evidence for it, and I did not want to assume that evidence.

The script requests seven Graph scopes. That set was arrived at by connecting with exactly those seven, with no directory-wide read, and confirming that twenty-two of the twenty-three checks completed. It is a measured minimum, not a guess that happened to work.

The interesting one is the scope that is not in the default set. Directory.Read.All is the scope a security team will push back on, and exactly one check needs it: the one that enumerates delegated OAuth consent grants, because that data has no narrower read scope. So it is opt-in behind a flag. Run without it and that single check reports as not measured while the other twenty-two run normally.

I want to be honest about the cost of that default rather than quietly claiming a smaller permission footprint as a pure win. Standing consent grants are where over-permission accumulates quietly, and the check that surfaces them is the one you would most regret skipping. If you drop it, drop it knowingly.

The tool makes that quite hard to do by accident, and this is the part to understand before you put it in a scheduler. A run in which any check went unmeasured exits 3 rather than 0, so the default configuration exits 3 every single time until you either pass -IncludeConsentGrants and measure all twenty-three, or pass -AllowPartial and state in writing that a twenty-two check run is what you meant. There is no third option where a partial run quietly reports itself as clean. That is inconvenient exactly once, at setup, which is the correct place for it to be inconvenient.

On the Exchange side, the View-Only Organization Management role group covers every Exchange check. Do not run this as a Global Administrator when a reader role will do.

A check that could not run says so, by name

This is the design decision I would defend hardest.

When a check cannot run because a permission is missing, it does not disappear from the output. It reports as not measured, naming the specific scope it needed, and the run’s completion message lists every check in that state.

The failure mode this prevents is the ugly one. A tool that silently omits what it could not read produces a report that looks identical to a clean report. A partial run then becomes the new normal, nobody notices that a third of the checks stopped executing months ago, and the artifact you are relying on has quietly become decorative.

The exit codes carry the same idea. Zero is clean, one is findings, two is a usage error, and three is could-not-measure. Measurement gaps are counted separately from differences and reported on their own line. Exit one therefore means “something changed” and never means “we could not look.”

The same logic governs sign-in failures. A sign-in that does not succeed is not allowed to quietly become a smaller run than you asked for. Where there is a human at the keyboard you are asked whether to continue. Where there is not, the run exits 3 rather than blocking forever on a prompt nothing will answer, and -ContinueOnSignInFailure is how you accept a partial capture up front. A failed sign-in is usually a typo, the wrong account, or the wrong tenant. A written snapshot reads as “the job was done,” and fifteen of twenty-three checks silently becoming the baseline is precisely how a baseline stops being trustworthy.

The hard part is refusing to report noise as signal

Gathering the data is the easy half. A drift detector that cries wolf gets ignored, and being ignored is the only failure mode that actually matters, so most of the work is in the comparison.

Records are compared by name, never by position. Each check declares its own key field and an explicit list of fields kept for comparison. A record is projected onto that field list, indexed by its key, and compared against whichever record carries the same key in the baseline. Reordering keys in a stored file therefore cannot affect the result.

Nested values go through a canonical serializer that sorts object keys recursively and sorts array members by their own canonical form. The directory API makes no ordering guarantee for nested properties, so without this, a policy whose conditions came back in a different order than last week reports as changed. Sorting array members is a deliberate trade: membership changes are reported, pure reordering is not.

Three more places where the obvious implementation produces a detector nobody trusts:

ConvertFrom-Json silently rehydrates an ISO-8601 string into a datetime, so a baseline read back from disk otherwise compares a culture-formatted date against a raw API string, and every date field reports as changed on every run forever. One normalizer runs on both sides of every comparison.

A live capture is a hashtable and a stored baseline is a different .NET type entirely. Both have to canonicalize identically or the first diff after a reload reports every nested field as changed.

Two runs started inside the same tick would resolve to the same timestamped directory, and the second would overwrite the first. A snapshot that can be clobbered is not evidence, so snapshot names carry a random suffix and an existing directory is refused rather than reused.

Adding a field to a check makes every record in that check look different, which is not drift. The tool detects that case and emits a single schema-changed row telling you to re-baseline, rather than a few hundred phantom findings.

Two checks read a rolling audit window rather than current state, so their diffs report additions only. An event ageing out of the lookback window is not drift, and treating it as drift would make every run report removals forever.

Where it runs

PowerShell 7, plus Microsoft.Graph.Authentication for the Graph checks and ExchangeOnlineManagement for the Exchange ones. Graph reads go through Invoke-MgGraphRequest rather than the per-workload cmdlets, so only the authentication module is needed rather than the full Microsoft.Graph module set. That matters a great deal when the thing you are handing to a client’s IT staff has to be installable in the fifteen minutes before a call.

Azure Cloud Shell turns out to be a reasonable place to run it, and the README documents one thing I did not expect. Cloud Shell backs your home directory with an Azure Files share, and the token cache lives there. A second run completed the Graph sign-in with no device-code prompt, and a third prompted for neither Graph nor Exchange, even though each run starts a fresh process with no in-memory session. The only way that happens is a token cache rehydrated from disk. Usable refresh tokens for an admin Graph session and an admin Exchange session are therefore sitting in a file share after you close the browser tab. That may be perfectly acceptable in your environment, but it should be a decision rather than a surprise, so disconnect both services when you are done.

The snapshot directory deserves the same care. It holds real directory state: account names, delegations, consent grants. It is excluded from version control and it should stay that way. It is evidence, and it belongs with your records rather than in a repository.

Take it

The repository is public: Resistor52/m365-identity-baseline. It is one script, a README that explains the reasoning rather than just the flags, and an offline test that needs no tenant and no credentials.

Readers who have been here a while will recognize the thread. In Rethinking “Identity Is the New Perimeter” I argued that identity is not the perimeter at all: identities are what cross perimeters, and Zero Trust gives you many small perimeters rather than one big one. This tool is the practical consequence of taking that seriously. If your architecture is now made of many small perimeters, each one defined by a policy object, then a silent change to any one of those objects is a change to the perimeter, and you need a way to notice.

There is also a direct line to Blocking Zoom Webinar Spam with Microsoft 365 Mail Flow Rules. That post is about building a transport rule. Two of the checks here watch transport rules and forwarding inbox rules for exactly the drift that would silently undo work like that: a rule disabled, reordered, or quietly given a new external target.

If you use it, I would genuinely like to know which check earned its keep first. My prediction is the delegated consent grants, and I would be pleased to be wrong.