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