Azure DevOps 服務 |Azure DevOps Server |Azure DevOps Server 2022
本文包含 PowerShell 腳本,可稽核整個 Azure DevOps 組織的安全性,並提供常見安全性工作的系統管理工具。 使用這些腳本來審核使用者存取、檢閱服務連線、掃描相依性是否有漏洞,以及執行特定的管理任務,例如變更專案可見性和管理群組成員資格。
這些腳本可協助您保持對安全狀況的可見性、識別潛在風險,並透過自動化簡化選取的管理安全任務。
Disclaimer
這些腳本是為方便起見而提供的範例。 檢閱它們,在非生產環境中進行測試,並在生產環境中執行之前調整以符合組織的政策。 驗證您環境的正確性和安全性。
先決條件
| 類別 | Description |
|---|---|
| Azure DevOps | - Azure DevOps 組織和專案。
免費創建一個。 - 權限: 組織層級腳本的組織擁有者或專案集合系統管理員;專案層級作業的專案系統管理員 |
| 驗證 | Microsoft Entra ID 識別代幣。 腳本會透過 Azure CLI 取得權杖: az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798。 確保您已經使用az login登入 |
| Tools | - 需要 Azure CLI (az) 和 PowerShell (pwsh)- 為 PowerShell 7+ 編寫的腳本,並將其適應於 Windows PowerShell - 某些腳本的 Azure PowerShell 模組 ( Install-Module -Name Az) |
| Safety | - 首先在非生產組織中進行測試 - 在移除使用者之前重新指派或關閉作用中工作 - 在公開專案之前檢閱專案可見度先決條件 - 保留自動變更的稽核追蹤 |
| 安全性 | - 安全地儲存認證和權杖 (金鑰保存庫或管線秘密變數) - 在進行大量作業後,如果需要進行認證輪替,請撤銷存取憑證和服務主體 |
開始
我應該使用哪個腳本?
| 您的目標 | 推薦腳本 | 頻率範例 |
|---|---|---|
| 發現安全風險 | 使用者存取稽核 | Monthly |
| 清除權限 | 移除群組成員 | 視需要 |
| 為使用者離開做準備 | 重新指派工作專案 → 移除群組成員 | 視需要 |
| 稽核連線 | 服務連線稽核 | 每季 |
| 檢查相依性 | 相依性掃描器 | Weekly |
稽核整個組織的安全性和存取權
稽核使用者存取與權限
此腳本會稽核並報告整個組織的使用者權限:
# User Access Audit Script
param(
[Parameter(Mandatory=$true)]
[string]$organization,
[string]$outputPath = "C:\AzureDevOps\UserAccessAudit.csv",
[string]$apiVersion = "7.1-preview"
)
# Authenticate and setup
if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
Connect-AzAccount
}
$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
$token = (Get-AzAccessToken -ResourceUrl $resourceUrl).Token
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}
Write-Host "Starting user access audit for organization: $organization" -ForegroundColor Cyan
$auditResults = @()
$riskFindings = @()
# Get all users in the organization
$usersUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/users?api-version=$apiVersion"
$users = Invoke-RestMethod -Uri $usersUrl -Method Get -Headers $headers
# Get all groups and their members
$groupsUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/groups?api-version=$apiVersion"
$groups = Invoke-RestMethod -Uri $groupsUrl -Method Get -Headers $headers
Write-Host "Found $($users.count) users and $($groups.count) groups" -ForegroundColor Yellow
foreach ($user in $users.value) {
try {
# Get user's group memberships
$membershipsUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/memberships/$($user.descriptor)?direction=up&api-version=$apiVersion"
$memberships = Invoke-RestMethod -Uri $membershipsUrl -Method Get -Headers $headers
# Analyze access level and permissions
$isAdmin = $false
$adminGroups = @()
$lastAccessDate = "Unknown"
foreach ($membership in $memberships.value) {
$groupName = ($groups.value | Where-Object { $_.descriptor -eq $membership.containerDescriptor }).displayName
if ($groupName -match "Administrator|Admin|Collection|Project Collection") {
$isAdmin = $true
$adminGroups += $groupName
}
}
# Check for inactive users with admin access
if ($isAdmin -and $user.lastAccessedDate) {
$lastAccess = [DateTime]$user.lastAccessedDate
if ($lastAccess -lt (Get-Date).AddDays(-90)) {
$riskFindings += [PSCustomObject]@{
Type = "Inactive Admin User"
User = $user.displayName
Email = $user.mailAddress
LastAccess = $lastAccess
AdminGroups = ($adminGroups -join ", ")
Risk = "High"
}
}
}
# Check for external users with high privileges
if ($user.mailAddress -notlike "*@yourdomain.com" -and $isAdmin) {
$riskFindings += [PSCustomObject]@{
Type = "External Admin User"
User = $user.displayName
Email = $user.mailAddress
LastAccess = $user.lastAccessedDate
AdminGroups = ($adminGroups -join ", ")
Risk = "High"
}
}
$auditResults += [PSCustomObject]@{
DisplayName = $user.displayName
Email = $user.mailAddress
Domain = if ($user.mailAddress) { $user.mailAddress.Split('@')[1] } else { "Unknown" }
IsAdmin = $isAdmin
AdminGroups = ($adminGroups -join ", ")
LastAccess = $user.lastAccessedDate
UserType = if ($user.mailAddress -like "*@yourdomain.com") { "Internal" } else { "External" }
AccountEnabled = if ($user.metaType -eq "member") { "Active" } else { "Inactive" }
}
Write-Progress -Activity "Auditing users" -Status "Processing $($user.displayName)" -PercentComplete (($auditResults.Count / $users.count) * 100)
}
catch {
Write-Warning "Failed to process user $($user.displayName): $($_.Exception.Message)"
}
}
# Generate reports
Write-Host "Generating audit reports..." -ForegroundColor Yellow
# Export full audit results
$auditResults | Export-Csv -Path $outputPath -NoTypeInformation
Write-Host "Full audit exported to: $outputPath" -ForegroundColor Green
# Display risk summary
if ($riskFindings.Count -gt 0) {
Write-Host "Security risks identified:" -ForegroundColor Red
$riskFindings | Format-Table -AutoSize
$riskPath = $outputPath.Replace('.csv', '_SecurityRisks.csv')
$riskFindings | Export-Csv -Path $riskPath -NoTypeInformation
Write-Host "Security risks exported to: $riskPath" -ForegroundColor Yellow
} else {
Write-Host "No critical security risks identified" -ForegroundColor Green
}
# Display summary statistics
$stats = @{
TotalUsers = $auditResults.Count
AdminUsers = ($auditResults | Where-Object { $_.IsAdmin }).Count
ExternalUsers = ($auditResults | Where-Object { $_.UserType -eq "External" }).Count
ExternalAdmins = ($auditResults | Where-Object { $_.UserType -eq "External" -and $_.IsAdmin }).Count
InactiveAccounts = ($auditResults | Where-Object { $_.AccountEnabled -eq "Inactive" }).Count
}
Write-Host "`nAudit Summary:" -ForegroundColor Cyan
$stats.GetEnumerator() | ForEach-Object {
Write-Host " $($_.Key): $($_.Value)" -ForegroundColor White
}
稽核服務連線和認證
此指令碼會稽核服務連線,並識別潛在的安全性風險:
# Service Connection Security Audit Script
param(
[Parameter(Mandatory=$true)]
[string]$organization,
[string]$outputPath = "C:\AzureDevOps\ServiceConnectionAudit.csv",
[string]$apiVersion = "7.1-preview"
)
# Authenticate and setup
if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
Connect-AzAccount
}
$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
$token = (Get-AzAccessToken -ResourceUrl $resourceUrl).Token
$headers = @{
Authorization = "Bearer $token"
"Content-Type" = "application/json"
}
Write-Host "Starting service connection audit for organization: $organization" -ForegroundColor Cyan
$allServiceConnections = @()
$securityRisks = @()
# Get all projects to scan their service connections
$projectsUrl = "https://dev.azure.com/$organization/_apis/projects?api-version=$apiVersion"
$projects = Invoke-RestMethod -Uri $projectsUrl -Method Get -Headers $headers
foreach ($project in $projects.value) {
Write-Host "Scanning project: $($project.name)" -ForegroundColor Yellow
try {
# Get service connections for this project
$serviceConnUrl = "https://dev.azure.com/$organization/$($project.name)/_apis/serviceendpoint/endpoints?api-version=$apiVersion"
$serviceConnections = Invoke-RestMethod -Uri $serviceConnUrl -Method Get -Headers $headers
foreach ($conn in $serviceConnections.value) {
# Analyze service connection security
$riskLevel = "Low"
$risks = @()
# Check for risky configurations
if ($conn.isShared -eq $true) {
$risks += "Shared across projects"
$riskLevel = "Medium"
}
if ($conn.authorization.scheme -eq "UsernamePassword") {
$risks += "Uses username/password authentication"
$riskLevel = "High"
}
if ($conn.isReady -eq $false) {
$risks += "Connection not verified/ready"
$riskLevel = "Medium"
}
# Check last used date
$lastUsed = "Unknown"
if ($conn.operationStatus.state -eq "Ready") {
# Attempt to get usage data (may require additional API calls)
$lastUsed = "Recently used"
}
# Check for overly permissive access
if ($conn.authorization.parameters.scope -like "*.*" -or $conn.authorization.parameters.scope -eq "FullAccess") {
$risks += "Overly broad scope/permissions"
$riskLevel = "High"
}
$connectionAudit = [PSCustomObject]@{
Project = $project.name
Name = $conn.name
Type = $conn.type
Url = $conn.url
AuthScheme = $conn.authorization.scheme
IsShared = $conn.isShared
IsReady = $conn.isReady
CreatedBy = $conn.createdBy.displayName
LastUsed = $lastUsed
RiskLevel = $riskLevel
SecurityRisks = ($risks -join "; ")
}
$allServiceConnections += $connectionAudit
# Add to security risks if high/medium risk
if ($riskLevel -in @("High", "Medium")) {
$securityRisks += $connectionAudit
}
}
}
catch {
Write-Warning "Failed to scan project $($project.name): $($_.Exception.Message)"
}
}
# Generate reports
Write-Host "Generating service connection audit reports..." -ForegroundColor Yellow
# Export full audit
$allServiceConnections | Export-Csv -Path $outputPath -NoTypeInformation
Write-Host "Service connection audit exported to: $outputPath" -ForegroundColor Green
# Export security risks
if ($securityRisks.Count -gt 0) {
Write-Host "Service connection security risks identified:" -ForegroundColor Red
$securityRisks | Format-Table -AutoSize
$riskPath = $outputPath.Replace('.csv', '_SecurityRisks.csv')
$securityRisks | Export-Csv -Path $riskPath -NoTypeInformation
Write-Host "Security risks exported to: $riskPath" -ForegroundColor Yellow
} else {
Write-Host "No critical service connection risks identified" -ForegroundColor Green
}
# Display summary
$summary = @{
TotalConnections = $allServiceConnections.Count
SharedConnections = ($allServiceConnections | Where-Object { $_.IsShared }).Count
HighRiskConnections = ($securityRisks | Where-Object { $_.RiskLevel -eq "High" }).Count
MediumRiskConnections = ($securityRisks | Where-Object { $_.RiskLevel -eq "Medium" }).Count
NotReadyConnections = ($allServiceConnections | Where-Object { $_.IsReady -eq $false }).Count
}
Write-Host "`nService Connection Summary:" -ForegroundColor Cyan
$summary.GetEnumerator() | ForEach-Object {
Write-Host " $($_.Key): $($_.Value)" -ForegroundColor White
}
掃描相依性以尋找安全性弱點
檢查易受攻擊的依賴項
這個腳本會掃描你倉庫內所有 NuGet 和 npm 專案,並回報過時和有漏洞的套件。
把腳本存到你的倉庫,從專案根執行以掃描你的相依性。 它會顯示所有發現的重點主控台摘要,並產生完整的 JSON 依賴報告,儲存為 dependency-results.json。
PowerShell 指令:(將 SCRIPT_PATH\SCRIPT_FILENAME.ps1 替換成實際的腳本路徑和檔名)
powershell -ExecutionPolicy Bypass -File SCRIPT_PATH\SCRIPT_FILENAME.ps1 -projectPath (Get-Location).Path -outputPath .\dependency-results.json
param(
[string]$projectPath = (Resolve-Path ".").Path,
[string]$outputPath = (Join-Path (Resolve-Path ".").Path "dependency-results.json")
)
# ---------------------------
# Helpers
# ---------------------------
function Write-Severity {
param(
[Parameter(Mandatory)] [string] $severity,
[Parameter(Mandatory)] [string] $text
)
$sev = $severity.ToLower()
switch ($sev) {
"critical" { Write-Host $text -ForegroundColor Red }
"high" { Write-Host $text -ForegroundColor Red }
"medium" { Write-Host $text -ForegroundColor Yellow }
"moderate" { Write-Host $text -ForegroundColor Yellow }
"low" { Write-Host $text -ForegroundColor DarkGray }
"info" { Write-Host $text -ForegroundColor Green }
default { Write-Host $text -ForegroundColor Green }
}
}
function To-Severity {
param([object]$s)
if (-not $s) { return "info" }
$t = "$s".ToLower()
if ($t -eq "moderate") { return "medium" }
return $t
}
# ---------------------------
# Data containers
# ---------------------------
$results = [ordered]@{
NuGet = @()
Npm = @()
Vulnerabilities= @()
}
Write-Host "Starting dependency check in: $projectPath"
# ---------------------------
# NuGet (.NET)
# ---------------------------
$nugetProjects = Get-ChildItem -Path $projectPath -Recurse -Filter "*.csproj" -File
foreach ($proj in $nugetProjects) {
$projDir = Split-Path $proj.FullName -Parent
Write-Host "Checking NuGet in project: $($proj.Name)"
Push-Location $projDir
try {
# Ensure restore done (quiet)
dotnet restore $proj.FullName | Out-Null
# Outdated (JSON)
$outJson = dotnet list $proj.FullName package --outdated --include-transitive --format json 2>$null
if ($outJson) {
$outObj = $outJson | ConvertFrom-Json
$results.NuGet += [ordered]@{
Project = $proj.FullName
Outdated = $outObj.outdatedPackages
}
# Print concise summary w/ "info" severity color (outdated isn't a vulnerability)
if ($outObj.outdatedPackages) {
Write-Host " Outdated packages:"
foreach ($p in $outObj.outdatedPackages) {
$line = " - {0} requested:{1} current:{2} latest:{3}" -f `
$p.name, $p.requestedVersion, $p.resolvedVersion, $p.latestVersion
Write-Severity -severity "info" -text $line
}
}
}
# Vulnerable (JSON)
$vulnJson = dotnet list $proj.FullName package --vulnerable --format json 2>$null
if ($vulnJson) {
$vulnObj = $vulnJson | ConvertFrom-Json
if ($vulnObj.vulnerablePackages) {
$results.Vulnerabilities += [ordered]@{
Project = $proj.FullName
NuGet = $vulnObj.vulnerablePackages
}
Write-Host " Vulnerable packages:"
foreach ($vp in $vulnObj.vulnerablePackages) {
$sev = To-Severity $vp.severity
$line = " - {0} {1} (severity:{2})" -f $vp.name, $vp.resolvedVersion, $sev
Write-Severity -severity $sev -text $line
if ($vp.advisories) {
foreach ($adv in $vp.advisories) {
$aline = " advisory: {0} ({1})" -f $adv.url, (To-Severity $adv.severity)
Write-Severity -severity (To-Severity $adv.severity) -text $aline
}
}
}
}
}
} catch {
Write-Host (" NuGet check failed for {0}: {1}" -f $proj.Name, $_.Exception.Message) -ForegroundColor Yellow
} finally {
Pop-Location
}
}
# ---------------------------
# npm (Node.js)
# ---------------------------
$packageJsons = Get-ChildItem -Path $projectPath -Recurse -Filter "package.json" -File | Where-Object { $_.FullName -notmatch "node_modules" }
foreach ($pkg in $packageJsons) {
$pkgDir = Split-Path $pkg.FullName -Parent
Write-Host "Checking npm folder: $pkgDir"
Push-Location $pkgDir
try {
# Deterministic install; suppress lifecycle scripts
if (Test-Path package-lock.json) {
$null = npm ci --ignore-scripts --no-progress 2>$null
} else {
$null = npm install --ignore-scripts --no-progress 2>$null
}
# Outdated (JSON)
$npmOutdated = npm outdated --json 2>$null
if ($npmOutdated) {
$outObj = $npmOutdated | ConvertFrom-Json
$results.Npm += [ordered]@{
Path = $pkgDir
Outdated= $outObj
}
Write-Host " Outdated packages:"
$props = $outObj.PSObject.Properties
foreach ($prop in $props) {
$pkgName = $prop.Name
$val = $prop.Value
$line = " - {0} current:{1} wanted:{2} latest:{3}" -f `
$pkgName, $val.current, $val.wanted, $val.latest
Write-Severity -severity "info" -text $line
}
}
# Audit (JSON) — supports modern and legacy shapes
$npmAudit = npm audit --json 2>$null
if ($npmAudit) {
$audit = $npmAudit | ConvertFrom-Json
$vulnBlock =
if ($audit.vulnerabilities) { $audit.vulnerabilities }
elseif ($audit.advisories) { $audit.advisories.Values }
else { $null }
if ($vulnBlock) {
$results.Vulnerabilities += [ordered]@{
Path = $pkgDir
Npm = $vulnBlock
}
if ($audit.vulnerabilities) {
# Modern shape: PSCustomObject whose properties are package names
$props = $audit.vulnerabilities.PSObject.Properties
foreach ($prop in $props) {
$name = $prop.Name
$info = $prop.Value
$sev = To-Severity $info.severity
$via = $info.via
if ($via -is [System.Collections.IEnumerable]) {
$viaText = ($via -join ", ")
} else {
$viaText = "$via"
}
$line = " - {0} severity:{1} via:{2}" -f $name, $sev, $viaText
Write-Severity -severity $sev -text $line
}
} elseif ($audit.advisories) {
# Legacy
foreach ($adv in $audit.advisories.Values) {
$sev = To-Severity $adv.severity
$line = " - {0} {1} severity:{2} url:{3}" -f $adv.module_name, $adv.findings[0].version, $sev, $adv.url
Write-Severity -severity $sev -text $line
}
}
}
}
} catch {
# NOTE: using $() around variables to avoid ':' parsing issues
Write-Host (" npm check failed for {0}: {1}" -f $pkgDir, $_.Exception.Message) -ForegroundColor Yellow
} finally {
Pop-Location
}
}
# ---------------------------
# Summary & Exit (always 0)
# ---------------------------
$results | ConvertTo-Json -Depth 8 | Out-File -Encoding utf8 $outputPath
Write-Host "Results exported to: $outputPath"
$hasIssues = ($results.NuGet.Count -gt 0) -or ($results.Npm.Count -gt 0) -or ($results.Vulnerabilities.Count -gt 0)
if ($hasIssues) {
Write-Host "Issues detected." -ForegroundColor Yellow
} else {
Write-Host "No dependency issues found." -ForegroundColor
}
管理安全腳本
| 行動 | 快速使用 | 目標 |
|---|---|---|
| 變更專案可見性:切換專案可見性(公開↔、私有) | az loginpwsh (參見本文中的腳本) -Organization "myorg" -ProjectId "myprojectid" -Visibility "private" |
變更許多專案的可見性設定時的自動化/大量更新 |
| 移除群組成員:從 Azure DevOps 群組移除成員 | az loginpwsh (參見本文中的腳本) -Organization "myorg" -GroupId "<group-guid>" -MemberId "<member-descriptor>" |
從群組權利中移除特定成員 (請謹慎使用;保留稽核追蹤) |
| 重新指派工作專案:將工作專案大量重新指派給新的受指派人 | az loginpwsh (參見本文中的腳本) -Organization "myorg" -Project "myproject" -WorkItemIds 123,456 -NewAssignee "Jane Doe <jane@contoso.com>" |
在移除/停用使用者之前重新指派作用中工作專案,以避免工作流程中斷 |
使用 Microsoft Entra ID 變更專案可見度 (公用↔私人)
此腳本會使用 Microsoft Entra ID 權杖變更專案可見度:
# Change Project Visibility Script using Microsoft Entra ID Token
param(
[Parameter(Mandatory=$true)]
[string]$organization,
[Parameter(Mandatory=$true)]
[string]$projectId,
[Parameter(Mandatory=$true)]
[ValidateSet("public", "private")]
[string]$visibility,
[string]$apiVersion = "6.0"
)
# Authenticate and get Microsoft Entra ID token
Write-Host "Authenticating to Azure..." -ForegroundColor Cyan
try {
# Get access token for Azure DevOps resource
$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798" # Azure DevOps resource ID
$tokenResponse = az account get-access-token --resource $resourceUrl --output json
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to get access token. Make sure you're logged in with 'az login'"
exit 1
}
$tokenInfo = $tokenResponse | ConvertFrom-Json
$accessToken = $tokenInfo.accessToken
Write-Host "Successfully obtained Microsoft Entra ID access token" -ForegroundColor Green
}
catch {
Write-Error "Failed to authenticate with Microsoft Entra ID: $($_.Exception.Message)"
exit 1
}
# Setup headers
$headers = @{
Authorization = "Bearer $accessToken"
"Content-Type" = "application/json"
}
# Get current project details first
Write-Host "Getting current project details..." -ForegroundColor Yellow
try {
$projectUrl = "https://dev.azure.com/$organization/_apis/projects/$projectId" + "?api-version=$apiVersion"
$currentProject = Invoke-RestMethod -Uri $projectUrl -Method Get -Headers $headers
Write-Host "Current project '$($currentProject.name)' visibility: $($currentProject.visibility)" -ForegroundColor White
}
catch {
Write-Error "Failed to get project details: $($_.Exception.Message)"
Write-Error "Make sure the organization name and project ID are correct, and you have proper permissions"
exit 1
}
# Check if change is needed
if ($currentProject.visibility -eq $visibility) {
Write-Host "Project is already $visibility. No changes needed." -ForegroundColor Green
exit 0
}
# Validate organization policy for public projects
if ($visibility -eq "public") {
Write-Host "Checking if organization allows public projects..." -ForegroundColor Yellow
try {
$policiesUrl = "https://dev.azure.com/$organization/_apis/policy/configurations?api-version=6.0"
$policies = Invoke-RestMethod -Uri $policiesUrl -Method Get -Headers $headers
# Note: This is a simplified check. In practice, you might need to check specific policy types
Write-Host "Organization policy check completed" -ForegroundColor Green
}
catch {
Write-Warning "Could not verify organization public project policy. Proceeding with caution..."
}
}
# Prepare update payload
$updatePayload = @{
name = $currentProject.name
description = $currentProject.description
visibility = $visibility
} | ConvertTo-Json
Write-Host "Updating project visibility to '$visibility'..." -ForegroundColor Cyan
try {
# Update project visibility
$updateUrl = "https://dev.azure.com/$organization/_apis/projects/$projectId" + "?api-version=$apiVersion"
$response = Invoke-RestMethod -Uri $updateUrl -Method Patch -Headers $headers -Body $updatePayload
# Check operation status
if ($response.status -eq "succeeded") {
Write-Host "Project visibility successfully changed to '$visibility'" -ForegroundColor Green
}
elseif ($response.status -eq "queued" -or $response.status -eq "inProgress") {
Write-Host "Project visibility change is in progress..." -ForegroundColor Yellow
# Poll for completion
$operationUrl = $response.url
$maxAttempts = 30
$attempt = 0
do {
Start-Sleep -Seconds 2
$attempt++
try {
$operationStatus = Invoke-RestMethod -Uri $operationUrl -Method Get -Headers $headers
Write-Host " Attempt $attempt/$maxAttempts - Status: $($operationStatus.status)" -ForegroundColor Gray
if ($operationStatus.status -eq "succeeded") {
Write-Host "Project visibility successfully changed to '$visibility'" -ForegroundColor Green
break
}
elseif ($operationStatus.status -eq "failed") {
Write-Error "Project visibility change failed: $($operationStatus.resultMessage)"
exit 1
}
}
catch {
Write-Warning "Could not check operation status: $($_.Exception.Message)"
}
} while ($attempt -lt $maxAttempts)
if ($attempt -ge $maxAttempts) {
Write-Warning "Operation status check timed out. Please verify the change manually in Azure DevOps."
}
}
else {
Write-Error "Unexpected response status: $($response.status)"
exit 1
}
}
catch {
$errorMessage = $_.Exception.Message
# Parse specific error scenarios
if ($errorMessage -like "*403*" -or $errorMessage -like "*Forbidden*") {
Write-Error "Access denied. You need 'Project Administrator' or higher permissions to change project visibility."
}
elseif ($errorMessage -like "*public projects*") {
Write-Error "Organization policy doesn't allow public projects. Enable 'Allow public projects' in Organization Settings > Policies."
}
else {
Write-Error "Failed to update project visibility: $errorMessage"
}
exit 1
}
# Verify the change
Write-Host "Verifying project visibility change..." -ForegroundColor Yellow
try {
Start-Sleep -Seconds 3 # Give the system time to update
$verifyProject = Invoke-RestMethod -Uri $projectUrl -Method Get -Headers $headers
if ($verifyProject.visibility -eq $visibility) {
Write-Host "Verification successful: Project is now $visibility" -ForegroundColor Green
# Display important warnings for public projects
if ($visibility -eq "public") {
Write-Host "`nIMPORTANT SECURITY NOTICE:" -ForegroundColor Red
Write-Host "- This project and ALL its contents are now publicly visible on the internet" -ForegroundColor Yellow
Write-Host "- Repository code, work items, wikis, and artifacts are accessible to anyone" -ForegroundColor Yellow
Write-Host "- 'Deny' permissions are not enforced for public projects" -ForegroundColor Yellow
Write-Host "- Review all content for sensitive information before making public" -ForegroundColor Yellow
}
}
else {
Write-Warning "Verification failed: Project visibility is still $($verifyProject.visibility)"
}
}
catch {
Write-Warning "Could not verify the change: $($_.Exception.Message)"
}
Write-Host "`nProject visibility change completed." -ForegroundColor Cyan
移除群組成員
此腳本會從 Azure DevOps 群組移除成員:
# Remove Azure DevOps Group Member Script
param(
[Parameter(Mandatory=$true)]
[string]$organization,
[Parameter(Mandatory=$true)]
[string]$groupId,
[Parameter(Mandatory=$true)]
[string]$memberId,
[string]$apiVersion = "7.1-preview",
[switch]$WhatIf
)
# Authenticate and get Microsoft Entra ID token
Write-Host "Authenticating to Azure..." -ForegroundColor Cyan
try {
$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
$tokenResponse = az account get-access-token --resource $resourceUrl --output json
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to get access token. Make sure you're logged in with 'az login'"
exit 1
}
$tokenInfo = $tokenResponse | ConvertFrom-Json
$accessToken = $tokenInfo.accessToken
Write-Host "Successfully obtained Microsoft Entra ID access token" -ForegroundColor Green
}
catch {
Write-Error "Failed to authenticate with Microsoft Entra ID: $($_.Exception.Message)"
exit 1
}
# Setup headers
$headers = @{
Authorization = "Bearer $accessToken"
"Content-Type" = "application/json"
}
Write-Host "Starting group member removal process..." -ForegroundColor Cyan
try {
# Get group information
$groupUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/groups/$groupId" + "?api-version=$apiVersion"
$group = Invoke-RestMethod -Uri $groupUrl -Method Get -Headers $headers
Write-Host "Target group: $($group.displayName) ($($group.principalName))" -ForegroundColor Yellow
# Get member information
$memberUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/users/$memberId" + "?api-version=$apiVersion"
try {
$member = Invoke-RestMethod -Uri $memberUrl -Method Get -Headers $headers
Write-Host "Target member: $($member.displayName) ($($member.mailAddress))" -ForegroundColor Yellow
}
catch {
# Try as a group instead of user
$memberUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/groups/$memberId" + "?api-version=$apiVersion"
$member = Invoke-RestMethod -Uri $memberUrl -Method Get -Headers $headers
Write-Host "Target member (group): $($member.displayName) ($($member.principalName))" -ForegroundColor Yellow
}
# Check if member is actually in the group
$membershipsUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/memberships/$memberId" + "?direction=up&api-version=$apiVersion"
$memberships = Invoke-RestMethod -Uri $membershipsUrl -Method Get -Headers $headers
$isMember = $false
foreach ($membership in $memberships.value) {
if ($membership.containerDescriptor -eq $group.descriptor) {
$isMember = $true
break
}
}
if (-not $isMember) {
Write-Host "Member is not currently in the specified group. No action needed." -ForegroundColor Green
exit 0
}
# Show what will be done
Write-Host "`nOperation Summary:" -ForegroundColor Cyan
Write-Host " Action: Remove member from group" -ForegroundColor White
Write-Host " Group: $($group.displayName)" -ForegroundColor White
Write-Host " Member: $($member.displayName)" -ForegroundColor White
Write-Host " Organization: $organization" -ForegroundColor White
if ($WhatIf) {
Write-Host "`n[WHAT-IF] Would remove member from group (no actual changes made)" -ForegroundColor Yellow
exit 0
}
# Confirm before proceeding
Write-Host "`nWARNING: This action will remove the member from the group and may affect their access to projects and resources." -ForegroundColor Red
$confirmation = Read-Host "Are you sure you want to proceed? (y/N)"
if ($confirmation -notmatch '^[Yy]') {
Write-Host "Operation cancelled by user." -ForegroundColor Yellow
exit 0
}
# Remove member from group
Write-Host "`nRemoving member from group..." -ForegroundColor Cyan
$removeUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/memberships/$memberId/$($group.descriptor)" + "?api-version=$apiVersion"
try {
Invoke-RestMethod -Uri $removeUrl -Method Delete -Headers $headers
Write-Host "Successfully removed member from group" -ForegroundColor Green
# Verify removal
Start-Sleep -Seconds 2
$verifyMemberships = Invoke-RestMethod -Uri $membershipsUrl -Method Get -Headers $headers
$stillMember = $false
foreach ($membership in $verifyMemberships.value) {
if ($membership.containerDescriptor -eq $group.descriptor) {
$stillMember = $true
break
}
}
if (-not $stillMember) {
Write-Host "Verification successful: Member has been removed from the group" -ForegroundColor Green
}
else {
Write-Warning "Verification failed: Member may still be in the group"
}
# Log the action for audit trail
$auditEntry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Action = "Remove Group Member"
Organization = $organization
GroupName = $group.displayName
GroupId = $groupId
MemberName = $member.displayName
MemberId = $memberId
ExecutedBy = (az account show --query user.name -o tsv)
Status = "Success"
}
$auditPath = "C:\AzureDevOps\GroupMemberRemoval_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
$auditEntry | ConvertTo-Json | Out-File $auditPath
Write-Host "Audit log saved to: $auditPath" -ForegroundColor Green
}
catch {
$errorMessage = $_.Exception.Message
if ($errorMessage -like "*403*" -or $errorMessage -like "*Forbidden*") {
Write-Error "Access denied. You need appropriate permissions to manage group memberships."
}
elseif ($errorMessage -like "*404*" -or $errorMessage -like "*Not Found*") {
Write-Error "Group or member not found. Please verify the IDs are correct."
}
else {
Write-Error "Failed to remove member from group: $errorMessage"
}
exit 1
}
}
catch {
Write-Error "Failed to process group member removal: $($_.Exception.Message)"
exit 1
}
Write-Host "`nGroup member removal completed." -ForegroundColor Cyan
重新指派工作項目
此腳本會將工作專案重新指派給新的受指派人:
# Reassign Work Items Script
param(
[Parameter(Mandatory=$true)]
[string]$organization,
[Parameter(Mandatory=$true)]
[string]$project,
[Parameter(Mandatory=$true)]
[int[]]$workItemIds,
[Parameter(Mandatory=$true)]
[string]$newAssignee,
[string]$apiVersion = "7.1-preview",
[switch]$WhatIf
)
# Authenticate and get Microsoft Entra ID token
Write-Host "Authenticating to Azure..." -ForegroundColor Cyan
try {
$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
$tokenResponse = az account get-access-token --resource $resourceUrl --output json
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to get access token. Make sure you're logged in with 'az login'"
exit 1
}
$tokenInfo = $tokenResponse | ConvertFrom-Json
$accessToken = $tokenInfo.accessToken
Write-Host "Successfully obtained Microsoft Entra ID access token" -ForegroundColor Green
}
catch {
Write-Error "Failed to authenticate with Microsoft Entra ID: $($_.Exception.Message)"
exit 1
}
# Setup headers
$headers = @{
Authorization = "Bearer $accessToken"
"Content-Type" = "application/json-patch+json"
}
Write-Host "Starting work item reassignment process..." -ForegroundColor Cyan
$successfulUpdates = @()
$failedUpdates = @()
foreach ($workItemId in $workItemIds) {
try {
# Get current work item details
$workItemUrl = "https://dev.azure.com/$organization/$project/_apis/wit/workitems/$workItemId" + "?api-version=$apiVersion"
$workItem = Invoke-RestMethod -Uri $workItemUrl -Method Get -Headers $headers
$currentAssignee = if ($workItem.fields.'System.AssignedTo') {
$workItem.fields.'System.AssignedTo'.displayName
} else {
"Unassigned"
}
Write-Host "`nWork Item $workItemId ($($workItem.fields.'System.Title'))" -ForegroundColor Yellow
Write-Host " Current Assignee: $currentAssignee" -ForegroundColor White
Write-Host " New Assignee: $newAssignee" -ForegroundColor White
Write-Host " State: $($workItem.fields.'System.State')" -ForegroundColor White
Write-Host " Work Item Type: $($workItem.fields.'System.WorkItemType')" -ForegroundColor White
if ($WhatIf) {
Write-Host " [WHAT-IF] Would reassign to $newAssignee" -ForegroundColor Yellow
continue
}
# Skip if already assigned to the target user
if ($workItem.fields.'System.AssignedTo' -and $workItem.fields.'System.AssignedTo'.displayName -eq $newAssignee) {
Write-Host " Already assigned to $newAssignee - skipping" -ForegroundColor Green
continue
}
# Prepare update payload
$updatePayload = @(
@{
op = "replace"
path = "/fields/System.AssignedTo"
value = $newAssignee
}
)
# Add a comment about the reassignment
$comment = "Work item reassigned from '$currentAssignee' to '$newAssignee' via automation on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
$updatePayload += @{
op = "add"
path = "/fields/System.History"
value = $comment
}
$updateBody = $updatePayload | ConvertTo-Json -Depth 3
# Update the work item
$updateResponse = Invoke-RestMethod -Uri $workItemUrl -Method Patch -Headers $headers -Body $updateBody
Write-Host " ✓ Successfully reassigned" -ForegroundColor Green
$successfulUpdates += [PSCustomObject]@{
WorkItemId = $workItemId
Title = $workItem.fields.'System.Title'
PreviousAssignee = $currentAssignee
NewAssignee = $newAssignee
WorkItemType = $workItem.fields.'System.WorkItemType'
State = $workItem.fields.'System.State'
UpdatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
catch {
$errorMessage = $_.Exception.Message
Write-Host " ✗ Failed to reassign: $errorMessage" -ForegroundColor Red
$failedUpdates += [PSCustomObject]@{
WorkItemId = $workItemId
Error = $errorMessage
AttemptedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
}
}
# Generate summary report
Write-Host "`n" + "="*50 -ForegroundColor Cyan
Write-Host "REASSIGNMENT SUMMARY" -ForegroundColor Cyan
Write-Host "="*50 -ForegroundColor Cyan
Write-Host "Total work items processed: $($workItemIds.Count)" -ForegroundColor White
Write-Host "Successful reassignments: $($successfulUpdates.Count)" -ForegroundColor Green
Write-Host "Failed reassignments: $($failedUpdates.Count)" -ForegroundColor Red
if ($successfulUpdates.Count -gt 0) {
Write-Host "`nSuccessful Updates:" -ForegroundColor Green
$successfulUpdates | Format-Table -AutoSize
# Export successful updates
$successPath = "C:\AzureDevOps\WorkItemReassignment_Success_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$successfulUpdates | Export-Csv -Path $successPath -NoTypeInformation
Write-Host "Successful updates exported to: $successPath" -ForegroundColor Green
}
if ($failedUpdates.Count -gt 0) {
Write-Host "`nFailed Updates:" -ForegroundColor Red
$failedUpdates | Format-Table -AutoSize
# Export failed updates
$failedPath = "C:\AzureDevOps\WorkItemReassignment_Failed_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$failedUpdates | Export-Csv -Path $failedPath -NoTypeInformation
Write-Host "Failed updates exported to: $failedPath" -ForegroundColor Yellow
}
# Create audit log
$auditEntry = [PSCustomObject]@{
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Action = "Bulk Work Item Reassignment"
Organization = $organization
Project = $project
NewAssignee = $newAssignee
TotalWorkItems = $workItemIds.Count
SuccessfulUpdates = $successfulUpdates.Count
FailedUpdates = $failedUpdates.Count
ExecutedBy = (az account show --query user.name -o tsv)
WorkItemIds = ($workItemIds -join ", ")
}
$auditPath = "C:\AzureDevOps\WorkItemReassignment_Audit_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
$auditEntry | ConvertTo-Json -Depth 3 | Out-File $auditPath
Write-Host "`nAudit log saved to: $auditPath" -ForegroundColor Green
if ($WhatIf) {
Write-Host "`n[WHAT-IF MODE] No actual changes were made" -ForegroundColor Yellow
}
Write-Host "`nWork item reassignment process completed." -ForegroundColor Cyan
# Exit with appropriate code
if ($failedUpdates.Count -gt 0) {
exit 1
} else {
exit 0
}
支持與審計
- 保留自動變更的審計記錄
- 安全地儲存認證 (金鑰保存庫或管線秘密變數)
- 如果在執行大量作業之後需要認證輪替,請撤銷權杖/服務主體
- 在生產環境中執行之前,先在非生產環境中進行測試