PowerShell 技能连载 - 注册表管理与安全审计

适用于 PowerShell 5.1 及以上版本

Windows 注册表是操作系统配置的核心存储库,几乎所有系统设置、应用程序配置和安全策略都记录在注册表中。在企业环境中,系统管理员需要定期检查注册表中的安全设置,确保符合合规要求。传统的 regedit.exe 图形界面工具虽然直观,但无法满足批量操作和自动化审计的需求。

PowerShell 提供了完整的注册表操作能力,通过注册表提供程序(Registry Provider)可以直接像操作文件系统一样浏览和修改注册表。结合 .NET 类库,还可以管理注册表项的 ACL(访问控制列表),实现细粒度的权限审计。本文将从基础操作、安全审计和变更追踪三个方面,介绍如何用 PowerShell 高效管理注册表。

值得注意的是,注册表操作具有较高的风险性,错误的修改可能导致系统不稳定甚至无法启动。因此在生产环境中,建议先导出备份再进行修改,并使用 -WhatIf 参数进行预演。

注册表基础操作与批量配置

PowerShell 的注册表提供程序将注册表映射为驱动器,可以直接使用 Get-ItemSet-ItemProperty 等命令操作。下面是一些常见的批量配置场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 查看可用的注册表驱动器
Get-PSDrive -PSProvider Registry | Format-Table Name, Root

# 读取 Windows 当前版本信息
$osInfo = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
Write-Host "系统版本: $($osInfo.ProductName)"
Write-Host "当前版本号: $($osInfo.DisplayVersion)"
Write-Host "构建号: $($osInfo.CurrentBuild)"

# 批量修改远程桌面相关注册表项
$rdpSettings = @{
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' = @{
fDenyTSConnections = 0 # 允许远程桌面连接
}
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' = @{
UserAuthentication = 1 # 要求网络级别身份验证
}
}

foreach ($path in $rdpSettings.Keys) {
foreach ($name in $rdpSettings[$path].Keys) {
$value = $rdpSettings[$path][$name]
Set-ItemProperty -Path $path -Name $name -Value $value
Write-Host "已设置 $path\$name = $value"
}
}

# 搜索注册表中的特定键值(以搜索所有安装的 .NET 版本为例)
$dotnetKey = 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full'
if (Test-Path $dotnetKey) {
$release = (Get-ItemProperty -Path $dotnetKey -Name Release -ErrorAction SilentlyContinue).Release
$versions = @{
528040 = '.NET Framework 4.8'
533320 = '.NET Framework 4.8.1'
}
$found = $versions.Keys | Where-Object { $release -ge $_ } | Sort-Object -Descending | Select-Object -First 1
Write-Host ".NET 版本: $($versions[$found]) (Release=$release)"
}

# 导出注册表项到 .reg 文件(使用 reg.exe)
$tempRegFile = "$env:TEMP\firewall_backup.reg"
reg export 'HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy' $tempRegFile /y
Write-Host "防火墙注册表已导出到: $tempRegFile"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Name Root
---- ----
HKCR HKEY_CLASSES_ROOT
HKCU HKEY_CURRENT_USER
HKLM HKEY_LOCAL_MACHINE
HKU HKEY_USERS

系统版本: Windows 11 Pro
当前版本号: 24H2
构建号: 26100

已设置 HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\fDenyTSConnections = 0
已设置 HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp\UserAuthentication = 1

.NET 版本: .NET Framework 4.8 (Release=528040)

防火墙注册表已导出到: C:\Users\ADMINI~1\AppData\Local\Temp\firewall_backup.reg

安全基线审计

在企业环境中,安全基线审计是确保系统合规的关键环节。以下脚本检查 UAC(用户账户控制)、远程桌面策略和防火墙规则等核心安全设置,并生成审计报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
function Invoke-RegistrySecurityAudit {
$auditResults = @()

# 定义安全基线检查项
$baselineChecks = @(
@{
Name = 'UAC - 管理员批准模式'
Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Key = 'EnableLUA'
Expected = 1
Severity = '高'
},
@{
Name = 'UAC - 提示提升时切换到安全桌面'
Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Key = 'PromptOnSecureDesktop'
Expected = 1
Severity = '高'
},
@{
Name = '远程桌面 - 空密码限制'
Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
Key = 'LimitBlankPasswordUse'
Expected = 1
Severity = '高'
},
@{
Name = '远程桌面 - NLA 要求'
Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
Key = 'UserAuthentication'
Expected = 1
Severity = '中'
},
@{
Name = '防火墙 - 域配置文件启用状态'
Path = 'HKLM:\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\DomainProfile'
Key = 'EnableFirewall'
Expected = 1
Severity = '高'
},
@{
Name = '防火墙 - 标准配置文件启用状态'
Path = 'HKLM:\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\StandardProfile'
Key = 'EnableFirewall'
Expected = 1
Severity = '高'
},
@{
Name = '审核策略 - 对象访问审计'
Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
Key = 'AuditBaseObjects'
Expected = 1
Severity = '中'
},
@{
Name = 'WinRM - 允许远程服务器管理'
Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WinRM\Service'
Key = 'AllowAutoConfig'
Expected = 1
Severity = '低'
}
)

foreach ($check in $baselineChecks) {
$actual = $null
$status = '跳过'

if (Test-Path $check.Path) {
$actual = (Get-ItemProperty -Path $check.Path -Name $check.Key -ErrorAction SilentlyContinue).($check.Key)
if ($null -ne $actual) {
$status = if ($actual -eq $check.Expected) { '通过' } else { '不合规' }
} else {
$status = '未配置'
}
} else {
$status = '路径不存在'
}

$auditResults += [PSCustomObject]@{
检查项 = $check.Name
预期值 = $check.Expected
实际值 = if ($null -ne $actual) { $actual } else { 'N/A' }
状态 = $status
严重级别 = $check.Severity
}
}

# 输出审计报告
Write-Host "`n========== 注册表安全基线审计报告 =========="
Write-Host "审计时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "计算机名: $env:COMPUTERNAME"
Write-Host "==============================================`n"

$auditResults | Format-Table -AutoSize

# 统计摘要
$compliant = ($auditResults | Where-Object { $_.状态 -eq '通过' }).Count
$total = $auditResults.Count
$nonCompliant = $auditResults | Where-Object { $_.状态 -eq '不合规' }

Write-Host "合规项: $compliant / $total"

if ($nonCompliant) {
Write-Host "`n不合规项详情:" -ForegroundColor Red
foreach ($item in $nonCompliant) {
Write-Host " [$($item.严重级别)] $($item.检查项): 预期=$($item.预期值), 实际=$($item.实际值)"
}
}

return $auditResults
}

Invoke-RegistrySecurityAudit

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
========== 注册表安全基线审计报告 ==========
审计时间: 2026-02-04 10:30:15
计算机名: SERVER01
==============================================

检查项 预期值 实际值 状态 严重级别
------ ------ ------ ---- --------
UAC - 管理员批准模式 1 1 通过 高
UAC - 提示提升时切换到安全桌面 1 1 通过 高
远程桌面 - 空密码限制 1 1 通过 高
远程桌面 - NLA 要求 1 0 不合规 中
防火墙 - 域配置文件启用状态 1 1 通过 高
防火墙 - 标准配置文件启用状态 1 1 通过 高
审核策略 - 对象访问审计 1 0 不合规 中
WinRM - 允许远程服务器管理 1 N/A 未配置 低

合规项: 5 / 8

不合规项详情:
[中] 远程桌面 - NLA 要求: 预期=1, 实际=0
[中] 审核策略 - 对象访问审计: 预期=1, 实际=0

注册表监控与变更追踪

在安全运维中,需要监控关键注册表项的变更,并管理其访问权限。以下脚本实现了 ACL 管理、变更检测和基线对比功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
function Protect-RegistryKey {
param(
[Parameter(Mandatory)]
[string]$Path,

[ValidateSet('Read', 'Write', 'FullControl')]
[string]$Permission = 'Read',

[string]$Account = 'BUILTIN\Users'
)

if (-not (Test-Path $Path)) {
Write-Warning "注册表路径不存在: $Path"
return
}

# 获取当前 ACL
$acl = Get-Acl -Path $Path

# 定义权限映射
$rightsMap = @{
Read = 'ReadKey'
Write = 'WriteKey'
FullControl = 'FullControl'
}

$rule = New-Object System.Security.AccessControl.RegistryAccessRule(
$Account,
$rightsMap[$Permission],
'ContainerInherit,ObjectInherit',
'None',
'Allow'
)

$acl.AddAccessRule($rule)
Set-Acl -Path $Path -AclObject $acl
Write-Host "已为 $Account 授予 $Permission 权限: $Path"
}

function New-RegistryBaseline {
param(
[string[]]$Paths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
'HKLM:\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy'
),
[string]$OutputPath = "$env:TEMP\registry_baseline.json"
)

$baseline = @{
CreatedAt = Get-Date -Format 'o'
Computer = $env:COMPUTERNAME
Entries = @{}
}

foreach ($path in $Paths) {
if (-not (Test-Path $path)) { continue }

$props = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
$entry = @{}

# 获取所有属性(排除默认的 PS* 属性)
$props.PSObject.Properties |
Where-Object { $_.Name -notmatch '^PS' } |
ForEach-Object {
$entry[$_.Name] = $_.Value
}

$baseline.Entries[$path] = $entry
}

$baseline | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8
Write-Host "基线已保存到: $OutputPath"
return $OutputPath
}

function Compare-RegistryBaseline {
param(
[Parameter(Mandatory)]
[string]$BaselineFile
)

if (-not (Test-Path $BaselineFile)) {
Write-Error "基线文件不存在: $BaselineFile"
return
}

$baseline = Get-Content -Path $BaselineFile -Raw | ConvertFrom-Json
Write-Host "`n========== 注册表变更检测报告 =========="
Write-Host "基线创建时间: $($baseline.CreatedAt)"
Write-Host "检查时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "========================================`n"

$changes = @()

foreach ($path in $baseline.Entries.PSObject.Properties.Name) {
if (-not (Test-Path $path)) {
$changes += [PSCustomObject]@{
路径 = $path
变更类型 = '已删除'
属性 = 'N/A'
原值 = 'N/A'
新值 = 'N/A'
}
continue
}

$current = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
$savedProps = $baseline.Entries.$path

foreach ($prop in $savedProps.PSObject.Properties) {
$currentValue = $current.($prop.Name)
$savedValue = $prop.Value

if ($null -ne $currentValue -and "$currentValue" -ne "$savedValue") {
$changes += [PSCustomObject]@{
路径 = $path
变更类型 = '已修改'
属性 = $prop.Name
原值 = $savedValue
新值 = $currentValue
}
}
}
}

if ($changes.Count -eq 0) {
Write-Host "未检测到任何变更,所有配置与基线一致。" -ForegroundColor Green
} else {
Write-Host "检测到 $($changes.Count) 处变更:" -ForegroundColor Yellow
$changes | Format-Table -AutoSize
}

return $changes
}

# 使用示例:创建基线并检测变更
$baselineFile = New-RegistryBaseline
Compare-RegistryBaseline -BaselineFile $baselineFile

# 限制 Users 组对关键注册表项的写入权限
Protect-RegistryKey -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Permission 'Read' -Account 'BUILTIN\Users'

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
基线已保存到: C:\Users\ADMINI~1\AppData\Local\Temp\registry_baseline.json

========== 注册表变更检测报告 ==========
基线创建时间: 2026-02-04T10:30:15.0000000+08:00
检查时间: 2026-02-04 14:22:08
========================================

检测到 2 处变更:

路径 变更类型 属性 原值 新值
---- -------- ---- ---- ----
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System 已修改 EnableLUA 1 0
HKLM:\SYSTEM\CurrentControlSet\Control\Lsa 已修改 LimitBlankPasswordUse 1 0

已为 BUILTIN\Users 授予 Read 权限: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System

注意事项

  1. 务必备份后再修改:在生产环境修改注册表前,务必使用 reg export 导出相关键值或创建系统还原点,以便在出现问题时快速恢复。

  2. 使用 -WhatIf 参数Set-ItemPropertyRemove-Item 等危险操作建议先加上 -WhatIf 参数预演,确认影响范围后再正式执行。

  3. 以管理员权限运行HKLM: 下的大部分操作需要以管理员身份运行 PowerShell,否则会遇到”拒绝访问”错误。可以使用 #Requires -RunAsAdministrator 预声明。

  4. 注意 32/64 位重定向:在 64 位系统上,部分注册表路径存在重定向机制(如 Wow6432Node),操作时需注意是否需要指定 -PSPath 'Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\...' 来绕过重定向。

  5. 远程操作需启用 WinRM:对远程计算机执行注册表操作时,目标机器需要启用 WinRM 服务,并且防火墙放行相应端口。可使用 Invoke-Command 配合 -ComputerName 参数。

  6. 审计脚本定期运行:建议将安全基线审计脚本配置为 Windows 计划任务或通过 CI/CD 流水线定期执行,及时发现配置漂移。导出的 JSON 基线文件应纳入版本控制系统进行追踪。

PowerShell 技能连载 - Azure DevOps 自动化

适用于 PowerShell 7.0 及以上版本

在现代软件交付体系中,Azure DevOps 已经成为许多团队的核心协作平台。它集成了代码仓库、CI/CD 流水线、工作项追踪和制品管理等功能,为端到端的 DevOps 实践提供了完整支撑。然而随着团队规模扩大和项目数量增多,仅依靠 Web 界面进行日常管理变得低效——批量创建项目、统一配置流水线、定期生成进度报告等场景迫切需要自动化手段。

PowerShell 凭借对 REST API 的原生支持和强大的对象处理能力,是自动化管理 Azure DevOps 的理想工具。通过脚本调用 Azure DevOps REST API,我们可以将重复性的管理操作编排成可重复执行的工作流,例如一键同步多个项目的仓库配置、自动触发全量回归测试流水线、定时生成冲刺健康度报告。

本文将从实际运维场景出发,介绍如何使用 PowerShell 实现 Azure DevOps 的三大类自动化操作:项目与仓库的批量管理、CI/CD 流水线的触发与监控、工作项的查询与报告生成。每个场景都提供可直接运行的完整脚本和执行结果演示。

Azure DevOps API 连接与项目管理

所有 Azure DevOps 自动化的基础是与 REST API 建立可靠的连接。以下代码封装了一个通用的 API 调用函数,并在此基础上实现项目的批量查询和仓库操作。我们将 Personal Access Token(PAT)存储在环境变量中,通过 Base64 编码构造认证头,确保凭据不会以明文形式出现在脚本中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
function Invoke-AzDoRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Organization,

[string]$Project,

[Parameter(Mandatory)]
[string]$ApiPath,

[Parameter(Mandatory)]
[securestring]$PatToken,

[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
[string]$Method = 'GET',

[object]$Body,

[string]$ApiVersion = '7.1'
)

# 将 PAT 转换为 Basic Auth 格式
$plainPat = (New-Object PSCredential('user', $PatToken)).GetNetworkCredential().Password
$base64Auth = [Convert]::ToBase64String(
[System.Text.Encoding]::ASCII.GetBytes(":$plainPat")
)

$headers = @{
Authorization = "Basic $base64Auth"
'Content-Type' = 'application/json'
}

# 构建请求 URI
$uri = if ($Project) {
"https://dev.azure.com/$Organization/$Project/_apis$ApiPath"
} else {
"https://dev.azure.com/$Organization/_apis$ApiPath"
}

# 追加 api-version 参数
$separator = if ($uri -match '\?') { '&' } else { '?' }
$uri = "$uri$separator`api-version=$ApiVersion"

$params = @{
Uri = $uri
Headers = $headers
Method = $Method
}

if ($Body) {
$params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
}

try {
Invoke-RestMethod @params
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
$errorReader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
$errorBody = $errorReader.ReadToEnd()
Write-Error "API 请求失败 [HTTP $statusCode]: $errorBody"
}
}

# 从环境变量获取 PAT
$pat = ConvertTo-SecureString $env:AZDO_PAT -AsPlainText -Force

# 查询组织下所有项目
$allProjects = Invoke-AzDoRequest `
-Organization 'mycompany' `
-ApiPath '/projects' `
-PatToken $pat

Write-Host "组织内的项目列表:"
Write-Host ("-" * 60)

foreach ($proj in $allProjects.value) {
$lastUpdate = [datetime]::Parse($proj.lastUpdateTime).ToString('yyyy-MM-dd')
Write-Host " 名称: $($proj.name)"
Write-Host " 状态: $($proj.state) | 上次更新: $lastUpdate"
Write-Host " ID: $($proj.id)"
Write-Host ""
}

# 查询指定项目的所有仓库
$repos = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/git/repositories' `
-PatToken $pat

Write-Host "PlatformService 项目的仓库:"
foreach ($repo in $repos.value) {
$branch = $repo.defaultBranch -replace 'refs/heads/', ''
Write-Host " [$($repo.name)] 默认分支: $branch, 大小: $([math]::Round($repo.size / 1MB, 1)) MB"
}

上述代码定义了 Invoke-AzDoRequest 函数,支持组织级和项目级的 API 调用。函数自动根据 URI 是否已包含查询参数来拼接 api-version,避免重复的 ? 符号。错误处理部分捕获 HTTP 异常并解析响应体,便于排查 API 调用失败的原因。通过该函数可以方便地查询项目列表和仓库信息,为后续的批量管理操作奠定基础。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
组织内的项目列表:
------------------------------------------------------------
名称: PlatformService
状态: wellFormed | 上次更新: 2026-01-28
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

名称: DataPipeline
状态: wellFormed | 上次更新: 2026-01-15
ID: b2c3d4e5-f6a7-8901-bcde-f12345678901

名称: MobileApp
状态: wellFormed | 上次更新: 2025-12-20
ID: c3d4e5f6-a7b8-9012-cdef-123456789012

PlatformService 项目的仓库:
[PlatformService.Api] 默认分支: main, 大小: 24.3 MB
[PlatformService.Web] 默认分支: main, 大小: 18.7 MB
[PlatformService.Infra] 默认分支: develop, 大小: 3.2 MB

流水线管理:触发构建与下载制品

CI/CD 流水线是 Azure DevOps 中最核心的自动化能力之一。在日常运维中,我们经常需要通过脚本触发流水线(例如数据迁移完成后触发部署)、查询构建状态(集成到监控看板),以及下载构建制品(用于自动化测试或灰度发布)。以下代码演示了这三个常见操作的完整实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# --- 1. 触发流水线构建 ---

$runPayload = @{
resources = @{
repositories = @{
self = @{
refName = 'refs/heads/main'
}
}
}
templateParameters = @{
environment = 'staging'
enableSmokeTest = 'true'
tagVersion = '2.4.1-rc.3'
}
}

$newRun = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/pipelines/42/runs' `
-Method POST `
-PatToken $pat `
-Body $runPayload

Write-Host "已触发流水线运行:"
Write-Host " 运行 ID: $($newRun.id)"
Write-Host " 流水线: $($newRun.pipeline.name)"
Write-Host " 状态: $($newRun.state)"
Write-Host ""

# --- 2. 轮询构建状态直到完成 ---

$runId = $newRun.id
$maxWaitMinutes = 30
$startTime = Get-Date

while (((Get-Date) - $startTime).TotalMinutes -lt $maxWaitMinutes) {
Start-Sleep -Seconds 20

$runStatus = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/pipelines/42/runs/$runId" `
-PatToken $pat

$elapsed = ((Get-Date) - $startTime).ToString('mm\:ss')
Write-Host "[$elapsed] 运行 #$runId 状态: $($runStatus.state)"

if ($runStatus.state -in 'completed', 'cancelling', 'cancelled') {
Write-Host ""
Write-Host "流水线运行结束:"
Write-Host " 最终结果: $($runStatus.result)"
Write-Host " 完成时间: $($runStatus.finishedAt)"
break
}
}

# --- 3. 下载构建制品 ---

if ($runStatus.result -eq 'succeeded') {
$artifacts = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/build/builds/$($newRun.id)/artifacts" `
-PatToken $pat

$downloadDir = Join-Path $HOME "Downloads/AzDo-Artifacts/$($newRun.id)"
New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null

foreach ($artifact in $artifacts.value) {
$downloadUrl = $artifact.resource.downloadUrl
$fileName = "$($artifact.name).zip"
$savePath = Join-Path $downloadDir $fileName

Write-Host "正在下载制品: $($artifact.name) -> $savePath"
Invoke-WebRequest -Uri $downloadUrl -Headers @{
Authorization = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$plainPat")))"
} -OutFile $savePath
}

Write-Host ""
Write-Host "所有制品已下载至: $downloadDir"
Get-ChildItem $downloadDir | ForEach-Object {
Write-Host " $($_.Name) ($([math]::Round($_.Length / 1KB, 1)) KB)"
}
}

这段脚本按照实际运维流程编排了三个步骤:首先触发流水线并传入模板参数(目标环境、是否执行冒烟测试、版本标签),然后以 20 秒间隔轮询运行状态直到完成,最后仅在构建成功时下载所有制品到本地。轮询部分使用时间差而非固定次数,避免长时间运行的流水线被提前终止。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
已触发流水线运行:
运行 ID: 1847
流水线: PlatformService-CI
状态: inProgress

[00:20] 运行 #1847 状态: inProgress
[00:40] 运行 #1847 状态: inProgress
[01:00] 运行 #1847 状态: inProgress
[01:20] 运行 #1847 状态: inProgress
[01:40] 运行 #1847 状态: completed

流水线运行结束:
最终结果: succeeded
完成时间: 2026-02-03T08:23:45.123Z
正在下载制品: drop -> /Users/wubo/Downloads/AzDo-Artifacts/1847/drop.zip
正在下载制品: testResults -> /Users/wubo/Downloads/AzDo-Artifacts/1847/testResults.zip

所有制品已下载至: /Users/wubo/Downloads/AzDo-Artifacts/1847
drop.zip (12456.3 KB)
testResults.zip (3287.5 KB)

工作项查询与冲刺报告生成

Azure DevOps Boards 的工作项数据是团队进度和质量的直接反映。定期从 Boards 中提取数据并生成报告,可以帮助团队及时发现阻塞、评估交付速率、辅助 Sprint 回顾。以下代码使用 WIQL(Work Item Query Language)查询当前冲刺的工作项,并生成一份结构化的文本报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# --- 1. 查询当前冲刺的活跃工作项 ---

$wiqlQuery = @{
query = @"
SELECT [System.Id], [System.WorkItemType], [System.Title],
[System.State], [System.AssignedTo],
[Microsoft.VSTS.Scheduling.StoryPoints],
[Microsoft.VSTS.Common.Priority]
FROM WorkItems
WHERE [System.IterationPath] = @currentIteration()
AND [System.State] NOT IN ('Closed', 'Removed')
AND [System.WorkItemType] IN ('User Story', 'Bug', 'Task')
ORDER BY [Microsoft.VSTS.Common.Priority] ASC
"@
}

$queryResult = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/wit/wiql' `
-Method POST `
-PatToken $pat `
-Body $wiqlQuery

Write-Host "查询到 $($queryResult.workItems.Count) 个活跃工作项"
Write-Host ""

# --- 2. 批量获取工作项详情 ---

$allIds = $queryResult.workItems | Select-Object -ExpandProperty id
$workItems = @()

# API 限制单次最多查询 200 个工作项
$idBatches = $allIds | Group-Object -Property { [math]::Floor([array]::IndexOf($allIds, $_) / 200) }

foreach ($batch in $idBatches) {
$idList = ($batch.Group | ForEach-Object { [int]$_ }) -join ','
$details = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/wit/workitems?ids=$idList&`$expand=fields" `
-PatToken $pat
$workItems += $details.value
}

# --- 3. 生成冲刺报告 ---

Write-Host "=" * 70
Write-Host " PlatformService - Sprint 进度报告"
Write-Host " 生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "=" * 70
Write-Host ""

# 按类型分组统计
$byType = $workItems | Group-Object { $_.fields.'System.WorkItemType' }

Write-Host "[按类型统计]"
foreach ($group in $byType) {
$totalPoints = ($group.Group | ForEach-Object {
$_.fields.'Microsoft.VSTS.Scheduling.StoryPoints'
} | Where-Object { $_ } | Measure-Object -Sum).Sum

$pointsStr = if ($totalPoints) { " | 故事点: $totalPoints" } else { '' }
Write-Host " $($group.Name): $($group.Count) 个$pointsStr"
}
Write-Host ""

# 按状态分组统计
$byState = $workItems | Group-Object { $_.fields.'System.State' }

Write-Host "[按状态统计]"
foreach ($group in $byState) {
$pct = [math]::Round($group.Count / $workItems.Count * 100, 1)
$bar = '#' * [math]::Floor($pct / 5)
Write-Host " $($group.Name): $($group.Count) 个 ($pct%) $bar"
}
Write-Host ""

# 按负责人分组统计
$byAssignee = $workItems | Group-Object {
$assigned = $_.fields.'System.AssignedTo'
if ($assigned) { $assigned.displayName } else { '未分配' }
} | Sort-Object Count -Descending

Write-Host "[按负责人统计]"
foreach ($group in $byAssignee) {
Write-Host " $($group.Name): $($group.Count) 个"
foreach ($item in $group.Group) {
$title = $_.fields.'System.Title'
Write-Host " - #$($item.id) $($item.fields.'System.Title')"
}
}
Write-Host ""

# 阻塞项警告
$blockedItems = $workItems | Where-Object {
$_.fields.'System.State' -eq 'Blocked' -or
$_.fields.'System.Tags' -match 'blocked'
}

if ($blockedItems) {
Write-Host "[警告] 存在 $($blockedItems.Count) 个阻塞项:"
foreach ($item in $blockedItems) {
Write-Host " !! #$($item.id) $($item.fields.'System.Title')"
}
}

Write-Host ""
Write-Host "=" * 70
Write-Host " 报告结束"
Write-Host "=" * 70

这段代码使用 WIQL 查询当前冲刺中所有活跃的工作项,然后通过批量接口获取详细字段。报告生成部分按工作项类型、状态、负责人三个维度进行分组统计,并高亮显示被阻塞的工作项。这种脚本非常适合在 Sprint Standup 会议前自动运行,或通过 CI/CD 定时任务发送到团队频道。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
查询到 24 个活跃工作项

======================================================================
PlatformService - Sprint 进度报告
生成时间: 2026-02-03 08:30:15
======================================================================

[按类型统计]
User Story: 8 个 | 故事点: 21
Bug: 6
Task: 10

[按状态统计]
Active: 12 个 (50%) ##############
New: 7 个 (29.2%) ######
Resolved: 3 个 (12.5%) ###
Blocked: 2 个 (8.3%) ##

[按负责人统计]
张三: 8
- #1856 用户注册接口性能优化
- #1861 修复订单列表分页异常
李四: 7
- #1858 实现批量导出功能
- #1863 消息推送服务重构
王五: 5
- #1859 日志采集模块升级
未分配: 4
- #1865 接入新版支付网关

[警告] 存在 2 个阻塞项:
!! #1860 第三方认证服务证书过期
!! #1867 等待上游团队提供接口文档

======================================================================
报告结束
======================================================================

注意事项

  • PAT 权限范围:创建 PAT 时需根据脚本操作范围选择最小权限集。管理项目需要 Project (Read & Write),操作流水线需要 Build (Read & Execute),查询工作项需要 Work Items (Read)。避免使用全权限 PAT,降低凭据泄露后的影响范围。
  • API 版本兼容性:示例中使用 api-version=7.1。Azure DevOps REST API 有 v5.x、v6.x、v7.x 多个主版本,部分接口在不同版本间行为有差异(如流水线 Runs API 在 7.0 后引入了 templateParameters 字段)。生产脚本务必锁定版本号并做兼容性测试。
  • 分页与批量限制:查询类接口(如项目列表、工作项查询)默认有分页限制,通常单次返回 100-1000 条。需要检查响应中的 continuationToken 字段并循环获取完整数据。工作项批量查询单次上限为 200 个 ID,大批量场景需要自行分批。
  • 并发与速率控制:Azure DevOps 对同一组织的 API 调用有速率限制(个人用户约每分钟 600 次)。批量操作时建议使用 Start-Sleep 添加间隔,或使用 PowerShell 的 ForEach-Object -Parallel 配合计数器实现受控并发。
  • 错误重试策略:网络抖动和临时限流会导致偶发的 5xx 或 429 响应。建议封装通用的重试逻辑,对 429 状态码读取 Retry-After 响应头确定等待时间,对 5xx 状态码采用指数退避策略,最多重试 3 次。
  • 敏感信息脱敏:冲刺报告可能包含员工姓名、工作项内容等信息。如果报告需要发送到外部渠道(如邮件、Slack),注意对 System.AssignedTo 等字段做脱敏处理,或将报告输出到 Azure DevOps 内部的 Wiki 页面,保持访问权限的一致性。

PowerShell 技能连载 - 春节假期自动化值守

适用于 PowerShell 5.1 及以上版本

春节长假是万家团圆的时刻,但对于 IT 运维团队来说,系统不会因为放假而停止运行。服务器、数据库、网络设备依然需要有人关注,而值班人员往往捉襟见肘——用最少的人力覆盖最长的假期,成为每年春节前的经典难题。

传统做法是安排轮班表,让值班人员定时登录系统查看状态。这种方式不仅效率低下,而且容易因为人为疏忽而遗漏关键告警。更理想的做法是构建一套自动化值守系统,让脚本替人完成日常巡检、故障处理和告警推送,值班人员只需要在真正出现异常时介入。

PowerShell 凭借其对 Windows 和 Linux(通过 PowerShell Core)的广泛支持、丰富的远程管理能力以及与 .NET 的深度融合,非常适合承担这个角色。本文将从监控系统、自动修复、告警通知三个方面,手把手搭建一个春节假期自动化值守方案。

假期值守监控系统

首先需要一套主动巡检机制,定期检查服务器的关键指标。以下脚本实现了磁盘空间、CPU 利用率和内存使用率的阈值检测,并将结果汇总为结构化对象,便于后续处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
function Start-HolidayWatch {
param(
[string[]]$ComputerName = $env:COMPUTERNAME,
[double]$DiskThresholdPercent = 10,
[double]$CpuThresholdPercent = 90,
[double]$MemoryThresholdPercent = 90,
[pscredential]$Credential
)

$results = foreach ($computer in $ComputerName) {
$params = @{
ComputerName = $computer
ErrorAction = 'Stop'
}
if ($Credential) { $params.Credential = $Credential }

try {
# 获取操作系统信息
$os = Get-CimInstance Win32_OperatingSystem @params
$freeMemoryPercent = [math]::Round(
($os.FreePhysicalMemory / $os.TotalVisibleMemorySize) * 100, 2
)
$usedMemoryPercent = [math]::Round(100 - $freeMemoryPercent, 2)

# 获取磁盘信息
$disks = Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' @params
$diskAlerts = foreach ($disk in $disks) {
$freePercent = [math]::Round(($disk.FreeSpace / $disk.Size) * 100, 2)
if ($freePercent -lt $DiskThresholdPercent) {
[PSCustomObject]@{
Drive = $disk.DeviceID
FreePercent = $freePercent
FreeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
Status = 'WARNING'
}
}
}

# 获取 CPU 使用率(采样 2 秒)
$cpu = Get-CimInstance Win32_Processor @params
$cpuPercent = [math]::Round(
($cpu | Measure-Object -Property LoadPercentage -Average).Average, 2
)

[PSCustomObject]@{
Computer = $computer
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
CPUPercent = $cpuPercent
MemoryPercent = $usedMemoryPercent
DiskAlerts = $diskAlerts
CPUStatus = if ($cpuPercent -gt $CpuThresholdPercent) { 'WARNING' } else { 'OK' }
MemoryStatus = if ($usedMemoryPercent -gt $MemoryThresholdPercent) { 'WARNING' } else { 'OK' }
OverallStatus = if ($cpuPercent -gt $CpuThresholdPercent -or
$usedMemoryPercent -gt $MemoryThresholdPercent -or $diskAlerts) {
'ALERT'
} else { 'OK' }
}
}
catch {
[PSCustomObject]@{
Computer = $computer
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
OverallStatus = 'ERROR'
ErrorMessage = $_.Exception.Message
}
}
}

# 汇总报告
$alertCount = ($results | Where-Object OverallStatus -eq 'ALERT').Count
$errorCount = ($results | Where-Object OverallStatus -eq 'ERROR').Count

[PSCustomObject]@{
ScanTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
TotalHosts = $ComputerName.Count
Alerts = $alertCount
Errors = $errorCount
Details = $results
}
}

# 执行监控——可以配合计划任务每 15 分钟运行一次
$watchParams = @{
ComputerName = 'SRV-WEB01', 'SRV-DB01', 'SRV-APP01'
DiskThresholdPercent = 15
CpuThresholdPercent = 85
MemoryThresholdPercent = 90
}
$report = Start-HolidayWatch @watchParams
$report.Details | Format-Table Computer, CPUPercent, MemoryPercent, CPUStatus, MemoryStatus, OverallStatus -AutoSize

执行结果示例:

1
2
3
4
5
Computer  CPUPercent MemoryPercent CPUStatus MemoryStatus OverallStatus
-------- ---------- ------------- --------- ------------ -------------
SRV-WEB01 12.5 62.30 OK OK OK
SRV-DB01 45.8 78.15 OK OK OK
SRV-APP01 91.2 92.50 WARNING WARNING ALERT

自动修复与应急响应

监控只是第一步,更高级的玩法是让系统自动处理常见故障。下面的脚本展示了服务自动重启、日志清理和磁盘空间释放三种应急操作,每种操作都有日志记录和回滚机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
function Invoke-AutoRemediation {
param(
[string]$ComputerName = $env:COMPUTERNAME,
[string[]]$CriticalServices = @('W3SVC', 'MSSQLSERVER', 'WinRM'),
[double]$LogCleanupThresholdGB = 2,
[string]$LogPath = 'C:\Logs',
[string]$TranscriptPath = 'C:\HolidayWatch\Remediation.log'
)

# 记录所有操作
Start-Transcript -Path $TranscriptPath -Append -Force
$actions = @()

foreach ($svc in $CriticalServices) {
try {
$service = Get-Service -Name $svc -ComputerName $ComputerName -ErrorAction Stop
if ($service.Status -ne 'Running') {
Write-Warning "服务 [$svc] 状态异常: $($service.Status),尝试启动..."
$service | Start-Service -ErrorAction Stop
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'RestartService'
Target = $svc
Result = 'SUCCESS'
}
Write-Host "服务 [$svc] 已成功启动" -ForegroundColor Green
}
}
catch {
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'RestartService'
Target = $svc
Result = "FAILED: $($_.Exception.Message)"
}
Write-Error "服务 [$svc] 启动失败: $($_.Exception.Message)"
}
}

# 清理旧日志文件
if (Test-Path $LogPath) {
$oldLogs = Get-ChildItem -Path $LogPath -Recurse -File |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) }

$totalSize = ($oldLogs | Measure-Object -Property Length -Sum).Sum / 1GB
if ($totalSize -gt $LogCleanupThresholdGB) {
Write-Warning "日志目录占用 $([math]::Round($totalSize, 2)) GB,超过阈值,开始清理..."
$oldLogs | Remove-Item -Force -ErrorAction SilentlyContinue
$freed = [math]::Round($totalSize, 2)
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'CleanLogs'
Target = $LogPath
Result = "SUCCESS - 释放 ${freed} GB"
}
}
}

# 磁盘空间应急释放:清理临时文件和回收站
$systemDrive = Get-CimInstance Win32_LogicalDisk -Filter 'DeviceID="C:"'
$freePercent = [math]::Round(($systemDrive.FreeSpace / $systemDrive.Size) * 100, 2)

if ($freePercent -lt 15) {
Write-Warning "C 盘剩余空间仅 $freePercent%,执行应急清理..."
$tempPaths = @(
"$env:TEMP\*",
'C:\Windows\Temp\*',
'C:\Windows\SoftwareDistribution\Download\*'
)
foreach ($path in $tempPaths) {
Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
}
# 清理回收站
Clear-RecycleBin -DriveLetter C -Force -ErrorAction SilentlyContinue

$newFree = [math]::Round(
((Get-CimInstance Win32_LogicalDisk -Filter 'DeviceID="C:"').FreeSpace /
(Get-CimInstance Win32_LogicalDisk -Filter 'DeviceID="C:"').Size) * 100, 2
)
$actions += [PSCustomObject]@{
Time = Get-Date -Format 'HH:mm:ss'
Action = 'EmergencyDiskCleanup'
Target = 'C:'
Result = "释放空间: $freePercent% -> $newFree%"
}
}

Stop-Transcript
return $actions
}

# 检测到告警后自动触发修复
$report = Start-HolidayWatch -ComputerName 'SRV-APP01'
if ($report.Details.OverallStatus -eq 'ALERT') {
Write-Host "检测到告警,启动自动修复流程..." -ForegroundColor Yellow
$remediation = Invoke-AutoRemediation -ComputerName 'SRV-APP01'
$remediation | Format-Table Time, Action, Target, Result -AutoSize
}

执行结果示例:

1
2
3
4
5
6
检测到告警,启动自动修复流程...
Time Action Target Result
---- ------ ------ ------
14:32 RestartService W3SVC SUCCESS
14:33 CleanLogs C:\Logs SUCCESS - 释放 3.45 GB
14:34 EmergencyDiskCleanup C: 释放空间: 11.20% -> 18.65%

告警通知与值班管理

监控系统发现问题、自动修复尝试完毕后,需要及时通知值班人员。以下脚本集成了邮件通知、企业微信 Webhook 和钉钉 Webhook 三种告警通道,并支持值班轮换和告警升级机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
function Send-HolidayAlert {
param(
[Parameter(Mandatory)]
[string]$Title,

[Parameter(Mandatory)]
[string]$Message,

[ValidateSet('Mail', 'WeCom', 'DingTalk', 'All')]
[string]$Channel = 'All',

# 邮件参数
[string]$SmtpServer = 'smtp.company.com',
[int]$SmtpPort = 587,
[string]$MailFrom = 'ops-holiday@company.com',
[string[]]$MailTo = @('oncall@company.com'),

# Webhook URL
[string]$WeComWebhookUrl,
[string]$DingTalkWebhookUrl,

# 告警级别
[ValidateSet('Info', 'Warning', 'Critical')]
[string]$Severity = 'Warning'
)

$severityEmoji = @{
Info = '[INFO]'
Warning = '[WARN]'
Critical = '[CRIT]'
}
$prefix = $severityEmoji[$Severity]
$body = "$prefix $Title`n`n$Message`n`n时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

# 邮件通知
if ($Channel -in 'Mail', 'All') {
try {
Send-MailMessage -From $MailFrom -To $MailTo -Subject "$prefix $Title" `
-Body $body -SmtpServer $SmtpServer -Port $SmtpPort -Encoding UTF8
Write-Host "邮件告警已发送至: $($MailTo -join ', ')" -ForegroundColor Green
}
catch {
Write-Warning "邮件发送失败: $($_.Exception.Message)"
}
}

# 企业微信通知
if ($Channel -in 'WeCom', 'All' -and $WeComWebhookUrl) {
$wecomBody = @{
msgtype = 'text'
text = @{ content = $body }
} | ConvertTo-Json -Compress

try {
Invoke-RestMethod -Uri $WeComWebhookUrl -Method Post `
-Body $wecomBody -ContentType 'application/json' | Out-Null
Write-Host '企业微信告警已发送' -ForegroundColor Green
}
catch {
Write-Warning "企业微信发送失败: $($_.Exception.Message)"
}
}

# 钉钉通知
if ($Channel -in 'DingTalk', 'All' -and $DingTalkWebhookUrl) {
$dingBody = @{
msgtype = 'text'
text = @{ content = $body }
} | ConvertTo-Json -Compress

try {
Invoke-RestMethod -Uri $DingTalkWebhookUrl -Method Post `
-Body $dingBody -ContentType 'application/json' | Out-Null
Write-Host '钉钉告警已发送' -ForegroundColor Green
}
catch {
Write-Warning "钉钉发送失败: $($_.Exception.Message)"
}
}
}

function Get-CurrentOnCall {
param(
[hashtable]$Schedule = @{
'2026-02-08' = '张三', '2026-02-09' = '张三'
'2026-02-10' = '李四', '2026-02-11' = '李四'
'2026-02-12' = '王五', '2026-02-13' = '王五'
'2026-02-14' = '张三'
},
[hashtable]$Contacts = @{
'张三' = @{ Email = 'zhangsan@company.com'; Phone = '138-0001-0001' }
'李四' = @{ Email = 'lisi@company.com'; Phone = '138-0002-0002' }
'王五' = @{ Email = 'wangwu@company.com'; Phone = '138-0003-0003' }
}
)

$today = Get-Date -Format 'yyyy-MM-dd'
$person = $Schedule[$today]
if (-not $person) {
# 如果当天没有排班,查找最近的一天
$nearest = $Schedule.Keys | Sort-Object | Where-Object { $_ -le $today } | Select-Object -Last 1
$person = $Schedule[$nearest]
}

[PSCustomObject]@{
Date = $today
OnCall = $person
Email = $Contacts[$person].Email
Phone = $Contacts[$person].Phone
}
}

# 告警升级:Critical 级别同时通知值班人员和运维经理
$onCall = Get-CurrentOnCall
$alertParams = @{
Title = 'SRV-APP01 CPU 使用率持续超过 90%'
Message = "服务器 SRV-APP01 CPU 使用率 91.2%,内存使用率 92.5%。`n自动修复已尝试重启服务。`n当前值班: $($onCall.OnCall) ($($onCall.Phone))"
Channel = 'All'
Severity = 'Critical'
MailTo = @($onCall.Email, 'ops-manager@company.com')
}
Send-HolidayAlert @alertParams

执行结果示例:

1
2
3
4
5
6
7
邮件告警已发送至: zhangsan@company.com, ops-manager@company.com
企业微信告警已发送
钉钉告警已发送

Date OnCall Email Phone
---- ------ ----- -----
2026-02-08 张三 zhangsan@company.com 138-0001-0001

完整部署建议

将以上三个模块整合后,可以创建一个计划任务,在假期期间每 15 分钟自动执行一轮巡检。核心逻辑如下:

1
2
3
4
5
# Deploy-HolidayWatch.ps1 - 注册计划任务
$action = New-ScheduledTaskAction -Execute 'pwsh.exe' -Argument '-File "C:\HolidayWatch\Run-Watch.ps1"'
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15)
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName 'HolidayWatch-2026' -Action $action -Trigger $trigger -Settings $settings -RunLevel Highest -Description '春节假期自动化值守任务'

注意事项

  1. 凭据安全:远程管理使用的凭据应存储在 Windows 凭据管理器或 Azure Key Vault 中,切勿以明文形式写在脚本里。可以使用 Get-Credential 交互式获取,或通过 Export-Clixml 加密存储。
  2. 网络可达性:确保执行脚本的跳板机与目标服务器之间网络畅通,WinRM(端口 5985/5986)或 SSH 已正确配置并允许远程连接。
  3. 告警风暴防护:设置告警冷却时间(如同一告警 30 分钟内不重复发送),避免因瞬时抖动产生大量重复通知淹没值班人员。
  4. 日志持久化:所有巡检和修复操作必须记录到持久化日志文件,建议同时写入本地文件和集中日志平台(如 ELK),以便假期结束后复盘。
  5. 自动修复边界:自动修复只应处理已知的安全操作(如重启服务、清理临时文件),切勿让脚本自动执行删除数据库、重启服务器等高风险操作。
  6. 节前演练:在放假前至少进行一次完整的端到端演练,包括模拟告警触发、自动修复执行、通知送达,确保每个环节都能正常工作。

PowerShell 技能连载 - 静态代码分析

适用于 PowerShell 5.1 及以上版本

编写 PowerShell 脚本时,很多人只关注”能不能跑通”,却忽略了代码的可读性、安全性和可维护性。变量命名不规范、使用了已弃用的 cmdlet、硬编码凭据等问题,在脚本量少时不容易暴露,但当团队协作或代码库膨胀到数百个脚本时,技术债务会迅速累积。

PSScriptAnalyzer 是 PowerShell 官方提供的静态代码分析工具,基于 .NET Compiler Platform 构建。它内置了数十条规则,覆盖代码风格、潜在 Bug、安全风险和性能问题等多个维度。每条规则都标注了严重级别(Error、Warning、Information),方便团队根据自身需求配置合适的检查策略。

本文将从 PSScriptAnalyzer 的基础使用入手,讲解如何开发自定义规则以满足团队编码规范,最后演示如何将它集成到 CI/CD 流水线中实现自动化的代码质量门控。

PSScriptAnalyzer 基础:安装与规则扫描

PSScriptAnalyzer 作为 PowerShell Gallery 上的模块发布,安装和更新都很便捷。安装完成后,核心命令 Invoke-ScriptAnalyzer 可以对单个文件或整个目录执行规则扫描,并支持按严重级别、规则名称进行过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 安装 PSScriptAnalyzer 模块
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser

# 查看所有可用的内置规则
Get-ScriptAnalyzerRule | Select-Object RuleName, Severity, Description |
Format-Table -Wrap

# 创建一个示例脚本用于测试
$testScript = @'
# BadScript.ps1 - 包含多种典型问题
$var = Get-ChildItem
foreach ($item in $var) {
if ($item.Length -gt 100kb) {
echo "$($item.Name) is large"
}
}
Write-Host "Done"
'@

$testScript | Set-Content -Path './BadScript.ps1' -Encoding UTF8

# 对示例脚本执行全面分析
$results = Invoke-ScriptAnalyzer -Path './BadScript.ps1'

# 按严重级别分组查看结果
$results | Group-Object Severity | Format-Table Name, Count

# 只显示 Warning 及以上级别的诊断信息
$results | Where-Object { $_.Severity -in 'Error', 'Warning' } |
Select-Object Severity, Line, RuleName, Message |
Format-Table -AutoSize

# 以详细格式输出(适合代码审查)
$results | Format-List Severity, RuleName, Line, Column, Message
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
RuleName                            Severity Description
-------- -------- -----------
PSAvoidDefaultValueForMandatoryParameter Warning ...
PSAvoidUsingWriteHost Warning ...
PSUseShouldProcessForStateChangingFunctions Warning ...
PSUseApprovedVerbs Warning ...
PSUseSingularNouns Information ...
...

Name Count
---- -----
Warning 3
Information 2

Severity RuleName Line Message
-------- -------- ---- -------
Warning PSAvoidUsingWriteHost 8 File 'BadScript.ps1' rule ...
Warning PSAvoidUsingCmdletAliases 5 ...
Warning PSUseApprovedVerbs 3 ...

Severity : Warning
RuleName : PSAvoidUsingWriteHost
Line : 8
Column : 1
Message : File 'BadScript.ps1' rule PSAvoidUsingWriteHost was not...

从输出可以看到,PSScriptAnalyzer 对示例脚本检测出了多处问题:使用了 Write-Host(应改用 Write-Output)、使用了 echo 别名(应使用完整 cmdlet 名称 Write-Output)等。Get-ScriptAnalyzerRule 可以列出所有内置规则,在配置团队规范时非常有用。通过 Where-Object 过滤严重级别,可以让代码审查聚焦在高优先级问题上。

自定义规则:打造团队专属编码规范

内置规则覆盖了通用场景,但每个团队往往有自己的编码约定——比如函数注释必须包含作者和日期、变量名必须使用 PascalCase、禁用特定的 cmdlet 等。PSScriptAnalyzer 支持通过编写 PowerShell 类来开发自定义规则,规则可以打包为模块供团队共享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# --- 文件: CustomRules/AvoidGlobalVariable.psm1 ---
# 自定义规则:禁止使用全局变量

using namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic

class AvoidGlobalVariable : IRule {
[string] GetSeverity() { return 'Warning' }
[string] GetName() { return 'AvoidGlobalVariable' }
[string] GetCommonName() { return '避免使用全局变量' }
[string] GetDescription() { return '全局变量会增加代码耦合度,建议使用参数传递或模块作用域变量' }
[string] GetSourceName() { return 'MyTeamCustomRules' }
[int] GetSourceVersion() { return 1 }

[System.Collections.Generic.IEnumerable[DiagnosticRecord]] GetViolation(
[System.Management.Automation.Language.Ast]$ast,
[string]$filePath
) {
$violations = [System.Collections.Generic.List[DiagnosticRecord]]::new()

# 查找所有 VariableExpressionAst,检查是否以 $global: 开头
$variableAsts = $ast.FindAll({
param($node)
$node -is [System.Management.Automation.Language.VariableExpressionAst]
}, $true)

foreach ($varAst in $variableAsts) {
$varName = $varAst.VariablePath.UserPath
if ($varAst.VariablePath.IsGlobal -or $varName -match '^global:') {
$record = [DiagnosticRecord]::new(
"检测到全局变量 `$$varName,建议使用参数或模块作用域变量替代",
$varAst.Extent,
$this.GetName(),
$this.GetSeverity(),
$filePath
)
$violations.Add($record)
}
}
return $violations
}
}

# 导出规则类
Export-ModuleMember -Function ([System.Management.Automation.Language.Ast]]::new) -Variable ''

上面是自定义规则的源码。接下来演示如何使用自定义规则,以及如何创建团队共享的规则集配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 使用自定义规则扫描脚本
$customRulePath = './CustomRules/AvoidGlobalVariable.psm1'
Invoke-ScriptAnalyzer -Path './BadScript.ps1' -CustomRulePath $customRulePath

# 创建团队规则集配置(profile 定义启用/禁用哪些规则)
$profileContent = @{
IncludeRules = @(
'PSAvoidUsingWriteHost'
'PSAvoidUsingCmdletAliases'
'PSUseApprovedVerbs'
'PSUseDeclaredVarsMoreThanAssignments'
'AvoidGlobalVariable' # 自定义规则
)
ExcludeRules = @(
'PSUseSingularNouns' # 团队允许复数名词
)
Rules = @{
PSAvoidUsingCmdletAliases = @{
Enable = $true
Allowlist = @('select', 'where', 'sort', 'group') # 允许的别名
}
PSAvoidUsingWriteHost = @{
Enable = $true
}
}
}

$profileContent | ConvertTo-Json -Depth 5 |
Set-Content -Path './TeamScriptAnalyzerProfile.json' -Encoding UTF8

# 使用团队规则集执行分析
Invoke-ScriptAnalyzer -Path './src/' -Recurse `
-Profile './TeamScriptAnalyzerProfile.json' `
-CustomRulePath './CustomRules/' |
Select-Object Severity, RuleName, Line, Message |
Format-Table -AutoSize

# 统计违规数量,用于 CI 质量门控
$allResults = Invoke-ScriptAnalyzer -Path './src/' -Recurse `
-Profile './TeamScriptAnalyzerProfile.json'
$errorCount = ($allResults | Where-Object Severity -eq 'Error').Count
$warningCount = ($allResults | Where-Object Severity -eq 'Warning').Count
Write-Output "扫描完成: $errorCount 个错误, $warningCount 个警告"
1
2
3
4
5
6
Severity RuleName               Line Message
-------- -------- ---- -------
Warning AvoidGlobalVariable 3 检测到全局变量 $global:Config,建议使用参数或模块作用域变量替代
Warning PSAvoidUsingWriteHost 8 ...

扫描完成: 0 个错误, 5 个警告

自定义规则通过实现 IRule 接口的 PowerShell 类来编写,核心逻辑在 GetViolation 方法中遍历 AST 节点进行匹配。规则集配置文件(Profile)以 JSON 格式定义,可以精确控制启用哪些规则、禁用哪些规则以及规则参数。这种方式让团队能够将编码规范版本化管理,新成员只需引用同一份 Profile 即可保持代码风格一致。

CI/CD 集成:自动化代码质量门控

将 PSScriptAnalyzer 集成到 CI/CD 流水线中,可以在代码合并前自动拦截质量问题。以下示例分别展示 GitHub Actions 和 Azure DevOps 的配置方式,并实现基于违规计数的质量门控。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# --- 文件: scripts/Invoke-QualityGate.ps1 ---
# CI/CD 质量门控脚本,供流水线调用

param(
[string]$SourcePath = './src/',
[string]$ProfilePath = './TeamScriptAnalyzerProfile.json',
[string]$CustomRulesPath = './CustomRules/',
[int]$MaxErrors = 0,
[int]$MaxWarnings = 10
)

# 执行分析
$splat = @{
Path = $SourcePath
Recurse = $true
Severity = @('Error', 'Warning')
}

if (Test-Path $ProfilePath) {
$splat['Profile'] = $ProfilePath
}
if (Test-Path $CustomRulesPath) {
$splat['CustomRulePath'] = $CustomRulesPath
}

$results = Invoke-ScriptAnalyzer @splat

# 统计并报告
$errorCount = ($results | Where-Object Severity -eq 'Error').Count
$warningCount = ($results | Where-Object Severity -eq 'Warning').Count

Write-Output "=== PSScriptAnalyzer 质量门控报告 ==="
Write-Output "扫描路径: $SourcePath"
Write-Output "错误数量: $errorCount (阈值: $MaxErrors)"
Write-Output "警告数量: $warningCount (阈值: $MaxWarnings)"
Write-Output ""

if ($results) {
$results | Select-Object Severity, RuleName, @{N='File';E={
Split-Path $_.ScriptPath -Leaf}}, Line, Message |
Format-Table -AutoSize
}

# 质量门控判断
$gatePassed = $true
if ($errorCount -gt $MaxErrors) {
Write-Output "质量门控失败: 错误数 $errorCount 超过阈值 $MaxErrors"
$gatePassed = $false
}
if ($warningCount -gt $MaxWarnings) {
Write-Output "质量门控失败: 警告数 $warningCount 超过阈值 $MaxWarnings"
$gatePassed = $false
}

if ($gatePassed) {
Write-Output "质量门控通过"
exit 0
} else {
Write-Output "质量门控未通过"
exit 1
}

接下来是 GitHub Actions 的工作流配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# --- 文件: .github/workflows/powershell-lint.yml ---
name: PowerShell Script Analysis

on:
push:
paths:
- '**.ps1'
- '**.psm1'
pull_request:
paths:
- '**.ps1'
- '**.psm1'

jobs:
analyze:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- name: 安装 PSScriptAnalyzer
shell: pwsh
run: Install-Module PSScriptAnalyzer -Force -Scope CurrentUser

- name: 执行代码质量分析
shell: pwsh
run: |
$results = Invoke-ScriptAnalyzer -Path './src/' -Recurse `
-Profile './TeamScriptAnalyzerProfile.json' `
-Severity Error, Warning

# 输出 GitHub Actions 注解(在 PR 中直接显示问题位置)
foreach ($r in $results) {
$level = $r.Severity.ToString().ToLower()
$file = $r.ScriptPath
$line = $r.Line
Write-Output "::$level file=$file,line=$line::$($r.RuleName): $($r.Message)"
}

# 质量门控
$errors = ($results | Where-Object Severity -eq 'Error').Count
if ($errors -gt 0) {
Write-Output "发现 $errors 个错误,阻止合并"
exit 1
}

- name: 上传分析报告
if: always()
uses: actions/upload-artifact@v4
with:
name: analysis-report
path: ./analysis-report.json
1
2
3
4
5
6
7
8
9
10
11
12
=== PSScriptAnalyzer 质量门控报告 ===
扫描路径: ./src/
错误数量: 0 (阈值: 0)
警告数量: 3 (阈值: 10)

Severity RuleName File Line Message
-------- -------- --- ---- -------
Warning PSAvoidUsingWriteHost Deploy.ps1 15 File 'Deploy.ps1' ...
Warning PSAvoidUsingCmdletAliases Utils.ps1 22 ...
Warning AvoidGlobalVariable Config.ps1 5 检测到全局变量 ...

质量门控通过

CI/CD 集成的核心思路是:用 Invoke-QualityGate.ps1 统一封装分析逻辑和质量门控判断,流水线只需调用该脚本并根据退出码决定是否继续。GitHub Actions 配置中使用了 ::error::warning 注解语法,分析结果会直接显示在 PR 的 Files Changed 标签页中,审查者无需切换工具即可定位问题。Azure DevOps 也有类似的 Logging Command 机制(##vso[task.logissue]),原理相通。

注意事项

  1. 规则配置应渐进式引入:不要一次性启用所有规则并设为 Error 级别,这会让存量代码库瞬间产生大量失败。建议先以 Warning 级别启用核心规则,逐步修复存量问题后再提高门控标准。

  2. 自定义规则需要充分测试:自定义规则基于 AST 解析实现,边界情况较多(如字符串内含变量名、嵌套作用域等)。建议为每条自定义规则编写单元测试,使用 Invoke-ScriptAnalyzer 验证误报和漏报率。

  3. 性能考量:PSScriptAnalyzer 对大文件或大量文件的分析可能较慢。在 CI 中可以只扫描变更文件(结合 git diff 获取文件列表),或设置超时阈值,避免分析环节阻塞流水线。

  4. 规则集版本化管理:团队规则集配置文件(Profile)应纳入 Git 仓库管理,与代码一同进行版本控制。规则变更应通过 PR 审查,确保团队成员达成共识。

  5. 与编辑器集成提升体验:VS Code 的 PowerShell 扩展内置了 PSScriptAnalyzer 支持,可以在编写代码时实时显示问题标记。开发者在本地修复大部分问题后再提交,减少 CI 反复失败的次数。

  6. 注意 PowerShell 版本差异:部分规则的行为在不同 PowerShell 版本中可能不同。例如 PSUseShouldProcessForStateChangingFunctions 规则的判断逻辑在 PowerShell 7 中有所增强。建议 CI 环境使用与生产环境一致的 PowerShell 版本执行分析。

PowerShell 技能连载 - 预测智能补全

适用于 PowerShell 7.0 及以上版本

在命令行环境中,重复输入相似的命令是日常工作的常态。无论是启动服务、查询日志还是部署应用,许多命令只是参数略有不同。PSReadLine 2.1 引入的预测智能补全(Predictive IntelliSense)改变了这一局面——它会在你输入时实时展示匹配的历史命令或插件建议,让你可以用一次按键完成整行输入。

预测补全的核心思想是「从过去学习」。默认情况下,它会从命令历史中提取与当前输入前缀匹配的记录,以灰色内联文本的形式显示在光标后方。如果你安装了额外的预测源插件(比如基于 AI 的补全模块),它还能根据语义理解推荐从未执行过的新命令。这种机制让命令行的效率大幅提升,尤其是面对长路径、复杂参数的场景。

本文将从三个层面展开:首先配置 PSReadLine 的预测功能并选择合适的视图模式;然后编写一个自定义预测插件,实现基于文件系统路径的智能建议;最后分享一套完整的快捷键方案,帮助你在日常工作中充分释放预测补全的潜力。

PSReadLine 预测补全配置

预测智能补全需要 PSReadLine 2.1 或更高版本。以下脚本检查当前版本、启用预测功能,并切换到列表视图模式——该模式会在命令行下方展示多条候选建议,比内联视图更适合从多个选项中挑选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 检查 PSReadLine 版本
$module = Get-Module PSReadLine -ListAvailable |
Sort-Object Version -Descending |
Select-Object -First 1
Write-Host "PSReadLine 版本: $($module.Version)"

if ($module.Version -lt [version]'2.1.0') {
Write-Host "需要升级 PSReadLine,正在安装..." -ForegroundColor Yellow
Install-Module PSReadLine -Force -SkipPublisherCheck
Write-Host "安装完成,请重启 PowerShell。" -ForegroundColor Green
return
}

# 启用预测智能补全
Set-PSReadLineOption -PredictiveSource History

# 设置视图模式:Inline(内联)或 ListView(列表)
Set-PSReadLineOption -PredictiveStyle ListView

# 配置历史记录保存条数(默认有限,建议调大)
Set-PSReadLineOption -MaximumHistoryCount 10000

# 历史记录去重:忽略连续重复的命令
Set-PSReadLineOption -HistoryNoDuplicates $true

# 历史记录保存路径(确保跨会话持久化)
$historyPath = Join-Path $env:USERPROFILE '.ps_history'
Set-PSReadLineOption -HistorySavePath $historyPath

# 验证配置
$options = Get-PSReadLineOption
Write-Host "`n当前预测配置:" -ForegroundColor Cyan
Write-Host " 预测源: $($options.PredictiveSource)"
Write-Host " 视图模式: $($options.PredictiveStyle)"
Write-Host " 历史上限: $($options.MaximumHistoryCount)"
Write-Host " 去重开关: $($options.HistoryNoDuplicates)"
Write-Host " 保存路径: $($options.HistorySavePath)"
1
2
3
4
5
6
7
8
PSReadLine 版本: 2.4.2

当前预测配置:
预测源: History
视图模式: ListView
历史上限: 10000
去重开关: True
保存路径: C:\Users\admin\.ps_history

配置完成后,每次在命令行输入内容时,下方会自动弹出匹配的历史命令列表。使用 上箭头下箭头 可以在候选列表中导航,按 右箭头End 接受选中的建议。

自定义预测插件

除了历史记录,PSReadLine 还支持通过 ICommandPrediction 接口注册自定义预测源。下面的示例实现了一个基于文件系统路径的预测插件——当你输入类似 cdcode 并加空格时,它会根据当前目录下的子文件夹给出补全建议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
using namespace System.Management.Automation.Subsystem.Prediction

# 定义预测插件类
class FileSystemPredictor : ICommandPrediction {
[string] $Name = 'FileSystem'
[string] $Description = '基于文件系统路径的命令预测'

# 唯一标识
[Guid] GetId() {
return [Guid]::new('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
}

# 核心预测逻辑
[void] GetSuggestion(
[PredictionClient] $client,
[PredictionContext] $context,
[System.Threading.CancellationToken] $cancellationToken
) {
$inputText = $context.InputAst.Extent.Text
$results = [System.Collections.Generic.List[PredictiveSuggestion]]::new()

# 检测路径型命令前缀
$pathCommands = @('cd ', 'Set-Location ', 'code ', 'nvim ', 'vim ')
$matched = $false
foreach ($cmd in $pathCommands) {
if ($inputText.StartsWith($cmd, [StringComparison]::OrdinalIgnoreCase)) {
$matched = $true
$partial = $inputText.Substring($cmd.Length).Trim()

# 获取当前目录下的匹配子目录
$searchPath = if ($partial) {
Join-Path (Get-Location) "$partial*"
} else {
Join-Path (Get-Location) '*'
}

$dirs = Get-Item -Path $searchPath -Directory -ErrorAction SilentlyContinue |
Select-Object -First 5

foreach ($dir in $dirs) {
$suggestion = "$cmd$($dir.Name)"
$results.Add([PredictiveSuggestion]::new($suggestion))
}
break
}
}

# 未匹配路径命令时,提供常用命令模板
if (-not $matched -and $inputText.Length -ge 2) {
$templates = @(
'Get-ChildItem -Recurse -Filter "*.ps1"'
'Select-Object -First 10'
'Where-Object { $_.Length -gt 1MB }'
'ForEach-Object { $_.FullName }'
)
foreach ($t in $templates) {
if ($t.StartsWith($inputText, [StringComparison]::OrdinalIgnoreCase)) {
$results.Add([PredictiveSuggestion]::new($t))
}
}
}

if ($results.Count -gt 0) {
$client.Report($results)
}
}
}

# 注册插件
$predictor = [FileSystemPredictor]::new()
$subsystem = [System.Management.Automation.Subsystem.SubsystemManager]::
GetSubsystem([ICommandPrediction])
$subsystem.Register($predictor.GetId(), $predictor)

# 同时启用历史和插件两个预测源
Set-PSReadLineOption -PredictiveSource HistoryAndPlugin

Write-Host "文件系统预测插件已注册。" -ForegroundColor Green
1
文件系统预测插件已注册。

注册后,当你在命令行输入 cd 并加空格时,预测列表不仅包含历史命令,还会展示当前目录下的子文件夹建议。两种预测源的结果会合并展示,帮助你更快找到目标命令。

高效命令行工作流

预测补全要发挥最大价值,离不开合理的快捷键绑定和搜索习惯。以下脚本将一组常用操作绑定到顺手的热键上,并演示如何优化历史搜索和多行编辑体验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# ===== 快捷键自定义 =====

# Ctrl+f:接受当前预测建议(比右箭头更顺手)
Set-PSReadLineKeyHandler -Chord 'Ctrl+f' -Function ForwardWord

# Ctrl+d:删除光标后的单词(与 Bash 一致)
Set-PSReadLineKeyHandler -Chord 'Ctrl+d' -Function DeleteChar

# Tab 补全改为菜单式选择(配合预测更直观)
Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete

# 上/下箭头:在预测列表中导航时,同时搜索历史
Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward
Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward

# Ctrl+Shift+o:打开预测列表的详细视图(需要 ListView 模式)
Set-PSReadLineKeyHandler -Chord 'Ctrl+Shift+o' -Function AcceptSuggestion

# ===== 历史搜索优化 =====

# 启用基于前缀的历史搜索(输入部分命令再按上箭头)
Set-PSReadLineOption -HistorySearchCursorMovesToEnd $true

# 增加补全工具提示的显示时长(毫秒)
Set-PSReadLineOption -CommandValidationHandler $null

# ===== 将配置持久化到 Profile =====

$profileContent = @'
# PSReadLine 预测补全配置
Import-Module PSReadLine
Set-PSReadLineOption -PredictiveSource HistoryAndPlugin
Set-PSReadLineOption -PredictiveStyle ListView
Set-PSReadLineOption -MaximumHistoryCount 10000
Set-PSReadLineOption -HistoryNoDuplicates $true
Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete
Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward
Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward
'@

# 写入当前用户的 Profile
$profileDir = Split-Path $PROFILE
if (-not (Test-Path $profileDir)) {
New-Item -ItemType Directory -Path $profileDir -Force | Out-Null
}
Add-Content -Path $PROFILE -Value $profileContent -Encoding UTF8
Write-Host "配置已追加到 Profile: $PROFILE" -ForegroundColor Green

# 查看当前所有快捷键绑定(筛选自定义的)
Get-PSReadLineKeyHandler | Where-Object {
$_.Key -in @('Tab', 'UpArrow', 'DownArrow')
} | Format-Table -Property Key, Function, Description -AutoSize
1
2
3
4
5
6
7
配置已追加到 Profile: C:\Users\admin\Documents\PowerShell\Microsoft.PowerShell_profile.ps1

Key Function Description
--- -------- -----------
Tab MenuComplete 补全当前项或在可用选项间循环
UpArrow HistorySearchBackward 搜索以当前输入为前缀的历史命令
DownArrow HistorySearchForward 搜索以当前输入为前缀的下一条历史命令

配置持久化后,每次打开 PowerShell 都会自动加载这些设置。你可以根据自己的使用习惯调整快捷键映射,比如将 Ctrl+Space 绑定为切换预测视图的开关,或用 Ctrl+r 实现增量历史搜索。

注意事项

  1. 版本要求:预测智能补全需要 PSReadLine 2.1+ 和 PowerShell 7.0+。Windows PowerShell 5.1 自带的 PSReadLine 版本过低,需要手动升级,且部分功能(如 ListView)在旧版控制台宿主中不可用。

  2. 控制台兼容性:ListView 模式需要 Windows Terminal、VS Code 终端或 iTerm2 等现代终端模拟器。传统的 conhost.exe(Windows 自带命令提示符窗口)仅支持 Inline 模式。

  3. 插件注册时机:自定义预测插件必须在 Profile 加载阶段注册,否则每次打开新会话都需要手动执行注册代码。建议将注册逻辑封装在模块的 OnImport 中。

  4. 性能影响:预测源如果返回过多结果或查询耗时过长,会导致输入卡顿。建议每个预测源将结果限制在 10 条以内,并在耗时操作中加入 CancellationToken 检查。

  5. 历史记录安全:包含敏感信息(密码、Token)的命令会被写入历史文件。可以通过 Set-PSReadLineOption -AddToHistoryHandler 自定义过滤逻辑,阻止特定模式的命令进入历史。

  6. 多预测源优先级:当同时启用 HistoryAndPlugin 时,PSReadLine 会合并所有预测源的结果,但不会保证特定顺序。如果需要控制优先级,可以在插件的 GetSuggestion 方法中根据上下文动态调整返回内容的数量。

PowerShell 技能连载 - Windows Defender 管理

适用于 PowerShell 5.1 及以上版本

在企业终端安全管理中,Windows Defender 已从简单的防病毒工具演变为完整的端点保护平台(EPP)。随着 Microsoft Defender for Endpoint 的持续升级,管理员需要一种可重复、可自动化的方式来统一部署安全策略。PowerShell 的 Defender 模块为此提供了全面的命令行支持。

传统上,管理员依赖组策略(GPO)或 Microsoft Endpoint Manager(Intune)来配置 Defender。但在混合环境中——尤其是需要快速响应安全事件、批量修复配置偏移或在新上线设备上应用基线时——PowerShell 脚本的灵活性和即时执行能力是 GUI 工具无法替代的。结合 DSC(Desired State Configuration)或 Ansible 等配置管理工具,还能实现持续合规。

本文将从三个维度介绍如何通过 PowerShell 管理 Windows Defender:基础配置与扫描管理、排除规则与高级策略、威胁响应与合规报告。每个部分都包含可直接用于生产环境的脚本示例。

Defender 配置与扫描管理

通过 Set-MpPreferenceGet-MpPreference 可以查看和修改 Defender 的核心配置。以下脚本展示了如何读取当前偏好设置、应用安全基线配置,以及触发不同类型的扫描。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 获取当前 Defender 偏好配置
$pref = Get-MpPreference
Write-Host "当前实时保护状态: $(Get-MpComputerStatus | Select-Object -ExpandProperty RealTimeProtectionEnabled)"
Write-Host "扫描计划: $($pref.ScanScheduleDay)"
Write-Host "快速扫描时间: $($pref.ScanScheduleTime)"

# 应用安全基线配置
$baselineParams = @{
DisableRealtimeMonitoring = $false
DisableBehaviorMonitoring = $false
DisableBlockAtFirstSeen = $false
DisableIOAVProtection = $false
DisableScriptScanning = $false
EnableNetworkProtection = 'Enabled'
EnableFileHashComputation = $true
PUAProtection = 'Enabled'
ScanScheduleDay = 0 # 每天扫描
ScanScheduleTime = 120 # 凌晨 02:00
ScanScheduleQuickScanTime = 720 # 中午 12:00
CheckForSignaturesBeforeRunningScan = $true
SignatureUpdateInterval = 4 # 每 4 小时更新一次
}
Set-MpPreference @baselineParams
Write-Host "安全基线配置已应用"

# 触发快速扫描
Write-Host "`n开始快速扫描..."
Start-MpScan -ScanType QuickScan

# 触发自定义路径扫描(针对高风险目录)
$highRiskPaths = @(
"$env:USERPROFILE\Downloads",
"$env:TEMP",
"$env:PUBLIC"
)
foreach ($path in $highRiskPaths) {
if (Test-Path $path) {
Write-Host "扫描目录: $path"
Start-MpScan -ScanType CustomScan -ScanPath $path
}
}

# 查看扫描历史记录
$scanHistory = Get-MpThreatDetection | Select-Object -First 10
$scanHistory | Format-Table -Property `
@{N='检测时间';E={$_.InitialDetectionTime}},
@{N='威胁名称';E={$_.ThreatName}},
@{N='资源';E={$_.Resources -join ', '}},
@{N='操作';E={$_.ActionSuccess}} -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
当前实时保护状态: True
扫描计划: 0
快速扫描时间: 720

安全基线配置已应用

开始快速扫描...

扫描目录: C:\Users\admin\Downloads
扫描目录: C:\Users\admin\AppData\Local\Temp
扫描目录: C:\Users\Public

检测时间 威胁名称 资源 操作
---------- ---------- ---- ----
2026/01/27 14:23:01 Trojan:Win32/Emotet.RPX!MTB C:\Users\admin\Downloads\report.exe True
2026/01/27 03:12:45 Adware:Win32/InstallCore C:\Temp\setup.exe True
2026/01/26 18:45:20 PUA:Win32/ByteFence C:\Program Files\ByteFence\... True

排除规则与高级策略

在企业环境中,某些业务应用程序会触发误报,需要配置排除规则。同时,攻击面减少(ASR)规则是现代 Defender 防御体系的重要组成部分。以下脚本展示了如何管理排除项和 ASR 规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 查看当前排除规则
$currentExclusions = Get-MpPreference
Write-Host "当前路径排除:"
$currentExclusions.ExclusionPath | ForEach-Object { Write-Host " - $_" }
Write-Host "当前进程排除:"
$currentExclusions.ExclusionProcess | ForEach-Object { Write-Host " - $_" }

# 配置路径排除(仅对已验证可信的业务目录)
$exclusionPaths = @(
'D:\BusinessApps\CoreERP',
'E:\DataWarehouse\Bin'
)
foreach ($path in $exclusionPaths) {
if (Test-Path $path) {
Add-MpPreference -ExclusionPath $path
Write-Host "已添加路径排除: $path"
} else {
Write-Warning "路径不存在,跳过排除: $path"
}
}

# 配置进程排除
$exclusionProcesses = @(
'erpservice.exe',
'dataworker.exe'
)
foreach ($proc in $exclusionProcesses) {
Add-MpPreference -ExclusionProcess $proc
Write-Host "已添加进程排除: $proc"
}

# 配置攻击面减少(ASR)规则
$asrRules = @{
# 阻止从 Windows 本地安全机构子系统 (lsass.exe) 窃取凭据
'9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2' = 'Enabled'
# 阻止 Office 应用程序创建可执行内容
'3b576869-a4ec-4529-8536-b80a7769e899' = 'Enabled'
# 阻止 JavaScript/VBScript 启动下载的可执行内容
'd3e037e1-3eb8-44c8-a917-57927947596d' = 'Enabled'
# 阻止通过 WMI 事件订阅进行持久化
'e6db77e5-3df2-4cf1-b95a-636979351e5b' = 'AuditMode'
# 配置针对勒索软件的高级保护
'c1db55ab-c21a-4637-bb3f-a8683d4c58c4' = 'Enabled'
}
foreach ($ruleId in $asrRules.Keys) {
Add-MpPreference -AttackSurfaceReductionRules_Ids $ruleId `
-AttackSurfaceReductionRules_Actions $asrRules[$ruleId]
$actionLabel = if ($asrRules[$ruleId] -eq 'AuditMode') { '审计模式' } else { '启用' }
Write-Host "ASR 规则 $ruleId -> $actionLabel"
}

# 验证 ASR 规则配置
$pref = Get-MpPreference
Write-Host "`n已配置 $(($pref.AttackSurfaceReductionRules_Ids).Count) 条 ASR 规则"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
当前路径排除:
- D:\BusinessApps\CoreERP
- E:\DataWarehouse\Bin
当前进程排除:
- erpservice.exe
- dataworker.exe
已添加路径排除: D:\BusinessApps\CoreERP
已添加路径排除: E:\DataWarehouse\Bin
已添加进程排除: erpservice.exe
已添加进程排除: dataworker.exe
ASR 规则 9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2 -> 启用
ASR 规则 3b576869-a4ec-4529-8536-b80a7769e899 -> 启用
ASR 规则 d3e037e1-3eb8-44c8-a917-57927947596d -> 启用
ASR 规则 e6db77e5-3df2-4cf1-b95a-636979351e5b -> 审计模式
ASR 规则 c1db55ab-c21a-4637-bb3f-a8683d4c58c4 -> 启用

已配置 5 条 ASR 规则

威胁响应与合规报告

当安全事件发生时,快速查询威胁状态、执行隔离或恢复操作至关重要。以下脚本展示了如何构建一套威胁响应与合规检查的工作流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 查询当前活跃威胁
$activeThreats = Get-MpThreat
if ($activeThreats) {
Write-Host "发现 $($activeThreats.Count) 个活跃威胁:" -ForegroundColor Red
$activeThreats | Format-Table -Property `
@{N='威胁ID';E={$_.ThreatID}},
@{N='名称';E={$_.ThreatName}},
@{N='严重级别';E={$_.SeverityID}},
@{N='类别';E={$_.CategoryID}},
@{N='状态';E={$_.ThreatStatus}} -AutoSize
} else {
Write-Host "未发现活跃威胁,系统安全。" -ForegroundColor Green
}

# 查看最近 7 天的威胁检测记录
$cutoffDate = (Get-Date).AddDays(-7)
$recentDetections = Get-MpThreatDetection | Where-Object {
[datetime]$_.InitialDetectionTime -ge $cutoffDate
}
Write-Host "`n最近 7 天检测到 $($recentDetections.Count) 个威胁"

# 获取已隔离威胁列表
$quarantined = Get-MpThreat | Where-Object { $_.ThreatStatus -eq 3 }
Write-Host "已隔离威胁数量: $($quarantined.Count)"

# 如需恢复误报的隔离文件(谨慎操作)
# $falsePositiveId = 'THREAT-ID-HERE'
# Remove-MpThreat -ThreatID $falsePositiveId

# 生成安全态势报告
$computerStatus = Get-MpComputerStatus
$signatureStatus = Get-MpPreference

$report = [PSCustomObject]@{
计算机名称 = $env:COMPUTERNAME
报告时间 = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Defender版本 = $computerStatus.AMServiceVersion
病毒库版本 = $computerStatus.AntivirusSignatureVersion
病毒库更新时间 = $computerStatus.AntivirusSignatureLastUpdated
实时保护 = $computerStatus.RealTimeProtectionEnabled
行为监控 = $computerStatus.BehaviorMonitorEnabled
网络保护 = $computerStatus.EnableNetworkProtection
IOAV保护 = $computerStatus.IoavProtectionEnabled
反间谍软件 = $computerStatus.AntispywareSignatureVersion
最近扫描时间 = $computerStatus.LastQuickScanEndTime
最近完整扫描 = $computerStatus.LastFullScanEndTime
}

Write-Host "`n========== 安全态势报告 =========="
$report | Format-List

# 导出报告为 CSV(适用于批量采集)
$report | Export-Csv -Path "C:\Reports\DefenderReport_$($env:COMPUTERNAME)_$(Get-Date -Format 'yyyyMMdd').csv" `
-NoTypeInformation -Encoding UTF8
Write-Host "报告已导出到 CSV 文件"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
未发现活跃威胁,系统安全。

最近 7 天检测到 3 个威胁
已隔离威胁数量: 0

========== 安全态势报告 ==========

计算机名称 : WKS-ADMIN-01
报告时间 : 2026-01-28 10:30:00
Defender版本 : 4.18.25010.9
病毒库版本 : 1.423.58.0
病毒库更新时间 : 2026/01/28 06:15:00
实时保护 : True
行为监控 : True
网络保护 : Enabled
IOAV保护 : True
反间谍软件 : 1.423.58.0
最近扫描时间 : 2026/01/28 02:00:15
最近完整扫描 : 2026/01/25 02:45:30

报告已导出到 CSV 文件

注意事项

  1. 管理员权限:所有 Defender 管理 cmdlet 都需要以管理员身份运行 PowerShell。普通用户只能执行 Get-MpPreference 等查询命令,无法修改配置。

  2. 排除规则最小化:每添加一条排除规则都会降低保护力度。建议定期审计排除列表,移除不再需要的排除项,并通过 Get-MpPreference 验证排除项的合理性。

  3. ASR 规则分阶段部署:新上线的 ASR 规则应先以 AuditMode 运行 1-2 周,收集日志确认无误报后,再切换为 Enabled 阻断模式,避免影响业务连续性。

  4. 病毒库更新频率:企业环境中建议将 SignatureUpdateInterval 设为 1-4 小时。零日威胁的签名通常在数小时内发布,过长的更新间隔会留下安全窗口。

  5. 兼容性检查Set-MpPreference 的部分参数在不同 Windows 版本上可用性不同。例如 EnableFileHashComputation 需要 Windows 10 1903+,PUAProtection 需要 Windows 10 1709+。部署前应在目标系统上验证参数是否生效。

  6. 与 Intune/GPO 的冲突:当 Intune 或组策略同时管理 Defender 设置时,PowerShell 的手动修改可能在下次策略刷新时被覆盖。建议在 DSC 或配置管理框架中统一管理,避免策略来源冲突导致配置偏移。

PowerShell 技能连载 - Azure Cosmos DB 操作

适用于 PowerShell 7.0 及以上版本

Azure Cosmos DB 是微软推出的全球分布式多模型数据库服务,原生支持 SQL API、MongoDB API、Cassandra API、Gremlin API 和 Table API 等多种数据模型。在全球化部署的微服务架构中,Cosmos DB 能够提供个位数毫秒级的读写延迟,并通过多区域写入实现 99.999% 的高可用性 SLA。无论是物联网场景的海量设备数据写入,还是电商平台的实时库存查询,Cosmos DB 都能胜任。

在混合云和多云管理场景下,运维团队通常需要同时管理多个 Cosmos DB 账户,涉及账户创建、一致性策略配置、多区域复制设置、吞吐量调整和备份恢复等操作。手动完成这些任务不仅耗时,而且容易因人为失误导致配置不一致。通过 PowerShell 脚本将这些操作标准化,可以实现一键部署、版本控制和审计追踪。

本文将介绍如何使用 PowerShell 和 Az 模块完成 Cosmos DB 的账户管理、容器操作以及数据备份恢复等常见任务,帮助你构建自动化的数据库运维流程。

Cosmos DB 账户与数据库管理

首先安装并导入必要的模块,然后创建 Cosmos DB 账户并配置一致性策略和多区域复制。以下脚本演示了从零开始创建一个带有会话一致性和双区域复制的 Cosmos DB 账户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 安装并导入 Az 模块
Install-Module -Name Az.CosmosDB -Force -Scope CurrentUser -Repository PSGallery
Import-Module Az.CosmosDB

# 连接到 Azure
Connect-AzAccount -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'

# 定义变量
$resourceGroupName = 'rg-cosmos-demo'
$location = 'eastasia'
$accountName = 'cosmos-psdemo-001'
$databaseName = 'BlogDatabase'

# 创建资源组
New-AzResourceGroup -Name $resourceGroupName -Location $location -Force

# 创建 Cosmos DB 账户(会话一致性 + 多区域写入)
$account = New-AzCosmosDBAccount `
-ResourceGroupName $resourceGroupName `
-Name $accountName `
-Location $location `
-DefaultConsistencyLevel 'Session' `
-EnableMultipleWriteLocations:$true `
-ApiKind 'Sql'

# 添加第二个复制区域(东南亚)
Update-AzCosmosDBAccount `
-ResourceGroupName $resourceGroupName `
-Name $accountName `
-Location $location `
-LocationObject @{ LocationName = 'southeastasia'; FailoverPriority = 1 }

# 创建数据库(手动吞吐量)
$database = New-AzCosmosDBSqlDatabase `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-Name $databaseName `
-Throughput 400

Write-Host "Cosmos DB 账户 '$accountName' 创建完成"
Write-Host "数据库 '$databaseName' 已就绪,吞吐量: 400 RU/s"

执行结果示例:

1
2
3
4
5
6
7
8
9
10
ResourceGroupName : rg-cosmos-demo
Location : eastasia
Name : cosmos-psdemo-001
Kind : GlobalDocumentDB
Tags : {}
DefaultConsistencyLevel : Session
EnableMultipleWriteLocations : True

Cosmos DB 账户 'cosmos-psdemo-001' 创建完成
数据库 'BlogDatabase' 已就绪,吞吐量: 400 RU/s

容器操作与吞吐量管理

创建容器时需要仔细设计分区键,它决定了数据的分布方式和查询性能。下面演示如何创建容器、配置自动扩缩吞吐量,以及查看容器指标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
$containerName = 'Posts'
$partitionKeyPath = '/categoryId'

# 创建容器(指定分区键和索引策略)
$indexingPolicy = @{
indexingMode = 'consistent'
includedPaths = @(
@{ path = '/title/?' },
@{ path = '/createdAt/?' },
@{ path = '/categoryId/?' }
)
excludedPaths = @(
@{ path = '/*' }
)
}

$container = New-AzCosmosDBSqlContainer `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-DatabaseName $databaseName `
-Name $containerName `
-PartitionKeyKind 'Hash' `
-PartitionKeyPath $partitionKeyPath `
-IndexingPolicyObject $indexingPolicy

Write-Host "容器 '$containerName' 创建完成,分区键: $partitionKeyPath"

# 启用自动扩缩吞吐量(400 - 4000 RU/s)
$throughput = Invoke-AzCosmosDBSqlContainerThroughputMigration `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-DatabaseName $databaseName `
-Name $containerName `
-ThroughputType Autoscale

$autoscaleSettings = Get-AzCosmosDBSqlContainerThroughput `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-DatabaseName $databaseName `
-Name $containerName

Write-Host "吞吐量模式: Autoscale"
Write-Host "最小 RU/s: $($autoscaleSettings.AutoscaleSettings.MinThroughput)"
Write-Host "最大 RU/s: $($autoscaleSettings.AutoscaleSettings.MaxThroughput)"

# 列出账户下所有容器
$containers = Get-AzCosmosDBSqlContainer `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-DatabaseName $databaseName

$containers | Format-Table Name, PartitionKey -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
容器 'Posts' 创建完成,分区键: /categoryId
吞吐量模式: Autoscale
最小 RU/s: 400
最大 RU/s: 4000

Name PartitionKey
---- ------------
Posts {/categoryId}

数据操作与备份恢复

通过 Cosmos DB 的 SQL API 可以直接在 PowerShell 中执行 CRUD 操作。结合定期备份和时间点恢复(PITR)功能,可以构建完整的数据保护方案。以下示例展示文档的增删改查以及备份策略的管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# 获取账户密钥用于数据平面操作
$keys = Get-AzCosmosDBAccountKey `
-ResourceGroupName $resourceGroupName `
-Name $accountName

$primaryKey = $keys[0].Value

# 构造 REST API 请求头
$databaseAccountUrl = "https://${accountName}.documents.azure.com"
$headers = @{
'x-ms-version' = '2020-07-15'
'Authorization' = $primaryKey
}

# 插入文档
$document = @{
id = 'post-001'
title = 'PowerShell Cosmos DB 指南'
categoryId = 'cat-powershell'
author = 'admin'
createdAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ssZ')
tags = @('powershell', 'azure', 'cosmosdb')
content = '这是一篇关于使用 PowerShell 管理 Cosmos DB 的文章。'
} | ConvertTo-Json -Depth 5

$body = @{
documents = @(
@{
id = 'post-001'
title = 'PowerShell Cosmos DB 指南'
categoryId = 'cat-powershell'
author = 'admin'
createdAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ssZ')
tags = @('powershell', 'azure', 'cosmosdb')
}
)
} | ConvertTo-Json -Depth 5

Write-Host "文档已准备: $($document.Length) 字节"

# 查看备份策略
$backupInfo = Get-AzCosmosDBAccount `
-ResourceGroupName $resourceGroupName `
-Name $accountName

Write-Host "备份类型: $($backupInfo.BackupPolicy.Type)"
Write-Host "备份间隔: $($backupInfo.BackupPolicy.PeriodicModeProperties.BackupIntervalInMinutes) 分钟"
Write-Host "备份保留: $($backupInfo.BackupPolicy.PeriodicModeProperties.BackupRetentionIntervalInHours) 小时"

# 启用连续备份(支持时间点恢复)
Enable-AzCosmosDBAccountContinuousBackup `
-ResourceGroupName $resourceGroupName `
-Name $accountName

Write-Host "已启用连续备份模式,支持 30 天内任意时间点恢复"

# 列出可恢复的数据库和容器
$restorableAccounts = Get-AzCosmosDBRestorableDatabaseAccount `
-Location $location `
-Name $accountName

Write-Host "可恢复账户数量: $($restorableAccounts.Count)"
Write-Host "最早可恢复时间: $($restorableAccounts[0].OldestRestorableTime)"

# 成本优化:检查低利用率容器
$allContainers = Get-AzCosmosDBSqlContainer `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-DatabaseName $databaseName

foreach ($c in $allContainers) {
$tp = Get-AzCosmosDBSqlContainerThroughput `
-ResourceGroupName $resourceGroupName `
-AccountName $accountName `
-DatabaseName $databaseName `
-Name $c.Name -ErrorAction SilentlyContinue

if ($tp) {
$ruInfo = "容器: $($c.Name) | 吞吐量: $($tp.Throughput) RU/s"
if ($tp.AutoscaleSettings) {
$ruInfo += " (自动扩缩: $($tp.AutoscaleSettings.MaxThroughput) RU/s 上限)"
}
Write-Host $ruInfo
}
}

执行结果示例:

1
2
3
4
5
6
7
8
文档已准备: 287 字节
备份类型: Periodic
备份间隔: 240 分钟
备份保留: 8 小时
已启用连续备份模式,支持 30 天内任意时间点恢复
可恢复账户数量: 1
最早可恢复时间: 2026-01-26T08:00:00Z
容器: Posts | 吞吐量: RU/s (自动扩缩: 4000 RU/s 上限)

注意事项

  1. 分区键设计至关重要:分区键一旦创建无法修改。选择基数高且分布均匀的属性作为分区键,避免”热分区”导致性能瓶颈。建议使用 categoryIdtenantId 等天然分散的字段。

  2. 吞吐量成本控制:Cosmos DB 按配置的 RU/s 计费,而非实际消耗。开发环境可使用无服务器(Serverless)模式按请求计费,生产环境建议启用自动扩缩以平衡性能与成本。

  3. 一致性级别权衡:从强到弱依次为 Strong、Bounded Staleness、Session、Consistent Prefix、Eventual。Session 是大多数场景的最佳选择,在保证读己之写的同时提供良好的性能。

  4. 备份策略选择:定期备份(Periodic)默认保留 8 小时,连续备份(Continuous)支持 30 天内任意时间点恢复(PITR),但连续备份会产生额外费用,建议仅对关键业务数据库启用。

  5. 多区域写入注意事项:启用多区域写入时,只支持 Session、Consistent Prefix 和 Eventual 三种一致性级别。同时需注意冲突解决策略的配置,避免数据不一致。

  6. 密钥安全与轮换:Cosmos DB 提供主密钥和资源令牌两种认证方式。生产环境建议使用 Azure Key Vault 存储连接字符串,并定期轮换主密钥(支持零停机轮换,新旧密钥可同时生效)。

PowerShell 技能连载 - 日志分析与取证

适用于 PowerShell 5.1 及以上版本

日志是系统运维和安全取证的基石。Windows 事件日志、IIS 日志、应用程序日志中隐藏着故障根因和安全威胁的关键线索。当系统出现异常行为或发生安全事件时,快速定位和分析日志数据是响应的第一步。

传统的日志分析往往依赖图形界面的事件查看器或第三方 SIEM 工具,但它们在面对大规模日志数据或需要自定义分析逻辑时显得力不从心。PowerShell 提供了 Get-WinEventGet-EventLog(旧版)以及强大的对象管道,让我们能够以脚本化的方式高效收集、过滤、关联和可视化日志数据。

本文将从三个层面展开:首先是 Windows 安全事件日志的审计分析,其次是跨日志源的关联与异常检测,最后是自动化取证报告的生成。掌握这些技能后,你可以在安全事件响应、合规审计和故障排查中大幅提升效率。

Windows 事件日志审计

Windows 安全日志是取证分析的首要数据源。以下脚本演示了如何批量提取登录失败事件、追踪特权使用情况,并按频率统计可疑账户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 定义分析时间范围
$StartTime = (Get-Date).AddDays(-7)
$EndTime = Get-Date

# 提取安全日志中的登录失败事件(Event ID 4625)
$LoginFailures = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4625
StartTime = $StartTime
EndTime = $EndTime
} -ErrorAction SilentlyContinue | ForEach-Object {
$xml = [xml]$_.ToXml()
$targetUser = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
$sourceIP = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'IpAddress' }).'#text'
$logonType = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'LogonType' }).'#text'

[PSCustomObject]@{
Time = $_.TimeCreated
User = $targetUser
SourceIP = $sourceIP
LogonType = $logonType
}
}

# 统计失败登录次数最多的账户
$LoginFailures | Group-Object User |
Sort-Object Count -Descending |
Select-Object Count, Name -First 10 |
Format-Table -AutoSize

# 提取特权使用事件(Event ID 4672)
$PrivilegeUse = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4672
StartTime = $StartTime
EndTime = $EndTime
} -ErrorAction SilentlyContinue | ForEach-Object {
$xml = [xml]$_.ToXml()
$subjectUser = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'SubjectUserName' }).'#text'
$privileges = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'PrivilegeList' }).'#text'

[PSCustomObject]@{
Time = $_.TimeCreated
Account = $subjectUser
Privileges = $privileges
}
}

# 输出特权使用摘要
$PrivilegeUse | Group-Object Account |
Select-Object Count, Name |
Sort-Object Count -Descending |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Count Name
----- ----
47 Administrator
12 backupsvc
8 testuser
3 jdoe
1 LOCAL SERVICE

Count Name
----- ----
156 Administrator
23 SYSTEM
8 backupsvc
2 NETWORK SERVICE

从结果可以看到 Administrator 账户在一周内有 47 次登录失败,值得进一步调查。同时特权使用频率也显示该账户活动异常频繁。

日志关联与异常检测

单一日志源往往无法呈现完整的安全图景。以下脚本展示如何将安全日志、系统日志和 PowerShell 脚本块日志进行关联,建立时间线并检测统计异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 收集多日志源数据并建立统一时间线
$TimeRange = @{
StartTime = (Get-Date).AddDays(-3)
EndTime = Get-Date
}

# 安全日志:账户管理变更(Event ID 4720 创建、4726 删除、4728 加入管理员组)
$AccountChanges = Get-WinEvent -FilterHashtable @{
LogName = 'Security'; Id = 4720, 4726, 4728; @TimeRange
} -ErrorAction SilentlyContinue | ForEach-Object {
$xml = [xml]$_.ToXml()
$targetAccount = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
[PSCustomObject]@{
Time = $_.TimeCreated
Source = 'Security'
Event = "AccountChange($($_.Id))"
Detail = $targetAccount
}
}

# 系统日志:服务安装和驱动加载
$SystemEvents = Get-WinEvent -FilterHashtable @{
LogName = 'System'; Id = 7045, 7036; @TimeRange
} -ErrorAction SilentlyContinue | ForEach-Object {
[PSCustomObject]@{
Time = $_.TimeCreated
Source = 'System'
Event = if ($_.Id -eq 7045) { 'NewService' } else { 'ServiceStateChange' }
Detail = $_.Message.Substring(0, [Math]::Min(80, $_.Message.Length))
}
}

# PowerShell 脚本块日志(Event ID 4104)
$PSLogs = Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'; Id = 4104; @TimeRange
} -ErrorAction SilentlyContinue | ForEach-Object {
[PSCustomObject]@{
Time = $_.TimeCreated
Source = 'PowerShell'
Event = 'ScriptBlock'
Detail = $_.Message.Substring(0, [Math]::Min(80, $_.Message.Length))
}
}

# 合并时间线并按时间排序
$Timeline = $AccountChanges + $SystemEvents + $PSLogs |
Sort-Object Time |
Select-Object Time, Source, Event, Detail

# 显示最近 20 条时间线事件
$Timeline | Select-Object -Last 20 | Format-Table -Wrap

# 统计异常检测:每小时事件数量,标记超过 2 个标准差的时间段
$HourlyStats = $Timeline | Group-Object { $_.Time.ToString('yyyy-MM-dd HH:00') } |
ForEach-Object { [PSCustomObject]@{ Hour = $_.Name; Count = $_.Count } }

$Mean = ($HourlyStats | Measure-Object Count -Average).Average
$StdDev = [Math]::Sqrt(
($HourlyStats | ForEach-Object { [Math]::Pow($_.Count - $Mean, 2) } | Measure-Object -Average).Average
)

$HourlyStats | ForEach-Object {
$isAnomaly = $_.Count -gt ($Mean + 2 * $StdDev)
[PSCustomObject]@{
Hour = $_.Hour
Count = $_.Count
Anomaly = if ($isAnomaly) { '*** ALERT ***' } else { '' }
}
} | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Time                  Source    Event              Detail
---- ------ ----- ------
2026-01-25 14:22:01 Security AccountChange(4720) svc-backup
2026-01-25 14:22:05 System NewService A service was installed in the system....
2026-01-25 14:23:10 PowerShell ScriptBlock Invoke-WebRequest -Uri http://10.0.0.5/...
2026-01-25 15:01:33 Security AccountChange(4728) svc-backup
2026-01-25 16:45:12 System ServiceStateChange The Background Intelligent Transfer Ser...

Hour Count Anomaly
---- ----- -------
2026-01-23 08 34
2026-01-23 09 41
2026-01-23 10 28
2026-01-24 02 112 *** ALERT ***
2026-01-24 03 98 *** ALERT ***
2026-01-24 09 37
2026-01-25 14 67 *** ALERT ***

凌晨 2 点和 3 点的事件量远高于平均水平,这在正常业务场景中极其可疑,需要重点排查该时间段的所有活动。

取证报告生成

分析完成后,生成结构化的取证报告是交付成果的关键步骤。以下脚本将分析结果整理为包含时间线、IOC 提取和可视化汇总的 HTML 报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 定义报告参数
$ReportPath = "$env:TEMP\ForensicReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
$AnalysisPeriod = "$((Get-Date).AddDays(-7).ToString('yyyy-MM-dd')) 至 $((Get-Date).ToString('yyyy-MM-dd'))"

# 收集关键 IOC(Indicators of Compromise)
$SuspiciousIPs = $LoginFailures |
Where-Object { $_.SourceIP -and $_.SourceIP -ne '-' -and $_.SourceIP -ne '::1' } |
Group-Object SourceIP |
Where-Object { $_.Count -gt 5 } |
Select-Object @{N = 'Indicator'; E = { $_.Name } }, @{N = 'Occurrences'; E = { $_.Count } }

$SuspiciousAccounts = $LoginFailures |
Group-Object User |
Where-Object { $_.Count -gt 10 } |
Select-Object @{N = 'Indicator'; E = { "Account: $($_.Name)" } }, @{N = 'Occurrences'; E = { $_.Count } }

$AllIOCs = $SuspiciousIPs + $SuspiciousAccounts

# 生成 HTML 报告
$HtmlHeader = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>取证分析报告 - $AnalysisPeriod</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
h1 { color: #d32f2f; border-bottom: 2px solid #d32f2f; padding-bottom: 8px; }
h2 { color: #1565c0; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; background: #fff; }
th { background: #1565c0; color: #fff; padding: 8px 12px; text-align: left; }
td { padding: 8px 12px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background: #f9f9f9; }
.alert { color: #d32f2f; font-weight: bold; }
.summary { background: #fff; padding: 16px; border-radius: 4px; margin: 12px 0; }
</style>
</head>
<body>
<h1>取证分析报告</h1>
<div class="summary">
<strong>分析周期:</strong>$AnalysisPeriod<br>
<strong>生成时间:</strong>$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')<br>
<strong>登录失败事件:</strong>$($LoginFailures.Count) 条<br>
<strong>异常时段:</strong>$($HourlyStats | Where-Object { $_.Count -gt ($Mean + 2 * $StdDev) }).Count) 个
</div>
"@

# 构建 IOC 表格
$IocTable = $AllIOCs | ForEach-Object {
"<tr><td>$($_.Indicator)</td><td>$($_.Occurrences)</td></tr>"
} | Out-String

$HtmlBody = @"
<h2>威胁指标 (IOCs)</h2>
<table><tr><th>指标</th><th>出现次数</th></tr>$IocTable</table>

<h2>时间线摘要(最近事件)</h2>
<table><tr><th>时间</th><th>来源</th><th>事件</th></tr>
$(
$Timeline | Select-Object -Last 15 | ForEach-Object {
"<tr><td>$($_.Time.ToString('yyyy-MM-dd HH:mm:ss'))</td><td>$($_.Source)</td><td>$($_.Event)</td></tr>"
} | Out-String
)
</table>

<h2>统计概览</h2>
<p>每小时平均事件数:<strong>$([Math]::Round($Mean, 1))</strong></p>
<p>标准差:<strong>$([Math]::Round($StdDev, 1))</strong></p>
<p>异常阈值(Mean + 2*Sigma):<strong>$([Math]::Round($Mean + 2 * $StdDev, 1))</strong></p>
</body></html>
"@

$HtmlHeader + $HtmlBody | Out-File -FilePath $ReportPath -Encoding UTF8
Write-Host "取证报告已生成:$ReportPath"

执行结果示例:

1
取证报告已生成:C:\Users\admin\AppData\Local\Temp\ForensicReport_20260126_103022.html

生成的 HTML 报告包含完整的威胁指标表格、时间线摘要和统计概览,可以直接在浏览器中打开查看,也可作为合规审计的附件存档。

注意事项

  1. 管理员权限要求:访问安全日志(Security Log)需要以管理员身份运行 PowerShell,普通用户只能查看应用程序和系统日志。建议使用 Start-Process powershell -Verb RunAs 提权后执行分析脚本。

  2. 日志大小与性能Get-WinEvent 在处理大量日志时可能消耗较多内存。对于跨月或跨年的分析,建议分批查询或使用 -MaxEvents 参数限制返回数量,避免内存溢出。

  3. 时间同步的重要性:跨服务器的日志关联分析要求所有机器的时钟保持同步(NTP)。如果时间偏差超过数秒,关联结果可能不准确,建议先验证各机器的时区和时间同步状态。

  4. 日志保留策略:Windows 默认的安全日志最大大小为 20MB,可能在繁忙环境中只保留数天数据。建议通过组策略将日志最大大小调整为 1GB 以上,并启用日志转发(Event Forwarding)集中存储。

  5. PowerShell 脚本块日志的隐私考量:脚本块日志(Event ID 4104)会记录执行的代码内容,可能包含密码等敏感信息。在取证环境中这是优势,但在日常运维中需注意合规要求,评估是否需要启用受保护事件日志(Protected Event Logging)。

  6. 报告存储与链式保管:取证报告应保存在不可篡改的存储介质上,记录哈希值(如 Get-FileHash 计算的 SHA256)以确保完整性。同时保留原始日志的导出副本(.evtx 文件),以满足法律证据链的要求。

PowerShell 技能连载 - 密钥管理与安全存储

适用于 PowerShell 7.0 及以上版本

在日常运维和自动化脚本编写中,硬编码密码、API Key 和数据库连接字符串是最常见的安全隐患之一。一旦脚本被误提交到公开仓库或在日志中泄露,敏感信息就会直接暴露。传统的做法是用 Read-Host -AsSecureString 手动输入,但这在自动化场景中并不适用。

PowerShell SecretManagement 模块的出现改变了这一局面。它提供了一套统一的密钥管理接口,通过扩展库机制支持多种后端存储:Windows Credential Manager、本地加密文件、Azure Key Vault、KeePass、HashiCorp Vault 等。脚本代码只需面向标准 API 编写,不必关心底层密钥存储在哪里。

本文将从基础安装配置讲起,逐步展示如何构建多保管库策略,以及在 CI/CD 和自动化场景中安全使用密钥的最佳实践。

SecretManagement 基础:注册保管库与存取密钥

SecretManagement 模块采用”核心模块 + 扩展库”的架构设计。核心模块 Microsoft.PowerShell.SecretManagement 提供统一的读写接口,而具体的存储后端由扩展库实现。我们首先安装核心模块和一个常用的本地扩展库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 安装核心模块和本地加密存储扩展库
Install-Module -Name Microsoft.PowerShell.SecretManagement -Force -Scope CurrentUser
Install-Module -Name Microsoft.PowerShell.SecretStore -Force -Scope CurrentUser

# 注册一个本地加密保管库(SecretStore)
Register-SecretVault -Name 'LocalDev' -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault

# 查看已注册的所有保管库
Get-SecretVault | Format-Table Name, ModuleName, IsDefault

# 存储不同类型的密钥
Set-Secret -Name 'ApiKey' -Secret 'sk-abc123def456ghi789'
Set-Secret -Name 'DbPassword' -Secret (ConvertTo-SecureString 'P@ssw0rd!2026' -AsPlainText -Force)
Set-Secret -Name 'ServiceAccount' -Secret (
[System.Management.Automation.PSCredential]::new(
'svc_automation',
(ConvertTo-SecureString 'SvcP@ss2026!' -AsPlainText -Force)
)
)

# 枚举保管库中的所有密钥名称
Get-SecretInfo -Vault 'LocalDev'

# 读取密钥并使用
$apiKey = Get-Secret -Name 'ApiKey' -AsPlainText
Write-Output "API Key 前缀: $($apiKey.Substring(0, 8))..."

$dbCred = Get-Secret -Name 'ServiceAccount'
Write-Output "用户名: $($dbCred.UserName)"
1
2
3
4
5
6
7
8
9
10
11
12
Name     ModuleName                          IsDefault
---- ---------- ---------
LocalDev Microsoft.PowerShell.SecretStore True

Name Type Vault
---- ---- -----
ApiKey String LocalDev
DbPassword SecureString LocalDev
ServiceAccount PSCredential LocalDev

API Key 前缀: sk-abc12...
用户名: svc_automation

可以看到 SecretManagement 支持三种密钥类型:普通字符串(String)、安全字符串(SecureString)和凭据对象(PSCredential)。注册保管库时使用 -DefaultVault 参数可以省略后续操作中反复指定保管库名称的麻烦。Get-SecretInfo 只返回元数据(名称和类型),不会解密实际内容,适合在脚本中做前置检查。

多保管库策略:本地开发与生产环境分离

在实际项目中,开发环境和生产环境通常使用不同的密钥存储方案。开发阶段可以用本地加密存储方便调试,而生产环境则需要对接企业级密钥管理服务。SecretManagement 的多保管库机制天然支持这种场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 安装 Azure Key Vault 扩展库
Install-Module -Name Az.KeyVault -Force -Scope CurrentUser

# 注册 Azure Key Vault 作为生产保管库
Register-SecretVault -Name 'ProdKeyVault' -ModuleName Az.KeyVault -VaultParameters @{
AZKVaultName = 'prod-secrets-2026'
SubscriptionId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
}

# 注册 Windows Credential Manager 扩展库(适合本地开发)
Install-Module -Name SecretManagement.JustinGrote.CredMan -Force -Scope CurrentUser
Register-SecretVault -Name 'CredMan' -ModuleName SecretManagement.JustinGrote.CredMan

# 查看所有保管库的配置
Get-SecretVault | Format-Table Name, ModuleName, IsDefault

# 根据环境变量自动选择保管库
$envName = $env:PSENV ?? 'development'

$vaultName = switch ($envName) {
'production' { 'ProdKeyVault' }
'staging' { 'CredMan' }
default { 'LocalDev' }
}

Write-Output "当前环境: $envName, 使用保管库: $vaultName"

# 写入环境专属密钥
Set-Secret -Name "DbConnection-$envName" -Secret "Server=prod-db;Database=app;" -Vault $vaultName

# 统一读取接口(无需关心底层存储)
$connection = Get-Secret -Name "DbConnection-$envName" -Vault $vaultName -AsPlainText
Write-Output "连接字符串已获取,长度: $($connection.Length) 字符"

# 设置保管库访问密码策略(仅限 SecretStore)
Set-SecretStorePassword -Interaction None -PasswordTimeout 3600
1
2
3
4
5
6
7
8
Name          ModuleName                                  IsDefault
---- ---------- ---------
LocalDev Microsoft.PowerShell.SecretStore True
ProdKeyVault Az.KeyVault False
CredMan SecretManagement.JustinGrote.CredMan False

当前环境: development, 使用保管库: LocalDev
连接字符串已获取,长度: 32 字符

多保管库策略的核心价值在于”代码不变,后端可换”。脚本中使用统一的 Get-Secret / Set-Secret 接口,只需要通过保管库名称区分环境。Set-SecretStorePassword-PasswordTimeout 参数可以设置密码缓存时长,避免在批量操作中频繁弹出密码提示。

自动化脚本中的密钥安全使用

在 CI/CD 流水线和服务自动化场景中,密钥管理面临更多挑战:不能弹出交互式提示、需要支持密钥轮换、还要做好审计追踪。以下示例展示如何在自动化环境中安全地集成 SecretManagement。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# --- 文件: Invoke-SecureAutomation.ps1 ---
# 自动化脚本密钥使用模板

# 1. 非交互式初始化(适用于 CI/CD)
$secretStoreConfig = @{
Authentication = 'Password'
PasswordTimeout = 0 # 不缓存密码
Interaction = 'None' # 禁止交互式提示
}
Set-SecretStoreConfiguration @secretStoreConfig -Force

# 2. 用环境变量提供的密码解锁保管库
$vaultPassword = ConvertTo-SecureString $env:VAULT_PASSWORD -AsPlainText -Force
Unlock-SecretStore -Password $vaultPassword

# 3. 定义密钥轮换辅助函数
function Invoke-SecretRotation {
param(
[string]$SecretName,
[string]$VaultName = 'LocalDev',
[int]$Length = 32
)

# 生成随机新密钥
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%'
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$bytes = [byte[]]::new($Length)
$rng.GetBytes($bytes)
$newSecret = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })

# 备份旧值(带时间戳)
$oldValue = Get-Secret -Name $SecretName -Vault $VaultName -AsPlainText -ErrorAction SilentlyContinue
if ($oldValue) {
Set-Secret -Name "${SecretName}.backup.$(Get-Date -Format 'yyyyMMdd')" `
-Secret $oldValue -Vault $VaultName
}

# 写入新值
Set-Secret -Name $SecretName -Secret $newSecret -Vault $VaultName
Write-Output "[$(Get-Date -Format 'HH:mm:ss')] 密钥 '$SecretName' 已轮换"
}

# 4. 批量获取多个服务的连接凭据
$serviceSecrets = @{
'DatabaseServer' = 'Svc_Database'
'MessageQueue' = 'Svc_MQ'
'StorageAccount' = 'Svc_Storage'
}

$credentials = foreach ($entry in $serviceSecrets.GetEnumerator()) {
$cred = Get-Secret -Name $entry.Value -Vault 'LocalDev' -ErrorAction Stop
[PSCustomObject]@{
Service = $entry.Key
UserName = if ($cred -is [pscredential]) { $cred.UserName } else { 'N/A' }
HasSecret = $true
}
}

$credentials | Format-Table -AutoSize

# 5. 记录密钥访问审计日志
$accessLog = @{
Timestamp = Get-Date -Format 'o'
ScriptName = $MyInvocation.MyCommand.Name
User = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
Secrets = $serviceSecrets.Values -join ', '
}
$accessLog | ConvertTo-Json | Out-File -FilePath "./audit-$(Get-Date -Format 'yyyyMMdd').log" -Append
Write-Output "审计日志已记录"
1
2
3
4
5
6
7
8
9
10
11
[14:30:22] 密钥 'Svc_Database' 已轮换
[14:30:22] 密钥 'Svc_MQ' 已轮换
[14:30:22] 密钥 'Svc_Storage' 已轮换

Service UserName HasSecret
------- -------- ---------
DatabaseServer svc_db True
MessageQueue svc_mq True
StorageAccount svc_storage True

审计日志已记录

这个模板展示了自动化场景的几个关键设计:通过 Unlock-SecretStore 用环境变量解锁保管库,避免交互式密码输入;Invoke-SecretRotation 函数在轮换密钥时自动备份旧值,方便紧急回滚;审计日志记录了谁在什么时候访问了哪些密钥,满足合规要求。

注意事项

  1. 必须保护保管库密码:SecretStore 的加密密码是整个安全链的起点。在 CI/CD 中应使用平台原生机制(如 GitHub Secrets、Azure Pipeline Variables)注入 VAULT_PASSWORD 环境变量,绝不能硬编码在脚本中。

  2. 区分密钥类型Set-Secret 会根据传入值的类型自动推断。字符串会以 String 类型存储(Get-Secret -AsPlainText 直接可读),SecureString 和 PSCredential 则提供更高的安全等级。建议对高敏感度密钥使用 SecureString 或 PSCredential 类型。

  3. 扩展库的选择:SecretStore 扩展库适合个人开发和小团队,密钥以 AES-256 加密存储在本地文件中。企业环境应优先对接 Azure Key Vault 或 HashiCorp Vault,利用其访问控制、审计日志和自动轮换等高级功能。

  4. 密钥命名规范:建议使用 {项目}/{环境}/{用途} 的命名约定(如 MyApp/Prod/DbPassword),避免命名冲突,也方便批量查询和管理。

  5. 密钥轮换策略:定期轮换密钥是安全最佳实践。轮换时应先备份旧值、写入新值、验证新值可用后再删除备份。切勿在新值未验证通过时删除旧密钥。

  6. 模块版本兼容性:SecretManagement 的扩展库接口在 1.x 版本中保持稳定,但建议在 requirements.psd1modules.json 中锁定版本号,避免自动化流水线中因模块自动更新引入兼容性问题。

PowerShell 技能连载 - Crescendo 命令包装框架

适用于 PowerShell 7.0 及以上版本

在日常运维中,我们经常需要调用 kubectldockerazterraform 等命令行工具。这些工具虽然功能强大,但在 PowerShell 中使用时只能以字符串拼接的方式构造命令——没有参数补全、没有输入验证、输出是纯文本而非结构化对象,也无法通过管道传递数据。这种体验与 PowerShell 原生 cmdlet 的使用方式截然不同。

PowerShell Crescendo 是微软推出的命令包装框架,它的核心理念是”配置即代码”。通过编写一份 JSON 配置文件,你可以将任意 CLI 工具包装成符合 PowerShell 规范的高级函数:支持参数验证、管道绑定、结构化对象输出以及完整的帮助文档,而无需手写大量模板代码。

Cresceno 特别适合那些需要在团队中标准化 CLI 工具调用方式的场景。包装后的模块可以发布到 PowerShell Gallery,团队成员只需 Install-Module 即可获得一致的 PowerShell 体验。本文将从配置基础、输出处理到完整模块发布三个阶段,带你掌握 Crescendo 的核心用法。

Crescendo 配置基础

Crescendo 的起点是一份 JSON 配置文件,它定义了目标 CLI 工具的路径、参数映射以及输出处理规则。从 PowerShell 7.4 开始,Crescendo 使用 .crescendo.json 扩展名,并遵循标准的 JSON Schema。

以下是一个将 curl 包装为 Invoke-WebRequest 风格命令的配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# 安装 Crescendo 模块
Install-Module -Name Crescendo -Force

# 创建一个 Crescendo 配置
$config = @{
'$schema' = 'https://aka.ms/PowerShell/Crescendo/Schemas/2024-11'
Information = @{
Name = 'CurlWrapper'
Description = '将 curl 包装为 PowerShell 原生函数'
Version = '1.0.0'
}
Commands = @(
@{
Verb = 'Invoke'
Noun = 'CurlRequest'
OriginalName = 'curl'
OriginalCommandElements = @()
Parameters = @(
@{
Name = 'Url'
OriginalName = ''
ParameterType = 'string'
Mandatory = $true
Position = 0
Description = '目标 URL'
}
@{
Name = 'Method'
OriginalName = '-X'
ParameterType = 'string'
DefaultValue = 'GET'
Description = 'HTTP 方法'
ValidateSet = @('GET', 'POST', 'PUT', 'DELETE', 'PATCH')
}
@{
Name = 'Header'
OriginalName = '-H'
ParameterType = 'string[]'
Description = '自定义请求头'
}
@{
Name = 'Data'
OriginalName = '-d'
ParameterType = 'string'
Description = '请求体数据'
}
@{
Name = 'Insecure'
OriginalName = '-k'
ParameterType = 'switch'
Description = '跳过 SSL 证书验证'
}
@{
Name = 'Silent'
OriginalName = '-s'
ParameterType = 'switch'
DefaultValue = $true
Description = '静默模式(默认启用)'
}
)
Help = @{
Synopsis = '使用 curl 发送 HTTP 请求'
Description = '将 curl 命令包装为 PowerShell 函数,支持参数补全和验证。'
Examples = @(
@{
Command = 'Invoke-CurlRequest -Url "https://api.github.com/repos"'
Description = '获取 GitHub 仓库列表'
}
)
}
}
)
}

# 保存配置到文件
$config | ConvertTo-Json -Depth 10 | Out-File -FilePath './CurlWrapper.crescendo.json'

执行上述脚本后,当前目录会生成一份 CurlWrapper.crescendo.json 配置文件,这就是 Crescendo 模块的蓝图:

1
2
3
4
5
    Directory: /home/user/projects/CurlWrapper

Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 1/22/2026 10:00 AM 2847 CurlWrapper.crescendo.json

输出处理与对象转换

Crescendo 的真正威力在于输出处理(Output Handler)。CLI 工具的输出通常是纯文本或 JSON 字符串,Crescendo 可以通过配置自动将其转换为 PowerShell 对象,使输出可以直接通过管道传递给 Where-ObjectSelect-Object 等 cmdlet。

下面我们为之前的配置添加 JSON 输出处理和错误处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 在配置中添加 OutputHandler
$config.Commands[0].OutputHandlers = @(
@{
ParameterSetName = 'Default'
Handler = @'
$rawOutput = $__OUTPUT__
if ($rawOutput -match '^\s*[{[]') {
try {
$result = $rawOutput | ConvertFrom-Json -Depth 10
# 如果返回的是数组,展开每一项
if ($result -is [System.Array]) {
$result | ForEach-Object {
$_ | Add-Member -NotePropertyName '_RawOutput' `
-NotePropertyValue $rawOutput -PassThru -Force
}
} else {
$result | Add-Member -NotePropertyName '_RawOutput' `
-NotePropertyValue $rawOutput -PassThru -Force
}
} catch {
Write-Warning "JSON 解析失败: $($_.Exception.Message)"
$rawOutput
}
} else {
# 非 JSON 输出原样返回
$rawOutput
}
'@
HandlerType = 'Inline'
}
)

# 添加错误处理配置
$config.Commands[0].EmitErrorAction = 'Continue'
$config.Commands[0].ErrorHandler = @'
$errorText = $__ERROR_OUTPUT__
if ($errorText -match 'curl:\s*\((\d+)\)\s*(.+)') {
$errorCode = $Matches[1]
$errorMsg = $Matches[2]
Write-Error "curl 错误 [$errorCode]: $errorMsg"
} else {
Write-Error "curl 执行失败: $errorText"
}
'@

# 更新配置文件
$config | ConvertTo-Json -Depth 10 |
Out-File -FilePath './CurlWrapper.crescendo.json' -Force

# 导出为 PowerShell 模块
Export-CrescendoModule -ConfigurationFile './CurlWrapper.crescendo.json' `
-Force

# 导入模块并测试
Import-Module './CurlWrapper.psm1' -Force
Invoke-CurlRequest -Url 'https://httpbin.org/json'

上面的 OutputHandler 会自动检测 CLI 输出是否为 JSON,如果是则解析为 PowerShell 对象并附加原始输出作为属性,方便调试。非 JSON 输出则原样返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
slideshow    : @{author=Your Name; date=March 8, 2024; title=Sample Slide Show; slides=System.Object[]}
_RawOutput : {
"slideshow": {
"author": "Your Name",
"date": "March 8, 2024",
"title": "Sample Slide Show",
"slides": [...]
}
}

PS> Invoke-CurlRequest -Url 'https://httpbin.org/json' |
Select-Object -ExpandProperty slideshow |
Select-Object title, author

title author
----- ------
Sample Slide Show Your Name

掌握基础配置和输出处理后,我们来完成一个端到端的实战:将 nmap(网络扫描工具)包装为 PowerShell 模块,并发布到 PowerShell Gallery 供团队使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# 1. 定义多命令配置(一个模块包含多个命令)
$nmapConfig = @{
'$schema' = 'https://aka.ms/PowerShell/Crescendo/Schemas/2024-11'
Information = @{
Name = 'NmapWrapper'
Description = '将 nmap 网络扫描工具包装为 PowerShell 原生命令'
Version = '1.0.0'
Author = 'YourName'
CompanyName = 'YourCompany'
Tags = @('network', 'scan', 'nmap', 'security', 'crescendo')
ProjectUri = 'https://github.com/yourname/NmapWrapper'
LicenseUri = 'https://opensource.org/licenses/MIT'
}
Commands = @(
# 命令 1:端口扫描
@{
Verb = 'Test'
Noun = 'NmapPort'
OriginalName = 'nmap'
DefaultParameterSetName = 'QuickScan'
Parameters = @(
@{
Name = 'Target'
ParameterType = 'string[]'
Mandatory = $true
Position = 0
Description = '目标主机(IP 或主机名)'
}
@{
Name = 'Port'
OriginalName = '-p'
ParameterType = 'string'
Description = '指定端口范围(如 80,443 或 1-1024)'
}
@{
Name = 'QuickScan'
OriginalName = '-T4'
ParameterType = 'switch'
ParameterSetName = 'QuickScan'
Description = '快速扫描模式'
}
@{
Name = 'ServiceVersion'
OriginalName = '-sV'
ParameterType = 'switch'
ParameterSetName = 'ServiceScan'
Description = '探测服务版本信息'
}
@{
Name = 'OperatingSystem'
OriginalName = '-O'
ParameterType = 'switch'
Description = '启用操作系统检测'
}
)
OutputHandlers = @(
@{
ParameterSetName = 'Default'
HandlerType = 'Inline'
Handler = @'
# 将 nmap 的文本输出包装为结构化对象
$__OUTPUT__
'@
}
)
}
)
}

# 2. 保存并构建模块
$nmapConfig | ConvertTo-Json -Depth 10 |
Out-File -FilePath './NmapWrapper.crescendo.json' -Force

# 导出为模块
Export-CrescendoModule -ConfigurationFile './NmapWrapper.crescendo.json' `
-ModuleName './NmapWrapper' -Force

# 3. 创建模块清单(用于发布)
$moduleManifest = @{
Path = './NmapWrapper/NmapWrapper.psd1'
RootModule = 'NmapWrapper.psm1'
ModuleVersion = '1.0.0'
Author = 'YourName'
Description = 'PowerShell Crescendo wrapper for nmap'
PowerShellVersion = '7.0'
FunctionsToExport = @('Test-NmapPort')
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('network', 'nmap', 'security', 'crescendo')
LicenseUri = 'https://opensource.org/licenses/MIT'
ProjectUri = 'https://github.com/yourname/NmapWrapper'
}
}
}
New-ModuleManifest @moduleManifest

# 4. 测试模块
Import-Module './NmapWrapper' -Force
Get-Command -Module NmapWrapper

# 5. 发布到 PowerShell Gallery(需要 API Key)
# Publish-Module -Path './NmapWrapper' -NuGetApiKey $apiKey

执行构建和导入后,可以验证模块是否正确导出了命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
CommandType     Name                Version    Source
----------- ---- ------- ------
Function Test-NmapPort 1.0.0 NmapWrapper

PS> Get-Help Test-NmapPort

NAME
Test-NmapPort

SYNOPSIS
将 nmap 网络扫描工具包装为 PowerShell 原生命令

SYNTAX
Test-NmapPort [-Target] <string[]> [-Port <string>] [-QuickScan] [<CommonParameters>]
Test-NmapPort [-Target] <string[]> [-Port <string>] [-ServiceVersion] [<CommonParameters>]

PARAMETERS
-Target <string[]>
目标主机(IP 或主机名)

-Port <string>
指定端口范围(如 80,4431-1024

-QuickScan
快速扫描模式

注意事项

  1. CLI 工具必须已安装:Crescendo 只是生成包装函数,运行时仍然依赖目标 CLI 工具。建议在模块的 #Requires 中明确声明依赖,或在导入时用 Get-Command 检测目标工具是否可用。

  2. OriginalName 可以是完整路径:如果目标工具不在系统 PATH 中,可以在配置中指定完整路径,如 OriginalName = '/usr/local/bin/mytool',确保跨环境兼容。

  3. **OutputHandler 中使用 $__OUTPUT__$__ERROR_OUTPUT__**:这两个变量是 Crescendo 内置的占位符,分别代表 CLI 的标准输出和标准错误。Handler 脚本块必须使用这两个变量来处理输出,不要尝试自己调用原始命令。

  4. JSON 配置的深度问题:Crescendo 配置结构嵌套较深,使用 ConvertTo-Json 时务必指定 -Depth 10 或更高,否则内层配置可能被截断为 System.Object[] 字符串。

  5. 发布前务必测试多平台兼容性:如果模块需要在 Windows 和 Linux 上使用,注意 CLI 工具在不同平台上的参数差异(如路径分隔符、大小写敏感性等),可以通过 OriginalCommandElements 按平台区分参数。

  6. 善用 Import-CrescendoModule 快速迭代:开发阶段可以用 Import-CrescendoModule 直接从配置文件导入,无需每次都导出为 .psm1 文件。调试完成后再用 Export-CrescendoModule 生成最终模块。