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:
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."
|
||||
}
|
||||
Reference in new issue
Block a user