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

-28
View File
@@ -1,28 +0,0 @@
'''
Created: 2025-03-05
Modified: 2025-03-05
Description: This script checks to ensure the DNS servers are set to the DNS servers that filter malware
'''
# Get all network adapters
$adapters = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' }
# Check DNS server settings
foreach ($adapter in $adapters) {
$dnsServers = Get-DnsClientServerAddress -InterfaceIndex $adapter.InterfaceIndex
$staticDnsServers = $dnsServers | Where-Object { $_.ServerAddresses -contains '9.9.9.9' -or $_.ServerAddresses -contains '149.112.112.11' -or $_.ServerAddresses -contains '1.1.1.3' -or $_.ServerAddresses -contains '1.0.0.3' }
if ($staticDnsServers.Count -eq 0) {
Write-Host "Adapter $($adapter.Name) does not have the correct DNS servers configured."
$compliance = "false"
}
else {
Write-Host "Adapter $($adapter.Name) has the correct DNS servers configured."
$compliance = "true"
}
}
$hash = @{ Compliant = $compliance}
return $hash | ConvertTo-Json -Compress
-209
View File
@@ -1,209 +0,0 @@
$root = 'E:\Sync Method\Corporate\Zefiro Methane Corp. - Documents'
$outputDirectory = 'C:\Users\MattSpeer\OneDrive - Add Source Group\Documents\1- Projects\Zefiro - Data Migration'
$textFile = Join-Path $outputDirectory 'Zefiro-Methane-Corp-Documents-tree.txt'
$htmlFile = Join-Path $outputDirectory 'Zefiro-Methane-Corp-Documents-tree.html'
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
throw "Folder not found: $root"
}
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
$script:textLines = [System.Collections.Generic.List[string]]::new()
$script:html = [System.Text.StringBuilder]::new()
$script:scanErrors = 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) {
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
[void]$script:html.AppendLine(
"<li><details data-path='$pathHtml'><summary>📁 $nameHtml</summary><ul>"
)
Add-TreeItems -Path $item.FullName -Prefix $nextPrefix
[void]$script:html.AppendLine('</ul></details></li>')
}
elseif ($item.PSIsContainer -and $isLink) {
$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 {
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
[void]$script:html.AppendLine(
"<li class='file' data-path='$pathHtml'>📄 $nameHtml</li>"
)
}
}
}
$rootNameHtml = ConvertTo-HtmlEncoded (Split-Path -Path $root -Leaf)
$rootPathHtml = ConvertTo-HtmlEncoded $root
$generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
[void]$script:textLines.Add($root)
[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>Team Site Documents Directory</title>
<style>
body {
font-family: "Segoe UI", Arial, sans-serif;
margin: 24px;
color: #1f2328;
background: #fff;
}
h1 { margin-bottom: 6px; }
.meta { color: #57606a; margin-bottom: 18px; }
.controls {
position: sticky;
top: 0;
background: #fff;
padding: 10px 0;
border-bottom: 1px solid #d0d7de;
z-index: 2;
}
input {
width: min(600px, 70vw);
padding: 8px;
font-size: 14px;
}
button {
padding: 8px 12px;
margin-left: 6px;
}
ul {
list-style: none;
margin: 4px 0 4px 20px;
padding-left: 10px;
border-left: 1px solid #d8dee4;
}
.tree { border-left: 0; margin-left: 0; }
summary { cursor: pointer; padding: 3px; }
.file, .link { padding: 3px; }
.link { color: #8250df; }
.error { color: #cf222e; }
small { color: #57606a; }
.hidden { display: none !important; }
</style>
</head>
<body>
<h1>Team Site – Documents</h1>
<div class="meta">
Generated $generated<br>
Root: <code>$rootPathHtml</code>
</div>
<div class="controls">
<input id="search" type="search" placeholder="Search folders and files">
<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>
"@)
Add-TreeItems -Path $root -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');
}
parent = parent.parentElement;
}
});
});
</script>
</body>
</html>
"@)
$script:textLines | Set-Content -LiteralPath $textFile -Encoding UTF8
$script:html.ToString() | Set-Content -LiteralPath $htmlFile -Encoding UTF8
Write-Host "Text report: $textFile"
Write-Host "HTML report: $htmlFile"
Write-Host "Unreadable folders: $script:scanErrors"
Start-Process $htmlFile
-112
View File
@@ -1,112 +0,0 @@
$SiteUrl = "https://zefiromethane.sharepoint.com/sites/contentTypeHub"
$Destination = "F:\TeamSite"
# Opens a Microsoft sign-in window.
Connect-MgGraph -Scopes "Sites.Read.All" -NoWelcome
function Get-GraphCollection {
param([string]$Uri)
do {
$response = Invoke-MgGraphRequest -Method GET -Uri $Uri
foreach ($item in @($response.value)) {
Write-Output $item
}
$Uri = $response.'@odata.nextLink'
}
while ($Uri)
}
function Copy-GraphFolder {
param(
[string]$DriveId,
[string]$ChildrenUri,
[string]$LocalFolder
)
New-Item -ItemType Directory -Path $LocalFolder -Force |
Out-Null
foreach ($item in Get-GraphCollection -Uri $ChildrenUri) {
$localPath = Join-Path $LocalFolder $item.name
$escapedDriveId = [Uri]::EscapeDataString($DriveId)
$escapedItemId = [Uri]::EscapeDataString([string]$item.id)
if ($null -ne $item.folder -or $null -ne $item.package) {
# Create the folder, including empty folders.
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
}
elseif ($null -ne $item.file) {
if (Test-Path -LiteralPath $localPath) {
Write-Host "Skipping existing file: $localPath"
continue
}
Write-Host "Downloading: $localPath"
$contentUri = `
"https://graph.microsoft.com/v1.0/drives/" +
"$escapedDriveId/items/$escapedItemId/content"
Invoke-MgGraphRequest `
-Method GET `
-Uri $contentUri `
-OutputFilePath $localPath
}
else {
Write-Warning "Unsupported item skipped: $($item.name)"
}
}
}
# Resolve the SharePoint site from its URL.
$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"
}
$site = Invoke-MgGraphRequest -Method GET -Uri $siteLookupUri
New-Item -ItemType Directory -Path $Destination -Force |
Out-Null
# A Graph "drive" represents a SharePoint document library.
$drivesUri = "https://graph.microsoft.com/v1.0/sites/$($site.id)/drives"
foreach ($drive in Get-GraphCollection -Uri $drivesUri) {
$localLibrary = Join-Path $Destination $drive.name
$escapedDriveId = [Uri]::EscapeDataString([string]$drive.id)
Write-Host "Downloading library: $($drive.name)"
$rootChildrenUri = `
"https://graph.microsoft.com/v1.0/drives/" +
"$escapedDriveId/root/children"
Copy-GraphFolder `
-DriveId $drive.id `
-ChildrenUri $rootChildrenUri `
-LocalFolder $localLibrary
}
Disconnect-MgGraph
-378
View File
@@ -1,378 +0,0 @@
<#
.SYNOPSIS
Exports completed DocuSign envelopes, including the Certificate of Completion.
.DESCRIPTION
Uses the DocuSign eSignature REST API to:
1. Authenticate with JWT.
2. List completed envelopes in a date range.
3. Download each envelope as either an archive ZIP or combined PDF.
4. Write a CSV export log.
Prerequisites:
- PowerShell 7 recommended.
- A DocuSign app/integration key.
- RSA private key PEM for the integration.
- Consent granted for scopes: signature impersonation.
- The impersonated user must have access to the target envelopes.
Consent URL format:
Production:
https://account.docusign.com/oauth/auth?response_type=code&scope=signature%20impersonation&client_id=INTEGRATION_KEY&redirect_uri=https://www.docusign.com
Demo:
https://account-d.docusign.com/oauth/auth?response_type=code&scope=signature%20impersonation&client_id=INTEGRATION_KEY&redirect_uri=https://www.docusign.com
.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 "2020-01-01" `
-OutputRoot "D:\DocuSignExport" `
-OutputFormat ArchiveZip
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$IntegrationKey,
[Parameter(Mandatory = $true)]
[string]$UserId,
[Parameter(Mandatory = $true)]
[string]$AccountId,
[Parameter(Mandatory = $true)]
[string]$PrivateKeyPath,
[Parameter(Mandatory = $true)]
[datetime]$FromDate,
[Parameter(Mandatory = $false)]
[datetime]$ToDate = (Get-Date),
[Parameter(Mandatory = $true)]
[string]$OutputRoot,
[Parameter(Mandatory = $false)]
[ValidateSet("Production", "Demo")]
[string]$Environment = "Production",
[Parameter(Mandatory = $false)]
[ValidateSet("ArchiveZip", "CombinedPdf")]
[string]$OutputFormat = "ArchiveZip",
[Parameter(Mandatory = $false)]
[int]$PageSize = 1000,
[Parameter(Mandatory = $false)]
[int]$RetryCount = 4
)
$ErrorActionPreference = "Stop"
function ConvertTo-Base64Url {
param([byte[]]$Bytes)
[Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
}
function ConvertFrom-Base64UrlString {
param([string]$Value)
$base64 = $Value.Replace("-", "+").Replace("_", "/")
switch ($base64.Length % 4) {
2 { $base64 += "==" }
3 { $base64 += "=" }
}
[Convert]::FromBase64String($base64)
}
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()
}
"$unsignedToken.$(ConvertTo-Base64Url $signature)"
}
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
}
$delaySeconds = [math]::Min(60, [math]::Pow(2, $attempt))
Write-Warning "$ActionName failed with HTTP $statusCode. Retrying in $delaySeconds seconds. Attempt $attempt of $RetryCount."
Start-Sleep -Seconds $delaySeconds
}
}
}
function Get-SafeFileName {
param(
[string]$Text,
[int]$MaxLength = 120
)
$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()
}
$safe
}
function Save-Bytes {
param(
[string]$Uri,
[hashtable]$Headers,
[string]$Path,
[int]$RetryCount
)
Invoke-WithRetry -RetryCount $RetryCount -ActionName "Download $Path" -ScriptBlock {
Invoke-WebRequest -Uri $Uri -Headers $Headers -OutFile $Path
} | Out-Null
}
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
$tokenUri = "https://$authServer/oauth/token"
Write-Host "Requesting DocuSign access 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
}
}
$headers = @{
Authorization = "Bearer $($tokenResponse.access_token)"
}
Write-Host "Discovering account base URI..."
$userInfo = Invoke-WithRetry -RetryCount $RetryCount -ActionName "UserInfo request" -ScriptBlock {
Invoke-RestMethod -Method Get -Uri "https://$authServer/oauth/userinfo" -Headers $headers
}
$account = $userInfo.accounts | Where-Object { $_.account_id -eq $AccountId } | Select-Object -First 1
if (-not $account) {
throw "AccountId '$AccountId' was not found for the impersonated user."
}
$baseUri = $account.base_uri.TrimEnd("/")
$apiBase = "$baseUri/restapi/v2.1/accounts/$AccountId"
$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"
$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" }
$downloadHeaders = @{
Authorization = "Bearer $($tokenResponse.access_token)"
Accept = $accept
}
$from = $FromDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$to = $ToDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
Write-Host "Finding completed envelopes from $from to $to..."
$allEnvelopes = New-Object System.Collections.Generic.List[object]
$startPosition = 0
do {
$query = @{
from_date = $from
to_date = $to
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 = "$apiBase/envelopes?$queryString"
$page = Invoke-WithRetry -RetryCount $RetryCount -ActionName "Envelope list page starting $startPosition" -ScriptBlock {
Invoke-RestMethod -Method Get -Uri $uri -Headers $headers
}
if ($page.envelopes) {
foreach ($envelope in $page.envelopes) {
$allEnvelopes.Add($envelope)
}
}
$returned = if ($page.envelopes) { @($page.envelopes).Count } else { 0 }
Write-Host "Found $returned envelopes on page starting at $startPosition."
$startPosition += $PageSize
$totalSetSize = [int]$page.totalSetSize
} while ($allEnvelopes.Count -lt $totalSetSize -and $returned -gt 0)
Write-Host "Total completed envelopes found: $($allEnvelopes.Count)"
$logRows = New-Object System.Collections.Generic.List[object]
$counter = 0
foreach ($envelope in $allEnvelopes) {
$counter++
$envelopeId = $envelope.envelopeId
$subject = Get-SafeFileName -Text $envelope.emailSubject
$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 = "$apiBase/envelopes/$envelopeId/documents/$documentId`?certificate=true"
Write-Progress `
-Activity "Downloading completed DocuSign agreements" `
-Status "$counter of $($allEnvelopes.Count): $subject" `
-PercentComplete (($counter / [math]::Max(1, $allEnvelopes.Count)) * 100)
$status = "Downloaded"
$errorMessage = ""
try {
Save-Bytes -Uri $downloadUri -Headers $downloadHeaders -Path $filePath -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
})
}
$logRows | Export-Csv -Path $logPath -NoTypeInformation -Encoding UTF8
$downloaded = @($logRows | Where-Object { $_.Status -eq "Downloaded" }).Count
$failed = @($logRows | Where-Object { $_.Status -eq "Failed" }).Count
Write-Host ""
Write-Host "DocuSign export complete."
Write-Host "Completed envelopes found: $($allEnvelopes.Count)"
Write-Host "Downloaded successfully: $downloaded"
Write-Host "Failed: $failed"
Write-Host "Files saved to: $downloadFolder"
Write-Host "Log saved to: $logPath"
if ($failed -gt 0) {
Write-Warning "Some envelopes failed. Review the CSV log and rerun after resolving the errors."
}
-23
View File
@@ -1,23 +0,0 @@
#!powershell
'''
Created: 2025-01-13
Modified: 2025-01-15
Description: This script will enroll the device into MDM.
'''
Write-Host -ForegroundColor Red "WARNING: This script will enroll the current device into MDM. This is meant for Add Source Group owned devices only. Do you want to continue? (Y/N)"
$answer = Read-Host
if ($answer -ne "Y") {
Write-Output "Exiting script - Device will not be enrolled into MDM"
exit
}
Write-Output "Enrolling device into MDM"
Start-Process -FilePath 'C:\Windows\system32\deviceenroller.exe' -ArgumentList '/c /AutoEnrollMDM'
Write-Host -ForegroundColor Green "Device enrolled script has completed. You can now close this window."
-131
View File
@@ -1,131 +0,0 @@
# Remove-ClassicTeams.ps1
# Removes classic Microsoft Teams and Teams Machine-Wide Installer.
# Leaves new Teams / MSTeams MSIX app installed.
$ErrorActionPreference = "Continue"
Write-Output "Starting classic Teams cleanup..."
# Stop Teams-related processes
$processNames = @(
"Teams",
"ms-teams",
"msteams",
"Microsoft.Teams",
"Update"
)
foreach ($process in $processNames) {
Get-Process -Name $process -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 5
# Remove Teams Machine-Wide Installer from HKLM uninstall 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 | Where-Object {
$_.DisplayName -eq "Teams Machine-Wide Installer"
}
}
foreach ($app in $machineWideInstallers) {
Write-Output "Found Teams Machine-Wide Installer: $($app.DisplayName) $($app.DisplayVersion)"
if ($app.UninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
$productCode = $Matches[0]
Write-Output "Removing Teams Machine-Wide Installer using MSI product code: $productCode"
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait
}
elseif ($app.UninstallString) {
Write-Output "Attempting uninstall using uninstall string: $($app.UninstallString)"
Start-Process -FilePath "cmd.exe" -ArgumentList "/c $($app.UninstallString) /quiet /norestart" -Wait
}
}
# Remove classic Teams per-user installs
$userProfiles = Get-ChildItem "C:\Users" -Directory -ErrorAction SilentlyContinue | Where-Object {
$_.Name -notin @(
"Public",
"Default",
"Default User",
"All Users",
"defaultuser0"
)
}
foreach ($profile in $userProfiles) {
$teamsFolder = Join-Path $profile.FullName "AppData\Local\Microsoft\Teams"
$updateExe = Join-Path $teamsFolder "Update.exe"
if (Test-Path $updateExe) {
Write-Output "Uninstalling classic Teams for profile: $($profile.Name)"
Start-Process -FilePath $updateExe -ArgumentList "--uninstall -s" -Wait -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
}
if (Test-Path $teamsFolder) {
Write-Output "Removing remaining classic Teams folder for profile: $($profile.Name)"
Remove-Item -Path $teamsFolder -Recurse -Force -ErrorAction SilentlyContinue
}
}
# Remove common classic Teams leftovers
$leftoverPaths = @(
"$env:ProgramData\Microsoft\Teams",
"$env:ProgramData\Microsoft\TeamsMachineInstaller"
)
foreach ($path in $leftoverPaths) {
if (Test-Path $path) {
Write-Output "Removing leftover path: $path"
Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
}
}
# Confirm new Teams remains installed
$newTeams = Get-AppxPackage -AllUsers | Where-Object {
$_.Name -eq "MSTeams"
}
if ($newTeams) {
Write-Output "New Teams detected and left installed:"
$newTeams | Select-Object Name, Version, PackageFullName | Format-List | Out-String | Write-Output
}
else {
Write-Output "WARNING: New Teams / MSTeams was not detected after cleanup."
}
# Final verification for classic Teams entries
$remainingMachineWide = foreach ($path in $uninstallPaths) {
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
$_.DisplayName -eq "Teams Machine-Wide Installer"
}
}
$remainingClassicFolders = foreach ($profile in $userProfiles) {
$teamsFolder = Join-Path $profile.FullName "AppData\Local\Microsoft\Teams"
if (Test-Path $teamsFolder) {
$teamsFolder
}
}
if ($remainingMachineWide -or $remainingClassicFolders) {
Write-Output "Classic Teams cleanup completed, but some remnants may remain."
if ($remainingMachineWide) {
$remainingMachineWide | Select-Object DisplayName, DisplayVersion | Format-Table | Out-String | Write-Output
}
if ($remainingClassicFolders) {
$remainingClassicFolders | ForEach-Object { Write-Output "Remaining folder: $_" }
}
exit 1
}
else {
Write-Output "Classic Teams and Teams Machine-Wide Installer cleanup completed successfully."
exit 0
}
-71
View File
@@ -1,71 +0,0 @@
# Remove-DotNetCore31.ps1
# Removes unsupported Microsoft .NET Core / ASP.NET Core / Windows Desktop Runtime / SDK 3.1.x components
$ErrorActionPreference = "Continue"
Write-Output "Starting .NET Core 3.1 remediation..."
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$targetApps = foreach ($path in $registryPaths) {
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
(
$_.DisplayName -match "Microsoft \.NET.*3\.1\." -or
$_.DisplayName -match "Microsoft ASP\.NET Core.*3\.1\." -or
$_.DisplayName -match "Microsoft Windows Desktop Runtime.*3\.1\."
) -and
$_.UninstallString
}
}
if (-not $targetApps) {
Write-Output "No .NET Core 3.1 components found. Nothing to remove."
exit 0
}
foreach ($app in $targetApps) {
Write-Output "Found: $($app.DisplayName) $($app.DisplayVersion)"
}
foreach ($app in $targetApps) {
Write-Output "Attempting to remove: $($app.DisplayName) $($app.DisplayVersion)"
$uninstallString = $app.UninstallString
if ($uninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
$productCode = $Matches[0]
Write-Output "Using MSI product code: $productCode"
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait
}
else {
Write-Output "No MSI product code found for $($app.DisplayName). Skipping."
}
}
Write-Output "Verifying remaining .NET Core 3.1 components..."
$remainingApps = foreach ($path in $registryPaths) {
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
(
$_.DisplayName -match "Microsoft \.NET.*3\.1\." -or
$_.DisplayName -match "Microsoft ASP\.NET Core.*3\.1\." -or
$_.DisplayName -match "Microsoft Windows Desktop Runtime.*3\.1\."
) -and
$_.UninstallString
}
}
if ($remainingApps) {
Write-Output "Some .NET Core 3.1 components may still remain:"
$remainingApps | ForEach-Object {
Write-Output "$($_.DisplayName) $($_.DisplayVersion)"
}
exit 1
}
else {
Write-Output ".NET Core 3.1 components removed successfully."
exit 0
}
-233
View File
@@ -1,233 +0,0 @@
# Remove-SpecificTeamsVersions.ps1
# Removes specified vulnerable/old versions of classic Microsoft Teams and Teams Machine-Wide Installer.
# Leaves new Teams / MSTeams MSIX app installed.
$ErrorActionPreference = "Continue"
Write-Output "Starting targeted Microsoft Teams cleanup..."
# Versions to remove
$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"
)
# Stop Teams-related processes
$ProcessNames = @(
"Teams",
"ms-teams",
"msteams",
"Microsoft.Teams",
"Update"
)
foreach ($ProcessName in $ProcessNames) {
Get-Process -Name $ProcessName -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 5
# Registry uninstall locations
$UninstallRegistryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
# Remove matching Teams Machine-Wide Installer versions
Write-Output "Checking for targeted Teams Machine-Wide Installer versions..."
$MachineWideInstallers = foreach ($Path in $UninstallRegistryPaths) {
Get-ItemProperty $Path -ErrorAction SilentlyContinue | Where-Object {
$_.DisplayName -eq "Teams Machine-Wide Installer" -and
$TargetVersions -contains $_.DisplayVersion
}
}
foreach ($App in $MachineWideInstallers) {
Write-Output "Found targeted Teams Machine-Wide Installer: $($App.DisplayVersion)"
if ($App.UninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
$ProductCode = $Matches[0]
Write-Output "Removing Teams Machine-Wide Installer using MSI product code: $ProductCode"
Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/x $ProductCode /qn /norestart" `
-Wait
}
elseif ($App.UninstallString) {
Write-Output "Removing Teams Machine-Wide Installer using uninstall string."
Start-Process -FilePath "cmd.exe" `
-ArgumentList "/c $($App.UninstallString) /quiet /norestart" `
-Wait
}
else {
Write-Output "No uninstall string found for Teams Machine-Wide Installer version $($App.DisplayVersion)."
}
}
# Remove matching classic Teams per-user installs
Write-Output "Checking user profiles for targeted classic Teams versions..."
$UserProfiles = Get-ChildItem "C:\Users" -Directory -ErrorAction SilentlyContinue | Where-Object {
$_.Name -notin @(
"Public",
"Default",
"Default User",
"All Users",
"defaultuser0"
)
}
foreach ($Profile in $UserProfiles) {
$TeamsFolder = Join-Path $Profile.FullName "AppData\Local\Microsoft\Teams"
$UpdateExe = Join-Path $TeamsFolder "Update.exe"
$CurrentExe = Join-Path $TeamsFolder "current\Teams.exe"
if (-not (Test-Path $TeamsFolder)) {
continue
}
$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 ($TargetVersions -contains $DetectedVersion) {
Write-Output "Found targeted classic Teams version $DetectedVersion in profile: $($Profile.Name)"
if (Test-Path $UpdateExe) {
Write-Output "Attempting silent uninstall for profile: $($Profile.Name)"
Start-Process -FilePath $UpdateExe `
-ArgumentList "--uninstall -s" `
-Wait `
-ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
}
if (Test-Path $TeamsFolder) {
Write-Output "Removing remaining classic Teams folder for profile: $($Profile.Name)"
Remove-Item -Path $TeamsFolder -Recurse -Force -ErrorAction SilentlyContinue
}
}
else {
Write-Output "Skipping profile $($Profile.Name). Classic Teams version '$DetectedVersion' is not in target list."
}
}
# Check HKCU-style per-user uninstall entries under user registry hives if loaded
Write-Output "Checking loaded user registry hives for targeted classic Teams uninstall entries..."
$LoadedUserUninstallPaths = Get-ChildItem "Registry::HKEY_USERS" -ErrorAction SilentlyContinue | ForEach-Object {
"Registry::$($_.Name)\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
}
foreach ($Path in $LoadedUserUninstallPaths) {
$UserTeamsApps = Get-ItemProperty $Path -ErrorAction SilentlyContinue | Where-Object {
(
$_.DisplayName -eq "Microsoft Teams" -or
$_.DisplayName -eq "MicrosoftTeams"
) -and
$TargetVersions -contains $_.DisplayVersion
}
foreach ($App in $UserTeamsApps) {
Write-Output "Found targeted per-user Teams uninstall entry: $($App.DisplayName) $($App.DisplayVersion)"
if ($App.UninstallString) {
Start-Process -FilePath "cmd.exe" `
-ArgumentList "/c $($App.UninstallString) --uninstall -s" `
-Wait `
-ErrorAction SilentlyContinue
}
}
}
# Verify new Teams remains installed
Write-Output "Checking for new Teams / MSTeams..."
$NewTeams = Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue | Where-Object {
$_.Name -eq "MSTeams"
}
if ($NewTeams) {
Write-Output "New Teams / MSTeams is installed and was left in place:"
$NewTeams | Select-Object Name, Version, PackageFullName | Format-List | Out-String | Write-Output
}
else {
Write-Output "WARNING: New Teams / MSTeams was not detected."
}
# Final verification
Write-Output "Performing final verification..."
$RemainingMachineWide = foreach ($Path in $UninstallRegistryPaths) {
Get-ItemProperty $Path -ErrorAction SilentlyContinue | Where-Object {
$_.DisplayName -eq "Teams Machine-Wide Installer" -and
$TargetVersions -contains $_.DisplayVersion
}
}
$RemainingClassicTeamsFolders = foreach ($Profile in $UserProfiles) {
$TeamsFolder = Join-Path $Profile.FullName "AppData\Local\Microsoft\Teams"
$CurrentExe = Join-Path $TeamsFolder "current\Teams.exe"
if (Test-Path $CurrentExe) {
$Version = (Get-Item $CurrentExe).VersionInfo.FileVersion
if ($TargetVersions -contains $Version) {
[PSCustomObject]@{
Profile = $Profile.Name
Version = $Version
Path = $TeamsFolder
}
}
}
}
if ($RemainingMachineWide -or $RemainingClassicTeamsFolders) {
Write-Output "Targeted Teams cleanup completed, but some targeted versions may still remain."
if ($RemainingMachineWide) {
Write-Output "Remaining Teams Machine-Wide Installer entries:"
$RemainingMachineWide |
Select-Object DisplayName, DisplayVersion, UninstallString |
Format-Table -AutoSize |
Out-String |
Write-Output
}
if ($RemainingClassicTeamsFolders) {
Write-Output "Remaining classic Teams folders:"
$RemainingClassicTeamsFolders |
Format-Table -AutoSize |
Out-String |
Write-Output
}
exit 1
}
else {
Write-Output "Targeted Teams versions were successfully removed or were not present."
exit 0
}
-9
View File
@@ -1,9 +0,0 @@
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
# ---------------------------
& "Test-SharePointDownload.ps1" `
-SiteUrl "https://zefiromethane.sharepoint.com/sites/zefirocorp" `
-LibraryName "Documents" `
-LocalRoot "F:\Zefiro Methane" `
-OutputDirectory "F:\SharePointValidation"
-74
View File
@@ -1,74 +0,0 @@
# Export-M365UserCertification.ps1
# Requires Microsoft Graph PowerShell SDK
#
# To run: .\User-Recertification.ps1 -OutputPath "C:\Users\Matt\OneDrive - Add Source Group\Documents\1- Projects\M365-User-Certification-Export.csv"
param(
[string]$OutputPath = ".\M365-User-Certification-Export.csv"
)
# Install if needed:
# Install-Module Microsoft.Graph -Scope CurrentUser
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Identity.DirectoryManagement
$Scopes = @(
"User.Read.All",
"Directory.Read.All",
"AuditLog.Read.All"
)
Connect-MgGraph -Scopes $Scopes
# Build SKU lookup so license GUIDs become readable names
$SkuLookup = @{}
Get-MgSubscribedSku -All | ForEach-Object {
$SkuLookup[$_.SkuId.ToString()] = $_.SkuPartNumber
}
$Properties = @(
"id",
"displayName",
"userPrincipalName",
"userType",
"accountEnabled",
"assignedLicenses",
"externalUserState",
"signInActivity"
)
$Users = Get-MgUser -All -Property $Properties
$Export = foreach ($User in $Users) {
$AssignedLicenses = if ($User.AssignedLicenses.Count -gt 0) {
($User.AssignedLicenses | ForEach-Object {
$SkuId = $_.SkuId.ToString()
if ($SkuLookup.ContainsKey($SkuId)) {
$SkuLookup[$SkuId]
}
else {
$SkuId
}
}) -join "; "
}
else {
"None"
}
[PSCustomObject]@{
Name = $User.DisplayName
UserPrincipalName = $User.UserPrincipalName
UserType = $User.UserType
AccountEnabled = $User.AccountEnabled
AssignedLicenses = $AssignedLicenses
GuestInvitationState = $User.ExternalUserState
LastInteractiveSignInDate = $User.SignInActivity.LastSignInDateTime
}
}
$Export |
Sort-Object Name |
Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host "Export complete: $OutputPath"
+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
}