Azure Security Assessment Templates: Reusable Infrastructure Patterns - WordPad

Azure Security Assessment Templates: Reusable Infrastructure Patterns

Last reviewed: June 2026

Scope

This article is about turning an Azure security assessment from a hand-written report into a tool. A template collection is only as good as its consistency, and the way to guarantee consistency is to make each check a small, testable function that emits a finding in a fixed schema. This piece shows that pattern with real, runnable PowerShell — a couple of working checks against live Azure, an orchestrator that discovers and runs them, structured output, and Pester tests — and the delivery practices that wrap around it.

Why a tool, not a checklist

Reusable assessment material is not about speed; it is about consistency, reviewability, and lower delivery risk. If the questions, control mapping, and evidence format change from project to project, the output can't be compared across engagements or handed over. So the collection is built as a suite of automated checks — on the order of 85 of them across identity, storage, networking, logging, and policy — each producing the same finding shape, mapped to the controls that matter (ISO 27001 and SOC 2 baselines), so the conversation is about risk rather than whose spreadsheet format won.

The architecture that makes 85 checks maintainable instead of an 85-branch monster is simple: one finding schema, one function per check, and an orchestrator that discovers the checks by naming convention. Adding a check is adding a function — nothing else changes.

The finding schema and the check pattern

Every check returns zero or more findings in one shape, built by a single helper:

function New-SecFinding {
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ControlArea,
        [Parameter(Mandatory)][string]$Title,
        [Parameter(Mandatory)][ValidateSet('High', 'Medium', 'Low', 'Info')][string]$Severity,
        [string]$Resource,
        [string]$Evidence,
        [string]$Remediation,
        [string]$ControlRef
    )
    [pscustomobject]@{
        ControlArea = $ControlArea
        Title       = $Title
        Severity    = $Severity
        Resource    = $Resource
        Evidence    = $Evidence
        Remediation = $Remediation
        ControlRef  = $ControlRef
    }
}

A check is any function named Test-AzSec* that takes a -SubscriptionId and returns findings for the gaps it sees (and nothing when the control passes). Here are two real ones.

function Test-AzSecStoragePublicAccess {
    <#
    .SYNOPSIS
        Flags storage accounts that permit public blob access.
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string]$SubscriptionId)

    Get-AzStorageAccount | Where-Object { $_.AllowBlobPublicAccess } | ForEach-Object {
        New-SecFinding -ControlArea 'Storage' -Title 'Storage account allows public blob access' `
            -Severity 'High' -Resource $_.Id -Evidence 'AllowBlobPublicAccess = True' `
            -Remediation 'Disable public blob access; enforce with an Azure Policy deny rule' `
            -ControlRef 'ISO 27001 A.8.20 / SOC 2 CC6.1'
    }
}

function Test-AzSecStandingOwner {
    <#
    .SYNOPSIS
        Flags permanent Owner role assignments at subscription scope (no just-in-time control).
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string]$SubscriptionId)

    $scope = "/subscriptions/$SubscriptionId"
    Get-AzRoleAssignment -Scope $scope |
        Where-Object { $_.RoleDefinitionName -eq 'Owner' -and $_.Scope -eq $scope } |
        ForEach-Object {
            New-SecFinding -ControlArea 'Identity' -Title 'Standing Owner assignment at subscription scope' `
                -Severity 'High' -Resource $_.DisplayName `
                -Evidence "Owner: $($_.SignInName) [$($_.ObjectType)]" `
                -Remediation 'Convert to a PIM-eligible, time-bound, reviewed assignment' `
                -ControlRef 'ISO 27001 A.5.18 / SOC 2 CC6.3'
        }
}

Each check is small, single-purpose, and independently testable. That is the whole point: a suite of 85 of these is just 85 small functions, not one unreadable script, and any one of them can be fixed or improved without touching the rest.

The orchestrator: discover, run, fail closed, export

function Invoke-AzSecurityAssessment {
    <#
    .SYNOPSIS
        Runs every Test-AzSec* check against a subscription and returns structured findings.
    .EXAMPLE
        Invoke-AzSecurityAssessment -SubscriptionId $sub -As Html -OutputPath .\report.html
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$SubscriptionId,
        [string]$ChecksPrefix = 'Test-AzSec',
        [ValidateSet('Object', 'Json', 'Html')][string]$As = 'Object',
        [string]$OutputPath
    )

    Set-AzContext -Subscription $SubscriptionId -ErrorAction Stop | Out-Null
    $checks = Get-Command -Name "$ChecksPrefix*" -CommandType Function
    Write-Verbose "Running $($checks.Count) checks against $SubscriptionId"

    $findings = foreach ($check in $checks) {
        try {
            & $check -SubscriptionId $SubscriptionId
        }
        catch {
            New-SecFinding -ControlArea 'Assessment' -Title "Check '$($check.Name)' failed to run" `
                -Severity 'Info' -Resource $SubscriptionId -Evidence $_.Exception.Message `
                -Remediation 'Investigate the check error — a failed check is not a pass' -ControlRef ''
        }
    }

    switch ($As) {
        'Json' { $findings | ConvertTo-Json -Depth 4 | Set-Content -Path $OutputPath -Encoding UTF8 }
        'Html' { $findings | ConvertTo-Html -Title 'Azure Security Assessment' | Set-Content -Path $OutputPath -Encoding UTF8 }
        default { $findings }
    }
}

Two design decisions matter here. The orchestrator discovers checks by name (Get-Command -Name 'Test-AzSec*'), so adding a function adds it to the run — there is no central list to maintain. And it fails closed: if a check throws — a throttled Graph call, a permission gap — that becomes an Info finding that says "this check did not run," not a silent omission. A failed check is not a pass, and the report should never let you believe otherwise.

What a finding looks like

The output is uniform, which is what makes it an actionable backlog rather than prose:

ControlArea Title Severity Evidence Remediation ControlRef
Storage Storage account allows public blob access High AllowBlobPublicAccess = True Disable; enforce via policy ISO 27001 A.8.20
Identity Standing Owner at subscription scope High Owner: jane@contoso.com [User] Move to PIM, time-bound ISO 27001 A.5.18
Network NSG allows broad inbound from internet Medium Rule source *, Allow, inbound Restrict source or remove ISO 27001 A.8.20

Every finding carries evidence, a severity, a remediation, and a control reference, in the same format every time — which is exactly what lets you compare results across subscriptions and engagements, and hand the suite to a customer to re-run.

Prove it with Pester

Because each check and the orchestrator call Az cmdlets, they are testable by mocking those cmdlets — no live tenant required.

BeforeAll {
    . $PSScriptRoot\AzSecurity.ps1
}

Describe 'Azure security checks' {
    BeforeAll { Mock Set-AzContext {} }

    Context 'storage' {
        It 'flags a storage account with public blob access' {
            Mock Get-AzStorageAccount { [pscustomobject]@{ Id = '/.../sa1'; AllowBlobPublicAccess = $true } }
            (Test-AzSecStoragePublicAccess -SubscriptionId 'sub').Title | Should -Be 'Storage account allows public blob access'
        }
        It 'returns nothing when storage is locked down' {
            Mock Get-AzStorageAccount { [pscustomobject]@{ Id = '/.../sa1'; AllowBlobPublicAccess = $false } }
            Test-AzSecStoragePublicAccess -SubscriptionId 'sub' | Should -BeNullOrEmpty
        }
    }

    Context 'identity' {
        It 'flags a standing subscription-scope Owner' {
            Mock Get-AzRoleAssignment { [pscustomobject]@{ RoleDefinitionName = 'Owner'; Scope = '/subscriptions/sub'; DisplayName = 'Jane'; SignInName = 'jane@contoso.com'; ObjectType = 'User' } }
            (Test-AzSecStandingOwner -SubscriptionId 'sub').ControlArea | Should -Be 'Identity'
        }
    }

    Context 'orchestrator' {
        It 'aggregates findings and fails closed when a check throws' {
            Mock Get-AzStorageAccount { [pscustomobject]@{ Id = '/.../sa1'; AllowBlobPublicAccess = $true } }
            Mock Get-AzRoleAssignment { throw 'Graph throttled' }
            $result = Invoke-AzSecurityAssessment -SubscriptionId 'sub'
            $result.Title | Should -Contain 'Storage account allows public blob access'
            ($result | Where-Object Severity -eq 'Info').Title | Should -Match 'failed to run'
        }
    }
}

The fail-closed test is the one I care about most: it proves that a throttled or unauthorized check shows up as a visible Info finding rather than quietly disappearing — the difference between an assessment you can trust and one that flatters the tenant.

How I use this in a customer project

  1. Run the assessment against the agreed scope (Invoke-AzSecurityAssessment), exporting JSON for the record and HTML for the readout.
  2. Triage findings by severity, attaching owner and target date.
  3. Map findings to the customer's authoritative controls (ISO 27001, SOC 2, or an internal baseline).
  4. Confirm logging, monitoring, and Sentinel readiness.
  5. Turn the findings into a prioritised remediation backlog.
  6. Agree ownership and exception handling with stakeholders.
  7. Hand the suite over so the customer can re-run it themselves.

That last step is what separates a deliverable from a dependency: if the customer can re-run the assessment after I leave, the engagement improved their capability; if they can't, I've sold them a snapshot.

Delivery decision points

Area Decision Why it matters
Control mapping Which standard or internal baseline is authoritative? Prevents conflicting remediation priorities
Evidence Where are findings and exports stored? Makes review and audit work repeatable
Severity model What makes a finding High vs Medium? Keeps triage consistent across runs
CI / change control How are checks added and reviewed? Prevents uncontrolled drift in the suite
Exceptions Who approves accepted risk, and when does it expire? Keeps governance decisions explicit

Delivery and assessment checklist

  • Keep one finding schema and one function per check.
  • Make the orchestrator discover checks by convention, not a hand-maintained list.
  • Fail closed: a check that can't run is a visible finding, never a silent pass.
  • Map every finding to a control reference and a remediation.
  • Run the Pester suite in CI on every change to a check.
  • Export JSON for the record and HTML for the human readout.
  • Record exceptions with approver, reason, expiry, and review date.
  • Run under a least-privilege identity (Reader plus the specific data-plane rights each check needs).
  • Hand the suite over so the customer can re-run it.

Final recommendation

A security assessment becomes valuable when it is consistent, evidenced, and repeatable — and the way to guarantee that is to build it as a tool, not write it as a report. One finding schema, small single-purpose checks discovered by convention, an orchestrator that fails closed, structured exports, and Pester tests turn "we reviewed your tenant" into "here are 30 findings with evidence, severity, and remediation, and here is the suite so you can re-run it next quarter." The templates don't replace engineering judgment; they make that judgment repeatable, reviewable, and ownable.

Related infrastructure guides

For Help, press F1 1466 words Ln 1, Col 1