[BP 1.4] Proving the Baseline: The Verification Loop

Maester installed, testing your Entra ID baseline against the controls the earlier articles defended, running unattended and alerting you the day the tenant drifts. The piece that turns a baseline into something you can prove.


By the end of this article you have Maester installed, testing your Entra ID baseline against the same controls the earlier articles defended, running unattended on a schedule, and alerting you the day the tenant drifts away from the baseline you built. This is the piece that turns a baseline from something you achieved once into something you can prove is still true. It is a build sheet, so it is written to be reproduced from the text alone.

Before you start, you need a few things to be true. PowerShell 7 on the machine you run the first pass from. An account with Global Administrator, used once, to consent to the read permissions. A place for the scheduled run to live, which for this build sheet is a GitHub repository, with the Azure Automation alternative noted where it differs. And nothing else: every test in the default set is read-only, so this never changes your tenant, it only reads and reports on it.


The verification loop A baseline you cannot test is a memory of one. Baseline set the reasoned articles Scheduled test Maester, read-only Compare to last run drift, or clean? Alert Teams or mail Correct and re-affirm close the gap

Step 1: Install Maester and its tests

Maester is a PowerShell module that runs a library of security tests against your tenant through Microsoft Graph and renders the results as a report. Pester is its test engine, and Maester is particular about the version, so pin it. Install both, create a folder to hold the test files, and pull the test library into it.

# PowerShell 7
Install-Module Pester -MinimumVersion 5.7.1 -MaximumVersion 5.7.1 -SkipPublisherCheck -Force -Scope CurrentUser
Install-Module Maester -Scope CurrentUser

New-Item -ItemType Directory maester-tests | Set-Location
Install-MaesterTests          # downloads the test library into this folder; Update-MaesterTests refreshes it later

# Expected: the folder fills with test files. If Install-MaesterTests is not found, the module
# did not import; run Import-Module Maester and retry rather than reinstalling.

Step 2: Run it once, by hand, and read the report

The first run is interactive, so you can see what the tool does before you automate it. Connecting requests a set of read-only Graph scopes; sign in with an account that can consent to them. Then run the tests and open the report.

Connect-Maester        # signs in and requests the read-only scope set (Get-MtGraphScope lists them)
Invoke-Maester         # runs every installed test, writes a timestamped report, opens the HTML

# Expected: a test-results folder containing HTML, Markdown, and JSON, and the HTML opens in your
# browser showing passed, failed, and skipped counts. A wall of "skipped" usually means a missing
# scope or license, not a healthy tenant. Read why they skipped before you trust the number.

Step 3: Scope the run to your baseline

Maester ships several test packs, and they are not equally relevant to an identity baseline. Select the ones that map to the work the earlier articles did, using tags, rather than running everything and drowning the signal.

PackTagWhat it checks
EIDSCAEIDSCAEntra ID configuration: authentication methods, consent, authorization policy. The closest match to this baseline
CISA SCuBACISAThe CISA baseline controls, including the privileged-access and strong-authentication requirements
Maester best practicesthe MT.#### testsThe community-curated core checks, including break-glass account handling
CISCISThe CIS Microsoft 365 benchmark items, for the audit conversation
Invoke-Maester -Tag 'EIDSCA','CISA','Maester'   # the two packs closest to this baseline, plus Maester's own core tests
Get-MtTestInventory                          # list every available test and tag to build your own selection

Step 4: Create the identity the schedule will run as

A scheduled run cannot sign in as you. It needs its own identity, and the right one is an application registration that authenticates without a stored secret, using workload identity federation so a GitHub Actions run proves who it is with a short-lived token rather than a password you have to store and rotate. Create the app registration in Entra ID, name it so its purpose is obvious, and add a federated credential that trusts your GitHub repository.

The durable nouns to look for: in the Microsoft Entra admin center this is an App registration, and the trust is added under its Certificates and secrets in the Federated credentials tab, using the GitHub Actions deploying Azure resources scenario, with the entity type set to Branch and the branch set to the one your workflow runs from. Maester also ships helper cmdlets that create the app and the federated credential for you, which is the faster path once you have done it by hand once and understand what they are building.

SettingValueWhy
App registration nameSEC-Maester-VerificationNames the purpose so nobody deletes it wondering what it is
Credential typeFederated credential, no client secretNo secret to store in the pipeline or rotate on a calendar
Federation subjectYour repo, entity type Branch, branch mainOnly a run from that branch of that repo can assume the identity
# The consolidated equivalent of the portal walk-through above:
New-MtMaesterApp -Name 'SEC-Maester-Verification'
Add-MtMaesterAppFederatedCredential -GitHubOrganization 'VirtualCaffeineIO' -GitHubRepository 'vcio-maester-tests' -GitHubBranch 'main'
# Then record the app (client) ID and your tenant ID as GitHub repository secrets:
#   AZURE_CLIENT_ID, AZURE_TENANT_ID    (there is deliberately no client secret)

Step 5: Grant it read, and only read

Because the scheduled run authenticates as an application rather than as a user, it needs application permissions on Microsoft Graph, and those are consented once by a Global Administrator. Grant the read-only set the built-in tests require and nothing more. The principle here is the same one the baseline itself enforces: least privilege, even for the thing that checks the privilege.

# Representative least-privilege application permissions (grant admin consent once):
#   Directory.Read.All, Policy.Read.All, Policy.Read.ConditionalAccess,
#   RoleManagement.Read.All, RoleEligibilitySchedule.Read.Directory,
#   PrivilegedAccess.Read.AzureAD, UserAuthenticationMethod.Read.All,
#   Reports.Read.All, IdentityRiskEvent.Read.All, DirectoryRecommendations.Read.All
# Add Mail.Send ONLY if this identity will email the results (Step 7); prefer a mailbox-scoped grant.

# Failure mode: a scheduled run that reports far more "skipped" than your interactive run almost
# always means a permission you consented to as a user is missing from the application grant.

Step 6: Put it on a schedule

With the identity in place, the schedule is a small workflow file in your repository. It runs daily, authenticates through the federated credential with no stored secret, runs the tests, and publishes the report as a build artifact. This is the step where the medium is genuinely automation, so the workflow is the spine and there is no portal equivalent to walk.

name: Maester baseline
on:
  schedule:
    - cron: "30 7 * * *"      # daily
  workflow_dispatch:
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      id-token: write          # required so the run can prove itself via federation
      contents: read
      checks: write            # the action publishes test results as a check run
    steps:
      - uses: maester365/[email protected]    # Maester's own sample says @main; pin a release so upstream changes cannot run against your tenant unreviewed
        with:
          tenant_id: ${{ secrets.AZURE_TENANT_ID }}
          client_id: ${{ secrets.AZURE_CLIENT_ID }}
          include_public_tests: true

# The Azure Automation alternative: an Automation account with a system-assigned managed identity
# granted the same application permissions, a PowerShell 7.4 runbook that runs Connect-MgGraph -Identity
# then Invoke-Maester, and a recurring schedule. It fits inside the free monthly runtime allowance.

Step 7: Alert on drift, not on state

A daily report nobody opens is not a control. The value is being told when something changed, so send the result to where your team already looks, and compare runs so you are alerted to drift rather than to the same known state every morning. Email needs the Mail.Send permission from Step 5, ideally scoped to a single sending mailbox. Teams can use an incoming webhook, which needs no Graph permission at all, or a Graph post if you would rather not depend on a webhook.

Alerting on change alone leaves a hole worth naming. A control that failed thirty days ago and has failed every morning since is no longer news, so it generates nothing, which is precisely the state a baseline exists to make impossible. Alert on two conditions rather than one: a test that changed state since the last run, and any critical test that has been failing longer than the remediation window you agreed. The first catches drift. The second catches the failure everyone has quietly stopped seeing.

$results = Invoke-Maester -Tag 'EIDSCA','CISA','Maester' -PassThru
Send-MtTeamsMessage -MaesterResults $results -TeamChannelWebhookUri $env:TEAMS_WEBHOOK -Subject 'Entra baseline'
# Compare two runs so the alert fires on CHANGE, not on the standing state:
Compare-MtTestResult -PriorTest .\test-results\previous.json -NewTest .\test-results\latest.json
# Or hand it the folder and let it pick the two newest result files itself:
Compare-MtTestResult -BaseDir .\test-results

Step 8: Tie each test back to the control it proves

The reason this loop is worth building is that its tests are not generic, they map one to one onto the baseline the earlier articles set. That mapping is what lets you say a specific control is still enforced, not merely that the tenant scored well. Each EIDSCA identifier names a single setting, and each CISA identifier names a single baseline requirement.

TestProves the baseline itemTier
EIDSCA.AF01 (FIDO2 method state)Passkeys are enabled as an authentication methodCritical
CISA.MS.AAD.3.5SMS, voice call and email one-time passcode are disabled as authentication methodsCritical
EIDSCA.AS04 (SMS not usable for sign-in)Text message is not usable as a primary sign-in method, which is a narrower claim than the row aboveCritical
CISA.MS.AAD.3.1Phishing-resistant MFA is required for all usersCritical
EIDSCA.AP10 (authorization policy)Users cannot register applicationsCritical

Read that as a worked example, and read the pair carefully, because the two tests do not say the same thing. EIDSCA.AS04 inspects a single property, whether text message is usable for sign-in, so a pass proves only that the primary-factor path is shut and says nothing about the second factor. The decision the critical tier actually made, that text message and voice are gone as authentication methods, is the one CISA.MS.AAD.3.5 asserts. Cite the test that proves the control you are claiming, not the one adjacent to it. That precision is the entire value of a mapping table, because a test that nearly proves your control is a comfort rather than evidence, and the difference only surfaces during an audit or an incident.


Step 9: Validate the loop end to end

A verification loop you have not seen catch anything is itself unverified. Prove it works. In a test tenant, run the baseline and confirm the relevant tests pass. Then deliberately introduce drift, re-enabling the text-message method is a safe and reversible choice, and let the schedule run. CISA.MS.AAD.3.5 should flip to failed and your alert should fire. When it does, and only when it does, you have a verification loop rather than a hope. Then put the setting back.


The periodic deep review

Maester is the always-on guardrail. It pairs with a slower, deeper instrument for the occasional strategic look: the Zero Trust Assessment module, which you run by hand, occasionally, to produce a narrative report across identity, devices, network, and data rather than a stream of pass-or-fail assertions. Install it, connect, and run it when you want the architect’s-eye view rather than the regression test.

Install-Module ZeroTrustAssessment -Scope CurrentUser
Connect-ZtAssessment      # first run needs Global Administrator to consent
Invoke-ZtAssessment       # writes an interactive HTML report; can take hours on a large tenant

Identity Secure Score has a place alongside both, but a smaller one than its prominence suggests. Read it as a trend line, not a target. It is an optimization measure rather than a control baseline, and it can be moved by marking a control as handled elsewhere, so a rising score is a weak claim and a falling one is a prompt to look, nothing more. The pass-or-fail tests are the evidence. The score is the weather.

That completes the loop, and with it the reasoned half of this baseline. The controls are set, defended, and now continuously proven. What remains is the fast reference: the whole baseline compressed into a checklist you can run down in an afternoon, which is where the last article in this wave takes over.


Best Practices
‹ Previous: [BP 1.3] The New Tenant: Sequencing and One-Way Doors
Next: [BP 1.5] The Entra ID Quick Checklist