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

+90
View File
@@ -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)
+296
View File
@@ -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
}
+210
View File
@@ -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."
}
+40
View File
@@ -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
}
+135
View File
@@ -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
}
+120
View File
@@ -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
}
+193
View File
@@ -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
}
+109
View File
@@ -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
}