AZSBTools.psm1


#region Variables

$EventKeyWords = [System.Diagnostics.Eventing.Reader.StandardEventKeywords] | Get-Member -Static -MemberType Property | foreach {
    [PSCustomObject][Ordered]@{
        Name   = $_.Name
        Number = ([System.Diagnostics.Eventing.Reader.StandardEventKeywords]::$($_.Name)).Value__
    }    
}
<#
$WellKnwonSids = [PSCustomObject][Ordered]@{
    Sid =
    Name =
    Description =
}
#>


#region AD

$thisComputersystem = Get-WmiObject -Class Win32_ComputerSystem
$IsDomainMember = $thisComputersystem.PartOfDomain
$thisForest = try { [system.directoryservices.activedirectory.Forest]::GetCurrentForest() } catch { 'Not domain joined' }
$thisDomainName = if ($IsDomainMember) { $thisComputersystem.Domain } else { $False }
$thisDomainDCList = foreach ($Domain in $thisForest.Domains) {
    if ($Domain.Name -eq $thisDomainName) { $Domain.DomainControllers | foreach { $_.Name } }
}
$KTicketEncType = @(
    New-Object -TypeName PSObject -Property @{ Id = 1 ; Name = 'DES-CBC-CRC' }
    New-Object -TypeName PSObject -Property @{ Id = 2 ; Name = 'DES-CBC-MD4' }
    New-Object -TypeName PSObject -Property @{ Id = 3 ; Name = 'DES-CBC-MD5' }
    New-Object -TypeName PSObject -Property @{ Id = 4 ; Name = '[Reserved]' }
    New-Object -TypeName PSObject -Property @{ Id = 5 ; Name = 'DES3-CBC-MD5' }
    New-Object -TypeName PSObject -Property @{ Id = 6 ; Name = '[Reserved]' }
    New-Object -TypeName PSObject -Property @{ Id = 7 ; Name = 'DES3-CDC-SHA1' }
    New-Object -TypeName PSObject -Property @{ Id = 9 ; Name = 'dsaWithSHA1-CmsOID' }
    New-Object -TypeName PSObject -Property @{ Id = 10; Name = 'md5WithRSAEncryption-CmsOID' }
    New-Object -TypeName PSObject -Property @{ Id = 11; Name = 'sha1WithRSAEncryption-CmsOID' }
    New-Object -TypeName PSObject -Property @{ Id = 12; Name = 'rc2CBC-EnvOID' }
    New-Object -TypeName PSObject -Property @{ Id = 13; Name = 'rsaEncryption-EnvOID' }
    New-Object -TypeName PSObject -Property @{ Id = 14; Name = 'rsaES-OAEP-ENV-OID' }
    New-Object -TypeName PSObject -Property @{ Id = 15; Name = 'des-ede3-cbc-Env-OID' }
    New-Object -TypeName PSObject -Property @{ Id = 16; Name = 'des3-cbc-sha1-kd' }
    New-Object -TypeName PSObject -Property @{ Id = 17; Name = 'AES128-CTS-HMAC-SHA-1' }
    New-Object -TypeName PSObject -Property @{ Id = 18; Name = 'AES256-CTS-HMAC-SHA-1' }
    New-Object -TypeName PSObject -Property @{ Id = 23; Name = 'RC4-HMAC' }
    New-Object -TypeName PSObject -Property @{ Id = 24; Name = 'RC4-HMAC-EXP' }
    New-Object -TypeName PSObject -Property @{ Id = 65; Name = 'subkey-keymaterial' }
) # https://datatracker.ietf.org/doc/html/rfc3961, https://docs.microsoft.com/en-us/archive/blogs/askds/hunting-down-des-in-order-to-securely-deploy-kerberos
$msDSSupportedEncryptionTypes = @(
    <#
    32-bit unsigned integer in little-endian format
    [Convert]::ToInt32('10000000000000000',2) # Position F in chart ==> 65536
    [Convert]::ToInt32('100000000000000000',2) # Position G in chart ==> 131072
    [Convert]::ToInt32('1000000000000000000',2) # Position H in chart ==> 262144
    [Convert]::ToInt32('10000000000000000000',2) # Position I in chart ==> 524288
    #>
    
    New-Object -TypeName PSObject -Property @{ Id = 524288; Name = 'Resource-SID-compression-disabled' }
    New-Object -TypeName PSObject -Property @{ Id = 262144; Name = 'Claims-supported' }
    New-Object -TypeName PSObject -Property @{ Id = 131072; Name = 'Compound-identity-supported' }
    New-Object -TypeName PSObject -Property @{ Id = 65536 ; Name = 'FAST-supported' }
    New-Object -TypeName PSObject -Property @{ Id = 16    ; Name = 'AES256-CTS-HMAC-SHA-1-96' }
    New-Object -TypeName PSObject -Property @{ Id = 8     ; Name = 'AES128-CTS-HMAC-SHA-1-96' }
    New-Object -TypeName PSObject -Property @{ Id = 4     ; Name = 'RC4-HMAC' }
    New-Object -TypeName PSObject -Property @{ Id = 2     ; Name = 'DES-CBC-MD5' }
    New-Object -TypeName PSObject -Property @{ Id = 1     ; Name = 'DES-CBC-CRC' }
) # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/6cfc7b50-11ed-4b4d-846d-6f08f0812919
$UserAccountControl = @(
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000001; Name = 'SCRIPT'; Desc = 'The logon script will be run.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000002; Name = 'ACCOUNTDISABLE'; Desc = 'The user account is disabled.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000008; Name = 'HOMEDIR_REQUIRED'; Desc = 'The home folder is required.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000010; Name = 'LOCKOUT'; Desc = 'The account is locked out.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000020; Name = 'PASSWD_NOTREQD'; Desc = 'No password is required.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000040; Name = 'PASSWD_CANT_CHANGE'; Desc = 'The user can''t change the password.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000080; Name = 'ENCRYPTED_TEXT_PWD_ALLOWED'; Desc = 'The user can send an encrypted password.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000100; Name = 'TEMP_DUPLICATE_ACCOUNT'; Desc = 'It''s an account for users whose primary account is in another domain. This account provides user access to this domain, but not to any domain that trusts this domain. It''s sometimes referred to as a local user account.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000200; Name = 'NORMAL_ACCOUNT'; Desc = 'It''s a default account type that represents a typical user.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00000800; Name = 'INTERDOMAIN_TRUST_ACCOUNT'; Desc = 'This is a permit to trust an account for a system domain that trusts other domains.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00001000; Name = 'WORKSTATION_TRUST_ACCOUNT'; Desc = 'This is a computer account for a computer that is running Microsoft Windows NT 4.0 Workstation, Microsoft Windows NT 4.0 Server, Microsoft Windows 2000 Professional, or Windows 2000 Server and is a member of this domain.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00002000; Name = 'SERVER_TRUST_ACCOUNT'; Desc = 'This is a computer account for a domain controller that is a member of this domain.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00010000; Name = 'DONT_EXPIRE_PASSWORD'; Desc = 'Password never expires.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00020000; Name = 'MNS_LOGON_ACCOUNT'; Desc = 'MNS logon account.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00040000; Name = 'SMARTCARD_REQUIRED'; Desc = 'Force the user to log on by using a smart card.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00080000; Name = 'TRUSTED_FOR_DELEGATION'; Desc = 'The service account (the user or computer account) under which a service runs is trusted for Kerberos delegation. Any such service can impersonate a client requesting the service.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00100000; Name = 'NOT_DELEGATED'; Desc = 'The security context of the user is not delegated to a service even if the service account is set as trusted for Kerberos delegation.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00200000; Name = 'USE_DES_KEY_ONLY'; Desc = '(Windows 2000/Server 2003) Restrict this principal to use only Data Encryption Standard (DES) encryption types for keys.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00400000; Name = 'DONT_REQ_PREAUTH'; Desc = '(Windows 2000/Server 2003) This account does not require Kerberos pre-authentication for logging on.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x00800000; Name = 'PASSWORD_EXPIRED'; Desc = '(Windows 2000/Server 2003) The user''s password has expired.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x01000000; Name = 'TRUSTED_TO_AUTH_FOR_DELEGATION'; Desc = '(Windows 2000/Server 2003) The account is enabled for delegation. This setting lets a service that runs under the account assume a client''s identity and authenticate as that user to other remote servers on the network.' }
    New-Object -TypeName PSObject -Property @{ Hex = 0x04000000; Name = 'PARTIAL_SECRETS_ACCOUNT'; Desc = '(Server 2008/Server 2008 R2) The account is a read-only domain controller (RODC). Removing this setting from an RODC compromises security on that server.' }
) # https://docs.microsoft.com/en-GB/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
$KerberosServiceTicketErrorList = @(
    New-Object -TypeName PSObject -Property @{ Id = 1 ; Name = 'Client''s entry in database has expired' }
    New-Object -TypeName PSObject -Property @{ Id = 2 ; Name = 'Server''s entry in database has expired' }
    New-Object -TypeName PSObject -Property @{ Id = 3 ; Name = 'Requested protocol version # not supported' }
    New-Object -TypeName PSObject -Property @{ Id = 4 ; Name = 'Client''s key encrypted in old master key' }
    New-Object -TypeName PSObject -Property @{ Id = 5 ; Name = 'Server''s key encrypted in old master key' }
    New-Object -TypeName PSObject -Property @{ Id = 6 ; Name = 'Client not found in Kerberos database (Bad user name, or new computer/user account has not replicated to DC yet)' }
    New-Object -TypeName PSObject -Property @{ Id = 7 ; Name = 'Server not found in Kerberos database (New computer account has not replicated yet or computer is pre-w2k)' }
    New-Object -TypeName PSObject -Property @{ Id = 8 ; Name = 'Multiple principal entries in database' }
    New-Object -TypeName PSObject -Property @{ Id = 9 ; Name = 'The client or server has a null key (administrator should reset the password on the account)' }
    New-Object -TypeName PSObject -Property @{ Id = 10; Name = 'Ticket not eligible for postdating' }
    New-Object -TypeName PSObject -Property @{ Id = 11; Name = 'Requested start time is later than end time' }
    New-Object -TypeName PSObject -Property @{ Id = 12; Name = 'KDC policy rejects request (Workstation restriction)' }
    New-Object -TypeName PSObject -Property @{ Id = 13; Name = 'KDC cannot accommodate requested option' }
    New-Object -TypeName PSObject -Property @{ Id = 14; Name = 'KDC has no support for encryption type' }
    New-Object -TypeName PSObject -Property @{ Id = 15; Name = 'KDC has no support for checksum type' }
    New-Object -TypeName PSObject -Property @{ Id = 16; Name = 'KDC has no support for padata type' }
    New-Object -TypeName PSObject -Property @{ Id = 17; Name = 'KDC has no support for transited type' }
    New-Object -TypeName PSObject -Property @{ Id = 18; Name = 'Clients credentials have been revoked (Account disabled, expired, locked out, logon hours.)' }
    New-Object -TypeName PSObject -Property @{ Id = 19; Name = 'Credentials for server have been revoked' }
    New-Object -TypeName PSObject -Property @{ Id = 20; Name = 'TGT has been revoked' }
    New-Object -TypeName PSObject -Property @{ Id = 21; Name = 'Client not yet valid - try again later' }
    New-Object -TypeName PSObject -Property @{ Id = 22; Name = 'Server not yet valid - try again later' }
    New-Object -TypeName PSObject -Property @{ Id = 23; Name = 'Password has expired (The user’'s password has expired.)' }
    New-Object -TypeName PSObject -Property @{ Id = 24; Name = 'Pre-authentication information was invalid (Usually means bad password)' }
    New-Object -TypeName PSObject -Property @{ Id = 25; Name = 'Additional pre-authentication required*' }
    New-Object -TypeName PSObject -Property @{ Id = 31; Name = 'Integrity check on decrypted field failed' }
    New-Object -TypeName PSObject -Property @{ Id = 32; Name = 'Ticket expired (Frequently logged by computer accounts)' }
    New-Object -TypeName PSObject -Property @{ Id = 33; Name = 'Ticket not yet valid' }
    New-Object -TypeName PSObject -Property @{ Id = 33; Name = 'Ticket not yet valid' }
    New-Object -TypeName PSObject -Property @{ Id = 34; Name = 'Request is a replay' }
    New-Object -TypeName PSObject -Property @{ Id = 35; Name = 'The ticket isn''t for us' }
    New-Object -TypeName PSObject -Property @{ Id = 36; Name = 'Ticket and authenticator don''t match' }
    New-Object -TypeName PSObject -Property @{ Id = 37; Name = 'Clock skew too great (Workstation''s clock too far out of sync with the DC''s)' }
    New-Object -TypeName PSObject -Property @{ Id = 38; Name = 'Incorrect net address (IP address change?)' }
    New-Object -TypeName PSObject -Property @{ Id = 39; Name = 'Protocol version mismatch' }
    New-Object -TypeName PSObject -Property @{ Id = 40; Name = 'Invalid msg type' }
    New-Object -TypeName PSObject -Property @{ Id = 41; Name = 'Message stream modified' }
    New-Object -TypeName PSObject -Property @{ Id = 42; Name = 'Message out of order' }
    New-Object -TypeName PSObject -Property @{ Id = 44; Name = 'Specified version of key is not available' }
    New-Object -TypeName PSObject -Property @{ Id = 45; Name = 'Service key not available' }
    New-Object -TypeName PSObject -Property @{ Id = 46; Name = 'Mutual authentication failed (may be a memory allocation failure)' }
    New-Object -TypeName PSObject -Property @{ Id = 47; Name = 'Incorrect message direction' }
    New-Object -TypeName PSObject -Property @{ Id = 48; Name = 'Alternative authentication method required*' }
    New-Object -TypeName PSObject -Property @{ Id = 49; Name = 'Incorrect sequence number in message' }
    New-Object -TypeName PSObject -Property @{ Id = 50; Name = 'Inappropriate type of checksum in message' }
    New-Object -TypeName PSObject -Property @{ Id = 60; Name = 'Generic error (description in e-text)' }
    New-Object -TypeName PSObject -Property @{ Id = 61; Name = 'Field is too long for this implementation' }
) # https://www.ultimatewindowssecurity.com/securitylog/encyclopedia/event.aspx?eventid=4769
$KerberosTicketOptions = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 1073741824; Name = 'Forwardable'; Description = '(TGT only). Tells the ticket-granting service that it can issue a new TGT—based on the presented TGT—with a different network address based on the presented TGT.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 536870912; Name = 'Forwarded'; Description = 'Indicates either that a TGT has been forwarded or that a ticket was issued from a forwarded TGT.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 268435456; Name = 'Proxiable'; Description = '(TGT only). Tells the ticket-granting service that it can issue tickets with a network address that differs from the one in the TGT.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 134217728; Name = 'Proxy'; Description = 'Indicates that the network address in the ticket is different from the one in the TGT used to obtain the ticket.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 67108864; Name = 'Allow-postdate'; Description = 'Postdated tickets SHOULD NOT be supported in KILE (Microsoft Kerberos Protocol Extension).' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 33554432; Name = 'Postdated'; Description = 'Postdated tickets SHOULD NOT be supported in KILE (Microsoft Kerberos Protocol Extension).' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 16777216; Name = 'Invalid'; Description = 'This flag indicates that a ticket is invalid, and it must be validated by the KDC before use. Application servers must reject tickets which have this flag set.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 8388608; Name = 'Renewable'; Description = 'Used in combination with the End Time and Renew Till fields to cause tickets with long life spans to be renewed at the KDC periodically.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 4194304; Name = 'Initial'; Description = 'Indicates that a ticket was issued using the authentication service (AS) exchange and not issued based on a TGT.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 2097152; Name = 'Pre-authent'; Description = 'Indicates that the client was authenticated by the KDC before a ticket was issued. This flag usually indicates the presence of an authenticator in the ticket. It can also flag the presence of credentials taken from a smart card logon.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 1048576; Name = 'Opt-hardware-auth'; Description = 'This flag was originally intended to indicate that hardware-supported authentication was used during pre-authentication. This flag is no longer recommended in the Kerberos V5 protocol. KDCs MUST NOT issue a ticket with this flag set. KDCs SHOULD NOT preserve this flag if it is set by another KDC.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 524288; Name = 'Transited-policy-checked'; Description = 'KILE MUST NOT check for transited domains on servers or a KDC. Application servers MUST ignore the TRANSITED-POLICY-CHECKED flag.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 262144; Name = 'Ok-as-delegate'; Description = 'The KDC MUST set the OK-AS-DELEGATE flag if the service account is trusted for delegation.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 131072; Name = 'Request-anonymous'; Description = 'KILE not use this flag.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 65536; Name = 'Name-canonicalize'; Description = 'In order to request referrals the Kerberos client MUST explicitly request the “canonicalize” KDC option for the AS-REQ or TGS-REQ.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 32768; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 16384; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 8192; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 4096; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 2048; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 1024; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 512; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 256; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 128; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 64; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 32; Name = 'Disable-transited-check'; Description = 'By default the KDC will check the transited field of a TGT against the policy of the local realm before it will issue derivative tickets based on the TGT. If this flag is set in the request, checking of the transited field is disabled. the Should not be in use, because Transited-policy-checked flag is not supported by KILE.DISABLE-TRANSITED-CHECK option.Tickets issued without the performance of this check will be noted by the reset (0) value of the TRANSITED-POLICY-CHECKED flag, indicating to the application server that the transited field must be checked locally. KDCs are encouraged but not required to honor' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 16; Name = 'Renewable-ok'; Description = 'The RENEWABLE-OK option indicates that a renewable ticket will be acceptable if a ticket with the requested life cannot otherwise be provided, in which case a renewable ticket may be issued with a renew-till equal to the requested end time. The value of the renew-till field may still be limited by local limits, or limits selected by the individual principal or server.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 8; Name = 'Enc-tkt-in-skey'; Description = 'No information.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 4; Name = 'Unused'; Description = '' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 2; Name = 'Renew'; Description = 'The RENEW option indicates that the present request is for a renewal. The ticket provided is encrypted in the secret key for the server on which it is valid. This option will only be honored if the ticket to be renewed has its RENEWABLE flag set and if the time in its renew-till field has not passed. The ticket to be renewed is passed in the padata field as part of the authentication header.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 1; Name = 'Validate'; Description = 'This option is used only by the ticket-granting service. The VALIDATE option indicates that the request is to validate a postdated ticket. Should not be in use, because postdated tickets are not supported by KILE.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 0; Name = 'Reserved'; Description = '' })
) # https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4769
$TcpStateList = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 0;  Name = 'Unknown';     Description = 'The TCP connection state is unknown.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 1;  Name = 'Closed';      Description = 'The TCP connection state is Closed.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 2;  Name = 'Listen';      Description = 'The local endpoint of the TCP connection is listening for a connection request from any remote endpoint.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 3;  Name = 'SynSent';     Description = 'The local endpoint of the TCP connection has sent the remote endpoint a segment header with the synchronize (SYN) control bit set and is waiting for a matching connection request.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 4;  Name = 'SynReceived'; Description = 'The local endpoint of the TCP connection has sent and received a connection request and is waiting for an acknowledgment.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 5;  Name = 'Established'; Description = 'The TCP handshake is complete. The connection has been established and data can be sent.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 6;  Name = 'FinWait1';    Description = 'The local endpoint of the TCP connection is waiting for a connection termination request from the remote endpoint or for an acknowledgement of the connection termination request sent previously.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 7;  Name = 'FinWait2';    Description = 'The local endpoint of the TCP connection is waiting for a connection termination request from the remote endpoint.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 8;  Name = 'CloseWait';   Description = 'The local endpoint of the TCP connection is waiting for a connection termination request from the local user.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 9;  Name = 'Closing';     Description = 'The local endpoint of the TCP connection is waiting for an acknowledgement of the connection termination request sent previously.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 10; Name = 'LastAck';     Description = 'The local endpoint of the TCP connection is waiting for the final acknowledgement of the connection termination request sent previously.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 11; Name = 'TimeWait';    Description = 'The local endpoint of the TCP connection is waiting for enough time to pass to ensure that the remote endpoint received the acknowledgement of its connection termination request.' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Id = 12; Name = 'DeleteTcb';   Description = 'The transmission control buffer (TCB) for the TCP connection is being deleted.' })
) # https://docs.microsoft.com/en-us/dotnet/api/system.net.networkinformation.tcpstate?view=net-6.0#endregion
$LogonType = @(
    New-Object -TypeName PSObject -Property @{ Id = 2 ; Name = 'Interactive - aka Logon Locally' }
    New-Object -TypeName PSObject -Property @{ Id = 3 ; Name = 'Network' }
    New-Object -TypeName PSObject -Property @{ Id = 4 ; Name = 'Batch' }
    New-Object -TypeName PSObject -Property @{ Id = 5 ; Name = 'Service' }
    # New-Object -TypeName PSObject -Property @{ Id = 6 ; Name = '' }
    New-Object -TypeName PSObject -Property @{ Id = 7 ; Name = 'Unlock' }
    New-Object -TypeName PSObject -Property @{ Id = 8 ; Name = 'NetworkCleartext' }
    New-Object -TypeName PSObject -Property @{ Id = 9 ; Name = 'NewCredentials' }
    New-Object -TypeName PSObject -Property @{ Id = 10; Name = 'RemoteInteractive, such as RDP' }
    New-Object -TypeName PSObject -Property @{ Id = 11; Name = 'CachedInteractive' }
) # https://docs.microsoft.com/en-us/windows-server/identity/securing-privileged-access/reference-tools-logon-types, https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2003/cc787567(v=ws.10)?redirectedfrom=MSDN
$msExchRemoteRecipientType = @(
    New-Object -TypeName PSObject -Property @{ Id = 1 ; Description = 'ProvisionMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 2 ; Description = 'ProvisionArchive (On-Prem Mailbox)' }
    New-Object -TypeName PSObject -Property @{ Id = 3 ; Description = 'ProvisionMailbox, ProvisionArchive' }
    New-Object -TypeName PSObject -Property @{ Id = 4 ; Description = 'Migrated (UserMailbox)' }
    New-Object -TypeName PSObject -Property @{ Id = 6 ; Description = 'ProvisionArchive, Migrated' }
    New-Object -TypeName PSObject -Property @{ Id = 8 ; Description = 'DeprovisionMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 10 ; Description = 'ProvisionArchive, DeprovisionMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 16 ; Description = 'DeprovisionArchive (On-Prem Mailbox)' }
    New-Object -TypeName PSObject -Property @{ Id = 17 ; Description = 'ProvisionMailbox, DeprovisionArchive' }
    New-Object -TypeName PSObject -Property @{ Id = 20 ; Description = 'Migrated, DeprovisionArchive' }
    New-Object -TypeName PSObject -Property @{ Id = 24 ; Description = 'DeprovisionMailbox, DeprovisionArchive' }
    New-Object -TypeName PSObject -Property @{ Id = 33 ; Description = 'ProvisionMailbox, RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 35 ; Description = 'ProvisionMailbox, ProvisionArchive, RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 36 ; Description = 'Migrated, RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 38 ; Description = 'ProvisionArchive, Migrated, RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 49 ; Description = 'ProvisionMailbox, DeprovisionArchive, RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 52 ; Description = 'Migrated, DeprovisionArchive, RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 65 ; Description = 'ProvisionMailbox, EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 67 ; Description = 'ProvisionMailbox, ProvisionArchive, EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 68 ; Description = 'Migrated, EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 70 ; Description = 'ProvisionArchive, Migrated, EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 81 ; Description = 'ProvisionMailbox, DeprovisionArchive, EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 84 ; Description = 'Migrated, DeprovisionArchive, EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 100 ; Description = 'Migrated, SharedMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 102 ; Description = 'ProvisionArchive, Migrated, SharedMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 116 ; Description = 'Migrated, DeprovisionArchive, SharedMailbox' }
) # https://answers.microsoft.com/en-us/msoffice/forum/all/recipient-type-values/7c2620e5-9870-48ba-b5c2-7772c739c651
$msExchRecipientDisplayType = @(
    New-Object -TypeName PSObject -Property @{ Id = -2147483642 ; Description = 'MailUser (RemoteUserMailbox)' }
    New-Object -TypeName PSObject -Property @{ Id = -2147481850 ; Description = 'MailUser (RemoteRoomMailbox)' }
    New-Object -TypeName PSObject -Property @{ Id = -2147481594 ; Description = 'MailUser (RemoteEquipmentMailbox)' }
    New-Object -TypeName PSObject -Property @{ Id = 0 ; Description = 'UserMailbox (shared)' }
    New-Object -TypeName PSObject -Property @{ Id = 1 ; Description = 'MailUniversalDistributionGroup' }
    New-Object -TypeName PSObject -Property @{ Id = 6 ; Description = 'MailContact' }
    New-Object -TypeName PSObject -Property @{ Id = 7 ; Description = 'UserMailbox (room)' }
    New-Object -TypeName PSObject -Property @{ Id = 8 ; Description = 'UserMailbox (equipment)' }
    New-Object -TypeName PSObject -Property @{ Id = 1073741824 ; Description = 'UserMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 1073741833 ; Description = 'MailUniversalSecurityGroup' }
) # https://answers.microsoft.com/en-us/msoffice/forum/all/recipient-type-values/7c2620e5-9870-48ba-b5c2-7772c739c651
$msExchRecipientTypeDetails = @(
    New-Object -TypeName PSObject -Property @{ Id = 1 ; Description = 'UserMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 2 ; Description = 'LinkedMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 4 ; Description = 'SharedMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 16 ; Description = 'RoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 32 ; Description = 'EquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 128 ; Description = 'MailUser' }
    New-Object -TypeName PSObject -Property @{ Id = 2147483648 ; Description = 'RemoteUserMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 8589934592 ; Description = 'RemoteRoomMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 17179869184 ; Description = 'RemoteEquipmentMailbox' }
    New-Object -TypeName PSObject -Property @{ Id = 34359738368 ; Description = 'RemoteSharedMailbox' }
) # https://answers.microsoft.com/en-us/msoffice/forum/all/recipient-type-values/7c2620e5-9870-48ba-b5c2-7772c739c651
$GeneralPortList = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 20  ; Protocol = 'TCP'; Name = 'FTP Data' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 20  ; Protocol = 'UDP'; Name = 'FTP Data' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 21  ; Protocol = 'TCP'; Name = 'FTP' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 21  ; Protocol = 'UDP'; Name = 'FTP' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 22  ; Protocol = 'TCP'; Name = 'SSH' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 22  ; Protocol = 'UDP'; Name = 'SSH' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 23  ; Protocol = 'TCP'; Name = 'Telnet' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 23  ; Protocol = 'UDP'; Name = 'Telnet' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 53  ; Protocol = 'TCP'; Name = 'DNS' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 53  ; Protocol = 'UDP'; Name = 'DNS' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 88  ; Protocol = 'TCP'; Name = 'Kerberos' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 88  ; Protocol = 'UDP'; Name = 'Kerberos' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 135 ; Protocol = 'TCP'; Name = 'RPC (Remote Procedure Call)' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 137 ; Protocol = 'TCP'; Name = 'NetBIOS name service' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 137 ; Protocol = 'UDP'; Name = 'NetBIOS name service' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 138 ; Protocol = 'UDP'; Name = 'NetBIOS datagram service, NetLogon' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 139 ; Protocol = 'TCP'; Name = 'NetBIOS session service, NetLogon' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 389 ; Protocol = 'TCP'; Name = 'LDAP' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 389 ; Protocol = 'UDP'; Name = 'LDAP' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 445 ; Protocol = 'TCP'; Name = 'SMB, NetLogon, SamR' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 445 ; Protocol = 'UDP'; Name = 'SMB, NetLogon, SamR' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 464 ; Protocol = 'TCP'; Name = 'Kerberos kpasswd' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 464 ; Protocol = 'UDP'; Name = 'Kerberos kpasswd' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 636 ; Protocol = 'TCP'; Name = 'LDAP SSL' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 636 ; Protocol = 'UDP'; Name = 'LDAP SSL' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 1433; Protocol = 'TCP'; Name = 'MS SQL' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 3268; Protocol = 'TCP'; Name = 'Global Catalog LDAP' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 3269; Protocol = 'TCP'; Name = 'Global Catalog LDAP SSL' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 3389; Protocol = 'TCP'; Name = 'RDP' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 5985; Protocol = 'TCP'; Name = 'PowerShell Remoting over HTTP (WinRM 2.0)' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 5986; Protocol = 'TCP'; Name = 'PowerShell Remoting over HTTPS (WinRM 2.0)' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Port = 9389; Protocol = 'TCP'; Name = 'AD Web Services, AD Management Gateway Service' })
)
$ADGroupTypeCodes = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = 2;           Hex = 2;        Binary = '00000000000000000000000000000010'; Category = 'Distribution'; Scope = 'Global' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = 4;           Hex = 4;        Binary = '00000000000000000000000000000100'; Category = 'Distribution'; Scope = 'DomainLocal' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = 8;           Hex = 8;        Binary = '00000000000000000000000000001000'; Category = 'Distribution'; Scope = 'Universal' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = -2147483640; Hex = 80000008; Binary = '10000000000000000000000000001000'; Category = 'Security';     Scope = 'Universal' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = -2147483643; Hex = 80000005; Binary = '10000000000000000000000000000101'; Category = 'Security';     Scope = 'DomainLocal' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = -2147483644; Hex = 80000004; Binary = '10000000000000000000000000000100'; Category = 'Security';     Scope = 'DomainLocal' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = -2147483645; Hex = 80000003; Binary = '10000000000000000000000000000011'; Category = 'Security';     Scope = 'Global' })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Decimal = -2147483646; Hex = 80000002; Binary = '10000000000000000000000000000010'; Category = 'Security';     Scope = 'Global' })
)

#endregion

#region Azure
$AzureTokenClaimDescription = @(
    New-Object -TypeName PSObject -Property @{ Name = 'alg'   ; Description = 'Algorithm. Example: RS256 = Asymmetric RSA 256 Encryption Algorithm.' }
    New-Object -TypeName PSObject -Property @{ Name = 'kid'   ; Description = 'The thumbprint of the public key that was used to sign the token.' }
    New-Object -TypeName PSObject -Property @{ Name = 'nonce' ; Description = 'A value used once in a cryptographic communication to protect against Replay attacks.' }
    New-Object -TypeName PSObject -Property @{ Name = 'typ'   ; Description = 'Token Type. JWT = Java Web Token.' }
    New-Object -TypeName PSObject -Property @{ Name = 'x5t'   ; Description = 'The thumbprint of the certificate used to sign the token. (same as kid, in legacy 1.0 tokens only)' }
    New-Object -TypeName PSObject -Property @{ Name = 'aio'   ; Description = 'An internal claim used by Azure AD to record data for token reuse.' }
    New-Object -TypeName PSObject -Property @{ Name = 'appid' ; Description = 'The application ID of the client using the token. (in legacy 1.0 tokens only)' }
    New-Object -TypeName PSObject -Property @{ Name = 'appidacr' ; Description = 'Indicates how the client was authenticated. 0 ==> Public client, 1 ==> Client secret was used, 2 ==> Client certificate was used for. (in legacy 1.0 tokens only)' }
    New-Object -TypeName PSObject -Property @{ Name = 'app_displayname' ; Description = 'User or Service Principal display name' }
    New-Object -TypeName PSObject -Property @{ Name = 'aud'   ; Description = 'Audience/Resource. This is the intended recipient of the token.' }
    New-Object -TypeName PSObject -Property @{ Name = 'exp'   ; Description = 'The time the token expires.' }
    New-Object -TypeName PSObject -Property @{ Name = 'iat'   ; Description = 'The time at which the token was issued.' }
    New-Object -TypeName PSObject -Property @{ Name = 'idp'   ; Description = 'The identity provider that authenticated the subject of the token. If different than ''iss'', this indicates that the user account is not in the same tenant as the issuer, such as invited guest users.' }
    New-Object -TypeName PSObject -Property @{ Name = 'idtyp' ; Description = 'Token type. ''app'' ==> app-only token, otherwise ==> app+user token.' }
    New-Object -TypeName PSObject -Property @{ Name = 'iss'   ; Description = 'Security token service (STS) that constructs and returns the token. Typical value: https://sts.windows.net/<Tenant_Id>/ where Tenant_Id identifies the directory in which the user was authenticated.' }
    New-Object -TypeName PSObject -Property @{ Name = 'nbf'   ; Description = 'The time after which the token is considered valid.' }
    New-Object -TypeName PSObject -Property @{ Name = 'oid'   ; Description = 'Object Id of the user.' }
    New-Object -TypeName PSObject -Property @{ Name = 'rh'    ; Description = 'An internal claim used by Azure to revalidate tokens.' }
    New-Object -TypeName PSObject -Property @{ Name = 'sub'   ; Description = 'Subject. The principal about which the token asserts information, such as the user of an application. Typically, the object ID of the Azure AD user.' }
    New-Object -TypeName PSObject -Property @{ Name = 'tenant_region_scope' ; Description = 'Region of the resource tenant. ''NA'' = North America.' }
    New-Object -TypeName PSObject -Property @{ Name = 'tid'   ; Description = 'Tenant Id of the user. ''9188040d-6c67-4c5b-b112-36a304b66dad'' is the Microsoft tenant Id used for personal Microsoft accounts.' }
    New-Object -TypeName PSObject -Property @{ Name = 'uti'   ; Description = 'An internal claim used by Azure to revalidate tokens.' }
    New-Object -TypeName PSObject -Property @{ Name = 'ver'   ; Description = 'Token version.' }
    New-Object -TypeName PSObject -Property @{ Name = 'wids'  ; Description = 'List of Azure AD role Template Ids - see https://docs.microsoft.com/en-us/azure/active-directory/roles/permissions-reference#all-roles' }
)

# Get-AzureADMSRoleDefinition | where { $_.IsBuiltIn } | foreach { "New-Object -TypeName PSObject -Property @{ Id = '$($_.Id)' ; DisplayName = '$($_.DisplayName)' }" }
$AzureADRoleNameList = @(
    New-Object -TypeName PSObject -Property @{ Id = '62e90394-69f5-4237-9190-012177145e10' ; DisplayName = 'Global Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '10dae51f-b6af-4016-8d66-8c2a99b929b3' ; DisplayName = 'Guest User' }
    New-Object -TypeName PSObject -Property @{ Id = '2af84b1e-32c8-42b7-82bc-daa82404023b' ; DisplayName = 'Restricted Guest User' }
    New-Object -TypeName PSObject -Property @{ Id = '95e79109-95c0-4d8e-aee3-d01accf2d47b' ; DisplayName = 'Guest Inviter' }
    New-Object -TypeName PSObject -Property @{ Id = 'fe930be7-5e62-47db-91af-98c3a49a38b1' ; DisplayName = 'User Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '729827e3-9c14-49f7-bb1b-9608f156bbb8' ; DisplayName = 'Helpdesk Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'f023fd81-a637-4b56-95fd-791ac0226033' ; DisplayName = 'Service Support Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe' ; DisplayName = 'Billing Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'a0b1b346-4d3e-4e8b-98f8-753987be4970' ; DisplayName = 'User' }
    New-Object -TypeName PSObject -Property @{ Id = '4ba39ca4-527c-499a-b93d-d9b492c50246' ; DisplayName = 'Partner Tier1 Support' }
    New-Object -TypeName PSObject -Property @{ Id = 'e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8' ; DisplayName = 'Partner Tier2 Support' }
    New-Object -TypeName PSObject -Property @{ Id = '88d8e3e3-8f55-4a1e-953a-9b9898b8876b' ; DisplayName = 'Directory Readers' }
    New-Object -TypeName PSObject -Property @{ Id = '9360feb5-f418-4baa-8175-e2a00bac4301' ; DisplayName = 'Directory Writers' }
    New-Object -TypeName PSObject -Property @{ Id = '29232cdf-9323-42fd-ade2-1d097af3e4de' ; DisplayName = 'Exchange Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' ; DisplayName = 'SharePoint Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '75941009-915a-4869-abe7-691bff18279e' ; DisplayName = 'Skype for Business Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'd405c6df-0af8-4e3b-95e4-4d06e542189e' ; DisplayName = 'Device Users' }
    New-Object -TypeName PSObject -Property @{ Id = '9f06204d-73c1-4d4c-880a-6edb90606fd8' ; DisplayName = 'Azure AD Joined Device Local Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '9c094953-4995-41c8-84c8-3ebb9b32c93f' ; DisplayName = 'Device Join' }
    New-Object -TypeName PSObject -Property @{ Id = 'c34f683f-4d5a-4403-affd-6615e00e3a7f' ; DisplayName = 'Workplace Device Join' }
    New-Object -TypeName PSObject -Property @{ Id = '17315797-102d-40b4-93e0-432062caca18' ; DisplayName = 'Compliance Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'd29b2b05-8046-44ba-8758-1e26182fcf32' ; DisplayName = 'Directory Synchronization Accounts' }
    New-Object -TypeName PSObject -Property @{ Id = '2b499bcd-da44-4968-8aec-78e1674fa64d' ; DisplayName = 'Device Managers' }
    New-Object -TypeName PSObject -Property @{ Id = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' ; DisplayName = 'Application Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'cf1c38e5-3621-4004-a7cb-879624dced7c' ; DisplayName = 'Application Developer' }
    New-Object -TypeName PSObject -Property @{ Id = '5d6b6bb7-de71-4623-b4af-96380a352509' ; DisplayName = 'Security Reader' }
    New-Object -TypeName PSObject -Property @{ Id = '194ae4cb-b126-40b2-bd5b-6091b380977d' ; DisplayName = 'Security Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814' ; DisplayName = 'Privileged Role Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '3a2c62db-5318-420d-8d74-23affee5d9d5' ; DisplayName = 'Intune Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '158c047a-c907-4556-b7ef-446551a6b5f7' ; DisplayName = 'Cloud Application Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '5c4f9dcd-47dc-4cf7-8c9a-9e4207cbfc91' ; DisplayName = 'Customer LockBox Access Approver' }
    New-Object -TypeName PSObject -Property @{ Id = '44367163-eba1-44c3-98af-f5787879f96a' ; DisplayName = 'Dynamics 365 Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'a9ea8996-122f-4c74-9520-8edcd192826c' ; DisplayName = 'Power BI Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' ; DisplayName = 'Conditional Access Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '4a5d8f65-41da-4de4-8968-e035b65339cf' ; DisplayName = 'Reports Reader' }
    New-Object -TypeName PSObject -Property @{ Id = '790c1fb9-7f7d-4f88-86a1-ef1f95c05c1b' ; DisplayName = 'Message Center Reader' }
    New-Object -TypeName PSObject -Property @{ Id = '7495fdc4-34c4-4d15-a289-98788ce399fd' ; DisplayName = 'Azure Information Protection Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '38a96431-2bdf-4b4c-8b6e-5d3d8abac1a4' ; DisplayName = 'Desktop Analytics Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '4d6ac14f-3453-41d0-bef9-a3e0c569773a' ; DisplayName = 'License Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '7698a772-787b-4ac8-901f-60d6b08affd2' ; DisplayName = 'Cloud Device Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'c4e39bd9-1100-46d3-8c65-fb160da0071f' ; DisplayName = 'Authentication Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' ; DisplayName = 'Privileged Authentication Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'baf37b3a-610e-45da-9e62-d9d1e5e8914b' ; DisplayName = 'Teams Communications Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'f70938a0-fc10-4177-9e90-2178f8765737' ; DisplayName = 'Teams Communications Support Engineer' }
    New-Object -TypeName PSObject -Property @{ Id = 'fcf91098-03e3-41a9-b5ba-6f0ec8188a12' ; DisplayName = 'Teams Communications Support Specialist' }
    New-Object -TypeName PSObject -Property @{ Id = '69091246-20e8-4a56-aa4d-066075b2a7a8' ; DisplayName = 'Teams Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'eb1f4a8d-243a-41f0-9fbd-c7cdf6c5ef7c' ; DisplayName = 'Insights Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'ac16e43d-7b2d-40e0-ac05-243ff356ab5b' ; DisplayName = 'Message Center Privacy Reader' }
    New-Object -TypeName PSObject -Property @{ Id = '6e591065-9bad-43ed-90f3-e9424366d2f0' ; DisplayName = 'External ID User Flow Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '0f971eea-41eb-4569-a71e-57bb8a3eff1e' ; DisplayName = 'External ID User Flow Attribute Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'aaf43236-0c0d-4d5f-883a-6955382ac081' ; DisplayName = 'B2C IEF Keyset Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '3edaf663-341e-4475-9f94-5c398ef6c070' ; DisplayName = 'B2C IEF Policy Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'be2f45a1-457d-42af-a067-6ec1fa63bc45' ; DisplayName = 'External Identity Provider Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'e6d1a23a-da11-4be4-9570-befc86d067a7' ; DisplayName = 'Compliance Data Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '5f2222b1-57c3-48ba-8ad5-d4759f1fde6f' ; DisplayName = 'Security Operator' }
    New-Object -TypeName PSObject -Property @{ Id = '74ef975b-6605-40af-a5d2-b9539d836353' ; DisplayName = 'Kaizala Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451' ; DisplayName = 'Global Reader' }
    New-Object -TypeName PSObject -Property @{ Id = '0964bb5e-9bdb-4d7b-ac29-58e794862a40' ; DisplayName = 'Search Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '8835291a-918c-4fd7-a9ce-faa49f0cf7d9' ; DisplayName = 'Search Editor' }
    New-Object -TypeName PSObject -Property @{ Id = '966707d0-3269-4727-9be2-8c3a10f19b9d' ; DisplayName = 'Password Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '644ef478-e28f-4e28-b9dc-3fdde9aa0b1f' ; DisplayName = 'Printer Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'e8cef6f1-e4bd-4ea8-bc07-4b8d950f4477' ; DisplayName = 'Printer Technician' }
    New-Object -TypeName PSObject -Property @{ Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80' ; DisplayName = 'Authentication Policy Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'fdd7a751-b60b-444a-984c-02652fe8fa1c' ; DisplayName = 'Groups Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '11648597-926c-4cf3-9c36-bcebb0ba8dcc' ; DisplayName = 'Power Platform Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'e3973bdf-4987-49ae-837a-ba8e231c7286' ; DisplayName = 'Azure DevOps Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2' ; DisplayName = 'Hybrid Identity Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '2b745bdf-0803-4d80-aa65-822c4493daac' ; DisplayName = 'Office Apps Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'd37c8bed-0711-4417-ba38-b4abe66ce4c2' ; DisplayName = 'Network Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '31e939ad-9672-4796-9c2e-873181342d2d' ; DisplayName = 'Insights Business Leader' }
    New-Object -TypeName PSObject -Property @{ Id = '3d762c5a-1b6c-493f-843e-55a3b42923d4' ; DisplayName = 'Teams Devices Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = 'c430b396-e693-46cc-96f3-db01bf8bb62a' ; DisplayName = 'Attack Simulation Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '9c6df0f2-1e7c-4dc3-b195-66dfbd24aa8f' ; DisplayName = 'Attack Payload Author' }
    New-Object -TypeName PSObject -Property @{ Id = '75934031-6c7e-415a-99d7-48dbd49e875e' ; DisplayName = 'Usage Summary Reports Reader' }
    New-Object -TypeName PSObject -Property @{ Id = 'b5a8dcf3-09d5-43a9-a639-8e29ef291470' ; DisplayName = 'Knowledge Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '744ec460-397e-42ad-a462-8b3f9747a02c' ; DisplayName = 'Knowledge Manager' }
    New-Object -TypeName PSObject -Property @{ Id = '8329153b-31d0-4727-b945-745eb3bc5f31' ; DisplayName = 'Domain Name Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '8424c6f0-a189-499e-bbd0-26c1753c96d4' ; DisplayName = 'Attribute Definition Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '58a13ea3-c632-46ae-9ee0-9c0d43cd7f3d' ; DisplayName = 'Attribute Assignment Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '1d336d2c-4ae8-42ef-9711-b3604ce3fc2c' ; DisplayName = 'Attribute Definition Reader' }
    New-Object -TypeName PSObject -Property @{ Id = 'ffd52fa5-98dc-465c-991d-fc073eb59f8f' ; DisplayName = 'Attribute Assignment Reader' }
    New-Object -TypeName PSObject -Property @{ Id = '31392ffb-586c-42d1-9346-e59415a2cc4e' ; DisplayName = 'Exchange Recipient Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '45d8d3c5-c802-45c6-b32a-1d70b5e1e86e' ; DisplayName = 'Identity Governance Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '892c5842-a9a6-463a-8041-72aa08ca3cf6' ; DisplayName = 'Cloud App Security Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '32696413-001a-46ae-978c-ce0f6b3620d2' ; DisplayName = 'Windows Update Deployment Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '11451d60-acb2-45eb-a7d6-43d0f0125c13' ; DisplayName = 'Windows 365 Administrator' }
    New-Object -TypeName PSObject -Property @{ Id = '3f1acade-1e04-4fbc-9b69-f0302cd84aef' ; DisplayName = 'Edge Administrator' }
)
$AzureADRoleNameList = $AzureADRoleNameList | sort Id
#endregion

#region Coptic

$CopticMonthList = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 1;  Name = 'Tute'     ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 2;  Name = 'Baaba'    ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 3;  Name = 'Hatour'   ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 4;  Name = 'Keyahk'   ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 5;  Name = 'Tubah'    ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 6;  Name = 'Amshir'   ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 7;  Name = 'Baramhat' ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 8;  Name = 'Baramouda'; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 9;  Name = 'Bashans'  ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 10; Name = 'Ba''ouna' ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 11; Name = 'Abeeb'    ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 12; Name = 'Mesrah'   ; Days = 30 })
    New-Object -TypeName PSObject -Property ([Ordered]@{ Order = 13; Name = 'Nase'''   ; Days = '5 or 6' })
)

$AboktySolarCycle = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 1  
        SolarAbokty  = 1.25
        Nayrouz      = 'Wednesday' # First of Baramouda
        NativityDay  = 'Tuesday'
        NativityDate = 29
        Epiphany     = 'Sunday'
        Annunciation = 'Monday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 2  
        SolarAbokty  = 2.5
        Nayrouz      = 'Thurday' # First of Baramouda
        NativityDay  = 'Wednesday'
        NativityDate = 29
        Epiphany     = 'Monday'
        Annunciation = 'Tuesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 3  
        SolarAbokty  = 3.75
        Nayrouz      = 'Friday' # First of Baramouda
        NativityDay  = 'Thurday'
        NativityDate = 29
        Epiphany     = 'Tuesday'
        Annunciation = 'Wednesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 4  
        SolarAbokty  = 5
        Nayrouz      = 'Sunday' # First of Baramouda
        NativityDay  = 'Friday'
        NativityDate = 28
        Epiphany     = 'Thurday'
        Annunciation = 'Friday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 5  
        SolarAbokty  = 6.25
        Nayrouz      = 'Monday' # First of Baramouda
        NativityDay  = 'Sunday'
        NativityDate = 29
        Epiphany     = 'Friday'
        Annunciation = 'Saturday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 6  
        SolarAbokty  = 7.5
        Nayrouz      = 'Tuesday' # First of Baramouda
        NativityDay  = 'Monday'
        NativityDate = 29
        Epiphany     = 'Saturday'
        Annunciation = 'Sunday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 7  
        SolarAbokty  = 1.75
        Nayrouz      = 'Wednesday' # First of Baramouda
        NativityDay  = 'Tuesday'
        NativityDate = 29
        Epiphany     = 'Sunday'
        Annunciation = 'Monday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 8  
        SolarAbokty  = 3
        Nayrouz      = 'Friday' # First of Baramouda
        NativityDay  = 'Wednesday'
        NativityDate = 28
        Epiphany     = 'Tuesday'
        Annunciation = 'Wednesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 9  
        SolarAbokty  = 4.25
        Nayrouz      = 'Saturday' # First of Baramouda
        NativityDay  = 'Friday'
        NativityDate = 29
        Epiphany     = 'Wednesday'
        Annunciation = 'Thursday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 10  
        SolarAbokty  = 5.5
        Nayrouz      = 'Sunday' # First of Baramouda
        NativityDay  = 'Saturday'
        NativityDate = 29
        Epiphany     = 'Thursday'
        Annunciation = 'Friday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 11  
        SolarAbokty  = 6.75
        Nayrouz      = 'Monday' # First of Baramouda
        NativityDay  = 'Sunday'
        NativityDate = 29
        Epiphany     = 'Friday'
        Annunciation = 'Saturday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 12  
        SolarAbokty  = 1
        Nayrouz      = 'Wednesday' # First of Baramouda
        NativityDay  = 'Monday'
        NativityDate = 28
        Epiphany     = 'Sunday'
        Annunciation = 'Monday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 13  
        SolarAbokty  = 2.25
        Nayrouz      = 'Thursdaay' # First of Baramouda
        NativityDay  = 'Wednesday'
        NativityDate = 29
        Epiphany     = 'Monday'
        Annunciation = 'Tuesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 14  
        SolarAbokty  = 3.5
        Nayrouz      = 'Friday' # First of Baramouda
        NativityDay  = 'Thursday'
        NativityDate = 29
        Epiphany     = 'Tuesday'
        Annunciation = 'Wednesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 15  
        SolarAbokty  = 4.75
        Nayrouz      = 'Saturday' # First of Baramouda
        NativityDay  = 'Friday'
        NativityDate = 29
        Epiphany     = 'Wednesday'
        Annunciation = 'Thursday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 16  
        SolarAbokty  = 6
        Nayrouz      = 'Monday' # First of Baramouda
        NativityDay  = 'Saturday'
        NativityDate = 28
        Epiphany     = 'Friday'
        Annunciation = 'Saturday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 17  
        SolarAbokty  = 7.25
        Nayrouz      = 'Tuesday' # First of Baramouda
        NativityDay  = 'Monday'
        NativityDate = 29
        Epiphany     = 'Saturday'
        Annunciation = 'Sunday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 18  
        SolarAbokty  = 1.5
        Nayrouz      = 'Wednesday' # First of Baramouda
        NativityDay  = 'Tuesday'
        NativityDate = 29
        Epiphany     = 'Sunday'
        Annunciation = 'Monday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 19  
        SolarAbokty  = 2.75
        Nayrouz      = 'Thursday' # First of Baramouda
        NativityDay  = 'Wednesday'
        NativityDate = 29
        Epiphany     = 'Monday'
        Annunciation = 'Tuesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 20  
        SolarAbokty  = 4
        Nayrouz      = 'Saturday' # First of Baramouda
        NativityDay  = 'Thursday'
        NativityDate = 28
        Epiphany     = 'Wednesday'
        Annunciation = 'Thursday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 21  
        SolarAbokty  = 5.25
        Nayrouz      = 'Sunday' # First of Baramouda
        NativityDay  = 'Saturday'
        NativityDate = 29
        Epiphany     = 'Thursday'
        Annunciation = 'Friday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 22  
        SolarAbokty  = 6.25
        Nayrouz      = 'Monday' # First of Baramouda
        NativityDay  = 'Sunday'
        NativityDate = 29
        Epiphany     = 'Friday'
        Annunciation = 'Saturday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 23  
        SolarAbokty  = 7.75
        Nayrouz      = 'Tuesday' # First of Baramouda
        NativityDay  = 'Monday'
        NativityDate = 29
        Epiphany     = 'Saturday'
        Annunciation = 'Sunday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 24  
        SolarAbokty  = 2
        Nayrouz      = 'Thursday' # First of Baramouda
        NativityDay  = 'Tuesday'
        NativityDate = 28
        Epiphany     = 'Monday'
        Annunciation = 'Tuesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 25  
        SolarAbokty  = 3.25
        Nayrouz      = 'Friday' # First of Baramouda
        NativityDay  = 'Thursday'
        NativityDate = 29
        Epiphany     = 'Tuesday'
        Annunciation = 'Wednesday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 26  
        SolarAbokty  = 4.5
        Nayrouz      = 'Saturday' # First of Baramouda
        NativityDay  = 'Friday'
        NativityDate = 29
        Epiphany     = 'Wednesday'
        Annunciation = 'Thursday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 27  
        SolarAbokty  = 5.75
        Nayrouz      = 'Sunday' # First of Baramouda
        NativityDay  = 'Saturday'
        NativityDate = 29
        Epiphany     = 'Thursday'
        Annunciation = 'Friday'    # First of Baramhat
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle        = 28  
        SolarAbokty  = 7
        Nayrouz      = 'Tuesday' # First of Baramouda
        NativityDay  = 'Sunday'
        NativityDate = 28
        Epiphany     = 'Saturday'
        Annunciation = 'Sunday'    # First of Baramhat
    })
) # 28

$AboktyLunarCycle = @(
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 1  
        LunarAbokty = 11
        LambDay     = 29
        LambMonth   = 'Baramhat'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 2  
        LunarAbokty = 22
        LambDay     = 18
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 3  
        LunarAbokty = 3
        LambDay     = 7
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 4  
        LunarAbokty = 14
        LambDay     = 26
        LambMonth   = 'Baramhat'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 5  
        LunarAbokty = 25
        LambDay     = 15
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 6  
        LunarAbokty = 6
        LambDay     = 4
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 7  
        LunarAbokty = 17
        LambDay     = 23
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 8  
        LunarAbokty = 28
        LambDay     = 12
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 9  
        LunarAbokty = 9
        LambDay     = 1
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 10  
        LunarAbokty = 20
        LambDay     = 20
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 11  
        LunarAbokty = 1
        LambDay     = 9
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 12 
        LunarAbokty = 12
        LambDay     = 28
        LambMonth   = 'Baramhat'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 13 
        LunarAbokty = 23
        LambDay     = 17
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 14 
        LunarAbokty = 4
        LambDay     = 6
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 15  
        LunarAbokty = 15
        LambDay     = 25
        LambMonth   = 'Baramhat'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 16  
        LunarAbokty = 26
        LambDay     = 14
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 17 
        LunarAbokty = 7
        LambDay     = 3
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 18 
        LunarAbokty = 18
        LambDay     = 22
        LambMonth   = 'Baramouda'
    })
    New-Object -TypeName PSObject -Property ([Ordered]@{ 
        Cycle       = 19 
        LunarAbokty = 30
        LambDay     = 10
        LambMonth   = 'Baramouda'
    })
) # 19 - Cycle += 1, Abokty += 11 (truncate at 30)

#endregion

#region Security

$WinDrive = ($env:windir -split ':')[0]
$thisOS = Get-CimInstance -Class Win32_OperatingSystem
$thisWindowsIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
$thisWindowsPrincipal = New-Object Security.Principal.WindowsPrincipal($thisWindowsIdentity)
$IsElevated = $thisWindowsPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$AESKey1 = '4e 99 06 e8 fc b6 6c c9 fa f4 93 10 62 0f fe e8 f4 96 e8 06 cc 05 79 90 20 9b 09 a4 33 b6 6c 1b' # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-gppref/2c15cbf0-f086-4c74-8b70-1f2fa45dd4be?redirectedfrom=MSDN
$ASCIINumber = 48..57
$ASCIIUpper  = 65..90
$ASCIILower  = 97..122

#endregion

#region IP
<#
1..32 | foreach {
    $Count = 33-$_
    $String = '1' * $Count
    foreach ($Zero in 1..(32-$Count)) { $String += '0' }
    "'$String'"
}
#>

$SubnetListBinary = @(
    '11111111111111111111111111111111'
    '11111111111111111111111111111110'
    '11111111111111111111111111111100'
    '11111111111111111111111111111000'
    '11111111111111111111111111110000'
    '11111111111111111111111111100000'
    '11111111111111111111111111000000'
    '11111111111111111111111110000000'
    '11111111111111111111111100000000'
    '11111111111111111111111000000000'
    '11111111111111111111110000000000'
    '11111111111111111111100000000000'
    '11111111111111111111000000000000'
    '11111111111111111110000000000000'
    '11111111111111111100000000000000'
    '11111111111111111000000000000000'
    '11111111111111110000000000000000'
    '11111111111111100000000000000000'
    '11111111111111000000000000000000'
    '11111111111110000000000000000000'
    '11111111111100000000000000000000'
    '11111111111000000000000000000000'
    '11111111110000000000000000000000'
    '11111111100000000000000000000000'
    '11111111000000000000000000000000'
    '11111110000000000000000000000000'
    '11111100000000000000000000000000'
    '11111000000000000000000000000000'
    '11110000000000000000000000000000'
    '11100000000000000000000000000000'
    '11000000000000000000000000000000'
    '10000000000000000000000000000000'
)
$SubnetMaskList = foreach ($Subnet in $SubnetListBinary) {
    New-Object -TypeName PSObject -Property ([Ordered]@{
        Binary        = $Subnet
        DottedDecimal = ([System.Net.IPAddress]"$([System.Convert]::ToInt64($Subnet,2))").IPAddressToString
        CIDR          = "/$($n = 0; $Subnet.ToCharArray() | foreach { if ($_ -eq '1') { $n++ } }; $n )"
    })
}
#endregion

#region Shodan
# https://developer.shodan.io/api
$ShodanAPIBaseURL = 'https://api.shodan.io'
$ShodanAPIMethodList = @(
    'api-info'
    'account/profile'
    'tools/httpheaders'
    'dns/reverse'
    'dns/resolve'
    'dns/domain'
    'org'
    'shodan/query'
    'shodan/query/search'
    'shodan/query/tags'
    'shodan/ports'
    'shodan/protocols'
    'shodan/scans'
    'shodan/host'
)
$ShodanPortList = @( # 27 August 2021
7,
11,
13,
15,
17,
19,
20,
21,
22,
23,
24,
25,
26,
37,
38,
43,
49,
51,
53,
69,
70,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91,
92,
95,
96,
97,
98,
99,
100,
102,
104,
106,
110,
111,
113,
119,
121,
123,
129,
131,
135,
137,
139,
143,
154,
161,
175,
179,
180,
195,
199,
211,
221,
222,
225,
263,
264,
311,
340,
389,
443,
444,
445,
447,
448,
449,
450,
465,
491,
500,
502,
503,
515,
520,
522,
523,
541,
548,
554,
555,
587,
593,
623,
626,
631,
636,
646,
666,
675,
685,
771,
772,
777,
789,
800,
801,
805,
806,
808,
830,
843,
873,
880,
888,
902,
943,
990,
992,
993,
994,
995,
999,
1000,
1010,
1012,
1022,
1023,
1024,
1025,
1026,
1027,
1028,
1029,
1050,
1063,
1080,
1099,
1110,
1111,
1119,
1167,
1177,
1194,
1200,
1234,
1250,
1290,
1311,
1344,
1355,
1366,
1388,
1400,
1433,
1434,
1471,
1494,
1500,
1515,
1521,
1554,
1588,
1599,
1604,
1650,
1660,
1723,
1741,
1777,
1800,
1820,
1830,
1833,
1883,
1900,
1901,
1911,
1935,
1947,
1950,
1951,
1962,
1981,
1990,
1991,
2000,
2001,
2002,
2003,
2006,
2008,
2010,
2012,
2018,
2020,
2021,
2022,
2030,
2048,
2049,
2050,
2051,
2052,
2053,
2054,
2055,
2056,
2057,
2058,
2059,
2060,
2061,
2062,
2063,
2064,
2065,
2066,
2067,
2068,
2069,
2070,
2077,
2079,
2080,
2081,
2082,
2083,
2086,
2087,
2095,
2096,
2100,
2111,
2121,
2122,
2123,
2126,
2150,
2152,
2181,
2200,
2201,
2202,
2211,
2220,
2221,
2222,
2223,
2225,
2232,
2233,
2250,
2259,
2266,
2320,
2323,
2332,
2345,
2351,
2352,
2375,
2376,
2379,
2382,
2404,
2443,
2455,
2480,
2506,
2525,
2548,
2549,
2550,
2551,
2552,
2553,
2554,
2555,
2556,
2557,
2558,
2559,
2560,
2561,
2562,
2563,
2566,
2567,
2568,
2569,
2570,
2572,
2598,
2601,
2602,
2626,
2628,
2650,
2701,
2709,
2761,
2762,
2806,
2985,
3000,
3001,
3002,
3005,
3048,
3049,
3050,
3051,
3052,
3053,
3054,
3055,
3056,
3057,
3058,
3059,
3060,
3061,
3062,
3063,
3066,
3067,
3068,
3069,
3070,
3071,
3072,
3073,
3074,
3075,
3076,
3077,
3078,
3079,
3080,
3081,
3082,
3083,
3084,
3085,
3086,
3087,
3088,
3089,
3090,
3091,
3092,
3093,
3094,
3095,
3096,
3097,
3098,
3099,
3100,
3101,
3102,
3103,
3104,
3105,
3106,
3107,
3108,
3109,
3110,
3111,
3112,
3113,
3114,
3115,
3116,
3117,
3118,
3119,
3120,
3121,
3128,
3129,
3200,
3211,
3221,
3260,
3270,
3283,
3299,
3306,
3307,
3310,
3311,
3333,
3337,
3352,
3386,
3388,
3389,
3391,
3400,
3401,
3402,
3403,
3404,
3405,
3406,
3407,
3408,
3409,
3410,
3412,
3443,
3460,
3479,
3498,
3503,
3521,
3522,
3523,
3524,
3541,
3542,
3548,
3549,
3550,
3551,
3552,
3554,
3555,
3556,
3557,
3558,
3559,
3560,
3561,
3562,
3563,
3566,
3567,
3568,
3569,
3570,
3671,
3689,
3690,
3702,
3749,
3780,
3784,
3790,
3791,
3792,
3793,
3794,
3838,
3910,
3922,
3950,
3951,
3952,
3953,
3954,
4000,
4001,
4002,
4010,
4022,
4040,
4042,
4043,
4063,
4064,
4070,
4100,
4117,
4118,
4157,
4190,
4200,
4242,
4243,
4282,
4321,
4369,
4430,
4433,
4443,
4444,
4445,
4482,
4500,
4505,
4506,
4523,
4524,
4545,
4550,
4567,
4643,
4646,
4664,
4700,
4730,
4734,
4747,
4782,
4786,
4800,
4808,
4840,
4848,
4911,
4949,
4999,
5000,
5001,
5002,
5003,
5004,
5005,
5006,
5007,
5008,
5009,
5010,
5025,
5050,
5060,
5070,
5080,
5090,
5094,
5122,
5150,
5172,
5190,
5201,
5209,
5222,
5269,
5280,
5321,
5353,
5357,
5400,
5431,
5432,
5443,
5446,
5454,
5494,
5500,
5542,
5552,
5555,
5560,
5567,
5568,
5569,
5577,
5590,
5591,
5592,
5593,
5594,
5595,
5596,
5597,
5598,
5599,
5600,
5601,
5602,
5603,
5604,
5605,
5606,
5607,
5608,
5609,
5632,
5672,
5673,
5683,
5684,
5800,
5801,
5822,
5853,
5858,
5900,
5901,
5906,
5907,
5908,
5909,
5910,
5938,
5984,
5985,
5986,
6000,
6001,
6002,
6003,
6004,
6005,
6006,
6007,
6008,
6009,
6010,
6036,
6080,
6102,
6161,
6262,
6264,
6308,
6352,
6363,
6379,
6443,
6464,
6503,
6510,
6511,
6512,
6543,
6550,
6560,
6561,
6565,
6580,
6581,
6588,
6590,
6600,
6601,
6602,
6603,
6605,
6622,
6650,
6662,
6664,
6666,
6667,
6668,
6697,
6748,
6789,
6881,
6887,
6955,
6969,
6998,
7000,
7001,
7002,
7003,
7004,
7005,
7010,
7014,
7070,
7071,
7080,
7081,
7090,
7170,
7171,
7218,
7401,
7415,
7433,
7443,
7444,
7445,
7465,
7474,
7493,
7500,
7510,
7535,
7537,
7547,
7548,
7634,
7654,
7657,
7676,
7700,
7776,
7777,
7778,
7779,
7788,
7887,
7979,
7998,
7999,
8000,
8001,
8002,
8003,
8004,
8005,
8006,
8007,
8008,
8009,
8010,
8011,
8012,
8013,
8014,
8015,
8016,
8017,
8018,
8019,
8020,
8021,
8022,
8023,
8024,
8025,
8026,
8027,
8028,
8029,
8030,
8031,
8032,
8033,
8034,
8035,
8036,
8037,
8038,
8039,
8040,
8041,
8042,
8043,
8044,
8045,
8046,
8047,
8048,
8049,
8050,
8051,
8052,
8053,
8054,
8055,
8056,
8057,
8058,
8060,
8064,
8066,
8069,
8071,
8072,
8080,
8081,
8082,
8083,
8084,
8085,
8086,
8087,
8088,
8089,
8090,
8091,
8092,
8093,
8094,
8095,
8096,
8097,
8098,
8099,
8100,
8101,
8102,
8103,
8104,
8105,
8106,
8107,
8108,
8109,
8110,
8111,
8112,
8118,
8123,
8126,
8139,
8140,
8143,
8159,
8180,
8181,
8182,
8184,
8190,
8200,
8222,
8236,
8237,
8238,
8239,
8241,
8243,
8248,
8249,
8251,
8252,
8282,
8291,
8333,
8334,
8383,
8401,
8402,
8403,
8404,
8405,
8406,
8407,
8408,
8409,
8410,
8411,
8412,
8413,
8414,
8415,
8416,
8417,
8418,
8419,
8420,
8421,
8422,
8423,
8424,
8425,
8426,
8427,
8428,
8429,
8430,
8431,
8432,
8433,
8442,
8443,
8444,
8445,
8446,
8447,
8448,
8500,
8513,
8545,
8553,
8554,
8585,
8586,
8590,
8602,
8621,
8622,
8623,
8637,
8649,
8663,
8666,
8686,
8688,
8700,
8733,
8765,
8766,
8767,
8779,
8782,
8784,
8787,
8788,
8789,
8790,
8791,
8800,
8801,
8802,
8803,
8804,
8805,
8806,
8807,
8808,
8809,
8810,
8811,
8812,
8813,
8814,
8815,
8816,
8817,
8818,
8819,
8820,
8821,
8822,
8823,
8824,
8825,
8826,
8827,
8828,
8829,
8830,
8831,
8832,
8833,
8834,
8835,
8836,
8837,
8838,
8839,
8840,
8841,
8842,
8843,
8844,
8845,
8846,
8847,
8848,
8849,
8850,
8851,
8852,
8853,
8854,
8855,
8856,
8857,
8858,
8859,
8860,
8861,
8862,
8863,
8864,
8865,
8866,
8867,
8868,
8869,
8870,
8871,
8872,
8873,
8874,
8875,
8876,
8877,
8878,
8879,
8880,
8881,
8885,
8887,
8888,
8889,
8890,
8891,
8899,
8935,
8969,
8988,
8989,
8990,
8991,
8993,
8999,
9000,
9001,
9002,
9003,
9004,
9005,
9006,
9007,
9008,
9009,
9010,
9011,
9012,
9013,
9014,
9015,
9016,
9017,
9018,
9019,
9020,
9021,
9022,
9023,
9024,
9025,
9026,
9027,
9028,
9029,
9030,
9031,
9032,
9033,
9034,
9035,
9036,
9037,
9038,
9039,
9040,
9041,
9042,
9043,
9044,
9045,
9046,
9047,
9048,
9049,
9050,
9051,
9070,
9080,
9082,
9084,
9088,
9089,
9090,
9091,
9092,
9093,
9094,
9095,
9096,
9097,
9098,
9099,
9100,
9101,
9102,
9103,
9104,
9105,
9106,
9107,
9108,
9109,
9110,
9111,
9119,
9136,
9151,
9160,
9189,
9191,
9199,
9200,
9201,
9202,
9203,
9204,
9205,
9206,
9207,
9208,
9209,
9210,
9211,
9212,
9213,
9214,
9215,
9216,
9217,
9218,
9219,
9220,
9221,
9222,
9251,
9295,
9299,
9300,
9301,
9302,
9303,
9304,
9305,
9306,
9307,
9308,
9309,
9310,
9311,
9389,
9418,
9433,
9443,
9444,
9445,
9500,
9527,
9530,
9550,
9595,
9600,
9606,
9633,
9663,
9682,
9690,
9704,
9743,
9761,
9765,
9861,
9869,
9876,
9898,
9899,
9943,
9944,
9950,
9955,
9966,
9981,
9988,
9990,
9991,
9992,
9993,
9994,
9997,
9998,
9999,
10000,
10001,
10134,
10243,
10250,
10443,
10554,
11112,
11211,
11300,
12000,
12345,
13579,
14147,
14265,
14344,
16010,
16464,
16992,
16993,
17000,
18081,
18245,
20000,
20087,
20256,
20547,
21025,
21379,
22222,
23023,
23424,
25105,
25565,
27015,
27016,
27017,
27036,
28015,
28017,
30718,
32400,
32764,
33060,
33338,
37215,
37777,
41794,
44818,
47808,
48899,
49152,
49153,
50000,
50050,
50070,
50100,
51106,
51235,
52869,
53413,
54138,
54984,
55442,
55443,
55553,
55554,
60001,
60129,
62078,
64738
)
#endregion

#region VirusTotal
$VTBaseURL = 'https://www.virustotal.com/api/v3'
#endregion

#endregion

#region Aliases

@(
    @{ Name = 'Log'                      ; Value = 'Write-Log' }
    @{ Name = 'Get-FileShares'           ; Value = 'Get-FileShareInfo' }
    @{ Name = 'New-SBAZServicePrincipal' ; Value = 'New-AzureServicePrincipal' }
    @{ Name = 'Get-GraphAPIToken'        ; Value = 'Get-AzureToken' }
    @{ Name = 'Get-WinEventLogMetdata'   ; Value = 'Get-WinEventLogMetadata' }    
    
) | foreach {
    Remove-Item -Path "Alias:$($_.Name)" -EA 0 
    try {
        New-Alias -Name $_.Name -Value $_.Value -EA 1
    } catch {
        Write-Log $_.Exception.Message Yellow
    }
} 

#endregion

#region Azure Functions

#region Azure Storage

function Login-AZSubscription {

    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$LogFile 
    )
    
    Begin { }

    Process {
        $LoggedIn = $false
        if ($Login = Get-AzContext) {
            if ($Login.Account.Id -eq $LoginName -and $Login.Name -match $SubscriptionName) {
# Write-Log 'Already connected to Azure subscription',$SubscriptionName,'as',$LoginName Green,Cyan,Green,Cyan $LogFile
            } elseif (-not $LoggedIn) {
                Connect-AzAccount -Credential (Get-SBCredential $LoginName) | Out-Null # -Environment AzureCloud
                Write-Log 'Connected to Azure subscription',$SubscriptionName,'as',$LoginName Green,Cyan,Green,Cyan $LogFile
                try {
                    Get-AzSubscription -SubscriptionName $SubscriptionName -WA 0 -EA 1 | Set-AzContext | Out-Null
                    Write-Log ' Set Azure subscription context to',$SubscriptionName Green,Cyan $LogFile
                } catch {
                    Write-Log $PSItem.Exception.Message Magenta $LogFile
                    break
                }         
            }
        }          
    }

    End { Get-AzContext }
}

function Retry-OnRequest {

# Requires -Modules Azure, Azure.Storage
# Requires -Version 5

<#
 .SYNOPSIS
  Function to retry storage requests when encountering temporary/transient errors
 
 .DESCRIPTION
  Function to retry storage requests when encountering temporary/transient errors,
  like network errors, or storage server busy errors
 
 .PARAMETER Action
  This is a script block to get the block list of a given BLOB
  This is invoked by this function
  Example:
    $action = {
        param ($requestOption)
        return $Blob.ICloudBlob.DownloadBlockList([Microsoft.WindowsAzure.Storage.Blob.BlockListingFilter]::All, $null, $requestOption)
    }
  where $Blob is a Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBlob object
  that can be obtained from the Get-AzureStorageBlob cmdlet for example
 
 .PARAMETER TimeOutInMinutes
  This is the time span in minutes on which the Microsoft.WindowsAzure.Storage.RetryPolicies.ExponentialRetry object is configured
  This is an optional parameter. Default is (New-TimeSpan -Minutes 15)
 
 .PARAMETER maxRetryCountOnException
  This is the maximum number of times the function will retry the call.
  This is an optional parameter. Default is 3 times
 
 .EXAMPLE
    $action = {
        param ($requestOption)
        return $Blob.ICloudBlob.DownloadBlockList([Microsoft.WindowsAzure.Storage.Blob.BlockListingFilter]::All, $null, $requestOption)
    }
    $blocks = Retry-OnRequest $action
  where $Blob is a Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBlob object
  that can be obtained from the Get-AzureStorageBlob cmdlet for example
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros, based on script by Emma Zhu - Microsoft/ShangHai - emmazhu@microsoft.com
  v0.1 - 5 December 2018
#>


    param(
        [Parameter(Mandatory=$true)]$Action,
        [Parameter(Mandatory=$false)][System.TimeSpan]$TimeOutInMinutes = (New-TimeSpan -Minutes 15),
        [Parameter(Mandatory=$false)][Int16]$maxRetryCountOnException = 3
    )
    
    Begin { }

    Process {
        $requestOption = @{
            RetryPolicy = (New-Object -TypeName Microsoft.WindowsAzure.Storage.RetryPolicies.ExponentialRetry -ArgumentList @($TimeOutInMinutes, 10))
        }
        $shouldRetryOnException = $false   
        $retryCount = 0  

        do {
            try {
                return $Action.Invoke($requestOption)
            } catch {
                if ($_.Exception.InnerException -ne $null -And $_.Exception.InnerException.GetType() -Eq [System.TimeoutException] -And $maxRetryCountOnException -gt 0) {
                    $shouldRetryOnException = $true
                    $maxRetryCountOnException --
                    $retryCount ++
                    Write-Log 'retrying request.. #',$retryCount Yellow,Cyan
                } else {
                    $shouldRetryOnException = $false
                    throw
                }
            }
        } while ($shouldRetryOnException)
    }

    End { }
}

function Get-BlobBytes {

# Requires -Modules Azure, Azure.Storage
# Requires -Version 5

<#
 .SYNOPSIS
  Function to calculate the amount of storage used by a BLOB
 
 .DESCRIPTION
  Function to calculate the amount of storage used by a BLOB
 
 .PARAMETER Blob
  This is a Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBlob object
  that can be obtained from the Get-AzureStorageBlob cmdlet for example
 
 .PARAMETER IsPremiumAccount
  An optional Boolean (True/False) parameter that defaults to False
 
 .EXAMPLE
    $LoginName = 'samb@mydomain.com'
    $SubscriptionName = 'my azure subscription name'
    $StorageAccountName = 'mystorageacct'
 
    # Import-Module Azure, Azure.Storage, AZSBTools -DisableNameChecking
    Login-AzureRmAccount -Credential (Get-SBCredential $LoginName) | Out-Null # -Environment AzureCloud
    $Subsciption = Get-AzureRmSubscription -SubscriptionName $SubscriptionName -WA 0
    $Subsciption | Set-AzureRmContext | Out-Null
    Write-Log 'Connected to',$Subsciption.Name,'as',$LoginName Green,Cyan,Green,Cyan
 
    $StorageAccount = Get-AzureRmStorageAccount | where StorageAccountName -eq $StorageAccountName
    $IsPremiumAccount = ($StorageAccount.Sku.Tier -eq "Premium")
    Write-Log 'Processing storage account',$StorageAccount.StorageAccountName,'in RG',$StorageAccount.ResourceGroupName Green,Cyan,Green,Cyan
 
    $ContainerList = Get-AzureStorageContainer -Context $StorageAccount.Context
    $Container = $ContainerList | select -First 1
    Write-Log ' Processing container',$Container.Name Green,Cyan
 
    $BlobList = Get-AzureStorageBlob -Context $StorageAccount.Context -Container $Container.Name
    $Blob = $BlobList | select -First 1
    Write-Log ' Processing blob',$Blob.Name Green,Cyan -NoNewLine
 
    $SizeInBytes = Get-BlobBytes $Blob $IsPremiumAccount
    $myOutput = [PSCustomObject][Ordered]@{
        Name = $Blob.Name
        StorageAccount = $storageAccount.StorageAccountName
        Container = $Container.Name
        Type = $Blob.BlobType
        SizeInBytes = $SizeInBytes
        LastModified = $Blob.LastModified
    }
    Write-log $SizeInBytes,'bytes' Yellow,Cyan
 
    $myOutput | select Name,Type,StorageAccount,Container,
        @{n='SizeInGB';e={[Math]::Round($_.SizeInBytes/1GB,1)}},LastModified |
            sort SizeInGB -Descending | FL
 
    This example calculates the size of the first Blob in the first container of the provided storage account
 
 .EXAMPLE
    $LoginName = 'samb@mydomain.com'
    $SubscriptionName = 'my azure subscription name'
    $StorageAccountName = 'mystorageacct'
 
    # Import-Module Azure, Azure.Storage, AZSBTools -DisableNameChecking
    Login-AzureRmAccount -Credential (Get-SBCredential $LoginName) | Out-Null # -Environment AzureCloud
    $Subsciption = Get-AzureRmSubscription -SubscriptionName $SubscriptionName -WA 0
    $Subsciption | Set-AzureRmContext | Out-Null
    Write-Log 'Connected to',$Subsciption.Name,'as',$LoginName Green,Cyan,Green,Cyan
 
    $StorageAccount = Get-AzureRmStorageAccount | where StorageAccountName -eq $StorageAccountName
    $IsPremiumAccount = ($StorageAccount.Sku.Tier -eq "Premium")
    Write-Log 'Processing storage account',$StorageAccount.StorageAccountName,'in RG',$StorageAccount.ResourceGroupName Green,Cyan,Green,Cyan
 
    $BlobList = foreach ($Container in (Get-AzureStorageContainer -Context $StorageAccount.Context)) {
        Write-Log ' Processing container',$Container.Name Green,Cyan
        $Token = $Null
        do {
            $Blobs = Get-AzureStorageBlob -Context $StorageAccount.Context -Container $Container.Name -ContinuationToken $Token
            if ($Blobs -eq $Null) { break }
 
            if ($Blobs.GetType().Name -eq 'AzureStorageBlob') {
                $Token = $Null
            } else {
                $Token = $Blobs[-1].ContinuationToken
            }
 
            $Blobs | ForEach {
                Write-Log ' Processing blob',$_.Name Green,Cyan -NoNewLine
                $SizeInBytes = Get-BlobBytes $_ $IsPremiumAccount
                [PSCustomObject][Ordered]@{
                    Name = $_.Name
                    StorageAccount = $storageAccount.StorageAccountName
                    Container = $Container.Name
                    Type = $_.BlobType
                    SizeInBytes = $SizeInBytes
                    LastModified = $_.LastModified
                }
                Write-log $SizeInBytes,'bytes' Yellow,Cyan
            }
        } While ($Token -ne $Null)
    }
 
    $BlobList | select Name,Type,StorageAccount,Container,
        @{n='SizeInGB';e={[Math]::Round($_.SizeInBytes/1GB,1)}},LastModified |
            sort SizeInGB -Descending | FT -a
 
    This example calculates blob sizes for all blobs in all containers of the provided storage account
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros, based on script by Emma Zhu - Microsoft/ShangHai - emmazhu@microsoft.com
  v0.1 - 5 December 2018
#>

    param(
        [Parameter(Mandatory=$true)]$Blob,
        [Parameter(Mandatory=$false)][bool]$IsPremiumAccount = $false
    )

    Begin {
        if (-not ([System.Management.Automation.PSTypeName]'PageRange').Type) {
            Add-Type -TypeDefinition "
                public class PageRange {
                    public long StartOffset;
                    public long EndOffset;
                }
            "
 
        }
    }

    Process {
        # Base + blobname
        $blobSizeInBytes = 124 + $Blob.Name.Length * 2

        # Size of metadata
        $metadataEnumerator = $Blob.ICloudBlob.Metadata.GetEnumerator()
        while($metadataEnumerator.MoveNext()) {
            $blobSizeInBytes += 3 + $metadataEnumerator.Current.Key.Length + $metadataEnumerator.Current.Value.Length
        }

        if (-not $IsPremiumAccount) {
            if ($Blob.BlobType -eq [Microsoft.WindowsAzure.Storage.Blob.BlobType]::BlockBlob) {
                $blobSizeInBytes += 8
                
                $action = { # Default is Microsoft.WindowsAzure.Storage.Blob.BlockListingFilter.Committed. Need All
                    param ($requestOption) 
                    return $Blob.ICloudBlob.DownloadBlockList([Microsoft.WindowsAzure.Storage.Blob.BlockListingFilter]::All, $null, $requestOption) 
                } 
                $blocks = Retry-OnRequest $action 

                if ($blocks -eq $null) {
                    $blobSizeInBytes += $Blob.ICloudBlob.Properties.Length
                } else {
                    $blocks | ForEach { $blobSizeInBytes += $_.Length + $_.Name.Length }
                }  
            } elseif ($Blob.BlobType -eq [Microsoft.WindowsAzure.Storage.Blob.BlobType]::PageBlob) {
                # It could cause server timeout issue when trying to get page ranges of highly fragmented page blob
                # Get page ranges in segment can mitigate chance of meeting such kind of server timeout issue
                # See https://blogs.msdn.microsoft.com/windowsazurestorage/2012/03/26/getting-the-page-ranges-of-a-large-page-blob-in-segments/ for more details.
                $pageRangesSegSize = 148 * 1024 * 1024L
                $totalSize = $Blob.ICloudBlob.Properties.Length
                $pageRangeSegOffset = 0
        
                $pageRangesTemp = New-Object System.Collections.ArrayList
                while ($pageRangeSegOffset -lt $totalSize) {
                    $action = {
                        param($requestOption) 
                        return $Blob.ICloudBlob.GetPageRanges($pageRangeSegOffset, $pageRangesSegSize, $null, $requestOption) 
                    }

                    Retry-OnRequest $action | ForEach { $pageRangesTemp.Add($_) }  | Out-Null
                    $pageRangeSegOffset += $pageRangesSegSize
                }

                $pageRanges = New-Object System.Collections.ArrayList
                foreach ($pageRange in $pageRangesTemp) {
                    if($lastRange -eq $Null) {
                        $lastRange = New-Object PageRange
                        $lastRange.StartOffset = $pageRange.StartOffset
                        $lastRange.EndOffset   =  $pageRange.EndOffset
                    } else {
                        if (($lastRange.EndOffset + 1) -eq $pageRange.StartOffset) {
                            $lastRange.EndOffset = $pageRange.EndOffset
                        } else {
                            $pageRanges.Add($lastRange)  | Out-Null
                            $lastRange = New-Object PageRange
                            $lastRange.StartOffset = $pageRange.StartOffset
                            $lastRange.EndOffset   =  $pageRange.EndOffset
                        }
                    }
                }

                $pageRanges.Add($lastRange) | Out-Null
                $pageRanges | ForEach { $blobSizeInBytes += 12 + $_.EndOffset - $_.StartOffset }
            } else {
                $blobSizeInBytes += $Blob.ICloudBlob.Properties.Length
            }

        } else {
            $blobSizeInBytes += $Blob.ICloudBlob.Properties.Length
        }        
    }

    End { $blobSizeInBytes }
}

function Get-ContainerBytes {

# Requires -Modules Azure, Azure.Storage
# Requires -Version 5

<#
 .SYNOPSIS
  Function to calculate container overhead storage size
 
 .DESCRIPTION
  Function to calculate container overhead storage size
 
 .PARAMETER Container
  This is an object of type Microsoft.WindowsAzure.Storage.Blob.CloudBlobContainer
  that can be obtained from the CloudBlobContainer property of the output object of
  the Get-AzureStorageContainer cmdlet - see example below
 
 .EXAMPLE
    $LoginName = 'samb@mydomain.com'
    $SubscriptionName = 'my subscription name'
    $StorageAccountName = 'mystorageacct'
 
    # Import-Module Azure, Azure.Storage, AZSBTools -DisableNameChecking
    Login-AzureRmAccount -Credential (Get-SBCredential $LoginName) | Out-Null # -Environment AzureCloud
    $Subsciption = Get-AzureRmSubscription -SubscriptionName $SubscriptionName -WA 0
    $Subsciption | Set-AzureRmContext | Out-Null
    Write-Log 'Connected to',$Subsciption.Name,'as',$LoginName Green,Cyan,Green,Cyan
 
    $StorageAccount = Get-AzureRmStorageAccount | where StorageAccountName -eq $StorageAccountName
    $IsPremiumAccount = ($StorageAccount.Sku.Tier -eq "Premium")
    Write-Log 'Processing storage account',$StorageAccount.StorageAccountName,'in RG',$StorageAccount.ResourceGroupName Green,Cyan,Green,Cyan
 
    Get-AzureStorageContainer -Context $StorageAccount.Context | foreach {
        Write-Log ' Calculating overhead bytes for container',$_.Name Green,Cyan -NoNewLine
        $ContainerOverheadBytes = Get-ContainerBytes -Container $_.CloudBlobContainer
        Write-Log $ContainerOverheadBytes,'bytes' Yellow,Cyan
    }
  This example calculate overhead bytes for all containers in the provided storage account
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros, based on script by Emma Zhu - Microsoft/ShangHai - emmazhu@microsoft.com
  v0.1 - 5 December 2018
#>

    param(
        [Parameter(Mandatory=$true)][Microsoft.WindowsAzure.Storage.Blob.CloudBlobContainer]$Container
    )

    Begin { }

    Process {
        # Base + name of container
        $ContainerOverheadBytes = 48 + $Container.Name.Length * 2

        # Get size of metadata
        $metadataEnumerator = $Container.Metadata.GetEnumerator()
        while($metadataEnumerator.MoveNext()) {
            $ContainerOverheadBytes += 3 + $metadataEnumerator.Current.Key.Length + $metadataEnumerator.Current.Value.Length
        }

        # Get size for SharedAccessPolicies
        $ContainerOverheadBytes += $Container.GetPermissions().SharedAccessPolicies.Count * 512
    }

    End { $ContainerOverheadBytes }
}

function Get-AzureRMDiskSpace {
<#
 .SYNOPSIS
  Function to obtain used disk space of one or more Azure VMs
 
 .DESCRIPTION
  Function to obtain used disk space of one or more Azure VMs
  This function calculates disk space of unmanaged disks only
  Microsoft charges for the entire allocated space of a managed disk regardless
  of how much is used, so finding the actual used size is irrelevent
 
 .PARAMETER AzureVM
  One or more of type Microsoft.Azure.Commands.Compute.Models.PSVirtualMachineList
  which can be obtained from the output of the AzureRM cmdlet Get-AzureRmVM
 
 .PARAMETER RetryCount
  This is an optional number between 0 and 99
  The cmdlet will retry the disks that fail to get used disk space amount that many times
 
 .EXAMPLE
    Login-AzureRmAccount -Credential (Get-SBCredential 'nam@domain.com') | Out-Null # -Environment AzureCloud
    Get-AzureRmSubscription -SubscriptionName 'my subscription anme' -WA 0 | Set-AzureRmContext | Out-Null
    $VMList = (Get-AzureRmVM -WA 0)[0..2]
    $DiskSpaceUsage = Get-AzureRMDiskSpace -AzureVM $VMList -RetryCount 1 -Verbose
    $DiskSpaceUsage | FT -a
 
. OUTPUTS
  PSCustom object (one for each disk) containing the following properties/example:
    VMName DiskName StorageAccount BlobName TotalSizeGB UsedSizeGB Source DateReported RetryCount
    ------ -------- -------------- -------- ----------- ---------- ------ ------------ ----------
    MigrationAdmin1 MigrationAdmin1 devgdisks756 MigrationAdmin104435.vhd 127 ? AzureStorage 8/8/2018 11:04 AM 5
    DEBCSV01 DEBCSV01 debcssa DEBCSV0120180802110039.vhd 32 3.96 AzureStorage 8/8/2018 10:49 AM 0
    DECEX16VO1 DECEX16VO1 decsa DECEX16VO120180403203752.vhd 127 30.33 AzureStorage 8/8/2018 10:50 AM 0
    DECEX16VO1 DECEX16VO1-DD1 decsa DECEX16VO1-DD1.vhd 40 ? AzureStorage 8/8/2018 11:06 AM 5
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 July 2018 - Known issue: not able to get used space of some disks, getting:
    $Blob.ICloudBlob.GetPageRanges(): Exception calling "GetPageRanges" with "0" argument(s):
    "Unable to read data from the transport connection: An existing connection was forcibly closed by the remote host."
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,ValueFromPipeLine=$true,ValueFromPipeLineByPropertyName=$true)]
            [Microsoft.Azure.Commands.Compute.Models.PSVirtualMachineList[]]$AzureVM,
        [Parameter(Mandatory=$false)][ValidateRange(0,99)][Int16]$RetryCount = 0
    )

    Begin { 
        $myOutput = @() 

        function Get-DiskBlobSize {
            [CmdletBinding(ConfirmImpact='Low')] 
            Param(
                [Parameter(Mandatory=$true)][PSCustomObject]$Disk,
                [Parameter(Mandatory=$true)][Microsoft.Azure.Commands.Compute.Models.PSVirtualMachineList]$VM
            )

            Write-Log 'Processing disk:' Green
            Write-Log ($Disk | Out-String).Trim() Cyan
            $StorageAccount = Get-AzureRmStorageAccount -ResourceGroupName $VM.ResourceGroupName -Name $Disk.StorageAccount
            $Blob = Get-AzureStorageBlob -Container vhds -Context $StorageAccount.Context -Blob $Disk.BlobName
            $blobSize  = 124 + $Blob.Name.Length * 2
            $blobSize += ($Blob.ICloudBlob.Metadata.Keys.Length   | measure -Sum).Sum
            $blobSize += ($Blob.ICloudBlob.Metadata.Values.Length | measure -Sum).Sum
            $PageRanges = $Blob.ICloudBlob.GetPageRangesAsync() # $Blob.ICloudBlob.GetPageRanges()
            Write-Verbose ($PageRanges | Out-String) 
            if ($PageRanges.Result) {                          
                $PageRanges.Result | foreach { $blobSize += 12 + $_.EndOffset - $_.StartOffset }
                [Math]::Round($blobSize/1GB,2)
            } else { 
                '?' 
            }
        }
    }

    Process {
        #region First run
        foreach ($VM in $AzureVM) {
            
            #region Get list of unmanaged VM disks
            $DiskList = @()
            if ($VM.StorageProfile.OsDisk.ManagedDisk) {
                Write-Log 'Disk',$VM.StorageProfile.OsDisk.Name,'is a managed disk, skipping..' Yellow,Cyan,Yellow
            } else {
                $DiskList += @($VM.StorageProfile.OsDisk | select Name,DiskSizeGB,
                    @{n='StorageAccount';e={$_.Vhd.Uri.Split('.')[0].Split('/')[2]}},
                    @{n='BlobName';e={$_.Vhd.Uri.Split('/')[-1]}})
            }
            foreach ($VMDisk in $VM.StorageProfile.DataDisks) {
                if ($VMDisk.ManagedDisk) {
                    Write-Log 'Disk',$VMDisk.Name,'is a managed disk, skipping..' Yellow,Cyan,Yellow
                } else {
                    $DiskList += $VMDisk | select Name,DiskSizeGB,
                        @{n='StorageAccount';e={$_.Vhd.Uri.Split('.')[0].Split('/')[2]}},
                        @{n='BlobName';e={$_.Vhd.Uri.Split('/')[-1]}}
                }
            }
            #endregion

            if ($DiskList) {
                Write-Log 'Calculating used disk space for',$DiskList.Count,'disk(s) of VM',$VM.Name Green,Cyan,Green,Cyan
                foreach ($Disk in $DiskList) {
                    $myOutput += [PSCustomObject][Ordered]@{
                        VMName         = $VM.Name
                        DiskName       = $Disk.Name
                        StorageAccount = $Disk.StorageAccount
                        BlobName       = $Disk.BlobName
                        TotalSizeGB    = $Disk.DiskSizeGB
                        UsedSizeGB     = Get-DiskBlobSize -Disk $Disk -VM $VM
                        Source         = 'AzureStorage'
                        DateReported   = Get-Date -Format g
                        RetryCount     = 0
                    }
                }
            }

        }
        #endregion

        #region Retries
        if ($RetryCount -gt 0) {
            foreach ($Retry in 1..$RetryCount) { 
                Write-Log 'Retry #',$Retry Cyan,Yellow
                foreach ($Disk in ($myOutput | where { $PSItem.UsedSizeGB -eq '?' })) {
                    $Disk.UsedSizeGB   = Get-DiskBlobSize -Disk $Disk -VM ($AzureVM | where { $Disk.VMName -eq $PSItem.Name })
                    $Disk.DateReported = Get-Date -Format g
                    $Disk.RetryCount   = $Retry
                }
            }
        }
        #endregion
    }

    End { $myOutput }
}

function Get-AzureStorageAccountList {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to get Azure storage accounts in a given subscription
 
 .DESCRIPTION
  Function to get Azure storage accounts in a given subscription
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .EXAMPLE
  Get-AzureStorageAccountList -LoginName 'sam.boutros@mydomain.com' -SubscriptionName 'my subscription name'
 
 .OUTPUTS
  This function returns a PS object for each Stprage Account containing the following properties/example:
    Name : maybcstorage
    Type : ARM-GPv1 # This is either ASM, ARM-GPv1, ARM-GPv2, or ARM-BlobOnly
    GeoReplication : Standard_RAGRS # This is either Standard_LRS, Standard_GRS, Standard_RAGRS, Standard_ZRS
    Tier : Standard # This is either Standard (HDD) or Enhanced (SSD)
    ResourceGroup : myrs1
    Location : uksouth
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://superwidgets.wordpress.com/2018/07/02/azure-storage-features-and-pricing-june-2018/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 24 October 2018
  v0.2 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName
    )

    Begin {
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {
        Get-AzResource | where ResourceType -Match 'storage' | foreach {
            [PSCustomObject][Ordered]@{
                Name           = $_.Name 
                Type           = $(
                    if ($_.ResourceType -eq 'Microsoft.ClassicStorage/storageAccounts') { 'ASM' } 
                    elseif ($_.ResourceType -eq 'Microsoft.Storage/storageAccounts') { 
                        if ($_.Kind -eq 'StorageV2') { 'ARM-GPv2' }
                        elseif ($_.Kind -eq 'BlobStorage') { 'ARM-BlobOnly' }
                        else { 'ARM-GPv1' }
                    }
                    else { '???' }
                )
                GeoReplication = $_.sku.name
                Tier           = $_.sku.tier
                ResourceGroup  = $_.ResourceGroupName
                Location       = $_.Location
            }
        }
    } 

    End {}
}

Function Delete-AzureRMUnattachedManagedDisks {

# Requires -Modules AzureRM,ImportExcel
# Requires -Version 5

<#
 .SYNOPSIS
  Function to delete Azure unused/unattached managed disks
 
 .DESCRIPTION
  Function to delete Azure unused/unattached managed disks
  This applies to ARM disks only not classic ASM disks
  This function depends on AzureRM and ImportExcel PowerShell modules available in the PowerShell Gallery
  To install: Install-Module AzureRM,ImportExcel
  This function has been tested to work with PowerShell version 5
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER OutputFile
  This is an optional parameter that specifies the path to output Excel file
  This defaults to a file in the current folder where the script is running
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Delete-AzureRMUnattachedManagedDisks -LoginName 'samb@mydomain.com' -SubscriptionName 'my Azure subscription name here'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 December 2018
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$false)][String]$OutputFile = ".\Unattached Managed Disk List - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx",
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Delete-AzureRMUnattachedManagedDisks - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        Login-AzureRmAccount -Credential (Get-SBCredential $LoginName) | Out-Null # -Environment AzureCloud
        try {
            Get-AzureRmSubscription -SubscriptionName $SubscriptionName -WA 0 -EA 1 | Set-AzureRmContext | Out-Null
            Write-Log 'Connected to Azure subscription',$SubscriptionName,'as',$LoginName Green,Cyan,Green,Cyan $LogFile
        } catch {
            Write-Log $PSItem.Exception.Message Yellow $LogFile
            break
        }
    }

    Process{

        $ManagedDisks = Get-AzureRmDisk

        if ($ManagedDisks) {
            Write-Log 'Identified',$ManagedDisks.Count,'managed disks' Green,Yellow,Green $LogFile
            $MDList = $ManagedDisks | foreach { 
                [PSCustomObject][Ordered]@{
                    DiskName      = $_.Name
                    SizeGB        = $_.DiskSizeGB
                    ResourceGroup = $_.ResourceGroupName
                    AttachedTo    = $_.ManagedBy
                }
            }
            Write-Log ($MDList | FT -a | Out-String).Trim() Cyan $LogFile
            $UnattachedMDList = $MDList | where {-not $_.AttachedTo }
            if ($UnattachedMDList) {
                Write-Log ' of which',$UnattachedMDList.Count,'disks are not attached to or used by any VM' Green,Yellow,Green $LogFile
                Write-Log ($UnattachedMDList | FT -a | Out-String).Trim() Yellow $LogFile
                
                Write-Log 'Exporting list of unattached managed disks to file',$OutputFile Green,Cyan $LogFile
                $UnattachedMDList | Export-Excel -Path $OutputFile -ConditionalText $(
                    ($UnattachedMDList | Get-Member -MemberType NoteProperty).Name | foreach { New-ConditionalText $_ White SteelBlue }
                ) -AutoSize -FreezeTopRowFirstColumn
                
                Write-Log 'Deleting',$UnattachedMDList.Count,'unattached managed disks' Green,Cyan,Green $LogFile -NoNewLine
                $Result = $UnattachedMDList | 
                    foreach { Remove-AzureRmDisk -ResourceGroupName $_.ResourceGroup -DiskName $_.DiskName -Force }
                Write-Log 'done, task details:' Cyan $LogFile
                Write-Log ($Result | FT -a | Out-String).Trim() Green $LogFile
            } else {
                Write-Log ' all of which are attached/used by VMs' Green $LogFile
            }
        } else {
            Write-Log 'No managed disks found' Green $LogFile
        }

    } 

    End { }
}

Function Remove-AzureUnmanagedDiskSnapshot {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to delete Azure disk snapshot(s) for unmanaged disks
 
 .DESCRIPTION
  Function to delete disk snapshot(s) for a given unmanaged disk
  This applies to unmanaged ARM disk snapshots only not classic ASM disks or managed ARM disks
  This function depends on Az PowerShell module available in the PowerShell Gallery
  To install required module: Install-Module Az
  This function has been tested to work with PowerShell version 5
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER StorageAccountName
  The Azure storage account name such as 'storfluxwidget3vm'
 
 .PARAMETER ContainerName
  The Container name such as 'Vhds'
 
 .PARAMETER BlobName
  The disk name such as 'Widget3VM-20181226-093810.vhd'
 
 .PARAMETER FromDate
  Snapshots with datetime stamp after this point and before the ToDate will be deleted
  Example: 1/1/2018, or 12/11/2018 11:00 AM
  If either ToDate or FromDate is not provided, all snapshots of the provided page blob will be deleted
 
 .PARAMETER ToDate
  Snapshots with datetime stamp before this point and after the FromDate will be deleted
  Example: 1/10/2018, or 12/12/2018 12:00 AM
  If either ToDate or FromDate is not provided, all snapshots of the provided page blob will be deleted
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  $ParameterList = @{
    LoginName = 'sam@dmain.com'
    SubscriptionName = 'my subscription name'
    StorageAccountName = 'storfluxwidget3vm'
    ContainerName = 'vhds'
    BlobName = 'Widget3VM-20181226-093810.vhd'
  }
  Remove-AzureUnmanagedDiskSnapshot @ParameterList
  This example deletes all snapshots of the provided disk
 
 .EXAMPLE
  $ParameterList = @{
    LoginName = 'sam@dmain.com'
    SubscriptionName = 'my subscription name'
    StorageAccountName = 'storfluxwidget3vm'
    ContainerName = 'vhds'
    FromDate = '1/1/2019'
    ToDate = Get-Date
    BlobName = 'Widget3VM-20181226-093810.vhd'
  }
  Remove-AzureUnmanagedDiskSnapshot @ParameterList
  This example deletes all snapshots of the provided disk from 1/1/2019 to now
 
 .EXAMPLE
    $LoginName = 'sam@dmain.com'
    $SubscriptionName = 'my subscription name'
    $DiskList = Get-AzureVMUnmanagedDisk -LoginName $LoginName -SubscriptionName $SubscriptionName -VMName (Get-AzVM).Name
    # By defining the $LogFile variable before the loop, we get to put all the logs in one file
    $LogFile = ".\Remove-AzureUnmanagedDiskSnapshot - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    $SnapShotList = foreach ($Disk in $DiskList) {
        $ParameterList = @{
            LoginName = $LoginName
            SubscriptionName = $SubscriptionName
            StorageAccountName = $Disk.StorageAccountName
            ContainerName = $Disk.ContainerName
            BlobName = $Disk.BlobName
            LogFile = $LogFile
        }
        Remove-AzureUnmanagedDiskSnapshot @ParameterList
    }
    This example lists all unmanaged disks of all ARM VMs in the given subscription, then deletes all their snapshots
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 December 2018
  v0.2 - 1 January 2019 - Rewrite based on Logan Zhao (zhezhao@microsoft.com) input regarding
         $storageContainer.CloudBlobContainer interface, and .CloudBlobContainer.ListBlobs() method
  v0.3 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$StorageAccountName,
        [Parameter(Mandatory=$true)][String]$ContainerName,
        [Parameter(Mandatory=$true)][String]$BlobName,
        [Parameter(Mandatory=$false)][String]$FromDate,
        [Parameter(Mandatory=$false)][String]$ToDate,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Remove-AzureUnmanagedDiskSnapshot - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process{

        #region Validate Input

        if ($StorageAccount = Get-AzStorageAccount | where StorageAccountName -EQ $StorageAccountName) {
            Write-Log 'Validated Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to find Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
            break
        }
        if ($StorageKey = (Get-AzStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value) {
            Write-Log 'Acquired access key for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to acquire access key for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            break
        }
        if ($Context = New-AzStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey) {
            Write-Log 'Acquired context for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to acquire context for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            break
        }
        if ($Container = Get-AzStorageContainer -Context $Context -Name $ContainerName) {
            Write-Log 'Read Storage Container',$ContainerName,'under',$StorageAccountName Green,Cyan,Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to read Storage Container',$ContainerName,'under',$StorageAccountName Magenta,Yellow,Magenta,Yellow $LogFile 
            break
        }

        if ($FromDate) {
            if ($FromDate -as [DateTime]) {
                $FromDate = [DateTime]$FromDate
                Write-Log 'From Date received:',$FromDate Green,Cyan $LogFile
            } else {
                Write-Log 'Bad Date/Time format received as -FromDate:',$FromDate,'stopping' Magenta,Yellow,Magenta $LogFile
                break
            }
        } else {
            Write-Log 'No From Date received, deleting all snapshots named',$BlobName,'in',"$StorageAccountName\$ContainerName" Yellow,Cyan,Green,Cyan $LogFile
            $FromDate = [DateTime]'1/1/1900'
        }
        if ($ToDate) {
            if ($ToDate -as [DateTime]) {
                $ToDate = [DateTime]$ToDate
                Write-Log 'To Date received:',$ToDate Green,Cyan $LogFile
            } else {
                Write-Log 'Bad Date/Time format received as -ToDate:',$ToDate,'stopping' Magenta,Yellow,Magenta $LogFile
                break
            }
        } else {
            Write-Log 'No To Date received, deleting all snapshots named',$BlobName,'in',"$StorageAccountName\$ContainerName" Yellow,Cyan,Green,Cyan $LogFile
            $ToDate = Get-Date
        }

        if ( $SnapshotList = $Container.CloudBlobContainer.ListBlobs($BlobName, $true,'Snapshot') | where { $_.IsSnapShot } ) {
            Write-Log 'Identified',$SnapshotList.Count,'disk snapshots for the disk/page Blob',$BlobName Green,yellow,Green,Cyan $LogFile 
            Write-Log ' dated',($SnapshotList.SnapShotTime -join ', ') Green,Cyan $LogFile 
        } else {
            Write-Log 'No disk snapshots found for the disk/page Blob',$BlobName Magenta,Yellow $LogFile
        }

        #endregion

        #region Delete snapshots
        foreach ($Snapshot in $SnapshotList) {            
            if ( ($Snapshot.SnapshotTime -le $ToDate -and $Snapshot.SnapshotTime -ge $FromDate) -or $DeleteAll ) { 
                Write-Log 'Deleting Snapshot',$Snapshot.SnapshotTime Green,Cyan $LogFile -NoNewLine
                $Snapshot.Delete() 
                $Container = Get-AzStorageContainer -Context $Context -Name $ContainerName
                if ($Container.CloudBlobContainer.ListBlobs($BlobName, $true,'Snapshot') | where { $_.SnapshotTime -eq $Snapshot.SnapshotTime }) {
                    Write-Log 'failed' Yellow $LogFile
                } else {
                    Write-Log 'done' DarkYellow $LogFile
                }
            }
        }
        #endregion

    } 

    End { }
}

Function Get-AzureUnmanagedDiskSnapshot {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to get Azure disk snapshot for unmanaged disks
 
 .DESCRIPTION
  Function to get disk snapshots for a given unmanaged disk
  This applies to unmanaged ARM disk snapshots only not classic ASM disks or managed ARM disks
  This function depends on Az PowerShell module available in the PowerShell Gallery
  To install required module: Install-Module Az
  This function has been tested to work with PowerShell version 5
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER StorageAccountName
  The Azure storage account name such as 'storfluxwidget3vm'
 
 .PARAMETER ContainerName
  The Container name such as 'Vhds'
 
 .PARAMETER BlobName
  The disk name such as 'Widget3VM-20181226-093810.vhd'
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  $ParameterList = @{
    LoginName = 'sam@dmain.com'
    SubscriptionName = 'my subscription name'
    StorageAccountName = 'storfluxwidget3vm'
    ContainerName = 'vhds'
    BlobName = 'Widget3VM-20181226-093810.vhd'
  }
  Get-AzureUnmanagedDiskSnapshot @ParameterList
  This example lists all snapshots of the provided disk
 
 .EXAMPLE
    $LoginName = 'sam@dmain.com'
    $SubscriptionName = 'my subscription name'
    $DiskList = Get-AzureVMUnmanagedDisk -LoginName $LoginName -SubscriptionName $SubscriptionName -VMName (Get-AzVM).Name
    # By defining the $LogFile variable before the loop, we get to put all the logs in one file
    $LogFile = ".\Get-AzureUnmanagedDiskSnapshot - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    $SnapShotList = foreach ($Disk in $DiskList) {
        $ParameterList = @{
            LoginName = $LoginName
            SubscriptionName = $SubscriptionName
            StorageAccountName = $Disk.StorageAccountName
            ContainerName = $Disk.ContainerName
            BlobName = $Disk.BlobName
            LogFile = $LogFile
        }
        Get-AzureUnmanagedDiskSnapshot @ParameterList
    }
    This example lists all unmanaged disks of all ARM VMs in the given subscription, then lists all their snapshots
 
 .OUTPUTS
  This function returns objects of type Microsoft.WindowsAzure.Storage.Blob.CloudPageBlob
  for each snapshot found that matches the provided storageaccount/container/blob parameters
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 2 January 2019
  v0.2 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$StorageAccountName,
        [Parameter(Mandatory=$true)][String]$ContainerName,
        [Parameter(Mandatory=$true)][String]$BlobName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-AzureUnmanagedDiskSnapshot - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process{

        #region Validate Input
        if ($StorageAccount = Get-AzStorageAccount | where StorageAccountName -EQ $StorageAccountName) {
            Write-Log 'Validated Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to find Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
            break
        }
        if ($StorageKey = (Get-AzStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value) {
            Write-Log 'Acquired access key for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to acquire access key for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            break
        }
        if ($Context = New-AzStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey) {
            Write-Log 'Acquired context for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to acquire context for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            break
        }
        if ($Container = Get-AzStorageContainer -Context $Context -Name $ContainerName) {
            Write-Log 'Read Storage Container',$ContainerName,'under',$StorageAccountName Green,Cyan,Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to read Storage Container',$ContainerName,'under',$StorageAccountName Magenta,Yellow,Magenta,Yellow $LogFile 
            break
        }
        #endregion

        #region Get snapshots
        if ($SnapshotList = $Container.CloudBlobContainer.ListBlobs($BlobName, $true,'Snapshot') | where { $_.IsSnapShot } ) {
            Write-Log 'Identified',$SnapshotList.Count,'disk snapshots for the disk/page Blob',$BlobName Green,yellow,Green,Cyan $LogFile 
            Write-Log ' dated',($SnapshotList.SnapShotTime -join ', ') Green,Cyan $LogFile 
        } else {
            Write-Log 'No disk snapshots found for the disk/page Blob',$BlobName Magenta,Yellow $LogFile
        }
        #endregion

    } 

    End { $SnapshotList }
}

Function New-AzureUnmanagedDiskSnapshot {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to create Azure disk snapshot for unmanaged disks
 
 .DESCRIPTION
  Function to create disk snapshots for a given unmanaged disk
  This applies to unmanaged ARM disk snapshots only not classic ASM disks or managed ARM disks
  This function depends on Az PowerShell modules available in the PowerShell Gallery
  To install required module: Install-Module Az
  This function has been tested to work with PowerShell version 5
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER StorageAccountName
  The Azure storage account name such as 'storfluxwidget3vm'
 
 .PARAMETER ContainerName
  The Container name such as 'Vhds'
 
 .PARAMETER BlobName
  The disk name such as 'Widget3VM-20181226-093810.vhd'
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  $ParameterList = @{
    LoginName = 'sam@domain.com'
    SubscriptionName = 'my subscription name'
    StorageAccountName = 'storfluxwidget4vm'
    ContainerName = 'vhds'
    BlobName = 'Widget4VM-20181226-093810.vhd'
  }
  New-AzureRMUnmanagedDiskSnapshot @ParameterList
  This example creates a new snapshot of the provided disk
 
 .OUTPUTS
  This function returns object of type Microsoft.WindowsAzure.Storage.Blob.CloudPageBlob for the snapshot created
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 2 January 2019
  v0.2 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$StorageAccountName,
        [Parameter(Mandatory=$true)][String]$ContainerName,
        [Parameter(Mandatory=$true)][String]$BlobName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\New-AzureUnmanagedDiskSnapshot - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process{

        #region Validate Input

        if ($StorageAccount = Get-AzStorageAccount | where StorageAccountName -EQ $StorageAccountName) {
            Write-Log 'Validated Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to find Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
            break
        }
        if ($StorageKey = (Get-AzStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value) {
            Write-Log 'Acquired access key for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to acquire access key for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            break
        }
        if ($Context = New-AzStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey) {
            Write-Log 'Acquired context for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log 'Unable to acquire context for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            break
        }
        
        if ($Blob = Get-AzStorageBlob -Container $ContainerName -Context $Context | Where { $_.Name -eq $BlobName -and (-not $_.ICloudBlob.IsSnapshot)}) {
            Write-Log 'Validated page blob/disk',$BlobName,'under',"$StorageAccountName\$ContainerName" Green,Cyan,Green,Cyan $LogFile
        } else {
            Write-Log 'Page blob/disk',$BlobName,'not found under',"$StorageAccountName\$ContainerName" Magenta,Yellow,Magenta,Yellow $LogFile 
            break
        }

        #endregion

        #region New snapshot

        $SnapShot = $Blob.ICloudBlob.CreateSnapshot()

        #endregion

    } 

    End { $SnapShot }
}

function Get-AzureVMUnmanagedDisk {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to return unmanaged disk information of a given Azure VM
 
 .DESCRIPTION
  Function to return unmanaged disk information of a given Azure VM
  This function is intended for ARM disks and VMs not ASM
  This function is intended for unmanaged disks only
  It returns information on OS disk and data disks if any
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER VMName
  The name of the Virtual Machine
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Get-AzureRMVMUnmanagedDisk -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -VMName 'Widget3VM'
  This example lists the unmanaged disks of a given VM
 
 .EXAMPLE
  Get-AzureRMVMUnmanagedDisk -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -VMName (Get-AzureRMVM).Name | FT -a
  This example lists all unmanaged disks in the given subscription
 
 .OUTPUTS
  Array of PS Custom objects, one for each disk found with the following properties:
    BlobName
    ContainerName
    StorageAccountName
    VMName
    ResourceGroup ==> this is the Resource Group Name
    IsOSDisk ==> True/False
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 2 January, 2019 - original release and minor updates
  v0.2 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String[]]$VMName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-AzureVMUnmanagedDisk - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {

        #region Get VM List
        $AllVMs = Get-AzVM -WA 0 
        $VMList = @()
        foreach ($VMItem in $VMName) {
            if ($MatchingVMs = $AllVMs | where Name -EQ $VMItem) {
                $VMList += $MatchingVMs
                Write-Log 'Validated VM',$VMItem Green,Cyan $LogFile
            } else {
                Write-Log 'Unable to find VM',$VMItem,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
            }
        }
        #endregion

        #region Get VM disks
        $DiskList = @()
        foreach ($VM in $VMList) {
            if ($VM.StorageProfile.OsDisk.Vhd.Uri) {
                $DiskName = Split-Path $VM.StorageProfile.OsDisk.Vhd.Uri -Leaf
                $DiskList += [PSCustomObject][Ordered]@{
                    BlobName           = $DiskName 
                    ContainerName      = (Split-Path $VM.StorageProfile.OsDisk.Vhd.Uri).Split('\')[3]
                    StorageAccountName = (Split-Path $VM.StorageProfile.OsDisk.Vhd.Uri).Split('\')[2].Split('.')[0]
                    VMName             = $VM.Name 
                    ResourceGroup      = $VM.ResourceGroupName 
                    IsOSDisk           = $true
                }
                Write-Log 'Identified VM',$VM.Name,'OS disk',$DiskName Green,Cyan,Green,Cyan $LogFile
            } else {
                Write-Log 'VM',$VM.Name,'OS disk is a Managed disk, skipping..' Magenta,Yellow,Magenta $LogFile    
            }

            if ($VM.StorageProfile.DataDisks) {
                foreach ($Disk in $VM.StorageProfile.DataDisks) {
                    if ($Disk.Vhd.Uri) {
                        $DiskName = Split-Path $Disk.Vhd.Uri -Leaf
                        $DiskList += [PSCustomObject][Ordered]@{
                            BlobName           = $DiskName
                            ContainerName      = (Split-Path $Disk.Vhd.Uri).Split('\')[3]
                            StorageAccountName = (Split-Path $Disk.Vhd.Uri).Split('\')[2].Split('.')[0]
                            VMName             = $VM.Name 
                            ResourceGroup      = $VM.ResourceGroupName 
                            IsOSDisk           = $false
                        }
                        Write-Log 'Identified VM',$VM.Name,'data disk',$DiskName Green,Cyan,Green,Cyan $LogFile
                    } else {
                        Write-Log 'VM',$VM.Name,'data disk',$DiskName,'is a Managed disk, skipping..' Magenta,Yellow,Magenta,Yellow,Magenta $LogFile    
                    }
                }
                
            } else {
                Write-Log 'VM',$VM.Name,'has no data disks, skipping..' Magenta,Yellow,Magenta $LogFile    
            }
        }
        #endregion

    } 

    End { $DiskList }
}

function Delete-AzBlobAndContainerAndAccount {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to delete an Azure Blob, its container if empty, and its storage account if empty
 
 .DESCRIPTION
  Function to delete an Azure Blob, its container if empty, and its storage account if empty
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 January 2019
  v0.2 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$StorageAccountName,
        [Parameter(Mandatory=$true)][String]$ContainerName,
        [Parameter(Mandatory=$true)][String]$BlobName,              
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Delete-AzBlobAndContainerAndAccount - $BlobName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        # Validate Azure access
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {
        $Go =$true
        if ($StorageAccount = Get-AzStorageAccount | where StorageAccountName -EQ $StorageAccountName) {
            Write-Log ' Identified Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log ' Unable to find Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
            $Go = $false
        }
        if ($StorageKey = (Get-AzStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value) {
            Write-Log ' Acquired access key for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log ' Unable to acquire access key for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            $Go = $false
        }
        if ($Context = New-AzStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey) {
            Write-Log ' Acquired context for Storage Account',$StorageAccountName Green,Cyan $LogFile
        } else {
            Write-Log ' Unable to acquire context for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
            Write-Log $Error[0].Exception.Message Yellow $LogFile
            $Go = $false
        }

        try { 
            $Container = Get-AzStorageContainer -Context $Context -Name $ContainerName -EA 1 
            Write-Log ' Read Storage Container',$ContainerName,'under',$StorageAccountName Green,Cyan,Green,Cyan $LogFile

            #region Delete Blob(s)
            if ($BlobList = $Container.CloudBlobContainer.ListBlobs() | where Name -Match $BlobName) {
                foreach ($Blob in $BlobList) {
                    Write-Log ' Deleting Blob',$Blob.Name Green,Yellow $LogFile
                    $Blob.Delete()
                }
                $Container = Get-AzStorageContainer -Context $Context -Name $ContainerName -EA 1 
                if ($BlobList = $Container.CloudBlobContainer.ListBlobs() | where Name -Match $BlobName) {
                    Write-Log ' Failed to delete 1 or more blobs' Magenta $LogFile
                } else {
                    Write-Log ' Blob deletion successful' Cyan $LogFile
                }
            } else {
                Write-Log ' Blob',$BlobName,'not found in',"$StorageAccountName/$ContainerName" Magenta,Yellow,Magenta,Yellow $LogFile
            } 
            #endregion

            #region Delete container if empty
            if ($BlobList = $Container.CloudBlobContainer.ListBlobs()) {
                Write-Log ' Container',$ContainerName,'is not empty - skipping, it has the following blobs:' Green,Yellow,Green $LogFile
                $BlobList | foreach { Write-Log " $($_.Name)" Cyan $LogFile } 
            } else {
                Write-Log ' Deleting empty container',$ContainerName Green,Yellow $LogFile -NoNewLine
                try {
                    $Result = $Container | Remove-AzureStorageContainer -PassThru -Force -EA 1 
                    Write-Log 'done' DarkYellow $LogFile
                } catch {
                    Write-Log 'failed' Magenta $LogFile
                }
            } 
            #endregion
                                      
        } catch {
            Write-Log ' Unable to read Storage Container',$ContainerName,'under',$StorageAccountName Magenta,Yellow,Magenta,Yellow $LogFile 
        } 

        #region Delete Storage Account if empty
        if ($Go) {                    
            if ($ContainerList = Get-AzStorageContainer -Context $Context) {
                Write-Log ' Storage account',$StorageAccountName,'is not empty - skipping, currently has the following container(s)' Cyan,Yellow,Cyan $LogFile
                $ContainerList.Name | foreach { Write-Log " $_" Green $LogFile }
            } else {
                Write-Log 'Deleting empty Storage Account',$StorageAccountName Green,Cyan $LogFile -NoNewLine
                $StorageAccount | Remove-AzStorageAccount -Force
                Write-Log 'done' Green $LogFile
            }
        } 
        #endregion
    }

    End {  }
}

function Delete-AzVM {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to delete an Azure ARM VM and all its objects
 
 .DESCRIPTION
  Function to delete an Azure ARM VM and all its objects including:
  - Boot Diagnostics blob(s), storage container, storage account if empty
  - VM object
  - OS disk, storage container if ampty, storage account if ampty
  - Data disk(s) if any, storage container(s) if ampty, storage account(s) if ampty
  - VM NIC(s)
  - VM public IP objects if any
  NSG's are not deleted by this function since they may be linked to many NICs
  This function will not delete a running VM by design
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER VMName
  The name of one or more ARM Virtual Machines
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Delete-AzVM -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -ARMVMName 'Widget3VM'
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 January 2019
  v0.2 - 24 May 2019 - Updated to use AZ module instead of AzureRM module
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$VMName,   
        [Parameter(Mandatory=$false)][String]$ResourceGroupName,   
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Delete-AzVM - $VMName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        # Validate Azure access, Input
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }

        Try {
            $StorageAccountList = Get-AzStorageAccount -EA 1 
            if (-not $StorageAccountList) {
                Write-Log 'No storage accounts found' Magenta $LogFile; Break
            }
        } catch {
            Write-Log 'Unable to list Storage Accounts in Subscription',$SubscriptionName Magenta,Yellow $LogFile; Break
        }

        Try {
            $RawVMList = Get-AzVM -EA 1 
            if (-not $RawVMList) {
                Write-Log 'No VMs found' Magenta $LogFile; Break
            }
        } catch {
            Write-Log 'Unable to list VMs in Subscription',$SubscriptionName Magenta,Yellow $LogFile; Break
        }

    }

    Process {

        if ($VM = $RawVMList | where Name -EQ $VMName) {
            if ($VM.Count -gt 1) {
                if ($ResourceGroupName) {
                    $VM = Get-AzVM -Name $VMName -ResourceGroupName $ResourceGroupName
                } else {
                    Write-Log 'Delete-AzVM input error:','Found more than 1 VM named',$VMName Magenta,Yellow,Magenta $LogFile
                    Write-Log ($VM|Out-String).Trim() Yellow $LogFile
                    Write-Log 'If more than 1 VM exist in the same subscription with the same name, you must specify the ResourceGroupName' Magenta $LogFile
                    break
                }
            }
        } else {
            Write-Log 'VM',$VMName,'not found in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
            break
        } 

    if ($VM) {
            Write-Log 'Processing VM' Green $LogFile
            Write-Log ($VM|Out-String).Trim() Cyan $LogFile

            $VMStatus = (Get-AzVM -ResourceGroupName $VM.ResourceGroupName -Name $VMName -Status).Statuses[1].DisplayStatus
            if ($VMStatus -eq 'VM deallocated') { 

                #region Delete Boot Diagnostics blob(s) if configured, container, storage account if empty
                    if ($VM.DiagnosticsProfile.bootDiagnostics.storageUri) {
                        $StorageAccountName = ($VM.DiagnosticsProfile.bootDiagnostics.storageUri).Split('/')[2].Split('.')[0]
                        $ContainerName = "bootdiagnostics-$($vm.Name.ToLower().Substring(0, 9))-$($VM.vmId)"
                
                        $Go =$true
                        if ($StorageAccount = Get-AzStorageAccount | where StorageAccountName -EQ $StorageAccountName) {
                            Write-Log ' Identified Diagnostics Storage Account',$StorageAccountName Green,Cyan $LogFile
                        } else {
                            Write-Log ' Unable to find Diagnostics Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
                            $Go = $false
                        }
                        if ($StorageKey = (Get-AzStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value) {
                            Write-Log ' Acquired access key for Storage Account',$StorageAccountName Green,Cyan $LogFile
                        } else {
                            Write-Log ' Unable to acquire access key for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
                            Write-Log $Error[0].Exception.Message Yellow $LogFile
                            $Go = $false
                        }
                        if ($Context = New-AzStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey) {
                            Write-Log ' Acquired context for Storage Account',$StorageAccountName Green,Cyan $LogFile
                        } else {
                            Write-Log ' Unable to acquire context for Storage Account',$StorageAccountName,'in subscription',$SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile 
                            Write-Log $Error[0].Exception.Message Yellow $LogFile
                            $Go = $false
                        }
                        try { 
                            $Container = Get-AzStorageContainer -Context $Context -Name $ContainerName -EA 1 
                            Write-Log ' Read Storage Container',$ContainerName,'under',$StorageAccountName Green,Cyan,Green,Cyan $LogFile
                            if ($BlobList = $Container.CloudBlobContainer.ListBlobs() ) {
                                Write-Log ' Found the following blobs in',"$StorageAccountName/$ContainerName" Green,Cyan $LogFile
                                $BlobList.Name | foreach { Write-Log " $_" Cyan $LogFile }
                            } else {
                                Write-Log ' No Blobs found in',"$StorageAccountName/$ContainerName" Magenta,Yellow $LogFile
                            }
                    
                            Write-Log ' Deleting the container',$ContainerName,'and all its Blobs..' Green,Yellow,Green $LogFile -NoNewLine
                            try {
                                $Result = $Container | Remove-AzStorageContainer -PassThru -Force -EA 1 
                                Write-Log 'done' Cyan $LogFile
                            } catch {
                                Write-Log 'failed' Magenta $LogFile
                            }
                        } catch {
                            Write-Log ' Unable to read Storage Container',$ContainerName,'under',$StorageAccountName Magenta,Yellow,Magenta,Yellow $LogFile 
                        } # Delete Container

                        if ($Go) {                    
                            if ($ContainerList = Get-AzStorageContainer -Context $Context) {
                                Write-Log ' Storage account',$StorageAccountName,'is not empty - skipping, currently has the following containers' Cyan,Yellow,Cyan $LogFile
                                $ContainerList.Name | foreach { Write-Log " $_" Green $LogFile }
                            } else {
                                Write-Log 'Deleting empty Storage Account',$StorageAccountName Green,Cyan $LogFile -NoNewLine
                                $StorageAccount | Remove-AzStorageAccount -Force
                                Write-Log 'done' Green $LogFile
                            }
                        } # Delete Storage Account if empty
                    } else {
                        Write-Log ' Boot diagnostics not configured for VM',$VM.Name Green,Yellow $LogFile
                    }
                    #endregion

                #region Delete VM
                Write-Log ' Deleting VM',$VMName Green,Cyan $LogFile -NoNewLine
                $Result = $VM | Remove-AzVM –Force
                Write-Log 'done' DarkYellow $LogFile
                #endregion

                #region Delete OS disk, status blob

                if($VM.StorageProfile.OsDisk.ManagedDisk) {
                    Write-Log ' Deleting managed OS disk',$VM.StorageProfile.OSDisk.Name,'for VM',$VM.Name Green,Cyan,Green,Cyan $LogFile -NoNewLine
                    Get-AzDisk -ResourceGroupName $VM.ResourceGroupName -DiskName $VM.StorageProfile.OSDisk.Name | Remove-AzDisk -Force
                    Write-Log 'done' DarkYellow $LogFile
                } else {
                    $StorageAccountName = ($VM.StorageProfile.OSDisk.Vhd.Uri).Split('/')[2].Split('.')[0]
                    $ContainerName = ($VM.StorageProfile.OSDisk.Vhd.Uri).Split('/')[3]
                    $BlobName = ($VM.StorageProfile.OSDisk.Vhd.Uri).Split('/')[4]
                    Write-Log 'Identified OS disk',$BlobName,'in Storage Account/Container',"$StorageAccountName/$ContainerName" Green,Cyan,Green,Cyan $LogFile
                    $ParameterList = @{
                        LoginName          = $LoginName
                        SubscriptionName   = $SubscriptionName
                        StorageAccountName = $StorageAccountName
                        ContainerName      = $ContainerName
                        BlobName           = $BlobName             
                        LogFile            = $LogFile                         
                    }
                    Delete-AzBlobAndContainerAndAccount @ParameterList     
                }               

                #endregion

                #region Delete data disks

                foreach ($DataDisk in $VM.StorageProfile.DataDisks) { 
                    if($DataDisk.ManagedDisk) {
                        Write-Log ' Deleting managed data disk',$DataDisk.Name,'for VM',$VM.Name Green,Cyan,Green,Cyan $LogFile -NoNewLine
                        Get-AzDisk -ResourceGroupName $VM.ResourceGroupName -DiskName $DataDisk.Name | Remove-AzDisk -Force
                        Write-Log 'done' DarkYellow $LogFile
                    } else {
                        $StorageAccountName = ($DataDisk.Vhd.Uri).Split('/')[2].Split('.')[0]
                        $ContainerName = ($DataDisk.Vhd.Uri).Split('/')[3]
                        $BlobName = ($DataDisk.Vhd.Uri).Split('/')[4]
                        Write-Log 'Identified data disk',$BlobName,'in Storage Account/Container',"$StorageAccountName/$ContainerName" Green,Cyan,Green,Cyan $LogFile
                        $ParameterList = @{
                            LoginName          = $LoginName
                            SubscriptionName   = $SubscriptionName
                            StorageAccountName = $StorageAccountName
                            ContainerName      = $ContainerName
                            BlobName           = $BlobName             
                            LogFile            = $LogFile                         
                        }
                        Delete-AzBlobAndContainerAndAccount @ParameterList  
                    }
                }

                #endregion

                #region delete vNIC(s)
                foreach ($VMNIC in ($VM.NetworkProfile.NetworkInterfaces | where {$_.ID})) {
                    $NICName = Split-Path -Path $VMNIC.ID -leaf
                    Write-Log ' Deleting VM NIC',$NICName Green,Cyan $LogFile -NoNewLine
                    Get-AzNetworkInterface -ResourceGroupName $VM.ResourceGroupName -Name $NICName | Remove-AzNetworkInterface -Force
                    Write-Log 'done' DarkYellow $LogFile
                }
                #endregion

                #region delete public IP if any
                Remove-Variable FoundPublicIP -EA 0 
                foreach ($VMNIC in $VM.NetworkProfile.NetworkInterfaces.Id) {
                    foreach ($PublicIP in (Get-AzPublicIpAddress -ResourceGroupName $VM.ResourceGroupName | Where { $_.IpConfiguration.Id })) {
                        if (($PublicIP.IpConfiguration.Id).Split('/')[8] -eq $VMNIC.Split('/')[8]) {
                            Write-Log 'Identified Public IP object',$PublicIP.Name,'associated with VM NIC',($VMNIC.Split('/')[8]),'of VM',$VM.Name Green,Cyan,Green,Cyan ,Green,Cyan 
                            $FoundPublicIP = $PublicIP
                        }        
                    }
                }

                if ($FoundPublicIP) {
                    Write-Log ' Deleting VM public IP object',$PublicIP.Name Green,Cyan $LogFile -NoNewLine
                    Get-AzPublicIpAddress -ResourceGroupName $VM.ResourceGroupName -Name $PublicIP.Name | Remove-AzPublicIpAddress -Force 
                    Write-Log 'done' DarkYellow $LogFile
                } else {
                    Write-Log ' No public IP object found for VM',$VM.Name Green,Cyan $LogFile
                }
                #endregion

                # Not deleting NSG's here, since they may apply to several NICs that belong to several VMs
                # Will have a separate function to delete unused NSG's (not linked to any NICs)

            } else {
                Write-Log 'VM',$VMName,'is not powered off. Current status is:',$VMStatus,'skipping..' Magenta,Yellow,Magenta,Yellow,Magenta $LogFile
            }
        }

    }

    End {  }
}

function Report-AzureRMSubscriptionVMBackup {
<#
 .SYNOPSIS
  Function to list backup recovery points of Azure VMs in one or more subscriptions
 
 .DESCRIPTION
  Function to list backup recovery points of Azure VMs in one or more subscriptions
  The script provides interim output to the console indicating its progress through the hierarchy of:
    Subscriptions
      Recovery Services Vaults
        Registered AzureVM Backup containers
          Backup Items
            Recovery points
 
 .PARAMETER SubscriptionName
  Name of Azure subscription
  If not provided it will default to all accessible Azure subscriptions
 
 .EXAMPLE
    Login-AzureRmAccount -Credential (Get-SBCredential 'name@domain.com') | Out-Null # -Environment AzureCloud
    Report-AzureRMSubscriptionVMBackup
 
 .EXAMPLE
    $VMBackupList = Report-AzureRMSubscriptionVMBackup -SubscriptionName 'my subscription name'
    $VMBackupList | Format-Table -Auto # to display to the console
    $VMBackupList | Out-GridView # to display to ISE GridView
    $VMBackupList | Export-Csv .\VMBackupList1.csv -NoType # to export to CSV
 
. OUTPUTS
  PSCustom object (one for each recovery point) containing the following properties/example:
    VMName VaultName ResourceGroup SubscriptionName RecoveryPointType RecoveryPointTime EncryptionEnabled
    ------ ------------ ------------- ---------------- ----------------- ----------------- -----------------
    ab123xyzw01 xyz abc my subscription name CrashConsistent 8/9/2018 6:01:25 AM False
    ab123xyzw01 xyz abc my subscription name CrashConsistent 8/8/2018 6:08:09 AM False
    ab123xyzw01 xyz abc my subscription name CrashConsistent 8/7/2018 6:11:49 AM False
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 9 August 2018
  v0.2 - 24 September 2018 - Fixed bug with Get-AzureRmResource line
  v0.3 - 25 September 2018 - Added Vault Name in output
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,ValueFromPipeLine=$true,ValueFromPipeLineByPropertyName=$true)]
            [String[]]$SubscriptionName
    )

    Begin { 
        $myOutput = @() 

        # Validate AzureRM PowerShell module is available
        if(-not (Get-Module -ListAvailable AzureRM)) {
            Write-Log 'Required AzureRM PowerShell module not found. You can install it from the PowerShell Gallery by running:' Magenta
            Write-Log 'Install-Module AzureRM' Yellow
            break
        }

        # Validate that we're logged in to Azure
        try { Get-AzureRmSubscription -EA 1 -WA 0 | Out-Null  } catch { Write-Log $_.exception.message Yellow; break }
    }

    Process {

        if (-not $SubscriptionName) { $SubscriptionName = (Get-AzureRmSubscription -WA 0).Name }

        foreach ($Subscription in $SubscriptionName) {
            Write-Log 'Processing subscription',$Subscription Green,Cyan
            try {
                Get-AzureRmSubscription -SubscriptionName $Subscription -EA 1 -WA 0 | Set-AzureRmContext | Out-Null  
                $VaultList = Get-AzureRmResource | where ResourceType -EQ Microsoft.RecoveryServices/vaults | select Name,ResourceGroupName,Location
                if ($VaultList) {
                    Write-Log ' Identified',$VaultList.Count,'Recovery Services Vaults;',($VaultList.Name -join ', ') Green,Cyan,Green,Cyan
                    foreach ($Vault in $VaultList) {
                        Write-Log ' Processing Recovery Services Vault',$Vault.Name Green,Cyan 
                        Set-AzureRmRecoveryServicesVaultContext -Vault $Vault
                        $ContainerList = Get-AzureRmRecoveryServicesBackupContainer  -ContainerType 'AzureVM' -Status 'Registered' 
                        if ($ContainerList) {
                            Write-Log ' Identified',$ContainerList.Count,'Azure VM backup sets/containers;',($ContainerList.FriendlyName -join ', ') Green,Cyan,Green,Cyan
                            foreach ($Container in $ContainerList) {
                                $backupitem = Get-AzureRmRecoveryServicesBackupItem -Container $Container -WorkloadType 'AzureVM'
                                if ($backupitem) {
                                    $RecoveryPointList = Get-AzureRmRecoveryServicesBackupRecoveryPoint -Item $backupitem 
                                    if ($RecoveryPointList) {
                                        Write-Log ' Identified',$RecoveryPointList.Count,'recovery points for VM',$Container.FriendlyName Green,Cyan,Green,Cyan
                                        foreach ($RecoveryPoint in $RecoveryPointList) {
                                            $myOutput += [PSCustomObject][Ordered]@{
                                                VMName            = $RecoveryPoint.ItemName.Split(';')[2]
                                                ResourceGroup     = $RecoveryPoint.ItemName.Split(';')[1]
                                                VaultName         = $Vault.Name 
                                                SubscriptionName  = $Subscription
                                                RecoveryPointType = $RecoveryPoint.RecoveryPointType
                                                RecoveryPointTime = $RecoveryPoint.RecoveryPointTime
                                                EncryptionEnabled = $RecoveryPoint.EncryptionEnabled
                                            }
                                        }
                                    } else {
                                        Write-Log ' No recovery points found for VM',$Container.FriendlyName Green,yellow
                                    }
                                }
                            }
                        } else {
                            Write-Log ' No registered VM backup containers found in Recovery Services Vault',$Vault.Name Green,Yellow
                        }
                    }
                } else {
                    Write-Log ' No Recovery Services Vaults found in subscription',$Subscription Green,Yellow
                }
            } catch {
                Write-Log $_.exception.message Yellow
            }
            
        }
    }

    End { $myOutput }
}

function Remove-AzureRMVMBackup {
<#
 .SYNOPSIS
  Function to disable backup of a given VM and delete existing backups (recovery points)
 
 .DESCRIPTION
  Function to disable backup of a given VM and delete existing backups (recovery points)
  If there are multiple VMs with the same name (under different Resource Groups) in the same
  subscription, this function will not delete the backups (cannot tell which VM the backups belong to)
  This function will work on both ARM and ASM VM backups
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER VMName
  The name of a given Virtual Machine
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    Remove-AzureRMVMBackup -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -VMName 'Widget3VM'
 
 .LINK
  https://superwidgets.wordpress.com/2019/01/16/remove-azurermvmbackup-function-added-to-azsbtools-powershell-module/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 16 January 2019
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$VMName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Remove-AzureRMVMBackup - $VMName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzureRMSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {
        
        # List backup containers because ASM VM backups may not show in which Vault their container is located
        $BackupContainerList = foreach ($RSVault in Get-AzureRmRecoveryServicesVault) {
            Get-AzureRmRecoveryServicesBackupContainer -ContainerType AzureVM -Status Registered -VaultId $RSVault.ID | select FriendlyName,
                @{n='VaultId'  ;e={$RSVault.ID}},
                @{n='Vault'    ;e={Split-Path $RSVault.ID -Leaf}},
                @{n='Container';e={$_.FriendlyName}}
        }
        Write-Verbose 'Identified list of backup vaults and containers:'
        Write-Verbose ($BackupContainerList | FT Vault,Container -a | Out-String).Trim()

        if ($FoundContainer = $BackupContainerList | where FriendlyName -EQ $VMName) {
            $BackupContainer = Get-AzureRmRecoveryServicesBackupContainer -ContainerType AzureVM -Status Registered -VaultId $FoundContainer.VaultId -FriendlyName $FoundContainer.FriendlyName
            Write-Log 'Identified VM Backup Container',$BackupContainer.FriendlyName,'for VM',$VMName Green,Cyan,Green,Cyan $LogFile
            Write-Log ($BackupContainer | FL | Out-String).Trim() Cyan
            if ($BackupContainer.Count -gt 1) {
                Write-Log 'Remove-AzureRMVMBackup: Found more than 1 backup container for VM',$VMName,'skipping..' Magenta,Yellow,Magenta $LogFile
            } else {
                if ($BackupItem = Get-AzureRmRecoveryServicesBackupItem -Container $BackupContainer -WorkloadType AzureVM -VaultId $FoundContainer.VaultId) {
                    Write-Log ' Identified',($BackupItem.Name.Split(';')[2]),'VM Backup Item' Green,Cyan,Green 
                    Write-Log ($BackupItem  | FL | Out-String).Trim() Cyan
                    if ($BackupItem.Count -gt 1) {
                        Write-Log 'Remove-AzureRMVMBackup: Found more than 1 Backup item for VM',$VMName,'skipping..' Magenta,Yellow,Magenta $LogFile
                    } else {
                        Write-Log ' Disabling backup for VM',$VMName,'and deleting existing backups' Green,Cyan,Green $LogFile -NoNewLine
                        $Result = Disable-AzureRmRecoveryServicesBackupProtection -Item $BackupItem -RemoveRecoveryPoints -Force -VaultId $FoundContainer.VaultId
                        Write-Log 'done' DarkYellow $LogFile
                    }
                } else {
                    Write-Log ' No Backup Item found for VM',$VMName Green,Yellow
                }
            }
        } else {
            Write-Log ' No Backup Container found for VM',$VMName Green,Yellow
        }            
        
    }

    End { }
}

function Get-AzureBlob {
<#
 .SYNOPSIS
  Function to return an Azure blob object if it exists based on a blob URL
 
 .DESCRIPTION
  Function to return an Azure blob object if it exists
  Function returns False if blob does not exist in the given URL
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER URL
  This is the Blob URL like https://paklfjlkdjalsdkfjalk5.blob.core.windows.net/vhds/AdfsdfsdI-2015-09-14.vhd
  This can be obtained from the Get-AzureVM and Get-AzureRMVM cmdlets
  For example, ASM VM OS disk: $VM.vm.OSVirtualHardDisk.MediaLink.AbsoluteUri
                ASM VM data disk URLs: $VM.VM.DataVirtualHardDisks.medialink.AbsoluteUri
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    Get-AzureBlob -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -URL 'https://paklfjlkdjalsdkfjalk5.blob.core.windows.net/vhds/AdfsdfsdI-2015-09-14.vhd'
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 17 January 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String[]]$URL,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-AzureBlob - $URL - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzureRMSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {
        foreach ($URI in $URL) {
            $Go = $true
            try {
                $StorageAccountName = $URL.Split('/')[2].Split('.')[0]
            } catch {
                $Go = $false
                Write-Log 'Unable to get Storage Account name from provided URL',$URI Magenta,Yellow $LogFile
                Write-Log 'Expecting URL in the format','https://paklfjlkdjalsdkfjalk5.blob.core.windows.net/vhds/AdfsdfsdI-2015-09-14.vhd' Cyan,Yellow $LogFile
            }            
            try {
                $ContainerName = $URL.Split('/')[3]
            } catch {
                $Go = $false
                Write-Log 'Unable to get Container name from provided URL',$URI Magenta,Yellow $LogFile
                Write-Log 'Expecting URL in the format','https://paklfjlkdjalsdkfjalk5.blob.core.windows.net/vhds/AdfsdfsdI-2015-09-14.vhd' Cyan,Yellow $LogFile
            }            
            try {
                $VHDName = $URL.Split('/')[4]
            } catch {
                $Go = $false
                Write-Log 'Unable to get VHD/Blob name from provided URL',$URI Magenta,Yellow $LogFile
                Write-Log 'Expecting URL in the format','https://paklfjlkdjalsdkfjalk5.blob.core.windows.net/vhds/AdfsdfsdI-2015-09-14.vhd' Cyan,Yellow $LogFile
            }
            if ($Go) {
                $Context = (Get-AzureStorageAccount -StorageAccountName $StorageAccountName).Context
                try {
                    Get-AzureStorageBlob -Container $ContainerName -Blob $VHDName -Context $Context -EA 1 
                } catch {
                    $false
                }
            }          
        }    
    }

    End { }
}

function Clone-AzureRMUnmanagedDisk {

# Requires -Modules AzureRM
# Requires -Version 5

<#
 .SYNOPSIS
  Function to copy Azure ARM VM unmanaged disk from one storage account to another
 
 .DESCRIPTION
  Function to copy Azure ARM VM unmanaged disk from one storage account to another or
  from one container to another in the same storage account
  This can be useful in migrating VMs from managed to unmanaged disks,
  VM backup that does not depend on VM OS or bakup agent in the VM,
  VM cloning scenarios,
  VM migration from one subscription to another,
  VM migration from one Azure region to another,
  VM migration from one storage account type to another (ASM/ARM, Standard/Premium)
  especially where not supported by the Microsoft provided tools
  Disk copy is validated by comparing the count of used bytes of the source disk snapshot and the destination disk
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER StorageAccountName
  The Azure storage account name such as 'storfluxwidget3vm'
 
 .PARAMETER DiskName
  This is the source disk name
   
 .PARAMETER SourceStorageAccount
  This is the name of the source Storage Account
   
 .PARAMETER SourceContainer
  This is the name of the source Container
   
 .PARAMETER DestinationStorageAccount
  This is the name of the destination Storage Account
   
 .PARAMETER DestinationContainer
  This is the name of the destination container
  If not present, the function will create it
   
 .PARAMETER OverWriteDest
  This is an optional parameter set to False by default
  When set to True, it causes the function to over-write the destination disk/page blob if it exists
  If set to False, the function will not over-write desination disk/page blob if it already exists
 
 .PARAMETER DeleteSource
  This is an optional parameter set to False by default
  When set to True, it causes the function to delete the source disk after a validated copy
  If set to False, the source disk must will be left behind to be deleted manually thereafter
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    $ParameterSet = @{
        LoginName = 'sam@mydomain.com'
        SubscriptionName = 'my subscription name'
        DiskName = 'mydiskname.vhd'
        SourceStorageAccount = 'mysourcesa'
        SourceContainer = 'vhds'
        DestinationStorageAccount = 'mydestsa'
    }
    Clone-AzureRMUnmanagedDisk @ParameterSet
    This will copy the provided disk and not delete the source
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 13 February 2019
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$DiskName,                                      # Example 'Widget1VM-20181218-123351'
        [Parameter(Mandatory=$true)][String]$SourceStorageAccount,                          # Example 'storfluxwidget1vm'
        [Parameter(Mandatory=$true)][String]$SourceContainer,                               # Example 'vhds'
        [Parameter(Mandatory=$true)][String]$DestinationStorageAccount,                     # Example 'storfluxwidget2vm'
        [Parameter(Mandatory=$false)][String]$DestinationContainer = $SourceContainer,      
        [Parameter(Mandatory=$false)][Switch]$DeleteSource = $false,
        [Parameter(Mandatory=$false)][Switch]$OverWriteDest = $false,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Clone-AzureRMUnmanagedDisk - $DiskName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        # Validate Azure access, Input
        if (-not ($Login = Login-AzureRMSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }

        Try {
            $StorageAccountList = Get-AzureRmStorageAccount -EA 1 
            if (-not $StorageAccountList) { Write-Log 'No storage accounts found in subscription',$SubscriptionName Magenta,Yellow $LogFile; Break }
        } catch {
            Write-Log 'No storage accounts found in subscription',$SubscriptionName Magenta,Yellow $LogFile; Break
        }

        @($SourceStorageAccount,$DestinationStorageAccount) | foreach {
            if (-not ($StorageAccountList | where StorageAccountName -EQ $_)) {
                Write-Log 'Storage Account',$_,'not found in subscription', $SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
                Break
            } else {
                Write-Log 'Validated Storage Account',$_,'in subscription', $SubscriptionName Green,Cyan,Green,Cyan $LogFile
            }
        }

        $StorageAccount = Get-AzureRmStorageAccount | where StorageAccountName -EQ $DestinationStorageAccount
        $StorageKey     = (Get-AzureRmStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value
        $DestContext    = New-AzureStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey
        $ContainerList  = Get-AzureRmStorageContainer -ResourceGroupName $StorageAccount.ResourceGroupName -StorageAccountName $StorageAccount.StorageAccountName
        if ($DestinationContainer -in $ContainerList.Name) {
            Write-Log 'Validated destination container',$DestinationContainer,'in destination Storage Account',$DestinationStorageAccount Green,Cyan,Green,Cyan $LogFile
        } else {
            Write-Log 'Destination container',$DestinationContainer,'not found in destination Storage Account',$DestinationStorageAccount,'creating..' Cyan,Yellow,Cyan,Yellow,Cyan -NoNewLine  $LogFile
            New-AzureRmStorageContainer -ResourceGroupName $StorageAccount.ResourceGroupName -StorageAccountName $StorageAccount.StorageAccountName -Name $DestinationContainer | Out-Null
            Write-Log 'done' Green $LogFile
        } 
        
        $StorageAccount = Get-AzureRmStorageAccount | where StorageAccountName -EQ $SourceStorageAccount
        $StorageKey     = (Get-AzureRmStorageAccountKey -ResourceGroupName $StorageAccount.ResourceGroupName -Name $StorageAccount.StorageAccountName)[0].Value
        $SrcContext     = New-AzureStorageContext -StorageAccountName $StorageAccount.StorageAccountName -StorageAccountKey $StorageKey
        $ContainerList  = Get-AzureRmStorageContainer -ResourceGroupName $StorageAccount.ResourceGroupName -StorageAccountName $StorageAccount.StorageAccountName
        if ($SourceContainer -in $ContainerList.Name) {
            Write-Log 'Validated source container',$SourceContainer,'in source Storage Account',$SourceStorageAccount Green,Cyan,Green,Cyan $LogFile
        } else {
            Write-Log 'Source container',$SourceContainer,'not found in source Storage Account',$SourceStorageAccount Magenta,Yellow,Magenta,Yellow $LogFile
            break
        } 

        $DiskName = $DiskName.ToLower()
# if (-not ($DiskName.EndsWith('.vhd'))) { $DiskName = "$DiskName.vhd" }
        if ($PageBlob = Get-AzureStorageBlob -Container $SourceContainer -Context $SrcContext | 
            where { $_.Name -EQ $DiskName -and -not $_.ICloudBlob.IsSnapshot} ) {
            Write-Log 'Validated unmanaged disk (page blob)',$DiskName,'in container',$SourceContainer Green,Cyan,Green,Cyan
        } else {
            Write-Log 'Unmanaged disk (page blob)',$DiskName,'not found in container',$SourceContainer Magenta,Yellow,Magenta,Yellow
            break
        }

    }

    Process {

        #region Snapshot, copy source disk to destination, monitor and wait for copy
        $Go = $true        
        if ($DestBlob = Get-AzureStorageBlob -Container $DestinationContainer -Context $DestContext | where Name -EQ $DiskName) {
            Write-Log 'Page blob already exists in the destination',"$DestinationStorageAccount/$DestinationContainer/$DiskName" Green,Cyan $LogFile 
            if ($OverWriteDest) {
                Write-Log ' and ''OverWriteDest'' switch is set to',$OverWriteDest,'- over-writing destination page blob..' Green,Cyan,Green -NoNewLine $LogFile
            } else {
               Write-Log ' and ''OverWriteDest'' switch is set to',$OverWriteDest,'- aborting..' Yellow,Magenta,Yellow $LogFile
               $Go = $false
            }
        } 

        if ($Go) {
            Write-Log 'Creating a snapshot of the source disk/page blob',"$SourceStorageAccount/$SourceContainer/$DiskName" Green,Cyan -NoNewLine $LogFile
            $Snapshot = $PageBlob.ICloudBlob.CreateSnapshot()
            $SnapshotBlob = Get-AzureStorageBlob -Container $SourceContainer -Context $SrcContext | 
                where SnapshotTime -EQ $Snapshot.SnapshotTime
            $SourceBlobSizeInBytes = Get-BlobBytes -Blob $SnapshotBlob -IsPremiumAccount ($SourceStorageAccount.Sku.Tier -eq 'Premium')
            if ($Snapshot.Name -eq $PageBlob.Name) {
                Write-Log 'done, time stamp',$Snapshot.SnapshotTime DarkYellow,Cyan $LogFile
                Write-Log 'Copying snapshot of source disk/page blob to destination',"$DestinationStorageAccount/$DestinationContainer/$DiskName" Green,Cyan $LogFile
                Write-Log ' Allocated size',"$([Math]::Round($SnapshotBlob.Length/1GB,1))GB ($('{0:n0}' -f $SnapshotBlob.Length) bytes)",'used size',"$([Math]::Round($SourceBlobSizeInBytes/1GB,1))GB ($('{0:n0}' -f $SourceBlobSizeInBytes) bytes)" Green,Cyan,Green,Cyan $LogFile
                $Duration = Measure-Command {
                    Start-AzureStorageBlobCopy -CloudBlob $SnapshotBlob.ICloudBlob -Context $SrcContext -DestContainer $DestinationContainer -DestContext $DestContext -Force | Out-Null
                    $DestBlob = Get-AzureStorageBlob -Container $DestinationContainer -Context $DestContext | where Name -EQ $DiskName
                    $Result = Get-AzureStorageBlobCopyState -CloudBlob $DestBlob.ICloudBlob -Context $DestContext -WaitForComplete 
                }
                if ($Result.Status -eq 'Failed') {
                    Write-Log 'Failed:' Magenta $LogFile
                    Write-Log " $($Result.StatusDescription)" Yellow $LogFile
                } else {
                    Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) hh:mm:ss" Green,Cyan $LogFile
                }    
                $Snapshot.Delete()        
            } else {
                Write-Log 'failed' Magenta $LogFile
            } 

            #region Validate copy success
            $DestBlob = Get-AzureStorageBlob -Container $DestinationContainer -Context $DestContext | where Name -EQ $DiskName
            $DestBlobSizeInBytes = Get-BlobBytes -Blob $DestBlob -IsPremiumAccount ($DestinationStorageAccount.Sku.Tier -eq 'Premium')
            if ($SourceBlobSizeInBytes -eq $DestBlobSizeInBytes) {
                Write-Log 'Validated successful disk/page blob copy' Green
            } else {
                Write-Log 'Destination blob/disk size is',$DestBlobSizeInBytes,'bytes which is different from the source blob/disk size of',$SourceBlobSizeInBytes,'bytes' Magenta,Yellow,Magenta,Yellow,Magenta
                break
            }
            #endregion

            #region Delete source
            if ($DeleteSource) {
                Write-Log 'Deleting source disk/page blob',"$SourceStorageAccount/$SourceContainer/$DiskName" Green,Cyan -NoNewLine $LogFile
                $PageBlob.ICloudBlob.Delete()
                if ($PageBlob = Get-AzureStorageBlob -Container $SourceContainer -Context $SrcContext | 
                    where { $_.Name -EQ $DiskName -and -not $_.ICloudBlob.IsSnapshot} ) {
                    Write-Log 'failed to delete source disk/page blob' Magenta $LogFile
                } else {
                    Write-Log 'done' Green $LogFile
                }
            }
            #endregion

        }  
        #endregion

    }

    End {  }
}

#endregion

#region Graph API

function Get-AzureToken {

<#
 .SYNOPSIS
  Function to get a Graph API token

 .DESCRIPTION
  Function to get a Graph API token

 .PARAMETER TenantId
  Your Azure Tenant Id. This is a Guid such as ef9d6c71-af43-4fc9-9364-08e24d4fd02e

 .PARAMETER AppId
  App Id is similar to the Name part of a credential. This is a Guid such as 84d47634-9322-4b89-8376-bf2e1d83b130

 .PARAMETER RefreshSecret
  Use this switch to interactively enter a new secret other than the cached one.

 .PARAMETER APIVersion
  API version such as v1.0 or beta.
  This defaults to v1.0
  https://docs.microsoft.com/en-us/graph/use-the-api#version

 .EXAMPLE
  $Token = Get-GraphAPIToken -TenantId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -AppId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'

 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/graph/use-the-api

 .NOTES
  Function by Sam Boutros
  v0.1 - 17 September 2019
  v0.2 - 20 October 2021
     Added RefreshSecret switch and removed AppName parameter, renamed to Get-AzureToken.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$TenantId,
        [Parameter(Mandatory=$true)][String]$AppId,
        [Parameter(Mandatory=$false)][Switch]$RefreshSecret,
        [Parameter(Mandatory=$false)][ValidateSet('v1.0','beta')][String]$APIVersion = 'v1.0'
    )

    Begin { }

    Process {
        if ($RefreshSecret) {
            $Secret = Get-SBCredential -UserName ($AppId -replace '-') -Refresh
        } else {
            $Secret = Get-SBCredential -UserName ($AppId -replace '-')
        } 
        $APIBaseUri    = "https://graph.microsoft.com/$APIVersion"
        $secretEncoded = [System.Uri]::EscapeDataString($Secret.GetNetworkCredential().Password)
        $ParameterList = @{
            Uri         = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
            Method      = 'Post'
            ContentType = 'application/x-www-form-urlencoded'
            Body        = "client_id=$AppId&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret=$secretEncoded&grant_type=client_credentials"
        }
        Write-Verbose 'Details:'
        Write-Verbose ($ParameterList | Out-String).Trim()
    }

    End {
        (Invoke-RestMethod @ParameterList).access_token
    }
}

function Get-AzureTokenDetails {
<#
 .SYNOPSIS
 Function to decode an Azure Graph API token.

 .DESCRIPTION
 Function to decode an Azure Graph API token.
 See https://JWT.MS

 .PARAMETER Token
 Version 1.0 or 2.0 Azure JWT token. Can be obtained via the Get-AzureToken function.

 .PARAMETER ShowAll
 This optional switch will show all token claims. Otherwise, the following less useful calims will not be shown:
 'x5t','rh','uti','alg','typ','nonce','xms_tcdt','aio'

 .EXAMPLE
 $Token = Get-GraphAPIToken -TenantId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -AppId 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
 $TokenDetails = Get-AzureTokenDetails -Token $Token

 .OUTPUTS
 Console output and PowerShell objects similar to:
    Part Name Value Description
    ---- ---- ----- -----------
    Header kid l3sQ-50cCH4xBVZLHTGwnSR7680 The thumbprint of the public key that was used to sign the token.
    Payload appid aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee The application ID of the client using the token. (in legacy 1.0 tokens only)
    Payload appidacr 1 Indicates how the client was authenticated. 0 ==> Public client, 1 ==> Client secret was used, 2 ==> Client certificate was used for. (in legacy 1.0 tokens only)
    Payload app_displayname TokenMan User or Service Principal display name
    Payload aud https://graph.microsoft.com Audience/Resource. This is the intended recipient of the token.
    Payload exp 10/20/2021 7:24:41 PM The time the token expires.
    Payload iat 10/20/2021 6:19:41 PM The time at which the token was issued.
    Payload idp https://sts.windows.net/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/ The identity provider that authenticated the subject of the token. If different than 'iss', this indicates that the user account is not in the same tenant as the is...
    Payload idtyp app Token type. 'app' ==> app-only token, otherwise ==> app+user token.
    Payload iss https://sts.windows.net/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/ Security token service (STS) that constructs and returns the token. Typical value: https://sts.windows.net/<Tenant_Id>/ where Tenant_Id identifies the directory in ...
    Payload nbf 10/20/2021 6:19:41 PM The time after which the token is considered valid.
    Payload oid aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee Object Id of the user.
    Payload sub aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee Subject. The principal about which the token asserts information, such as the user of an application. Typically, the object ID of the Azure AD user.
    Payload tenant_region_scope NA Region of the resource tenant. 'NA' = North America.
    Payload tid aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee Tenant Id of the user. '9188040d-6c67-4c5b-b112-36a304b66dad' is the Microsoft tenant Id used for personal Microsoft accounts.
    Payload ver 1.0 Token version.
    Payload wids aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee (Unknown!!??) List of Azure AD role Template Ids - see https://docs.microsoft.com/en-us/azure/active-directory/roles/permissions-reference#all-roles

 .LINK
 https://superwidgets.wordpress.com/category/powershell/
 https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
 https://docs.microsoft.com/en-us/dotnet/api/system.identitymodel.tokens.jwt?view=azure-dotnet
 https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-optional-claims
 https://JWT.MS

 .NOTES
 Function by Sam Boutros
 v0.1 - 20 October 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][Alias('AzureToken')][String]$Token,
        [Parameter(Mandatory=$False)][Switch]$ShowAll
    )

    Begin {     
        if (-not $Token.Contains('.')) { Write-Log 'Invalid Token','no dots detected' Magenta,Yellow; break }
        $TokenParts = $Token -Split '\.'
        if ($TokenParts.Count -ne 3) { Write-Log 'Invalid Token','incorrect number of dots detected' Magenta,Yellow; break }
        $HideMeList = @('x5t','rh','uti','alg','typ','nonce','xms_tcdt','aio')
    }

    Process {    
        
        #region Decode Token

        $Header = $TokenParts[0] -replace '-','+' -replace '_', '/'
        Switch ($Header.Length % 4) { 2 { $Header += '==' }; 3 { $Header += '=' } }
        $DecodedHeader = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Header)) | ConvertFrom-Json 

        $Payload = $TokenParts[1] -replace '-','+' -replace '_', '/'
        Switch ($Payload.Length % 4) { 2 { $Payload += '==' }; 3 { $Payload += '=' } }
        $DecodedPayload = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload)) | ConvertFrom-Json         

        $Signature = $TokenParts[2] -replace '-','+' -replace '_', '/'
        Switch ($Signature.Length % 4) { 2 { $Signature += '==' }; 3 { $Signature += '=' } }

        #endregion

        #region Compile Output object

        $DecodedToken = @()
        $DecodedToken += ($DecodedHeader | Get-Member -MemberType NoteProperty).Name | foreach {
            if ($ShowAll -or $_ -notin $HideMeList) {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    Part        = 'Header'
                    Name        = $_
                    Value       = $DecodedHeader.$_
                    Description = ($AzureTokenClaimDescription | where Name -EQ $_).Description
                })
            } 
        }
        $DecodedToken += ($DecodedPayload | Get-Member -MemberType NoteProperty).Name | foreach {
            if ($ShowAll -or $_ -notin $HideMeList) {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    Part        = 'Payload'
                    Name        = $_
                    Value       = $(
                        if ($_ -in @('iat','exp','nbf')) { # Convert Epoch time to Datetime
                            (([System.DateTimeOffset]::FromUnixTimeSeconds($DecodedPayload.$_)).DateTime).ToString()
                        } elseif ($_ -eq 'wids') { # Expand wids AAD roles
                            $RoleList =  foreach ($RoleId in $DecodedPayload.$_) { if ($FoundRole = $AzureADRoleNameList | where Id -eq $RoleId) { "$($FoundRole.DisplayName) ($($FoundRole.Id))" } }
                            if ($RoleList) {
                                $RoleList -join ', '
                            } else {
                                "$($DecodedPayload.$_) (Unknown!!??)"
                            }
                        } else {
                            $DecodedPayload.$_
                        }
                    )
                    Description = ($AzureTokenClaimDescription | where Name -EQ $_).Description
                })
            }
        }
        if ($ShowAll) {
            $DecodedToken += New-Object -TypeName PSObject -Property ([Ordered]@{
                Part        = 'Signature'
                Name        = $null
                Value       = $Signature 
                Description = 'Signature part of the token.'
            })
        } 
        Write-Log ($DecodedToken | FL * | Out-String).Trim() Cyan

        #endregion

    }

    End { $DecodedToken }

}

function Report-AzureServicePrincipals {
<#
 .SYNOPSIS
  Function to report on Azure Service Principals in a given Azure tenant.
 
 .DESCRIPTION
  Function to to report on Azure Service Principals in a given Azure tenant.
  This function requires the following PowerShell modules:
    'AzureADPreview'
    'Microsoft.Graph.Authentication'
    'Microsoft.Graph.Applications'
 
 .PARAMETER CertWarnDays
  Number of days to consider a certificate needs warning for renewal.
 
 .PARAMETER SecretWarnDays
  Number of days to consider a secret needs warning for renewal.
 
 .PARAMETER AppDisplayName
  One or more Apps.
  If not provided, this function will report on all the Apps in the tenant.
 
 .PARAMETER ReportPath
  Folder Path where this function will write its CSV report.
 
 .PARAMETER LogFile
  Path to log file where this function will write its console output.
 
 .EXAMPLE
  Report-AzureServicePrincipals
 
 .OUTPUTS
  Each App will have a record similar to:
    DisplayName : TemasAutomation1
    AppId : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    DateCreated : 8/30/2019 1:40:24 PM
    AllowPublicClientFlows : False
    SecretAboutToExpire :
    SecretExpired : 'Sec1' expired 09/17/2020 14:02:05
    SecretAgeTooLong : ==> Any secret with life span longer than 1 year will show up here - separated by ' ##### '
    CertAboutToExpire :
    CertExpired :
    CertAgeTooLong : ==> Any certificate with life span longer than 2 years will show up here - separated by ' ##### '
    MultiTenantApp : False
    AppPublisher : mytenant.com
    AppDescription :
    AppNotes :
    Tags :
    AllowApp2IssueAccessTokens_ImplicitFlows : False
    IdTokenAdditionalClaims : None
    AccessTokenAdditionalClaims : None
    Saml2TokenAdditionalClaims : None
    AdditionalTokenProperties : None
    SecretCredentials : Sec1 (KeyId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) (From 17 September 2019 to 17 September 2020 - 1:0 Years:Days)
    CertificateCredentials : None
    APIPermissions : Microsoft Graph:e1fe6dd8-ba31-4d61-89e7-88639da4683d:Delegated, 19dbc75e-c2e2-444c-a770-ec69d8559fc7:Application, 62a82d76-70ea-41e2-9197-370581804d09:Application, df021288-bdef-4463-88db-98f22de89214:Application
    Owners : FisrtName LastName (myemail@mydomain.com)
    ExposedAPIs : user_impersonation (User) ==> 'User' means both admins and users can consent. 'Admin' means admins only can consent.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 30 June 2022 - The API permissions will need work to show permission name like users.read.all instead of its GUID.
  v0.2 - 5 July 2022 - Update to present API permissions like: Microsoft Graph:User.Read:Delegated
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Int16]$CertWarnDays = 90,
        [Parameter(Mandatory=$false)][Int16]$SecretWarnDays = 90,
        [Parameter(Mandatory=$false)][String[]]$AppDisplayName,
        [Parameter(Mandatory=$false)][String]$ReportPath = '.\',
        [Parameter(Mandatory=$false)][String]$LogFile    = ".\Report-AzureServicePrincipals-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )  
 
    Begin {  
        
        #region Validate required modules
        $RequiredModuleList = @('AzureADPreview','Microsoft.Graph.Authentication','Microsoft.Graph.Applications')
        $FoundList = Get-Module $RequiredModuleList -ListAvailable
        $MissingList = $RequiredModuleList | foreach { if ($_ -notin $FoundList.Name) { $_ } }
        if ($MissingList) {
            Write-Log 'Report-AzureServicePrincipals Error: missing required modules:',($MissingList -join ', ') Magenta,Yellow $LogFile
            Write-Log 'Please install the required modules from the Microsoft PowerShell Gallery (https://www.powershellgallery.com/) as in' Yellow $LogFile
            Write-Log " Install-Module $($MissingList -join ', ')" Cyan $LogFile  
            break        
        }
        #endregion
        
        #region Connect-MgGraph
        
        if ($MgContext = Get-MgContext) {
            Write-Log 'Connected to the',$MgContext.TenantId,'Azure tenant as',$MgContext.Account,"($($MgContext.AppName))" Green,Cyan,Green,Cyan,Green $LogFile
        } else {
            try {
                Connect-MgGraph -EA 1 | Out-Null
                $MgContext = Get-MgContext
                Write-Log 'Successfully connected to the',$MgContext.TenantId,'Azure tenant as',$MgContext.Account,"($($MgContext.AppName))" Green,Cyan,Green,Cyan,Green $LogFile
            } catch {
                Write-Log 'Report-AzureServicePrincipals Error: failed to connect to Azure' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }
        } 

        try {
            $SPLIst = Get-MgApplication -All -Property * -EA 1 
        } catch {
            Connect-Graph -Scopes 'User.Read','Application.Read.All' | Out-Null
        } 

        #endregion

        #region Connect-AzureAD
        
        try { 
            $AADDomainList = Get-AzureADDomain -EA 1
            $TenantName = ($AADDomainList | where { $_.IsDefault }).Name
            Write-Log 'Connected to the',$TenantName,'Azure tenant','(AzureADPreview)' Green,Cyan,Green,Cyan $LogFile
        } catch {
            try {
                $Connection = Connect-AzureAD -EA 1
                $TenantName = $Connection.TenantDomain
                Write-Log 'Successfully connected to the',$TenantName,'Azure tenant as',$Connection.Account,'(AzureADPreview)' Green,Cyan,Green,Cyan,Green $LogFile
            } catch {
                Write-Log 'Report-AzureServicePrincipals Error: failed to connect to Azure' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }
        } 

        #endregion

        ' '
 
    }

    Process {    
 
        #region Read Service Principal/App Registration details, compile object array
 
        if ($AppDisplayName) {
            Write-Log 'Reporting on',($AppDisplayName -join ', '),'Service Principal(s) in the',$TenantName,'Azure tenant' Green,Cyan,Green,Cyan,Green $LogFile
        } else {
            try {
                $SPLIst = Get-MgApplication -All -Property * -EA 1 
            } catch {
                Write-Log 'Report-AzureServicePrincipals Error: Get-MgApplication error:' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            } 
            $AppDisplayName = $SPLIst.DisplayName | select -Unique | sort
            Write-Log 'Reporting on',$AppDisplayName.Count,'Service Principal(s) in the',$TenantName,'Azure tenant' Green,Cyan,Green,Cyan,Green $LogFile
        }
 
        $APISPList = Get-AzureADServicePrincipal -All:$true
        $myOutput = foreach ($DisplayName in $AppDisplayName) {
            $AppDetailList = try {
                Get-MgApplication -Filter "DisplayName eq '$DisplayName'" -Property * -EA 1
            } catch {
                Write-Log 'Report-AzureServicePrincipals Error:' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }
            if ($AppDetailList) {
                # $AzureADOAuth2PermissionGrantList = Get-AzureADOAuth2PermissionGrant -All $true # Not sure how to match to a specific App
                foreach ($AppDetail in $AppDetailList) {
                    Write-Log 'Identified',"$($AppDetail.DisplayName) (AppId: $($AppDetail.AppId))" Green,Cyan $LogFile
                    $AzureADApplication =  Get-AzureADApplication -Filter "DisplayName eq '$DisplayName'"
                    $ExpiredPwd = $WarnPwd = $PwdList = $PwdAgeTooLong = $ExpiredCert = $WarnCert = $CertList = $CertAgeTooLong = @()
                    if ($AppDetail.PasswordCredentials) {
                        foreach ($PwdCred in $AppDetail.PasswordCredentials) {
                            $Duration = New-TimeSpan -Start (Get-Date) -End $PwdCred.EndDateTime
                            if ($Duration.Days -le $SecretWarnDays -and $Duration.Days -gt 0) { $WarnPwd += "'$(if ($PwdCred.DisplayName) {$PwdCred.DisplayName} else {$PwdCred.KeyId})' expires $($PwdCred.EndDateTime)" }
                            if ($Duration.Days -le 0) { $ExpiredPwd += "'$(if ($PwdCred.DisplayName) {$PwdCred.DisplayName} else {$PwdCred.KeyId})' expired $($PwdCred.EndDateTime)" }
                            $Duration = New-TimeSpan -Start $PwdCred.StartDateTime -End $PwdCred.EndDateTime
                            $Age = "$([Math]::Floor(($Duration.Days/365))):$($Duration.Days % 365) Years:Days"
                            If ($Duration.Days -ge 367) { $PwdAgeTooLong += "'$(if ($PwdCred.DisplayName) {$PwdCred.DisplayName} else {$PwdCred.KeyId})' age is ($Age) > 1 year"}
                            $PwdList += "$($PwdCred.DisplayName) (KeyId: $($PwdCred.KeyId)) (From $(Get-Date($PwdCred.StartDateTime) -Format 'dd MMMM yyyy') to $(Get-Date($PwdCred.EndDateTime) -Format 'dd MMMM yyyy') - $Age)"
                        }
                    }
                    if ($AppDetail.KeyCredentials) {
                        foreach ($CertCred in $AppDetail.KeyCredentials) {
                            $Duration = New-TimeSpan -Start (Get-Date) -End $CertCred.EndDateTime
                            if ($Duration.Days -le $CertWarnDays -and $Duration.Days -gt 0) { $WarnCert += "'$(if ($CertCred.DisplayName) {$CertCred.DisplayName} else {$CertCred.KeyId})' expires $($CertCred.EndDateTime)" }
                            if ($Duration.Days -le 0) { $ExpiredCert += "'$(if ($CertCred.DisplayName) {$CertCred.DisplayName} else {$CertCred.KeyId})' expired $($CertCred.EndDateTime)" }
                            $Duration = New-TimeSpan -Start $CertCred.StartDateTime -End $CertCred.EndDateTime
                            $Age = "$([Math]::Floor(($Duration.Days/365))):$($Duration.Days % 365) Years:Days"
                            If ($Duration.Days -ge 733) { $CertAgeTooLong += "'$(if ($CertCred.DisplayName) {$CertCred.DisplayName} else {$CertCred.KeyId})' age is ($Age) > 2 years"}
                            $CertList += "$($CertCred.DisplayName) (KeyId: $($CertCred.KeyId)) (From $(Get-Date($CertCred.StartDateTime) -Format 'dd MMMM yyyy') to $(Get-Date($CertCred.EndDateTime) -Format 'dd MMMM yyyy') - $Age)"
                        }
                    }
 
                    New-Object -TypeName PSObject -Property([Ordered]@{
                        DisplayName            = $AppDetail.DisplayName
                        AppId                  = $AppDetail.AppId
                        DateCreated            = $AppDetail.CreatedDateTime
                        AllowPublicClientFlows = $(if ($AppDetail.IsFallbackPublicClient) {$AppDetail.IsFallbackPublicClient} else {$false})
                        SecretAboutToExpire    = $WarnPwd -join ' ##### '
                        SecretExpired          = $ExpiredPwd -join ' ##### '
                        SecretAgeTooLong       = $PwdAgeTooLong -join ' ##### '
                        CertAboutToExpire      = $WarnCert -join ' ##### '
                        CertExpired            = $ExpiredCert -join ' ##### '
                        CertAgeTooLong         = $CertAgeTooLong -join ' ##### '
                        MultiTenantApp         = $(switch ($AppDetail.SignInAudience) {'AzureADMyOrg'{$false};'AzureADMultipleOrgs'{$true};'AzureADandPersonalMicrosoftAccount'{$true};'Default'{$AppDetail.SignInAudience}})
                        AppPublisher           = $AppDetail.PublisherDomain
                        AppDescription         = $AppDetail.Description
                        AppNotes               = $AppDetail.Notes
                        Tags                   = $AppDetail.Tags -join ', '
 
                        AllowApp2IssueAccessTokens_ImplicitFlows = $AzureADApplication.Oauth2AllowImplicitFlow
                        # AllowApp2IssueIDTokens_ImplicitAndHybridFlows = '???' # Not seeing anything that tells me about ID Token being enabled
                        IdTokenAdditionalClaims     = $(if ($AppDetail.OptionalClaims.IdToken) {$AppDetail.OptionalClaims.IdToken.Name -join ', '} else {'None'})
                        AccessTokenAdditionalClaims = $(if ($AppDetail.OptionalClaims.AccessToken) {$AppDetail.OptionalClaims.AccessToken.Name -join ', '} else {'None'})
                        Saml2TokenAdditionalClaims  = $(if ($AppDetail.OptionalClaims.Saml2Token) {$AppDetail.OptionalClaims.Saml2Token.Name -join ', '} else {'None'})
                        AdditionalTokenProperties   = $(if ($AppDetail.OptionalClaims.AdditionalProperties.Count -gt 0) {$AppDetail.OptionalClaims.AdditionalProperties.Name -join ', '} else {'None'})
                        SecretCredentials           = $(if ($PwdList) {$PwdList -join ' ##### '} else {'None'})
                        CertificateCredentials      = $(if ($CertList) {$CertList -join ' ##### '} else {'None'}) 
                        APIPermissions = $(
                            if ($AppDetail.RequiredResourceAccess) {
                                $APIPermList = foreach ($Perm in $AppDetail.RequiredResourceAccess[0]) {
                                    $thisAPI = $APISPList | where AppId -EQ $Perm.ResourceAppId
                                    $Out1 = $Perm.ResourceAccess | select Id,@{n='Type';e={switch ($_.Type) {'Scope' {'Delegated'}; 'Role' {'Application'}; 'Default' {$_.Type} }}}
                                    $Out2 = $Out1 | foreach { 
                                        $Id = $_.Id
                                        if ($FoundPerm = $thisAPI.Oauth2Permissions | where Id -eq $Id) {$PermName = $FoundPerm.Value} else {$PermName = $Id}
                                        "$($PermName):$($_.Type)" 
                                    }
                                    "$($thisAPI.DisplayName):$($Out2 -join ', ')"
                                }
                                $APIPermList -join ' ##### '
                            } else {
                                'None'
                            }               
                        )
                        Owners = $(if ($FoundOwner = Get-AzureADApplicationOwner -ObjectId $AppDetail.Id) {"$($FoundOwner.DisplayName) ($($FoundOwner.UserPrincipalName))"} else {'None'} ) # "$($AppDetail.Owners) ???"
                        ExposedAPIs = $(
                            if ($AzureADApplication.Oauth2Permissions) {
                                $ExposedList = foreach ($Oauth2Permissions in $AzureADApplication.Oauth2Permissions) {
                                    $Oauth2Permissions.Value + ' (' + $Oauth2Permissions.Type + ')'
                                }
                                $ExposedList -join ' ##### '
                            } else {'None'}
                        )
                    })
                }
            } else {
                Write-Log 'Report-AzureServicePrincipals Error: The Service Prinsipal/App Registration',$DisplayName,'is not found in the',$TenantName,'Azure tenant' Magenta,Yellow,Magenta,Yellow,Magenta $LogFile
            }
        }
 
        #endregion
 
        #region Reporting
 
        $ReportFile = "$ReportPath\$($TenantName)_ServicePrincipal-AppRegistration_Report_$(Get-Date -f 'ddMMMMyyyy-HHmm').csv"
        $myOutput | Export-Csv -Path $ReportFile -NoTypeInformation
        Write-Log 'Report exported to',$ReportFile Green,Cyan $LogFile
 
        #endregion

    }

    End {  }
} 

function Remove-EnterpriseAppApiPermission {
<#
 .SYNOPSIS
  Function to remove individual Delegated API permissions of Enteprise Azure Apps.
  
.DESCRIPTION
  Function to remove individual Delegated API permissions of Enteprise Azure Apps.
  This function requires the PowerShell module 'MSAL.PS' which can be obtained from
  the PowerShell Gallery at https://www.powershellgallery.com/packages/MSAL.PS
  
.PARAMETER TenantId
  The tenant Id can be obtained from the Azure portal at
  https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview
  or via PowerShell as in: Get-AzureADTenantDetail
  
.PARAMETER ClientId
  The Client Id for Service Principal used to perform this operation.
  Such as 'My Local Azure App with (Cloud Application Administrator) Azure role'
  
.PARAMETER RefreshSecret
  This optional switch is relevant only when using a secret-secured Service Principal.
  The default behavior is to store the secret in encrypted format.
  This switch will force the function to request the secret interactively from the operator.
  
.PARAMETER TargetAppId
  ObjectId of the Enterprise App to be modified
  
.PARAMETER Thumbprint
  Thumbprint if using a certificate-secured service principal.
  This can be obtained by:
  (Get-ChildItem cert:\LocalMachine\My\ | Where Subject -eq 'My certificate subject here').Thumbprint
  
.PARAMETER ApiPermission
  The API permission to be removed, such as 'offline_access'
  
.PARAMETER LogFile
  Path to log file where this function will write its console output.
  
.EXAMPLE
  $ParameterSet = @{
    TenantId = 'abcd1234-abcd-1234-abcd-abcd1234abcd'
    ClientId = '1234ancd-abcd-1234-abcd-abcd1234abcd' # for 'My Local Azure App with (Cloud Application Administrator) Azure role'
    TargetAppId = 'ab1234cd-abcd-1234-abcd-abcd1234abcd' # ObjectId of the Enterprise App to be modified
    ApiPermission = 'offline_access' # To be removed
  }
  Remove-EnterpriseAppApiPermission @ParameterSet
  This example uses a secret-secured Service Principal
  
.EXAMPLE
  $ParameterSet = @{
    TenantId = 'abcd1234-abcd-1234-abcd-abcd1234abcd'
    ClientId = '1234ancd-abcd-1234-abcd-abcd1234abcd' # for 'My Local Azure App with (Cloud Application Administrator) Azure role'
    Thumbprint = (Get-ChildItem cert:\LocalMachine\My\ | Where Subject -eq 'My certificate subject here').Thumbprint
    TargetAppId = 'ab1234cd-abcd-1234-abcd-abcd1234abcd' # ObjectId of the Enterprise App to be modified
    ApiPermission = 'offline_access' # To be removed
  }
  Remove-EnterpriseAppApiPermission @ParameterSet
  This example uses a certificate-secured Service Principal
  
.LINK
  https://superwidgets.wordpress.com/category/powershell/
  
.NOTES
  Function by Sam Boutros
  v0.1 - 28 December 2022
  v0.2 - 23 January 2023
    Added feature to use Service Principal secured by certificate.
#>

 
    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$TenantId,       
        [Parameter(Mandatory=$true)][String]$ClientId,        # of the SP that this function will operate as, such as
                                                              # 'abcd1234-abcd-1234-abcd-abcd1234abcd' for 'My Local Azure App with (Cloud Application Administrator) Azure role'
        [Parameter(Mandatory=$false)][Switch]$RefreshSecret,  # When using a secret-secured SP
        [Parameter(Mandatory=$true)][String]$TargetAppId,     # ObjectId of the Enterprise App to be modified
        [Parameter(Mandatory=$false)][String]$Thumbprint,     # Example 90AAABCD1234ABCD1234ABCD1234ABCD123443D6 as obtained by
                                                              # $Thumbprint = (Get-ChildItem cert:\LocalMachine\My\ | Where Subject -eq 'My certificate subject here').Thumbprint
        [Parameter(Mandatory=$true)][String[]]$ApiPermission, # To be removed such as 'offline_access'
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Remove-EnterpriseAppApiPermission-$(Get-Date -Format 'ddMMMyyyy_HH-mm').log"
    ) 
 
    Begin { 
        
        #region Validate required modules
 
        $RequiredModuleList = @('MSAL.PS')
        $FoundList = Get-Module $RequiredModuleList -ListAvailable
        $MissingList = $RequiredModuleList | foreach { if ($_ -notin $FoundList.Name) { $_ } }
        if ($MissingList) {
            Write-Log 'Report-AzureServicePrincipals Error: missing required modules:',($MissingList -join ', ') Magenta,Yellow $LogFile
            Write-Log 'Please install the required modules from the Microsoft PowerShell Gallery (https://www.powershellgallery.com/) as in' Yellow $LogFile
            Write-Log " Install-Module $($MissingList -join ', ')" Cyan $LogFile 
            break       
        }
 
        #endregion
 
        if ($Thumbprint) {
            if ($Cert = Get-ChildItem -path "Cert:\*$Thumbprint" -Recurse) {
                 Write-Log 'Validated certificate',$Cert.Subject Green,Cyan $LogFile
            } else {
                Write-Log 'Error: no certificate found with the Thumbprint',$Thumbprint Magenta,Yellow $LogFile
                break
            }
            $Token = (Get-MsalToken -TenantId $TenantId -ClientId $ClientId -ClientCertificate $Cert).AccessToken
        } else {
            $ParamList = @{ TenantId = $TenantId; AppId = $TargetAppId }
            if ($RefreshSecret) { $ParamList += @{ RefreshSecret = $true } }
            $Token = Get-AzureToken @ParamList
        }
        
        if ($Token) {
            $Headers = @{
                Authorization  = $Token
                'Content-Type' = 'application/json'
            }
            $BaseUri = 'https://graph.microsoft.com/v1.0' 
            Write-Log 'Got access token starting with',($Token.Substring(0,32)) Green,Cyan $LogFile
        } else {
            Write-Log 'Error: unable to obtain access token' Yellow $LogFile
            break
        }
 
    }
 
    Process {   
 
        #region Get Target App Details

       try {
            $AppDetail = Invoke-RestMethod -Headers $Headers -Uri "$BaseUri/serviceprincipals/$TargetAppId" -Method Get -EA 1
            Write-Log 'Validated App:' Green $LogFile
            Write-Log ($AppDetail | FL displayName,appDisplayName,appId,appOwnerOrganizationId | Out-String).Trim() Cyan $LogFile
        } catch {
            Write-Log 'Remove-EnterpriseAppApiPermission Error:' Magenta $LogFile
            Write-Log 'Target App (id)',$TargetAppId,'not found' Magenta,Yellow,Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }

       #endregion

       #region Get App DELEGATED API permissions
 
        try {
            $Result1 = Invoke-RestMethod -Headers $Headers -Uri "$BaseUri/serviceprincipals/$TargetAppId/oauth2PermissionGrants" -Method Get -EA 1
            $AppPermissions = ($Result1.Value.Scope -split ' ').Trim().ToLower() | where { $_ } | sort
            Write-Log 'Current','DELEGATED','App permissions:' Green,Yellow,Green $LogFile
            $AppPermissions | foreach { Write-Log " $_" Cyan $LogFile }           
        } catch {
            Write-Log 'Remove-EnterpriseAppApiPermission Error:' Magenta $LogFile
            Write-Log 'Target App (id)',$TargetAppId,'not found' Magenta,Yellow,Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }

       #endregion
 
        #region Remove selected DELEGATED API permission(s)
 
        foreach ($Perm in $ApiPermission) {
            if ($Perm.ToLower() -in $AppPermissions) {
                $Body = @{ scope = ($Result1.value.scope -replace $Perm,'').Trim() }
                Write-Log 'Removing DELEGATED permission',$Perm Green,Cyan $LogFile -NoNewLine
                try {
                    $Result2 = Invoke-RestMethod -Headers $Headers -Uri "$BaseUri/oauth2PermissionGrants/$($Result1.value.id)" -Method Patch -Body ($Body | ConvertTo-Json) -EA 1
                    Write-Log 'done' DarkYellow $LogFile
                } catch {
                    Write-Log 'failed' Magenta $LogFile
                    Write-Log " $($_.Exception.Message)" Yellow $LogFile
                }
            } else {
                Write-Log ' Remove-EnterpriseAppApiPermission Error:' Magenta $LogFile
                Write-Log ' Permission',$Perm,'is not delegated to the',$TargetAppId,'App' Magenta,Yellow,Magenta,Yellow,Magenta $LogFile
            }
        }
 
        #endregion
 
    }
 
    End {  }
}

#endregion

#region O365

function Get-O365LicenseReference {
<#
 .SYNOPSIS
  Function to download the 'O365 license reference' CSV file if not present
 
 .DESCRIPTION
  Function to download the 'O365 license reference' CSV file if not present
  This function validates that the CSV file has the expected headers:
  'GUID','Product_Display_Name','Service_Plans_Included_Friendly_Names','Service_Plan_Id','Service_Plan_Name','String_Id'
 
 .PARAMETER URL
  URL to download the 'O365 license reference' CSV file from
  The default download page is 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product names and service plan identifiers for licensing.csv'
 
 .PARAMETER FilePath
  Existing local file path if any.
 
 .PARAMETER LogFile
  Path to log file where this function will log its console output if any.
 
 .EXAMPLE
  Get-O365LicenseReference
  This example downloads the O365 license reference CSV file and validates its headers
 
 .EXAMPLE
  Get-O365LicenseReference -FilePath .\O365LicenseReference.CSV
  This example validates the provides O365 license reference CSV file headers
 
 .OUTPUTS
  Full path of the 'O365 license reference' CSV file.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 March 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$URL = 'https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv',
        [Parameter(Mandatory=$false)][String]$FilePath,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-O365LicenseReference_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  }

    Process {      
        $OutputFullPath = if ($(try {Test-Path $FilePath} catch {})) { 
            (Get-Item $FilePath).FullName
        } else {
            $TempFile = New-TemporaryFile
            try {
                Invoke-WebRequest -Uri $URL -OutFile $TempFile -EA 1 
                (Get-Item $TempFile).FullName
            } catch {
                Write-Log 'Get-O365LicenseReference error:','unable to download','O365 license reference sheet from',(Decode-String $URL -Silent) Magenta,Yellow,Magenta,Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile                
            } 
        }

        # Validate headers
        $ExpectedHeaderList = @('GUID','Product_Display_Name','Service_Plans_Included_Friendly_Names','Service_Plan_Id','Service_Plan_Name','String_Id')
        $O365LicenseRef = Import-Csv -Path $OutputFullPath 
        $CurrentHeaderList = ($O365LicenseRef | Get-Member -MemberType NoteProperty).Name
        $MissingHeaderList = foreach ($Header in $ExpectedHeaderList) {
            if ($Header -notin $CurrentHeaderList) { $Header }
        }
        if ($MissingHeaderList) {
            Write-Log 'Get-O365LicenseReference error:','missing headers in the downloaded file',$OutputFullPath Magenta,Yellow,Magenta $LogFile
            Write-Log 'Expected headers:',($ExpectedHeaderList -join ', ') Magenta,Yellow $LogFile
            Write-Log 'File headers: ',($CurrentHeaderList -join ', ') Magenta,Yellow $LogFile
            Write-Log 'Missing headers: ',($MissingHeaderList -join ', ') Magenta,Yellow $LogFile
        } else {
            $OutputFullPath
        }
    }

    End {  }
} 

function Get-O365LicenseAggregateReport {
<#
 .SYNOPSIS
  Function to return a CSV aggregate report on O365 licenses
 
 .DESCRIPTION
  Function to return a CSV aggregate report on O365 licenses
  This function depends on the MSOnline PowerShell module that can be obtained from https://www.powershellgallery.com/packages/MSOnline
  via Install-Module -Name MSOnline
 
 .PARAMETER LicenseReferenceFilePath
  Optional file. If not provided, this function will attempt to download it.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output if any.
 
 .EXAMPLE
  Connect-MsolService
  Get-O365LicenseAggregateReport
 
 .EXAMPLE
  $O365LicenseAggregateReport = Get-O365LicenseAggregateReport
  $O365LicenseAggregateReport | Export-Csv .\O365LicenseAggregateReport.CSV -NoType
 
 .OUTPUTS
  This cmdlet returns a number of PS objects such as:
    TenantName : YourAzureTenantName
    SkuPartNumber : STANDARDPACK
    FriendlyName : OFFICE 365 E1
    ActiveUnits : 15
    ConsumedUnits : 15
    WarningUnits : 0
    LockedOutUnits : 0
    SuspendedUnits : 0
    TargetClass : User
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 March 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$LicenseReferenceFilePath,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\O365LicenseAggregateReport_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  }

    Process {      
        if ($LicenseReferenceFile = Get-O365LicenseReference -FilePath $LicenseReferenceFilePath -LogFile $LogFile) {
            Write-Verbose "Verified O365 License Reference file $LicenseReferenceFile"
            $LicenseReference = Import-Csv $LicenseReferenceFile 
        } else {
            Write-Log 'Failed to download/validate O365 License Reference file',$LicenseReferenceFilePath Yellow,Cyan $LogFile
        }

        try {        
            $CurrentLicenseList = Get-MsolAccountSku -EA 1 | sort ConsumedUnits,ActiveUnits -Descending
            $CurrentLicenseList | foreach {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    TenantName     = $_.AccountName
                    SkuPartNumber  = $_.SkuPartNumber
                    FriendlyName   = $(
                        if ($LicenseReference) {
                            (($LicenseReference | where String_Id -eq $_.SkuPartNumber).Product_Display_Name | select -Unique) -join ', '
                        }
                    )
                    ActiveUnits    = $_.ActiveUnits
                    ConsumedUnits  = $_.ConsumedUnits
                    WarningUnits   = $_.WarningUnits
                    LockedOutUnits = $_.LockedOutUnits
                    SuspendedUnits = $_.SuspendedUnits
                    TargetClass    = $_.TargetClass
                })
            }
        } catch {
            Write-Log 'Get-O365LicenseAggregateReport error:' Magenta $LogFile
            if ($_.Exception.Message -match 'The term ''Get-MsolAccountSku'' is not recognized') {
                Write-Log 'You need to install the MSOnline Powershell module as in:' Yellow $LogFile
                Write-Log ' Install-Module -Name MSOnline' Cyan $LogFile
                Write-Log ' For more information see https://www.powershellgallery.com/packages/MSOnline' Yellow $LogFile
            }
            Write-Log $_.Exception.Message Yellow $LogFile                
        }
    }

    End {  }
} 

function Get-O365ValidationReport {
<#
 .SYNOPSIS
  Function to return a CSV report on O365 users with validation errors.
 
 .DESCRIPTION
  Function to return a CSV report on O365 users with validation errors in the current Azure tenant.
  This function depends on the MSOnline PowerShell module that can be obtained from https://www.powershellgallery.com/packages/MSOnline
  via Install-Module -Name MSOnline
  For more information on O365 users validation errors see
  https://support.microsoft.com/en-us/topic/you-see-validation-errors-for-users-in-the-office-365-portal-or-in-the-azure-active-directory-module-for-windows-powershell-5c3bf8f7-de1b-6f51-6623-3c005d1f5900
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output if any.
 
 .EXAMPLE
  Connect-MsolService
  Get-O365ValidationReport
 
 .EXAMPLE
  $O365ValidationReport = Get-O365ValidationReport
  $O365ValidationReport | Export-Csv .\O365ValidationReport.CSV -NoType
 
 .OUTPUTS
  This cmdlet returns a number of PS objects such as:
    FirstName : myFirstName
    LastName : myLastName
    DisplayName : myFirstName, myLastName
    SignInName : name@domain.com
    UserPrincipalName : name@domain.com
    ProxyAddresses : smtp:name@domain.com, smtp:name@domain.onmicrosoft.com, SMTP:name@domain.somthing.com
    Title : Some Analyst
    Department : Some Dept.
    UsageLocation : US
    Country : USA
    UserType : Member
    WhenCreated : 10/19/2021 9:16:49 PM
    ObjectId : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    ErrorName : ValidationStatus
    ErrorServiceInstance : exchange/namprd17-001-01
    ErrorDetail : exchange/namprd17-001-01
    ErrorTimeStamp : 10/19/2021 22:06:23
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://support.microsoft.com/en-us/topic/you-see-validation-errors-for-users-in-the-office-365-portal-or-in-the-azure-active-directory-module-for-windows-powershell-5c3bf8f7-de1b-6f51-6623-3c005d1f5900
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 March 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$LogFile = ".\O365ValidationReport_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  }

    Process {      
        try {        
            $ValidationErrorUserList = Get-MsolUser -HasErrorsOnly -All:$true -EA 1
            $ValidationErrorUserList | foreach {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    FirstName            = $_.FirstName
                    LastName             = $_.LastName
                    DisplayName          = $_.DisplayName
                    SignInName           = $_.SignInName
                    UserPrincipalName    = $_.UserPrincipalName
                    ProxyAddresses       = $_.ProxyAddresses -join ', '
                    Title                = $_.Title
                    Department           = $_.Department
                    UsageLocation        = $_.UsageLocation
                    Country              = $_.Country
                    UserType             = $_.UserType
                    WhenCreated          = $_.WhenCreated
                    ObjectId             = $_.ObjectId
                    ErrorName            = 'ValidationStatus'
                    ErrorServiceInstance = $_.Errors.ServiceInstance -join ', '
                    ErrorDetail          = $_.Errors.ErrorDetail.Name -join ', '
                    ErrorTimeStamp       = $_.Errors.TimeStamp -join ', '
                })
            }
        } catch {
            Write-Log 'Get-O365LicenseAggregateReport error:' Magenta $LogFile
            if ($_.Exception.Message -match 'The term ''Get-MsolAccountSku'' is not recognized') {
                Write-Log 'You need to install the MSOnline Powershell module as in:' Yellow $LogFile
                Write-Log ' Install-Module -Name MSOnline' Cyan $LogFile
                Write-Log ' For more information see https://www.powershellgallery.com/packages/MSOnline' Yellow $LogFile
            }
            Write-Log $_.Exception.Message Yellow $LogFile                
        }
    }

    End {  }
} 

function Get-O365ReconciliationReport {
<#
 .SYNOPSIS
  Function to return a CSV report on O365 users with ReconciliationNeeded flag.
 
 .DESCRIPTION
  Function to return a CSV report on O365 users with ReconciliationNeeded flag in the current Azure tenant.
  This function depends on the MSOnline PowerShell module that can be obtained from https://www.powershellgallery.com/packages/MSOnline
  via Install-Module -Name MSOnline
 
 .PARAMETER LicenseReferenceFilePath
  Optional file. If not provided, this function will attempt to download it.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output if any.
 
 .EXAMPLE
  Connect-MsolService
  Get-O365ReconciliationReport
 
 .EXAMPLE
  $O365ReconciliationReport = Get-O365ReconciliationReport
  $O365ReconciliationReport | Export-Csv .\O365ReconciliationReport.CSV -NoType
 
 .OUTPUTS
  This cmdlet returns a number of PS objects such as:
    FirstName : First
    LastName : Last
    DisplayName : Last, First
    SignInName : name@domain.com
    UserPrincipalName : name@domain.com
    ProxyAddresses : smtp:name@domain.com, SMTP:name@domain.other.com, smtp:name@domain.onmicrosoft.com
    Title : ENGINEER MGR
    Department : Some Dept.
    UsageLocation : US
    Country : USA
    UserType : Member
    WhenCreated : 11/13/2020 9:12:25 PM
    ObjectId : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    ValidationStatus : Healthy
    SkuPartNumber :
    FriendlyName :
    ServiceInformation : sharepoint/*
    ServiceParameter : https://domain-my.sharepoint.com/personal/user_domain_com/
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 March 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$LicenseReferenceFilePath,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\O365ReconciliationReport_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  }

    Process {      
        if ($LicenseReferenceFile = Get-O365LicenseReference -FilePath $LicenseReferenceFilePath -LogFile $LogFile) {
            Write-Verbose "Verified O365 License Reference file $LicenseReferenceFile"
            $LicenseReference = Import-Csv $LicenseReferenceFile 
        } else {
            Write-Log 'Failed to download/validate O365 License Reference file',$LicenseReferenceFilePath Yellow,Cyan $LogFile
        }

        try {        
            $LicenseReconciliationNeededUserList = Get-MsolUser -LicenseReconciliationNeededOnly -All:$true -EA 1 
            $LicenseReconciliationNeededUserList | foreach {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    FirstName            = $_.FirstName
                    LastName             = $_.LastName
                    DisplayName          = $_.DisplayName
                    SignInName           = $_.SignInName
                    UserPrincipalName    = $_.UserPrincipalName
                    ProxyAddresses       = $_.ProxyAddresses -join ', '
                    Title                = $_.Title
                    Department           = $_.Department
                    UsageLocation        = $_.UsageLocation
                    Country              = $_.Country
                    UserType             = $_.UserType
                    WhenCreated          = $_.WhenCreated
                    ObjectId             = $_.ObjectId
                    ValidationStatus     = $_.ValidationStatus
                    SkuPartNumber        = $_.LicenseAssignmentDetails.AccountSku.SkuPartNumber -join ', '
                    FriendlyName   = $(
                        if ($LicenseReference) {
                            (($LicenseReference | where String_Id -eq $_.SkuPartNumber).Product_Display_Name | select -Unique) -join ', '
                        }
                    )
                    ServiceInformation   = $_.ServiceInformation.ServiceInstance -join ', '
                    ServiceParameter     = $_.ServiceInformation.ServiceElements.ServiceParameters.ServiceParameter.Value -join ', '
                })
            }
        } catch {
            Write-Log 'Get-O365ReconciliationReport error:' Magenta $LogFile
            if ($_.Exception.Message -match 'The term ''Get-MsolAccountSku'' is not recognized') {
                Write-Log 'You need to install the MSOnline Powershell module as in:' Yellow $LogFile
                Write-Log ' Install-Module -Name MSOnline' Cyan $LogFile
                Write-Log ' For more information see https://www.powershellgallery.com/packages/MSOnline' Yellow $LogFile
            }
            Write-Log $_.Exception.Message Yellow $LogFile                
        }
    }

    End {  }
} 

function Get-O365DetailedLicenseReport {
<#
 .SYNOPSIS
  Function to return a CSV report on O365 users license details.
 
 .DESCRIPTION
  Function to return a CSV report on O365 users license details in the current Azure tenant, including how a license is assigned (direct assignment or via membership in one or more Azure groups).
  This function depends on the MSOnline PowerShell module that can be obtained from https://www.powershellgallery.com/packages/MSOnline
  via Install-Module -Name MSOnline
 
 .PARAMETER LicenseReferenceFilePath
  Optional file. If not provided, this function will attempt to download it.
 
 .PARAMETER SaveUserList
  Optional parameter. When set to True, this function will save the entire user list in XML format.
 
 .PARAMETER SaveGroupList
  Optional parameter. When set to True, this function will save the entire group list in XML format.
 
 .PARAMETER ReportOnConflictingLicensesOnly
  Optional parameter. When set to True, this function will report on users with O365 license assignments conflict only.
  Otherwise, it will report on all O365 licensed users.
 
 .PARAMETER ReportFolder
  Optional parameter. If not provided, this function will default to saving reports in the current folder.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output if any.
 
 .EXAMPLE
  Connect-MsolService
  Get-O365DetailedLicenseReport
 
 .EXAMPLE
  $O365DetailedLicenseReport = Get-O365DetailedLicenseReport
  $O365DetailedLicenseReport | Export-Csv .\O365DetailedLicenseReport.CSV -NoType
 
 .EXAMPLE
  $O365ConflictingLicenseReport = Get-O365DetailedLicenseReport -SaveUserList -SaveGroupList -ReportOnConflictingLicensesOnly
  $O365ConflictingLicenseReport | Export-Csv .\O365ConflictingLicenseReport.CSV -NoType
 
 .OUTPUTS
  This cmdlet returns a number of PS objects such as:
    GivenName : First
    SurName : Last
    DisplayName : Last, First
    Mail : name@domain.com
    UserPrincipalName : name@domain.com
    AssignedLicenses : STANDARDPACK, EMS
    LicenseFriendlyName : Office 365 E1, ENTERPRISE MOBILITY + SECURITY E3
    LicenseAssignments : Direct Assignment, Some_Azure_Group_1
    LicenseErrors : CountViolation: ENTERPRISEPACK from Some_Azure_Group_2
    UserType : Member
    ImmutableId : xxxx/xxxxxxxxxx/uxxxxx==
    ObjectId : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 March 2022
  v0.2 - 19 April 2022
    Added parameters: SaveUserList, SaveGroupList, ReportOnConflictingLicensesOnly, ReportFolder
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$SaveUserList,
        [Parameter(Mandatory=$false)][Switch]$SaveGroupList,
        [Parameter(Mandatory=$false)][Switch]$ReportOnConflictingLicensesOnly,
        [Parameter(Mandatory=$false)][String]$LicenseReferenceFilePath,
        [Parameter(Mandatory=$false)][String]$ReportFolder,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\O365DetailedLicenseReport_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 
        try {
            Test-Path $ReportFolder -EA 1
        } catch {
            try {
                New-Item -Path $ReportFolder -ItemType Directory -Force -EA 1 
                Write-Log 'Report folder',$ReportFolder,'missing..','created' Yellow,Cyan,Yellow,Green $LogFile
            } catch {
                Write-Log 'Report folder',$ReportFolder,'missing..','and unable to create it, using current folder',(Get-Item '.\').FullName,'instead' Yellow,Cyan,Yellow,Magenta,Cyan,Yellow $LogFile
                $ReportFolder = (Get-Item '.\').FullName 
           }
        }

        $CompanyInfo = Get-MsolCompanyInformation
    }

    Process {      
        if ($LicenseReferenceFile = Get-O365LicenseReference -FilePath $LicenseReferenceFilePath -LogFile $LogFile) {
            Write-Verbose "Verified O365 License Reference file $LicenseReferenceFile"
            $LicenseReference = Import-Csv $LicenseReferenceFile 
        } else {
            Write-Log 'Failed to download/validate O365 License Reference file',$LicenseReferenceFilePath Yellow,Cyan $LogFile
        }

        try {        
            $Duration = Measure-Command { $AzureUserList  = Get-MsolUser -All:$true -EA 1 }
            Write-Log 'Retrieved details for',('{0:N0}' -f $AzureUserList.Count),'Azure users in',"$($Duration.Minutes):$($Duration.Seconds) (mm:ss)" Green,Cyan,Green,Cyan $LogFile
            if ($SaveUserList) {
                $ReportFileName = "$ReportFolder\MSOL_User_List_$($CompanyInfo.InitialDomain)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').XML"
                $AzureUserList | Export-Clixml $ReportFileName 
                Write-Log ' saved to',$ReportFileName Green,Cyan $LogFile
            }

            $Duration = Measure-Command { $AzureGroupList = Get-MsolGroup -All:$true -EA 1 }
            Write-Log 'Retrieved details for',('{0:N0}' -f $AzureGroupList.Count),'Azure groups in',"$($Duration.Minutes):$($Duration.Seconds) (mm:ss)" Green,Cyan,Green,Cyan $LogFile
            if ($SaveGroupList) {
                $ReportFileName = "$ReportFolder\MSOL_Group_List_$($CompanyInfo.InitialDomain)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').XML"
                $AzureGroupList | Export-Clixml $ReportFileName 
                Write-Log ' saved to',$ReportFileName Green,Cyan $LogFile
            }
            
            Write-Log 'Compiling detailed user license report..' Green $LogFile -NoNewLine
            $Duration = Measure-Command { 
                if ($ReportOnConflictingLicensesOnly) {
                    $LicensedUserList = $AzureUserList | where { $_.IndirectLicenseErrors }
                    Write-Log 'Reporting on',('{0:N0}' -f $LicensedUserList.Count),'users with conflicting O365 license assignments' Green,Cyan,Green $LogFile
                } else {
                    $LicensedUserList = $AzureUserList | where { $_.LicenseAssignmentDetails }
                    Write-Log 'Reporting on',('{0:N0}' -f $LicensedUserList.Count),'(all) users with O365 license assignments' Green,Cyan,Green $LogFile
                }
                $LicensedAzureUserList = foreach ($MSOLUser in $LicensedUserList) {
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        GivenName           = $MSOLUser.FirstName
                        SurName             = $MSOLUser.LastName
                        DisplayName         = $MSOLUser.DisplayName
                        Mail                = $MSOLUser.SignInName
                        UserPrincipalName   = $MSOLUser.UserPrincipalName
                        AssignedLicenses    = $MSOLUser.LicenseAssignmentDetails.AccountSku.SkuPartNumber -join ', '
                        LicenseFriendlyName = $(
                            if ($LicenseReference) {
                                foreach ($LicenseAssignment in $MSOLUser.LicenseAssignmentDetails.AccountSku.SkuPartNumber) {
                                    (($LicenseReference | where String_Id -eq $LicenseAssignment).Product_Display_Name | select -Unique) -join ', '
                                }
                            }
                        ) -join ', '
                        LicenseAssignments  = $(
                            foreach ($LicenseAssignment in $MSOLUser.LicenseAssignmentDetails) {
                                $LicenseAssignment.Assignments.ReferencedObjectId.Guid | foreach { if ($FoundGroup = $AzureGroupList | where ObjectId -EQ $_ ) { $FoundGroup.DisplayName } else { 'Direct Assignment' } }
                            }
                        ) -join ', '
                        LicenseErrors       = $(
                            foreach ($LError in $MSOLUser.IndirectLicenseErrors) {
                                "$($LError.Error): $($LError.AccountSku.SkuPartNumber) from $(($AzureGroupList | where ObjectId -EQ $LError.ReferencedObjectId.Guid).DisplayName)"
                            }
                        ) -join ', '
                        UserType            = $MSOLUser.UserType
                        ImmutableId         = $MSOLUser.ImmutableId
                        ObjectId            = $MSOLUser.ObjectId
                    })
                }
            }
            Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" Cyan,DarkYellow $LogFile

        } catch {
            Write-Log 'Get-O365DetailedLicenseReport error:' Magenta $LogFile
            if ($_.Exception.Message -match 'The term ''Get-MsolAccountSku'' is not recognized') {
                Write-Log 'You need to install the MSOnline Powershell module as in:' Yellow $LogFile
                Write-Log ' Install-Module -Name MSOnline' Cyan $LogFile
                Write-Log ' For more information see https://www.powershellgallery.com/packages/MSOnline' Yellow $LogFile
            }
            Write-Log $_.Exception.Message Yellow $LogFile                
        }
    }

    End { $LicensedAzureUserList }
} 

function Get-O365DetailedLicenseReport-Updated {
<#
 .SYNOPSIS
  Function to return a CSV report on O365 users license details.
 
 .DESCRIPTION
  Function to return a CSV report on O365 users license details in the current Azure tenant, including how a license is assigned (direct assignment or via membership in one or more Azure groups).
  This function depends on the az.accounts PowerShell module that can be obtained from https://www.powershellgallery.com/packages/Az.Accounts
  via Install-Module -Name az.accounts
 
 .PARAMETER LicenseReferenceFilePath
  Optional file. If not provided, this function will attempt to download it.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output if any.
 
 .EXAMPLE
  Get-O365DetailedLicenseReport -Verbose
 
 .EXAMPLE
  $O365DetailedLicenseReport = Get-O365DetailedLicenseReport -Verbose
  $O365DetailedLicenseReport | Export-Csv .\O365DetailedLicenseReport.CSV -NoType
 
 .OUTPUTS
  This cmdlet returns a number of PS objects such as:
    FirstName : First
    LastName : Last
    DisplayName : Last, First
    SignInName : name@domain.com
    UserPrincipalName : name@domain.com
    UserType : Member
    AssignedLicenses : ENTERPRISEPACK, EMS
    LicenseFriendlyName : Office 365 E3, ENTERPRISE MOBILITY + SECURITY E3
    LicenseAssignments : Direct Assignment, G_AZUREONLY_Win10_Protection
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.commands.common.authentication.azuresession
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 March 2022
  v0.2 - 4 April 2022
    Rewrite to avoid using the legacy MSOnline PwoerShell module.
    Added graphToken parameter
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$LicenseReferenceFilePath,
        [Parameter(Mandatory=$false)][String]$graphToken,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\O365DetailedLicenseReport_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  
        if (-not $graphToken) {
            
            # Validate we have Az.Accounts PS module dependency
            if (-not (Get-Module Az.Accounts -ListAvailable)) {
                Write-Log 'Get-O365DetailedLicenseReport error:','You need the Az.Accounts PowerShell module.' Magenta,Yellow $LogFile
                Write-Log 'Use:','Install-Module Az.Accounts' Yellow,Cyan $LogFile
                Write-Log 'or see','https://www.powershellgallery.com/packages/Az.Accounts','for more details' Yellow,Cyan,Yellow $LogFile
                break
            } 

            # Login via Connect-AzAccount if not logged in
            $Context = Get-AzContext
            if (-not $Context) { 
                try {
                    $null = Connect-AzAccount -EA 1 
                    $Context = Get-AzContext
                } catch {
                    Write-Log 'Get-O365DetailedLicenseReport error:','Failed to login to Azure.' Magenta,Yellow $LogFile
                    Write-Log $_.Exception.Message Yellow $LogFile
                    break
                }
            }

            # Obtain token via
            try {
                $graphToken = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate($Context.Account,$Context.Environment,$Context.Tenant.Id,$null,'Never',$null,'https://graph.microsoft.com').AccessToken
                Write-Verbose 'Obtained graph token:'
                Write-Verbose ($graphToken | Out-String).Trim()
                Write-Verbose 'Token details:'
                Get-AzureTokenDetails -Token $graphToken | Out-Null
            } catch {
                Write-Log 'Get-O365DetailedLicenseReport error:','Failed to obtain graph Token.' Magenta,Yellow $LogFile
                Write-Log 'Context:' Magenta $LogFile
                Write-Log ($Context | FL * | Out-String).Trim() Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }

        }
        $BaseUri = 'https://graph.microsoft.com/v1.0/'
    }

    Process {  

        if ($LicenseReferenceFile = Get-O365LicenseReference -FilePath $LicenseReferenceFilePath -LogFile $LogFile) {
            Write-Verbose "Verified O365 License Reference file $LicenseReferenceFile"
            $LicenseReference = Import-Csv $LicenseReferenceFile 
        } else {
            Write-Log 'Failed to download/validate O365 License Reference file',$LicenseReferenceFilePath Yellow,Cyan $LogFile
        }

        $Headers = @{ Authorization = $graphToken; ConsistencyLevel = 'eventual' }

        $Duration = Measure-Command { 

            # Get user count, validate token:
            try {
                $Uri = "$BaseUri/users/" + '$count'
                $UserCount = Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get -EA 1 
            } catch {
                Write-Log 'Get-O365DetailedLicenseReport error:','Graph API error, command details:' Magenta,Yellow $LogFile
                Write-Log "Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get" Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            } 

            # Get user list
            $AzureUserList = @()
            $Uri = "$BaseUri/users/" + '?$select=GivenName,SurName,DisplayName,UserPrincipalName,Mail,Country,Department,Id,AssignedLicenses'
            $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get 
            $AzureUserList += $APIResponse.value
            $i = $APIResponse.value.Count
            $AzureUserList += while ($APIResponse.'@Odata.NextLink') {
                $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $APIResponse.'@Odata.NextLink' -Method Get
                $APIResponse.value
                $i += $APIResponse.value.Count
                Write-Progress -Activity "Retrieving Azure user details" -Status "$i of $UserCount retrieved" 
            }
        }
        Write-Log 'Retrieved details for',('{0:N0}' -f $AzureUserList.Count),'Azure users in',"$($Duration.Minutes):$($Duration.Seconds) (mm:ss)" Green,Cyan,Green,Cyan $LogFile

        # $User = $AzureUserList| where id -eq '011fd7fc-0c9c-4b21-8dc6-8afa26918e5d'
        # $User | FL *
        # $User.assignedLicenses.skuId

        $Duration = Measure-Command { 
            $Uri = "$BaseUri/groups/" + '$count'
            $GroupCount = Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get -EA 1 
            $AzureGroupList = @()
            $Uri = "$BaseUri/groups/" # + '?$select=DisplayName,description,Mail,createdDateTime,isAssignableToRole,Id,membershipRule,onPremisesSyncEnabled,securityEnabled'
            $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get 
            $AzureGroupList += $APIResponse.value
            $i = $APIResponse.value.Count
            $AzureGroupList += while ($APIResponse.'@Odata.NextLink') {
                $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $APIResponse.'@Odata.NextLink' -Method Get
                $APIResponse.value
                $i += $APIResponse.value.Count
                Write-Progress -Activity "Retrieving Azure group details" -Status "$i of $GroupCount retrieved" 
            }
        }

<#
        $AzureGroupList | group onPremisesSyncEnabled # True or blank
        $AzureGroupList | group isAssignableToRole # True or blank ## Need details on the AAD role
        $AzureGroupList | group mailEnabled # True or False
        $AzureGroupList | where { $_.mailEnabled } # groupTypes = unified, mail = has value, proxyaddresse = has values, resourceProvisioningOptions = team
        $AzureGroupList | group securityEnabled # True or False
        $AzureGroupList | where { -not $_.securityEnabled }
        $AzureGroupList | group visibility # Private, Public, or blank
        $AzureGroupList | where { $_.visibility -eq 'Public' }
        $AzureGroupList | where { $_.visibility -eq 'Private' }
        $AzureGroupList | where displayname -match 'Sam Boutros - test O365 license granting group' # id : 72b73cec-4ca6-47c0-bb98-d4b01f4c08d1
         
        # Get Group members
        $Uri = "$BaseUri/groups/72b73cec-4ca6-47c0-bb98-d4b01f4c08d1/members" # + '?$select=DisplayName,description,Mail,createdDateTime,isAssignableToRole,Id,membershipRule,onPremisesSyncEnabled,securityEnabled'
        $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get
        $APIResponse.value
 
#>


        Write-Log 'Retrieved details for',('{0:N0}' -f $AzureGroupList.Count),'Azure groups in',"$($Duration.Minutes):$($Duration.Seconds) (mm:ss)" Green,Cyan,Green,Cyan $LogFile
        
        $Duration = Measure-Command { 
            $O365SkuList = @()
            $Uri = "$BaseUri/subscribedSkus/" # + '?$select=DisplayName,description,Mail,createdDateTime,isAssignableToRole,Id,membershipRule,onPremisesSyncEnabled,securityEnabled'
            $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $Uri -Method Get 
            $O365SkuList += $APIResponse.value
            $i = $APIResponse.value.Count
            $O365SkuList += while ($APIResponse.'@Odata.NextLink') {
                $APIResponse = Invoke-RestMethod -Headers $Headers -Uri $APIResponse.'@Odata.NextLink' -Method Get
                $APIResponse.value
                $i += $APIResponse.value.Count
                Write-Progress -Activity "Retrieving Azure O365 subscribed Sku details" -Status $i
            }
        }
        
        Write-Log 'Retrieved details for',('{0:N0}' -f $O365SkuList.Count),'Azure O365 subscribed Skus in',"$($Duration.Minutes):$($Duration.Seconds) (mm:ss)" Green,Cyan,Green,Cyan $LogFile
<#
capabilityStatus : Enabled
consumedUnits : 15
id : 9dab725a-13fe-4fd1-8122-a75b8bec73d9_18181a46-0d4e-45cd-891e-60aabd171b4e
skuId : 18181a46-0d4e-45cd-891e-60aabd171b4e
skuPartNumber : STANDARDPACK
appliesTo : User
prepaidUnits : @{enabled=15; suspended=0; warning=0}
servicePlans : {@{servicePlanId=199a5c09-e0ca-4e37-8f7c-b05d533e1ea2; servicePlanName=MICROSOFTBOOKINGS; provisioningStatus=Success; appliesTo=User}, @{servicePlanId=b76fb638-6ba6-402a-b9f9-83d28acb3d86; servicePlanName=VIVA_LEARNING_SEEDED;
                   provisioningStatus=Success; appliesTo=User}, @{servicePlanId=db4d623d-b514-490b-b7ef-8885eee514de; servicePlanName=Nucleus; provisioningStatus=Success; appliesTo=Company}, @{servicePlanId=31cf2cfc-6b0d-4adc-a336-88b724ed8122;
                   servicePlanName=RMS_S_BASIC; provisioningStatus=Success; appliesTo=Company}...}
#>
        



        Write-Log 'Compiling detailed user license report..' Green $LogFile -NoNewLine
        $Duration = Measure-Command { 
            $LicensedAzureUserList = foreach ($AzureUser in $AzureUserList) {
                if ($AzureUser.LicenseAssignmentDetails) {
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        FirstName           = $AzureUser.FirstName
                        LastName            = $AzureUser.LastName
                        DisplayName         = $AzureUser.DisplayName
                        SignInName          = $AzureUser.SignInName
                        UserPrincipalName   = $AzureUser.UserPrincipalName
                        UserType            = $AzureUser.UserType
                        AssignedLicenses    = $AzureUser.LicenseAssignmentDetails.AccountSku.SkuPartNumber -join ', '
                        LicenseFriendlyName = $(
                            if ($LicenseReference) {
                                foreach ($LicenseAssignment in $AzureUser.LicenseAssignmentDetails.AccountSku.SkuPartNumber) {
                                    (($LicenseReference | where String_Id -eq $LicenseAssignment).Product_Display_Name | select -Unique) -join ', '
                                }
                            }
                        ) -join ', '
                        LicenseAssignments  = $(
                            foreach ($LicenseAssignment in $AzureUser.LicenseAssignmentDetails) {
                                $LicenseAssignment.Assignments.ReferencedObjectId.Guid | foreach { if ($FoundGroup = $AzureGroupList | where ObjectId -EQ $_ ) { $FoundGroup.DisplayName } else { 'Direct Assignment' } }
                            }
                        ) -join ', '
                    })
                }
            }
        }
        Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" Cyan,DarkYellow $LogFile


    }

    End { $LicensedAzureUserList }
} 

#endregion

function Deploy-AzureARMVM {
<#
 .SYNOPSIS
  Function to automate provisioning of Azure ARM VM(s)
 
 .DESCRIPTION
  Function to automate provisioning of Azure ARM VM(s)
 
 .PARAMETER SubscriptionName
    Name of existing Azure subscription
 
 .PARAMETER Location
    Name of Azure Data center/Location
    Example: 'eastus'
    To see location list use:
        Get-AzureRmLocation | sort Location | Select Location
 
 .PARAMETER ResourceGroup
    Name of Resource Group.
    Example: 'VMGroup17'
    The script will create it if it does not exist
 
 .PARAMETER AvailabilitySetName
    Example: 'Availability17'
     The script will create it if it does not exist
 
 .PARAMETER ConfirmShutdown
    This switch accepts $true or $False, and defaaults to $False
    If adding existing VMs to Availaibility set, the script must shut down the VMs
 
 .PARAMETER StorageAccountPrefix
    Only lower case letters and numbers, must be Azure (globally) unique
 
 .PARAMETER AdminName
    Example: 'myAdmin17'
    This will be the new VM local administrator
 
 .PARAMETER VMName
    Example: ('vm01','vm02')
    Name(s) of VM(s) to be created. Each is 15 characters maximum. If VMs exist, they will be added to Availability Set
 
 .PARAMETER VMSize
    Example: 'Standard_A1_v2'
    To see available sizes in this Azure location use:
        (Get-AzureRoleSize).RoleSizeLabel
 
 .PARAMETER WinOSImage
    This defaults to '2012-R2-Datacenter'
    Available options:
        '2008-R2-SP1','2012-Datacenter','2012-R2-Datacenter','2016-Datacenter','2016-Datacenter-Server-Core','2016-Datacenter-with-Containers','2016-Nano-Server'
    To see current options in a given Azure Location use:
        (Get-AzureRMVMImageSku -Location usgovvirginia -Publisher MicrosoftWindowsServer -Offer WindowsServer).Skus
    For more information see https://docs.microsoft.com/en-us/azure/virtual-machines/windows/cli-ps-findimage
 
 .PARAMETER vNetName
    Example: 'Seventeen'
    This will be the name of the virtual network to be created/updated if exist
 
 .PARAMETER vNetPrefix
    Example: '10.17.0.0/16'
    To be created/updated
 
 .PARAMETER SubnetName
    Example: 'vmSubnet'
    This will be the name of the subnet to be created/updated
 
 .PARAMETER SubnetPrefix
    Example: '10.17.0.0/24'
    Must be subset of vNetPrefix above - to be created/updated
 
 .PARAMETER LogFile'
    Path to log file where this scrit will log its commands and output
    Default is ".\Logs\Deploy-AzureARMVM-$($VMName -join '_')-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
 
 .EXAMPLE
    Connect-AzureRmAccount -Environment AzureUSGovernment
    $myParamters = @{
        SubscriptionName = 'Azure Government T1'
        Location = 'usgovvirginia'
        ResourceGroup = 'EncryptionTest01'
        AvailabilitySetName = 'AvailabilityTest01'
        ConfirmShutdown = $false
        StorageAccountPrefix = 'sam150318a'
        AdminName = 'myAdmin150318a'
        VMName = @('vm01','vm02','vm03')
        VMSize = 'Standard_A0'
        WinOSImage = '2016-Datacenter'
        vNetName = 'EncryptionTest01VNet'
        vNetPrefix = '10.3.0.0/16'
        SubnetName = 'vmSubnet'
        SubnetPrefix = '10.3.15.0/24'
    }
    Deploy-AzureARMVM @myParamters
 
 .LINK
  http://www.exigent.net/blog/microsoft-azure/provisioning-and-tearing-down-azure-virtual-machines/
 
 .NOTES
  Function by Sam Boutros
    3 January 2017 - v0.1 - Initial release
    19 January 2017 - v0.2
        Updated parameters - set to mandatory
        Updated Storage Account creation region, create a separate storage account for each VM
        Updated Initialize region; removing subscription login, adding input echo, adding error handling
        Added functionality to configure VMs in availability set
    5 March 2018 - v0.3 Cosmetic updates
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$SubscriptionName         , # Example: 'Sam Test 1' # Name of existing Azure subscription
        [Parameter(Mandatory=$true)][String]$Location                 , # Example: 'eastus' # Get-AzureRmLocation | sort Location | Select Location
        [Parameter(Mandatory=$true)][String]$ResourceGroup            , # Example: 'VMGroup17' # To be created if not exist
        [Parameter(Mandatory=$false)][String]$AvailabilitySetName     , # Example: 'Availability17' # To be created if not exist
        [Parameter(Mandatory=$false)][Switch]$ConfirmShutdown = $false, # If adding existing VMs to Availaibility set, the script must shut down the VMs
        [Parameter(Mandatory=$false)][String]$StorageAccountPrefix    , # To be created if not exist, only lower case letters and numbers, must be Azure unique
        [Parameter(Mandatory=$true)][String]$AdminName                , # Example: 'myAdmin17' # This will be the new VM local administrator
        [Parameter(Mandatory=$true)][String[]]$VMName                 , # Example: ('vm01','vm02') # Name(s) of VM(s) to be created. Each is 15 characters maximum. If VMs exist, they will be added to Availability Set
        [Parameter(Mandatory=$true)][String]$VMSize                   , # Example: 'Standard_A1_v2' # (Get-AzureRoleSize).RoleSizeLabel to see available sizes in this Azure location
        [Parameter(Mandatory=$false)][ValidateSet('2008-R2-SP1','2012-Datacenter','2012-R2-Datacenter','2016-Datacenter','2016-Datacenter-Server-Core','2016-Datacenter-with-Containers','2016-Nano-Server')]
            [String]$WinOSImage = '2012-R2-Datacenter'               , # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/cli-ps-findimage
        [Parameter(Mandatory=$true)][String]$vNetName                 , # Example: 'Seventeen' # This will be the name of the virtual network to be created/updated if exist
        [Parameter(Mandatory=$true)][String]$vNetPrefix               , # Example: '10.17.0.0/16' # To be created/updated
        [Parameter(Mandatory=$true)][String]$SubnetName               , # Example: 'vmSubnet' # This will be the name of the subnet to be created/updated
        [Parameter(Mandatory=$true)][String]$SubnetPrefix             , # Example: '10.17.0.0/24' # Must be subset of vNetPrefix above - to be created/updated
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Logs\Deploy-AzureARMVM-$($VMName -join '_')-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {

        #region Initialize
        if (!(Test-Path (Split-Path $LogFile))) { New-Item -Path (Split-Path $LogFile) -ItemType directory -Force | Out-Null }
        Write-Log 'Input received:' Green $LogFile
        write-log " SubscriptionName: $SubscriptionName" Cyan $LogFile
        write-log " Location: $Location" Cyan $LogFile
        write-log " ResourceGroup: $ResourceGroup" Cyan $LogFile
        write-log " AvailabilitySetName: $AvailabilitySetName" Cyan $LogFile
        write-log " ConfirmShutdown: $ConfirmShutdown" Cyan $LogFile
        write-log " StorageAccountPrefix: $StorageAccountPrefix" Cyan $LogFile
        write-log " AdminName: $AdminName" Cyan $LogFile
        write-log " VMName(s): $($VMName -join ', ')" Cyan $LogFile
        write-log " VMSize: $VMSize" Cyan $LogFile
        write-log " vNetName: $vNetName" Cyan $LogFile
        write-log " vNetPrefix: $vNetPrefix" Cyan $LogFile
        write-log " SubnetName: $SubnetName" Cyan $LogFile
        write-log " SubnetPrefix: $SubnetPrefix"  Cyan $LogFile
        $Cred = Get-SBCredential -UserName $AdminName 
        #endregion

        #region Connect to Azure subscription
        Write-Log 'Connecting to Azure subscription',$SubscriptionName Green,Cyan $LogFile -NoNewLine
        try { 
            $Result = Get-AzureRmSubscription –SubscriptionName $SubscriptionName -ErrorAction Stop | Select-AzureRmSubscription 
            Write-Log 'done' Green $LogFile    
            Write-Log ($Result | Out-String).Trim() Cyan $LogFile
        } catch {
            throw "unable to get Azure Subscription '$SubscriptionName'"
        }
        #endregion

        #region Create/Update Resource group
        Write-Log 'Create/Update Resource group',$ResourceGroup Green,Cyan $LogFile -NoNewLine
        try {
            $Result = New-AzureRmResourceGroup -Name $ResourceGroup -Location $Location -Force -ErrorAction Stop
            Write-Log 'done' Green $LogFile    
            Write-Log ($Result | Out-String).Trim() Cyan $LogFile
        } catch {
            throw "Failed to create Resource Group '$ResourceGroup'"
        }
        #endregion

        #region Create/Update Subnet and vNet
        Write-Log 'Creating/updating vNet',$vNetName,$vNetPrefix,'and subnet',$SubnetName,$SubnetPrefix Cyan,Green,DarkYellow,Cyan,Green,DarkYellow $LogFile -NoNewLine
        $Subnet = New-AzureRmVirtualNetworkSubnetConfig -Name $SubnetName -AddressPrefix $SubnetPrefix
        $vNet = New-AzureRmVirtualNetwork -Name $vNetName -ResourceGroupName $ResourceGroup -Location $Location -AddressPrefix $vNetPrefix -Subnet $Subnet -Force
        Write-Log 'done' Green
        #endregion

    }

    Process {
        foreach ($Name in $VMName) { # Provision Azure VM(s)

            #region Create Storage Account if it does not exist
            $StorageAccountName = "stor$($StorageAccountPrefix.ToLower())$($Name.ToLower())"
            if ($StorageAccountName.Length -gt 20) { 
                Write-Log 'Storage account name',$StorageAccountName,'is too long, using first 20 characters only..' Green,Yellow,Green $LogFile
                $StorageAccountName = $StorageAccountName.Substring(0,19) 
            }  
            Write-Log 'Creating Storage Account',$StorageAccountName Green,Cyan $LogFile
            try {
                $StorageAccount = Get-AzureRmStorageAccount -Name $StorageAccountName -ResourceGroupName $ResourceGroup -ErrorAction Stop
                Write-Log 'Using existing storage account',$StorageAccountName Green,Cyan $LogFile
            } catch {
                $i=0
                $DesiredStorageAccountName = $StorageAccountName
                while (!(Get-AzureRmStorageAccountNameAvailability $StorageAccountName).NameAvailable) {
                    $i++
                    $StorageAccountName = "$StorageAccountName$i"
                }
                if ($DesiredStorageAccountName -ne $StorageAccountName ) {
                    Write-Log 'Storage account',$DesiredStorageAccountName,'is taken, using',$StorageAccountName,'instead (available)' Greem,Yellow,Green,Cyan,Green $LogFile
                }
                try {
                    $Splatt = @{
                        ResourceGroupName = $ResourceGroup
                        Name              = $StorageAccountName 
                        SkuName           = 'Standard_LRS' 
                        Kind              = 'Storage' 
                        Location          = $Location 
                        ErrorAction       = 'Stop'
                    }
                    $StorageAccount = New-AzureRmStorageAccount @Splatt
                    Write-Log 'Created storage account',$StorageAccountName Green,Cyan $LogFile
                } catch {
                    Write-Log 'Failed to create storage account',$StorageAccountName Magenta,Yellow $LogFile
                    throw $PSItem.exception.message
                }
            }
            #endregion

            #region Create/validate Availability Set
            if ($AvailabilitySetName) {
                Write-Log 'Creating/verifying Availability Set',$AvailabilitySetName Green,Cyan $LogFile
                try {
                    $AvailabilitySet = Get-AzureRmAvailabilitySet -ResourceGroupName $ResourceGroup -Name $AvailabilitySetName -ErrorAction Stop
                    Write-Log 'Availability Set',$AvailabilitySetName,'already exists' Green,Yellow,Green $LogFile
                    Write-Log ($AvailabilitySet | Out-String).Trim() Cyan $LogFile
                } catch {
                    try {
                        $AvailabilitySet = New-AzureRmAvailabilitySet -ResourceGroupName $ResourceGroup -Name $AvailabilitySetName -Location $Location -ErrorAction Stop
                        Write-Log 'Created Availability Set',$AvailabilitySetName Green,Cyan $LogFile
                    } catch {
                        Write-Log 'Failed to create Availability Set',$AvailabilitySetName Magenta,Yellow $LogFile
                        throw $PSItem.exception.message
                    }
                }
                if ($AvailabilitySet.Location -ne $Location) {
                    Write-Log 'Unable to proceed, Availability set must be in the same location',$AvailabilitySet.Location,'as the desired VM location',$Location Magenta,Yellow,Magenta,Yellow $LogFile
                    break
                }
            }
        #endregion

            try {
                $ExistingVM = Get-AzureRmVM -ResourceGroupName $ResourceGroup -Name $Name -ErrorAction Stop
                Write-Log 'VM',$ExistingVM.Name,'already exists' Green,Yellow,Gree $LogFile
                if ($AvailabilitySetName) {
                    if ($ConfirmShutdown) {
                        Write-Log 'Shutting down VM',$Name,'to add it to Availability set',$AvailabilitySetName Green,Cayn,Green,Cyan $LogFile
                        Stop-AzureRmVM -Name $Name -Force -StayProvisioned -ResourceGroupName $ResourceGroup -Confirm:$false

                        # Remove current VM
                        Remove-AzureRmVM -ResourceGroupName $ResourceGroup -Name $Name -Force -Confirm:$false

                        # Prepare to recreate VM
                        $VM = New-AzureRmVMConfig -VMName $ExistingVM.Name -VMSize $ExistingVM.HardwareProfile.VmSize -AvailabilitySetId $AvailabilitySet.Id
                        Set-AzureRmVMOSDisk -VM $VM -VhdUri $ExistingVM.StorageProfile.OsDisk.Vhd.Uri -Name $ExistingVM.Name -CreateOption Attach -Windows

                        #Add Data Disks
                        foreach ($Disk in $ExistingVM.StorageProfile.DataDisks) { 
                            Add-AzureRmVMDataDisk -VM $VM -Name $Disk.Name -VhdUri $Disk.Vhd.Uri -Caching $Disk.Caching -Lun $Disk.Lun -CreateOption Attach -DiskSizeInGB $Disk.DiskSizeGB
                        }

                        #Add NIC(s)
                        foreach ($NIC in $ExistingVM.NetworkInterfaceIDs) {
                            Add-AzureRmVMNetworkInterface -VM $VM -Id $NIC
                        }

                        # Recreate the VM as part of the Availability Set
                        New-AzureRmVM -ResourceGroupName $ResourceGroup -Location $ExistingVM.Location -VM $VM -DisableBginfoExtension
                    } else {
                        Write-Log 'To add existing VM(s) to availability set, the VM(s) must be shut down. Use the','-ConfirmShutdown:$true','switch' Yellow,Cyan,Yellow $LogFile
                        break
                    }
                }
            } catch {
                Write-Log 'Preparing to create new VM',$Name Green,Cyan $LogFile

                Write-Log 'Requesting/updating public IP address assignment',"$Name-PublicIP" Green,Cyan $LogFile 
                $PublicIp = New-AzureRmPublicIpAddress -Name "$Name-PublicIP" -ResourceGroupName $ResourceGroup -Location $Location -AllocationMethod Dynamic -Force

                Write-Log 'Provisining/updating vNIC',"$Name-vNIC" Green,Cyan $LogFile
                $vNIC = New-AzureRmNetworkInterface -Name "$Name-vNIC" -ResourceGroupName $ResourceGroup -Location $Location -SubnetId $vNet.Subnets[0].Id -PublicIpAddressId $PublicIp.Id -Force

                Write-Log 'Provisioning VM configuration object for VM',$Name Green,Cyan $LogFile
                if ($AvailabilitySetName) {
                    $VM = New-AzureRmVMConfig -VMName $Name -VMSize $VMSize -AvailabilitySetId $AvailabilitySet.Id
                } else {
                    $VM = New-AzureRmVMConfig -VMName $Name -VMSize $VMSize 
                }

                Write-Log 'Configuring VM OS (Windows),',$Cred.UserName,'local admin' Green,Cyan,Green $LogFile
                $VM = Set-AzureRmVMOperatingSystem -VM $VM -Windows -ComputerName $Name -Credential $Cred -ProvisionVMAgent -EnableAutoUpdate

                Write-Log 'Selecting VM image - Latest',$WinOSImage Green,Cyan $LogFile
                $VM = Set-AzureRmVMSourceImage -VM $VM -PublisherName "MicrosoftWindowsServer" -Offer "WindowsServer" -Skus $WinOSImage -Version "latest"

                Write-Log 'Adding vNIC' Green $LogFile
                $VM = Add-AzureRmVMNetworkInterface -VM $VM -Id $vNIC.Id 

                $VhdUri = "$($StorageAccount.PrimaryEndpoints.Blob.ToString())vhds/$($Name)-OsDisk1.vhd"
                Write-Log 'Configuring OS Disk',$VhdUri Green,Cyan $LogFile
                $VM = Set-AzureRmVMOSDisk -VM $VM -Name 'OSDisk' -VhdUri $VhdUri -CreateOption FromImage

                Write-Log 'Creating VM..' Green -NoNewLine
                New-AzureRmVM -ResourceGroupName $ResourceGroup -Location $Location -VM $VM 
                Write-Log 'done' Green $LogFile
                $DoneVM = Get-AzureRmVM | where { $_.Name -eq $Name } | FT -a 
                Write-Log ($DoneVM | Out-String).Trim() cyan $LogFile
            }
        }
    }

    End {
        if ($AvailabilitySetName) {
            $AvailabilitySet = Get-AzureRmAvailabilitySet -ResourceGroupName $ResourceGroup -Name $AvailabilitySetName
            $VMDomains = $AvailabilitySet.VirtualMachinesReferences | foreach { 
                $VM = Get-AzureRMVM -Name (Get-AzureRmResource -Id $_.id).Name -ResourceGroup $ResourceGroup -Status
                [PSCustomObject][Ordered]@{
                    Name         = $VM.Name
                    FaultDomain  = $VM.PlatformFaultDomain
                    UpdateDomain = $VM.PlatformUpdateDomain
                }
            }
            Write-Log ($VMDomains | sort Name | FT -a | Out-String).Trim() Cyan $LogFile
        } 
    }

}

function Tag-AzResource {

    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$ResourceId,
        [Parameter(Mandatory=$true)][HashTable]$TagList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Tag-AzResource-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { }

    Process {
        try {
            $Resource = Get-AzResource -ResourceId $ResourceId -EA 1
        } catch {
            Write-Log '[Tag-AzResource] Error:' Magenta $LogFile
            Write-Log $PSItem.Exception.Message Yellow $LogFile
            return
        }

        $OK2Save = $false
        if ($Resource.Tags) {
            [HashTable]$CurrentTags = $Resource.Tags
            foreach ($key in $TagList.Keys) {
                if (-not($CurrentTags.keys -icontains $key)) {
                    Write-Log ' Tag',$key,'is not set for resource',$Resource.Name,'setting as',$TagList.$key Green,Cyan,Yellow,Cyan,Green,Cyan $LogFile
                    $UpdatedTagList = $CurrentTags + @{ $key = $TagList.$key }
                    $OK2Save = $true
                } elseif ($CurrentTags[$key] -eq $TagList[$key]) {
                    Write-Log ' Tag',$key,'is already set for resource',$Resource.Name,'value:',$CurrentTags[$key],'skipping..' Green,Cyan,Green,Cyan,Green,Cyan,Green $LogFile
                } else {
                    Write-Log ' Tag',$key,'is already set for resource',$Resource.Name,'value:',$CurrentTags[$key],'updating to',$TagList.$key Green,Cyan,Green,Cyan,Green,Yellow,Green,Cyan $LogFile
                    $Resource.Tags.$key = $TagList.$key
                    [HashTable]$UpdatedTagList = $Resource.Tags
                    $OK2Save = $true
                }
            }
        } else {
            $UpdatedTagList = $TagList
            Write-Log ' No tags configured for resource',$Resource.Name,'adding tag(s)',($UpdatedTagList.Keys -join ','),'value(s)',($UpdatedTagList.Values -join ',') Green,Cyan,Green,Cyan,Green,Cyan $LogFile
            $OK2Save = $true
        }

        if ($OK2Save) {
            try {
                Set-AzResource -Tag $UpdatedTagList -ResourceId $ResourceId -Force -EA 1 | Out-Null
                Write-Log 'done' Green $LogFile
            } catch {
                Write-Log 'failed' Magenta $LogFile
                Write-Log $PSItem.Exception.Message Yellow $LogFile
            }
        }
    }

    End { }

}

function Tag-AzVM {

<#
 .SYNOPSIS
  Function to apply one or more Azure resource tags to one or more VMs and its related objects

 .DESCRIPTION
  Function to apply one or more Azure resource tags to one or more VMs and its related objects.
  Curently this function supports the following related VM objects:
    NICs
    Managed OS Disks
    Managed Data Disks
  This function is intended for Azure ARM VMs not ASM VMs.

 .PARAMETER $VMObj
  This is an objct of Type Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine
  that can be obtained via the Get-AzVM cmdlet of the Az.Compute PS module

 .PARAMETER TagList
  This is a HashTable of desired tags. Example:
    @{
        COMPANY = 'my company'
        OWNER = 'Sam.Boutros'
    }

 .PARAMETER LogFile
  Path to the file where this function will save time-stamped entries of its console output

 .EXAMPLE
  Tag-AzVM -VMObj (Get-AzVM -Name myVMName -ResourceGroupName myResourceGroup) -TagList @{ CostCenter = 'myCostCenter'; COMPANY = 'myCompany' }

 .OUTPUTS
  None

 .LINK
  https://superwidgets.wordpress.com/category/powershell/

 .NOTES
  Function by Sam Boutros
  v0.1 - 4 June 2018 - Initial release
  v0.2 - 14 June 2018 - Parameterized, added error handling and documentation
  v0.3 - 9 April 2020 - Rewrite to work with Az PS module instead of AzureRM, update logic

#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine]$VMObj,
        [Parameter(Mandatory=$true)][HashTable]$TagList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Tag-AzVM-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { }

    Process {
        Write-Log 'Processing VM',$VMObj.Name,'in resource group',$VMObj.ResourceGroupName Green,Cyan,Green,Cyan $LogFile

        # Tag VM
        Tag-AzResource -ResourceId $VMObj.Id -TagList $TagList -LogFile $LogFile

        # Tag managed OS disk
        if ($OSDiskId = $VMObj.StorageProfile.OsDisk.ManagedDisk.Id) {
            Tag-AzResource -ResourceId $OSDiskId -TagList $TagList -LogFile $LogFile
        }

        # Tag managed Data disks
        if ($DataDiskName = $VMObj.StorageProfile.DataDisks.ManagedDisk.Name) {
            foreach ($Name in $DataDiskName) {
                $Id = (Get-AzDisk -ResourceGroupName $VMObj.ResourceGroupName -DiskName $Name).Id
                Tag-AzResource -ResourceId $Id -TagList $TagList -LogFile $LogFile
            }
        }

        # Tag NICs
        if ($NICId = $VMobj.NetworkProfile.NetworkInterfaces.Id) {
            foreach ($Id in $NICId) {
                Tag-AzResource -ResourceId $Id -TagList $TagList -LogFile $LogFile
            }
        }
    }

    End { }
}

function Expand-Json {
<#
 .SYNOPSIS
  Function to expand a custom PowerShell object in a more readable format
 
 .DESCRIPTION
  Function to expand a custom PowerShell object in a more readable format
  The ConvertFrom-Json cmdlet of the Microsoft.PowerShell.Utility module outputs
  a PS Custom Object that often contains sub objects and so on.
  This function expands all objects and displays the key/value pairs in a
  more humanly readable format - see the example
 
 .PARAMETER Json
  PS Custom Object, typically the output of ConvertFrom-Json cmdlet - see the example
 
 .PARAMETER Parent
  This is optional parameter used to show sub-objects when using the function recursively
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Get-Content E:\Scripts\ARMTemplates\Storage1.json | ConvertFrom-Json | Expand-Json
  where the contents of Storage1.json file are:
 
    {
      "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
      "contentVersion": "1.0.0.0",
      "parameters": {
        "storageAccountType": {
          "type": "string",
          "defaultValue": "Standard_LRS",
          "allowedValues": [
            "Standard_LRS",
            "Standard_GRS",
            "Standard_ZRS",
            "Premium_LRS"
          ],
          "metadata": {
            "description": "Storage Account type"
          }
        }
      },
      "variables": {
        "storageAccountName": "[concat(uniquestring(resourceGroup().id), 'standardsa')]"
      },
      "resources": [
        {
          "type": "Microsoft.Storage/storageAccounts",
          "name": "[variables('storageAccountName')]",
          "apiVersion": "2016-01-01",
          "location": "[resourceGroup().location]",
          "sku": {
              "name": "[parameters('storageAccountType')]"
          },
          "kind": "Storage",
          "properties": {
          }
        }
      ],
      "outputs": {
          "storageAccountName": {
              "type": "string",
              "value": "[variables('storageAccountName')]"
          }
      }
    }
   
  The output of Get-Content E:\Scripts\ARMTemplates\Storage1.json | ConvertFrom-Json would look like:
 
    $schema : https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#
    contentVersion : 1.0.0.0
    parameters : @{storageAccountType=}
    variables : @{storageAccountName=[concat(uniquestring(resourceGroup().id), 'standardsa')]}
    resources : {@{type=Microsoft.Storage/storageAccounts; name=[variables('storageAccountName')]; apiVersion=2016-01-01; location=[resourceGroup().location]; sku=; kind=Storage; properties=}}
    outputs : @{storageAccountName=}
 
  which does not show sub-objects such as parameters.storageAccountType.allowedValues, parameters.storageAccountType.defaultValue, ...
 
  However, the output of Get-Content E:\Scripts\ARMTemplates\Storage1.json | ConvertFrom-Json | Expand-Json
  shows all objects, sub-objects, and their key/pair values:
 
    $schema: https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#
    contentVersion: 1.0.0.0
    outputs.storageAccountName.type: string
    outputs.storageAccountName.value: [variables('storageAccountName')]
    parameters.storageAccountType.allowedValues: Standard_LRS, Standard_GRS, Standard_ZRS, Premium_LRS
    parameters.storageAccountType.defaultValue: Standard_LRS
    parameters.storageAccountType.metadata.description: Storage Account type
    parameters.storageAccountType.type: string
    resources.apiVersion: 2016-01-01
    resources.kind: Storage
    resources.location: [resourceGroup().location]
    resources.name: [variables('storageAccountName')]
    resources.sku.name: [parameters('storageAccountType')]
    resources.type: Microsoft.Storage/storageAccounts
    variables.storageAccountName: [concat(uniquestring(resourceGroup().id), 'standardsa')]
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 28 March 2018
  v0.2 - 20 June 2019 - Added Log feature to allow logging output to file
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,ValueFromPipeLine=$true,ValueFromPipeLineByPropertyName=$true)][PSCustomObject]$JSON,
        [Parameter(Mandatory=$false)][String[]]$Parent,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Expand-Json - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        Write-Verbose "JSON: $($JSON | Out-String)"
        Write-Verbose "Parent: $($Parent -join '.')"
    }

    Process {
        foreach ($NoteProperty in ($JSON | Get-Member -MemberType NoteProperty)) {
            if ($NoteProperty.Definition -match 'PSCustomObject') {
                Expand-Json -JSON $JSON.($NoteProperty.Name) -Parent ($Parent + $NoteProperty.Name)
            } else {
                if (($JSON.($NoteProperty.Name) -join '').Trim()) {
                    Write-Log "$(($Parent + $NoteProperty.Name) -join '.'):",($JSON.($NoteProperty.Name) -join ', ') Green,Cyan $LogFile
                } else {
                    Expand-Json -JSON $JSON.($NoteProperty.Name) -Parent ($Parent + $NoteProperty.Name) -EA 0
                }
            } 
        }
    }

    End { }
}

function Report-AzureRMVM {

# Requires -Modules Az, ImportExcel
# Requires -Version 5

<#
 .SYNOPSIS
  Function to report on Azure VM population in a given Azure subscription
 
 .DESCRIPTION
  Function to report on Azure VM population in a given Azure subscription
  The report is saved to xlsx file
  This function uses ImportExcel PowerShell module available in the PowerShell gallery
  This function reports on Azure ARM VMs only (not classic ASM VMs)
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER OutputFile
  Path to xlsx file, where the function will write its output
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Report-AzureRMVM -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -Verbose
 
 .EXAMPLE
  $SubscriptionList = Get-AzSubscription | where Name -Match Citrix
  $myVMList = foreach ($SubscriptionName in $SubscriptionList.Name) {
    Report-AzureRMVM -LoginName 'does not matter' -SubscriptionName $SubscriptionName -Verbose
  }
  $OutputFile = ".\Report-AzureRMVM - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx"
  $myVMList | Export-Excel -Path $OutputFile -AutoSize -FreezeTopRowFirstColumn -ConditionalText $(
      ($myVMList | Get-Member -MemberType NoteProperty).Name | foreach { New-ConditionalText $_ White SteelBlue }
  )
  This example will create a single report for VMs from several subscriptions
 
 .OUTPUTS
  Array of PS Custom objects, one for each ARM VM found with the following properties/example:
    VMName : AZ-abcBDEV-01
    ResourceGroup : AZ-abcEV-RG
    Status : VM running
    Subscription : abc Enterprise Dev/Test
    Size : Standard_D2s_v3
    Cores : 2
    RAM(GB) : 8
    HybridLicense : False
    Location : eastus
    MACAddress : 00-0D-3A-1C-87-11
    IPv4Address : 172.129.132.112
    AdminName : cdabcadmin
    OperatingSystem : Windows
    OSDiskSize(GB) : 127
    DataDisks : (AZ-abcBDEV-01_SQLDATA, 1028 GB, LUN 0), (AZ-abcBDEV-01_SQLLOG, 1028 GB, LUN 1)
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 June 2018 - Initial release
  v0.2 - 14 June 2018 - Parameterized, added error handling and documentation
  v0.3 - 23 January 2019 - Added logfile parameter,
            updated subscription login section,
            added HybridLicense property to output
  v0.4 - 28 February 2019 - Added Status (running/deallocated)
  v0.5 - 24 May 2019 - Update to use AZ module instead of AzureRM
  v0.6 - 7 April 2020 - Added AzLogon Switch to bypass Azure Logon check (for use with Azure Cloud Shell)
    Added auto-install of ImportExcel PS module
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$false)][Switch]$AzLogon,
        [Parameter(Mandatory=$false)][String]$OutputFile = ".\Report-AzureRMVM - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx",
        [Parameter(Mandatory=$false)][String]$LogFile    = ".\Report-AzureRMVM - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if ($AzLogon) {
            if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
        }

        if (-not (Get-Module Import-Excel -ListAvailable)) { Install-Module ImportExcel -Force }

        if ($OutputFile -match '/' ) { $OutputFile = $OutputFile.Replace('/','_') }
    }

    Process {
        $VMList = Get-AzVM -WA 0 -EA 0
        if (-not $VMList) { 
            Write-Log 'No ARM VMs found in subscription',$SubscriptionName Green,Yellow $LogFile
            break
        }
        $LocationList = $VMList.Location | select -Unique
        $VMTagList = $VMList | % { $_.Tags.Keys } | % { $_.ToString().ToLower().Trim() } | select -Unique | sort
        $ResourceGroupList = $VMList.ResourceGroupName | select -Unique
        Write-Log 'Identified',$VMList.Count,'VMs' Green,Cyan,Green $Logfile
        Write-Log 'Identified Azure site(s)',($LocationList -join ', ') Green,Cyan $Logfile
        Write-Log 'Identified',$ResourceGroupList.Count,'Resource Groups' Green,Cyan,Green $Logfile

        $myVMList = foreach ($VM in $VMList) {

            Write-Verbose "Processing VM ($($VM.Name)) in Resource Group ($($VM.ResourceGroupName))"
            $VMSize = Get-AzVMSize -Location $VM.Location | where { $_.Name -eq $VM.HardWareProfile.VmSize }
            $myOutput = [PSCustomObject][Ordered]@{
                VMName           = $VM.Name
                ResourceGroup    = $VM.ResourceGroupName
                Status           = (Get-AzVM -ResourceGroupName $VM.ResourceGroupName -Name $VM.Name -Status -WA 0).Statuses[1].DisplayStatus
                Subscription     = $SubscriptionName
                Size             = $VM.HardWareProfile.VmSize
                Cores            = $VMSize.NumberOfCores              
                'RAM(GB)'        = $VMSize.MemoryInMB/1KB
                HybridLicense    = $(if ($VM.LicenseType -eq 'Windows_Server') { $true } else { $false })
                Location         = $VM.Location
                MACAddress       = ($VM.NetworkProfile.NetworkInterfaces.id | foreach { (Get-AzResource -ResourceId $_).Properties.MacAddress }) -join ', '
                IPv4Address      = ((Get-AzNetworkInterface -ResourceGroupName $VM.ResourceGroupName | 
                                    where {$PSItem.virtualmachine.id -match $VM.Name } | Get-AzNetworkInterfaceIpConfig).PrivateIPAddress) -join ', '
                AdminName        = $VM.OSProfile.AdminUsername
                OperatingSystem  = $VM.StorageProfile.OsDisk.OsType
                'OSDiskSize(GB)' = $VM.StorageProfile.OsDisk.DiskSizeGB
                DataDisks        = $(
                    if ($VM.StorageProfile.DataDisks) {
                        ($VM.StorageProfile.DataDisks | foreach { "($($_.Name), $($_.DiskSizeGB) GB, LUN $($_.Lun))" }) -join ', '
                    } else {
                        'None'
                    }
                )
            }

            foreach ($TagName in $VMTagList) {
                $myOutput | Add-Member -MemberType NoteProperty -Name "Tag: $TagName" -Value $(
                    $myTagList = $VM.Tags.Keys | foreach { "$_=$($VM.Tags.$_)" }
                    if  ($FoundTag = $myTagList | where { $_ -match $TagName }) { $FoundTag.Split('=')[1] }
                ) 
            }

            $myOutput
        } 
    } 

    End {
        try {
            if (Test-Path $OutputFile) { Remove-Item -Path $OutputFile -Force -Confirm:$false -EA 1 }
            $myVMList | Export-Excel -Path $OutputFile -ConditionalText $(
                ($myVMList | Get-Member -MemberType NoteProperty).Name | foreach { New-ConditionalText $_ White SteelBlue }
            ) -AutoSize -FreezeTopRowFirstColumn 
        } catch {
            Write-Log 'Output file',$OutputFile,'already open!!??' Magenta,Yellow,Magenta $Logfile
        }

        $myVMList
    }
}

function Set-AzVMHybridLicense {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to enable/disable Windows Hybrid Licensing feature on a given Azure VM
 
 .DESCRIPTION
  Function to enable/disable Windows Hybrid Licensing feature on a given Azure VM
  This function uses Az PowerShell module available in the PowerShell gallery
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER VMName
  The name of the VM. This is a required parameter
 
 .PARAMETER ResourceGroupName
  The name of the Resource Group where the VM lives. This is only required if you
  have more than1 VM with the same name in the provided subscription
 
 .PARAMETER EnableHybridLicensing
  This is a switch that defaults to true causing the function to enable Windows Hybrid Licensing feature
  When set to false, the function disables the Windows Hybrid Licensing feature for the given VM
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Set-AzVMHybridLicense -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -VMName 'myvm1'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 23 January 2019
  v0.2 - 25 January 2019 - Added logic to weed out Linux VMs
  v0.3 - 3 June 2019 - Updated to use Az module instead of AzureRM
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$true)][String]$VMName,
        [Parameter(Mandatory=$false)][String]$ResourceGroupName,
        [Parameter(Mandatory=$false)][Switch]$EnableHybridLicensing = $true,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Set-AzVMHybridLicense - $VMName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {
        
        $Proceed = $false
        if ($VM = Get-AzVM | where Name -EQ $VMName) {
            if ($VM.Count -gt 1) {
                if ($ResourceGroupName) {
                    if ($VM = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName) {
                        $Proceed = $true
                    } else {
                        Write-Log 'No VM named',$VMName,'found in subscription',$SubscriptionName,'under Resource Group',$ResourceGroupName Magenta,Yellow,Magenta,Yellow,Magenta,Yellow $LogFile
                    }
                } else {
                    Write-Log 'More than 1 VM named',$VMName,'found in subscription', $SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
                    Write-Log ' You must specify ''ResourceGroupName'' parameter for this VM' Yellow $LogFile
                }                
            } else {
                $Proceed = $true
            }
        } else {
            Write-Log 'VM',$VMName,'not found in subscription', $SubscriptionName Magenta,Yellow,Magenta,Yellow $LogFile
        }

        if ($VM.StorageProfile.OsDisk.OsType -ne 'Windows') {
            Write-Log 'VM',$VM.Name,'has OS',$VM.StorageProfile.OsDisk.OsType,'skipping..' Green,Cyan,Green,Yellow,Green $LogFile
            $Proceed = $false
        }

        if ($Proceed) {
            if ($VM.LicenseType -eq 'Windows_Server') {
                if ($EnableHybridLicensing) {
                    Write-Log 'Windows hybrid licensing for VM',$VM.Name,'in Resource Group',$VM.ResourceGroupName,'is already enabled' Green,Cyan,Green,Cyan,Yellow $LogFile
                } else {
                    Write-Log 'Disabling Windows hybrid licensing for VM',$VM.Name,'in Resource Group',$VM.ResourceGroupName Green,Cyan,Green,Cyan $LogFile -NoNewLine
                    $VM.LicenseType = 'None'
                    Update-AzVM -ResourceGroupName $VM.ResourceGroupName -VM $VM | Out-Null
                    $VM = Get-AzureRmVM -ResourceGroupName $VM.ResourceGroupName -Name $VM.Name
                    if ($VM.LicenseType -eq 'Windows_Server') {
                        Write-Log 'failed' Yellow $LogFile
                    } else {
                        Write-Log 'done and validated' Green $LogFile
                    }
                }                
            } else {
                if ($EnableHybridLicensing) {
                    Write-Log 'Enabling Windows hybrid licensing for VM',$VM.Name,'in Resource Group',$VM.ResourceGroupName Green,Cyan,Green,Cyan $LogFile -NoNewLine
                    $VM.LicenseType = 'Windows_Server'
                    Update-AzVM -ResourceGroupName $VM.ResourceGroupName -VM $VM | Out-Null
                    $VM = Get-AzVM -ResourceGroupName $VM.ResourceGroupName -Name $VM.Name
                    if ($VM.LicenseType -eq 'Windows_Server') {
                        Write-Log 'done and validated' Green $LogFile
                    } else {
                        Write-Log 'failed' Yellow $LogFile
                    }
                } else {
                    Write-Log 'Windows hybrid licensing for VM',$VM.Name,'in Resource Group',$VM.ResourceGroupName,'is already disabled' Green,Cyan,Green,Cyan,Yellow $LogFile
                }  
            }
        }

    } 

    End { } 
    
}

function Report-AzureClassicResources {

# Requires -Modules AzureRM
# Requires -Version 5

<#
 .SYNOPSIS
  Function to report on Azure classic ASM in a given Azure subscription
 
 .DESCRIPTION
  Function to report on Azure classic ASM in a given Azure subscription
  This function uses AzureRM PowerShell module available in the PowerShell gallery
 
 .PARAMETER LoginName
  The username required to authenticate to Azure
  Example: samb@mydomain.com
 
 .PARAMETER SubscriptionName
  The Azure subscription name such as 'My Dev EA subscription'
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Report-AzureClassicResources -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here' -Verbose
 
 .OUTPUTS
  Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.PSResource objects for each classic ASM resource found
  Example:
    Name : txxxxxxxx8
    ResourceGroupName : aaaServer
    ResourceType : Microsoft.ClassicCompute/virtualMachines
    Location : eastus
    ResourceId : /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/aaaServer/providers/Microsoft.ClassicCompute/virtualMachines/txxxxxxxx8
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 6 February 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$LoginName,
        [Parameter(Mandatory=$true)][String]$SubscriptionName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-AzureClassicResources - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        if (-not ($Login = Login-AzureRMSubscription -LoginName $LoginName -SubscriptionName $SubscriptionName -LogFile $LogFile)) { break }
    }

    Process {
        $ResourceProviderList = Get-AzureRmResourceProvider -ListAvailable | 
            where ProviderNamespace -Match 'Microsoft' | select ProviderNamespace,ResourceTypes
        $ResourceTypeList = foreach ($Provider in $ResourceProviderList) { 
            foreach ($Type in $Provider.ResourceTypes) {
                "$($Provider.ProviderNamespace)/$($Type.ResourceTypeName)"
            }
        }
        $ClassicTypes = $ResourceTypeList -match 'classic' | sort
        Write-Log 'Reporting on',$ClassicTypes.Count,'classic ASM resources types' Green,Cyan,Green $LogFile
        Write-Verbose ($ClassicTypes | Out-String).Trim() 

        if ($ClassicResourceList = Get-AzureRmResource | where { $_.ResourceType -in $ClassicTypes }) {
            Write-Log 'Identified',$ClassicResourceList.Count,'classic ASM resources in subscription',$SubscriptionName Green,Yellow,Green,Yellow $LogFile
            $ClassicResourceList
        } else {
            Write-Log 'No classic ASM resources found in subscription', $SubscriptionName Green,Cyan $LogFile
        }
    } 

    End { }
}

function Report-AzureResourceTags {

# Requires -Modules AZ,ImportExcel
# Requires -Version 5

<#
 .SYNOPSIS
  Function to report on Azure Tags of ARM resources in a given Azure subscription
 
 .DESCRIPTION
  Function to report on Azure Tags of ARM resources in a given Azure subscription
  This function uses and depends on Az and ImportExcel PowerShell modules available in the PowerShell gallery
 
 .PARAMETER SubscriptionId
  The Azure subscription Id such as 'My Dev EA subscription'
  that can be obtained by Get-AZSubscription
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .PARAMETER Output
  This is an optional parameter that specifies the path to the XLSX file where the script Excel output report is saved
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Report-AzureResourceTags -LoginName 'samb@mydomain.com' -SubscriptionName 'my azure subscription name here'
 
 .OUTPUTS
  PowerShell object for each ARM resource found with the following properties/example
  Example:
    SubscriptionName : my azure subscription name here
    ResourceName : wxxx9170
    ResourceGroupName : Wxxxr
    ResourceType : Microsoft.Storage/storageAccounts
    ResourceLocation : eastus
  Note that there will be an additional property for each Azure tag found in the given subscription
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 6 February 2019
  v0.2 - 9 May 2019 - update for AZ module instead of AzureRM
  v0.3 - 13 August 2020
    - Switching to SubscriptionId instead SubscriptionName input
      since Subscription Name is not necessarily unque within the same Azure tenant
    - Removing the Azure login requirement/check,
      expecting to be logged into an Azure tenant before invoking this function
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$SubscriptionId,
        [Parameter(Mandatory=$false)][String]$OutputFile = ".\Report-AzureResourceTags - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx",
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-AzureResourceTags - $SubscriptionName - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        try {
            
        } catch {

        }
    }

    Process {
<#
        Write-Log 'Checking if there are any classic ASM resources..' Green $LogFile
        if ($ClassicResources = Report-AzureClassicResources -LoginName $LoginName -SubscriptionName $SubscriptionName) {
            Write-Log 'skipping classic ASM reources..' Green $LogFile
        }
#>

        if ($ResourceList = Get-AzResource | where ResourceType -NotMatch 'classic') {
            $TagList = @()
            $TagList += ($ResourceList | % { $_.Tags | % { $_.Keys } }) -notmatch 'hidden' | select -Unique
            if ($TagList) {
                Write-Log 'Identified',$ResourceList.Count,'ARM resources bearing',$TagList.Count,'unique tag(s)..' Green,Cyan,Green,Cyan,Green $LogFile
                
                # Create output object definition with dynamic property list (tags)
                $Proplist = @('SubscriptionName','ResourceName','ResourceGroupName','ResourceType','Location')
                $TagList | foreach { $Proplist += "Tag:$_" -as [String] }

                $myOutput = foreach ($Resource in $ResourceList) {
                    # Instantiate output object with dynamic property list (tags)
                    $myObj = New-Object -TypeName PSObject 
                    $Proplist | foreach { Add-Member -InputObject $myObj -MemberType NoteProperty -Name $_ -Value $null -EA 0 }

                    # Populate output object properties
                    $myObj.SubscriptionName  = $SubscriptionName
                    $myObj.ResourceName      = $Resource.Name
                    $myObj.ResourceGroupName = $Resource.ResourceGroupName
                    $myObj.ResourceType      = $Resource.ResourceType
                    $myObj.Location          = $Resource.Location
                    foreach ($Tag in $TagList) { $myObj.("Tag:$Tag") = $Resource.Tags.$Tag }
                    $myObj
                }            
                
                Remove-Item -Path $OutputFile -Force -Confirm:$false -EA 0
                try {
                    $myOutput | Export-Excel -Path $OutputFile -ConditionalText $(
                        ($myOutput | Get-Member -MemberType NoteProperty).Name | foreach { New-ConditionalText $_ White SteelBlue }
                    ) -AutoSize -FreezeTopRowFirstColumn 
                } catch {
                    Write-Log 'Output file',$OutputFile,'already open!!??' Magenta,Yellow,Magenta $Logfile
                } 
                         
                $myOutput   
                              
            } else {
                Write-Log 'Identified',$ResourceList.Count,'ARM resources bearing','NO','tags..' Green,Cyan,Green,yellow,Green $LogFile
            } # if $TagList

        } else {
            Write-Log 'No ARM resources found in subscription',$SubscriptionName Magenta,Yellow $LogFile
        } # if $ResourceList
    } 

    End { }
}

function Report-AzureCustomRBACRoles {

# Requires -Modules AZ,ImportExcel
# Requires -Version 5

<#
 .SYNOPSIS
  Function to report on Azure custom RBAC roles in one or more Azure subscriptions
 
 .DESCRIPTION
  Function to report on Azure custom RBAC roles in one or more Azure subscriptions
  This function uses and depends on Az and ImportExcel PowerShell modules available in the PowerShell gallery
  This function expects to be authenticated to Azure before it's invoked (Connect-AzAccount)
 
 .PARAMETER SubscriptionId
  One or more Azure subscription Ids such as 'abcdabcd-abcd-abcd-abcd-abcdabcdabcd'
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .PARAMETER Output
  This is an optional parameter that specifies the path to the XLSX file where the script Excel output report is saved
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Report-AzureCustomRBACRoles -SubscriptionId 'abcdabcd-abcd-abcd-abcd-abcdabcdabcd'
 
 .EXAMPLE
  $CustomRoles = Report-AzureCustomRBACRoles -SubscriptionId (Get-AzSubscription).Id
 
 .OUTPUTS
  PowerShell object for each ARM resource found with the following properties/example
  Example:
    SubscriptionName : my azure subscription name here
    SubscriptionId : abcdabcd-abcd-abcd-abcd-abcdabcdabcd
    Role : Azure Infra Admin
    AssignedTo : user1@domain1.com, user2@domain1.com, AD-Group1
    Actions : *
    NotActions : Microsoft.Authorization/*/Delete, Microsoft.Authorization/*/Write,
  Note that there will be an additional property for each Azure tag found in the given subscription
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 10 May 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String[]]$SubscriptionId,
        [Parameter(Mandatory=$false)][String]$OutputFile = ".\Report-AzureCustomRBACRoles - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx",
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-AzureCustomRBACRoles - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        try {
            $AllSubscriptionList = Get-AzSubscription -EA 1 
        } catch {
            Write-Log 'Unable to list subscriptions','are we logged on to Azure?' Magenta,Yellow $LogFile
            break
        } 
        if ($SubscriptionList = $SubscriptionId | where { $_ -in $AllSubscriptionList.Id } | select -Unique) {
            $SubscriptionList = $SubscriptionList | foreach { Get-AzSubscription -SubscriptionId $_ }
            Write-Log 'The following',$SubscriptionList.Count,'subscriptions are found under the current tenant:' Green,Cyan,Green $LogFile
            Write-Log ($SubscriptionList.Name | Out-String).Trim() Cyan $LogFile
        } else {
            Write-Log 'The provided subscription Id(s)','are not found under the current tenant' Magenta,Yellow $LogFile
            break
        }
    }

    Process {        
        $CustomRoles = foreach ($Subscription in $SubscriptionList) {
            Get-AzSubscription -SubscriptionId $Subscription.Id | Set-AzContext | Out-Null  
            Write-Log 'Checking for RBAC custom roles in subscription',$Subscription.Name Green,Cyan $LogFile -NoNewLine
            try {
                $RoleList = Get-AzRoleDefinition -Custom -EA 1
            } catch {
                Write-Log 'no access -' Magenta $LogFile -NoNewLine
            }
            if ($RoleList) {
                Write-Log 'found',$RoleList.count,'custom roles' Green,Yellow,Green $LogFile
                foreach ($Role in $RoleList) {
                    [PSCustomObject][Ordered]@{
                        SubscriptionName = $Subscription.Name
                        SubscriptionId   = $Subscription.Id
                        Role             = $Role.Name
                        AssignedTo       = (Get-AzRoleAssignment -RoleDefinitionName $Role.Name).DisplayName -join ', '
                        Actions          = $Role.Actions -join ', '
                        NotActions       = $Role.NotActions -join ', '
                    }
                }
            } else {
                Write-Log 'found','no','custom roles' Green,Yellow,Green $LogFile
            }
        }
    } 

    End { 
        Remove-Item -Path $OutputFile -Force -Confirm:$false -EA 0
        if ($CustomRoles) {
            try {
                $CustomRoles | Export-Excel -Path $OutputFile -EA 1 -AutoSize -FreezeTopRowFirstColumn -ConditionalText $(
                    ($CustomRoles | Get-Member -MemberType NoteProperty).Name | foreach { New-ConditionalText $_ White SteelBlue }
                ) 
            } catch {
                Write-Log 'Output file',$OutputFile,'already open!!??' Magenta,Yellow,Magenta $Logfile
            }
        }  
        $CustomRoles     
    }
}

function Deploy-AzRBACRoleDefinition {

<#
 .SYNOPSIS
  Function to deploy custom RBAC role definitions in one or more Azure subscriptions
 
 .DESCRIPTION
  Function to deploy the following custom RBAC role definitions in one or more Azure subscriptions
    1. Network Admin:
        Manage Vnets, Subnets, Express Routes and Routing and Switching Manage NSGs and ASGs,
        Manage WAF Devices, Manage Internal and External Load Balancers
        "Actions":
            "Microsoft.Network/*",
            "Microsoft.Compute/*/read",
            "Microsoft.Resources/deployments/*",
            "Microsoft.Resources/deployments/validate/action",
            "Microsoft.Resources/subscriptions/resourceGroups/read",
            "Microsoft.Support/*"
    2. Infra Admin:
        Access to all Resources except Networking and user access administration,
        Manage VMs, Availability Sets, Assign Static IP, Static MAC, Add or Remove NICs
        "Actions": "*"
        "NotActions":
            "Microsoft.Authorization/*/Delete",
            "Microsoft.Authorization/*/Write",
            "Microsoft.Authorization/elevateAccess/Action",
            "Microsoft.Network/applicationGateways/delete",
            "Microsoft.Network/dnsZones/delete",
            "Microsoft.Network/expressRouteCrossConnections/delete",
            "Microsoft.Network/expressRouteGateways/delete",
            "Microsoft.Network/expressRouteCircuits/delete",
            "Microsoft.Network/expressRoutePorts/delete",
            "Microsoft.Network/frontDoors/delete",
            "Microsoft.Network/networkWatchers/delete",
            "Microsoft.Network/routeFilters/delete",
            "Microsoft.Network/routeTables/delete",
            "Microsoft.Network/serviceEndpointPolicies/delete",
            "Microsoft.Network/trafficManagerProfiles/delete",
            "Microsoft.Network/virtualNetworkGateways/delete",
            "Microsoft.Network/loadBalancers/delete",
            "Microsoft.Network/networkSecurityGroups/delete",
            "Microsoft.Network/virtualNetworks/delete",
            "Microsoft.Network/localNetworkGateways/delete",
            "Microsoft.Network/applicationGateways/write",
            "Microsoft.Network/dnsZones/write",
            "Microsoft.Network/expressRouteCrossConnections/write",
            "Microsoft.Network/expressRouteGateways/write",
            "Microsoft.Network/expressRouteCircuits/write",
            "Microsoft.Network/expressRoutePorts/write",
            "Microsoft.Network/frontDoors/write",
            "Microsoft.Network/networkWatchers/write",
            "Microsoft.Network/routeFilters/write",
            "Microsoft.Network/routeTables/write",
            "Microsoft.Network/serviceEndpointPolicies/write",
            "Microsoft.Network/trafficManagerProfiles/write",
            "Microsoft.Network/virtualNetworkGateways/write",
            "Microsoft.Network/loadBalancers/write",
            "Microsoft.Network/networkSecurityGroups/write",
            "Microsoft.Network/virtualNetworks/write",
            "Microsoft.Network/localNetworkGateways/write",
            "Microsoft.Blueprint/blueprintAssignments/write",
            "Microsoft.Blueprint/blueprintAssignments/delete"
    3. Tag Editor:
        Manage (Add/modify/delete) Azure tags for VMs, VM disks, and VM NICs
        "Actions":
            "*/read",
            "Microsoft.Compute/VirtualMachines/write",
            "Microsoft.Compute/Disks/write",
            "Microsoft.Network/networkInterfaces/write",
            "Microsoft.Resources/subscriptions/resourceGroups/read",
            "Microsoft.Support/*"
  This function uses and depends on Az PowerShell module available in the PowerShell gallery
  This function expects to be authenticated to Azure before it's invoked (Connect-AzAccount)
 
 .PARAMETER SubscriptionId
  One or more Azure subscription Ids such as 'abcdabcd-abcd-abcd-abcd-abcdabcdabcd'
 
 .PARAMETER RoleList
  One or more of the roles defined in this script
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Deploy-AzRBACRoleDefinition -SubscriptionId 'abcdabcd-abcd-abcd-abcd-abcdabcdabcd'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 May 2019
  v0.2 - 3 June 2019 - Updated built-in help to provide role definition details
  v0.3 - 10 April, 2020 - Added TagEditor role, added RoleList parameter
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String[]]$SubscriptionId,
        [Parameter(Mandatory=$true)][ValidateSet('NetworkAdmin','InfraAdmin','TagEditor')][String[]]$RoleList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Deploy-AzRBACRoleDefinition - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        try {
            $AllSubscriptionList = Get-AzSubscription -EA 1 
        } catch {
            Write-Log 'Unable to list subscriptions','are we using AZ module and logged on to Azure?' Magenta,Yellow $LogFile
            break
        } 
        if ($SubscriptionList = $SubscriptionId | where { $_ -in $AllSubscriptionList.Id } | select -Unique) {
            $SubscriptionList = $SubscriptionList | foreach { Get-AzSubscription -SubscriptionId $_ }
            Write-Log 'The following',$SubscriptionList.Count,'subscriptions are found under the current tenant:' Green,Cyan,Green $LogFile
            Write-Log ($SubscriptionList.Name | Out-String).Trim() Cyan $LogFile
        } else {
            Write-Log 'The provided subscription Id(s)','are not found under the current tenant' Magenta,Yellow $LogFile
            break
        }
    }

    Process {        
        foreach ($Subscription in $SubscriptionList) {
            $Subscription | Set-AzContext | Out-Null  
            $JSONFile = New-TemporaryFile

            if ('TagEditor' -in $RoleList) {
                # Putting the subscription Id in the Role definition name since they must be all unique in AAD !!??
                $RoleName = "TagEditor_($($Subscription.Id.ToCharArray()[-8..-1] -join ''))"
@"
                {
                    "Name": "$RoleName",
                    "Description": "Manage (Add/modify/delete) Azure tags for VMs, VM disks, and VM NICs",
                    "Actions": [
                                    "*/read",
                                    "Microsoft.Compute/VirtualMachines/write",
                                    "Microsoft.Compute/Disks/write",
                                    "Microsoft.Network/networkInterfaces/write",
                                    "Microsoft.Resources/subscriptions/resourceGroups/read",
                                    "Microsoft.Support/*"
                                ],
                    "NotActions": [ ],
                    "AssignableScopes": [ "/subscriptions/$($Subscription.Id)" ]
                }
"@
 | Out-File $JSONFile
                try {
                    $Result = New-AzRoleDefinition -InputFile $JSONFile -EA 1
                    Write-Log ($Result|Out-String).Trim() Green $LogFile
                } catch {
                    Write-Log 'Unable to deploy role defintion',$RoleName,'in subscription',$Subscription.Name Magenta,Yellow,Magenta,Yellow $LogFile
                    Write-log " $($_.Exception.Message)" Yellow $LogFile
                }
            }

            if ('NetworkAdmin' -in $RoleList) {
                # Putting the subscription Id in the Role definition name since they must be all unique in AAD !!??
                $RoleName = "NetworkAdmin_($($Subscription.Id.ToCharArray()[-8..-1] -join ''))"
@"
                {
                    "Name": "$RoleName",
                    "Description": "Manage (Add/modify/delete) network resources",
                    "Actions": [
                                    "Microsoft.Network/*",
                                    "Microsoft.Compute/*/read",
                                    "Microsoft.Resources/deployments/*",
                                    "Microsoft.Resources/deployments/validate/action",
                                    "Microsoft.Resources/subscriptions/resourceGroups/read",
                                    "Microsoft.Support/*"
                                ],
                    "NotActions": [ ],
                    "AssignableScopes": [ "/subscriptions/$($Subscription.Id)" ]
                }
"@
 | Out-File $JSONFile
                try {
                    $Result = New-AzRoleDefinition -InputFile $JSONFile -EA 1
                    Write-Log ($Result|Out-String).Trim() Green $LogFile
                } catch {
                    Write-Log 'Unable to deploy role defintion',$RoleName,'in subscription',$Subscription.Name Magenta,Yellow,Magenta,Yellow $LogFile
                    Write-log " $($_.Exception.Message)" Yellow $LogFile
                }
            }

            if ('InfraAdmin' -in $RoleList) {
                # Putting the subscription Id in the Role definition name since they must be all unique in AAD !!??
                $RoleName = "InfraAdmin ($($Subscription.Id.ToCharArray()[-8..-1] -join ''))"
@"
                {
                    "Name": "$RoleName",
                    "IsCustom": true,
                    "Description": "Access to (Create/Modify/Delete) all Resources except Networking and User Access administration",
                    "Actions": [ "*" ],
                    "NotActions": [
                            "Microsoft.Authorization/*/Delete",
                            "Microsoft.Authorization/*/Write",
                            "Microsoft.Authorization/elevateAccess/Action",
                            "Microsoft.Network/applicationGateways/delete",
                            "Microsoft.Network/dnsZones/delete",
                            "Microsoft.Network/expressRouteCrossConnections/delete",
                            "Microsoft.Network/expressRouteGateways/delete",
                            "Microsoft.Network/expressRouteCircuits/delete",
                            "Microsoft.Network/expressRoutePorts/delete",
                            "Microsoft.Network/frontDoors/delete",
                            "Microsoft.Network/networkWatchers/delete",
                            "Microsoft.Network/routeFilters/delete",
                            "Microsoft.Network/routeTables/delete",
                            "Microsoft.Network/serviceEndpointPolicies/delete",
                            "Microsoft.Network/trafficManagerProfiles/delete",
                            "Microsoft.Network/virtualNetworkGateways/delete",
                            "Microsoft.Network/loadBalancers/delete",
                            "Microsoft.Network/networkSecurityGroups/delete",
                            "Microsoft.Network/virtualNetworks/delete",
                            "Microsoft.Network/localNetworkGateways/delete",
                            "Microsoft.Network/applicationGateways/write",
                            "Microsoft.Network/dnsZones/write",
                            "Microsoft.Network/expressRouteCrossConnections/write",
                            "Microsoft.Network/expressRouteGateways/write",
                            "Microsoft.Network/expressRouteCircuits/write",
                            "Microsoft.Network/expressRoutePorts/write",
                            "Microsoft.Network/frontDoors/write",
                            "Microsoft.Network/networkWatchers/write",
                            "Microsoft.Network/routeFilters/write",
                            "Microsoft.Network/routeTables/write",
                            "Microsoft.Network/serviceEndpointPolicies/write",
                            "Microsoft.Network/trafficManagerProfiles/write",
                            "Microsoft.Network/virtualNetworkGateways/write",
                            "Microsoft.Network/loadBalancers/write",
                            "Microsoft.Network/networkSecurityGroups/write",
                            "Microsoft.Network/virtualNetworks/write",
                            "Microsoft.Network/localNetworkGateways/write",
                            "Microsoft.Blueprint/blueprintAssignments/write",
                            "Microsoft.Blueprint/blueprintAssignments/delete"
                        ],
                    "AssignableScopes": [ "/subscriptions/$($Subscription.Id)" ]
    }
"@
 | Out-File $JSONFile
                try {
                    $Result = New-AzRoleDefinition -InputFile $JSONFile -EA 1
                    Write-Log ($Result|Out-String).Trim() Green $LogFile
                } catch {
                    Write-Log 'Unable to deploy role',$RoleName,'defintion in subscription',$Subscription.Name Magenta,Yellow,Magenta,Yellow $LogFile
                    Write-log " $($_.Exception.Message)" Yellow $LogFile
                }
            }

        }
    } 

    End {  }
}

function Deploy-AzPolicy {

# Requires -Modules AZ
# Requires -Version 5

<#
 .SYNOPSIS
  Function to deploy custom RBAC role definitions in one or more Azure subscriptions
 
 .DESCRIPTION
  Function to deploy custom RBAC role definitions in one or more Azure subscriptions
  This function uses and depends on Az PowerShell module available in the PowerShell gallery
  This function expects to be authenticated to Azure before it's invoked (Connect-AzAccount)
 
 .PARAMETER SubscriptionId
  One or more Azure subscription Ids such as 'abcdabcd-abcd-abcd-abcd-abcdabcdabcd'
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
  Deploy-AzPolicy -SubscriptionId 'abcdabcd-abcd-abcd-abcd-abcdabcdabcd'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 May 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String[]]$SubscriptionId,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Deploy-AzPolicy - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {
        try {
            $AllSubscriptionList = Get-AzSubscription -EA 1 
        } catch {
            Write-Log 'Unable to list subscriptions','are we using AZ module and logged on to Azure?' Magenta,Yellow $LogFile
            break
        } 
        if ($SubscriptionList = $SubscriptionId | where { $_ -in $AllSubscriptionList.Id } | select -Unique) {
            $SubscriptionList = $SubscriptionList | foreach { Get-AzSubscription -SubscriptionId $_ }
            Write-Log 'The following',$SubscriptionList.Count,'subscriptions are found under the current tenant:' Green,Cyan,Green $LogFile
            Write-Log ($SubscriptionList.Name | Out-String).Trim() Cyan $LogFile
        } else {
            Write-Log 'The provided subscription Id(s)','are not found under the current tenant' Magenta,Yellow $LogFile
            break
        }
    }

    Process {        
        foreach ($Subscription in $SubscriptionList) {
            $Subscription | Set-AzContext | Out-Null  
            $JSONFile = New-TemporaryFile

            #region Network Admin
            # Putting the subscription Id in the Role definition name since they must be all unique in AAD !!??
            $RoleName = "Azure Network Admin ($($Subscription.Id.ToCharArray()[-8..-1] -join ''))"
@"
            {
                "Name": "$RoleName",
                "Description": "Manage (Add/modify/delete) network resources",
                "Actions": [
                                "Microsoft.Network/*",
                                "Microsoft.Compute/*/read",
                                "Microsoft.Resources/deployments/*",
                                "Microsoft.Resources/deployments/validate/action",
                                "Microsoft.Resources/subscriptions/resourceGroups/read",
                                "Microsoft.Support/*"
                            ],
                "NotActions": [ ],
                "AssignableScopes": [ "/subscriptions/$($Subscription.Id)" ]
            }
"@
 | Out-File $JSONFile
            try {
                $Result = New-AzRoleDefinition -InputFile $JSONFile -EA 1
                Write-Log ($Result|Out-String).Trim() Green $LogFile
            } catch {
                Write-Log 'Unable to deploy role',$RoleName,'defintion in subscription',$Subscription.Name Magenta,Yellow,Magenta,Yellow $LogFile
                Write-log " $($_.Exception.Message)" Yellow $LogFile
            }
            
            #endregion

            #region Infra Admin
            # Putting the subscription Id in the Role definition name since they must be all unique in AAD !!??
            $RoleName = "Azure Infra Admin ($($Subscription.Id.ToCharArray()[-8..-1] -join ''))"
@"
            {
                "Name": "$RoleName",
                "IsCustom": true,
                "Description": "Access to (Create/Modify/Delete) all Resources except Networking and User Access administration",
                "Actions": [ "*" ],
                "NotActions": [
                        "Microsoft.Authorization/*/Delete",
                        "Microsoft.Authorization/*/Write",
                        "Microsoft.Authorization/elevateAccess/Action",
                        "Microsoft.Network/applicationGateways/delete",
                        "Microsoft.Network/dnsZones/delete",
                        "Microsoft.Network/expressRouteCrossConnections/delete",
                        "Microsoft.Network/expressRouteGateways/delete",
                        "Microsoft.Network/expressRouteCircuits/delete",
                        "Microsoft.Network/expressRoutePorts/delete",
                        "Microsoft.Network/frontDoors/delete",
                        "Microsoft.Network/networkWatchers/delete",
                        "Microsoft.Network/routeFilters/delete",
                        "Microsoft.Network/routeTables/delete",
                        "Microsoft.Network/serviceEndpointPolicies/delete",
                        "Microsoft.Network/trafficManagerProfiles/delete",
                        "Microsoft.Network/virtualNetworkGateways/delete",
                        "Microsoft.Network/loadBalancers/delete",
                        "Microsoft.Network/networkSecurityGroups/delete",
                        "Microsoft.Network/virtualNetworks/delete",
                        "Microsoft.Network/localNetworkGateways/delete",
                        "Microsoft.Network/applicationGateways/write",
                        "Microsoft.Network/dnsZones/write",
                        "Microsoft.Network/expressRouteCrossConnections/write",
                        "Microsoft.Network/expressRouteGateways/write",
                        "Microsoft.Network/expressRouteCircuits/write",
                        "Microsoft.Network/expressRoutePorts/write",
                        "Microsoft.Network/frontDoors/write",
                        "Microsoft.Network/networkWatchers/write",
                        "Microsoft.Network/routeFilters/write",
                        "Microsoft.Network/routeTables/write",
                        "Microsoft.Network/serviceEndpointPolicies/write",
                        "Microsoft.Network/trafficManagerProfiles/write",
                        "Microsoft.Network/virtualNetworkGateways/write",
                        "Microsoft.Network/loadBalancers/write",
                        "Microsoft.Network/networkSecurityGroups/write",
                        "Microsoft.Network/virtualNetworks/write",
                        "Microsoft.Network/localNetworkGateways/write",
                        "Microsoft.Blueprint/blueprintAssignments/write",
                        "Microsoft.Blueprint/blueprintAssignments/delete"
                    ],
                "AssignableScopes": [ "/subscriptions/$($Subscription.Id)" ]
}
"@
 | Out-File $JSONFile
            try {
                $Result = New-AzRoleDefinition -InputFile $JSONFile -EA 1
                Write-Log ($Result|Out-String).Trim() Green $LogFile
            } catch {
                Write-Log 'Unable to deploy role',$RoleName,'defintion in subscription',$Subscription.Name Magenta,Yellow,Magenta,Yellow $LogFile
                Write-log " $($_.Exception.Message)" Yellow $LogFile
            }
            #endregion

        }
    } 

    End { 
    
    }
}

function Assign-AzPolicy {

# Requires -Modules AZ
# Requires -Version 5

<#
 .SYNOPSIS
  Function to assign an Azure Policy definition to an Azure subscription scope
 
 .DESCRIPTION
  Function to assign an Azure Policy definition to an Azure subscription scope
  This function uses and depends on Az PowerShell module available in the PowerShell gallery
  This function expects to be authenticated to Azure before it's invoked (Connect-AzAccount)
 
 .PARAMETER Subscription
  Azure subscription object obtained from Get-AzSubscription Cmdlet of the Az PS module
 
 .PARAMETER PolicyDefinition
  PS Custom object obtained from New-AzPolicyDefinition Cmdlet of the Az PS module
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    Connect-AzAccount
    $Subscription = Get-AzSubscription -SubscriptionName 'My Subscription Name here'
    $PolicyName = 'Policy (Standardization) > Resource Group names start with AZ-'
    # '1234567890123456789012345678901234567890123456789012345678901234' 64 characters max
    $ParameterSet = @{
        Name = $PolicyName
        DisplayName = $PolicyName
        Description = $PolicyName
        Mode = 'All'
        Policy = @'
            {
                "if": {
                    "allOf": [
                        {
                            "field": "type",
                            "equals": "Microsoft.Resources/subscriptions/resourceGroups"
                        },
                        {
                            "not": {
                                "field": "name",
                                "Like": "AZ-*"
                            }
                        },
                    ]
                },
                "then": {
                    "effect": "deny"
                }
            }
'@
        ErrorAction = 1
    }
    $PolicyDefinition = New-AzPolicyDefinition @ParameterSet
    AssignAzPolicy -Subscription $Subscription -PolicyDefinition $PolicyDefinition
 
 .OUTPUTS
    TypeName: System.Management.Automation.PSCustomObject
    Name MemberType Definition
    ---- ---------- ----------
    Equals Method bool Equals(System.Object obj)
    GetHashCode Method int GetHashCode()
    GetType Method type GetType()
    ToString Method string ToString()
    Name NoteProperty string Name=test Policy (Standardization) start with AZ-
    PolicyAssignmentId NoteProperty string PolicyAssignmentId=/subscriptions/f0caexxxx142/providers/Microsoft.Authorization/policyAssignmen...
    Properties NoteProperty System.Management.Automation.PSCustomObject Properties=@{displayName=test Policy (Standardization) start with AZ-; policyDefini...
    ResourceId NoteProperty string ResourceId=/subscriptions/f0caexxxxx142/providers/Microsoft.Authorization/policyAssignments/test ...
    ResourceName NoteProperty string ResourceName=test Policy (Standardization) start with AZ-
    ResourceType NoteProperty string ResourceType=Microsoft.Authorization/policyAssignments
    Sku NoteProperty System.Management.Automation.PSCustomObject Sku=@{name=A0; tier=Free}
    SubscriptionId NoteProperty string SubscriptionId=f0caexxxxx142
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 5 June 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][Microsoft.Azure.Commands.Profile.Models.PSAzureSubscription]$Subscription,
        [Parameter(Mandatory=$true)][PSCustomObject]$PolicyDefinition,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Assign-AzPolicy - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { }

    Process {                
        $ParameterSet = @{
            Name             = $PolicyDefinition.Name 
            DisplayName      = $PolicyDefinition.Name  
            Description      = $PolicyDefinition.Name  
            Scope            = "/subscriptions/$($Subscription.Id)"
            PolicyDefinition = $PolicyDefinition 
            ErrorAction      = 1
        }
        try {
            New-AzPolicyAssignment @ParameterSet 
            Write-Log 'Assigned policy definition',$PolicyDefinition.Name,'in subscription',$Subscription.Name,'to scope',"/subscriptions/$($Subscription.Id)" Green,Cyan,Green,Cyan,Green,Cyan $LogFile
        } catch {
            Write-Log 'Unable to assign policy definition',$PolicyDefinition.Name,'to scope',"/subscriptions/$($Subscription.Id)",'for subscription',$Subscription.Name Magenta,Yellow,Magenta,Yellow,Magenta,Yellow $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
    } 

    End { }
}

function Test-AzVMConnection {

# Requires -Modules Az
# Requires -Version 5

<#
 .SYNOPSIS
  Function to test TCP connectivity between 2 Azure VMs over one or more ports
 
 .DESCRIPTION
  Function to test TCP connectivity between 2 Azure VMs over one or more ports
  This function uses Az PowerShell module available in the PowerShell gallery
  This function will display color-coded console output similar to:
    Testing connectivity from AZ-Jump1-VM (10.5.255.164) to AZ-myApp1SQL-VM (10.6.2.4)
        TCP Port 111 failed
        TCP Port 135 failed
        TCP Port 22 failed
        TCP Port 3389 passed
        TCP Port 25 failed
        TCP Port 80 failed
        TCP Port 443 failed
        TCP Port 5985 passed
        TCP Port 5986 failed
  This function will test connectivity from/to private IPs only not public IPs
  If a source or target VMs has more than 1 NIC, all NICs will be tested
 
 .PARAMETER FromVM
  This is the source VM. This object can be obtained via the Get-AzVM cmdlet
 
 .PARAMETER ToVM
  This is the target VM. This object can be obtained via the Get-AzVM cmdlet
 
 .PARAMETER TCPPortList
  One or more TCP ports.
  If not provided the following ports will be tested:
    TCP Port 111 ==> Linux VM connectivity
    TCP Port 135 ==> Windows VM connectivity
    TCP Port 22 ==> SSH
    TCP Port 3389 ==> RDP
    TCP Port 25 ==> SMTP
    TCP Port 80 ==> HTTP
    TCP Port 443 ==> HTTPS
    TCP Port 5985 ==> PS Remoting (WinRM) over HTTP
    TCP Port 5986 ==> PS Remoting (WinRM) over HTTPS
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
   Test-AzVMConnection -FromVM (Get-AzVM -Name AZ-Jump1-VM) -ToVM (Get-AzVM -Name AZ-myApp1SQL-VM)
 
 .OUTPUTS
  This function returns a PS Custom object similar to:
    SourceComputer SourceIP TargetComputer TargetIP TCPPort CanConnect
    -------------- -------- -------------- -------- ------- ----------
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 111 False
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 135 False
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 22 False
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 3389 True
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 25 False
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 80 False
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 443 False
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 5985 True
    AZ-Jump1-VM 10.5.255.164 AZ-myApp1SQL-VM 10.6.2.4 5986 False
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 June 2019
 
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine]$FromVM,
        [Parameter(Mandatory=$true)][Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine]$ToVM,
        [Parameter(Mandatory=$false)][Int[]]$TCPPortList = @(111,135,22,3389,25,80,443,5985,5986),
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Test-AzVMConnection - $FromVM - $ToVM - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { }

    Process {
        $TempFile = New-TemporaryFile 
        $FromInterfaceNameList = $FromVM.NetworkProfile.NetworkInterfaces.Id | foreach { Split-Path $_ -Leaf }
        $myOutput = foreach ($FromInterfaceName in $FromInterfaceNameList) {
            $FromPrivateIP = (Get-AzNetworkInterface -ResourceGroupName $FromVM.ResourceGroupName -Name $FromInterfaceName).IpConfigurations.PrivateIpAddress
            $ToInterfaceNameList = $ToVM.NetworkProfile.NetworkInterfaces.Id | foreach { Split-Path $_ -Leaf }
            foreach ($ToInterfaceName in $ToInterfaceNameList) {
                $ToPrivateIP = (Get-AzNetworkInterface -ResourceGroupName $ToVM.ResourceGroupName -Name $ToInterfaceName).IpConfigurations.PrivateIpAddress
                Write-Log 'Testing connectivity from',"$($FromVM.Name) ($FromPrivateIP)","to $($ToVM.Name) ($ToPrivateIP)" DarkYellow,Green,Cyan $LogFile 
                foreach ($Port in $TCPPortList) {
                    "Test-SBNetConnection -ComputerName $ToPrivateIP -Port $Port -WA 0" | Out-File $TempFile  
                    $Result = Invoke-AzVMRunCommand -ResourceGroupName $FromVM.ResourceGroupName -Name $FromVM.Name -CommandId 'RunPowerShellScript' -ScriptPath $TempFile
                    if ($Result.Value[0].Message -match 'True') {
                        Write-Log " TCP Port $Port".PadRight(20,' '),'passed' Green,Cyan $LogFile
                        [PSCustomObject]@{
                            SourceComputer = $FromVM.Name
                            SourceIP       = $FromPrivateIP
                            TargetComputer = $ToVM.Name
                            TargetIP       = $ToPrivateIP
                            TCPPort        = $Port
                            CanConnect     = $true
                        }
                    } else {
                        Write-Log " TCP Port $Port".PadRight(20,' '),"failed $($Result.Value[1].Message)" Green,Yellow $LogFile
                        [PSCustomObject]@{
                            SourceComputer = $FromVM.Name
                            SourceIP       = $FromPrivateIP
                            TargetComputer = $ToVM.Name
                            TargetIP       = $ToPrivateIP
                            TCPPort        = $Port
                            CanConnect     = $false
                        }
                    } 
                }
            }
        }
    } 

    End { $myOutput } 
    
}

function Fix-Json {
<#
 .SYNOPSIS
  Function to fix bug with ConvertTo-Json where nested object appear as a hash table - see example
 
 .DESCRIPTION
  Function to fix bug with ConvertTo-Json where nested object appear as a hash table - see example
 
 .PARAMETER FilePath
  Path to JSON File. This is expected to be a file similar in syntax to the example below.
 
 .EXAMPLE
@'
{
    "$schema": "https://schema.management.azure.com/schemas/2018-05-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "ApplicationName": {
            "type": "string",
            "maxLength": 3,
            "metadata": {
                "description": "my desc"
            }
        },
        "plan_name": {
            "type": "String"
        }
    },
    "variables": {
        "resourceNames": {
            "name": "EDSENDGRID06",
            "commonResourceGroup": "[tolower(concat(parameters('ApplicationName'),'-',parameters('Environment'),'-',parameters('shortlocation'),'-',parameters('tenant'),'-rgp-','01'))]"
        },
        "TemplateURLs": {
            "sendgrid": "[concat(parameters('artifacts_baseUri'),'/ArmTemplates/master/Public/lib/linkedTemplates/sendgrid.json')]"
        }
    }
}
'@ | ConvertFrom-Json | ConvertTo-Json
 
    This shows the bug where the 'metadata' object is not represented properly:
        "metadata": "@{description=my desc}"
    instead of it should be:
        "metadata": {
            "description": "my desc"
        }
    as seen in the source input.
 
    This function fixes this issue as in:
$TempFile = New-TemporaryFile
@'
{
    "$schema": "https://schema.management.azure.com/schemas/2018-05-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "ApplicationName": {
            "type": "string",
            "maxLength": 3,
            "metadata": {
                "description": "my desc"
            }
        },
        "plan_name": {
            "type": "String"
        }
    },
    "variables": {
        "resourceNames": {
            "name": "EDSENDGRID06",
            "commonResourceGroup": "[tolower(concat(parameters('ApplicationName'),'-',parameters('Environment'),'-',parameters('shortlocation'),'-',parameters('tenant'),'-rgp-','01'))]"
        },
        "TemplateURLs": {
            "sendgrid": "[concat(parameters('artifacts_baseUri'),'/ArmTemplates/master/Public/lib/linkedTemplates/sendgrid.json')]"
        }
    }
}
'@ | ConvertFrom-Json | ConvertTo-Json | Out-File $TempFile
Fix-Json $TempFile
 
 .LINK
  https://superwidgets.wordpress.com/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 17 July 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,ValueFromPipeLine=$true,ValueFromPipeLineByPropertyName=$true)]
            [ValidateScript({Test-Path $_})][String]$FilePath
    )

    Begin { }

    Process {
        $myOutput = foreach ($Line in (Get-Content $FilePath)) {
            if ($Line -match '@') {
                Write-Log 'Fixing bad line',$Line Green,Cyan 
                $Indent = $Line.Split('"')[0].ToCharArray().Count 
                "$($Line.Split('"')[0])""$($Line.Split('"')[1])""$($Line.Split('"')[2]){"               # Line 1
                $Temp = $Line.Split('"')[3].replace('@','').replace('{','').replace('}','')
                ' ' * ($Indent + 4) + '"' + $Temp.Split('=')[0] + '": "' + $Temp.Split('=')[1] + '"'    # Line 2
                ' ' * $Indent + "}"                                                                     # Line 3
            } else {
                $Line
            }
        }
    }

    End { $myOutput }
}

function Azure-PFC {

<#
 .SYNOPSIS
  Function to perform the following basic validations for using Azure.
 
 .DESCRIPTION
  Function to perform the following basic validations for using Azure:
    - Validate/Install AZ and other PowerShell module(s)
    - Validate connection to Azure
    - Validate Azure subscription (if SubscriptionId is provided)
 
 .PARAMETER SubscriptionId
  Optional parameter of an Azure Subscription Id.
  If provided, this function will validate that the subscription exists
 
 .PARAMETER Module
  Optional parameter that informs this function to validate/install additional PowerShell modules.
  AZ module will always be validated/installed.
  Valid input is one or more of:
    'AZ'
    'AzureAD'
    'AzureADPreview'
    'MSOnline'
    'SharePointPnPPowerShellOnline'
 
 .PARAMETER LogFile
  Path to a file where this function will log its output
 
 .EXAMPLE
    if (-not (Azure-PFC)) {
        Write-Log 'Not connected to Azure, stopping..' Yellow
        break
    }
 
 .OUTPUTS
  Function returns $true if all checks pass or $false if any of the checks fail
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
    v0.1 - 11 February 2020
    v0.2 - 20 October 2020 - Minor updates, exposed externally
    v0.3 - 20 October 2020 - Added validation for additional Azure PowerShell modules
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$false)][String]$SubscriptionId,
        [Parameter(Mandatory=$false)][ValidateSet('AZ','AzureAD','AzureADPreview','MSOnline','SharePointPnPPowerShellOnline')]
            [String[]]$Module = 'AZ',
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Azure-PFC - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { $Go = $true }

    Process {   
                 
        #region Validate/Install PowerShell module(s)
        $ModuleList = @('AZ')
        $ModuleList += $Module
        $ModuleList = $ModuleList | select -Unique
        foreach ($ModuleName in $ModuleList) {
            if ($ModuleName.ToUpper() -eq 'AZ') { $ModuleCheck = 'AZ.*' } else { $ModuleCheck = $ModuleName }
            if (Get-Module $ModuleCheck -ListAvailable -WA 0) { 
                Write-Log 'Validated module',$ModuleName Green,Cyan $LogFile
            } else {
                Write-Log 'PowerShell module',$ModuleName,'is not installed, installing from the PowerShell Gallery..' Yellow,Cyan,Yellow $LogFile -NoNewLine
                try {
                    # PowerShellGallery dropped Ssl3 and Tls as of 1 April 2020
                    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 
                    Install-Module $ModuleName -Scope CurrentUser -AllowClobber -Force -SkipPublisherCheck -EA 1 
                    if (Get-Module $ModuleCheck -ListAvailable) { 
                        Write-Log 'done' Green $LogFile 
                    } else { 
                        Write-Log 'failed' Magenta $LogFile
                        Write-Log $_.Exception.Message Yellow $LogFile
                        $Go = $false 
                    }
                } catch {
                    Write-Log 'failed' Magenta $LogFile
                    Write-Log $_.Exception.Message Yellow $LogFile
                    $Go = $false
                }
            }
        }
        #endregion

        #region Validate connection to Azure
        try {
            Get-AzSubscription -EA 1 | Out-Null
            $AzContext = Get-AzContext 
            Write-Log ' Connected to tenant',$AzContext.Name Green,Cyan $LogFile
        } catch {
            Write-Log $_.Exception.Message Yellow $LogFile
            $Go = $false
        }
        #endregion

        #region Validate Azure subscription (if SubscriptionId is provided)
        if ($SubscriptionId) {
            try {
                Get-AzSubscription -SubscriptionId $SubscriptionId -WA 0 -EA 1 | Set-AzContext | Out-Null 
                $AzContext = Get-AzContext 
                Write-Log 'Now connected to subscription',($AzContext.Name).Split('(')[0].Trim(),'Id',$AzContext.Subscription,'as',$AzContext.Account Green,Cyan,Green,Cyan,Green,Cyan $LogFile
            } catch {
                Write-Log 'Unable to find SubcriptionId',$SubscriptionId Magenta,Yellow $LogFile
                Write-Log 'Available subscriptions:' Yellow $LogFile
                Write-Log (Get-AzSubscription|Out-String).Trim() Yellow $LogFile
                $Go = $false
            }
        }
        #endregion

    } 

    End { $Go }
}

function Deploy-ARMVnet {

<#
 .SYNOPSIS
  Function to Deploy Vnet to Azure subscription via ARM template
 
 .DESCRIPTION
  Function to Deploy Vnet to Azure subscription via ARM template
  This function requires PowerShell version 5 and AZ PowerShell module.
  This function uses API version 2019-09-01 which addresses the issue
  of having to make each subnet dependent on prior subnets - see
  https://github.com/Azure/azure-powershell/issues/1817
  Caution:
  Although ARM templates are deployed in 'incremental mode' by default -
  (https://docs.microsoft.com/en-us/azure/azure-resource-manager/templates/deployment-modes),
  where resources in the template are added to the resource group,
  without deleting resources not specified in the ARM template.
  However, Subnets are considered part of the Vnet resource,
  meaning that this script may delete existing subnets, and
  only subnets specified in the input of this function will remain.
  This function will display verbose details during Template processing.
 
 .PARAMETER SubscriptionId
  Azure subscription Id that can be obtained from Get-AzSubscription Cmdlet of the Az PS module
  This is an optional parameter. If specified, this function will change context to deploy in the specified subscription.
 
 .PARAMETER ResourceGroupName
  Name of the Resource Group where the Vnet will be deployed
 
 .PARAMETER AzureLocation
  Name of the Azure Location where the Vnet will be deployed
  For a list of Azure locations use: "(Get-AzLocation).Location"
  Example: westus
 
 .PARAMETER VnetName
  Name of the Vnet to deploy, example: "Picard_Hub_Vnet"
 
 .PARAMETER VnetPrefix
  IPv4 address space for this Vnet in CIDR notation, example: "10.11.0.0/16"
 
 .PARAMETER DdosProtection
  This is a switch that defaults to False.
  The False setting enables 'Basic DDoS Protection'
  The True setting enables 'Standard DDoS Protection'
  See https://docs.microsoft.com/en-us/azure/virtual-network/ddos-protection-overview for more details
 
 .PARAMETER ShowTemplate
  This is a switch that defaults to False.
  When set to True, this function will display the resulting ARM template in notepad and
  will also make it part of the script log file
 
 .PARAMETER SubnetList
  This is an optional parameter that has information on one or more subnets to be provisioned within this Vnet.
  Only Subnets listed here will remain in the Vnet when this function is invoked.
  For example, if subnets sub1 and sub2 are specified here, and the Vnet exists with subnets sub1 and sub3,
  when this function is invoked sub3 will be deleted and sub2 will be added.
  If no value is provided for this parameter, all existing subnets will be removed from this Vnet.
  Example (1 subnet):
    $SubnetList = @{ Name = 'Hub_Gateway_Subnet'; Prefix = '10.11.0.0/27' }
  Example (3 subnets):
    $SubnetList = @(
        @{ Name = 'Hub_Gateway_Subnet'; Prefix = '10.11.0.0/27' }
        @{ Name = 'Hub_NVA_Subnet'; Prefix = '10.11.0.32/27' }
        @{ Name = 'Hub_Infra_Subnet'; Prefix = '10.11.0.64/27' }
    )
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    Connect-AzAccount # To connect to Azure tenant
    $Subscription = Get-AzSubscription -SubscriptionName 'My Subscription Name here'
    $ParameterSet = @{
        SubscriptionId = $Subscription.Id
        ResourceGroupName = 'MyOrg_Hub_RG'
        AzureLocation = 'centralus'
        VnetName = 'MyOrg_Hub_Vnet'
        VnetPrefix = '10.11.0.0/16'
        SubnetList = @(
            @{ Name = 'Hub_Gateway_Subnet'; Prefix = '10.11.0.0/27' }
            @{ Name = 'Hub_NVA_Subnet'; Prefix = '10.11.0.32/27' }
            @{ Name = 'Hub_Infra_Subnet'; Prefix = '10.11.0.64/27' }
        )
        DdosProtection = $false
        ShowTemplate = $true
    }
    Deploy-ARMVnet @ParameterSet
 
 .OUTPUTS
    None
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 11 February 2020
#>


    [CmdletBinding(ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$false, HelpMessage='Azure Subscription Id, Use "help Deploy-ARMVnet -Show" for more details')]
            [String]$SubscriptionId,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Resource Group where this Vnet will be deployed, example: "Picard_Hub_RG"')]
            [String]$ResourceGroupName,
        [Parameter(Mandatory=$true, HelpMessage='For a list of Azure locations use: "(Get-AzLocation).Location"')]
            [String]$AzureLocation,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Vnet to deploy, example: "Picard_Hub_Vnet"')]
            [String]$VnetName,
        [Parameter(Mandatory=$true, HelpMessage='IPv4 address space for this Vnet in CIDR notation, example: "10.11.0.0/16"')]
            [String]$VnetPrefix,
        [Parameter(Mandatory=$false, HelpMessage='True or False, Use "help Deploy-ARMVnet -Show" for more details')]
            [Switch]$DdosProtection = $false,
        [Parameter(Mandatory=$false, HelpMessage='True or False, Use "help Deploy-ARMVnet -Show" for more details')]
            [Switch]$ShowTemplate = $false,
        [Parameter(Mandatory=$false, HelpMessage='One or more Subnets, Use "help Deploy-ARMVnet -Show" to see example')]
            [Hashtable[]]$SubnetList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Deploy-ARMVnet - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        if (-not (Azure-PFC -SubscriptionId $SubscriptionId -LogFile $LogFile)) { break }
    }

    Process {                
        try {
            New-AzResourceGroup -Name $ResourceGroupName -Location $AzureLocation -Force -EA 1 | Out-Null
            Write-Log 'Created/Validated Resource Group',$ResourceGroupName Green,Cyan $LogFile
        } catch {
            Write-Log 'Failed to create Resource Group',$ResourceGroupName Magenta,Yellow $LogFile; break
        }

        #region Build ARM template
        $TemplateFile = New-TemporaryFile 
        @'
{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
'@
 | Out-File $TemplateFile 
        @"
    "parameters": {
        "vnetName": {
            "type": "string",
            "DefaultValue": "$VnetName",
        },
        "location": {
            "type": "string",
            "DefaultValue": "$AzureLocation",
        },
        "resourceGroup": {
            "type": "string",
            "DefaultValue": "$ResourceGroupName",
        },
        "vnetAddressPrefix": {
            "type": "string",
            "DefaultValue": "$VnetPrefix",
        },
        "enableDdosProtection": {
            "type": "bool",
            "DefaultValue": $(if ($DdosProtection) { 'true' } else { 'false' }),
        },
"@
 | Out-File $TemplateFile -Append
        $n = 0
        foreach ($Subnet in $SubnetList) {
            $n++
            @"
        "subnet$($n)Name": {
            "type": "string",
            "DefaultValue": "$($Subnet.Name)",
        },
        "subnet$($n)Prefix": {
            "type": "string",
            "DefaultValue": "$($Subnet.Prefix)",
        },
"@
 | Out-File $TemplateFile -Append
        }
        @"
    },
    "resources": [
        {
            "apiVersion": "2019-09-01",
            "name": "[parameters('vnetName')]",
            "type": "Microsoft.Network/virtualNetworks",
            "location": "[parameters('location')]",
            "properties": {
                "addressSpace": {
                    "addressPrefixes": [
                        "[parameters('vnetAddressPrefix')]"
                    ]
                },
                "subnets": [
"@
 | Out-File $TemplateFile -Append
        $n = 0
        foreach ($Subnet in $SubnetList) {
            $n++
                @"
                    {
                        "name": "[parameters('subnet$($n)Name')]",
                        "properties": {
                            "addressPrefix": "[parameters('subnet$($n)Prefix')]",
                            "addressPrefixes": []
                        }
                    },
"@
 | Out-File $TemplateFile -Append
        }
        @"
                ],
                "enableDdosProtection": "[parameters('enableDdosProtection')]"
            }
        }
    ]
}
"@
 | Out-File $TemplateFile -Append
        #endregion

        if ($ShowTemplate) {
            Write-Log (Get-Content $TemplateFile | Out-String) Green $LogFile
            notepad $TemplateFile
        }

        try {
            New-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $TemplateFile -Verbose -EA 1 | Out-Null
        } catch {
            Write-Log 'failed' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
    } 

    End { }
}

function Deploy-ARMNIC {

<#
 .SYNOPSIS
  Function to Deploy a network interface to Azure subscription via ARM template
 
 .DESCRIPTION
  Function to Deploy network interface to Azure subscription via ARM template
  This function requires PowerShell version 5 and AZ PowerShell module.
  This is typically done before deploying a VM (Virtual Machine)
  This function will display verbose details during Template processing.
 
 .PARAMETER SubscriptionId
  Azure subscription Id that can be obtained from Get-AzSubscription Cmdlet of the Az PS module
  This is a required parameter. This function will change context to deploy in the specified subscription.
 
 .PARAMETER ResourceGroupName
  Name of the Resource Group where the NIC will be deployed
 
 .PARAMETER AzureLocation
  Name of the Azure Location where the NIC will be deployed
  NIC must be deployed in the same Azure location where the VNet is
  For a list of Azure locations use: "(Get-AzLocation).Location"
  Example: westus
 
 .PARAMETER NICName
  Name of the NIC to deploy, example: "Picard-DC01-NIC"
 
 .PARAMETER VnetName
  Name of the Vnet to deploy this NIC into, example: "Picard_Hub_Vnet"
 
 .PARAMETER SubnetName
  Name of the Subnet to deploy this NIC into, example: "Hub_Infra_Subnet"
 
 .PARAMETER ShowTemplate
  This is a switch that defaults to False.
  When set to True, this function will display the resulting ARM template in notepad and
  will also make it part of the script log file
 
 .PARAMETER TagList
  Zero or more tags, each in a hashtable containing Name and Value keys - see example below
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    Connect-AzAccount # To connect to Azure tenant
    $Subscription = Get-AzSubscription -SubscriptionName 'My Subscription Name here'
    $ParameterSet = @{
        SubscriptionId = $Subscription.Id
        ResourceGroupName = 'Picard_Hub_RG'
        AzureLocation = 'centralus'
        NICName = 'Picard-DC01-NIC'
        VnetName = 'Picard_Hub_Vnet'
        SubnetName = 'Hub_Infra_Subnet'
        ShowTemplate = $true
        TagList = @(
            @{ Name = 'Owner'; Value = 'Sam Boutros' }
            @{ Name = 'CostCenter'; Value = 'My Azure Demo' }
            @{ Name = 'DateProvisioned'; Value = $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt') }
        )
    }
    Deploy-ARMNIC @ParameterSet
 
 .OUTPUTS
    None
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 February 2020
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true, HelpMessage='Azure Subscription Id, Use "help Deploy-ARMNIC -Show" for more details')]
            [String]$SubscriptionId,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Resource Group where this NIC will be deployed, example: "Picard_Hub_RG"')]
            [String]$ResourceGroupName,
        [Parameter(Mandatory=$true, HelpMessage='For a list of Azure locations use: "(Get-AzLocation).Location"')]
            [String]$AzureLocation,
        [Parameter(Mandatory=$true, HelpMessage='Name of the NIC to deploy, example: "Picard-DC01-NIC"')]
            [String]$NICName,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Vnet to attach this NIC to, example: "Picard_Hub_Vnet"')]
            [String]$VnetName,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Subnet to attach this NIC to, example: "Hub_Infra_Subnet"')]
            [String]$SubnetName,
        [Parameter(Mandatory=$false, HelpMessage='True or False, Use "help Deploy-ARMNIC -Show" for more details')]
            [Switch]$ShowTemplate = $false,
        [Parameter(Mandatory=$false, HelpMessage='One or more Tags, Use "help Deploy-ARMNIC -Show" to see example')]
            [Hashtable[]]$TagList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Deploy-ARMNIC - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        if (-not (Azure-PFC -SubscriptionId $SubscriptionId -LogFile $LogFile)) { break }
    }

    Process {                
        try {
            New-AzResourceGroup -Name $ResourceGroupName -Location $AzureLocation -Force -EA 1 | Out-Null
            Write-Log 'Created/Validated Resource Group',$ResourceGroupName Green,Cyan $LogFile
        } catch {
            Write-Log 'Failed to create Resource Group',$ResourceGroupName Magenta,Yellow $LogFile; break
        }

        #region Build ARM template
        $TemplateFile = New-TemporaryFile 
        @'
{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
'@
 | Out-File $TemplateFile 
        @"
    "parameters": {
        "networkInterfaceName": {
            "type": "string",
            "defaultvalue": "$NICName"
        },
        "location": {
            "type": "string",
            "defaultvalue": "$AzureLocation"
        },
        "subnetId": {
            "type": "string",
            "defaultvalue": "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Network/virtualNetworks/$VnetName/subnets/$SubnetName"
        },
        "privateIPAllocationMethod": {
            "type": "string",
            "defaultvalue": "Dynamic"
        }
    },
    "resources": [
        {
            "name": "[parameters('networkInterfaceName')]",
            "type": "Microsoft.Network/networkInterfaces",
            "apiVersion": "2019-07-01",
            "location": "[parameters('location')]",
            "dependsOn": [],
            "properties": {
                "ipConfigurations": [
                    {
                        "name": "ipconfig1",
                        "properties": {
                            "privateIpAddressVersion": "IPv4",
                            "privateIPAllocationMethod": "[parameters('privateIPAllocationMethod')]",
                            "subnet": {
                                "id": "[parameters('subnetId')]"
                            }
                        }
                    }
                ]
            },
"@
 | Out-File $TemplateFile -Append
        if ($TagList) {
            @'
            "tags": {
'@
 | Out-File $TemplateFile -Append
            foreach ($Tag in $TagList) {
                @"
                "$($Tag.Name)": "$($Tag.Value)",
"@
 | Out-File $TemplateFile -Append
            }
            @'
            }
'@
 | Out-File $TemplateFile -Append            
        }
@"
        }
    ]
}
"@
 | Out-File $TemplateFile -Append
        #endregion

        if ($ShowTemplate) {
            Write-Log (Get-Content $TemplateFile | Out-String) Green $LogFile
            notepad $TemplateFile
        }

        try {
            New-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $TemplateFile -Verbose -EA 1 | Out-Null
        } catch {
            Write-Log 'failed' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
    } 

    End { }
}

function Deploy-ARMStorageAccount {

<#
 .SYNOPSIS
  Function to Deploy a Storage Account to Azure subscription via ARM template
 
 .DESCRIPTION
  Function to Deploy Storage Account to Azure subscription via ARM template
  This function requires PowerShell version 5 and AZ PowerShell module.
  This is typically done before deploying a VM (Virtual Machine)
  This function will display verbose details during Template processing.
 
 .PARAMETER SubscriptionId
  Azure subscription Id that can be obtained from Get-AzSubscription Cmdlet of the Az PS module
  This is an optional parameter. This function will change context to deploy in the specified subscription.
 
 .PARAMETER ResourceGroupName
  Name of the Resource Group where the Storage Account will be deployed
 
 .PARAMETER AzureLocation
  Name of the Azure Location where the Storage Account will be deployed
  For a list of Azure locations use: "(Get-AzLocation).Location"
  Example: westus
 
 .PARAMETER storageAccountName
  Name of the Storage Account to deploy, example: "picard02122020"
  Storage account names must be between 3 and 24 characters in length and may contain numbers and lowercase letters only.
  Storage account name must be unique within Azure. No two storage accounts can have the same name.
  See https://docs.microsoft.com/en-us/azure/storage/common/storage-account-overview#naming-storage-accounts
 
 .PARAMETER storageAccountType
  As of 12 February 2020, this can be either:
    Premium_LRS
    Premium_ZRS
    Standard_LRS
    Standard_ZRS
    Standard_GRS
    Standard_RAGRS
  This is an optional parameter that defaults to Standard_LRS
 
 .PARAMETER storageAccountKind
  As of 12 February 2020, this can be either:
    BlobStorage
    BlockBlobStorage
    FileStorage
    Storage
    StorageV2
  This is an optional parameter that defaults to StorageV2
 
 .PARAMETER ShowTemplate
  This is a switch that defaults to False.
  When set to True, this function will display the resulting ARM template in notepad and
  will also make it part of the script log file
 
 .PARAMETER TagList
  Zero or more tags, each in a hashtable containing Name and Value keys - see example below
 
 .PARAMETER LogFile
  This is an optional parameter that specifies the path to the log file where the script logs its progress
  This defaults to a file in the current folder where the script is running
 
 .EXAMPLE
    Connect-AzAccount # To connect to Azure tenant
    $Subscription = Get-AzSubscription -SubscriptionName 'My Subscription Name here'
    $ParameterSet = @{
        SubscriptionId = $Subscription.Id
        ResourceGroupName = 'Picard_Hub_RG'
        AzureLocation = 'centralus'
        storageAccountName = "picardhubdisks$(Get-Random -Minimum 111111 -Maximum 999999)"
        ShowTemplate = $true
        TagList = @(
            @{ Name = 'Owner'; Value = 'Sam Boutros' }
            @{ Name = 'CostCenter'; Value = 'My Azure Demo' }
            @{ Name = 'DateProvisioned'; Value = $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt') }
        )
    }
    Deploy-ARMStorageAccount @ParameterSet
 
 .OUTPUTS
    None
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 February 2020
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false, HelpMessage='Azure Subscription Id, Use "help Deploy-ARMStorageAccount -Show" for more details')]
            [String]$SubscriptionId,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Resource Group where this Storage Account will be deployed, example: "Picard_Hub_RG"')]
            [String]$ResourceGroupName,
        [Parameter(Mandatory=$true, HelpMessage='For a list of Azure locations use: "(Get-AzLocation).Location"')]
            [String]$AzureLocation,
        [Parameter(Mandatory=$true, HelpMessage='Name of the Storage Account to deploy, example: "picard02122020"')]
            [String]$storageAccountName,
        [Parameter(Mandatory=$false, HelpMessage='The type of storage account, example: "Standard_LRS"')]
            [ValidateSet('Premium_LRS','Premium_ZRS','Standard_LRS','Standard_ZRS','Standard_GRS','Standard_RAGRS')]
            [String]$storageAccountType = 'Standard_LRS',
        [Parameter(Mandatory=$false, HelpMessage='The kind of storage account, example: "StorageV2"')]
            [ValidateSet('BlobStorage','BlockBlobStorage','FileStorage','Storage','StorageV2')]
            [String]$storageAccountKind = 'StorageV2',
        [Parameter(Mandatory=$false, HelpMessage='True or False, Use "help Deploy-ARMStorageAccount -Show" for more details')]
            [Switch]$ShowTemplate = $false,
        [Parameter(Mandatory=$false, HelpMessage='One or more Tags, Use "help Deploy-ARMStorageAccount -Show" to see example')]
            [Hashtable[]]$TagList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Deploy-ARMStorageAccount - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        if (-not (Azure-PFC -SubscriptionId $SubscriptionId -LogFile $LogFile)) { break }
    }

    Process {                
        try {
            New-AzResourceGroup -Name $ResourceGroupName -Location $AzureLocation -Force -EA 1 | Out-Null
            Write-Log 'Created/Validated Resource Group',$ResourceGroupName Green,Cyan $LogFile
        } catch {
            Write-Log 'Failed to create Resource Group',$ResourceGroupName Magenta,Yellow $LogFile; break
        }

        #region Build ARM template
        $TemplateFile = New-TemporaryFile 
        @'
{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
'@
 | Out-File $TemplateFile 
        @"
    "parameters": {
        "location": {
            "type": "string",
            "defaultvalue": "$AzureLocation"
        },
        "storageAccountName": {
            "type": "string",
            "defaultvalue": "$storageAccountName"
        },
        "storageAccountType": {
            "type": "string",
            "defaultvalue": "$storageAccountType"
        },
        "storageAccountKind": {
            "type": "string",
            "defaultvalue": "$storageAccountKind"
        }
    },
    "resources": [
        {
            "name": "[parameters('storageAccountName')]",
            "type": "Microsoft.Storage/storageAccounts",
            "apiVersion": "2019-06-01",
            "location": "[parameters('location')]",
            "properties": {},
            "kind": "[parameters('storageAccountKind')]",
            "sku": {
                "name": "[parameters('storageAccountType')]"
            },
"@
 | Out-File $TemplateFile -Append
        if ($TagList) {
            @'
            "tags": {
'@
 | Out-File $TemplateFile -Append
            foreach ($Tag in $TagList) {
                @"
                "$($Tag.Name)": "$($Tag.Value)",
"@
 | Out-File $TemplateFile -Append
            }
            @'
            }
'@
 | Out-File $TemplateFile -Append            
        }
@"
        }
    ]
}
"@
 | Out-File $TemplateFile -Append
        #endregion

        if ($ShowTemplate) {
            Write-Log (Get-Content $TemplateFile | Out-String) Green $LogFile
            notepad $TemplateFile
        }

        try {
            New-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -TemplateFile $TemplateFile -Verbose -EA 1 | Out-Null
        } catch {
            Write-Log 'failed' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
    } 

    End { }
}

function New-AzureSPSecret {
<#
 .SYNOPSIS
  Function to create a new secret for a given Azure Service Principal.
 
 .DESCRIPTION
  Function to create a new secret (Password/Key) for a given Azure Service Principal.
  This function depends on and requires the AZ and AzureAD PowerShell modules.
 
 .PARAMETER ServicePrincipalId
  This is a required parameter.
  This can be obtained via the cmdlet Get-AzADServicePrincipal.
 
 .PARAMETER SecretDays
  This is an optional parameter that defaults to 365 days.
  This is used to set the expiration date of the new secret.
  Valid values are from 1 to 7305 days (20 years).
 
 .PARAMETER RemoveExisting
  When this optional parameter is set to True, this function will delete all existing
  secrets for this Service Principal.
 
 .PARAMETER Length
  This is an optional parameter that defaults to 24.
  This is used to determine how long the password will be.
  Valid values are from 2 to 256.
 
 .PARAMETER Include
  This optional parameter determines which characters are used to create the random password.
  Valid values are one or more of: 'UpperCase','LowerCase','Numbers','SpecialCharacters'.
  When not provided, the password will contain characters from all four groups.
  For example, if 'UpperCase' is only provided, the password will contain upper case letters only.
 
 .PARAMETER CodeFriendly
  This optional parameter defaults to True.
  It excludes the following 4 characters from the 'SpecialCharacters' list of the password
  " ==> ASCII 34
  $ ==> ASCII 36
  ' ==> ASCII 39
  ` ==> ASCII 96
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output.
 
 .EXAMPLE
    $SP = Get-AzADServicePrincipal -DisplayName SamTestSP01
    $mySP = New-AzureSPSecret -ServicePrincipalId $SP.Id
    $mySP.Secret
  This example will create new secret, and output a list of all this SP secrets.
  Only the new secret value (password) will be displayed.
 
 .EXAMPLE
    $SP = Get-AzADServicePrincipal -DisplayName SamTestSP01
    $mySP = New-AzureSPSecret -ServicePrincipalId $SP.Id -RemoveExisting
    $mySP.Secret
  This example will create new secret, deletes all existing secrets for this SP if any,
  and output the new secret including its value (password).
 
 .OUTPUTS
  This cmdlet returns a collection of objects - one for each secret similar to:
    KeyId Expires Secret
    ----- ------- ------
    a85f7748-2203-49f8-937a-843cbe40c720 21 October 2021 xywd5\CevjK2E-}{:Vgr!/(9
    1b6ac347-48fc-40d4-9d4d-aa0303ff536b 21 October 2021
    eb2d74b3-5740-48f6-b7a5-059567c02266 21 October 2021
    623efb41-74b5-4777-bd45-91174a8776ed 21 October 2021
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 October 2020
#>


    [CmdletBinding(ConfirmImpact='High')]
    Param(
        [Parameter(Mandatory=$true)][String]$ServicePrincipalId,
        [Parameter(Mandatory=$false)][ValidateRange(1,7305)][String]$SecretDays = 365,
        [Parameter(Mandatory=$false)][Switch]$RemoveExisting,
        [Parameter(Mandatory=$false)][ValidateRange(2,256)][Int32]$Length = 24, 
        [Parameter(Mandatory=$false)][ValidateSet('UpperCase','LowerCase','Numbers','SpecialCharacters')]
            [String[]]$Include = @('UpperCase','LowerCase','Numbers','SpecialCharacters'),
        [Parameter(Mandatory=$false)][Switch]$CodeFriendly = $true,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\New-AzureSPSecret_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        if (-not (Azure-PFC -Module AzureAD -LogFile $LogFile)) {
            Write-Log 'Not connected to Azure, stopping..' Yellow $LogFile
            break
        }

        try {
            $SP = Get-AzADServicePrincipal -ObjectId $ServicePrincipalId -EA 1 
            Write-Log 'Validated Azure Service Principal:' Green $LogFile
            ($SP | Get-Member -MemberType Properties).Name | foreach {
                Write-Log $(if ($SP.$_) {" $($_.PadRight(25)) : $($SP.$_ -join ', ')"}) Cyan $LogFile
            } 
        } catch {
            Write-Log 'Unable to validate the provided Service Principal Id',$ServicePrincipalId Magenta,Yellow $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }
    }

    Process {     

        #region Remove Existing Secret(s)
        <#
        Unfortuantely, as of 20 October, 2020, the default 1 year Secret created via
            $sp = New-AzADServicePrincipal -DisplayName ServicePrincipalName
        which can be viewed via
            $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($sp.Secret)
            $UnsecureSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
        as outlined in https://docs.microsoft.com/en-us/powershell/azure/create-azure-service-principal-azureps
        is not visible via either
            Get-AzureADServicePrincipalPasswordCredential
            Get-AzADServicePrincipalCredential
        which makes deleting it not possible at this time
        #>
 

        if ($RemoveExisting) {
            try {
                $SecretList = Get-AzureADServicePrincipalPasswordCredential -ObjectId $SP.Id -EA 1 
                foreach ($Secret in $SecretList) {
                    try {
                        Remove-AzureADServicePrincipalPasswordCredential -ObjectId $SP.Id -KeyId $Secret.KeyId -EA 1 
                        Write-Log 'Removed Service Principal Secret:' Green $LogFile
                        Write-Log ($Secret | Out-String).Trim() Cyan $LogFile 
                    } catch {
                        Write-Log 'Failed to remove Service Principal Secret:' Magenta $LogFile
                        Write-Log ($Secret | Out-String).Trim() Yellow $LogFile 
                        Write-Log $_.Exception.Message Yellow $LogFile
                    }
                }
            } catch {
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }
        }

        #endregion

        #region Add new Secret

        $SecretList = Get-AzureADServicePrincipalPasswordCredential -ObjectId $SP.Id

        $ParameterSet = @{
            ObjectId    = $SP.Id
            StartDate   = Get-Date
            EndDate     = (Get-Date).AddDays($SecretDays)
            Value       = New-Password -CodeFriendly:$CodeFriendly -Length $Length -Include $Include
            ErrorAction = 'STOP'
        }

        try {
            $Result = New-AzureADServicePrincipalPasswordCredential @ParameterSet
            $NewSecret = Get-AzureADServicePrincipalPasswordCredential -ObjectId $SP.Id | 
                where { $_.KeyId -notin $SecretList.KeyId }
            $SecretList = Get-AzureADServicePrincipalPasswordCredential -ObjectId $SP.Id
            Write-Log 'Added new Secret, expiring',$Result.EndDate Green,Cyan $LogFile
            New-Object -TypeName PSObject -Property ([Ordered]@{
                DisplayName   = $SP.DisplayName
                Id            = $SP.Id
                ApplicationId = $SP.ApplicationId
                Secret        = $(
                    foreach ($Secret in $SecretList) {
                        $Secret | select KeyId,
                            @{n='Expires';e={Get-Date($_.EndDate) -Format 'dd MMMM yyyy'}},
                            @{n='Secret' ;e={if ($_.KeyId -eq $NewSecret.KeyId) { $ParameterSet.Value }}}
                    } 
                )
            })
        } catch {
            Write-Log 'Failed to add new Secret:' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
        #endregion

    }

    End {  }
} 

function Convert-ObjectGuid2ImmutableId {
<#
 .SYNOPSIS
  Function to convert (Active Directory) Object Guid to (Azure Active Directory) Immutable Id
 
 .DESCRIPTION
  Function to convert (Active Directory) Object Guid to (Azure Active Directory) Immutable Id
 
 .PARAMETER ObjectGuid
  This is a required parameter of type System.Guid
  This can be obtained by
    (Get-ADUser samb).ObjectGuid
 
 .EXAMPLE
  Convert-ObjectGuid2ImmutableId (New-Guid)
 
 .OUTPUTS
  This cmdlet returns a base 64 encoded string like QBLtiithN0yENM4ji3SYjw==
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 10 December 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param( [Parameter(Mandatory=$true)][Guid]$ObjectGuid )

    Begin {  }

    Process { [Convert]::ToBase64String($ObjectGuid.ToByteArray()) }

    End {  }
} 

function Convert-ImmutableId2ObjectGuid {
<#
 .SYNOPSIS
  Function to convert (Azure Active Directory) Immutable Id to (Active Directory) Object Guid
 
 .DESCRIPTION
  Function to convert (Azure Active Directory) Immutable Id to (Active Directory) Object Guid
 
 .PARAMETER ObjectGuid
  This is a required parameter. It should be a base 64 encoded string of a Guid.
  This can be obtained by
    Convert-ObjectGuid2ImmutableId (New-Guid)
 
 .EXAMPLE
  Convert-ImmutableId2ObjectGuid lg6ze0F/fkO2kImEstdJgA==
 
 .EXAMPLE
  Convert-ImmutableId2ObjectGuid (Convert-ObjectGuid2ImmutableId (New-Guid))
  This is the same thing as New-Guid but it illustrates the function use..
 
 .OUTPUTS
  This cmdlet returns a Guid like 7bb30e96-7f41-437e-b690-8984b2d74980
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 10 December 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param( [Parameter(Mandatory=$true)][String]$ImmutableId )

    Begin {  }

    Process { 
        try {
            [Guid]([Convert]::FromBase64String($ImmutableId))
        } catch {
            Write-Log 'Convert-ImmutableId2ObjectGuid Error: bad input received',$ImmutableId,
                'expecting a base 64 encoded string like','QBLtiithN0yENM4ji3SYjw==' Magenta,Yellow,Magenta,Yellow
        }
    }

    End {  }
} 

function Remove-AzureUserProxyAddresses {
<#
 .SYNOPSIS
  Function to delete unwanted proxy addresses from an Azure user
 
.DESCRIPTION
  Function to delete unwanted proxy addresses from an Azure user that is synchronized from an on-premises AD user via ADConnect
  This function depends on and requires the following PowerShell modules:
  ActiveDirectory
  AzureAD
  MsOnline
 
.PARAMETER SamAccountName
  This is a required parameter. It is the samAccountName of the AD user such as 'abcdef'.
 
.PARAMETER LogFile
  This is an optional parameter. It's a path to a file where this script saves time-stamped entries of its console output.
  If not provided, it defaults to a file in the current folder.
 
.EXAMPLE
  Remove-AzureUserProxyAddresses -samAccountName 'abcdef'
 
.OUTPUTS
  None
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
.NOTES
  Function by Sam Boutros
  v0.1 - 23 March 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$True)][String]$samAccountName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Remove-AzureUserProxyAddresses_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {
        Write-Warning 'Please ensure that the AD Sync Scheduler is stopped before running this script.'
        Write-Warning 'On your ADConnect server run:'
        Write-Warning 'Set-ADSyncScheduler -SyncCycleEnabled $false'
        Write-Warning 'You will also need to stop or wait for any synchronization in progress (In the Synchronization Service Manager GUI tool).'

        try {
            Get-MsolUser -MaxResults 1 -EA 1 | Out-Null
            Write-Log 'Validated connection to Microsoft Online Service.' Green $LogFile
        } catch {
            Write-Log $_.Exception.Message Yellow $LogFile
            Connect-MsolService | Out-Null
        }

        try {
            Get-AzureADUser -Top 1 -EA 1 | Out-Null
            Write-Log 'Validated connection to Azure AD.' Green $LogFile
        } catch {
            Write-Log $_.Exception.Message Yellow $LogFile
            Connect-AzureAD | Out-Null
        }

        try {
            $ADUser = Get-ADUser -Identity $samAccountName -Properties proxyaddresses,objectguid -EA 1
            Write-Log 'Identified AD user',"'$($ADUser.DisplayName)' ($($ADUser.DistinguishedName))" Green,Cyan $LogFile
        } catch {
            Write-Log 'Remove-AzureUserProxyAddresses Error: User samAccountName',$samAccountName,'not found' Magenta,Yellow,Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }       

        if (-not ($AzureUser = Get-AzureADUser -Filter "ImmutableId eq '$(Convert-ObjectGuid2ImmutableId -ObjectGuid $ADUser.ObjectGUID)'")) {
            Write-Log 'Remove-AzureUserProxyAddresses Error:','Azure user with ImmutableId',(Convert-ObjectGuid2ImmutableId -ObjectGuid $ADUser.ObjectGUID),'corresponding to AD user with ObjectGUID',$ADUser.ObjectGUID,'not found' Magenta,Yellow,Magenta,Yellow,Magenta,Yellow $LogFile
            break
        }

        if ($ExtraProxyAddressLit = (compare $AzureUser.proxyaddresses $ADUser.proxyaddresses | where SideIndicator -EQ '<=').InputObject) {
            Write-Log 'Identified the following proxy addresses to be removed' Green $LogFile
            $ExtraProxyAddressLit | foreach {  Write-Log " $_ " Cyan $LogFile }
        } else {
            Write-Log 'No extra proxy addresses found in the Azure user that don''t exist in the AD user' Yellow $LogFile
            break
        }
    }

    Process {  

        #region Soft delete the user
        Write-Log 'Deleting the user',$AzureUser.UserPrincipalName Green,Cyan $LogFile -NoNewLine
        try {
            Remove-Msoluser -UserPrincipalName $AzureUser.UserPrincipalName -Force -EA 1
            Write-Log 'done' DarkYellow -NoNewLine
        } catch { }
        try {
            Get-MsolUser -UserPrincipalName $AzureUser.UserPrincipalName -EA 1 | Out-Null
            Write-Log 'Remove-AzureUserProxyAddresses Error:','failed to delete user - UPN:',$AzureUser.UserPrincipalName Magenta,Yellow,Magenta $LogFile
            break
        } catch {
            Write-Log 'and validated' Green $LogFile
        }
        #endregion

        #region Create temp cloud user(s) with the email addresses to be removed
        $PasswordProfile = New-Object -TypeName Microsoft.Open.AzureAD.Model.PasswordProfile
        $PasswordProfile.Password = New-Password -Length 16 -Include LowerCase,UpperCase,Numbers   # This temporary user will be deleted as part of this script
        foreach ($UserUPN in $ExtraProxyAddressLit) {
            $UserUPN = ($UserUPN -split ':')[1]
            Write-Log 'Creating temp user',$UserUPN Green,Cyan $LogFile -NoNewLine
            try {
                New-AzureADUser -AccountEnabled $True -DisplayName $AzureUser.DisplayName -PasswordProfile $PasswordProfile -MailNickName $AzureUser.MailNickName -UserPrincipalName $UserUPN -EA 1 | Out-Null
                Write-Log 'done' DarkYellow $LogFile
            } catch {
                Write-Log 'failed' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                # break
            }

            # To add proxy addresses, assign a license
            Start-Sleep -Seconds 2
            Set-AzureADUser -ObjectID $UserUPN -UsageLocation 'US'
            Start-Sleep -Seconds 2
            Set-MsolUserLicense -UserPrincipalName $UserUPN -AddLicenses 'cignatlp:STANDARDPACK'
        }
        #endregion

        #region Restore the user
        Write-Log 'Restoring the user',$AzureUser.UserPrincipalName Green,Cyan $LogFile -NoNewLine
        try {
            Restore-MsolUser -UserPrincipalName $AzureUser.UserPrincipalName -AutoReconcileProxyConflicts -EA 1 | Out-Null
            Write-Log 'done' DarkYellow $LogFile
        } catch {
            Write-Log 'failed' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
        Start-Sleep -Seconds 3
        $NewUser = Get-AzureADUser -Filter "UserPrincipalName eq '$($AzureUser.UserPrincipalName)'"
        Write-Log 'New Proxy Address list:' Green $LogFile
        Write-Log ($NewUser.ProxyAddresses -match 'smtp:' | sort | Out-String).Trim() Cyan $LogFile
        #endregion

        # Re-enable ADConnect scheduler
        Write-Warning 'Now re-enable the AD Sync Scheduler.'
        Write-Warning 'On your ADConnect server run:'
        Write-Warning 'Set-ADSyncScheduler -SyncCycleEnabled $true'

        #region Clean up the temp cloud user(s)
        foreach ($UserUPN in $ExtraProxyAddressLit) {
            $UserUPN = ($UserUPN -split ':')[1]
            Write-Log 'Removing temp user',$UserUPN Green,Cyan $LogFile -NoNewLine
            Set-MsolUserLicense -UserPrincipalName $UserUPN -RemoveLicenses 'cignatlp:STANDARDPACK' -EA 0
            Start-Sleep -Seconds 3
            Get-AzureADUser -Filter "UserPrincipalName eq '$UserUPN'" | Remove-AzureADUser
            if ($StillThere = Get-AzureADUser -Filter "UserPrincipalName eq '$UserUPN'") {
                Write-Log 'failed' Magenta $LogFile
            } else {
                Write-Log 'done' DarkYellow $LogFile
            }
        }
        #endregion

    }

    End {  }

}

function Get-AzSBSubscription {
<#
 .SYNOPSIS
  Function to return Azure subscription information including parent Management Group(s)
 
 .DESCRIPTION
  Function to return Azure subscription information including parent Management Group(s)
  This function also requires prior login to an Azure tenant via Connect-AzAccount cmdlet of the Az.Accounts PowerShell module.
  This function depends on the following PowerShell modules:
    - Az.Accounts
    - Az.Resources
 
 .PARAMETER ManagementGroupName
  This is an optional parameter. This function expects a valid Management Group Name for the currently logged on Azure tenant.
  It's mainly used for the recursive feature of this function.
 
 .PARAMETER SubscriptionList
  This is an optional parameter.
  This function expects the output of Get-AzSubscription cmdlet of the Az.Accounts PowerShell module.
  It's mainly used for the recursive feature of this function to reduce the repetition of invoking Get-AzSubscription cmdlet.
 
 .PARAMETER MGTree
  This is an optional parameter.
  This function expects a string of comma separated Management Group names representing the parents of the current Management Group.
  Example: Root, MxxxA, mxxxxl, mxxxxxxs, mxxxxxxd
  It's mainly used for the recursive feature of this function.
 
 .PARAMETER Silent
  This is an optional Switch. When set to True, this function will suppress console progress messages.
 
 .PARAMETER ExcludeDisabled
  This is an optional Switch. When set to True, this function will not return information on disabled subscriptions.
 
 .PARAMETER ExcludeAADSub
  This is an optional Switch. When set to True, this function will not return information on subscription(s) named 'Access to Azure Active Directory'.
 
 .PARAMETER MGTree
  This is an optional parameter. It defaults to a file name in the current folder where this function will save its console output.
 
 .EXAMPLE
    $mySublist = Get-AzSBSubscription
    This will show console output similar to:
    Identified 23 subscriptions in Azure tenant 7xxxxxxxxxxxxxxxxxxxxxxxxxf
        Processing MG 7xxxxxxxxxxxxxxxxxxxxxxxxf
        Processing MG Sxxxxxxxxxxxd
        Processing MG Sxxxxxxxxxxxxxxs
        Processing MG Pxxxxxxxxxxxxxs
        Processing MG Fxxxxxxxxxxs
        Processing MG Dxxxxxxxxxxxxxxs
        Processing MG Fxxxxxxxxxxxxxs
 
 .EXAMPLE
    Get-AzSBSubscription -Silent
 
 .OUTPUTS
  This function returns a PowerShell object for each subscription such as:
    Id Name MGTree
    -- ---- ------
    exxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx7 Access to Azure Active Directory Root
    6xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx4 hxxxxxxxxv Root, MxxxxxxA, mxxxxxxxxp, mxxxxxxxxxxxxxxxxxs, mxxxxxxxxxxxxxxxxxxxxxv
    cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx4 hxxxxxxxxxd Root, MxxxxxxA, mxxxxxxxxp, mxxxxxxxxxxxxxxxxxs, mxxxxxxxxxxxxxxxxxxxxxxd
    exxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx5 hxxxxxxxxxv Root, MxxxxxxA, mxxxxxxxxp, mxxxxxxxxxxxxxxxxxxs, mxxxxxxxxxxxxxxxxxxxxxxv
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 27 July 2021
  v0.2 - 27 July 2021 - Added ExcludeDisabled and ExcludeAADSub switches.
  v0.3 - 3 August 2021 - Added State property to output.
    Known Issues: ExcludeDisabled and ExcludeAADSub switches don't seem to work past the Root management group
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$ManagementGroupName,
        [Parameter(Mandatory=$false)][String]$MGTree,
        [Parameter(Mandatory=$false)][String[]]$SubscriptionList,
        [Parameter(Mandatory=$false)][Switch]$Silent,
        [Parameter(Mandatory=$false)][Switch]$ExcludeDisabled,
        [Parameter(Mandatory=$false)][Switch]$ExcludeAADSub,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-AzSBSubscription_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  
    
    }

    Process { 

        $Context = Get-AzContext      
        if (-not $SubscriptionList) { $SubscriptionList = Get-AzSubscription }   

        if ($SubscriptionList) {
                
            if (-not $ManagementGroupName) { 
                $ManagementGroupName = $Context.Tenant.Id
                if (-not $Silent) { Write-Log 'Identified',$SubscriptionList.Count,'subscriptions in Azure tenant',$Context.Tenant Green,Cyan,Green,Cyan $LogFile }
            }

            try {
                if (-not $Silent) { Write-Log ' Processing MG',$ManagementGroupName Green,Cyan $LogFile }
                $ChildList = Get-AzManagementGroup -Recurse -GroupName $ManagementGroupName -Expand -WA 0 -EA 1 | Select Id,Name,Children,MGTree | sort Name
                $NextMGTree = if ($MGTree) { $MGTree,$ManagementGroupName -join ', ' } else { 'Root' }
                $SubList = foreach ($Sub in ($ChildList.Children | where Type -Match 'subscriptions')) {
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        Id     = $Sub.Name
                        Name   = $Sub.DisplayName
                        State  = ($SubscriptionList | where Id -EQ $Sub.Name).State
                        MGTree = $NextMGTree
                    })
                }
                foreach ($Sub in $SubList) {
                    if ($ExcludeAADSub -and $Sub.Name -eq 'Access to Azure Active Directory') {
                        # Suppress the Output
                    } elseif ($ExcludeDisabled -and ($SubscriptionList | where Id -EQ $Sub.Id).State -eq 'Disabled') {
                        # Suppress the Output
                    } else {
                        $Sub
                    }                    
                }

                $MGList = $ChildList.Children | where Type -Match 'managementGroups' | Select Name,Id,MGTree 
                $MGList | foreach { $_.MGTree = $NextMGTree }
                $MGList | foreach { 
                    $ParamList = @{
                        ManagementGroupName = $_.Name 
                        SubscriptionList    = $SubscriptionList 
                        MGTree              = $NextMGTree 
                        LogFile             = $LogFile 
                    }
                    if ($Silent) { $ParamList += @{ Silent = $true } }
                    if ($ExcludeAADSub) { $ParamList += @{ ExcludeAADSub = $true } }
                    if ($ExcludeDisabled) { $ParamList += @{ ExcludeDisabled = $true } }
                    Get-AzSBSubscription @ParamList
                }
            } catch {
                Write-Log 'Get-AzSBSubscription Error:','Bad ManagementGroupName provided',$ManagementGroupName  Magenta,Yellow,Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            }            

        } else {
            Write-Log 'Get-AzSBSubscription Error:','No subscriptions found in the tenant',$Context.Tenant  Magenta,Yellow,Magenta $LogFile
            Write-Log ' or this user/service principal does not have enough permission to list subscriptions, recommend running under a user with Reader Resource RBAC role at the Root Management group scope' Yellow $LogFile
            Write-Log 'Current Azure Context:' Magenta $LogFile
            Write-Log ($Context | FL * | Out-String).Trim() Yellow $LogFile
            break
        }

    }

    End {  }
} 

function New-AzureServicePrincipal {

<#
 .SYNOPSIS
  Function to provision an Azure Service Principal/App Registration.
  
.DESCRIPTION
  Function to provision an Azure Service Principal/App Registration secured by a secret (password).
  This function also requires prior login to an Azure tenant via Connect-AzAccount cmdlet of the Az.Accounts PowerShell module, and
  Connect-AzureAD cmdlet of the AzureAD PowerShell Module.
  This function depends on the following PowerShell modules:
    - Az.Accounts
    - Az.Resources
    - AzureAD or AzureADPreview
  
.PARAMETER ServicePrincipalName
  Name of the Azure Service Principal to be provisioned.
  
.PARAMETER Description
  This text will be saved to the App's Description.
  
.PARAMETER Notes
  This text will be saved to the App's Notes.
  
.PARAMETER SecretDays
  This optional parameter determines the life of the secret (password) of the Service Principal.
  It defaults to 365 days, or 1 year from the time it's provisioned.
  
.PARAMETER SaveSecretToLog
  When this Switch is set to True, this function will write the Service Principal secret (password) to the log file (PLAIN TEXT).
  This function will always display the Service Principal secret (password) on the console.
  
.PARAMETER SubscriptionList
  One or more subscription names.
  This parameter is used with ResourceRoleList parameter.
  When both are provided, this function will assign the provided Resource Roles to the new Service Principal at scope of each of the provided subscription names.
  
.PARAMETER ResourceRoleList
  One or more Resource Role names such as 'Owner' or 'Reader'.
  This parameter is used with ResourceRoleList parameter.
  When both are provided, this function will assign the provided Resource Roles to the new Service Principal at scope of each of the provided subscription names.
  
.PARAMETER NewSecret
  When this Switch is set to True, this function will issue a new secret for an existing Service Principal.
  
.PARAMETER RemoveExpiredSecrets
  When this Switch is set to True, this function will remove expired secrets if any are found for an existing Service Principal.
  
.PARAMETER RemoveExpiredCerts
  When this Switch is set to True, this function will remove expired certificates if any are found for an existing Service Principal.
  
.PARAMETER LogFile
  This is an optional parameter. It defaults to a file name in the current folder, where this function will save its console output.
  
.EXAMPLE
    $ServicePrincipal = New-AzureServicePrincipal -ServicePrincipalName samtest6
    This will create a new Azure Service Principal, display its secret on the console but not in the log file.
    $ServicePrincipal variable will contain details on the new Service Principal including the secret.
  
.EXAMPLE
    $ServicePrincipal = New-AzureServicePrincipal -ServicePrincipalName samtest6
    This will create a new Azure Service Principal called samtest6, display its secret on the console but not in the log file.
    $ServicePrincipal variable will contain details on the new Service Principal including the secret.
  
    $CredFile = ".\$($ServicePrincipal.SPName.Replace('\','_').Replace('/','_')).txt"
    $Cred = New-Object -TypeName PSCredential -ArgumentList $ServicePrincipal.SPName , (ConvertTo-SecureString -String $ServicePrincipal.SPSecret -AsPlainText -Force)
    $Cred.Password | ConvertFrom-SecureString | Out-File $CredFile
    These 3 lines will save the secret/password securely to disk. It can only be decrypted by the same Windows user who saved it.
  
    $Pwd = Get-Content ".\$($ServicePrincipal.SPName.Replace('\','_').Replace('/','_')).txt" | ConvertTo-SecureString
    $Cred = New-Object -TypeName PSCredential -ArgumentList $ServicePrincipal.SPName , $Pwd
    $Cred.GetNetworkCredential().Password # Display the plain text secret/password
    These 3 lines will retrieve the secret/password from disk.
  
.EXAMPLE
    $ServicePrincipal = New-AzureServicePrincipal -ServicePrincipalName samtest6 -NewSecret
    This will create a new secret for an existing Azure Service Principal, display its secret on the console but not in the log file.
    $ServicePrincipal variable will contain details on the Service Principal including the new secret.
  
.EXAMPLE
    $ServicePrincipal = New-AzureServicePrincipal -ServicePrincipalName samtest6 -NewSecret -RemoveExpiredSecrets
    This will create a new secret for an existing Azure Service Principal, display its secret on the console but not in the log file.
    It will also delete any existing expired secrtes for this existing Service Principal.
    $ServicePrincipal variable will contain details on the Service Principal including the new secret.
  
.EXAMPLE
    New-AzureServicePrincipal -ServicePrincipalName samtest6 -RemoveExpiredCerts
    For this existing Service Principal, this function will delete any existing expired certificates.
  
.OUTPUTS
  This function returns a PowerShell object such as:
    SPName : samtest5
    SPId : bxxxxxx1-8185-43e4-99ef-cxxxxxxxxxx8
    SPAppId : bxxxxxx7-ed64-49da-b2ac-3xxxxxxxxxx9
    SPSecret : krMioq4v0EjLe3AY5D6udPRy
    Expires : 9/28/2022 8:56:38 AM
    ResourceRoleAssignments : {@{RoleName=Reader; RoleScopeById=/subscriptions/6xxxxxx3-1xxx-xxxx-xxxx-1xxxxxxxxa; RoleScopeByName=/subscriptions/MySubscrioptionName}}
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://superwidgets.wordpress.com/2018/03/15/new-sbazserviceprincipal-cmdlet-to-create-new-azure-ad-service-principal-added-to-azsbtools-powershell-module/
  
.NOTES
  Function by Sam Boutros
  v0.1 - 28 September 2021 - Original release.
  v0.2 - 30 September 2021
    Added -NewSecret and -RemoveExpiredSecrets and -RemoveExpiredCerts switches and related code
  v0.3 - 4 October 2021
    Added -SecretLength Parameter and related code.
    Added a GUID to the secret for identification.
  v0.4 - 2 Feb 2022
    Updates after the Micrsoft's Dec. 2021 breaking changes to the underlying API and PowerShell cmdlets.
    See https://docs.microsoft.com/en-us/powershell/azure/azps-msgraph-migration-changes for more details.
    Added Description parameter, removed SecretLength and IncludeSpecialCharacters parameters.
  v0.5 - 14 April 2022
    Added more details to error messages when this function fails due to expired Token.
  v0.6 - 20 July 2022
    Added information about tenant details.
    Write separately to Description and Notes properties.
    Do not write to Tags.
  
  Upcoming improvements:
    - Ability to assign Resource Roles at resource group level.
    - Ability to assign Azure AD roles directly.
    - Ability to assign Azure AD roles via Azure group membership.
#>

 
    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$ServicePrincipalName,
        [Parameter(Mandatory=$false)][String]$Description,
        [Parameter(Mandatory=$false)][String]$Notes,
        [Parameter(Mandatory=$false)][ValidateRange(1,4000)][Int32]$SecretDays = 365,
        [Parameter(Mandatory=$false)][Switch]$SaveSecretToLog,
        [Parameter(Mandatory=$false)][Switch]$NewSecret,
        [Parameter(Mandatory=$false)][Switch]$RemoveExpiredSecrets,
        [Parameter(Mandatory=$false)][Switch]$RemoveExpiredCerts,
        [Parameter(Mandatory=$false)][String[]]$ResourceRoleList,
        [Parameter(Mandatory=$false)][String[]]$SubscriptionList,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\New-AzureServicePrincipal_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )
 
    Begin {
 
        $ModuleList = @(
            'Az.Accounts'
            'az.resources'
            'AzureAD -or AzureADPreview'
        )
        $ModuleGo = $true
        foreach ($Module in $ModuleList) {
            if ($Module -match '-or') {
                $FoundCount = 0
                $ModSubList = ($Module -split '-or').Trim()
                foreach ($ModuleName in $ModSubList) {
                    try { Import-Module $ModuleName -Force -EA 1 | out-null; $FoundCount ++ } catch {}
                }
                if ($FoundCount -eq 0) { Write-Log 'Error:','unable to load any of the required modules',$Module Magenta,Yellow,Magenta $LogFile; $ModuleGo = $false }
            } else {
                try {
                    Import-Module $Module -Force -EA 1 | out-null
                } catch {
                    Write-Log 'Error:','unable to load required module',$Module Magenta,Yellow,Magenta $LogFile
                    Write-Log $_.Exception.Message Yellow $LogFile
                    $ModuleGo = $false
                } 
            }
        }
        if (-not $ModuleGo) { break }
 
        if (-not $Description) { $Description = "Provisioned using New-AzureServicePrincipal function of the AZSBTools PowerShell module on $(Get-Date)" }
        if (-not $Notes) { $Notes = "Provisioned using New-AzureServicePrincipal function of the AZSBTools PowerShell module on $(Get-Date)" }

        try {
            $TenantInfo = Get-AzureADTenantDetail -EA 1 
        } catch {
            Write-Log 'New-AzureServicePrincipal Error:','Get-AzureADTenantDetail cmdlet failed:' Magenta,Yellow $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        } 

    }
 
    Process {
 
        if ($FoundSP = Get-AzureADServicePrincipal -Filter "DisplayName eq '$ServicePrincipalName'" ) {
            Write-Log 'Identified Service Principal:' Green $LogFile
            Write-Log ($FoundSP | Select DisplayName,ObjectId,AppId,PublisherName,@{n='TenantId';e={$TenantInfo.ObjectId}},AppOwnerTenantId | Out-String).Trim() Cyan $LogFile
            try {
                $APP = Get-AzureADApplication -Filter "DisplayName eq '$ServicePrincipalName'" -EA 1
                Write-Log 'Identified App:' Green $LogFile
                Write-Log ($APP | Select DisplayName,ObjectId,AppId,PublisherDomain,@{n='TenantId';e={$TenantInfo.ObjectId}} | Out-String).Trim() Cyan $LogFile
 
                if ($SecretList = Get-AzureADApplicationPasswordCredential -ObjectId $APP.ObjectId) {
                    Write-Log 'Identified App secret(s):' Green $LogFile

                    Write-Log ($SecretList | FL KeyId,EndDate | Out-String).Trim() Cyan $LogFile
 
                    if ($RemoveExpiredSecrets) {
                        foreach ($Secret in ($SecretList | where {($_.EndDate - (Get-Date)) -le 0})) {
                            Write-Log 'Removing expired secret',"$($Secret.KeyId) (Expired $($Secret.EndDate))" Green,Cyan $LogFile -NoNewLine
                            try {
                                $Removed = Remove-AzureADApplicationPasswordCredential -ObjectId $APP.ObjectId -KeyId $Secret.KeyId -EA 1
                                Write-Log 'done' DarkYellow $LogFile
                            } catch {
                                Write-Log 'failed' Magenta $LogFile
                                Write-Log $_.Exception.Message Yellow $LogFile
                            }
                        }
                    } # Remove Expired Secrets
 
                } else {
                    Write-Log 'No App secrets found!?' Yellow $LogFile
                }
 
                if ($CertList = Get-AzureADApplicationKeyCredential -ObjectId $APP.ObjectId) {
                    Write-Log 'Identified App certificate(s):' Green $LogFile
                    Write-Log ($CertList | FL KeyId,EndDate,@{n='CustomKeyId';e={ $_.CustomKeyIdentified -join ',' }} | Out-String).Trim() Cyan $LogFile
 
                    if ($RemoveExpiredCerts) {
                        foreach ($Cert in ($CertList | where {($_.EndDate - (Get-Date)) -le 0})) {
                            Write-Log 'Removing expired certificate',"$($Cert.KeyId) (Expired $($Cert.EndDate))" Green,Cyan $LogFile -NoNewLine
                            try {
                                $Removed = Remove-AzureADApplicationKeyCredential -ObjectId $APP.ObjectId -KeyId $Cert.KeyId -EA 1
                                Write-Log 'done' DarkYellow $LogFile
                            } catch {
                                Write-Log 'failed' Magenta $LogFile
                                Write-Log $_.Exception.Message Yellow $LogFile
                            }
                        }
                    } # Remove Expired Certificates
 
                } else {
                    Write-Log 'No App certificates found.' Green $LogFile
                }
 
 
            } catch {
                Write-Log 'New-AzureServicePrincipal Error:','Get-AzureADApplication cmdlet failed:' Magenta,Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            } # Get-AzureADApplication
 
            if ($NewSecret) {
           
                Write-Log 'Creating new secret for Service Principal',$ServicePrincipalName Green,Cyan $LogFile -NoNewLine
                try {
                    $CreatedSecret = New-AzADAppCredential -StartDate (Get-Date) -EndDate ((Get-Date).AddDays($SecretDays)) -ApplicationId $APP.AppId -EA 1
                    Write-Log 'done' DarkYellow $LogFile
                } catch {
                    Write-Log 'failed' Magenta $LogFile
                    Write-Verbose '$CreatedSecret:'
                    Write-Verbose ($CreatedSecret | FL * | Out-String).Trim()
                    if ($_.Exception.Message -match 'Token acquisition failed') {
                        Write-Log 'This error may indicate that you need to get a Toekn via','Connect-AzAccount' Green,Cyan $LogFile
                    } 
                    Write-Log $_.Exception.Message Yellow $LogFile
                    break
                }
                $mySP = New-Object -TypeName PSObject -Property ([Ordered]@{
                    SPName   = $FoundSP.DisplayName
                    SPId     = $FoundSP.ObjectId # $FoundSP.Id
                    SPAppId  = $FoundSP.AppId # $FoundSP.ApplicationId
                    SPSecret = $CreatedSecret.SecretText
                    Expires  = $CreatedSecret.EndDateTime
                    ResourceRoleAssignments = @()
                })
                Write-Verbose '$mySP:'
                Write-Verbose ($mySP | FL * | Out-String).Trim()
 
            } else {
                Write-Log 'New-AzureServicePrincipal Error: Service Principal',$ServicePrincipalName,'already exists.' Magenta,Yellow,Magenta $LogFile
                Write-Log 'To provision a new secret for an existing Service Principal, use -NewSecret switch.' Yellow $LogFile
                break
            } # NewSecret
 
        } else {
            if ($NewSecret) {
                Write-Log 'New-AzureServicePrincipal Error: -NewSecret switch used, but Service Principal',$ServicePrincipalName,'is not found' Magenta,Yellow,Magenta $LogFile
                Write-Log 'Use the -NewSecret switch with existing Service Principals only.' Yellow $LogFile
                break
            } else {
 
                #region Create SP
 
                # New flow Feb 2022: Create SP, delete default secret, create new secret
                # 2/2/22: Description, Note, Tag: show in the manifest but nowhere else in the portal
                # NotificationEmailAddress 'sam.boutros@cigna.com, Livingston.Beazer@Cigna.com':
                # Specifies the list of email addresses where Azure AD sends a notification when the active certificate is near the expiration date. This is only for the certificates used to sign the SAML token issued for Azure AD Gallery applications.
                Write-Log 'Creating Azure Service Principal',$ServicePrincipalName Green,Cyan $LogFile -NoNewLine
                try {
                    $SP = New-AzADServicePrincipal -DisplayName $ServicePrincipalName -StartDate (Get-Date) -EndDate ((Get-Date).AddDays(1)) -Description $Description -Note $Notes -EA 1
                    # Delete the 1 day secret:
                    Remove-Variable Deleted -EA 0
                    while (-not $Deleted) {
                        try { $Deleted = Remove-AzADAppCredential -ApplicationId $sp.AppId -Confirm:$false -PassThru -EA 1 } catch { Start-Sleep -Seconds 1 }                       
                    }
                    Write-Log 'done' DarkYellow $LogFile
                } catch {
                    Write-Log 'failed' Magenta $LogFile
                    if ($_.Exception.Message -match 'Token acquisition failed') {
                        Write-Log 'This error may indicate that you need to get a Toekn via','Connect-AzAccount' Green,Cyan $LogFile
                    } 
                    Write-Log $_.Exception.Message Yellow $LogFile
                    break
                } # Create SP
                  
                $NewKey = New-AzADAppCredential -StartDate (Get-Date) -EndDate ((Get-Date).AddDays($SecretDays)) -ApplicationId $SP.AppId
                # 2/2/22: No way to add key Description!!??
                # -CustomKeyIdentifier <base 64 char array>
                                    
                $mySP = New-Object -TypeName PSObject -Property ([Ordered]@{
                    SPName   = $SP.DisplayName
                    SPId     = $SP.Id
                    TenantId = $SP.AppOwnerOrganizationId
                    SPAppId  = $SP.AppId
                    SPSecret = $NewKey.SecretText
                    Expires  = $NewKey.EndDateTime
                    ResourceRoleAssignments = @()
                })
 
                #endregion
 
                #region Assign Resource Roles
 
                if ($ResourceRoleList) {
                    foreach ($SubscriptionName in $SubscriptionList) {
                        Write-Log 'Setting to context to subscription',$SubscriptionName Green,Cyan $LogFile -NoNewLine
                        try {
                            $Result = Set-AzContext -Subscription $SubscriptionName -EA 1
                            Write-Log 'done' DarkYellow $LogFile
 
                            foreach ($ResourceRole in $ResourceRoleList) {
                                try {
                                    $Role = Get-AzRoleDefinition -Name $ResourceRole -EA 1
                                    Write-Log ' Identified Role',$Role.Name,'Description:',$Role.Description Green,Cyan,Green,Cyan $LogFile
                                    $Scope = "/subscriptions/$($Result.Subscription.Id)"
 
                                    #region retry for 60 sec while the Azure API catches up..
                                    $RetryTotalSeconds = 60
                                    $RetryWaitSeconds  = 5
                                    $StartRetry = Get-Date
                                    $Assigned = $False
                                    while (-not $Assigned -and (New-TimeSpan -Start $StartRetry -End (Get-Date)).TotalSeconds -le $RetryTotalSeconds) {
                                        try {
                                            $Temp = New-AzRoleAssignment -ObjectId $mySP.SPId -RoleDefinitionId $Role.Id -Scope $Scope -EA 1
                                            $Assigned = $true
                                            Write-Log ' Assigned Resource Role',$Role.Name,'to Service Principal',"$($mySP.SPName) ($($mySP.SPId))" Green,Cyan,Green,Cyan $LogFile
                                            $mySP.ResourceRoleAssignments += New-Object -TypeName PSObject -Property ([Ordered]@{
                                                RoleName        = $Role.Name
                                                RoleScopeById   = $Scope = "/subscriptions/$($Result.Subscription.Id)"
                                                RoleScopeByName = $Scope = "/subscriptions/$SubscriptionName"
                                            })
                                        } catch {
                                            if ($_.Exception.Message -match 'does not exist in the directory') {
                                                Write-Log ' Waiting on Azure API to catch up before assigning Resource Role',$Role.Name,'to Service Principal',"$($mySP.SPName) ($($mySP.SPId))" Yellow,Cyan,Green,Cyan $LogFile
                                                Start-Sleep -Seconds $RetryWaitSeconds
                                            } else {
                                                Write-Log 'New-AzureServicePrincipal Error:','Azure Resource Role Assignment failed, details of command used:' Magenta,Yellow $LogFile
                                                Write-Log "New-AzRoleAssignment -ObjectId $($mySP.SPId) -RoleDefinitionId $($Role.Id) -Scope $Scope" Yellow $LogFile
                                                Write-Log $_.Exception.Message Yellow $LogFile
                                                $Temp = $_.Exception.Message
                                            }
                                        } # New-AzRoleAssignment
                                    }
                                    if (-not $Temp.DisplayName) {
                                        Write-Log 'New-AzureServicePrincipal Error:','Azure Resource Role Assignment failed, details of command used:' Magenta,Yellow $LogFile
                                        Write-Log "New-AzRoleAssignment -ObjectId $($mySP.SPId) -RoleDefinitionId $($Role.Id) -Scope $Scope" Yellow $LogFile
                                        Write-Log $Temp Yellow $LogFile
                                    }
                                    #endregion
 
                                } catch {
                                    Write-Log 'New-AzureServicePrincipal Error:','No role definition for Resource Role',$ResourceRole,'found in subscription',$SubscriptionName Magenta,Yellow,Cyan,Yellow,Cyan $LogFile
                                    Write-Log $_.Exception.Message Yellow $LogFile
                                } # Get-AzRoleDefinition
                            } # foreach $ResourceRole
                        } catch {
                            Write-Log 'failed' Magenta $LogFile
                            Write-Log $_.Exception.Message Yellow $LogFile
                        } # Set-AzContext
                    } # foreach $SubscriptionName
                } # if ($ResourceRoleList

               #endregion
 
            } # New SP
        }
 
    }
 
    End {
     
        if ($SaveSecretToLog) {
            Write-Log ($mySP | FL SPName,SPId,TenantId,SPAppId,SPSecret,Expires | Out-String).Trim() Cyan $LogFile
        } else {
            Write-Log ($mySP | FL SPName,SPId,TenantId,SPAppId,Expires | Out-String).Trim() Cyan $LogFile
            Write-Host "SPSecret: $($mySP.SPSecret)" -ForegroundColor Cyan
        }
 
        foreach ($RoleAssignment in $mySP.ResourceRoleAssignments) {
            Write-Log ' Resource Role Assigned:', "'$($RoleAssignment.RoleName)'",'at scope',"'$($RoleAssignment.RoleScopeById)' ($($RoleAssignment.RoleScopeByName))" Green,Cyan,Green,Cyan $LogFile
        }
 
        $mySP
   
    }
}

function Get-AzureTenantId {
<#
 .SYNOPSIS
  Function to return the Azure tenant Id of a given Web domain or tenant name
  
.DESCRIPTION
  Function to return the Azure tenant Id of a given Web domain or tenant name
  
.PARAMETER DomainName
  Azure tenant name such as cnn.onmicrosoft.com
  or a web domain such as cnn.com
  
.EXAMPLE
  Get-AzureTenantId -DomainName cnn.onmicrosoft.com
  The function returns 08ff11b9-d85c-477f-b28d-850de885b980
  
.EXAMPLE
  Get-AzureTenantId -DomainName cnn.com
  The function returns 0eb48825-e871-4459-bc72-d0ecd68f1f39
  
.EXAMPLE
  Get-AzureTenantId -DomainName cnn
  The function returns an error if the provided domain is not found like:
    Error:
    AADSTS90002: Tenant 'cnn' not found. Check to make sure you have the correct tenant ID and are signing into the correct cloud.
    Check with your subscription administrator, this may happen if there are no active subscriptions for the tenant.
    Trace ID: b9dedd8a-c52f-4d1c-94a6-79151fc4f300
    Correlation ID: 15de73a1-d2a7-44be-b0e4-603aa6cf2243
    Timestamp: 2023-02-02 17:28:07Z
  
.OUTPUTS
  This cmdlet returns the Id of the provided tenant name or web domain name if found
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  
.NOTES
  Function by Sam Boutros
  v0.1 - 2 Feb 2023
#>

 
    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$DomainName
    )
 
    Begin {  }
 
    Process {     
        try {
            $Response = Invoke-WebRequest https://login.microsoftonline.com/$DomainName/.well-known/openid-configuration -EA 1
            if (($Response.Content | ConvertFrom-Json).issuer) {
                ($Response.Content | ConvertFrom-Json).issuer -replace 'https://sts.windows.net','' -replace '/',''
            } else {
                Write-Verbose $Response
                Write-Verbose $Response.Content
                Write-Verbose ($Response.Content | ConvertFrom-Json)
            }
        } catch {
            $myError = $_
            Write-Log 'Error:' Magenta
            Write-Log ($myError.ErrorDetails.Message | ConvertFrom-Json).error_description Yellow
            Write-Verbose $myError
        }
       
    }
 
    End {  }
}

#endregion

#region Hyper-V functions

function Get-ParentPath {
<#
 .Synopsis
  Function to get parent disk/path tree of VHD(x) file
 
 .Description
  Function to get parent disk/path tree of VHD(x) file
 
 .Parameter VHDPath
  Full local path to the VHD(x) file.
  For example: 'e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_55DB25B0-EFA9-415F-A5D1-738A62742B4E.avhdx'
 
 .Parameter ComputerName
  Name of Hyper-V host where the VHD(x) file resides
  If absent, defaults to localhost
 
 .Parameter Silent
  Switch parameter, when set to True, this function will supress its console output.
  This parameter will NOT suppress error messages.
 
 .Example
  Get-ParentPath -VHDPath 'e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_55DB25B0-EFA9-415F-A5D1-738A62742B4E.avhdx' -ComputerName 'xhost16'
  Retunrs an array of the disk path and all its parents paths
 
 .Example
  $VMName = 'v-2012R2-G2a'
  $HVName = 'xHost16'
  $VMDisks = Invoke-Command -ComputerName $HVName -ArgumentList $VMName -ScriptBlock {
    Param($VMName)
    Get-VMHardDiskDrive -VMName $VMName
  }
  ($VMDisks.Path | Where { $_ -match ':' }) | foreach {
    Get-ParentPath -VHDPath $_ -ComputerName $HVName
  }
 
  Retunrs an array for each disk attached to the VM $VMName, containing the disk tree.
  Sample output:
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_55DB25B0-EFA9-415F-A5D1-738A62742B4E.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_0020A6D3-0371-48E3-B67D-DE2ADF0BEDF1.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_C2BA8DE5-8FE6-49AD-B12B-789853306524.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_79E7AFEB-F867-4068-A3DA-6BF64F49E819.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C_232D8FD8-5855-4518-800C-2659385521FA.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-C.VHDX
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-D_C492420D-011D-42F4-862A-A04A7222B7FC.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-D_7B0A54C4-36E5-4229-9746-49566B0566AF.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-D_460EDAAF-F30A-4744-8396-87449EAF1A8C.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-D_9D3805B2-79F5-4E5F-865D-D69620E31B6A.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-D_F37513BE-AD00-41FD-B24E-CBB3E0BDEFC4.avhdx
    e:\VMs\v-2012R2-G2a\v-2012R2-G2a-D.vhdx
 
 .Link
  https://superwidgets.wordpress.com/2014/11/11/powershell-script-to-merge-hyper-v-virtual-machine-disks
 
 .Notes
  Function by Sam Boutros
  v0.1 - 1 November 2014
  v0.2 - 23 August 2021 - Rewrite for AZSBTools after Microsoft retired the Technet Gallery effective June 2020 – see https://docs.microsoft.com/en-us/teamblog/technet-gallery-retirement
  v0.3 - 27 August 2021 - Added 'silent' parameter to supress console output.
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][ValidateNotNullorEmpty()][String]$VHDPath,
        [Parameter(Mandatory=$false)][alias('HVName','HyperVHostName')][String]$ComputerName = '.',       
        [Parameter(Mandatory=$false)][Switch]$Silent,       
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-ParentPath_$(Get-Date -format yyyyMMdd_hhmmsstt).Log"
    )

    Begin {  }

    Process {      
        if (-not $Silent) { Write-Log 'Getting disk information for file',$VHDPath,'on computer',$ComputerName Green,Cyan,Green,Cyan $LogFile }

        try {
            $VHDSVC = Get-WmiObject -ComputerName $ComputerName -Namespace root\virtualization\v2 -Class Msvm_ImageManagementService -ErrorAction Stop
            $VHDInfo = [xml]$VHDSVC.GetVirtualHardDiskSettingData($VHDPath).SettingData
            if ($VHDInfo) {
                $ParentPath = ($VHDInfo.INSTANCE.PROPERTY | Where { $_.Name -eq 'ParentPath' }).Value
                if ($ParentPath) { 
                    $Result = @($VHDPath,$ParentPath)
                    While ($ParentPath.Split(".")[1] -match 'avhd') {
                        $VHDInfo = [xml]$VHDSVC.GetVirtualHardDiskSettingData($ParentPath).SettingData
                        $ParentPath = ($VHDInfo.INSTANCE.PROPERTY | Where { $_.Name -eq "ParentPath" }).Value
                        $Result += $ParentPath
                    }
                    if (-not $Silent) { 
                        Write-Log 'Got disk chain information:' Green $LogFile 
                        $Result | foreach { Write-Log " $_" Cyan $LogFile }
                    }
                } else {
                    $Result = $VHDPath  
                    Write-Log 'Disk',$Result,'is not a differencing disk - does not have any parent..' Magenta,Yellow,Cyan $LogFile 
                }
            } else {
                Write-Log 'Disk file',$VHDPath,'does not exist on computer',$ComputerName Magenta,Yellow,Magenta,Yellow $LogFile 
            }
            $Result
        } catch {
            Write-Log 'Computer',$ComputerName,'is offline or cannot be contacted.' Magenta,Yellow,Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }

    }

    End {  }

}

function Merge-VMDisks {
<#
 .Synopsis
  Function to merge VM disks
 
 .Description
  Function/script to merge VM disks.
  The script will power down the VM in the process.
  This script requires to be invoked under credentials that have permission to remote into the VM and its Hyper-V host.
 
 .Parameter VMName
  Name of the VM whose VHD(x) disks are to be merged.
 
 .Parameter HyperVHoatName
  Name of the Hyper-V Host where the VM resides.
  If absent, this function will try to query the VM for its Hyper-V host name.
 
 .Parameter LogFile
  Name and path of the file where the script will log its steps and progress.
 
 .Example
  Merge-VMDisks -VMName 'myVMName'
  This will merge the VM disks if needed, and will require user manual confirmation before stopping the VM.
 
 .Example
  Merge-VMDisks -VMName 'myVMName' -Confirm:$false
  This will merge the VM disks if needed, without requiring user manual confirmation before stopping the VM.
 
 .Link
  https://superwidgets.wordpress.com/2014/11/11/powershell-script-to-merge-hyper-v-virtual-machine-disks
 
 .Notes
  Function by Sam Boutros
  v0.1 - 1 November 2014
  v0.2 - 23 August 2021 - Rewrite for AZSBTools after Microsoft retired the Technet Gallery effective June 2020 – see https://docs.microsoft.com/en-us/teamblog/technet-gallery-retirement
  V0.3 - 27 August 2021 - Update to allow this function to work on powered off VMs.
 
#>


    [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='High')] 
    Param(
        [Parameter(Mandatory=$true)][String]$VMName,    
        [Parameter(Mandatory=$false)][String]$HyperVHostName,    
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Merge-VMDisks_$(Get-Date -format yyyyMMdd_hhmmsstt).Log"
    )

    Begin {
        if ($VMName) {
            Write-Log 'Received input: VMName:',$VMName Green,Cyan $LogFile
        } 
        if ($HyperVHostName) {
            Write-Log 'Received input: HyperVHostName:',$HyperVHostName Green,Cyan $LogFile
        } 
        if (-not $VMName -and -not $HyperVHostName) {
            Write-Log 'Merge-VMDisks Error: parameters VMName and HyperVHostName not provided. Need at least one of the two.' Magenta $LogFile
            break
        }
    }

    Process{
        
        #region Get Hyper-V host name from VM
        if (-not ($HyperVHostName)) {
            Write-Log 'HyperVHostName not provided, trying to get it from the VM',$VMName Green,Cyan $LogFile
            try {
                $HyperVHostName = Invoke-Command -ComputerName $VMName -ErrorAction Stop -ScriptBlock { 
                    try {
                        (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Virtual Machine\Guest\Parameters' -EA 1).PhysicalHostName
                    } catch {
                        "Failed: $($_.Exception.Message)"
                    }
                }
                if ($HyperVHostName.IndexOf('Failed') -ge 0) {
                    Write-Log 'Merge-VMDisks Error: failed to get Hyper-V host name from VM, VM returned:' Magenta $LogFile
                    Write-Log $HyperVHostName Yellow $LogFile
                    break
                } else {
                    Write-Log 'Identified Hyper-V host',$HyperVHostName Green,Cyan $LogFile
                }
            } catch {
                Write-Log 'Merge-VMDisks Error' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }
        }
        #endregion

        #region Get VM disk information from Hyper-V host
        try {
            $VMDisks = Invoke-Command -ComputerName $HyperVHostName -EA 1 -ScriptBlock { Get-VMHardDiskDrive -VMName $Using:VMName }
            $VMDisks = $VMDisks | Where Path -match ':' 

            # Get Disk parent path information
            foreach ($Disk in $VMDisks) {
                $DiskTree = Get-ParentPath -Silent -VHDPath $Disk.Path -ComputerName $HyperVHostName -LogFile $LogFile
                if ($DiskTree.Count -gt 1) { $Differencing = $true } else { $Differencing = $false }
                $Disk | Add-Member -MemberType NoteProperty -Name DiskTree -Value $DiskTree -EA 0 
                $Disk | Add-Member -MemberType NoteProperty -Name Differencing -Value $Differencing -EA 0 
            }
            Write-Log 'Identified VM disk(s):' Green $LogFile
            Write-Log ($VMDisks | FL Name,Path,Differencing,@{n='DiskTree';e={$_.DiskTree -join ', '}}| Out-String).Trim() Cyan $LogFile

            if ($VMDisks.Differencing -match 'True') {} else {
                Write-Log 'No differencing disks found, nothing to merge, stopping..' Yellow $LogFile 
                break
            }

        } catch {
            Write-Log 'Merge-VMDisks Error: unable to identify VM disks' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }
        #endregion

        #region Stop VM if running
        $VMState = Invoke-Command -ComputerName $HyperVHostName -ScriptBlock { (Get-VM -Name $Using:VMName).State }
        if ($VMState.Value -eq 'Running') {
            Write-Log 'Stopping VM',$VMName,'on Hyper-V host',$HyperVHostName Green,Cyan,Yellow,Cyan $LogFile -NoNewLine
        }
        if ($VMState.Value -eq 'Running' -and $PSCmdlet.ShouldProcess($VMName)) {
            $Result = Invoke-Command -ComputerName $HyperVHostName -ScriptBlock { 
                try {
                    Stop-VM -Name $Using:VMName -Force -EA 1
                } catch {
                    $_.Exception.Message
                }
            }
            if ($Result) {
                Write-Log 'Merge-VMDisks error: Unable to stop VM',$VMName,'on hyper-V host',$HyperVHostName Magenta,Yellow,Magenta,Yellow $LogFile 
                Write-Log $Result Yellow $LogFile
                break
            } else {
                Write-Log 'done' Green $LogFile
                $OriginalVMState = 'Running'
            }
        }
        $VMState = Invoke-Command -ComputerName $HyperVHostName -ScriptBlock { (Get-VM -Name $Using:VMName).State }
        if ($VMState.Value -eq 'Running') {
            Write-Log 'aborting based on user input' Yellow $LogFile
            break
        }
        #endregion

        #region Merge disks, attach new merged disks to VM
        foreach ($Disk in $VMDisks) {
            Write-Log 'Processing Disk',$Disk.Path Green,Cyan $LogFile
            for ($i=0; $i -lt $DiskTree.Count-1; $i++) {
                Write-Log 'Merging file',$Disk.DiskTree[$i],'#',($i+1),'of',($Disk.DiskTree.Count-1) Green,Cyan,Green,Cyan,Green,Cyan $LogFile
                Invoke-Command -ComputerName $HyperVHostName -ArgumentList $Disk.DiskTree[$i] -ScriptBlock { 
                    Param($DiskFile)
                    Merge-VHD -Path $DiskFile -Confirm:$false -Force
                }
            }
            Write-Log 'Attaching merged disk',($Disk.DiskTree[$Disk.DiskTree.Count-1]) Green,Cyan $LogFile
            Invoke-Command -ComputerName $HyperVHostName -ArgumentList $VMName,$Disk.DiskTree,$Disk -ScriptBlock { 
                Param($VMName,$DiskTree,$Disk)
                $Splat = @{
                    VMName             = $VMName
                    ControllerType     = $Disk.ControllerType
                    ControllerNumber   = $Disk.ControllerNumber
                    ControllerLocation = $Disk.ControllerLocation
                }
                Remove-VMHardDiskDrive @Splat
                $Splat += @{ Path = $DiskTree[$DiskTree.Count-1] }
                Add-VMHardDiskDrive @Splat
            }
        }
        Write-Log 'Done merging disks' Green $LogFile
        #endregion

        #region Start VM if it was running
        if ($OriginalVMState -eq 'Running') {
            Write-Log 'Starting VM',$VMName Green,Cyan $LogFile -NoNewLine
            $Result = Invoke-Command -ComputerName $HyperVHostName -ScriptBlock { 
                try {
                    Start-VM -VMName $Using:VMName -EA 1
                } catch {
                    $_.Exception.Message
                }
            }
            if ($Result) {
                Write-Log 'failed' Magenta $LogFile 
                Write-Log $Result Yellow $LogFile
                break
            } else {
                Write-Log 'done' Green $LogFile
            }
        }
        #endregion

    } # end process

    end { }
} 

#endregion

#region Core functions

function Function-Template {
<#
 .SYNOPSIS
  Function to return the Geographical location of an Internet IP address
 
 .DESCRIPTION
  Function to return the Geographical location of an Internet IP address
  This function depends on ip-api.com and ipinfo.io
 
 .PARAMETER Source
  One or more URLs
  This is an optional parameter. These URLs will be queried for WAN IP.
 
 .EXAMPLE
  Get-MyWANIP
 
 .OUTPUTS
  This cmdlet returns a System.Net.IPAddress object such as:
    Address : 1132553623
    AddressFamily : InterNetwork
    ScopeId :
    IsIPv6Multicast : False
    IsIPv6LinkLocal : False
    IsIPv6SiteLocal : False
    IsIPv6Teredo : False
    IsIPv4MappedToIPv6 : False
    IPAddressToString : 151.101.129.67
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Alias('IPsToBlock')][IPAddress[]]$IPAddress = (Get-MyWANIP),
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Function-Template_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMyyyy-HH-mm').log"
    )

    Begin {  }

    Process {      

    }

    End {  }
} 

function Write-Log {
<#
 .SYNOPSIS
  Function to log input string to file and display it to screen
 
 .DESCRIPTION
  Function to log input string to file and display it to screen.
  Log entries in the log file are time stamped.
  Function allows for displaying text to screen in different colors.
 
 .PARAMETER String
  The string to be displayed to the screen and saved to the log file
 
 .PARAMETER Color
  The color in which to display the input string on the screen
  Default is White
  16 valid options for [System.ConsoleColor] type are
    Black
    Blue
    Cyan
    DarkBlue
    DarkCyan
    DarkGray
    DarkGreen
    DarkMagenta
    DarkRed
    DarkYellow
    Gray
    Green
    Magenta
    Red
    White
    Yellow
 
 .PARAMETER LogFile
  Path to the file where the input string should be saved.
  Example: c:\log.txt
  If absent, the input string will be displayed to the screen only and not saved to log file
 
 .EXAMPLE
  Write-Log -String "Hello World" -Color Yellow -LogFile c:\log.txt
  This example displays the "Hello World" string to the console in yellow, and adds it as a new line to the file c:\log.txt
  If c:\log.txt does not exist it will be created.
  Log entries in the log file are time stamped. Sample output:
    2014.08.06 06:52:17 AM: Hello World
 
 .EXAMPLE
  Write-Log "$((Get-Location).Path)" Cyan
  This example displays current path in Cyan, and does not log the displayed text to log file.
 
 .EXAMPLE
  "$((Get-Process | select -First 1).name) process ID is $((Get-Process | select -First 1).id)" | Write-Log -color DarkYellow
  Sample output of this example:
    "MDM process ID is 4492" in dark yellow
 
 .EXAMPLE
  Write-Log 'Found',(Get-ChildItem -Path .\ -File).Count,'files in folder',(Get-Item .\).FullName Green,Yellow,Green,Cyan .\mylog.txt
  Sample output will look like:
    Found 520 files in folder D:\Sandbox - and will have the listed foreground colors
 
 .EXAMPLE
  Write-Log (Get-Volume | sort DriveLetter | Out-String).Trim() Cyan .\mylog.txt
  Sample output will look like (in Cyan, and will also be written to .\mylog.txt):
    DriveLetter FriendlyName FileSystemType DriveType HealthStatus OperationalStatus SizeRemaining Size
    ----------- ------------ -------------- --------- ------------ ----------------- ------------- ----
                Recovery NTFS Fixed Healthy OK 101.98 MB 450 MB
    C NTFS Fixed Healthy OK 7.23 GB 39.45 GB
    D Unknown CD-ROM Healthy Unknown 0 B 0 B
    E Data NTFS Fixed Healthy OK 26.13 GB 49.87 GB
 
 .LINK
  https://superwidgets.wordpress.com/2014/12/01/powershell-script-function-to-display-text-to-the-console-in-several-colors-and-save-it-to-log-with-timedate-stamp/
 
 .NOTES
  Function by Sam Boutros
  v1.0 - 6 August 2014
  v1.1 - 1 December 2014 - added multi-color display in the same line
  v1.2 - 8 August 2016 - updated date time stamp format, protect against bad LogFile name
  v1.3 - 22 September 2017 - Re-write: Error handling for no -String parameter, bad color(s), and bad -LogFile without errors
                                        Add Verbose messages
  v1.4 - 27 March 2020 - Update to skip writing to file if LogFile parameter is not provided
  v1.5 - 15 May 2020 - Update to fix bug related to colors (thanks Stephen)
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [String[]]$String, 
        [Parameter(Mandatory=$false,Position=1)][String[]]$Color, 
        [Parameter(Mandatory=$false,Position=2)][String]$LogFile,
        [Parameter(Mandatory=$false,Position=3)][Switch]$NoNewLine
    )

    if ($String) {

        #region Write to Console

        $i=0
        foreach ($item in $String) { 
            try {
                Write-Host "$item " -ForegroundColor $Color[$i] -NoNewline -EA 1 
            } catch {
                Write-Host "$item " -NoNewline
            }
            $i++
        }
        if (-not $NoNewLine) { Write-Host ' ' }

        #endregion

        #region Write to file

        if ($LogFile) {
            try {
                "$(Get-Date -format 'dd MMMM yyyy hh:mm:ss tt'): $($String -join ' ')" | 
                    Out-File -Filepath $Logfile -Append -ErrorAction Stop
            } catch {
                Write-Warning "Write-Log: Bad LogFile name ($LogFile). Will not save input string(s) to log file.."
            }
        } else {
            Write-Verbose 'Write-Log: Missing -LogFile parameter. Will not save input string(s) to log file..'
        }
        
        #endregion

    } else {
        Write-Verbose 'Write-Log: Missing -String parameter - nothing to write or log..'
    }
}

function Get-SBCredential {
<#
 .SYNOPSIS
  Function to get a credential, save encrypted password to file for future automation
 
 .DESCRIPTION
  Function to get a credential, save encrypted password to file for future automation.
  The function will use saved password if the password file exists.
  The function will prompt for the password if the password file does not exist,
    or the -Refresh switch is used.
  It creates a PSCredential object to be used securely for future automation, eleminating the need
  to type in the password every time the function is needed, or the need to save passwords in clear text in scripts.
 
 .PARAMETER UserName
  This can be in the format 'myusername' or 'domain\username'
  If not provided, the function assumes the username under which the function is executed
   
 .PARAMETER CredPath
  This is the folder where this function will save the pwd encrypted file.
  It defaults to $env:Temp folder, like C:\Users\myname\AppData\Local\Temp
 
 .PARAMETER Refresh
  This switch will force the function to prompt for the password and over-write the password file
 
 .PARAMETER ValidateCredential
  This switch will validate the credential against the current domain.
 
 .OUTPUTS
  The function returns a PSCredential object that can be used with other cmdlets that use the -Credential parameter
 
 .EXAMPLE
  $MyCred = Get-SBCredential
 
 .EXAMPLE
  $Cred2 = Get-SBCredential -UserName 'sboutros' -Verbose -Refresh
 
 .EXAMPLE
  $Cred3 = 'domain2\ADSuperUser' | Get-SBCredential
  Disable-ADAccount -Identity 'Someone' -Server 'MyDomainController' -Credential $Cred3
  This example obtains and saves credential of 'domain2\ADSuperUser' in $Cred3 varialble
  Second line uses that credential to disable an AD account of 'Someone'
 
 .LINK
  https://superwidgets.wordpress.com/2016/08/05/powershell-script-to-provide-a-ps-credential-object-saving-password-securely/
 
 .NOTES
  Function by Sam Boutros
  5 Aug 2016 - v0.1
  1 Apr 2020 - v0.2 - Parameterized CredPath
  30 Sep 2021 - v0.3 - Added error handling for bad Cred file.
  24 Jan 2023 - v0.4
    Moved default CredPath to c:\Windows\KeyChain
    Added logic to create CredPath if not exist
    Added ValidateCredential switch
    Added recursive logic to validate credential and auto-use Refresh parameter if pwd is no longer valid.
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [String]$UserName = "$env:USERDOMAIN\$env:USERNAME", 
        [Parameter(Mandatory=$false,Position=1)][String]$CredPath,
        [Parameter(Mandatory=$false,Position=2)][Switch]$Refresh,
        [Parameter(Mandatory=$false,Position=2)][Switch]$ValidateCredential
    )

    Begin {
        if (-not $CredPath) { $CredPath = "$env:windir\KeyChain" }
        $null = New-Item -Path $CredPath -ItemType Directory -Force -EA 0 
        if (-not (Test-Path -Path $CredPath)) { Write-Log 'Get-SBCredential Error:',$CredPath,'not found/could not be created' Magenta,Yellow,Cyan; break }
        if (-not ((Get-Item -Path $CredPath) -is [System.IO.DirectoryInfo])) { Write-Log 'Get-SBCredential Error:',$CredPath,'is not a folder' Magenta,Yellow,Cyan; break }

        $CredFile = "$CredPath\$($UserName.Replace('\','_').Replace('/','_')).txt"
        if ($Refresh) { Remove-Item -Path $CredFile -Force -Confirm:$false -EA 0 } 
    }

    Process {
        
        if (-not (Test-Path -Path $CredFile)) {
            Read-Host "Enter the pwd for $UserName" -AsSecureString | ConvertFrom-SecureString | Out-File $CredFile 
        }   
        try {
            $Pwd = Get-Content $CredFile | ConvertTo-SecureString -EA 1 
        } catch {
            if ($_.Exception.Message -match 'Key not valid for use in specified state') {
                Write-Log 'Get-SBCredential Error:','The provided Credential File',$CredFile,'was encrypted/saved by other than the current user',"$env:USERDNSDOMAIN\$env:USERNAME" Magenta,Yellow,Cyan,Yellow,cyan
            } else {
                Write-Log 'Get-SBCredential Error:',$_.Exception.Message Magenta,Yellow
            }
            break
        }

        $Cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $UserName, $Pwd 
        if ($ValidateCredential) {
            if (-not (Validate-WindowsCredential -Credential $Cred)) {
                Write-Log 'Get-SBCredential:','saved pwd is no longer valid in the',$thisDomainName,'domain' Magenta,Yellow,Magenta,Yellow
                Get-SBCredential -UserName $UserName -CredPath $CredPath -Refresh 
            }
        }
    }

    End { $Cred }
}

function ConvertTo-EnhancedHTML {
<#
.SYNOPSIS
Provides an enhanced version of the ConvertTo-HTML command that includes
inserting an embedded CSS style sheet, JQuery, and JQuery Data Tables for
interactivity. Intended to be used with HTML fragments that are produced
by ConvertTo-EnhancedHTMLFragment. This command does not accept pipeline
input.
 
.PARAMETER jQueryURI
A Uniform Resource Indicator (URI) pointing to the location of the
jQuery script file. You can download jQuery from www.jquery.com; you should
host the script file on a local intranet Web server and provide a URI
that starts with http:// or https://. Alternately, you can also provide
a file system path to the script file, although this may create security
issues for the Web browser in some configurations.
 
Tested with v1.8.2.
 
Defaults to http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.2.min.js, which
will pull the file from Microsoft's ASP.NET Content Delivery Network.
 
.PARAMETER jQueryDataTableURI
A Uniform Resource Indicator (URI) pointing to the location of the
jQuery Data Table script file. You can download this from www.datatables.net;
you should host the script file on a local intranet Web server and provide a URI
that starts with http:// or https://. Alternately, you can also provide
a file system path to the script file, although this may create security
issues for the Web browser in some configurations.
 
Tested with jQuery DataTable v1.9.4
 
Defaults to http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.3/jquery.dataTables.min.js,
which will pull the file from Microsoft's ASP.NET Content Delivery Network.
 
.PARAMETER CssStyleSheet
The CSS style sheet content - not a file name. If you have a CSS file,
you can load it into this parameter as follows:
 
    -CSSStyleSheet (Get-Content MyCSSFile.css)
 
Alternately, you may link to a Web server-hosted CSS file by using the
-CssUri parameter.
 
.PARAMETER CssUri
A Uniform Resource Indicator (URI) to a Web server-hosted CSS file.
Must start with either http:// or https://. If you omit this, you
can still provide an embedded style sheet, which makes the resulting
HTML page more standalone. To provide an embedded style sheet, use
the -CSSStyleSheet parameter.
 
.PARAMETER Title
A plain-text title that will be displayed in the Web browser's window
title bar. Note that not all browsers will display this.
 
.PARAMETER PreContent
Raw HTML to insert before all HTML fragments. Use this to specify a main
title for the report:
 
    -PreContent "<H1>My HTML Report</H1>"
 
.PARAMETER PostContent
Raw HTML to insert after all HTML fragments. Use this to specify a
report footer:
 
    -PostContent "Created on $(Get-Date)"
 
.PARAMETER HTMLFragments
One or more HTML fragments, as produced by ConvertTo-EnhancedHTMLFragment.
 
    -HTMLFragments $part1,$part2,$part3
.EXAMPLE
The following is a complete example script showing how to use
ConvertTo-EnhancedHTMLFragment and ConvertTo-EnhancedHTML. The
example queries 6 pieces of information from the local computer
and produces a report in C:\. This example uses most of the
avaiable options. It relies on Internet connectivity to retrieve
JavaScript from Microsoft's Content Delivery Network. This
example uses an embedded stylesheet, which is defined as a here-string
at the top of the script.
 
$computername = 'localhost'
$path = 'c:\'
$style = @"
<style>
body {
    color:#333333;
    font-family:Calibri,Tahoma;
    font-size: 10pt;
}
h1 {
    text-align:center;
}
h2 {
    border-top:1px solid #666666;
}
 
 
th {
    font-weight:bold;
    color:#eeeeee;
    background-color:#333333;
    cursor:pointer;
}
.odd { background-color:#ffffff; }
.even { background-color:#dddddd; }
.paginate_enabled_next, .paginate_enabled_previous {
    cursor:pointer;
    border:1px solid #222222;
    background-color:#dddddd;
    padding:2px;
    margin:4px;
    border-radius:2px;
}
.paginate_disabled_previous, .paginate_disabled_next {
    color:#666666;
    cursor:pointer;
    background-color:#dddddd;
    padding:2px;
    margin:4px;
    border-radius:2px;
}
.dataTables_info { margin-bottom:4px; }
.sectionheader { cursor:pointer; }
.sectionheader:hover { color:red; }
.grid { width:100% }
.red {
    color:red;
    font-weight:bold;
}
</style>
"@
 
function Get-InfoOS {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)][string]$ComputerName
    )
    $os = Get-WmiObject -class Win32_OperatingSystem -ComputerName $ComputerName
    $props = @{'OSVersion'=$os.version;
               'SPVersion'=$os.servicepackmajorversion;
               'OSBuild'=$os.buildnumber}
    New-Object -TypeName PSObject -Property $props
}
 
function Get-InfoCompSystem {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)][string]$ComputerName
    )
    $cs = Get-WmiObject -class Win32_ComputerSystem -ComputerName $ComputerName
    $props = @{'Model'=$cs.model;
               'Manufacturer'=$cs.manufacturer;
               'RAM (GB)'="{0:N2}" -f ($cs.totalphysicalmemory / 1GB);
               'Sockets'=$cs.numberofprocessors;
               'Cores'=$cs.numberoflogicalprocessors}
    New-Object -TypeName PSObject -Property $props
}
 
function Get-InfoBadService {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)][string]$ComputerName
    )
    $svcs = Get-WmiObject -class Win32_Service -ComputerName $ComputerName `
           -Filter "StartMode='Auto' AND State<>'Running'"
    foreach ($svc in $svcs) {
        $props = @{'ServiceName'=$svc.name;
                   'LogonAccount'=$svc.startname;
                   'DisplayName'=$svc.displayname}
        New-Object -TypeName PSObject -Property $props
    }
}
 
function Get-InfoProc {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)][string]$ComputerName
    )
    $procs = Get-WmiObject -class Win32_Process -ComputerName $ComputerName
    foreach ($proc in $procs) {
        $props = @{'ProcName'=$proc.name;
                   'Executable'=$proc.ExecutablePath}
        New-Object -TypeName PSObject -Property $props
    }
}
 
function Get-InfoNIC {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)][string]$ComputerName
    )
    $nics = Get-WmiObject -class Win32_NetworkAdapter -ComputerName $ComputerName `
           -Filter "PhysicalAdapter=True"
    foreach ($nic in $nics) {
        $props = @{'NICName'=$nic.servicename;
                   'Speed'=$nic.speed / 1MB -as [int];
                   'Manufacturer'=$nic.manufacturer;
                   'MACAddress'=$nic.macaddress}
        New-Object -TypeName PSObject -Property $props
    }
}
 
function Get-InfoDisk {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True)][string]$ComputerName
    )
    $drives = Get-WmiObject -class Win32_LogicalDisk -ComputerName $ComputerName `
           -Filter "DriveType=3"
    foreach ($drive in $drives) {
        $props = @{'Drive'=$drive.DeviceID;
                   'Size'=$drive.size / 1GB -as [int];
                   'Free'="{0:N2}" -f ($drive.freespace / 1GB);
                   'FreePct'=$drive.freespace / $drive.size * 100 -as [int]}
        New-Object -TypeName PSObject -Property $props
    }
}
 
foreach ($computer in $computername) {
    try {
        $everything_ok = $true
        Write-Verbose "Checking connectivity to $computer"
        Get-WmiObject -class Win32_BIOS -ComputerName $Computer -EA Stop | Out-Null
    } catch {
        Write-Warning "$computer failed"
        $everything_ok = $false
    }
 
    if ($everything_ok) {
        $filepath = Join-Path -Path $Path -ChildPath "$computer.html"
 
        $params = @{'As'='List';
                    'PreContent'='<h2>OS</h2>'}
        $html_os = Get-InfoOS -ComputerName $computer |
                   ConvertTo-EnhancedHTMLFragment @params
 
        $params = @{'As'='List';
                    'PreContent'='<h2>Computer System</h2>'}
        $html_cs = Get-InfoCompSystem -ComputerName $computer |
                   ConvertTo-EnhancedHTMLFragment @params
 
        $params = @{'As'='Table';
                    'PreContent'='<h2>&diams; Local Disks</h2>';
                    'EvenRowCssClass'='even';
                    'OddRowCssClass'='odd';
                    'MakeTableDynamic'=$true;
                    'TableCssClass'='grid';
                    'Properties'='Drive',
                                 @{n='Size(GB)';e={$_.Size}},
                                 @{n='Free(GB)';e={$_.Free};css={if ($_.FreePct -lt 80) { 'red' }}},
                                 @{n='Free(%)';e={$_.FreePct};css={if ($_.FreeePct -lt 80) { 'red' }}}}
        $html_dr = Get-InfoDisk -ComputerName $computer |
                   ConvertTo-EnhancedHTMLFragment @params
 
        $params = @{'As'='Table';
                    'PreContent'='<h2>&diams; Processes</h2>';
                    'MakeTableDynamic'=$true;
                    'TableCssClass'='grid'}
        $html_pr = Get-InfoProc -ComputerName $computer |
                   ConvertTo-EnhancedHTMLFragment @params
 
        $params = @{'As'='Table';
                    'PreContent'='<h2>&diams; Services to Check</h2>';
                    'EvenRowCssClass'='even';
                    'OddRowCssClass'='odd';
                    'MakeHiddenSection'=$true;
                    'TableCssClass'='grid'}
        $html_sv = Get-InfoBadService -ComputerName $computer |
                   ConvertTo-EnhancedHTMLFragment @params
 
        $params = @{'As'='Table';
                    'PreContent'='<h2>&diams; NICs</h2>';
                    'EvenRowCssClass'='even';
                    'OddRowCssClass'='odd';
                    'MakeHiddenSection'=$true;
                    'TableCssClass'='grid'}
        $html_na = Get-InfoNIC -ComputerName $Computer |
                   ConvertTo-EnhancedHTMLFragment @params
 
        $params = @{'CssStyleSheet'=$style;
                    'Title'="System Report for $computer";
                    'PreContent'="<h1>System Report for $computer</h1>";
                    'HTMLFragments'=@($html_os,$html_cs,$html_dr,$html_pr,$html_sv,$html_na)}
        ConvertTo-EnhancedHTML @params |
        Out-File -FilePath $filepath
    }
}
 
 .Notes
  Function by Don Jones
  Generated on: 9/10/2013
  For more information see Powershell.org
  included in AZSBTools module with permission by Don Jones
   
#>

    [CmdletBinding()]
    param(
        [string]$jQueryURI = 'http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.2.min.js',
        [string]$jQueryDataTableURI = 'http://ajax.aspnetcdn.com/ajax/jquery.dataTables/1.9.3/jquery.dataTables.min.js',
        [Parameter(ParameterSetName='CSSContent')][string[]]$CssStyleSheet,
        [Parameter(ParameterSetName='CSSURI')][string[]]$CssUri,
        [string]$Title = 'Report',
        [string]$PreContent,
        [string]$PostContent,
        [Parameter(Mandatory=$True)][string[]]$HTMLFragments
    )


    <#
        Add CSS style sheet. If provided in -CssUri, add a <link> element.
        If provided in -CssStyleSheet, embed in the <head> section.
        Note that BOTH may be supplied - this is legitimate in HTML.
    #>

    Write-Verbose "Making CSS style sheet"
    $stylesheet = ""
    if ($PSBoundParameters.ContainsKey('CssUri')) {
        $stylesheet = "<link rel=`"stylesheet`" href=`"$CssUri`" type=`"text/css`" />"
    }
    if ($PSBoundParameters.ContainsKey('CssStyleSheet')) {
        $stylesheet = "<style>$CssStyleSheet</style>" | Out-String
    }


    <#
        Create the HTML tags for the page title, and for
        our main javascripts.
    #>

    Write-Verbose "Creating <TITLE> and <SCRIPT> tags"
    $titletag = ""
    if ($PSBoundParameters.ContainsKey('title')) {
        $titletag = "<title>$title</title>"
    }
    $script += "<script type=`"text/javascript`" src=`"$jQueryURI`"></script>`n<script type=`"text/javascript`" src=`"$jQueryDataTableURI`"></script>"


    <#
        Render supplied HTML fragments as one giant string
    #>

    Write-Verbose "Combining HTML fragments"
    $body = $HTMLFragments | Out-String


    <#
        If supplied, add pre- and post-content strings
    #>

    Write-Verbose "Adding Pre and Post content"
    if ($PSBoundParameters.ContainsKey('precontent')) {
        $body = "$PreContent`n$body"
    }
    if ($PSBoundParameters.ContainsKey('postcontent')) {
        $body = "$body`n$PostContent"
    }


    <#
        Add a final script that calls the datatable code
        We dynamic-ize all tables with the .enhancedhtml-dynamic-table
        class, which is added by ConvertTo-EnhancedHTMLFragment.
    #>

    Write-Verbose "Adding interactivity calls"
    $datatable = ""
    $datatable = "<script type=`"text/javascript`">"
    $datatable += '$(document).ready(function () {'
    $datatable += "`$('.enhancedhtml-dynamic-table').dataTable();"
    $datatable += '} );'
    $datatable += "</script>"


    <#
        Datatables expect a <thead> section containing the
        table header row; ConvertTo-HTML doesn't produce that
        so we have to fix it.
    #>

    Write-Verbose "Fixing table HTML"
    $body = $body -replace '<tr><th>','<thead><tr><th>'
    $body = $body -replace '</th></tr>','</th></tr></thead>'


    <#
        Produce the final HTML. We've more or less hand-made
        the <head> amd <body> sections, but we let ConvertTo-HTML
        produce the other bits of the page.
    #>

    Write-Verbose "Producing final HTML"
    ConvertTo-HTML -Head "$stylesheet`n$titletag`n$script`n$datatable" -Body $body  
    Write-Debug "Finished producing final HTML"


}

function ConvertTo-EnhancedHTMLFragment {
<#
.SYNOPSIS
Creates an HTML fragment (much like ConvertTo-HTML with the -Fragment switch
that includes CSS class names for table rows, CSS class and ID names for the
table, and wraps the table in a <DIV> tag that has a CSS class and ID name.
 
.PARAMETER InputObject
The object to be converted to HTML. You cannot select properties using this
command; precede this command with Select-Object if you need a subset of
the objects' properties.
 
.PARAMETER EvenRowCssClass
The CSS class name applied to even-numbered <TR> tags. Optional, but if you
use it you must also include -OddRowCssClass.
 
.PARAMETER OddRowCssClass
The CSS class name applied to odd-numbered <TR> tags. Optional, but if you
use it you must also include -EvenRowCssClass.
 
.PARAMETER TableCssID
Optional. The CSS ID name applied to the <TABLE> tag.
 
.PARAMETER DivCssID
Optional. The CSS ID name applied to the <DIV> tag which is wrapped around the table.
 
.PARAMETER TableCssClass
Optional. The CSS class name to apply to the <TABLE> tag.
 
.PARAMETER DivCssClass
Optional. The CSS class name to apply to the wrapping <DIV> tag.
 
.PARAMETER As
Must be 'List' or 'Table.' Defaults to Table. Actually produces an HTML
table either way; with Table the output is a grid-like display. With
List the output is a two-column table with properties in the left column
and values in the right column.
 
.PARAMETER Properties
A comma-separated list of properties to include in the HTML fragment.
This can be * (which is the default) to include all properties of the
piped-in object(s). In addition to property names, you can also use a
hashtable similar to that used with Select-Object. For example:
 
 Get-Process | ConvertTo-EnhancedHTMLFragment -As Table `
               -Properties Name,ID,@{n='VM';
                                     e={$_.VM};
                                     css={if ($_.VM -gt 100) { 'red' }
                                          else { 'green' }}}
 
This will create table cell rows with the calculated CSS class names.
E.g., for a process with a VM greater than 100, you'd get:
 
  <TD class="red">475858</TD>
   
You can use this feature to specify a CSS class for each table cell
based upon the contents of that cell. Valid keys in the hashtable are:
 
  n, name, l, or label: The table column header
  e or expression: The table cell contents
  css or csslcass: The CSS class name to apply to the <TD> tag
   
Another example:
 
  @{n='Free(MB)';
    e={$_.FreeSpace / 1MB -as [int]};
    css={ if ($_.FreeSpace -lt 100) { 'red' } else { 'blue' }}
     
This example creates a column titled "Free(MB)". It will contain
the input object's FreeSpace property, divided by 1MB and cast
as a whole number (integer). If the value is less than 100, the
table cell will be given the CSS class "red." If not, the table
cell will be given the CSS class "blue." The supplied cascading
style sheet must define ".red" and ".blue" for those to have any
effect.
 
.PARAMETER PreContent
Raw HTML content to be placed before the wrapping <DIV> tag.
For example:
 
    -PreContent "<h2>Section A</h2>"
 
.PARAMETER PostContent
Raw HTML content to be placed after the wrapping <DIV> tag.
For example:
 
    -PostContent "<hr />"
 
.PARAMETER MakeHiddenSection
Used in conjunction with -PreContent. Adding this switch, which
needs no value, turns your -PreContent into clickable report
section header. The section will be hidden by default, and clicking
the header will toggle its visibility.
 
When using this parameter, consider adding a symbol to your -PreContent
that helps indicate this is an expandable section. For example:
 
    -PreContent '<h2>&diams; My Section</h2>'
 
If you use -MakeHiddenSection, you MUST provide -PreContent also, or
the hidden section will not have a section header and will not be
visible.
 
.PARAMETER MakeTableDynamic
When using "-As Table", makes the table dynamic. Will be ignored
if you use "-As List". Dynamic tables are sortable, searchable, and
are paginated.
 
You should not use even/odd styling with tables that are made
dynamic. Dynamic tables automatically have their own even/odd
styling. You can apply CSS classes named ".odd" and ".even" in
your CSS to style the even/odd in a dynamic table.
 
.EXAMPLE
 $fragment = Get-WmiObject -Class Win32_LogicalDisk |
             Select-Object -Property PSComputerName,DeviceID,FreeSpace,Size |
             ConvertTo-HTMLFragment -EvenRowClass 'even' `
                                    -OddRowClass 'odd' `
                                    -PreContent '<h2>Disk Report</h2>' `
                                    -MakeHiddenSection `
                                    -MakeTableDynamic
 
 You will usually save fragments to a variable, so that multiple fragments
 (each in its own variable) can be passed to ConvertTo-EnhancedHTML.
.NOTES
Consider adding the following to your CSS when using dynamic tables:
 
    .paginate_enabled_next, .paginate_enabled_previous {
        cursor:pointer;
        border:1px solid #222222;
        background-color:#dddddd;
        padding:2px;
        margin:4px;
        border-radius:2px;
    }
    .paginate_disabled_previous, .paginate_disabled_next {
        color:#666666;
        cursor:pointer;
        background-color:#dddddd;
        padding:2px;
        margin:4px;
        border-radius:2px;
    }
    .dataTables_info { margin-bottom:4px; }
 
This applies appropriate coloring to the next/previous buttons,
and applies a small amount of space after the dynamic table.
 
If you choose to make sections hidden (meaning they can be shown
and hidden by clicking on the section header), consider adding
the following to your CSS:
 
    .sectionheader { cursor:pointer; }
    .sectionheader:hover { color:red; }
 
This will apply a hover-over color, and change the cursor icon,
to help visually indicate that the section can be toggled.
 
 .Notes
  Function by Don Jones
  Generated on: 9/10/2013
  For more information see Powershell.org
  included in AZSBTools module with permission by Don Jones
 
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$True,ValueFromPipeline=$True)]
        [object[]]$InputObject,
        [string]$EvenRowCssClass,
        [string]$OddRowCssClass,
        [string]$TableCssID,
        [string]$DivCssID,
        [string]$DivCssClass,
        [string]$TableCssClass,
        [ValidateSet('List','Table')]
        [string]$As = 'Table',
        [object[]]$Properties = '*',
        [string]$PreContent,
        [switch]$MakeHiddenSection,
        [switch]$MakeTableDynamic,
        [string]$PostContent
    )
    BEGIN {
        <#
            Accumulate output in a variable so that we don't
            produce an array of strings to the pipeline, but
            instead produce a single string.
        #>

        $out = ''
        <#
            Add the section header (pre-content). If asked to
            make this section of the report hidden, set the
            appropriate code on the section header to toggle
            the underlying table. Note that we generate a GUID
            to use as an additional ID on the <div>, so that
            we can uniquely refer to it without relying on the
            user supplying us with a unique ID.
        #>

        Write-Verbose "Precontent"
        if ($PSBoundParameters.ContainsKey('PreContent')) {
            if ($PSBoundParameters.ContainsKey('MakeHiddenSection')) {
               [string]$tempid = [System.Guid]::NewGuid()
               $out += "<span class=`"sectionheader`" onclick=`"`$('#$tempid').toggle(500);`">$PreContent</span>`n"
            } else {
                $out += $PreContent
                $tempid = ''
            }
        }
        <#
            The table will be wrapped in a <div> tag for styling
            purposes. Note that THIS, not the table per se, is what
            we hide for -MakeHiddenSection. So we will hide the section
            if asked to do so.
        #>

        Write-Verbose "DIV"
        if ($PSBoundParameters.ContainsKey('DivCSSClass')) {
            $temp = " class=`"$DivCSSClass`""
        } else {
            $temp = ""
        }
        if ($PSBoundParameters.ContainsKey('MakeHiddenSection')) {
            $temp += " id=`"$tempid`" style=`"display:none;`""
        } else {
            $tempid = ''
        }
        if ($PSBoundParameters.ContainsKey('DivCSSID')) {
            $temp += " id=`"$DivCSSID`""
        }
        $out += "<div $temp>"
        <#
            Create the table header. If asked to make the table dynamic,
            we add the CSS style that ConvertTo-EnhancedHTML will look for
            to dynamic-ize tables.
        #>

        Write-Verbose "TABLE"
        $_TableCssClass = ''
        if ($PSBoundParameters.ContainsKey('MakeTableDynamic') -and $As -eq 'Table') {
            $_TableCssClass += 'enhancedhtml-dynamic-table '
        }
        if ($PSBoundParameters.ContainsKey('TableCssClass')) {
            $_TableCssClass += $TableCssClass
        }
        if ($_TableCssClass -ne '') {
            $css = "class=`"$_TableCSSClass`""
        } else {
            $css = ""
        }
        if ($PSBoundParameters.ContainsKey('TableCSSID')) {
            $css += "id=`"$TableCSSID`""
        } else {
            if ($tempid -ne '') {
                $css += "id=`"$tempid`""
            }
        }
        $out += "<table $css>"
        <#
            We're now setting up to run through our input objects
            and create the table rows
        #>

        $fragment = ''
        $wrote_first_line = $false
        $even_row = $false

        if ($properties -eq '*') {
            $all_properties = $true
        } else {
            $all_properties = $false
        }

    }
    PROCESS {

        foreach ($object in $inputobject) {
            Write-Verbose "Processing object"
            $datarow = ''
            $headerrow = ''

            <#
                Apply even/odd row class. Note that this will mess up the output
                if the table is made dynamic. That's noted in the help.
            #>

            if ($PSBoundParameters.ContainsKey('EvenRowCSSClass') -and $PSBoundParameters.ContainsKey('OddRowCssClass')) {
                if ($even_row) {
                    $row_css = $OddRowCSSClass
                    $even_row = $false
                    Write-Verbose "Even row"
                } else {
                    $row_css = $EvenRowCSSClass
                    $even_row = $true
                    Write-Verbose "Odd row"
                }
            } else {
                $row_css = ''
                Write-Verbose "No row CSS class"
            }


            <#
                If asked to include all object properties, get them.
            #>

            if ($all_properties) {
                $properties = $object | Get-Member -MemberType Properties | Select -ExpandProperty Name
            }


            <#
                We either have a list of all properties, or a hashtable of
                properties to play with. Process the list.
            #>

            foreach ($prop in $properties) {
                Write-Verbose "Processing property"
                $name = $null
                $value = $null
                $cell_css = ''


                <#
                    $prop is a simple string if we are doing "all properties,"
                    otherwise it is a hashtable. If it's a string, then we
                    can easily get the name (it's the string) and the value.
                #>

                if ($prop -is [string]) {
                    Write-Verbose "Property $prop"
                    $name = $Prop
                    $value = $object.($prop)
                } elseif ($prop -is [hashtable]) {
                    Write-Verbose "Property hashtable"
                    <#
                        For key "css" or "cssclass," execute the supplied script block.
                        It's expected to output a class name; we embed that in the "class"
                        attribute later.
                    #>

                    if ($prop.ContainsKey('cssclass')) { $cell_css = $Object | ForEach $prop['cssclass'] }
                    if ($prop.ContainsKey('css')) { $cell_css = $Object | ForEach $prop['css'] }


                    <#
                        Get the current property name.
                    #>

                    if ($prop.ContainsKey('n')) { $name = $prop['n'] }
                    if ($prop.ContainsKey('name')) { $name = $prop['name'] }
                    if ($prop.ContainsKey('label')) { $name = $prop['label'] }
                    if ($prop.ContainsKey('l')) { $name = $prop['l'] }


                    <#
                        Execute the "expression" or "e" key to get the value of the property.
                    #>

                    if ($prop.ContainsKey('e')) { $value = $Object | ForEach $prop['e'] }
                    if ($prop.ContainsKey('expression')) { $value = $tObject | ForEach $prop['expression'] }


                    <#
                        Make sure we have a name and a value at this point.
                    #>

                    if ($name -eq $null -or $value -eq $null) {
                        Write-Error "Hashtable missing Name and/or Expression key"
                    }
                } else {
                    <#
                        We got a property list that wasn't strings and
                        wasn't hashtables. Bad input.
                    #>

                    Write-Warning "Unhandled property $prop"
                }


                <#
                    When constructing a table, we have to remember the
                    property names so that we can build the table header.
                    In a list, it's easier - we output the property name
                    and the value at the same time, since they both live
                    on the same row of the output.
                #>

                if ($As -eq 'table') {
                    Write-Verbose "Adding $name to header and $value to row"
                    $headerrow += "<th>$name</th>"
                    $datarow += "<td$(if ($cell_css -ne '') { ' class="'+$cell_css+'"' })>$value</td>"
                } else {
                    $wrote_first_line = $true
                    $headerrow = ""
                    $datarow = "<td$(if ($cell_css -ne '') { ' class="'+$cell_css+'"' })>$name :</td><td$(if ($cell_css -ne '') { ' class="'+$cell_css+'"' })>$value</td>"
                    $out += "<tr$(if ($row_css -ne '') { ' class="'+$row_css+'"' })>$datarow</tr>"
                }
            }


            <#
                Write the table header, if we're doing a table.
            #>

            if (-not $wrote_first_line -and $as -eq 'Table') {
                Write-Verbose "Writing header row"
                $out += "<tr>$headerrow</tr><tbody>"
                $wrote_first_line = $true
            }


            <#
                In table mode, write the data row.
            #>

            if ($as -eq 'table') {
                Write-Verbose "Writing data row"
                $out += "<tr$(if ($row_css -ne '') { ' class="'+$row_css+'"' })>$datarow</tr>"
            }
        }
    }
    END {
        <#
            Finally, post-content code, the end of the table,
            the end of the <div>, and write the final string.
        #>

        Write-Verbose "PostContent"
        if ($PSBoundParameters.ContainsKey('PostContent')) {
            $out += "`n$PostContent"
        }
        Write-Verbose "Done"
        $out += "</tbody></table></div>"
        Write-Output $out
    }
}

Function Get-SBWMI {
<#
 .SYNOPSIS
  Function query WMI with Timeout
 
 .DESCRIPTION
  Function query WMI with Timeout
 
 .PARAMETER Class
  Class name such as 'Win32_computerSystem'
 
 .PARAMETER Property
  Property name such as 'NumberofLogicalProcessors'
 
 .PARAMETER Filter
  In the format Property=Value such as DriveLetter=G:
 
 .PARAMETER ComputerName
  Computer name
 
 .PARAMETER NameSpace
  Default is 'root\cimv2'
  To see name spaces type:
    (Get-WmiObject -Namespace 'root' -Class '__Namespace').Name
 
 .PARAMETER Cred
  PS Credential object
 
 .PARAMETER TimeOut
  In seconds
 
 .EXAMPLE
  Get-SBWMI -Class Win32_computerSystem -Property NumberofLogicalProcessors
 
 .EXAMPLE
  Get-SBWMI -Class Win32_Volume -Filter 'DriveType=3'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 September 2017
  v0.2 - 29 September 2017 - Added parameter to use a different credential other than the one running the script
                             Added error checking for failure to WMI connect
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)][string]$Class,
        [Parameter(Mandatory=$false)][String[]]$Property = '*',
        [Parameter(Mandatory=$false)][String]$Filter,
        [Parameter(Mandatory=$false)][String]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory=$false)][String]$NameSpace = 'root\cimv2',
        [Parameter(Mandatory=$false)][PSCredential]$Cred,
        [Parameter(Mandatory=$false)][int]$TimeOut=20
    )

    Begin {
        if ($Filter) {
            if ($Filter -match '=') {
                $FilterProperty = $Filter.Split('=')[0].Trim()
                $FilterValue    = $Filter.Split('=')[1].Trim()
            } else {
                Write-Log 'Get-SBWMI Input Error:','Filter',', supported syntax is','Property=Value','such as','DriveLetter=G' Magenta,Yellow,Magenta,Yellow,Magenta,Yellow
                Write-Log ' ignoring filter',$Filter Magenta,Yellow 
            }
        }
    }

    Process{
        $ConnOpt = New-Object System.Management.ConnectionOptions 
        if ($ComputerName -ne $env:COMPUTERNAME -and $Cred) { # User credentials cannot be used for local connections
            $ConnOpt.EnablePrivileges = $true
            $ConnOpt.Username = $Cred.UserName
            $ConnOpt.SecurePassword = $Cred.Password
        }
        $Scope = New-Object System.Management.ManagementScope “\\$ComputerName\$NameSpace", $ConnOpt
        try { 
            $Scope.Connect()
        } catch {
            $Message = $_.Exception.InnerException
        }
        if ($Scope.IsConnected) {
            $EnumOptions = New-Object System.Management.EnumerationOptions
            $EnumOptions.set_timeout((New-TimeSpan -seconds $TimeOut))
            $Search = New-Object System.Management.ManagementObjectSearcher 
            $Search.set_options($EnumOptions) 
            $Search.Query = “SELECT $Property FROM $Class” 
            $Search.Scope = $Scope
            $Result = $Search.get()
        } else {
            Write-Warning "Get-SBWMI: Error: $(($Message|Out-String).Trim())"
        }
    } 

    End {
        if ($Result){
            if ($Filter) {
                if ($FilterProperty -in ($Result | Get-Member -MemberType Property).Name) {
                    $Result | where { $_.$FilterProperty -eq $FilterValue }
                } else {
                    Write-Log 'Class',$Class,'doesn''t contain filter property',$FilterProperty Magenta,Yellow,Magenta,Yellow
                    Write-Log 'Class',$Class,'has the following properties:' Cyan,Yellow,Cyan
                    Write-Log (($Result | Get-Member -MemberType Property).Name | ? { $_ -notmatch '__' } | Out-String).Trim() Cyan
                }
            } else {
                $Result
            }
        }
    }
}

function Get-SBDisk {
<#
 .SYNOPSIS
  Function to get disk information including block (allocation unit) size
 
 .DESCRIPTION
  Function to get disk information including block (allocation unit) size
  Function returns information on all fixed disks (Type 3)
  Function will fail to return computer disk information if:
    - Target computer is offline or name is misspelled
    - Function/script is run under an account with no read permission on the target computer
    - WMI services not running on the target computer
    - Target computer firewall or AntiVirus blocks WMI or RPC calls
 
 .PARAMETER ComputerName
  The name or IP address of computer(s) to collect disk information on
  Default value is local computer name
 
 .PARAMETER WMITimeOut
  Timeout in seconds. The default value is 20
 
 .PARAMETER Cred
  PS Credential object
 
 .PARAMETER IncludeRecoveryVolume
  This parameter takes a $true or $false value, and is set to $false by default
  When set to $true the script will return information on Recovery Volume
 
 .EXAMPLE
  Get-SBDisk
  Returns fixed disk information of local computer
 
 .EXAMPLE
  Get-SBDisk computer1, 192.168.19.26, computer3 -Verbose
  Returns fixed disk information of the 3 listed computers
  The 'verbose' parameter will display a message if the target computer cannot be reached
 
 .OUTPUTS
  The script returns a PS Object with the following properties:
    ComputerName
    VolumeName
    DriveLetterOrMountPoint
    BlockSizeKB
    SizeGB
    FreeGB
    'Free%'
    FileSystem
    Compressed
 
 .LINK
  https://superwidgets.wordpress.com/2017/01/09/powershell-script-to-get-disk-information-including-block-size/
 
 .NOTES
  Function by Sam Boutros - v1.0 - 9 January 2017
    v2.0 - 24 January 2017
        Used WMI object Win32_Volume instead of Win32_LogicalDisk to capture mount points as well
        Added parameter to skip Recovery Volume
        Updated output object properties
    v3.0 - 12 July 2017
        Updated output object to change data types to Int32 instead of the default String for BlockSizeKB,SizeGB,FreeGB,'Free%'
    v4.0 - 20 September 2017 - Used Get-SBWMI instead to take advanrage of the default 20 sec Timeout
    v4.1 - 22 September 2017 - Added WMITimeout parameter,
        removed -Filter parameter from Get-SBWMI call and filtered via updated if statement to speed processing by 200%
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [String[]]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory=$false)][Int32]$WMITimeOut = 20,
        [Parameter(Mandatory=$false)][System.Management.Automation.PSCredential]$Cred = (Get-SBCredential -UserName "$env:USERDOMAIN\$env:USERNAME"),
        [Parameter(Mandatory=$false)][Switch]$IncludeRecoveryVolume
    )

    foreach ($Computer in $ComputerName) {
        try {
            Get-SBWMI -ComputerName $Computer -Class Win32_Volume -TimeOut $WMITimeOut -Cred $Cred -ErrorAction Stop | % {
                if ($_.DriveType -eq 3 -and ($_.Label-notlike'Recovery' -or $IncludeRecoveryVolume)) {
                    [PSCustomObject][Ordered]@{
                        ComputerName    = $Computer
                        VolumeName      = $_.Label
                        DriveLetterOrMountPoint = $(if ($_.Name.Contains(':')) {$_.Name} else {'<Not mounted>'})
                        BlockSizeKB     = [Int32]($_.Blocksize/1KB)
                        SizeGB          = [Math]::Round($_.Capacity/1GB,1)
                        FreeGB          = [Math]::Round($_.FreeSpace/1GB,1)
                        'Free%'         = [Math]::Round($_.FreeSpace/$_.Capacity*100,1)
                        FileSystem      = $_.FileSystem
                        Compressed      = $_.Compressed
                        Indexed         = $_.IndexingEnabled
                        Automount       = $_.Automount
                        QuotasEnabled   = $_.QuotasEnabled
                        PageFilePresent = $_.PageFilePresent
                        BootVolume      = $_.BootVolume
                        SystemVolume    = $_.SystemVolume
                    } # PSCustomObject
                } # if
            } # Get-SBWMI
        } catch {
            Write-Verbose "Unable to read disk information from computer $Computer"
        }
    }
}

function Format-SBCounter {
<#
 .SYNOPSIS
  Function to format the output of Get-Counter cmdlet
 
 .DESCRIPTION
  Function to format the output of Get-Counter cmdlet of the Microsoft.PowerShell.Diagnostics PS module
 
 .PARAMETER CounterSample
  This is of type Microsoft.PowerShell.Commands.GetCounter.PerformanceCounterSampleSet
  which can be obtained from the output of the Get-Counter cmdlet
 
 .EXAMPLE
  Get-Counter | Format-SBCounter
 
 .OUTPUTS
  The script returns a PS Object with the following properties/example:
    DateTime : 3/1/2019 12:43:57 PM
    ComputerName : mycomputernamehere
    CounterSet : physicaldisk(_total)
    Counter : current disk queue length
    Value : 0
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros - v0.1 - 1 March 2019
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    param (
        [Parameter(Mandatory,ValueFromPipeline)]
        [Microsoft.PowerShell.Commands.GetCounter.PerformanceCounterSampleSet]$CounterSample
    )

    Begin {}

    Process {
        foreach ($Counter in $CounterSample.CounterSamples){
            $Temp = $Counter.Path.Split('\')
            [PSCustomObject][Ordered]@{
                DateTime     = $Counter.Timestamp
                ComputerName = $Temp[2]
                CounterSet   = $Temp[3]
                Counter      = $Temp[4]
                Value        = $Counter.CookedValue
            } 
        }
    }
     
    End {} 
}

function Validate-WindowsCredential {
<#
 .SYNOPSIS
  Function to validate whether a provided Credential is correct on a provided target Windows Computer
 
 .DESCRIPTION
  Function to validate whether a provided Credential is correct on a provided target Windows Computer
 
 .PARAMETER Credential
  PSCredential object. This can be obtained from the Get-Credential cmdlet of the Microsoft.PowerShell.Security,
  or the Get-SBCredential function of the SB-Tools PS module
 
 .PARAMETER Session
  PSSession object. This can be obtained via the New-PSSession cmdlet of the Microsoft.PowerShell.Core
 
 .OUTPUTS
  The script outputs a TRUE/FALSE result if the provided PSSession is valid and opened.
 
 .EXAMPLE
  $Session = New-PSSession -ComputerName test-vm0116.test.domain.com -Credential (Get-SBCredential 'test\superuser')
  Validate-WindowsCredential -Credential (Get-SBCredential '.\administrator') -Session $Session
  A 'TRUE' result indicates that the local administrator account of the test-vm0116.test.domain.com is valid (name and password)
  A 'FALSE' result indicates failure to authenticate. This can be due to bad username or password, or locked or disabled account..
 
 .EXAMPLE
  $Session = New-PSSession -ComputerName test-vm0116.test.domain.com -Credential (Get-SBCredential 'test\superuser')
  Validate-WindowsCredential -Credential (Get-SBCredential 'test\OtherUser') -Session $Session
  A 'TRUE' result indicates that the test\OtherUser account on the test-vm0116.test.domain.com is valid (name and password)
 
 .LINK
  https://superwidgets.wordpress.com/2017/11/28/validate-windowscredential-and-validate-linuxcredential-powershell-functions/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 November 2017
  v0.2 - 17 May 2019 - Added feature to work against local computer making $Session an optional parameter
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][System.Management.Automation.PSCredential]$Credential,
        [Parameter(Mandatory=$false)][System.Management.Automation.Runspaces.PSSession]$Session
    )

    Begin { }

    Process{
        if ($Session) {
            if ($Session.State -eq 'Opened') {
                Invoke-Command -Session $Session -ScriptBlock {
                    $Credential = $Using:Credential
                    Add-Type -AssemblyName System.DirectoryServices.AccountManagement
                    $DS = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('domain')
                    $DS.ValidateCredentials($Credential.UserName.Split('\')[1], $Credential.GetNetworkCredential().Password)      
                } 
            } else { 
                Write-Log 'Validate-WindowsCredential: Error: Session provided is not ''opened'':' Magenta
                Write-Log ($Session|FT -a|Out-String).Trim() Yellow      
            }
        } else {
            Add-Type -AssemblyName System.DirectoryServices.AccountManagement
            $DS = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('domain')
            $DS.ValidateCredentials($Credential.UserName.Split('\')[1], $Credential.GetNetworkCredential().Password)  
        }
    } 

    End { }
}

function Validate-LinuxCredential {
<#
 .SYNOPSIS
  Function to validate whether a provided Credential is correct on a provided target Linux Computer
 
 .DESCRIPTION
  Function to validate whether a provided Credential is correct on a provided target Linux Computer
 
 .PARAMETER Credential
  PSCredential object. This can be obtained from the Get-Credential cmdlet of the Microsoft.PowerShell.Security,
  or the Get-SBCredential function of the SB-Tools PS module
 
 .PARAMETER Session
  SSH.SshSession object. This can be obtained via the New-SSHSession cmdlet of the POSH-SSH PS module
 
 .OUTPUTS
  The script outputs a TRUE/FALSE result if the provided SSHSession is valid and Connected.
 
 .EXAMPLE
  $Session = New-SSHSession -ComputerName test-vm0112.test.domain.com -Credential (Get-SBCredential 'opsuser') -AcceptKey
  Validate-LinuxCredential -Credential (Get-SBCredential 'root') -Session $Session
  A 'TRUE' result indicates that the local root account of the test-vm0116.test.domain.com is valid (name and password)
  A 'FALSE' result indicates failure to authenticate. This can be due to bad username or password, or locked or disabled account..
 
 .LINK
  https://superwidgets.wordpress.com/2017/11/28/validate-windowscredential-and-validate-linuxcredential-powershell-functions/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 November 2017
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][System.Management.Automation.PSCredential]$Credential,
        [Parameter(Mandatory=$true)][SSH.SshSession]$Session
    )

    Begin { }

    Process{
        if ($Session.Connected) {
            [String]$ConnectedUserName = (Invoke-SSHCommand -SessionId $Session.SessionId -Command 'whoami').Output 
            $ConnectedCred = Get-SBCredential $ConnectedUserName
            $myCommand = "echo '$($ConnectedCred.GetNetworkCredential().Password)' | sudo -S cat /etc/shadow | grep $($Credential.UserName)"
            $Result = Invoke-SSHCommand -SessionId $Session.SessionId -Command $myCommand
            if ($Result.ExitStatus) {
                Write-Log 'Validate-LinuxCredential: Error:' Magenta $LogFile
                if ($Result.Output) { Write-Log ($Result.Output | Out-String).Trim() Yellow }
            } else {
                if ($Hash = $Result.Output) { 
                    Write-Log 'Obtained user',$Credential.UserName,'hash',$Hash Green,Cyan,Green,Cyan
                    $Salt = $Hash.Split('$')[2]
                    $myCommand = "echo '$($Credential.GetNetworkCredential().Password)' | openssl passwd -1 -salt $Salt"
                    $Result = Invoke-SSHCommand -SessionId $Session.SessionId -Command $myCommand
                    if ($Result.ExitStatus) {
                        Write-Log 'Validate-LinuxCredential: Error:' Magenta $LogFile
                        if ($Result.Output) { Write-Log ($Result.Output | Out-String).Trim() Yellow }
                    } else {
                        $Hash.Split('$')[3].Split(':')[0] -eq $Result.Output.Split('$')[3]
                    }
                }
            }             
        } else { 
            Write-Log 'Validate-LinuxCredential: Error: Session provided is not ''Connected'':' Magenta
            Write-Log ($Session|FT -a|Out-String).Trim() Yellow      
        }
    } 

    End { }
}

function Flatten-XML {

<#
 .SYNOPSIS
  Function to flatten the heirachical structure of an XML input
 
 .DESCRIPTION
  Function to flatten the heirachical structure of an XML input
  This produces a collection of PS Custom Objects that can be combined
  into a single PS Custom Object using the Combine-Objects function of
  this PS module
 
 .PARAMETER XML
  This is the required XML input.
  For example this can be obtained via
    [XML]$XML = SCHTASKS /Query /XML /TN '\Microsoft\Windows\Time Synchronization\SynchronizeTime'
 
 .PARAMETER SkipElement
  Optional one or more elements to be ignored.
  This defaults to 'version','xmlns', and 'xml'
 
 .EXAMPLE
    [XML]$XML = SCHTASKS /Query /XML /TN '\Microsoft\Windows\Time Synchronization\SynchronizeTime'
    Flatten-XML -XML $XML | Combine-Objects
  This example prvides the details of a given scheduled task as an easy to use PS object such as:
    StopIfGoingOnBatteries : true
    Period : P1D
    Deadline : P2D
    Description : $(@%systemroot%\system32\w32time.dll,-201)
    Source : $(@%systemroot%\system32\w32time.dll,-200)
    UserId : S-1-5-19
    Author : $(@%systemroot%\system32\w32time.dll,-202)
    Context : LocalService
    MultipleInstancesPolicy : IgnoreNew
    DisallowStartIfOnBatteries : true
    Arguments : start w32time task_started
    UseUnifiedSchedulingEngine : true
    URI : \Microsoft\Windows\Time Synchronization\SynchronizeTime
    StopOnIdleEnd : true
    RunLevel : HighestAvailable
    id : LocalService
    RunOnlyIfNetworkAvailable : true
    Command : %windir%\system32\sc.exe
    RestartOnIdle : false
    Triggers :
    StartWhenAvailable : true
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 29 July 2019
#>


    param(
        [Parameter(Mandatory=$true)]$XML,
        [Parameter(Mandatory=$false)][String[]]$SkipElement = @('version','xmlns','xml')
    )
    
    Begin { }

    Process {
        foreach ($Property in ($XML | Get-Member -MemberType Property).Name) {
            $Value = $XML.$Property
            if ($Value.GetType().Name -ne 'XmlElement') {
                if ($Property -in $SkipElement) {
                    Write-Log 'Skipping property',$Property,'value',$Value Green,Yellow,Green,Yellow
                } else {
                    Write-Log 'Processing property',$Property,'value',$Value Green,cyan,Green,Cyan
                    [PSCustomObject]@{ $Property = $Value }
                }
            } else {
                Flatten-XML -XML $Value 
            }
        }
    }

    End { }
}

function Combine-Objects {

<#
 .SYNOPSIS
  Function to combine a collection of PS Custom Objects into one.
 
 .DESCRIPTION
  Function to combine a collection of PS Custom Objects into one.
  This is often used with Flatten-XML function of this PS module
 
 .PARAMETER Object
  One or more PS Custom Object
 
 .EXAMPLE
    [XML]$XML = SCHTASKS /Query /XML /TN '\Microsoft\Windows\Time Synchronization\SynchronizeTime'
    Flatten-XML -XML $XML | Combine-Objects
  This example prvides the details of a given scheduled task as an easy to use PS object such as:
    StopIfGoingOnBatteries : true
    Period : P1D
    Deadline : P2D
    Description : $(@%systemroot%\system32\w32time.dll,-201)
    Source : $(@%systemroot%\system32\w32time.dll,-200)
    UserId : S-1-5-19
    Author : $(@%systemroot%\system32\w32time.dll,-202)
    Context : LocalService
    MultipleInstancesPolicy : IgnoreNew
    DisallowStartIfOnBatteries : true
    Arguments : start w32time task_started
    UseUnifiedSchedulingEngine : true
    URI : \Microsoft\Windows\Time Synchronization\SynchronizeTime
    StopOnIdleEnd : true
    RunLevel : HighestAvailable
    id : LocalService
    RunOnlyIfNetworkAvailable : true
    Command : %windir%\system32\sc.exe
    RestartOnIdle : false
    Triggers :
    StartWhenAvailable : true
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 29 July 2019
#>


    param(
        [Parameter(ValueFromPipeline,Mandatory=$true)][PSCustomObject[]]$Object
    )
    
    Begin { }

    Process {
        foreach ($Item in $Object) {
            foreach ($Property in ($Item | Get-Member -MemberType NoteProperty).Name){
                $ArgumentList += @{ $Property = $Item.$Property }        
            }
        }
    }

    End { [PSCustomObject]$ArgumentList }
}

function Grant-UserRight {

<#
 .SYNOPSIS
  Function to grant the provided local user(s) the provided user right
 
 .DESCRIPTION
  Function to grant the provided local user(s) the provided user right
  This function modifies Local Security Policy - see secpol.msc
 
 .PARAMETER UserName
  One or more local users
 
 .EXAMPLE
  Grant-UserRight -UserName samb,notthere -UserRight 'SeManageVolumePrivilege'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  3 October 2019 - v0.1
#>


    param(
        [Parameter(Mandatory=$true)][String[]]$UserName,        # Must be local user
        [Parameter(Mandatory=$true)][ValidateSet(
            'SeAssignPrimaryTokenPrivilege',
            'SeAuditPrivilege',
            'SeBackupPrivilege',
            'SeBatchLogonRight',
            'SeChangeNotifyPrivilege',
            'SeCreateGlobalPrivilege',
            'SeCreatePagefilePrivilege',
            'SeCreateSymbolicLinkPrivilege',
            'SeDebugPrivilege',
            'SeDelegateSessionUserImpersonatePrivilege',
            'SeImpersonatePrivilege',
            'SeIncreaseBasePriorityPrivilege',
            'SeIncreaseQuotaPrivilege',
            'SeIncreaseWorkingSetPrivilege',
            'SeLoadDriverPrivilege',
            'SeManageVolumePrivilege',
            'SeNetworkLogonRight',
            'SeProfileSingleProcessPrivilege',
            'SeRemoteInteractiveLogonRight',
            'SeRemoteShutdownPrivilege',
            'SeRestorePrivilege',
            'SeSecurityPrivilege',
            'SeServiceLogonRight',
            'SeShutdownPrivilege',
            'SeSystemEnvironmentPrivilege',
            'SeSystemProfilePrivilege',
            'SeSystemtimePrivilege',
            'SeTakeOwnershipPrivilege',
            'SeTimeZonePrivilege',
            'SeUndockPrivilege'
        )][String]$userRight,       
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Grant-UserRight - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )
    
    Begin { }

    Process {
        Write-Log 'Backing up current Local Security Policy..' Green -NoNewLine $Logfile
        $FileName = "$env:TEMP\policies-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').inf" # No spaces
        $ExitCode = (Start-Process secedit -ArgumentList "/export /areas USER_RIGHTS /cfg $FileName" -Wait -PassThru).ExitCode
        if ($ExitCode -eq 0) {
            Write-Log 'done',(Get-Item $FileName).FullName Cyan,DarkYellow $LogFile
        } else {
            Write-Log 'failed, stopping..' Yellow $LogFile
            break
        }

        $Policy = Get-Content $FileName
        # $Policy | % { if ($_ -match '= \*') { "'$($_.Split('=')[0].Trim())'," } } | sort

        foreach ($LocalUser in $UserName) {
            try {
                $Sid = ((Get-LocalUser $LocalUser -EA 1).SID).Value
                Write-Log 'Identified local user',$LocalUser,'Sid',$Sid Green,Cyan,Green,Cyan $LogFile
                $Policy = foreach ($Line in $Policy) {
                    if ($Line -match $userRight) {
                        if ($Line -match $Sid) {
                            Write-Log ' Local user',$LocalUser,'already has the right',$userRight Green,Cyan,Green,Cyan $LogFile
                            $Line
                        } else {
                            Write-Log     'Granting local user',$LocalUser,'the right',$userRight Green,Cyan,Green,Cyan $LogFile
                            "$Line,*$Sid"
                        }                
                    } else {
                        $Line
                    } 
                }            
            } catch {
                Write-Log ' Local user',$LocalUser,'not found, skipping..' Magenta,Yellow,Magenta $LogFile
            }
        }

        $Policy | Out-File $FileName -Force

        $ExitCode = (Start-Process secedit -ArgumentList "/configure /db $env:windir\security\database\secedit.sdb /cfg $FileName /areas USER_RIGHTS /log $($FileName.Replace('.inf','.log'))" -Wait -PassThru).ExitCode
        if ($ExitCode -eq 0) {
            Write-Log ' done' Cyan $LogFile
        } else {
            Write-Log ' failed','no changes made to Local Policies' Yellow,Magenta $LogFile
            Write-Log (Get-Content $FileName.Replace('.inf','.log') | Out-String).Trim() Yellow $LogFile
        }
        <#
        Error 1208: An extended error has occurred.
             Error creating database.
        Error 12: The access code is invalid.
        https://social.technet.microsoft.com/Forums/en-US/0c888948-3a0d-49e4-ac81-e71138c8d5b8/facing-an-issue-while-running-quotseceditquot-command?forum=ws2016
        https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/secedit
        https://support.microsoft.com/en-us/help/324383/troubleshooting-scecli-1202-events
        #>

        Remove-Item -Path $FileName -Force
    }

    End { }
}

Function Monitor-Service {
<#
 .SYNOPSIS
  Function to query one or more TCP ports
 
 .DESCRIPTION
  Function query WMI with Timeout
 
 .PARAMETER Class
  Class name such as 'Win32_computerSystem'
 
 .PARAMETER Property
  Property name such as 'NumberofLogicalProcessors'
 
 .PARAMETER Filter
  In the format Property=Value such as DriveLetter=G:
 
 .PARAMETER ComputerName
  Computer name
 
 .PARAMETER NameSpace
  Default is 'root\cimv2'
  To see name spaces type:
    (Get-WmiObject -Namespace 'root' -Class '__Namespace').Name
 
 .PARAMETER Cred
  PS Credential object
 
 .PARAMETER TimeOut
  In seconds
 
 .EXAMPLE
  Get-SBWMI -Class Win32_computerSystem -Property NumberofLogicalProcessors
 
 .EXAMPLE
  Get-SBWMI -Class Win32_Volume -Filter 'DriveType=3'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 September 2017
  v0.2 - 29 September 2017 - Added parameter to use a different credential other than the one running the script
                             Added error checking for failure to WMI connect
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][Object[]]$MontitoredPort = @(
            [PSCustomObject]@{
                FromIP       = 'Any'      # 'Any' Or valid IPv4 address
                ToIP         = 'cnn.com'  # FQDN or IPv4
                ToPort       = 80         # TCP port (0-65536)
            }
        ),
        [Parameter(Mandatory=$false)][Int]$Frequency    = 5,          # Number of minutes between checks
        [Parameter(Mandatory=$false)][Int]$CountToAlert = 2,          # Number of failed checks before alert is triggered
        [Parameter(Mandatory=$true)][String]$SenderEmail,             
        [Parameter(Mandatory=$true)][String]$AlertTo,                 # one or more email addresses
        [Parameter(Mandatory=$true)][String]$SMTPRelayServer = 20,    # IP address or FQDN of SMTP relay server
        [Parameter(Mandatory=$false)][PSCrednetial]$SMTPCred          # Credential needed to use SMTP server
    )

    Begin {
        # Validate input
        $ProprtyList = @('FromIP','ToIP','ToPort')
        $PortList = foreach ($PortSpec in $MontitoredPort) {
            $ThisPropList = ($PortSpec | Get-Member -MemberType NoteProperty).Name
            $Keep = $true
            foreach ($Property in $ProprtyList) {
                if ($Property -notin $ThisPropList) { 
                    Write-Log 'Input MonitoredPort missing required property',$Property Magenta,Yellow 
                    $Keep = $false
                }
            }
            if ($Keep) { $PortSpec }
        }        
    }

    Process{
        if ($PortList) {
            Write-Log 'Monitoring' Green
            Write-Log ($PortList | FT -a | Out-String).Trim() Cyan
            foreach ($PortSpec in $PortList) {
                if ($PortSpec.FromIP.ToLower() -eq 'any') {
                    $Result = Test-SBNetConnection -ComputerName $PortSpec.ToIP -PortNumber $PortSpec.ToPort 
                    foreach ($Ping in $Result) {
                        if ($Ping.TcpTestSucceeded) {
                            if ($PortSpec.ToIP -eq $Ping.ComputerName) {
                                Write-Log "$($Ping.ComputerName)",'online' Cyan,Green
                            } else {
                                Write-Log "$($Ping.ComputerName)($($PortSpec.ToIP))",'online' Cyan,Green
                            }
                        } else {
                            if ($PortSpec.ToIP -eq $Ping.ComputerName) {
                                Write-Log "$($Ping.ComputerName)",'unreacheable' Cyan,Yellow
                            } else {
                                Write-Log "$($Ping.ComputerName)($($PortSpec.ToIP))",'unreacheable' Cyan,Yellow
                            }
                        }
                    }
                } else {

                }
            }
        }
    } 

    End {
        
    }
}

function Get-VssWriters {

<#
 .Synopsis
  Function to get information about VSS Writers on one or more computers
 
 .Description
  Function will parse information from VSSAdmin tool and return object containing
  WriterName, StateID, StateDesc, and LastError
   
 .PARAMETER ComputerName
  This is the name of one or more computers.
  If absent, localhost is assumed.
 
 .Example
  Get-VssWriters
  This example will return a list of VSS Writers on localhost
 
 .Example
  # Get VSS Writers on localhost, sort list by WriterName
  $VssWriters = Get-VssWriters | Sort "WriterName"
  $VssWriters | FT -AutoSize # Displays it on screen
  $VssWriters | Out-GridView # Displays it in GridView
  $VssWriters | Export-CSV ".\myVSSWriterReport.csv" -NoTypeInformation # Exports it to CSV
 
 .Example
  # Get VSS Writers on the list of $Computers, sort list by ComputerName
  $Computers = "xHost11","notThere","xHost12",$env:ComputerName
  $VssWriters = Get-VssWriters -ComputerName $Computers -Verbose | Sort "ComputerName"
  $VssWriters | FT -AutoSize # Displays it on screen
  $VssWriters | Out-GridView # Displays it in GridView
  $VssWriters | Export-CSV ".\myVSSWriterReport.csv" -NoTypeInformation # Exports it to CSV
 
 .Example
  # Reports any errors on VSS Writers on the computers listed in MyComputerList.txt, sorts list by ComputerName
  $Computers = Get-Content ".\MyComputerList.txt"
  $VssWriters = Get-VssWriters $Computers -Verbose |
    Where { $_.StateDesc -ne 'Stable' } | Sort "ComputerName"
  $VssWriters | FT -AutoSize # Displays it on screen
  $VssWriters | Out-GridView # Displays it in GridView
  $VssWriters | Export-CSV ".\myVSSWriterReport.csv" -NoTypeInformation # Exports it to CSV
  
 .Example
  # Get VSS Writers on all computers in current AD domain, sort list by ComputerName
  $Computers = (Get-ADComputer -Filter *).Name
  $VssWriters = Get-VssWriters $Computers -Verbose | Sort "ComputerName"
  $VssWriters | Out-GridView # Displays it in GridView
  $VssWriters | Export-CSV ".\myVSSWriterReport.csv" -NoTypeInformation # Exports it to CSV
 
 .EXAMPLE
  # Get VSS Writers on all Hyper-V hosts in current AD domain, sort list by ComputerName
  $Computers = (Get-ADComputer -Filter *).Name
  $FilteredComputerList = Foreach ($Computer in $Computers) {
      if (Get-WindowsFeature -ComputerName $Computer -ErrorAction SilentlyContinue |
        where { $_.Name -eq "Hyper-V" -and $_.InstallState -eq "Installed"}) { $Computer }
  }
  $VssWriters = Get-VssWriters $FilteredComputerList -Verbose | Sort "ComputerName"
  $VssWriters | FT -AutoSize # Displays it on screen
  $VssWriters | Out-GridView # Displays it in GridView
  $VssWriters | Export-CSV ".\myVSSWriterReport.csv" -NoTypeInformation # Exports it to CSV
 
 .OUTPUTS
  Scripts returns a PS Object with the following properties:
    ComputerName
    WriterName
    StateID
    StateDesc
    LastError
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://gallery.technet.microsoft.com/scriptcenter/Powershell-ScriptFunction-415e9e70
 
 .NOTES
  Function by Sam Boutros
    v1.0 - 17 September 2014
    v1.1 - 12 February 2020 - Rewrite, improved parsing and error handling
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [ValidateNotNullorEmpty()]
            [String[]]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-VssWriters - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {  }

    Process {                
        foreach ($Computer in $ComputerName) {
            Write-Log 'Processing computer',$Computer Green,Cyan $LogFile
            $Raw = if ($Computer -eq $env:COMPUTERNAME) {
                try { VssAdmin List Writers } catch { $_.Exception.Message }
            } else {
                try {
                    Invoke-Command -ComputerName $Computer -EA 1 -ScriptBlock {
                        try { VssAdmin List Writers } catch { $_.Exception.Message }
                    }
                } catch {  
                    $_.Exception.Message
                }
            }
            
            # Parse $Raw
            # $n=0; $Raw | % { "Line $($n): $_"; $n++ }
            if ($Raw -match "The term 'VssAdmin' is not recognized" -or
                $Raw -match "Connecting to remote server $Computer failed with the following error message") {
                Write-Log 'Error with Computer',$Computer Magenta,Yellow $LogFile
                Write-Log ($Raw | Out-String).Trim() Yellow $LogFile
            } elseif ($Raw[3] -match "Error: You don't have the correct permissions to run this command") {
                Write-Log 'Error with Computer',$Computer Magenta,Yellow $LogFile
                Write-Log ("$($Raw[3]) $($Raw[4])").Trim() Yellow $LogFile
            } else {
                if ($Raw -match 'Writer Name') {
                    $n=0; $WriterStartLines = foreach ($Line in $Raw) { if ($Line -match 'Writer Name') { $n }; $n++ }
                    foreach ($Writer in $WriterStartLines) {
                        [PSCustomObject]@{
                            ComputerName = $Computer
                            WriterName   = $Raw[$Writer].Split(':')[1].Trim().Replace("'","")
                            StateId      = $Raw[$Writer+3].Split(':')[1].Trim().Split(']')[0].Replace('[','')
                            StateDesc    = $Raw[$Writer+3].Split(':')[1].Trim().Split(']')[1].Trim()
                            LastError    = $Raw[$Writer+4].Split(':')[1].Trim()
                        }
                    }
                } else {
                    Write-Log 'No VSS Writers identified on Computer',$Computer,'- details:' Magenta,Yellow,Magenta $LogFile
                    Write-Log ($Raw | Out-String).Trim() Yellow $LogFile
                }
            }
        }
    } 

    End { }
}

function Get-DayOfMonth {
<#
 .SYNOPSIS
  Function to get a given day of the week such as Sunday of a given Month/Year like March/2020
 
 .DESCRIPTION
  Function to get a given day of the week such as Sunday of a given Month/Year like March/2020
   
 .PARAMETER DayOfWeek
  Optional parameter that defaults to 'Sunday'
  Valid options are 'Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'
   
 .PARAMETER First
  Optional switch parameter. By default it returns the first day of the month
  When set to $true, it returns the last day of month
   
 .PARAMETER Month
  Optional parameter from 1 to 12
   
 .PARAMETER Year
  Optional parameter from 1 to 10,000
   
 .EXAMPLE
    Get-DayOfMonth
    This will return the last Sunday of the current Month/Year as in:
    Sunday, March 29, 2020 12:26:49 PM
 
 .EXAMPLE
    Get-DayOfMonth -DayofWeek Monday
    This will return the last Monday of the current Month/Year as in:
    Monday, March 30, 2020 12:27:34 PM
 
 .EXAMPLE
    Get-DayOfMonth -DayofWeek Saturday -First
    This will return the first Saturday of the current Month/Year as in:
    Saturday, March 7, 2020 12:28:25 PM
 
 .EXAMPLE
    Get-DayOfMonth -DayofWeek Friday -Month 3 -Year 1945
    This will return the last Friday of March 1945 as in:
    Friday, March 30, 1945 12:29:54 PM
 
 .OUTPUTS
  This cmdlet returns a DateTime object
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 26 March 2020
 
#>


    [CmdletBinding(ConfirmImpact = 'Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')][String]$DayofWeek = 'Sunday',
        [Parameter(Mandatory=$false)][Switch]$First, 
        [Parameter(Mandatory=$false)][ValidateRange(1,12)][Int]$Month = (Get-Date).Month,
        [Parameter(Mandatory=$false)][ValidateRange(1,10000)][Int]$Year = (Get-Date).Year
    )

    Begin { }

    Process {
        $Days = 0..31 | foreach { 
            (Get-Date -Year $Year -Month $Month -Day 1).AddDays($_) | 
                where { $_.Month -eq $Month -and $_.DayOfWeek -eq $DayofWeek }
        }
    }

    End { 
        if ($First) { $Days | select -First 1 } else { $Days | select -Last 1 }
    }
} 

function Get-PCInfo {
<#
 .SYNOPSIS
  Function to ping and report on given one or more Windows computers.
 
 .DESCRIPTION
  Function to ping and report on given one or more Windows computers.
  If the computer has more than one network interface, this function will report all IP and MAC addresses
 
 .PARAMETER ComputerName
  One or more computer names to be reported on. This defaults to the current computer.
 
 .PARAMETER Cred
  PS Credential object that can be obtained from Get-Credential or Get-SBCredential
 
 .PARAMETER Refresh
  This switch will supress progress messages to speed up processing.
 
 .OUTPUTS
  The function returns a PS object that has the following properties/example:
    ComputerName : XXXXXXX-Sam1
    Status : Online
    IPAddress : 192.168.xx.xx
    MACAddress : xx:xx:xx:xx:xx:xx
    DateBuilt : 9/7/2017 3:20:09 PM
    OSVersion : 10.0.14393
    OSCaption : Microsoft Windows Server 2016 Datacenter
    OSArchitecture : 64-bit
    Model : Some Model
    Manufacturer : Dell
    VM : False
    LastBootTime : 6/16/2022 1:31:55 AM
    FreeRAM : 14 ==> This is free RAM calculated as (1 - $OS.FreePhysicalMemory/$OS.TotalVisibleMemorySize) expressed as %
    CPU : 1 ==> this is average CPU load in percentage
 
 .EXAMPLE
  Get-PCInfo
  This returns the current PC information
 
 .EXAMPLE
  $PCInfo = Get-PCInfo -ComputerName @('PC1','PC2','PC3')
  This checks the listed computers and saves the collected information in $PCInfo variable
 
 .EXAMPLE
  (Import-Csv .\ComputerList1.csv).ComputerName | Get-PCInfo | Export-Csv .\ComputerReport.csv -NoType
  This example will read a list of computer names from the CSV file provided which has a 'ComputerName' column,
  gather each computer information and save it to the provided CSV output file.
 
 .EXAMPLE
  Get-PCInfo -ComputerName Server111 -Cred (Get-SBCredential 'domain\user')
  This example will report on information of the provided computer using the provided credentials
 
 .LINK
  https://superwidgets.wordpress.com/2017/01/04/powershell-script-to-report-on-computer-inventory/
 
 .NOTES
  Function by Sam Boutros
    31 October 2014 v0.1
    4 January 2017 v0.2
    17 March 2017 v0.3 - chnaged the logic to output 1 record per computer even when it has several NICs
    2 April 2020 v0.4 - Added Silent switch to speed up processing of large number of computers
        Switched to using Get-SBWMI instead of Get-WMIObject
        Added Cred Parameter to be able to query computers outside the domain
    16 December 2021 v0.5 - Added FreeRAM (percent) and CPU (used percent) metrics.
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
            [String[]]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory=$false)][PSCredential]$Cred,
        [Parameter(Mandatory=$false)][Switch]$Silent
    )

    Begin { }

    Process {
        
        foreach ($PC in $ComputerName) {
            if (-not $Silent) { Write-Log 'Checking computer',$PC Green,Cyan -NoNewLine }
            try {
                $Result = Test-Connection -ComputerName $PC -Count 2 -ErrorAction Stop 
                if ($Cred) {
                    $OS  = Get-SBWMI -ComputerName $PC -Class Win32_OperatingSystem -Cred $Cred -EA 0
                    $Mfg = Get-SBWMI -ComputerName $PC -Class Win32_ComputerSystem  -Cred $Cred -EA 0
                    $CPU = Get-SBWMI -ComputerName $PC -Class Win32_Processor       -Cred $Cred -EA 0
                    $IPs = (Get-SBWMI -ComputerName $PC -Class Win32_NetworkAdapterConfiguration -Cred $Cred -EA 0 | 
                            Where { $_.IpEnabled }).IPAddress | where { $_ -match "\." } # IPv4 only
                } else {
                    $OS  = Get-SBWMI -ComputerName $PC -Class Win32_OperatingSystem -EA 0
                    $Mfg = Get-SBWMI -ComputerName $PC -Class Win32_ComputerSystem  -EA 0
                    $CPU = Get-SBWMI -ComputerName $PC -Class Win32_Processor       -EA 0
                    $IPs = (Get-SBWMI -ComputerName $PC -Class Win32_NetworkAdapterConfiguration -EA 0 | 
                            Where { $_.IpEnabled }).IPAddress | where { $_ -match "\." } # IPv4 only
                }
                $MACs = foreach ($IPAddress in $IPs) {
                    if ($Cred) {
                        (Get-SBWMI -ComputerName $PC -Class Win32_NetworkAdapterConfiguration -Cred $Cred -EA 0 | 
                            Where { $_.IPAddress -eq $IPAddress }).MACAddress
                    } else {
                        (Get-SBWMI -ComputerName $PC -Class Win32_NetworkAdapterConfiguration -EA 0 | 
                            Where { $_.IPAddress -eq $IPAddress }).MACAddress
                    }                        
                }
                if (-not $Silent) { Write-Log 'done' Green }
                [PSCustomObject]@{
                    ComputerName   = $PC
                    Status         = 'Online'
                    IPAddress      = $IPs -join ', '
                    MACAddress     = $MACs -join ', '
                    DateBuilt      = ([WMI]'').ConvertToDateTime($OS.InstallDate)
                    OSVersion      = $OS.Version
                    OSCaption      = $OS.Caption
                    OSArchitecture = $OS.OSArchitecture
                    Model          = $Mfg.model
                    Manufacturer   = $Mfg.Manufacturer
                    VM             = $(if ($Mfg.Manufacturer -match 'vmware' -or $Mfg.Manufacturer -match 'microsoft') { $true } else { $false })
                    LastBootTime   = ([WMI]'').ConvertToDateTime($OS.LastBootUpTime)
                    FreeRAM        = 100 - [math]::Round(($OS.FreePhysicalMemory/$OS.TotalVisibleMemorySize)*100,0)
                    CPU            = [math]::Round(($CPU | measure LoadPercentage -Average).Average,0)
                }
            } catch { # either ping failed or access denied
                if ($Result) {
                    if (-not $Silent) { Write-Log 'done' Magenta }
                    [PSCustomObject]@{
                        ComputerName   = $PC
                        Status         = $Error[0].Exception
                    }
                } else {
                    if (-not $Silent) { Write-Log 'done' Yellow }
                    [PSCustomObject]@{
                        ComputerName   = $PC
                        Status         = 'No response to ping'
                    }
                }
            }
        }
    }

    End { }
}

function Parse-String {

<#
 .Synopsis
  Function to parse an input string returning values between Start Marker and End Marker strings
 
 .Description
  Function to parse an input string returning values between Start Marker and End Marker strings
  Start and End marker strings cannot be the same
  This function will return multiple values if the $InputString has several occurances of the Start and End Markers
  For useful results look for unqiue Start and End markers in your $InputString
  This function can be useful in parsing the Message property of Windows Event Logs
   
 .PARAMETER InputString
  The input string
 
 .PARAMETER StartMarker
  The Start Marker string
 
 .PARAMETER EndMarker
  The End Marker string
 
 .PARAMETER OpenEnded
  When this switch is set to True, this function will respond if one of the two markers is not provided.
  If EndMarker is not provided, and StartMarker is provided, and the OpenEnded switch is set, this function will return the string from the StarMarker to the end of the Input String.
  If StartMarker is not provided, and EndMarker is provided, and the OpenEnded switch is set, this function will return the string from the beginning of the Input String to the StarMarker.
 
 .Example
  $InputString = 'A sleek red fox emerged from its deep under ground burrow A sleek green fox emerged from its deep under ground burrow'
  Parse-String -InputString $InputString -StartMarker 'sleek' -EndMarker 'emerged'
  This example will parse the input string and return values between 'sleek' and 'emerged'
 
 .Example
  if ($LogEntry = Get-EventLog -LogName Security -EntryType FailureAudit | select -First 1) {
    $LogonType = Parse-String -InputString $LogEntry.Message -StartMarker 'Logon Type:' -EndMarker 'Account For Which Logon Failed:'
    $AccountAttempted = Parse-String -InputString $LogEntry.Message -StartMarker 'Account Name:' -EndMarker 'Account Domain:'
    $IPAttemptedFrom = Parse-String -InputString $LogEntry.Message -StartMarker 'Source Network Address:' -EndMarker 'Source Port:'
    "Logon Type: $LogonType (2 = interactive, 3 = network)"
    "Account Attempted: $($AccountAttempted | where { $_ -ne '-' })"
    "IP Address from which Logon was attempted: $IPAttemptedFrom"
  }
  This example will find the first AuditFailure event in the Security EventLog, and will parse its Message property
  to show Logon Type, Account Attempted, and IP Address from which Logon was attempted
 
 .Example
  $InputString = 'A sleek red fox emerged from its deep under ground burrow A sleek green fox emerged from its deep under ground burrow'
  Parse-String -InputString $InputString -StartMarker 'sleek' -OpenEnded
  This example will parse the input string and return values between the first 'sleek' to the end of the string, such as:
  'red fox emerged from its deep under ground burrow A sleek green fox emerged from its deep under ground burrow'
 
 .Example
  $InputString = 'A sleek red fox emerged from its deep under ground burrow A sleek green fox emerged from its deep under ground burrow'
  Parse-String -InputString $InputString -EndMarker 'emerged' -OpenEnded
  This example will parse the input string and return values between the beginning of the string to the first 'emerged', such as:
  'A sleek red fox'
 
 .OUTPUTS
  This function returns one or more strings
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
    v0.1 - 12 April 2020
    v0.2 - 14 April 2020
        Updated logic to report errors as verbose output
        Updated logic to continue on error
    v0.3 - 29 September 2021
        Added OpenEnded switch, allowing to omit either StartMarker or EndMarker.
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$InputString,
        [Parameter(Mandatory=$false)][String]$StartMarker,
        [Parameter(Mandatory=$false)][String]$EndMarker,
        [Parameter(Mandatory=$false)][Switch]$OpenEnded
    )

    Begin { 
        Write-Verbose "InputString: $InputString"
        Write-Verbose "StartMarker: $StartMarker"
        Write-Verbose "EndMarker: $EndMarker"
        Write-Verbose "OpenEnded: $OpenEnded"
        if ($StartMarker -eq $EndMarker) {
            Write-Warning "Parse-String Error: 'StartMarker' and 'EndMarker' parameter values cannot be the same"
            break
        }
    }

    Process {   
    
        # Both markers provided, both markers found
        if ($StartMarker) {
            if ($EndMarker) {
                if ($InputString -match $StartMarker -and $InputString -match $EndMarker) {
                    $StartMarkerCount = ($InputString -split $StartMarker).Count - 1 
                    $EndMarkerCount   = ($InputString -split $EndMarker).Count - 1         
                    foreach ($Occurance in (1..$StartMarkerCount)) {
                        (($InputString -split $StartMarker)[$Occurance].Trim() -split $EndMarker)[0].Trim()
                    } # Foreach
                } # Match
            } # $EndMarker
        } # $StartMarker

        # StartMarker only provided and found
        if ($StartMarker -and -not $EndMarker) {
            if ($InputString -match $StartMarker -and $OpenEnded) {
                $MarkerCharNumber = $InputString.ToLower().IndexOf($StartMarker.ToLower())
                $InputString.Substring($MarkerCharNumber+$StartMarker.Length,$InputString.Length-($MarkerCharNumber+$StartMarker.Length)).Trim()
            } # Match
        } # $StartMarker

        # EndMarker only provided and found
        if ($EndMarker -and -not $StartMarker) {
            if ($InputString -match $EndMarker -and $OpenEnded) {
                ($InputString -split $EndMarker)[0].Trim()
            } # Match
        } # $EndMarker

    } 

    End { }
}

function Update-PSModule {
<#
 .SYNOPSIS
  Function to update one or more PowerShell Modules from the PowerShellGalery.com
 
 .DESCRIPTION
  Function to update one or more PowerShell Modules from the PowerShellGalery.com
 
 .PARAMETER ModuleList
  One or more Module names
  This is an optional parameter that defaults to AZSBTools
 
 .EXAMPLE
  Update-PSModule -ModuleList AZSBTools,ImportExcel
 
 .OUTPUTS
  This cmdlet returns PS Objects for each module such as:
    Name Version
    ---- -------
    AZSBTools 1.173.107
    ImportExcel 7.1.0
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 13 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$ModuleList = @('AZSBTools')
    )

    Begin { 
        Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -EA 0
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        $Elevated = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
        $Length = $ModuleList | foreach { $_.Length } | sort | select -Last 1
    }

    Process {

        $myOutput = foreach ($Module in $ModuleList) {
            try {
                $NewModule = Find-Module $Module -EA 1 
                $CurrentModule = Get-Module $Module -ListAvailable | sort Version | select -Last 1
                $CurrentVersion = if ($CurrentModule) {$CurrentModule.Version.ToString()} else {'None'}
                Write-Log 'Validating PS module',"$Module".PadRight($Length+1),'version',($NewModule.Version.ToString()).PadRight(10) Green,Cyan,Green,Cyan -NoNewLine
                if ($CurrentVersion -eq $NewModule.Version) {
                    Write-Log 'Validated' DarkYellow
                } else {
                    Write-Log "Not (Current Version $CurrentVersion), installing.." Yellow -NoNewline
                    if ($Elevated) {
                        Install-Module $Module -Force -AllowClobber
                        Remove-Module $Module -Force -EA 0 # To allow for auto-loading the latest version
                        # Remove older copies of the module under 'CurrentUser' scope, because they get prioritized for auto-loading:
                        Remove-Item "$([Environment]::GetFolderPath('MyDocuments'))\WindowsPowerShell\Modules\$Module" -Recurse -Force -EA 0 
                    } else {
                        Install-Module $Module -Force -AllowClobber -Scope CurrentUser
                        Remove-Module $Module -Force -EA 0 # To allow for auto-loading the latest version
                    }
                    Write-Log 'done' Green
                }
                $CurrentModule = Get-Module $Module -ListAvailable | sort Version | select -Last 1
                $CurrentVersion = if ($CurrentModule) {$CurrentModule.Version.ToString()} else {'None'}
            } catch {
                $CurrentVersion = 'Not found in PS Gallery'
                Write-Log $_.Exception.Message Magenta
            }
            [PSCustomObject][Ordered]@{
                Name    = $Module
                Version = $CurrentVersion
            }            
        }
    }

    End { $myOutput }
} 

function New-PSProfile {
<#
 .SYNOPSIS
  Function to create a PS profile
 
 .DESCRIPTION
  Function to create a PS profile
  If a profile file exists, this function appends $FileContent to it
 
 .PARAMETER FileContent
  This is an optional parameter that defaults 'Update-PSModule',
  which defaults to updating AZSBTools and ImportExcel PS Modules
 
 .EXAMPLE
  New-PSProfile
 
 .OUTPUTS
  None
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 13 April 2020
  v0.2 - 20 April 2022 - allowed adding content that would not cause a regex comparison issue
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$Content = 'Update-PSModule'
    )

    Begin {  }

    Process {

        if (Test-Path $profile) {
            $OldContent = Get-Content $profile
            Write-Log 'Current PS Profile',$profile Green,Cyan
            Write-Log (Get-Content $profile -Raw) Yellow
            if (-not ($OldContent -match [Regex]::Escape($Content))) {             
                Write-Log 'Updating PS Profile file',$profile Green,Cyan 
                $NewContent = $OldContent += $Content
                $NewContent | Out-File $profile -Force # Over write the content to avoid file encoding issues
                New-PSProfile -Content $Content
            }
        } else {
            Write-Log 'Creating new PS Profile file',$profile Green,Cyan 
            New-Item "$([Environment]::GetFolderPath('MyDocuments'))\WindowsPowerShell" -ItemType Directory -Force -EA 0 | Out-Null
            $Content | Out-File $profile -Force
            New-PSProfile -Content $Content
        }

    }

    End {  }
} 

function Get-IPLocation {
<#
 .SYNOPSIS
  Function to return the Geographical location of an Internet IP address
 
 .DESCRIPTION
  Function to return the Geographical location of an Internet IP address
  This function depends on ip-api.com and/or ipinfo.io
  This function defaults to querying ipinfo.io because it also provides reverse dns
 
 .PARAMETER Uri
  One or more URLs
  This is an optional parameter. These URLs will be queried for WAN IP.
 
 .PARAMETER IPAddress
  One or more IP addresses
  This is an optional parameter that defaults to the current WAN IP.
 
 .PARAMETER ReportAll
  This is an optional switch. When set to True, this function will return
  information from every Uri source on every provided IP address
 
 .EXAMPLE
  Get-IPLocation (Resolve-DnsName CNN.com -Type A).IPAddress -Verbose
  This example will return information of all IP addresses of CNN.com from ipinfo.io
 
 .EXAMPLE
  Get-IPLocation (Resolve-DnsName Google.com -Type A).IPAddress -ReportAll -Verbose
  This example will return information of the IP address of Google.com from ipinfo.io and ip-api.com
 
 .EXAMPLE
  Get-IPLocation -ReportAll 192.168.1.1 -Verbose
  This example returns no data. This function returns no data for Private IP addresses
 
 .OUTPUTS
  This cmdlet returns aa object such as:
    IPAddress : 172.217.11.46
    ReverseDNS : lga25s61-in-f14.1e100.net
    Country : US
    Region : New York
    City : New York City
    ZipCode : 10004
    Coords : 40.7143,-74.0060
    TimeZone : America/New_York
    Org : AS15169 Google LLC
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 April 2020
  v0.2 - 15 April 2020 - Manually validate that the IP input is a valid IP Address
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$IPAddress = (Get-MyWANIP).IPAddressToString,
        [Parameter(Mandatory=$false)][String[]]$Uri = @('http://ip-api.com/json','http://ipinfo.io'),
        [Parameter(Mandatory=$false)][Switch]$ReportAll = $false
    )

    Begin { 
        function GetInfoFrom-IPAPI  {
            [CmdletBinding(ConfirmImpact='Low')]
            Param( [Parameter(Mandatory=$true)][String]$Uri)
            try {
                $Result = Invoke-RestMethod -Method Get -Uri $Uri -UseBasicParsing -EA 1 
                if ($Result.status -eq 'success') {
                    [PSCustomObject][Ordered]@{
                        IPAddress = $Result.query
                        Country   = $Result.country
                        Region    = $Result.regionname
                        City      = $Result.city
                        ZipCode   = $Result.zip
                        Coords    = "$($Result.lat),$($Result.lon)"
                        TimeZone  = $Result.timezone
                        Org       = "$($Result.as) ($($Result.org))"
                    }
                }
            } catch {
                Write-Verbose $_.Message.Exception
            }
        }
        function GetInfoFrom-IPINFO {
            [CmdletBinding(ConfirmImpact='Low')]
            Param( [Parameter(Mandatory=$true)][String]$Uri)
            try {
                $Result = Invoke-RestMethod -Method Get -Uri $Uri -UseBasicParsing -EA 1 
                if (-not $Result.bogon) {
                    [PSCustomObject][Ordered]@{
                        IPAddress  = $Result.ip
                        ReverseDNS = $Result.hostname
                        Country    = $Result.country
                        Region     = $Result.region
                        City       = $Result.city
                        ZipCode    = $Result.postal
                        Coords     = $Result.loc
                        TimeZone   = $Result.timezone
                        Org        = $Result.org
                    }
                }
            } catch {
                Write-Verbose $_.Message.Exception
            }
        }
        Write-Verbose 'Received input:'
        Write-Verbose "IPAddress: $($IPAddress -join ', ')"
        Write-Verbose "Uri: $($Uri -join ', ')"
        Write-Verbose "ReportAll: $ReportAll"
    }

    Process {    
        foreach ($IP in $IPAddress) {
            try {
                $IP = [IPAddress]$IP.trim() # Manually validate that the IP input is a valid IP Address
                $IP = $IP.IPAddressToString
                if ($ReportAll) {
                    foreach ($1Uri in $Uri) {
                        switch ($1Uri) {
                            'http://ip-api.com/json' { GetInfoFrom-IPAPI  "$1Uri/$IP" }
                            'http://ipinfo.io'       { GetInfoFrom-IPINFO "$1Uri/$IP" }
                            default { Invoke-RestMethod -Method Get -Uri "$1Uri/$IP" -UseBasicParsing }
                        }
                    }
                } else { # Prefer ipinfo.io because it also provides reverse dns
                    if       ($Uri -match 'ipinfo.io') { GetInfoFrom-IPINFO "$($Uri -match 'ipinfo.io')/$IP"
                    } elseif ($Uri -match 'ip-api.com') { GetInfoFrom-IPAPI "$($Uri -match 'ip-api.com')/$IP"
                    } else { # return raw
                        Invoke-RestMethod -Method Get -Uri "$($Uri | select -First 1)/$IP" -UseBasicParsing
                    }
                }
            } catch {
                Write-Verbose "Get-IPLocation Error: invalid IP address input received: $IP"
            }
        }
    }

    End {  }
} 

function Get-EventLogNames {
    [CmdletBinding()] 
    Param()
    [System.Diagnostics.Eventing.Reader.EventLogSession]::GlobalSession.GetLogNames() 
}

function Backup-EventLog {
<#
 .SYNOPSIS
  Function to backup one or more Windows event logs
 
 .DESCRIPTION
  Function to backup one or more Windows event logs
 
 .PARAMETER EventLogName
  One or more Windows event logs
  To see a list of Windows Event Logs:
    (Get-WinEvent -ListLog '*' -EA 0).LogName | sort
  This parameter features auto-complete
  This is an optional parameter that defaults to 'Application'
  Note that some event logs like 'Security' event log require elevation
 
 .PARAMETER BackupFolder
  Path to the folder where this function will make a backup of the provided Windows event log
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output
 
 .OUTPUTS
  This function returns a list of successfully backed up Windows event logs
 
 .EXAMPLE
  Backup-EventLog -EventLogName Application,Security,Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational -BackupFolder c:\Logs\Test
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 29 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)]
            [ArgumentCompleter( { param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) Get-EventLogNames } )]
            [ValidateScript( { $_ -in (Get-EventLogNames) } )]
            [String[]]$EventLogName = 'Application',
        [Parameter(Mandatory=$false)][String]$BackupFolder,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Backup-EventLog_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {

        if (-not $BackupFolder) {
            Write-Log '$BackupFolder parameter not provided, using current folder' Yellow $LogFile -NoNewLine
            $BackupFolder = (Get-Location).Path
            Write-Log $BackupFolder Cyan $LogFile
        }
        if (-not (Test-Path $BackupFolder)) {
            Write-Log '$BackupFolder',$BackupFolder,'does not exist, using current folder' Yellow,Cyan,Yellow $LogFile -NoNewLine
            $BackupFolder = (Get-Location).Path
            Write-Log $BackupFolder Cyan $LogFile
        }
        $BackupFolder = (Get-Item $BackupFolder).FullName

    }

    Process {   
        
        $EventSession = New-Object System.Diagnostics.Eventing.Reader.EventLogSession
        $Succeeded = foreach ($LogName in $EventLogName) {
            if ($LogName -in (Get-EventLogNames)) {
                $Destination = "$BackupFolder\$($LogName.Replace('/','_'))_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').evtx"
                Write-Log 'Backing up',$LogName,'Windows event log to',$Destination Green,Cyan,Green,Cyan $LogFile -NoNewLine
                try { 
                    $EventSession.ExportLogAndMessages($LogName,'LogName','*',$Destination) 
                    Write-Log 'done' Green $LogFile -NoNewLine
                    if (Test-Path $Destination) { $LogName; Write-Log 'and validated' Cyan $LogFile } else { Write-Log 'but failed validation' Magenta $LogFile }
                } catch { 
                    # ExportLogAndMessages works but gives this error message if not running under elevated permissions
                    $msg = 'Exception calling "ExportLogAndMessages" with "4" argument(s): "The directory name is invalid"'
                    if ($_.Exception.Message -eq $msg) { 
                        Write-Log 'done' Green $LogFile -NoNewLine
                        if (Test-Path $Destination) { $LogName; Write-Log 'and validated' Cyan $LogFile } else { Write-Log 'but failed validation' Magenta $LogFile }
                    } else {
                        Write-Log 'failed' Magenta $LogFile
                        Write-Log $_.Exception.Message Magenta $LogFile
                    }
                }                
            } else {
                Write-Log 'Backup-EventLog Error: bad log name provided:', $LogName Yellow,Cyan $LogFile
            }
        }
    }

    End { $Succeeded }
} 

function Clear-SBEventLog {
<#
 .SYNOPSIS
  Function to clear one or more Windows event logs
 
 .DESCRIPTION
  Function to clear one or more Windows event logs
  Unlike the native Clear-EventLog, this function can clear all Windows event logs
  This function requires elevated permissions
 
 .PARAMETER EventLogName
  One or more Windows event logs
  To see a list of Windows Event Logs:
    (Get-WinEvent -ListLog '*' -EA 0).LogName | sort
  This parameter features auto-complete
  This is an optional parameter that defaults to 'Application'
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output
 
 .EXAMPLE
  Clear-SBEventLog -EventLogName Application
       
 .EXAMPLE
  Clear-SBEventLog -EventLogName Application,Security,Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational -Confirm:$false
  This example will clear the listed Windows event logs without interactive confirmation
       
 .EXAMPLE
    $EventLogList = @('Application','Security','Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational')
    Backup-EventLog -EventLogName $EventLogList -BackupFolder c:\Sandbox\Logs\Test
    Clear-SBEventLog -EventLogName $EventLogList -Confirm:$false
  This example backs up and clears the listed event logs
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 29 April 2020
#>


    [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='High')]
    Param(
        [Parameter(Mandatory=$false)]
            [ArgumentCompleter( { param($Command, $Parameter, $WordToComplete, $CommandAst, $FakeBoundParams) Get-EventLogNames } )]
            [ValidateScript( { $_ -in (Get-EventLogNames) } )]
            [String[]]$EventLogName = 'Application',
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Clear-SBEventLog_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin {

        # Check elevation
        if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]'Administrator')) {
            Write-Log 'Clear-SBEventLog Error: This function requires elevation (run as administrator)' Magenta $LogFile
            Break
        }

    }

    Process {   
        
        $EventSession = New-Object System.Diagnostics.Eventing.Reader.EventLogSession
        foreach ($LogName in $EventLogName) {
            if ($LogName -in (Get-EventLogNames)) {
                $LogInfo = $EventSession.GetLogInformation("$LogName",'LogName')
                Write-Log 'Clearing',$LogInfo.RecordCount,'events in',$LogName,'Windows event log..' Green,Cyan,Green,Cyan,Green $LogFile -NoNewLine
                If ($PSCmdlet.ShouldProcess("$LogName", "Clear log file")) {
                    try { 
                        $EventSession.ClearLog("$LogName")
                        Write-Log 'done' DarkYellow $LogFile 
                    } catch { 
                        Write-Log 'failed' Magenta $LogFile
                        Write-Log $_.Exception.Message Magenta $LogFile
                    }                
                }
            } else {
                Write-Log 'Clear-SBEventLog Error: bad log name provided:', $LogName Yellow,Cyan $LogFile
            }
        }
    }

    End { }
} 

function Get-FileShareInfo {
<#
 .SYNOPSIS
  Script to report on file share information
 
 .DESCRIPTION
  Function to provide file share information.
  This function also obtains and saves the registry entries for file shares under the current user Temp folder.
  USer the -Verbose switch for more details
 
 .PARAMETER IncludeDefaultShares
  This is an optional Switch parameter. When set to True, this function will report on default shares such as c$
 
 .EXAMPLE
  Get-FileShareInfo
 
 .EXAMPLE
  cls; $Result = Get-FileShareInfo -Verbose -IncludeDefaultShares; $Result | Out-GridView
 
 .OUTPUTS
  This cmdlet returns a PS object for each share permission such as:
    ComputerName : myComputerName
    ShareName : myShareName
    Path : x:\myFolderName
    Description :
    ConnectedUsers : 2
    DriveTotalGB : 1788
    DriveUsedGB : 1457
    DriveFreeGB : 331
    DriveFree% : 19
    SharePrincipal : myDomainName\Domain Users
    SharePermission : Modify, Synchronize
    ShareAccess : AccessAllowed
  Note that several objects may be returned for the same share if it has multiple share permissions assigned
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://superwidgets.wordpress.com/2015/03/11/file-share-migration-phase-1-discovery/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 9 February 2015 - Original version https://gallery.technet.microsoft.com/scriptcenter/Powershell-script-to-get-39c73c74
    Microsoft is retiring the Technet Gallery by June 2020, see https://docs.microsoft.com/en-us/teamblog/technet-gallery-retirement
  v0.2 - 2 May 2020 - Rewrite
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$IncludeDefaultShares
    )

    Begin {  }

    Process {      

        #region LANMAN registry key dump
        REG export HKLM\SYSTEM\CurrentControlSet\Services\LanmanServer\Shares "$env:TEMP\$env:COMPUTERNAME-Shares.reg" /y | Out-Null
        Write-Verbose "Shares' registry info saved to file '$env:TEMP\$env:COMPUTERNAME-Shares.reg', details:"
        Write-Verbose (Get-Content "$env:TEMP\$env:COMPUTERNAME-Shares.reg" | Out-String).Trim()
        #endregion

        #region Drive info
        $DriveInfo = Get-PSDrive | where { $_.Free } | sort $_.Root | foreach {
            [PSCustomObject][Ordered]@{
                Drive      = $_.Root
                UsedBytes  = $_.Used
                UsedGB     = [Math]::Round($_.Used/1GB, 0)
                FreeBytes  = $_.Free
                FreeGB     = [Math]::Round($_.Free/1GB, 0) 
                'Free%'    = [Math]::Round((100 * $_.Free/($_.Used + $_.Free)), 0)
                TotalBytes = $_.Used + $_.Free
                TotalGB    = [Math]::Round(($_.Used + $_.Free)/1GB, 0)
            }
        }
        Write-Verbose 'Drive info:'
        Write-Verbose ($DriveInfo | FT Drive,UsedGB,FreeGB,Free%,TotalGB -a | Out-String).Trim()
        #endregion

        #region Fileshare info
        $FileShareInfo = Get-WmiObject -Class Win32_Share | select Name, Path, Description | sort Path
        if (-not $IncludeDefaultShares) { $FileShareInfo = $FileShareInfo | where { -not $_.Description.StartsWith('Default') -and $_.Path } }
        Write-Verbose 'Fileshare info:'
        Write-Verbose ($FileShareInfo | FT -a | Out-String).Trim()
        #endregion
 
        #region ConnectedUsers info
        $ConnectedUsers = Get-WmiObject -Class Win32_ServerConnection -Namespace 'root\CIMV2' | 
            select ShareName, UserName, ComputerName, @{n='ActiveTimeSec';e={$_.ActiveTime}} | sort ShareName
        Write-Verbose ($ConnectedUsers | FT -a | Out-String).Trim()

        $ConnectedUsersTallies = $ConnectedUsers | group ShareName | Sort Count -Descending | select @{n='Share';e={$_.Name}},@{n='Connections';e={$_.Count}}
        Write-Verbose ($ConnectedUsersTallies | FT -a | Out-String).Trim() 
        #endregion

        #region SharePermissions
        $SharePermissions =  foreach ($ShareSecuritySetting in (Get-WmiObject -Class Win32_LogicalShareSecuritySetting)) {
            foreach ($DACL in ($ShareSecuritySetting.GetSecurityDescriptor()).Descriptor.DACL) {
                [PSCustomObject][ordered]@{
                    ShareName = $ShareSecuritySetting.Name
                    SecurityPrincipal = $( 
                        try {
                            "$($DACL.Trustee.Domain)\$($DACL.Trustee.Name)"
                        } catch {
                            $DACL.Trustee.Name
                        }
                    )
                    FileSystemRights = ($DACL.AccessMask -as [Security.AccessControl.FileSystemRights])
                    AccessType = [Security.AccessControl.AceType]$DACL.AceType
                }
            }
        }
        $SharePermissions = $SharePermissions | sort ShareName
        Write-Verbose 'Share (not NTFS) permissions:'
        Write-Verbose ($SharePermissions | FT -a | Out-String).Trim() 
        #endregion

        #region Summary
        $SummaryShareInfo = foreach ($thisFileShare in $FileShareInfo)  {
            Write-Verbose "Processing $($thisFileShare.Name)"
            if ($SharePermissions | where ShareName -EQ $thisFileShare.Name) {
                foreach ($thisSharePermission in ($SharePermissions | where ShareName -EQ $thisFileShare.Name)) {
                    Write-Verbose "Processing $($thisSharePermission.SecurityPrincipal)"
                    [PSCustomObject][ordered]@{
                        ComputerName    = $env:COMPUTERNAME
                        ShareName       = $thisFileShare.Name
                        Path            = $thisFileShare.Path
                        Description     = $thisFileShare.Description
                        ConnectedUsers  = ($ConnectedUsersTallies | where Share -EQ $thisFileShare.Name).Connections
                        DriveTotalGB    = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).TotalGB
                        DriveUsedGB     = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).UsedGB
                        DriveFreeGB     = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).FreeGB
                        'DriveFree%'    = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).'Free%'
                        SharePrincipal  = $thisSharePermission.SecurityPrincipal
                        SharePermission = $thisSharePermission.FileSystemRights
                        ShareAccess     = $thisSharePermission.AccessType
                    }
                }
            } else {
                [PSCustomObject][ordered]@{
                    ComputerName    = $env:COMPUTERNAME
                    ShareName       = $thisFileShare.Name
                    Path            = $thisFileShare.Path
                    Description     = $thisFileShare.Description
                    ConnectedUsers  = ($ConnectedUsersTallies | where Share -EQ $thisFileShare.Name).Connections
                    DriveTotalGB    = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).TotalGB
                    DriveUsedGB     = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).UsedGB
                    DriveFreeGB     = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).FreeGB
                    'DriveFree%'    = ($DriveInfo | where { $_.Drive[0] -eq $thisFileShare.Path[0] }).'Free%'
                    SharePrincipal  = 'None'
                    SharePermission = 'None'
                    ShareAccess     = 'None'
                }
            }

        }
        $SummaryShareInfo = $SummaryShareInfo | Sort ConnectedUsers -Descending        
        #endregion

    }

    End { $SummaryShareInfo }
} 

function Where-AMI {
<#
 .SYNOPSIS
  Function to return the output of different variables to indicate where a cmdlet/script is invoked from in the file system
 
 .DESCRIPTION
  Function to return the output of different variables to indicate where a cmdlet/script is invoked from in the file system
 
 .PARAMETER ShowCommandDefinition
  Optional Switch parameter. when set to True this funtion will also display $MyInvocation.MyCommand.Definition
 
 .EXAMPLE
  Where-AMI
 
 .EXAMPLE
  Where-AMI -ShowCommandDefinition
 
 .OUTPUTS
  PS Object containing the following properties:
    Command
    Direct
    Function
          
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 3 May 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$ShowCommandDefinition
    )

    Begin { 
    
        function PSCommandPath1()      { $PSCommandPath }
        function ScriptName()          { $MyInvocation.ScriptName }
        function MyCommandName()       { $MyInvocation.MyCommand.Name }
        function MyCommandDefinition() { $MyInvocation.MyCommand.Definition } 
        function PSCommandPath2()      { $MyInvocation.PSCommandPath }

    }

    Process {      

        $CommandList = @(
            [PScustomObject][Ordered]@{Command='$PSCommandPath';Direct=$PSCommandPath;Function=(PSCommandPath1)}
            [PScustomObject][Ordered]@{Command='$MyInvocation.ScriptName';Direct=$MyInvocation.ScriptName;Function=(ScriptName)}
            [PScustomObject][Ordered]@{Command='$MyInvocation.MyCommand.Name';Direct=$MyInvocation.MyCommand.Name;Function=(MyCommandName)}
            [PScustomObject][Ordered]@{Command='$MyInvocation.PSCommandPath';Direct=$MyInvocation.PSCommandPath;Function=(PSCommandPath2)}
        ) 
        
        if ($ShowCommandDefinition) {
            $CommandList += [PScustomObject][Ordered]@{Command='$MyInvocation.MyCommand.Definition';Direct=$MyInvocation.MyCommand.Definition;Function=(MyCommandDefinition)}
        }

        Write-Log ' '
        Write-Log 'PS Version:',$PSVersionTable.PSVersion Green,Cyan

        $Result = foreach ($Command in $CommandList) {
            [PSCustomObject][Ordered]@{
                Command  = $Command.Command
                Direct   = $Command.Direct
                Function = $Command.Function
            }
            Write-Log ' '
            Write-Log 'Command: ',$Command.Command Green,Cyan
            Write-Log 'Direct: ',$Command.Direct Green,Cyan
            Write-Log 'Function: ',$Command.Function Green,Cyan
        }

    }

    End { $Result }
} 

function Get-FolderSize {
<#
 .SYNOPSIS
  Function to return total folder size
 
 .DESCRIPTION
  Function to return total folder size
  This function can also return the size of subfolders
  This function uses Robocopy.exe (https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/robocopy)
 
 .PARAMETER Folder
  Path to folder.
  This parameter defaults to the current folder
 
 .PARAMETER Depth
  A number that can be 0-999 and defaults to 0.
  This is how deep into the subfolders this function will report on.
  This is a recursive function.
 
 .EXAMPLE
  Get-FolderSize
 
 .EXAMPLE
  Get-FolderSize -Folder C:\Windows -Depth 3 | sort SizeGB -Descending | FT -a
 
 .OUTPUTS
  This cmdlet returns a PS object for each folder (and subfolder if depth > 0) such as:
    FolderName FolderCount FileCount SizeGB DurationSec
    ---------- ----------- --------- ------ -----------
    C:\Windows 52043 183425 18.44 17
    C:\Windows\WinSxS 23946 65516 8.13 46
    C:\Windows\System32 1454 13775 3.09 53
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 23 September 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$Folder = '.\',
        [Parameter(Mandatory=$false)][ValidateRange(0,999)][Int]$Depth = 0
    )

    Begin {  }

    Process {  
        if (Test-Path $Folder) {    
            $Duration = Measure-Command { $RawOutput = robocopy /l /nfl /ndl /e /bytes /ia:RASHCNETO /W:0 /R:0 $Folder \Whatevs }
            New-Object -TypeName PSObject -Property ([Ordered]@{
                FolderName  = (Get-Item $Folder).FullName
                FolderCount = $(
                    if ($Found = ($RawOutput -match 'Dirs :')) {
                        [Math]::Round($Found.Split(':')[1].Trim().Split(' ')[0]) 
                    }
                )
                FileCount   = $(
                    if ($Found = ($RawOutput -match 'Files : ')) {
                        [Math]::Round($Found.Split(':')[1].Trim().Split(' ')[0]) 
                    }
                ) 
                SizeGB      = $(
                    if ($Found = ($RawOutput -match 'Bytes :')) {
                        [Math]::Round($Found.Split(':')[1].Trim().Split(' ')[0]/1GB,2) 
                    }
                )
                DurationSec = [Math]::Round($Duration.TotalSeconds,2)
            })
            if ($Depth -gt 0) {
                if ($FolderList = Get-ChildItem -Path $Folder -Directory -EA 0) {
                    foreach ($FolderName in $FolderList.FullName) {
                        Get-FolderSize -Folder $FolderName -Depth ($Depth -1)
                    }
                }
            }
        } else {
            Write-Log 'Folder',$Folder,'not found' Magenta,Yellow,Magenta
        }
    }

    End {  }
} 

function Self-Elevate {
<#
 .SYNOPSIS
  Function to self-elevate a PowerShell script
 
 .DESCRIPTION
  Function to self-elevate a PowerShell script
 
 .PARAMETER Exe
  Optional parameter that indicates which executable to use for the new elevated PowerShell session.
  Valid options are either PowerShell.exe or PowerShell_Ise.exe
  This defaults to PowerShell.exe
 
 .EXAMPLE
  if (-not $IsElevated) { Self-Elevate }
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 26 December 2020
#>


    [CmdletBinding(ConfirmImpact='High')]
    Param(
        [Parameter(Mandatory=$false)][ValidateSet('PowerShell_ise.exe','PowerShell.exe')][String]$Exe = 'PowerShell.exe'
    )

    Begin {  }

    Process {      
        if ([int]($thisOS.BuildNumber) -ge 6000) {
            $ArgumentList = "-File ""$($MyInvocation.MyCommand.Path)"" $($MyInvocation.UnboundArguments)"
            Start-Process -FilePath $Exe -Verb Runas -ArgumentList $ArgumentList
        } else {
            Write-Log 'This OS',$thisOs.Caption,"(Version $($thisOS.Version))",'does not support elevation' Magenta,Yellow,Magenta,Yellow
        }
    }

    End {  }
} 

function Truncate-String {
<#
 .SYNOPSIS
  Function to truncate a given string according to the given maximum.
 
 .DESCRIPTION
  Function to truncate a given string according to the given maximum.
 
 .PARAMETER String
  Any string like 'hello there'.
 
 .PARAMETER Maximum
  The number of characters to truncate the given string at.
 
 .EXAMPLE
  Truncate-String 'Hello there' 4
 
 .OUTPUTS
 String object
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 17 February 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$String,
        [Parameter(Mandatory=$true)][Int16]$Maximum
    )

    Begin {  }

    Process {      
        $String.Substring(0, ($String.Length,$Maximum | measure -Minimum).Minimum )
    }

    End {  }
} 

function New-FileSeed {
<#
 .SYNOPSIS
  Function to return the Geographical location of an Internet IP address
 
 .DESCRIPTION
  Function to return the Geographical location of an Internet IP address
  This function depends on ip-api.com and ipinfo.io
 
 .PARAMETER Source
  One or more URLs
  This is an optional parameter. These URLs will be queried for WAN IP.
 
 .EXAMPLE
  Get-MyWANIP
 
 .OUTPUTS
  This cmdlet returns a System.Net.IPAddress object such as:
    Address : 1132553623
    AddressFamily : InterNetwork
    ScopeId :
    IsIPv6Multicast : False
    IsIPv6LinkLocal : False
    IsIPv6SiteLocal : False
    IsIPv6Teredo : False
    IsIPv4MappedToIPv6 : False
    IPAddressToString : 151.101.129.67
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][ValidateSet('10KB','100KB','1MB','10MB','100MB','1GB','10GB','100GB','1TB')][String]$SeedSize,
        [Parameter(Mandatory=$true)][ValidateScript({ Test-Path $_ })][String]$WorkFolder
    )

    Begin { }

    Process {      
        $SizeList = @('10KB','100KB','1MB','10MB','100MB','1GB','10GB','100GB','1TB')
        $Seed = $SizeList.IndexOf($SeedSize.ToUpper())
        $WorkFolder = (Get-Item $WorkFolder).FullName
        $SeedInt64 = Switch ($SeedSize) {
            '10KB'  { 10KB }
            '100KB' { 100KB }
            '1MB'   { 1MB }
            '10MB'  { 10MB }
            '100MB' { 100MB }
            '1GB'   { 1GB }
            '10GB'  { 10GB }
            '100GB' { 100GB }
            '1TB'   { 1TB }
        }
        $SeedFileName = "$WorkFolder\Seed$SeedSize.txt"
        if ($SeedInt64 -eq 10KB) { # Smallest seed starts from scratch
            $Missing = try { (Get-Item $SeedFileName -EA 1).length -ne $SeedInt64 } catch { $true }
            if ($Missing) {
                $Duration = Measure-Command {
                    do { Get-Random -Minimum 100000000 -Maximum 999999999 | Out-File -Filepath $SeedFileName -append } 
                        while ((Get-Item $SeedFileName).length -lt ($SeedInt64-8))
                    Get-Random -Minimum 10 -Maximum 99 | Out-File -Filepath $SeedFileName -append # + 8 bytes
                }
            }
        } else { # Each subsequent seed depends on the prior one
            $PriorSeed = "$WorkFolder\Seed$($SizeList[$Seed-1]).txt"
            if (-not (Test-Path $PriorSeed)) { New-FileSeed -SeedSize $SizeList[$Seed-1] -WorkFolder $WorkFolder } # Recursive function :)
            $Missing = try { (Get-Item $SeedFileName -EA 1).length -ne $SeedInt64 } catch { $true }
            if ($Missing) {
                $Duration = Measure-Command {
                    $command = "cmd.exe /C copy $PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed+$PriorSeed $SeedFileName /y"
                    Invoke-Expression -Command:$command | Out-Null
                    Get-Random -Minimum 1000000 -Maximum 9999999 | Out-File -Filepath $SeedFileName -append # + 18 bytes
                }
            }
        }
        Write-Log 'Created/Validated',($SeedSize).PadRight(5),'seed file',($SeedFileName).PadRight(33),'in',('{0:N2}' -f $Duration.TotalSeconds).PadLeft(6),'seconds.' Green,Cyan,Cyan,Green,Green,Cyan,Green
    }

    End {  }
} 

function Test-Disk {
<#
 .SYNOPSIS
  Function to return the Geographical location of an Internet IP address
 
 .DESCRIPTION
  Function to return the Geographical location of an Internet IP address
  This function depends on ip-api.com and ipinfo.io
 
 .PARAMETER Source
  One or more URLs
  This is an optional parameter. These URLs will be queried for WAN IP.
 
 .EXAMPLE
  Get-MyWANIP
 
 .OUTPUTS
  This cmdlet returns a System.Net.IPAddress object such as:
    Address : 1132553623
    AddressFamily : InterNetwork
    ScopeId :
    IsIPv6Multicast : False
    IsIPv6LinkLocal : False
    IsIPv6SiteLocal : False
    IsIPv6Teredo : False
    IsIPv4MappedToIPv6 : False
    IPAddressToString : 151.101.129.67
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    param(
        [Parameter (Mandatory=$true,HelpMessage='WorkFolder to run this test in, like c:\support')][String]$WorkFolder,
        [Parameter (Mandatory=$true,HelpMessage='Maximum amount of disk space to use for this test')][Int64]$MaxSpaceToUseOnDisk,
        [Parameter (Mandatory=$false)][Int32]$Threads = 1,
        [Parameter (Mandatory=$false)][Int32]$Cycles = 3,
        [Parameter (Mandatory=$false)][Int32]$SmallestFile = 4
    )
    Begin {  }

    Process {      

    }

    End {  }
} 

function Get-PendingReboot {
<#
 .SYNOPSIS
  Function to test if a remote computer is pending reboot
 
 .DESCRIPTION
  Function to test if a remote computer is pending reboot
  This function performs three tests against each reachable computer provided:
  1. Pending Reboot: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing
  2. Reboot Required: HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update
  3. Pending File Rename Operations: HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager
 
 .PARAMETER ComputerName
  One or more computer names
 
 .PARAMETER Cred
  Optional PSCredential object that can be obtained via Get-Credential or Get-SBCredential
 
 .PARAMETER Detailed
  Optional switch. When set to True, this function returns detailed results on each of the three tests attempted.
 
 .PARAMETER LogFile
  Path to file where this function will log its console output.
 
 .EXAMPLE
  Get-PendingReboot Comp1,Comp2
 
 .OUTPUTS
  This function returns a PS object for test against each reachable computer such as:
    ComputerName : Comp1
    OS : Microsoft Windows Server 2016 Datacenter (10.0.14393 64-bit)
    PendingReboot : False
 
  If the 'Detailed' switch is set to True, the ouput looks like:
  This function returns a PS object for test against each reachable computer such as:
    ComputerName : Comp1
    OS : Microsoft Windows Server 2016 Datacenter (10.0.14393 64-bit)
    TestName : PendingFileRenameOperations
    TestType : NonNullValue
    TestResult : False
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 7 April 2021
  v0.2 - 10 April 2021 - Added Detailed switch, and summary output.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String[]]$ComputerName,
        [Parameter(Mandatory=$false)][PSCredential]$Cred,
        [Parameter(Mandatory=$false)][Switch]$Detailed,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-PendingReboot_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 
        $pendingRebootTests = @(
            New-Object -TypeName PSObject -Property @{
                Name     = 'RebootPending'
                Test     = { Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing' -Name 'RebootPending' -EA 0 }
                TestType = 'ValueExists'
            }
            New-Object -TypeName PSObject -Property @{
                Name     = 'RebootRequired'
                Test     = { Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update' -Name 'RebootRequired' -EA 0 }
                TestType = 'ValueExists'
            }
            New-Object -TypeName PSObject -Property @{
                Name     = 'PendingFileRenameOperations'
                Test     = { Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -EA 0 }
                TestType = 'NonNullValue'
            }
        )    
    }

    Process {   
        $DetailedResult = foreach ($Computer in $ComputerName) {   
            try {
                if ($Cred) {
                    $session = New-PSSession -Computer $Computer -Credential $Cred -EA 1 
                } else {
                    $session = New-PSSession -Computer $Computer -EA 1 
                }                
                foreach ($test in $pendingRebootTests) {
                    $result = Invoke-Command -Session $session -ScriptBlock $test.Test
                    $OS = Invoke-Command -Session $session -ScriptBlock { Get-WmiObject -Class Win32_OperatingSystem -EA 0 }
                    $TestResult = if ($test.TestType -eq 'ValueExists' -and $result) {
                        $true                       
                    } elseif ($test.TestType -eq 'NonNullValue' -and $result -and $result.($test.Name)) {
                        $true                    
                    } else {
                        $false                  
                    }
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        ComputerName = $Computer
                        OS           = "$($OS.Caption) ($($OS.Version) $($OS.OSArchitecture))"
                        TestName     = $test.Name
                        TestType     = $test.TestType
                        TestResult   = $TestResult
                    })                        
                }
                $session | Remove-PSSession
            } catch {
                Write-Log 'Get-PendingReboot Error:' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    ComputerName = $Computer
                    OS           = 'Unreachable'
                    TestName     = 'N/A'
                    TestType     = 'N/A'
                    TestResult   = 'N/A'
                }) 
            }
        }
    }

    End {  
        if ($Detailed) {
            $DetailedResult
        } else {
            $DetailedResult | Group-Object -Property ComputerName | foreach {
                if ($Found = $_.Group | where { $_.TestResult }) {
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        ComputerName  = $Found.ComputerName | select -First 1
                        OS            = $Found.OS | select -First 1
                        PendingReboot = $Found.TestName -join ', '
                    })                      
                } else {
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        ComputerName  = $_.Group.ComputerName | select -First 1
                        OS            = $_.Group.OS | select -First 1
                        PendingReboot = $false
                    })                      
                }
            }
        }
    }
} 

function Cleanup-WindowsFolder {
<#
 .SYNOPSIS
  Function to clean up Windows folder by deleting unused components and service packs.
 
 .DESCRIPTION
  Function to clean up Windows folder by deleting unused components and service packs.
  This function uses DISM.EXE and requires elevation.
  https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/clean-up-the-winsxs-folder
 
 .PARAMETER Level
  This optional parameter takes 0, 1, or 2 values and defaults to 0.
  0 ==> Delete files with attribute 'Temporary' under the Windows Font Cache folder.
        This is typically C:\WINDOWS\ServiceProfiles\LocalService\AppData\Local
  1 ==> Delete older unused components, safely cleanup c:\Windows\WinSXS
        No 30 day grace period.
  2 ==> Remove all superseded versions of every component in the component store
        All existing service packs and updates cannot be uninstalled
  3 ==> Remove any backup components needed for uninstallation of the service pack
        The service pack cannot be uninstalled after this command is completed
 
 .PARAMETER LogFile
  Path to a file where this function will save its console output.
 
 .EXAMPLE
  Cleanup-WindowsFolder
  This example will invoke this function at level 0, which will delete older unused components, and safely cleanup c:\Windows\WinSXS. No 30 day grace period.
 
 .EXAMPLE
  Cleanup-WindowsFolder -Level 2
  This example will invoke this function at level 2, which will delete older unused components, safely cleanup c:\Windows\WinSXS, with no 30 day grace period.
  It will also remove all superseded versions of every component in the component store. All existing service packs and updates cannot be uninstalled.
  It will also remove any backup components needed for uninstallation of the service pack. The service pack cannot be uninstalled after this command is completed.
 
 .OUTPUTS
  This function displays command details, the time it took, and the disk space savings, to the console and log file.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 2 October 2021
  v0.2 - 9 October 2021
    Added DISM error trapping
    Added Level to delete temporary files in Windows Font Cache folder
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateRange(0,3)][Int16]$Level = 0,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Cleanup-WindowsFolder_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 
        if (-not $IsElevated) {
            Write-Log 'Cleanup-WindowsFolder Error:','This function requires elevation.' Magenta,Yellow $LogFile
            break
        }
        Write-Log 'Performing Windows folder cleanup, this will take several minutes..' Green $LogFile 
    }

    Process {   
    
        switch ($Level) {
            0 {
                $FolderPath = "$env:windir\ServiceProfiles\LocalService\AppData\Local"
                if (Test-Path $FolderPath) {
                    Write-Log 'Listing files with','Temporary','attribute under the Windows Font Cache folder',$FolderPath Green,Cyan,Green,Cyan $LogFile 
                    $Before   = Get-Volume -DriveLetter $WinDrive 
                    $Duration = measure-Command { 
                        $FileList = Get-ChildItem -Path $FolderPath -Recurse 
                        $TotalSize = (($FileList | foreach { $_.Length }) | Measure  -Sum).Sum
                        Write-Log ' Identified',('{0:N0}' -f $FileList.Count),'files under',$FolderPath,'Total size:',('{0:N2}' -f ($TotalSize/1GB)),'GB' Green,Cyan,Green,Cyan,Green,Cyan,Green $LogFile
                        $TempList = $FileList | where Attributes -match 'Temp'
                        $TempSize = (($TempList | foreach { $_.Length }) | Measure  -Sum).Sum
                        Write-Log ' of which',('{0:N0}' -f $TempList.Count),'files have the','Temporary','attribute. Total size:',('{0:N2}' -f ($TempSize/1GB)),'GB' Green,Cyan,Green,Cyan,Green,Cyan,Green $LogFile
                        if ($TempList) {
                            Write-Log ' Deleting..' Green -NoNewLine
                            Remove-Item $TempList.Fullname -Force -Confirm:$false
                            Write-Log 'done' Cyan $LogFile
                        } else {
                            Write-Log ' Nothing to cleanup here.' Green $LogFile
                        }
                    }
                    $After    = Get-Volume -DriveLetter $WinDrive 
                    Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'(hh:mm:ss)' Green,Cyan,Green $LogFile                 
                    Write-Log ' Free disk space before cleanup:',('{0:N2}' -f ($Before.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                    Write-Log ' Free disk space after cleanup: ',('{0:N2}' -f ($After.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                    Write-Log ' Amount of freed disk space: ',('{0:N2}' -f (($After.SizeRemaining-$Before.SizeRemaining)/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile
                } else {
                    Write-Log 'Windows Font Cache folder',$FolderPath,'not found' Magenta,Yellow,Magenta $LogFile
                }

            }
            1 {
                Write-Log 'Invoking','Dism.exe /online /Cleanup-Image /StartComponentCleanup' Green,Cyan $LogFile 
                Write-Log " To delete older unused components and safely cleanup $env:windir\WinSXS",'No 30 day grace period..' Green,Cyan $LogFile -NoNew
                $Before   = Get-Volume -DriveLetter $WinDrive 
                $Duration = measure-Command { $Result = Dism.exe /online /Cleanup-Image /StartComponentCleanup }
                $After    = Get-Volume -DriveLetter $WinDrive 
                Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'(hh:mm:ss)' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space before cleanup:',('{0:N2}' -f ($Before.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space after cleanup: ',('{0:N2}' -f ($After.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Amount of freed disk space: ',('{0:N2}' -f (($After.SizeRemaining-$Before.SizeRemaining)/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile  
                if (-not ($Result -match 'The operation completed successfully.')) {
                    Write-Log 'DISM issue(s) encountered:' Magenta $LogFile
                    Write-Log ($Result | Out-String).Trim() Yellow $LogFile
                }
            }
            2 {
                Write-Log 'Invoking','Dism.exe /online /Cleanup-Image /StartComponentCleanup /ResetBase' Green,Cyan $LogFile 
                Write-Log " To delete older unused components and safely cleanup $env:windir\WinSXS",'No 30 day grace period,' Green,Cyan $LogFile 
                Write-Log ' AND remove all superseded versions of every component in the component store','All existing service packs and updates cannot be uninstalled' Green,Cyan $LogFile -NoNew
                $Before   = Get-Volume -DriveLetter $WinDrive 
                $Duration = measure-Command { $Result = Dism.exe /online /Cleanup-Image /StartComponentCleanup /ResetBase }
                $After    = Get-Volume -DriveLetter $WinDrive 
                Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'(hh:mm:ss)' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space before cleanup:',('{0:N2}' -f ($Before.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space after cleanup: ',('{0:N2}' -f ($After.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Amount of freed disk space: ',('{0:N2}' -f (($After.SizeRemaining-$Before.SizeRemaining)/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile  
                if (-not ($Result -match 'The operation completed successfully.')) {
                    Write-Log 'DISM issue(s) encountered:' Magenta $LogFile
                    Write-Log ($Result | Out-String).Trim() Yellow $LogFile
                }
            }
            3 {
                Write-Log 'Invoking','Dism.exe /online /Cleanup-Image /StartComponentCleanup /ResetBase' Green,Cyan $LogFile 
                Write-Log " To delete older unused components and safely cleanup $env:windir\WinSXS",'No 30 day grace period,' Green,Cyan $LogFile 
                Write-Log ' AND remove all superseded versions of every component in the component store','All existing service packs and updates cannot be uninstalled' Green,Cyan $LogFile -NoNew
                $Before   = Get-Volume -DriveLetter $WinDrive 
                $Duration = measure-Command { $Result = Dism.exe /online /Cleanup-Image /StartComponentCleanup /ResetBase }
                $After    = Get-Volume -DriveLetter $WinDrive 
                Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'(hh:mm:ss)' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space before cleanup:',('{0:N2}' -f ($Before.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space after cleanup: ',('{0:N2}' -f ($After.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Amount of freed disk space: ',('{0:N2}' -f (($After.SizeRemaining-$Before.SizeRemaining)/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile  
                if (-not ($Result -match 'The operation completed successfully.')) {
                    Write-Log 'DISM issue(s) encountered:' Magenta $LogFile
                    Write-Log ($Result | Out-String).Trim() Yellow $LogFile
                }
                                                              
                Write-Log 'Invoking','Dism.exe /online /Cleanup-Image /SPSuperseded' Green,Cyan $LogFile 
                Write-Log ' To remove any backup components needed for uninstallation of the service pack.','The service pack cannot be uninstalled after this command is completed.' Green,Cyan $LogFile -NoNew
                $Before   = Get-Volume -DriveLetter $WinDrive 
                $Duration = measure-Command { $Result = Dism.exe /online /Cleanup-Image /SPSuperseded }
                $After    = Get-Volume -DriveLetter $WinDrive 
                Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'(hh:mm:ss)' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space before cleanup:',('{0:N2}' -f ($Before.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Free disk space after cleanup: ',('{0:N2}' -f ($After.SizeRemaining/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile                 
                Write-Log ' Amount of freed disk space: ',('{0:N2}' -f (($After.SizeRemaining-$Before.SizeRemaining)/1GB)).PadLeft(9),'GB' Green,Cyan,Green $LogFile  
                if (-not ($Result -match 'The operation completed successfully.')) {
                    Write-Log 'DISM issue(s) encountered:' Magenta $LogFile
                    Write-Log ($Result | Out-String).Trim() Yellow $LogFile
                }
            }
            default {
                Write-Log 'Cleanup-WindowsFolder Error:','Unrecognized Level',$Level Magenta,Yellow,Cyan $LogFile
            }
        }

    }

    End { }
} 

function Get-SmallestSubnet {
<#
 .SYNOPSIS
  Function to return the smallest subnet Mask that would apply to a group of provided IP addresses
 
 .DESCRIPTION
  Function to return the smallest subnet Mask that would apply to a group of provided IP addresses
 
 .PARAMETER IPAddressList
  One or more IPv4 addresses
 
 .EXAMPLE
  Get-SmallestSubnet '123.45.13.41','123.45.104.121','123.45.125.130','123.45.30.215','123.45.83.219','123.45.150.227','123.45.10.240','123.45.91.255'
 
 .OUTPUTS
  This cmdlet returns a PS object such as:
    Binary DottedDecimal CIDR
    ------ ------------- ----
    11111111111111110000000000000000 255.255.0.0 /16
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 17 January 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][IPAddress[]]$IPAddressList
    )

    Begin { 
        if ($IPAddressList.Count -eq 1) {
            Write-Log 'Single IP address received',$IPAddressList.IPAddressToString Green,Cyan
            Write-Log 'Smallest Mask is', $SubnetMaskList[0].DottedDecimal Green,Cyan
            $SubnetMaskList[0]
        }
    }

    Process {      

        foreach ($Mask in $SubnetMaskList) {
            $SameSubnet = $true
            foreach ($Count in 1..($IPAddressList.Count-1)) {
                $Same1 = Test-SameSubnet -IP1 $IPAddressList[0].IPAddressToString -Mask1 $Mask.DottedDecimal -IP2 $IPAddressList[$Count].IPAddressToString -Mask2 $Mask.DottedDecimal
                if (-not $Same1) { $SameSubnet = $false }
            }
            if ($SameSubnet) {
                ' '
                Write-Log 'Mask',$Mask.DottedDecimal.PadRight(16),'is common to all the following IPs' Green,Cyan,Green
                $IPAddressList.IPAddressToString | foreach { Write-Log " $_" Cyan }
                $Mask
                return
            } else {
                Write-Log 'Mask',$Mask.DottedDecimal.PadRight(16),'is not it' Yellow,Cyan,Yellow
            }
        }

    }

    End {  }
} 

function Update-GoogleImageCreationTimeStamp {
<#
 .SYNOPSIS
  Function to change Google image Creation Time Stamp to match the value provided in the corresponding .JSON file
 
 .DESCRIPTION
  Function to change Google image Creation Time Stamp to match the value provided in the corresponding .JSON file
  Google photos under https://photos.google.com/ can be downloaded via Google Takeout https://takeout.google.com/
  This produces a .ZIP file, that contains one or more folders, which contain .HEIC image files and corresponding .JSON files such as IMG_0001.HEIC and IMG_0001.HEIC.JSON
  The corresponding .JSON file contains information about the actual image Creation Time among other things.
  Example:
    {
      "title": "IMG_0001.HEIC",
      "description": "",
      "imageViews": "5",
      "creationTime": {
        "timestamp": "1565457830",
        "formatted": "Aug 10, 2019, 5:23:50 PM UTC"
      },
      "photoTakenTime": {
        "timestamp": "1565314549",
        "formatted": "Aug 9, 2019, 1:35:49 AM UTC"
      },
      "geoData": {
        "latitude": xx.yyyy,
        "longitude": aa.bbbbb,
        "altitude": cc.dddd,
        "latitudeSpan": ee.ffff,
        "longitudeSpan": gg.hhhh
      },
      "geoDataExif": {
        "latitude": xx.yyyy,
        "longitude": aa.bbbbb,
        "altitude": cc.dddd,
        "latitudeSpan": ee.ffff,
        "longitudeSpan": gg.hhhh
      },
      "url": "https://xxx.googleusercontent.com/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      "googlePhotosOrigin": {
        "mobileUpload": {
          "deviceType": "IOS_PHONE"
        }
      },
      "photoLastModifiedTime": {
        "timestamp": "1646710576",
        "formatted": "Mar 8, 2022, 3:36:16 AM UTC"
      }
    }
 
 .PARAMETER ImageFileFullPath
  Full Path to the image file.
  This is can be obtained from the Get-Item or Get-ChildItem cmdlets. See examples below.
 
 .PARAMETER TimeToUse
  Valid options are: 'creationTime','photoTakenTime', or 'photoLastModifiedTime'
  These are values obtained from the corresponding .JSON file.
  This defaults to photoTakenTime.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output.
 
 .EXAMPLE
  (Get-ChildItem '.\Google Photos' -Include '*.HEIC','*.MP4' -Recurse).FullName | foreach { Update-GoogleImageCreationTimeStamp -ImageFileFullPath $_ }
 
 .OUTPUTS
  Console output similar to:
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0011.MP4 done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:50 PM to 08/19/2019 21:52:18
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0012.HEIC done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:51 PM to 08/20/2019 10:25:52
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0012.MP4 done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:52 PM to 08/20/2019 10:25:52
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0013.HEIC done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:55 PM to 08/20/2019 10:25:57
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0013.MP4 done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:56 PM to 08/20/2019 10:25:57
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0014.HEIC done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:57 PM to 08/20/2019 10:26:01
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0014.MP4 done, Creation Time Stamp (UTC) changed from 4/14/2022 9:21:58 PM to 08/20/2019 10:26:01
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0015.HEIC done, Creation Time Stamp (UTC) changed from 4/14/2022 9:22:00 PM to 08/20/2019 10:26:13
    Processing image file C:\Sandbox\Google Photos\Photos from 2019\IMG_0015.MP4 done, Creation Time Stamp (UTC) changed from 4/14/2022 9:22:02 PM to 08/20/2019 10:26:13
         
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 14 April 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)] [String]$ImageFileFullPath,
        [Parameter(Mandatory=$false)][ValidateSet('creationTime','photoTakenTime','photoLastModifiedTime')][String]$TimeToUse = 'photoTakenTime',
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Update-GoogleImageCreationTimeStamp-$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  }

    Process {      
        if (Test-Path $ImageFileFullPath) {
            Write-Log 'Processing image file',$ImageFileFullPath Green,Cyan $LogFile -NoNewLine
            try {
                $ImageFile = Get-Item -Path $ImageFileFullPath -EA 1
                $JSONFilePath = $ImageFileFullPath + '.JSON'
                if (-not (Test-Path $JSONFilePath)) { $JSONFilePath = $ImageFileFullPath -replace $ImageFile.Extension,'.HEIC.JSON' }
                if (Test-Path $JSONFilePath) {
                    try {
                        $EpochTime = (Get-Content $JSONFilePath -EA 1 | ConvertFrom-Json -EA 1).$TimeToUse.timeStamp 
                        try {
                            $ImageFile = Get-Item -Path $ImageFileFullPath -EA 1
                            $OriginalCreationTime = $ImageFile.CreationTimeUtc.ToString()
                            try {
                                $ImageFile.CreationTimeUtc = ([System.DateTimeOffset]::FromUnixTimeSeconds($EpochTime)).DateTime
                                Write-Log 'done, Creation Time Stamp (UTC) changed from',$OriginalCreationTime,'to',(Get-Item -Path $ImageFileFullPath).CreationTimeUtc Green,Cyan,Green,Cyan $LogFile
                            } catch {
                                Write-Log 'failed, Creation Time Stamp (UTC) remains as',$OriginalCreationTime Magenta,Yellow $LogFile
                                Write-Log 'Update-GoogleImageCreationTimeStamp Error:',$_.Exception.Message Magenta,Yellow $LogFile
                            }
                        } catch {
                            Write-Log 'Update-GoogleImageCreationTimeStamp Error: unable to read image file',$ImageFileFullPath Magenta,Yellow $LogFile
                            Write-Log $_.Exception.Message Yellow $LogFile
                        } 
                    } catch {
                        Write-Log 'Update-GoogleImageCreationTimeStamp Error:',$_.Exception.Message Magenta,Yellow $LogFile
                    }
                } else {
                    Write-Log 'Update-GoogleImageCreationTimeStamp Error: JSON file',"$JSONFilePath / $($ImageFileFullPath + '.JSON')",'not found' Magenta,Yellow,Magenta $LogFile
                }
            } catch {
                Write-Log 'Update-GoogleImageCreationTimeStamp Error: unable to read image file',$ImageFileFullPath Magenta,Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            } 
        } else {
            Write-Log 'Update-GoogleImageCreationTimeStamp Error: image file',$ImageFileFullPath,'not found' Magenta,Yellow,Magenta $LogFile
        }
    }

    End {  }
} 

function Get-CountryCodes {
<#
 .SYNOPSIS
  Function to return ISO 3166 Country codes.
 
 .DESCRIPTION
  Function to return ISO 3166 Country codes.
  See https://www.iso.org/obp/ui/#search
 
 .EXAMPLE
  Get-CountryCodes
 
 .OUTPUTS
  List of records similar to:
    name : Benin
    alpha-2 : BJ
    alpha-3 : BEN
    country-code : 204
    iso_3166-2 : ISO 3166-2:BJ
    region : Africa
    sub-region : Sub-Saharan Africa
    intermediate-region : Western Africa
    region-code : 002
    sub-region-code : 202
    intermediate-region-code : 011
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://www.iso.org/obp/ui/#search
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 19 September 2022
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param()

    Begin { }

    Process {
        $CountryCodeCSVFile = "$(Split-Path -Path $PSCommandPath)\ISO-3166-Country-Codes.csv" 
        try {        
            Import-Csv -Path $CountryCodeCSVFile -EA 1 
        } catch {
            Write-Log 'Failed to read country CSV file',$CountryCodeCSVFile Magenta,Yellow 
            break 
        }
    }

    End { }
}

function Test-FileLock {

<#
 .Synopsis
  Function to check if a given file is open or not.
 
 .Description
  Function to check if a given file is open or not.
   
 .PARAMETER Path
  Required. This is the path to the file to test.
 
 .Example
  Test-FileLock c:\temp\test1.txt
 
 .OUTPUTS
  This function returns True of the file is open, and False if not.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
    v0.1 - 18 October 2022
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$Path
    )

    Begin {  }

    Process {    
        
        $File = New-Object System.IO.FileInfo $Path

        if (Test-Path -Path $Path) {
            try {
                $Stream = $File.Open([System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
                if ($Stream) { $Stream.Close() }
                return $false
            } catch {
                # file is locked by a process.
                return $true
            }    
        } else {
            Write-Log 'The file',$Path,'does not exist' Magenta,Yellow,Cyan
        }
                   
    } 

    End {  }
}

function Get-FileEncoding {

<#
 .Synopsis
  Function to return text file encoding.
 
 .Description
  Function to return text file encoding.
  This is based on the file's byte-order mark (BOM).
  A BOM is used to indicate how a processor places serialized text into a sequence of bytes.
  A BOM can also be used as a file signature to identify the encoding of a text file, in addition to the byte order.
  This function's 'UTF-8' or 'ASCII' results may not be entirely accurate.
   
 .PARAMETER Path
  Required path to the file.
 
 .Example
  Get-FileEncoding -Path c:\test1.txt
 
 .OUTPUTS
  The file encoding such as UTF-8 or ASCII.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://learn.microsoft.com/en-us/globalization/encoding/byte-order-mark
 
 .NOTES
  Function by Sam Boutros
    v0.1 - 19 October 2022
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$Path
    )

    Begin { 
        
        if (-not (Test-Path -Path $Path)) {
            Write-Log $Path,'does not exist' Magenta,Yellow
            break
        }

        if ((Get-Item -Path $Path).PSIsContainer) {
            Write-Log $Path,'is a folder, expecting a file.' Magenta,Yellow
            break
        }

    }

    Process {  
      
        $Bytes = [byte[]](Get-Content $Path -Encoding byte -ReadCount 4 -TotalCount 4)

        if (-not $Bytes) { return 'UTF-8' }

        switch -regex ('{0:x2}{1:x2}{2:x2}{3:x2}' -f $Bytes[0],$Bytes[1],$Bytes[2],$Bytes[3]) {
            '^efbbbf'   { return 'UTF-8' }
            '^2b2f76'   { return 'UTF-7' }
            '^feff'     { return 'UTF-16 Big-Endian (Unicode?)' }
            '^fffe'     { return 'UTF-16 Little-Endian (Unicode?)' }
            '^0000feff' { return 'UTF-32 Big-Endian' }
            '^fffe0000' { return 'UTF-32 Little-Endian' }
            default     { return 'ASCII' }
        }
           
    } 

    End {  }
}

function Flatten-PSObject {

<#
 .SYNOPSIS
  Function to flatten nested PSObject.
  
.DESCRIPTION
  A recursive to flatten nested PSObject.
  Azure PowerShell commands often return an object with nested objects several layers deep.
  This function unpacks such an object and displays it to the console.
  
.PARAMETER PSObj
  Nested PSObject - required.
  
.PARAMETER Indent
  An optional integer used internally when displaying nested objects.
  
.EXAMPLE
  Display-PSObject ((Get-AzureADMSConditionalAccessPolicy | select -First 1).ToJson() | ConvertFrom-Json)
  Sample output:
     conditions :
       applications :
         excludeApplications :
         includeApplications : aaaa1111-bbbb-2222-cccc-3333dddd4444
         includeAuthenticationContextClassReferences :
         includeUserActions :
       clientApplications :
       clientAppTypes : browser, mobileAppsAndDesktopClients
       devices :
       deviceStates :
       locations :
       platforms :
         excludePlatforms :
         includePlatforms : all
       servicePrincipalRiskLevels :
       signInRiskLevels :
       times :
       userRiskLevels :
       users :
         excludeGroups :
         excludeGuestsOrExternalUsers :
         excludeRoles :
         excludeUsers :
         includeGroups :
         includeGuestsOrExternalUsers :
         includeRoles :
         includeUsers : aaaa1111-bbbb-2222-cccc-3333dddd444, aaaa1111-bbbb-2222-cccc-3333dddd444
     createdDateTime :
     displayName : My CAP name here
     grantControls :
       authenticationStrength :
       authenticationStrength@odata.context : https://graph.microsoft.com/beta/$metadata#identity/conditionalAccess/policies('aaaa1111-bbbb-2222-cccc-3333dddd444')/grantControls/authenticationStrength/$entity
       builtInControls : block
       customAuthenticationFactors :
       operator : OR
       termsOfUse :
     id : aaaa1111-bbbb-2222-cccc-3333dddd444
     modifiedDateTime : 2022-01-29T00:17:00.5518094Z
     sessionControls :
     state : enabled
  
.EXAMPLE
  Get-AzureADMSConditionalAccessPolicy | foreach { $_.ToJson() | ConvertFrom-Json } | foreach { Display-PSObject $_ }
  This command unpacks all Conditional Access Policies in the current Azue tenant.
  
.OUTPUTS
  This cmdlet returns console output - see example.
  It also saves its output to log file.
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  
.NOTES
  Function by Sam Boutros
  v0.1 - 20 Feb 2023
  v0.2 - 20 Feb 2023 - added feature to output to log file
#>

 
    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][PSCustomObject]$PSObj,
        [Parameter(Mandatory=$false)][PSCustomObject]$CurrentObj = (New-Object -TypeName PSObject),
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Flatten-PSObject-$(Get-Date -Format 'ddMMMyyyy_HH-mm').log"
    )
 
    Begin {  }
 
    Process {     
 
        $PropList = ($PSObj | Get-Member -MemberType NoteProperty,Property).Name
        foreach ($Prop in $PropList) {
            if ($PSObj.$Prop -is [Hashtable]) {
                Flatten-PSObject -PSObj (New-Object -TypeName PSObject -Property $PSObj.$Prop) -CurrentObj $CurrentObj -LogFile $LogFile
            } elseif ($PSObj.$Prop -is [PSCustomObject]) {
                Write-Log ' ' -LogFile $LogFile
                Flatten-PSObject -PSObj $PSObj.$Prop -CurrentObj $CurrentObj -LogFile $LogFile
            } else {
                $CurrentObj | Add-Member -MemberType NoteProperty -Name $Prop -Value ($PSObj.$Prop -join ', ')
            }           
        } 
    }
 
    End {  }
}

function UnGZip-File {
<#
 .SYNOPSIS
  Function to decompress a GZip file.
 
 .DESCRIPTION
  Function to decompress a GZip file.
  This function leverages native .Net 2.0 and above.
 
 .PARAMETER GzFile
  Required path to GZ file such as c:\Sandbox\pfSense\pfSense-CE-2.6.0-RELEASE-amd64.iso.gz
 
 .PARAMETER OutFile
  Optional path to decompressed file(s)
  If not provided it defaults to the same GzFile name less the .gz* at the end.
 
 .PARAMETER ShowProgress
  Optional switch. When set to True, this function will show decompression progress.
  Warning: This switch will significantly slow the process down (~ 43x slower).
 
 .EXAMPLE
  UnGZip-File 'c:\Sandbox\pfSense\pfSense-CE-2.6.0-RELEASE-amd64.iso.gz'
 
 .EXAMPLE
  UnGZip-File 'c:\Sandbox\pfSense\pfSense-CE-2.6.0-RELEASE-amd64.iso.gz' -ShowProgress
 
 .OUTPUTS
  This cmdlet displays decompression stats at the end to the console, and writes the decompressed file(s) to disk.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 22 March 2023
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][ValidateScript({ Test-Path $_ -PathType Leaf })][String]$GzFile,
        [Parameter(Mandatory=$false)][String]$OutFile,
        [Parameter(Mandatory=$false)][Switch]$ShowProgress
    )

    Begin { 
        if (-not $OutFile) {
            $PartList = $GzFile -split '\.'
            $OutFile = $PartList[0..($PartList.Count-2)] -join '.'
        }
    }

    Process {      
        $InStream = New-Object System.IO.FileStream $GzFile, ([IO.FileMode]::Open), ([IO.FileAccess]::Read), ([IO.FileShare]::Read)
        $OutStream = New-Object System.IO.FileStream $OutFile, ([IO.FileMode]::Create), ([IO.FileAccess]::Write), ([IO.FileShare]::None)
        try {
            $GzipStream = New-Object System.IO.Compression.GzipStream $InStream, ([IO.Compression.CompressionMode]::Decompress) -EA 1 

            $Buffer = New-Object byte[](1024)
            $i = 0 
            $Duration = Measure-Command {
                while ($true){
                    ++$i
                    if ($ShowProgress) { Write-Progress -Activity "Decompressed $('{0:N0}' -f $i) KBs." }
                    $Read = $GzipStream.Read($Buffer, 0, 1024)
                    if ($Read -le 0) { break }
                    $OutStream.Write($Buffer, 0, $Read)
                }
            }
            $GzipStream.Close()
            $InFileInfo  = Get-Item $GzFile
            $OutFileInfo = Get-Item $OutFile
            Write-Log 'Decompressed the file',$InFileInfo.FullName,"$('{0:N0}' -f ($InFileInfo.Length/1MB)) MB" Green,Cyan,Yellow
            Write-Log ' to',$OutFileInfo.FullName,"$('{0:N0}' -f ($OutFileInfo.Length/1MB)) MB" Green,Cyan,Yellow
            Write-Log ' in',"$($Duration.Minutes):$($Duration.Seconds)",'mm:ss' Green,Cyan,Green
        } catch {
            Write-Log 'Error:',$_.Exception.Message Magenta,Yellow
        }
        $OutStream.Close()
        $InStream.Close()
    }

    End {  }
} 

function Match-LargeArrays {
<#
 .SYNOPSIS
  Function to match two large arrays based on a given property.
 
 .DESCRIPTION
  Function to match two large arrays based on a given property.
 
 .PARAMETER Array1
  The first Array.
 
 .PARAMETER Property1
  A property of the first array, whose values are to be used for the matching.
 
 .PARAMETER Array2
  The second Array.
 
 .PARAMETER Property2
  A property of the second array, whose values are to be used for the matching.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output.
 
 .EXAMPLE
  $Result = Match-LargeArrays -Array1 $ADUserList -Property1 'ms-ds-ConsistencyGuid' -Array2 $AzureUserList -Property2 'ImmutableId'
 
 .OUTPUTS
  This cmdlet returns an array similar to the input Array1 with additional property 'MatchingObject' whose value is the maching records from Array2.
  If no maches are found, the 'MatchingObject' property will have the 'Not Found' value.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 29 July 2023
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)]$Array1,
        [Parameter(Mandatory=$true)][String]$Property1,
        [Parameter(Mandatory=$true)]$Array2,
        [Parameter(Mandatory=$true)][String]$Property2,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Match-LargeArrays_$(Get-Date -Format 'ddMMMyyyy-HH-mm').log"
    )

    Begin { 
        #Validate Input
        $myError = $false
        if ($Array1.Count -le 2) {
            Write-Log 'Error:','the provided Array1 is too small - has only',$Array1.Count,'elements' Magenta,Yellow,Cyan,Yellow $LogFile
            $myError = $true
        }
        if ($Array2.Count -le 2) {
            Write-Log 'Error:','the provided Array2 is too small - has only',$Array2.Count,'elements' Magenta,Yellow,Cyan,Yellow $LogFile
            $myError = $true
        }
        if ($Property1 -notin ($Array1 | Get-Member).Name) {
            Write-Log 'Error:','the provided Array1 elements do not contain a property',$Property1 Magenta,Yellow,Cyan $LogFile
            $myError = $true
        }
        if ($Property2 -notin ($Array2 | Get-Member).Name) {
            Write-Log 'Error:','the provided Array1 elements do not contain a property',$Property1 Magenta,Yellow,Cyan $LogFile
            $myError = $true
        }
        if ($myError) { break }
    }

    Process {      
        $Duration  = Measure-Command {
            # First build a Hashtable indexed by the search field
            $myHashTable = @{} # Explicity declare an empty hashtable
            foreach ($Array2Element in $Array2) { 
                try { 
                    $myHashTable[$Array2Element.$Property2] = $Array2Element 
                } catch { 
                    # Supressing the "Index operation failed: the array index evaluated to null." message.
                    # Write-log $_.Exception.Message,'for the record:' Magenta,Yellow $LogFile
                    # Write-Log ($Array2Element | FL * | Out-String).Trim() Cyan $LogFile
                } 
            }

            # Next we match using the Hashtable index
            foreach ($Array1Element in $Array1) {
                try { 
                    $FoundInArray2 = $myHashTable[$Array1Element.$Property1]
                } catch { 
                    # Supressing the "Index operation failed: the array index evaluated to null." message.
                } 
                if ($FoundInArray2) {
                    $Array1Element | Add-Member -MemberType NoteProperty -Name MatchingObject -Value $FoundInArray2 -Force
                } else {
                    $Array1Element | Add-Member -MemberType NoteProperty -Name MatchingObject -Value 'Not Found' -Force
                }
            }
        }
        Write-Log 'Matching',('{0:N0}' -f $Array1.Count),'records of',"Array1/$Property1",'vs',('{0:N0}' -f $Array2.Count),'records of',"Array2/$Property2" Green,Cyan,Green,Cyan,Green,Cyan,Green,Cyan,Green $LogFile
        Write-Log ' Identified',('{0:N0}' -f ($Array1 | where MatchingObject -ne 'Not Found').Count),'matching records in',
            "$($Duration.Minutes):$($Duration.Seconds):$($Duration.Milliseconds)",'min:sec:ms' Green,Cyan,Green,Cyan,Green $LogFile
    }

    End { $Array1 }
} 

function Invoke-jSearchxRapidAPI {
<#
 .SYNOPSIS
  Function to return job listings from jSearch xRapid API
 
 .DESCRIPTION
  Function to return job listings from jSearch xRapid API
  See https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch
  You'll need to get a (free) account and a (free) subscription.
 
 .PARAMETER Query
  Text string like 'Junior Developer'.
 
 .PARAMETER APIKey
  See https://rapidapi.com/letscrape-6bRBa3QguO5/api/jsearch
  You'll need to get a (free) account and a (free) subscription.
 
 .PARAMETER DatePosted
  Defaults to 'today'.
  Valid values are: 'all','today','3days','week','month'
 
 .PARAMETER ReportFolder
  Folder where this function will save its Excel report.
 
 .EXAMPLE
    $APiKey = Get-SBCredential -UserName 'X-RapidAPI-Key' -CredPath 'C:\Somefolder'
    $JobList = Invoke-jSearchxRapidAPI -Query 'Junior developer' -APIKey $APiKey.GetNetworkCredential().Password -ReportFolder 'D:\Somefolder\Career'
 
 .EXAMPLE
    $APiKey = Get-SBCredential -UserName 'X-RapidAPI-Key' -CredPath 'C:\Somefolder'
    $QueryList = @(
        'Junior Developer'
        'Entry Level Developer'
        'Help Desk Engineer'
    )
    $JobList = foreach ($Query in $QueryList) {
        Invoke-jSearchxRapidAPI -Query $Query -APIKey $APiKey.GetNetworkCredential().Password -ReportFolder 'D:\Somefolder\Career'
    }
 
 .OUTPUTS
  This cmdlet returns a list of found jobs, with full details.
  In addition it produces an Excel file with relevant details.
  See Examples.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 21 Feb 2024
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$Query = 'Junior developer',
        [Parameter(Mandatory=$true)][String]$APIKey,
        [Parameter(Mandatory=$false)][ValidateSet('all','today','3days','week','month')][String]$DatePosted = 'today',
        [Parameter(Mandatory=$false)][String]$ReportFolder = '.\'
    )

    Begin {  }

    Process {  
        Write-Host ' '    
        $FinalQuery = $Query + '&page=1'          # Page to return (each page includes up to 10 results). Allowed values: 1-100.
        $FinalQuery += '&num_pages=20'            # Number of pages to return, starting from page. Allowed values: 1-20.
        $FinalQuery += "&date_posted=$DatePosted" # Find jobs posted within the time you specify. Allowed values: all, today, 3days, week,month.

        $Headers = @{
            'X-RapidAPI-Key'  = $APiKey
            'X-RapidAPI-Host' = 'jsearch.p.rapidapi.com'
        }
        $Uri = "https://jsearch.p.rapidapi.com/search?query=$FinalQuery"
        try {
            $Response = Invoke-WebRequest -Uri $Uri -Method GET -Headers $Headers -EA 1 
            $JobList = ($Response.Content | ConvertFrom-Json).data
            Write-Log 'For the Query',"$Query ($DatePosted)",'got',('{0:N0}' -f $JobList.Count),'jobs' Green,Cyan,Green,Cyan,Green    
            $List4Excel = foreach ($Job in $JobList) {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    Employer = $Job.employer_name
                    State    = $Job.job_state
                    'Experience(Months)' = if ($Job.job_required_experience.required_experience_in_months) {
                        $Job.job_required_experience.required_experience_in_months
                    } else {
                        'Unspecified'
                    }
                    DatePostedEst = [TimeZoneInfo]::ConvertTimeBySystemTimeZoneId(([DateTime]$Job.job_posted_at_datetime_utc).ToUniversalTime(), 'Eastern Standard Time')
                    'Summary' = if ($Job.job_highlights.Qualifications) {
                        $Job.job_highlights.Qualifications -join ', '
                    } else {
                        $Job.job_description
                    }        
                    ApplyLink = $Job.apply_options.apply_link -join ', '
                })
            }
            $FileName = "$ReportFolder\JobList-$Query-$DatePosted($($JobList.Count))$(Get-Date -f 'ddMMMyyyy-HH-mm-ss').xlsx"
            $List4Excel | sort 'Experience(Months)' | Export-Excel $FileName -AutoSize -FreezeTopRowFirstColumn
            Write-Log 'Exported to',$FileName Green,Cyan
            $JobList
        } catch {
            Write-Log 'Error',$_.Exception.Mesasge Magenta,Yellow
        }    
    }

    End {  }
} 

#endregion

#region Networking

function Validate-NameResolution {
<#
 .SYNOPSIS
  Function to validate that a given computer name resolves to the same IP address by all domain controllers
 
 .DESCRIPTION
  Function to validate that a given computer name resolves to the same IP address by all domain controllers
 
 .PARAMETER ComputerName
  One or more computer names
 
 .EXAMPLE
  Validate-NameResolution -ComputerName 'myTestPC'
 
 .EXAMPLE
  $DNSValidationResult = Validate-NameResolution @('comp1','comp2','comp3')
 
 .OUTPUTS
  This cmdlet returns PSCustom Objects, one for each resolved IP address with the following properties/example:
    ComputerName ResolvesTo DNSServer
    ------------ ---------- ---------
    devtestaaav47 10.70.122.134 {DEVaaaDCRWV01.dev.tst.local, DEVaaaDCRWV02.dev.tst.local, tstCJRDCRWV01.tst.local, tstJUNDCRWV01.tst.local...}
    devtestaaav47 10.19.133.168 {DEVCJRDCRWV01.dev.tst.local, tstaaaDCRWV03.tst.local}
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 July 2018
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param([Parameter(Mandatory=$true,ValueFromPipeLineByPropertyName=$true)][String[]]$ComputerName)

    Begin { $DCList = Get-DCList }

    Process {       
        $myOutput = foreach ($Computer in $ComputerName) {
            $NameResolutionList = foreach ($DC in ($DCList | where { $_.Forest })) { Resolve-DnsName -Name $Computer -Server $DC.Name | 
                select @{n='ComputerName';e={$_.Name}},Type,TTL,IPAddress,@{n='DNSServer';e={$DC.Name}} | sort IPAddress
            }

            if (($Groups = $NameResolutionList | group IPAddress).Count.Count -gt 1) { # Yes .Count twice, not a typo :)
                Write-Log 'Identified name resolution inconsistency:',$Computer,'resolves to',(($NameResolutionList.IPAddress | select -Unique) -join ', ') Magenta,Yellow,Magenta,Yellow
            } else {
                Write-Log 'All DNS servers resolved',$Computer,'to the same IP address',($NameResolutionList.IPAddress | select -Unique) Green,Cyan,Green,Cyan
            }  
                          
            $Groups | foreach {
                [PSCustomObject][Ordered]@{
                    ComputerName = $Computer
                    ResolvesTo   = $_.Name
                    DNSServer    = $_.Group.DNSServer
                }
            }
        }
    }

    End { $myOutput }
} 
  
function Test-SBNetConnection {
<#
 .SYNOPSIS
  Function to test open TCP ports
 
 .DESCRIPTION
  Function to test open TCP ports
  Compared to the Test-NetConnection native function of the NetTCPIP module,
  this command is much faster particularly when it comes to closed ports.
  In addition, the timeout value is adjustable by using the TimeoutSec parameter.
 
 .PARAMETER ComputerName
  This parameter accepts a computer name or IPv4 Address.
  If a computer name is provided, the function attempts to resolve it to an IP address
 
 .PARAMETER PortNumber
  This is one or more TCP port number(s). Valid values are from 1 to 65535.
 
 .PARAMETER PortGroup
  This is ignored if PortNumber parameter is provided.
  This accepts one of the currently supported port groups:
  General (default): Ports 111,135,22,3389,25,80,5985,5986
    Ports 111,135 help identify the system as a Linux or Windows system respectively
    Ports 22,3389 are Linux/SSH and Windows/RDP ports
    Ports 25,80 are SMTP and HTTP ports
    Ports 5895,5986 are PowerShell/WinRM ports over HTTP and HTTPS respectively
  DC: Ports 389,626,88,464,3268,3269
    Ports 389,626 for LDAP and LDAP over SSL
    Ports 88,464 for Kerberos and Kerberos over SSL
    Ports 3268,3269 for GC (Global Catalog) and GC over SSL
 
 .PARAMETER TimeoutSec
  Time out in seconds
  This defaults to 1, and accepts valid values from 1 to 300 seconds.
 
 .OUTPUTS
  The script outputs a PS array of objects, one for each open port including the following properties/example:
    ComputerName RemotePort PortDescription TcpTestSucceeded
    ------------ ---------- --------------- ----------------
    192.168.124.12 88 Kerberos False
    192.168.124.12 135 RPC (Remote Procedure Call) True
    192.168.124.12 389 LDAP False
    192.168.124.12 636 LDAP SSL False
    192.168.124.12 3268 Global Catalog LDAP False
    192.168.124.12 3269 Global Catalog LDAP SSL False
 
 .EXAMPLE
  Test-SBNetConnection -ComputerName 10.127.73.195
 
 .EXAMPLE
  Test-SBNetConnection -ComputerName $Env:ComputerName -PortGroup DC -WA 0
 
 .EXAMPLE
  $Cred = Get-SBCredential 'domain\admin'
  $Session = New-PSSession -ComputerName 'Remote1' -Credential $Cred
  Export-SessionCommand -Command Test-SBNetConnection -Session $Session
  $IP = (Resolve-DnsName -Name 'Remote2' -Type A).IPAddress
  Invoke-Command -Session $Session -ScriptBlock {
    Test-SBNetConnection -ComputerName $Using:IP -Port 1234
  }
 
  This example illustrates using functions of the AZSBTools PS module to test TCP port connectivity
  from 'Remote1' computer to 'Remote2' computer over TCP port 1234, where 'Remote1' has PS version 2
  and does not have the cmdlets Test-NetConnection or Resolve-DNSName, or the underlying .NET libraries.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 18 October 2017
  v0.2 - 5 January 2018 - Fixed bug to account for computers that resolve to more than 1 IP
  v0.3 - 20 December 2019 - Added code to exclude IPv6 addresses
  v0.4 - 10 September 2021 - Made this function work with PS version 2
  v0.5 - 8 November 2021 - Added PortGroup Parameter
  v0.6 - 24 October 2022 - Update 'DC' PortGroup, added PortDescription to the output
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$ComputerName,
        [Parameter(Mandatory=$false)][uInt16[]]$PortNumber,
        [Parameter(Mandatory=$false)][ValidateSet('General','DC')][String]$PortGroup = 'General',
        [Parameter(Mandatory=$false)][ValidateRange(1,300)][Int16]$TimeoutSec = 1
    )
    
    Begin { 
        $PortList = @(
            New-Object -TypeName PSObject -Property ([Ordered]@{ PortGroupName = 'General'; PortGroupPortList = @(22,25,80,111,135,3389,5985,5986) })
            New-Object -TypeName PSObject -Property ([Ordered]@{ PortGroupName = 'DC'; PortGroupPortList = @(88,135,389,445,464,636,3268,3269,9389) }) 
         )
    }

    Process{
        if (-not ($IPv4Address = $Computername -as 'IPAddress')) {
            try {
                [IPAddress[]]$IPv4Address = (Resolve-DnsName -Name $ComputerName -Type A -EA 1).IPAddress
            } catch {
                Write-Warning "Unable to resolve computer name '$ComputerName'"
                Write-Warning $_.Exception.Message
            }        
        }
        if ($IPv4Address) {
            foreach ($IP in $IPv4Address.IPAddressToString) {
                if (-not $PortNumber) {
                    $PortNumber = ($PortList | where PortGroupName -EQ $PortGroup).PortGroupPortList
                }
                foreach ($Item in $PortNumber) {
                    $TCP = New-Object System.Net.Sockets.TcpClient
                    $AsyncResult = $TCP.BeginConnect("$IP","$Item",$null,$null)
                    $PortOpen = $false
                    if ($AsyncResult.AsyncWaitHandle.WaitOne($TimeoutSec*1000,$false)) {
                        try {
                            $TCP.EndConnect($AsyncResult)
                            $PortOpen = $true
                        } catch {
                            Write-Warning $_.Exception.InnerException 
                        }                        
                    } else {
                        Write-Warning "TCP connect to $($IP):$Item timed out ($TimeoutSec sec)"
                    } # if $AsyncResult
                    $TCP.Close()

                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        ComputerName     = $IP
                        RemotePort       = $Item
                        PortDescription  = ($GeneralPortList | where { $_.Port -EQ $Item -and $_.Protocol -eq 'TCP'}).Name
                        TcpTestSucceeded = $PortOpen
                    }) 

                } # foreach port
            } # foreach IP
        } # if $IPv4Address
    } # Process

    End { }
}

function Convert-IpAddressToMaskLength {
<#
 .SYNOPSIS
  Function to return the length of an IPv4 subnet mask
 
 .DESCRIPTION
  Function to return the length of an IPv4 subnet mask
  For example, 255.255.255.0 will return 24
 
 .PARAMETER DottedDecimalIP
  Dotted IPv4 address (subnet mask) such as 255.255.224.0
 
 .EXAMPLE
  Convert-IpAddressToMaskLength -DottedDecimalIP 255.255.255.0
  This will return 24
 
 .EXAMPLE
  Convert-IpAddressToMaskLength 255.0.0.0,255.192.0.0,255.255.255.224
  This will return 8, 10, and 27
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [String[]]$DottedDecimalIP
    )

    Begin {  }

    Process{
        foreach ($Address in $DottedDecimalIP) {
            $Result = 0
            [IPAddress]$IPv4 = $Address
            foreach ($Octet in ($IPv4.IPAddressToString.Split('.'))) {
                while ($Octet -ne 0) {
                    $Octet = ($Octet -shl 1) -band [byte]::MaxValue
                    $Result ++
                } # while
            } # foreach
            $Result        
        } # foreach
    } # Process

    End {  }
}

function Convert-MaskLengthToIpAddress {
<#
 .SYNOPSIS
  Function to return the IPv4 subnet mask provided a mask length
 
 .DESCRIPTION
  Function to return the IPv4 subnet mask provided a mask length
  For example, 10 will return 255.192.0.0
 
 .PARAMETER MaskLength
  IPv4 subnet mask length. Valid values are 1 to 32
 
 .EXAMPLE
  Convert-MaskLengthToIpAddress -MaskLength 12
  This will return 255.240.0.0
 
 .EXAMPLE
  8,10,20,27 | Convert-MaskLengthToIpAddress
  This will return
    255.0.0.0
    255.192.0.0
    255.255.240.0
    255.255.255.224
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,
                   ValueFromPipeLine=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [ValidateRange(1,32)]
            [UInt32[]]$MaskLength
    )

    Begin {    }

    Process{
        foreach ($Item in $MaskLength) { 
            if ($Item -lt 9) {
                "$((1..$Item | % { [math]::Pow(2,8-$_) } | measure -Sum).Sum).0.0.0"
            } elseif ($Item -lt 17) {
                "255.$((1..$($Item-8) | % { [math]::Pow(2,8-$_) } | measure -Sum).Sum).0.0"
            } elseif ($Item -lt 25) {
                "255.255.$((1..$($Item-16) | % { [math]::Pow(2,8-$_) } | measure -Sum).Sum).0"
            } else {
                "255.255.255.$((1..$($Item-24) | % { [math]::Pow(2,8-$_) } | measure -Sum).Sum)"
            }            
        } # foreach
    } # Process

    End {    }
}

function Get-IPv4Details {
<#
 .SYNOPSIS
  Function to return the details of a given IPv4 address
 
 .DESCRIPTION
  Function to return the details of a given IPv4 address
 
 .PARAMETER CIDRAddress
  IPv4 address in CIDR notation such as 11.12.13.64/27
  Part of the 'CIDR' Parameter Set.
  When provided, IPAddress and SubnetMask are not required
 
 .PARAMETER IPAddress
  Dotted decimal IPv4 address such as 11.12.13.14
  Part of the 'Mask' Parameter Set.
 
 .PARAMETER SubnetMask
  Dotted decimal IPv4 subnet mask such as 255.255.0.0
  Part of the 'Mask' Parameter Set.
 
 .OUTPUTS
  This function returns a PS object with the following properties (and example):
    IPDottedDecimal : 10.120.30.11
    IPDecimal : 186546186
    IPBitLength : 12
    IPDottedBinary : 00001010.01111000.00011110.00001011
    MaskDottedDecimal : 255.255.240.0
    MaskDecimal : 15794175
    MaskBitLength : 20
    MaskDottedBinary : 11111111.11111111.11110000.00000000
    NetDottedDecimal : 10.120.16.0
    NetDecimal : 1079306
    NetCIDR : 255.255.240.0/20
    NetDottedBinary : 00001010.01111000.00010000.00000000
    HostDottedDecimal : 0.0.14.11
    HostDecimal : 185466880
    HostDottedBinary : 00000000.00000000.00001110.00001011
    FirstSubnetIP : 10.120.16.1
    LastSubnetIP : 10.120.31.254
    SubnetMaximumHosts : 4094
 
 .EXAMPLE
  Get-IPv4Details -IPAddress 10.120.30.11 -SubnetMask 255.255.240.0
 
 .EXAMPLE
  Get-IPv4Details -CIDRAddress 10.120.30.64/27
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
  v0.2 - 1 July 2019 - updates to properly address /32 mask
  v0.3 - 12 February 2020 - Added Parameter Set to accept IP input in CIDR format
        Known issue: Extreme cases are not detailed properly such as /31 and /32 mask
  v0.4 - 18 April 2020 - Updated to not Terminate upon input error, so it can be used to detect valid input CIDR format
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,ParameterSetName='CIDR')][String]$CIDRAddress,
        [Parameter(Mandatory=$true,ParameterSetName='Mask')][Alias('IPv4','IP')][IPAddress]$IPAddress,
        [Parameter(Mandatory=$true,ParameterSetName='Mask')][Alias('Mask','NetMAsk')][IPAddress]$SubnetMask
    )

    Begin { 
        # Extract $IPAddress, $MaskLength, and $SubnetMask from $CIDRAddress if provided
        $Go = $true
        if ($CIDRAddress) {
            if ($CIDRAddress -match '/') {
                if ($CIDRAddress.Split('/').Count -eq 2) {
                    $MaskLength = $CIDRAddress.Split('/')[1] -as [Int]
                    if ($MaskLength -gt 32 -or $MaskLength -lt 0) {
                        Write-Verbose "Get-IPv4Details Error: CIDRAddress '$CIDRAddress' must have a mask length between 0 and 32"
                        $Go = $false
                    }
                    [IPAddress]$SubnetMask = Convert-MaskLengthToIpAddress -MaskLength $MaskLength
                    if (-not ($IPAddress = $CIDRAddress.Split('/')[0] -as [IPAddress])) {
                        Write-Verbose "Get-IPv4Details Error: CIDRAddress '$CIDRAddressv' must be in the format DottedDecimalIPv4Address/MaskLength as in 10.1.2.0/24"
                        $Go = $false
                    } else {
                        [IPAddress]$IPAddress = $CIDRAddress.Split('/')[0] -as [IPAddress]
                    }
                } else {
                    Write-Verbose "Get-IPv4Details Error: CIDRAddress '$CIDRAddressv' must be in the format DottedDecimalIPv4Address/MaskLength as in 10.1.2.0/24" 
                    $Go = $false
                }
            } else {
                Write-Verbose "Get-IPv4Details Error: CIDRAddress '$CIDRAddressv' must be in the format DottedDecimalIPv4Address/MaskLength as in 10.1.2.0/24" 
                $Go = $false
            } 
        } 
    }

    Process{
        if ($Go) {
            if (-not ($MaskLength)) {
                $MaskLength = 0
                foreach($Octet in ($SubnetMask.GetAddressBytes())) {
                    while ($Octet -ne 0) { $Octet = $Octet*2 -band 255; $MaskLength ++ }
                }
            } 
            $IPLength = 32 - $MaskLength
            $IPBinary = $IPAddress.GetAddressBytes()  | % { [Convert]::ToString($_,2).PadLeft(8,'0') }
            $MaskBinary = $SubnetMask.GetAddressBytes() | % { [Convert]::ToString($_,2).PadLeft(8,'0') }
            $NetAddress = [IPAddress]($IPAddress.Address -band $SubnetMask.Address)
            $NetBinary = $NetAddress.GetAddressBytes() | % { [Convert]::ToString($_,2).PadLeft(8,'0') }
            $Temp = foreach ($Octet in $MaskBinary) { 0..7 | % { if ($Octet[$_] -eq '1') { '0' } else { '1' } } }
            $MaskMirrorBinary = @(); 0,8,16,24 | % {$MaskMirrorBinary += ($Temp -join '').Substring($_,8)  }
            [IPAddress]$MaskMirror = ($MaskMirrorBinary | % { [Convert]::ToInt32($_,2) }) -join '.'
            $HostAddress = [IPAddress]($IPAddress.Address -band $MaskMirror.Address)
            $HostBinary = $HostAddress.GetAddressBytes() | % { [Convert]::ToString($_,2).PadLeft(8,'0') }
            $FirstSubnetIP = Next-IP -IPAddress $NetAddress.IPAddressToString
            if (([Math]::Pow(2,$IPLength) - 2) -lt 0) {
                $LastSubnetIP = $FirstSubnetIP
            } else {
                $LastSubnetIP = Next-IP -IPAddress $NetAddress.IPAddressToString -Increment ([Math]::Pow(2,$IPLength) - 2)
            }
        
            [PSCustomObject]@{
                IPDottedDecimal    = $IPAddress.IPAddressToString
                IPDecimal          = $IPAddress.Address
                IPBitLength        = $IPLength
                IPDottedBinary     = $IPBinary -join '.'

                MaskDottedDecimal  = $SubnetMask.IPAddressToString
                MaskDecimal        = $SubnetMask.Address
                MaskBitLength      = $MaskLength
                MaskDottedBinary   = $MaskBinary -join '.'

                NetDottedDecimal   = $NetAddress.IPAddressToString
                NetDecimal         = $NetAddress.Address
                NetCIDR            = "$($NetAddress.IPAddressToString)/$MaskLength"
                NetDottedBinary    = $NetBinary -join '.'

                HostDottedDecimal  = $HostAddress.IPAddressToString
                HostDecimal        = $HostAddress.Address
                HostDottedBinary   = $HostBinary -join '.'

                FirstSubnetIP      = $FirstSubnetIP
                LastSubnetIP       = $LastSubnetIP
                SubnetMaximumHosts = if (([Math]::Pow(2,$IPLength) - 2) -lt 0) { 0 } else { ([Math]::Pow(2,$IPLength) - 2) }
            }
        }
    }

    End { }
}

function Next-IP {
<#
 .SYNOPSIS
  Function to return an IP address relative to the input IP address
 
 .DESCRIPTION
  Function to return an IP address relative to the input IP address
 
 .PARAMETER IPAddress
  Dotted IPv4 address such as 10.12.13.15
 
 .PARAMETER Increment
  A whole number between -4294967294 and 4294967295
  For example when using 1, the function will return the next IP address
  This defaults to 1
 
 .EXAMPLE
  Next-IP -IPAddress 10.10.10.11 -Increment 1
    Will return 10.10.10.12
 
 .EXAMPLE
  Next-IP -IPAddress 201.120.252.253 -Verbose
    Will return 201.120.252.254
 
 .EXAMPLE
  Next-IP -IPAddress 201.120.252.253 -Increment 100 -Verbose
    Will return 201.120.253.97
 
 .EXAMPLE
  Next-IP -IPAddress 201.120.252.253 -Increment -500 -Verbose
    Will return 201.120.251.9
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][Alias('IPv4','IP')][IPAddress]$IPAddress,
        [Parameter(Mandatory=$false)][ValidateRange(-4294967294,4294967295)][Int64]$Increment = 1
    )

    Begin { }

    Process{
        $DecimalArray = $IPAddress.GetAddressBytes()
        [Array]::Reverse($DecimalArray)
        $Decimal = ([IPAddress]($DecimalArray -join '.')).Address
        $Decimal = $Decimal + $Increment
        if ($Decimal -le 4294967295 -and $Decimal -ge -4294967294) {
            $DecimalArray = ([IPAddress]$Decimal).GetAddressBytes()
            [Array]::Reverse($DecimalArray)
            $DecimalArray -join '.'  
        } else {
            Write-Verbose "Cannot increment/decrement the provided IP addresses '$($IPAddress.IPAddressToString)' by '$Increment'"
            Write-Verbose "The resulting address '$Decimal' would exceed a 32-bit address (-4294967294 to 4294967295)"
        }      
    }

    End { }
}

function Test-SameSubnet {
<#
 .SYNOPSIS
  Function to compare a pair of IPv4 addresess and their subnet masks and
  identify if they're on the same subnet or not
 
 .DESCRIPTION
  Function to compare a pair of IPv4 addresess and their subnet masks
  If the 2 IPs are on the same subnet, the function retirns the subnet ID in CIDR format,
  otherwise it returns False
 
 .PARAMETER IP1
  Dotted decimal IPv4 address such as 11.12.13.14
 
 .PARAMETER Mask1
  Dotted decimal IPv4 subnet mask such as 255.255.0.0
 
 .PARAMETER IP2
  Dotted decimal IPv4 address such as 11.12.13.15
 
 .PARAMETER Mask2
  Dotted decimal IPv4 subnet mask such as 255.255.240.0
 
 .EXAMPLE
  Test-SameSubnet -IP1 10.124.170.1 -Mask1 255.255.252.0 -IP2 10.124.170.2 -Mask2 255.255.252.0
  This will return 10.124.168.0/22
 
 .EXAMPLE
  Test-SameSubnet -IP1 10.124.170.117 -Mask1 255.255.255.240 -IP2 10.124.170.2 -Mask2 255.255.255.240
  This will return False
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][IPAddress]$IP1,
        [Parameter(Mandatory=$true)][IPAddress]$Mask1,
        [Parameter(Mandatory=$true)][IPAddress]$IP2,
        [Parameter(Mandatory=$true)][IPAddress]$Mask2
    )

    Begin { }

    Process{
        $Network1 = (Get-IPv4Details -IPAddress $IP1 -SubnetMask $Mask1).NetDecimal
        $Network2 = (Get-IPv4Details -IPAddress $IP2 -SubnetMask $Mask2).NetDecimal
        if ($Network1 -eq $Network2) { 
            [IPAddress]$IP = 0
            $IP.Address = $Network1
            "$($IP.IPAddressToString)/$(Convert-IpAddressToMaskLength -DottedDecimalIP $Mask1)"
        } else {
            $false
        }
    }

    End { }
}

function Get-IPv4Summary {
<#
 .SYNOPSIS
  Function to return IPv4 information of enabled network adapters
 
 .DESCRIPTION
  Function to return IPv4 information of enabled network adapters
  This function requires the Convert-IpAddressToMaskLength function
  available in the SB-Tools modules in the PowerShell Gallery
 
 .PARAMETER ServiceName
  This is set to 'netvsc' by default
  To see available Service Names use:
  Get-WmiObject -Class Win32_NetworkAdapterConfiguration | FT Description,Index,IPAddress,ServiceName,DefaultIPGateway -a
 
 .EXAMPLE
  Get-IPv4Summary -Verbose
  
 .EXAMPLE
  Get-IPv4Summary -ServiceName 'vmsmp' -Verbose
  
 .OUTPUTS
  This function/cmdlet returns a PS object for each netvsc NIC with the following properties/example:
    IPv4Address : 192.168.124.44
    IPv4Subnet : 255.255.255.0
    MaskLength : 24
    DefaultGateway : 192.168.124.1
    DNSServers : {8.8.8.8,4.4.4.4}
    Description : Ethernet Network Adapter
    DHCPEnabled : False
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param([Parameter(Mandatory=$false)][String]$ServiceName = 'netvsc') # 'vmsmp'

    Begin { }

    Process{
        $AdapterList = Get-WmiObject -Class Win32_NetworkAdapterConfiguration -Filter "servicename = ""$ServiceName""" | ? { $_.IPAddress }
        if ($AdapterList) {
            $myOutput = foreach ($NIC in $AdapterList) {
                Write-Verbose "Get-IPv4Summary: Processing adapter '$($NIC.Description)'"
                if (($NIC.IPSubnet -match '\.').Count -eq 1) { 
                    $IPv4Subnet = $NIC.IPSubnet -match '\.' | select -First 1 
                    $MaskLength = Convert-IpAddressToMaskLength $IPv4Subnet 
                } else {
                    $IPv4Subnet = $NIC.IPSubnet -match '\.'
                    $MaskLength = $IPv4Subnet | % {Convert-IpAddressToMaskLength $_}
                }
                if (($NIC.IPAddress -match '\.').Count -eq 1) { 
                    $IPv4Address = $NIC.IPAddress -match '\.' | select -First 1 
                } else {
                    $IPv4Address = $NIC.IPAddress -match '\.'
                }
                if (($NIC.DefaultIPGateway -match '\.').Count -eq 1) { 
                    $DefaultGateway = $NIC.DefaultIPGateway -match '\.' | select -First 1 
                } else {
                    $DefaultGateway = $NIC.DefaultIPGateway -match '\.'
                }
                if (($NIC.DNSServerSearchOrder -match '\.').Count -eq 1) { 
                    $DNSServers = $NIC.DNSServerSearchOrder -match '\.' | select -First 1 
                } else {
                    $DNSServers = $NIC.DNSServerSearchOrder -match '\.'
                }
                [PSCustomObject]@{
                    IPv4Address    = $IPv4Address
                    IPv4Subnet     = $IPv4Subnet
                    MaskLength     = $MaskLength
                    DefaultGateway = $DefaultGateway
                    DNSServers     = $DNSServers
                    Description    = $NIC.Description
                    DHCPEnabled    = $NIC.DHCPEnabled
                } # PSCustomObject
            } # foreach $NIC
            $myOutput
        } else {
            Write-Verbose "Bad ServiceName '$ServiceName' provided, available Service Names are: $( Get-WmiObject -Class Win32_NetworkAdapterConfiguration | FT description,Index,IPAddress,ServiceName,DefaultIPGateway -a | Out-String)"
        } 
    }

    End { }
}

function Get-FTPFileList {
<#
 .SYNOPSIS
  Function to get file list from FTP site
 
 .DESCRIPTION
  Function to get file list from FTP site
 
 .PARAMETER FTPURL
  For example: ftp://site.domain.com
  This is the URL to the FTP site
 
 .PARAMETER Port
  Optional parameter that defaults to port 21
 
 .PARAMETER Cred
  PSCredential object obtained via Get-Credential or Get-SBCredential
  It is used to authenticate to the FTP site.
  For anonymous FTP create a credential that has the name 'anonymous' and any password
 
 .PARAMETER Recurse
  Optional switch parameter. When set to True, the function will return all files and subfolders
 
 .EXAMPLE
  Get-FTPFileList -FTPURL ftp://123.45.56.78 -Cred (Get-SBCredential 'samb@mysite.ftpdomain.com') | FT -a
  This example list the files listed from the given FTP site
  
 .EXAMPLE
  $myFileList = Get-FTPFileList -FTPURL ftp://mysite.ftpsite.com -Cred (Get-SBCredential 'samb@mysite.ftpdomain.com') -Recurse
  $FileOnlyList = $myFileList | where Type -EQ 'File'
  Write-log 'File and directory listing contains', $myFileList.Count, 'items' Green,Cyan,Green
  Write-log ' including', ($myFileList.Count-$FileOnlyList.Count), 'directories' Green,Cyan,Green
  Write-log ' and', $FileOnlyList.Count, 'files' Green,Cyan,Green
  Write-log 'Calculating total size...' Green -noNew
  $SizeBytes = ($myFileList | measure SizeBytes -Sum).Sum
  Write-log $SizeBytes, 'bytes', "($([Math]::Round($SizeBytes/1GB,2)) GB)" Cyan,Green,Cyan
 
 
 .OUTPUTS
  The function returns an object for each file/directory found with the following properties/example:
    Type Name Path SizeBytes Date Permission
    ---- ---- ---- --------- ---- ----------
    File 8xxxx5 ftp://mysite.ftpsite.com//8xxxx5/8xxxx5 47 12/27/2014 12:00:00 AM -r--r--r--
    File 8xxxx5.zip ftp://mysite.ftpsite.com//8xxxx5/8xxxx5.zip 61728 12/27/2014 12:00:00 AM -r--r--r--
    Directory June Amazon ftp://mysite.ftpsite.com//Amazon/June Amazon 0 6/9/2015 12:00:00 AM drwxr-xr-x
    File MANIFEST.txt ftp://mysite.ftpsite.com//Amazon/MANIFEST.txt 636 3/18/2015 12:00:00 AM -r--r--r--
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  17 October 2018 - v0.1
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,HelpMessage='Such as ftp://site.domain.com')][String]$FTPURL,  
        [Parameter(Mandatory=$false)][Int]$Port = 21,
        [Parameter(Mandatory=$true)][PSCredential]$Cred,
        [Parameter(Mandatory=$false)][Switch]$Recurse = $false
    )

    Begin {
        # Compile URL from FTPURL and Port
        if (($FTPURL -as [system.uri]).AbsoluteUri) {
            $Temp = $FTPURL -as [system.uri]
            [system.uri]$FTPURL = "ftp://$($Temp.Host):$Port$($Temp.LocalPath)"
            Write-Log 'Get-FTPFileList: Processing URL:',$FTPURL.AbsoluteUri Green,Cyan
        } else {
            Write-Log 'Get-FTPFileList: Error: bad FTPURL received:',$FTPURL,"expecting FTP URL such as ftp://site.domain.com" Magenta,Yellow,Magenta
            break
        }
    }

    Process {
        try {
        
            $FTPRequest = [System.Net.FtpWebRequest]::Create($FTPURL)
            $FTPRequest.Credentials = $Cred
            $FTPRequest.Method = [System.Net.WebRequestMethods+Ftp]::ListDirectoryDetails 
            $FTPResponse = $FTPRequest.GetResponse() 
            $ResponseStream = $FTPResponse.GetResponseStream()
            $StreamReader = New-Object System.IO.StreamReader $ResponseStream  
            $FileList = New-Object System.Collections.ArrayList
            While ($File = $StreamReader.ReadLine()) { [void]$FileList.add($File) }

        } catch {
            Write-Log $_.Exception.InnerException.Message Yellow
            break
        }

        $StreamReader.close()
        $ResponseStream.close()
        $FTPResponse.Close()

        $myOutput = foreach ($FileLine in $FileList) {
            $Name = $FileLine.Substring(49,$FileLine.Length-49)
            [PSCustomObject][Ordered]@{
                Type       = $(if ($FileLine.Substring(0,1) -eq 'd') { 'Directory' } else { 'File' })
                Name       = $Name
                Path       = "$($FTPURL.AbsoluteUri)/$Name"
                SizeBytes  = $FileLine.Substring(20,15).Trim() -as [Int64]
                Date       = $FileLine.Substring(35,13) -as [DateTime]
                Permission = $FileLine.Substring(0,10) -as [String]
            }
        }

        if ($Recurse) {
            foreach ($Directory in ($myOutput | where Type -EQ 'Directory')) { 
                Get-FTPFileList -FTPURL $Directory.Path -Cred $Cred 
            }
        }
    } 

    End { $myOutput }
}

function Listen-Port {
<#
 .SYNOPSIS
  Function to listen on a given TCP port
 
 .DESCRIPTION
  Function to listen on a given TCP port
  This is typically useful for testing firewall rules
  This port listener will auto-shutdown in 1 minute after it's invoked.
  This duration can be increased via a parameter up to 1440 minutes (1 day)
 
 .PARAMETER TCPPort
  TCP port number - required
 
 .PARAMETER IPAddress
  Optional parameter for the computer IPv4 address
 
 .PARAMETER AddFirewallRule
  Optional parameter to create a windows firewall rule to allow testing that TCP port listener
  The script will remove this temporary rule upon its completion
 
 .PARAMETER AutoShutdownMinutes
  Optional paramter that defaults to 1 minute
  Can be as high as 1440 minutes (1 day)
 
 .EXAMPLE
  Listen-Port 12345
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 19 June 2019
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][ValidateRange(0,65535)][Int32]$TCPPort,
        [Parameter(Mandatory=$false)][String]$IPAddress = 'any',
        [Parameter(Mandatory=$false)][ValidateRange(1,1440)][Int16]$AutoShutdownMinutes = 1,
        [Parameter(Mandatory=$false)][Switch]$AddFirewallRule =$true
    )

    Begin {
        if ($AddFirewallRule) {
            Write-Log 'Adding',"Listen-Port-$TCPPort",'firewall rule' Green,Cyan,Green -NoNewLine
            try {
                $ParameterSet = @{
                    DisplayName = "Listen-Port-$TCPPort" 
                    Direction   = 'inbound' 
                    LocalPort   = $TCPPort 
                    Protocol    = 'TCP' 
                    Action      = 'Allow' 
                    Enabled     = 'True' 
                    Profile     = 'Any' 
                    ErrorAction = 'Stop'
                
                }
                $Rule = New-NetFirewallRule @ParameterSet
                Write-Log 'done' DarkYellow
             } catch {
                Write-Log 'failed' Magenta
                Write-Log $_.Exception.Message Yellow  
             }
         }

        $PingingJob = Start-Job -ScriptBlock {
            0..($Using:AutoShutdownMinutes*6+4) | foreach { 
                Test-SBNetConnection -ComputerName $env:COMPUTERNAME -Port $Using:TCPPort -EA 0 -WA 0 
                Start-Sleep -Seconds 10 
            }
        } 
    }

    Process{
        $IPEndPoint  = New-Object System.Net.IPEndPoint ([IPAddress]::$IPAddress, $TCPPort)    
        $TcpListener = New-Object System.Net.Sockets.TcpListener $IPEndPoint
        $TcpListener.Start()   
        $StartTime = Get-Date 
        $Running = $true
        try {
            While ($Running) {
                if (-not $TcpListener.Pending()) { Start-Sleep -Seconds 1 }
                $TCPClient = $TcpListener.AcceptTcpClient()
                $TimeRemaining =  New-TimeSpan -Start (Get-Date) -End $StartTime.AddMinutes($AutoShutdownMinutes)
                if ($TimeRemaining -le 0) { 
                    $Running = $false 
                    Write-Log 'Auto-shutdown duration exceeded, shutting down..' Green
                } else {
                    Write-Log 'Listening on port',"$TCPPort,",'auto-shutdown in',"$($TimeRemaining.Hours):$($TimeRemaining.Minutes):$($TimeRemaining.Seconds)",'''hh:mm:ss''' Green,Cyan,Green,Yellow,Green
                }
                $TCPClient.Close()
            }
        } catch {
            Write-Log $_.Exception.Message Yellow       
        } finally {
            $TcpListener.Stop()            
        }
    }

    End { 
        if ($AddFirewallRule) { Remove-NetFirewallRule -DisplayName "Listen-Port-$TCPPort" -EA 0 }
        $PingingJob | Remove-Job -Force
    }

}

function Get-MyWANIP {
<#
 .SYNOPSIS
  Function to return current WAN IP address
 
 .DESCRIPTION
  Function to return current WAN IP address
 
 .PARAMETER Source
  One or more URLs
  This is an optional parameter. These URLs will be queried for WAN IP.
 
 .EXAMPLE
  Get-MyWANIP
 
 .OUTPUTS
  This cmdlet returns a System.Net.IPAddress object such as:
    Address : 1132553623
    AddressFamily : InterNetwork
    ScopeId :
    IsIPv6Multicast : False
    IsIPv6LinkLocal : False
    IsIPv6SiteLocal : False
    IsIPv6Teredo : False
    IsIPv4MappedToIPv6 : False
    IPAddressToString : 151.101.129.67
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 December 2019
  v0.2 - 12 April 2020 - Added -UseBasicParsing Switch to Invoke-WebRequest Cmdlet call
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$Source = @(
            'http://ipinfo.io/ip'
            'http://ifconfig.me/ip'
            'http://icanhazip.com'
            'http://ident.me'
            'http://smart-ip.net/myip'
        )    
    )

    Begin { }

    Process {      
        Remove-Variable FoundIP -Force -EA 0 
        foreach ($SourceURL in $Source) {
            $FoundIP = (Invoke-WebRequest -uri $SourceURL -EA 0 -UseBasicParsing).Content
            $FoundIP = $FoundIP.Trim()
            if ($FoundIP -as [IPAddress]) { 
                $FoundIP = [IPaddress]$FoundIP
                break
            }
        }
    }

    End { $FoundIP }
} 

function Get-RDPDetails {
<#
 .SYNOPSIS
  Function to return details on Terminal Services process
 
 .DESCRIPTION
  Function to return details on Terminal Services process including process ID and listening port
 
 .EXAMPLE
  Get-RDPDetails -Verbose
 
 .OUTPUTS
  If there are established RDP sessions this function will return a PS object for each session like:
    ComputerName : myComputerName
    ProcessId : 1160
    Port : 3389
    RemoteAddress : 123.23.34.45
    RemotePort : 56916
    StartTime : 4/18/2020 6:31:32 AM
    DurationMinutes : 105
     
  If there is no established RDP sessions this function will return a PS object like:
    ComputerName : myComputerName
    ProcessId : 1160
    Port : 3389
 
  If Terminal Service is disabled this function will return no output
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 18 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param()

    Begin {  }

    Process {    

        if ($TermId = (Get-SBWMI -Class Win32_TerminalService).ProcessId) {
            Write-Verbose "Identified 'TerminalService' Process ID '$TermId' on computer '$env:COMPUTERNAME'"
            Write-Verbose (Get-Process -Id $TermId | FL * | Out-String).Trim()
            try {
                $ConnectionList = Get-NetTCPConnection -OwningProcess $TermId -EA 1 
                if ($Established = $ConnectionList | where State -EQ Established ) {
                    $Established | foreach {
                        [PSCustomObject][Ordered]@{
                            ComputerName    = $env:COMPUTERNAME
                            ProcessId       = $TermId
                            Port            = $ConnectionList.LocalPort | select -First 1
                            RemoteAddress   = $_.RemoteAddress
                            RemotePort      = $_.RemotePort
                            StartTime       = $_.CreationTime
                            DurationMinutes = '{0:N0}' -f (New-TimeSpan -Start $_.CreationTime -End (Get-Date)).TotalMinutes
                        }
                    }
                } else {
                    [PSCustomObject][Ordered]@{
                        ComputerName = $env:COMPUTERNAME
                        ProcessId    = $TermId
                        Port         = $ConnectionList.LocalPort | select -First 1
                    }
                }
            } catch {
                Write-Verbose "TerminalService is disabled (not listening) on computer '$env:COMPUTERNAME'"
            }
        } else {
            Write-Warning 'Win32_TerminalService not found!!??'
        } 

    }

    End {  }
} 

function Sort-IPList {
<#
 .SYNOPSIS
  Function to sort a list of IPv4 addresses
 
 .DESCRIPTION
  Function to sort a list of IPv4 addresses
 
 .PARAMETER IPAddress
  Required one or more IPv4 address in dotted decomal format such as 1.2.3.4
 
 .EXAMPLE
  Sort-IPList @('1.2.3.4','2.3.4.5','10.11.2.13') -Verbose
 
 .OUTPUTS
  Sorted list of IPv4 addresses
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 10 May 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][IPAddress[]]$IPAddress
    )

    Begin { 
        Write-Verbose 'Sort-IPList: Input received:'
        Write-Verbose ($IPAddress -join ', ')
    }

    Process {      
        
        if ($IPAddress) {
            $SortedList = foreach ($IP in $IPAddress) { 
                [PSCustomObject][Ordered]@{
                    Address           = $IP.Address
                    IPAddressToString = $IP.IPAddressToString
                    IPDottedBinary    = (Get-IPv4Details -IPAddress $IP.IPAddressToString -SubnetMask 255.255.255.255).IPDottedBinary     
                }
            }
        } else {
            Write-Log 'Sort-IPList Error: No input provided for parameter (IPAddress)' Yellow
        } 
        $SortedList = $SortedList | sort IPDottedBinary
        Write-Verbose ($SortedList | FT -a | Out-String)       

    }

    End { $SortedList.IPAddressToString }
} 

function New-BlockList {
<#
 .SYNOPSIS
  Function to return a list of IPv4 address ranges that includes the entire
  IPv4 address space except the input IPs/IP CIDR ranges.
 
 .DESCRIPTION
  Function to return a list of IPv4 address ranges that includes the entire
  IPv4 address space except the input IPs/IP CIDR ranges.
  This function is useful when setting up a Windows Firewall rule that's
  intended to block all IPs except the list provided in this function's input.
 
 .PARAMETER AllowedIP
  One or more IPv4 addresses or CIDR ranges
 
 .EXAMPLE
  New-BlockList -AllowedIP @(
    '99.88.77.66'
    '33.44.55.111'
  )
  Will return:
    1.0.0.1-33.44.55.110
    33.44.55.112-99.88.77.65
    99.88.77.67-255.255.255.255
 
 .EXAMPLE
  New-BlockList -AllowedIP @(
    '99.88.77.66'
    '33.44.55.111'
    '192.168.11.0/24'
    '10.0.0.0/12'
    '66.77.88.48/29'
  )
  Will return:
    1.0.0.1-9.255.255.255
    10.16.0.0-33.44.55.110
    33.44.55.112-66.77.88.47
    66.77.88.56-99.88.77.65
    99.88.77.67-192.168.10.255
    192.168.12.0-255.255.255.255
     
 .EXAMPLE
    $ParameterSet = @{
        RemoteAddress = New-BlockList -AllowedIP @(
            '99.88.77.66'
            '33.44.55.111'
            (Resolve-DnsName -Name mytrustedhost1.mydomain.com).IPAddress
            '192.168.11.0/24'
            '10.0.0.0/12'
            '66.77.88.48/29'
        )
        Direction = 'Inbound'
        Profile = 'Any'
        Action = 'Block'
        Enabled = 'True'
        Name = 'Allow authorized IPs only'
        DisplayName = 'Allow authorized IPs only'
        Description = 'Allow authorized IPs only'
    }
    New-NetFirewallRule @ParameterSet
 
  This will create a new Windows Firewall rule that blocks all incoming connections
  except from the provided IP list.
 
 .OUTPUTS
  This cmdlet returns a list of IP address ranges such as:
    1.0.0.1-9.255.255.255
    10.16.0.0-33.44.55.110
    33.44.55.112-66.77.88.47
    66.77.88.56-99.88.77.65
    99.88.77.67-192.168.10.255
    192.168.12.0-255.255.255.255
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 6 October 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][Alias('IPAddress')][String[]]$AllowedIP
    )

    Begin { 

        Write-Verbose "Input IPAddress(s): $($AllowedIP -join ', ')"
        # Validate IP addresses:
        $AllowedIP = $AllowedIP | where { $_ } # Remove blanks
        $IPList = @()
        foreach ($IP in $AllowedIP) { 
            if ($IP -as [IPAddress]) { 
                $IPList += New-Object -TypeName PsObject -Property @{
                    IP   = $IP
                    Type = 'IP'
                }                 
            } elseif ($CIDR = Get-IPv4Details -CIDRAddress $IP) { 
                # Get CIDR Start and End IPs
                $IPList += New-Object -TypeName PsObject -Property @{
                    IP   = Next-IP -IPAddress $CIDR.FirstSubnetIP -Increment -1
                    Type = 'Start'
                } 
                $IPList += New-Object -TypeName PsObject -Property @{
                    IP   = Next-IP -IPAddress $CIDR.LastSubnetIP -Increment 1
                    Type = 'End'
                }
            }
        }

    }

    Process {  
        try {        
            $SortedIPList = Sort-IPList -IPAddress $IPList.IP | foreach { $IPList | where IP -EQ $_ }
            $StartIP = '1.0.0.1'
            $RangeList = @()
            foreach ($IP in $SortedIPList) {
            $EndIP = Next-IP -IPAddress $IP.IP -Increment -1
            if (-not ($StartIP -eq (Next-IP -IPAddress $EndIP -Increment 1)) -and $IP.Type -ne 'End') {
                $RangeList += "$StartIP-$EndIP" # Range to block
            }
            $StartIP = Next-IP -IPAddress $IP.IP -Increment 1
            }
            $EndIP = '255.255.255.255'
            $RangeList += "$StartIP-$EndIP"
        } catch { }         
    }

    End { $RangeList }
} 

#endregion

#region Remoting

Function Export-SessionCommand {
<#
 .SYNOPSIS
  Function to export one or more session commands
 
 .DESCRIPTION
  Function to export one or more session commands
  This function takes one or more Powershell script functions/commands from current session
  and exports them to a remote PS session
  This function will ignore and not export binary functions
  Exported functions will persist on the remote computer for the user profile used with the PS remote session
 
 .PARAMETER Command
  This is one or more script commands available in the current PS session
  For example Update-SmbMultichannelConnection cmdlet/function of the SmbShare PS module
  To see available script commands, you can use:
    Get-Command | ? { $_.CommandType -eq 'function' }
 
 .PARAMETER ModuleName
  This is the name of the module that this function will create on the remote computer
  under the user profile of the remote PS session
  This will over-write prior existing module with the same name
 
 .PARAMETER Session
  PSSession object usually obtained by using New-PSSession cmdlet.
 
 .EXAMPLE
  Export-SessionCommand get-saervice,get-sbdisk,bla,get-bitlockerstatus,get-service -Session $Session -Verbose
 
 .OUTPUTS
  The function returns a list of successfully exported commands/functions, or $false if it fails
  Example:
    CommandType Name ModuleName
    ----------- ---- ----------
    Function Get-BitLockerStatus SBjr
    Function Get-SBDisk SBjr
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 July 2018
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][string[]]$Command,
        [Parameter(Mandatory=$false)][String]$ModuleName = 'SBjr',
        [Parameter(Mandatory=$true)][System.Management.Automation.Runspaces.PSSession]$Session
    )

    Begin { 
        if ($Session.State -ne 'Opened') {
            Write-Log 'Export-SessionCommand: Error: Session State is not ''opened''' Magenta
            Write-Log ($Session|Out-String).Trim() Yellow
            break
        }

        $FunctionList = foreach ($Name in $Command) {
            try { 
                Get-Command $Name -EA 1 | Out-Null
                if ((Get-Command $Name).ScriptBlock) {
                    $Name
                } else {
                    Write-Warning "Command '$Name' is not a script command, ignoring"
                }
            } catch {
                Write-Warning "Command '$Name' not found, ignoring"
            }
        }
        $FunctionList = $FunctionList | select -Unique 
        Write-Log 'Exporting function(s):',($FunctionList -join ', ') Green,Cyan 
    }

    Process{ 
        $FirstCommand = $true
        $FunctionList | % {
            $myCommand = Get-Command $_
            Write-Verbose "Exporting command '$($myCommand.Name)' to module '$ModuleName'"
            Invoke-Command -Session $Session -ScriptBlock { 
                $ModPath = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\$Using:ModuleName"
                $PSM     = "$ModPath\$Using:ModuleName.psm1"
                if ($Using:FirstCommand) {
                    New-Item -Path $ModPath -ItemType Directory -Force | Out-Null 
                    "Function $($Using:myCommand.Name) {" | Out-File $PSM                        
                } else {
                    "Function $($Using:myCommand.Name) {" | Out-File $PSM -Append
                }
                                       
                $Using:myCommand.ScriptBlock,'}',' ' | % { $_ | Out-File $PSM -Append }
            }
            $FirstCommand = $false 
        }

    } # Process

    End {         
        Invoke-Command -Session $Session -ScriptBlock { 
            ' ','Export-ModuleMember -Function *' | % { $_ | Out-File $PSM -Append }
            Remove-Module $Using:ModuleName -Force -Confirm:$false -EA 0
            Import-Module $Using:ModuleName
            try { Get-Command -Module $Using:ModuleName -EA 1 | FT -a } catch { $false }
        }
    }

}

function Import-SessionCommands {
<#
 .SYNOPSIS
  Function to import commands from another computer
 
 .DESCRIPTION
  Function will import commands from remote computer from the module(s) listed.
 
 .PARAMETER ModuleName
  Name(s) of the module(s) that we want to import their commands into the current
  PS console.
  Note that session commands will not be available in other PS instances.
 
 .PARAMETER ComputerName
  Computer name that has the module(s) that we need to import their commands.
 
 .PARAMETER Keep
  This is a switch. When selected, the function will export the imported module(s)
  locally under "C:\Program Files\WindowsPowerShell\Modules" if it's in the PSModulePath,
  otherwise, it will export it to the default path "$home\Documents\WindowsPowerShell\Modules"
  - Note 1: Exported modules and their commands can be used directly from any PS instance
            after a module has been exported with the -keep switch
  - Note 2: Even though a module has been exported locally, everytime you try to use one of
            its commands, PS will start an implicit remoting session to the server where the
            module was imported from.
 
 .EXAMPLE
  Import-SessionCommands -ModuleName ActiveDirectory -ComputerName DC01
  This example imports all the commands from the ActiveDirectory module from the DC01 server
  So, in this PS console instance we can use AD commands like Get-ADComputer without the need
  to install AD features, tools, or PS modules on this computer!
 
 .EXAMPLE
  Import-SessionCommands SQLPS,Storage V-2012R2-SQL1 -Verbose
  This example imports all the commands from the PSSQL and Storage modules from the MySQLServer
  server into the current PS instance
 
 .EXAMPLE
  Import-SessionCommands WebAdministration,BestPractices,MMAgent CM01 -keep
  This example imports all the commands from the WebAdministration, BestPractices, and MMAgent
  modules from the CM01 server into the current PS instance, and exports them locally.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  Requires PS 3.0
  v1.0 - 08/17/2014
    Although we need to eventually run:
    Remove-PSSession -Session $Session
    We cannot do that in the function as we'll lose the imported session commands
    Two things to consider:
    1. The session will be automatically removed when the PS console is closed
    2. If in the parent script that's using this function a blanket Remove-PSSession
    command is run, like:
    Get-PSSession | Remove-PSSession
    We'll lose this session and its commands, which could cripple the parent script
#>

    
    [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=0)]
            [String[]]$ModuleName, 
        [Parameter(Mandatory=$true,
                   ValueFromPipeLineByPropertyName=$true,
                   Position=1)]
            [String]$ComputerName,
        [Parameter(Mandatory=$false,
                   Position=2)]
            [Switch]$Keep
    )
    
    # Get a random session name:
    Do { $SessionName = "Import" + (Get-Random -Minimum 10000000 -Maximum 99999999) }
        While (Get-PSSession -Name $SessionName -ErrorAction SilentlyContinue) 
    Write-Verbose "New PSSession name: $SessionName"
    if ($Env:PSModulePath.Split(';') -contains 'C:\Program Files\WindowsPowerShell\Modules') {
        $ExportTo = 'C:\Program Files\WindowsPowerShell\Modules'
    } else {
        $ExportTo = "$home\Documents\WindowsPowerShell\Modules"
    }
    try { 
        Write-Log 'Connecting to computer', $ComputerName Green,Cyan
        $CurrentSessions = Get-PSSession -ErrorAction SilentlyContinue -ComputerName $ComputerName
        if ($CurrentSessions.ComputerName -Contains $ComputerName) {
            $Session = $CurrentSessions[0]
        } else {
            $Session = New-PSSession -ComputerName $ComputerName -Name $SessionName -ErrorAction Stop
        }
        Write-Verbose "Current PSSessions: $(Get-PSSession)"
        $RemoteModules = Invoke-Command -ScriptBlock { Get-Module -ListAvailable | Select Name } -Session $Session 
        $LocalModules = Get-Module -ListAvailable | Select Name
        foreach ($Module in $ModuleName) {
            if ($LocalModules.Name -Contains $Module -or $LocalModules.Name -Contains "Imported-$Module") {
                Write-Log 'Module', $Module, 'exists locally, not importing..' Yellow,Cyan,Yellow
            } else {
                if ($RemoteModules.Name -Contains $Module) {
                    Write-Log 'Found module', $Module, 'on computer', $ComputerName, 'importing its commands..' Green,Cyan,Green,Cyan,Green
                    Invoke-Command -Session $Session -ArgumentList $Module -ScriptBlock { 
                        Param($Module)
                        Import-Module $Module 
                    }
                    try { 
                        $ImportedModule = Import-PSSession -Session $Session -Module $Module -DisableNameChecking -ErrorAction Stop 
                        if ($Keep) {
                            Write-Log 'Keeping module', $Module, 'locally..' Green,Cyan,Green
                            Remove-Module -Name $ImportedModule.Name
                            Export-PSSession -Module $Module -OutputModule "$ExportTo\Imported-$Module" -Session $Session -Force
                            Import-Module -Name "Imported-$Module"
                        }
                    } catch { 
                        Write-Log 'Module', $Module, 'already imported, skipping..' Yellow,Cyan,Yellow
                    }
                } else {
                    Write-Log 'Error: module', $Module, 'not found on server', $ComputerName Magenta,Yellow,Magenta,Yellow
                }
            }
        }
    } catch {
        Write-Log 'Error: unable to connect to server', $ComputerName Magenta,Yellow
        Write-Log ' Check if', $ComputerName, 'exists, is online, ' Magenta,Yellow,Magenta
        Write-Log ' has WinRM enabled and configured, and ' Magenta
        Write-Log ' you have sufficient permissions to it' Magenta
    }
}

function Connect-Computer {
<#
 .SYNOPSIS
  Function to establish PowerShell Remoting session with a remote computer that's not domain member
 
 .DESCRIPTION
  Function to establish PowerShell Remoting session with a remote computer that's not domain member
 
 .PARAMETER ComputerName
  This can be a NetBios computer name like'mycomputer' or an IPv4 address like '10.20.30.40'
  If using a computer name, make sure it can be resolved to an IPv4 address
 
 .PARAMETER Credential
  This is a PSCredential Object not text.
 
 .EXAMPLE
  $Session = Connect-Computer -ComputerName '10.171.120.68' -Credential (Get-SBCredential -UserName '.\Administrator') -Verbose
  This establishes a session with 10.171.120.68
  To see built in help for the Get-SB-Credential function use: Get-Help Get-SBCredential -Show
  The returned PSSession object is stored in the $Session variable in this example, to used for further automation such as:
  Invoke-command -Session $Session -ScriptBlock { Get-Service }
 
 .OUTPUTS
  This function returns a PSSession object [System.Management.Automation.Runspaces.PSSession]
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 4 October 2018
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)]
            [String]$ComputerName,
        [Parameter(Mandatory=$true)]
            [System.Management.Automation.PSCredential]$Credential
    )

    Begin {
        Write-Verbose 'Connect-Computer: Checking Trusted Hosts list'
        $TrustedHosts = Get-Item wsman:\localhost\Client\TrustedHosts
        if ($TrustedHosts.Value -match $ComputerName) {
            Write-Verbose "Connect-Computer: $ComputerName is already in Trusted Hosts"
        } else {
            Write-Verbose "Connect-Computer: Adding $ComputerName to Trusted Hosts"
            try {
                Set-Item wsman:\localhost\Client\TrustedHosts $ComputerName -Concatenate -Force -ErrorAction Stop
                Write-Verbose 'done'
            } catch {
                throw "Failed to add $ComputerName to Trusted Hosts"
            }              
        }        
    }

    Process{
        Write-Verbose "Connect-Computer: Establishing PowerShell Remoting session with $ComputerName using Credential $($Credential.UserName)"
        try {
            New-PSSession -ComputerName $ComputerName -Credential $Credential -ErrorAction Stop
            Write-Verbose 'done'
        } catch {
            Write-Error "Failed to establish PowerShell Remoting session with $ComputerName"
            throw $_
        }         
    }

    End {    }
}

#endregion

#region PageFile

function Get-PageFile {
<#
 .SYNOPSIS
  List the drives that have page file(s) and their configuration
 
 .DESCRIPTION
  List the drives that have page file(s) and their configuration
  Note that 0 value for Initial or Maximum size indicate a system-managed page file
  This function does not require or accept any parameters
 
 .OUTPUTS
  This function returns a PS object for each drive that has a page file on it,
  each having the following 3 properties/example:
    DriveLetter InitialSizeMB MaximumSizeMB
    ----------- ------------- -------------
              C 0 0
              D 1024 4096
 
 .EXAMPLE
  Get-PageFile
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  https://superwidgets.wordpress.com/category/powershell/
  18 September 2018 - v0.1
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param()

    Begin { }

    Process {
        Get-WmiObject -Class Win32_PageFileSetting | 
            select  @{n='DriveLetter';  e={$_.Name[0]}},
                    @{n='InitialSizeMB';e={$_.InitialSize}},
                    @{n='MaximumSizeMB';e={$_.MaximumSize}}
        Write-Verbose '0 value for Initial or Maximum size indicate a system-managed page file'
    }

    End { }

}

function Set-PageFile {
<#
 .SYNOPSIS
  Function to set page file to be on a given drive
 
 .DESCRIPTION
  Function to set page file to be on a given drive
  Function will create page file if it does not exist on the provided drive
 
 .PARAMETER PageFile
  This is a PS Custom Object containing the following 3 properties:
    DriveLetter such as c
    InitialSizeMB such as 1024 (0 value indicate system managed page file)
    MaximumSizeMB such as 4096 (0 value indicate system managed page file)
  This object can be constructed manually as in:
  $PageFile = [PSCustomObject]@{
    DriveLetter = 'c'
    InitialSizeMB = 0
    MaximumSizeMB = 0
  }
  or obtained from the Get-PageFile function of this PS module
 
 .EXAMPLE
  Set-PageFile -PageFile ([PSCustomObject]@{
    DriveLetter = 'c'
    InitialSizeMB = 0
    MaximumSizeMB = 0
  })
  This example configures a system-managed page file on drive c
 
 .EXAMPLE
  Get-PageFile | foreach { $_.InitialSizeMB = 0; $_.MaximumSizeMB = 0; $_ } | Set-PageFile
  This example sets every page file to system-managed size
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  20 September 2018 - v0.1
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,ValueFromPipeline=$true)][PSCustomObject]$PageFile = [PSCustomObject]@{
            DriveLetter   = ((Get-WmiObject Win32_Volume | where PageFilePresent).DriveLetter | foreach { $_[0] } | select -First 1)
            InitialSizeMB = 0 # 0 = System Managed Size
            MaximumSizeMB = 0 # 0 = System Managed Size
        }
    )

    Begin { 
        Write-Verbose 'Input received:'
        Write-Verbose ($PageFile | Out-String)
        
        $DriveletterList = (Get-WmiObject Win32_Volume | where PageFilePresent).DriveLetter | foreach { $_[0] } 
        if ($PageFile.DriveLetter -notin $DriveletterList) {
            Write-Log 'Set-PageFile error:','Provided drive letter',$PageFile.DriveLetter,
                'does not exist on this computer, available drive letters are',($DriveletterList -join ', ') Magenta,Yellow,Magenta,Yellow,Magenta 
            break
        } else {
            Write-Verbose "Validated that provided drive letter '$($PageFile.DriveLetter)' exists on this computer '$($env:COMPUTERNAME)'"
        }
    }

    Process {

        $CurrentPageFile = Get-PageFile | where { $_.DriveLetter -match $PageFile.DriveLetter }
        if ($CurrentPageFile.InitialSizeMB -eq $PageFile.InitialSizeMB -and $CurrentPageFile.MaximumSizeMB -eq $PageFile.MaximumSizeMB) {
            Write-Log 'Existing page file',($CurrentPageFile | Out-String),'already matches provided parameters' Green,Yellow,Green
        } else {
            Write-Log 'Updating page file',($CurrentPageFile | Out-String) Green,Cyan

            #region Disable AutomaticManagedPagefile feature
            $compObj = Get-WmiObject Win32_ComputerSystem -EnableAllPrivileges
            if ($compObj.AutomaticManagedPagefile) {
                $compObj.AutomaticManagedPagefile = $false
                $compObj.Put() | Out-Null
                $compObj = Get-WmiObject -Class Win32_compObj -EnableAllPrivileges
                if ($compObj.AutomaticManagedPagefile) { 
                    Write-Log 'Set-PageFile:','Unable to Disable AutomaticManagedPagefile feature','Get-WmiObject -Class Win32_compObj' Magenta,Yellow,Magenta
                    break
                } else {
                    Write-Log 'Disabled','AutomaticManagedPagefile','feature on',$compObj.Name Green,Cyan,Green,Cyan
                }
            } else {
                Write-Log 'Computer',$compObj.Name,'AutomaticManagedPagefile','feature is already disabled' Green,Cyan,Green,Cyan
            }
            #endregion

            # Change/Create Page File
            $pageFileSetting = Get-WmiObject -Class Win32_PageFileSetting | where { $_.Name.StartsWith($PageFile.DriveLetter) }
            if (-not $pageFileSetting) {
                Set-WmiInstance -Class Win32_PageFileSetting -Arguments @{
                    Name        = "$($PageFile.DriveLetter):\pagefile.sys"
                    InitialSize = 0
                    MaximumSize = 0
                } -EnableAllPrivileges | Out-Null
                $pageFileSetting = Get-WmiObject -Class Win32_PageFileSetting | where { $_.Name.StartsWith($PageFile.DriveLetter) }
            }
            $pageFileSetting.InitialSize = $PageFile.InitialSizeMB
            $pageFileSetting.MaximumSize = $PageFile.MaximumSizeMB
            $pageFileSetting.Put() | Out-Null
            $CurrentPageFile = Get-PageFile | where { $_.DriveLetter -match $PageFile.DriveLetter }
            Write-Verbose 'PageFile setting:'
            Write-Verbose ($PageFile | Out-String)
            Write-Verbose 'CurrentPageFile setting:'
            Write-Verbose ($CurrentPageFile | Out-String)
            if ($CurrentPageFile.InitialSizeMB -eq $PageFile.InitialSizeMB -and $CurrentPageFile.MaximumSizeMB -eq $PageFile.MaximumSizeMB) {
                Write-Log 'Successfully updated page file settings to',($CurrentPageFile | Out-String) Green,Cyan
                Write-Log 'Remember that a reboot is required to complete this process' Yellow
            } else {
                Write-log 'Unable to change Page File setting',($CurrentPageFile | Out-String) Magenta,Yellow
            }
        }

    }

    End { }

}

function Remove-PageFile {
<#
 .SYNOPSIS
  Function to remove page file from a given drive
 
 .DESCRIPTION
  Function to remove page file from a given drive
 
 .PARAMETER DriveLetter
  Drive such as 'c' or 'e' that has a page file to be removed
 
 .EXAMPLE
  Remove-PageFile 'c'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  20 September 2018 - v0.1
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,ValueFromPipeline=$true)]
            [String]$DriveLetter = ((Get-WmiObject Win32_Volume | where PageFilePresent).DriveLetter | foreach { $_[0] } | select -First 1)
    )

    Begin { 
        Write-Verbose "Input received: DriveLetter $DriveLetter"
        
        $DriveletterList = (Get-WmiObject Win32_Volume | where PageFilePresent).DriveLetter | foreach { $_[0] } 
        if ($DriveLetter -notin $DriveletterList) {
            Write-Log 'Remove-PageFile error:','Provided drive letter',$DriveLetter,
                'does not exist on this computer, available drive letters are',($DriveletterList -join ', ') Magenta,Yellow,Magenta,Yellow,Magenta 
            break
        } else {
            Write-Verbose "Validated that provided drive letter '$($DriveLetter)' exists on this computer '$($env:COMPUTERNAME)'"
        }
    }

    Process { 
        Write-Log 'Current page file(s):', (Get-PageFile|Out-String) Green,Cyan 

        if ($DriveLetter -in (Get-PageFile).DriveLetter) {
            (Get-WmiObject -Class Win32_PageFileSetting | where { $_.Name.StartsWith($DriveLetter) }).Delete()
            Write-Log 'Removed page file from drive',$DriveLetter Green,Cyan
            Write-Log 'Current page file(s):', (Get-PageFile|Out-String) Green,Cyan 
            Write-Log 'Remember that a reboot is required to complete this process' Yellow
        } else {
            Write-Log 'No page file found on drive',$DriveLetter Yellow,Cyan
        }
    }

    End { }

}

#endregion

#region Active Directory

function Get-DCList {
<#
 .SYNOPSIS
  Function to provide domain controller information for the current/given AD forest
 
 .DESCRIPTION
  Function to provide domain controller information for the current/given AD forest
   
 .PARAMETER DCName
  Optional parameter to be used to query other than current AD forest
   
 .PARAMETER Cred
  Optional parameter when querying cuurent AD forest (not providing a DCName)
  Required parameter when querying other than current AD forest.
  (Will default to current user credential if not provided when required)
   
 .EXAMPLE
  $myDCList = Get-DCList
  This returns information on the current forest to the console such as:
    Identified AD Forest ABC.local
        Identified the following domains:
    ForestName DomainName DomainLevel PDCEmulator DCCount
    ---------- ---------- ----------- ----------- -------
    ABC.local ABC.local 2012R2 XYZ-DC1.ABC.local 2
  as well as a PS object (stored in $myDCList variable) such as:
    ForestName : ABC.local
    DomainName : ABC.local
    DomainLevel : 2012R2
    PDCEmulator : XYZ-DC1.ABC.local
    DCList : {XYZ-DC1.ABC.local, XYZ-DC2.ABC.local}
 
 .EXAMPLE
  $myDCList = Get-DCList -DCName dc1.mydomain.com -Cred (Get-SBCredential 'mydomain\myname')
  This returns information on the current forest to the console such as:
 
 .OUTPUTS
  This cmdlet returns PSCustom Objects, one for each Domain containing the following properties/example:
    ForestName : ABC.local
    DomainName : ABC.local
    DomainLevel : 2012R2
    PDCEmulator : XYZ-DC1.ABC.local
    DCList : {XYZ-DC1.ABC.local, XYZ-DC2.ABC.local}
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 20 July 2018
  v0.2 - 14 January 2020
    - Rewrite to speed up processing (not quering individial DCs)
    - Added parameter 'DCName' and code to query other than current AD forest
    - Added parameter 'Cred' and code to query other than current AD forest using a different credential
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$DCName, # Full FQDN like server.domain.com
        [Parameter(Mandatory=$false)][PSCredential]$Cred 
    )

    Begin {
        if (-not $IsDomainMember) {
            Write-Log 'Error: This cmdlet is designed to run from a domain joined computer' Magenta
            break
        }
    }

    Process {
        if ($DCName) {
            Write-log 'Querying DC',$DCName,'using',$Cred.UserName,'credential..' Green,Cyan,Green,Cyan,Green
            if (-not $Cred) { $Cred = Get-SBCredential "$env:USERDNSDOMAIN\$env:USERNAME" }
            $Context = New-Object -TypeName system.directoryservices.activedirectory.directorycontext -ArgumentList @(
                'DirectoryServer',$DCName,$Cred.UserName,$Cred.GetNetworkCredential().Password)
            try {
                $Forest = [system.directoryservices.activedirectory.Forest]::GetForest($Context) 
            } catch {
                Write-Log $_.Exception.Message Magenta
                break
            }
        } else {
            Write-log 'Identifying current AD forest, domains, domain controllers...' Green
            try {
                $Forest = [system.directoryservices.activedirectory.Forest]::GetCurrentForest()
            } catch {
                Write-Log $_.Exception.Message Magenta
                break
            }
        } 

        if ($Forest) {
            Write-Log 'Identified AD Forest',$Forest.Name Green,Cyan
            $DomainList = foreach ($Domain in $Forest.Domains) {
                [PSCustomObject]@{
                    ForestName  = $Forest.Name
                    DomainName  = $Domain.Name
                    DomainLevel = ($Domain.DomainMode | Out-String).Replace('Windows','').Replace('Domain','').Trim()
                    PDCEmulator = $Domain.PdcRoleOwner
                    DCList      = $Domain.DomainControllers
                }
            }
            if ($DomainList) {
                Write-Log ' Identified the following',$DomainList.Count,'domains:' Green,Cyan,Green
                Write-Log ($DomainList | FT ForestName,DomainName,DomainLevel,PDCEmulator,
                    @{n='DCCount';e={$_.DCList.Count}} -a | Out-String).Trim() Cyan
            } else {
                Write-Log ' AD Forest',$Forest.Name,'has no domains' Magenta,Yellow,Magenta
            }
        } else {
            Write-Log ' Failed to identify AD Forest' Magenta
            break
        }
        
        
# $DCList = [system.directoryservices.activedirectory.Forest]::GetCurrentForest().domains.domaincontrollers |
# select Forest,Name,CurrentTime,OSVersion,Roles,Domain,IPAddress,SiteName
# Write-Log 'Identified',$DCList.Count,'domain controllers in the',(($DCList.Domain.Name | select -Unique) -join ', '),
# 'domain(s), in the',(($DCList | select -First 1).Forest),'forest' Green,Cyan,Green,Cyan,Green,Cyan,Green
    }

    End { $DomainList }
} 

function Get-SBADComputer {
<#
.SYNOPSIS
 Function to get one or all computer objects' information from Active Directory.
                       
.DESCRIPTION
 Function to get one or all computer objects' information from Active Directory using LDAP.
 Does not need ActiveDirectory PowerShell module.
 Must be run from a domain joined computer.
                        
.PARAMETER ComputerName
 This is an optional parameter that takes a computer name
 This parameter accepts wild cards such as *
 
.PARAMETER DomainController
 This is an optional parameter to contain the FQDN of the Domain Controller to query, as DC1.myDomain.com
 If omitted, the function will query the currently logged on domain controller
                        
.PARAMETER OtherAttributeList
 This is an optional parameter that instructs this function to fetch one or more computer attributes
 in addition to the ones already provided.
                        
.PARAMETER MaxCount
 This is an optional number. When provided the output is limited to that many computers.
                        
.PARAMETER Quiet
 This is an optional parameter that takes either True or False values and defaults to False
 When set to True, it supresses console progress messages, speeding up prcessing
 
.EXAMPLE
 Get-SBADComputer
 Returns enabled computer information in the current AD domain
                        
.EXAMPLE
 Get-SBADComputer -ComputerName abc* -MaxCount 5 -OtherAttributeList objectsid,objectguid,memberof,dnshostnamelastlogontimestamp,accountexpires
 Returns the first 5 enabled computers in the current AD domain that start with abc
 showing the listed additional properties
                        
.OUTPUTS
 Returns a PowerShell object containing the following properties:
    ComputerName
    OSName ==> For example: Windows Server 2012 R2 Standard
    DN ==> Distinguished name, for example: CN=Server10V,OU=Domain Computer,DC=mydomain,DC=com
    AD_OU ==> Active Directory Organization Unit where the computer object is located
    LastLogon ==> Date of last time the computer object logged on to AD
    ADCreated ==> Date the computer object was created in AD
    SPN ==> The computer's Service Principal Name if any
    DomainController ==> The DC queried by this function to obtain the computer information
 Additional properties will be returnd if specified in the OtherAttributeList parameter
 Returns nothing if the computer name is not found or a matching computer object is found but disabled
                       
.LINK
 https://superwidgets.wordpress.com/category/powershell/
                       
.NOTES
 Function by Sam Boutros
 v0.1 - 10 Sep 2018 - Initial release.
 v0.2 - 11 Apr 2020 - Added parameters: ComputerName, MaxCount, OtherAttributeList, DomainController, Quiet.
 v0.3 - 12 Sep 2023 - Added several properties to the output object.
#>

    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][String]$ComputerName,
        [Parameter(Mandatory=$false)][Int]$MaxCount,
        [Parameter(Mandatory=$false)][String[]]$OtherAttributeList,
        [Parameter(Mandatory=$false)][String]$DomainController = "$($env:LOGONSERVER.Replace('\\','')).$($env:USERDNSDOMAIN)",
        [Parameter(Mandatory=$false)][Switch]$Quiet = $false
    )
                      
    Begin {
        if (-not $IsDomainMember) {
            Write-Log 'This function','Get-SBADComputer','must be run from a domain joined computer' Magenta, Yellow, Magenta
            break
        }
    }
                      
    Process{
        if (-not $Quiet) {
            Write-Log 'Input received:' Green
            if ($ComputerName) { Write-Log ' ComputerName:',$ComputerName Green,Cyan }
            if ($OtherAttributeList) { 
                $OtherAttributeList = $OtherAttributeList.ToLower()
                Write-Log ' OtherAttributeList:',($OtherAttributeList -join ', ') Green,Cyan 
            }
            Write-Log ' DomainController:',$DomainController Green,Cyan
        }

        $adsi = [adsisearcher][adsi]"LDAP://$DomainController" 
        if ($ComputerName) {
            if (-not $Quiet) { Write-Log 'Processing ComputerName',$ComputerName,'from DC',$DomainController Green,Cyan,Green,Cyan }
            $adsi.filter = "(&(objectClass=Computer)(name=$ComputerName)(!userAccountControl:1.2.840.113556.1.4.803:=2))" 
        } else {
            if (-not $Quiet) { Write-Log 'Processing Computer objects from DC', $DomainController Green,Cyan }
            $adsi.filter = "(&(objectClass=Computer)(!userAccountControl:1.2.840.113556.1.4.803:=2))" # To return only enabled computer objects
        }
        $adsi.PageSize = 1000000 
        $ComputerList = if ($MaxCount) { $adsi.FindAll() | select -First $MaxCount } else { $adsi.FindAll() } 
        $ComputerList | foreach {
            $obj = $_.Properties
            $myOutput = [PSCustomObject][ordered]@{
                ComputerName        = [string]$obj.name
                DNSHostName         = [string]$obj.dnshostname
                OSName              = [string]$obj.operatingsystem
                OSVersion           = [string]$obj.operatingsystemversion
                DN                  = [string]$obj.distinguishedname
                AD_OU               = [string](($obj.distinguishedname) -replace '^CN=[\w\d-_]+,\w\w=','' -replace ',OU=','/' -replace ',DC=.*')
                LastLogonTimeStamp  = $(
                    try {
                        $Temp1 = [DateTime]::FromFileTime($($obj.lastlogontimestamp) -as [int64])
                        if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                    } catch {'Never'}
                        
                )                 
                PasswordLastSet     = $(
                    try {
                        $Temp1 = [DateTime]::FromFileTime($($obj.pwdlastset) -as [int64])
                        if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                    } catch {'Never'}
                        
                )                 
                AccountExpires      = $(
                    try {
                        $Temp1 = [DateTime]::FromFileTime($($obj.accountexpires) -as [int64])
                        if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                    } catch {'Never'}
                        
                )                 
                DateCreated         = ($obj.whencreated).ToShortDateString()
                DateChanged         = ($obj.whenchanged).ToShortDateString()
                SPN                 = [string]$obj.serviceprincipalname -join ', '
                DomainController    = $DomainController
                SID                 = (New-Object System.Security.Principal.SecurityIdentifier($($obj.objectsid),0)).Value
                GUID                = $(
                    # Translate guid from Octet Array to String
                    $i = 0
                    $guidAsString = ''
                    $($obj.objectguid) | ForEach {
                        $i ++
                        if ($i -in (5,7,9,11)) { $guidAsString += '-' }
                        $guidAsString += $_.ToString('x2').ToUpper()
                    }
                    $guidAsString
                )
                MemberOf            = [string]$obj.memberof -join ', '
                UAC                 = (Parse-UserAccountControl $($obj.useraccountcontrol)).Name -join ', '
                SupportedEncTypes   = (Parse-msDSSupportedEncryptionTypes $($obj.'msds-supportedencryptiontypes')).Name -join ', '
                IsCriticalSystemObj = $($obj.iscriticalsystemobject)
                ADCreated           = ($obj.whencreated).ToShortDateString()
            }

            if ($OtherAttributeList) { 
                foreach ($PCAttribute in $OtherAttributeList) { 
                    $myOutput | Add-Member -MemberType NoteProperty -Name $PCAttribute -EA 0 -Value $( $($obj.$PCAttribute) )
                }
            }
            $myOutput
        }
    }
                      
    End { }
}

function Get-SBADUser {
<#
.SYNOPSIS
 Function to get user objects information from Active Directory.
                       
.DESCRIPTION
 Function to get user objects information from Active Directory using LDAP.
 Does not need ActiveDirectory PowerShell module.
 Must be run from a domain joined computer.
 Used samaccounttype reference:
    SAM_DOMAIN_OBJECT 0x0
    SAM_GROUP_OBJECT 0x10000000
    SAM_NON_SECURITY_GROUP_OBJECT 0x10000001
    SAM_ALIAS_OBJECT 0x20000000
    SAM_NON_SECURITY_ALIAS_OBJECT 0x20000001
    SAM_USER_OBJECT 0x30000000
    SAM_NORMAL_USER_ACCOUNT 0x30000000
    SAM_MACHINE_ACCOUNT 0x30000001
    SAM_TRUST_ACCOUNT 0x30000002
    SAM_APP_BASIC_GROUP 0x40000000
    SAM_APP_QUERY_GROUP 0x40000001
    SAM_ACCOUNT_TYPE_MAX 0x7fffffff
 Used UserAccountControl reference (Also see Parse-UserAccountControl function):
    0x00000002 ADS_UF_ACCOUNTDISABLE The user account is disabled.
    0x00000010 ADS_UF_LOCKOUT The account is currently locked out.
    0x00000200 ADS_UF_NORMAL_ACCOUNT This is a default account type that represents a typical user.
    0x00000800 ADS_UF_INTERDOMAIN_TRUST_ACCOUNT This is a permit to trust account for a system domain that trusts other domains.
    0x00001000 ADS_UF_WORKSTATION_TRUST_ACCOUNT This is a computer account for a computer that is a member of this domain.
    0x00002000 ADS_UF_SERVER_TRUST_ACCOUNT This is a computer account for a system backup domain controller that is a member of this domain.
    0x00010000 ADS_UF_DONT_EXPIRE_PASSWD The password for this account will never expire.
    0x00020000 ADS_UF_MNS_LOGON_ACCOUNT This is an MNS logon account.
    0x00040000 ADS_UF_SMARTCARD_REQUIRED The user must log on using a smart card.
    0x00080000 ADS_UF_TRUSTED_FOR_DELEGATION The service account (user or computer account), under which a service runs, is trusted for Kerberos delegation. Any such service can impersonate a client requesting the service.
    0x00100000 ADS_UF_NOT_DELEGATED The security context of the user will not be delegated to a service even if the service account is set as trusted for Kerberos delegation.
    0x00200000 ADS_UF_USE_DES_KEY_ONLY Restrict this principal to use only Data Encryption Standard (DES) encryption types for keys.
    0x00400000 ADS_UF_DONT_REQUIRE_PREAUTH This account does not require Kerberos pre-authentication for logon.
    0x00800000 ADS_UF_PASSWORD_EXPIRED The user password has expired. This flag is created by the system using data from the Pwd-Last-Set attribute and the domain policy.
    0x01000000 ADS_UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION The account is enabled for delegation. This is a security-sensitive setting; accounts with this option enabled should be strictly controlled. This setting enables a service running under the account to assume a client identity and authenticate as that user to other remote servers on the network.
 
.PARAMETER FilterSamAccountName
 This is an optional parameter that takes the user's login name AKA samaccountname
 If omitted, the function will return all user accounts (excluding computer accounts)
 This parameter accepts wild cards such as *
 
.PARAMETER FilterFisrtName
 This is an optional parameter that takes the user's first name
 
.PARAMETER FilterLastName
 This is an optional parameter that takes the user's last name
 
.PARAMETER Server
 This is an optional parameter to contain the FQDN of the Domain Controller to query, as DC1.myDomain.com
 If omitted, the function will query the currently logged on domain controller
                        
.PARAMETER Properties
 This is an optional parameter that instructs this function to fetch one or more user attributes
 in addition to the ones already provided.
                        
.PARAMETER Quiet
 This is an optional parameter that takes either True or False values and defaults to False
 When set to True, it supresses console progress messages, speeding up prcessing
                        
.EXAMPLE
 Get-SBADUser
 Returns all users' information in the current AD domain
 
.Example
 Get-SBADUser *Sam*
 This will return all users that have 'sam' as part of the login name
 
.Example
 Get-SBADUser *test*
 This will return all users that have 'test' as part of the login name
 
.Example
 $UserList = Get-SBADUser
 $UserList | where useraccountcontrol -Match 'Normal' # list of normal working accounts
 $UserList | where useraccountcontrol -Match 'Disabled' # list of disabled accounts
 $UserList | where useraccountcontrol -Match 'PasswordNeverExpires' # list of account with passswords that never expire
 $UserList | where useraccountcontrol -Match 'Locked-Out' # list of locked out accounts
 $UserList | where useraccountcontrol -Match 'PasswordExpired' # list of accounts with expired passwords
 $UserList | where DN -Match 'OU=Partners,OU=Users,OU=Two,DC=One,DC=Domain,DC=com' | FT -a # list of accounts in the 'OU=Partners,OU=Users,OU=Two,DC=One,DC=Domain,DC=com' OU
 
.Example
 $UserName = 'samb' # Logon Name / SamName
 $DCList = Get-DCList # This may take a few minutes in large domains with many DCs across slow wan links
 $myUserLogins = foreach ($DC in ($DCList)) { Get-SBADUser -samaccountname $UserName -DomainController $DC.Name }
 $myUserLogins | where LastLogon -ne 'Never' | sort LastLogon |
    FT UserName,DomainController,
        @{n='DomainControllerIP';e={($DCList|where Name -eq $_.DomainController).IPAddress}},LastLogon -auto
 This example queries all domain controllers for a given user's information including lastlogon
 This is helpful to show where a given user has logged on last.
 This can be used along with event log analysis to audit user logons.
 
 .Example
  Get-SBADUser -FirstName sam -LastName tom -Properties objectguid,objectsid
 
.OUTPUTS
 Returns a PowerShell object for each returned user containing the following properties/example:
    UserName : Small, Robert
    samaccountname : Robert.Small
    DateCreated : 2/4/2016 1:04:05 PM
    useraccountcontrol : {Disabled, Normal}
    lastlogon : 10/10/2018 1:56:14 PM
    DateExpires : AccountNeverExpires
    DN : CN=Small\, Robert,OU=MyOU,DC=Mysubdomain,DC=mydomain,DC=com
  Additional properties will be returnd if specified in the OtherAttributeList parameter
  Notice the use of the '\' in the DN (Distinguished Name) as an escape character for the ',' part of the CN (Common Name)
  Note: DateExpires property speaks to the account expiration not the password expiration.
             
.LINK
 https://superwidgets.wordpress.com/category/powershell/
                       
.NOTES
 Function by Sam Boutros
 v0.1 - 9 October 2018
 v0.2 - 17 May 2019 - improved reporting lastlogon to show 'never' if older then 1/1/1900
        (zero value is 1/1/1601 12:00 AM UTC, in EST = GMT-5 - that would show as 12/31/1600 7:00 PM)
 v0.3 - 12 September 2019 - Added FirstName, LastName, and DisplayName properties
        Added parameters to allow finding a user by First or Last Name
        Added parameter to show custom/other user attributes
 v0.4 - 6 March 2020 - Added Byte Array to String transalation for sid properties
        Added Octet Array to String transalation for guid properties
        Added logic to filter by BOTH first and last names when both are provided
        Known issues:
        - GUID property translation from Octet Array to String may be inaccurate
        - SID property translation from Byte Array to String may fail
 v0.5 - 20 March 2020 - Minor updates to
        Avoid error message if attribute is provided to OtherAttribute parameter that's already in the user object
        Display Lastlogontimestamp attribute in DateTime format if requested
        Add -Quiet parameter to speed up processing by not displaying progress messages to the console
 v0.6 - 29 July 2020
        Added sipProxyAddress property
 v0.7 - 29 October 2021
        Normalized parameter names to match Get-ADUser cmdlet.
        Add UACDescription property.
 
        Known issues:
        - GUID property translation from Octet Array to String may be inaccurate
        - SID property translation from Byte Array to String may fail
#>

    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][Alias('SamAccountName')][String]$FilterSamAccountName,
        [Parameter(Mandatory=$false)][Alias('FirstName')][String]$FilterFirstName,
        [Parameter(Mandatory=$false)][Alias('LastName')][String]$FilterLastName,
        [Parameter(Mandatory=$false)][Alias('OtherAttributeList')][String[]]$Properties,
        [Parameter(Mandatory=$false)][Alias('DomainController')][String]$Server = "$($env:LOGONSERVER.Replace('\\','')).$($env:USERDNSDOMAIN)",
        [Parameter(Mandatory=$false)][Switch]$Quiet
    )
                      
    Begin {
        if (-not $IsDomainMember) {
            Write-Log 'This function','Get-SBADUser','must be invoked from a domain-joined computer' Magenta, Yellow, Magenta
            break
        }
        $ADUserPropList = @('AccountExpirationDate','accountExpires','AccountLockoutTime','AccountNotDelegated','AllowReversiblePasswordEncryption','AuthenticationPolicy','AuthenticationPolicySilo','BadLogonCount','badPasswordTime','badPwdCount','CannotChangePassword','CanonicalName','Certificates','City','CN','co','codePage','Company','CompoundIdentitySupported','Country','countryCode','Created','createTimeStamp','Deleted','Department','departmentNumber','Description','DisplayName','DistinguishedName','Division','DoesNotRequirePreAuth','dSCorePropagationData','EmailAddress','EmployeeID','EmployeeNumber','employeeStatus','employeeType','Enabled','expenseCenterCode','expenseCenterName','extensionAttribute15','extensionAttribute3','Fax','GivenName','HomeDirectory','HomedirRequired','HomeDrive','homeMDB','HomePage','HomePhone','Initials','instanceType','isDeleted','KerberosEncryptionType','LastBadPasswordAttempt','LastKnownParent','lastLogon','LastLogonDate','lastLogonTimestamp','legacyExchangeDN','LockedOut','lockoutTime','logonCount','LogonWorkstations','mail','mailNickname','managedObjects','Manager','managerEIN','managerName','managerPDON','mDBUseDefaults','MemberOf','middleName','MNSLogonAccount','MobilePhone','Modified','modifyTimeStamp','msDS-AuthenticatedAtDC','msDS-cloudExtensionAttribute1','msDS-ExternalDirectoryObjectId','msDS-User-Account-Control-Computed','msExchArchiveQuota','msExchArchiveWarnQuota','msExchAuditAdmin','msExchAuditDelegate','msExchAuditDelegateAdmin','msExchAuditOwner','msExchCalendarLoggingQuota','msExchCoManagedObjectsBL','msExchDumpsterQuota','msExchDumpsterWarningQuota','msExchELCMailboxFlags','msExchHomeServerName','msExchMailboxAuditEnable','msExchMailboxAuditLastAdminAccess','msExchMailboxGuid','msExchMailboxSecurityDescriptor','msExchMailboxTemplateLink','msExchMobileMailboxFlags','msExchOmaAdminWirelessEnable','msExchPoliciesExcluded','msExchRBACPolicyLink','msExchRecipientDisplayType','msExchRecipientTypeDetails','msExchSafeSendersHash','msExchTextMessagingState','msExchUMDtmfMap','msExchUserAccountControl','msExchUserCulture','msExchVersion','msExchWhenMailboxCreated','msRTCSIP-DeploymentLocator','msRTCSIP-FederationEnabled','msRTCSIP-InternetAccessEnabled','msRTCSIP-OptionFlags','msRTCSIP-PrimaryHomeServer','msRTCSIP-PrimaryUserAddress','msRTCSIP-UserEnabled','msRTCSIP-UserPolicies','msRTCSIP-UserRoutingGroupId','msTSExpireDate','msTSLicenseVersion','msTSLicenseVersion2','msTSLicenseVersion3','msTSManagingLS','Name','nTSecurityDescriptor','ObjectCategory','ObjectClass','ObjectGUID','objectSid','Office','OfficePhone','Organization','OtherName','ou','PasswordExpired','PasswordLastSet','PasswordNeverExpires','PasswordNotRequired','physicalDeliveryOfficeName','POBox','PostalCode','PrimaryGroup','primaryGroupID','PrincipalsAllowedToDelegateToAccount','ProfilePath','ProtectedFromAccidentalDeletion','proxyAddresses','pwdLastSet','SamAccountName','sAMAccountType','ScriptPath','sDRightsEffective','ServicePrincipalNames','showInAddressBook','SID','SIDHistory','SmartcardLogonRequired','sn','st','State','StreetAddress','Surname','Title','TrustedForDelegation','TrustedToAuthForDelegation','UseDESKeyOnly','userAccountControl','userCertificate','UserPrincipalName','uSNChanged','uSNCreated','whenChanged','whenCreated')
    }
                      
    Process {
        if (-not $Quiet) {
            Write-Log 'Input received:' Green
            if ($Filtersamaccountname) { Write-Log ' Filtersamaccountname:',$Filtersamaccountname Green,Cyan }
            if ($FilterFirstName)      { Write-Log ' FilterFirstName:',$FilterFirstName Green,Cyan }
            if ($FilterLastName)       { Write-Log ' FilterLastName:',$FilterLastName Green,Cyan }
            if ($Properties)           { Write-Log ' Properties:',($Properties -join ', ') Green,Cyan  }
                                         Write-Log ' Server:',$Server Green,Cyan
        }
        $adsi = [adsisearcher][adsi]"LDAP://$Server" 
        if ($Filtersamaccountname) {
            if (-not $Quiet) { Write-Log 'Processing user - SamAccountName',$Filtersamaccountname,'from DC',$Server Green,Cyan,Green,Cyan }
            $adsi.filter = "(samaccountname=$Filtersamaccountname)" 
        } elseif ($FilterFirstName -and $FilterLastName) {
            if (-not $Quiet) { Write-Log 'Processing user - FirstName',$FilterFirstName,'- LastName',$FilterLastName,'from DC',$Server Green,Cyan,Green,Cyan,Green,Cyan }
            $adsi.filter = "(&(givenname=$FilterFirstName)(sn=$FilterLastName))" 
        } elseif ($FilterFirstName) {
            if (-not $Quiet) { Write-Log 'Processing user - FirstName',$FilterFirstName,'from DC',$Server Green,Cyan,Green,Cyan }
            $adsi.filter = "(givenname=$FilterFirstName)" 
        } elseif ($FilterLastName) {
            if (-not $Quiet) { Write-Log 'Processing user - LastName',$FilterLastName,'from DC',$Server Green,Cyan,Green,Cyan }
            $adsi.filter = "(sn=$FilterLastName)" 
        } else {
            if (-not $Quiet) { Write-Log 'Processing user objects from DC', $Server Green,Cyan }
            $adsi.filter = "(&(objectClass=person)(samaccounttype=805306368))" # Filtering on person class objects, and type user account (not computer account)
        }
        $adsi.PageSize = 10000000 
        try {
            $adsi.FindAll() | foreach {
                $obj = $_.Properties                          # Property names are CASE SENSITIVE - all lowercase
                $UACDescription = (Parse-UserAccountControl -UAC ([Int32]($obj.useraccountcontrol -as [String]))).Name -join ', '
                $myOutput = New-Object -TypeName PSObject -Property ([ordered]@{
                    GivenName          = $($obj.givenname)
                    SurName            = $($obj.sn)
                    DisplayName        = $($obj.displayname)
                    UserName           = $($obj.name)
                    samaccountname     = $($obj.samaccountname)
                    DateCreated        = $($obj.whencreated)
                    useraccountcontrol = $($obj.useraccountcontrol)
                    UACDescription     = $UACDescription 
                    lastlogontimestamp = $(
                        try {
                            $Temp1 = [datetime]::FromFileTime($($obj.lastlogontimestamp) -as [int64])
                            if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                        } catch {'Never'}
                    )
                    DomainController   = $Server
                    Lastlogon          = $(
                        try {
                            $Temp1 = [datetime]::FromFileTime($($obj.lastlogon) -as [int64])
                            if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                        } catch {'Never'}                        
                    ) 
                    DateExpires        = $(try {[datetime]::FromFileTime($($obj.accountexpires) -as [int64])} catch {'Never'})
                    DistinguishedName  = $($obj.distinguishedname)
                    UserWorkstations   = $($obj.userworkstations)
                    PasswordLastSet    = $(try {[datetime]::FromFileTime($($obj.pwdlastset) -as [int64])} catch {'Never'})
                    MemberOf           = $($obj.memberof) # -join ' - '
                    sipProxyAddress    = $( if ($Temp = $obj.proxyaddresses -match 'sip:') { $Temp.Split(':')[1] } )
                })
                if ($Properties) { 
                    foreach ($UserAttribute in $Properties) { 
                        $myOutput | Add-Member -MemberType NoteProperty -Name $UserAttribute -EA 0 -Value $(
                            switch ($UserAttribute.ToLower()) {
                                {$_ -in @('sid','objectsid')} { (New-Object System.Security.Principal.SecurityIdentifier($($obj.objectsid),0)).Value } # Translate sid from Binary Array to String
                                {$_ -in @('guid','objectguid')} { Remove-Variable guidAsString -EA 0; $i = 0; $obj.objectguid -split ' ' | ForEach { $i ++; if ($i -in (5,7,9,11)) { $guidAsString += '-' }; $guidAsString += ([Byte]$_).ToString('x2').ToUpper() }; $guidAsString } # Translate guid from Octet Array to String
                                {$_ -eq 'enabled'} { if ($UACDescription -match 'AccountDisable') { 'False' } else { 'True' } }
                                default { $obj.($UserAttribute.ToLower()) }
                            } 
                        )
                    }
                }
            }
        } catch { Write-Log $_.Exception.Message Magenta }
    }
                      
    End { $myOutput }
}

function Get-SBADGroup {
<#
.SYNOPSIS
 Function to get details of an AD group
                       
.DESCRIPTION
 Function to get details of an AD group from Active Directory using LDAP
 Does not need ActiveDirectory PowerShell module
 Must be run from a domain-joined computer
 
.PARAMETER GroupName
 Optional parameter that accepts one or more AD group names.
 It also accepts wild cards, like 'Alaska*' to return all groups starting with 'Alaska'
 If omitted, all groups are returned.
 
.PARAMETER QUIET
 Optional switch. When set to True it supresses console output for faster processing.
                        
.PARAMETER AllProperties
 Optional switch. When set to True it retuens all group properties.
                        
.EXAMPLE
 Get-SBADGroup -GroupName 'DomainAdmins'
 Returns details and members of the 'DomainAdmins' AD group in the current AD domain
                        
.OUTPUTS
 Returns a PowerShell object containing the following properties/example:
    GroupName : My-Azure-Admin
    DN : CN=My-Azure-Admin,OU=Groups,OU=xxx,OU=xxx,DC=xxx,DC=MyCorp,DC=com
    AD_OU : Groups/xxx/xxx
    Scope : Global
    Category : Security
    ADCreated : 12/7/2018
    ADChanged : 3/6/2019
    MemberDNs : {CN=My-nvxxx,OU=xxx,OU=Users,OU=xxx,OU=xxx,DC=xxx,DC=MyCorp,DC=com, CN=My-bgxxx,OU=xxx,OU=Users,OU=xxx,OU=xxx,DC=xxx,DC=MyCorp,DC=com,
                  CN=My-sbxxx,OU=xxx,OU=Users,OU=xxx,OU=xxx,DC=xxx,DC=MyCorp,DC=com, CN=My-pkxxxx,OU=xxx,OU=Users,OU=xxx,OU=xxx,DC=xxx,DC=MyCorp,DC=com...}
    MemberNames : {My-nvxxx, My-bgxxx, My-sbxxx, My-pkxxx...}
 Returns nothing if the group name is not found
                       
.LINK
 https://superwidgets.wordpress.com/category/powershell/
                       
.NOTES
 Function by Sam Boutros
 v0.1 - 14 March 2019
 v0.2 - 22 October 2020
    Fixed bug to list all groups when using a wild card for a group name, not just the first 1,000
    Added 2 new properties:
        Scope: Global, Domain Local, or Universal
        Category: Security or Distribution
    Added Quiet switch to not display console output speeding up processing
 v0.3 - 22 November 2022
    Added AllProperties switch
    Updated logic for Scope and Category properties
#>

    [CmdletBinding(ConfirmImpact='Low')] 
    Param( 
        [Parameter(Mandatory=$false)][String[]]$GroupName,
        [Parameter(Mandatory=$false)][Switch]$Quiet,
        [Parameter(Mandatory=$false)][Switch]$AllProperties 
    )
                      
    Begin { }
                      
    Process{
        if ($IsDomainMember) {
            $adsi = [adsisearcher]"objectcategory=group"
            $adsi.PageSize = 1000000 
            if ($GroupName) {
                $GroupList = foreach ($Group in $GroupName) {
                    $adsi.filter = "(&(objectCategory=group)(cn=$Group))"
                    ($adsi.FindAll()).Properties
                }                
            } else {
                $adsi.filter = '(objectCategory=group)'
                $GroupList   = ($adsi.FindAll()).Properties
            }

            foreach ($ADGroup in $GroupList) {
                if (-not $Quiet) {
                    Write-Log 'Processing group',$ADGroup.distinguishedname Green,Cyan
                }
                if ($AllProperties) {
                    $myOutput = New-Object -TypeName PSObject -Property ([ordered]@{
                        DN          = [string]$ADGroup.distinguishedname
                        AD_OU       = [string](($ADGroup.distinguishedname) -replace '^CN=[\w\d-_]+,\w\w=','' -replace ',OU=','/' -replace ',DC=.*')
                        Scope       = $( 
                            switch ([Int32]"$($ADGroup.grouptype)") {
                                { $_ -band 2 } { 'Global' }
                                { $_ -band 4 } { 'Domain Local' }
                                { $_ -band 8 } { 'Universal' }
                                default        { "Unknown groupType $($ADGroup.grouptype)" }
                            }
                        )
                        Category    = $( if ([Int32]"$($ADGroup.grouptype)" -band -2147483648) { 'Security' } else { 'Distribution' } )
                        MemberNames = $( if ($ADGroup.member) { $ADGroup.member | foreach { $_.Split(',')[0].Split('=')[1] } } )
                    })
                    foreach ($Prop in ($ADGroup.Keys | sort)) {
                        $myOutput | Add-Member -MemberType NoteProperty -Name $Prop -Value $($ADGroup.$Prop)
                    }
                    $myOutput
                } else {
                    New-Object -TypeName PSObject -Property ([ordered]@{
                        GroupName   = [string]$ADGroup.name
                        DN          = [string]$ADGroup.distinguishedname
                        AD_OU       = [string](($ADGroup.distinguishedname) -replace '^CN=[\w\d-_]+,\w\w=','' -replace ',OU=','/' -replace ',DC=.*')
                        Scope       = $( 
                            switch ([Int32]"$($ADGroup.grouptype)") {
                                { $_ -band 2 } { 'Global' }
                                { $_ -band 4 } { 'Domain Local' }
                                { $_ -band 8 } { 'Universal' }
                                default        { "Unknown groupType $($ADGroup.grouptype)" }
                            }
                        )
                        Category    = $( if ([Int32]"$($ADGroup.grouptype)" -band -2147483648) { 'Security' } else { 'Distribution' } )
                        ADCreated   = ($ADGroup.whencreated).ToShortDateString()
                        ADChanged   = ($ADGroup.whenchanged).ToShortDateString()
                        MemberDNs   = $ADGroup.member
                        MemberNames = $( if ($ADGroup.member) { $ADGroup.member | foreach { $_.Split(',')[0].Split('=')[1] } } )
                    })
                }
            }
                       
        } else {
            Write-Log 'This function','Get-SBADGroup','must be invoked from a domain-joined computer' Magenta, Yellow, Magenta
        }
    }
                      
    End { }
}

function Get-SBADGroupMembers {
<#
.SYNOPSIS
 Function to get members of AD group including sub-groups
                       
.DESCRIPTION
 Function to get members of AD group including sub-groups using LDAP
 Does not need ActiveDirectory PowerShell module
 Must be run from a domain-joined computer
 
.PARAMETER GroupName
 Name of the AD group - required
               
.PARAMETER Parent
 Name of the parent AD group - optional - used to enable the recursive use to search sub-groups
               
.PARAMETER Recurse
 Switch that is set to True by default. It causes this function to search sub-groups
               
.EXAMPLE
 Get-SBADGroupMembers testgroup1
                        
.OUTPUTS
 Returns a PowerShell object containing the following properties/example:
    UserName DN OU MemberOf
    -------- -- -- --------
    testuser1 CN=testuser1,DC=abcd,DC=local abcd testgroup1
    testuser2 CN=testuser2,DC=abcd,DC=local abcd testgroup2.testgroup1
 Returns nothing if the group name is not found
                       
.LINK
 https://superwidgets.wordpress.com/category/powershell/
                       
.NOTES
 Function by Sam Boutros
    v0.1 - 15 June 2019
    v0.2 - 25 September 2019 - Fixed bug with Group members, added 'mail' property to to group members
#>

    [CmdletBinding(ConfirmImpact='Low')] 
    Param( 
        [Parameter(Mandatory=$true)][String]$GroupName,
        [Parameter(Mandatory=$false)][String]$Parent,
        [Parameter(Mandatory=$false)][Switch]$Recurse = $true 
    )
                      
    Begin { }
                      
    Process{
        $myOutput = if ($IsDomainMember) {
            $adsi = [adsisearcher]"objectcategory=group"
            $adsi.filter = "(&(objectCategory=group)(cn=$GroupName))"
            if ($ADGroup = ($adsi.FindAll()).Properties) {

                if ($Parent) {
                    Write-Log 'Processing child group',$ADGroup.distinguishedname,"(Parent: $Parent)" Green,Cyan,DarkYellow
                } else {
                    Write-Log 'Processing group ',$ADGroup.distinguishedname Green,Cyan
                }
                $GroupObj = [PSCustomObject][ordered]@{
                    GroupName   = [string]$ADGroup.name
                    MemberNames = $( if ($ADGroup.member) { $ADGroup.member | foreach { $_.Split(',')[0].Split('=')[1] } } )
                }    
                    
                foreach ($Member in $GroupObj.MemberNames) {
                    $adsi = [adsisearcher]''
                    $adsi.filter = "cn=$Member"
                    $MemberObj = ($adsi.FindAll()).Properties 
                    if ($MemberObj.objectclass -match 'group') { 
                         if ($Recurse) { Get-SBADGroupMembers $MemberObj.name -Parent $GroupObj.GroupName }
                    } else { 
                        [PSCustomObject][ordered]@{
                            UserName    = [string]$MemberObj.name
                            Mail        = [string]$MemberObj.mail
                            DN          = [string]$MemberObj.distinguishedname
                            OU          = [string](($MemberObj.distinguishedname) -replace '^CN=[\w\d-_]+,\w\w=','' -replace ',OU=','/' -replace ',DC=.*')
                            MemberOf    = $( 
                                if ($Parent) { "$($GroupObj.GroupName).$Parent" } else { $GroupObj.GroupName } 
                            )
                        } 
                    }
                }   
            
            }  else { 
                Write-Log 'Group',$GroupName,'not found' Green,Yellow,Cyan
            }       
 
        } else {
            Write-Log 'This function','Get-SBADGroupMembers','must be invoked from a domain-joined computer' Magenta, Yellow, Magenta
        }                            

    }
                      
    End { $myOutput }
}

function Report-LastLogon {
<#
 .SYNOPSIS
  Function to report on last logon information for users in a given AD domain
 
 .DESCRIPTION
  Function to report on last logon information for users in a given AD domain
  This function depends on ImportExcel and ActiveDirectory PowerShell modules
  This function runs parallel jobs to process the retrieval of last logon information concurrently.
  If a given domain controller is accessible via PowerShell remoting (TCP 5985), this function will invoke a remote job,
  otherwise it will invoke a local job.
 
 .PARAMETER DomainName
  Active Directory domain name such as myaddomain.com.
 
 .PARAMETER DCName
  Any accessible domain controller in the above domain.
 
 .PARAMETER Cred
  Credential used to invoke remote Get-ADuser commands against the domain controllers.
  This can be obtained via the Get-Credential cmdlet or the Get-SBCredential function.
 
 .PARAMETER Filter
  Optional Get-ADuser Filter such as
  'Enabled -eq $True -and Mail -like "*" -and ManagerName -like "*" -and EmployeeID -like "*" -and EmployeeID -notlike "*-*"'
 
 .PARAMETER OUFilter
  Optional Get-ADuser SearchBase to filter per OU.
 
 .PARAMETER ExcludeDC
  Known offline domain controller list.
 
 .PARAMETER ReportFolder
  Path to existing folder where this function will write its log and Excel reports.
 
 .EXAMPLE
  Report-LastLogon -DomainName $thisDomainName -DCName $thisDomainDCList[0] -Cred (Get-SBCredential -UserName "$Env:USERDNSDOMAIN\$env:USERNAME")
 
 .EXAMPLE
  $ParamList = @{
    DomainName = $thisDomainName
    DCName = $thisDomainDCList[0]
    Cred = (Get-SBCredential -UserName "$Env:USERDNSDOMAIN\$env:USERNAME")
    Filter = 'Enabled -eq $True -and Mail -like "*" -and ManagerName -like "*" -and EmployeeID -like "*" -and EmployeeID -notlike "*-*"'
  }
  Report-LastLogon @ParamList
 
 .OUTPUTS
  This cmdlet creates an Excel report for the identified users with the following fields/columns:
    FirstName
    LastName
    EmployeeId
    SamAccountName
    LastLogon
    UPN
    DN
    DomainController
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 2 February 2021
  v0.2 - 21 Feb 2023 - Added OUFilter parameter and related code.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true,HelpMessage='Active Directory domain name such as myaddomain.com')][String]$DomainName,
        [Parameter(Mandatory=$true,HelpMessage='Any accessible domain controller in the above domain')][String]$DCName,
        [Parameter(Mandatory=$true,HelpMessage='Credential used to invoke remote Get-ADuser commands against the domain controllers')][PSCredential]$Cred, 
        [Parameter(Mandatory=$false,HelpMessage='Optional Get-ADuser Filter such as ''Enabled -eq $True -and Mail -like "*" -and ManagerName -like "*" -and EmployeeID -like "*" -and EmployeeID -notlike "*-*"''')][String]$Filter = '*',
        [Parameter(Mandatory=$false,HelpMessage='Optional Get-ADuser OU Filter (SearchBase) such as ''OU=myOU,DC=mydomain,DC=com''')][String]$OUFilter,
        [Parameter(Mandatory=$false,HelpMessage='Known offline domain controller list')][String[]]$ExcludeDC, 
        [Parameter(Mandatory=$false)][String]$ReportFolder = '.\'
    )

    Begin { 

        #region Check required PS Modules
        $StartTime = Get-Date
        $ModuleList = @('AZSBTools','ImportExcel')
        foreach ($Module in $ModuleList) {
            if (-not (Get-Module -Name $Module -ListAvailable)) {
                Install-Module $ModuleList -Force -AllowClobber -Scope CurrentUser
            }
        }
        Import-Module $ModuleList -DisableNameChecking -Force -WA 0 | Out-Null
        #endregion

        #region Check required folders
        try { Set-Location (Split-Path -Parent $MyInvocation.MyCommand.Path) } catch {}
        if (-not (Test-Path $ReportFolder)) {
            Write-Log '$ReportFolder',$ReportFolder,'does not exist, using current folder',(Get-Location).Path,'instead...' Magenta,Yellow,Cyan,Yellow,Cyan
            $ReportFolder = (Get-Location).Path
        }
        New-Item "$ReportFolder\Logs" -ItemType Directory -Force -EA 0 | Out-Null # Quietly create Logs subfolder if it does not exist
        $LogFile = "$ReportFolder\Logs\Report-LastLogon-$DomainName-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
        $ReportFile = "$ReportFolder\Report-LastLogon-$DomainName-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx"
        #endregion

        #region Echo input parameters
        Write-Log 'Current location:',(Get-Location) Green,Cyan $LogFile
        Write-Log 'Current User: ',(Whoami) Green,Cyan $LogFile
        Write-Log 'Elevation: ',$IsElevated Green,Cyan $LogFile
        Write-Log 'Script location: ',$(try { Split-Path -Parent $MyInvocation.MyCommand.Path } catch {}) Green,Cyan $LogFile
        Write-Log 'Current modules: ',(Get-Module | Out-String).Trim() Green,Cyan $LogFile
        Write-Log 'Input received: ' Green $LogFile
        Write-Log ' DomainName: ',$DomainName Green,Cyan $LogFile
        Write-Log ' ReportFolder: ',$ReportFolder Green,Cyan $LogFile
        Write-Log ' DCName: ',$DCName Green,Cyan $LogFile
        Write-Log ' ExcludeDC: ',($ExcludeDC -join ', ') Green,Cyan $LogFile
        Write-Log ' Filter: ',$Filter Green,Cyan $LogFile
        Write-Log ' OU Filter: ',$OUFilter Green,Cyan $LogFile
        Write-Log ' Credential: ',$Cred.UserName Green,Cyan $LogFile
        #endregion

        #region Get DC list, check connectivity
        try {
            $DCList = Get-DCList -DCName $DCName -Cred $Cred -EA 1
        } catch {
            Write-Log 'Unable to get DC List, do we have the correct credential?' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }
        $ThisDomainDCList = ($DCList | where DomainName -EQ $DomainName).DCList.Name | sort
        $ThisDomainDCList = $ThisDomainDCList | foreach { if ($_ -notin $ExcludeDC) { $_ } }
        $thisDCList = foreach ($DC in $ThisDomainDCList) {
            Write-Log 'Checking if DC',($DC).PadRight(35,' '),'is reachable:' Green,Cyan,Green $LogFile -NoNewLine
            if ($Result = Test-SBNetConnection -ComputerName $DC -PortNumber 389,5985 -TimeoutSec 10 -WA 0) {
                [PSCustomObject]@{
                    Name = $DC
                    Port389Open = $Result[0].TcpTestSucceeded
                    Port5985Open = $Result[1].TcpTestSucceeded
                }
                if ($Result[0].TcpTestSucceeded) {
                    Write-Log 'LDAP port 389 OK,' DarkYellow $LogFile -NoNewLine
                } else {
                    Write-Log 'LDAP port 389 unreachable,' Magenta $LogFile -NoNewLine
                }
                if ($Result[1].TcpTestSucceeded) {
                    Write-Log 'PS Remoting port 5985 OK' DarkYellow $LogFile
                } else {
                    Write-Log 'PS Remoting port 5985 unreachable' Magenta $LogFile
                }
            } else {
                Write-Log 'Unable to reach LDAP port 389 or PS Remoting port 5985' Magenta $LogFile
            }
        }
        if ($thisDCList.Count -lt 1) {
            Write-Log 'No reachable DCs found !?' Magenta $LogFile
            break
        }
        #endregion

        $PropertyList = @(
            'sn'
            'givenname'
            'samaccountname'
            'lastlogon'
            'EmployeeId'
            'DistinguishedName'
        ) # When changing, also change $CombinedUserList output object
    
    }

    Process {      

        $Duration = Measure-Command {
            Get-Job | Remove-Job -Force
            Write-Log 'Starting jobs..' Green $LogFile
            foreach ($DC in $thisDCList) {
                if ($DC.Port5985Open) { # Remote Job
                    if ($OUFilter) {
                        Invoke-Command -AsJob -ComputerName $DC.Name -JobName $DC.Name -Credential $Cred -ScriptBlock {
                            try {
                                Import-Module ActiveDirectory -EA 1 # For Win 2008 servers running PS 2 :(
                                try {
                                    Get-ADUser -Filter $Using:Filter -SearchBase $Using:OUFilter -Properties $Using:PropertyList -EA 1
                                } Catch {
                                    $_.Exception.Message
                                }
                            } Catch {
                                $_.Exception.Message
                            }
                        }
                    } else {
                        Invoke-Command -AsJob -ComputerName $DC.Name -JobName $DC.Name -Credential $Cred -ScriptBlock {
                            try {
                                Import-Module ActiveDirectory -EA 1 # For Win 2008 servers running PS 2 :(
                                try {
                                    Get-ADUser -Filter $Using:Filter -Properties $Using:PropertyList -EA 1
                                } Catch {
                                    $_.Exception.Message
                                }
                            } Catch {
                                $_.Exception.Message
                            }
                        }
                    }
                } elseif ($DC.Port389Open) { # Local Job
                    if ($OUFilter) {
                        Start-Job -Name $DC.Name -Credential $Cred -ScriptBlock {
                            try {
                                Get-ADUser -Filter $Using:Filter -SearchBase $Using:OUFilter -Server $Using:DC.Name -Properties $Using:PropertyList -EA 1
                            } Catch {
                                $_.Exception.Message
                            }
                        }
                    } else {
                        Start-Job -Name $DC.Name -Credential $Cred -ScriptBlock {
                            try {
                                Get-ADUser -Filter $Using:Filter -Server $Using:DC.Name -Properties $Using:PropertyList -EA 1
                            } Catch {
                                $_.Exception.Message
                            }
                        }
                    }
                } else {
                    Write-Log ' Skipping inaccessible DC',$DC.Name Green,Cyan $LogFile
                }
            }

            $JobMonitor = foreach ($JobStatus in (Get-Job)) {
                if ($JobStatus.State -eq 'Running') { $StatusColor = 'DarkYellow' } else { $StatusColor = 'Yellow' }
                Write-Log 'Remote Job',($JobStatus.Name).PadRight(35,' '),$JobStatus.State Green,Cyan,$StatusColor $LogFile
                [PSCustomObject]@{
                    Name = $JobStatus.Name
                    State = $JobStatus.state
                    Changed = $false
                    StartTime = Get-Date
                    Duration = $null
                }
            }
            Write-Log 'Monitoring Jobs'' status..' Green $LogFile

            $LiveStatus = Get-job
            $DisplayJobStatusScriptBlock = {
                $thisJobMonitor = $JobMonitor | where Name -EQ $JobStatus.Name
                if ($JobStatus.State -ne $thisJobMonitor.State -and -not $thisJobMonitor.Changed) { # Only display changed job status (once)
                    $thisJobMonitor.Changed = $true
                    $thisJobMonitor.Duration = New-TimeSpan -Start $thisJobMonitor.StartTime -End (Get-Date) # Record and display each DC job time
                    if ($JobStatus.State -eq 'Running') { $StatusColor = 'DarkYellow' } else { $StatusColor = 'Yellow' }
                    if ($Jobstatus.PSJobTypeName -eq 'BackgroundJob') { Write-Log 'Local Job' Yellow $LogFile -No } else { Write-Log 'Remote Job' Green $LogFile -No }
                    Write-Log ($JobStatus.Name).PadRight(35,' '),"$($JobStatus.State) in" Cyan,$StatusColor $LogFile -NoNewLine
                    Write-Log "$($thisJobMonitor.Duration.Hours):$($thisJobMonitor.Duration.Minutes):$($thisJobMonitor.Duration.Seconds) (hh:mm:ss)" DarkYellow $LogFile
                }
            }
            while (($LiveStatus | where State -eq 'Running')) {
                foreach ($JobStatus in $LiveStatus) {
                    if ($JobStatus.State -eq 'Failed' -and $JobStatus.PSJobTypeName -eq 'RemoteJob') { # Remote Job failed, try Local Job
                        $DC = $JobStatus.Name
                        Get-Job -Name $JobStatus.Name | Remove-Job
                        Start-Job -Name $DC -Credential $Cred -ScriptBlock {
                        try {
                            Get-ADUser -Filter $Using:Filter -Server $Using:DC.Name -Properties $Using:PropertyList -EA 1
                        } Catch {
                            $_.Exception.Message
                        }
                    }
                    Write-Log ($JobStatus.Name).PadRight(35,' '),$JobStatus.State,'trying Local Job..' Cyan,$StatusColor,Red $LogFile
                    $JobStatus = Get-Job -Name $DC
                    if ($JobStatus.State -eq 'Running') { $StatusColor = 'DarkYellow' } else { $StatusColor = 'Yellow' }
                        Write-Log 'Local Job',$DC.PadRight(35,' '),$JobStatus.State Yellow,Cyan,$StatusColor $LogFile
                    } else {
                        & $DisplayJobStatusScriptBlock
                    }
                }
                Start-Sleep -Seconds 1
            }
            & $DisplayJobStatusScriptBlock
        } # Start and wait for Jobs

        $Duration = Measure-Command {
            Write-Log 'Receiving job data..' Green $LogFile -NoNewLine
            $CombinedUserList = foreach ($DC in $thisDCList.Name) {
                $Temp = Receive-Job -Name $DC
                if ($Temp.SamAccountName) { # Job returning expected data, accept it
                    $Temp
                } else { # Job not returning expected data, probably an error, display it
                    Write-Log 'Job error',$DC,$Temp Yellow,Magenta,Yellow $LogFile
                }
            }
            Get-Job | Remove-Job -Force
            $CombinedUserList = $CombinedUserList | foreach {
                [PSCustomObject][Ordered]@{
                    FirstName        = $_.GivenName
                    LastName         = $_.Surname
                    EmployeeId       = $_.EmployeeId
                    SamAccountName   = $_.SamAccountName
                    LastLogon        = $(
                        try {
                            $Temp1 = [DateTime]::FromFileTime($($_.lastlogon) -as [Int64])
                            if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                        } catch { 'Never' }
                    )
                    UPN              = $_.UserPrincipalName
                    DN               = $_.DistinguishedName
                    DomainController = $_.PSComputerName
                }
            }
            $CombinedUserList = $CombinedUserList | where LastLogon -NE 'Never'
        } # Receive Job data
        Write-Log 'Received',$CombinedUserList.Count,'filtered user logins, in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" Green,Cyan,Green,DarkYellow $LogFile

        $Duration = Measure-Command {
            Write-Log 'Processing',$CombinedUserList.Count,'user login time stamps...' Green,Cyan,Green $LogFile -NoNewLine
            $myOutput = $CombinedUserList | group SamAccountName | foreach {
                $_.Group | sort LastLogon | select -Last 1
            }
            $myOutput = $myOutput | sort LastName,FirstName
        } # Process user logins
        Write-Log 'Done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" Cyan,DarkYellow $LogFile

    }

    End { 
        Write-Log 'Exporting report to file',$ReportFile Green,Cyan $LogFile -NoNewLine
        $Duration = Measure-Command {
            $myOutput | Export-Excel -Path $ReportFile -ConditionalText $(
            ($myOutput | Get-Member -MemberType NoteProperty).Name | foreach { New-ConditionalText $_ White SteelBlue }
            ) -AutoSize -FreezeTopRowFirstColumn
        }
        Write-Log ' Done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)(hh:mm:ss)" Green,DarkYellow $LogFile

        $CombinedDuration = New-TimeSpan -Start $StartTime -End (Get-Date)
        Write-Host ' '
        Write-Log 'All done in',"$($CombinedDuration.Hours):$($CombinedDuration.Minutes):$($CombinedDuration.Seconds) (hh:mm:ss)" Cyan,DarkYellow $LogFile
        Write-Host ' '    
    }
} 

function Get-OUFromDN {
<#
 .SYNOPSIS
  Function to return an AD OU (Active Directory Organization Unit) based on a provided Distinguished Name
 
 .DESCRIPTION
  Function to report on last logon information for users in a given AD domain
  This function depends on ImportExcel and ActiveDirectory PowerShell modules
  This function runs parallel jobs to process the retrieval of last logon information concurrently.
  If a given domain controller is accessible via PowerShell remoting (TCP 5985), this function will invoke a remote job,
  otherwise it will invoke a local job.
 
 .PARAMETER DistinguishedName
  Active Directory Distinguished Name such as CN=Sam Boutros,OU=USA,DC=MyDomain,DC=local
 
 .EXAMPLE
  Get-OUFromDN -DistinguishedName 'CN=Sam Boutros,OU=USA,DC=MyDomain,DC=local'
 
 .OUTPUTS
  This cmdlet returns a string like
    OU=USA,DC=MyDomain,DC=local
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 29 September 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true,HelpMessage='AD object DistinguishedName Name')][String]$DistinguishedName
    )

    Begin { }

    Process {      

        if ($DistinguishedName.IndexOf(',') -ge 0) {
            $PartList = $DistinguishedName -split ','
            $OUList = $PartList -match 'OU='
            if ($OUList) {
                ($OUList -join ','),($PartList -match 'DC=' -join ',') -join ','
            } else {
                Write-Warning "Get-OUFromDN Notice: No OU found in the provided DistinguishedName: '$DistinguishedName'"
            } # Return nothing if no OU is found.
        } else {
            Write-Warning "Get-OUFromDN Error: Bad DistinguishedName provided: '$DistinguishedName'"
        } # Must have a comma.

    }

    End { }
} 

function Parse-UserAccountControl {
<#
 .SYNOPSIS
  Function to parse userAccountControl attribute of an Active Directory user or computer object.
 
 .DESCRIPTION
  Function to parse userAccountControl attribute of an Active Directory user or computer object.
  For more information see https://docs.microsoft.com/en-GB/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
 
 .PARAMETER UAC
  This parameter takes an 32-bit integer that ranges from 0 to 2,147,483,647
  If not provided, this function will display the full list of userAccountControl attribute options.
 
 .EXAMPLE
  Parse-UserAccountControl 514
 
 .OUTPUTS
  Records similar to:
    Hex Name Desc
    --- ---- ----
      2 ACCOUNTDISABLE The user account is disabled.
    512 NORMAL_ACCOUNT It's a default account type that represents a typical user.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-GB/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 15 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param   ( [Parameter(Mandatory=$False)][Int32]$UAC )

    Begin   {  }

    Process { 
        if ($UAC) {
            $UserAccountControl | foreach { if ($UAC -band $_.Hex) { $_ } } 
        } else {
            $myUAC = $UserAccountControl | select @{n='Hex';e={"0x$(('{0:x}' -f $_.Hex))"}},@{n='Decimal';e={$_.Hex}},Name,@{n='Description';e={$_.Desc}}
            Write-Host ''
            Write-Log 'UserAccountControl details:' Green $LogFile
            Write-Log ($myUAC | Out-String).Trim() Cyan $LogFile
            $myUAC | Export-Csv '.\UserAccountControl.csv' -NoTypeInformation
            Write-Log 'UserAccountControl detailed list saved to',(Get-Item '.\UserAccountControl.csv').FullName Green,Yellow $LogFile
        }
    }

    End     {  }
} 

function Parse-msDSSupportedEncryptionTypes {
<#
 .SYNOPSIS
  Function to parse msDS-SupportedEncryptionTypes attribute of an Active Directory user or computer object.
 
 .DESCRIPTION
  Function to parse msDS-SupportedEncryptionTypes attribute of an Active Directory user or computer object.
  For more information see https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/6cfc7b50-11ed-4b4d-846d-6f08f0812919
 
 .PARAMETER UAC
  This parameter takes an 32-bit integer that ranges from 0 to 2,147,483,647
  If not provided, this function will display the full list of msDS-SupportedEncryptionTypes attribute options.
 
 .EXAMPLE
  Parse-msDSSupportedEncryptionTypes 24
 
 .OUTPUTS
  Records similar to:
    Id Name
    -- ----
    16 AES256-CTS-HMAC-SHA-1-96
     8 AES128-CTS-HMAC-SHA-1-96
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/6cfc7b50-11ed-4b4d-846d-6f08f0812919
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 18 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param   ( [Parameter(Mandatory=$False)][Int32]$msDSSupportedEncryptionType )

    Begin   {  }

    Process { 
        if ($msDSSupportedEncryptionType) {
            $msDSSupportedEncryptionTypes | foreach { if ($msDSSupportedEncryptionType -band $_.Id) { $_ } } 
        } else {
            $myMsDSSET = $msDSSupportedEncryptionTypes | select @{n='Hex';e={"0x$(('{0:x}' -f $_.Id))"}},@{n='Decimal';e={$_.Id}},Name | sort Decimal
            Write-Host ''
            Write-Log 'msDS-SupportedEncryptionTypes details:' Green $LogFile
            Write-Log ($myMsDSSET | Out-String).Trim() Cyan $LogFile
            $myMsDSSET | Export-Csv '.\msDS-SupportedEncryptionTypes.csv' -NoTypeInformation
            Write-Log 'msDS-SupportedEncryptionTypes detailed list saved to',(Get-Item '.\msDS-SupportedEncryptionTypes.csv').FullName Green,Yellow $LogFile
        }
    }

    End     {  }
} 

function Parse-KTicketEncType {
<#
 .SYNOPSIS
  Function to parse Kerberos Enryption Type value.
 
 .DESCRIPTION
  Function to parse Kerberos Enryption Type value.
  These values can be seen in Security event log, events 4769 and 4770.
 
 .PARAMETER TicketEncType
  This parameter takes an 32-bit integer that ranges from 0 to 2,147,483,647
  If not provided, this function will display the full list of Kerberos Enryption Types.
 
 .PARAMETER Silent
  When this switch is used, this function will not display the full list of Kerberos Enryption Types.
 
 .EXAMPLE
  Parse-KTicketEncType 23
  This returns a record like:
     Id Name
     -- ----
     23 RC4-HMAC
 
 .EXAMPLE
  Parse-KTicketEncType
  This prints a list of known Kerberos Ticket Encryption Types and exports them to CSV file in the current folder:
    Kerberos Ticket Encryption Type details:
    Hex Decimal Name
    --- ------- ----
    0x1 1 DES-CBC-CRC
    0x2 2 DES-CBC-MD4
    0x3 3 DES-CBC-MD5
    0x4 4 [Reserved]
    0x5 5 DES3-CBC-MD5
    0x6 6 [Reserved]
    0x7 7 DES3-CDC-SHA1
    0x9 9 dsaWithSHA1-CmsOID
    0xa 10 md5WithRSAEncryption-CmsOID
    0xb 11 sha1WithRSAEncryption-CmsOID
    0xc 12 rc2CBC-EnvOID
    0xd 13 rsaEncryption-EnvOID
    0xe 14 rsaES-OAEP-ENV-OID
    0xf 15 des-ede3-cbc-Env-OID
    0x10 16 des3-cbc-sha1-kd
    0x11 17 AES128-CTS-HMAC-SHA-1
    0x12 18 AES256-CTS-HMAC-SHA-1
    0x17 23 RC4-HMAC
    0x18 24 RC4-HMAC-EXP
    0x41 65 subkey-keymaterial
    Kerberos Ticket Encryption Type detailed list saved to C:\Sandbox\TicketEncType.csv
 
 .EXAMPLE
  Parse-KTicketEncType 233
  This returns a record like:
     Id Name
     -- ----
    233 Unknown
 
 .OUTPUTS
  Record similar to:
    Id Name
    -- ----
    23 RC4-HMAC
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/archive/blogs/askds/hunting-down-des-in-order-to-securely-deploy-kerberos
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param   ( 
        [Parameter(Mandatory=$False)][Int32]$TicketEncType,
        [Parameter(Mandatory=$False)][Switch]$Silent 
    )

    Begin   {  }

    Process { 
        if ($TicketEncType) {
            if ($FoundType = $KTicketEncType | where Id -EQ $TicketEncType) { 
                $FoundType
            } else { 
                New-Object -TypeName PSObject -Property @{ Id = $TicketEncType ; Name = 'Unknown' } 
            }
        } else {
            if (-not $Silent) {
                $myTicketEncType = $KTicketEncType | select @{n='Hex';e={"0x$(('{0:x}' -f $_.Id))"}},@{n='Decimal';e={$_.Id}},Name | sort Decimal
                Write-Host ''
                Write-Log 'Kerberos Ticket Encryption Type details:' Green $LogFile
                Write-Log ($myTicketEncType | Out-String).Trim() Cyan $LogFile
                $myTicketEncType | Export-Csv '.\TicketEncType.csv' -NoTypeInformation
                Write-Log 'Kerberos Ticket Encryption Type detailed list saved to',(Get-Item '.\TicketEncType.csv').FullName Green,Yellow $LogFile
            }
        }
    }

    End     {  }
} 

function Parse-KerberosTicketOptions {
<#
 .SYNOPSIS
  Function to parse Kerberos Ticket Options.
 
 .DESCRIPTION
  Function to parse Kerberos Ticket Options.
  These are found in EventLog events 4769 and 4770.
 
 .PARAMETER KTicketOptions
  This parameter takes an 32-bit integer that ranges from 0 to 2,147,483,647
  If not provided, this function will display the full list of Kerberos Ticket Options.
 
 .PARAMETER Silent
  When this switch is used, this function will not display the full list of Kerberos Ticket Options.
 
 .EXAMPLE
  Parse-KerberosTicketOptions 0x40810010
  This example will return output like:
            Id Name Description
            -- ---- -----------
    1073741824 Forwardable (TGT only). Tells the ticket-granting service that it can issue a new TGT—based on the presented TGT—with a different network address based on the presented TGT.
       8388608 Renewable Used in combination with the End Time and Renew Till fields to cause tickets with long life spans to be renewed at the KDC periodically.
         65536 Name-canonicalize In order to request referrals the Kerberos client MUST explicitly request the “canonicalize” KDC option for the AS-REQ or TGS-REQ.
            16 Renewable-ok The RENEWABLE-OK option indicates that a renewable ticket will be acceptable if a ticket with the requested life cannot otherwise be provided, in which case a renewable ticket may be issued with a renew-till equal to the requ...
 
 .EXAMPLE
  Parse-KerberosTicketOptions
  This example will display Kerberos Ticket Options and export it to CSV:
    Kerberos Ticket Options details:
    Hex Decimal Name Description
    --- ------- ---- -----------
    0x0 0 Reserved
    0x40000000 1073741824 Forwardable (TGT only). Tells the ticket-granting service that it can issue a new TGT—based on the presented TGT—with a different network address based on the presented TGT.
    0x20000000 536870912 Forwarded Indicates either that a TGT has been forwarded or that a ticket was issued from a forwarded TGT.
    0x10000000 268435456 Proxiable (TGT only). Tells the ticket-granting service that it can issue tickets with a network address that differs from the one in the TGT.
    0x8000000 134217728 Proxy Indicates that the network address in the ticket is different from the one in the TGT used to obtain the ticket.
    0x4000000 67108864 Allow-postdate Postdated tickets SHOULD NOT be supported in KILE (Microsoft Kerberos Protocol Extension).
    0x2000000 33554432 Postdated Postdated tickets SHOULD NOT be supported in KILE (Microsoft Kerberos Protocol Extension).
    0x1000000 16777216 Invalid This flag indicates that a ticket is invalid, and it must be validated by the KDC before use. Application servers must reject tickets which have this flag set.
    0x800000 8388608 Renewable Used in combination with the End Time and Renew Till fields to cause tickets with long life spans to be renewed at the KDC periodically.
    0x400000 4194304 Initial Indicates that a ticket was issued using the authentication service (AS) exchange and not issued based on a TGT.
    0x200000 2097152 Pre-authent Indicates that the client was authenticated by the KDC before a ticket was issued. This flag usually indicates the presence of an authenticator in the ticket. It can also flag the presence of credentials tak...
    0x100000 1048576 Opt-hardware-auth This flag was originally intended to indicate that hardware-supported authentication was used during pre-authentication. This flag is no longer recommended in the Kerberos V5 protocol. KDCs MUST NOT issue a ...
    0x80000 524288 Transited-policy-checked KILE MUST NOT check for transited domains on servers or a KDC. Application servers MUST ignore the TRANSITED-POLICY-CHECKED flag.
    0x40000 262144 Ok-as-delegate The KDC MUST set the OK-AS-DELEGATE flag if the service account is trusted for delegation.
    0x20000 131072 Request-anonymous KILE not use this flag.
    0x10000 65536 Name-canonicalize In order to request referrals the Kerberos client MUST explicitly request the “canonicalize” KDC option for the AS-REQ or TGS-REQ.
    0x8000 32768 Unused
    0x4000 16384 Unused
    0x2000 8192 Unused
    0x1000 4096 Unused
    0x800 2048 Unused
    0x400 1024 Unused
    0x200 512 Unused
    0x100 256 Unused
    0x80 128 Unused
    0x40 64 Unused
    0x20 32 Disable-transited-check By default the KDC will check the transited field of a TGT against the policy of the local realm before it will issue derivative tickets based on the TGT. If this flag is set in the request, checking of the ...
    0x10 16 Renewable-ok The RENEWABLE-OK option indicates that a renewable ticket will be acceptable if a ticket with the requested life cannot otherwise be provided, in which case a renewable ticket may be issued with a renew-till...
    0x8 8 Enc-tkt-in-skey No information.
    0x4 4 Unused
    0x2 2 Renew The RENEW option indicates that the present request is for a renewal. The ticket provided is encrypted in the secret key for the server on which it is valid. This option will only be honored if the ticket to...
    0x1 1 Validate This option is used only by the ticket-granting service. The VALIDATE option indicates that the request is to validate a postdated ticket. Should not be in use, because postdated tickets are not supported by...
    Kerberos Ticket Options detailed list saved to C:\Sandbox\KerberosTicketOptions.csv
 
 .EXAMPLE
  (Parse-KerberosTicketOptions 0x40810000).Name -join ', '
  This example will return output like:
  Forwardable, Renewable, Name-canonicalize
 
 .EXAMPLE
  (Parse-KerberosTicketOptions 0x60810010).Name -join ', '
  This example will return output like:
  Forwardable, Forwarded, Renewable, Name-canonicalize, Renewable-ok
 
 .OUTPUTS
  Records similar to:
            Id Name Description
            -- ---- -----------
    1073741824 Forwardable (TGT only). Tells the ticket-granting service that it can issue a new TGT—based on the presented TGT—with a different network address based on the presented TGT.
       8388608 Renewable Used in combination with the End Time and Renew Till fields to cause tickets with long life spans to be renewed at the KDC periodically.
         65536 Name-canonicalize In order to request referrals the Kerberos client MUST explicitly request the “canonicalize” KDC option for the AS-REQ or TGS-REQ.
            16 Renewable-ok The RENEWABLE-OK option indicates that a renewable ticket will be acceptable if a ticket with the requested life cannot otherwise be provided, in which case a renewable ticket may be issued with a renew-till equal to the requ...
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4769
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param   ( 
        [Parameter(Mandatory=$False)][Int32]$KTicketOptions, 
        [Parameter(Mandatory=$False)][Switch]$Silent 
    )

    Begin   {  }

    Process { 
        if ($KTicketOptions) {
            $KerberosTicketOptions | foreach { if ($KTicketOptions -band $_.Id) { $_ } } 
        } else {
            if (-not $Silent) {
                $myKerberosTicketOptions = $KerberosTicketOptions | select @{n='Hex';e={"0x$(('{0:x}' -f $_.Id))"}},@{n='Decimal';e={$_.Id}},Name,Description
                Write-Host ''
                Write-Log 'Kerberos Ticket Options details:' Green $LogFile
                Write-Log ($myKerberosTicketOptions | Out-String).Trim() Cyan $LogFile
                $myKerberosTicketOptions | Export-Csv '.\KerberosTicketOptions.csv' -NoTypeInformation
                Write-Log 'Kerberos Ticket Options detailed list saved to',(Get-Item '.\KerberosTicketOptions.csv').FullName Green,Yellow $LogFile
            }
        }
    }

    End     {  }
} 

function Install-SBActiveDirectory {
<#
 .SYNOPSIS
  Function to install SB version of ActiveDirectory module
 
 .DESCRIPTION
  Function to install SB version of ActiveDirectory module
  This overcomes the need to install RSAT tools on a Windows 10 machine.
 
 .PARAMETER Logfile
  Path to where this function will log its console output/error messages.
 
 .EXAMPLE
  Install-SBActiveDirectory
 
 .OUTPUTS
  This function returns console output as it updates teh $profile file.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 28 April 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Install-SBActiveDirectory_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { }

    Process { 
    
        # Is the file there?
        $SBADZipFile = "$(Split-Path -Path $PSCommandPath)\SBActiveDirectory.zip"
        try {        
            $null = Test-Path -Path $SBADZipFile -EA 1 
        } catch {
            Write-Log 'Install-SBActiveDirectory Error: SBActiveDirectory file',$SBADZipFile,'not found' Magenta,Yellow,Magenta $LogFile
            break 
        }        

        # Validate file integrity
        if (-not (Get-FileHash -Path $SBADZipFile -Algorithm SHA256).Hash -eq 'CD8600F91E628C5904D3D61244B0A8EF9E78D8F7B63B7A6B4004B23E7656F01C') {
            Write-Log 'Install-SBActiveDirectory Error: file',$SBADZipFile,'integrity validation failed' Magenta,Yellow,Magenta $LogFile
            break 
        }

        # Unzip the file
        if ($IsElevated) {
            $TargetFolder = "$env:ProgramFiles\WindowsPowerShell\Modules"
        } else {
            $TargetFolder = "$([Environment]::GetFolderPath("MyDocuments"))\WindowsPowerShell\Modules"
        } 
        $null = New-Item -Path $TargetFolder -ItemType Directory -Force -EA 0
        Expand-Archive -Path $SBADZipFile -DestinationPath $TargetFolder

        # Load the required assemblies
        Import-Module "$TargetFolder\SBActiveDirectory\Microsoft.ActiveDirectory.Management.dll","$TargetFolder\SBActiveDirectory\Microsoft.ActiveDirectory.Management.resources.dll"

        # Add command to load the required assemblies in $profile
        $Lines2Add = @()
        $Lines2Add += ''
        $Lines2Add += "Import-Module ""$TargetFolder\SBActiveDirectory\Microsoft.ActiveDirectory.Management.dll"",""$TargetFolder\SBActiveDirectory\Microsoft.ActiveDirectory.Management.resources.dll"""
        $Lines2Add | foreach { New-PSProfile -Content $_ }

    }

    End {  }
} 

function Get-ExchangeServerList {
<#
 .SYNOPSIS
  Function to get a list of exchange servers in the current AD domain.
 
 .DESCRIPTION
  Function to get a list of exchange servers in the current AD domain.
  This function uses LDAP and does not use ActiveDirectory PowerShell cmdlets.
   
 .PARAMETER ShowAllProperties
  Optional switch that returns all properties of found Exchange servers.
    
 .EXAMPLE
  Get-ExchangeServerList -ShowAllProperties
 
 .OUTPUTS
  This cmdlet returns PSCustom Objects, one for each Exchange server containing the following properties:
    Name
    SerialNumber
    WhenCreated
    MsExchServerSite
    DistinguishedName
  If the -ShowAllProperties switch is used, then all properties are returned.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 10 October 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$ShowAllProperties
    )

    Begin {
        if (-not $IsDomainMember) {
            Write-Log 'Validate-TimeSync error: This cmdlet is designed to run from a domain joined computer' Magenta
            break
        }
    }

    Process {
        
        try {
            $RootDSE = New-Object -TypeName DirectoryServices.DirectoryEntry -ArgumentList 'LDAP://rootDse' -EA 1 
        } catch {
            Write-Log 'Get-ExchangeServerList Error:', $_.Exception.Message Magenta,Yellow
            break
        }

        try {
            $Searcher = New-Object DirectoryServices.DirectorySearcher -EA 1 
        } catch {
            Write-Log 'Get-ExchangeServerList Error:', $_.Exception.Message Magenta,Yellow
            break
        }
                
        $Searcher.Filter = '(objectCategory=msExchExchangeServer)'
        $Searcher.SearchRoot = "LDAP://$($RootDSE.Properties['configurationNamingContext'].Value)"
        $FoundList = $Searcher.FindAll().Properties

        if ($FoundList) {
            if ($ShowAllProperties) {
                foreach ($ExchServer in $FoundList) {
                    $ObjOut = New-Object -TypeName PSObject 
                    foreach ($Prop in ($ExchServer.PropertyNames | sort)) {
                        $ObjOut | Add-Member -MemberType NoteProperty -Name $Prop -Value $($ExchServer.$Prop)
                    }
                    $ObjOut
                }
            } else {
                foreach ($ExchServer in $FoundList) {
                    New-Object -TypeName PSObject -Property ([Ordered]@{
                        Name              = $($ExchServer.name)
                        SerialNumber      = $($ExchServer.serialnumber)
                        WhenCreated       = $($ExchServer.whencreated)
                        MsExchServerSite  = $($ExchServer.msexchserversite)
                        DistinguishedName = $($ExchServer.distinguishedname)
                    })
                }
            }
        } else {
            Write-Log 'No Exchange servers found in the',$thisDomainName,'domain' Yellow,Cyan,Green
        }

    }

    End { }
} 

function Get-ADSite {
<#
 .SYNOPSIS
  Function to get AD site of a given IPv4 address.
 
 .DESCRIPTION
  Function to get AD site of a given IPv4 address.
  This function uses the nltest tool and parses its output.
 
 .PARAMETER IPAddress
  This parameter accepts an IPv4 Address.
 
 .OUTPUTS
  The script outputs a PS object, with the following properties/example:
    IPAddress ADSite MappingFrom
    --------- ------ -----------
    192.168.34.42 Default-First-Site-Name vComputerName-DC2.Domain.local
 
 .EXAMPLE
  Get-ADSite -IPAddress 10.127.73.195
 
 .EXAMPLE
  Get-ADSite -IPAddress (Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Manual | select -First 1).IPAddress
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 24 October 2022
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][IPAddress]$IPAddress
    )
    
    Begin { }

    Process{
        
        try {
            $DsAddressToSite = nltest /dsaddresstosite:$IPAddress 2> $null
            if ($DsAddressToSite.Count -eq 5) {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    IPAddress   = $IPAddress
                    ADSite      = ($DsAddressToSite[2].Trim() -split ' ')[2] 
                    MappingFrom = $DsAddressToSite[1] -replace "Get the site-subnet mapping for '$IPAddress' from '\\\\",'' -replace '''.',''                
                })
            } else {
                Write-Log 'Get-ADSite error:','unexpected ''nltest'' response:' Magenta,Yellow
                Write-Log ($DsAddressToSite | Out-String) Cyan
            }

        } catch {
            Write-Log 'Get-ADSite error invoking ''nltest'':' Magenta
            Write-Log $_.Exception.Message Yellow
        } 

    }

    End { }
}

function Get-NestedADGroup-Recursive {
<#
 .SYNOPSIS
  Function to get nested AD group membership.
 
 .DESCRIPTION
  This function is used internally by Get-NestedADGroup.
  See built-in help for Get-NestedADGroup for more information.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 October 2022
  v0.2 - 27 October 2022
    Added error handling for missing/bad AD group name
    Added feature to use $ADGroupList instead of querying AD
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$GroupName,
        [Parameter(Mandatory=$false)][PSObject[]]$ADGroupList,
        [Parameter(Mandatory=$false)][String]$DC,
        [Parameter(Mandatory=$false)][String[]]$GroupStack
    )
    
    Begin { 
        
        if ($ADGroupList) {
            $RequiredFieldList = @('DistinguishedName','MemberOf')
            foreach ($RequiredField in $RequiredFieldList) {
                if ($RequiredField -notin ($ADGroupList | Get-Member -MemberType Property).Name) {
                    Write-Log $RequiredField,'attribute is missing from input','$ADGroupList' Yellow,Magenta,Yellow
                    break
                }
            }
        }
                
    }

    Process{
        
        if (-not $GroupStack) { $GroupStack = @() }
        if ($ADGroupList) {
            $ADGroup = $ADGroupList | where Name -EQ $GroupName
            if (-not $ADGroup) { $ADGroup = $ADGroupList | where DistinguishedName -EQ $GroupName }
            if (-not $ADGroup) {
                Write-Log 'ADGroup',$GroupName,'not found in the provided','$ADGroupList' Magenta,Yellow,Magenta,Yellow
                break
            }
        } else {
            $ParamList = @{ Identity = $GroupName; Properties = 'memberof'; EA = 1 }
            if ($DC) { $ParamList += @{ Server = $DC } }
            try { 
                $ADGroup = Get-ADGroup @ParamList | select DistinguishedName,MemberOf
            } catch {
                Write-Log $_.Exception.Message Yellow
                break
            }
        }
       
        $GroupStack += $ADGroup.DistinguishedName
        $GroupStack
        foreach ($ParentADGroup in $ADGroup.MemberOf) {
            if ($ParentADGroup -in $GroupStack) {
                Write-Verbose "Skipping group membership circular reference for group '$ParentADGroup'"
            } else {
                $ParamList = @{ GroupName = $ParentADGroup; GroupStack = $GroupStack }
                if ($DC) { $ParamList += @{ DC = $DC } }
                if ($ADGroupList) { $ParamList += @{ ADGroupList = $ADGroupList } }
                Get-NestedADGroup-Recursive @ParamList
            }
            Write-Verbose '$ADGroup.MemberOf is:'
            Write-Verbose ($ADGroup.MemberOf | Out-String).Trim() 
            Write-Verbose '$GroupStack is:'
            Write-Verbose ($GroupStack | Out-String).Trim() 
        }

    }

    End { }
}

function Get-NestedADGroup {
<#
 .SYNOPSIS
  Function to get nested AD group membership.
 
 .DESCRIPTION
  Function to get nested AD group membership.
  This function does not return user members of the input AD group.
  It returns the Distinguished names of the parent AD groups starting from the input group.
  For example, the output:
    CN=testgroup2,DC=MyDomain,DC=local
    CN=testgroup1,DC=MyDomain,DC=local
    CN=testgroup3,DC=MyDomain,DC=local
  indicates that testgroup2 is a member of testgroup1 which is a member of testgroup3
  This function avoids circular reference - in this example testgroup3 is also a member of testgroup2.
 
 .PARAMETER GroupName
  Required. Name of AD group.
 
 .PARAMETER ADGroupList
  Optional. This can be obtained via:
    Get-ADGroup -Filter * -Properties memberof
  This parameter saves time when querying many groups across slow WAN links
 
 .PARAMETER DC
  Optional. Name or IP address of DC (domain controller) to query for AD groups.
 
 .OUTPUTS
  The script outputs a list of the Distinguished names of the parent AD groups starting from the input group. Example:
    VERBOSE: Skipping group membership circular reference for group 'CN=testgroup2,DC=MyDomain,DC=local'
    VERBOSE: $ADGroup.MemberOf is:
    VERBOSE: CN=testgroup2,DC=MyDomain,DC=local
    VERBOSE: $GroupStack is:
    VERBOSE: CN=testgroup2,DC=MyDomain,DC=local
    CN=testgroup1,DC=MyDomain,DC=local
    CN=testgroup3,DC=MyDomain,DC=local
    VERBOSE: $ADGroup.MemberOf is:
    VERBOSE: CN=testgroup3,DC=MyDomain,DC=local
    VERBOSE: $GroupStack is:
    VERBOSE: CN=testgroup2,DC=MyDomain,DC=local
    CN=testgroup1,DC=MyDomain,DC=local
    VERBOSE: $ADGroup.MemberOf is:
    VERBOSE: CN=testgroup1,DC=MyDomain,DC=local
    VERBOSE: $GroupStack is:
    VERBOSE: CN=testgroup2,DC=MyDomain,DC=local
    CN=testgroup2,DC=MyDomain,DC=local
    CN=testgroup1,DC=MyDomain,DC=local
    CN=testgroup3,DC=MyDomain,DC=local
 
 .EXAMPLE
  Get-NestedADGroup -GroupName 'testgroup2' -Verbose
 
 .EXAMPLE
  $ADGroupList = Get-ADGroup -Filter * -Properties memberof # One time query of all groups
  foreach ($ADGroupName in $myTenKgroupList) { Get-NestedADGroup -GroupName $ADGroupName -ADGroupList $ADGroupList } # Process 10,000 groups without querying AD - much faster
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 October 2022
  v0.2 - 27 October 2022
    Added feature to use $ADGroupList instead of querying AD
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$GroupName,
        [Parameter(Mandatory=$false)][PSObject[]]$ADGroupList,
        [Parameter(Mandatory=$false)][String]$DC
    )
    
    Begin { }

    Process{
        
        $ParamList = @{ GroupName = $GroupName }
        if ($DC) { $ParamList += @{ DC = $DC } }
        if ($ADGroupList) { $ParamList += @{ ADGroupList = $ADGroupList } }
        $Result = Get-NestedADGroup-Recursive @ParamList
        $Result | select -unique

    }

    End { }
}

function Parse-ADGroupType {
<#
 .SYNOPSIS
  Function to parse groupType attribute of the Active Directory group object.
 
 .DESCRIPTION
  Function to parse groupType attribute of the Active Directory group object.
  This function returns what we would typically see under the group object's GroupCategory and GroupScope Attributes such as "DomainLocal, Security".
  If the leftmost bit is set, that indicates a Security group, otherwise it's a Distribution group
    For example: 10000000000000000000000000000010 (Decimal -2147483646) is a Security Global group
        whereas: 00000000000000000000000000000010 (Decimal 2) is a Distribution Global group
  If the second bit from the right is set, that indicates a Global group
  If the third bit from the right is set, that indicates a DomainLocal group
  If the fourth bit from the right is set, that indicates a Universal group
 
 .PARAMETER ADGroupTypeDecimal
  This parameter takes a 32-bit integer that ranges from -2,147,483,647 to 2,147,483,647
  If not provided, this function will display the full list of known groupType attribute options.
 
 .EXAMPLE
  Parse-ADGroupType -2147483644
 
 .OUTPUTS
  This function returns what we would typically see under the group object's GroupCategory and GroupScope Attributes such as "DomainLocal, Security".
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 22 November 2022
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param   ( [Parameter(Mandatory=$False)][Int32]$ADGroupTypeDecimal )

    Begin   { $Result = @() }

    Process { 
        if ($ADGroupTypeDecimal) {
            if ($ADGroupTypeDecimal -band -2147483648) { $Result += 'Security' } else { $Result += 'Distribution' }
            if ($ADGroupTypeDecimal -band 2) { $Result += 'Global' }
            if ($ADGroupTypeDecimal -band 4) { $Result += 'DomainLocal' }
            if ($ADGroupTypeDecimal -band 8) { $Result += 'Universal' }
        } else {
            Write-Host ''
            Write-Log 'Known AD Group Type details:' Green 
            Write-Log ($ADGroupTypeCodes | FT -a | Out-String).Trim() Cyan 
        }
    }

    End     { $Result -join ', ' }
} 

function Get-ForestSID {
<#
 .SYNOPSIS
  Function to provide the forest part of the SID for a given AD domain.
 
 .DESCRIPTION
  Function to provide the forest part of the SID for a given AD domain.
  This is useful when identifying Foriegn Security Ids that belong to orphaned AD trust relationships.
  This function depends on and requires the ActiveDirectory PowerShell module.
   
 .PARAMETER DomainName
  Optional parameter that defaults to the current AD domain.
    
 .EXAMPLE
  Get-ForestSID
  This will return the forest part of the SID.
 
 .OUTPUTS
  This cmdlet returns a string like: S-1-5-21-0123456789-0123456789-012345678
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 Sep 2023
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$DomainName 
    )

    Begin {
        if (-not $IsDomainMember) {
            Write-Log 'Error: This cmdlet is designed to run from a domain joined computer' Magenta
            break
        }
        if (-not $DomainName) { $DomainName = $thisDomainName }
    }

    Process {
        if ($DomainName -eq $thisDomainName) { # Use LDAP but limited to current domain
            try {
                $ComputerObj = Get-SBADComputer -ComputerName * -MaxCount 1 -Quiet -EA 1
                $PartList = $ComputerObj.SID -split '-'
                $PartList[0..6] -join '-'
            } catch {
                Write-Log 'Error: unable to get AD computer info via LDAP',$_.Exception.Message Magenta,Yellow
            }
        } else { # Use ActiveDirectory PS module to query other domains/forests
            try {
                $ComputerObj = Get-ADComputer -Filter * -ResultSetSize 1 -Server $DomainName -EA 1
                $PartList = $ComputerObj.SID -split '-'
                $PartList[0..6] -join '-'
            } catch {
                Write-Log 'Error: unable to get AD computer info via Get-ADComputer',$_.Exception.Message Magenta,Yellow
            }
        }
    }

    End { }
} 

#endregion

#region SQL functions

function Report-SQLServer {
<#
 .SYNOPSIS
  Function to report of databases of one or more SQL servers
 
 .DESCRIPTION
  Function to report of databases of one or more SQL servers
  The report is in plain text format
  The report lists the databases, their tables, columns, and optionally row count
 
 .PARAMETER ComputerName
  One or more computer names
  This is an optional parameter that defaults to the current computer name
 
 .PARAMETER IncludeSystemDatabases
  This is an optional parameter that defaults to False
  When set to True, the report includes system databases
 
 .PARAMETER IncludeRowCount
  This is an optional parameter that defaults to False
  When set to True, the report includes row count of every table found in every database
  This parameter requires either module SQLPS or SqlServer
  SqlServer is available in the PowerShell Gallery: Install-Module SqlServer
 
 .PARAMETER LogFile
  This is an optional parameter that contains the path to the log file where this function will log its output
 
 .EXAMPLE
  Report-SQLServer
  This example reports on all databases on the current server excluding system databases and not showing row counts
 
 .EXAMPLE
  Report-SQLServer -ComputerName SQL1,SQL2
  This example reports on all databases on the 2 provided SQL servers excluding system databases and not showing row counts
 
 .EXAMPLE
  Report-SQLServer -IncludeRowCount
  This example reports on all databases on the current server excluding system databases and showing row counts
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  23 February 2019 - v0.1
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false,ValueFromPipeline=$true)][String[]]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory=$false)][Switch]$IncludeSystemDatabases = $false,
        [Parameter(Mandatory=$false)][Switch]$IncludeRowCount = $false,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-SQLServer - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        [void][reflection.assembly]::LoadWithPartialName('Microsoft.SqlServer.Smo')
        if (Get-Module SQLPS,SqlServer -ListAvailable) { 
            $FoundSQL = $true 
        } else {
            if ($IncludeRowCount) {
                Write-Log 'Report-SQLServer: Error:','Missing PS module SQLPS and SqlServer (one of which is needed to get row count)' Magenta,Yellow $LogFile
                Write-Log ' SqlServer module is available in the PowerShell Gallery:','Install-module SqlServer' Yellow,Cyan $LogFile
            }
        }
    }

    Process {
        foreach ($Name in $ComputerName) {
            
            Write-Log 'Reporting on SQL server',$Name Green,Cyan $LogFile
            $Server = New-Object ('Microsoft.SqlServer.Management.Smo.Server') $Name

            if ($IncludeSystemDatabases) {
                $DatabaseList = $Server.databases 
            } else {
                $DatabaseList = $Server.databases | Where { -not $_.IsSystemObject }
            }            

            $DatabaseReport = ".\DBReport-$Name-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
            "Database report for server '$env:computername'" | Out-File $DatabaseReport 
            " generated on $(Get-Date)" | Out-File $DatabaseReport -Append
            ' ' | out-file $DatabaseReport -Append
            "Database list ($($DatabaseList.Count)):"  | Out-File $DatabaseReport -Append
            foreach ($DB in $DatabaseList) { " $($DB.Name)" | Out-File $DatabaseReport -Append }
            ' ' | out-file $DatabaseReport -Append
            $DatabaseReport = (Get-Item $DatabaseReport).FullName

            foreach ($DB in $DatabaseList) {
                ' ' | Out-File $DatabaseReport -Append
                "Database: $($DB.Name)" | Out-File $DatabaseReport -Append
                foreach ($Table in $DB.Tables) {
                    if ($IncludeRowCount) {
                        if ($FoundSQL) {
                            $RowCount = (Invoke-Sqlcmd -Query "USE $($DB.Name); SELECT COUNT(*) FROM $($Table.Name)" -EA 1).Column1 
                            $Rows = "($RowCount rows)"
                        } else {
                            $Rows = 'Need SqlServer PS module to get row count'
                        }
                        " Table: $($Table.Name) $Rows" | Out-File $DatabaseReport -Append
                        foreach ($Column in $Table.Columns) {
                            " Column: $($Column.Name)" | Out-File $DatabaseReport -Append        
                        } # foreach $Column
                    } # if $IncludeRowCount
                } # foreach $Table
            } # foreach $DB
        } # foreach $Name
    } # Process

    End {
        Write-Log 'Report saved to', $DatabaseReport Green,Cyan
    }

}

function Enable-SQLPageCompression {
<#
 .SYNOPSIS
  Function to enable database page compression on one or more databases
 
 .DESCRIPTION
  Function to enable database page compression on one or more databases
  Page compression is enabled for all database tables and indices
  https://docs.microsoft.com/en-us/sql/relational-databases/data-compression/page-compression-implementation
  https://docs.microsoft.com/en-us/sql/relational-databases/data-compression/enable-compression-on-a-table-or-index
 
 .PARAMETER DatabaseName
  This is an optional parameter. If absent, compression is turned on for all databases
  This function does not alter system databases
 
 .PARAMETER LogFile
  This is an optional parameter that contains the path to the log file where this function will log its output
 
 .EXAMPLE
  Enable-SQLPageCompression
  This example enables page compression on all non-system databases on the current SQL server
 
 .EXAMPLE
  Enable-SQLPageCompression -DatabaseName badname1,mydb1,badname2
  This example enables page compression on mydb1 skipping badname1 and badname2 (database that don;t exist on this SQL server)
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  2 October 2019 - v0.1
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][String[]]$DatabaseName,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Enable-SQLPageCompression - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        if (Get-Module SQLPS,SqlServer -ListAvailable) { 
            $FoundSQL = $true 
        } else {
            if ($IncludeRowCount) {
                Write-Log 'Report-SQLServer: Error:','Missing PS module SQLPS and SqlServer (one of which is needed to get row count)' Magenta,Yellow $LogFile
                Write-Log ' SqlServer module is available in the PowerShell Gallery:','Install-module SqlServer' Yellow,Cyan $LogFile
            }
        }

        $DatabaseList = (Invoke-Sqlcmd -Query "SELECT * FROM sys.databases" | Where { $_.database_id -gt 4 }).Name 
        if ($DatabaseName) {
            $DatabaseName = foreach ($DBName in $DatabaseName) {
                if ($DBName -in $DatabaseList) { 
                    $DBName
                } else {
                    Write-Log 'Database',$DBName,'not found on this SQL server',$env:computername,'skipping..' Magenta,Yellow,Magenta,Yellow,Magenta $LogFile
                }
            } 
        } else {
            $DatabaseName = $DatabaseList
            Write-Verbose "Database count: $($DatabaseName.Count)"    
            Write-Verbose ($DatabaseName -join ', ')
        }

        if ($DatabaseName) {
            Write-Log 'Enabling page compression on the following database(s):',($DatabaseName -join ', ') Green,Cyan $LogFile
        }
    }

    Process {
        foreach ($Database in $DatabaseName) {
            $Query = Invoke-Sqlcmd -Query "
                USE $Database
 
                --Creates the ALTER TABLE Statements
 
                SET NOCOUNT ON
                SELECT 'ALTER TABLE ' + '[' + s.[name] + ']'+'.' + '[' + o.[name] + ']' + ' REBUILD WITH (DATA_COMPRESSION=PAGE);'
                FROM sys.objects AS o WITH (NOLOCK)
                INNER JOIN sys.indexes AS i WITH (NOLOCK)
                ON o.[object_id] = i.[object_id]
                INNER JOIN sys.schemas AS s WITH (NOLOCK)
                ON o.[schema_id] = s.[schema_id]
                INNER JOIN sys.dm_db_partition_stats AS ps WITH (NOLOCK)
                ON i.[object_id] = ps.[object_id]
                AND ps.[index_id] = i.[index_id]
                WHERE o.[type] = 'U'
                ORDER BY ps.[reserved_page_count]
 
 
                --Creates the ALTER INDEX Statements
 
                SET NOCOUNT ON
                SELECT 'ALTER INDEX '+ '[' + i.[name] + ']' + ' ON ' + '[' + s.[name] + ']' + '.' + '[' + o.[name] + ']' + ' REBUILD WITH (DATA_COMPRESSION=PAGE);'
                FROM sys.objects AS o WITH (NOLOCK)
                INNER JOIN sys.indexes AS i WITH (NOLOCK)
                ON o.[object_id] = i.[object_id]
                INNER JOIN sys.schemas s WITH (NOLOCK)
                ON o.[schema_id] = s.[schema_id]
                INNER JOIN sys.dm_db_partition_stats AS ps WITH (NOLOCK)
                ON i.[object_id] = ps.[object_id]
                AND ps.[index_id] = i.[index_id]
                WHERE o.type = 'U' AND i.[index_id] >0
                ORDER BY ps.[reserved_page_count]
            "

            Write-Log 'Processing database',$Database Green,Cyan $LogFile -NoNewLine
            try {
                Invoke-Sqlcmd -Query "USE $Database; $($Query.Column1 -join ' ')" -EA 1 
                Write-Log 'done' DarkYellow $LogFile
            } catch {
                if ($_.Exception.Message -match 'Execution Timeout Expired') { # Default 30 sec
                    # https://docs.microsoft.com/en-us/dotnet/api/system.data.sqlclient.sqlcommand.commandtimeout
                    Write-Log 'Database page compression set, actual compression in progress..' DarkYellow $LogFile
                } else {
                    Write-Log 'failed' Magenta $LogFile
                    Write-Log $_.Exception.Message Yellow $LogFile
                }
            }
        } 
    } 

    End { }

}

function Get-SQLDatabaseFile {
<#
 .SYNOPSIS
  Function to return a SQL database file information
 
 .DESCRIPTION
  Function to return a SQL database file information
 
 .PARAMETER DatabaseName
  One or more database names.
  This is an optional parameter.
  If absent, the function returns information on data files of all databases except system databases.
 
 .PARAMETER IncludeSystemDatabases
  This is an optional switch. If set to TRUE, this function will report on system databases as well.
 
 .PARAMETER IncludeLogFiles
  This is an optional parameter.
  This is an optional switch. If set to TRUE, this function will report on LOG files as well.
 
 .PARAMETER LogFile
  This is an optional parameter that contains the path to the log file where this function will log its output
 
 .EXAMPLE
  Get-SQLDatabaseFile
  This example reports on DATA files of all non-system databases on the current SQL server
 
 .EXAMPLE
  Get-SQLDatabaseFile -DatabaseName dmdire -IncludeLogFiles
  This example returns file information for database 'dmdire' including both DATA and LOG files
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  7 October 2019 - v0.1
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][String[]]$DatabaseName,
        [Parameter(Mandatory=$false)][Switch]$IncludeSystemDatabases,
        [Parameter(Mandatory=$false)][Switch]$IncludeLogFiles,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-SQLDatabaseFile - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        if (-not (Get-Module SQLPS,SqlServer -ListAvailable)) { 
            Write-Log 'Get-SQLDatabaseFile: Error:','Missing PS module SQLPS and SqlServer (one of which is needed to get row count)' Magenta,Yellow $LogFile
            Write-Log ' SqlServer module is available in the PowerShell Gallery:','Install-module SqlServer' Yellow,Cyan $LogFile
            break
        }
    }

    Process {
        $Missing = $false
        $myOutput = $DatabaseList = Invoke-Sqlcmd -Query "
            SELECT
                db.name AS DBName,
                db.is_auto_shrink_on AS AutoShrink,
                mf.name AS FileName,
                Physical_Name AS Location,
                db.database_id,
                type,
                size,
                max_size,
                growth,
                is_percent_growth
            FROM
                sys.master_files mf
            INNER JOIN
                sys.databases db ON db.database_id = mf.database_id"
 | select DBName,FileName,Location,AutoShrink
                @{n='Id';e={$_.database_id}},
                @{n='Type';e={if ($_.type -eq 0) {'Data'} else {'Log'}}},
                @{n='SizeMB';e={[Math]::Round($_.size/128,1)}},      # size is reported in 8 KB pages
                @{n='MaxSizeMB';e={
                    if ($_.max_size -gt 0) { 
                        [Math]::Round($_.max_size/128,1)             # size is reported in 8 KB pages
                    } elseif ($_.max_size -eq 0) { 
                        'None' 
                    } else {
                        'Unlimited'
                    }
                }},
                @{n='Growth';e={
                    if ($_.is_percent_growth) {
                        if ($_.growth -gt 0) { "$($_.growth)%" } else { 'None' }
                    } elseif ($_.growth -gt 0) {
                        "$([Math]::Round($_.growth/128,1))MB"        # growth is reported in 8 KB pages
                    } elseif ($_.growth -eq 0) {
                        'None'
                    } else {
                        'Unlimited'
                    }
                }}

        if (-not $IncludeSystemDatabases) {
            $myOutput = $myOutput | where { $_.Id -gt 4 }
        }

        if (-not $IncludeLogFiles) {
            $myOutput = $myOutput | where { $_.Type -eq 'Data' }
        }       
        
        $myOutput = if ($DatabaseName) {
            foreach ($Name in $DatabaseName) { 
                if ($Temp = $myOutput | where {$_.Name -eq $Name}) {
                    $Temp
                } else {
                    $Missing = $true
                    Write-Log 'Database',$Name,'not found on SQL server',$env:COMPUTERNAME Magenta,Yellow,Magenta,Yellow $LogFile
                }                 
             }
        } else {
            $myOutput
        }

        if ($Missing) {
            Write-Log 'Here''s the list of databases on this',$env:COMPUTERNAME,'SQL server' Green,Cyan,Green $LogFile
            $DatabaseList.Name | select -Unique | sort| foreach { Write-Log " $_" DarkYellow $LogFile }
        }

    } 

    End { $myOutput }

}

function Truncate-SQLLogs {
<#
 .SYNOPSIS
  Function to truncate SQL log files for databases on one or more SQL servers.
 
 .DESCRIPTION
  Function to truncate SQL log files for all databases except (master, tempdb, model, msdb) on one or more SQL servers.
  This function depends on SQLPS PS module.
 
 .PARAMETER ComputerName
  One or more SQL servers
  This is an optional parameter. It defaults to the current computername.
 
 .PARAMETER LogFile
  This is an optional parameter that contains the path to the log file where this function will log its output.
 
 .EXAMPLE
  Truncate-SQLLogs
 
 .OUTPUTS
  This function returns a powershell object for each database processed containing the following properties:
    SQLServerName
    DBName
    DBLogFile ==> physical disk path to the log file
    BeforeSizeMB
    AfterSizeMB
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 30 January 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Alias('SQLServerName')][String[]]$ComputerName = $env:COMPUTERNAME,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Truncate-SQLLogs-$ComputerName-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  }

    Process {  
        $myLocation = Get-Location
        $myOutput = foreach ($Server in $ComputerName) {                
            try {
                $DatabaseList = Invoke-SQLCMD -Query 'SELECT * FROM sysdatabases WHERE dbid > 4' -ServerInstance $Server -EA 1 # skipping first 4 databases: master, tempdb, model, msdb
                Set-Location $myLocation # This is needed since Invoke-SQLCMD changes location to the SQL drive SQLSERVER:\ which may interfere with logging or/and subsequent automations
                Write-Log 'Starting to truncate log files for',$DatabaseList.Count,'databases on server',$Server Green,Cyan,Green,Cyan $LogFile
                foreach ($DB in $DatabaseList) {
                    $DBLog = Invoke-SQLCMD -Query ("SELECT Name,Physical_Name,Size FROM sys.master_files WHERE database_id = $($DB.dbid) AND type = 1") -ServerInstance $Server 
                    Write-Log 'Truncating log file',$DBLog.Physical_Name,"($($DBLog.Size*8) KB)",'for database',$DB.name,"(database_id = $($DB.dbid))" Green,Cyan,Green,Cyan,Green,Cyan $LogFile -NoNewLine
                    try {
                        Invoke-SQLCMD -Query ("
                            USE [$($DB.name)];
                            ALTER DATABASE [$($DB.name)] SET RECOVERY SIMPLE WITH NO_WAIT;
                        "
) -EA 1 -ServerInstance $Server 
                        $Result = Invoke-SQLCMD -Query ("
                            USE [$($DB.name)];
                            DBCC SHRINKFILE(N'$($DBLog.Name)', 1);
                            ALTER DATABASE [$($DB.name)] SET RECOVERY FULL WITH NO_WAIT;
                        "
) -EA 1 -ServerInstance $Server 
                        Write-Log 'done, now',"($($Result.CurrentSize*8) KB)" Green,Cyan $LogFile
                        New-Object -TypeName psobject -Property ([Ordered]@{
                            SQLServerName = $Server
                            DBName        = $DB.Name
                            DBLogFile     = $DBLog.Physical_Name
                            BeforeSizeMB  = $DBLog.Size/128
                            AfterSizeMB   = $Result.CurrentSize/128
                        })
                    } catch {
                        Write-Log 'failed:' Magenta $LogFile
                        Write-Log $_.Exception.Message Yellow $LogFile
                    }
                }
            } catch {
                Write-Log 'Truncate-SQLLogs Error on server',$Server Magenta,Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            }
        }
    }

    End { $myOutput | sort SQLServerName,DBName }
} 

function Install-SQLExpress {
<#
 .SYNOPSIS
  Function to return the Geographical location of an Internet IP address
 
 .DESCRIPTION
  Function to return the Geographical location of an Internet IP address
  This function depends on ip-api.com and ipinfo.io
 
 .PARAMETER Source
  One or more URLs
  This is an optional parameter. These URLs will be queried for WAN IP.
 
 .EXAMPLE
  Get-MyWANIP
 
 .OUTPUTS
  This cmdlet returns a System.Net.IPAddress object such as:
    Address : 1132553623
    AddressFamily : InterNetwork
    ScopeId :
    IsIPv6Multicast : False
    IsIPv6LinkLocal : False
    IsIPv6SiteLocal : False
    IsIPv6Teredo : False
    IsIPv4MappedToIPv6 : False
    IPAddressToString : 151.101.129.67
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 April 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$URL = 'https://download.microsoft.com/download/7/f/8/7f8a9c43-8c8a-4f7c-9f92-83c18d96b681/SQL2019-SSEI-Expr.exe', # 'https://go.microsoft.com/fwlink/?linkid=866658',
        [Parameter(Mandatory=$false)][String]$FileName = 'SQL2019-SSEI-Expr.exe',
        [Parameter(Mandatory=$false)][String]$FileHash = '095D77F3B46A708D3F3D7763E60EE46805C3B0E3D1F4F821F9DA8A23A40167C8',
        [Parameter(Mandatory=$false)][Int]$SizeInBytes = 6376336,
        [Parameter(Mandatory=$false)][String]$TempFolder = $env:TEMP,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Install-SQLExpress_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )
    
    Begin { 

        # Validate TempFolder
        if (-not (Test-Path $TempFolder)) {
            Write-Log 'Install-SQLExpress Error:','Invalid path provided for TempFolder parameter',$TempFolder Magenta,Yellow,Magenta $LogFile
            break
        } 

        # Validate Free Space
        $FreeSpaceBytes = (Get-Volume -DriveLetter (Get-Item $TempFolder).FullName[0]).SizeRemaining
        if ($FreeSpaceBytes -le $SizeInBytes+1MB) {
            Write-Log 'Install-SQLExpress Error:','Not enough disk space at',$TempFolder Magenta,Yellow,Magenta $LogFile
            Write-Log 'Available KB:',('{0:N0}' -f ($FreeSpaceBytes/1KB)),'Needed KB:',('{0:N0}' -f ($SizeInBytes/1KB+1KB)) Magenta,Yellow,Magenta,Yellow $LogFile
            break
        } 

        #region Download if needed

        $Go = $true
        if (Test-Path "$TempFolder\$FileName") {
            $Hash = (Get-FileHash -Path "$TempFolder\$FileName" -Algorithm SHA256).Hash
            $File = Get-Item "$TempFolder\$FileName"
            if ($FileHash -eq $Hash -and $SizeInBytes -eq $File.Length) {
                $Go = $false
                Write-Log 'Validated existing file',$File.FullName Green,Cyan $LogFile
            } 
        }

        if ($Go) {
            Write-Log 'Downloading file',$FileName,'from',$URL Green,Cyan,Green,Cyan $LogFile -NoNewLine
            try {
                Invoke-WebRequest $URL -OutFile "$TempFolder\$FileName" -UseBasicParsing -EA 1
                Write-Log 'done' DarkYellow $LogFile

                $Hash = (Get-FileHash -Path "$TempFolder\$FileName" -Algorithm SHA256).Hash
                $File = Get-Item "$TempFolder\$FileName"
                if ($FileHash -eq $Hash -and $SizeInBytes -eq $File.Length) {
                    $Go = $false
                    Write-Log 'Validated file',$File.FullName Green,Cyan $LogFile
                } else {
                    Write-Log 'Install-SQLExpress Error:','Downloaded file validation failed' Magenta,Yellow $LogFile
                    Write-Log 'Downloaded File Hash:',$Hash,'Expected Hash:',$FileHash Magenta,Yellow,Magenta,Yellow $LogFile
                    Write-Log 'Downloaded File Size:',$File.Length,'Expected Size:',$SizeInBytes Magenta,Yellow,Magenta,Yellow $LogFile
                    break
                }

            } catch {
                Write-Log 'failed' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            } 
        }

        #endregion

    }

    Process {   
        
        # Download the full package
        Start-Process -FilePath "$TempFolder\$FileName" -Args "/ACTION=Download /MEDIAPATH=$TempFolder /MEDIATYPE=Core /QUIET" -Verb RunAs -Wait  
        Set-ItemProperty -Path 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NetFramework\v4.0.30319' -Name 'SchUseStrongCrypto' -Value '1' -Type Dword
        # https://silentinstallhq.com/microsoft-sql-server-2019-express-silent-install-how-to-guide/
        # https://techcommunity.microsoft.com/t5/sql-server/2019-express-silent-install/m-p/1115671

        # Install - requires elevation
        if ($IsElevated) {
            Write-Log 'Installing',"$TempFolder\$FileName" Green,Cyan $LogFile -NoNewLine
            Start-Process -FilePath "$TempFolder\$FileName" -Args "/ACTION=INSTALL /IACCEPTSQLSERVERLICENSETERMS /QUIET" -Verb RunAs -Wait  
            Write-Log 'done' DarkYellow $LogFile
        } else {
            Write-Log 'Install-SQLExpress Error:','This function requires elevation to install' Magenta,Yellow $LogFile
            break
        }

        # Validate
        Test-Path 'HKLM:\Software\Microsoft\Microsoft SQL Server\Instance Names\SQL'

        $IsElevated

    }

    End {  }
} 


#endregion

#region IIS functions

function Get-WebSiteList {
<#
 .SYNOPSIS
  Function to provide Web site list from IIS servers on one or many Hyper-V hosts
 
 .DESCRIPTION
  Function to provide Web site list from IIS servers on one or many Hyper-V hosts
  This is usefull to get a web site list from all IIS servers in a Hyper-V farm
  This function uses PowerShell remoting which requires that Hyper-V hosts run Server 2016 or above,
  and IIS VMs run Server 2016 or above, or Windows 10
   
 .PARAMETER HvHostName
  Required parameter that provides one or many Hyper-V computer names
   
 .PARAMETER Cred
  Required parameter that can be obtained via Get-Credential or Get-SBCredential - see Example
   
 .PARAMETER IISVMNameStringMatch
  Optional parameter that defaults to 'IIS'. This function uses this string to identify which VMs are IIS VMs
   
 .PARAMETER IncludeNotStarted
  Optional parameter. When set to $True, the output will include web sites that are not 'Started'
   
 .PARAMETER IncludeDefault
  Optional parameter. When set to $True, the output will include 'Default web site'
 
 .EXAMPLE
  $myWebSiteList = Get-WebSiteList -HvHostName @('HV123','HV124','HV125') -Cred (Get-SBCredential 'domain\admin')
  This returns web site information such as:
    Name VMName HvHostName Bindings
    ---- ------ ---------- --------
    website11111.com vm123-IIS4 HV12345 {https *:443:website11111.com sslFlags=None, https *:443:www.website11111.com sslFlags=None}
    website11111.com-redirect vm123-IIS4 HV12345 {http *:80:website11111.com, http *:80:www.website11111.com}
    book.website22222.com vm124-IIS4 HV12346 {http *:80:book.website22222.com}
    reps-webs1.com vm124-IIS4 HV12346 {http *:80:reps-webs1.com, http *:80:www.reps-webs1.com}
 
 .OUTPUTS
  This cmdlet returns PSCustom Objects, one for each Domain containing the following properties/example:
    Name : wesiteaaa.com
    SSL : False
    VMName : vm222-IIS4
    HvHostName : HV345
    Bindings : {https *:443:wesiteaaa.com sslFlags=None, https *:443:www.wesiteaaa.com sslFlags=None}
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 23 March 2020
  v0.2 - 23 March 2020 - Added SSL True/False property in the output.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String[]]$HvHostName,
        [Parameter(Mandatory=$true)][PSCredential]$Cred,
        [Parameter(Mandatory=$false)][String]$IISVMNameStringMatch = 'IIS',
        [Parameter(Mandatory=$false)][Switch]$IncludeNotStarted,  
        [Parameter(Mandatory=$false)][Switch]$IncludeDefault  
    )

    Begin { }

    Process {
        $WebSiteList = foreach ($ComputerName in $HvHostName) {
            try {
                $VMList = Get-VM -ComputerName $ComputerName -EA 1 
                Write-Log 'Identified',$VMList.Count,'VMs on Hyper-V host',$ComputerName Green,Cyan,Green,Cyan 
                $IISList = $VMList | where { $_.State -eq 'Running' -and $_.Name -match $IISVMNameStringMatch }
                Write-Log ' of which, there''s',$IISList.Count,'running IIS VM(s)' Green,Cyan,Green
                Write-Log ($IISList|Out-String).Trim() Cyan
                foreach ($VMId in $IISList.VMId) {
                    Invoke-Command -ComputerName $ComputerName -ScriptBlock {
                        try {
                            Invoke-Command -VMId $Using:VMId -Credential $Using:Cred -EA 1 -ScriptBlock { 
                                Get-IISSite | select Bindings,Name,State,@{n='VMName';e={$env:COMPUTERNAME}},
                                    @{n='SSL';e={$SSL=$False; $_.Bindings.CertificateHash|foreach{if($_){$SSL=$true}}; $SSL}}
                           } 
                        } catch {
                            Write-Log $_.Exception.Message Yellow
                            if ($_.Exception.Message -match 'An error has occurred which Windows PowerShell cannot handle.') {
                                Write-Log ' VM may not be running Server 2016 or Windows 10 OS, and PowerShell Direct won''t work..' DarkYellow
                            }
                        }
                    }
                }
            } catch {
                Write-Log $_.Exception.Message Magenta
            }
        }
    }

    End { 
        if ($IncludeNotStarted) {
            $WebSiteList = $WebSiteList | select Name,SSL,VMName,@{n='HvHostName';e={$_.PSComputerName}},Bindings
        } else {
            $WebSiteList = $WebSiteList | where State -match 'Started' | 
                select Name,SSL,VMName,@{n='HvHostName';e={$_.PSComputerName}},Bindings
        }

        if ($IncludeDefault) {
            $WebSiteList 
        } else {
            $WebSiteList | where Name -NotMatch 'Default Web Site' 
        }
    }
} 

function Report-IISLogs {
<#
 .SYNOPSIS
  Function to report on IIS log files of the websites of the current computer
 
 .DESCRIPTION
  Function to report on IIS log files of the websites of the current computer
 
 .PARAMETER WebSiteName
  One or more Web Site Names. This should exist on the computer where this function is invoked.
  If this parameter is not provided, this function will report on the log files of all websites on this computer
 
 .EXAMPLE
  Report-IISLogs -WebSiteName www.mydomain.com
  This example will report on IIS log files for the provided website on this computer
 
 .EXAMPLE
  Report-IISLogs -WebSiteName www.mysite.com
  This example will report on log files of www.mysite.com on this computer
 
 .EXAMPLE
  Report-IISLogs
  This example will report on all log files of all websites on this computer
 
 .EXAMPLE
  Report-IISLogs | Export-Csv ".\Report-IISLogs_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv" -NoTypeInformation
  This example will report on the current server website log files and save them to CSV file
 
 .OUTPUTS
  This cmdlet returns a PS object collection such as:
    Name Id LogFolder LogFileCount TotalMB
    ---- -- --------- ------------ -------
    domain1.com 7 C:\inetpub\logs\LogFiles\w3svc7 1749 1966.3
    www.domain2.com 23 C:\inetpub\logs\LogFiles\w3svc23 1749 985.1
    site.domain3.com 11 C:\inetpub\logs\LogFiles\w3svc11 579 229.7
    www.domain4.com 2 C:\inetpub\logs\LogFiles\w3svc2 1749 125.2
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 9 May 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$WebSiteName
    )

    Begin { 
        Write-Verbose 'Report-IISLogs: received input:'
        Write-Verbose "WebSiteName: $WebSiteName"
    }

    Process {      
        
        if ($WebSiteName) {
            $WebSiteInfo = foreach ($WebSite in $WebSiteName) {                
                if ($Info = Get-Website -Name $WebSite) { 
                    $Info
                } else {
                    Write-Log 'Report-IISLogs Error: web site',$WebSite,'not found' Magenta,Yellow,Magenta
                }
            }
        } 

        # If no $WebSiteName(s) are provided, or provided names do not exist, get a list of all web sites
        if (-not $WebSiteInfo) { $WebSiteInfo = Get-Website }

        $myOutput = foreach ($WebSite in $WebSiteInfo) {
            $LogFolder = "$($Website.logFile.directory)\w3svc$($WebSite.id)".replace("%SystemDrive%",$env:SystemDrive)
            $LogFileList = try {
                Get-ChildItem $LogFolder -File -Force -EA 1 | select FullName,Length 
            } catch {
                Write-Log $_.Exception.Message Yellow
            } 
            $TotalMB = 0
            $LogFileList | foreach { $TotalMB += $_.Length }

            [PSCustomObject][Ordered]@{
                Name         = $WebSite.Name
                Id           = $WebSite.Id 
                LogFolder    = $LogFolder
                LogFileCount = $LogFileList.Count
                TotalMB      = [Math]::Round($TotalMB/1MB,1)
            }             
        }

    }

    End { $myOutput | sort TotalMB -Descending }
} 

function Parse-IISLogs {
<#
 .SYNOPSIS
  Function to parse one or more IIS log files
 
 .DESCRIPTION
  Function to parse one or more IIS log files
 
 .PARAMETER IISLogFile
  One or more IIS log files. This should be the full path to the log file(s).
  If this parameter is provided, the IISLogFolder and WebSiteName parameters will be ignored
 
 .PARAMETER IISLogFolder
  One or more IIS log folders. This should be the full path to the log folder(s).
  When this parameter is provided, this function will
  - parse all the files in the provided folder(s), AND
  - ignore the WebSiteName parameter if present
 
 .PARAMETER WebSiteName
  One or more Web Site Names. This should exist on the computer where this function is invoked.
  When this parameter is provided, this function will parse all the log files of the provided website(s).
  If this parameter is not provided, this function will parse all the log files of all websites on this computer
 
 .EXAMPLE
  Parse-IISLogs -IISLogFile C:\inetpub\logs\LogFiles\w3svc1\u_ex161121.log
  This example will parse the provided log file
 
 .EXAMPLE
  $WebVisits = Parse-IISLogs -IISLogFolder C:\inetpub\logs\LogFiles\w3svc1,C:\inetpub\logs\LogFiles\w3svc2 -Verbose
  This example will parse all the IIS log files in the provided folders, and save the results to $WebVisits variable
 
 .EXAMPLE
  $myWebSiteName = 'my.website.com'
  $WebVisits = Parse-IISLogs -WebSiteName $myWebSiteName
  $WebVisits | Export-Csv ".\Parse-IISLogs_$($myWebSiteName)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv" -NoTypeInformation
  This example will parse IIS log file for the provided website on this computer, save the results to $WebVisits variable,
  and export it to CSV file
 
 .EXAMPLE
  Parse-IISLogs
  This example will parse all the log files of all websites on this computer
 
 .EXAMPLE
    $WebSiteName = 'WWW.MYDOMAIN.com'
    $LastLogFile = Get-ChildItem (Report-IISLogs -WebSiteName $WebSiteName).LogFolder -File | sort LastWriteTime | select -Last 1
    $AccessEventList = Parse-IISLogs -IISLogFile $LastLogFile.FullName
    $AccessEventList | Export-CSV ".\Parse-IISLogs_$($WebSiteName)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv" -NoType
  This example will find the provided website's last IIS log, parse it, and export the data to CSV file.
 
 .OUTPUTS
  This cmdlet returns a PS object collection such as:
    DateTime : 07/30/2015 21:22:02
    ServerName : myserver-IIS2
    ServerIP : 10.11.12.13
    WebSite : my.website.com
    Method : GET
    Stem : /robots.txt
    Query : -
    Port : 80
    UserName : -
    ClientIP : 54.196.144.100
    UserAgent : CCBot/2.0+(http://commoncrawl.org/faq/)
    Referer : -
    Status : 404
    SubStatus : 0
    Win32Status : 2
    DurationMS : 6
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 9 May 2020
  v0.2 - 10 May 2020 - Combined Date and Time properties into DateTime property
 
10/10/2021 - needs a rewtire like Report-IISLogs
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$IISLogFile,
        [Parameter(Mandatory=$false)][String[]]$IISLogFolder,
        [Parameter(Mandatory=$false)][String[]]$WebSiteName
    )

    Begin { 
        Write-Verbose 'Parse-IISLogs: received input:'
        Write-Verbose "WebSiteName: $WebSiteName"
        Write-Verbose "IISLogFile: $IISLogFile"
        Write-Verbose "IISLogFolder: $IISLogFolder"
    }

    Process {      
        
        #region Get LogFileList depending on what input is provided

        if ($IISLogFile) {
            $LogFileList = foreach ($FileName in $IISLogFile) {
                try  {
                    Get-Item $FileName -EA 1 | select FullName,Length
                } catch {
                    Write-Log 'Parse-IISLogs Error: Provided IISLogFile',$FileName,'not found' Magenta,Yellow,Magenta
                }
            }
        } elseif ($IISLogFolder) {
            $LogFileList = foreach ($FolderName in $IISLogFolder) {
                try  {
                    Get-ChildItem $FolderName -File -Force -EA 1 | select FullName,Length
                } catch {
                    Write-Log 'Parse-IISLogs Error: Provided IISLogFolder',$FolderName,'not found' Magenta,Yellow,Magenta
                }
            }
        } else {
            if ($WebSiteName) {
                $WebSiteInfo = foreach ($WebSite in $WebSiteName) {                
                    if ($Info = Get-Website -Name $WebSite) { 
                        $Info
                    } else {
                        Write-Log 'Parse-IISLogs Error: web site',$WebSite,'not found' Magenta,Yellow,Magenta
                    }
                }
            } 

            # If no $WebSiteName(s) are provided, or provided names do not exist, get a list of all web sites
            if (-not $WebSiteInfo) { $WebSiteInfo = Get-Website }

            $LogFileList = foreach ($WebSite in $WebSiteInfo) {
                try {
                    Get-ChildItem "$($Website.logFile.directory)\w3svc$($WebSite.id)".replace("%SystemDrive%",$env:SystemDrive) -File -Force -EA 1 | select FullName,Length 
                } catch {
                    Write-Log $_.Exception.Message Yellow
                }              
            }
        }
        
        #endregion


        if ($LogFileList) {
            $WebSiteList = Get-WebSite
            $LogFileList | foreach { $TotalMB += $_.Length }
            $TotalMB = [Math]::Round($TotalMB/1MB,1)
            Write-Log 'Parsing',$LogFileList.Count,'IIS log files',"($TotalMB MB)" Green,Cyan,Green,Cyan

            $i=0
            foreach ($Log in $LogFileList) {
                $WebSite = $WebSiteList | where Id -EQ ([Int]($Log.FullName.Split('\') -match 'w3svc').Replace('w3svc',''))
                $i++ 
                Write-Verbose "Processing log file $($Log.FullName)"
                if ($LogFileList.Count -ge 1) {
                    $Percent = [Math]::Round($i/$LogFileList.Count*100,1)
                    Write-Progress -Activity "Parsing IIS log file # $i of $($LogFileList.Count)"  -PercentComplete $Percent
                } else {
                    Write-Progress -Activity "Parsing IIS log file # $i"  -PercentComplete 50
                }
                
                $ReadLog = (Get-Content $Log.FullName) -notmatch '#'
                foreach ($Line in $ReadLog) {
                    $Visitor = $Line -split ' '
                    [PSCustomObject][Ordered]@{
                        DateTime    = [DateTime]"$($Visitor[0]) $($Visitor[1])" -f ''
                        ServerName  = $env:COMPUTERNAME
                        ServerIP    = $Visitor[2]
                        WebSite     = $WebSite.Name
                        Method      = $Visitor[3]
                        Stem        = $Visitor[4]
                        Query       = $Visitor[5]
                        Port        = $Visitor[6]
                        UserName    = $Visitor[7]
                        ClientIP    = $Visitor[8]
                        UserAgent   = $Visitor[9]
                        Referer     = $Visitor[10]
                        Status      = $Visitor[11]
                        SubStatus   = $Visitor[12]
                        Win32Status = $Visitor[13]
                        DurationMS  = $Visitor[14]
                    }
                }

            }

        } else {
            Write-Log 'Parse-IISLogs Error: No IIS Log Files provided' Yellow
        }
    }

    End {  }
} 

function Report-IISLogs {
<#
 .SYNOPSIS
  Function to report on, and optionally delete IIS log files of the websites of the current computer
 
 .DESCRIPTION
  Function to report on, and optionally delete IIS log files of the websites of the current computer
 
 .PARAMETER WebSiteName
  One or more Web Site Names. This should exist on the computer where this function is invoked.
  If this parameter is not provided, this function will report on the log files of all websites on this computer
 
 .PARAMETER DeleteLogFiles
  If this switch is set to True, this function will delete old web site log files.
 
 .PARAMETER DeleteHTTPERRFiles
  If this switch is set to True, this function will delete old DeleteHTTPERRFiles files.
  These are typically located under C:\Windows\system32\LogFiles\HTTPERR
 
 .PARAMETER OlderThanDays
  This defaults to 30 (days). When set to 30 for example, this function will delete web site log files older than 30 days.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output.
 
 .EXAMPLE
  Report-IISLogs
  This example will report on all log files of all websites on this computer
 
 .EXAMPLE
  Report-IISLogs -WebSiteName www.mydomain.com
  This example will report on IIS log files for the provided website on this computer
 
 .EXAMPLE
  $WebSiteLogReport = Report-IISLogs -DeleteLogFiles -OlderThanDays 120
  This example will report on log files of all web sites on this computer, and delete log files older than 120 days.
 
 .EXAMPLE
  $WebSiteLogReport = Report-IISLogs -DeleteLogFiles -DeleteHTTPERRFiles -OlderThanDays 90
  This example will report on log files of all web sites on this computer, delete log files older than 90 days,
  and delete log files under C:\Windows\system32\LogFiles\HTTPERR that are older than 90 days.
 
 .EXAMPLE
  Report-IISLogs | Export-Csv ".\Report-IISLogs_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv" -NoTypeInformation
  This example will report on the current server website log files and save them to CSV file
 
 .OUTPUTS
  This cmdlet returns a PS object collection such as:
    Name Id LogFolder LogFileCount TotalMB
    ---- -- --------- ------------ -------
    domain1.com 7 C:\inetpub\logs\LogFiles\w3svc7 1749 1966.3
    www.domain2.com 23 C:\inetpub\logs\LogFiles\w3svc23 1749 985.1
    site.domain3.com 11 C:\inetpub\logs\LogFiles\w3svc11 579 229.7
    www.domain4.com 2 C:\inetpub\logs\LogFiles\w3svc2 1749 125.2
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 9 May 2020
  v0.2 - 9 October 2021
    Added console progress reports, updated size calculation logic to speed the process up.
    Added OlderThanDays, DeleteLogFiles, DeleteHTTPERRFiles, and LogFile parameters
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$WebSiteName,
        [Parameter(Mandatory=$false)][Switch]$DeleteLogFiles,
        [Parameter(Mandatory=$false)][Switch]$DeleteHTTPERRFiles,
        [Parameter(Mandatory=$false)][ValidateRange(1,3650)][Int16]$OlderThanDays = 30,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-IISLogs - $(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        Write-Verbose 'Report-IISLogs: received input:'
        Write-Verbose "WebSiteName: $WebSiteName"
        Write-Verbose "OlderThanDays: $OlderThanDays"
        Write-Verbose "DeleteLogFiles: $DeleteLogFiles"
        Write-Verbose "DeleteHTTPERRFiles: $DeleteHTTPERRFiles"
        Write-Verbose "LogFile: $LogFile"
    }

    Process {              

        if ($WebSiteName) {
            $WebSiteInfo = foreach ($WebSite in $WebSiteName) {                
                if ($Info = Get-Website -Name $WebSite) { 
                    $Info
                } else {
                    Write-Log 'Report-IISLogs Error: web site',$WebSite,'not found' Magenta,Yellow,Magenta $LogFile
                }
            }
        } 

        # If no $WebSiteName(s) are provided, or provided names do not exist, get a list of all web sites
        if (-not $WebSiteInfo) { 
            Write-Log 'Gathering website info from IIS' Green $LogFile -NoNewLine
            try {
                $WebSiteInfo = Get-Website -EA 1 | Select Name,Id,@{n='LogFolder';e={$_.LogFile.Directory}}
                Write-Log 'done, obtained details on',$WebSiteInfo.Count,'websites' DarkYellow,Cyan,Green $LogFile
            } catch {
                Write-Log 'failed' Yellow $LogFile
                Write-Log 'Report-IISLogs Error:' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                Break
            }
        }

        #region Get log file details
        $myOutput = foreach ($WebSite in $WebSiteInfo) { 
            Write-Log ' Listing web site',$WebSite.Name.PadRight(30),'log files' Green,Cyan,Green $LogFile -NoNewLine
            $LogFolder = "$($Website.LogFolder)\w3svc$($WebSite.id)".replace("%SystemDrive%",$env:SystemDrive)
            try {
                $LogFileList = Get-ChildItem $LogFolder -File -Force -EA 1 | select FullName,Length,CreationTime 
                Write-Log 'identified',('{0:N0}' -f $LogFileList.Count).PadRight(10),'log files, totalling' DarkYellow,Cyan,Green $LogFile -NoNewLine
            } catch {
                Write-Log 'failed' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            } 
            $TotalBytes = ($LogFileList | foreach { $_.Length } | measure -Sum).Sum
            Write-Log ('{0:N0}' -f ($TotalBytes/1MB)).PadRight(10),'MB' Cyan,Green $LogFile

            New-Object -TypeName PSObject -Property ([Ordered]@{
                Name         = $WebSite.Name
                Id           = $WebSite.Id 
                LogFolder    = $LogFolder
                LogFileCount = $LogFileList.Count
                TotalMB      = [Math]::Round($TotalBytes/1MB,1)
                LogfileList  = $LogFileList
            })             
        }
        $myOutput = $myOutput | sort TotalMB -Descending 
        Write-Log ($myOutput | FT Name,Id,LogFolder,LogFileCount,TotalMB -a | Out-String).Trim() Cyan $LogFile
        #endregion

        #region Delete log files older than $OlderThanDays days
        if ($DeleteLogFiles) {
            Write-Log 'Deleting web site log files older than',$OlderThanDays,'days',"(before $((Get-Date).AddDays(-$OlderThanDays)))" Green,Cyan,Green,Cyan $LogFile
            foreach ($WebSite in $myOutput) {
                Write-Log ' Processing web site',$Website.Name.PadRight(40) Green,Cyan $LogFile -NoNewLine
                $DeleteList = $WebSite.LogfileList | where CreationTime -lt (Get-Date).AddDays(-$OlderThanDays)
                if ($DeleteList) {
                    Write-Log ' deleting',$DeleteList.Count,'old log files' Green,Cyan,Green $LogFile -NoNewLine
                    Remove-Item $DeleteList.FullName -Force -Confirm:$false
                    Write-Log 'done' DarkYellow $LogFile
                } else {
                    Write-Log 'no old log files found.' DarkYellow $LogFile
                }
            }
        }
        #endregion

        #region Delete old files under C:\Windows\system32\LogFiles\HTTPERR
        if ($DeleteHTTPERRFiles) {
            $FolderPath = "$($env:ComSpec -replace 'cmd.exe')LogFiles\HTTPERR"
            Write-Log 'Deleting log files older than',$OlderThanDays,'days',"(before $((Get-Date).AddDays(-$OlderThanDays)))",'under',$FolderPath Green,Cyan,Green,Cyan,Green,Cyan $LogFile
            $DeleteList = Get-ChildItem -Path $FolderPath -File | select FullName,LastWriteTime | where LastWriteTime -lt (Get-Date).AddDays(-$OlderThanDays)
            if ($DeleteList) {
                Write-Log 'deleting',$DeleteList.Count,'old log files' Green,Cyan,Green $LogFile -NoNewLine
                Remove-Item $DeleteList.FullName -Force -Confirm:$false
                Write-Log 'done' DarkYellow $LogFile
            } else {
                Write-Log 'no old log files found.' DarkYellow $LogFile
            }
        }
        #endregion
    }

    End { 
        $myOutput | select Name,Id,LogFolder,LogFileCount,TotalMB
    }
} 

#endregion

#region Security

function Report-FailureAudit {

<#
 .Synopsis
  Function to search and parse Windows Security EventLog for Failure Audit events
 
 .Description
  Function to search and parse Windows Security EventLog for Failure Audit events (EventID 4625, 5061, 140)
   
 .PARAMETER MaxCount
  If an integer value of this optional parameter is provided,
  this function will limit its search to the newest $MaxCount events of each of the
  Security and Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational event logs
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output
 
 .Example
  Report-FailureAudit
  This example will return information of Failure Audit events in the Windows Security EventLog
 
 .Example
  Report-FailureAudit -MaxCount 10 -Verbose
  This example will return information of the 10 most recent Failure Audit events in the Windows Security EventLog
 
 .Example
  $EventList = Report-FailureAudit -MaxCount 4000 -LogFile "C:\myFolder\Report-FailureAudit_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
  This example will return information of the 4000 most recent Failure Audit events
 
 .Example
    $LogFile = ".\Logs\Report-FailureAudit_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    $CSVFile = ".\Reports\Report-FailureAudit_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv"
    $EventList = Report-FailureAudit -LogFile $LogFile
    $EventList | Export-Csv $CSVFile -NoTypeInformation
  This example will return information on Failure Audit events, and save them to CSV file
 
 .Example
    Summarize-FailureAudit -FailureAuditData (Report-FailureAudit -MaxCount 1000) -ReportFolder .\Reports
  This example will return information on top 1000 Failure Audit events, and display summary analysis to the console,
  and save summary analysis to CSV files under .\Reports folder such as:
  Summarize-FailureAudit_All_16April2020_04-22-39_PM.CSV ==> This file has all the records from Report-FailureAudit
  Summarize-FailureAudit_PerLogonType_16April2020_04-22-39_PM.CSV ==> This file has break down per Logon Type
  Summarize-FailureAudit_PerSourceIP_16April2020_04-22-39_PM.CSV ==> This file has break down per Source IP
  Summarize-FailureAudit_PerUserName_16April2020_04-22-39_PM.CSV ==> This file has break down per Attemptd Account
  Summarize-FailureAudit_PerLog_Security_16April2020_04-22-39_PM ==> This file has break down per Security Event Log
  Summarize-FailureAudit_PerLog_RdpCoreTS_16April2020_04-22-39_PM ==> This file has break down per rdpCoreTS Event Log
 
 .OUTPUTS
  PS Objects for each event such as:
    EventID : 4625
    ComputerName : computername.domain.com
    LogName : Security
    Provider :
    EventType : Audit Failure
    LogonType : Network
    Account : \gvradmin
    SourceIP : 185.202.2.179
    TimeCreated : 4/11/2020 10:12:46 PM
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://superwidgets.wordpress.com/2020/04/17/using-powershell-to-report-on-failed-remote-desktop-logon-attempts/
 
 .NOTES
  Function by Sam Boutros
    v0.1 - 12 April 2020
    v0.2 - 14 April 2020
        Updated summary reporting
        Added parsing for event 5061 in addition to event 4625
        Added reading of event 140 of the Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational event log
        Added duration tracking of each processing section
    v0.3 - 15 April 2020
        Read event details from $Event.ReplacementStrings instead of parsing $Event.Message
        Added source IP geolocation details in the IP summary section
        Known issues, future wish list:
        - Break off the reporting into a separate function ==> done in v0.4 - 15 April 2020
        - Report to HTML
        - Function to remediate by setting/updating Windows firewall rule or Azure NSG
        - Function to schedule tasks like reporting/remediation ==> done in Update-WindowsFirewall - 17 April 2020
        - Function to optimize Windows firewall rules by super-netting /32 IP entries when possible
    v0.4 - 15 April 2020 - Removed reporting into a separate function: Summarize-FailureAudit
    v0.5 - 17 April 2020 - Added code to report on Application event log event 18456 for SQL users failed logon
    v0.6 - 18 April 2020 - Added handling for RdpCoreTS log event Id 139
    v0.7 - 23 April 2020 - Standardize on using Get-WinEvent with FilterHashTable
    v0.8 - 1 May 2020
        - Added handling for Security event 4771 - Kerberos pre-authentication failed
        - Added feature to dump unrecognized failed logon audit events to text file
    v0.9 - 25 December 2021 - Update LogonType handling
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][Int]$MaxCount,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-FailureAudit_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        $StartTime = Get-Date
        Write-Verbose "MaxCount: $MaxCount"
        Write-Verbose "LogFile: $LogFile"
        Write-Log 'Reading Security Event Log on computer',$env:COMPUTERNAME Green,Cyan $LogFile -NoNewLine
        $Duration = Measure-Command {
            try {
                $functionEventList = Get-WinEvent -EA 1 -FilterHashtable @{
                    logname  = 'Security'
                    Keywords = ([System.Diagnostics.Eventing.Reader.StandardEventKeywords]::AuditFailure).Value__
                }
                if ($MaxCount) { $functionEventList = $functionEventList | select -First $MaxCount  }       
            } catch {
                if ($_.Exception.Message -match 'No events were found') {
                    Write-Log 'No FailureAudit events found in Security Event Log for computer',$env:COMPUTERNAME Green,Cyan $LogFile
                } else {
                    Write-Log 'Report-FailureAudit Error: unable to read Windows Security EventLog for computer',$env:COMPUTERNAME Magenta,Yellow $LogFile
                    Write-Log 'This function needs to run under elevated permissions' DarkYellow $LogFile
                    Write-Log $_.Exception.Message Magenta $LogFile
                }           
            }
        }
        if ($functionEventList) { Write-Log '..','read',$functionEventList.Count,'events in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" white,Green,Cyan,Green,DarkYellow $LogFile }

        Write-Log 'Reading ''RdpCoreTS/Operational'' Event Log on computer',$env:COMPUTERNAME Green,Cyan $LogFile -NoNewLine
        $Duration = Measure-Command {
            try {
                $RDPList = Get-WinEvent -EA 1 -FilterHashtable @{
                    logname = 'Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational'
                    Id      = 139,140
                }
                if ($MaxCount) { $RDPList = $RDPList | select -First $MaxCount }     
            } catch {
                if ($_.Exception.Message -match 'No events were found') {
                    Write-Log 'No RDP 139/140 events found in RdpCoreTS Event Log for computer',$env:COMPUTERNAME Green,Cyan $LogFile
                } else {
                    Write-Log 'Report-FailureAudit Error: unable to read Windows RdpCoreTS EventLog for computer',$env:COMPUTERNAME Magenta,Yellow $LogFile
                    Write-Log 'This function needs to run under elevated permissions' DarkYellow $LogFile
                    Write-Log $_.Exception.Message Magenta $LogFile
                } 
            }
        }
        if ($RDPList) { Write-Log '..','read',$RDPList.Count,'events in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" white,Green,Cyan,Green,DarkYellow $LogFile }
        
        Write-Log 'Reading ''SQL/Application'' Event Log on computer',$env:COMPUTERNAME Green,Cyan $LogFile -NoNewLine
        $Duration = Measure-Command {
            try {
                $SQLList = Get-WinEvent -EA 1 -FilterHashtable @{
                    logname  = 'Application'
                    Keywords = ([System.Diagnostics.Eventing.Reader.StandardEventKeywords]::AuditFailure).Value__
                }
                if ($MaxCount) { $SQLList = $SQLList | select -First $MaxCount  }       
            } catch {
                if ($_.Exception.Message -match 'No events were found') {
                    Write-Log 'No FailureAudit events found in Application Event Log for computer',$env:COMPUTERNAME Green,Cyan $LogFile
                } else {
                    Write-Log 'Report-FailureAudit Error: unable to read Windows Application EventLog for computer',$env:COMPUTERNAME Magenta,Yellow $LogFile
                    Write-Log 'This function needs to run under elevated permissions' DarkYellow $LogFile
                    Write-Log $_.Exception.Message Magenta $LogFile
                } 
            }
        }
        if ($SQLList) { Write-Log '..','read',$SQLList.Count,'events in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" white,Green,Cyan,Green,DarkYellow $LogFile }
    }

    Process {    
        
        $myOutput = $OutOfReportEvents = @() 
        if ($functionEventList) {
            $functionEventList = $functionEventList | sort TimeCreated
            Write-Log 'Processing Security Log events 4625 and 5061 on computer',$env:COMPUTERNAME Green,Cyan $LogFile -NoNewLine
            $Duration = Measure-Command {
                $myOutput += foreach ($Event in $functionEventList) {
                    Switch ($Event.Id) {
                        4625 {
                            $Temp1 = Parse-String -InputString $Event.Message -StartMarker 'Account For Which Logon Failed:' -EndMarker 'Failure Reason:'
                            $AccountName   = Parse-String -InputString $Temp1 -StartMarker 'Account Name:' -EndMarker 'Account Domain:'
                            $AccountDomain = Parse-String -InputString $Temp1 -StartMarker 'Account Domain:' -EndMarker 'Failure Information:'  
                            $LogonCode = Parse-String -InputString $Event.Message -StartMarker 'Logon Type:' -EndMarker 'Account For Which Logon Failed:'                          
                            [PSCustomObject][Ordered]@{
                                EventID      = $Event.Id
                                ComputerName = $Event.MachineName
                                LogName      = $Event.LogName
                                Provider     = $Event.ProviderName
                                EventType    = $Event.KeywordsDisplayNames -join ', '
                                LogonType    = if ($LogonCode -in $LogonType.Id) { ($LogonType | where Id -EQ $LogonCode).Name } else { "Unkown ($LogonCode)" }
                                Account      = "$AccountDomain\$AccountName"
                                SourceIP     = Parse-String -InputString $Event.Message -StartMarker 'Source Network Address:' -EndMarker 'Source Port:'
                                TimeCreated  = $Event.TimeCreated
                            }
                        }
                        4771 {
                            $AccountName   = Parse-String -InputString $Event.Message -StartMarker 'Account Name:' -EndMarker 'Service Information:'
                            $AccountDomain = ((Parse-String -InputString $Event.Message -StartMarker 'Service Name:' -EndMarker 'Network Information:') -split '/')[1]
                            [PSCustomObject][Ordered]@{
                                EventID      = $Event.Id
                                ComputerName = $Event.MachineName
                                LogName      = $Event.LogName
                                Provider     = $Event.ProviderName
                                EventType    = $Event.KeywordsDisplayNames -join ', '
                                LogonType    = 'Kerberos pre-authentication'
                                Account      = "$AccountDomain\$AccountName"
                                SourceIP     = Parse-String -InputString $Event.Message -StartMarker 'Client Address:' -EndMarker 'Client Port:'
                                TimeCreated  = $Event.TimeCreated
                            }
                        }
                        5061 {
                            $AccountName   = Parse-String -InputString $Event.Message -StartMarker 'Account Name:' -EndMarker 'Account Domain:'
                            $AccountDomain = Parse-String -InputString $Event.Message -StartMarker 'Account Domain:' -EndMarker 'Logon ID:'                            
                            [PSCustomObject][Ordered]@{
                                EventID      = $Event.Id
                                ComputerName = $Event.MachineName
                                LogName      = $Event.LogName
                                Provider     = $Event.ProviderName
                                EventType    = $Event.KeywordsDisplayNames -join ', '
                                LogonType    = "Not reported in event $($Event.Id)"
                                Account      = "$AccountDomain\$AccountName"                  
                                SourceIP     = "Not reported in event $($Event.Id)"
                                TimeCreated  = $Event.TimeCreated
                            }
                        }
                        Default { 
                            Write-Log 'Report-FailureAudit: Encountered unknown FailureAudit Event: ID', $Event.Id Yellow,Cyan $LogFile 
                            $OutOfReportEvents += $Event
                        }
                    }
                }
            }
            Write-Log '..','done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" white,Green,DarkYellow $LogFile
        } else {
            Write-Log 'No events of type FailureAudit found in the Windows Security EventLog' Green $LogFile
        }

        if ($RDPList) {
            $RDPList = $RDPList | sort TimeCreated
            Write-Log 'Processing ''RdpCoreTS/Operational'' Log events 139/140 on computer',$env:COMPUTERNAME Green,Cyan $LogFile -NoNewLine
            $Duration = Measure-Command {
                $myOutput += foreach ($Event in $RDPList) {
                    Switch ($Event.Id) {
                        139 {
                            [PSCustomObject][Ordered]@{
                                EventID      = $Event.Id
                                ComputerName = $Event.MachineName
                                LogName      = $Event.LogName
                                Provider     = $Event.ProviderName
                                EventType    = $(
                                    if ($Event.KeywordsDisplayNames) {
                                        $Event.KeywordsDisplayNames -join ', '
                                    } else {
                                        ($EventKeyWords | where Number -EQ $Event.Keywords).Name 
                                    }
                                )
                                LogonType    = $( 
                                    if ($Event.UserId -eq 'S-1-5-20') {
                                        'Network'
                                    } else {
                                        $Event.UserId # "Not reported in event $($Event.Id)"
                                    }
                                )
                                Account      = "Not reported in event $($Event.Id)"
                                SourceIP     = Parse-String -InputString $Event.Message -StartMarker ([Regex]::Escape('Client IP:')) -EndMarker ([Regex]::Escape(') has been disconnected'))
                                TimeCreated  = $Event.TimeCreated
                            }
                        }                        
                        140 {
                            [PSCustomObject][Ordered]@{
                                EventID      = $Event.Id
                                ComputerName = $Event.MachineName
                                LogName      = $Event.LogName
                                Provider     = $Event.ProviderName
                                EventType    = $(
                                    if ($Event.KeywordsDisplayNames) {
                                        $Event.KeywordsDisplayNames -join ', '
                                    } else {
                                        ($EventKeyWords | where Number -EQ $Event.Keywords).Name 
                                    }
                                )
                                LogonType    = $( 
                                    if ($Event.UserId -eq 'S-1-5-20') {
                                        'Network'
                                    } else {
                                        $Event.UserId # "Not reported in event $($Event.Id)"
                                    }
                                )
                                Account      = "Not reported in event $($Event.Id)"
                                SourceIP     = Parse-String -InputString $Event.Message -StartMarker ([Regex]::Escape('IP address of')) -EndMarker ([Regex]::Escape('failed because'))
                                TimeCreated  = $Event.TimeCreated
                            }
                        }
                    }
                }
            }
            Write-Log '..','done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" white,Green,DarkYellow $LogFile
        } else {
            Write-Log 'No Events 139/140 found in the ''RdpCoreTS/Operational'' EventLog' Green $LogFile
        }

        if ($SQLList) {
            $SQLList = $SQLList | sort TimeCreated
            Write-Log 'Processing Application Log event 18456 on computer',$env:COMPUTERNAME Green,Cyan $LogFile -NoNewLine
            $Duration = Measure-Command {
                $myOutput += foreach ($Event in $SQLList) {
                    Switch ($Event.Id) {
                        18456 {
                            [PSCustomObject][Ordered]@{
                                EventID      = $Event.Id
                                ComputerName = $Event.MachineName
                                LogName      = $Event.LogName
                                Provider     = $Event.ProviderName
                                EventType    = $(
                                    if ($Event.KeywordsDisplayNames) {
                                        $Event.KeywordsDisplayNames -join ', '
                                    } else {
                                        ($EventKeyWords | where Number -EQ $Event.Keywords).Name 
                                    }
                                )
                                LogonType    = $( 
                                    if ($Event.UserId -eq 'S-1-5-20') {
                                        'Network'
                                    } else {
                                        $Event.UserId # "Not reported in event $($Event.Id)"
                                    }
                                )
                                Account      = Parse-String -InputString $Event.Message -StartMarker 'user ''' -EndMarker '''. Reason'                  
                                SourceIP     = Parse-String -InputString $Event.Message -StartMarker '\[CLIENT:' -EndMarker '\]'
                                TimeCreated = $Event.TimeCreated
                            }
                        }
                        Default { 
                            Write-Log 'Report-FailureAudit: Encountered unknown FailureAudit Event: ID', $Event.Id Yellow,Cyan $LogFile 
                            $OutOfReportEvents += $Event
                        }
                    }
                }
            }
            Write-Log '..','done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" white,Green,DarkYellow $LogFile
        } else {
            Write-Log 'No events of type FailureAudit found in the Windows Application EventLog' Green $LogFile
        }

    } 

    End {
        if ($myOutput) {
            $myOutput = $myOutput | sort TimeCreated -Descending
            $myOutput
        }
        if ($OutOfReportEvents) {
            $OutOfReportEvents = $OutOfReportEvents | sort TimeCreated -Descending
            $FileName = (Get-Item $LogFile).FullName.Replace('Report-FailureAudit_','Report-FailureAudit_OutOfReportEvents_')
            $OutOfReportEvents | FL * | Out-String | Out-File $FileName -Force
            Write-Log $OutOfReportEvents.Count,'Unrecognized events dumped to file:',$FileName Cyan,Green,Cyan $LogFile
        }
    }
}

function Summarize-FailureAudit {
<#
 .SYNOPSIS
  Function to provide summary report on data returned from Report-FailureAudit function
 
 .DESCRIPTION
  Function to provide summary report on data returned from Report-FailureAudit function
  This function is designed to aggregate reporting on multiple computers in the same environment
  Summary reporting is provided by:
    Event Log: Security and RDP (Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational)
    Source IP: with the most frequent ones on top
    Logon Type: such as Network/Interactive/... with the most frequent ones on top
    Attempted User Name: with the most frequent ones on top
 
 .PARAMETER FailureAuditData
  PS Objects returned from Report-FailureAudit function containing the following required properties:
    Account
    ComputerName
    EventID
    EventType
    LogName
    LogonType
    Provider
    SourceIP
    TimeCreated
 
 .PARAMETER ShowTop
  Optional parameter containing the count of records to report on.
  Such as show top 10 most frequent IP addresses.
  This defaults to 10.
 
 .PARAMETER ReportFolder
  Path to a folder where this function will save its CSV output reports
 
 .PARAMETER LogFile
  Optional parameter containing the path to a file to which this function logs its console output
 
 .Example
    Summarize-FailureAudit -FailureAuditData (Report-FailureAudit -MaxCount 1000) -ReportFolder .\Reports
  This example will return information on top 1000 Failure Audit events, and display summary analysis to the console,
  and save summary analysis to CSV files under .\Reports folder such as:
  Summarize-FailureAudit_All_16April2020_04-22-39_PM.CSV ==> This file has all the records from Report-FailureAudit
  Summarize-FailureAudit_PerLogonType_16April2020_04-22-39_PM.CSV ==> This file has break down per Logon Type
  Summarize-FailureAudit_PerSourceIP_16April2020_04-22-39_PM.CSV ==> This file has break down per Source IP
  Summarize-FailureAudit_PerUserName_16April2020_04-22-39_PM.CSV ==> This file has break down per Attempted Account
  Summarize-FailureAudit_PerLog_Security_16April2020_04-22-39_PM ==> This file has break down per Security Event Log
  Summarize-FailureAudit_PerLog_RdpCoreTS_16April2020_04-22-39_PM ==> This file has break down per rdpCoreTS Event Log
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://superwidgets.wordpress.com/2020/04/17/using-powershell-to-report-on-failed-remote-desktop-logon-attempts/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 April 2020
  v0.2 - 17 April 2020 - Updated to summarize SQL/Application log events
  v0.3 - 23 April 2020 - Removed SourceName property and added Provider
  v0.4 - 29 April 2020 - Lookup a maximum of 3 IP locations - IP Location API will lock out source IP if sending too many requests
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][PSCustomObject[]]$FailureAuditData,
        [Parameter(Mandatory=$false)][Int]$ShowTop = 10,
        [Parameter(Mandatory=$false)][Switch]$PerLog,
        [Parameter(Mandatory=$false)][Switch]$PerSourceIP,
        [Parameter(Mandatory=$false)][Switch]$PerLogonType,
        [Parameter(Mandatory=$false)][Switch]$PerUserName,
        [Parameter(Mandatory=$false)][ValidateScript({Test-Path $_})][String]$ReportFolder = '.\',
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Summarize-FailureAudit_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 
        # Validate PS Objects' required properties
        $RequiredProperties = @('Account','ComputerName','EventID','EventType','LogName','LogonType','Provider','SourceIP','TimeCreated')
        $ProvidedProperties = ($FailureAuditData | select -First 1 | Get-Member -MemberType NoteProperty).Name
        $MissingProperties  = foreach ($Property in $RequiredProperties) {
            if ($Property -notin $ProvidedProperties) { $Property }
        }

        # If none of the individual summaries is selected, select them all
        if (-not($PerLog-and$PerSourceIP-and$PerLogonType-and$PerUserName)) { $All = $true }

        Write-Verbose "FailureAuditData: $($FailureAuditData.Count)"
        Write-Verbose "ShowTop: $ShowTop"
        Write-Verbose "PerLog: $PerLog"
        Write-Verbose "PerSourceIP: $PerSourceIP"
        Write-Verbose "PerLogonType: $PerLogonType"
        Write-Verbose "PerUserName: $PerUserName"
        Write-Verbose "ReportFolder: $ReportFolder"
        Write-Verbose "LogFile: $LogFile"

        if ($MissingProperties) {
            Write-Log 'Summarize-FailureAudit Error: missing one or more input object properties:' Magenta $LogFile
            Write-Log 'Missing properties:',($MissingProperties -join ',') Magenta,Yellow $LogFile
            Write-Log 'Expected properties:',($RequiredProperties -join ',') Green,Cyan $LogFile
            Write-Log 'Provided properties:',($ProvidedProperties -join ',') Green,Yellow $LogFile
            break
        } 
    }

    Process {      
        Write-Log 'Processing summary report' Green $LogFile -NoNewLine
        $TimeStamp = Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt'
        $FailureAuditData = $FailureAuditData | sort TimeCreated

        if ($PerLog -or $All) {
                $functionEventList = $FailureAuditData | where LogName -EQ Security
                if ($functionEventList) {
                    $functionEventList = $functionEventList | sort TimeCreated
                    $LastHour  = $functionEventList | where TimeCreated -GT (Get-Date $functionEventList[-1].TimeCreated).AddHours(-1)
                    $LD = New-TimeSpan -Start $functionEventList[0].TimeCreated -End $functionEventList[-1].TimeCreated
                    $SecurityEventSummary = [PSCustomObject][Ordered]@{
                        EventCount       = '{0:N0}' -f $functionEventList.Count
                        FirstEventTime   = $functionEventList[0].TimeCreated
                        LastEventTime    = $functionEventList[-1].TimeCreated 
                        Duration         = "$($LD.Days):$($LD.Hours):$($LD.Minutes):$($LD.Seconds) (dd:hh:mm:ss)"
                        AttemptsPerHour  = '{0:N0}' -f ($functionEventList.Count/$LD.TotalHours)
                        AttemptsLastHour = '{0:N0}' -f ($LastHour.Count)
                        EventLog         = 'Security'
                        EventType        = $(($functionEventList.EventType | select -Unique) -join ', ')
                        EventId          = $(($functionEventList.EventId | select -Unique) -join ', ')
                    }
                    Write-Host ' '
                    Write-Log 'Security Event summary:' Green $LogFile
                    Write-Log ($SecurityEventSummary | FL * | Out-String).Trim() Cyan $LogFile
                    $ReportFile = "$ReportFolder\Summarize-FailureAudit_PerLog_Security_$TimeStamp.CSV"
                    $SecurityEventSummary | Export-Csv $ReportFile -NoTypeInformation 
                    Write-Log  'Security Event summary exported to',$ReportFile Green,Cyan $LogFile
                } else {
                    Write-Log 'No Failure Audit Events found in Security event log' Green $LogFile
                }

                $RDPList = $FailureAuditData | where LogName -EQ RdpCoreTS
                if ($RDPList) {
                    $RDPList = $RDPList | sort TimeCreated
                    $LastHour  = $RDPList | where TimeCreated -GT (Get-Date $RDPList[-1].TimeCreated).AddHours(-1)
                    $LD = New-TimeSpan -Start $RDPList[0].TimeCreated -End $RDPList[-1].TimeCreated
                    $RDPEventSummary = [PSCustomObject][Ordered]@{
                        EventCount       = '{0:N0}' -f $RDPList.Count
                        FirstEventTime   = $RDPList[0].TimeCreated
                        LastEventTime    = $RDPList[-1].TimeCreated 
                        Duration         = "$($LD.Days):$($LD.Hours):$($LD.Minutes):$($LD.Seconds) (dd:hh:mm:ss)"
                        AttemptsPerHour  = '{0:N0}' -f ($RDPList.Count/$LD.TotalHours)
                        AttemptsLastHour = '{0:N0}' -f ($LastHour.Count)
                        EventLog         = 'RdpCoreTS'
                        EventType        = $(($RDPList.EventType | select -Unique) -join ', ')
                        EventId          = $(($RDPList.EventId | select -Unique) -join ', ')
                    }
                    Write-Host ' '
                    Write-Log 'RDP Event summary:' Green $LogFile
                    Write-Log ($RDPEventSummary | FL * | Out-String).Trim() Cyan $LogFile
                    $ReportFile = "$ReportFolder\Summarize-FailureAudit_PerLog_RdpCoreTS_$TimeStamp.CSV"
                    $RDPEventSummary | Export-Csv $ReportFile -NoTypeInformation 
                    Write-Log 'RdpCoreTS Event summary exported to',$ReportFile Green,Cyan $LogFile
                } else {
                    Write-Log 'No Failure Audit Events found in RdpCoreTS event log' Green $LogFile
                }

                $SQLList = $FailureAuditData | where LogName -EQ Application
                if ($SQLList) {
                    $SQLList = $SQLList | sort TimeCreated
                    $LastHour  = $SQLList | where TimeCreated -GT (Get-Date $SQLList[-1].TimeCreated).AddHours(-1)
                    $LD = New-TimeSpan -Start $SQLList[0].TimeCreated -End $SQLList[-1].TimeCreated
                    $SQLEventSummary = [PSCustomObject][Ordered]@{
                        EventCount       = '{0:N0}' -f $SQLList.Count
                        FirstEventTime   = $SQLList[0].TimeCreated
                        LastEventTime    = $SQLList[-1].TimeCreated 
                        Duration         = "$($LD.Days):$($LD.Hours):$($LD.Minutes):$($LD.Seconds) (dd:hh:mm:ss)"
                        AttemptsPerHour  = '{0:N0}' -f ($SQLList.Count/$LD.TotalHours)
                        AttemptsLastHour = '{0:N0}' -f ($LastHour.Count)
                        EventLog         = 'Application'
                        EventType        = $(($SQLList.EventType | select -Unique) -join ', ')
                        EventId          = $(($SQLList.EventId | select -Unique) -join ', ')
                    }
                    Write-Host ' '
                    Write-Log 'SQL/Application Event summary:' Green $LogFile
                    Write-Log ($SQLEventSummary | FL * | Out-String).Trim() Cyan $LogFile
                    $ReportFile = "$ReportFolder\Summarize-FailureAudit_PerLog_SQL-Application_$TimeStamp.CSV"
                    $SQLEventSummary | Export-Csv $ReportFile -NoTypeInformation 
                    Write-Log 'SQL/Application Event summary exported to',$ReportFile Green,Cyan $LogFile
                } else {
                    Write-Log 'No Failure Audit Events found in Application event log' Green $LogFile
                }
            }

        if ($PerSourceIP -or $All) {
            $i=0 # Lookup a maximum of 3 IP locations - IP Location API will lock out source IP if sending too many requests
            $SourceIP = foreach ($Group in ($FailureAuditData | where { $_.SourceIP } | group SourceIP)) { 
                $i++
                if ($i -le 3) {
                    $IPLocation = Get-IPLocation $Group.Name
                } else {
                    Remove-Variable IPLocation -Force -EA 0 
                }                    
                [PSCustomObject][Ordered]@{
                    IPAddress    = $Group.Name
                    ReverseDNS   = $IPLocation.ReverseDNS
                    IPLocation   = $(
                        if ($IPLocation) {
                            "$($IPLocation.City), $($IPLocation.Region), $($IPLocation.ZipCode) - $($IPLocation.Country) ($($IPLocation.Coords))"
                        }
                    )
                    IPOrg        = $IPLocation.Org
                    IPTimeZone   = $IPLocation.TimeZone
                    AttemptCount = $Group.Count
                    Percent      = ($Group.Count/$FailureAuditData.Count).tostring("P")
                }
            }
            $SourceIP = $SourceIP | sort AttemptCount -Descending
            Write-Host ' '
            Write-Log "Source IP summary (Top $ShowTop):" Green $LogFile
            Write-Log ($SourceIP | select -First $ShowTop | FL * | Out-String).Trim() Cyan $LogFile
            $ReportFile = "$ReportFolder\Summarize-FailureAudit_PerSourceIP_$TimeStamp.CSV"
            $SourceIP | Export-Csv $ReportFile -NoTypeInformation 
            Write-Log  'Source IP summary exported to',$ReportFile Green,Cyan $LogFile
        }

        if ($PerLogonType -or $All) {
            $LogonType = $FailureAuditData | where { $_.LogonType } | group LogonType | select @{n='LogonType';e={$_.Name}},
                @{n='AttemptCount';e={$_.Count}},
                @{n='Percent';e={($_.Count/$FailureAuditData.Count).tostring("P")}} | sort AttemptCount -Descending
            Write-Host ' '
            Write-Log "Logon Attempt Type summary (Top $ShowTop):" Green $LogFile
            Write-Log ($LogonType | select -First $ShowTop | FT -a | Out-String).Trim() Cyan $LogFile 
            $ReportFile = "$ReportFolder\Summarize-FailureAudit_PerLogonType_$TimeStamp.CSV"
            $LogonType | Export-Csv $ReportFile -NoTypeInformation 
            Write-Log  'Logon Type summary exported to',$ReportFile Green,Cyan $LogFile
        }

        if ($PerUserName -or $All) {
            $Account = $FailureAuditData | where { $_.Account } | group Account | sort count -Descending | 
                select @{n='Account';e={$_.Name}},@{n='AttemptCount';e={$_.Count}},
                @{n='Percent';e={($_.Count/$FailureAuditData.Count).tostring("P")}} | sort AttemptCount -Descending
            Write-Host ' '
            Write-Log "Attempted Account summary (Top $ShowTop):" Green $LogFile
            Write-Log ($Account | select -First $ShowTop | FT -a | Out-String).Trim() Cyan $LogFile  
            $ReportFile = "$ReportFolder\Summarize-FailureAudit_PerUserName_$TimeStamp.CSV"
            $Account | Export-Csv $ReportFile -NoTypeInformation 
            Write-Log  'User Name summary exported to',$ReportFile Green,Cyan $LogFile
        }

        if ($All) {
            $ReportFile = "$ReportFolder\Summarize-FailureAudit_All_$TimeStamp.CSV"
            $FailureAuditData | Export-Csv $ReportFile -NoTypeInformation 
            Write-Log  'All records exported to',$ReportFile Green,Cyan $LogFile
        }

        Write-Host ' '
        Write-Log 'Latest',$ShowTop,'attempts:' Green,Cyan,Green $LogFile
        Write-Log ($FailureAuditData | select -Last $ShowTop | 
            select EventId,ComputerName,LogName,Account,SourceIP,TimeCreated | 
            sort TimeCreated -Descending | FT -a | Out-String).Trim() Cyan $LogFile  

    }

    End {  }
} 

function Update-WindowsFirewall {
<#
 .SYNOPSIS
  Function to create/update Windows firewall rule to block 1 or more IP addresses
 
 .DESCRIPTION
  Function to create/update Windows firewall rule to block 1 or more IP addresses
 
 .PARAMETER BlockIPList
  One or more IP addresses to block
  This can be a dotted decimal IPv4 address such as 123.45.67.89,
  or in CIDR notation such as 123.45.67.0/24
 
 .PARAMETER AllowIPList
  One or more IP addresses to ensure are not blocked by this firewall rule
  This can be a dotted decimal IPv4 address such as 123.45.67.89,
  or in CIDR notation such as 123.45.67.0/24
  This function is capable of recognizing and allowing an IP if its subnet is listed under this parameter.
  For example, if the BlockIPList parameter included '10.11.22.33' and the AllowIPList parameter included a subnet like
  10.11.22.0/24 or 10.11.22.0/26, this function will recognize 10.11.22.33 as part of a subnet to be allowed,
  and as such it will not be blocked. Furthermore, if '10.11.22.33' already exists in this firewall rule, it will removed.
 
 .PARAMETER RuleName
  Name of the firewall rule to be created/updated. This defaults to 'BlockAttackers'
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output
 
 .EXAMPLE
    Update-WindowsFirewall -BlockIPList '10.2.3.4'
 
 .EXAMPLE
    $BlockIPList = (Get-ChildItem -Path .\ -Filter Summarize-FailureAudit_All*.csv | foreach { Import-Csv $_.FullName }).SourceIP | select -Unique | sort
    $AllowIPList = @(
        '123.45.67.48/29' # My WAN subnet
        '10.0.1.0/16' # My LAN subnet
        (Resolve-DnsName -Name someallowedhost.domain.com).IPAddress
        '123.45.67.89' # Some known remote user IP
    )
    $BlockedIPs = Update-WindowsFirewall -BlockIPList $BlockIPList -AllowIPList $AllowIPList -Verbose
    The first line of this example searches for CSV reports generated by the Summarize-FailureAudit function in the current folder,
    imports the SourceIP column, and deduplicates the IP List.
    The next line lists a bunch of allowed IPs and subnets.
    The last line uses the $BlockIPList and $AllowIPList as input to create/update a firewall rule to block the attacking IPs.
    Using the $AllowIPList ensures that ligitimate IPs are not blocked if they show up in the logs due to occasional failed logon.
 
 .OUTPUTS
  This cmdlet returns one or more Dotted Decimal string notations of the blocked IP addresses/subnets such as
    185.209.0.20
    185.209.0.68
    185.231.71.184
    185.56.90.90
    186.202.178.2
    186.91.191.103
    186.95.172.116
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 17 April 2020
  v0.2 - 18 April 2020
    Added Exclude parameter
    Added accepting CIDR ranges in addition to individual IPs for IPAddress and Exclude paramters
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Alias('IPAddress')][String[]]$BlockIPList,
        [Parameter(Mandatory=$false)][Alias('Exclude')][String[]]$AllowIPList,
        [Parameter(Mandatory=$false)][String]$RuleName = 'BlockAttackers',
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Update-WindowsFirewall_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').txt"
    )

    Begin { 

        Write-Verbose "IPAddress: $($BlockIPList -join ', ')"
        Write-Verbose "Exclude : $($AllowIPList -join ', ')"
        Write-Verbose "RuleName : $RuleName"
        Write-Verbose "LogFile : $LogFile"

        # Validate IP addresses:
        $BlockIPList = $BlockIPList | where { $_ } # Remove blanks
        $IPList = foreach ($IP in $BlockIPList) { 
            if ($IP -as [IPAddress]) { 
                $IP 
            } elseif ($CIDR = Get-IPv4Details -CIDRAddress $IP) { 
                $CIDR.NetCIDR 
            }
        }

        $ExcludeList = foreach ($IP in $AllowIPList) { 
            if ( $IP -as [IPAddress] ) { 
                $IP 
            } elseif ($CIDR = Get-IPv4Details -CIDRAddress $IP) { 
                $CIDR.NetCIDR 
                0..($CIDR.SubnetMaximumHosts-1) | foreach { Next-IP -IPAddress $CIDR.FirstSubnetIP -Increment $_ } # Expand CIDR
            }
        }

    }

    Process {  
      
        if ($IPList) {
            $Description = "Rule to deny access to a list of IP addesses and subnets. "
            $Description += "This rule is set by Update-WindowsFirewall PS function of the AZSBTools PS Module "
            $Description += "which was last invoked on '$(Get-Date -Format 'dd MMMM yyyy, hh:mm:ss tt')' "
            $Description += "by '$($env:USERDOMAIN)\$($env:USERNAME)'"
            if ($BlockRule = Get-NetFirewallRule | where DisplayName -EQ $RuleName) {
                Write-Log 'Identified Block rule in Windows firewall:' Green $LogFile
                Write-Log ($BlockRule | FL DisplayName,Enabled,Profile,Direction,Action | Out-String).Trim() Cyan $LogFile
                if ($RemoteAddressList = $BlockRule | Get-NetFirewallAddressFilter) {
                    Write-Log ' blocking',$RemoteAddressList.RemoteIP.Count,'address(es)' Green,Cyan,Green $LogFile
                    $UpdatedList = @()
                    $UpdatedList += $RemoteAddressList.RemoteIP 
                    $UpdatedList += $IPList
                    $UpdatedList = $UpdatedList | select -Unique | sort
                    $UpdatedList = foreach ($IP in $UpdatedList) { if ($IP -notin $ExcludeList) { $IP } } # Remove ExcludeList IPs
                    Write-Log ' Updating IP list, now',$UpdatedList.Count,'address(es)' Green,Cyan,Green $LogFile
                    $BlockRule | Set-NetFirewallRule -RemoteAddress $UpdatedList -NewDisplayName $RuleName -Enabled True -Profile Any -Direction Inbound -Action Block -Description $Description
                    Write-Verbose 'Blocked IPs:'
                    Write-Verbose ($UpdatedList|Out-String).trim()
                } else {
                    $UpdatedList = foreach ($IP in $IPList) { if ($IP -notin $ExcludeList) { $IP } } # Remove ExcludeList IPs
                    Write-Log ' Updating IP list, now',$UpdatedList.Count,'address(es)' Green,Cyan,Green $LogFile
                    $BlockRule | Set-NetFirewallRule -RemoteAddress $UpdatedList -NewDisplayName $RuleName -Enabled True -Profile Any -Direction Inbound -Action Block -Description $Description
                    Write-Verbose 'Blocked IPs:'
                    Write-Verbose ($UpdatedList|Out-String).trim()
                }
            } else {
                $UpdatedList = foreach ($IP in $IPList) { if ($IP -notin $ExcludeList) { $IP } } # Remove ExcludeList IPs
                Write-Log 'No Block rule found in Windows firewall, adding',$UpdatedList.Count,'address(es)' Yellow,Cyan,Green $LogFile
                New-NetFirewallRule -RemoteAddress $UpdatedList -Name $RuleName -DisplayName $RuleName -Enabled True -Direction Inbound -Profile Any -Action Block -Description $Description
                Write-Verbose 'Blocked IPs:'
                Write-Verbose ($UpdatedList|Out-String).trim()
            }
        } else {
            Write-Log 'Update-WindowsFirewall: No IP addresses provided in input' Yellow $LogFile
        }

    }

    End { $UpdatedList }
} 

function Block-FailedLogonIPs {
<#
 .SYNOPSIS
  Function to automate blocking the IPs/subnets of failed Windows and SQL logon attempts
 
 .DESCRIPTION
  Function to automate blocking the IPs/subnets of failed Windows and SQL logon attempts
  Using the default parameter values, this function will:
  - Create Logs and Reports folders under its current location, with _Archive subfolder under each
  - Schedule itself to run hourly (under LocalSystem context) if not already scheduled
  - Read and parse Security and RDP (Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational) event logs for failed Windows logon events
  - Read and parse Application event log for failed SQL logon events
  - Summarize the data in 6 time-stamped CSV reports under the Reports folder
  - Combine and deduplicate the IP list from the above reports
  - Create/update a windows firewall rule to block these IPs, ensuring the IPs/subnets in the AllowIPList parameter are not blocked
  - Clear the Security, RDP, and Application event logs for faster processing next hour
  - Archive the Log and Report files under the corresponding _Archive folders
  
 .PARAMETER AllowIPList
  One or more IPs or subnets
  For example 123.45.67.89 or/and 10.20.30.0/24
  This function adds the local LAN subnet(s) to this list
 
 .PARAMETER ScheduleHourly
  Optional switch parameter
  When set to True this function will schedule itself to run hourly
 
 .PARAMETER WorkFolder
  Optional parameter that defaults to current folder
  This function will create/validate the following folders under this folder:
  .\Logs
  .\Reports
  .\Logs\_Archive
  .\Reports\_Archive
 
 .PARAMETER ClearRdpCoreTSEventLog
  Optional switch parameter that defaults to True
  When set to True this function will clear the Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational Event Log after reading and analysing its events
  Before clearing the event log, this function will back it up under $WorkFolder\Logs
 
 .PARAMETER ClearSecurityEventLog
  Optional switch parameter that defaults to True
  When set to True this function will clear the Scruity Event Log after reading and analysing its events
  Before clearing the event log, this function will back it up under $WorkFolder\Logs
 
 .PARAMETER ClearApplicationEventLog
  Optional switch parameter that defaults to True
  When set to True this function will clear the Application Event Log after reading and analysing its events
  Before clearing the event log, this function will back it up under $WorkFolder\Logs
 
 .EXAMPLE
    $myScriptRoot = 'C:\Sandbox' # Change this line as needed
    New-Item $myScriptRoot -ItemType Directory -EA 0 | Out-Null # Create Script folder if not exist
    @'
    Block-FailedLogonIPs -WorkFolder $myScriptRoot -AllowIPList @(
        '22.33.44.55' # Trusted end point
        '10.1.2.0/24' # Trusted Local Subnet
        '123.45.67.48/29' # Trusted subnet 1
    ) # -ScheduleHourly # Use this switch on the first run to schedule this script to run hourly
    '@ | Out-File "$myScriptRoot\Block-Attackers.ps1"
    ise "$myScriptRoot\Block-Attackers.ps1" # Review the file and invoke manually in ISE
    # & "$myScriptRoot\Block-Attackers.ps1" # Or invoke it now
  This example creates and invokes Block-Attackers.ps1 script which
  invokes this Block-FailedLogonIPs function abd self-schedules to run hourly.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 21 April 2020
  v0.2 - 24 April 2020 - Added Verbose output, changed default value for switch ScheduleHourly to False
  v0.3 - 29 April 2020 - Added ClearRdpCoreTSEventLog, WorkFolder parameters
  v0.4 - 30 April 2020 - Added code to not archive empty Windows event logs
  v0.5 - 10 October 2021 - Minor update / error trapping for $thisCommand
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$AllowIPList,
        [Parameter(Mandatory=$false)][String]$WorkFolder = (Get-Location).Path,
        [Parameter(Mandatory=$false)][Switch]$ScheduleHourly,        
        [Parameter(Mandatory=$false)][Switch]$ClearRdpCoreTSEventLog,
        [Parameter(Mandatory=$false)][Switch]$ClearSecurityEventLog,
        [Parameter(Mandatory=$false)][Switch]$ClearApplicationEventLog
    )

    Begin { 
        
        if (-not $AllowIPList) { # Add local subnet(s)
            $AllowIPList = Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Manual,DHCP | foreach {
                Write-Verbose "Adding local subnet ($($_.IPAddress + '/' + $_.PrefixLength)) to (AllowIPList)"
                $_.IPAddress + '/' + $_.PrefixLength
            }
        }

        $ThisFile    = $MyInvocation.ScriptName # FullName
        $thisCommand = try { ($ThisFile | Split-Path -Leaf -EA 1) -replace '.ps1' } catch { 'Block-FailedLogonIPs' }
        $LogFile     = "$WorkFolder\Logs\$thisCommand-$($env:COMPUTERNAME)-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
        New-Item -Path "$WorkFolder\Logs" -ItemType Directory -Force -EA 0 | Out-Null
        New-Item -Path "$WorkFolder\Reports" -ItemType Directory -Force -EA 0 | Out-Null
        New-Item -Path "$WorkFolder\Logs\_Archive" -ItemType Directory -Force -EA 0 | Out-Null
        New-Item -Path "$WorkFolder\Reports\_Archive" -ItemType Directory -Force -EA 0 | Out-Null
        Write-Verbose "Block-FailedLogonIPs (AllowIPList): $($AllowIPList -join ', ')"
        Write-Verbose "Block-FailedLogonIPs (ScheduleHourly): $ScheduleHourly"
        Write-Verbose "Block-FailedLogonIPs (ClearSecurityEventLog): $ClearSecurityEventLog"
        Write-Verbose "Block-FailedLogonIPs (ClearApplicationEventLog): $ClearApplicationEventLog"
        Write-Verbose "Block-FailedLogonIPs (LogFile): $LogFile"

        function Backup-thisLog($LogName,$WorkFolder,$LogFile){
            $EventSession = New-Object System.Diagnostics.Eventing.Reader.EventLogSession
            $LogInfo = $EventSession.GetLogInformation("$LogName",'LogName')
            if ($LogInfo.RecordCount -gt 1) { 
                $Result = Backup-EventLog -EventLogName $LogName -BackupFolder "$WorkFolder\Logs" -LogFile $LogFile 
                Clear-SBEventLog -EventLogName $LogName -LogFile $LogFile -Confirm:$false
            } else {
                Write-Log 'Windows event log',$LogName,'has',$LogInfo.RecordCount,'records, skipping..' Green,Cyan,Green,Cyan,Green $LogFile
            }
        }

    }

    Process {      


        #region ScheduleHourly

        if ($ScheduleHourly) {
            $StartAt = (Get-Date).AddMinutes(50).Hour.ToString().PadLeft(2,'0') + ':' + (Get-Date).AddMinutes(50).Minute.ToString().PadLeft(2,'0')
            $Result = SCHTasks /Create /RU System /SC HOURLY /TN "PowerShell-$thisCommand" /TR "PowerShell $ThisFile" /ST $StartAt /RL HIGHEST /F
            if ($Result -match 'SUCCESS') {
                Write-Log $Result Cyan $LogFile
            } else {
                Write-Log $Result Yellow $LogFile
            }
        }

        #endregion


        #region Check logs, clear event logs, update firewall rules, archive logs/reports

        $functionEventList = Report-FailureAudit -LogFile $LogFile 
        if ($functionEventList) { Summarize-FailureAudit -FailureAuditData $functionEventList -ReportFolder .\Reports -LogFile $LogFile }

        $BlockIPList = (Get-ChildItem -Path .\Reports\ -Filter Summarize-FailureAudit_All*.csv | 
            foreach { Import-Csv $_.FullName }).SourceIP | select -Unique | sort
        $RuleIPList  = Update-WindowsFirewall -BlockIPList $BlockIPList -AllowIPList $AllowIPList -LogFile $LogFile
        Write-Log ($RuleIPList|Out-String).Trim() Cyan $LogFile

        # Clear event logs and archive log files
        if ($ClearRdpCoreTSEventLog) { 
            Backup-thisLog -LogName 'Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational' -WorkFolder $WorkFolder -LogFile $LogFile
        }
        if ($ClearSecurityEventLog) { 
            Backup-thisLog -LogName Security -WorkFolder $WorkFolder -LogFile $LogFile
        }
        if ($ClearApplicationEventLog) { 
            Backup-thisLog -LogName Application -WorkFolder $WorkFolder -LogFile $LogFile
        }
        Get-ChildItem -Path .\Logs -File    | Move-Item -Destination .\Logs\_Archive    -EA 0 
        Get-ChildItem -Path .\Reports -File | Move-Item -Destination .\Reports\_Archive -EA 0 

        #endregion

    }

    End {  }
} 

function New-Password {
<#
 .SYNOPSIS
  Function to generate random password
 
 .DESCRIPTION
  Function to generate random password
 
 .PARAMETER Length
  Number between 2 and 256
  Default is 25
 
 .PARAMETER Include
  One or more of the following:
    UpperCase
    LowerCase
    Numbers
    SpecialCharacters
  Default is all 4
 
 .PARAMETER CodeFriendly
  When set to True, this function excludes the following 4 characters from the 'SpecialCharacters' list of the password
  " ==> ASCII 34
  $ ==> ASCII 36
  ' ==> ASCII 39
  ` ==> ASCII 96
 
 .EXAMPLE
  New-Password
 
 .EXAMPLE
  New-Password -Length 10 -Include LowerCase,UpperCase,Numbers -Verbose
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 27 July 2017
  v0.2 - 3 May 2020 - included in AZSBTools PS module.
  v0.3 - 19 October 2020 - Added Switch to remove 4 code unfriendly characters.
  v0.4 - 4 October 2021 - Fixed bug to allow maximum password length past 94.
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][ValidateRange(2,256)][Int32]$Length = 37, 
        [Parameter(Mandatory=$false)][ValidateSet('UpperCase','LowerCase','Numbers','SpecialCharacters')]
            [String[]]$Include = @('UpperCase','LowerCase','Numbers'),
        [Parameter(Mandatory=$false)][Switch]$CodeFriendly
    )

    Begin { }

    Process {
        Write-Verbose "Generate-Password: Input: Length = $Length"
        Write-Verbose "Generate-Password: Input: Include = $($Include -join ', ')"

        Remove-Variable MyRange -EA 0
        $Include | foreach {
            if ($_ -eq 'UpperCase') { 
                $MyRange += 65..90 # 26
                Write-Verbose 'Generate-Password: MyRange: +UpperCase' 
            }
            if ($_ -eq 'LowerCase') { 
                $MyRange += 97..122 # 26
                Write-Verbose 'Generate-Password: MyRange: +LowerCase' 
            }
            if ($_ -eq 'Numbers') { 
                $MyRange += 48..57 # 10
                Write-Verbose 'Generate-Password: MyRange: +Numbers' 
            }
            if ($_ -eq 'SpecialCharacters') { 
                $MyRange += (33..47) + (58..64) + (91..96) + (123..126) # 32
                Write-Verbose 'Generate-Password: MyRange: +SpecialCharacters' 
            }
        }

        if ($CodeFriendly) {
            $MyRange = $MyRange | foreach { if ($_ -notin (34,36,39,96)) { $_ } }
        }

        # ($MyRange | Get-Random -Count $Length | foreach {[char]$_}) -join '' # This produces a maximum password length of the $MyRange count (94)
        (1..$Length | foreach { [Char]($MyRange | Get-Random) }) -join ''
    }

    End {  }
}

function Get-StringHash {
<#
 .SYNOPSIS
  Function to Hash a string
 
 .DESCRIPTION
  Function to Hash a string with one of 7 different hash algorithms
 
 .PARAMETER String
  The string to be hashed - required
 
 .PARAMETER Algorithm
  The algorithm used to hash the string. Available options are:
    SHA1
    SHA256
    SHA384
    SHA512
    MD5
    RIPEMD160
    MACTripleDES
  Default is SHA256
 
 .EXAMPLE
  Get-StringHash 'hello' -Algorithm MD5
 
 .OUTPUTS
  Hash value such as 5D41402ABC4B2A76B9719D911017C592
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 May 2020
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$String,
        [Parameter(Mandatory=$false)][ValidateSet('SHA1','SHA256','SHA384','SHA512','MD5','RIPEMD160','MACTripleDES')][String]$Algorithm = 'SHA256'
    )

    Begin {  }

    Process {      
        $stringAsStream = [System.IO.MemoryStream]::new()
        $Writer = [System.IO.StreamWriter]::new($stringAsStream)
        $Writer.write($String)
        $Writer.Flush()
        $stringAsStream.Position = 0
        Get-FileHash -InputStream $stringAsStream -Algorithm $Algorithm | Select-Object -ExpandProperty Hash
    }

    End {  }
} 

function Invoke-2CowsAPI {
<#
 .SYNOPSIS
  Function to Query 2Cows Domain Name Registrar API
 
 .DESCRIPTION
  Function to Query 2Cows Domain Name Registrar API
  This function stores API Key on disk in encrypted form - see Example to specify folder
  API call must originate from the WAN IP specified in your 2Cows Admin portal (Second Factor)
 
 .PARAMETER Cred
  This is a PSCredential object that includes:
  - Your 2Cows API reseller user name - see https://domains.opensrs.guide/docs
  - Your 2Cows 112-character API Key - See Example
 
 .PARAMETER Command
  PSCustomObject with the following properties/example:
    [PSCustomObject]@{
        protocol = 'XCP'
        action = 'LOOKUP'
        object = 'DOMAIN'
        attributes = [PSCustomObject]@{ domain = 'google.com' }
    }
  See https://domains.opensrs.guide/docs for more details
 
 .EXAMPLE
    $myParameterSet = @{
        Cred = Get-SBCredential -UserName 'my2CowsUser_Name' -CredPath C:\folderName
        Command = [PSCustomObject]@{
            protocol = 'XCP'
            action = 'LOOKUP'
            object = 'DOMAIN'
            attributes = [PSCustomObject]@{ domain = 'google.com' }
        }
    }
    Invoke-2CowsAPI @myParameterSet
    This example will lookup the domain google.com
 
 .OUTPUTS
  PS Object containing the following properties/example:
    Domain : google.com
    Command : LOOKUP DOMAIN
    Response : Domain taken
    Code : 211
    Success : True
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://domains.opensrs.guide/docs
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 May 2020
  v0.2 - 26 May 2020 - Minor updates, Changed output property 'status' to 'success' as True/False
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][PSCredential]$Cred, 
        [Parameter(Mandatory=$false)][PSCustomObject]$Command = [PSCustomObject][Ordered]@{
            protocol   = 'XCP'   
            action     = 'LOOKUP'
            object     = 'DOMAIN'
            attributes = [PSCustomObject]@{ domain = 'google.com' }
        }
    )

    Begin {  }

    Process { 

        $Query = [PSCustomObject][Ordered]@{
            reseller_username = $Cred.UserName 
            api_key           = $Cred.GetNetworkCredential().Password 
            api_host_port     = 'https://rr-n1-tor.opensrs.net:55443'
            xml = @"
                <?xml version='1.0' encoding='UTF-8' standalone='no' ?>
                <!DOCTYPE OPS_envelope SYSTEM 'ops.dtd'>
                <OPS_envelope>
                <header>
                    <version>0.9</version>
                </header>
                <body>
                <data_block>
                    <dt_assoc>
                        <item key="protocol">$($Command.protocol)</item>
                        <item key="action">$($Command.action)</item>
                        <item key="object">$($Command.object)</item>
                        <item key="attributes">
                         <dt_assoc>
                                <item key="domain">$($Command.attributes.domain)</item>
                         </dt_assoc>
                        </item>
                    </dt_assoc>
                </data_block>
                </body>
                </OPS_envelope>
"@
.Trim()
        }

        $Hash1 = (Get-StringHash ($Query.xml + $Query.api_key).Trim() -Algorithm MD5).ToLower() 

        $Hash2 = (Get-StringHash ($Hash1 + $Query.api_key).Trim() -Algorithm MD5).ToLower()     

        $Headers = @{
            'Content-Type' = 'text/xml'
            'X-Username'   = $Query.reseller_username
            'X-Signature'  = $Hash2
        }

        $ParameterSet = @{
            Uri     = $Query.api_host_port 
            Headers = $Headers 
            Method  = 'Post' 
            Body    = $Query.xml
        }

        $Result = Invoke-WebRequest @ParameterSet
        Write-Verbose $Result.Content
        
    }

    End { 
        [PSCustomObject][Ordered]@{
            Domain   = $Command.attributes.domain
            Command  = $Command.action + ' ' + $Command.object
            Response = (([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | where key -EQ response_text).'#text'
            Code     = (([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | where key -EQ response_code).'#text'
            Success  = [Boolean](([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | where key -EQ is_success).'#text'
        }
    }
} 

function Invoke2CowsAPI-GetDNSZone {
<#
 .SYNOPSIS
  Function to Query DNS Zone in 2Cows Domain Name Registrar API
 
 .DESCRIPTION
  Function to Query DNS Zone in 2Cows Domain Name Registrar API
  This function stores API Key on disk in encrypted form - see Example to specify folder
  API call must originate from the WAN IP specified in your 2Cows Admin portal (Second Factor)
  Using the Verbose parameter will show the raw API XML returned data
 
 .PARAMETER Cred
  This is a PSCredential object that includes:
  - Your 2Cows API reseller user name - see https://domains.opensrs.guide/docs
  - Your 2Cows 112-character API Key - See Example
 
 .PARAMETER Domain
  This is your domain name registered with 2Cows
 
 .EXAMPLE
    $myParameterSet = @{
        Cred = Get-SBCredential -UserName 'my2CowsUserName' -CredPath C:\folder
        domain = 'mydomain.com'
    }
    $Result = Invoke2CowsAPI-GetDNSZone @myParameterSet
    $Result | FT Domain,Command,Response,Code,Status -a
    $Result.DNSRecords | FT -a
 
 .OUTPUTS
  PS Object containing the following properties/example:
    Domain : mydomain.com
    Command : Get DNS Zone
    Response : Command Successful
    Code : 200
    Success : True
    DNSRecords : {@{RecordType=A; IPAddress=....}
 
  $Result.DNSRecords would show the following properties/example:
    RecordType IPAddress Name hostname Priority text
    ---------- --------- ---- -------- -------- ----
    A 11.22.33.44 jn41.mydomain.com
    A 22.33.44.55 x155.mydomain.com
    TXT mydomain.com v=spf1 a mx ptr ip4:33.44.55.66 include:somedomain.com ?all
    CNAME taxpilot.mydomain.com vhost66.mydomain.com
    CNAME mail.mydomain.com ghs.google.com
    MX mydomain.com aspmx4.googlemail.com 30
    MX mydomain.com aspmx5.googlemail.com 30
    MX mydomain.com aspmx.l.google.com 10
    MX mydomain.com alt1.aspmx.l.google.com 20
    MX mydomain.com alt2.aspmx.l.google.com 20
    MX mydomain.com aspmx2.googlemail.com 30
    MX mydomain.com aspmx3.googlemail.com 30
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://domains.opensrs.guide/docs/get_dns_zone
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 May 2020
  v0.2 - 26 May 2020 - Minor updates, Changed output property 'status' to 'success' as True/False
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][PSCredential]$Cred, 
        [Parameter(Mandatory=$true)][String]$Domain
    )

    Begin {  }

    Process { 

        $Query = [PSCustomObject][Ordered]@{
            reseller_username = $Cred.UserName 
            api_key           = $Cred.GetNetworkCredential().Password 
            api_host_port     = 'https://rr-n1-tor.opensrs.net:55443'
            xml = @"
                <?xml version='1.0' encoding='UTF-8' standalone='no' ?>
                <!DOCTYPE OPS_envelope SYSTEM 'ops.dtd'>
                <OPS_envelope>
                <header>
                    <version>0.9</version>
                </header>
                <body>
                <data_block>
                    <dt_assoc>
                        <item key="protocol">XCP</item>
                        <item key="action">get_dns_zone</item>
                        <item key="object">DOMAIN</item>
                        <item key="attributes">
                         <dt_assoc>
                                <item key="domain">$Domain</item>
                         </dt_assoc>
                        </item>
                    </dt_assoc>
                </data_block>
                </body>
                </OPS_envelope>
"@
.Trim()
        }

        $Hash1 = (Get-StringHash ($Query.xml + $Query.api_key).Trim() -Algorithm MD5).ToLower() 

        $Hash2 = (Get-StringHash ($Hash1 + $Query.api_key).Trim() -Algorithm MD5).ToLower()     

        $Headers = @{
            'Content-Type' = 'text/xml'
            'X-Username'   = $Query.reseller_username
            'X-Signature'  = $Hash2
        }

        $ParameterSet = @{
            Uri     = $Query.api_host_port 
            Headers = $Headers 
            Method  = 'Post' 
            Body    = $Query.xml
        }

        $Result = Invoke-WebRequest @ParameterSet
        Write-Verbose $Result.Content

    }

    End { 
        [PSCustomObject][Ordered]@{
            Domain   = $Domain
            Command  = 'Get DNS Zone'
            Response = (([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | where key -EQ response_text).'#text'
            Code     = (([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | where key -EQ response_code).'#text'
            Success  = [Boolean](([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | where key -EQ is_success).'#text'
            DNSRecords = $(
                $List = ((([XML]$Result.Content).OPS_envelope.body.data_block.dt_assoc.ChildNodes | 
                    where key -EQ attributes).dt_assoc.ChildNodes | where key -EQ records).dt_assoc.ChildNodes
                foreach ($DNSRecordType in @('A','AAAA','CNAME','MX','SRV','TXT')) {
                    ($List | where key -EQ $DNSRecordType).dt_array.ChildNodes | foreach {                        
                        if ($Subdomain = ($_.dt_assoc.ChildNodes | where key -EQ subdomain).'#text') { $Subdomain += '.' }
                        [PSCustomObject][Ordered]@{
                            RecordType = $DNSRecordType
                            IPAddress = ($_.dt_assoc.ChildNodes | where key -EQ ip_address).'#text'
                            Name      = $Subdomain + $Domain
                            hostname  = ($_.dt_assoc.ChildNodes | where key -EQ hostname).'#text'
                            Priority  = ($_.dt_assoc.ChildNodes | where key -EQ priority).'#text'
                            text      = ($_.dt_assoc.ChildNodes | where key -EQ text).'#text'
                        }
                    }
                }
            )
        }
    }
} 

function New-Passphrase {
<#
 .SYNOPSIS
  Function to generate random passphrase.
 
 .DESCRIPTION
  Function to generate random passphrase using English words.
  It takes about a minute for this function to execute.
 
 .PARAMETER PhraseCount
  Optional number that defaults to 1
  This serves to produce several passphrases quickly by loading and filtering the word list once.
 
 .PARAMETER WordCount
  Optional number between 2 and 256
  Default is 9
 
 .PARAMETER MinLength
  This is the minimum word length
  Optional number between 3 and 99
  Default is 12
   
 .PARAMETER MaxLength
  This is the maximum word length
  Optional number between 2 and 15
  Default is 6
 
 .PARAMETER Delimiter
  Optional character that defaults to a space.
  Acceptable values are: ' ','-','_',',','#','!'
 
 .PARAMETER LettersOnly
  Optional switch the defaults to True.
  When set to Ture this parameter excludes words with dashes or dots.
 
 .EXAMPLE
  New-Passphrase
  This example generates a 9-word passphrase similar to:
  mordants tickings upsoars Pleurodira neurotomy Zizania tensioner emotionally sombreros
 
 .EXAMPLE
  New-Passphrase -PhraseCount 7
  This example generates seven 9-word passphrases similar to:
    wowing Schiedam cystotome monadology neodiprion sourtop remedying millipede boucle
    Amagasaki hatbox nonsugars Navahos sindry curtalaxes outfitter fluidities pandour
    relapses westernizing asininities porporate allonym illest stalinists chaffer faultiness
    Valsaceae martyrology Atakapas pannus sandweed beyonds combatant groundspeed rugous
    kelpie arterialise undivinely Grindelia shrewly Connochaetes gagtooth limuloid cyprine
    hereat applotment schmeer caulicule masthead Rotameter stirrable unhooding anomies
    superoxide suretyship Petrarchism catfooted dermographic pidgins roughages cashews connections
 
 .EXAMPLE
  New-Passphrase -PhraseCount 15 -WordCount 2 -Delimiter '-'
  This example generates fifteen 2-word passphrases similar to:
    nigged-supples
    glycerize-chevalet
    umiaks-batlan
    carcajous-antinuke
    premen-siscowet
    piscatory-misrules
    hysteroid-calamistrum
    archines-figured
    seedmen-Girtin
    Maracaibo-vaster
    strigine-paperhangers
    titters-polygonic
    shunpiker-intonational
    Necturus-backstrokes
    spiritistic-Cogswellia
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 21 October 2020
  v0.2 - 8 Apr 2023 - Updated MerriamWordList.txt list by removing words that contain non-letter characters and sorting it.
    Removed LettersOnly switch and related code.
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][Int32]$PhraseCount = 1, 
        [Parameter(Mandatory=$false)][ValidateRange(2,256)][Int32]$WordCount = 9, 
        [Parameter(Mandatory=$false)][ValidateRange(2,15)][Int32]$MinLength = 6, 
        [Parameter(Mandatory=$false)][ValidateRange(3,99)][Int32]$MaxLength = 12, 
        [Parameter(Mandatory=$false)][ValidateSet(' ','-','_',',','#','!')][String]$Delimiter = ' '
    )

    Begin {        
        $WorkFolder = Split-Path -Path $PSCommandPath
        try {        
            $WordList = Get-Content -Path "$WorkFolder\MerriamWordList.txt" -EA 1 
        } catch {
            Write-Log 'Failed to read dictionary file',"$WorkFolder\MerriamWordList.txt" Magenta,Yellow $LogFile
            break 
        }
    }

    Process {

        $DesiredWordList = $WordList | where { $_.Length -ge $MinLength -and $_.Length -le $MaxLength }
        
        1..$PhraseCount | foreach { 
            $OutList = 1..$WordCount | foreach { $DesiredWordList[(Get-Random -Maximum $DesiredWordList.Count)] }
            $OutList -join $Delimiter
        }
    }

    End { }
}

function Encrypt-String {
<#
 .SYNOPSIS
  Function to encrypt a plain text string.
 
 .DESCRIPTION
  Function to encrypt a plain text string to an encrypted standard string,
  using the Advanced Encryption Standard (AES) encryption algorithm.
 
 .PARAMETER PlainTextString
  Required parameter. This is the string to be encrypted.
 
 .PARAMETER EncryptionKey
  Optional string representing 16, 24, or 32 Byte Array such as
  '76 33 170 234 30 100 129 180 79 200 12 14 172 254 34 158'
  If not provided this function will pick a random key.
  Key will be displayed to the console but not part of the (standard) output - see examples.
  This is also accepted as hex values.
 
 .PARAMETER Base64
  Optional switch. When set to True, this function will base64 encode the input string before encrypting it.
 
 .EXAMPLE
  $myEncryptedString = Encrypt-String -PlainTextString 'hello there'
  This example will encrypt the provided string using a random key.
  Console output will look like:
    Plain Text String: hello there
    Encryption Key: 218 132 75 11 9 221 124 243 70 120 9 85 188 12 213 104 246 145 133 102 2 157 167 17 3 176 167 37 55 88 144 154
    Encrypted String: 76492d1116743f0423413b16050a5345MgB8AGcAVQA3AHMAWAB3ADEATAAxAHcAcABmAC8AVgBzADAAVwBWAGgAaQBHAHcAPQA9AHwAMABmADAAYQBkADMAZQBmAGYAMQA1AGYANgA0ADQAOQAwADIAMQA1ADgAYQA2AGIAOAAxADQAMQA0ADQAZgA5ADgAMwA2AGQANAA4ADMAZgA3ADMAZQAxADgAYQBmADAAYwAwAGUAZgA3AGQANAA0AGMAYQBhADAANgA0ADMAYQA=
 
 .EXAMPLE
  $myEncryptedString = Encrypt-String -PlainTextString 'hello there' -EncryptionKey '90 42 50 159 243 105 189 152 198 248 189 123 188 83 195 168'
  This example will encrypt the provided string using the provided key.
  Console output will look like:
    Plain Text String: hello there
    Encryption Key: 90 42 50 159 243 105 189 152 198 248 189 123 188 83 195 168
    Encrypted String: 76492d1116743f0423413b16050a5345MgB8AE8AZgBZAHMAKwBZAGQAZgB3AHIANQBoAE8ANQBNAEEARQBuAEQAWgBLAGcAPQA9AHwANwBhAGMAOQA5ADcANQAzADkANQBhAGYAYQBhAGEAOQBjADYAZQBkADMANgA5ADEAYwBiADgANAAwAGIAOAAzADYAOQAyADgAMwAzAGYANgBkADkANgA3AGIAZABlADgAOQA5ADUAZgBlADUANgBhAGEAOAA1ADIAZQBhADkAYgA=
 
 .EXAMPLE
  $myEncryptedString = Encrypt-String -PlainTextString 'hello there' -EncryptionKey '90 42 50 159 243 105 189 152 198 248 189 123 188 83 195 168' -Base64
  This example will base64-encode the provided string, then encrypt it using the provided key.
  Console output will look like:
    Plain Text String: hello there
    Encryption Key: 90 42 50 159 243 105 189 152 198 248 189 123 188 83 195 168
    Base64 String: aABlAGwAbABvACAAdABoAGUAcgBlAA==
    Encrypted String: 76492d1116743f0423413b16050a5345MgB8AHoAYgBLAEoANABRAFUALwA0ADYAbgBzAHAAWABBAFQATwBTAFoAbgBoAFEAPQA9AHwAZgBiADIANwAyADQAYgAzAGQAYgA4AGIAMgBhAGYAOAA1ADkAMgAzAGYAYwAxAGIAYgBmADAAZgA3ADgAMwAxAGQAMABjADQAYQBiADQANABhADkAYgBhADcAMgAzAGIANwBjADcANAA3AGYAMgBkADEANgAyADEANQBlAGEAMwA0ADAANwBkADkAMgA4ADEAOAAyAGIAYgBlAGYAZABlADkAZQBhADIAYQA3ADQANwBhADIAZAA5ADAAYQBjADAAZgBhADUAMAA1ADAAMwAyAGMAZAA5ADEAZABlADcAZABhAGYAZgA3ADQAMwA5AGEAMgBhADcANQBjADUANwAyAGMANgBhAGEAYQBlAGQAZABlADUAYwBiAGEANgA0ADEAZgA2ADIAOQAzADEANgA0ADYAMAA2AGQAMQBmAGEAMwA=
 
 .OUTPUTS
  Encrypted string such as:
  76492d1116743f0423413b16050a5345MgB8ACsAYwBrAGQAMQBJAEkAMQBLAE0AZABtAEcANABxADAASgBJADcAcgBnAHcAPQA9AHwANgBhADgAYwAwADkAZgA3ADYAZQA1AGQANAAzADEANgBjAGEAYgA2ADQAMwA3ADYAYQAwAGUAZgAzADEAYgA5AGQANAAyADkAZgBjADMAMwA5ADYAMAAyAGEAZABmAGYAYQA4AGUAMgA0ADcAOABkADEAYwA0ADYAYwBlADkAOQBkAGUAZQBmADEAYwBiADYAYgA4AGYAZAAxADcAOABkAGMAZgA3AGMAYgAxADgANgA3ADIAMQA2ADIANQAwAGUANgA3ADAANwAzAGQAOQA5ADgAOABlADgANQBmADIAZAAyADgAMQBjADcAZgA2ADMAYwA2ADAAMABhAGYANwAzADIAYQAwAGYANwBkAGQAMgAzADcAOAA3ADkAOAA4AGEANQAwADEAYgBkADYAZQAxADEAMwA4ADcAYwA1AGMAYwA=
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 5 January 2021
  v0.2 - 17 January 2021 - Added code to accept hex values as well as decimal values for the input EncryptionKey
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$PlainTextString,
        [Parameter(Mandatory=$False, HelpMessage="String representing 16, 24, or 32 Byte Array such as '76 33 170 234 30 100 129 180 79 200 12 14 172 254 34 158'")]
            [String]$EncryptionKey = ((1..32 | foreach { Get-Random -Minimum 1 -Maximum 255 }) -join ' '),
        [Parameter(Mandatory=$False)][Switch]$Base64
    )

    Begin { 
        $Rand = (1..32 | foreach { Get-Random -Minimum 1 -Maximum 255 }) -join ' '
        Remove-Variable Key -EA 0 
        try {
            $Key = [Byte[]]($EncryptionKey -split ' ') 
        } catch { }
        try { # Try hex input such as '0f fe e8 f4'
            $myEncKey = foreach ($Number in ($EncryptionKey -split ' ')) { [uint32]"0x$Number" }
            $Key = [Byte[]]$myEncKey
        } catch { }
        if (-not $Key) {
            Write-Log 'Encrypt-String Error:' Magenta
            Write-Log $_.Exception.Message Yellow
            Write-Log 'Received ''EncryptionKey'':',$EncryptionKey Magenta,Yellow
            Write-Log 'Expecting ''EncryptionKey'' parameter value to be a String representing 16, 24, or 32 Byte Array such as',$Rand,'(decimal or hex values accepted)' Green,Cyan,Green
            break            
        }
    }

    Process {

        Try {
            if ($Base64) {
                $bytes    = [System.Text.Encoding]::Unicode.GetBytes($PlainTextString)
                $EncodeMe = [Convert]::ToBase64String($bytes)
            } else {
                $EncodeMe = $PlainTextString
            }
            $SecureString    = Convertto-SecureString $EncodeMe -AsPlainText -Force -EA 1 
            $EncryptedString = ConvertFrom-SecureString -SecureString $SecureString -Key $Key -EA 1 
        } Catch {
            Write-Log 'Encrypt-String Error:' Magenta
            Write-Log $_.Exception.Message Yellow
            Write-Log 'Expecting ''EncryptionKey'' parameter value to be a String representing 16, 24, or 32 Byte Array such as',$Rand Green,Cyan
        }

    }

    End { 
        Write-Log 'Plain Text String:',$PlainTextString Green,Cyan
        Write-Log 'Encryption Key: ',($Key -join ' ') Green,Cyan
        if ($Base64) { Write-Log 'Base64 String: ',$EncodeMe Green,Cyan } 
        Write-Log 'Encrypted String: ',$EncryptedString Green,Cyan
        $EncryptedString
    }
}

function Decrypt-String {
<#
 .SYNOPSIS
  Function to decrypt a plain text string.
 
 .DESCRIPTION
  Function to decrypt a plain text string from an encrypted standard string,
  using the Advanced Encryption Standard (AES) decryption algorithm.
 
 .PARAMETER EncryptedString
  Required parameter. This is the string to be decrypted.
 
 .PARAMETER EncryptionKey
  Required string representing 16, 24, or 32 Byte Array such as
  '76 33 170 234 30 100 129 180 79 200 12 14 172 254 34 158'
  This is also accepted as hex values
 
 .EXAMPLE
  $myPlainTextString = Decrypt-String -EncryptedString '76492d1116743f0423413b16050a5345MgB8AEIAbQBDAC8AcwBWAGwAdgA5AHEAQwBtAHkAcwBFAEEANgAzAEEAWQBUAEEAPQA9AHwANQAxADgAZgA5AGUAOAA2ADMAMQBkADIAOAA0ADUAMQBjAGQANwAwADAAOQBmADkAZAAwAGYAOAA4ADMAYwAwADQAZQA1ADYAOQAxAGQAMwA1ADAAMAAxADYAOQAzAGEANABkADQAZgAxADQANgAwAGYAMgAxAGQANQBkADEAOQA=' -EncryptionKey '163 109 123 60 14 100 156 17 1 233 56 222 102 230 39 14 161 233 126 125 219 248 69 174 8 163 14 146 154 47 116 64'
  This example will decrypt the provided string using the provided key.
  Console output will look like:
    Encrypted String: 76492d1116743f0423413b16050a5345MgB8AEIAbQBDAC8AcwBWAGwAdgA5AHEAQwBtAHkAcwBFAEEANgAzAEEAWQBUAEEAPQA9AHwANQAxADgAZgA5AGUAOAA2ADMAMQBkADIAOAA0ADUAMQBjAGQANwAwADAAOQBmADkAZAAwAGYAOAA4ADMAYwAwADQAZQA1ADYAOQAxAGQAMwA1ADAAMAAxADYAOQAzAGEANABkADQAZgAxADQANgAwAGYAMgAxAGQANQBkADEAOQA=
    Encryption Key: 163 109 123 60 14 100 156 17 1 233 56 222 102 230 39 14 161 233 126 125 219 248 69 174 8 163 14 146 154 47 116 64
    Plain Text String: hello there
 
 .EXAMPLE
  $myPlainTextString = Decrypt-String -EncryptedString '76492d1116743f0423413b16050a5345MgB8AGcASwA1AHQASABBAHgALwBvAHMAeQBBADcAbQBKAE0AbAB5AFIAWQBrAFEAPQA9AHwAZAA2AGIAOQBjAGUAYwAzADUAZQAzAGIAMwA2ADAAOQBiADcAYgA2AGEAOQAyAGMANQBlADQANQAzAGUAMgAxAGQAMABiADgAZQA0AGYAMwA0AGIAYQAwADAAMABkADcAYgBjADMANQBhADYAZQAzADkAZAA2AGIAYQBiAGUAYQBmADEANQA5AGEANQA4ADUAOABhAGIAZgBjAGYANwBjADAAOQA5AGEAOQBiAGEAMgA1AGMAMQA5ADMAMQA0ADQAOQA5ADYAMgAyADcAZQBmADgANAA3ADQAMQA5ADAANABmAGEAYQBmADgAMwAwADgAZQA2ADQAZgBjAGIANgAwAGEAZQA5ADcAMwAyADYAOABiADkAZQA2ADcAYQA1AGYAMgA0AGYANwBkADAAZAA5ADIAZgBkADEAYwA2AGEAMAA=' -EncryptionKey '90 42 50 159 243 105 189 152 198 248 189 123 188 83 195 168'
  This example will decrypt the provided string using the provided key, detect that the resulting string is base64-encoded, and decode the resulting base64 string.
  Console output will look like:
    Encrypted String: 76492d1116743f0423413b16050a5345MgB8AGcASwA1AHQASABBAHgALwBvAHMAeQBBADcAbQBKAE0AbAB5AFIAWQBrAFEAPQA9AHwAZAA2AGIAOQBjAGUAYwAzADUAZQAzAGIAMwA2ADAAOQBiADcAYgA2AGEAOQAyAGMANQBlADQANQAzAGUAMgAxAGQAMABiADgAZQA0AGYAMwA0AGIAYQAwADAAMABkADcAYgBjADMANQBhADYAZQAzADkAZAA2AGIAYQBiAGUAYQBmADEANQA5AGEANQA4ADUAOABhAGIAZgBjAGYANwBjADAAOQA5AGEAOQBiAGEAMgA1AGMAMQA5ADMAMQA0ADQAOQA5ADYAMgAyADcAZQBmADgANAA3ADQAMQA5ADAANABmAGEAYQBmADgAMwAwADgAZQA2ADQAZgBjAGIANgAwAGEAZQA5ADcAMwAyADYAOABiADkAZQA2ADcAYQA1AGYAMgA0AGYANwBkADAAZAA5ADIAZgBkADEAYwA2AGEAMAA=
    Encryption Key: 90 42 50 159 243 105 189 152 198 248 189 123 188 83 195 168
    Base64 String: aABlAGwAbABvACAAdABoAGUAcgBlAA==
    Plain Text String: hello there
 
 .OUTPUTS
  Plain text string.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 5 January 2021
  v0.2 - 17 January 2021 - Added code to accept hex values as well as decimal values for the input EncryptionKey
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$EncryptedString,
        [Parameter(Mandatory=$true,HelpMessage="String representing 16, 24, or 32 Byte Array such as '76 33 170 234 30 100 129 180 79 200 12 14 172 254 34 158'")][String]$EncryptionKey
    )

    Begin { 
        $Rand = (1..32 | foreach { Get-Random -Minimum 1 -Maximum 255 }) -join ' '
        Remove-Variable Key -EA 0 
        try {
            $Key = [Byte[]]($EncryptionKey -split ' ') 
        } catch { }
        try { # Try hex input such as '0f fe e8 f4'
            $myEncKey = foreach ($Number in ($EncryptionKey -split ' ')) { [uint32]"0x$Number" }
            $Key = [Byte[]]$myEncKey
        } catch { }
        if (-not $Key) {
            Write-Log 'Encrypt-String Error:' Magenta
            Write-Log $_.Exception.Message Yellow
            Write-Log 'Received ''EncryptionKey'':',$EncryptionKey Magenta,Yellow
            Write-Log 'Expecting ''EncryptionKey'' parameter value to be a String representing 16, 24, or 32 Byte Array such as',$Rand,'(decimal or hex values accepted)' Green,Cyan,Green
            break            
        }
    }

    Process {

        Try {
            $SecureString = ConvertTo-SecureString $EncryptedString -Key $Key
            $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
            [string]$DecodeMe = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
            if ($Base64 = try {[convert]::FromBase64String($DecodeMe)} catch {}) {
                $PlainTextString = ($Base64 | Foreach { if ($_ -ne 0) { [char]$_ } }) -join ''
            } else {
                $PlainTextString = $DecodeMe
            }
        } Catch {
            Write-Log 'Decrypt-String Error:' Magenta
            Write-Log $_.Exception.Message Yellow
            Write-Log 'Expecting ''EncryptionKey'' parameter value to be a String representing 16, 24, or 32 Byte Array such as',$Rand Green,Cyan
        }

    }

    End { 
        Write-Log 'Encrypted String: ',$EncryptedString Green,Cyan
        Write-Log 'Encryption Key: ',($Key -join ' ') Green,Cyan
        if ($Base64) { Write-Log 'Base64 String: ',$DecodeMe Green,Cyan } 
        Write-Log 'Plain Text String:',$PlainTextString Green,Cyan
        $PlainTextString
    }
}

function Report-WinEvent {
<#
 .SYNOPSIS
  Function to gather information on a given Windows Event by Id across many computers
 
 .DESCRIPTION
  Function to gather information on a given Windows Event by Id across many computers
  This function uses PowerShell remorting to invoke parallel remote jobs for gathering event data.
  For example, when gathering events from all domain controllers in a given Active Directory domain.
 
 .PARAMETER EventId
  Windows Event Id such as 5829
  This is currently limited to System event log event Id 5829.
  This function can be updated to handle additonal events from other event logs by updating the 'Receive Job data' region which parses a specifc event for relevent data.
 
 .PARAMETER LogName
  Windows event log name such as System
  This is currently limited to System event log event Id 5829.
 
 .PARAMETER ComputerList
  List of computers to query. This defaults to the list of the domain controllers of the current Active Directory domain
 
 .PARAMETER Cred
  PSCredential object that defaults to the current logged on user.
  This should have access/permission to PS-remote into the target computers.
  This can be obtained by Get-Credential or Get-SBCredential cmdlets
 
 .PARAMETER ReportFile
  Path to the Excel file where this function will write its Event List Excel report/output.
 
 .PARAMETER ComputerListFile
  Path to the Excel file where this function will write its Computer List Excel report/output.
 
 .PARAMETER LogFile
  Path to a file where this function will write its console output.
 
 .EXAMPLE
  Report-WinEvent
  This will report on event 5829 on all DCs of the current AD domain.
      
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 26 January 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateSet(5829)][Int]$EventId        = 5829,
        [Parameter(Mandatory=$false)][ValidateSet('System')][String]$LogName = 'System',
        [Parameter(Mandatory=$false)][String[]]$ComputerList   = $thisDomainDCList,
        [Parameter(Mandatory=$false)][PSCredential]$Cred       = (Get-SBCredential -UserName "$env:USERDOMAIN\$env:USERNAME"),
        [Parameter(Mandatory=$false)][String]$ReportFile       = ".\Report-Event$EventId-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx",
        [Parameter(Mandatory=$false)][String]$ComputerListFile = ".\Report-Event$EventId-ComputerList-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').xlsx",
        [Parameter(Mandatory=$false)][String]$LogFile          = ".\Report-Event$EventId-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { }

    Process {      

        $StartTime = Get-Date

        #region Get list of online computers
        $OnLineList = foreach ($Computer in $ComputerList) {
            Write-Log 'Checking if computer',($Computer).PadRight(35,' '),'is reachable:' Green,Cyan,Green $LogFile -NoNewLine
            if ($Result = Test-SBNetConnection -ComputerName $Computer -PortNumber 5985 -TimeoutSec 3 -WA 0) {
                [PSCustomObject]@{
                    Name = $Computer
                    Port5985Open = $Result[0].TcpTestSucceeded
                }
                if ($Result[0].TcpTestSucceeded) {
                    Write-Log 'PS Remoting port 5985 OK' DarkYellow $LogFile
                } else {
                    Write-Log 'PS Remoting port 5985 unreachable' Magenta $LogFile
                }
            }
        }

        $ComputerCount = ($OnLineList | where { $_.Port5985Open }).Count
        if ($ComputerCount -lt 1) {
            Write-Log 'No reachable DCs found !?' Magenta $LogFile
            break
        } else {
            $OnLineList = $OnLineList | sort Name
        }
        #endregion

        #region Submit and wait for remote jobs
        Write-Log 'Gathering events for Event ID',$EventId,'from',$ComputerCount,'computers' Green,Cyan,Green,Cyan,Green $LogFile
        $Duration = Measure-Command {
            #region Submit remote jobs
            Get-Job | Remove-Job -Force
            foreach ($Computer in $OnLineList) {
                if ($Computer.Port5985Open) { # Remote Job
                    Invoke-Command -AsJob -ComputerName $Computer.Name -JobName $Computer.Name -Credential $Cred -ScriptBlock {
                        try {
                            Get-EventLog -LogName $Using:LogName -InstanceId $Using:EventId -EA 1
                        } Catch {
                            $_.Exception.Message
                        }
                    }
                }
            }
            #endregion
            #region Wait for jobs
            $JobMonitor = foreach ($JobStatus in (Get-Job)) {
                if ($JobStatus.State -eq 'Running') { $StatusColor = 'DarkYellow' } else { $StatusColor = 'Yellow' }
                Write-Log 'Remote Job',($JobStatus.Name).PadRight(35,' '),$JobStatus.State Green,Cyan,$StatusColor $LogFile
                New-Object -TypeName psobject -Property ([Ordered]@{
                    Name = $JobStatus.Name
                    State = $JobStatus.state
                    Changed = $false
                    StartTime = Get-Date
                    Duration = $null
                })
            }
            Write-Log 'Monitoring Jobs'' status..' Green $LogFile

            $LiveStatus = Get-job
            while (($LiveStatus | where State -eq 'Running')) {
                foreach ($JobStatus in $LiveStatus) {
                    $thisJobMonitor = $JobMonitor | where Name -EQ $JobStatus.Name
                    if ($JobStatus.State -ne $thisJobMonitor.State -and -not $thisJobMonitor.Changed) { # Only display changed job status (once)
                        $thisJobMonitor.Changed = $true
                        $thisJobMonitor.Duration = New-TimeSpan -Start $thisJobMonitor.StartTime -End (Get-Date) # Record and display each DC job time
                        if ($JobStatus.State -eq 'Running') { $StatusColor = 'DarkYellow' } else { $StatusColor = 'Yellow' }
                        Write-Log 'Remote Job',($JobStatus.Name).PadRight(35,' '),"$($JobStatus.State) in" Green,Cyan,$StatusColor $LogFile -NoNewLine
                        Write-Log "$($thisJobMonitor.Duration.Hours):$($thisJobMonitor.Duration.Minutes):$($thisJobMonitor.Duration.Seconds) (hh:mm:ss)" DarkYellow $LogFile
                    }
                }
                Start-Sleep -Seconds 1
            }
            #endregion
        } 
        #endregion

        #region Receive Job data
        $Duration = Measure-Command {
            Write-Log 'Receiving job data..' Green $LogFile
            $rawCombinedEventList = foreach ($Job in (Get-Job | where { $_.HasMoreData })) {
                $Temp = Receive-Job -Name $Job.Name
                if ($Temp.InstanceID) { # Job returning expected data, accept it
                    $Temp
                } elseif ($Temp -eq 'No matches found') { # Job returning no data
                    Write-Log $Job.Name,'reports',$Temp,'for event ID',$EventId Green,Cyan,Green,Cyan,Yellow $LogFile
                } else { # Job not returning expected data, probably an error, display it
                    Write-Log 'Job error',$Job.Name,$Temp Yellow,Magenta,Yellow $LogFile
                }
            }
            Get-Job | Remove-Job -Force
            $myCombinedEventList = foreach ($Event in $rawCombinedEventList) {
                New-Object -TypeName psobject -Property ([Ordered]@{
                    DCName = $Event.PSComputerName
                    ClientName = $Event.ReplacementStrings[0]
                    ClientOS = $Event.ReplacementStrings[3]
                    Date = $Event.TimeGenerated
                })
            }
        } 
        Write-Log 'Received',$myCombinedEventList.Count,'events from',$ComputerCount,'DCs in',
        "$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" Green,Cyan,Green,Cyan,Green,DarkYellow $LogFile
        #endregion

        #region Export Excel reports
        if ($myCombinedEventList) {
            Write-Log 'Exporting events to',$ReportFile Green,Cyan $LogFile -NoNewLine
            $myCombinedEventList | Export-Excel $ReportFile -AutoSize -FreezeTopRowFirstColumn
            Write-Log 'done' Yellow $LogFile

            $Duration = Measure-Command {
                Write-Log 'Processing client computer list...' Green $LogFile -NoNewLine
                $ClientList = $myCombinedEventList | group ClientName | sort count -Descending
                $myClientList = foreach ($Client in $ClientList) {
                    New-Object -TypeName psobject -Property ([Ordered]@{
                        ComputerName = $Client.Name
                        IPv4Address = (Resolve-DnsName $Client.Name -Type A -EA 0).IPAddress -join ', '
                        EventCount = $Client.Count
                        EventId = $EventId
                    })
                }
            } # Process Events
            Write-Log 'done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds) (hh:mm:ss)" Cyan,DarkYellow $LogFile
            Write-Log 'Exporting client computer list to',$ComputerListFile Green,Cyan $LogFile -NoNewLine
            $myClientList | Export-Excel $ComputerListFile -AutoSize -FreezeTopRowFirstColumn
            Write-Log 'done' Yellow $LogFile
        } else {
            Write-Log 'No events for event ID',$EventId,'found','(are you using the correct credential!?)' Green,Cyan,Green,Yellow $LogFile
        }

        $CombinedDuration = New-TimeSpan -Start $StartTime -End (Get-Date)
        Write-Log 'All done in',"$($CombinedDuration.Hours):$($CombinedDuration.Minutes):$($CombinedDuration.Seconds) (hh:mm:ss)" Cyan,DarkYellow $LogFile
        #endregion

    }

    End {  }
} 

function Disable-WindowsWeakProtocols {
<#
 .SYNOPSIS
  Function to disable Windows weak protocols, hashes, and ciphers.
 
 .DESCRIPTION
  Function to disable Windows weak protocols, hashes, and ciphers.
  When a windows computer negotiates a secure connection, it may use a legacy insecure protocol, hash, or cipher if the other end of the connection requires it.
  Caution: Disabling Windows weak protocols, hashes, and ciphers will prevent this computer from establishing secure connections with computers that cannot meet the same requirements.
  For example, by default this function will prevent this computer from establishing an SSL or HTTPS connection to a site that can only use TLS 1.1.
  This function makes registry changes to prevent the use olf legacy weak protocols, hashes, and ciphers.
  This function requires elevation since it makes changes to the registry key HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL
 
 .PARAMETER Protocol
  One or more protocols such as
  PCT 1.0
  SSL 2.0
  SSL 3.0
  TLS 1.0
  TLS 1.1
  TLS 1.2
  By default, this function will disable all these protcols except TLS 1.2
 
 .PARAMETER Hash
  One or more hashes such as
  MD5
  SHA (SHA 1 that is..)
  SHA 256
  SHA 384
  SHA 512
  By default, this function will disable MD5 and SHA, leaving SHA 256. 384, and 512
 
 .PARAMETER Protocol
  One or more ciphers such as
  DES 56/56
  RC2 40/128
  RC2 56/128
  RC2 128/128
  RC4 40/128
  RC4 56/128
  RC4 64/128
  RC4 128/128
  Triple DES 168
  AES 128/128
  AES 256/256
  By default, this function will disable all these ciphers except AES 128/128 and AES 256/256
 
 .EXAMPLE
  Disable-WindowsWeakProtocols
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 13 March 2017 - Originally published as a script in the Technet gallery, which Microsoft retired in December 2020 without migrating community scripts to Github.
  v0.2 - 9 February 2021 - Rewrite
  v0.3 - 1 March 2021 - Added code to affirmatively enable protocols/hashes/ciphers not listed in this function's parameters.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateSet('PCT 1.0','SSL 2.0','SSL 3.0','TLS 1.0','TLS 1.1','TLS 1.2')]
            [String[]]$Protocol = @('PCT 1.0','SSL 2.0','SSL 3.0','TLS 1.0','TLS 1.1'), # Leaving 'TLS 1.2'
        [Parameter(Mandatory=$false)][ValidateSet('MD5','SHA','SHA 256','SHA 384','SHA 512')]
            [String[]]$Hash = @('MD5','SHA'), # SHA here means SHA1, leaving 'SHA 256', 'SHA 384', and 'SHA 512'
        [Parameter(Mandatory=$false)][ValidateSet('DES 56/56','RC2 40/128','RC2 56/128','RC2 128/128','RC4 40/128','RC4 56/128','RC4 64/128','RC4 128/128','Triple DES 168','AES 128/128','AES 256/256')]
            [String[]]$Cipher = @('DES 56/56','RC2 40/128','RC2 56/128','RC2 128/128','RC4 40/128','RC4 56/128','RC4 64/128','RC4 128/128','Triple DES 168'), # Leaving 'AES 128/128' and 'AES 256/256'
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Disable-WindowsWeakProtocols-$env:COMPUTERNAME-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 
        $ProtocolList = @('PCT 1.0','SSL 2.0','SSL 3.0','TLS 1.0','TLS 1.1','TLS 1.2')
        $EnabledProtocolList = $ProtocolList| foreach { if ($_ -notin $Protocol) { $_ } } 
        $HashList = @('MD5','SHA','SHA 256','SHA 384','SHA 512')
        $EnabledHashList = $HashList| foreach { if ($_ -notin $Hash) { $_ } } 
        $CipherList = @('DES 56/56','RC2 40/128','RC2 56/128','RC2 128/128','RC4 40/128','RC4 56/128','RC4 64/128','RC4 128/128','Triple DES 168','AES 128/128','AES 256/256')
        $EnabledCipherList = $CipherList| foreach { if ($_ -notin $Cipher) { $_ } } 
    }

    Process {

        $RegKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL'

        #region Disable Protocols
        $myError = @()
        foreach ($Entry in $Protocol) {
            Write-Log 'Disablig protocol',$Entry Green,Cyan $LogFile
            try { New-Item -Path "$RegKey\Protocols\$Entry" -Name 'Client' -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Disabling protocol $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Protocols\$Entry\Client" -PropertyType DWORD -Name 'DisabledByDefault' -Value 1 -Force -EA 1 | Out-Null } catch { $myError += "Disabling protocol $Entry failed: $($_.Exception.Message)" }
            try { New-Item -Path "$RegKey\Protocols\$Entry" -Name 'Server' -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Disabling protocol $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Protocols\$Entry\Server" -PropertyType DWORD -Name 'DisabledByDefault' -Value 1 -Force -EA 1 | Out-Null } catch { $myError += "Disabling protocol $Entry failed: $($_.Exception.Message)" }
        }
        foreach ($Entry in $EnabledProtocolList) {
            Write-Log 'Enabling protocol',$Entry Green,Cyan $LogFile
            try { New-Item -Path "$RegKey\Protocols\$Entry" -Name 'Client' -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Enabling protocol $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Protocols\$Entry\Client" -PropertyType DWORD -Name 'DisabledByDefault' -Value 0 -Force -EA 1 | Out-Null } catch { $myError += "Enabling protocol $Entry failed: $($_.Exception.Message)" }
            try { New-Item -Path "$RegKey\Protocols\$Entry" -Name 'Server' -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Enabling protocol $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Protocols\$Entry\Server" -PropertyType DWORD -Name 'DisabledByDefault' -Value 0 -Force -EA 1 | Out-Null } catch { $myError += "Enabling protocol $Entry failed: $($_.Exception.Message)" }
        }
        if ($myError) {
            Write-Log 'failed' Magenta $LogFile
            Write-Log ($myError | Out-String).Trim() Yellow $LogFile
        } else {
            Write-Log 'done' DarkYellow $LogFile
        }
        #endregion

        #region Disable Hashes
        $myError = @()
        foreach ($Entry in $Hash) {
            Write-Log 'Disablig hash',$Entry Green,Cyan $LogFile
            try { New-Item -Path "$RegKey\Hashes" -Name $Entry -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Disabling hash $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Hashes\$Entry" -PropertyType DWORD -Name 'Enabled' -Value 0 -Force -EA 1 | Out-Null } catch { $myError += "Disabling hash $Entry failed: $($_.Exception.Message)" }
        }
        foreach ($Entry in $EnabledHashList) {
            Write-Log 'Enabling hash',$Entry Green,Cyan $LogFile
            try { New-Item -Path "$RegKey\Hashes" -Name $Entry -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Enabling hash $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Hashes\$Entry" -PropertyType DWORD -Name 'Enabled' -Value 1 -Force -EA 1 | Out-Null } catch { $myError += "Enabling hash $Entry failed: $($_.Exception.Message)" }
        }
        if ($myError) {
            Write-Log 'failed' Magenta $LogFile
            Write-Log ($myError | Out-String).Trim() Yellow $LogFile
        } else {
            Write-Log 'done' DarkYellow $LogFile
        }
        #endregion

        #region Disable Ciphers
        $myError = @()
        foreach ($Entry in $Cipher) {
            if ($Entry -match '/') { $Name = "$($Entry.Split('/')[0])$([char]0x2215)$($Entry.Split('/')[1])" } else { $Name = $Entry } 
            Write-Log 'Disablig Cipher',$Entry Green,Cyan $LogFile
            try { New-Item -Path "$RegKey\Ciphers" -Name $Name -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Disabling Cipher $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Ciphers\$Name" -PropertyType DWORD -Name 'Enabled' -Value 0 -Force -EA 1 | Out-Null } catch { $myError += "Disabling Cipher $Entry failed: $($_.Exception.Message)" }
        }
        foreach ($Entry in $EnabledCipherList) {
            if ($Entry -match '/') { $Name = "$($Entry.Split('/')[0])$([char]0x2215)$($Entry.Split('/')[1])" } else { $Name = $Entry } 
            Write-Log 'Enabling Cipher',$Entry Green,Cyan $LogFile
            try { New-Item -Path "$RegKey\Ciphers" -Name $Name -ItemType directory -Force -EA 1 | Out-Null } catch { $myError += "Enabling Cipher $Entry failed: $($_.Exception.Message)" }
            try { New-ItemProperty -Path "$RegKey\Ciphers\$Name" -PropertyType DWORD -Name 'Enabled' -Value 1 -Force -EA 1 | Out-Null } catch { $myError += "Enabling Cipher $Entry failed: $($_.Exception.Message)" }
        }
        if ($myError) {
            Write-Log 'failed' Magenta $LogFile
            Write-Log ($myError | Out-String).Trim() Yellow $LogFile
        } else {
            Write-Log 'done' DarkYellow $LogFile
        }
        #endregion

    }

    End {  }
} 

function Restrict-PointAndPrint {
<#
 .SYNOPSIS
  Function to stop and disable the spooler service and ensure that only administrators can install printer drivers.
 
 .DESCRIPTION
  Function to stop and disable the spooler service and ensure that only administrators can install printer drivers.
  This function creates HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows NT\Printers\PointAndPrint
  DWord name - RestrictDriverInstallationToAdministrators
  Value data - 1
 
 .PARAMETER LogFile
  Path to a file where this function logs its console output.
 
 .EXAMPLE
  Restrict-PointAndPrint
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://cyber.dhs.gov/ed/21-04/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 13 July 2021.
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Restrict-PointAndPrint-$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { }

    Process {
        
        #region Stop and disable the Print Spooler service

        if ($IsElevated) {
            Stop-Service Spooler -Force -Confirm:$false -NoWait -PassThru
            $Result = Set-Service Spooler -Status stopped -StartupType disabled -PassThru -Confirm:$false
            if ($Result.Status -eq 'Stopped' -and $Result.StartType -eq 'Disabled') {
                Write-Log 'Stopped and disabled the spooler service on',$env:COMPUTERNAME Green,Cyan $LogFile
                Write-Log ($Result | Select Name,Status,StartType | Out-String).Trim() Cyan $LogFile
            } else {
                Write-Log 'Failed to stop/disable the spooler service on',$env:COMPUTERNAME Magenta,Yellow $LogFile
                Write-Log ($Result | Select Name,Status,StartType | Out-String).Trim() Yellow $LogFile
            }
        } else {
            Write-Log 'Unable to stop/disable the spooler service','- need elevation' Magenta,Yellow $LogFile
        } 

        #endregion

        #region Ensure that only administrators can install printer drivers

        $RegKey = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers\'
        if ($IsElevated) {
            try { New-Item -Path $RegKey -Name 'PointAndPrint' -ItemType directory -Force -EA 1 | Out-Null } 
                catch { Write-Log $_.Exception.Message Magenta $LogFile }
            try { New-ItemProperty -Path "$RegKey\PointAndPrint" -PropertyType DWORD -Name 'RestrictDriverInstallationToAdministrators' -Value 1 -Force -EA 1 | Out-Null } 
                catch { Write-Log $_.Exception.Message Magenta $LogFile }

            $Validation = Get-ItemProperty -Path "$RegKey\PointAndPrint" -EA 0
            if ($Validation.RestrictDriverInstallationToAdministrators -eq 1) {
                Write-Log 'Ensured that only administrators can install print drivers' Green $LogFile
            } else {
                Write-Log 'Failed to ensure that only administrators can install print drivers' Magenta $LogFile
            }
        } else {
            Write-Log 'Unable to modify the registry to ensure that only administrators can install print drivers','- need elevation' Magenta,Yellow $LogFile
        } 

        #endregion

    }

    End {  }
} 

function Invoke-ShodanAPI {
<#
 .SYNOPSIS
  Function to query the Shodan API
 
 .DESCRIPTION
  Function to query the Shodan API
  It requires a Shodan API key - See https://developer.shodan.io/
  Enterprise subscription level methods have not been implemented.
  shodan/query method optional parameters page, sort, and order have not been implemented.
  This function asks the user for API key and saves it securely to disk.
 
  To be implemented: Search, On-Demand Scanning, Network Alerts, and Notifiers methods.
 
 .PARAMETER Method
  Currently implemented methods are:
    'api-info'
    'account/profile'
    'tools/httpheaders'
    'dns/reverse'
    'dns/resolve'
    'dns/domain'
    'org'
    'shodan/query'
    'shodan/query/search'
    'shodan/query/tags'
    'shodan/ports'
    'shodan/protocols'
    'shodan/scans'
    'shodan/host'
 
 .PARAMETER Ips
  One or more IPv4 addresses. Needed with dns/reverse and shodan/host methods.
  Example: @('74.125.227.230','204.79.197.200')
 
 .PARAMETER Hostnames
  One or more hostnames. Needed with dns/resolve method.
  Example: @('google.com','bing.com')
 
 .PARAMETER Domain
  Domain name to lookup. Needed with dns/domain method.
  Example: 'cnn.com'
 
 .PARAMETER History
  Switch parameter. When set to $True, the API returns historical DNS data. Optional with dns/domain method.
 .PARAMETER Method
 
 .PARAMETER Type
  DNS type. Optional with dns/domain method.
  Valid values are: 'A','AAAA','CNAME','NS','SOA','MX','TXT'
 
 .PARAMETER Page
  The page number to page through results 100 at a time. Optional with dns/domain method.
  Defaults to 1.
 
 .PARAMETER Query
  What to search for in the directory of saved search queries. Needed with shodan/query/search method.
  Defaults to 'webcam'
 
 .PARAMETER Size
  The number of tags to return. Optional with shodan/query/tags method.
  Defaults to 99,999
 
 .PARAMETER NewAPIKey
  Switch Parameter. When set to $True, the user is prompted to enter a new API key.
 
 .PARAMETER LogFile
  Path to a file where this function will save time-stamped entries similar to its console output.
 
 .EXAMPLE
  Invoke-ShodanAPI -Verbose -Method dns/reverse -Ips '8.8.8.8,1.1.1.1'
 
 .EXAMPLE
  Invoke-ShodanAPI -Verbose -Method dns/resolve -Hostnames 'google.com,bing.com'
   
 .EXAMPLE
  $PortList = invoke-shodanapi -Verbose -Method shodan/ports
  Returns a list of port numbers that the crawlers are looking for.
 
 .EXAMPLE
  $ProtocolList = invoke-shodanapi -Verbose -Method shodan/protocols
  Returns all the protocols that can be used when launching an Internet scan and their description.
 
 .EXAMPLE
  $ScanList = invoke-shodanapi -Verbose -Method shodan/scans
  Returns a listing of all the on-demand scans that are currently active on the account.
 
 .EXAMPLE
  $DomainInfo = Invoke-ShodanAPI -Verbose -Method dns/domain
  The dns/domain method requires the -Domain parameter, which defaults to 'CNN.Com'
  This is the same as
  Invoke-ShodanAPI -Verbose -Method dns/domain -Domain 'CNN.Com'
 
  $DomainInfo # Shows DNS data summary
  $DomainInfo.data # Shows DNS details
  $DomainInfo.data | where Type -eq 'A' # shows DNS A recors only
  $DomainInfo.data.subdomain | select -unique | sort # shows list of subdomains
 
 .EXAMPLE
  $DomainInfo = Invoke-ShodanAPI -Verbose -Method dns/domain -Domain 'shodan.io' -History
  $DomainInfo # Shows DNS data summary
  $DomainInfo.tags # Shows tag list
  $DomainInfo.data # Shows DNS details
  $DomainInfo.data | where Type -eq 'A' # shows DNS A recors only
  $DomainInfo.data.subdomain | select -unique | sort # shows list of subdomains
  ($DomainInfo.data | where subdomain -eq 'WWW').Ports | select -unique | sort # shows open TCP ports on the WWW subdomain
 
 .EXAMPLE
    $HostServiceList = invoke-shodanapi -Method shodan/host -Ips '8.8.8.8' -Verbose
    Will return output like:
 
    VERBOSE: GET https://api.shodan.io/shodan/host/8.8.8.8?key=9V1fZoOHMpuxrZ1qVlHB7YslfPM2G2s7 with 0-byte payload
    VERBOSE: received -1-byte response of content type application/json; charset=UTF-8
    VERBOSE: StatusCode : 200
    StatusDescription : OK
    Content : {"region_code": "CA", "ip": 134744072, "postal_code": null, "country_code": "US", "city": "Mountain View", "dma_code": null, "last_update": "2021-08-28T11:23:27.888451",
                        "latitude": 37.4056, "tags": [...
    RawContent : HTTP/1.1 200 OK
                        Transfer-Encoding: chunked
                        Connection: keep-alive
                        Vary: Accept-Encoding
                        Access-Control-Allow-Origin: *
                        X-Frame-Options: DENY
                        X-Content-Type-Options: nosniff
                        X-XSS-Protection: 1;...
    Forms :
    Headers : {[Transfer-Encoding, chunked], [Connection, keep-alive], [Vary, Accept-Encoding], [Access-Control-Allow-Origin, *]...}
    Images : {}
    InputFields : {}
    Links : {}
    ParsedHtml :
    RawContentLength : 1349
 
    PS c:\> $HostServiceList
    Will return output like:
     
    region_code : CA
    ip : 134744072
    postal_code :
    country_code : US
    city : Mountain View
    dma_code :
    last_update : 2021-08-28T11:23:27.888451
    latitude : 37.4056
    tags : {}
    area_code :
    country_name : United States
    hostnames : {dns.google}
    org : Google LLC
    data : {@{_shodan=; hash=-553166942; os=; opts=; timestamp=2021-08-28T11:23:27.888451; isp=Google LLC; port=53; hostnames=System.Object[]; location=; dns=; ip=134744072;
                    domains=System.Object[]; org=Google LLC; data=
                    Recursion: enabled; asn=AS15169; transport=udp; ip_str=8.8.8.8}}
    asn : AS15169
    isp : Google LLC
    longitude : -122.0775
    country_code3 :
    domains : {dns.google}
    ip_str : 8.8.8.8
    os :
    ports : {53}
 
    PS c:\> $HostServiceList.data
    Will return output like:
 
    _shodan : @{id=148b3c6c-3f29-494f-9bcb-aa05ac534bac; options=; ptr=True; module=dns-udp; crawler=cdd92e2d835a37d2798fa6c7105171f4d214012f}
    hash : -553166942
    os :
    opts : @{raw=34ef818200010000000000000776657273696f6e0462696e640000100003}
    timestamp : 2021-08-28T11:23:27.888451
    isp : Google LLC
    port : 53
    hostnames : {dns.google}
    location : @{city=Mountain View; region_code=CA; area_code=; longitude=-122.0775; country_code3=; country_name=United States; postal_code=; dma_code=; country_code=US; latitude=37.4056}
    dns : @{resolver_hostname=; recursive=True; resolver_id=; software=}
    ip : 134744072
    domains : {dns.google}
    org : Google LLC
    data :
                Recursion: enabled
    asn : AS15169
    transport : udp
    ip_str : 8.8.8.8
 
 .EXAMPLE
  $SavedSearchQueries = Invoke-ShodanAPI -Verbose -Method shodan/query
  $SavedSearchQueries.matches # Shows saved search queries like:
    votes : 1
    description :
    tags : {iis}
    timestamp : 2021-05-15T21:52:55.316000
    title : Seagate.com
    query : Seagate.com
 
    votes : 1
    description :
    tags : {}
    timestamp : 2021-05-14T15:17:22.864000
    title : 80
    query : net:193.110.3.0/24
 
    votes : 2
    description : Electronic highway message signs
    tags : {iot, signs}
    timestamp : 2021-05-13T16:34:00.023000
    title : Saferoads Variable Message Signs
    query : Saferoads VMS
 
    votes : 3
    description :
    tags : {adb, port 5555}
    timestamp : 2021-05-12T00:40:50.411000
    title : ADB Remote Access
    query : Android Debug Bridge port:5555
 
    votes : 1
    description : shodan.io result
    tags : {}
    timestamp : 2021-05-11T11:36:34.190000
    title : shodan
    query : intellicar.in
 
    votes : 1
    description :
    tags : {}
    timestamp : 2021-05-08T07:46:02.973000
    title : 高明区
    query : title:"高明区"
 
    votes : 2
    description :
    tags : {}
    timestamp : 2021-05-07T14:10:36.212000
    title : crosslink
    query : net:3.214.40.103,3.236.72.167,54.175.33.251,54.173.230.130,3.236.12.118,34.233.129.30,44.192.123.74,52.202.154.236
 
    votes : 2
    description : Pfizer Inc (Pharma) Jabber clients across the world.
    tags : {pfizer, pharma, jabber}
    timestamp : 2021-05-07T13:50:03.890000
    title : Pfizer Jabber Servers/Client
    query : org:"Pfizer Inc." port:"5222"
 
    votes : 1
    description : fra shodan.io
    tags : {}
    timestamp : 2021-05-07T09:09:26.710000
    title : JMA Internet exposure
    query : org:JMA country:DK
 
    votes : 1
    description :
    tags : {}
    timestamp : 2021-05-06T22:47:42.599000
    title : 208.83.148.0/26
    query : net:"208.83.148.0/26"
 
 .EXAMPLE
  $ShodanQuery = Invoke-ShodanAPI -Verbose -Method shodan/query/search -Query 'voip'
  $ShodanQuery.matches # Shows query results like:
    votes : 1
    description :
    title : voip
    timestamp : 2017-03-04T23:29:13.959000
    tags : {}
    query : title:"Apache HTTP Server Test Page powered by CentOS" Content-Length: 4897 port:"80"
 
    votes : 8
    description : voip
    title : Snom
    timestamp : 2010-09-12T17:09:08.891000
    tags : {voip}
    query : snom embedded country:DE
 
    votes : 1
    description : nec voip
    title : NEC Voip Phones
    timestamp : 2013-02-07T20:52:26.911000
    tags : {nec, voip}
    query : title:"Web programming" chunked no-cache Transfer-Encoding
 
    votes : 4
    description : MX VoIP
    title : MX VoIP
    timestamp : 2013-07-31T03:38:41.199000
    tags : {3}
    query : MX VoIP
 
    votes : 10
    description : 39 voip
    title : 39 voip
    timestamp : 2014-02-05T18:53:26.840000
    tags : {39, voip}
    query : 39 voip
 
    votes : 3
    description :
    title : Voip co
    timestamp : 2017-03-28T12:41:31.835000
    tags : {}
    query : Voip
 
    votes : 2
    description : Voip
    title : MyPBX Italy
    timestamp : 2014-02-20T22:26:56.784000
    tags : {italy}
    query : mypbx country:IT
 
    votes : 3
    description : sudanese voip online servers
    title : sudanese voip servers
    timestamp : 2012-02-01T17:59:56.557000
    tags : {voip}
    query : country:SD port:5060
 
    votes : 4
    description : sagem voip phones and routers
    title : sagem
    timestamp : 2012-11-24T18:19:53.639000
    tags : {voip}
    query : sagem
 
    votes : 1
    description : voip
    title : Insped
    timestamp : 2012-08-10T15:51:36.098000
    tags : {ornago}
    query : cisco-ios city:"Ornago" port:161
 
 .EXAMPLE
  $ShodanTags = Invoke-ShodanAPI -Verbose -Method shodan/query/tags
  $ShodanTags.matches.Count # 2863
  $ShodanTags.matches | sort Count -Desc | Select -First 10 # Shows top 10 tags
    count value
    ----- -----
      212 webcam
      176 cam
      166 camera
      101 ip
       93 router
       91 scada
       91 ftp
       87 server
       67 http
       57 test
 
 
 .OUTPUTS
  This cmdlet returns the API data.
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 25 August 2021 - Implemented api-info, tools/myip, tools/httpheaders, dns/resolve, dns/reverse,
    dns/domain/{domain}, account/profile, org, shodan/query, shodan/query/search, shodan/query/tags
    Not implemented put/delete: org/member/{user}, shodan/data, shodan/data/{dataset}
  v0.2 - 27 August 2021 - Added scanning methods: shodan/ports, shodan/protocols, shodan/scans
    Added search methods: /shodan/host/{ip}
    Not implemented: put: shodan/scan, put: shodan/scan/internet
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateSet(
            'api-info',
            'account/profile',
            'tools/httpheaders',
            'dns/reverse',
            'dns/resolve',
            'dns/domain',
            'org',
            'shodan/query',
            'shodan/query/search',
            'shodan/query/tags',
            'shodan/ports',
            'shodan/protocols',
            'shodan/scans',
            'shodan/host'
        )][String]$Method = 'api-info',
        [Parameter(Mandatory=$false)][IPAddress[]]$Ips = @('74.125.227.230','204.79.197.200'),   
        [Parameter(Mandatory=$false)][String[]]$Hostnames = @('google.com','bing.com'),       
        [Parameter(Mandatory=$false)][String]$Domain = 'cnn.com',                     
        [Parameter(Mandatory=$false)][Switch]$History,                                
        [Parameter(Mandatory=$false)][ValidateSet('A','AAAA','CNAME','NS','SOA','MX','TXT')][String]$Type,
        [Parameter(Mandatory=$false)][Int32]$Page = 1,                                 
        [Parameter(Mandatory=$false)][String]$Query = 'webcam',                        
        [Parameter(Mandatory=$false)][Int32]$Size = 99999,                             
        [Parameter(Mandatory=$false)][Switch]$NewAPIKey,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Invoke-ShodanAPI_$Method_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 

        $ShodanAPIKey = if ($NewAPIKey) {
            Get-SBCredential -UserName 'ShodanAPIKey' -Refresh
        } else {
            Get-SBCredential -UserName 'ShodanAPIKey' 
        }
        if (-not $ShodanAPIKey) {
            Write-Log 'Shodan API key not provided, stopping..' Magenta $LogFile
            break
        } 

    }

    Process {  
    
        #region Validate method parameters, compile Uri
        $Method = $Method.ToLower()
        if ($Method -in $ShodanAPIMethodList) {
            $Uri = "$ShodanAPIBaseURL/$($Method)?key=$($ShodanAPIKey.GetNetworkCredential().password)"
            switch ($Method) {
                'dns/resolve'         { $Uri += "&hostnames=$($Hostnames -join ',')" }
                'dns/reverse'         { $Uri += "&ips=$($IPs.IPAddressToString -join ',')" }
                'shodan/query/search' { $Uri += "&query=$($Query)" }
                'shodan/query/tags'   { $Uri += "&size=$($Size)" }
                'dns/domain'  { 
                    $Uri = "$ShodanAPIBaseURL/$($Method)/$($Domain)?key=$($ShodanAPIKey.GetNetworkCredential().password)&page=$($Page)"
                    if ($History) { $Uri += '&history' }
                    if ($Type) { $Uri += "&type=$($Type)" }
                }
                'shodan/host'  { 
                    if ($Ips.Count -eq 1) {
                        $Uri = "$ShodanAPIBaseURL/$($Method)/$($Ips)?key=$($ShodanAPIKey.GetNetworkCredential().password)"
                    } else {
                        foreach ($IPAddress in $Ips) {
                            Invoke-ShodanAPI -Method $Method -Ips $IPAddress -LogFile $LogFile 
                        }
                    }
                }
                default {  }
            }
        } else {
            Write-Log 'Invoke-ShodanAPI Error:','bad API method provided',$Method Magenta,Yellow,Magenta $LogFile
            Write-Log 'Known Shodan API methods (from https://developer.shodan.io/api):' Yellow $LogFile
            $ShodanAPIMethodList | foreach { Write-Log " $_" Cyan $LogFile }
            break
        }
        
        #endregion
            
        try {
            $Result = Invoke-WebRequest -Uri $Uri -UseBasicParsing -EA 1 
            Write-Verbose ($Result | Out-String).Trim()
            if ($Method -eq 'shodan/protocols') {
                $Obj = $Result.Content | ConvertFrom-Json
                foreach ($Prop in ($Obj | Get-Member -MemberType NoteProperty).Name) {
                    New-Object -TypeName PSObject -Property ([Ordered]@{    
                        Protocol    = $Prop
                        Description = $Obj.$Prop
                    })
                }
            } else {
                $Result.Content | ConvertFrom-Json
            }
        } catch {
            Write-Log $_.Exception.Message Magenta $LogFile
            Write-Log $_.ErrorDetails.Message Yellow $LogFile
        }
    }

    End {  }
} 

function Report-Kerberoasting {

<#
 .SYNOPSIS
  Function to return information on AD user accounts in the current AD domain that have SPN's.
 
.DESCRIPTION
  Function to return information on AD user accounts in the current AD domain that have Service Principal Names and are subject to Kerberoasting attacks.
  Note that LastLogonTimeStamp may be off by up to 14 days.
  This function depends on and uses:
  Get-ADUser cmdlet of the ActiveDirectory PowerShell module.
 
  LogonCount notes:
    This attribute is not replicated and is maintained on each domain controller in the domain. To get an accurate value for the user's total
    number of successful logon attempts in the domain, each domain controller in the domain must be queried and the sum of the values should
    be used. Keep in mind that the attribute is not replicated, therefore domain controllers that are retired may have counted logons for the
    user as well, and these will be missing from the count.
    Due to compatibility with 16-bit versions of LAN Manager, the attribute has an upper limit of 65535.
    https://docs.microsoft.com/en-us/windows/win32/adschema/a-logoncount
 
  Notes on delegation:
        Accounts trusted for delegation (unconstrained delegation) (userAccountControl:1.2.840.113556.1.4.803:=524288)
        Accounts that are sensitive and not trusted for delegation (userAccountControl:1.2.840.113556.1.4.803:=1048576)
        1.2.840.113556.1.4.803
            This is the bitwise AND operator (LDAP_MATCHING_RULE_BIT_AND). The rule is true only if all bits from the property match the value.
        1.2.840.113556.1.4.804
            This is the bitwise OR operator (LDAP_MATCHING_RULE_BIT_OR). The rule is true if any bits from the property match the value.
        TRUSTED_FOR_DELEGATION 0x80000 524288
            When this flag is set, the service account (the user or computer account) under which a service runs is trusted for Kerberos delegation.
            Any such service can impersonate a client requesting the service.
        NOT_DELEGATED 0x100000 1048576
            When this flag is set, the security context of the user is not delegated to a service even if the service account is set as trusted for Kerberos delegation.
        TRUSTED_TO_AUTH_FOR_DELEGATION 0x1000000 16777216
            (Windows 2000/Windows Server 2003) The account is enabled for delegation. This is a security-sensitive setting. Accounts that have this
            option enabled should be tightly controlled. This setting lets a service that runs under the account assume a client’s identity and authenticate
            as that user to other remote servers on the network.
 
 .PARAMETER PropList
  Optional parameter that lists the attributes that this report should query for AD users.
  It defaults to 'Description','info','EmployeeId','EmailAddress','Enabled','userWorkstations','Created','LastLogonTimeStamp','PasswordLastSet','PasswordNeverExpires','ServicePrincipalNames','MemberOf'
 
 .PARAMETER SelectList
  Optional parameter that lists the attributes that this report should return on AD users.
  It defaults to 'Name','samAccountName','DistinguishedName','UserPrincipalName','Description','info','EmployeeId','EmailAddress','Enabled','userWorkstations','Created','LastLogonTimeStamp','PasswordLastSet','PasswordNeverExpires','ServicePrincipalNames','MemberOf'
 
 .PARAMETER Server
  Optional parameter for which domain/domain controller to query. It defaults to the current domain.
 
 .PARAMETER Cred
  Optional parameter that can be used to query domains with different credential than the currently logged on user.
 
 .PARAMETER ShowAttributeInfo
  When this switch is used, this function displays details about UserAccountControl and msDS-SupportedEncryptionTypes.
 
 .PARAMETER ShowSupportedEncryptionTypes
  When this switch is used, this function searches for AD users that have a value in the attribute msDS-SupportedEncryptionTypes.
 
 .PARAMETER ShowuserAccountControl
  When this switch is used, this function searches for AD users that have their userAccountControl attribute flagged for either
  'TRUSTED_FOR_DELEGATION' or 'TRUSTED_TO_AUTH_FOR_DELEGATION'.
 
 .PARAMETER IdentifyEncType
  When this switch is used, this function attempts to get kerberos tickets to identify ticket encryption type for each reported AD user.
 
 .PARAMETER LogFile
  Optional parameter that contains the name of a text file where this function will log its console output.
  When not provided, it defaults to a file in the current folder.
 
 .EXAMPLE
  Report-Kerberoasting
 
 .EXAMPLE
  $AccountList = Report-Kerberoasting
  $ReportFileName = ".\KerberoastingAccountList-$($thisDomainName)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv"
  $AccountList | Export-csv $ReportFileName -NoTypeInformation
  This example exports the resulting output to CSV file
 
 .EXAMPLE
  $AccountList = Report-Kerberoasting -ShowAttributeInfo
  $ReportFileName = ".\KerberoastingAccountList-$($thisDomainName)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv"
  $AccountList | Export-csv $ReportFileName -NoTypeInformation # Export the resulting output to CSV file
  # Report on accounts that support RC4 ticket encryption
  $SupportsRC4Enc = $AccountList | where SupportedEncTypeDescription -match 'RC4-HMAC'
  $SupportsRC4Enc | Export-csv ($ReportFileName -replace 'KerberoastingAccountList','KerberoastingSupportsRC4Enc') -NoTypeInformation
  # Report on accounts that have PASSWD_NOTREQD (password not required)
  $PASSWD_NOTREQD = $AccountList | where UserAccountControlDescription -match 'PASSWD_NOTREQD'
  $PASSWD_NOTREQD | Export-csv ($ReportFileName -replace 'KerberoastingAccountList','KerberoastingPASSWD_NOTREQD') -NoTypeInformation
  # Report on accounts that have NOT_DELEGATED
  $NOT_DELEGATED = $AccountList | where UserAccountControlDescription -match 'NOT_DELEGATED'
  $NOT_DELEGATED | Export-csv ($ReportFileName -replace 'KerberoastingAccountList','KerberoastingNOT_DELEGATED') -NoTypeInformation
  # Report on accounts with Service Principal Names only
  $SPNAccountsOnly = $AccountList | where { $_.ServicePrincipalNames }
  $SPNAccountsOnly | Export-csv ($ReportFileName -replace 'KerberoastingAccountList','KerberoastingSPNAccountsOnly') -NoTypeInformation
 
 .OUTPUTS
  Progress output is displayed to the console and log file. Records similar to:
    Name : Brad Falcom
    samAccountName : Brad.Falcom
    DistinguishedName : CN=Brad Falcom,OU=PACRIM,DC=domain,DC=local
    UserPrincipalName : Brad.Falcom@domain.local
    Description :
    info :
    EmployeeId :
    EmailAddress :
    Enabled : True
    userWorkstations :
    Created : 10/15/2021 2:27:31 PM
    LastLogonTimeStamp : Never
    PasswordLastSet : 10/15/2021 2:27:31 PM
    PasswordNeverExpires : True
    ServicePrincipalNames : http/daserver [AES256-CTS-HMAC-SHA-1]
    MemberOf :
    UserAccountControl : 6357504
    userAccountControlDescription : NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, USE_DES_KEY_ONLY, DONT_REQ_PREAUTH
    msDS-SupportedEncryptionTypes : 24
    SupportedEncTypesDescription : AES256-CTS-HMAC-SHA-1-96, AES128-CTS-HMAC-SHA-1-96
    TRUSTED_FOR_DELEGATION : True
    TRUSTED_TO_AUTH_FOR_DELEGATION : False
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
.NOTES
  Function by Sam Boutros
  v0.1 - 14 September 2021
  v0.2 - 17 September 2021
    Added SPN Kerberos Ticket Encryption Type
  v0.3 - 21 October 2021
    Added attributes:
        UserAccountControl
        userAccountControlDescription
        msDS-SupportedEncryptionTypes
        SupportedEncTypesDescription
        TRUSTED_FOR_DELEGATION
        TRUSTED_TO_AUTH_FOR_DELEGATION
  v0.4 - 18 March 2022
    Added 3 switch parameters:
        ShowSupportedEncryptionTypes
        ShowuserAccountControl
        IdentifyEncType
  v0.5 - 28 March 2022
    Added 'Server' and 'Cred' parameters to query domains other than the current domain.
  v0.6 - 4 October 2022
    Improved console output.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$PropList = @('Description','info','EmployeeId','EmailAddress','Enabled','userWorkstations','Created','LastLogonTimeStamp','PasswordLastSet','PasswordNeverExpires','ServicePrincipalNames','MemberOf'),
        [Parameter(Mandatory=$false)][String[]]$SelectList = @('Name','samAccountName','DistinguishedName','UserPrincipalName','Description','info','EmployeeId','EmailAddress','Enabled','userWorkstations','Created','LastLogonTimeStamp','PasswordLastSet','PasswordNeverExpires','ServicePrincipalNames','MemberOf'),
        [Parameter(Mandatory=$false, HelpMessage = "Domain name or Domain Controller name - defaults to current domain")][String]$Server = $env:USERDNSDOMAIN,
        [Parameter(Mandatory=$false)][PSCredential]$Cred,
        [Parameter(Mandatory=$false, HelpMessage = 'Show details about UserAccountControl and msDS-SupportedEncryptionTypes attributes')][Switch]$ShowAttributeInfo,
        [Parameter(Mandatory=$false)][Switch]$ShowSupportedEncryptionTypes,
        [Parameter(Mandatory=$false)][Switch]$ShowuserAccountControl,
        [Parameter(Mandatory=$false, HelpMessage = 'Get kerberos tickets to identify ticket encryption type for each reported AD user')][Switch]$IdentifyEncType,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-Kerberoasting_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 

        $StartTime = Get-Date
        Write-Host ' '
        if (-not $IsDomainMember) {
            Write-Log 'Report-Kerberoasting Error: This function can only be invoked on a domain joined computer' Magenta $LogFile
            break
        }
        Write-Log 'Starting automation to report on AD accounts subject to Kerberoasting in the',$Server,'AD domain/domain controller' Green,Cyan,Green $LogFile
        if ('LastLogonTimeStamp' -in $PropList) {
            Write-Log 'Please note that','LastLogonTimeStamp','may be off by up to 14 days.' Yellow,Cyan,Yellow $LogFile
        }

    }

    Process {     

        #region Get AD accounts - Deliverable: $AccountList:

        $PropList += @('UserAccountControl','msDS-SupportedEncryptionTypes')    
        $PropList = $PropList | select -Unique    
        $SelectList += @('UserAccountControl','userAccountControlDescription','msDS-SupportedEncryptionTypes','SupportedEncTypesDescription','TRUSTED_FOR_DELEGATION','TRUSTED_TO_AUTH_FOR_DELEGATION')        
        $SelectList = $SelectList | select -Unique 
        $AccountList = @()
        
        # ServicePrincipalNames attribute
        Write-Log 'Retrieving AD accounts with SPN''s in the',$thisDomainName,'AD domain..' Green,Cyan,Green $LogFile -NoNewLine
        $Duration = Measure-Command {
            try {
                if ($Cred) {
                    $AccountList = Get-ADUser -Filter "ServicePrincipalNames -like '*'" -Properties $PropList -Server $Server -Credential $Cred -EA 1 | select $SelectList 
                } else {
                    $AccountList = Get-ADUser -Filter "ServicePrincipalNames -like '*'" -Properties $PropList -Server $Server -EA 1 | select $SelectList 
                }
            } catch {
                Write-Log 'Command','Get-ADUser','failed' Magenta,Yellow,Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
                break
            }
        }
        if ($AccountList) { 
            Write-Log 'identified',('{0:N0}' -f ($AccountList | measure -Sum -EA 0).Count),'account(s) with SPN''s in' Green,Cyan,Green $LogFile -NoNewLine
        } else { 
            Write-Log 'identified','NO','accounts with SPN''s in' Green,Cyan,Green $LogFile -NoNewLine
        }
        Write-Log "$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' Cyan,Green $Logfile

        # msDS-SupportedEncryptionTypes attribute
        if ($ShowSupportedEncryptionTypes) {
            Write-Log ' Retrieving AD accounts with msDS-SupportedEncryptionTypes..' Green $LogFile -NoNewLine
            $Duration = Measure-Command {
                $FoundAccounts = Get-ADUser -Filter "msDS-SupportedEncryptionTypes -like '*'" -Properties $PropList | select $SelectList 
            }
            if ($FoundAccounts) { 
                $AccountList += $FoundAccounts
                Write-Log 'identified',('{0:N0}' -f ($FoundAccounts | measure -Sum -EA 0).Count),'account(s) with msDS-SupportedEncryptionTypes in' Green,Cyan,Green $Logfile -NoNewLine
            } else { 
                Write-Log 'identified','NO','accounts with msDS-SupportedEncryptionTypes in' Green,Cyan,Green $Logfile -NoNewLine
            } 
            Write-Log "$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' Cyan,Green $Logfile
        }       

        # Delegation flags from userAccountControl attribute
        if ($ShowuserAccountControl) {
            $AccountList | foreach { $_.TRUSTED_FOR_DELEGATION = $_.TRUSTED_TO_AUTH_FOR_DELEGATION = $false }
            foreach ($userAccountControlFlag in @('TRUSTED_FOR_DELEGATION','TRUSTED_TO_AUTH_FOR_DELEGATION')) {
                $Duration = Measure-Command {
                    $Flag = $UserAccountControl | where Name -EQ $userAccountControlFlag
                    $FoundAccounts = Get-ADUser -LDAPFilter "(userAccountControl:1.2.840.113556.1.4.803:=$($Flag.Hex))" -Properties $PropList | select $SelectList
                    $FoundAccounts | foreach { $_.$userAccountControlFlag = $true }
                }
                if ($FoundAccounts) {
                    $AccountList += $FoundAccounts
                    Write-Log ' and',$FoundAccounts.Count,'accounts flagged',$Flag.Name,'in' Green,Cyan,Green,Yellow,Cyan $LogFile -NoNewLine
                } else {
                    Write-Log ' No accounts were found flagged',$Flag.Name,'in' Green,Cyan,Green,Yellow,Cyan $LogFile -NoNewLine
                }
                Write-Log "$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss',$Flag.Desc.Trim()  Green,Cyan,Green $Logfile
            }
        }

        # Deduplicate records, sort
        Write-Log 'Deduplicating and sorting records..' Green $LogFile -NoNewLine
        $Duration = Measure-Command {
            $AccountList = $AccountList | group DistinguishedName | foreach { $_.Group | select -First 1 }
            $AccountList = $AccountList | sort DistinguishedName
        }
        Write-Log 'total',('{0:N0}' -f ($AccountList | measure -Sum -EA 0).Count),'done in',
            "$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' Green,Cyan,Green,Cyan,Green $Logfile

        #endregion

        if ($IdentifyEncType) {

            #region Identify SPN Ticket Encryption Type - Deliverable: $SPNList: SPN, EncTypeId, EncTypeName

            $Duration = Measure-Command {
                $SPNNameList = $AccountList.ServicePrincipalNames | select -Unique | sort 
                Write-Log 'Identifying',('{0:N0}' -f ($SPNNameList | measure -Sum -EA 0).Count),'unique SPNs'' Encryption Type..' Green,Cyan,Green $LogFile -NoNewLine 
                $SPNList = foreach ($SPN in $SPNNameList) {
                    $thisEncType = Get-KTicketEncType $SPN
                    New-Object -TypeName PSObject -Property ([Ordered]@{ SPN = $SPN; EncTypeId = $thisEncType.Id; EncTypeName = $(if ($thisEncType.Name) {$thisEncType.Name} else {$thisEncType}) })
                }
            }
            Write-Log 'identified' Green $LogFile -NoNewLine
            if ($FoundGoodSPNs = $SPNList | where EncTypeName -NE 'Unable to get Kerberos Ticket') { 
                Write-Log ('{0:N0}' -f ($FoundGoodSPNs | measure -Sum -EA 0).Count),'SPNs' Cyan,Green $LogFile -NoNewLine
            } else {
                Write-Log 'No','SPNs' Yellow,Green $LogFile -NoNewLine
            } 
            Write-Log 'encryption type in',"$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' DarkYellow,Cyan,Green $LogFile

            #endregion

            #region Update SPN information, remove user accounts with bad SPNs

            $Duration = Measure-Command {
                Write-Log 'Updating',('{0:N0}' -f ($SPNList | measure -Sum -EA 0).Count),'unique SPNs'' information..' Green,Cyan,Green $LogFile -NoNewLine 
                foreach ($ADAccount in $AccountList) {
                    $UpdatedNameList = foreach ($Name in $ADAccount.ServicePrincipalNames) {
                        "$Name [$(($SPNList | where SPN -EQ $Name).EncTypeName)]"
                    }
                    $ADAccount.ServicePrincipalNames = $UpdatedNameList -join ', '
                }
            }
            Write-Log 'done in',"$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' Cyan,Yellow,Green $LogFile

            #endregion

        }

        #region Normalize LastLogonTimeStamp, ServicePrincipalNames, MemberOf, UserAccountControl, and msDS-SupportedEncryptionTypes (keep as last region)
        Write-Log 'Updating attribute information..' Green $LogFile -NoNewLine
        $Duration = Measure-Command {
            foreach ($ADAccount in $AccountList) {
                if ('LastLogonTimeStamp' -in $PropList) {
                    $ADAccount.LastLogonTimeStamp = $(
                        try {
                            $Temp1 = [datetime]::FromFileTime($($ADAccount.LastLogonTimeStamp) -as [int64])
                            if ($Temp1 -le [DateTime]'1/1/1900') { 'Never' } else { $Temp1 }
                        } catch { 'Never' }
                    )
                }

                if ('ServicePrincipalNames' -in $PropList) {
                    $ADAccount.ServicePrincipalNames = $ADAccount.ServicePrincipalNames -join ', '
                }

                if ('MemberOf' -in $PropList) {
                    $ADAccount.MemberOf = $ADAccount.MemberOf -join ', '
                }

                $ADAccount.userAccountControlDescription = if ($ADAccount.userAccountControl -gt 0) { (Parse-UserAccountControl -UAC $ADAccount.userAccountControl).Name -join ', ' } else { $null }
                $ADAccount.SupportedEncTypesDescription = if ($ADAccount.'msDS-SupportedEncryptionTypes' -gt 0) { (Parse-msDSSupportedEncryptionTypes -msDSSupportedEncryptionType $ADAccount.'msDS-SupportedEncryptionTypes').Name -join ', ' } else { $null }

            }
        }
        Write-Log 'Done in',"$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' Cyan,DarkYellow $LogFile          

        #endregion

    }

    End {         
        if ($ShowAttributeInfo) {
            $myUAC = $UserAccountControl | select @{n='Hex';e={"0x$(('{0:x}' -f $_.Hex))"}},@{n='Decimal';e={$_.Hex}},Name,@{n='Description';e={$_.Desc}}
            Write-Log 'UserAccountControl details:' Green $LogFile
            Write-Log ($myUAC | Out-String).Trim() Cyan $LogFile
            $myUAC | Export-Csv '.\UserAccountControl.csv' -NoTypeInformation
            Write-Log 'UserAccountControl detailed list saved to',(Get-Item '.\UserAccountControl.csv').FullName Green,Yellow $LogFile

            $myMsDSSET = $msDSSupportedEncryptionTypes | select @{n='Hex';e={"0x$(('{0:x}' -f $_.Id))"}},@{n='Decimal';e={$_.Id}},Name | sort Decimal
            Write-Log 'msDS-SupportedEncryptionTypes details:' Green $LogFile
            Write-Log ($myMsDSSET | Out-String).Trim() Cyan $LogFile
            $myMsDSSET | Export-Csv '.\msDS-SupportedEncryptionTypes.csv' -NoTypeInformation
            Write-Log 'msDS-SupportedEncryptionTypes detailed list saved to',(Get-Item '.\msDS-SupportedEncryptionTypes.csv').FullName Green,Yellow $LogFile
        }

        $Duration = New-TimeSpan -Start $StartTime -End (Get-Date)
        Write-Log 'All done in',"$(($Duration.Hours).ToString().PadLeft(2,'0')):$(($Duration.Minutes).ToString().PadLeft(2,'0')):$(($Duration.Seconds).ToString().PadLeft(2,'0'))",'hh:mm:ss' Green,Cyan,Green $LogFile
        $AccountList 
    }

}

function Get-KTicketEncType {
<#
 .SYNOPSIS
  Function to return Encryption Type of a Kerberos Ticket of a given Service Principal Name
 
 .DESCRIPTION
  Function to return Encryption Type of a Kerberos Ticket of a given Service Principal Name
  This function obtains a Kerberos Ticket for a given SPN and returns its Encryption Type
 
 .PARAMETER SPN
  SPN Such as 'http/daserver'
 
 .EXAMPLE
  Get-KTicketEncType -SPN 'http/daserver'
 
 .EXAMPLE
  Get-KTicketEncType -SPN 'http/bla' -Verbose
 
 .OUTPUTS
  This cmdlet returns a PS Object such as:
    Id Name
    -- ----
    18 AES256-CTS-HMAC-SHA-1
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 17 September 2021
  v0.2 - 17 October 2021 - updated output as PS object instead of string.
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param( [Parameter(Mandatory=$true)][String]$SPN )

    Begin {  }

    Process {      

        $null = Add-Type -AssemblyName System.IdentityModel
        Try {
            $Ticket     = New-Object System.IdentityModel.Tokens.KerberosRequestorSecurityToken -ArgumentList $SPN -EA 1
            $ByteStream = $Ticket.GetRequest()
            $HexStream  = [System.BitConverter]::ToString($ByteStream) -replace '-'
            $eType      = [Convert]::ToInt32(($HexStream -replace '.*A0030201')[0..1] -join '', 16) 
            if ($FoundType = $KTicketEncType | where Id -EQ $eType) { 
                $FoundType
                # "$($FoundType.Name) ($($FoundType.Id))"
            } else { 
                New-Object -TypeName PSObject -Property @{ Id = $eType ; Name = 'Unknown' } 
            }
        } catch {
            Write-Verbose $_.Exception.InnerException.InnerException
            # serviceclass/host:port/servicename
            'Unable to get Kerberos Ticket'
        }

    }

    End {  }
} 

function Encrypt-File {
<#
 .SYNOPSIS
  Function to encrypt a file using AES CBC encryption.
 
 .DESCRIPTION
  Function to encrypt a file using using the Advanced Encryption Standard (AES) encryption algorithm,
  Cipher Block Chaining (CBC) mode for data confidentiality, 128 bit block size, and 256 bit key size.
 
 .PARAMETER FilePath
  Path to the file to be encrypted.
 
 .PARAMETER Key
  The key to be used for the aes encryption.
 
 .PARAMETER KeepOriginal
  Optional switch. When set to True, this function will not delete the original file.
 
 .PARAMETER DoNotWriteSecretsToLog
  Optional switch. When set to True, this function will not write the key to the log file.
 
 .PARAMETER LogFile
  Path to a file where this function will write its console output.
 
 .EXAMPLE
  Encrypt-File -FilePath .\Questions.txt -Key 'My secret key phrase here'
  This example, EAS-encrypts the provided file using the provided key, deletes the original file,
  and displays progress messages to the console and writes them to log file, including the key.
  The output file will be named the same as the input file + aes extension.
  If the output file exists, this function will over-write it.
 
 .EXAMPLE
  Encrypt-File -FilePath .\Questions.txt -Key 'My secret key phrase here' -KeepOriginal -DoNotWriteSecretsToLog
  This example, EAS-encrypts the provided file using the provided key, does NOT delete the original file,
  and displays progress messages to the console and writes them to log file, NOT including the key.
  The output file will be named the same as the input file + aes extension.
  If the output file exists, this function will over-write it.
 
 .OUTPUTS
  Console output, log file, and the encrypted .aes file.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 6 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$FilePath,
        [Parameter(Mandatory=$true)][String]$Key,
        [Parameter(Mandatory=$false)][Switch]$KeepOriginal,
        [Parameter(Mandatory=$false)][Switch]$DoNotWriteSecretsToLog,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Encrypt-File_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 
        $File = Get-Item -Path $FilePath -EA 0 
        if ($File.FullName) {
            $PlainBytes = [System.IO.File]::ReadAllBytes($File.FullName)
        } else {
            Write-Log 'File',$FilePath,'not found!' Magenta,Yellow,Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }
    }

    Process {
        Write-Log 'Encrypting file',$File.FullName Green,Cyan $LogFile -NoNewLine
        if ($DoNotWriteSecretsToLog) {
            Write-host 'using key ' -ForegroundColor Yellow -NoNewline
            Write-host $Key -ForegroundColor Cyan
        } else {
            Write-Log 'using key',$Key Yellow,Cyan $LogFile
        }
        try {
            $aesManaged = New-Object System.Security.Cryptography.AesManaged
            $aesManaged.Mode = [System.Security.Cryptography.CipherMode]::CBC
            $aesManaged.Padding = [System.Security.Cryptography.PaddingMode]::Zeros
            $aesManaged.BlockSize = 128
            $aesManaged.KeySize = 256
            $shaManaged = New-Object System.Security.Cryptography.SHA256Managed
            $aesManaged.Key = $shaManaged.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Key))
            $Encryptor = $aesManaged.CreateEncryptor()
            $EncryptedBytes = $aesManaged.IV + $Encryptor.TransformFinalBlock($PlainBytes, 0, $PlainBytes.Length)

            $FileAlreadyExists = Test-Path "$($File.FullName).aes"
            [System.IO.File]::WriteAllBytes("$($File.FullName).aes", $EncryptedBytes)
            if ($FileAlreadyExists) {
                Write-Log ' done,','over-writing exiting',"$($File.FullName).aes" Green,Yellow,Cyan $LogFile
            } else {
                Write-Log ' done,',"$($File.FullName).aes" Green,Cyan $LogFile
            }
            if (-not $KeepOriginal) {
                Write-Log ' deleting original file' Green $LogFile -NoNewLine
                try {
                    Remove-Item -Path $File.FullName -Force -Confirm:$false -EA 1 
                    Write-Log 'done' Cyan $LogFile
                } catch {
                    Write-Log ' failed.' Magenta $LogFile
                    Write-Log $_.Exception.Message Yellow $LogFile
                }
            }
        } catch {
            Write-Log ' failed.' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
    }

    End { 
        $shaManaged.Dispose()
        $aesManaged.Dispose()
    }
}

function Decrypt-File {
<#
 .SYNOPSIS
  Function to decrypt a file that was encrypted with the Encrypt-File function.
 
 .DESCRIPTION
  Function to decrypt a file that was encrypted with the Encrypt-File function.
 
 .PARAMETER FilePath
  Path to the file to be encrypted.
 
 .PARAMETER Key
  The key to be used for the AES-CBC encryption.
 
 .PARAMETER DoNotWriteSecretsToLog
  Optional switch. When set to True, this function will not write the key to the log file.
 
 .PARAMETER LogFile
  Path to a file where this function will write its console output.
 
 .EXAMPLE
  Decrypt-File -FilePath .\Questions.txt.aes -Key 'My secret key phrase here'
  This example decrypts the provided file using the provided key,
  and displays progress messages to the console and writes them to log file, including the key.
  The output file will be named the same as the input file less the aes extension.
  If the output file exists, this function will over-write it.
 
 .EXAMPLE
  Decrypt-File -FilePath .\Questions.txt -Key 'My secret key phrase here' -DoNotWriteSecretsToLog
  This example, decrypts the provided file using the provided key,
  and displays progress messages to the console and writes them to log file, NOT including the key.
  The output file will be named the same as the input file less aes extension.
  If the output file exists, this function will over-write it.
 
 .OUTPUTS
  Console output, log file, and the decrypted file.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 6 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][String]$FilePath,
        [Parameter(Mandatory=$true)][String]$Key,
        [Parameter(Mandatory=$false)][Switch]$DoNotWriteSecretsToLog,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Decrypt-File_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 
        $File = Get-Item -Path $FilePath -EA 0 
        if ($File.FullName) {
            $CipherBytes = [System.IO.File]::ReadAllBytes($File.FullName)
            $OutFileName = if ($File.FullName.ToLower().EndsWith('.aes')) {
                $File.FullName -replace '.aes'
            } else {
                "$($File.FullName).Decrypted"
            }            
        } else {
            Write-Log 'File',$FilePath,'not found!' Magenta,Yellow,Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
            break
        }
    }

    Process {
        Write-Log 'Decrypting file',$File.FullName Green,Cyan $LogFile -NoNewLine
        if ($DoNotWriteSecretsToLog) {
            Write-host 'using key ' -ForegroundColor Yellow -NoNewline
            Write-host $Key -ForegroundColor Cyan
        } else {
            Write-Log 'using key',$Key Yellow,Cyan $LogFile
        }
        try {
            $aesManaged = New-Object System.Security.Cryptography.AesManaged
            $aesManaged.Mode = [System.Security.Cryptography.CipherMode]::CBC
            $aesManaged.Padding = [System.Security.Cryptography.PaddingMode]::Zeros
            $aesManaged.BlockSize = 128
            $aesManaged.KeySize = 256
            $shaManaged = New-Object System.Security.Cryptography.SHA256Managed
            $aesManaged.Key = $shaManaged.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Key))
            $aesManaged.IV = $CipherBytes[0..15]
            $Decryptor = $aesManaged.CreateDecryptor()
            $DecryptedBytes = $Decryptor.TransformFinalBlock($CipherBytes, 16, $CipherBytes.Length - 16)
            
            $FileAlreadyExists = Test-Path $OutFileName
            [System.IO.File]::WriteAllBytes($OutFileName, $DecryptedBytes)
            (Get-Item $OutFileName).LastWriteTime = $File.LastWriteTime
            if ($FileAlreadyExists) {
                Write-Log ' done,','over-writing exiting',$OutFileName Green,Yellow,Cyan $LogFile
            } else {
                Write-Log ' done,',$OutFileName Green,Cyan $LogFile
            }
        } catch {
            Write-Log ' failed.' Magenta $LogFile
            Write-Log $_.Exception.Message Yellow $LogFile
        }
    }

    End { 
        $shaManaged.Dispose()
        $aesManaged.Dispose()
    }
}

function Block-IPsPerCountry {
<#
 .SYNOPSIS
  Function to block all incoming IPv4 traffic from all countries except specified list.
 
 .DESCRIPTION
  Function to block all incoming IPv4 traffic from all countries except specified list.
  IPv4 CIDR list is courtesy of ipdeny.com
  If the list of IP CIDR ranges to be blocked exceeds 10,000, this function will create several Windows firewall rules,
  since a firewall rule can have a maximum of 10,000 IPs or CIDR ranges.
  The rules will be named BlockIPsPerCountry with a 2 digit sequential suffix, and will apply to all public/private/domain profiles.
 
 .PARAMETER AllowCountry
  One or more 2-letter country abbreviations. Default is 'us','gb','ca','dk','fi','fr','de','gr','ie','it','nl','nz','no','pr','se','ch'.
 
 .PARAMETER Profile
  Accepts one or more of the 3 network profiles: Public, Private, Domain.
  Defaults to Public and Private.
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output
 
 .EXAMPLE
  Block-IPsPerCountry
  This creates/updates Windows firewall rules to block IPv4 traffic from all countries except
  'us','gb','ca','dk','fi','fr','de','gr','ie','it','nl','nz','no','pr','se','ch','eg'
 
 .EXAMPLE
  $RuleSet = Block-IPsPerCountry -AllowCountry @('us','gb')
  This creates/updates Windows firewall rules to block IPv4 traffic from all countries except 'us' and 'gb'
 
 .OUTPUTS
  Console and log file progress output, and a collection of Windows firewall rules
  (Microsoft.Management.Infrastructure.CimInstance#root/standardcimv2/MSFT_NetFirewallRule), such as:
    Name : BlockIPsPerCountry04
    DisplayName : BlockIPsPerCountry04
    Description : Rule (4 of 13) to deny access to a list of IP addesses and subnets by Country. This rule is set by Block-IPsPerCountry PS function of the AZSBTools PS Module which was last invoked on '10 October 2021, 02:37:32 PM' by 'domain\user'
    DisplayGroup :
    Group :
    Enabled : True
    Profile : Any
    Platform : {}
    Direction : Inbound
    Action : Block
    EdgeTraversalPolicy : Block
    LooseSourceMapping : False
    LocalOnlyMapping : False
    Owner :
    PrimaryStatus : OK
    Status : The rule was parsed successfully from the store. (65536)
    EnforcementStatus : NotApplicable
    PolicyStoreSource : PersistentStore
    PolicyStoreSourceType : Local
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 10 October 2021
  v0.2 - 31 December 2021 - Added Profile parameter
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateSet('ad','ae','af','ag','ai','al','am','ao','ap','aq','ar','as','at','au','aw','ax','az','ba','bb','bd','be','bf','bg','bh','bi','bj','bl','bm','bn','bo','bq','br','bs','bt','bw','by','bz','ca','cc','cd','cf','cg','ch','ci','ck','cl','cm','cn','co','cr','cu','cv','cw','cy','cz','de','dj','dk','dm','do','dz','ec','ee','eg','er','es','et','eu','fi','fj','fk','fm','fo','fr','ga','gb','gd','ge','gf','gg','gh','gi','gl','gm','gn','gp','gq','gr','gt','gu','gw','gy','hk','hn','hr','ht','hu','id','ie','il','im','in','io','iq','ir','is','it','je','jm','jo','jp','ke','kg','kh','ki','km','kn','kp','kr','kw','ky','kz','la','lb','lc','li','lk','lr','ls','lt','lu','lv','ly','ma','mc','md','me','mf','mg','mh','mk','ml','mm','mn','mo','mp','mq','mr','ms','mt','mu','mv','mw','mx','my','mz','na','nc','ne','nf','ng','ni','nl','no','np','nr','nu','nz','om','pa','pe','pf','pg','ph','pk','pl','pm','pr','ps','pt','pw','py','qa','re','ro','rs','ru','rw','sa','sb','sc','sd','se','sg','si','sk','sl','sm','sn','so','sr','ss','st','sv','sx','sy','sz','tc','td','tg','th','tj','tk','tl','tm','tn','to','tr','tt','tv','tw','tz','ua','ug','um','us','uy','uz','va','vc','ve','vg','vi','vn','vu','wf','ws','ye','yt','za','zm','zw')]
            [Alias('CountryCode')][String[]]$AllowCountry = @('us','gb','ca','dk','fi','fr','de','gr','ie','it','nl','nz','no','pr','se','ch','eg'),
        [Parameter(Mandatory=$false)][ValidateSet('Public','Private','Domain')][String[]]$Profile = @('Public','Private'),
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Block-IPsPerCountry_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin {  
        $WorkFolder = Split-Path -Path $PSCommandPath
        try {        
            $CIDRList = Import-Csv -Path "$WorkFolder\GeoIPList.csv" -EA 1 # As of 10 October 2021
        } catch {
            Write-Log 'Failed to read GeoIPList file',"$WorkFolder\GeoIPList.csv" Magenta,Yellow $LogFile
            break 
        }    
    }

    Process {      
        
        $AllowCountry = $AllowCountry | sort
        Write-Log 'Blocking all IPs except those from the following countires',($AllowCountry -join ', ') Green,Cyan $LogFile
        $BlockIPList = $CIDRList | where Country -NotIn $AllowCountry
        Write-Log ' That''s',('{0:N0}' -f $BlockIPList.Count),'IPv4 CIDR networks' Green,Cyan,Green $LogFile

        # Delete any existing BlockIPsPerCountry firewall rules if any
        Get-NetFirewallRule | where DisplayName -Match BlockIPsPerCountry | Remove-NetFirewallRule -Confirm:$false

        # A rule has a max of 10k IPs/CIDR blocks
        $10kBunldles = [Math]::ceiling($BlockIPList.Count/10000)
        Write-Log ' Setting',$10kBunldles,'Windows firewall rules' Green,Cyan,Green $LogFile
        $Result = 1..$10kBunldles | foreach {
            $RuleName = "BlockIPsPerCountry$(([String]$_).PadLeft(2,'0'))" 
            Write-Log ' Setting',$RuleName,'Windows firewall rule' Green,Cyan,Green $LogFile -NoNewLine
            $First1 = 10000*($_-1)
            $Last1  = if (10000*$_-1 -gt $BlockIPList.Count) { $BlockIPList.Count-1 } else { 10000*$_-1 }
            $Description  = "Rule ($_ of $10kBunldles) to deny access to a list of subnets by Country. "
            $Description += "This rule is set by Block-IPsPerCountry PS function of the AZSBTools PS Module, "
            $Description += "invoked on '$(Get-Date -Format 'dd MMMM yyyy, hh:mm:ss tt')' "
            $Description += "by '$($env:USERDOMAIN)\$($env:USERNAME)'"
            try {
                New-NetFirewallRule -RemoteAddress $BlockIPList[$First1..$Last1].CIDR -Name $RuleName -DisplayName $RuleName -Enabled True -Direction Inbound -Profile $Profile -Action Block -Description $Description -EA 1 
                Write-Log 'done' DarkYellow $LogFile
            } catch {
                Write-Log 'failed' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            }               
        }

    }

    End { $Result }
} 

function Get-PasswordMaxSafeLifeTime {
<#
 .SYNOPSIS
  Function to calculate the maximum safe life time of a given password strength
 
 .DESCRIPTION
  Function to calculate the maximum safe life time of a given password strength
 
 .PARAMETER PasswordLength
  Number between 2 and 256
  Default is 8
 
 .PARAMETER Include
  One or more of the following:
    UpperCase
    LowerCase
    Numbers
    SpecialCharacters
  Default is all 4
  This is used to calculate the PossibleCharacterCount value ONLY if it's not provided.
 
 .PARAMETER AttemptCountPerSecond
  Accepted values are 1 to 9,223,372,036,854,775,807
  Default is 1
 
 .PARAMETER PossibleCharacterCount
  Accepted values are 0 to 94
  If this value is provided, the Include parameter will be ignored.
 
 .EXAMPLE
  Get-PasswordMaxSafeLifeTime
  This should display output like:
    Calculating password maximum safe life time
      Password Length: 8
      Attempts per second: 1
      Possible Character Count: 94
      Possible Password Count: 6,095,689,385,410,820
                            or 6,096 trillions
      Password maximum safe life time: 193,160,740.53 years
      In other words, it will take 193,160,740.53 years to crack a 8 character long password, that uses 94 different possible characters (UpperCase, LowerCase, Numbers, SpecialCharacters)
 
 .EXAMPLE
  Get-PasswordMaxSafeLifeTime -PasswordLength 10 -Include LowerCase,UpperCase,Numbers
  This should display output like:
    Calculating password maximum safe life time
      Password Length: 10
      Attempts per second: 1
      Possible Character Count: 62
      Possible Password Count: 839,299,365,868,340,000
                            or 839,299 trillions
      Password maximum safe life time: 26,595,792,007.89 years
      In other words, it will take 26,595,792,007.89 years to crack a 10 character long password, that uses 62 different possible characters (LowerCase, UpperCase, Numbers)
 
 .EXAMPLE
  Get-PasswordMaxSafeLifeTime -PasswordLength 10 -Include LowerCase,UpperCase,Numbers -AttemptCountPerSecond 32767
  This should display output like:
    Calculating password maximum safe life time
      Password Length: 10
      Attempts per second: 1
      Possible Character Count: 62
      Possible Password Count: 839,299,365,868,340,000
                            or 839,299 trillions
      Password maximum safe life time: 26,595,792,007.89 years
      In other words, it will take 26,595,792,007.89 years to crack a 10 character long password, that uses 62 different possible characters (LowerCase, UpperCase, Numbers)
 
 .EXAMPLE
  Get-PasswordMaxSafeLifeTime -PasswordLength 10 -Include LowerCase,UpperCase,Numbers -AttemptCountPerSecond 2147483647
  This should display output like:
    Calculating password maximum safe life time
      Password Length: 10
      Attempts per second: 2147483647
      Possible Character Count: 62
      Possible Password Count: 839,299,365,868,340,000
                            or 839,299 trillions
      Password maximum safe life time: 12.38 years
      In other words, it will take 12.38 years to crack a 10 character long password, that uses 62 different possible characters (LowerCase, UpperCase, Numbers)
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 13 October 2021
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][ValidateRange(2,256)][Int16]$PasswordLength = 8, 
        [Parameter(Mandatory=$false)][ValidateRange(1,9223372036854775807)][Int64]$AttemptCountPerSecond = 1, 
        [Parameter(Mandatory=$false)][ValidateSet('UpperCase','LowerCase','Numbers','SpecialCharacters')]
            [String[]]$Include = @('UpperCase','LowerCase','Numbers','SpecialCharacters'),
        [Parameter(Mandatory=$false)][ValidateRange(0,94)][Int16]$PossibleCharacterCount
    )

    Begin { }

    Process {

        Write-Log 'Calculating password maximum safe life time' Green
        Write-Log ' Password Length: ',$PasswordLength Green,Cyan
        Write-Log ' Attempts per second: ',('{0:N0}' -f $AttemptCountPerSecond) Green,Cyan
        if (-not $PossibleCharacterCount) {
            $CharCountProvided = $false
            $PossibleCharacterCount = 0
            if ($Include -match 'UpperCase') { $PossibleCharacterCount += 26 }
            if ($Include -match 'LowerCase') { $PossibleCharacterCount += 26 }
            if ($Include -match 'Numbers') { $PossibleCharacterCount += 10 }
            if ($Include -match 'SpecialCharacters') { $PossibleCharacterCount += 32 } # CodeFriendly = -4
        } 
        Write-Log ' Possible Character Count:',$PossibleCharacterCount Green,Cyan
        Write-Host ''
        $PossiblePasswordCount = [Math]::Pow($PossibleCharacterCount,$PasswordLength)  
        Write-Log ' Possible Password Count:',('{0:N0}' -f $PossiblePasswordCount),"($PossibleCharacterCount to the Power of $PasswordLength)" Green,Cyan,Green
        if ($PossiblePasswordCount -ge 100000000000) {
            Write-Log ' or',('{0:N0}' -f ($PossiblePasswordCount/1000000000000)),'trillions'  Green,Cyan,Green
        }
        Write-Host ''
        $SecondsToCrack = $PossiblePasswordCount/$AttemptCountPerSecond
        $YearsToCrack = $SecondsToCrack/(3600*24*365.25)
        if ($YearsToCrack -ge .01) {
            Write-Log ' Password maximum safe life time:',('{0:N2}' -f $YearsToCrack),'years' Green,Cyan,Green
            Write-Log ' In other words, it will take',('{0:N2}' -f $YearsToCrack),'years to crack a',$PasswordLength,'character long password, that uses',$PossibleCharacterCount,'different possible characters' Green,Cyan,Green,Cyan,Green,Cyan,Green -NoNewLine
        } else {
            Write-Log ' Password maximum safe life time:',('{0:N0}' -f $SecondsToCrack),'seconds' Green,Cyan,Green
            Write-Log ' In other words, it will take',('{0:N2}' -f $SecondsToCrack),'seconds to crack a',$PasswordLength,'character long password, that uses',$PossibleCharacterCount,'different possible characters' Green,Cyan,Green,Cyan,Green,Cyan,Green -NoNewLine
        }
        if (-not $CharCountProvided) { Write-Log "($($Include -join ', '))" Cyan }

    }

    End {  }
}

function Report-KerberosTicketEvents {

<#
 .SYNOPSIS
  Function to return information Security EventLog events 4769 and 4770 relating to Kerberos Ticket requests and renewals.
 
.DESCRIPTION
  Function to return information Security EventLog events 4769 and 4770 relating to Kerberos Ticket requests and renewals.
  This is helpful in detecting Kerberoasting attacks.
 
 .PARAMETER Cred
  Optional parameter that contains a PSCredential object that can be obtained via Get-Credential or Get-SBCredential.
  It may be needed to invoke PS remoting sessions against all Domain Controllers in the current domain to gather Security EventLog events 4769 and 4770.
 
 .PARAMETER InThePastXMinutes
  Optional parameter that limits the event collection to the past x minutes.
  It defaults to 3*60 minutes or 3 hours.
 
 .PARAMETER Exclude
  This parameter takes one or more values that represet Kerebros Ticket Encryption Types to be excluded from this reporting.
  Valid Options are:
    DES-CBC-CRC
    DES-CBC-MD4
    DES-CBC-MD5
    DES3-CBC-MD5
    DES3-CDC-SHA1
    dsaWithSHA1-CmsOID
    md5WithRSAEncryption-CmsOID
    sha1WithRSAEncryption-CmsOID
    rc2CBC-EnvOID
    rsaEncryption-EnvOID
    rsaES-OAEP-ENV-OID
    des-ede3-cbc-Env-OID
    des3-cbc-sha1-kd
    AES128-CTS-HMAC-SHA-1
    AES256-CTS-HMAC-SHA-1
    RC4-HMAC
    RC4-HMAC-EXP
    subkey-keymaterial
  Default setting is:
    AES128-CTS-HMAC-SHA-1
    AES256-CTS-HMAC-SHA-1
  Typically, we're interested in tickets encrypted with anything other than AES128 or AES256.
 
 .PARAMETER LogFile
  Optional parameter that contains the name of a text file where this function will log its console output.
  When not provided, it defaults to a file in the current folder.
 
 .EXAMPLE
  Report-KerberosTicketEvents
 
 .EXAMPLE
  $KerberosTicketEventList = Report-KerberosTicketEvents
  $ReportFileName = ".\KerberosTicketEventList-$($thisDomainName)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv"
  $KerberosTicketEventList | Export-csv $ReportFileName -NoTypeInformation
  This example exports the resulting output to CSV file.
 
 .EXAMPLE
  $KerberosTicketEventList = Report-KerberosTicketEvents -InThePastXMinutes 10
  $ReportFileName = ".\KerberosTicketEventList-$($thisDomainName)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').csv"
  $KerberosTicketEventList | Export-csv $ReportFileName -NoTypeInformation
  This example exports on Kerberos Tickets in the last 10 minutes and exports the resulting output to CSV file.
 
 .OUTPUTS
  Progress output is displayed to the console and log file. Records similar to:
    ComputerName : mydc1.mydomain.local
    AccountName : myhost$@mydomain.LOCAL
    AccountDomain : mydomain.LOCAL
    ServiceName : krbtgt
    ServiceId : S-1-5-21-1234567890-1234567890-1234567890-502
    TicketOptions : 0x60810010
    TicketOptionDesc : Forwardable, Forwarded, Renewable, Name-canonicalize, Renewable-ok
    TicketEncTypeHex : 0x12
    TicketEncTypeDesc : AES256-CTS-HMAC-SHA-1
    ClientAddress : 192.123.123.12
    ClientPort : 65515
    FailureCode : 0x0
    FailureDesc :
    LogonGUID : {ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD}
    TransitedServices : -
 
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4769
 
.NOTES
  Function by Sam Boutros
  v0.1 - 25 October 2021
  v0.2 - 28 October 2021 - Capture Get-EventLog errors.
 
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false, HelpMessage='Credential to login to domain controllers and retrieve event log events.')][PSCredential]$Cred,
        [Parameter(Mandatory=$false)][Int32]$InThePastXMinutes = 3*60,
        [Parameter(Mandatory=$false)][ValidateSet('DES-CBC-CRC','DES-CBC-MD4','DES-CBC-MD5','DES3-CBC-MD5','DES3-CDC-SHA1','dsaWithSHA1-CmsOID','md5WithRSAEncryption-CmsOID','sha1WithRSAEncryption-CmsOID','rc2CBC-EnvOID','rsaEncryption-EnvOID','rsaES-OAEP-ENV-OID','des-ede3-cbc-Env-OID','des3-cbc-sha1-kd','AES128-CTS-HMAC-SHA-1','AES256-CTS-HMAC-SHA-1','RC4-HMAC','RC4-HMAC-EXP','subkey-keymaterial')]
            [String[]]$Exclude = @('AES128-CTS-HMAC-SHA-1','AES256-CTS-HMAC-SHA-1'),
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-KerberosTicketEvents$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 

        $StartTime = Get-Date
        if (-not $IsDomainMember) {
            Write-Log 'Report-KerberosTickets Error: This function can only be invoked on a domain joined computer' Magenta $LogFile
            break
        }
        Write-Log 'Starting automation to report on Kerberos Tickets in the',$thisDomainName,'AD domain' Green,Cyan,Green $LogFile
        if ($InThePastXMinutes -le 0) {
            Write-Log 'Bad value',$InThePastXMinutes,'provided for parameter','InThePastXMinutes','over-writing as',30,'minutes' Green,Yellow,Green,Cyan,Green,Cyan,Green $LogFile
            $InThePastXMinutes = 30
        }
    }

    Process {     

        #region Get DC list, check connectivity - Deliverable: $thisDCList

        Write-Host ''
        Write-Log 'Retrieving DC List in the',$thisDomainName,'AD domain..' Green,Cyan,Green $LogFile
        $Duration = Measure-Command {
            try { $DCList = Get-DCList -EA 1 } catch { Write-Log 'Report-KerberosTickets Error: invoking Get-DCList function:',$_.Exception.Message Magenta,Yellow $LogFile; break }
            $ThisDomainDCList = ($DCList | where DomainName -EQ $thisDomainName).DCList.Name | sort
            $thisDCList = foreach ($DC in $ThisDomainDCList) {
                Write-Log ' Checking if DC',($DC).PadRight(35,' '),'is reachable ==>' Green,Cyan,Green $LogFile -NoNewLine
                $Result = Test-SBNetConnection -ComputerName $DC -PortNumber 5985 -TimeoutSec 10 -WA 0
                if ($Result.TcpTestSucceeded) { 
                    $DC 
                    Write-Log 'Yes' DarkYellow $LogFile
                } else {
                    Write-Log 'Unable to reach PS Remoting port 5985' Magenta $LogFile
                }
            }
            if ($thisDCList.Count -lt 1) { Write-Log 'No reachable DCs found !?' Magenta $LogFile; break }
        } 
        Write-Log ' done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'hh:mm:ss' Green,Cyan,Green $Logfile

        #endregion

        #region Collect EventLog events 4769, 4770 - Deliverable: $myEventLogList

        Write-Host ''
        Write-Log 'Connecting to DC''s to collect Security EventLog events 4769, 4770' Green $LogFile -NoNewLine 
        $Duration = Measure-Command {
            $ParamSet = @{ ComputerName = $thisDCList }
            if ($Cred) { $ParamSet += @{ Credential = $Cred }; Write-Log 'using credential',$Cred.UserName Green,Cyan $LogFile } else { Write-Log ' ' }
            Get-PSSession | Remove-PSSession
            try {
                $Session = New-PSSession @ParamSet -EA 1 
                Write-Log ' Done, connected to:',($Session.Computername -join ', ') Gree,Cyan $LogFile
            } catch {
                Write-Log ' Failed',$_.Exception.Message Magenta,Yellow $LogFile
                break
            }
            $EventLogList = Invoke-Command -Session $Session -ScriptBlock {
                try {
                    Get-EventLog -LogName Security -InstanceId 4769,4770 -After (Get-Date).AddMinutes(-$Using:InThePastXMinutes) -EA 1 
                } catch {
                    New-object -TypeName PSObject -Property ([Ordered]@{
                        MachineName = $Env:COMPUTERNAME
                        Error       = $_.Exception.Message
                    })
                }
            }
            if ($EventLogList) {
                Write-Log 'Gathered',('{0:N0}' -f $EventLogList.Count),'events' Green,Cyan,Green $LogFile
                Write-Log 'Updating records' Green $LogFile -NoNewLine
                $myEventLogList = foreach ($Event in $EventLogList) {
                    if ($Event.Error) {
                        Write-Host ' '
                        Write-Log 'Report-KerberosTicketEvents Error:','not getting events from',$Event.MachineName,'Detail:',$Event.Error Magenta,Yellow,Cyan,Yellow,Cyan $LogFile
                    } else {
                        New-object -TypeName PSObject -Property ([Ordered]@{
                            ComputerName      = $Event.MachineName
                            AccountName       = $Event.ReplacementStrings[0]
                            AccountDomain     = $Event.ReplacementStrings[1]
                            ServiceName       = $Event.ReplacementStrings[2]
                            ServiceId         = $Event.ReplacementStrings[3]
                            TicketOptions     = $Event.ReplacementStrings[4]
                            TicketOptionDesc  = (Parse-KerberosTicketOptions $Event.ReplacementStrings[4] -Silent).Name -join ', '
                            TicketEncTypeHex  = $Event.ReplacementStrings[5]
                            TicketEncTypeDesc = (Parse-KTicketEncType $Event.ReplacementStrings[5] -Silent).Name
                            ClientAddress     = $(if ($Event.ReplacementStrings[6] -match ':') { $Event.ReplacementStrings[6] -split ':' | select -Last 1 } else { $Event.ReplacementStrings[6] })
                            ClientPort        = $Event.ReplacementStrings[7]
                            FailureCode       = $Event.ReplacementStrings[8]
                            FailureDesc       = $(if (($Event.ReplacementStrings[8] -as [Int]) -gt 0) { ($KerberosServiceTicketErrorList | where Id -eq ($Event.ReplacementStrings[8] -as [Int])).Name })
                            LogonGUID         = $Event.ReplacementStrings[9]
                            TransitedServices = $Event.ReplacementStrings[10]
                        })
                    }
                } 
                Write-Log 'done' Cyan $LogFile
                if ($Exclude) {
                    Write-Log 'Excluding records with ticket encryption type(s)',($Exclude -join ', ') Green,Cyan $LogFile -NoNewLine
                    $myEventLogList = $myEventLogList | where TicketEncTypeDesc -NotIn $Exclude
                    Write-Log 'done' DarkYellow $LogFile
                }
            } else {
                Write-Log 'No events 4769, 4770 found for the past',$InThePastXMinutes,'minutes' Yellow,Cyan,Yellow $LogFile
                break
            }
            Get-PSSession | Remove-PSSession
        }
        Write-Log ' done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'hh:mm:ss' Green,Cyan,Green $Logfile

        #endregion

    }

    End {    
        
        $Duration = New-TimeSpan -Start $StartTime -End (Get-Date)
        Write-Log 'All done in',"$($Duration.Hours):$($Duration.Minutes):$($Duration.Seconds)",'hh:mm:ss' Green,Cyan,Green $LogFile
        if ($myEventLogList) {
            $myEventLogList
        } else {
            Write-Log 'No events 4769, 4770 found for the past',$InThePastXMinutes,'minutes' Yellow,Cyan,Yellow $LogFile -NoNewLine
            if ($Exclude) { Write-Log 'with ticket encryption type(s)',($Exclude -join ', ') Green,Cyan $LogFile }
        }

    }

}

function Get-WinEventLogMetadata {
<#
 .SYNOPSIS
  Function to return metadata about one or more Windows Event Logs.
 
 .DESCRIPTION
  Function to return metadata about one or more Windows Event Logs.
 
 .PARAMETER EventLogName
  One or more event log names. This is an optional parameter. It defaults to 'Security'.
  For a list of event log names use: Get-EventLogNames
 
 .EXAMPLE
  Get-WinEventLogMetadata -EventLogName Microsoft-Windows-DriverFrameworks-UserMode/Operational,bla,Security
 
 .OUTPUTS
  This cmdlet returns PS objects such as:
    LogName : Security
    LogFilePath : C:\WINDOWS\System32\Winevt\Logs\Security.evtx
    LogTimeSpan : 16.05:51:16.3301888
    LogMode : Circular
    FileSizeMB : 15.1
    MaxSizeMB : 20
    RecordCount : 19768
    CreationTime : 7/11/2020 6:57:46 PM
    IsLogFull : False
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 28 October 2021
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$EventLogName = 'Security',
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-WinEventLogMetadata_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { }

    Process {     
        $EventLogList = Get-EventLogNames 
        foreach ($thisEventLog in $EventLogName ) {
            if ($thisEventLog -in $EventLogList) {
                try {
                    $Newest = (Get-WinEvent -LogName $thisEventLog -MaxEvents 1 -EA 1).TimeCreated
                    Write-Verbose "Newest event time: $Newest"
                    $Oldest = (Get-WinEvent -LogName $thisEventLog -MaxEvents 1 -Oldest -EA 1).TimeCreated
                    Write-Verbose "Oldest event time: $Oldest"
                    $LogTimeSpan = New-TimeSpan -Start $Oldest -End $Newest -EA 1
                    Write-Verbose "LogTimeSpan: $($LogTimeSpan | Out-String)"
                } catch {}

                $EventSession = New-Object System.Diagnostics.Eventing.Reader.EventLogSession
                $LogInfo = $EventSession.GetLogInformation($thisEventLog,1)

                $LogDetail = Get-WinEvent -ListLog $thisEventLog

                New-Object -TypeName PSObject -Property ([Ordered]@{
                    LogName      = $thisEventLog
                    LogFilePath  = $LogDetail.LogFilePath -replace '%SystemRoot%',$env:SystemRoot
                    LogTimeSpan  = "$($LogTimeSpan.Days):$($LogTimeSpan.Hours):$($LogTimeSpan.Minutes) (Days:Hours:Minutes)"
                    # "$('{0:N0}' -f ($LogTimeSpan.Days/365-1)):$('{0:N0}' -f (($LogTimeSpan.Days%365)/30-1)):$(($LogTimeSpan.Days%365) % 30):$($LogTimeSpan.Hours):$($LogTimeSpan.Minutes) (Years:Months:Days:Hours:Minutes)"
                    LogMode      = $LogDetail.LogMode
                    FileSizeMB   = [Math]::Round($LogDetail.FileSize/1MB,1)
                    MaxSizeMB    = [Math]::Round($LogDetail.MaximumSizeInBytes/1MB,1)
                    RecordCount  = $LogDetail.RecordCount
                    CreationTime = $LogInfo.CreationTime
                    IsLogFull    = $LogDetail.IsLogFull
                })
            } else {
                Write-Log 'Get-WinEventLogMetadata Error:','bad EventLogName provided:',$thisEventLog Magenta,Yellow,Cyan $LogFile
            }
        }
    }

    End {  }
} 

function Get-SBFileHash {
<#
 .SYNOPSIS
  Function to return a file hash or HMAC signature.
 
 .DESCRIPTION
  Function to return a file hash or HMAC signature.
  Like the Get-FilHash cmdlet of the Microsoft.PowerShell.Utility module, this function returns a file hash when the HMACSecret is NOT provided.
  Unlike the Get-FilHash cmdlet, this function returns the HMAC signature of the provided file using the provided HMAC secret.
 
 .PARAMETER Path
  Path to the file to be hashed.
 
 .PARAMETER Algorithm
  The algorithm to be used for hashing. If not provided, it defaults to MD5.
  Valid options are:
    MACTripleDES
    MD5
    RIPEMD160
    SHA1
    SHA256
    SHA384
    SHA512
 
 .PARAMETER HMACSecret
  When provided, this function returns the Base64 encoded HMAC signature of the provided file using the provided HMAC secret.
  In this case, acceptable algorithms are SHA1, SHA256, SHA384, and SHA512.
  HMACSecret is displayed on the console but not saved to log file.
  When not provided, this function returns a file hash.
 
 .PARAMETER LogFile
  Path to a file where this function will write its console output.
 
 .EXAMPLE
  Get-SBFileHash -Path .\DFEv1-Module01.pdf | FT -a
 
 .EXAMPLE
  Get-SBFileHash -Path .\DFEv1-Module01.pdf -HMACSecret 'My secret passphrase here:)'
 
 .OUTPUTS
    Algorithm Hash Path
    --------- ---- ----
    MD5 A092F0F3DBB9151DD54D66D4604F831B C:\Sandbox\DFEv1-Module01.pdf
    or
    Algorithm HMACSigBase64 Path
    --------- ------------- ----
    HMACSHA256 juLRK0JDL+xCl/RIsk7P9x0SiHdy0OVuGFhviEUlyBM= C:\Sandbox\DFEv1-Module01.pdf
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 1 Feb 2022
 
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$true)][ValidateScript({Test-Path $_})][String]$Path,
        [Parameter(Mandatory=$false)][ValidateSet('MACTripleDES','MD5','RIPEMD160','SHA1','SHA256','SHA384','SHA512')][String]$Algorithm = 'MD5',
        [Parameter(Mandatory=$false)][String]$HMACSecret,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-SBFileHash_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { }

    Process {
        if ($HMACSecret) {
            $FullName = (Get-Item $Path).FullName
            $HMACAlgorithm = Switch ($Algorithm) {
                'SHA1'   { 'HMACSHA1' }
                'SHA384' { 'HMACSHA384' }
                'SHA512' { 'HMACSHA512' }
                default  { 'HMACSHA256' }
            }
            $HMACSHA = New-Object System.Security.Cryptography.$HMACAlgorithm
            $HMACSHA.key = [Text.Encoding]::ASCII.GetBytes($HMACSecret)
            $HMACSignature = $HMACSHA.ComputeHash([Text.Encoding]::ASCII.GetBytes((Get-Content $Path -Raw)))
            $Base64 = [Convert]::ToBase64String($HMACSignature)

            Write-Host 'HMAC secret provided ' -ForegroundColor Green -NoNewline
            Write-Host $HMACSecret -ForegroundColor Cyan
            Write-Log 'Using HMAC algorithm ',$HMACAlgorithm Green,Cyan $LogFile
            Write-Log 'On file ',$FullName Green,Cyan $LogFile
            Write-Log 'HMAC Signature Bytes ',$HMACSignature Green,Cyan $LogFile   
            Write-Log 'HMAC Signature Base64',$Base64 Green,Cyan $LogFile   

            New-Object -TypeName PSObject -Property ([Ordered]@{
                Algorithm     = $HMACAlgorithm
                HMACSigBase64 = $Base64
                Path          = $FullName
            })         
        } else {
            try {
                Get-FileHash -Path $Path -Algorithm $Algorithm -EA 1 
            } catch {
                Write-Log $_.Exception.Message Yellow $LogFile
            }
        }

    }

    End { }
}

function Invoke-VTAPI {
<#
 .SYNOPSIS
  Function to query the Shodan API
 
 .DESCRIPTION
  Function to query the Shodan API
  It requires a Shodan API key - See https://developer.shodan.io/
  Enterprise subscription level methods have not been implemented.
  shodan/query method optional parameters page, sort, and order have not been implemented.
  This function asks the user for API key and saves it securely to disk.
 
  To be implemented: Search, On-Demand Scanning, Network Alerts, and Notifiers methods.
 
 .PARAMETER Method
  Currently implemented methods are:
    'GetFile'
 
 .PARAMETER FileHash
  SHA-256, SHA-1 or MD5 identifying the file.
 
 .PARAMETER NewAPIKey
  Switch Parameter. When set to $True, the user is prompted to enter a new API key.
  A free account can be obtained at https://www.virustotal.com/gui/join-us
 
 .PARAMETER LogFile
  Path to a file where this function will save time-stamped entries similar to its console output.
 
 .EXAMPLE
  Invoke-VTAPI -FileHash 'c37ae9efc4eefcf1fe9cefa69a9e51f4'
 
 .OUTPUTS
  This cmdlet displays console output and returns a PS object similar to:
    FileName : myfile.exe, infected.pdf, x5.bin, 367547f151358c3ff872bda0017ed0871842b946c7b61da5e4d91f48176a617d.pdf
    FileType : PDF
    FileSizeKB : 6.61
    MD5 : c37ae9efc4eefcf1fe9cefa69a9e51f4
    SHA1 : 1d917b8bb8794064d5d8fbf917bd7342dd84a343
    SHA256 : 367547f151358c3ff872bda0017ed0871842b946c7b61da5e4d91f48176a617d
    VHASH : 93c0204e302f909f89d7993940b8d9778
    SSDEEP : 192:ZCt+rgfWPZ8kZNyI2q284BrCPjKa1hu/7ko5U19ceD:ZCtCgmW+4cCrCma1huTfW19ceD
    TLSH : T168D17B29C25438DDF4510AD523AC3EA89997B12B96FD98DE72F1DF054026F4C4823679
    Magic : PDF document, version 1.5
    TrID : Adobe Portable Document Format (100.0%)
    Tags : cve-2008-2992,runtime-modules,detect-debug-environment,exploit,direct-cpu-clock-access,checks-user-input,pdf,js-embedded,autoaction
    Detection : {@{Result=BehavesLike.PDF.Trojan.xb; EngineName=McAfee-GW-Edition; EngineVersion=v2019.1.2+3728; Category=malicious; Method=blacklist; EngineUpdate=20211129}, @{Result=Bloodhound.Exploit.213; EngineName=Symantec; EngineVersion=1.16.0.0;
                 Category=malicious; Method=blacklist; EngineUpdate=20211129}, @{Result=Exploit.JS.Pdfka.cil; EngineName=Kaspersky; EngineVersion=21.0.1.45; Category=malicious; Method=blacklist; EngineUpdate=20211129}, @{Result=Exploit.PDF-Name.Gen;
                 EngineName=Ad-Aware; EngineVersion=3.0.21.193; Category=malicious; Method=blacklist; EngineUpdate=20211129}...}
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 1 Feb 2022
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true,HelpMessage='SHA-256, SHA-1 or MD5 identifying the file')][String]$FileHash,                 
        [Parameter(Mandatory=$false)][ValidateSet(
            'GetFile'
        )][String]$Method = 'GetFile',
        [Parameter(Mandatory=$false)][Switch]$NewAPIKey,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Invoke-VTAPI_$Method_$(Get-Date -Format 'ddMMMMyyyy_hh-mm-ss_tt').log"
    )

    Begin { 

        $VTAPIKey = if ($NewAPIKey) {
            Get-SBCredential -UserName 'VTAPIKey' -Refresh
        } else {
            Get-SBCredential -UserName 'VTAPIKey' 
        }
        if (-not $VTAPIKey) {
            Write-Log '''Virus Total'' API key not provided (Get a free account at https://www.virustotal.com/gui/join-us), stopping..' Magenta $LogFile
            break
        } 

    }

    Process {  
    
        $VTHeader = @{ 'x-apikey' = $VTAPIKey.GetNetworkCredential().Password }
        switch ($Method) {
            'GetFile' { $Uri = "$VTBaseURL/files/$FileHash" }
            default   { }
        }        
            
        try {
            $Result = Invoke-WebRequest -UseBasicParsing -Uri $Uri -Headers $VTHeader -Method Get -EA 1 
            $VTRaw = ($Result.Content | ConvertFrom-Json).Data.attributes
            Write-Verbose ($VTRaw | Out-String).Trim()
            $Detection = foreach ($FoundItem in ($VTRaw.last_analysis_results | Get-Member -MemberType NoteProperty).Name) {
                if ($VTRaw.last_analysis_results.$FoundItem.result -and $VTRaw.last_analysis_results.$FoundItem.result -ne 'undetected') {
                    New-Object -TypeName PsObject -Property ([Ordered]@{
                        Result        = $VTRaw.last_analysis_results.$FoundItem.result
                        EngineName    = $VTRaw.last_analysis_results.$FoundItem.engine_Name
                        EngineVersion = $VTRaw.last_analysis_results.$FoundItem.engine_version
                        Category      = $VTRaw.last_analysis_results.$FoundItem.category
                        Method        = $VTRaw.last_analysis_results.$FoundItem.method
                        EngineUpdate  = $VTRaw.last_analysis_results.$FoundItem.engine_update
                    })      
                }          
            }            
            $Detection = $Detection | sort Result 
            $FileDetails = New-Object -TypeName PsObject -Property ([Ordered]@{
                FileName   = $VTRaw.names -join ', '
                FileType   = $VTRaw.type_description
                FileSizeKB = '{0:N2}' -f ($VTRaw.size/1KB)
                MD5        = $VTRaw.md5
                SHA1       = $VTRaw.sha1
                SHA256     = $VTRaw.sha256
                VHASH      = $VTRaw.vhash
                SSDEEP     = $VTRaw.ssdeep
                TLSH       = $VTRaw.tlsh
                Magic      = $VTRaw.magic
                TrID       = "$($VTRaw.trid[0].file_type) ($($VTRaw.trid[0].probability)%)"
                Tags       = $VTRaw.tags -join ','
            })
            Write-Log 'File details:' Green $LogFile
            Write-Log ($FileDetails | Out-String).Trim() Cyan $LogFile
            ' '
            Write-Log 'Detection:' Green $LogFile
            Write-Log ($Detection | FT -a | Out-String).Trim() Cyan $LogFile
            $FileDetails | Add-Member -MemberType NoteProperty -Name Detection -Value $Detection
        } catch {
            Write-Log $_.Exception.Message Magenta $LogFile
            Write-Log $_.ErrorDetails.Message Yellow $LogFile
        }
    }

    End { $FileDetails }
} 

function Decode-String {
<#
 .SYNOPSIS
  Function to decode hexadecimal codes used in URLs and web server logs
 
 .DESCRIPTION
  Function to decode hexadecimal codes used in URLs and web server logs
 
 .PARAMETER KeepPlus
  An optional switch. When set to $true, this function does not replace '+' signs with spaces.
  In Java script '+' is replaced with a space.
 
 .PARAMETER Silent
  An optional switch. when set to $true, this function does not display any console messages.
 
 .PARAMETER EncodedString
  An encoded string like '%3CSCRIPT%3Evar+x+%3D+String(%2FXSS%2F)%3Bx+%3D+x.substring(1%2C+x.length-1)%3Balert(x)%3C%2FSCRIPT%3E'
 
 .EXAMPLE
  Decode-String -EncodedString '%3CSCRIPT%3Evar+x+%3D+String(%2FXSS%2F)%3Bx+%3D+x.substring(1%2C+x.length-1)%3Balert(x)%3C%2FSCRIPT%3E'
 
 .EXAMPLE
  Decode-String -EncodedString '%3CSCRIPT%3Evar+x+%3D+String(%2FXSS%2F)%3Bx+%3D+x.substring(1%2C+x.length-1)%3Balert(x)%3C%2FSCRIPT%3E' -KeepPlus
 
 .OUTPUTS
  This cmdlet returns the decoded string like '<SCRIPT>var x = String(/XSS/);x = x.substring(1, x.length-1);alert(x)</SCRIPT>'
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 24 March 2022
  v0.2 - 25 March 2022 - Added -Silent switch
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$KeepPlus,
        [Parameter(Mandatory=$false)][Switch]$Silent,
        [Parameter(Mandatory=$false)][String]$EncodedString = '%3CSCRIPT%3Evar+x+%3D+String(%2FXSS%2F)%3Bx+%3D+x.substring(1%2C+x.length-1)%3Balert(x)%3C%2FSCRIPT%3E'
    )

    Begin {  }

    Process {   
        
        if (-not $Silent) { Write-Log 'Received Encoded String:',$EncodedString Green,Cyan }
        $i = 0; $FoundList = $EncodedString.ToCharArray() | foreach { if ($_ -eq '%') { $i }; $i++ }
        $ReplaceSet = foreach ($FoundX in $FoundList) { $EncodedString.Substring($FoundX,3) }
        $ReplaceSet = $ReplaceSet | select -Unique | sort
        $DecodedString = $EncodedString
        foreach ($ReplaceMe in $ReplaceSet) {
            $DecodedString = $DecodedString -replace $ReplaceMe,[char][uint32]"0x$($ReplaceMe.Substring(1,2))"
        }  
        if (-not $KeepPlus) { $DecodedString = $DecodedString -replace '\+',' ' }
        if (-not $Silent) { Write-Log ' Decoded String:',$DecodedString Green,Cyan } 
        $DecodedString
    }

    End {  }
} 

function Get-StringCharPattern {
<#
 .SYNOPSIS
  Function to return the character pattern of a given string.
 
 .DESCRIPTION
  Function to return the character pattern of a given string.
 
 .PARAMETER inString
  This is the input string.
 
 .EXAMPLE
  Get-StringCharPattern System.Net.IPAddress
           This returns ULLLLLSULLSUUULLLLLL
 
 .OUTPUTS
  This cmdlet returns a string where every character in the input is replaced with either N, U, L, or S
  Where N = Number
        U = Upper Case Character
        L = Lower Case Character
        S = Special Character (anything that's not N, U, or L)
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 8 Apr 2023
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String]$inString
    )

    Begin {  }

    Process {      
        $CharPattern = foreach($Char in $inString.ToCharArray()) { 
            switch ($Char) {
                {[Int]$_ -in $ASCIINumber} {'N'} # Number
                {[Int]$_ -in $ASCIIUpper}  {'U'} # Upper Case Letter
                {[Int]$_ -in $ASCIILower}  {'L'} # Lower Case Letter
                default                    {'S'} # Special Character
            }
        }
    }

    End { $CharPattern -join '' }
} 

function Check-HIBPPassword {
<#
 .SYNOPSIS
  Function to check a provided password against the HIBP database.
 
 .DESCRIPTION
  Function to check a provided password against the HIBP database.
  For more information see https://haveibeenpwned.com/
  This function may require a HIBP API key.
  This function use the k-anonymity algorithm. Passwords are not sent to the HIBP API to check if they have been observed, instread the forst 5 characters of the SHA1 hash of the password is sent to HIBP, obfuscating the actual password during network trasmission.
  For more information on k-anoymity see https://blog.cloudflare.com/validating-leaked-passwords-with-k-anonymity/
 
 .PARAMETER PasswordToCheck
  One or more password(s) to be checked.
   
 .PARAMETER RefreshAPIKey
  Optional switch that allows the function operator to enter a different API key, if one is already seaved to disk.
   
 .PARAMETER ShowPassword
  Optional switch. When set to True, the function will return the password entered in plain text as part of the return PS object.
  See examples for more details.
   
 .PARAMETER WorkFolder
  Optional path that defaults to a 'Sandbox' directory on the root of the system drive, such as c:\Sandbox.
  This is where this function where save API keys and log files.
  Note: API keys are encrypted on disk and can only be descrypted by the user who entered/saved them.
   
 .PARAMETER LogFile
  Optional path to a log file where this function will save its console output.
  Passwords are obfuscated in both console output and log files, showing only the first three letters of a given password.
  For example ldkflkdfh shows as ldk******
   
 .EXAMPLE
  Check-HIBPPassword -PasswordToCheck 'qwerty','myverysafepassword'
  This will return console output similar to:
    The password qwe*** has been observed in prior HIBP leaks 10,584,568 time(s)
    The password myv*************** has NOT been observed in prior HIBP leaks
  In addition to a PS object with properties values like:
    Obfuscated FoundCount
    ---------- ----------
    qwe*** 10584568
    myv*************** 0
 
 .EXAMPLE
  $Result = Check-HIBPPassword -PasswordToCheck 'my123456','myverysafepassword' -ShowPassword
  This will return console output similar to:
    The password my1***** has been observed in prior HIBP leaks 9,400 time(s)
    The password myv*************** has NOT been observed in prior HIBP leaks
  In addition, the $Result variable will have PS object(s) with properties/values like:
    Obfuscated FoundCount Password
    ---------- ---------- --------
    my1***** 9400 my123456
    myv*************** 0 myverysafepassword
 
 .OUTPUTS
  This cmdlet returns PS object(s) such as:
    Obfuscated FoundCount Password
    ---------- ---------- --------
    my1***** 9400 my123456
    myv*************** 0 myverysafepassword
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://haveibeenpwned.com/API/v3
  https://blog.cloudflare.com/validating-leaked-passwords-with-k-anonymity/
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 6 May 2023
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$true)][String[]]$PasswordToCheck,
        [Parameter(Mandatory=$false)][Switch]$ShowPassword,
        [Parameter(Mandatory=$false)][Switch]$RefreshAPIKey,
        [Parameter(Mandatory=$false)][String]$WorkFolder = "$env:SystemDrive\Sandbox",
        [Parameter(Mandatory=$false)][String]$LogFile = "$WorkFolder\Check-HIBPPassword_$(Get-Date -Format 'ddMMMyyyy-HH-mm').log"
    )

    Begin { 
        $null = New-Item -Path "$WorkFolder\KeyChain" -ItemType Directory -EA 0 
        $APIKey = if ($RefreshAPIKey) {
            Get-SBCredential -UserName 'Have I been Pawned API Key' -CredPath "$WorkFolder\KeyChain" -Refresh       
        } else {
            Get-SBCredential -UserName 'Have I been Pawned API Key' -CredPath "$WorkFolder\KeyChain"       
        }
    }

    Process {      
        $Headers = @{
            'User-Agent'   = 'Mozilla/5.0'
            'hibp-api-key' = $APIKey.GetNetworkCredential().Password
        }
        Write-Verbose "Headers: $($Headers | Out-String)"
        $FinalOutput = foreach ($String in $PasswordToCheck) {
            $objPass = New-Object -TypeName psobject -Property ([Ordered]@{
                Obfuscated = $String.Substring(0,3) + '*' * ($String.Length - 3)
                FoundCount = 0
            })
            if ($ShowPassword) { $objPass | Add-Member -MemberType NoteProperty -Name Password -Value $String }
            $Hash = Get-StringHash -String $String -Algorithm SHA1
            Write-Verbose "Hash: $Hash"
            $Response = Invoke-WebRequest -Uri "https://api.pwnedpasswords.com/range/$($Hash.Substring(0,5))" -Headers $Headers
            # Write-Verbose "Response: $Response"
            if ($Response.StatusCode -eq 200) {
                Write-Verbose "API response: $(($Response.Content -split "`n").Count) responses like $($Response.Content -split "`n" | select -First 1)"
                $FoundHash = $Response.Content -split "`n" | foreach { if (($Hash.Substring(0,5) + ($_ -split ':')[0]) -eq $Hash) { $_ } }
                if ($FoundHash) {
                    Write-Verbose "FoundHash: $FoundHash"
                    $objPass.FoundCount = [Int]($FoundHash -split ':')[1]
                    Write-Log 'The password',$objPass.Obfuscated,'has been observed in prior HIBP leaks',('{0:N0}' -f $objPass.FoundCount),'time(s)' Green,Yellow,Cyan,Yellow,Green $LogFile
                } else {
                    Write-Log 'The password',$objPass.Obfuscated,'has NOT been observed in prior HIBP leaks' Green,Cyan,Green $LogFile
                }
            } else {
                Write-Log 'Error:' Magenta $LogFile
                Write-Log ($Response | FL * | Out-String).Trim() Yellow $LogFile
            }
            $objPass
        }
    }

    End { $FinalOutput }
} 

function Report-ADS {
<#
 .SYNOPSIS
  Function to report on one or more folders, identifying any files with Alternate Data Streams
 
 .DESCRIPTION
  Function to report on one or more folders, identifying any files with Alternate Data Streams
  Please note that Windows Internet Explorer uses the stream name Zone.Identifier for storage of URL security zones.
  For example, {[ZoneTransfer], ZoneId=3} in the Zone.Identifier stream indicates that the file came from the Internet.
  More information on URL security zones can be found at https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms537183(v=vs.85)
 
 .PARAMETER FolderPath
  One or more folders. This defaults to the current folder.
 
 .EXAMPLE
  Report-ADS
 
 .OUTPUTS
  This cmdlet displays console output and returns PS objects like:
    FileName StreamName StreamLength StreamContent
    -------- ---------- ------------ -------------
    C:\Sandbox\testfile.zip NewStream 46 Added a stream named NewStream
  If the stream length is larger than 999, it's written to a file in the current folder with format like:
    C:\Sandbox\testfile.zip_NewStream_16May2023-12-13
       
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms537183(v=vs.85)
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 12 April 2020
    Known limitations:
    Files with full names longer than 260 characters (https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry).
    Reparse files (https://learn.microsoft.com/en-us/windows/win32/fileio/reparse-points).
#>


    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][String[]]$FolderPath = (Get-Location).Path,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Report-ADS_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMyyyy-HH-mm').log"
    )

    Begin {  }

    Process {      
        foreach ($Folder in $FolderPath) {
            try {
                $FolderInfo = Get-Item $Folder -EA 1 
                $myOutput = if ($FolderInfo -is [System.IO.DirectoryInfo]){
                    $FileList = Get-ChildItem -Path $FolderInfo.FullName -Recurse -Force -EA 0 
                    if ($FileList) {
                        Write-Log 'Reporting on Alternate Data Streams for',('{0:N0}' -f $FileList.Count),'files under the',$FolderInfo.FullName,'folder' Green,Cyan,Green,Cyan,Green $LogFile
                        $ADSList = foreach ($File in $FileList) { try { Get-Item -Path $File.FullName -Stream * -EA 1 | where Stream -NE ':$DATA' } catch {} }
                        Write-Log ' Identified alternate data stream(s)..' Yellow $LogFile
                        if ($ADSList) {
                            foreach ($FoundADS in $ADSList) {
                                New-Object -TypeName psobject -Property ([Ordered]@{
                                    FileName      = $FoundADS.FileName 
                                    StreamName    = $FoundADS.Stream
                                    StreamLength  = $FoundADS.Length
                                    StreamContent = $(
                                        if ($FoundADS.Length -le 999) {
                                            Get-Content -Path $FoundADS.FileName -Stream $FoundADS.Stream
                                        } else {
                                            $OutputStreamFile = "$($FoundADS.FileName)_$($FoundADS.Stream)_$(Get-Date -Format 'ddMMMyyyy-HH-mm')"
                                            try {
                                                Get-Content -Path $FoundADS.FileName -Stream $FoundADS.Stream -EA 1 | Out-File $OutputStreamFile -Force
                                                "Saved to $OutputStreamFile"
                                            } catch {
                                                "Failed to save to $OutputStreamFile - $($_.Exception.Message)"
                                            }
                                        }
                                    )
                                })
                                
                            }
                        } else {
                            Write-Log ' No alternate data streams found in files visible under this folder' Green $LogFile
                        }
                    } else {
                        Write-Log 'No files found under',$FolderInfo.FullName Yellow,Cyan $LogFile
                    }
                } else {
                    Write-Log 'Error:',$Folder,'is not a folder' Magenta,Yellow,Magenta $LogFile
                }
            } catch {
                Write-Log 'Error:',$_.Exception.Message Magenta,Yellow $LogFile
            }
        }
    }

    End { $myOutput }
} 

function Get-ShareList {

<#
 .SYNOPSIS
  Function to return list of shares on a given target server.
  
.DESCRIPTION
  Function to return list of shares on a given target server.
  This function depends on the DNSClient and Microsoft.PowerShell.Security PowerShell modules and the Net View command.
  This function uses Net View instead of the native PowerShell cmdlets like:
    $CimSession = New-CimSession -ComputerName $ServerName
    $ShareList = Get-SmbShare -CimSession $CimSession
  Or:
    Get-WmiObject -class Win32_Share -Computer $ServerName
  because these cmdlets would only work on Windows servers that are serving SMB shares.
  Native cmdlets would not work if PowerShell Remoting port(s) 5985 and/or 5986 are blocked or not configured.
  Net View on the other hand works on 'NetBIOS session service' TCP port 139 and works for both Windows and Linux hosts like SAN/NAS devices.
  
.PARAMETER
  When this switch parameter is et to True, the function adds the share permission details to the output.
  
.PARAMETER ServerName
  The name of the target server, such as 'Server1' or 'Server1.domain.com'.
  This parameter will only be used if no ServerIP is provided or a bad ServerIP is provided.
  
.PARAMETER ServerIP
  IPv4 of the target server formatted like 11.22.33.44
  If a valid IP address is provided, the ServerName parameter will be ignored.
  
.PARAMETER LogFile
  Path to a file where this script will save time-stamped entries of its console output for unattended execution.
  
.EXAMPLE
    $ShareList = Get-ShareList -ServerIP '11.22.33.44' -ShowPermissions
  
.EXAMPLE
    $ShareList = Get-ShareList -ServerName 'Server1'
  
.EXAMPLE
    $ShareList = Get-ShareList -ServerName 'Server1.domain.com'
  
.OUTPUTS
  This function returns one or more PowerShell objects with the following properties/example:
    ServerName : server1
    ServerIP : 11.22.33.44
    ShareName : F$
    ShareType : Disk
    ShareComment : Default share
  When the ShowPermissions switch is used, this function returns one or more PowerShell objects with the following properties/example:
    ServerName : server1
    ServerIP : 11.22.33.44
    ShareName : MyFiles
    ShareType : Disk
    ShareComment : My Comments
    PermissionPrincipal : BUILTIN\Administrators
    PermissionType : Allow
    PermissionRights : FullControl
    PermissionIsInherited : False
    PermissionInheritanceFlags : ContainerInherit, ObjectInherit
    PermissionPropagationFlags : None
  
.LINK
    https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/hh875576(v=ws.11)
    https://learn.microsoft.com/en-us/powershell/module/dnsclient/?view=windowsserver2022-ps
    https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/?view=powershell-7.4
    https://superwidgets.wordpress.com/category/powershell/
  
.NOTES
  Function by Sam Boutros
  v0.1 - 23 May 2024
  v0.2 - 23 May 2024 - replaced 'break' statements with a $Go variable to allow processing multiple servers via external ForEach loop.
#>

 
    [CmdletBinding(ConfirmImpact='Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$ShowPermissions,
        [Parameter(Mandatory=$false)][String]$ServerName,
        [Parameter(Mandatory=$false)][String]$ServerIP,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Get-ShareList-$(Get-Date -f 'ddMMMyyyy-HH-mm').log"
    )
 
    Begin { 
        
        $Go = $true
        Write-Verbose "ServerName: '$ServerName'"
        Write-Verbose "ServerIP: '$ServerIP'"
        Write-Verbose "IPAddress: '$($IPAddress.IPAddressToString)'"
        Write-Verbose "LogFile: '$LogFile'"
 
        if ($ServerName) {
            try {
                $IPList = Resolve-DnsName -Name $ServerName -Type A -EA 1
                if ($IPList) {
                    Write-Log 'Identified server',$ServerName,'IP(s):',($IPList.IPAddress -join ', ') Green,Cyan,Green,Cyan $LogFile
                } else {
                    Write-Log 'Unable to resolve server name',$ServerName,'to any IPv4 address' Magenta,Yellow,Magenta $LogFile
                }
            } catch {
                Write-Log 'Unable to resolve server name',$ServerName Magenta,Yellow $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            }
        }
 
        if ($ServerIP) {
            if ($IPAddress = $ServerIP -as [IPAddress]) {
                Write-Log 'Validated provided server IP address',$IPAddress.IPAddressToString Green,Cyan $LogFile
            } else {
                Write-Log 'Invalid ServerIP provided',$ServerIP Magenta,Yellow $LogFile
            }
        }        
 
        if (-not $IPAddress -and $IPList) {
            $IPAddress = $IPList.IPAddress | select -First 1
        }
 
        Write-Verbose "ServerName: '$ServerName'"
        Write-Verbose "ServerIP: '$ServerIP'"
        Write-Verbose "IPAddress: '$($IPAddress.IPAddressToString)'"
 
        if ($IPAddress -and -not $ServerName) {
            try {
                $ServerName = (Resolve-DnsName -Name $IPAddress -EA 1).NameHost
                Write-Log ' Resolved IPAddress',$IPAddress,'to ServerName:',$ServerName Green,Cyan,Green,Cyan $LogFile
            } catch {
                Write-Log 'Unable to resolve ServerName',$ServerName Magenta,Yellow $LogFile
            }
        }
       
        Write-Verbose "ServerName: '$ServerName'"
        Write-Verbose "ServerIP: '$ServerIP'"
        Write-Verbose "IPAddress: '$($IPAddress.IPAddressToString)'"
 
        if ($IPAddress) {
            Write-Log ' Using IP address:',$IPAddress Green,Cyan $LogFile
            # Validate needed 'NetBIOS session service' TCP port 139
            $Result = Test-SBNetConnection -ComputerName $IPAddress -WA 0 -PortNumber 139
            if ($Result.TcpTestSucceeded) {
                Write-Log ' Validated ''NetBIOS session service'' TCP port 139' Green,Cyan $LogFile
            } else {
                Write-Log 'Not reaching ''NetBIOS session service'' TCP port 139',$ServerName Magenta,Yellow $LogFile
                $Go = $false
            }       
        } else {
            Write-Log 'No valid IPv4 address or ServerName provided' Magenta,Yellow $LogFile
            $Go = $false
        }
    }
 
    Process {     
 
        if ($Go) {
            $NetViewResult = Net View $IPAddress /All 2>&1 # Redirecting Error Pipeline (2) to STIO for error handling
 
            if ($NetViewResult -match 'There are no entries in the list') {
                Write-Log 'No shares found' Yellow $LogFile
            } elseif ($NetViewResult -match 'System error') {
                Write-Log 'Net View Error:',$NetViewResult.Exception.Message Magenta,Yellow,Yellow $LogFile
            } else {
                # Parse the NetView string into a PS object
                if ($ShowPermissions) { Write-Log ' Adding permissions details' Green,Cyan $LogFile }
                $DaCount = 0
                foreach ($Line in $NetViewResult[7..($NetViewResult.Count-3)]) {
                    $DaCount++
                    $PartList = $Line -split ' ' | where { $_ }
                    # Add Permissions
                    if ($ShowPermissions) {
                        try {
                            $ACLList = (Get-Acl \\$ServerName\$($PartList[0]) -EA 1).Access
                            foreach ($ACL in $ACLList) {
                                New-Object -TypeName PSObject -Property ([Ordered]@{
                                    ServerName   = $ServerName
                                    ServerIP     = $IPAddress
                                    ShareName    = $PartList[0]
                                    ShareType    = $PartList[1]
                                    ShareComment = $(if ($PartList.Count -gt 2) { $PartList[2..($PartList.Count-1)] -join ' ' })
                                    PermissionPrincipal        = $ACL.IdentityReference
                                    PermissionType             = $ACL.AccessControlType
                                    PermissionRights           = $ACL.FileSystemRights
                                    PermissionIsInherited      = $ACL.IsInherited
                                    PermissionInheritanceFlags = $ACL.InheritanceFlags
                                    PermissionPropagationFlags = $ACL.PropagationFlags
                                })
                            }
                        } catch {
                            Write-Log 'Unable to get permissions for',\\$ServerName\$($PartList[0]),'Get-ACL error:',$_.Exception.Message Magenta,Yellow,Magenta,Yellow $LogFile
                            New-Object -TypeName PSObject -Property ([Ordered]@{
                                ServerName   = $ServerName
                                ServerIP     = $IPAddress
                                ShareName    = $PartList[0]
                                ShareType    = $PartList[1]
                                ShareComment = $(if ($PartList.Count -gt 2) { $PartList[2..($PartList.Count-1)] -join ' ' })
                            }) 
                        }
                    } else {
                        New-Object -TypeName PSObject -Property ([Ordered]@{
                            ServerName   = $ServerName
                            ServerIP     = $IPAddress
                            ShareName    = $PartList[0]
                            ShareType    = $PartList[1]
                            ShareComment = $(if ($PartList.Count -gt 2) { $PartList[2..($PartList.Count-1)] -join ' ' })
                        })
                    }
                }
                       
                Write-Log ' Share Count:',$DaCount Green,Cyan $LogFile
            }
        }
 
    }
 
    End {  }
}

#endregion

#region Python

function Install-Python {

<#
 .Synopsis
  Function to install python.
 
 .Description
  Function to install python.
  This function installs version 3.10.8 by default.
   
 .PARAMETER Version
  Optional python version - like '3.10.8'
 
 .PARAMETER URL
  Optinoal download URL - if not provided, the script will build a download URL based on version like
  "https://www.python.org/ftp/python/$Version/python-$Version.exe"
 
 .PARAMETER Silent
  If set to $true, and Python is already installed, the script will only return the Python version like 'Python 3.10.8'
 
 .PARAMETER LogFile
  Path to a file where this function will log its console output
 
 .Example
  Install-Python
 
 .OUTPUTS
  The Python version like 'Python 3.10.8'
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
 
 .NOTES
  Function by Sam Boutros
    v0.1 - 18 October 2022
#>


    [CmdletBinding(ConfirmImpact='Low')] 
    Param(
        [Parameter(Mandatory=$false)][String]$Version = '3.10.8',
        [Parameter(Mandatory=$false)][String]$URL, 
        [Parameter(Mandatory=$false)][Switch]$Silent,
        [Parameter(Mandatory=$false)][String]$LogFile = ".\Install-Python_$($env:COMPUTERNAME)_$(Get-Date -Format 'ddMMMyyyy_HH-mm').log"
    )

    Begin {  }

    Process {    
        
        try {
            $PyVersion = python --version
            if (-not $Silent) { Write-Log 'Python version',$PyVersion,'is already installed.' Green,Cyan,Green $LogFile }
            $PyVersion
        } catch {
            if ($_.Exception.Message -match 'The term ''python'' is not recognized') {

                Write-Log 'python not found, installing..' Green $LogFile
                [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
                if (-not $URL) { $URL = "https://www.python.org/ftp/python/$Version/python-$Version.exe" }
                $TempFile = New-TemporaryFile
                $PyExe = Join-Path -Path (Split-Path $TempFile.FullName) -ChildPath "python-$Version.exe"
                Remove-Item -Path $PyExe -Force -EA 0 
                Write-Log 'Downloading from',$URL Green,Cyan $LogFile -NoNewLine
                try {
                    Invoke-WebRequest -Uri $URL -OutFile $PyExe -EA 1 
                    $File = Get-Item -Path $PyExe
                    Write-Log 'downloaded to',$PyExe,"($('{0:N0}' -f $File.Length)) bytes" DarkYellow,Green,Cyan $LogFile
                    Write-Host ' '
                    Write-Log 'Installing',"$PyExe /quiet InstallAllUsers=0 PrependPath=1 Include_test=0" Green,Cyan $LogFile
                    try {
                        Invoke-Expression -Command "$PyExe /quiet InstallAllUsers=0 PrependPath=1 Include_test=0" 
                        while (Test-FileLock -Path $PyExe) { Start-Sleep -Seconds 1 } 
                        # Default dir is "$Env:USERPROFILE\AppData\Local\Programs\Python\Python310-32"
                        if ($Found = Get-ChildItem -Path "$Env:USERPROFILE\AppData\Local\Programs\Python" -Filter 'python.exe' -Recurse) {
                            $Exe = ($Found | select -First 1).FullName
                            $VersionNow = & $Exe --version 
                            Write-Log 'Installed at',$Exe,'version',$VersionNow Green,Cyan,Green,Cyan $LogFile
                        } else {
                            Write-Log 'failed' Magenta $LogFile
                        }
                    } catch {
                        Write-Log 'Failed' Magenta $LogFile
                        Write-Log $_.Exception.Message Yellow $LogFile
                    } 
                } catch {
                    Write-Log 'Failed to download python EXE from',$URL Magenta,Yellow $LogFile
                    Write-Log $_.Exception.Message Yellow $LogFile
                }
            } else {
                Write-Log '''python --version'' error:' Magenta $LogFile
                Write-Log $_.Exception.Message Yellow $LogFile
            }
        } 
           
    } 

    End {  }
}

#endregion

#region Coptic

function Get-CorrespondingCopticDate {
<#
 .SYNOPSIS
  Function to get the corresponding Coptic date of a given Gregorian date
 
 .DESCRIPTION
  Function to get the corresponding Coptic date of a given Gregorian date
    The Coptic Calendar is used in Egypt, and the Coptic Church.
    Coptic is a word that means Egyptian.
    Calendar type: Solar
    Number of days:
        Common year: 365
        Leap year: 366
    Number of months: 13
    Correction mechanism: Leap day
    Year 1 = 284 CE
    Coptic years are counted from 284 AD, the year Diocletian became Roman Emperor, whose reign was
    marked by tortures and mass executions of Christians, especially in Egypt. Hence, the Coptic year
    is identified by the abbreviation A.M. (for Anno Martyrum or "Year of the Martyrs"). The first
    day of the year I of the Coptic era was 29 August 284 in the Julian calendar.
    Months in the Coptic Calendar:
    Months (Coptic / Arabic) Number of Days
    Tute توت Tūt 30
    Paopi بابه Bābah 30
    Hathor هاتور Hātūr 30
    Koiak كيهك Kiyāhk 30
    Tobi طوبه Ṭūbah 30
    Meshir أمشير ʾAmshīr 30
    Paremhat برمهات Baramhāt 30
    Parmouti برموده Baramūdah 30
    Pashons بشنس Bashans 30
    Paoni بؤنة Baʾūnah 30
    Epip أبيب ʾAbīb 30
    Mesori مسرى Misrā 30
    Nasei نسيئ Nasīʾ 5 (or 6 in a leap year)
   
 .PARAMETER Silent
  Optional parameter. When set to True, this function supresses its console output.
   
 .PARAMETER Date
  Optional parameter that defaults to current date.
  Expected input is a valid date such as 1/13/2022
   
 .EXAMPLE
    Get-CorrespondingCopticDate
    This will return the Coptic date corresponding to today such as:
    Day Month Year
    --- ----- ----
    19 3 1739
    It will also display console output like:
    Monday 28 November 2022 corresponds to 19 Hatour 1739
 
 .EXAMPLE
    Get-CorrespondingCopticDate -Date 1/31/1121 -Verbose
    This will return the Coptic date corresponding to the provided "Date":
    Day Month Year
    --- ----- ----
     29 5 837
    It will also display console output like:
    Monday 31 January 1121 corresponds to 28 Tubah 837
    VERBOSE: Date Input: Monday 31 January 1121
    VERBOSE: Counting Day 0 as 28 August 284 AD, the year Diocletian became Roman Emperor, whose reign was marked by tortures and mass executions of Christians, especially in Egypt.
    VERBOSE: AllDays: 305,497 (Since day zero - 8/28/284)
    VERBOSE: CountofDays: 148 (Day # 148 of the Coptic year # 837)
 
 .EXAMPLE
    Get-CorrespondingCopticDate -Date 9/10/2022 -Verbose
    This will return the Coptic date corresponding to the provided "Date":
    Day Month Year
    --- ----- ----
      5 13 1738
    Saturday 10 September 2022 corresponds to 5 Nase' 1738
    VERBOSE: Date Input: Saturday 10 September 2022
    VERBOSE: Counting Day 0 as 28 August 284 AD, the year Diocletian became Roman Emperor, whose reign was marked by tortures and mass executions of Christians, especially in Egypt.
    VERBOSE: AllDays: 634,804 (Since day zero - 8/28/284)
    VERBOSE: CountofDays: 365 (Day # 365 of the Coptic year # 1738)
    VERBOSE: CopticLeap: False
 
 .EXAMPLE
    Get-CorrespondingCopticDate -Date 9/11/2022 -Verbose
    This will return the Coptic date corresponding to the provided "Date":
    Day Month Year
    --- ----- ----
      1 1 1739
    Sunday 11 September 2022 corresponds to 1 Tute 1739
    VERBOSE: Date Input: Sunday 11 September 2022
    VERBOSE: Counting Day 0 as 28 August 284 AD, the year Diocletian became Roman Emperor, whose reign was marked by tortures and mass executions of Christians, especially in Egypt.
    VERBOSE: AllDays: 634,805 (Since day zero - 8/28/284)
    VERBOSE: CountofDays: 1 (Day # 1 of the Coptic year # 1739)
    VERBOSE: CopticLeap: False
 
 .EXAMPLE
    Get-CorrespondingCopticDate -Date 9/11/2023 -Verbose
    This will return the Coptic date corresponding to the provided "Date":
    Day Month Year
    --- ----- ----
      6 13 1739
    Monday 11 September 2023 corresponds to 6 Nase' 1739
    VERBOSE: Date Input: Monday 11 September 2023
    VERBOSE: Counting Day 0 as 28 August 284 AD, the year Diocletian became Roman Emperor, whose reign was marked by tortures and mass executions of Christians, especially in Egypt.
    VERBOSE: AllDays: 635,170 (Since day zero - 8/28/284)
    VERBOSE: CountofDays: 366 (Day # 366 of the Coptic year # 1739)
    VERBOSE: CopticLeap: True
 
 .EXAMPLE
    Get-CorrespondingCopticDate -Date 9/12/2023 -Verbose
    This will return the Coptic date corresponding to the provided "Date":
    Day Month Year
    --- ----- ----
      1 1 1740
    Tuesday 12 September 2023 corresponds to 1 Tute 1740
    VERBOSE: Date Input: Tuesday 12 September 2023
    VERBOSE: Counting Day 0 as 28 August 284 AD, the year Diocletian became Roman Emperor, whose reign was marked by tortures and mass executions of Christians, especially in Egypt.
    VERBOSE: AllDays: 635,171 (Since day zero - 8/28/284)
    VERBOSE: CountofDays: 1 (Day # 1 of the Coptic year # 1740)
    VERBOSE: CopticLeap: True
 
 .OUTPUTS
  This cmdlet returns a PS object that has the following 3 properties/example:
    Day Month Year
    --- ----- ----
     19 3 1739
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://www.timeanddate.com/calendar/coptic-calendar.html
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 28 Nov 2022
 
#>


    [CmdletBinding(ConfirmImpact = 'Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$Silent,
        [Parameter(Mandatory=$false)][String]$Date = (Get-Date -Format 'MM/dd/yyyy')
    )

    Begin { 
        Import-Module AZSBTools -WA 0 -Verbose:$false # Load needed variables
        try {
            $Date = [DateTime]$Date
            [DateTime]$Date = Get-Date($Date) -Format 'MM/dd/yyyy'
        } catch {
            Write-Log 'The provided date',$Date,'is invalid' Magenta,Yellow,Magenta
            Write-Log 'Please provide a date in a format like','1/13/2022' Yellow,Cyan
            break
        }
        $Day0 = Get-Date('8/28/284') # This corresponds to 0/0/0 on the Coptic/Martyrs Calendar.
        $AllDays = [Math]::Ceiling((New-TimeSpan -Start $Day0 -End $Date).TotalDays)
        if ($AllDays -lt 0) {
            Write-Log 'The provided date',(Get-Date($Date) -Format 'dddd dd MMMM yyyy'),'is invalid' Magenta,Yellow,Magenta
            Write-Log 'Because it preceeds the beginning of the Coptic/Martyrs Calendar on',(Get-Date('8/28/284') -Format 'dddd dd MMMM yyyy') Yellow,Cyan
            break
        } 
    }

    Process {
        $CountofDays = [Math]::Ceiling($AllDays % 365.25)
        $CopticYear  = [Math]::Ceiling($AllDays / 365.25)
        $CopticLeap  = if (($CopticYear+1) % 4 -eq 0) { $true } else { $false }
        <# if ($CopticLeap) {
            if ($CountofDays -eq 1) {
                $CountofDays += 365
                $CopticYear -= 1
            } else {
                $CountofDays -= 1
            }
        }#>

        $CopticMonth = $CopticMonthList | where Order -EQ ([Math]::Ceiling($CountofDays / 30))
        $CopticDay   = [Math]::Ceiling($CountofDays % 30)
        if ($CopticDay -eq 0) { $CopticDay = 30 }
        if (-not $Silent) { 
            Write-Log (Get-Date($Date) -Format 'dddd').PadRight(9),(Get-Date($Date) -Format 'dd MMMM yyyy').PadRight(17),
                'corresponds to',$CopticDay.ToString().PadLeft(2,'0'),"$($CopticMonth.Name) $CopticYear" Green,Cyan,Green,Yellow,Yellow 
        }
        Write-Verbose "Date Input: $(Get-Date($Date) -Format 'dddd dd MMMM yyyy')"
        Write-Verbose 'Counting Day 0 as 28 August 284 AD, the year Diocletian became Roman Emperor, whose reign was marked by tortures and mass executions of Christians, especially in Egypt.'
        Write-Verbose "AllDays: $('{0:N0}' -f $AllDays) (Since day zero - 8/28/284)"
        Write-Verbose "CountofDays: $CountofDays (Day # $CountofDays of the Coptic year # $CopticYear)"
        Write-Verbose "CopticLeap: $CopticLeap"
    }

    End { 
        New-Object -TypeName PSObject -Property([Ordered]@{
            Day   = $CopticDay 
            Month = $CopticMonth.Order
            Year  = $CopticYear 
        })
    }
} 

function Get-CopticMonthlyRemembranceDays {
<#
 .SYNOPSIS
  Function to get the nth day of the Coptic month
 
 .DESCRIPTION
  Function to get the nth day of the Coptic month for the next 12 months (by default)
    For example, on the 12th day of every Coptic month, the Coptic (Egyptian) Orthodox Church celebtates the
    commemoration of Archangel Michael. This function will identify those dates for the next 12 months.
   
 .PARAMETER Day
  Optional parameter that defaults to 12.
  This should be a day between 1 and 30.
   
 .PARAMETER StartDate
  Optional parameter that defaults to today.
   
 .PARAMETER EndDate
  Optional parameter that defaults to 12 months after today.
   
 .EXAMPLE
  Get-CopticMonthlyRemembranceDays
  This will return console output like:
    The 12 day of the Coptic month between Monday 28 November 2022 and Tuesday 28 November 2023 is/are:
    Tuesday 20 December 2022 12 Keyahk 1739
    Thursday 19 January 2023 12 Tubah 1739
    Saturday 18 February 2023 12 Amshir 1739
    Monday 20 March 2023 12 Baramhat 1739
    Wednesday 19 April 2023 12 Baramouda 1739
    Friday 19 May 2023 12 Bashans 1739
    Sunday 18 June 2023 12 Ba'ouna 1739
    Tuesday 18 July 2023 12 Abeeb 1739
    Thursday 17 August 2023 12 Mesrah 1739
    Friday 22 September 2023 12 Tute 1740
    Sunday 22 October 2023 12 Baaba 1740
    Tuesday 21 November 2023 12 Hatour 1740
  And a PS object with properties/example like:
    GregorianDate CopticDate
    ------------- ----------
    12/20/2022 4:56:02 PM @{Day=12; Month=4; Year=1739}
    1/19/2023 4:56:02 PM @{Day=12; Month=5; Year=1739}
    2/18/2023 4:56:02 PM @{Day=12; Month=6; Year=1739}
    3/20/2023 4:56:02 PM @{Day=12; Month=7; Year=1739}
    4/19/2023 4:56:02 PM @{Day=12; Month=8; Year=1739}
    5/19/2023 4:56:02 PM @{Day=12; Month=9; Year=1739}
    6/18/2023 4:56:02 PM @{Day=12; Month=10; Year=1739}
    7/18/2023 4:56:02 PM @{Day=12; Month=11; Year=1739}
    8/17/2023 4:56:02 PM @{Day=12; Month=12; Year=1739}
    9/22/2023 4:56:03 PM @{Day=12; Month=1; Year=1740}
    10/22/2023 4:56:03 PM @{Day=12; Month=2; Year=1740}
    11/21/2023 4:56:03 PM @{Day=12; Month=3; Year=1740}
 
 .EXAMPLE
  Get-CopticMonthlyRemembranceDays -Day 21
  The 21st of every Coptic month is the commemoration of the Virgin St. Mary, the Theotokos.
  This will return console output like:
    The 21 day of the Coptic month between Monday 07 August 2023 and Wednesday 07 August 2024 is/are:
    Sunday 27 August 2023 21 Mesrah 1739
    Monday 02 October 2023 21 Tute 1740
    Wednesday 01 November 2023 21 Baaba 1740
    Friday 01 December 2023 21 Hatour 1740
    Sunday 31 December 2023 21 Keyahk 1740
    Tuesday 30 January 2024 21 Tubah 1740
    Thursday 29 February 2024 21 Amshir 1740
    Saturday 30 March 2024 21 Baramhat 1740
    Monday 29 April 2024 21 Baramouda 1740
    Wednesday 29 May 2024 21 Bashans 1740
    Friday 28 June 2024 21 Ba'ouna 1740
    Sunday 28 July 2024 21 Abeeb 1740
 
    GregorianDate CopticDate
    ------------- ----------
    8/27/2023 7:33:45 AM @{Day=21; Month=12; Year=1739}
    10/2/2023 7:33:45 AM @{Day=21; Month=1; Year=1740}
    11/1/2023 7:33:46 AM @{Day=21; Month=2; Year=1740}
    12/1/2023 7:33:46 AM @{Day=21; Month=3; Year=1740}
    12/31/2023 7:33:46 AM @{Day=21; Month=4; Year=1740}
    1/30/2024 7:33:46 AM @{Day=21; Month=5; Year=1740}
    2/29/2024 7:33:46 AM @{Day=21; Month=6; Year=1740}
    3/30/2024 7:33:46 AM @{Day=21; Month=7; Year=1740}
    4/29/2024 7:33:46 AM @{Day=21; Month=8; Year=1740}
    5/29/2024 7:33:46 AM @{Day=21; Month=9; Year=1740}
    6/28/2024 7:33:46 AM @{Day=21; Month=10; Year=1740}
    7/28/2024 7:33:46 AM @{Day=21; Month=11; Year=1740}
 
 .EXAMPLE
  Get-CopticMonthlyRemembranceDays -EndDate 12/31/2023
  This will return console output like:
    The 12 day of the Coptic month between Monday 07 August 2023 and Sunday 31 December 2023 is/are:
    Friday 18 August 2023 12 Mesrah 1739
    Saturday 23 September 2023 12 Tute 1740
    Monday 23 October 2023 12 Baaba 1740
    Wednesday 22 November 2023 12 Hatour 1740
    Friday 22 December 2023 12 Keyahk 1740
 
    GregorianDate CopticDate
    ------------- ----------
    8/18/2023 7:34:13 AM @{Day=12; Month=12; Year=1739}
    9/23/2023 7:34:13 AM @{Day=12; Month=1; Year=1740}
    10/23/2023 7:34:13 AM @{Day=12; Month=2; Year=1740}
    11/22/2023 7:34:13 AM @{Day=12; Month=3; Year=1740}
    12/22/2023 7:34:13 AM @{Day=12; Month=4; Year=1740}
 
 .OUTPUTS
  This function returns PS objects with properties/example like:
    GregorianDate CopticDate
    ------------- ----------
    12/20/2022 4:56:02 PM @{Day=12; Month=4; Year=1739}
    1/19/2023 4:56:02 PM @{Day=12; Month=5; Year=1739}
    2/18/2023 4:56:02 PM @{Day=12; Month=6; Year=1739}
    3/20/2023 4:56:02 PM @{Day=12; Month=7; Year=1739}
    4/19/2023 4:56:02 PM @{Day=12; Month=8; Year=1739}
    5/19/2023 4:56:02 PM @{Day=12; Month=9; Year=1739}
    6/18/2023 4:56:02 PM @{Day=12; Month=10; Year=1739}
    7/18/2023 4:56:02 PM @{Day=12; Month=11; Year=1739}
    8/17/2023 4:56:02 PM @{Day=12; Month=12; Year=1739}
    9/22/2023 4:56:03 PM @{Day=12; Month=1; Year=1740}
    10/22/2023 4:56:03 PM @{Day=12; Month=2; Year=1740}
    11/21/2023 4:56:03 PM @{Day=12; Month=3; Year=1740}
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://www.copticchurch.net/synaxarium/3_12.html
  https://www.copticchurch.net/synaxarium/3_21.html
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 28 Nov 2022
 
#>


    [CmdletBinding(ConfirmImpact = 'Low')]
    Param(
        [Parameter(Mandatory=$false)][ValidateRange(1,30)][Int]$Day = 12,
        [Parameter(Mandatory=$false)][String]$StartDate = (Get-Date),
        [Parameter(Mandatory=$false)][String]$EndDate = (Get-Date).AddMonths(12)
    )

    Begin { 
        Import-Module AZSBTools -WA 0 -Verbose:$false # Load needed variables
        try {
            $StartDate = [DateTime]$StartDate
        } catch {
            Write-Log 'The provided StartDate',$StartDate,'is invalid' Magenta,Yellow,Magenta
            Write-Log 'Please provide a StartDate in a format like','1/13/2022' Yellow,Cyan
            break
        }
        try {
            $EndDate = [DateTime]$EndDate
        } catch {
            Write-Log 'The provided EndDate',$EndDate,'is invalid' Magenta,Yellow,Magenta
            Write-Log 'Please provide a EndDate in a format like','1/13/2023' Yellow,Cyan
            break
        }
        $AllDays = [Math]::Ceiling((New-TimeSpan -Start $StartDate -End $EndDate).TotalDays)
        if ($AllDays -le 0) {
            Write-Log 'The provided EndDate',(Get-Date($EndDate) -Format 'dddd dd MMMM yyyy'),'must be after the provided StartDate',(Get-Date($StartDate) -Format 'dddd dd MMMM yyyy') Magenta,Yellow,Magenta,Yellow
            break
        }     
    }

    Process {
        $DateList = foreach ($OneDay in (0..$AllDays)) {
            $Date = (Get-Date).AddDays($OneDay)
            $CopticDate = Get-CorrespondingCopticDate -Date $Date -Silent
            if ($CopticDate.Day -eq $Day) {
                New-Object -TypeName PSObject -Property ([Ordered]@{
                    GregorianDate = $Date
                    CopticDate    = $CopticDate
                })
            }
        }
        Write-Log 'The',$Day,'day of the Coptic month between',(Get-Date($StartDate) -Format 'dddd dd MMMM yyyy'),
            'and',(Get-Date($EndDate) -Format 'dddd dd MMMM yyyy'),'is/are:' Green,Cyan,Green,Cyan,Green,Cyan,Green
        foreach ($Date in $DateList) {
            Write-Log ([String](Get-Date($Date.GregorianDate) -Format 'dddd')).PadRight(10),
                ([String](Get-Date($Date.GregorianDate) -Format 'dd MMMM yyyy')).PadRight(18),
                "$($Date.CopticDate.Day) $(($CopticMonthList | where Order -eq $Date.CopticDate.Month).Name) $($Date.CopticDate.Year)" Green,Cyan,Yellow
        }
    }

    End { $DateList }
} 

function Get-CorrespondingGregorianDate {
<#
 .SYNOPSIS
  Function to get the corresponding Gregorian date of a given Coptic date
 
 .DESCRIPTION
  Function to get the corresponding Gregorian date of a given Coptic date
    The Coptic Calendar is used in Egypt, and the Coptic Church.
    Coptic is a word that means Egyptian.
    Calendar type: Solar
    Number of days:
        Common year: 365
        Leap year: 366
    Number of months: 13
    Correction mechanism: Leap day
    Year 1 = 284 CE
    Coptic years are counted from 284 AD, the year Diocletian became Roman Emperor, whose reign was
    marked by tortures and mass executions of Christians, especially in Egypt. Hence, the Coptic year
    is identified by the abbreviation A.M. (for Anno Martyrum or "Year of the Martyrs"). The first
    day of the year I of the Coptic era was 29 August 284 in the Julian calendar.
    Months in the Coptic Calendar:
    Months (Coptic / Arabic) Number of Days
    Tute توت Tūt 30
    Paopi بابه Bābah 30
    Hathor هاتور Hātūr 30
    Koiak كيهك Kiyāhk 30
    Tobi طوبه Ṭūbah 30
    Meshir أمشير ʾAmshīr 30
    Paremhat برمهات Baramhāt 30
    Parmouti برموده Baramūdah 30
    Pashons بشنس Bashans 30
    Paoni بؤنة Baʾūnah 30
    Epip أبيب ʾAbīb 30
    Mesori مسرى Misrā 30
    Nasei نسيئ Nasīʾ 5 (or 6 in a leap year)
   
 .PARAMETER CopticDate
  Optional parameter that defaults to '12/26/1739' for 26 Mesrah 1739.
   
 .EXAMPLE
    Get-CorrespondingGregorianDate -CopticDate 1/1/1739
    This will return the Gregorian date corresponding to 1/1/1739 such as:
    Sunday, September 11, 2022 10:45:16 PM
 
 .OUTPUTS
  This cmdlet returns a DateTime Object.
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://www.timeanddate.com/calendar/coptic-calendar.html
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 1 Sep 2023
 
#>


    [CmdletBinding(ConfirmImpact = 'Low')]
    Param(
        [Parameter(Mandatory=$false)][String]$CopticDate = '12/26/1739'
    )

    Begin { 
        Import-Module AZSBTools -WA 0 -Verbose:$false # Load needed variables
        $Parts = $CopticDate -split '/'
        try {
            $Month = [Int]$Parts[0]
        } catch {
            Write-Log 'Error: bad CopticDate',$CopticDate,'expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        }
        try {
            $Day = [Int]$Parts[1]
        } catch {
            Write-Log 'Error: bad CopticDate',$CopticDate,'expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        }
        try {
            $Year = [Int]$Parts[2]
        } catch {
            Write-Log 'Error: bad CopticDate',$CopticDate,'expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        }
        Write-Verbose "Month: $Month, Day: $Day, Year: $Year"
        if ($Parts.Count -ne 3) {
            Write-Log 'Error: bad CopticDate',$CopticDate,'expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        }
        if ($Month -gt 13 -or $Month -le 0) {
            Write-Log 'Error: bad CopticDate',$CopticDate,'The month value must be between 1 and 13. Expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        }
        if ($Month -eq 13 -and ($Day -gt 6 -or $Day -le 0)) {
            Write-Log 'Error: bad CopticDate',$CopticDate,'The 13th small month can have a maximum of 6 days. Expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        }
        if ($Day -gt 30 -or $Day -le 0) {
            Write-Log 'Error: bad CopticDate',$CopticDate,'The days value must be between 1 and 30. Expecting a date formatted like','12/26/1739','for','26 Mesrah 1739' Magenta,Cyan,Yellow,Cyan,Yellow,Cyan 
            break
        } 
    }

    Process {
        $AllDays = [Math]::Ceiling(($Year-1)*365.25) + ($Month-1)*30 + $Day - 1 
        if ($Year-1 % 4 -eq 0) { $AllDays +=1 }
        Write-Verbose "Al Days: $AllDays"
        $Day0 = '8/28/284' # This corresponds to 0/0/0 on the Coptic/Martyrs Calendar.
        # $PoD = New-TimeSpan -Start (Get-Date)
    }

    End { 
        (Get-Date($Day0)).AddDays($AllDays) # .AddHours($PoD.Hours).AddMinutes($PoD.Minutes).AddSeconds($PoD.Seconds)
    }
} 

function Get-Abokty {
<#
 .SYNOPSIS
  Function to get the Christian holiday dates for any given year such as Easter.
 
 .DESCRIPTION
  Function to get the Christian holiday dates for any given year such as Easter.
  Abokty is a Coptic word that means the remainder.
  The cycle (Kiklos) repeats every 532 Years.
  Based on the book (in Arabic language): "The Baramosi Masterpiece for the rules of Abokty calculations of the Coptic Orthodox Church",
  Shams Press, Cairo, 1925, by Hegumen AbdElMassieh Salib ElMasoudi ElBaramosi.
     
 .PARAMETER Silent
  Optional parameter. When set to True, this function supresses its console output.
   
 .PARAMETER CopticYear
  Optional parameter that defaults to current year.
   
 .EXAMPLE
    $Holi1 = Get-Abokty
  This example returns console output like:
    For Coptic/Martyrs year 1739 - the Gregorian/Julian years 2022/2023
    The Solar Cycle is 27 and the Solar Abokty is 5
    The Lunar Cycle is 9 and the Lunar Abokty is 9
    Name Description Gregorian Day Month CopticYear DayOfWeek Major
    ---- ----------- --------- --- ----- ---------- --------- -----
    Nayrouz Coptic New Year 09/11/2022 1 Tute 1739 Sunday False
    Nativity Christmas - Birth of Jesus Christ 01/07/2023 29 Keyahk 1739 Saturday True
    Circumcision Circumcision of Lord Jesus Christ 01/14/2023 6 Tubah 1739 Saturday False
    Epiphany Baptism of Jesus in the Jordan river by John the Baptist 01/19/2023 11 Tubah 1739 Thursday True
    Wedding of Cana of Galilee Jesus turning water into wine at the wedding of Cana of Galilee 01/21/2023 13 Tubah 1739 Saturday False
    Entrance into the Temple Entrance into the Temple of Lord Jesus Christ 02/15/2023 8 Amshir 1739 Wednesday False
    Annunciation ArcAngel Gabriel announces to Mary that she will birth Jesus Christ 04/07/2023 29 Baramhat 1739 Friday True
    Palm Sunday Entry of Lord Jesus Christ into Jerusalem (Hosanna Sunday) 04/09/2023 1 Baramouda 1739 Sunday True
    Covenant Thursday The day before Good Friday 04/13/2023 5 Baramouda 1739 Thursday False
    Easter Resurrection of Lord Jesus Christ 04/16/2023 8 Baramouda 1739 Sunday True
    Thomas Sunday Thomas Sunday 04/23/2023 15 Baramouda 1739 Sunday False
    Ascension Ascension of Lord Jesus Christ 05/25/2023 17 Bashans 1739 Sunday True
    Entrance into Egypt Entrance into Egypt of Lord Jesus Christ 06/01/2023 24 Bashans 1739 Thursday False
    Pentecost The Holy Spirit descending onto the Apostles 06/04/2023 27 Bashans 1739 Sunday True
    Transfiguration Transfiguration of Jesus Christ on mount Tabour 08/19/2023 13 Mesrah 1739 Saturday False
 
 .OUTPUTS
  This cmdlet returns PS objects that has the following properties/example:
    Name : Pentecost
    Description : The Holy Spirit descending onto the Apostles
    Gregorian : 06/04/2023
    Day : 27
    Month : Bashans
    CopticYear : 1739
    DayOfWeek : Sunday
    Major : True
 
 .LINK
  https://superwidgets.wordpress.com/category/powershell/
  https://www.timeanddate.com/calendar/coptic-calendar.html
 
 .NOTES
  Function by Sam Boutros
  v0.1 - 7 Aug 2023
  v0.2 - 1 Sep 2023 - Updated Major and Minor holidays.
 
#>


    [CmdletBinding(ConfirmImpact = 'Low')]
    Param(
        [Parameter(Mandatory=$false)][Switch]$Silent,
        [Parameter(Mandatory=$false)][Int]$CopticYear = (Get-CorrespondingCopticDate -Date (Get-Date) -Silent).Year
    )

    Begin { 
        Import-Module AZSBTools -WA 0 -Verbose:$false # Load needed variables
        if ($CopticYear -gt 9999 -or $CopticYear -lt 1) {
            Write-Log 'The provided year',$CopticYear,'is invalid' Magenta,Yellow,Magenta
            Write-Log 'Please provide a year in the range','1:9999' Yellow,Cyan
            break
        }
    }

    Process {
        if (-not $Silent) { Write-Log 'For Coptic/Martyrs year',$CopticYear,'- the Gregorian/Julian years',"$($CopticYear + 283)/$($CopticYear + 284)" Green,Cyan,Green,Cyan }
        $SolarCycle = ($CopticYear - 4) % 28    
        if ($SolarCycle -eq 0) { $SolarCycle = 28 }                                                 
        $SolarAbokty = [Math]::Floor($SolarCycle * 1.25) % 7                              
        if (-not $Silent) { Write-Log 'The Solar Cycle is',([String]$SolarCycle).PadRight(2),'and the Solar Abokty is',$SolarAbokty Green,Cyan,Green,Cyan,Green,Cyan }
        $LunarCycle = ($CopticYear - 1) % 19                                                  
        $LunarAbokty = ($LunarCycle * 11) % 30                                        
        if (-not $Silent) { Write-Log 'The Lunar Cycle is',([String]$LunarCycle).PadRight(2),'and the Lunar Abokty is',$LunarAbokty Green,Cyan,Green,Cyan,Green,Cyan }

        $CurrentSolarCycle = $AboktySolarCycle | where Cycle -EQ $SolarCycle
        $CurrentLunarCycle = $AboktyLunarCycle | where Cycle -EQ $LunarCycle
        $PalmSunday = (($CopticMonthList | where Name -EQ $CurrentLunarCycle.LambMonth).Order -1) * 30 +  $CurrentLunarCycle.LambDay

        $HolidayList = @()

        # Nayrouz
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Nayrouz'
            Description = 'Coptic New Year'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Tute').Order)/1/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 1 
            Month       = 'Tute'
            CopticYear  = $CopticYear
            DayOfWeek   = $CurrentSolarCycle.Nayrouz
            Major       = $false
        })

        # Christmas
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Nativity'
            Description = 'Christmas - Birth of Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Keyahk').Order)/29/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 29 
            Month       = 'Keyahk'
            CopticYear  = $CopticYear
            DayOfWeek   = $CurrentSolarCycle.NativityDay
            Major       = $true
        })

        # Circumcision
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Circumcision'
            Description = 'Circumcision of Lord Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Tubah').Order)/6/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 6 
            Month       = 'Tubah'
            CopticYear  = $CopticYear
            DayOfWeek   = (Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Tubah').Order)/6/$CopticYear").DayOfWeek
            Major       = $false
        })

        # Epiphany
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Epiphany'
            Description = 'Baptism of Jesus in the Jordan river by John the Baptist'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Tubah').Order)/11/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 11 
            Month       = 'Tubah'
            CopticYear  = $CopticYear
            DayOfWeek   = $CurrentSolarCycle.Epiphany
            Major       = $true
        })

        # Wedding of Cana of Galilee
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Wedding of Cana of Galilee'
            Description = 'Jesus turning water into wine at the wedding of Cana of Galilee'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Tubah').Order)/13/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 13
            Month       = 'Tubah'
            CopticYear  = $CopticYear
            DayOfWeek   = (Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Tubah').Order)/13/$CopticYear").DayOfWeek 
            Major       = $false
        })

        # Entrance into the Temple
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Entrance into the Temple'
            Description = 'Entrance into the Temple of Lord Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Amshir').Order)/8/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 8 
            Month       = 'Amshir'
            CopticYear  = $CopticYear
            DayOfWeek   = (Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Amshir').Order)/8/$CopticYear").DayOfWeek
            Major       = $false
        })

        # Annunciation
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Annunciation'
            Description = 'ArcAngel Gabriel announces to Mary that she will birth Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Baramhat').Order)/29/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 29 
            Month       = 'Baramhat'
            CopticYear  = $CopticYear
            DayOfWeek   = $CurrentSolarCycle.Annunciation 
            Major       = $true
        })

        # Entrance into Egypt
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Entrance into Egypt'
            Description = 'Entrance into Egypt of Lord Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Bashans').Order)/24/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 24 
            Month       = 'Bashans'
            CopticYear  = $CopticYear
            DayOfWeek   = (Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Bashans').Order)/24/$CopticYear").DayOfWeek
            Major       = $false
        })

        # Transfiguration
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Transfiguration'
            Description = 'Transfiguration of Jesus Christ on mount Tabour'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Mesrah').Order)/13/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = 13
            Month       = 'Mesrah'
            CopticYear  = $CopticYear
            DayOfWeek   = (Get-CorrespondingGregorianDate -CopticDate "$(($CopticMonthList | where Name -EQ 'Mesrah').Order)/13/$CopticYear").DayOfWeek
            Major       = $false
        })

        # Palm Sunday
        $Day   = $PalmSunday % 30
        $Month = [Math]::Floor($PalmSunday / 30)
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Palm Sunday'
            Description = 'Entry of Lord Jesus Christ into Jerusalem (Hosanna Sunday)'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$($Month+1)/$Day/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = $Day
            Month       = ($CopticMonthList[$Month]).Name
            CopticYear  = $CopticYear
            DayOfWeek   = 'Sunday'
            Major       = $true
        })

        # Covenant Thursday
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Covenant Thursday'
            Description = 'The day before Good Friday'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$([Math]::Floor($PalmSunday / 30) + 1)/$(($PalmSunday % 30) + 4)/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = ($PalmSunday + 4) % 30
            Month       = ($CopticMonthList[[Math]::Floor($PalmSunday / 30)]).Name
            CopticYear  = $CopticYear
            DayOfWeek   = 'Thursday'
            Major       = $false
        })

        # Easter
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Easter'
            Description = 'Resurrection of Lord Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$([Math]::Floor($PalmSunday / 30) + 1)/$(($PalmSunday % 30) + 7)/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = ($PalmSunday + 7) % 30
            Month       = ($CopticMonthList[[Math]::Floor($PalmSunday / 30)]).Name
            CopticYear  = $CopticYear
            DayOfWeek   = 'Sunday'
            Major       = $true
        })

        # Thomas Sunday
        $Day   = ($PalmSunday + 14) % 30
        $Month = [Math]::Floor($PalmSunday / 30) + 1
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Thomas Sunday'
            Description = 'Thomas Sunday'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$($Month+1)/$Day/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = $Day
            Month       = ($CopticMonthList[$Month]).Name
            CopticYear  = $CopticYear
            DayOfWeek   = 'Sunday'
            Major       = $false
        })

        # Ascension
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Ascension'
            Description = 'Ascension of Lord Jesus Christ'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$([Math]::Floor($PalmSunday / 30) + 2)/$(($PalmSunday % 30) + 16)/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = ($PalmSunday + 16) % 30
            Month       = ($CopticMonthList[[Math]::Floor($PalmSunday / 30) + 1]).Name
            CopticYear  = $CopticYear
            DayOfWeek   = 'Sunday'
            Major       = $true
        })

        # Pentecost
        $HolidayList += New-Object -TypeName PSObject -Property ([Ordered]@{
            Name        = 'Pentecost'
            Description = 'The Holy Spirit descending onto the Apostles'
            Gregorian   = Get-Date(Get-CorrespondingGregorianDate -CopticDate "$([Math]::Floor($PalmSunday / 30) + 2)/$(($PalmSunday % 30) + 26)/$CopticYear") -Format 'MM/dd/yyyy'
            Day         = ($PalmSunday + 26) % 30
            Month       = ($CopticMonthList[[Math]::Floor($PalmSunday / 30) + 1]).Name
            CopticYear  = $CopticYear
            DayOfWeek   = (Get-Date(Get-CorrespondingGregorianDate -CopticDate "$([Math]::Floor($PalmSunday / 30) + 2)/$(($PalmSunday % 30) + 26)/$CopticYear")).DayOfWeek
            Major       = $true
        })

        $HolidayList = $HolidayList | sort Gregorian
        $HolidayList = @($HolidayList[-1]) + $HolidayList[0..($HolidayList.Count-2)]
        if (-not $Silent) { Write-Log ($HolidayList | FT | Out-String).Trim() Cyan }

    }

    End { 
        $HolidayList
    }
} 

#endregion

Export-ModuleMember -Function * -Variable * -Alias *