Created
February 18, 2026 23:41
-
-
Save noahpeltier/6bb5dd1b1d8341b5b65f0ad7b0f581ff to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| if (!(get-module JiraPS -ListAvailable)) { | |
| Write-Host "Hang on, i'm installing the JiraModule..." | |
| Install-Module JiraPS -Force | out-null | |
| } | |
| if (!(get-module NinjaOne -ListAvailable)) { | |
| "Hang on, i'm installing the Ninja module..." | |
| Install-Module NinjaOne -Force | out-null | |
| } | |
| #region Connections | |
| function Connect-365Admin { | |
| "Connecting to Graph and Exchange..." | |
| if (!(Get-mgcontext)) { | |
| Connect-MgGraph -NoWelcome | |
| } | |
| if (!(Get-command Get-mailbox -ErrorAction SilentlyContinue)) { | |
| Connect-ExchangeOnline -UserPrincipalName (Get-mgcontext).Account -ShowBanner:$False | |
| } | |
| } | |
| function Connect-SahNinja { | |
| Import-SahModuleConfig | |
| Connect-NInjaOne -UseClientAuth ` | |
| -ClientId $Global:SahModuleConfig.ninja.clientId ` | |
| -ClientSecret ($Global:SahModuleConfig.ninja.clientSecret | ConvertFrom-SecureString -AsPlainText) ` | |
| -Instance us | |
| } | |
| function Connect-SahJira { | |
| Import-SahModuleConfig | |
| $credential = [pscredential]::new( | |
| $Global:SahModuleConfig.jira.username, | |
| $Global:SahModuleConfig.jira.apiKey | |
| ) | |
| New-JiraSession -Credential $credential | Out-Null | |
| } | |
| #endregion | |
| #region Servers | |
| function Get-SahServer { | |
| param( | |
| $Hostname | |
| ) | |
| Connect-SahNinja | |
| $orgs = Get-NinjaOneOrganisation | group-object -Property id -AsHashTable -AsString | |
| $Results = get-ninjaonedevice -deviceId (Find-NinjaOneDevice -searchQuery $Hostname -limit 1).devices.id | |
| $servers = @($Results) | where { $_.systemName -Like "$Hostname*" } | |
| foreach ($server in $servers) { | |
| $Function = switch ($server) { | |
| { $_.systemName -match "SQL" } { "SQL Server" } | |
| { $_.systemName -match "FILE|FIL|STORAGE" } { "File Server" } | |
| { $_.systemName -match "R7" } { "Rapid 7 Server" } | |
| { $_.systemName -match "IIS" } { "IIS Server" } | |
| { $_.systemName -match "IIS" -and $_.systemName -match "SQL" } { "IIS and SQL Server" } | |
| { $_.systemName -match "-HVH-|HYPER" } { "Hyper-v Host" } | |
| { $_.systemName -match "ADCS|CS0" } { "Certificate Server" } | |
| { $_.systemName -match "Web|App" } { "Web Host" } | |
| { $_.systemName -match "backup|veeam|vms" } { "Backups Server" } | |
| { $_.systemName -match "Dev|Test|LAB" } { "Dev or Test Server" } | |
| { $_.systemName -match "PRN|PRINT" } { "Print Server" } | |
| { $_.systemName -match "Util|Script|PYT|CSC" } { "General Utilities Server" } | |
| { $_.systemName -match "DC|AD" -and $_.system.DomainRole -match "Domain Controller" } { "Domain Controler" } | |
| default { "Unknown" } | |
| } | |
| $isCloud = switch ($server.system) { | |
| { $_.biosSerialNumber -like "0000-*" -or $_.Manufacturer -match "Amazon" } { $true } | |
| default { $false } | |
| } | |
| if ($server.offline) { | |
| $uptime = "N/A" | |
| } | |
| else { | |
| $uptime = ConvertTo-HumanDuration -TimeSpan ((get-date) - ($server.os.lastBootTime | convertfrom-EpocTime -ToLocalTime)) -MaxParts 1 | |
| } | |
| [pscustomobject]@{ | |
| Systemname = $server.systemName | |
| OS = $server.os.name | |
| Uptime = $uptime | |
| NinjaOnline = !$server.offline | |
| LANOnline = ([System.Net.NetworkInformation.Ping]::new().Send($server.ipAddresses[0]).Status -eq "Success") | |
| LastLoggedInUser = $server.lastLoggedInUser | |
| CompanyTLA = ($orgs[$server.organizationId.ToString()]).Name | |
| Companyname = ($orgs[$server.organizationId.ToString()]).description | |
| DomainRole = $server.system.DomainRole | |
| IPV4 = ($server.ipAddresses | % { [System.Net.IPAddress]::Parse($_) } | where { $_.AddressFamily -eq "InterNetwork" }).IPAddressToString -join "," | |
| BusinessUse = ($Function -join ",") | |
| Type = $server.system.model | |
| isCloud = $isCloud | |
| HyperVHost = (Get-NinjaOneDeviceCustomFields -deviceId $server.id).hypervhost | |
| } | |
| } | |
| } | |
| #endregion | |
| #region User discovery | |
| function Get-SahUser { | |
| [CmdletBinding()] | |
| param( | |
| [string]$UserId, | |
| [Alias('Properties', 'AdditionalProperties')] | |
| [string[]]$Property, | |
| [string]$Filter, | |
| [switch]$SkipADLookup | |
| ) | |
| $baseProps = @( | |
| "DisplayName", | |
| "GivenName", | |
| "SurName", | |
| "UserPrincipalName", | |
| "Mail", | |
| "JobTitle", | |
| "Department", | |
| "CompanyName", | |
| "OnPremisesSyncEnabled", | |
| "OnPremisesSamAccountName", | |
| "OnPremisesDomainName", | |
| "OnPremisesDistinguishedName", | |
| "AccountEnabled" | |
| ) | |
| # Accept both array input and comma-separated input | |
| $extraProps = @( | |
| $Property | | |
| ForEach-Object { $_ -split '\s*,\s*' } | | |
| Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| ) | |
| $props = @($baseProps + $extraProps | Select-Object -Unique) | |
| # Escape single quotes for OData filter safety | |
| $escapedUserId = $UserId.Replace("'", "''") | |
| if (!$Filter -and $UserId) { | |
| $filter = "onPremisesSamAccountName eq '$escapedUserId' or userPrincipalName eq '$escapedUserId' or startsWith(displayName, '$escapedUserId') or startsWith(userprincipalname, '$UserId@') or proxyAddresses/any(x:x eq 'SMTP:$Userid') or proxyAddresses/any(x:x eq 'smtp:$Userid')" | |
| } | |
| $result = Get-MgUser -Filter $filter -Property $props -ConsistencyLevel eventual -CountVariable count | |
| if (-not $result) { return } | |
| # Promote values from AdditionalProperties so Select-Object can show them | |
| foreach ($u in $result) { | |
| foreach ($p in $props) { | |
| if (-not $u.PSObject.Properties[$p] -and $u.AdditionalProperties.ContainsKey($p)) { | |
| $u | Add-Member -NotePropertyName $p -NotePropertyValue $u.AdditionalProperties[$p] -Force | |
| } | |
| } | |
| if (!$u.OnPremisesSyncEnabled -and !$SkipADLookup) { | |
| $ADAccount = Resolve-ADUser -SearchString ("{0} {1}" -f $u.givenname, $u.surname) | |
| if ($ADAccount -is [array]) { | |
| $ADAccount = ($ADAccount | Out-GridView -PassThru -Title "Multiple results") | |
| } | |
| if ($ADAccount) { | |
| $u.OnPremisesSamAccountName = $ADAccount.samaccountname | |
| $u.OnPremisesDistinguishedName = $ADAccount.DistinguishedName | |
| $u.OnPremisesDomainName = (Get-DomainFromDN -DistinguishedName $ADAccount.DistinguishedName) | |
| } | |
| } | |
| } | |
| $result | Select-Object -Property $props | |
| } | |
| function Get-SahWritableDc { | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$DnOrDomain, | |
| [pscredential]$Credential | |
| ) | |
| $domain = if ($DnOrDomain -like '*,DC=*') { | |
| ($DnOrDomain -split ',DC=' | Select-Object -Skip 1) -join '.' | |
| } | |
| else { | |
| $DnOrDomain | |
| } | |
| if ($Credential) { | |
| (Get-ADDomainController -DomainName $domain -Discover -Writable -Credential $Credential).HostName | |
| } | |
| else { | |
| (Get-ADDomainController -DomainName $domain -Discover -Writable).HostName | |
| } | |
| } | |
| function Get-SahUserDisabledOu { | |
| param( | |
| [string]$UserId, | |
| [pscredential]$Credential | |
| ) | |
| $mguser = Get-SahUser -UserId $UserId | |
| $dc = if ($Credential) { | |
| Get-SahWritableDc -DnOrDomain $mguser.OnPremisesDistinguishedName -Credential $Credential | |
| } | |
| else { | |
| Get-SahWritableDc -DnOrDomain $mguser.OnPremisesDistinguishedName | |
| } | |
| $ouObj = if ($Credential) { | |
| Get-ADOrganizationalUnit -Filter "name -like '*Users*Disabled*'" -Server $dc -Credential $Credential | | |
| Select-Object -First 1 | |
| } | |
| else { | |
| Get-ADOrganizationalUnit -Filter "name -like '*Users*Disabled*'" -Server $dc | | |
| Select-Object -First 1 | |
| } | |
| if ($ouObj) { | |
| return $ouObj.DistinguishedName | |
| } | |
| if ($mguser.OnPremisesDistinguishedName) { | |
| return ($mguser.OnPremisesDistinguishedName -replace '^CN=.*?,(?=OU=|DC=)') | |
| } | |
| throw "Could not resolve disabled OU for '$UserId'." | |
| } | |
| function Get-DomainFromDN { | |
| param ( | |
| [Parameter(Mandatory)] | |
| [string]$DistinguishedName | |
| ) | |
| ([regex]::Matches($DistinguishedName, '(?:^|,)DC=([^,]+)') | | |
| ForEach-Object { $_.Groups[1].Value }) -join '.' | |
| } | |
| function Resolve-AdUser { | |
| param( | |
| $SearchString, | |
| $MatchThreshold = 70 | |
| ) | |
| $Domains = Get-TrustedDomains | |
| $Results = $Domains | Foreach-Object -Parallel { | |
| $ProgressPreference = 'SilentlyContinue' | |
| $part = ((-split $using:SearchString)[-1]).trim() | |
| if (([System.Net.NetworkInformation.Ping]::new().Send($_).Status -eq "Success")) { | |
| Get-ADUser -Filter "anr -eq '$part'" -Server $_ | |
| } | |
| } | |
| $Results | where { | |
| (Get-StringSimilarity -A $_.name -B "$SearchString") -ge $MatchThreshold -and | |
| $_.Name -notmatch 'admin' -and | |
| $_.SamAccountName -notmatch 'admin' -and | |
| $_.DistinguishedName -notmatch 'admin' | |
| } | |
| } | |
| #endregion | |
| #region User manage | |
| function Disable-SahUser { | |
| param( | |
| [Parameter(ValueFromPipeline)] | |
| $UserId, | |
| [pscredential]$Credential | |
| ) | |
| if ($UserId -is [pscustomobject]) { | |
| $UserAccount = $UserId | |
| } | |
| else { | |
| $UserAccount = Get-SahUser -UserId $UserId | |
| } | |
| if ($UserAccount.OnPremisesSyncEnabled) { | |
| try { | |
| if ($Credential) { | |
| Disable-ADAccount -Identity $UserAccount.OnpremisesSamaccountname -Server $UserAccount.OnpremisesDomainName -Credential $Credential | |
| } | |
| else { | |
| Disable-ADAccount -Identity $UserAccount.OnpremisesSamaccountname -Server $UserAccount.OnpremisesDomainName | |
| } | |
| Write-LogMsg "Disabled $($UserAccount.DisplayName)" | |
| } | |
| catch { | |
| Write-LogMsg "Disabled $($mguser.DisplayName): $_" -LogLevel FAIL | |
| } | |
| } | |
| else { | |
| try { | |
| Update-MgUser -UserId $UserAccount -AccountEnabled $false | |
| Write-LogMsg "Disabled $($UserAccount.DisplayName)" | |
| } | |
| catch { | |
| Write-LogMsg "Disabled $($mguser.DisplayName): $_" -LogLevel FAIL | |
| } | |
| } | |
| (Get-SahUser -UserId $UserAccount.userprincipalname) | |
| } | |
| function Unlock-SahUser { | |
| param( | |
| $UserId | |
| ) | |
| $SahUser = Get-SahUser -UserId $UserId | |
| $DCs = (Get-ADDomain).ReplicaDirectoryServers | |
| $DCs | ForEach-Object -Parallel { | |
| $ProgressPreference = 'SilentlyContinue' | |
| Unlock-ADAccount -Identity $using:SahUser.OnPremisesSamAccountName -Server $_ | |
| } | |
| } | |
| function Set-SahUserRandomPassword { | |
| param( | |
| $UserId | |
| ) | |
| $mguser = Get-SahUser -UserId $UserId | |
| $splat = @{ | |
| Identity = $mguser.OnpremisesSamaccountname | |
| Server = $Mguser.OnPremisesDomainName | |
| } | |
| $PW = New-SecureRandomPassword | |
| Set-ADAccountPassword @splat -Reset -NewPassword $PW | |
| } | |
| function Set-PrimarySMTPAddress { | |
| [CmdletBinding(SupportsShouldProcess = $true)] | |
| param( | |
| [Parameter(Mandatory, ValueFromPipeline)] | |
| [Alias('samaccountname', 'OnPremisesSamAccountName')] | |
| [object]$Identity, | |
| [Parameter(Mandatory)] | |
| [string]$NewPrimarySMTPAddress, | |
| [Parameter(Mandatory, ValueFromPipeline)] | |
| [Alias('OnPremisesDomainName')] | |
| [string]$Server, | |
| [pscredential]$Credential | |
| ) | |
| begin { | |
| $commonParams = @{} | |
| if ($Server) { $commonParams.Server = $Server } | |
| if ($Credential) { $commonParams.Credential = $Credential } | |
| } | |
| process { | |
| # Normalize identity into an ADUser with ProxyAddresses loaded | |
| $adUser = | |
| if ($Identity -is [Microsoft.ActiveDirectory.Management.ADUser]) { | |
| if (-not $Identity.ProxyAddresses) { | |
| Get-ADUser -Identity $Identity.DistinguishedName -Properties ProxyAddresses @commonParams | |
| } | |
| else { | |
| $Identity | |
| } | |
| } | |
| else { | |
| Get-ADUser -Identity $Identity -Properties ProxyAddresses @commonParams | |
| } | |
| if (-not $adUser) { | |
| Write-Error "User '${Identity}' not found." | |
| return | |
| } | |
| $newPrimary = $NewPrimarySMTPAddress.Trim() | |
| # Build a case-insensitive set to dedupe, preserving order as best we can | |
| $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) | |
| $result = New-Object System.Collections.Generic.List[string] | |
| foreach ($p in ($adUser.ProxyAddresses | ForEach-Object { $_.ToString() })) { | |
| # demote any current primary SMTP | |
| $normalized = $p -creplace '^SMTP:', 'smtp:' | |
| if ($seen.Add($normalized)) { | |
| $null = $result.Add($normalized) | |
| } | |
| } | |
| # Remove any existing entries for the new address (smtp/SMTP) then add as primary | |
| $smtpValue = "smtp:${newPrimary}" | |
| $SMTPValue = "SMTP:${newPrimary}" | |
| for ($i = $result.Count - 1; $i -ge 0; $i--) { | |
| if ($result[$i].Equals($smtpValue, [System.StringComparison]::OrdinalIgnoreCase) -or | |
| $result[$i].Equals($SMTPValue, [System.StringComparison]::OrdinalIgnoreCase)) { | |
| $result.RemoveAt($i) | |
| } | |
| } | |
| # Insert primary SMTP at the front (common convention); you can append if you prefer | |
| $result.Insert(0, $SMTPValue) | |
| if ($PSCmdlet.ShouldProcess($adUser.SamAccountName, "Set primary SMTP to ${newPrimary}")) { | |
| Set-ADUser -Identity $adUser -Replace @{ ProxyAddresses = $result.ToArray() } @commonParams | |
| } | |
| [pscustomobject]@{ | |
| SamAccountName = $adUser.SamAccountName | |
| DistinguishedName = $adUser.DistinguishedName | |
| NewPrimarySMTPAddress = $newPrimary | |
| ProxyAddresses = $result | |
| } | |
| } | |
| } | |
| function Add-ProxyAddress { | |
| [CmdletBinding(SupportsShouldProcess = $true)] | |
| param( | |
| [Parameter(Mandatory, ValueFromPipeline)] | |
| [object]$Identity, | |
| [Parameter(Mandatory)] | |
| [string]$ProxyAddress, | |
| [switch]$IsPrimary, | |
| [string]$Server, | |
| [pscredential]$Credential | |
| ) | |
| begin { | |
| if (-not $Server) { $Server = (Get-ADDomain).DNSRoot } | |
| $commonParams = @{ Server = $Server } | |
| if ($Credential) { $commonParams.Credential = $Credential } | |
| } | |
| process { | |
| $adUser = | |
| if ($Identity -is [Microsoft.ActiveDirectory.Management.ADUser]) { | |
| if (-not $Identity.ProxyAddresses) { | |
| Get-ADUser -Identity $Identity.DistinguishedName -Properties ProxyAddresses @commonParams | |
| } | |
| else { | |
| $Identity | |
| } | |
| } | |
| else { | |
| Get-ADUser -Identity $Identity -Properties ProxyAddresses @commonParams | |
| } | |
| if (-not $adUser) { | |
| Write-Error "User '${Identity}' not found." | |
| return | |
| } | |
| $addr = $ProxyAddress.Trim() | |
| $smtpValue = "smtp:${addr}" | |
| # Add only if not already present (case-insensitive) | |
| $current = @($adUser.ProxyAddresses | ForEach-Object { $_.ToString() }) | |
| $exists = $current | Where-Object { $_.Equals($smtpValue, [System.StringComparison]::OrdinalIgnoreCase) -or | |
| $_.Equals(("SMTP:${addr}"), [System.StringComparison]::OrdinalIgnoreCase) } | |
| if (-not $exists) { | |
| if ($PSCmdlet.ShouldProcess($adUser.SamAccountName, "Add proxy address ${smtpValue}")) { | |
| Set-ADUser -Identity $adUser -Add @{ ProxyAddresses = $smtpValue } @commonParams | |
| } | |
| } | |
| if ($IsPrimary) { | |
| Set-PrimarySMTPAddress -Identity $adUser -NewPrimarySMTPAddress $addr -Server $Server -Credential $Credential | |
| } | |
| else { | |
| $adUser # return user object for pipeline chaining if desired | |
| } | |
| } | |
| } | |
| function New-SAHUserTemplate { | |
| @{ | |
| companyTla = "" | |
| manager = "" | |
| title = "" | |
| adGroups = @() | |
| entraGroups = @() | |
| entraLicenses = @() | |
| adServer = "" | |
| } | |
| } | |
| function Get-ADComputerBitLocker { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$ComputerName, | |
| $Credential | |
| ) | |
| try { | |
| $Computer = Get-ADComputer -Identity $ComputerName -Properties DistinguishedName | |
| } | |
| catch { | |
| Write-Warning "Computer '$ComputerName' not found in Active Directory." | |
| return | |
| } | |
| $RecoveryObjects = Get-ADObject -Filter 'objectClass -eq "msFVE-RecoveryInformation"' ` | |
| -SearchBase $Computer.DistinguishedName ` | |
| -Properties 'msFVE-RecoveryPassword', 'msFVE-RecoveryGuid', 'whenCreated' | |
| if (-not $RecoveryObjects) { | |
| Write-Output "No BitLocker recovery keys found for '$ComputerName'." | |
| return | |
| } | |
| $RecoveryObjects | Select-Object ` | |
| @{Name = 'ComputerName'; Expression = { $ComputerName } }, | |
| @{Name = 'RecoveryKeyID'; Expression = { [guid]$_.'msFVE-RecoveryGuid' } }, | |
| @{Name = 'RecoveryPassword'; Expression = { $_.'msFVE-RecoveryPassword' } }, | |
| @{Name = 'Created'; Expression = { Get-date $_.whenCreated -format "MM/dd/yyy" } } | Sort-Object -Property Created -Descending | |
| } | |
| function New-SAHUser { | |
| param( | |
| $Firstname, | |
| $Lastname, | |
| [Parameter()] | |
| $OnpremServer, | |
| $UPNPrefix, | |
| [Parameter()] | |
| $UPNSuffix, | |
| $Company | |
| ) | |
| @{ | |
| DisplayName = "{0} ({1})" -f (("${Firstname} ${Lastname}") | Format-ProperNoun),$Company | |
| UserRpincipalName = "{0}@{1}" -f $UPNPrefix,$UPNSuffix | |
| CompanyName = $Company | |
| } | |
| } | |
| #endregion | |
| #region Group management | |
| function New-SAHCompanyDistributionList { | |
| param ( | |
| $Company, | |
| $Domain | |
| ) | |
| (get-command Get-Mailbox -ErrorAction SilentlyContinue) ? "" :(Connect-365Admin) | |
| $RecipientFilter = "((((((((((((Company -eq '$Company') -and (RecipientType -eq 'UserMailbox'))) -and (-not(RecipientTypeDetailsValue -eq 'RoomMailbox')))) -and (-not(RecipientType -eq 'MailUniversalDistributionGroup')))) -and (-not(RecipientTypeDetailsValue -eq 'SharedMailbox')))) -and (-not(CustomAttribute1 -eq 'exclude-all')))) -and (-not(Name -like 'SystemMailbox{*')) -and (-not(Name -like 'CAS_{*')) -and (-not(RecipientTypeDetailsValue -eq 'MailboxPlan')) -and (-not(RecipientTypeDetailsValue -eq 'DiscoveryMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'PublicFolderMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'ArbitrationMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'AuditLogMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'AuxAuditLogMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'SupervisoryReviewPolicyMailbox')))" | |
| $listName = "[DL] $Company All" | |
| $alias = ("all-{0}" -f $Company).ToLower() | |
| $primarySmtpAddress = ("all-{0}@{1}" -f $Company, $Domain).ToLower() | |
| $Group = New-DynamicDistributionGroup -RecipientFilter $RecipientFilter -Alias $Alias -DisplayName $ListName -Name $ListName -PrimarySmtpAddress $PrimarySMTPAddress | |
| $Group | |
| } | |
| #endregion | |
| #region Domain | |
| function Get-TrustedDomains { | |
| $domainList = @() | |
| try { | |
| $forest = Get-ADForest -ErrorAction Stop | |
| $domainList += $forest.Domains | |
| } | |
| catch { | |
| try { $domainList += (Get-ADDomain -ErrorAction Stop).DNSRoot } catch {} | |
| } | |
| try { $domainList += (Get-ADTrust -Filter * | Select-Object -ExpandProperty Name) } catch {} | |
| $domainList | Sort-Object -Unique | |
| } | |
| #endregion | |
| #region Utilities | |
| function Format-ProperNoun { | |
| param( | |
| [Parameter(ValueFromPipeline)] | |
| [string]$String | |
| ) | |
| process { | |
| if ([string]::IsNullOrWhiteSpace($String)) { return $String } | |
| (Get-Culture).TextInfo.ToTitleCase($String.ToLower()) | |
| } | |
| } | |
| function Get-StringSimilarity { | |
| <# | |
| .SYNOPSIS | |
| Returns similarity percent (0-100) between two strings using Levenshtein distance. | |
| .PARAMETER A | |
| First string. | |
| .PARAMETER B | |
| Second string. | |
| .PARAMETER IgnoreCase | |
| Compare case-insensitively. | |
| .PARAMETER Trim | |
| Trim inputs before comparing. | |
| .EXAMPLE | |
| Get-StringSimilarity -A "kitten" -B "sitting" | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)][AllowNull()][string]$A, | |
| [Parameter(Mandatory)][AllowNull()][string]$B, | |
| [switch]$IgnoreCase, | |
| [switch]$Trim | |
| ) | |
| if ($null -eq $A) { $A = "" } | |
| if ($null -eq $B) { $B = "" } | |
| if ($Trim) { | |
| $A = $A.Trim() | |
| $B = $B.Trim() | |
| } | |
| if ($IgnoreCase) { | |
| $A = $A.ToLowerInvariant() | |
| $B = $B.ToLowerInvariant() | |
| } | |
| # Fast paths | |
| if ($A -eq $B) { return 100.0 } | |
| if ($A.Length -eq 0 -and $B.Length -eq 0) { return 100.0 } | |
| if ($A.Length -eq 0 -or $B.Length -eq 0) { return 0.0 } | |
| $lenA = $A.Length | |
| $lenB = $B.Length | |
| # Use two rows to reduce memory | |
| $prev = New-Object int[] ($lenB + 1) | |
| $curr = New-Object int[] ($lenB + 1) | |
| for ($j = 0; $j -le $lenB; $j++) { $prev[$j] = $j } | |
| for ($i = 1; $i -le $lenA; $i++) { | |
| $curr[0] = $i | |
| $charA = $A[$i - 1] | |
| for ($j = 1; $j -le $lenB; $j++) { | |
| $cost = if ($charA -eq $B[$j - 1]) { 0 } else { 1 } | |
| $del = $prev[$j] + 1 | |
| $ins = $curr[$j - 1] + 1 | |
| $sub = $prev[$j - 1] + $cost | |
| $curr[$j] = [Math]::Min($del, [Math]::Min($ins, $sub)) | |
| } | |
| # swap rows | |
| $tmp = $prev | |
| $prev = $curr | |
| $curr = $tmp | |
| } | |
| $distance = $prev[$lenB] | |
| $maxLen = [Math]::Max($lenA, $lenB) | |
| # Similarity as (1 - normalized distance) | |
| $similarity = (1.0 - ($distance / [double]$maxLen)) * 100.0 | |
| return [Math]::Round($similarity, 2) | |
| } | |
| function Write-LogMsg { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory, ValueFromPipeline, Position = 0)] | |
| [Alias('Message')] | |
| [AllowNull()] | |
| [object]$InputObject, | |
| [ValidateSet('FAIL', 'INFO', 'WARN')] | |
| [string]$LogLevel, | |
| [string]$LogFilePath, | |
| [object]$TextBox | |
| ) | |
| process { | |
| if ($env:LogFilePath) { $LogFilePath = $env:LogFilePath } | |
| $autoLevel = 'INFO' | |
| $message = '' | |
| switch ($InputObject) { | |
| { $_ -is [System.Management.Automation.ErrorRecord] } { | |
| $autoLevel = 'FAIL' | |
| $message = if ($_.Exception?.Message) { $_.Exception.Message } else { $_.ToString() } | |
| break | |
| } | |
| { $_ -is [System.Management.Automation.WarningRecord] } { | |
| $autoLevel = 'WARN' | |
| $message = $_.Message | |
| break | |
| } | |
| { $_ -is [System.Management.Automation.InformationRecord] } { | |
| $autoLevel = 'INFO' | |
| $md = $_.MessageData | |
| $message = if ($md -is [string]) { $md } elseif ($null -ne $md) { ($md | Out-String).Trim() } else { $_.ToString() } | |
| break | |
| } | |
| { $_ -is [System.Management.Automation.VerboseRecord] } { | |
| $autoLevel = 'INFO' | |
| $message = "[VERBOSE] $($_.Message)" | |
| break | |
| } | |
| { $_ -is [System.Management.Automation.DebugRecord] } { | |
| $autoLevel = 'INFO' | |
| $message = "[DEBUG] $($_.Message)" | |
| break | |
| } | |
| default { | |
| $message = if ($null -eq $InputObject) { '' } | |
| elseif ($InputObject.PSObject.Properties['Message']) { [string]$InputObject.Message } | |
| else { ($InputObject | Out-String).TrimEnd() } | |
| } | |
| } | |
| $effectiveLevel = if ($PSBoundParameters.ContainsKey('LogLevel')) { $LogLevel } else { $autoLevel } | |
| $prefix = switch ($effectiveLevel) { | |
| 'FAIL' { '[ FAIL ]' } | |
| 'WARN' { '[ WARN ]' } | |
| default { '[ INFO ]' } | |
| } | |
| $timestamp = Get-Date -Format '[ yyyy-MM-dd HH:mm:ss.fff ]' | |
| $logMsg = "$timestamp $prefix $message" | |
| if ($LogFilePath) { | |
| try { Add-Content -LiteralPath $LogFilePath -Value $logMsg -Encoding utf8 -ErrorAction Stop } | |
| catch { Write-Warning "Failed to write to log file '$LogFilePath': $($_.Exception.Message)" } | |
| } | |
| $targetTextBox = if ($TextBox) { $TextBox } elseif ($Global:LogTextBox) { $Global:LogTextBox } else { $null } | |
| if ($targetTextBox) { | |
| Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | |
| $append = { param($tb, $text) $tb.AppendText("$text`r`n") } | |
| if ($targetTextBox.InvokeRequired) { [void]$targetTextBox.BeginInvoke($append, @($targetTextBox, $logMsg)) } | |
| else { & $append $targetTextBox $logMsg } | |
| } | |
| $logMsg | |
| } | |
| } | |
| function Get-SahFilesSharedWithMe { | |
| [CmdletBinding()] | |
| param( | |
| # Optional: filter to a specific user's OneDrive shares (UPN/email) | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [Alias("UserPrincipalName")] | |
| [string]$SharedByUpn, | |
| # Optional: filename filter (PowerShell -like), e.g. "*DNS*" or "*.xlsx" | |
| [Parameter()] | |
| [string]$NameLike, | |
| # Search page size | |
| [Parameter()] | |
| [ValidateRange(1, 500)] | |
| [int]$PageSize = 200, | |
| # Pull all pages (up to MaxPages) | |
| [Parameter()] | |
| [switch]$All, | |
| # Safety cap when -All is used | |
| [Parameter()] | |
| [ValidateRange(1, 200)] | |
| [int]$MaxPages = 20, | |
| # If not already connected, connect with these scopes | |
| [Parameter()] | |
| [string[]]$Scopes = @("Files.Read.All", "Sites.Read.All") | |
| ) | |
| if (-not (Get-MgContext)) { | |
| Connect-MgGraph -Scopes $Scopes | Out-Null | |
| } | |
| # Get your OneDrive webUrl so we can exclude your own "personal/<you>/" path | |
| $meDrive = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me/drive?`$select=webUrl" | |
| $myDriveUrl = $meDrive.webUrl | |
| $uriHost = ([Uri]$myDriveUrl).Host | |
| $personalBase = "https://$uriHost/personal/" | |
| # If filtering by sharer, convert UPN/email to the SPO personal token | |
| # amalinowsky@apcisg.com -> amalinowsky_apcisg_com | |
| $sharedByToken = $null | |
| if ($SharedByUpn) { | |
| $sharedByToken = ($SharedByUpn -replace '[@\.]', '_') | |
| } | |
| $out = New-Object System.Collections.Generic.List[object] | |
| $pagesToFetch = if ($All) { $MaxPages } else { 1 } | |
| for ($page = 0; $page -lt $pagesToFetch; $page++) { | |
| $from = $page * $PageSize | |
| # KQL: look in all personal OneDrives, exclude your own personal drive path | |
| $kql = "path:`"$personalBase`" AND -path:`"$myDriveUrl`"" | |
| $body = @{ | |
| requests = @( | |
| @{ | |
| entityTypes = @("driveItem") | |
| query = @{ queryString = $kql } | |
| from = $from | |
| size = $PageSize | |
| fields = @("id", "name", "webUrl", "parentReference") | |
| } | |
| ) | |
| } | ConvertTo-Json -Depth 10 | |
| # Microsoft Search API query endpoint | |
| $resp = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/search/query" -Body $body | |
| $hits = @($resp.value[0].hitsContainers[0].hits) | |
| if (-not $hits -or $hits.Count -eq 0) { break } | |
| foreach ($h in $hits) { | |
| $r = $h.resource | |
| if (-not $r) { continue } | |
| # Optional: filter to a specific sharer's OneDrive path | |
| if ($sharedByToken) { | |
| if ($r.webUrl -notmatch "/personal/$([Regex]::Escape($sharedByToken))/" ) { continue } | |
| } | |
| # Optional: -like filter on the name | |
| if ($NameLike) { | |
| if ($r.name -notlike $NameLike) { continue } | |
| } | |
| $out.Add([pscustomobject]@{ | |
| Name = $r.name | |
| WebUrl = $r.webUrl | |
| }) | |
| } | |
| # Stop early if we got less than a full page (no more results) | |
| if ($hits.Count -lt $PageSize) { break } | |
| } | |
| $out | Sort-Object Name, WebUrl -Unique | |
| } | |
| function New-DicePassphrase { | |
| param ( | |
| [int]$WordCount = 3, | |
| [string]$Separator = "-", | |
| [switch]$AsSecureString | |
| ) | |
| $WordListPath = "$($Env:LOCALAPPDATA)\eff_large_wordlist.txt" | |
| if (!(Test-Path $WordListPath)) { | |
| Invoke-RestMethod 'https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt' -OutFile $WordListPath | |
| } | |
| $WordList = Get-Content $WordListPath -Raw | |
| $words = 1 .. $WordCount | ForEach-Object { | |
| $Dice = ( -join (1 .. 5 | ForEach-Object { | |
| Get-SecureRandom -Maximum 6 -Minimum 1 | |
| })).Tostring() | |
| $StringLower = (((($Wordlist -split "`n") | Where-Object { | |
| $_ -match $Dice | |
| }) -replace "\d").trim()) | |
| $StringUpper = $StringLower.Substring(0, 1).ToUpper() + $StringLower.Substring(1) | |
| $StringUpper | |
| } | |
| $RandomWord = ($words | Get-SecureRandom) | |
| $ModWord = $RandomWord -replace "$", (Get-SecureRandom -Maximum 9 -Minimum 1) | |
| $Words = $words -replace $RandomWord, $ModWord | |
| if (!$AsSecureString) { | |
| return $words -join $Separator | |
| } | |
| else { | |
| return (($words -join $Separator) | ConvertTo-SecureString -AsPlainText -Force) | |
| } | |
| } | |
| function Convertfrom-EpocTime { | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory, ValueFromPipeline)] | |
| [double]$EpochTime, | |
| [switch]$ToLocalTime | |
| ) | |
| $DateTime = [datetime]::UnixEpoch.AddSeconds($EpochTime) | |
| switch ($ToLocalTime) { | |
| $true { | |
| $DateTime.ToLocalTime() | |
| } | |
| default { | |
| $DateTime | |
| } | |
| } | |
| } | |
| function ConvertTo-HumanDuration { | |
| [CmdletBinding(DefaultParameterSetName = 'Timespan')] | |
| param( | |
| # Pass a [TimeSpan] directly | |
| [Parameter(Mandatory, ParameterSetName = 'Timespan', ValueFromPipeline)] | |
| [TimeSpan]$TimeSpan, | |
| # Or pass two dates (or a date and "now") and it will compute the difference | |
| [Parameter(Mandatory, ParameterSetName = 'Dates')] | |
| [datetime]$From, | |
| [Parameter(ParameterSetName = 'Dates')] | |
| [datetime]$To = (Get-Date), | |
| # How many units to include in the output (e.g., "1 hour, 30 mins" => 2) | |
| [ValidateRange(1, 5)] | |
| [int]$MaxParts = 2, | |
| # Rounding behavior for the last part in the output | |
| [ValidateSet('Floor', 'Round', 'Ceiling')] | |
| [string]$Rounding = 'Floor', | |
| # When the duration is 0, return this string | |
| [string]$ZeroText = '0 seconds', | |
| # If using Dates: show "in ..." for future and "... ago" for past | |
| [switch]$IncludeDirection | |
| ) | |
| begin { | |
| function Get-UnitLabel([string]$Unit, [long]$Value) { | |
| if ($Value -eq 1) { return $Unit } | |
| switch ($Unit) { | |
| 'day' { 'days' } | |
| 'hour' { 'hours' } | |
| 'minute' { 'mins' } | |
| 'second' { 'secs' } | |
| default { "${Unit}s" } | |
| } | |
| } | |
| function Apply-Rounding([double]$Value, [string]$Mode) { | |
| switch ($Mode) { | |
| 'Floor' { [math]::Floor($Value) } | |
| 'Ceiling' { [math]::Ceiling($Value) } | |
| 'Round' { [math]::Round($Value) } | |
| } | |
| } | |
| } | |
| process { | |
| $directionPrefix = $null | |
| $directionSuffix = $null | |
| if ($PSCmdlet.ParameterSetName -eq 'Dates') { | |
| $delta = $To - $From | |
| if ($IncludeDirection) { | |
| if ($delta.Ticks -lt 0) { | |
| $delta = $delta.Negate() | |
| $directionPrefix = 'in ' | |
| } | |
| else { | |
| $directionSuffix = ' ago' | |
| } | |
| } | |
| else { | |
| if ($delta.Ticks -lt 0) { $delta = $delta.Negate() } | |
| } | |
| $TimeSpan = $delta | |
| } | |
| if ($TimeSpan.Ticks -lt 0) { $TimeSpan = $TimeSpan.Negate() } | |
| if ($TimeSpan.Ticks -eq 0) { | |
| return ($directionPrefix + $ZeroText + $directionSuffix) | |
| } | |
| # Work in whole seconds to keep formatting stable | |
| $totalSeconds = [math]::Floor($TimeSpan.TotalSeconds) | |
| $units = @( | |
| @{ Name = 'day'; Seconds = 86400L } | |
| @{ Name = 'hour'; Seconds = 3600L } | |
| @{ Name = 'minute'; Seconds = 60L } | |
| @{ Name = 'second'; Seconds = 1L } | |
| ) | |
| $parts = New-Object System.Collections.Generic.List[string] | |
| $remaining = [double]$totalSeconds | |
| # Build all but the last part using floor | |
| for ($i = 0; $i -lt $units.Count -and $parts.Count -lt ($MaxParts - 1); $i++) { | |
| $u = $units[$i] | |
| $count = [long]([math]::Floor($remaining / $u.Seconds)) | |
| if ($count -gt 0) { | |
| $parts.Add(("{0} {1}" -f $count, (Get-UnitLabel $u.Name $count))) | |
| $remaining -= ($count * $u.Seconds) | |
| } | |
| } | |
| # Last part: choose the best unit for what's left (or next smaller unit) and apply rounding mode | |
| if ($parts.Count -lt $MaxParts) { | |
| # Pick the largest unit that fits, otherwise seconds | |
| $lastUnit = $units | Where-Object { $remaining -ge $_.Seconds } | Select-Object -First 1 | |
| if (-not $lastUnit) { $lastUnit = $units[-1] } | |
| $raw = $remaining / $lastUnit.Seconds | |
| $lastCount = [long](Apply-Rounding $raw $Rounding) | |
| # If rounding produced 0, drop to the next smaller unit if possible | |
| if ($lastCount -le 0 -and $lastUnit.Seconds -gt 1) { | |
| $idx = [array]::IndexOf($units, $lastUnit) | |
| $lastUnit = $units[[math]::Min($idx + 1, $units.Count - 1)] | |
| $raw = $remaining / $lastUnit.Seconds | |
| $lastCount = [long](Apply-Rounding $raw $Rounding) | |
| } | |
| if ($lastCount -gt 0) { | |
| $parts.Add(("{0} {1}" -f $lastCount, (Get-UnitLabel $lastUnit.Name $lastCount))) | |
| } | |
| } | |
| if ($parts.Count -eq 0) { | |
| $out = $ZeroText | |
| } | |
| else { | |
| $out = ($parts -join ', ') | |
| } | |
| return ($directionPrefix + $out + $directionSuffix) | |
| } | |
| } | |
| #endregion | |
| #region External Services | |
| function Get-SahUserNinjaDevice { | |
| param( | |
| [Parameter(ValueFromPipelineByPropertyName)] | |
| [Alias("OnPremisesSamAccountName")] | |
| $UserId | |
| ) | |
| Connect-SahNinja | |
| $results = (Find-NinjaOneDevice -searchQuery $UserId).devices | where { $_.score -ge 95 -and $_.nodeClass -eq "WINDOWS_WORKSTATION" } | |
| foreach ($result in $results) { | |
| $result | Add-member -MemberType NoteProperty -Name URL -Value ("https://app.ninjarmm.com/#/deviceDashboard/$($result.Id)/overview") | |
| } | |
| $results | |
| } | |
| function Get-SahUserJiraIncidents { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory, ValueFromPipelineByPropertyName)] | |
| [Alias("DisplayName")] | |
| [string]$ReporterDisplayName, # e.g. 'Lynn Bastian (PMP)' | |
| [string[]]$Fields = @('summary', 'status', 'priority', 'created', 'updated', 'assignee', 'reporter'), | |
| [int]$SearchPageSize = 1000 | |
| ) | |
| $jql = 'status NOT IN ("Closed","Resolved") AND reporter = "{0}" ORDER BY created DESC' -f $ReporterDisplayName | |
| # 1) Pull IDs (paginate via nextPageToken if present) | |
| $allIds = New-Object System.Collections.Generic.List[string] | |
| $nextPageToken = $null | |
| do { | |
| $body = @{ | |
| jql = $jql | |
| maxResults = $SearchPageSize | |
| } | |
| if ($nextPageToken) { $body.nextPageToken = $nextPageToken } | |
| $resp = Invoke-JiraMethod -Method POST ` | |
| -Uri 'https://spectrumag.atlassian.net/rest/api/3/search/jql' ` | |
| -Body ($body | ConvertTo-Json -Depth 10) | |
| foreach ($i in $resp.issues) { $allIds.Add([string]$i.id) } | |
| # depending on rollout you may see nextPageToken or isLast | |
| $nextPageToken = $resp.nextPageToken | |
| $isLast = $resp.isLast | |
| } while ($nextPageToken -and -not $isLast) | |
| if ($allIds.Count -eq 0) { return @() } | |
| # 2) Hydrate (BulkFetch) in chunks of 100 | |
| $results = New-Object System.Collections.Generic.List[object] | |
| $chunks = [System.Linq.Enumerable]::Range(0, [Math]::Ceiling($allIds.Count / 100)) | ForEach-Object { | |
| $start = $_ * 100 | |
| $count = [Math]::Min(100, $allIds.Count - $start) | |
| $allIds.GetRange($start, $count) | |
| } | |
| foreach ($chunk in $chunks) { | |
| $bulkBody = @{ | |
| issueIdsOrKeys = @($chunk) | |
| fields = @($Fields) | |
| } | |
| $bulk = Invoke-JiraMethod -Method POST ` | |
| -Uri 'https://spectrumag.atlassian.net/rest/api/3/issue/bulkfetch' ` | |
| -Body ($bulkBody | ConvertTo-Json -Depth 10) | |
| # bulk.issues is the hydrated issue list; bulk.errors contains any misses | |
| $bulk.issues | ForEach-Object { $results.Add($_) } | |
| } | |
| return $results | Select Key, @{n = 'Summary'; e = { $_.fields.summary } }, @{n = "Assignee"; e = { $_.fields.assignee.displayName } }, @{n = "Uri"; e = { "https://spectrumag.atlassian.net/browse/$($_.Key)" } } | |
| } | |
| function Get-SahMyJiraSprint { | |
| [CmdletBinding()] | |
| param( | |
| [string[]]$Fields = @('summary', 'status', 'priority', 'created', 'updated', 'assignee', 'reporter'), | |
| [int]$SearchPageSize = 1000 | |
| ) | |
| $jql = 'sprint in openSprints() AND assignee = currentUser()' -f $ReporterDisplayName | |
| # 1) Pull IDs (paginate via nextPageToken if present) | |
| $allIds = New-Object System.Collections.Generic.List[string] | |
| $nextPageToken = $null | |
| do { | |
| $body = @{ | |
| jql = $jql | |
| maxResults = $SearchPageSize | |
| } | |
| if ($nextPageToken) { $body.nextPageToken = $nextPageToken } | |
| $resp = Invoke-JiraMethod -Method POST ` | |
| -Uri 'https://spectrumag.atlassian.net/rest/api/3/search/jql' ` | |
| -Body ($body | ConvertTo-Json -Depth 10) | |
| foreach ($i in $resp.issues) { $allIds.Add([string]$i.id) } | |
| # depending on rollout you may see nextPageToken or isLast | |
| $nextPageToken = $resp.nextPageToken | |
| $isLast = $resp.isLast | |
| } while ($nextPageToken -and -not $isLast) | |
| if ($allIds.Count -eq 0) { return @() } | |
| # 2) Hydrate (BulkFetch) in chunks of 100 | |
| $results = New-Object System.Collections.Generic.List[object] | |
| $chunks = [System.Linq.Enumerable]::Range(0, [Math]::Ceiling($allIds.Count / 100)) | ForEach-Object { | |
| $start = $_ * 100 | |
| $count = [Math]::Min(100, $allIds.Count - $start) | |
| $allIds.GetRange($start, $count) | |
| } | |
| foreach ($chunk in $chunks) { | |
| $bulkBody = @{ | |
| issueIdsOrKeys = @($chunk) | |
| fields = @($Fields) | |
| } | |
| $bulk = Invoke-JiraMethod -Method POST ` | |
| -Uri 'https://spectrumag.atlassian.net/rest/api/3/issue/bulkfetch' ` | |
| -Body ($bulkBody | ConvertTo-Json -Depth 10) | |
| # bulk.issues is the hydrated issue list; bulk.errors contains any misses | |
| $bulk.issues | ForEach-Object { $results.Add($_) } | |
| } | |
| return $results | Select Key, @{n = 'Summary'; e = { $_.fields.summary } }, @{n = "Assignee"; e = { $_.fields.assignee.displayName } }, @{n = "Uri"; e = { "https://spectrumag.atlassian.net/browse/$($_.Key)" } } | |
| } | |
| function Invoke-ManageJiraAPIKeys { | |
| Start-Process 'https://id.atlassian.com/manage-profile/security/api-tokens' | |
| } | |
| function Get-SahCriticalAlerts { | |
| param( | |
| [ValidateSet("P1", "P2", "P3")] | |
| $Priority = "P1" | |
| ) | |
| $Results = (Invoke-JiraMethod -Method Get -URI 'https://api.atlassian.com/jsm/ops/api/c61f119b-c7a8-4052-a1f6-01d0358f0676/v1/alerts').Values | | |
| where { $_.priority -eq $Priority -and $_.status -ne "closed" } | | |
| select alias, createdAt, message, acknowledged, status, Seen | |
| if (!$Results) { | |
| "Whew! nothing bad is happening. 🙂" | |
| } | |
| $results | |
| } | |
| #endregion | |
| #region Credentials | |
| function Set-SahJiraCredentials { | |
| param( | |
| $Username, | |
| $ApiKey | |
| ) | |
| Import-SahModuleConfig | |
| if (!$Global:SahModuleConfig) { Initialize-SahModuleConfig } | |
| $Global:SahModuleConfig.jira.username = $Username | |
| $Global:SahModuleConfig.jira.apiKey = ($ApiKey | ConvertTo-SecureString -AsPlainText -Force) | |
| Export-SahModuleConfig | |
| Import-SahModuleConfig | |
| } | |
| function Set-SahNinjaCredentials { | |
| param( | |
| $ClientId, | |
| $ClientSecret | |
| ) | |
| Import-SahModuleConfig | |
| if (!$Global:SahModuleConfig) { Initialize-SahModuleConfig } | |
| $Global:SahModuleConfig.ninja.clientId = $ClientId | |
| $Global:SahModuleConfig.ninja.clientSecret = ($ClientSecret | ConvertTo-SecureString -AsPlainText -Force) | |
| Export-SahModuleConfig | |
| Import-SahModuleConfig | |
| } | |
| function Initialize-SahModuleConfig { | |
| $config = @{ | |
| jira = @{ | |
| username = "" | |
| apiKey = "" | |
| } | |
| ninja = @{ | |
| clientId = "" | |
| clientSecret = "" | |
| } | |
| } | |
| $Path = Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml" | |
| if (!(Test-Path $Path)) { | |
| $config | Export-Clixml $Path | |
| $Path | |
| } | |
| Import-SahModuleConfig | |
| } | |
| function Import-SahModuleConfig { | |
| $Path = Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml" | |
| $Global:SahModuleConfig = Import-Clixml $Path | |
| } | |
| function Export-SahModuleConfig { | |
| $Path = Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml" | |
| $Global:SahModuleConfig | Export-Clixml $Path | |
| Import-SahModuleConfig | |
| } | |
| function Add-SahConfigData { | |
| param( | |
| $Name, | |
| $Object | |
| ) | |
| $Global:SahModuleConfig[$Name] = $Object | |
| Export-SahModuleConfig | |
| } | |
| function Get-SahConfigValue { | |
| param( | |
| [Parameter()] | |
| [ArgumentCompleter({ | |
| param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) | |
| $Global:SahModuleConfig.Keys | |
| })] | |
| $Name | |
| ) | |
| if (!$Name) { | |
| $Global:SahModuleConfig | |
| } | |
| else { | |
| $Global:SahModuleConfig.$Name | |
| } | |
| } | |
| # ------------------------- | |
| # Helpers | |
| # ------------------------- | |
| function New-SahAesKeyFile { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Path, | |
| [ValidateSet(16, 24, 32)] | |
| [int]$Bytes = 32 | |
| ) | |
| $key = New-Object byte[] $Bytes | |
| [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($key) | |
| $dir = Split-Path -Parent $Path | |
| if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } | |
| # Store as Base64 for easy transport | |
| [IO.File]::WriteAllText($Path, [Convert]::ToBase64String($key), [Text.Encoding]::ASCII) | |
| $Path | |
| } | |
| function Get-SahAesKeyFromFile { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$Path | |
| ) | |
| $b64 = (Get-Content -LiteralPath $Path -Raw).Trim() | |
| [Convert]::FromBase64String($b64) | |
| } | |
| function ConvertFrom-SecureStringPlain { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [SecureString]$SecureString | |
| ) | |
| $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) | |
| try { | |
| [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) | |
| } | |
| finally { | |
| [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) | |
| } | |
| } | |
| # Deep-walk any object/hashtable/pscustomobject and transform SecureString leaf nodes | |
| function Invoke-SahTransformObject { | |
| param( | |
| [Parameter(Mandatory)] $InputObject, | |
| [Parameter(Mandatory)] [scriptblock] $OnSecureString | |
| ) | |
| if ($null -eq $InputObject) { return $null } | |
| if ($InputObject -is [SecureString]) { | |
| return & $OnSecureString $InputObject | |
| } | |
| # Strings (and other primitive-ish leaf values) should not be expanded into their properties | |
| # (e.g. [string] => Length). Treat them as terminal nodes. | |
| if ($InputObject -is [string]) { | |
| return $InputObject | |
| } | |
| if ($InputObject -is [byte[]]) { | |
| return $InputObject | |
| } | |
| if ($InputObject -is [System.Collections.IDictionary]) { | |
| $out = @{} | |
| foreach ($k in $InputObject.Keys) { | |
| $out[$k] = Invoke-SahTransformObject -InputObject $InputObject[$k] -OnSecureString $OnSecureString | |
| } | |
| return $out | |
| } | |
| # Handle arrays/lists | |
| if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { | |
| $items = New-Object System.Collections.Generic.List[object] | |
| foreach ($item in $InputObject) { | |
| $items.Add((Invoke-SahTransformObject -InputObject $item -OnSecureString $OnSecureString)) | |
| } | |
| return @($items) | |
| } | |
| # Handle PSCustomObject / other objects with properties | |
| if ($InputObject -is [pscustomobject] -or ($InputObject.PSObject?.Properties?.Count -gt 0)) { | |
| $out = [ordered]@{} | |
| foreach ($p in $InputObject.PSObject.Properties) { | |
| $out[$p.Name] = Invoke-SahTransformObject -InputObject $p.Value -OnSecureString $OnSecureString | |
| } | |
| return [pscustomobject]$out | |
| } | |
| return $InputObject | |
| } | |
| # ------------------------- | |
| # Export: DPAPI SecureStrings -> AES-encrypted strings | |
| # ------------------------- | |
| function Export-SahModuleConfigPortable { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$OutPath, | |
| # Provide either a key byte[] OR a KeyFile | |
| [byte[]]$Key, | |
| [string]$KeyFile | |
| ) | |
| if (-not $Key) { | |
| if (-not $KeyFile) { throw "Provide -Key or -KeyFile." } | |
| $Key = Get-SahAesKeyFromFile -Path $KeyFile | |
| } | |
| Import-SahModuleConfig | |
| if (-not $Global:SahModuleConfig) { throw "Global:SahModuleConfig is empty. Initialize/Import first." } | |
| $portable = Invoke-SahTransformObject -InputObject $Global:SahModuleConfig -OnSecureString { | |
| param([SecureString]$ss) | |
| # Convert to portable encrypted string using AES key | |
| ConvertFrom-SecureString -SecureString $ss -Key $Key | |
| } | |
| $dir = Split-Path -Parent $OutPath | |
| if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } | |
| $portable | Export-Clixml -LiteralPath $OutPath | |
| $OutPath | |
| } | |
| # ------------------------- | |
| # Import: AES-encrypted strings -> DPAPI SecureStrings (re-key for this machine/user) | |
| # ------------------------- | |
| function Import-SahModuleConfigPortable { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$InPath, | |
| # Provide either a key byte[] OR a KeyFile | |
| [byte[]]$Key, | |
| [string]$KeyFile, | |
| # Where to write the "normal" DPAPI-backed config (defaults to your current path) | |
| [string]$ConfigPath = (Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml") | |
| ) | |
| if (-not $Key) { | |
| if (-not $KeyFile) { throw "Provide -Key or -KeyFile." } | |
| $Key = Get-SahAesKeyFromFile -Path $KeyFile | |
| } | |
| $portable = Import-Clixml -LiteralPath $InPath | |
| # Walk all leaf strings and attempt to treat them as AES-encrypted SecureString payloads. | |
| # If it fails, keep original value (covers non-secure plain strings like username, clientId). | |
| function TryDecrypt($val) { | |
| if ($val -isnot [string] -or [string]::IsNullOrWhiteSpace($val)) { return $val } | |
| try { | |
| $ss = ConvertTo-SecureString -String $val -Key $Key -ErrorAction Stop | |
| # Re-key to DPAPI for this machine/user | |
| return ($ss | ConvertFrom-SecureStringPlain | ConvertTo-SecureString -AsPlainText -Force) | |
| } | |
| catch { | |
| return $val | |
| } | |
| } | |
| $rehydrated = Invoke-SahTransformObject -InputObject $portable -OnSecureString { | |
| # Portable export should not contain SecureString types; but if it does, re-key anyway | |
| param([SecureString]$ss) | |
| ($ss | ConvertFrom-SecureStringPlain | ConvertTo-SecureString -AsPlainText -Force) | |
| } | |
| # Now also scan string leaves (portable encrypted strings are strings) | |
| function WalkAndDecryptStrings($obj) { | |
| if ($null -eq $obj) { return $null } | |
| # Leaf values | |
| if ($obj -is [SecureString]) { | |
| return $obj | |
| } | |
| if ($obj -is [string]) { | |
| return (TryDecrypt $obj) | |
| } | |
| if ($obj -is [System.Collections.IDictionary]) { | |
| $out = @{} | |
| foreach ($k in $obj.Keys) { $out[$k] = WalkAndDecryptStrings $obj[$k] } | |
| return $out | |
| } | |
| # Handle arrays/lists | |
| if ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) { | |
| $items = New-Object System.Collections.Generic.List[object] | |
| foreach ($item in $obj) { $items.Add((WalkAndDecryptStrings $item)) } | |
| return @($items) | |
| } | |
| if ($obj -is [pscustomobject] -or $obj.PSObject?.Properties?.Count -gt 0) { | |
| $out = [ordered]@{} | |
| foreach ($p in $obj.PSObject.Properties) { | |
| $out[$p.Name] = WalkAndDecryptStrings $p.Value | |
| } | |
| return [pscustomobject]$out | |
| } | |
| return $obj | |
| } | |
| $rehydrated = WalkAndDecryptStrings $rehydrated | |
| $rehydrated | Export-Clixml -LiteralPath $ConfigPath | |
| Import-SahModuleConfig | |
| $ConfigPath | |
| } | |
| # ------------------------- | |
| # Convenience wrappers | |
| # ------------------------- | |
| function Export-SahConfigBundle { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$BundleFolder | |
| ) | |
| $keyFile = Join-Path $BundleFolder "SahConfig.aeskey" | |
| $dataFile = Join-Path $BundleFolder "SahModuleConfig.portable.xml" | |
| if (-not (Test-Path $BundleFolder)) { New-Item -ItemType Directory -Path $BundleFolder -Force | Out-Null } | |
| New-SahAesKeyFile -Path $keyFile | Out-Null | |
| Export-SahModuleConfigPortable -OutPath $dataFile -KeyFile $keyFile | Out-Null | |
| [pscustomobject]@{ | |
| KeyFile = $keyFile | |
| DataFile = $dataFile | |
| } | |
| } | |
| function Import-SahConfigBundle { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$KeyFile, | |
| [Parameter(Mandatory)] | |
| [string]$DataFile | |
| ) | |
| Import-SahModuleConfigPortable -InPath $DataFile -KeyFile $KeyFile | |
| } | |
| #endregion | |
| #Export-ModuleMember -Function * | |
| Initialize-SahModuleConfig | |
| <# | |
| Get-GraphToken -integratedWindowsAuth -tenantId (Get-TenantIDfromUserID (whoami /upn)) -scopes "openid offline_access" | |
| function Get-SahUser { | |
| [CmdletBinding()] | |
| param( | |
| [string]$UserId, | |
| [Alias('Properties', 'AdditionalProperties')] | |
| [string[]]$Property, | |
| [string]$Filter, | |
| [switch]$SkipADLookup | |
| ) | |
| $baseProps = @( | |
| "DisplayName", | |
| "GivenName", | |
| "SurName", | |
| "UserPrincipalName", | |
| "Mail", | |
| "JobTitle", | |
| "Department", | |
| "CompanyName", | |
| "OnPremisesSyncEnabled", | |
| "OnPremisesSamAccountName", | |
| "OnPremisesDomainName", | |
| "OnPremisesDistinguishedName", | |
| "AccountEnabled" | |
| ) | |
| # Accept both array input and comma-separated input | |
| $extraProps = @( | |
| $Property | | |
| ForEach-Object { $_ -split '\s*,\s*' } | | |
| Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| ) | |
| $props = @($baseProps + $extraProps | Select-Object -Unique) | |
| # Escape single quotes for OData filter safety | |
| $escapedUserId = $UserId.Replace("'", "''") | |
| if (!$Filter -and $UserId) { | |
| $filter = "onPremisesSamAccountName eq '$escapedUserId' or userPrincipalName eq '$escapedUserId' or startsWith(displayName, '$escapedUserId') or startsWith(userprincipalname, '$UserId@') or proxyAddresses/any(x:x eq 'SMTP:$Userid') or proxyAddresses/any(x:x eq 'smtp:$Userid')" | |
| } | |
| $result = Get-MgUser -Filter $filter -Property $props -ConsistencyLevel eventual -CountVariable count | |
| if (-not $result) { return } | |
| # Promote values from AdditionalProperties so Select-Object can show them | |
| foreach ($u in $result) { | |
| foreach ($p in $props) { | |
| if (-not $u.PSObject.Properties[$p] -and $u.AdditionalProperties.ContainsKey($p)) { | |
| $u | Add-Member -NotePropertyName $p -NotePropertyValue $u.AdditionalProperties[$p] -Force | |
| } | |
| } | |
| } | |
| if (!$result.OnPremisesSyncEnabled -and !$SkipADLookup) { | |
| $ADAccount = Resolve-ADUser -SearchString ("{0} {1}" -f $result.givenname, $result.surname) | |
| if ($ADAccount -is [array]) { | |
| $ADAccount = ($ADAccount | Out-GridView -PassThru -Title "Multiple results") | |
| } | |
| if ($ADAccount) { | |
| $result.OnPremisesSamAccountName = $ADAccount.samaccountname | |
| $result.OnPremisesDistinguishedName = $ADAccount.DistinguishedName | |
| $result.OnPremisesDomainName = (Get-DomainFromDN -DistinguishedName $ADAccount.DistinguishedName) | |
| } | |
| } | |
| $result | Select-Object -Property $props | |
| } | |
| function Get-SahUser-old { | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory)] | |
| [string]$UserId, | |
| [Alias('Properties', 'AdditionalProperties')] | |
| [string[]]$Property, | |
| [switch]$IncludeADObject, | |
| [switch]$ForceADLookup | |
| ) | |
| $baseProps = @( | |
| "DisplayName", | |
| "GivenName", | |
| "SurName", | |
| "UserPrincipalName", | |
| "Mail", | |
| "JobTitle", | |
| "Department", | |
| "CompanyName", | |
| "OnPremisesSyncEnabled", | |
| "OnPremisesSamAccountName", | |
| "OnPremisesDomainName", | |
| "OnPremisesDistinguishedName", | |
| "AccountEnabled" | |
| ) | |
| # Accept both array input and comma-separated input | |
| $extraProps = @( | |
| $Property | | |
| ForEach-Object { $_ -split '\s*,\s*' } | | |
| Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| ) | |
| $props = @($baseProps + $extraProps | Select-Object -Unique) | |
| # Escape single quotes for OData filter safety | |
| $escapedUserId = $UserId.Replace("'", "''") | |
| $filter = "onPremisesSamAccountName eq '$escapedUserId' or userPrincipalName eq '$escapedUserId' or startsWith(displayName, '$escapedUserId') or startsWith(userprincipalname, '$UserId@') or proxyAddresses/any(x:x eq 'SMTP:$Userid') or proxyAddresses/any(x:x eq 'smtp:$Userid')" | |
| $result = Get-MgUser -Filter $filter -Property $props -ConsistencyLevel eventual -CountVariable count | |
| if (-not $result) { return } | |
| # Promote values from AdditionalProperties so Select-Object can show them | |
| foreach ($u in $result) { | |
| foreach ($p in $props) { | |
| if (-not $u.PSObject.Properties[$p] -and $u.AdditionalProperties.ContainsKey($p)) { | |
| $u | Add-Member -NotePropertyName $p -NotePropertyValue $u.AdditionalProperties[$p] -Force | |
| } | |
| } | |
| } | |
| if ($ForceADLookup) { | |
| $ADAccount = Resolve-ADIdentity -Identity ("{0} {1}" -f $result.givenname, $result.surname) | |
| if ($ADAccount -is [array]) { | |
| $ADAccount = ($ADAccount | Out-GridView -PassThru -Title "Multiple results") | |
| } | |
| $result.OnPremisesSamAccountName = $ADAccount.samaccountname | |
| $result.OnPremisesDistinguishedName = $ADAccount.DistinguishedName | |
| $result.OnPremisesDomainName = (Get-DomainFromDN -DistinguishedName $ADAccount.DistinguishedName) | |
| } | |
| $result | Select-Object -Property $props | |
| } | |
| #> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment