refactor(scripts): optimize system remediation and M365 export scripts

- teams: harden Classic Teams removal, protect New Teams, and clean HKU run keys
- dotnet: add bootstrapper EXE uninstall support and remove orphaned 3.1.* folders
- teams: add MSIX package targeting and version parsing for vulnerable New Teams builds
- m365: improve Graph SDK export speed, reuse sessions, and add null-safe handling
This commit is contained in:
mattspeer committed 2026-10-04 10:58:05 -05:00
1 parent 4fd95b76d6
commit 5ef82842f2
19 files changed
+1651 -1268

No files matched your search

@@ -0,0 +1,458 @@
<#
.SYNOPSIS
Exports completed DocuSign envelopes and Certificates of Completion via the eSignature REST API.
.DESCRIPTION
Uses the DocuSign eSignature REST API to:
1. Authenticate with JWT Bearer assertion and handle automatic token renewal.
2. Enumerate completed envelopes across a specified date range with API pagination.
3. Download each envelope as an archive ZIP or combined PDF.
4. Support incremental runs by skipping previously downloaded files.
5. Output a detailed CSV export log and execution summary.
.PARAMETER IntegrationKey
The DocuSign App / Integration Key (Client ID).
.PARAMETER UserId
The API User ID (GUID) of the user being impersonated.
.PARAMETER AccountId
The DocuSign Account ID (GUID).
.PARAMETER PrivateKeyPath
Path to the RSA private key PEM file associated with the Integration Key.
.PARAMETER FromDate
Start date for querying completed envelopes.
.PARAMETER ToDate
End date for querying completed envelopes. Defaults to the current date/time.
.PARAMETER OutputRoot
Root directory where downloads and logs will be saved.
.PARAMETER Environment
DocuSign environment target: 'Production' or 'Demo'. Defaults to 'Production'.
.PARAMETER OutputFormat
Format to download documents: 'ArchiveZip' (ZIP containing all docs + certificate) or 'CombinedPdf' (Single combined PDF).
.PARAMETER SkipExisting
If specified, skips downloading envelopes whose target file already exists locally. Defaults to $true.
.PARAMETER PageSize
Number of envelopes to request per API page call. Defaults to 1000 (DocuSign max limit).
.PARAMETER RetryCount
Maximum retry attempts for transient API errors or rate-limit throttling. Defaults to 4.
.EXAMPLE
.\Export-DocuSignCompletedAgreements.ps1 `
-IntegrationKey "00000000-0000-0000-0000-000000000000" `
-UserId "11111111-1111-1111-1111-111111111111" `
-AccountId "22222222-2222-2222-2222-222222222222" `
-PrivateKeyPath "C:\Keys\docusign_private.key" `
-FromDate "2024-01-01" `
-OutputRoot "D:\DocuSignExport" `
-OutputFormat ArchiveZip
.NOTES
Created: 2025-03-05
Modified: 2026-10-04
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$IntegrationKey,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$UserId,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$AccountId,
[Parameter(Mandatory = $true)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]$PrivateKeyPath,
[Parameter(Mandatory = $true)]
[datetime]$FromDate,
[Parameter(Mandatory = $false)]
[datetime]$ToDate = (Get-Date),
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$OutputRoot,
[Parameter(Mandatory = $false)]
[ValidateSet("Production", "Demo")]
[string]$Environment = "Production",
[Parameter(Mandatory = $false)]
[ValidateSet("ArchiveZip", "CombinedPdf")]
[string]$OutputFormat = "ArchiveZip",
[Parameter(Mandatory = $false)]
[bool]$SkipExisting = $true,
[Parameter(Mandatory = $false)]
[ValidateRange(1, 1000)]
[int]$PageSize = 1000,
[Parameter(Mandatory = $false)]
[int]$RetryCount = 4
)
$ErrorActionPreference = "Stop"
# Global state tracking for OAuth token management
$script:currentToken = $null
$script:tokenExpiration = [DateTimeOffset]::MinValue
$script:apiBaseUri = $null
# Helper Functions
function ConvertTo-Base64Url {
param([byte[]]$Bytes)
[Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
}
function Import-RsaPrivateKey {
param([string]$Path)
$pem = Get-Content -Path $Path -Raw
$rsa = [System.Security.Cryptography.RSA]::Create()
if ($pem -match "-----BEGIN RSA PRIVATE KEY-----") {
$body = $pem -replace "-----BEGIN RSA PRIVATE KEY-----", "" -replace "-----END RSA PRIVATE KEY-----", "" -replace "\s", ""
$bytes = [Convert]::FromBase64String($body)
[void]$rsa.ImportRSAPrivateKey($bytes, [ref]0)
return $rsa
}
if ($pem -match "-----BEGIN PRIVATE KEY-----") {
$body = $pem -replace "-----BEGIN PRIVATE KEY-----", "" -replace "-----END PRIVATE KEY-----", "" -replace "\s", ""
$bytes = [Convert]::FromBase64String($body)
[void]$rsa.ImportPkcs8PrivateKey($bytes, [ref]0)
return $rsa
}
throw "Private key must be PEM formatted with 'BEGIN RSA PRIVATE KEY' or 'BEGIN PRIVATE KEY'."
}
function New-DocuSignJwtAssertion {
param(
[string]$IntegrationKey,
[string]$UserId,
[string]$AuthServer,
[string]$PrivateKeyPath
)
$now = [DateTimeOffset]::UtcNow
$header = @{
alg = "RS256"
typ = "JWT"
} | ConvertTo-Json -Compress
$payload = @{
iss = $IntegrationKey
sub = $UserId
aud = $AuthServer
iat = $now.ToUnixTimeSeconds()
exp = $now.AddMinutes(55).ToUnixTimeSeconds()
scope = "signature impersonation"
} | ConvertTo-Json -Compress
$encodedHeader = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($header))
$encodedPayload = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($payload))
$unsignedToken = "$encodedHeader.$encodedPayload"
$rsa = Import-RsaPrivateKey -Path $PrivateKeyPath
try {
$signature = $rsa.SignData(
[Text.Encoding]::UTF8.GetBytes($unsignedToken),
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.RSASignaturePadding]::Pkcs1
)
}
finally {
$rsa.Dispose()
}
return "$unsignedToken.$(ConvertTo-Base64Url $signature)"
}
function Get-ValidAccessToken {
# Refresh token 5 minutes before actual expiry to avoid race conditions
if ($null -ne $script:currentToken -and [DateTimeOffset]::UtcNow -lt $script:tokenExpiration) {
return $script:currentToken
}
Write-Verbose "Obtaining fresh DocuSign access token..."
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
$tokenUri = "https://$authServer/oauth/token"
$assertion = New-DocuSignJwtAssertion `
-IntegrationKey $IntegrationKey `
-UserId $UserId `
-AuthServer $authServer `
-PrivateKeyPath $PrivateKeyPath
$tokenResponse = Invoke-WithRetry -RetryCount $RetryCount -ActionName "JWT Token Request" -ScriptBlock {
Invoke-RestMethod `
-Method Post `
-Uri $tokenUri `
-ContentType "application/x-www-form-urlencoded" `
-Body @{
grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
assertion = $assertion
}
}
$script:currentToken = $tokenResponse.access_token
# DocuSign tokens typically last 3600s (1 hr); refresh 5 mins early
$expiresInSeconds = [int]($tokenResponse.expires_in ?? 3600) - 300
$script:tokenExpiration = [DateTimeOffset]::UtcNow.AddSeconds($expiresInSeconds)
return $script:currentToken
}
function Invoke-WithRetry {
param(
[scriptblock]$ScriptBlock,
[int]$RetryCount = 4,
[string]$ActionName = "DocuSign API request"
)
for ($attempt = 1; $attempt -le ($RetryCount + 1); $attempt++) {
try {
return & $ScriptBlock
}
catch {
$response = $_.Exception.Response
$statusCode = $null
if ($response -and $response.StatusCode) {
$statusCode = [int]$response.StatusCode
}
$retryable = $statusCode -in @(429, 500, 502, 503, 504)
if (-not $retryable -or $attempt -gt $RetryCount) {
throw
}
# Honor DocuSign 'Retry-After' header if provided on HTTP 429
$delaySeconds = 0
if ($statusCode -eq 429 -and $response.Headers["Retry-After"]) {
[int]::TryParse($response.Headers["Retry-After"], [ref]$delaySeconds) | Out-Null
}
if ($delaySeconds -le 0) {
$delaySeconds = [math]::Min(60, [math]::Pow(2, $attempt))
}
Write-Warning "$ActionName encountered HTTP $statusCode. Retrying in $delaySeconds seconds (Attempt $attempt of $RetryCount)..."
Start-Sleep -Seconds $delaySeconds
}
}
}
function Get-SafeFileName {
param(
[string]$Text,
[int]$MaxLength = 100
)
$invalid = [Regex]::Escape(([IO.Path]::GetInvalidFileNameChars() -join ""))
$safe = [Regex]::Replace($Text, "[$invalid]", "_")
$safe = [Regex]::Replace($safe, "\s+", " ").Trim()
if ([string]::IsNullOrWhiteSpace($safe)) {
$safe = "Envelope"
}
if ($safe.Length -gt $MaxLength) {
$safe = $safe.Substring(0, $MaxLength).Trim()
}
return $safe
}
function Save-EnvelopeDocument {
param(
[string]$Uri,
[string]$Path,
[string]$AcceptHeader,
[int]$RetryCount
)
Invoke-WithRetry -RetryCount $RetryCount -ActionName "Downloading document to '$Path'" -ScriptBlock {
$token = Get-ValidAccessToken
$headers = @{
Authorization = "Bearer $token"
Accept = $AcceptHeader
}
Invoke-WebRequest -Uri $Uri -Headers $headers -OutFile $Path -ErrorAction Stop
} | Out-Null
}
# Main Execution Flow
# 1. Directory Initialization
$downloadFolder = Join-Path $OutputRoot "completed-agreements"
$logFolder = Join-Path $OutputRoot "logs"
New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
New-Item -ItemType Directory -Path $logFolder -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$logPath = Join-Path $logFolder "docusign-export-log-$timestamp.csv"
# 2. Base URI Discovery
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
$initialToken = Get-ValidAccessToken
Write-Host "Discovering account base URI for Account ID: $AccountId..." -ForegroundColor Cyan
$userInfo = Invoke-WithRetry -RetryCount $RetryCount -ActionName "UserInfo Discovery" -ScriptBlock {
Invoke-RestMethod -Method Get -Uri "https://$authServer/oauth/userinfo" -Headers @{ Authorization = "Bearer $initialToken" }
}
$account = $userInfo.accounts | Where-Object { $_.account_id -eq $AccountId } | Select-Object -First 1
if (-not $account) {
throw "AccountId '$AccountId' was not found for the authenticated impersonated user."
}
$baseUri = $account.base_uri.TrimEnd("/")
$script:apiBaseUri = "$baseUri/restapi/v2.1/accounts/$AccountId"
# 3. Request Format Settings
$documentId = if ($OutputFormat -eq "ArchiveZip") { "archive" } else { "combined" }
$extension = if ($OutputFormat -eq "ArchiveZip") { ".zip" } else { ".pdf" }
$accept = if ($OutputFormat -eq "ArchiveZip") { "application/zip" } else { "application/pdf" }
$fromIso = $FromDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$toIso = $ToDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
Write-Host "Querying completed envelopes from $fromIso to $toIso..." -ForegroundColor Cyan
# 4. Enumerate Completed Envelopes (Paginated)
$allEnvelopes = [System.Collections.Generic.List[object]]::new()
$startPosition = 0
do {
$query = @{
from_date = $fromIso
to_date = $toIso
status = "completed"
count = $PageSize
start_position = $startPosition
order_by = "completed"
order = "asc"
}
$queryString = ($query.GetEnumerator() | ForEach-Object {
"{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString([string]$_.Value)
}) -join "&"
$uri = "$($script:apiBaseUri)/envelopes?$queryString"
$page = Invoke-WithRetry -RetryCount $RetryCount -ActionName "Fetching Envelope List Page (Offset: $startPosition)" -ScriptBlock {
$token = Get-ValidAccessToken
Invoke-RestMethod -Method Get -Uri $uri -Headers @{ Authorization = "Bearer $token" }
}
if ($page.envelopes) {
foreach ($env in $page.envelopes) {
$allEnvelopes.Add($env)
}
}
$returnedCount = if ($page.envelopes) { @($page.envelopes).Count } else { 0 }
Write-Host " Retrieved $returnedCount envelopes (Page Start: $startPosition)." -ForegroundColor Gray
$startPosition += $PageSize
$totalSetSize = [int]($page.totalSetSize ?? 0)
} while ($allEnvelopes.Count -lt $totalSetSize -and $returnedCount -gt 0)
Write-Host "Total completed envelopes identified: $($allEnvelopes.Count)" -ForegroundColor Green
# 5. Download Documents & Build Audit Log
$logRows = [System.Collections.Generic.List[object]]::new()
$counter = 0
foreach ($envelope in $allEnvelopes) {
$counter++
$envelopeId = $envelope.envelopeId
$subject = Get-SafeFileName -Text $envelope.emailSubject -MaxLength 80
$completedRaw = if ($envelope.completedDateTime) { $envelope.completedDateTime } else { $envelope.statusChangedDateTime }
$completedDate = if ($completedRaw) {
([datetime]$completedRaw).ToString("yyyy-MM-dd")
} else {
"unknown-date"
}
$fileName = "$completedDate - $subject - $envelopeId$extension"
$filePath = Join-Path $downloadFolder $fileName
$downloadUri = "$($script:apiBaseUri)/envelopes/$envelopeId/documents/$documentId`?certificate=true"
Write-Progress `
-Activity "Downloading DocuSign Agreements" `
-Status "Item $counter of$($allEnvelopes.Count):$subject" `
-PercentComplete (($counter / [math]::Max(1, $allEnvelopes.Count)) * 100)
$status = "Downloaded"
$errorMessage = ""
# Check for incremental skip
if ($SkipExisting -and (Test-Path -LiteralPath $filePath)) {
$status = "Skipped (Exists)"
Write-Verbose "Skipping existing file: $filePath"
}
else {
try {
Save-EnvelopeDocument -Uri $downloadUri -Path $filePath -AcceptHeader $accept -RetryCount $RetryCount
}
catch {
$status = "Failed"
$errorMessage = $_.Exception.Message
Write-Warning "Failed to download envelope $envelopeId`: $errorMessage"
}
}
$logRows.Add([PSCustomObject]@{
EnvelopeId = $envelopeId
Status = $status
EnvelopeStatus = $envelope.status
CompletedDate = $completedRaw
Subject = $envelope.emailSubject
OutputFormat = $OutputFormat
OutputPath = $filePath
Error = $errorMessage
})
}
# 6. Save Export Summary Log
$logRows \vert{} Export-Csv -Path$logPath -NoTypeInformation -Encoding UTF8
$downloadedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Downloaded" }).Count
$skippedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Skipped (Exists)" }).Count
$failedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Failed" }).Count
Write-Host "`n========================================" -ForegroundColor Green
Write-Host "DocuSign Agreement Export Completed" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host " Total Identified : $($allEnvelopes.Count)"
Write-Host " Downloaded : $downloadedCount"
Write-Host " Skipped Existing : $skippedCount"
Write-Host " Failed : $failedCount"
Write-Host " Files Saved To : $downloadFolder"
Write-Host " Audit Log Saved : $logPath`n"
if ($failedCount -gt 0) {
Write-Warning "One or more envelope downloads failed. Review the CSV audit log for detailed error reasons."
}