☰
Azure安全中心策略自动化测试:用Pester构建Policy as Code回归链路
2026/10/1 14:14:49 网站建设 项目流程

在Azure上做安全治理的同学应该都有同感:真正难的往往不是策略本身怎么写,而是策略上线之后的那一哆嗦。我之前给一个生产订阅加过一条“禁止创建公网IP”的Deny策略,逻辑本身没什么问题,结果下午就有人来敲我——说内部一个负载均衡器的公网IP也被误伤了,创建了新的实例直接失败。根源很简单,我当初只是在Portal里点了两下、确认了策略能Avail,根本没在真实作用域跑过一轮完整的自动化验证。

后面我索性搭了一套Azure安全中心策略自动化测试套件,核心组件是PowerShell的Pester框架,配合Azure Policy的JSON定义与REST API,把“策略能不能上线”这件事变成了一条可以随时回归、可以交给CI/CD执行的工程链路。这篇文章把整套方案从设计思路、目录结构,到行为验证、流水线接入,再到常见的坑,完整记录下来。适合正在做Azure云治理、安全基线、或者打算把IaC(基础设施即代码)“测试化”的团队参考。

顺嘴提一句,Azure安全中心现在的官方名称已经叫Microsoft Defender for Cloud,但圈子里还是习惯叫“安全中心”,下面内容我就统一用“安全中心”指代。

1. 整体设计:策略测试为什么必须自动化,以及三层测试模型

1.1 安全策略的本质:从“一次性配置”变成“长期代码债”

不管你是用安全中心自带的安全建议,还是用Azure Policy自定义Deny、Audit、DeployIfNotExists效果,最终这些策略都会被固化成JSON定义,并且被分配到管理组、订阅或者资源组上。一旦你开始用代码管理策略,就意味着你迈入了“策略即代码”(Policy as Code)的范畴。而只要是代码,就一定有回归风险、有环境差异、有需要反复验证的场景。

我见过太多团队是这种状态:策略在开发环境测过一遍,然后复制到生产,之后除非出了线上事故,基本没有人再去碰它。但事实是,Azure的策略评估结果会受到作用域叠加、资源提供者注册情况、参数类型、甚至策略别名的拼写影响。今天写的一条Deny策略可能只挡住了VM,下周同一个作用域下面新增了一种存储账号类型,策略却压根没覆盖到。这些问题靠人肉检查几乎看不出来。

另一个现实问题是时间成本。安全基线里动辄几十上百条策略,你不可能每天用Portal一个个建测试资源去试。就算每周试一遍,一次全套验证折腾下来也得小半天。而自动化测试套件要解决的正是这几个问题:可重复、可追溯、低成本、能提前发现问题。

1.2 三层测试模型:语法、语义、行为缺一不可

在设计套件之前,我先想清楚了一个问题:到底要测策略的什么?拆到最后其实就是三层:

第一层是语法层。说白了就是保证策略JSON文件是合法JSON,必需字段(如displayName、policyType、parameters、policyRule)齐全,结构上没有低级错误。Azure Policy的Schema校验在Portal上会帮你做,但当你用代码管理、多人PR时,机器校验比人眼靠谱得多。

第二层是语义层。这层比语法更“软”一些,看的是策略逻辑是否合理。比如policyRule的if条件里引用的别名(alias)是否存在、参数是否定义了AllowedValues、effect是否落在合法枚举(Audit、Deny、Modify等)范围内。语义层测试特别适合检测“看着能通过Schema,实际上策略执行不了”的情况。

第三层是行为层,也是最有价值、最容易被忽略的一层。行为层意味着要在隔离的测试订阅里真实分配策略、执行资源创建、然后检查策略评估结果和实际拦截/审计行为是否符合预期。这一层测试能扑捉到最真实的回归——比如你把一条Deny策略从“禁止VM创建公网IP”不小心加了一个等价条件,语法和语义都没问题,但行为变了,只有实际跑一遍才知道。

在实际搭建套件时,我把这套概念映射成了三个测试目录:tests/syntax、tests/semantic、tests/behavior。这三个目录在流水线里承担不同的角色,后面会细说。

1.3 为什么选Pester而不是pytest或者别的工具

网上关于策略测试的讨论,大部分都聚焦在pytest配合ARM模板测试上。但我最终选了Pester,理由很直接:Azure Policy的定义本身就是PowerShell生态和REST API的组合,而且安全中心日常运维几乎离不开PowerShell的Az模块。用Pester可以直接在测试脚本里调用Get-AzPolicyAssignment、Get-AzPolicyState这些原生命令,不用再包一层解析器或通过Python SDK中转。

更关键的是,Pester的Describe、Context、It这套结构非常接近自然语言。测试用例写完,安全团队里哪怕不太懂代码的同学也能大概读出来在验什么。比如:

Context "当存储账号允许HTTP访问时" { It "应该被安全中心标记为不合规" { $state = Get-AzPolicyState -ResourceId $storageAccount.Id $state.IsCompliant | Should -Be $false } }

这种可读性在跨团队协作时非常值钱。另外Pester v5对数据驱动测试的支持也比早期版本好很多,后面我会展示怎么用一条测试逻辑批量覆盖几十个策略定义。

2. 套件结构设计:被测对象、目录约定与数据驱动测试

2.1 我们到底在测什么:安全中心里的策略生态

搭建套件前,首先要明确“被测对象”到底包括什么。Azure安全中心里策略相关的东西其实有四类实体:

第一类是单个策略定义,即一条Policy Definition,可能来自内建策略或自定义策略。第二类是策略计划(Initiative),安全中心自带的安全基线本质上就是一组Initiative,里面挂着几十上百条策略。第三类是策略分配,也就是把策略或计划绑定到某个管理组/订阅/资源组,分配时可以指定参数和排除范围。第四类是策略评估结果,也就是合规状态,这才是用户最终感受到的东西。

所以一个完整的测试套件,绝不能只测策略JSON文件本身的合法性,还要测分配逻辑和评估结果。我在项目里维护了三套内容:policies/目录存自定义策略JSON,assignments/目录存分配模板(带参数和排除项),tests/目录里则写测试脚本。这样任何一次改动——不管是策略逻辑还是分配范围——都能触发对应的测试。

顺便说个经验:安全中心自带的那些内建策略原则上不要直接改,但你必须把它们纳入回归范围。因为你的自定义策略很可能跟内建策略存在叠加竞争关系。比如Azure内置了一条“应禁用存储账号的公开访问”,你又自建了一条“禁止存储账号任何网络例外”,两者评估逻辑一旦冲突,合规状态可能反复横跳。测试套件里定期跑一遍基线列表,就是为了把这些冲突早期揪出来。

2.2 目录结构约定与命名规则

我目前的套件目录长这样:

policy-tests/ ├─ policies/ │ ├─ deny-public-ip-vm.json │ ├─ audit-storage-https-only.json │ └─ initiatives/ │ └─ custom-security-baseline.json ├─ assignments/ │ ├─ dev/assignment.deny-public-ip-vm.json │ └─ prod/assignment.deny-public-ip-vm.json ├─ tests/ │ ├─ syntax/ │ │ └─ syntax.policy.definition.ps1 │ ├─ semantic/ │ │ └─ semantic.policy.rule.ps1 │ └─ behavior/ │ ├─ behavior.deny-public-ip-vm.ps1 │ └─ behavior.audit-storage-https.ps1 ├─ scripts/ │ ├─ connect.ps1 │ ├─ invoke-all-tests.ps1 │ └─ cleanup-test-resources.ps1 └─ azure-pipelines.yml

命名规则就两条:一条策略对应一个JSON文件;一个行为测试对应一条策略的某个关键场景,文件名直接带策略名。好处是失败时你从测试报告里一眼就能定位到是哪条策略出了问题,而不是面对一堆test1.ps1、test2.ps1。

2.3 数据驱动测试:一份逻辑覆盖所有策略

如果你老老实实给每条策略写一份独立测试代码,维护成本会爆炸。所以我在语法和语义层大量使用了Pester v5的数据驱动能力。核心做法是从目录里读取策略文件列表,把它作为参数循环注入测试用例。

一个典型例子是我遍历所有策略JSON,验证必备字段是否存在:

BeforeAll { $policyFiles = Get-ChildItem "$PSScriptRoot/../../policies/*.json" -ErrorAction SilentlyContinue } Describe "每个策略定义都应包含必要字段" -ForEach $policyFiles { Context "验证 <_.BaseName>" { BeforeAll { $policyJson = Get-Content $_.FullName -Raw | ConvertFrom-Json } It "displayName 不为空" { $policyJson.displayName | Should -Not -BeNullOrEmpty } It "policyType 是 Custom 或 BuiltIn" { $policyJson.policyType | Should -BeIn @("Custom", "BuiltIn") } It "policyRule 非空且包含 then 字段" { $policyJson.policyRule.then | Should -Not -BeNullOrEmpty } } }

这段代码的精髓在于-ForEach参数。它让Pester自动把列表里的每个文件变成一个独立的测试上下文,测试标题里可以用<_.BaseName>占位符标明当前测的是哪个文件。这样你新增一条策略文件时,根本不需要新增测试代码,只需要保证它出现在policies/目录里,测试逻辑自动把它纳入回归范围。

同样道理也适用于语义层:把一组别名列表和允许的effect枚举做成数据表,用Should -BeIn批量验证。数据驱动测试在策略数量超过30条以后,省下的维护成本非常可观。

3. 实操记录:从零搭建策略自动化测试套件的完整过程

3.1 环境准备与权限设计

先交代我的环境基线:Windows和Linux都实测过,统一用PowerShell 7.3以上版本,Az模块至少10.x,Pester必须用5.x。注意,Pester 5和4的语法差异不小,网上很多老教程还停留在4.x的New-TestDrive那套,直接照着写会报错。

权限这块我只说结论:在测试订阅里给测试用的服务主体分配“Policy Contributor”角色,并且确保它可以在这个订阅里创建Resource Group、存储账号、VM等测试资源。思路很简单——测试服务主体本质上是“管理员”,但它只能在隔离的测试订阅里活动,永远不要拿生产环境当实验场。

登录逻辑我单独放在scripts/connect.ps1里,支持服务主体和交互登录两种模式:

param( [string]$ApplicationId, [string]$SubscriptionId, [string]$TenantId, [string]$CertificateThumbprint ) $context = Get-AzContext if (-not $context -or -not $context.Account) { if ($CertificateThumbprint) { Connect-AzAccount -ServicePrincipal -ApplicationId $ApplicationId ` -Tenant $TenantId -CertificateThumbprint $CertificateThumbprint ` -Subscription $SubscriptionId } else { Connect-AzAccount -Subscription $SubscriptionId } }

这里我加了一个条件判断:如果当前PowerShell会话已经有有效AzContext,就不要重复Connect-AzAccount。否则你在同一个Runner里连续跑多次测试,容易遇到上下文互相覆盖、Token过期不刷新之类的玄学问题。

3.2 语法层测试的实现细节

语法层测试除了检查字段存在性,还应该做一件事:用Azure Policy的REST API做真正的Schema校验。Az模块虽然能解析JSON,但不会替你做完整Schema校验。我的做法是用Test-AzPolicyDefinition这个cmdlet,先直接尝试创建策略定义,失败就说明语法有问题。

Describe "策略定义可被正常创建" -ForEach $policyFiles { It "<_.BaseName> 可以通过 Azure Policy Schema 校验" { $parameter = @{ Name = "test-$($_.BaseName)" Policy = $_.FullName Mode = "Indexed" Location = (Get-AzLocation | Select-Object -First 1).Location } $result = Test-AzPolicyDefinition @parameter -ErrorAction SilentlyContinue $result | Should -Be $true } }

注意这里的命名不能直接使用策略原有的Name,因为策略定义Name在Azure里是全局命名的(至少要求在订阅/管理组范围内唯一),直接使用生产环境的Name容易冲突。加上test-前缀是一种廉价但有效的碰撞规避方案。

真实跑下来你会发现,语法层测试抓到的多数问题都是JSON里多了逗号、字段名大小写不对、或者mode写错成了All而不是Indexed。这些问题放在人肉流程里往往要等到分配策略时才会爆出来。

3.3 行为层测试:在隔离订阅里“搞破坏”

行为层测试是整套套件里含金量最高、也最容易被跳过的部分。以前我也没有做这层,后来被“策略看似生效但实际评估结果异常”教训过一次,才开始重视。

给大家看一下我测“禁止VM公网IP”的行为层脚本核心逻辑:

Describe "deny-public-ip-vm 策略行为" { Context "尝试创建一个带公网IP的VM时" { BeforeAll { # 1. 在隔离测试订阅中创建资源组 $rgName = "rg-policy-behavior-test-$(Get-Random -Minimum 1000 -Maximum 9999)" New-AzResourceGroup -Name $rgName -Location "eastus2" try { # 2. 分配待测策略,作用域限定到这个RG New-AzPolicyAssignment -Name "test-deny-public-ip" ` -PolicyDefinition $policyDefId ` -Scope $rgName.ResourceId # 3. 尝试创建带公网IP的NIC $badNicParams = @{ ... } New-AzNetworkInterface @badNicParams -ErrorAction Stop $creationSucceeded = $true } catch { $creationSucceeded = $false $errorMessage = $_.Exception.Message } } It "创建操作应被策略拦截" { $creationSucceeded | Should -Be $false $errorMessage | Should -Match "deny-public-ip-vm" } AfterAll { # 4. 测试结束必须清理,防止资源残留 Remove-AzResourceGroup -Name $rgName -Force -AsJob } } }

这里有三个关键工程细节,值得单独说。

第一,策略评估不是毫秒级的。Azure Policy是异步评估引擎,New-AzNetworkInterface被Deny拦住是快的,但如果你测的是Audit效果,你可能需要等一两分钟才能看到Get-AzPolicyState里出现不合规记录。所以行为测试里我一般都会写一个轮询函数:每隔10秒查一次状态,最多等90秒,超时再报失败。

function Wait-PolicyState { param($ResourceId, $TimeoutSec = 90) $deadline = (Get-Date).AddSeconds($TimeoutSec) do { $state = Get-AzPolicyState -ResourceId $ResourceId -ErrorAction SilentlyContinue if ($state) { return $state } Start-Sleep -Seconds 10 } while (Get-Date -lt $deadline) return $null }

第二,行为层测试里永远要写清理逻辑,而且是try/finally级别的清理。我刚开始没注意,测试几次之后测试订阅里堆了一堆资源,安全中心合规简报上全是红色告警,反而不利于基线数据对比。后来我统一在AfterAll或finally块里用Remove-AzResourceGroup -Force清理测试资源组,因为RG里所有资源会一起删除,比逐个删好用。

第三,行为层测试非常吃订阅配额。比如VM数量、公共IP数量都有默认配额限制,连续跑多了可能撞配额。所以CI流水线里,我默认行为层测试只跑在两个可能冲突的策略组上,而不是每次PR都全量跑。后面流水线部分会展开讲。

3.4 语义层的关键窍门:验证别名与effect组合

语义层测试中,最容易被忽略的是“别名验证”。Azure Policy里的field可以引用资源属性的别名,比如Microsoft.Storage/storageAccounts/networkAcls.defaultAction。如果别名拼错或者属性路径在某个资源类型上不存在,策略会静默降级为不评估,不会报错,但永远不会命中任何资源。这是典型的语法测试抓不到、语义测试最重要的一环。

我在语义层做了这样一件事:从所有策略JSON里抽取所有field值,过滤出包含Microsoft.开头的别名,然后调用Azure提供的别名REST接口或直接用Get-AzPolicyAlias批量校验存在性:

BeforeAll { $aliases = Get-AzPolicyAlias -ResourceTypeMatch "Microsoft.Storage" -ListAvailable $validAliasSet = $aliases.Aliases.AliasName } It "策略引用的别名存在且拼写正确" { $badAliases = $aliasUsedInPolicy | Where-Object { $_ -notin $validAliasSet } $badAliases | Should -BeNullOrEmpty }

这个测试看起来简陋,实际价值很大。因为Azure的别名列表会随着新服务发布而更新,如果你的策略用了某个新服务的别名,而当前云环境还没有更新别名表,那这条策略等于废的。早期发现总比上线后收到“策略被忽略”的工单要好。

4. 接入CI/CD:让每次策略变更都自动跑测试

4.1 触发机制设计:PR阶段验证,发布阶段全量验证

测试套件写好了,如果不接流水线,那还是靠人肉执行,价值大打折扣。我自己的工程实践是把测试分成两级:

第一级是PR触发级。只要代码合入到主分支之前,但凡路径变更涉及policies/或assignments/目录,就触发一个快速管道,只跑语法和语义测试。这个级别不跑行为测试,因为行为测试耗时较久,而且要创建真实资源,不适合高频执行。它主要负责挡掉低级错误,让Reviewer不用自己去克隆代码、切分支、手动跑脚本。

第二级是发布触发级。当主分支上有release/policy的Tag打出时,触发全量管道,跑语法、语义、行为全套测试。行为测试会在隔离订阅里真实模拟一次“违规资源创建”,确保策略在目标环境中的表现完全符合预期。

4.2 GitHub Actions的流水线参考

我在项目里用的是GitHub Actions,Workflow文件关键部分如下:

name: policy-tests on: pull_request: paths: - 'policies/**' - 'assignments/**' push: tags: - 'release/policy-*' jobs: quick-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: azure/login@v1 with: creds: ${{ secrets.AZURE_TEST_SUB_CREDENTIALS }} - name: Run syntax and semantic tests shell: pwsh run: | Install-Module Pester -RequiredVersion 5.5.0 -Force Install-Module Az.Policy -Force Invoke-Pester ./tests/syntax, ./tests/semantic -CI - name: Publish test report uses: actions/upload-artifact@v4 if: always() with: name: pester-report path: '**/TEST-*.xml' full-tests: if: startsWith(github.ref, 'refs/tags/release/policy-') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: azure/login@v1 with: creds: ${{ secrets.AZURE_TEST_SUB_CREDENTIALS }} - name: Run behavior tests shell: pwsh run: | Invoke-Pester ./tests/behavior -CI

这里最值得说的是azure/login@v1这个Step。它会把Azure凭据注入到Runner的PowerShell环境中,这样后面的Connect-AzAccount就不需要手动传证书指纹或密码了,直接用Connect-AzAccount -Identity或让脚本检测到已有Context即可。别把服务主体密钥直接写进YAML,GitHub Secrets是更安全的方式。

另一个细节是测试报告。Pester启用-CI参数后会输出NUnitXML格式的结果,GitHub Actions原生的test report摘要和Annotate功能能自动解析这些XML并在PR页面标出失败的测试用例。这个体验比看控制台日志舒服太多,值得配一下。

4.3 测试失败后如何通知团队

触发失败后,默认GitHub Actions会在PR页面给出红色状态,这对保护主分支已经足够了。但我个人的习惯是再加一层通知:当full-tests失败时,通过Webhook推送到团队群。

实现方式并不复杂,用一个PowerShell脚本读取Pester的Result对象,如果失败就调用Webhook:

$result = Invoke-Pester -PassThru -CI if ($result.FailedCount -gt 0) { $body = @{ text = "策略测试失败,共 $($result.FailedCount) 个用例,请检查: $env:RUN_URL" } | ConvertTo-Json Invoke-RestMethod -Uri $env:TEAMS_WEBHOOK_URL -Method Post -Body $body -ContentType "application/json" exit 1 }

这一步做完,团队里的每个人在合并策略之前就能自动获得安全感,不用再靠“谁有空谁去手动验证”这种脆弱机制。

5. 常见问题与排查技巧实录

5.1 策略行为与预期不符的排查三板斧

不管测试套件写得多完美,总有很多问题是它本身没法完全预防的,因为Azure的底层行为也需要你理解。我总结了三步排查法,遇到“策略已经分配,但就是没生效/没拦截”的情况,按顺序查:

第一查作用域。Azure Policy有四个层级:管理组、订阅、资源组、资源。子作用域可以继承父作用域的分配,但如果某个子作用域被加入排除范围,父策略就不会对它生效。另外,Deny效果不能被子作用域“覆盖”成Audit,你只能让子作用域变得更严格。很多人被这条规则坑过——他们以为把某个RG从父级Deny中排除就能开绿灯,实际不行,必须在同一作用域单独分配允许策略并设置优先级。

第二查评估延迟。Azure Policy的完整评估周期最高可能需要几十分钟(尤其是Audit效果),资源刚创建完的那几秒查不到状态是正常的。我的测试套件里特意加了Wait-PolicyState轮询,但如果你在做人工排查,也请给评估留出至少15分钟,再判断“没生效”还是“延迟”。

第三查资源提供者。如果策略用的是deployIfNotExists效果,DINE策略的实质是触发一次部署任务。如果目标资源提供者没有注册,这个部署任务会失败,导致策略永远处于NonCompliant但不修复。遇到这种情况不是去改策略,而是先注册对应的Resource Provider,再重跑评估。

5.2 测试套件自身容易踩的坑

先分享三个我在Pester使用中踩过的坑。第一个是并行执行冲突。Pester v5自带并行测试能力,看起来很美,但如果你的测试代码里调用了Az模块,多个测试用例并行执行时会共享同一个PowerShell的AzContext,经常出现一个用例清了Context、另一个直接401错误。我的解决办法是用-Skip或直接不启用并行,必要时用-RunCount 1限制并行度。测试套件的价值在于稳定,不是跑得快。

第二个坑是BeforeAll里连接Azure,AfterAll里又断开。看似干净,实际上如果多个测试文件在同一个Runner里先后执行,前一个文件断开后,后一个文件会傻掉。正确做法是只在流水线的全局启动脚本里连接一次,所有测试文件共享Context,测试内部不做Connect/Disconnect操作。

第三个坑是测试里创建资源后的清理时机。我们前面说过用AfterAll清理,但如果AfterAll里也报错,清理过程就中断了,导致残留资源。稳妥方案是把资源ID写成一个数组,在AfterAll里用foreach轮询强制删除,并对每个删除操作单独try/catch,不要让单点失败阻断整个清理。

5.3 常见问题速查表

现象可能原因处理方式
策略JSON能解析但创建策略定义时报错Schema字段非法,如mode写了Indexed但policyRule里有不符合限制的字段用Test-AzPolicyDefinition做前置校验
策略分配成功但没有合规结果作用域不存在资源,或资源提供者未注册先创建测试资源,再等待评估周期
Deny策略没有拦住资源创建分配参数里effect被改成Audit检查参数值和策略优先级
行为测试中创建操作一直成功策略排除项包含了测试RG检查分配排除范围,测试A/B隔离
Audit测试一直查不到不合规状态PolicyState接口有延迟加轮询,等待60-120秒
Pester运行时一直报401AzContext过期或并行上下文冲突全局连接一次,不启用测试级并行
测试订阅资源残留越滚越多清理逻辑只清理了部分资源用try/finally + Remove-AzResourceGroup -Force

这张表是我实际维护套件这一年里最常遇到的七类问题,基本覆盖了八成以上的“测试爆炸”场景。建议你把这张表也留在项目仓库的README里,新同学接手时能少走很多弯路。

5.4 一个真实回滚案例:策略测试救了我一次

最后讲一个真实案例,也顺便说明行为层测试的价值到底在哪里。

生产环境曾经有一条“禁止暴露公网IP”的Deny策略,我拍脑袋觉得策略只作用于VM网卡,于是趁着周五下班前上线。结果第二天早上,大量UAT环境中的应用因为无法创建新的负载均衡器直接失败——原来这条策略当时把Microsoft.Network/loadBalancers/publicIpAddresses这个字段也覆盖进去了,任何新绑定的负载均衡公网IP都被拦掉。

那段时间我刚好还没有这套自动化测试套件,只能靠人肉一条条翻日志、看参数。事后我复盘,如果当时套件已经存在,行为层测试只需要在测试订阅里尝试创建一个带公网IP的负载均衡器,Deny拦截会直接报错,我根本不会把这个问题带进生产。

从那以后,我把行为层测试的覆盖范围从VM扩展到了LB、存储账号、SQL Server这些常见联网资源,并且在策略变更的PR描述里强制要求附上行为测试的通过报告。之后再没出过类似问题。

现在每次上线安全策略,我都会对自己说一句话:宁可让测试套件多跑一轮,也别让半夜的告警短信替你跑。这套东西用起来之后,价值会随着策略数量增长而指数级放大。后面我还在计划把Pester的测试结果跟安全中心的合规报告做对接,做成每周自动巡检的策略成色报表,把策略漂移这个隐患往前再压一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询