PowerShell 技能连载 - 凭据安全管理

适用于 PowerShell 7.0 及以上版本

在自动化脚本中硬编码密码是最常见也最危险的安全隐患之一。一段包含明文密码的脚本一旦被提交到版本控制系统,就会永久留在 Git 历史中,即使后续删除也无济于事。据统计,GitHub 上每天有数千个包含泄露凭据的提交被安全研究员发现。对于企业运维团队来说,一个泄露的服务账号密码可能意味着整个基础设施面临风险。

PowerShell 提供了完善的凭据安全管理机制。从传统的 PSCredential + SecureString 方案,到现代化的 SecretManagement 模块生态,再到凭据轮换与审计的完整生命周期管理,PowerShell 生态已经覆盖了凭据安全的各个环节。合理运用这些工具,可以确保密码永远不会以明文形式出现在脚本、日志或配置文件中。

本文将从三个维度展开:使用 PSCredential 和安全字符串进行本地凭据保护;通过 SecretManagement 模块实现跨平台密钥管理;以及构建凭据轮换与审计机制,形成完整的凭据安全闭环。

PSCredential 与安全字符串

PSCredential 是 PowerShell 中封装用户名和密码的标准对象,密码在内部以 SecureString 形式存储,不会以明文暴露在内存中。结合 Windows 的 DPAPI(数据保护 API),可以将凭据安全地序列化到文件,供后续脚本复用。

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
# 方式一:交互式创建凭据(适合手动执行场景)
$cred = Get-Credential -Message '请输入服务账号凭据'

# 方式二:通过代码创建凭据(适合自动化场景)
$plainPassword = 'My$ecureP@ssw0rd!2026'
$securePassword = $plainPassword | ConvertTo-SecureString -AsPlainText -Force
$cred = [System.Management.Automation.PSCredential]::new('svcautomation', $securePassword)

# 将凭据安全导出到文件(DPAPI 加密,仅当前用户可解密)
$cred | Export-Clixml -Path './svcautomation.cred.xml'

# 从文件导入凭据
$loadedCred = Import-Clixml -Path './svcautomation.cred.xml'

# 使用 SecureString 的 DPAPI 加密/解密
$encrypted = $plainPassword | ConvertTo-SecureString -AsPlainText -Force |
ConvertFrom-SecureString

# 将加密字符串保存到文件
$encrypted | Set-Content -Path './encrypted-password.txt'

# 在另一台机器或用户上下文中解密(需配合 Key 参数使用 AES 对称加密)
$key = (1..16 | ForEach-Object { Get-Random -Minimum 0 -Maximum 255 })
$encryptedWithKey = $plainPassword | ConvertTo-SecureString -AsPlainText -Force |
ConvertFrom-SecureString -Key $key

Write-Host "用户名: $($loadedCred.UserName)"
Write-Host "密码长度: $($loadedCred.GetNetworkCredential().Password.Length) 个字符"
Write-Host "加密字符串前 40 字符: $($encrypted.Substring(0, 40))..."

执行结果示例:

1
2
3
用户名: svcautomation
密码长度: 20 个字符
加密字符串前 40 字符: 01000000d08c9ddf0115d1118c7a00c04f...

需要注意的是,不带 -Key 参数的 ConvertFrom-SecureString 使用 Windows DPAPI 加密,加密后的数据只能在同一台机器的同一用户账户下解密。如果需要跨机器传输,必须使用 -Key 参数指定 AES 密钥。

SecretManagement 模块

Microsoft.PowerShell.SecretManagement 是微软官方推出的密钥管理框架,采用”核心模块 + 扩展保管库”的架构。核心模块提供统一的读写接口,而实际的密钥存储由扩展库实现,支持 Windows Credential Manager、Azure Key Vault、KeePass、HashiCorp Vault 等多种后端。脚本代码面向统一 API 编写,不必关心底层存储细节。

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
# 安装核心模块和扩展保管库
Install-Module -Name Microsoft.PowerShell.SecretManagement -Force -Scope CurrentUser
Install-Module -Name Microsoft.PowerShell.SecretStore -Force -Scope CurrentUser

# 注册本地 SecretStore 保管库
Register-SecretVault -Name 'LocalDevVault' -ModuleName Microsoft.PowerShell.SecretStore

# 配置保管库策略(需要密码才能访问)
Set-SecretStoreConfiguration -Authentication Password -Interaction None

# 存储各种类型的密钥
Set-Secret -Name 'DatabaseConnection' -Secret 'Server=db01;User=app;Password=S3cure!;' -Vault LocalDevVault
Set-Secret -Name 'ApiKey' -Secret 'sk-proj-abc123def456ghi789' -Vault LocalDevVault
Set-Secret -Name 'ServiceAccount' -Secret (
[System.Management.Automation.PSCredential]::new(
'DOMAIN\svcautomation',
('P@ssw0rd!' | ConvertTo-SecureString -AsPlainText -Force)
)
) -Vault LocalDevVault

# 读取密钥
$dbConn = Get-Secret -Name 'DatabaseConnection' -Vault LocalDevVault
$apiKey = Get-Secret -Name 'ApiKey' -Vault LocalDevVault
$svcCred = Get-Secret -Name 'ServiceAccount' -Vault LocalDevVault

# 列出所有密钥名称(不暴露值)
Get-SecretInfo -Vault LocalDevVault | Format-Table Name, Type

# 删除不需要的密钥
Remove-Secret -Name 'ApiKey' -Vault LocalDevVault

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

执行结果示例:

1
2
3
4
5
6
7
8
9
Name                Type
---- ----
DatabaseConnection SecureString
ApiKey SecureString
ServiceAccount PSCredential

Name ModuleName IsDefault
---- ---------- ---------
LocalDevVault Microsoft.PowerShell.SecretStore False

SecretManagement 的优势在于扩展性。如果未来需要从本地文件迁移到 Azure Key Vault 或 HashiCorp Vault,只需注册新的保管库并迁移数据,业务脚本中的 Get-Secret 调用完全不需要修改。

凭据轮换与审计

凭据安全不止于安全存储,还需要定期轮换密码、记录使用日志并设置过期告警。以下代码构建了一个简易的凭据生命周期管理系统,可以自动生成强密码、更新保管库中的密钥,并记录完整的审计日志。

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
# 凭据轮换与审计管理系统

# 生成强随机密码
function New-RandomPassword {
[CmdletBinding()]
[OutputType([string])]
param(
[int]$Length = 24,
[int]$NonAlphaNumeric = 6
)
$assembly = [System.Reflection.Assembly]::LoadWithPartialName('System.Web')
$password = [System.Web.Security.Membership]::GeneratePassword($Length, $NonAlphaNumeric)
return $password
}

# 凭据审计日志(追加式 JSON 日志)
function Write-CredentialAuditLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SecretName,
[Parameter(Mandatory)]
[ValidateSet('Create', 'Read', 'Rotate', 'Delete')]
[string]$Action,
[string]$VaultName = 'LocalDevVault',
[string]$Operator = $env:USERNAME
)
$logEntry = [ordered]@{
Timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ'
Action = $Action
SecretName = $SecretName
VaultName = $VaultName
Operator = $Operator
Machine = $env:COMPUTERNAME
}
$logPath = './credential-audit.json'
$existing = @()
if (Test-Path $logPath) {
$existing = Get-Content $logPath -Raw | ConvertFrom-Json
if ($existing -isnot [array]) { $existing = @($existing) }
}
$existing += $logEntry
$existing | ConvertTo-Json -Depth 5 | Set-Content $logPath -Encoding UTF8
}

# 凭据轮换:生成新密码并更新保管库
function Invoke-CredentialRotation {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SecretName,
[string]$VaultName = 'LocalDevVault'
)
$newPassword = New-RandomPassword -Length 32 -NonAlphaNumeric 8
$secureNew = $newPassword | ConvertTo-SecureString -AsPlainText -Force
Set-Secret -Name $SecretName -Secret $secureNew -Vault $VaultName
Write-CredentialAuditLog -SecretName $SecretName -Action Rotate -VaultName $VaultName
Write-Host "[$SecretName] 密码已轮换,新密码长度: $($newPassword.Length) 字符"
}

# 检查凭据是否即将过期(基于审计日志中的最后轮换时间)
function Test-CredentialExpiry {
[CmdletBinding()]
param(
[int]$ExpireDays = 90
)
$logPath = './credential-audit.json'
if (-not (Test-Path $logPath)) {
Write-Warning '审计日志文件不存在,无法检查凭据过期状态'
return
}
$logs = Get-Content $logPath -Raw | ConvertFrom-Json
$rotateLogs = $logs | Where-Object { $_.Action -eq 'Rotate' }
$latestRotations = $rotateLogs | Group-Object SecretName | ForEach-Object {
$latest = ($_.Group | Sort-Object Timestamp -Descending | Select-Object -First 1)
[PSCustomObject]@{
SecretName = $_.Name
LastRotated = [datetime]$latest.Timestamp
DaysSince = [math]::Floor((Get-Date) - [datetime]$latest.Timestamp)
ShouldRotate = ((Get-Date) - [datetime]$latest.Timestamp).TotalDays -gt $ExpireDays
}
}
$latestRotations | Format-Table SecretName, LastRotated, DaysSince, ShouldRotate
$expired = $latestRotations | Where-Object { $_.ShouldRotate }
if ($expired) {
Write-Warning "以下凭据已超过 $ExpireDays 天未轮换:$($expired.SecretName -join ', ')"
}
}

# 执行轮换并记录审计日志
Invoke-CredentialRotation -SecretName 'DatabaseConnection' -VaultName LocalDevVault
Invoke-CredentialRotation -SecretName 'ServiceAccount' -VaultName LocalDevVault

# 检查凭据过期状态
Test-CredentialExpiry -ExpireDays 90

执行结果示例:

1
2
3
4
5
6
7
[DatabaseConnection] 密码已轮换,新密码长度: 32 字符
[ServiceAccount] 密码已轮换,新密码长度: 32 字符

SecretName LastRotated DaysSince ShouldRotate
---------- ------------ --------- ------------
DatabaseConnection 2026-04-16T10:30:00.000Z 0 False
ServiceAccount 2026-04-16T10:30:01.000Z 0 False

注意事项

  • 绝不硬编码密码:脚本中出现的任何明文密码都是安全隐患,应始终通过 PSCredentialSecureStringSecretManagement 模块来管理凭据。
  • DPAPI 加密的局限性:不带 -Key 参数的 ConvertFrom-SecureString 使用 Windows DPAPI,加密数据绑定于当前机器和用户账户,无法跨机器解密。跨机器场景请使用 -Key 参数配合 AES 对称加密。
  • SecretStore 密码保护:生产环境中务必将 SecretStore 配置为需要密码访问(Set-SecretStoreConfiguration -Authentication Password),不要使用 None 模式,否则任何能登录的用户都能读取密钥。
  • 审计日志的存储安全:凭据审计日志本身也包含敏感操作记录,应存放在受保护的位置,并设置适当的 NTFS 权限或文件加密,防止未授权访问。
  • 定期轮换密码:建议建立密码轮换策略,至少每 90 天轮换一次服务账号密码。轮换后确保所有依赖该凭据的自动化任务都使用更新后的密钥。
  • 密钥传输安全:在 CI/CD 管线中使用密钥时,应通过安全的管线变量或环境变量传递,避免将密钥写入磁盘文件或打印到控制台输出中。

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 中锁定版本号,避免自动化流水线中因模块自动更新引入兼容性问题。