refactor(scripts): optimize system remediation and M365 export scripts
- teams: harden Classic Teams removal, protect New Teams, and clean HKU run keys - dotnet: add bootstrapper EXE uninstall support and remove orphaned 3.1.* folders - teams: add MSIX package targeting and version parsing for vulnerable New Teams builds - m365: improve Graph SDK export speed, reuse sessions, and add null-safe handling
This commit is contained in:
1 parent
4fd95b76d6
commit
5ef82842f2
19 files changed
+1651
-1268
No files matched your search
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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."
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Audits active network adapters to verify malware-filtering DNS configuration.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Retrieves all connected network adapters ('Up' status) and checks whether their configured
|
||||||
|
DNS server addresses match an approved list of security-filtering DNS providers
|
||||||
|
(e.g., Quad9 or Cloudflare for Families).
|
||||||
|
|
||||||
|
Outputs a compressed JSON payload for RMM systems or Intune Custom Compliance discovery.
|
||||||
|
|
||||||
|
.PARAMETER ApprovedDnsServers
|
||||||
|
Array of IPv4 DNS server addresses considered compliant.
|
||||||
|
Defaults to Quad9 (9.9.9.9, 149.112.112.11) and Cloudflare Security/Family (1.1.1.2, 1.0.0.2, 1.1.1.3, 1.0.0.3).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Test-DnsCompliance.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Test-DnsCompliance.ps1 -ApprovedDnsServers "9.9.9.9", "1.1.1.2"
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Created: 2025-03-05
|
||||||
|
Modified: 2026-10-04
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[string[]]$ApprovedDnsServers = @(
|
||||||
|
"9.9.9.9", # Quad9 Primary (Malware + DNSSEC)
|
||||||
|
"149.112.112.11", # Quad9 Secondary (Malware + DNSSEC)
|
||||||
|
"1.1.1.2", # Cloudflare Security Primary (Malware)
|
||||||
|
"1.0.0.2", # Cloudflare Security Secondary (Malware)
|
||||||
|
"1.1.1.3", # Cloudflare Family Primary (Malware + Adult)
|
||||||
|
"1.0.0.3" # Cloudflare Family Secondary (Malware + Adult)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Retrieve all active/connected network adapters
|
||||||
|
$ActiveAdapters = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' }
|
||||||
|
|
||||||
|
if (-not $ActiveAdapters) {
|
||||||
|
Write-Verbose "No active network adapters found on this endpoint."
|
||||||
|
$NoAdapterOutput = [ordered]@{
|
||||||
|
Compliant = $true
|
||||||
|
Reason = "No active network adapters found."
|
||||||
|
Adapters = @()
|
||||||
|
}
|
||||||
|
return ($NoAdapterOutput | ConvertTo-Json -Compress)
|
||||||
|
}
|
||||||
|
|
||||||
|
$IsOverallCompliant = $true
|
||||||
|
$AdapterAuditResults = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
|
||||||
|
# 2. Audit DNS server assignments per active interface
|
||||||
|
foreach ($Adapter in $ActiveAdapters) {
|
||||||
|
|
||||||
|
# Query assigned IPv4 DNS server addresses
|
||||||
|
$DnsConfig = Get-DnsClientServerAddress -InterfaceIndex $Adapter.InterfaceIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
|
||||||
|
$ConfiguredServers = $DnsConfig.ServerAddresses
|
||||||
|
|
||||||
|
# Verify if configured DNS contains at least one approved security DNS IP
|
||||||
|
$MatchingServers = $ConfiguredServers | Where-Object { $ApprovedDnsServers -contains $_ }
|
||||||
|
$IsAdapterCompliant = ($MatchingServers.Count -gt 0)
|
||||||
|
|
||||||
|
# If any active adapter fails the check, mark the endpoint overall non-compliant
|
||||||
|
if (-not $IsAdapterCompliant) {
|
||||||
|
$IsOverallCompliant = $false
|
||||||
|
Write-Verbose "Adapter '$($Adapter.Name)' (Index: $($Adapter.InterfaceIndex)) is NON-COMPLIANT. Configured: $($ConfiguredServers -join ', ')"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Adapter '$($Adapter.Name)' (Index: $($Adapter.InterfaceIndex)) is COMPLIANT. Configured: $($ConfiguredServers -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
$AdapterAuditResults.Add([PSCustomObject]@{
|
||||||
|
AdapterName = $Adapter.Name
|
||||||
|
InterfaceIndex = $Adapter.InterfaceIndex
|
||||||
|
ConfiguredDns = $ConfiguredServers
|
||||||
|
IsCompliant = $IsAdapterCompliant
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Construct summary object and output JSON
|
||||||
|
$ComplianceReport = [ordered]@{
|
||||||
|
Compliant = $IsOverallCompliant
|
||||||
|
TestedAt = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
|
||||||
|
Adapters = $AdapterAuditResults
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($ComplianceReport | ConvertTo-Json -Compress)
|
||||||
Executable
+296
@@ -0,0 +1,296 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates interactive HTML and plain-text directory tree reports.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Recursively scans a target directory and produces two report files:
|
||||||
|
1. A plain-text ASCII directory tree file (.txt).
|
||||||
|
2. An interactive HTML tree file (.html) with search filtering and collapse/expand controls.
|
||||||
|
|
||||||
|
.PARAMETER RootPath
|
||||||
|
The target root folder path to scan.
|
||||||
|
|
||||||
|
.PARAMETER OutputDirectory
|
||||||
|
The destination directory where reports will be saved. Defaults to the current working directory.
|
||||||
|
|
||||||
|
.PARAMETER ReportNamePrefix
|
||||||
|
Optional custom prefix for generated filenames. Defaults to the sanitized root folder name.
|
||||||
|
|
||||||
|
.PARAMETER OpenInBrowser
|
||||||
|
Switch parameter to automatically open the generated HTML report upon completion.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Export-DirectoryTree.ps1 -RootPath "E:\Sync Method\Corporate\Documents" -OutputDirectory "C:\Reports" -OpenInBrowser
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Created: 2025-03-02
|
||||||
|
Modified: 2026-10-04
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline =$true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$RootPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, Position = 1)] [string]$OutputDirectory = (Get-Location).Path,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$ReportNamePrefix,
|
||||||
|
|
||||||
|
[switch]$OpenInBrowser
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Validate & Resolve Paths
|
||||||
|
if (-not (Test-Path -LiteralPath $RootPath -PathType Container)) {
|
||||||
|
Write-Error "Target root directory not found or unreadable: '$RootPath'"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$ResolvedRoot = (Get-Item -LiteralPath$RootPath).FullName
|
||||||
|
$RootLeafName = Split-Path -Path$ResolvedRoot -Leaf
|
||||||
|
|
||||||
|
# Auto-generate report prefix if not explicitly provided
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ReportNamePrefix)) {
|
||||||
|
$ReportNamePrefix =$RootLeafName -replace '[\/\\:\*\?"<>\|]', '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
if (-not (Test-Path -LiteralPath $OutputDirectory)) {
|
||||||
|
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$TextFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.txt"
|
||||||
|
$HtmlFile = Join-Path -Path $OutputDirectory -ChildPath "$ReportNamePrefix-tree.html"
|
||||||
|
|
||||||
|
# 2. State Tracking Setup
|
||||||
|
$script:textLines = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$script:html = [System.Text.StringBuilder]::new()$script:scanErrors = 0
|
||||||
|
$script:totalFiles = 0$script:totalFolders = 0
|
||||||
|
|
||||||
|
function ConvertTo-HtmlEncoded {
|
||||||
|
param([string]$Text)
|
||||||
|
return [System.Net.WebUtility]::HtmlEncode($Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-TreeItems {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$items = @(
|
||||||
|
Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop |
|
||||||
|
Sort-Object @{ Expression = { if ($_.PSIsContainer) { 0 } else { 1 } } }, Name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$script:scanErrors++
|
||||||
|
[void]$script:textLines.Add("$Prefix[Unable to read: $($_.Exception.Message)]")
|
||||||
|
[void]$script:html.AppendLine(
|
||||||
|
"<li class='error'>Unable to read: $(ConvertTo-HtmlEncoded$_.Exception.Message)</li>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($index = 0; $index -lt $items.Count; $index++) {
|
||||||
|
$item =$items[$index]$isLast = $index -eq ($items.Count - 1)
|
||||||
|
$connector = if ($isLast) { '\-- ' } else { '|-- ' }
|
||||||
|
$nextPrefix =$Prefix + $(if ($isLast) { ' ' } else { '| ' })
|
||||||
|
|
||||||
|
$nameHtml = ConvertTo-HtmlEncoded$item.Name
|
||||||
|
$pathHtml = ConvertTo-HtmlEncoded$item.FullName
|
||||||
|
$isLink = -not [string]::IsNullOrWhiteSpace([string]$item.LinkType)
|
||||||
|
|
||||||
|
if ($item.PSIsContainer -and -not $isLink) {$script:totalFolders++
|
||||||
|
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
|
||||||
|
[void]$script:html.AppendLine(
|
||||||
|
"<li><details data-path='$pathHtml'><summary>📁 $nameHtml</summary><ul>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recurse subdirectories
|
||||||
|
Add-TreeItems -Path $item.FullName -Prefix$nextPrefix
|
||||||
|
|
||||||
|
[void]$script:html.AppendLine('</ul></details></li>')
|
||||||
|
}
|
||||||
|
elseif ($item.PSIsContainer -and $isLink) {$script:totalFolders++
|
||||||
|
$target = @($item.Target) -join ', '
|
||||||
|
[void]$script:textLines.Add("$Prefix$connector$($item.Name) [Link → $target]")
|
||||||
|
[void]$script:html.AppendLine(
|
||||||
|
"<li class='link' data-path='$pathHtml'>🔗 $nameHtml <small>→ $(ConvertTo-HtmlEncoded$target)</small></li>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$script:totalFiles++
|
||||||
|
[void]$script:textLines.Add("$Prefix$connector$($item.Name)")
|
||||||
|
[void]$script:html.AppendLine(
|
||||||
|
"<li class='file' data-path='$pathHtml'>📄 $nameHtml</li>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Document Building
|
||||||
|
Write-Host "Scanning directory structure: $ResolvedRoot" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$rootNameHtml = ConvertTo-HtmlEncoded $RootLeafName$rootPathHtml = ConvertTo-HtmlEncoded $ResolvedRoot$generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
|
||||||
|
[void]$script:textLines.Add($ResolvedRoot)
|
||||||
|
|
||||||
|
[void]$script:html.AppendLine(@"
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Directory Tree - $rootNameHtml</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #ffffff;
|
||||||
|
--text-color: #1f2328;
|
||||||
|
--border-color: #d0d7de;
|
||||||
|
--link-color: #8250df;
|
||||||
|
--error-color: #cf222e;
|
||||||
|
--subtext-color: #57606a;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 24px;
|
||||||
|
color: var(--text-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
h1 { margin-bottom: 6px; font-size: 1.5rem; }
|
||||||
|
.meta { color: var(--subtext-color); margin-bottom: 18px; font-size: 0.9rem; line-height: 1.4; }
|
||||||
|
.controls {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
input[type="search"] {
|
||||||
|
width: min(500px, 60vw);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background-color: #f3f4f6; }
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 4px 0 4px 16px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 1px solid #d8dee4;
|
||||||
|
}
|
||||||
|
.tree { border-left: 0; margin-left: 0; padding-left: 0; }
|
||||||
|
summary { cursor: pointer; padding: 3px; font-weight: 500; }
|
||||||
|
summary:hover { background-color: #f6f8fa; border-radius: 4px; }
|
||||||
|
.file, .link { padding: 3px; }
|
||||||
|
.link { color: var(--link-color); }
|
||||||
|
.error { color: var(--error-color); }
|
||||||
|
small { color: var(--subtext-color); }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Directory Tree Report</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<strong>Root:</strong> <code>$rootPathHtml</code><br>
|
||||||
|
<strong>Generated:</strong> $generated
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="search" type="search" placeholder="Search files and folders...">
|
||||||
|
<button type="button" onclick="setAll(true)">Expand All</button>
|
||||||
|
<button type="button" onclick="setAll(false)">Collapse All</button>
|
||||||
|
</div>
|
||||||
|
<ul class="tree">
|
||||||
|
<li>
|
||||||
|
<details open data-path="$rootPathHtml">
|
||||||
|
<summary>📁 $rootNameHtml</summary>
|
||||||
|
<ul>
|
||||||
|
"@)
|
||||||
|
|
||||||
|
# Run recursive scan
|
||||||
|
Add-TreeItems -Path $ResolvedRoot -Prefix ''
|
||||||
|
|
||||||
|
[void]$script:html.AppendLine(@"
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<script>
|
||||||
|
function setAll(state) {
|
||||||
|
document.querySelectorAll('details').forEach(function (item) {
|
||||||
|
item.open = state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('search').addEventListener('input', function () {
|
||||||
|
const term = this.value.trim().toLowerCase();
|
||||||
|
const nodes = Array.from(document.querySelectorAll('[data-path]'));
|
||||||
|
|
||||||
|
nodes.forEach(function (node) {
|
||||||
|
node.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!term) return;
|
||||||
|
|
||||||
|
nodes.forEach(function (node) {
|
||||||
|
if (!node.dataset.path.toLowerCase().includes(term)) {
|
||||||
|
node.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.filter(function (node) {
|
||||||
|
return node.dataset.path.toLowerCase().includes(term);
|
||||||
|
}).forEach(function (node) {
|
||||||
|
node.classList.remove('hidden');
|
||||||
|
let parent = node.parentElement;
|
||||||
|
|
||||||
|
while (parent) {
|
||||||
|
if (parent.tagName === 'DETAILS') {
|
||||||
|
parent.open = true;
|
||||||
|
parent.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (parent.tagName === 'LI') {
|
||||||
|
parent.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"@)
|
||||||
|
|
||||||
|
# 4. Save Output Files
|
||||||
|
$script:textLines \vert{} Set-Content -LiteralPath$TextFile -Encoding UTF8
|
||||||
|
$script:html.ToString() \vert{} Set-Content -LiteralPath$HtmlFile -Encoding UTF8
|
||||||
|
|
||||||
|
# Output Summary
|
||||||
|
Write-Host "`nScan Completed Successfully:" -ForegroundColor Green
|
||||||
|
Write-Host " Folders Scanned : $script:totalFolders"
|
||||||
|
Write-Host " Files Cataloged : $script:totalFiles"
|
||||||
|
Write-Host " Unreadable Errors: $script:scanErrors"
|
||||||
|
Write-Host " Text Report : $TextFile"
|
||||||
|
Write-Host " HTML Report : $HtmlFile`n"
|
||||||
|
|
||||||
|
if ($OpenInBrowser) {
|
||||||
|
Start-Process $HtmlFile
|
||||||
|
}
|
||||||
Executable
+210
@@ -0,0 +1,210 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Recursively downloads all SharePoint document libraries and files from a site using Microsoft Graph.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Connects to the Microsoft Graph API, resolves a SharePoint site URL, enumerates all document libraries (drives),
|
||||||
|
and recursively downloads folders and files to a designated local target directory.
|
||||||
|
|
||||||
|
Includes automatic filename sanitization for Windows file systems, pagination support, skipping existing files,
|
||||||
|
and a final download statistics report.
|
||||||
|
|
||||||
|
.PARAMETER SiteUrl
|
||||||
|
The full URL of the target SharePoint site (e.g., "https://zefiromethane.sharepoint.com/sites/contentTypeHub").
|
||||||
|
|
||||||
|
.PARAMETER Destination
|
||||||
|
The local root folder path where document libraries and files will be saved.
|
||||||
|
|
||||||
|
.PARAMETER SkipAuthentication
|
||||||
|
Switch parameter to skip 'Connect-MgGraph' if an active Graph session is already established.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Export-SharePointSiteFiles.ps1 -SiteUrl "https://zefiromethane.sharepoint.com/sites/contentTypeHub" -Destination "F:\TeamSite"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Export-SharePointSiteFiles.ps1 -SiteUrl "https://contoso.sharepoint.com/sites/hr" -Destination "C:\Backup\HR" -Verbose
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Created: 2025-03-05
|
||||||
|
Modified: 2026-10-04
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$SiteUrl,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true, Position = 1)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$Destination,
|
||||||
|
|
||||||
|
[switch]$SkipAuthentication
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Prerequisite Module Check
|
||||||
|
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
|
||||||
|
Write-Error "The 'Microsoft.Graph.Authentication' module is required. Install it using: Install-Module Microsoft.Graph"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. State & Metric Counters
|
||||||
|
$script:downloadCount = 0
|
||||||
|
$script:skippedCount = 0
|
||||||
|
$script:folderCount = 0
|
||||||
|
$script:errorCount = 0
|
||||||
|
$script:totalBytes = 0
|
||||||
|
|
||||||
|
# 3. Helper Functions
|
||||||
|
|
||||||
|
function Sanitize-PathSegment {
|
||||||
|
param([string]$Name)
|
||||||
|
# Replaces Windows reserved filename characters: \ / : * ? " < > |
|
||||||
|
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars()
|
||||||
|
$sanitized = $Name
|
||||||
|
foreach ($char in $invalidChars) {
|
||||||
|
$sanitized = $sanitized.Replace([string]$char, '_')
|
||||||
|
}
|
||||||
|
return $sanitized.TrimEnd('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GraphCollection {
|
||||||
|
param([string]$Uri)
|
||||||
|
|
||||||
|
do {
|
||||||
|
$response = Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop
|
||||||
|
|
||||||
|
foreach ($item in @($response.value)) {
|
||||||
|
Write-Output $item
|
||||||
|
}
|
||||||
|
|
||||||
|
$Uri = $response.'@odata.nextLink'
|
||||||
|
}
|
||||||
|
while ($Uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Copy-GraphFolder {
|
||||||
|
param(
|
||||||
|
[string]$DriveId,
|
||||||
|
[string]$ChildrenUri,
|
||||||
|
[string]$LocalFolder
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $LocalFolder)) {
|
||||||
|
New-Item -ItemType Directory -Path $LocalFolder -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$items = Get-GraphCollection -Uri $ChildrenUri
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to retrieve folder contents from URI: $ChildrenUri. Error: $_"
|
||||||
|
$script:errorCount++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($item in $items) {
|
||||||
|
# Sanitize item name to prevent Windows file path errors
|
||||||
|
$safeName = Sanitize-PathSegment -Name $item.name
|
||||||
|
$localPath = Join-Path -Path $LocalFolder -ChildPath $safeName
|
||||||
|
$escapedDriveId = [Uri]::EscapeDataString($DriveId)
|
||||||
|
$escapedItemId = [Uri]::EscapeDataString([string]$item.id)
|
||||||
|
|
||||||
|
# Handle Folders / Packages
|
||||||
|
if ($null -ne $item.folder -or $null -ne $item.package) {
|
||||||
|
$script:folderCount++
|
||||||
|
New-Item -ItemType Directory -Path $localPath -Force | Out-Null
|
||||||
|
|
||||||
|
$childUri = "https://graph.microsoft.com/v1.0/drives/$escapedDriveId/items/$escapedItemId/children"
|
||||||
|
|
||||||
|
Copy-GraphFolder -DriveId $DriveId -ChildrenUri $childUri -LocalFolder $localPath
|
||||||
|
}
|
||||||
|
# Handle Files
|
||||||
|
elseif ($null -ne $item.file) {
|
||||||
|
if (Test-Path -LiteralPath $localPath) {
|
||||||
|
Write-Verbose "Skipping existing file: $localPath"
|
||||||
|
$script:skippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Downloading: $safeName" -ForegroundColor Cyan
|
||||||
|
$contentUri = "https://graph.microsoft.com/v1.0/drives/$escapedDriveId/items/$escapedItemId/content"
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoke-MgGraphRequest -Method GET -Uri $contentUri -OutputFilePath $localPath -ErrorAction Stop
|
||||||
|
$script:downloadCount++
|
||||||
|
if ($item.size) { $script:totalBytes += $item.size }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to download '$($item.name)' to '$localPath'. Error: $_"
|
||||||
|
$script:errorCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning "Unsupported item skipped: $($item.name)"
|
||||||
|
$script:skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Main Execution Flow
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Establish Graph Connection
|
||||||
|
if (-not $SkipAuthentication) {
|
||||||
|
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Yellow
|
||||||
|
Connect-MgGraph -Scopes "Sites.Read.All" -NoWelcome
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve SharePoint Site ID
|
||||||
|
$parsedSiteUrl = [Uri]$SiteUrl
|
||||||
|
$sitePath = $parsedSiteUrl.AbsolutePath.TrimEnd("/")
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($sitePath)) {
|
||||||
|
$siteLookupUri = "https://graph.microsoft.com/v1.0/sites/$($parsedSiteUrl.Host)"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$siteLookupUri = "https://graph.microsoft.com/v1.0/sites/$($parsedSiteUrl.Host):$sitePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Resolving SharePoint site..." -ForegroundColor Yellow
|
||||||
|
$site = Invoke-MgGraphRequest -Method GET -Uri $siteLookupUri -ErrorAction Stop
|
||||||
|
|
||||||
|
# Ensure root destination folder exists
|
||||||
|
if (-not (Test-Path -LiteralPath $Destination)) {
|
||||||
|
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enumerate Site Drives (Document Libraries)
|
||||||
|
$drivesUri = "https://graph.microsoft.com/v1.0/sites/$($site.id)/drives"
|
||||||
|
$drives = Get-GraphCollection -Uri $drivesUri
|
||||||
|
|
||||||
|
foreach ($drive in $drives) {
|
||||||
|
$safeDriveName = Sanitize-PathSegment -Name $drive.name
|
||||||
|
$localLibrary = Join-Path -Path $Destination -ChildPath $safeDriveName
|
||||||
|
$escapedDriveId = [Uri]::EscapeDataString([string]$drive.id)
|
||||||
|
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Green
|
||||||
|
Write-Host "Downloading Library: $($drive.name)" -ForegroundColor Green
|
||||||
|
Write-Host "Local Target : $localLibrary" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
|
||||||
|
$rootChildrenUri = "https://graph.microsoft.com/v1.0/drives/$escapedDriveId/root/children"
|
||||||
|
|
||||||
|
Copy-GraphFolder -DriveId $drive.id -ChildrenUri $rootChildrenUri -LocalFolder$localLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display Execution Summary
|
||||||
|
$MBTransferred = [math]::Round($script:totalBytes / 1MB, 2)
|
||||||
|
Write-Host "`nSharePoint Download Summary:" -ForegroundColor Green
|
||||||
|
Write-Host " Files Downloaded : $script:downloadCount ($MBTransferred MB)"
|
||||||
|
Write-Host " Files Skipped : $script:skippedCount"
|
||||||
|
Write-Host " Folders Created : $script:folderCount"
|
||||||
|
Write-Host " Errors Encountered: $script:errorCount"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (-not $SkipAuthentication) {
|
||||||
|
Write-Host "`nDisconnecting Microsoft Graph session..." -ForegroundColor Yellow
|
||||||
|
Disconnect-MgGraph
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports completed DocuSign envelopes and Certificates of Completion via the eSignature REST API.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Uses the DocuSign eSignature REST API to:
|
||||||
|
1. Authenticate with JWT Bearer assertion and handle automatic token renewal.
|
||||||
|
2. Enumerate completed envelopes across a specified date range with API pagination.
|
||||||
|
3. Download each envelope as an archive ZIP or combined PDF.
|
||||||
|
4. Support incremental runs by skipping previously downloaded files.
|
||||||
|
5. Output a detailed CSV export log and execution summary.
|
||||||
|
|
||||||
|
.PARAMETER IntegrationKey
|
||||||
|
The DocuSign App / Integration Key (Client ID).
|
||||||
|
|
||||||
|
.PARAMETER UserId
|
||||||
|
The API User ID (GUID) of the user being impersonated.
|
||||||
|
|
||||||
|
.PARAMETER AccountId
|
||||||
|
The DocuSign Account ID (GUID).
|
||||||
|
|
||||||
|
.PARAMETER PrivateKeyPath
|
||||||
|
Path to the RSA private key PEM file associated with the Integration Key.
|
||||||
|
|
||||||
|
.PARAMETER FromDate
|
||||||
|
Start date for querying completed envelopes.
|
||||||
|
|
||||||
|
.PARAMETER ToDate
|
||||||
|
End date for querying completed envelopes. Defaults to the current date/time.
|
||||||
|
|
||||||
|
.PARAMETER OutputRoot
|
||||||
|
Root directory where downloads and logs will be saved.
|
||||||
|
|
||||||
|
.PARAMETER Environment
|
||||||
|
DocuSign environment target: 'Production' or 'Demo'. Defaults to 'Production'.
|
||||||
|
|
||||||
|
.PARAMETER OutputFormat
|
||||||
|
Format to download documents: 'ArchiveZip' (ZIP containing all docs + certificate) or 'CombinedPdf' (Single combined PDF).
|
||||||
|
|
||||||
|
.PARAMETER SkipExisting
|
||||||
|
If specified, skips downloading envelopes whose target file already exists locally. Defaults to $true.
|
||||||
|
|
||||||
|
.PARAMETER PageSize
|
||||||
|
Number of envelopes to request per API page call. Defaults to 1000 (DocuSign max limit).
|
||||||
|
|
||||||
|
.PARAMETER RetryCount
|
||||||
|
Maximum retry attempts for transient API errors or rate-limit throttling. Defaults to 4.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Export-DocuSignCompletedAgreements.ps1 `
|
||||||
|
-IntegrationKey "00000000-0000-0000-0000-000000000000" `
|
||||||
|
-UserId "11111111-1111-1111-1111-111111111111" `
|
||||||
|
-AccountId "22222222-2222-2222-2222-222222222222" `
|
||||||
|
-PrivateKeyPath "C:\Keys\docusign_private.key" `
|
||||||
|
-FromDate "2024-01-01" `
|
||||||
|
-OutputRoot "D:\DocuSignExport" `
|
||||||
|
-OutputFormat ArchiveZip
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Created: 2025-03-05
|
||||||
|
Modified: 2026-10-04
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$IntegrationKey,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$UserId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$AccountId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
|
||||||
|
[string]$PrivateKeyPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[datetime]$FromDate,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[datetime]$ToDate = (Get-Date),
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]$OutputRoot,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[ValidateSet("Production", "Demo")]
|
||||||
|
[string]$Environment = "Production",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[ValidateSet("ArchiveZip", "CombinedPdf")]
|
||||||
|
[string]$OutputFormat = "ArchiveZip",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[bool]$SkipExisting = $true,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[ValidateRange(1, 1000)]
|
||||||
|
[int]$PageSize = 1000,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[int]$RetryCount = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Global state tracking for OAuth token management
|
||||||
|
$script:currentToken = $null
|
||||||
|
$script:tokenExpiration = [DateTimeOffset]::MinValue
|
||||||
|
$script:apiBaseUri = $null
|
||||||
|
|
||||||
|
# Helper Functions
|
||||||
|
|
||||||
|
function ConvertTo-Base64Url {
|
||||||
|
param([byte[]]$Bytes)
|
||||||
|
[Convert]::ToBase64String($Bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Import-RsaPrivateKey {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
$pem = Get-Content -Path $Path -Raw
|
||||||
|
$rsa = [System.Security.Cryptography.RSA]::Create()
|
||||||
|
|
||||||
|
if ($pem -match "-----BEGIN RSA PRIVATE KEY-----") {
|
||||||
|
$body = $pem -replace "-----BEGIN RSA PRIVATE KEY-----", "" -replace "-----END RSA PRIVATE KEY-----", "" -replace "\s", ""
|
||||||
|
$bytes = [Convert]::FromBase64String($body)
|
||||||
|
[void]$rsa.ImportRSAPrivateKey($bytes, [ref]0)
|
||||||
|
return $rsa
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pem -match "-----BEGIN PRIVATE KEY-----") {
|
||||||
|
$body = $pem -replace "-----BEGIN PRIVATE KEY-----", "" -replace "-----END PRIVATE KEY-----", "" -replace "\s", ""
|
||||||
|
$bytes = [Convert]::FromBase64String($body)
|
||||||
|
[void]$rsa.ImportPkcs8PrivateKey($bytes, [ref]0)
|
||||||
|
return $rsa
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Private key must be PEM formatted with 'BEGIN RSA PRIVATE KEY' or 'BEGIN PRIVATE KEY'."
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-DocuSignJwtAssertion {
|
||||||
|
param(
|
||||||
|
[string]$IntegrationKey,
|
||||||
|
[string]$UserId,
|
||||||
|
[string]$AuthServer,
|
||||||
|
[string]$PrivateKeyPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$now = [DateTimeOffset]::UtcNow
|
||||||
|
$header = @{
|
||||||
|
alg = "RS256"
|
||||||
|
typ = "JWT"
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
|
||||||
|
$payload = @{
|
||||||
|
iss = $IntegrationKey
|
||||||
|
sub = $UserId
|
||||||
|
aud = $AuthServer
|
||||||
|
iat = $now.ToUnixTimeSeconds()
|
||||||
|
exp = $now.AddMinutes(55).ToUnixTimeSeconds()
|
||||||
|
scope = "signature impersonation"
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
|
||||||
|
$encodedHeader = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($header))
|
||||||
|
$encodedPayload = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($payload))
|
||||||
|
$unsignedToken = "$encodedHeader.$encodedPayload"
|
||||||
|
|
||||||
|
$rsa = Import-RsaPrivateKey -Path $PrivateKeyPath
|
||||||
|
try {
|
||||||
|
$signature = $rsa.SignData(
|
||||||
|
[Text.Encoding]::UTF8.GetBytes($unsignedToken),
|
||||||
|
[Security.Cryptography.HashAlgorithmName]::SHA256,
|
||||||
|
[Security.Cryptography.RSASignaturePadding]::Pkcs1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$rsa.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$unsignedToken.$(ConvertTo-Base64Url $signature)"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ValidAccessToken {
|
||||||
|
# Refresh token 5 minutes before actual expiry to avoid race conditions
|
||||||
|
if ($null -ne $script:currentToken -and [DateTimeOffset]::UtcNow -lt $script:tokenExpiration) {
|
||||||
|
return $script:currentToken
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Obtaining fresh DocuSign access token..."
|
||||||
|
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
|
||||||
|
$tokenUri = "https://$authServer/oauth/token"
|
||||||
|
|
||||||
|
$assertion = New-DocuSignJwtAssertion `
|
||||||
|
-IntegrationKey $IntegrationKey `
|
||||||
|
-UserId $UserId `
|
||||||
|
-AuthServer $authServer `
|
||||||
|
-PrivateKeyPath $PrivateKeyPath
|
||||||
|
|
||||||
|
$tokenResponse = Invoke-WithRetry -RetryCount $RetryCount -ActionName "JWT Token Request" -ScriptBlock {
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri $tokenUri `
|
||||||
|
-ContentType "application/x-www-form-urlencoded" `
|
||||||
|
-Body @{
|
||||||
|
grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||||
|
assertion = $assertion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:currentToken = $tokenResponse.access_token
|
||||||
|
# DocuSign tokens typically last 3600s (1 hr); refresh 5 mins early
|
||||||
|
$expiresInSeconds = [int]($tokenResponse.expires_in ?? 3600) - 300
|
||||||
|
$script:tokenExpiration = [DateTimeOffset]::UtcNow.AddSeconds($expiresInSeconds)
|
||||||
|
|
||||||
|
return $script:currentToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-WithRetry {
|
||||||
|
param(
|
||||||
|
[scriptblock]$ScriptBlock,
|
||||||
|
[int]$RetryCount = 4,
|
||||||
|
[string]$ActionName = "DocuSign API request"
|
||||||
|
)
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le ($RetryCount + 1); $attempt++) {
|
||||||
|
try {
|
||||||
|
return & $ScriptBlock
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$response = $_.Exception.Response
|
||||||
|
$statusCode = $null
|
||||||
|
|
||||||
|
if ($response -and $response.StatusCode) {
|
||||||
|
$statusCode = [int]$response.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
$retryable = $statusCode -in @(429, 500, 502, 503, 504)
|
||||||
|
if (-not $retryable -or $attempt -gt $RetryCount) {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
|
# Honor DocuSign 'Retry-After' header if provided on HTTP 429
|
||||||
|
$delaySeconds = 0
|
||||||
|
if ($statusCode -eq 429 -and $response.Headers["Retry-After"]) {
|
||||||
|
[int]::TryParse($response.Headers["Retry-After"], [ref]$delaySeconds) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($delaySeconds -le 0) {
|
||||||
|
$delaySeconds = [math]::Min(60, [math]::Pow(2, $attempt))
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "$ActionName encountered HTTP $statusCode. Retrying in $delaySeconds seconds (Attempt $attempt of $RetryCount)..."
|
||||||
|
Start-Sleep -Seconds $delaySeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SafeFileName {
|
||||||
|
param(
|
||||||
|
[string]$Text,
|
||||||
|
[int]$MaxLength = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
$invalid = [Regex]::Escape(([IO.Path]::GetInvalidFileNameChars() -join ""))
|
||||||
|
$safe = [Regex]::Replace($Text, "[$invalid]", "_")
|
||||||
|
$safe = [Regex]::Replace($safe, "\s+", " ").Trim()
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($safe)) {
|
||||||
|
$safe = "Envelope"
|
||||||
|
}
|
||||||
|
if ($safe.Length -gt $MaxLength) {
|
||||||
|
$safe = $safe.Substring(0, $MaxLength).Trim()
|
||||||
|
}
|
||||||
|
return $safe
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-EnvelopeDocument {
|
||||||
|
param(
|
||||||
|
[string]$Uri,
|
||||||
|
[string]$Path,
|
||||||
|
[string]$AcceptHeader,
|
||||||
|
[int]$RetryCount
|
||||||
|
)
|
||||||
|
|
||||||
|
Invoke-WithRetry -RetryCount $RetryCount -ActionName "Downloading document to '$Path'" -ScriptBlock {
|
||||||
|
$token = Get-ValidAccessToken
|
||||||
|
$headers = @{
|
||||||
|
Authorization = "Bearer $token"
|
||||||
|
Accept = $AcceptHeader
|
||||||
|
}
|
||||||
|
Invoke-WebRequest -Uri $Uri -Headers $headers -OutFile $Path -ErrorAction Stop
|
||||||
|
} | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main Execution Flow
|
||||||
|
|
||||||
|
# 1. Directory Initialization
|
||||||
|
$downloadFolder = Join-Path $OutputRoot "completed-agreements"
|
||||||
|
$logFolder = Join-Path $OutputRoot "logs"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $logFolder -Force | Out-Null
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$logPath = Join-Path $logFolder "docusign-export-log-$timestamp.csv"
|
||||||
|
|
||||||
|
# 2. Base URI Discovery
|
||||||
|
$authServer = if ($Environment -eq "Demo") { "account-d.docusign.com" } else { "account.docusign.com" }
|
||||||
|
$initialToken = Get-ValidAccessToken
|
||||||
|
|
||||||
|
Write-Host "Discovering account base URI for Account ID: $AccountId..." -ForegroundColor Cyan
|
||||||
|
$userInfo = Invoke-WithRetry -RetryCount $RetryCount -ActionName "UserInfo Discovery" -ScriptBlock {
|
||||||
|
Invoke-RestMethod -Method Get -Uri "https://$authServer/oauth/userinfo" -Headers @{ Authorization = "Bearer $initialToken" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$account = $userInfo.accounts | Where-Object { $_.account_id -eq $AccountId } | Select-Object -First 1
|
||||||
|
if (-not $account) {
|
||||||
|
throw "AccountId '$AccountId' was not found for the authenticated impersonated user."
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseUri = $account.base_uri.TrimEnd("/")
|
||||||
|
$script:apiBaseUri = "$baseUri/restapi/v2.1/accounts/$AccountId"
|
||||||
|
|
||||||
|
# 3. Request Format Settings
|
||||||
|
$documentId = if ($OutputFormat -eq "ArchiveZip") { "archive" } else { "combined" }
|
||||||
|
$extension = if ($OutputFormat -eq "ArchiveZip") { ".zip" } else { ".pdf" }
|
||||||
|
$accept = if ($OutputFormat -eq "ArchiveZip") { "application/zip" } else { "application/pdf" }
|
||||||
|
|
||||||
|
$fromIso = $FromDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
$toIso = $ToDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
|
||||||
|
Write-Host "Querying completed envelopes from $fromIso to $toIso..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 4. Enumerate Completed Envelopes (Paginated)
|
||||||
|
$allEnvelopes = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$startPosition = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
$query = @{
|
||||||
|
from_date = $fromIso
|
||||||
|
to_date = $toIso
|
||||||
|
status = "completed"
|
||||||
|
count = $PageSize
|
||||||
|
start_position = $startPosition
|
||||||
|
order_by = "completed"
|
||||||
|
order = "asc"
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryString = ($query.GetEnumerator() | ForEach-Object {
|
||||||
|
"{0}={1}" -f [Uri]::EscapeDataString($_.Key), [Uri]::EscapeDataString([string]$_.Value)
|
||||||
|
}) -join "&"
|
||||||
|
|
||||||
|
$uri = "$($script:apiBaseUri)/envelopes?$queryString"
|
||||||
|
|
||||||
|
$page = Invoke-WithRetry -RetryCount $RetryCount -ActionName "Fetching Envelope List Page (Offset: $startPosition)" -ScriptBlock {
|
||||||
|
$token = Get-ValidAccessToken
|
||||||
|
Invoke-RestMethod -Method Get -Uri $uri -Headers @{ Authorization = "Bearer $token" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($page.envelopes) {
|
||||||
|
foreach ($env in $page.envelopes) {
|
||||||
|
$allEnvelopes.Add($env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnedCount = if ($page.envelopes) { @($page.envelopes).Count } else { 0 }
|
||||||
|
Write-Host " Retrieved $returnedCount envelopes (Page Start: $startPosition)." -ForegroundColor Gray
|
||||||
|
|
||||||
|
$startPosition += $PageSize
|
||||||
|
$totalSetSize = [int]($page.totalSetSize ?? 0)
|
||||||
|
|
||||||
|
} while ($allEnvelopes.Count -lt $totalSetSize -and $returnedCount -gt 0)
|
||||||
|
|
||||||
|
Write-Host "Total completed envelopes identified: $($allEnvelopes.Count)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# 5. Download Documents & Build Audit Log
|
||||||
|
$logRows = [System.Collections.Generic.List[object]]::new()
|
||||||
|
$counter = 0
|
||||||
|
|
||||||
|
foreach ($envelope in $allEnvelopes) {
|
||||||
|
$counter++
|
||||||
|
$envelopeId = $envelope.envelopeId
|
||||||
|
$subject = Get-SafeFileName -Text $envelope.emailSubject -MaxLength 80
|
||||||
|
|
||||||
|
$completedRaw = if ($envelope.completedDateTime) { $envelope.completedDateTime } else { $envelope.statusChangedDateTime }
|
||||||
|
$completedDate = if ($completedRaw) {
|
||||||
|
([datetime]$completedRaw).ToString("yyyy-MM-dd")
|
||||||
|
} else {
|
||||||
|
"unknown-date"
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = "$completedDate - $subject - $envelopeId$extension"
|
||||||
|
$filePath = Join-Path $downloadFolder $fileName
|
||||||
|
$downloadUri = "$($script:apiBaseUri)/envelopes/$envelopeId/documents/$documentId`?certificate=true"
|
||||||
|
|
||||||
|
Write-Progress `
|
||||||
|
-Activity "Downloading DocuSign Agreements" `
|
||||||
|
-Status "Item $counter of$($allEnvelopes.Count):$subject" `
|
||||||
|
-PercentComplete (($counter / [math]::Max(1, $allEnvelopes.Count)) * 100)
|
||||||
|
|
||||||
|
$status = "Downloaded"
|
||||||
|
$errorMessage = ""
|
||||||
|
|
||||||
|
# Check for incremental skip
|
||||||
|
if ($SkipExisting -and (Test-Path -LiteralPath $filePath)) {
|
||||||
|
$status = "Skipped (Exists)"
|
||||||
|
Write-Verbose "Skipping existing file: $filePath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
Save-EnvelopeDocument -Uri $downloadUri -Path $filePath -AcceptHeader $accept -RetryCount $RetryCount
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = "Failed"
|
||||||
|
$errorMessage = $_.Exception.Message
|
||||||
|
Write-Warning "Failed to download envelope $envelopeId`: $errorMessage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$logRows.Add([PSCustomObject]@{
|
||||||
|
EnvelopeId = $envelopeId
|
||||||
|
Status = $status
|
||||||
|
EnvelopeStatus = $envelope.status
|
||||||
|
CompletedDate = $completedRaw
|
||||||
|
Subject = $envelope.emailSubject
|
||||||
|
OutputFormat = $OutputFormat
|
||||||
|
OutputPath = $filePath
|
||||||
|
Error = $errorMessage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. Save Export Summary Log
|
||||||
|
$logRows \vert{} Export-Csv -Path$logPath -NoTypeInformation -Encoding UTF8
|
||||||
|
|
||||||
|
$downloadedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Downloaded" }).Count
|
||||||
|
$skippedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Skipped (Exists)" }).Count
|
||||||
|
$failedCount = @($logRows \vert{} Where-Object {$_.Status -eq "Failed" }).Count
|
||||||
|
|
||||||
|
Write-Host "`n========================================" -ForegroundColor Green
|
||||||
|
Write-Host "DocuSign Agreement Export Completed" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " Total Identified : $($allEnvelopes.Count)"
|
||||||
|
Write-Host " Downloaded : $downloadedCount"
|
||||||
|
Write-Host " Skipped Existing : $skippedCount"
|
||||||
|
Write-Host " Failed : $failedCount"
|
||||||
|
Write-Host " Files Saved To : $downloadFolder"
|
||||||
|
Write-Host " Audit Log Saved : $logPath`n"
|
||||||
|
|
||||||
|
if ($failedCount -gt 0) {
|
||||||
|
Write-Warning "One or more envelope downloads failed. Review the CSV audit log for detailed error reasons."
|
||||||
|
}
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Enrolls the endpoint into Microsoft Intune / MDM.
|
||||||
|
.DESCRIPTION
|
||||||
|
Triggers the Windows auto-enrollment process via deviceenroller.exe.
|
||||||
|
Intended for company-owned endpoints.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Prompt for confirmation (case-insensitive)
|
||||||
|
Write-Host "WARNING: This script will enroll the current device into MDM." -ForegroundColor Red
|
||||||
|
Write-Host "This is meant for company-owned devices only." -ForegroundColor Red
|
||||||
|
$answer = Read-Host "Do you want to continue? (Y/N)"
|
||||||
|
|
||||||
|
if ($answer -notmatch '^[Yy](es)?$') {
|
||||||
|
Write-Host "Exiting script — Device will not be enrolled into MDM." -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Triggering Intune MDM enrollment..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Launch deviceenroller and wait for command trigger completion
|
||||||
|
$process = Start-Process -FilePath "$env:SystemRoot\System32\deviceenroller.exe" `
|
||||||
|
-ArgumentList "/c /AutoEnrollMDM" `
|
||||||
|
-Wait `
|
||||||
|
-PassThru `
|
||||||
|
-NoNewWindow
|
||||||
|
|
||||||
|
if ($process.ExitCode -eq 0) {
|
||||||
|
Write-Host "MDM AutoEnrollment command initiated successfully." -ForegroundColor Green
|
||||||
|
Write-Host "Note: Enrollment processing occurs asynchronously in the background." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "deviceenroller.exe exited with code: $($process.ExitCode)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to launch deviceenroller.exe: $_" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes Classic Microsoft Teams and Teams Machine-Wide Installer.
|
||||||
|
.DESCRIPTION
|
||||||
|
Stops Classic Teams processes, uninstalls the Machine-Wide Installer via MSI,
|
||||||
|
removes per-user Classic Teams installations, cleans residual files/registry run keys,
|
||||||
|
and verifies that New Teams (MSTeams) remains intact.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
|
||||||
|
Write-Host "Starting Classic Microsoft Teams cleanup..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Stop ONLY Classic Teams processes (preserve New Teams)
|
||||||
|
Get-Process -Name "Teams" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Stop Update.exe processes originating specifically from Classic Teams directories
|
||||||
|
Get-Process -Name "Update" -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.Path -like "*\Microsoft\Teams\Update.exe"
|
||||||
|
} | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
# 2. Remove Teams Machine-Wide Installer from HKLM registry
|
||||||
|
$uninstallPaths = @(
|
||||||
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||||
|
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||||
|
)
|
||||||
|
|
||||||
|
$machineWideInstallers = foreach ($path in$uninstallPaths) {
|
||||||
|
Get-ItemProperty $path -ErrorAction SilentlyContinue \vert{} Where-Object {$_.DisplayName -eq "Teams Machine-Wide Installer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($app in$machineWideInstallers) {
|
||||||
|
Write-Host "Found Teams Machine-Wide Installer: $($app.DisplayName) $($app.DisplayVersion)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if ($app.UninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
|
||||||
|
$productCode =$Matches[0]
|
||||||
|
Write-Host "Uninstalling Machine-Wide Installer via MSI product code: $productCode" -ForegroundColor Cyan
|
||||||
|
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait -NoNewWindow
|
||||||
|
}
|
||||||
|
elseif ($app.UninstallString) {
|
||||||
|
Write-Host "Uninstalling via UninstallString: $($app.UninstallString)" -ForegroundColor Cyan
|
||||||
|
if ($app.UninstallString -match "msiexec") {
|
||||||
|
$args =$app.UninstallString -replace "msiexec.exe", "" -replace "/I", "/x"
|
||||||
|
Start-Process -FilePath "msiexec.exe" -ArgumentList "$args /qn /norestart" -Wait -NoNewWindow
|
||||||
|
} else {
|
||||||
|
Start-Process -FilePath "cmd.exe" -ArgumentList "/c `"$($app.UninstallString)`" /quiet /norestart" -Wait -NoNewWindow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Clean per-user Classic Teams installations
|
||||||
|
$usersRoot = Join-Path$env:SystemDrive "Users"
|
||||||
|
$userProfiles = Get-ChildItem -Path $usersRoot -Directory -ErrorAction SilentlyContinue \vert{} Where-Object {$_.Name -notin @("Public", "Default", "Default User", "All Users", "defaultuser0")
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($profile in$userProfiles) {
|
||||||
|
$localTeamsFolder = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams"
|
||||||
|
$roamingTeamsFolder = Join-Path$profile.FullName "AppData\Roaming\Microsoft\Teams"
|
||||||
|
$updateExe = Join-Path$localTeamsFolder "Update.exe"
|
||||||
|
|
||||||
|
if (Test-Path $updateExe) {
|
||||||
|
Write-Host "Uninstalling Classic Teams for profile: $($profile.Name)" -ForegroundColor Cyan
|
||||||
|
Start-Process -FilePath $updateExe -ArgumentList "--uninstall -s" -Wait -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove residual AppData folders (both Local and Roaming)
|
||||||
|
foreach ($folder in @($localTeamsFolder,$roamingTeamsFolder)) {
|
||||||
|
if (Test-Path $folder) {
|
||||||
|
Write-Host "Removing directory: $folder" -ForegroundColor Yellow
|
||||||
|
Remove-Item -Path $folder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Remove System-Wide ProgramData Leftovers
|
||||||
|
$leftoverPaths = @(
|
||||||
|
"$env:ProgramData\Microsoft\Teams",
|
||||||
|
"$env:ProgramData\Microsoft\TeamsMachineInstaller",
|
||||||
|
"$env:ProgramData\Microsoft\TeamsCustomData"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($path in$leftoverPaths) {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
Write-Host "Removing leftover ProgramData path: $path" -ForegroundColor Yellow
|
||||||
|
Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Clean Startup Registry Run Keys across loaded user hives
|
||||||
|
$hkuPath = "Registry::HKEY_USERS"
|
||||||
|
Get-ChildItem -Path $hkuPath -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
$runKey = Join-Path$_.PSPath "Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
|
if (Test-Path $runKey) {
|
||||||
|
$val = Get-ItemProperty -Path$runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||||
|
if ($val) {
|
||||||
|
Write-Host "Removing Classic Teams startup reg key from hive: $($_.PSChildName)" -ForegroundColor Yellow
|
||||||
|
Remove-ItemProperty -Path $runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. Confirm New Teams (MSTeams) status
|
||||||
|
$newTeams = Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($newTeams) {
|
||||||
|
Write-Host "New Teams (MSTeams) is installed:" -ForegroundColor Green
|
||||||
|
$newTeams | Select-Object Name, Version, PackageFullName | Format-Table -AutoSize | Out-String | Write-Host
|
||||||
|
} else {
|
||||||
|
Write-Host "WARNING: New Teams (MSTeams) was not detected on this device." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7. Final Verification
|
||||||
|
$remainingMachineWide = foreach ($path in$uninstallPaths) {
|
||||||
|
Get-ItemProperty $path -ErrorAction SilentlyContinue \vert{} Where-Object {$_.DisplayName -eq "Teams Machine-Wide Installer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingClassicFolders = foreach ($profile in$userProfiles) {
|
||||||
|
$localTeamsFolder = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams"
|
||||||
|
if (Test-Path $localTeamsFolder) {$localTeamsFolder }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remainingMachineWide -or$remainingClassicFolders) {
|
||||||
|
Write-Host "Cleanup completed, but some Classic Teams remnants remain." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Host "Classic Teams and Teams Machine-Wide Installer cleanup completed successfully." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes unsupported Microsoft .NET Core / ASP.NET Core / Windows Desktop Runtime / SDK 3.1.x components.
|
||||||
|
.DESCRIPTION
|
||||||
|
Queries HKLM uninstall registry locations (64-bit and 32-bit) for .NET 3.1.x components,
|
||||||
|
executes quiet uninstallation for both MSI and Executable/Bundle bootstrappers,
|
||||||
|
cleans orphaned runtime directories, and verifies removal.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
|
||||||
|
Write-Host "Starting .NET Core 3.1 remediation..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$registryPaths = @(
|
||||||
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||||
|
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search pattern targeting 3.1.x runtimes, SDKs, targeting packs, and hosting bundles
|
||||||
|
$targetApps = foreach ($path in$registryPaths) {
|
||||||
|
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
(
|
||||||
|
$_.DisplayName -match "Microsoft .*\b3\.1\.\d+\b" -or
|
||||||
|
$_.DisplayName -match "Microsoft \.NET (Core|SDK|Runtime|Windows Desktop|ASP\.NET Core).*3\.1"
|
||||||
|
) -and ($_.UninstallString -or$_.QuietUninstallString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $targetApps) {
|
||||||
|
Write-Host "No .NET Core 3.1 components found. System is clean." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Found $($targetApps.Count) .NET Core 3.1 component(s) to remove:" -ForegroundColor Yellow
|
||||||
|
foreach ($app in$targetApps) {
|
||||||
|
Write-Host " - $($app.DisplayName) ($($app.DisplayVersion))" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($app in$targetApps) {
|
||||||
|
Write-Host "`nAttempting to remove: $($app.DisplayName)..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$uninstallCmd = if ($app.QuietUninstallString) { $app.QuietUninstallString } else { $app.UninstallString }
|
||||||
|
|
||||||
|
if ($uninstallCmd -match "\{[A-Fa-f0-9\-]{36}\}") {
|
||||||
|
# Standard MSI Uninstall
|
||||||
|
$productCode = $Matches[0]
|
||||||
|
Write-Host "Executing MSI uninstall for GUID: $productCode" -ForegroundColor Gray
|
||||||
|
$proc = Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait -PassThru -NoNewWindow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Executable / WiX Burn Bootstrapper Uninstall
|
||||||
|
Write-Host "Executing Bootstrapper EXE uninstall..." -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Parse EXE path and arguments safely
|
||||||
|
if ($uninstallCmd -match '^"([^"]+)"(.*)$') {
|
||||||
|
$exePath = $Matches[1]
|
||||||
|
$exeArgs = "$($Matches[2]) /uninstall /quiet /norestart".Trim()
|
||||||
|
}
|
||||||
|
elseif ($uninstallCmd -match '^(\S+)(.*)$') {
|
||||||
|
$exePath = $Matches[1]
|
||||||
|
$exeArgs = "$($Matches[2]) /uninstall /quiet /norestart".Trim()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$exePath = $uninstallCmd
|
||||||
|
$exeArgs = "/uninstall /quiet /norestart"
|
||||||
|
}
|
||||||
|
|
||||||
|
$proc = Start-Process -FilePath $exePath -ArgumentList $exeArgs -Wait -PassThru -NoNewWindow -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Evaluate return codes (0 = Success, 3010 = Success, reboot required)
|
||||||
|
if ($proc.ExitCode -in @(0, 3010)) {
|
||||||
|
Write-Host "Successfully processed uninstall for $($app.DisplayName)." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "Warning: Uninstall process returned exit code $($proc.ExitCode) for $($app.DisplayName)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean residual 3.1.x folder remnants from dotnet installation directories
|
||||||
|
$dotnetPaths = @(
|
||||||
|
"$env:ProgramFiles\dotnet",
|
||||||
|
"${env:ProgramFiles(x86)}\dotnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($basePath in $dotnetPaths) {
|
||||||
|
if (Test-Path $basePath) {
|
||||||
|
Get-ChildItem -Path $basePath -Recurse -Directory -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.Name -like "3.1.*"
|
||||||
|
} | ForEach-Object {
|
||||||
|
Write-Host "Removing leftover directory: $($_.FullName)" -ForegroundColor Yellow
|
||||||
|
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final Verification
|
||||||
|
Write-Host "`nVerifying remaining .NET Core 3.1 components..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$remainingApps = foreach ($path in$registryPaths) {
|
||||||
|
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
(
|
||||||
|
$_.DisplayName -match "Microsoft .*\b3\.1\.\d+\b" -or
|
||||||
|
$_.DisplayName -match "Microsoft \.NET (Core|SDK|Runtime|Windows Desktop|ASP\.NET Core).*3\.1"
|
||||||
|
) -and ($_.UninstallString -or$_.QuietUninstallString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remainingApps) {
|
||||||
|
Write-Host "ERROR: Some .NET Core 3.1 components remain installed:" -ForegroundColor Red
|
||||||
|
$remainingApps | ForEach-Object {
|
||||||
|
Write-Host " - $($_.DisplayName) ($($_.DisplayVersion))" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host ".NET Core 3.1 remediation completed successfully." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes specific vulnerable/outdated versions of Classic Teams and New Teams (MSTeams).
|
||||||
|
.DESCRIPTION
|
||||||
|
Checks for targeted build numbers across Classic Teams (Machine-Wide Installer, user AppData)
|
||||||
|
and New Teams (MSIX AppX provisioned and per-user packages), cleanly uninstalling only
|
||||||
|
the matching versions while leaving compliant versions untouched.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
|
||||||
|
Write-Host "Starting targeted Microsoft Teams cleanup..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Targeted vulnerable versions (includes Classic Teams 1.x and New Teams 23x/24x/25x builds)
|
||||||
|
$TargetVersions = @(
|
||||||
|
"24334.1103.3302.5694",
|
||||||
|
"25045.406.3471.2143",
|
||||||
|
"24124.2402.2858.5617",
|
||||||
|
"24004.1403.2634.2418",
|
||||||
|
"23195.1506.2253.6778",
|
||||||
|
"24277.3102.3183.2670",
|
||||||
|
"24256.2502.3123.1",
|
||||||
|
"24165.1306.2986.9504",
|
||||||
|
"25031.702.3408.1909",
|
||||||
|
"24244.507.3118.4732",
|
||||||
|
"23119.303.2080.2726",
|
||||||
|
"1.7.0.7956",
|
||||||
|
"25044.2208.3471.2155",
|
||||||
|
"24348.802.3311.5092",
|
||||||
|
"23335.242.2641.4129"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helper function for version string normalization & comparison
|
||||||
|
function Test-IsTargetVersion {
|
||||||
|
param (
|
||||||
|
[string]$VersionString,
|
||||||
|
[string[]]$TargetList
|
||||||
|
)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($VersionString)) { return$false }
|
||||||
|
|
||||||
|
$cleanVersion =$VersionString.Trim()
|
||||||
|
if ($TargetList -contains $cleanVersion) { return$true }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$parsedVersion = [version]$cleanVersion
|
||||||
|
foreach ($target in$TargetList) {
|
||||||
|
try {
|
||||||
|
if ([version]$target -eq $parsedVersion) { return$true }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Safely stop targeted processes
|
||||||
|
Write-Host "Checking for running processes..." -ForegroundColor Cyan
|
||||||
|
Get-Process -Name "Teams" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Get-Process -Name "Update" -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.Path -like "*\Microsoft\Teams\Update.exe"
|
||||||
|
} | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Stop ms-teams ONLY if the running executable belongs to a target version
|
||||||
|
Get-Process -Name "ms-teams" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
$procVersion =$_.MainModule.FileVersionInfo.FileVersion
|
||||||
|
if (Test-IsTargetVersion -VersionString $procVersion -TargetList$TargetVersions) {
|
||||||
|
Write-Host "Stopping New Teams process (running vulnerable version $procVersion)..." -ForegroundColor Yellow
|
||||||
|
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Remediate targeted Teams Machine-Wide Installer (HKLM)
|
||||||
|
Write-Host "`nScanning Teams Machine-Wide Installer..." -ForegroundColor Cyan
|
||||||
|
$UninstallRegistryPaths = @(
|
||||||
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||||
|
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($path in $UninstallRegistryPaths) {
|
||||||
|
Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.DisplayName -eq "Teams Machine-Wide Installer" -and
|
||||||
|
(Test-IsTargetVersion -VersionString $_.DisplayVersion -TargetList $TargetVersions)
|
||||||
|
} | ForEach-Object {
|
||||||
|
Write-Host "Found targeted Machine-Wide Installer version: $($_.DisplayVersion)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if ($_.UninstallString -match "\{[A-Fa-f0-9\-]{36}\}") {
|
||||||
|
$productCode = $Matches[0]
|
||||||
|
Start-Process -FilePath "msiexec.exe" -ArgumentList "/x $productCode /qn /norestart" -Wait -NoNewWindow
|
||||||
|
} elseif ($_.UninstallString) {
|
||||||
|
Start-Process -FilePath "cmd.exe" -ArgumentList "/c `"$($_.UninstallString)`" /quiet /norestart" -Wait -NoNewWindow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Remediate targeted Classic Teams (User Profiles)
|
||||||
|
Write-Host "`nScanning user profiles for Classic Teams..." -ForegroundColor Cyan
|
||||||
|
$usersRoot = Join-Path$env:SystemDrive "Users"
|
||||||
|
$userProfiles = Get-ChildItem -Path $usersRoot -Directory -ErrorAction SilentlyContinue \vert{} Where-Object {$_.Name -notin @("Public", "Default", "Default User", "All Users", "defaultuser0")
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($profile in$userProfiles) {
|
||||||
|
$localTeamsFolder = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams"
|
||||||
|
$roamingTeamsFolder = Join-Path$profile.FullName "AppData\Roaming\Microsoft\Teams"
|
||||||
|
$currentExe = Join-Path$localTeamsFolder "current\Teams.exe"
|
||||||
|
$updateExe = Join-Path$localTeamsFolder "Update.exe"
|
||||||
|
|
||||||
|
if (Test-Path $localTeamsFolder) {
|
||||||
|
$detectedVersion =$null
|
||||||
|
if (Test-Path $currentExe) { $detectedVersion = (Get-Item$currentExe).VersionInfo.FileVersion }
|
||||||
|
if (-not $detectedVersion -and (Test-Path$updateExe)) { $detectedVersion = (Get-Item$updateExe).VersionInfo.FileVersion }
|
||||||
|
|
||||||
|
if (Test-IsTargetVersion -VersionString $detectedVersion -TargetList$TargetVersions) {
|
||||||
|
Write-Host "Removing Classic Teams ($detectedVersion) for profile: $($profile.Name)" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path $updateExe) {
|
||||||
|
Start-Process -FilePath $updateExe -ArgumentList "--uninstall -s" -Wait -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item -Path $localTeamsFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $roamingTeamsFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Remediate targeted New Teams (AppX / MSIX Packages)
|
||||||
|
Write-Host "`nScanning New Teams (MSTeams) MSIX packages..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Remove provisioned package if version matches
|
||||||
|
Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.DisplayName -eq "MSTeams" -and (Test-IsTargetVersion -VersionString $_.Version -TargetList $TargetVersions)
|
||||||
|
} | ForEach-Object {
|
||||||
|
Write-Host "Removing targeted provisioned MSTeams package version: $($_.Version)" -ForegroundColor Yellow
|
||||||
|
Remove-AppxProvisionedPackage -Online -PackageName $_.PackageName -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove per-user AppX packages if version matches
|
||||||
|
Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
Test-IsTargetVersion -VersionString $_.Version -TargetList $TargetVersions
|
||||||
|
} | ForEach-Object {
|
||||||
|
Write-Host "Removing targeted MSTeams AppX package version: $($_.Version) for all users" -ForegroundColor Yellow
|
||||||
|
Remove-AppxPackage -Package $_.PackageFullName -AllUsers -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Clean Startup Run Keys across loaded user hives
|
||||||
|
$hkuPath = "Registry::HKEY_USERS"
|
||||||
|
Get-ChildItem -Path $hkuPath -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
$runKey = Join-Path $_.PSPath "Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
|
if (Test-Path $runKey) {
|
||||||
|
$val = Get-ItemProperty -Path $runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||||
|
if ($val) {
|
||||||
|
Remove-ItemProperty -Path $runKey -Name "com.squirrel.teams.teams" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. Final Verification
|
||||||
|
Write-Host "`nPerforming final verification..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$remainingMachineWide = foreach ($path in$UninstallRegistryPaths) {
|
||||||
|
Get-ItemProperty $path -ErrorAction SilentlyContinue \vert{} Where-Object {$_.DisplayName -eq "Teams Machine-Wide Installer" -and
|
||||||
|
(Test-IsTargetVersion -VersionString $_.DisplayVersion -TargetList$TargetVersions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingClassicFolders = foreach ($profile in$userProfiles) {
|
||||||
|
$exe = Join-Path$profile.FullName "AppData\Local\Microsoft\Teams\current\Teams.exe"
|
||||||
|
if (Test-Path $exe) {
|
||||||
|
$ver = (Get-Item$exe).VersionInfo.FileVersion
|
||||||
|
if (Test-IsTargetVersion -VersionString $ver -TargetList $TargetVersions) {$exe }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingNewTeams = Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
Test-IsTargetVersion -VersionString $_.Version -TargetList$TargetVersions
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remainingMachineWide -or $remainingClassicFolders -or$remainingNewTeams) {
|
||||||
|
Write-Host "ERROR: Some targeted Teams versions still remain on the endpoint." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
} else {
|
||||||
|
Write-Host "Targeted Teams remediation completed successfully." -ForegroundColor Green
|
||||||
|
|
||||||
|
# Display active/healthy New Teams version if installed
|
||||||
|
$healthyTeams = Get-AppxPackage -Name "MSTeams" -AllUsers -ErrorAction SilentlyContinue
|
||||||
|
if ($healthyTeams) {
|
||||||
|
Write-Host "Active, non-vulnerable New Teams installation detected:" -ForegroundColor Green
|
||||||
|
$healthyTeams | Select-Object Name, Version, PackageFullName | Format-Table -AutoSize | Out-String | Write-Host
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Exports Microsoft 365 user recertification and licensing report using Microsoft Graph SDK.
|
||||||
|
.DESCRIPTION
|
||||||
|
Retrieves all M365 users, resolves license SKU IDs to readable part numbers,
|
||||||
|
extracts last interactive sign-in dates, and exports the dataset to CSV.
|
||||||
|
.EXAMPLE
|
||||||
|
.\Export-M365UserCertification.ps1 -OutputPath ".\M365-User-Certification-Export.csv"
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$OutputPath = ".\M365-User-Certification-Export.csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Silence Graph cmdlet progress bars to drastically decrease execution time
|
||||||
|
$OriginalProgressPreference = $ProgressPreference
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Check existing Microsoft Graph session
|
||||||
|
$CurrentContext = Get-MgContext
|
||||||
|
$RequiredScopes = @("User.Read.All", "Directory.Read.All", "AuditLog.Read.All")
|
||||||
|
|
||||||
|
if (-not $CurrentContext) {
|
||||||
|
Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan
|
||||||
|
Connect-MgGraph -Scopes $RequiredScopes
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Connected as $($CurrentContext.Account)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve relative path to absolute path
|
||||||
|
$ResolvedOutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
|
||||||
|
|
||||||
|
Write-Host "Building license SKU lookup table..." -ForegroundColor Cyan
|
||||||
|
$SkuLookup = @{}
|
||||||
|
Get-MgSubscribedSku -All | ForEach-Object {
|
||||||
|
$SkuLookup[$_.SkuId.ToString()] = $_.SkuPartNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
# Explicitly define target properties (signInActivity requires AuditLog.Read.All)
|
||||||
|
$Properties = @(
|
||||||
|
"id",
|
||||||
|
"displayName",
|
||||||
|
"userPrincipalName",
|
||||||
|
"userType",
|
||||||
|
"accountEnabled",
|
||||||
|
"assignedLicenses",
|
||||||
|
"externalUserState",
|
||||||
|
"signInActivity"
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Fetching user data from Microsoft Graph..." -ForegroundColor Cyan
|
||||||
|
$Users = Get-MgUser -All -Property $Properties
|
||||||
|
|
||||||
|
Write-Host "Processing $($Users.Count) users..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$Export = foreach ($User in $Users) {
|
||||||
|
# Resolve licenses
|
||||||
|
$AssignedLicenses = if ($User.AssignedLicenses -and $User.AssignedLicenses.Count -gt 0) {
|
||||||
|
($User.AssignedLicenses | ForEach-Object {
|
||||||
|
$SkuId = $_.SkuId.ToString()
|
||||||
|
if ($SkuLookup.ContainsKey($SkuId)) {
|
||||||
|
$SkuLookup[$SkuId]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$SkuId
|
||||||
|
}
|
||||||
|
}) -join "; "
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
"None"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Safe null-check for SignInActivity
|
||||||
|
$LastSignIn = if ($User.SignInActivity -and $User.SignInActivity.LastSignInDateTime) {
|
||||||
|
$User.SignInActivity.LastSignInDateTime
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
"Never / No Record"
|
||||||
|
}
|
||||||
|
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = $User.DisplayName
|
||||||
|
UserPrincipalName = $User.UserPrincipalName
|
||||||
|
UserType = $User.UserType
|
||||||
|
AccountEnabled = $User.AccountEnabled
|
||||||
|
AssignedLicenses = $AssignedLicenses
|
||||||
|
GuestInvitationState = $User.ExternalUserState
|
||||||
|
LastInteractiveSignInDate = $LastSignIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export results to CSV
|
||||||
|
$Export |
|
||||||
|
Sort-Object Name |
|
||||||
|
Export-Csv -Path $ResolvedOutputPath -NoTypeInformation -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "Export complete: $ResolvedOutputPath" -ForegroundColor Green
|
||||||
|
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "An error occurred during execution: $_"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Restore original progress preference
|
||||||
|
$ProgressPreference = $OriginalProgressPreference
|
||||||
|
}
|
||||||
Reference in new issue
Block a user