共用方式為


自動化安全稽核和管理

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 login
pwsh (參見本文中的腳本) -Organization "myorg" -ProjectId "myprojectid" -Visibility "private"
變更許多專案的可見性設定時的自動化/大量更新
移除群組成員:從 Azure DevOps 群組移除成員 az login
pwsh (參見本文中的腳本) -Organization "myorg" -GroupId "<group-guid>" -MemberId "<member-descriptor>"
從群組權利中移除特定成員 (請謹慎使用;保留稽核追蹤)
重新指派工作專案:將工作專案大量重新指派給新的受指派人 az login
pwsh (參見本文中的腳本) -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
}

支持與審計

  • 保留自動變更的審計記錄
  • 安全地儲存認證 (金鑰保存庫或管線秘密變數)
  • 如果在執行大量作業之後需要認證輪替,請撤銷權杖/服務主體
  • 在生產環境中執行之前,先在非生產環境中進行測試