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 5.1 及以上版本

回顾这一年的技能连载,我们从基础命令一路走到了高级自动化场景。在实际生产环境中,能跑通一段脚本只是起点,让脚本在面对异常输入、网络波动、权限变更时依然稳定运行,才是工程师的真正功力。今天我们就来系统梳理 PowerShell 脚本编写的最佳实践。

这些实践并非教条,而是从大量生产故障中提炼出来的经验总结。遵循它们可以让你的脚本更健壮、更易维护,也让接手代码的同事少踩几个坑。无论你是刚入门的新手还是资深运维工程师,这些原则都值得时刻对照。

代码结构与命名规范

良好的命名和结构是可维护脚本的基石。PowerShell 社区有一套广泛接受的动词-名词命名约定,遵循它能让你的函数与内置 cmdlet 保持一致的调用体验。同时,参数验证、类型约束和结构化注释也是专业脚本的标配。

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
<#
.SYNOPSIS
获取指定路径下的文件大小统计信息
.DESCRIPTION
递归扫描目标目录,返回按扩展名分类的文件大小汇总
.PARAMETER Path
要扫描的目录路径,必须存在且可访问
.PARAMETER TopN
返回最大的 N 种文件类型,默认为 10
.EXAMPLE
Get-FileStatistics -Path "C:\Logs" -TopN 5
#>
function Get-FileStatistics {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[ValidateScript({
if (-not (Test-Path -Path $_ -PathType Container)) {
throw "指定的路径不存在或不是目录: $_"
}
$true
})]
[string]$Path,

[Parameter()]
[ValidateRange(1, 100)]
[int]$TopN = 10
)

begin {
# 记录开始时间,用于性能统计
$startTime = Get-Date
Write-Verbose "开始扫描目录: $Path"
}

process {
$files = Get-ChildItem -Path $Path -File -Recurse -ErrorAction SilentlyContinue

$stats = $files |
Group-Object Extension |
Sort-Object Count -Descending |
Select-Object -First $TopN |
ForEach-Object {
[PSCustomObject]@{
Extension = $_.Name
Count = $_.Count
TotalSizeMB = [math]::Round(
($_.Group | Measure-Object Length -Sum).Sum / 1MB,
2
)
}
}

$stats
}

end {
$elapsed = (Get-Date) - $startTime
Write-Verbose "扫描完成,耗时: $($elapsed.TotalSeconds.ToString('F2')) 秒"
}
}

# 调用示例
Get-FileStatistics -Path "/var/log" -TopN 5 -Verbose

执行结果示例:

1
2
3
4
5
6
7
8
9
10
VERBOSE: 开始扫描目录: /var/log
VERBOSE: 扫描完成,耗时: 0.34 秒

Extension Count TotalSizeMB
--------- ----- -----------
.log 128 256.45
.gz 42 1024.80
.json 18 12.30
.txt 7 3.15
.err 3 0.85

上面的代码展示了几个关键实践:函数名使用-approved动词加名词的形式,参数加了 [CmdletBinding] 和完整的验证属性,并用基于注释的帮助文档让 Get-Help 能直接识别。begin/process/end 块的划分让管道处理逻辑更清晰。

错误处理与防御性编程

脚本在开发环境能跑通很容易,但生产环境充满了意外:磁盘满、网络断、权限不足、文件被占用。防御性编程的核心理念是”永远假设会出错”,然后用结构化的方式处理每一个可能的失败点。

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
function Copy-LogArchive {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$SourcePath,

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

[Parameter()]
[int]$RetryCount = 3,

[Parameter()]
[int]$RetryDelaySeconds = 5
)

# 前置检查:源路径是否存在
if (-not (Test-Path $SourcePath)) {
Write-Error "源路径不存在: $SourcePath"
return
}

# 确保目标目录存在
if (-not (Test-Path $DestinationPath)) {
if ($PSCmdlet.ShouldProcess($DestinationPath, "创建目标目录")) {
try {
New-Item -Path $DestinationPath -ItemType Directory -Force |
Out-Null
Write-Verbose "已创建目标目录: $DestinationPath"
}
catch {
Write-Error "无法创建目标目录: $_"
return
}
}
}

# 获取待复制的文件列表
$files = @(Get-ChildItem -Path $SourcePath -File -Filter "*.log")

if ($files.Count -eq 0) {
Write-Warning "源目录中没有 .log 文件,操作跳过"
return
}

$copiedCount = 0
$failedFiles = @()

foreach ($file in $files) {
$destFile = Join-Path $DestinationPath $file.Name
$attempt = 0
$success = $false

while ($attempt -lt $RetryCount -and -not $success) {
$attempt++
try {
if ($PSCmdlet.ShouldProcess($file.FullName, "复制到 $DestinationPath")) {
Copy-Item -Path $file.FullName -Destination $destFile -Force
$copiedCount++
$success = $true
Write-Verbose "[$attempt/$RetryCount] 复制成功: $($file.Name)"
}
}
catch {
Write-Warning "[$attempt/$RetryCount] 复制失败: $($file.Name) - $($_.Exception.Message)"
if ($attempt -lt $RetryCount) {
Write-Verbose "等待 $RetryDelaySeconds 秒后重试..."
Start-Sleep -Seconds $RetryDelaySeconds
}
else {
$failedFiles += $file.Name
}
}
}
}

# 汇总报告
$report = [PSCustomObject]@{
TotalFiles = $files.Count
CopiedFiles = $copiedCount
FailedFiles = $failedFiles.Count
FailedList = $failedFiles -join ", "
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}

if ($failedFiles.Count -gt 0) {
Write-Warning "部分文件复制失败: $($failedFiles -join ', ')"
}

$report
}

# 调用示例
Copy-LogArchive -SourcePath "C:\Logs\App" -DestinationPath "\\Server\Backup\Logs" -RetryCount 3 -Verbose

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
VERBOSE: 已创建目标目录: \\Server\Backup\Logs
VERBOSE: [1/3] 复制成功: app-2025-12-28.log
VERBOSE: [1/3] 复制成功: app-2025-12-29.log
WARNING: [1/3] 复制失败: app-2025-12-30.log - 被其他进程占用。
VERBOSE: 等待 5 秒后重试...
VERBOSE: [2/3] 复制成功: app-2025-12-30.log

TotalFiles : 3
CopiedFiles : 3
FailedFiles : 0
FailedList :
Timestamp : 2025-12-30 10:15:30

这个函数体现了多层防御:前置检查确保输入合法,重试机制应对瞬态故障,SupportsShouldProcess 提供 -WhatIf-Confirm 支持,汇总报告让运维人员一眼看清执行结果。特别注意 @(...) 包裹管道赋值,这确保即使没有文件也返回空数组而非 $null,避免后续 Count 属性报错。

性能优化与安全编码

当脚本处理的文件从几十个变成几万个,或者从单机扩展到数百台远程服务器时,性能和安全就成了不可忽视的问题。掌握这些技巧可以避免脚本在生产中”慢到不可用”或”泄露敏感信息”。

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
function Get-SecureAuditReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$ComputerName,

[Parameter()]
[pscredential]$Credential,

[Parameter()]
[ValidateRange(1, 50)]
[int]$ThrottleLimit = 10
)

# 构建安全的 CimSession 参数
$cimParams = @{
ErrorAction = 'Stop'
}

if ($Credential) {
$cimParams.Credential = $Credential
}

# 并行处理远程主机,控制并发数
$results = $ComputerName | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
$computer = $_
$params = $using:cimParams

try {
# 使用 CIM 替代 WMI,更安全且支持 WinRM
$os = Get-CimInstance -ClassName Win32_OperatingSystem @params
$patches = Get-CimInstance -ClassName Win32_QuickFixEngineering @params |
Sort-Object InstalledOn -Descending -ErrorAction SilentlyContinue |
Select-Object -First 5

# 安全地提取凭据信息:只记录用户名,不记录密码
[PSCustomObject]@{
ComputerName = $computer
Status = "Online"
OSVersion = $os.Caption
LastBootTime = $os.LastBootUpTime
FreeSpaceGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
RecentPatches = $patches.HotFixID -join "; "
AuditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
catch {
[PSCustomObject]@{
ComputerName = $computer
Status = "Error: $($_.Exception.Message)"
OSVersion = "N/A"
LastBootTime = $null
FreeSpaceGB = "N/A"
RecentPatches = "N/A"
AuditTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
}

# 安全地输出结果,过滤敏感字段
$results | Format-Table -AutoSize

# 审计日志脱敏处理
$logEntry = @{
Action = "SecurityAudit"
Targets = $ComputerName -join ","
ResultCount = ($results | Where-Object Status -eq "Online").Count
ExecutedBy = $env:USERNAME
Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
}

# 注意:不要将 $Credential 或密码写入日志
$logEntry | ConvertTo-Json -Compress
}

# 安全地传递凭据(交互式输入)
$cred = Get-Credential -Message "输入远程服务器管理凭据"
Get-SecureAuditReport -ComputerName "SRV01", "SRV02", "SRV03" -Credential $cred -ThrottleLimit 5

执行结果示例:

1
2
3
4
5
6
7
ComputerName Status  OSVersion                     LastBootTime         FreeSpaceGB RecentPatches         AuditTime
------------ ------ --------- ------------ ----------- ------------- ---------
SRV01 Online Microsoft Windows Server 2022 2025-12-28 03:00:00 12.45 KB5044280;KB503... 2025-12-30 14:20:10
SRV02 Online Microsoft Windows Server 2019 2025-12-29 06:30:00 8.72 KB5044277;KB503... 2025-12-30 14:20:12
SRV03 Error: 无法连接到远程服务器。 N/A N/A N/A 2025-12-30 14:20:15

{"Action":"SecurityAudit","Targets":"SRV01,SRV02,SRV03","ResultCount":2,"ExecutedBy":"admin","Timestamp":"2025-12-30T14:20:15Z"}

这个函数使用了 ForEach-Object -Parallel 实现受控并发,用 CIM 替代已弃用的 WMI 协议,通过 PSCredential 对象安全传递凭据而非明文密码,审计日志中严格排除敏感字段。这些都是生产环境中必须考虑的安全和性能要点。

注意事项

  1. 始终使用 approved verbs:用 Get-Verb 查看合法动词列表,避免自定义动词导致命名不一致。如果不确定用什么动词,参考类似功能的内置 cmdlet。

  2. 参数验证优于函数内 if 判断[ValidateNotNullOrEmpty()][ValidateRange()][ValidateScript()] 等属性在参数绑定时就生效,比函数体内的 if 检查更早报错,错误信息也更标准。

  3. 永远不要在脚本中硬编码密码:使用 Get-Credential 交互获取凭据,或从 Azure Key Vault、Windows Credential Manager 等安全存储读取。代码中的 $Credential.PasswordSecureString,不能直接转为明文。

  4. -ErrorAction Stop 配合 try/catch:全局设置 $ErrorActionPreference = 'Stop' 可能导致意想不到的中断,建议在特定命令上用 -ErrorAction 精确控制,然后在 try 块中捕获。

  5. 大集合用 @() 包裹管道赋值$items = @(Get-ChildItem ...) 确保 $items 始终是数组,即使结果为空或只有一个元素。这样后续的 .Countforeach 行为一致,不会踩类型陷阱。

  6. 为函数写基于注释的帮助.SYNOPSIS.DESCRIPTION.PARAMETER.EXAMPLE 四件套是最低要求。三个月后你自己也会感谢今天写了注释的那个函数,更不用说接手代码的同事了。