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
Executable
+90
@@ -0,0 +1,90 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Audits active network adapters to verify malware-filtering DNS configuration.
|
||||
|
||||
.DESCRIPTION
|
||||
Retrieves all connected network adapters ('Up' status) and checks whether their configured
|
||||
DNS server addresses match an approved list of security-filtering DNS providers
|
||||
(e.g., Quad9 or Cloudflare for Families).
|
||||
|
||||
Outputs a compressed JSON payload for RMM systems or Intune Custom Compliance discovery.
|
||||
|
||||
.PARAMETER ApprovedDnsServers
|
||||
Array of IPv4 DNS server addresses considered compliant.
|
||||
Defaults to Quad9 (9.9.9.9, 149.112.112.11) and Cloudflare Security/Family (1.1.1.2, 1.0.0.2, 1.1.1.3, 1.0.0.3).
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-DnsCompliance.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-DnsCompliance.ps1 -ApprovedDnsServers "9.9.9.9", "1.1.1.2"
|
||||
|
||||
.NOTES
|
||||
Created: 2025-03-05
|
||||
Modified: 2026-10-04
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[string[]]$ApprovedDnsServers = @(
|
||||
"9.9.9.9", # Quad9 Primary (Malware + DNSSEC)
|
||||
"149.112.112.11", # Quad9 Secondary (Malware + DNSSEC)
|
||||
"1.1.1.2", # Cloudflare Security Primary (Malware)
|
||||
"1.0.0.2", # Cloudflare Security Secondary (Malware)
|
||||
"1.1.1.3", # Cloudflare Family Primary (Malware + Adult)
|
||||
"1.0.0.3" # Cloudflare Family Secondary (Malware + Adult)
|
||||
)
|
||||
)
|
||||
|
||||
# 1. Retrieve all active/connected network adapters
|
||||
$ActiveAdapters = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' }
|
||||
|
||||
if (-not $ActiveAdapters) {
|
||||
Write-Verbose "No active network adapters found on this endpoint."
|
||||
$NoAdapterOutput = [ordered]@{
|
||||
Compliant = $true
|
||||
Reason = "No active network adapters found."
|
||||
Adapters = @()
|
||||
}
|
||||
return ($NoAdapterOutput | ConvertTo-Json -Compress)
|
||||
}
|
||||
|
||||
$IsOverallCompliant = $true
|
||||
$AdapterAuditResults = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# 2. Audit DNS server assignments per active interface
|
||||
foreach ($Adapter in $ActiveAdapters) {
|
||||
|
||||
# Query assigned IPv4 DNS server addresses
|
||||
$DnsConfig = Get-DnsClientServerAddress -InterfaceIndex $Adapter.InterfaceIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||
$ConfiguredServers = $DnsConfig.ServerAddresses
|
||||
|
||||
# Verify if configured DNS contains at least one approved security DNS IP
|
||||
$MatchingServers = $ConfiguredServers | Where-Object { $ApprovedDnsServers -contains $_ }
|
||||
$IsAdapterCompliant = ($MatchingServers.Count -gt 0)
|
||||
|
||||
# If any active adapter fails the check, mark the endpoint overall non-compliant
|
||||
if (-not $IsAdapterCompliant) {
|
||||
$IsOverallCompliant = $false
|
||||
Write-Verbose "Adapter '$($Adapter.Name)' (Index: $($Adapter.InterfaceIndex)) is NON-COMPLIANT. Configured: $($ConfiguredServers -join ', ')"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Adapter '$($Adapter.Name)' (Index: $($Adapter.InterfaceIndex)) is COMPLIANT. Configured: $($ConfiguredServers -join ', ')"
|
||||
}
|
||||
|
||||
$AdapterAuditResults.Add([PSCustomObject]@{
|
||||
AdapterName = $Adapter.Name
|
||||
InterfaceIndex = $Adapter.InterfaceIndex
|
||||
ConfiguredDns = $ConfiguredServers
|
||||
IsCompliant = $IsAdapterCompliant
|
||||
})
|
||||
}
|
||||
|
||||
# 3. Construct summary object and output JSON
|
||||
$ComplianceReport = [ordered]@{
|
||||
Compliant = $IsOverallCompliant
|
||||
TestedAt = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||||
Adapters = $AdapterAuditResults
|
||||
}
|
||||
|
||||
return ($ComplianceReport | ConvertTo-Json -Compress)
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates interactive HTML and plain-text directory tree reports.
|
||||
|
||||
.DESCRIPTION
|
||||
Recursively scans a target directory and produces two report files:
|
||||
1. A plain-text ASCII directory tree file (.txt).
|
||||
2. An interactive HTML tree file (.html) with search filtering and collapse/expand controls.
|
||||
|
||||
.PARAMETER RootPath
|
||||
The target root folder path to scan.
|
||||
|
||||
.PARAMETER OutputDirectory
|
||||
The destination directory where reports will be saved. Defaults to the current working directory.
|
||||
|
||||
.PARAMETER ReportNamePrefix
|
||||
Optional custom prefix for generated filenames. Defaults to the sanitized root folder name.
|
||||
|
||||
.PARAMETER OpenInBrowser
|
||||
Switch parameter to automatically open the generated HTML report upon completion.
|
||||
|
||||
.EXAMPLE
|
||||
.\Export-DirectoryTree.ps1 -RootPath "E:\Sync Method\Corporate\Documents" -OutputDirectory "C:\Reports" -OpenInBrowser
|
||||
|
||||
.NOTES
|
||||
Created: 2025-03-02
|
||||
Modified: 2026-10-04
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline =$true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$RootPath,
|
||||
|
||||
[Parameter(Mandatory = $false, Position = 1)] [string]$OutputDirectory = (Get-Location).Path,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ReportNamePrefix,
|
||||
|
||||
[switch]$OpenInBrowser
|
||||
)
|
||||
|
||||
# 1. Validate & Resolve Paths
|
||||
if (-not (Test-Path -LiteralPath $RootPath -PathType Container)) {
|
||||
Write-Error "Target root directory not found or unreadable: '$RootPath'"
|
||||
return
|
||||
}
|
||||
|
||||
$ResolvedRoot = (Get-Item -LiteralPath$RootPath).FullName
|
||||
$RootLeafName = Split-Path -Path$ResolvedRoot -Leaf
|
||||
|
||||
# Auto-generate report prefix if not explicitly provided
|
||||
if ([string]::IsNullOrWhiteSpace($ReportNamePrefix)) {
|
||||
$ReportNamePrefix =$RootLeafName -replace '[\/\\:\*\?"<>\|]', '-'
|
||||
}
|
||||
|
||||
# Ensure output directory exists
|
||||
if (-not (Test-Path -LiteralPath $OutputDirectory)) {
|
||||
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
$TextFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.txt"
|
||||
$HtmlFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.html"
|
||||
|
||||
# 2. State Tracking Setup
|
||||
$script:textLines = [System.Collections.Generic.List[string]]::new()
|
||||
$script:html = [System.Text.StringBuilder]::new()$script:scanErrors = 0
|
||||
$script:totalFiles = 0$script:totalFolders = 0
|
||||
|
||||
function ConvertTo-HtmlEncoded {
|
||||
param([string]$Text)
|
||||
return [System.Net.WebUtility]::HtmlEncode($Text)
|
||||
}
|
||||
|
||||
function Add-TreeItems {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Prefix
|
||||
)
|
||||
|
||||
try {
|
||||
$items = @(
|
||||
Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop |
|
||||
Sort-Object @{ Expression = { if ($_.PSIsContainer) { 0 } else { 1 } } }, Name
|
||||
)
|
||||
}
|
||||
catch {
|
||||
$script:scanErrors++
|
||||
[void]$script:textLines.Add("$Prefix[Unable to read: $($_.Exception.Message)]")
|
||||
[void]$script:html.AppendLine(
|
||||
"<li class='error'>Unable to read: $(ConvertTo-HtmlEncoded$_.Exception.Message)</li>"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $items.Count; $index++) {
|
||||
$item =$items[$index]$isLast = $index -eq ($items.Count - 1)
|
||||
$connector = if ($isLast) { '\-- ' } else { '|-- ' }
|
||||
$nextPrefix =$Prefix + $(if ($isLast) { ' ' } else { '| ' })
|
||||
|
||||
$nameHtml = ConvertTo-HtmlEncoded$item.Name
|
||||
$pathHtml = ConvertTo-HtmlEncoded$item.FullName
|
||||
$isLink = -not [string]::IsNullOrWhiteSpace([string]$item.LinkType)
|
||||
|
||||
if ($item.PSIsContainer -and -not $isLink) {$script:totalFolders++
|
||||
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
|
||||
[void]$script:html.AppendLine(
|
||||
"<li><details data-path='$pathHtml'><summary>📁 $nameHtml</summary><ul>"
|
||||
)
|
||||
|
||||
# Recurse subdirectories
|
||||
Add-TreeItems -Path $item.FullName -Prefix$nextPrefix
|
||||
|
||||
[void]$script:html.AppendLine('</ul></details></li>')
|
||||
}
|
||||
elseif ($item.PSIsContainer -and $isLink) {$script:totalFolders++
|
||||
$target = @($item.Target) -join ', '
|
||||
[void]$script:textLines.Add("$Prefix$connector$($item.Name) [Link → $target]")
|
||||
[void]$script:html.AppendLine(
|
||||
"<li class='link' data-path='$pathHtml'>🔗 $nameHtml <small>→ $(ConvertTo-HtmlEncoded$target)</small></li>"
|
||||
)
|
||||
}
|
||||
else {
|
||||
$script:totalFiles++
|
||||
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
|
||||
[void]$script:html.AppendLine(
|
||||
"<li class='file' data-path='$pathHtml'>📄 $nameHtml</li>"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Document Building
|
||||
Write-Host "Scanning directory structure: $ResolvedRoot" -ForegroundColor Cyan
|
||||
|
||||
$rootNameHtml = ConvertTo-HtmlEncoded $RootLeafName$rootPathHtml = ConvertTo-HtmlEncoded $ResolvedRoot$generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
|
||||
[void]$script:textLines.Add($ResolvedRoot)
|
||||
|
||||
[void]$script:html.AppendLine(@"
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Directory Tree - $rootNameHtml</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #1f2328;
|
||||
--border-color: #d0d7de;
|
||||
--link-color: #8250df;
|
||||
--error-color: #cf222e;
|
||||
--subtext-color: #57606a;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 24px;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
h1 { margin-bottom: 6px; font-size: 1.5rem; }
|
||||
.meta { color: var(--subtext-color); margin-bottom: 18px; font-size: 0.9rem; line-height: 1.4; }
|
||||
.controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-color);
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
input[type="search"] {
|
||||
width: min(500px, 60vw);
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
background-color: #f6f8fa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background-color: #f3f4f6; }
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 4px 0 4px 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid #d8dee4;
|
||||
}
|
||||
.tree { border-left: 0; margin-left: 0; padding-left: 0; }
|
||||
summary { cursor: pointer; padding: 3px; font-weight: 500; }
|
||||
summary:hover { background-color: #f6f8fa; border-radius: 4px; }
|
||||
.file, .link { padding: 3px; }
|
||||
.link { color: var(--link-color); }
|
||||
.error { color: var(--error-color); }
|
||||
small { color: var(--subtext-color); }
|
||||
.hidden { display: none !important; }
|
||||
code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Directory Tree Report</h1>
|
||||
<div class="meta">
|
||||
<strong>Root:</strong> <code>$rootPathHtml</code><br>
|
||||
<strong>Generated:</strong> $generated
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="search" type="search" placeholder="Search files and folders...">
|
||||
<button type="button" onclick="setAll(true)">Expand All</button>
|
||||
<button type="button" onclick="setAll(false)">Collapse All</button>
|
||||
</div>
|
||||
<ul class="tree">
|
||||
<li>
|
||||
<details open data-path="$rootPathHtml">
|
||||
<summary>📁 $rootNameHtml</summary>
|
||||
<ul>
|
||||
"@)
|
||||
|
||||
# Run recursive scan
|
||||
Add-TreeItems -Path $ResolvedRoot -Prefix ''
|
||||
|
||||
[void]$script:html.AppendLine(@"
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
<script>
|
||||
function setAll(state) {
|
||||
document.querySelectorAll('details').forEach(function (item) {
|
||||
item.open = state;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('search').addEventListener('input', function () {
|
||||
const term = this.value.trim().toLowerCase();
|
||||
const nodes = Array.from(document.querySelectorAll('[data-path]'));
|
||||
|
||||
nodes.forEach(function (node) {
|
||||
node.classList.remove('hidden');
|
||||
});
|
||||
|
||||
if (!term) return;
|
||||
|
||||
nodes.forEach(function (node) {
|
||||
if (!node.dataset.path.toLowerCase().includes(term)) {
|
||||
node.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
nodes.filter(function (node) {
|
||||
return node.dataset.path.toLowerCase().includes(term);
|
||||
}).forEach(function (node) {
|
||||
node.classList.remove('hidden');
|
||||
let parent = node.parentElement;
|
||||
|
||||
while (parent) {
|
||||
if (parent.tagName === 'DETAILS') {
|
||||
parent.open = true;
|
||||
parent.classList.remove('hidden');
|
||||
}
|
||||
if (parent.tagName === 'LI') {
|
||||
parent.classList.remove('hidden');
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"@)
|
||||
|
||||
# 4. Save Output Files
|
||||
$script:textLines \vert{} Set-Content -LiteralPath$TextFile -Encoding UTF8
|
||||
$script:html.ToString() \vert{} Set-Content -LiteralPath$HtmlFile -Encoding UTF8
|
||||
|
||||
# Output Summary
|
||||
Write-Host "`nScan Completed Successfully:" -ForegroundColor Green
|
||||
Write-Host " Folders Scanned : $script:totalFolders"
|
||||
Write-Host " Files Cataloged : $script:totalFiles"
|
||||
Write-Host " Unreadable Errors: $script:scanErrors"
|
||||
Write-Host " Text Report : $TextFile"
|
||||
Write-Host " HTML Report : $HtmlFile`n"
|
||||
|
||||
if ($OpenInBrowser) {
|
||||
Start-Process $HtmlFile
|
||||
}
|
||||
Executable
+210
@@ -0,0 +1,210 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recursively downloads all SharePoint document libraries and files from a site using Microsoft Graph.
|
||||
|
||||
.DESCRIPTION
|
||||
Connects to the Microsoft Graph API, resolves a SharePoint site URL, enumerates all document libraries (drives),
|
||||
and recursively downloads folders and files to a designated local target directory.
|
||||
|
||||
Includes automatic filename sanitization for Windows file systems, pagination support, skipping existing files,
|
||||
and a final download statistics report.
|
||||
|
||||
.PARAMETER SiteUrl
|
||||
The full URL of the target SharePoint site (e.g., "https://zefiromethane.sharepoint.com/sites/contentTypeHub").
|
||||
|
||||
.PARAMETER Destination
|
||||
The local root folder path where document libraries and files will be saved.
|
||||
|
||||
.PARAMETER SkipAuthentication
|
||||
Switch parameter to skip 'Connect-MgGraph' if an active Graph session is already established.
|
||||
|
||||
.EXAMPLE
|
||||
.\Export-SharePointSiteFiles.ps1 -SiteUrl "https://zefiromethane.sharepoint.com/sites/contentTypeHub" -Destination "F:\TeamSite"
|
||||
|
||||
.EXAMPLE
|
||||
.\Export-SharePointSiteFiles.ps1 -SiteUrl "https://contoso.sharepoint.com/sites/hr" -Destination "C:\Backup\HR" -Verbose
|
||||
|
||||
.NOTES
|
||||
Created: 2025-03-05
|
||||
Modified: 2026-10-04
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$SiteUrl,
|
||||
|
||||
[Parameter(Mandatory = $true, Position = 1)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$Destination,
|
||||
|
||||
[switch]$SkipAuthentication
|
||||
)
|
||||
|
||||
# 1. Prerequisite Module Check
|
||||
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
|
||||
Write-Error "The 'Microsoft.Graph.Authentication' module is required. Install it using: Install-Module Microsoft.Graph"
|
||||
return
|
||||
}
|
||||
|
||||
# 2. State & Metric Counters
|
||||
$script:downloadCount = 0
|
||||
$script:skippedCount = 0
|
||||
$script:folderCount = 0
|
||||
$script:errorCount = 0
|
||||
$script:totalBytes = 0
|
||||
|
||||
# 3. Helper Functions
|
||||
|
||||
function Sanitize-PathSegment {
|
||||
param([string]$Name)
|
||||
# Replaces Windows reserved filename characters: \ / : * ? " < > |
|
||||
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars()
|
||||
$sanitized = $Name
|
||||
foreach ($char in $invalidChars) {
|
||||
$sanitized = $sanitized.Replace([string]$char, '_')
|
||||
}
|
||||
return $sanitized.TrimEnd('.')
|
||||
}
|
||||
|
||||
function Get-GraphCollection {
|
||||
param([string]$Uri)
|
||||
|
||||
do {
|
||||
$response = Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop
|
||||
|
||||
foreach ($item in @($response.value)) {
|
||||
Write-Output $item
|
||||
}
|
||||
|
||||
$Uri = $response.'@odata.nextLink'
|
||||
}
|
||||
while ($Uri)
|
||||
}
|
||||
|
||||
function Copy-GraphFolder {
|
||||
param(
|
||||
[string]$DriveId,
|
||||
[string]$ChildrenUri,
|
||||
[string]$LocalFolder
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $LocalFolder)) {
|
||||
New-Item -ItemType Directory -Path $LocalFolder -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
$items = Get-GraphCollection -Uri $ChildrenUri
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to retrieve folder contents from URI: $ChildrenUri. Error: $_"
|
||||
$script:errorCount++
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($item in $items) {
|
||||
# Sanitize item name to prevent Windows file path errors
|
||||
$safeName = Sanitize-PathSegment -Name $item.name
|
||||
$localPath = Join-Path -Path $LocalFolder -ChildPath $safeName
|
||||
$escapedDriveId = [Uri]::EscapeDataString($DriveId)
|
||||
$escapedItemId = [Uri]::EscapeDataString([string]$item.id)
|
||||
|
||||
# Handle Folders / Packages
|
||||
if ($null -ne $item.folder -or $null -ne $item.package) {
|
||||
$script:folderCount++
|
||||
New-Item -ItemType Directory -Path $localPath -Force | Out-Null
|
||||
|
||||
$childUri = "https://graph.microsoft.com/v1.0/drives/$escapedDriveId/items/$escapedItemId/children"
|
||||
|
||||
Copy-GraphFolder -DriveId $DriveId -ChildrenUri $childUri -LocalFolder $localPath
|
||||
}
|
||||
# Handle Files
|
||||
elseif ($null -ne $item.file) {
|
||||
if (Test-Path -LiteralPath $localPath) {
|
||||
Write-Verbose "Skipping existing file: $localPath"
|
||||
$script:skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "Downloading: $safeName" -ForegroundColor Cyan
|
||||
$contentUri = "https://graph.microsoft.com/v1.0/drives/$escapedDriveId/items/$escapedItemId/content"
|
||||
|
||||
try {
|
||||
Invoke-MgGraphRequest -Method GET -Uri $contentUri -OutputFilePath $localPath -ErrorAction Stop
|
||||
$script:downloadCount++
|
||||
if ($item.size) { $script:totalBytes += $item.size }
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to download '$($item.name)' to '$localPath'. Error: $_"
|
||||
$script:errorCount++
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "Unsupported item skipped: $($item.name)"
|
||||
$script:skippedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Main Execution Flow
|
||||
|
||||
try {
|
||||
# Establish Graph Connection
|
||||
if (-not $SkipAuthentication) {
|
||||
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Yellow
|
||||
Connect-MgGraph -Scopes "Sites.Read.All" -NoWelcome
|
||||
}
|
||||
|
||||
# Resolve SharePoint Site ID
|
||||
$parsedSiteUrl = [Uri]$SiteUrl
|
||||
$sitePath = $parsedSiteUrl.AbsolutePath.TrimEnd("/")
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($sitePath)) {
|
||||
$siteLookupUri = "https://graph.microsoft.com/v1.0/sites/$($parsedSiteUrl.Host)"
|
||||
}
|
||||
else {
|
||||
$siteLookupUri = "https://graph.microsoft.com/v1.0/sites/$($parsedSiteUrl.Host):$sitePath"
|
||||
}
|
||||
|
||||
Write-Host "Resolving SharePoint site..." -ForegroundColor Yellow
|
||||
$site = Invoke-MgGraphRequest -Method GET -Uri $siteLookupUri -ErrorAction Stop
|
||||
|
||||
# Ensure root destination folder exists
|
||||
if (-not (Test-Path -LiteralPath $Destination)) {
|
||||
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
|
||||
}
|
||||
|
||||
# Enumerate Site Drives (Document Libraries)
|
||||
$drivesUri = "https://graph.microsoft.com/v1.0/sites/$($site.id)/drives"
|
||||
$drives = Get-GraphCollection -Uri $drivesUri
|
||||
|
||||
foreach ($drive in $drives) {
|
||||
$safeDriveName = Sanitize-PathSegment -Name $drive.name
|
||||
$localLibrary = Join-Path -Path $Destination -ChildPath $safeDriveName
|
||||
$escapedDriveId = [Uri]::EscapeDataString([string]$drive.id)
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host "Downloading Library: $($drive.name)" -ForegroundColor Green
|
||||
Write-Host "Local Target : $localLibrary" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
|
||||
$rootChildrenUri = "https://graph.microsoft.com/v1.0/drives/$escapedDriveId/root/children"
|
||||
|
||||
Copy-GraphFolder -DriveId $drive.id -ChildrenUri $rootChildrenUri -LocalFolder$localLibrary
|
||||
}
|
||||
|
||||
# Display Execution Summary
|
||||
$MBTransferred = [math]::Round($script:totalBytes / 1MB, 2)
|
||||
Write-Host "`nSharePoint Download Summary:" -ForegroundColor Green
|
||||
Write-Host " Files Downloaded : $script:downloadCount ($MBTransferred MB)"
|
||||
Write-Host " Files Skipped : $script:skippedCount"
|
||||
Write-Host " Folders Created : $script:folderCount"
|
||||
Write-Host " Errors Encountered: $script:errorCount"
|
||||
}
|
||||
finally {
|
||||
if (-not $SkipAuthentication) {
|
||||
Write-Host "`nDisconnecting Microsoft Graph session..." -ForegroundColor Yellow
|
||||
Disconnect-MgGraph
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Enrolls the endpoint into Microsoft Intune / MDM.
|
||||
.DESCRIPTION
|
||||
Triggers the Windows auto-enrollment process via deviceenroller.exe.
|
||||
Intended for company-owned endpoints.
|
||||
#>
|
||||
|
||||
# Prompt for confirmation (case-insensitive)
|
||||
Write-Host "WARNING: This script will enroll the current device into MDM." -ForegroundColor Red
|
||||
Write-Host "This is meant for company-owned devices only." -ForegroundColor Red
|
||||
$answer = Read-Host "Do you want to continue? (Y/N)"
|
||||
|
||||
if ($answer -notmatch '^[Yy](es)?$') {
|
||||
Write-Host "Exiting script — Device will not be enrolled into MDM." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Triggering Intune MDM enrollment..." -ForegroundColor Cyan
|
||||
|
||||
try {
|
||||
# Launch deviceenroller and wait for command trigger completion
|
||||
$process = Start-Process -FilePath "$env:SystemRoot\System32\deviceenroller.exe" `
|
||||
-ArgumentList "/c /AutoEnrollMDM" `
|
||||
-Wait `
|
||||
-PassThru `
|
||||
-NoNewWindow
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Host "MDM AutoEnrollment command initiated successfully." -ForegroundColor Green
|
||||
Write-Host "Note: Enrollment processing occurs asynchronously in the background." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "deviceenroller.exe exited with code: $($process.ExitCode)" -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Failed to launch deviceenroller.exe: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes Classic Microsoft Teams and Teams Machine-Wide Installer.
|
||||
.DESCRIPTION
|
||||
Stops Classic Teams processes, uninstalls the Machine-Wide Installer via MSI,
|
||||
removes per-user Classic Teams installations, cleans residual files/registry run keys,
|
||||
and verifies that New Teams (MSTeams) remains intact.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
Write-Host "Starting Classic Microsoft Teams cleanup..." -ForegroundColor Cyan
|
||||
|
||||
# 1. Stop ONLY Classic Teams processes (preserve New Teams)
|
||||
Get-Process -Name "Teams" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Stop Update.exe processes originating specifically from Classic Teams directories
|
||||
Get-Process -Name "Update" -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Path -like "*\Microsoft\Teams\Update.exe"
|
||||
} | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# 2. Remove Teams Machine-Wide Installer from HKLM registry
|
||||
$uninstallPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
|
||||
$machineWideInstallers = foreach ($path in$uninstallPaths) {
|
||||
Get-ItemProperty $path -ErrorAction SilentlyContinue \vert{} Where-Object {$_.DisplayName -eq "Teams Machine-Wide Installer"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($app in$machineWideInstallers) {
|
||||
Write-Host "Found Teams Machine-Wide Installer: $($app.DisplayName) $($app.DisplayVersion)" -ForegroundColor Yellow
|
||||
|
||||
if ($app.UninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
|
||||
$productCode =$Matches[0]
|
||||
Write-Host "Uninstalling Machine-Wide Installer via MSI product code: $productCode" -ForegroundColor Cyan
|
||||
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait -NoNewWindow
|
||||
}
|
||||
elseif ($app.UninstallString) {
|
||||
Write-Host "Uninstalling via UninstallString: $($app.UninstallString)" -ForegroundColor Cyan
|
||||
if ($app.UninstallString -match "msiexec") {
|
||||
$args =$app.UninstallString -replace "msiexec.exe", "" -replace "/I", "/x"
|
||||
Start-Process -FilePath "msiexec.exe" -ArgumentList "$args /qn /norestart" -Wait -NoNewWindow
|
||||
} else {
|
||||
Start-Process -FilePath "cmd.exe" -ArgumentList "/c `"$($app.UninstallString)`" /quiet /norestart" -Wait -NoNewWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Clean per-user Classic Teams installations
|
||||
$usersRoot = Join-Path$env:SystemDrive "Users"
|
||||
$userProfiles = Get-ChildItem -Path $usersRoot -Directory -ErrorAction SilentlyContinue \vert{} Where-Object {$_.Name -notin @("Public", "Default", "Default User", "All Users", "defaultuser0")
|
||||
}
|
||||
|
||||
foreach ($profile in$userProfiles) {
|
||||
$localTeamsFolder = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams"
|
||||
$roamingTeamsFolder = Join-Path$profile.FullName "AppData\Roaming\Microsoft\Teams"
|
||||
$updateExe = Join-Path$localTeamsFolder "Update.exe"
|
||||
|
||||
if (Test-Path $updateExe) {
|
||||
Write-Host "Uninstalling Classic Teams for profile: $($profile.Name)" -ForegroundColor Cyan
|
||||
Start-Process -FilePath $updateExe -ArgumentList "--uninstall -s" -Wait -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Remove residual AppData folders (both Local and Roaming)
|
||||
foreach ($folder in @($localTeamsFolder,$roamingTeamsFolder)) {
|
||||
if (Test-Path $folder) {
|
||||
Write-Host "Removing directory: $folder" -ForegroundColor Yellow
|
||||
Remove-Item -Path $folder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Remove System-Wide ProgramData Leftovers
|
||||
$leftoverPaths = @(
|
||||
"$env:ProgramData\Microsoft\Teams",
|
||||
"$env:ProgramData\Microsoft\TeamsMachineInstaller",
|
||||
"$env:ProgramData\Microsoft\TeamsCustomData"
|
||||
)
|
||||
|
||||
foreach ($path in$leftoverPaths) {
|
||||
if (Test-Path $path) {
|
||||
Write-Host "Removing leftover ProgramData path: $path" -ForegroundColor Yellow
|
||||
Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 5. Clean Startup Registry Run Keys across loaded user hives
|
||||
$hkuPath = "Registry::HKEY_USERS"
|
||||
Get-ChildItem -Path $hkuPath -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$runKey = Join-Path$_.PSPath "Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
if (Test-Path $runKey) {
|
||||
$val = Get-ItemProperty -Path$runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||
if ($val) {
|
||||
Write-Host "Removing Classic Teams startup reg key from hive: $($_.PSChildName)" -ForegroundColor Yellow
|
||||
Remove-ItemProperty -Path $runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 6. Confirm New Teams (MSTeams) status
|
||||
$newTeams = Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue
|
||||
|
||||
if ($newTeams) {
|
||||
Write-Host "New Teams (MSTeams) is installed:" -ForegroundColor Green
|
||||
$newTeams | Select-Object Name, Version, PackageFullName | Format-Table -AutoSize | Out-String | Write-Host
|
||||
} else {
|
||||
Write-Host "WARNING: New Teams (MSTeams) was not detected on this device." -ForegroundColor Red
|
||||
}
|
||||
|
||||
# 7. Final Verification
|
||||
$remainingMachineWide = foreach ($path in$uninstallPaths) {
|
||||
Get-ItemProperty $path -ErrorAction SilentlyContinue \vert{} Where-Object {$_.DisplayName -eq "Teams Machine-Wide Installer"
|
||||
}
|
||||
}
|
||||
|
||||
$remainingClassicFolders = foreach ($profile in$userProfiles) {
|
||||
$localTeamsFolder = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams"
|
||||
if (Test-Path $localTeamsFolder) {$localTeamsFolder }
|
||||
}
|
||||
|
||||
if ($remainingMachineWide -or$remainingClassicFolders) {
|
||||
Write-Host "Cleanup completed, but some Classic Teams remnants remain." -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "Classic Teams and Teams Machine-Wide Installer cleanup completed successfully." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes unsupported Microsoft .NET Core / ASP.NET Core / Windows Desktop Runtime / SDK 3.1.x components.
|
||||
.DESCRIPTION
|
||||
Queries HKLM uninstall registry locations (64-bit and 32-bit) for .NET 3.1.x components,
|
||||
executes quiet uninstallation for both MSI and Executable/Bundle bootstrappers,
|
||||
cleans orphaned runtime directories, and verifies removal.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
Write-Host "Starting .NET Core 3.1 remediation..." -ForegroundColor Cyan
|
||||
|
||||
$registryPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
|
||||
# Search pattern targeting 3.1.x runtimes, SDKs, targeting packs, and hosting bundles
|
||||
$targetApps = foreach ($path in$registryPaths) {
|
||||
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
|
||||
(
|
||||
$_.DisplayName -match "Microsoft .*\b3\.1\.\d+\b" -or
|
||||
$_.DisplayName -match "Microsoft \.NET (Core|SDK|Runtime|Windows Desktop|ASP\.NET Core).*3\.1"
|
||||
) -and ($_.UninstallString -or$_.QuietUninstallString)
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $targetApps) {
|
||||
Write-Host "No .NET Core 3.1 components found. System is clean." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Found $($targetApps.Count) .NET Core 3.1 component(s) to remove:" -ForegroundColor Yellow
|
||||
foreach ($app in$targetApps) {
|
||||
Write-Host " - $($app.DisplayName) ($($app.DisplayVersion))" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
foreach ($app in$targetApps) {
|
||||
Write-Host "`nAttempting to remove: $($app.DisplayName)..." -ForegroundColor Cyan
|
||||
|
||||
$uninstallCmd = if ($app.QuietUninstallString) { $app.QuietUninstallString } else { $app.UninstallString }
|
||||
|
||||
if ($uninstallCmd -match "\{[A-Fa-f0-9\-]{36}\}") {
|
||||
# Standard MSI Uninstall
|
||||
$productCode = $Matches[0]
|
||||
Write-Host "Executing MSI uninstall for GUID: $productCode" -ForegroundColor Gray
|
||||
$proc = Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait -PassThru -NoNewWindow
|
||||
}
|
||||
else {
|
||||
# Executable / WiX Burn Bootstrapper Uninstall
|
||||
Write-Host "Executing Bootstrapper EXE uninstall..." -ForegroundColor Gray
|
||||
|
||||
# Parse EXE path and arguments safely
|
||||
if ($uninstallCmd -match '^"([^"]+)"(.*)$') {
|
||||
$exePath = $Matches[1]
|
||||
$exeArgs = "$($Matches[2]) /uninstall /quiet /norestart".Trim()
|
||||
}
|
||||
elseif ($uninstallCmd -match '^(\S+)(.*)$') {
|
||||
$exePath = $Matches[1]
|
||||
$exeArgs = "$($Matches[2]) /uninstall /quiet /norestart".Trim()
|
||||
}
|
||||
else {
|
||||
$exePath = $uninstallCmd
|
||||
$exeArgs = "/uninstall /quiet /norestart"
|
||||
}
|
||||
|
||||
$proc = Start-Process -FilePath $exePath -ArgumentList $exeArgs -Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Evaluate return codes (0 = Success, 3010 = Success, reboot required)
|
||||
if ($proc.ExitCode -in @(0, 3010)) {
|
||||
Write-Host "Successfully processed uninstall for $($app.DisplayName)." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Warning: Uninstall process returned exit code $($proc.ExitCode) for $($app.DisplayName)." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Clean residual 3.1.x folder remnants from dotnet installation directories
|
||||
$dotnetPaths = @(
|
||||
"$env:ProgramFiles\dotnet",
|
||||
"${env:ProgramFiles(x86)}\dotnet"
|
||||
)
|
||||
|
||||
foreach ($basePath in $dotnetPaths) {
|
||||
if (Test-Path $basePath) {
|
||||
Get-ChildItem -Path $basePath -Recurse -Directory -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Name -like "3.1.*"
|
||||
} | ForEach-Object {
|
||||
Write-Host "Removing leftover directory: $($_.FullName)" -ForegroundColor Yellow
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Final Verification
|
||||
Write-Host "`nVerifying remaining .NET Core 3.1 components..." -ForegroundColor Cyan
|
||||
|
||||
$remainingApps = foreach ($path in$registryPaths) {
|
||||
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
|
||||
(
|
||||
$_.DisplayName -match "Microsoft .*\b3\.1\.\d+\b" -or
|
||||
$_.DisplayName -match "Microsoft \.NET (Core|SDK|Runtime|Windows Desktop|ASP\.NET Core).*3\.1"
|
||||
) -and ($_.UninstallString -or$_.QuietUninstallString)
|
||||
}
|
||||
}
|
||||
|
||||
if ($remainingApps) {
|
||||
Write-Host "ERROR: Some .NET Core 3.1 components remain installed:" -ForegroundColor Red
|
||||
$remainingApps | ForEach-Object {
|
||||
Write-Host " - $($_.DisplayName) ($($_.DisplayVersion))" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
else {
|
||||
Write-Host ".NET Core 3.1 remediation completed successfully." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Removes specific vulnerable/outdated versions of Classic Teams and New Teams (MSTeams).
|
||||
.DESCRIPTION
|
||||
Checks for targeted build numbers across Classic Teams (Machine-Wide Installer, user AppData)
|
||||
and New Teams (MSIX AppX provisioned and per-user packages), cleanly uninstalling only
|
||||
the matching versions while leaving compliant versions untouched.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
Write-Host "Starting targeted Microsoft Teams cleanup..." -ForegroundColor Cyan
|
||||
|
||||
# Targeted vulnerable versions (includes Classic Teams 1.x and New Teams 23x/24x/25x builds)
|
||||
$TargetVersions = @(
|
||||
"24334.1103.3302.5694",
|
||||
"25045.406.3471.2143",
|
||||
"24124.2402.2858.5617",
|
||||
"24004.1403.2634.2418",
|
||||
"23195.1506.2253.6778",
|
||||
"24277.3102.3183.2670",
|
||||
"24256.2502.3123.1",
|
||||
"24165.1306.2986.9504",
|
||||
"25031.702.3408.1909",
|
||||
"24244.507.3118.4732",
|
||||
"23119.303.2080.2726",
|
||||
"1.7.0.7956",
|
||||
"25044.2208.3471.2155",
|
||||
"24348.802.3311.5092",
|
||||
"23335.242.2641.4129"
|
||||
)
|
||||
|
||||
# Helper function for version string normalization & comparison
|
||||
function Test-IsTargetVersion {
|
||||
param (
|
||||
[string]$VersionString,
|
||||
[string[]]$TargetList
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($VersionString)) { return$false }
|
||||
|
||||
$cleanVersion =$VersionString.Trim()
|
||||
if ($TargetList -contains $cleanVersion) { return$true }
|
||||
|
||||
try {
|
||||
$parsedVersion = [version]$cleanVersion
|
||||
foreach ($target in$TargetList) {
|
||||
try {
|
||||
if ([version]$target -eq $parsedVersion) { return$true }
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# 1. Safely stop targeted processes
|
||||
Write-Host "Checking for running processes..." -ForegroundColor Cyan
|
||||
Get-Process -Name "Teams" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Get-Process -Name "Update" -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Path -like "*\Microsoft\Teams\Update.exe"
|
||||
} | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Stop ms-teams ONLY if the running executable belongs to a target version
|
||||
Get-Process -Name "ms-teams" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$procVersion =$_.MainModule.FileVersionInfo.FileVersion
|
||||
if (Test-IsTargetVersion -VersionString $procVersion -TargetList$TargetVersions) {
|
||||
Write-Host "Stopping New Teams process (running vulnerable version $procVersion)..." -ForegroundColor Yellow
|
||||
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Remediate targeted Teams Machine-Wide Installer (HKLM)
|
||||
Write-Host "`nScanning Teams Machine-Wide Installer..." -ForegroundColor Cyan
|
||||
$UninstallRegistryPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
|
||||
foreach ($path in $UninstallRegistryPaths) {
|
||||
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.DisplayName -eq "Teams Machine-Wide Installer" -and
|
||||
(Test-IsTargetVersion -VersionString $_.DisplayVersion -TargetList $TargetVersions)
|
||||
} | ForEach-Object {
|
||||
Write-Host "Found targeted Machine-Wide Installer version: $($_.DisplayVersion)" -ForegroundColor Yellow
|
||||
|
||||
if ($_.UninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
|
||||
$productCode = $Matches[0]
|
||||
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait -NoNewWindow
|
||||
} elseif ($_.UninstallString) {
|
||||
Start-Process -FilePath "cmd.exe" -ArgumentList "/c `"$($_.UninstallString)`" /quiet /norestart" -Wait -NoNewWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Remediate targeted Classic Teams (User Profiles)
|
||||
Write-Host "`nScanning user profiles for Classic Teams..." -ForegroundColor Cyan
|
||||
$usersRoot = Join-Path$env:SystemDrive "Users"
|
||||
$userProfiles = Get-ChildItem -Path $usersRoot -Directory -ErrorAction SilentlyContinue \vert{} Where-Object {$_.Name -notin @("Public", "Default", "Default User", "All Users", "defaultuser0")
|
||||
}
|
||||
|
||||
foreach ($profile in$userProfiles) {
|
||||
$localTeamsFolder = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams"
|
||||
$roamingTeamsFolder = Join-Path$profile.FullName "AppData\Roaming\Microsoft\Teams"
|
||||
$currentExe = Join-Path$localTeamsFolder "current\Teams.exe"
|
||||
$updateExe = Join-Path$localTeamsFolder "Update.exe"
|
||||
|
||||
if (Test-Path $localTeamsFolder) {
|
||||
$detectedVersion =$null
|
||||
if (Test-Path $currentExe) { $detectedVersion = (Get-Item$currentExe).VersionInfo.FileVersion }
|
||||
if (-not $detectedVersion -and (Test-Path$updateExe)) { $detectedVersion = (Get-Item$updateExe).VersionInfo.FileVersion }
|
||||
|
||||
if (Test-IsTargetVersion -VersionString $detectedVersion -TargetList$TargetVersions) {
|
||||
Write-Host "Removing Classic Teams ($detectedVersion) for profile: $($profile.Name)" -ForegroundColor Yellow
|
||||
|
||||
if (Test-Path $updateExe) {
|
||||
Start-Process -FilePath $updateExe -ArgumentList "--uninstall -s" -Wait -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
Remove-Item -Path $localTeamsFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $roamingTeamsFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Remediate targeted New Teams (AppX / MSIX Packages)
|
||||
Write-Host "`nScanning New Teams (MSTeams) MSIX packages..." -ForegroundColor Cyan
|
||||
|
||||
# Remove provisioned package if version matches
|
||||
Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.DisplayName -eq "MSTeams" -and (Test-IsTargetVersion -VersionString $_.Version -TargetList $TargetVersions)
|
||||
} | ForEach-Object {
|
||||
Write-Host "Removing targeted provisioned MSTeams package version: $($_.Version)" -ForegroundColor Yellow
|
||||
Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Remove per-user AppX packages if version matches
|
||||
Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue | Where-Object {
|
||||
Test-IsTargetVersion -VersionString $_.Version -TargetList $TargetVersions
|
||||
} | ForEach-Object {
|
||||
Write-Host "Removing targeted MSTeams AppX package version: $($_.Version) for all users" -ForegroundColor Yellow
|
||||
Remove-AppxPackage -Package $_.PackageFullName -AllUsers -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# 5. Clean Startup Run Keys across loaded user hives
|
||||
$hkuPath = "Registry::HKEY_USERS"
|
||||
Get-ChildItem -Path $hkuPath -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$runKey = Join-Path $_.PSPath "Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
if (Test-Path $runKey) {
|
||||
$val = Get-ItemProperty -Path $runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||
if ($val) {
|
||||
Remove-ItemProperty -Path $runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 6. Final Verification
|
||||
Write-Host "`nPerforming final verification..." -ForegroundColor Cyan
|
||||
|
||||
$remainingMachineWide = foreach ($path in$UninstallRegistryPaths) {
|
||||
Get-ItemProperty $path -ErrorAction SilentlyContinue \vert{} Where-Object {$_.DisplayName -eq "Teams Machine-Wide Installer" -and
|
||||
(Test-IsTargetVersion -VersionString $_.DisplayVersion -TargetList$TargetVersions)
|
||||
}
|
||||
}
|
||||
|
||||
$remainingClassicFolders = foreach ($profile in$userProfiles) {
|
||||
$exe = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams\current\Teams.exe"
|
||||
if (Test-Path $exe) {
|
||||
$ver = (Get-Item$exe).VersionInfo.FileVersion
|
||||
if (Test-IsTargetVersion -VersionString $ver -TargetList $TargetVersions) {$exe }
|
||||
}
|
||||
}
|
||||
|
||||
$remainingNewTeams = Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue | Where-Object {
|
||||
Test-IsTargetVersion -VersionString $_.Version -TargetList$TargetVersions
|
||||
}
|
||||
|
||||
if ($remainingMachineWide -or $remainingClassicFolders -or$remainingNewTeams) {
|
||||
Write-Host "ERROR: Some targeted Teams versions still remain on the endpoint." -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "Targeted Teams remediation completed successfully." -ForegroundColor Green
|
||||
|
||||
# Display active/healthy New Teams version if installed
|
||||
$healthyTeams = Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue
|
||||
if ($healthyTeams) {
|
||||
Write-Host "Active, non-vulnerable New Teams installation detected:" -ForegroundColor Green
|
||||
$healthyTeams | Select-Object Name, Version, PackageFullName | Format-Table -AutoSize | Out-String | Write-Host
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Exports Microsoft 365 user recertification and licensing report using Microsoft Graph SDK.
|
||||
.DESCRIPTION
|
||||
Retrieves all M365 users, resolves license SKU IDs to readable part numbers,
|
||||
extracts last interactive sign-in dates, and exports the dataset to CSV.
|
||||
.EXAMPLE
|
||||
.\Export-M365UserCertification.ps1 -OutputPath ".\M365-User-Certification-Export.csv"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputPath = ".\M365-User-Certification-Export.csv"
|
||||
)
|
||||
|
||||
# Silence Graph cmdlet progress bars to drastically decrease execution time
|
||||
$OriginalProgressPreference = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
try {
|
||||
# Check existing Microsoft Graph session
|
||||
$CurrentContext = Get-MgContext
|
||||
$RequiredScopes = @("User.Read.All", "Directory.Read.All", "AuditLog.Read.All")
|
||||
|
||||
if (-not $CurrentContext) {
|
||||
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||
Connect-MgGraph -Scopes $RequiredScopes
|
||||
}
|
||||
else {
|
||||
Write-Host "Connected as $($CurrentContext.Account)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Resolve relative path to absolute path
|
||||
$ResolvedOutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
|
||||
|
||||
Write-Host "Building license SKU lookup table..." -ForegroundColor Cyan
|
||||
$SkuLookup = @{}
|
||||
Get-MgSubscribedSku -All | ForEach-Object {
|
||||
$SkuLookup[$_.SkuId.ToString()] = $_.SkuPartNumber
|
||||
}
|
||||
|
||||
# Explicitly define target properties (signInActivity requires AuditLog.Read.All)
|
||||
$Properties = @(
|
||||
"id",
|
||||
"displayName",
|
||||
"userPrincipalName",
|
||||
"userType",
|
||||
"accountEnabled",
|
||||
"assignedLicenses",
|
||||
"externalUserState",
|
||||
"signInActivity"
|
||||
)
|
||||
|
||||
Write-Host "Fetching user data from Microsoft Graph..." -ForegroundColor Cyan
|
||||
$Users = Get-MgUser -All -Property $Properties
|
||||
|
||||
Write-Host "Processing $($Users.Count) users..." -ForegroundColor Cyan
|
||||
|
||||
$Export = foreach ($User in $Users) {
|
||||
# Resolve licenses
|
||||
$AssignedLicenses = if ($User.AssignedLicenses -and $User.AssignedLicenses.Count -gt 0) {
|
||||
($User.AssignedLicenses | ForEach-Object {
|
||||
$SkuId = $_.SkuId.ToString()
|
||||
if ($SkuLookup.ContainsKey($SkuId)) {
|
||||
$SkuLookup[$SkuId]
|
||||
}
|
||||
else {
|
||||
$SkuId
|
||||
}
|
||||
}) -join "; "
|
||||
}
|
||||
else {
|
||||
"None"
|
||||
}
|
||||
|
||||
# Safe null-check for SignInActivity
|
||||
$LastSignIn = if ($User.SignInActivity -and $User.SignInActivity.LastSignInDateTime) {
|
||||
$User.SignInActivity.LastSignInDateTime
|
||||
}
|
||||
else {
|
||||
"Never / No Record"
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
Name = $User.DisplayName
|
||||
UserPrincipalName = $User.UserPrincipalName
|
||||
UserType = $User.UserType
|
||||
AccountEnabled = $User.AccountEnabled
|
||||
AssignedLicenses = $AssignedLicenses
|
||||
GuestInvitationState = $User.ExternalUserState
|
||||
LastInteractiveSignInDate = $LastSignIn
|
||||
}
|
||||
}
|
||||
|
||||
# Export results to CSV
|
||||
$Export |
|
||||
Sort-Object Name |
|
||||
Export-Csv -Path $ResolvedOutputPath -NoTypeInformation -Encoding UTF8
|
||||
|
||||
Write-Host "Export complete: $ResolvedOutputPath" -ForegroundColor Green
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "An error occurred during execution: $_"
|
||||
}
|
||||
finally {
|
||||
# Restore original progress preference
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
}
|
||||
Reference in new issue
Block a user