Category Archives: Series: PowerShell Monitoring

Monitoring with PowerShell: Monitoring B-Series VM credits

A lot of MSPs use the B-Series VMs for tasks, and why woulnd’t you? It are cheap VMs that allow you to use azure as a cost effective solution for clients. The B-Series VM are “burstable” VMs, meaning they don’t get the full CPU performance constantly.

The description of Microsoft explains it best:

The B-series burstable VMs are ideal for workloads that do not need the full performance of the CPU continuously, like web servers, small databases and development and test environments. These workloads typically have burstable performance requirements. The B-Series provides these customers the ability to purchase a VM size with a price conscience baseline performance that allows the VM instance to build up credits when the VM is utilizing less than its base performance. When the VM has accumulated credit, the VM can burst above the VM’s baseline using up to 100% of the CPU when your application requires the higher CPU performance.

Microsoft – https://azure.microsoft.com/en-us/blog/introducing-b-series-our-new-burstable-vm-size/

This is of course great for smaller servers such as domain controllers, small RemoteApp machines or generic low performance VMs, but you do need to pay attention that you don’t run out of “credits” when performance is required.

So let’s start alerting on B-Series VMs that are running out of steam. Full disclosure and credit where it’s due: A part of this script was shared with me by Andrew Cullen of Lanter Technologies, thanks for that Andrew!

The script

This script checks all the subscriptions for each VM that is in the B-series, from there on we check the current credits remaining and alert on it if those get under 90.

To fix this, you could temporarily upscale the VM to a larger series which gives you new credits, or move tasks to different VM’s if it happens a lot. This will most likely also help in the “why is my VM suddenly so slow” scenarios ūüėČ

We’re using the secure application model and Azure Lighthouse for these tasks, as such you can load these scripts into any RMM system that is able to handle credentials securely.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$azureToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://management.azure.com/user_impersonation' -ServicePrincipal -Tenant $TenantId
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationID -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $TenantId
 
Connect-Azaccount -AccessToken $azureToken.AccessToken -GraphAccessToken $graphToken.AccessToken -AccountId $upn -TenantId $tenantID
$Subscriptions = Get-AzSubscription  | Where-Object { $_.State -eq 'Enabled' } | Sort-Object -Unique -Property Id
$VMCredits = foreach ($Sub in $Subscriptions) {
    write-host "Processing client $($sub.name)"
    $null = $Sub | Set-AzContext
    get-azvm -status | Where-Object { $_.HardwareProfile.VmSize -like "Standard_B*" -and $_.PowerState -like "*Running*" } | ForEach-Object {
        $Credits = (Get-AzMetric -ResourceId $_.Id -MetricName "CPU Credits Remaining").data.average | Where-Object { $_ -ne "" } | select-object -last 1
        [PSCustomObject]@{
            VMName           = $_.Name
            CreditsRemaining = $Credits
            Subscription     = $sub.name
        }
    }
}

foreach ($VMCredit in $VMCredits | where-object { $_.CreditsRemaining -lt "90" }) {
    write-host "$($VMCredit.VMname) has $($VMCredit.CreditsRemaining) credits remaining"
}

And that’s it! I hope this helps in tackling those pesky performance issues when using the cheaper VMs in Azure. As always, Happy PowerShelling

Monitoring with PowerShell: O365 location alerts

A while back someone asked me to convert a script they had to the Secure Application Model. This specific script checked the Office 365 audit log IPs against a online database of locations. I declined at first and suggested it to look into Microsoft 365, a P1 or P2 subscription which allows you to do this native.

The reader came back to me recently asking once more, and giving a explanation on why P1/P2 or M365 was not possible in her case. I understand that sometimes you might have to make due with what you have. I also figured others might be in the same boat.

So this script is made to monitor the O365 unified audit log and to compare the IP addresses to a database online. The database she used previously was very rate-limited and as such not really suitable. Not a lot of people know that there actually is a great 100% free online lookup service for IPs at https://ip2c.org/. The script uses that database as there are no API limitations. ūüôā

As always, I’d like to note that this should just be one layer in your entire security model and you should not put all your faith in this. Enable MFA, and keep good security hygiene.

The Script

You’ll need the Secure Application Model for this script. The script currently checks all your tenants except the ones you state in “$Skiplist”.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'VeryLongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-User-To-Generate-IDs"
######### Secrets #########

$AllowedCountries = @('Belgium', 'Netherlands', 'Germany', 'United Kingdom')
$Skiplist = @("bla1.onmicrosoft.com", "bla2.onmicrosoft.com", "bla2.onmicrosoft.com")

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken

$customers = Get-MsolPartnerContract -All | Where-Object { $_.DefaultDomainName -notin $SkipList }

$StrangeLocations = foreach ($customer in $customers) {
    Write-Host "Getting logon location details for $($customer.Name)" -ForegroundColor Green
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -CommandName Search-UnifiedAuditLog -AllowClobber
 

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    $logs = do {
        $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations UserLoggedIn
        Write-Host "Retrieved $($log.count) logs" -ForegroundColor Yellow
        $log
    } while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
 
    $userIds = $logs.userIds | Sort-Object -Unique

    $LocationMonitoring = foreach ($userId in $userIds) {
 
        $searchResult = ($logs | Where-Object { $_.userIds -contains $userId }).auditdata | ConvertFrom-Json -ErrorAction SilentlyContinue
        $ips = $searchResult.clientip | Sort-Object -Unique
        foreach ($ip in $ips) {
            $IsIp = ($ip -as [ipaddress]) -as [bool]
            if ($IsIp) { $ipresult = (Invoke-restmethod -method get -uri "https://ip2c.org/$($ip)") -split ';' }
            [PSCustomObject]@{
                user              = $userId
                IP                = $ip
                Country           = ($ipresult | Select-Object -index 3)
                CountryCode       = ($ipresult | Select-Object -Index 1)
                Company           = $customer.Name
                TenantID          = $customer.tenantID
                DefaultDomainName = $customer.DefaultDomainName
            }
           
        }

    }
    foreach ($Location in $LocationMonitoring) {
        if ($Location.country -notin $AllowedCountries) { $Location }
    }
}
if (!$StrangeLocations) {
    $StrangeLocations = 'Healthy'
}

$StrangeLocations

And that’s it! Now you’re monitoring the locations from where users are logging on. As always, Happy PowerShelling!

Monitoring with PowerShell: Host isolation

So 2 weeks ago we talked about PowerShell canaries and using them as an early warning system. I got a couple of questions about the follow up, especially the host isolation. There’s a lot of tricks that you can use to isolate a host from others.

The version below allows you to isolate, and de-isolate, or re-integrate the OS into normal operation. To isolate the machine, we use the Windows Firewall, we block all outbound traffic, and we stop both the ‘workstation’ and ‘server’ service. This means the machine can no longer use shares or access network resources.

We also disable DNS by forwarding it to a fake server and clear the DNS cache. To make sure we can still remotely access the machine via our RMM or other tools, we add those to the host file and create an allow rule in the firewall.

All of these tasks can be reverted by changing the $isolation variable to $false. Don’t forget to add your own tooling to the list of allowed hosts.

The Script

$AllowedHosts = "google.com", "YourSuperDuperTurboRMM.com", '1.2.3.4'
$isolation = $false


write-host "Checking all IPs for hosts"
$ConvertedHosts = foreach ($Remotehost in $AllowedHosts) {
    $IsIp = ($RemoteHost -as [ipaddress]) -as [bool]
    if ($IsIp) {
        $ipList = $Remotehost
    }
    else {
        $IPList = (Resolve-DnsName $Remotehost).ip4address
    }
    Foreach ($IP in $IPList) {
        [PSCustomObject]@{
            Hostname = $Remotehost
            IP       = $IP
        }
    }
}


if ($isolation) {
    write-host "Checking if Windows firewall is enabled" -ForegroundColor Green
    $WindowsFirewall = Get-NetFirewallProfile | Where-Object { $_.Enabled -ne $false }
    if (!$WindowsFirewall) { 
        write-host "Windows firewall is enabled. Moving onto next task" -ForegroundColor Green
    }
    else {
        Write-Host "Windows Firewall is not enabled. Enabling for extra isolation" -ForegroundColor Yellow
        $WindowsFirewall | Set-NetFirewallProfile -Enabled:True
    }
    write-host "Preparing Windows Firewall isolation rule" -ForegroundColor Green

    $ExistingRule = Get-NetFirewallRule -DisplayName "ISOLATION: Allowed Hosts" -ErrorAction SilentlyContinue
    if ($ExistingRule) {
        write-host "Setting existing Windows Firewall isolation rule" -ForegroundColor Green
        Get-NetFirewallRule -Direction Outbound | Set-NetFirewallRule -Enabled:False
        set-NetFirewallRule -Direction Outbound -Enabled:True -Action Allow -RemoteAddress $ConvertedHosts.IP -DisplayName "ISOLATION: Allowed Hosts"
        get-netfirewallprofile | Set-NetFirewallProfile -DefaultOutboundAction Block
    }
    else {
        write-host "Creating Firewall isolation rule" -ForegroundColor Green
        Get-NetFirewallRule -Direction Outbound | Set-NetFirewallRule -Enabled:False
        New-NetFirewallRule -Direction Outbound -Enabled:True -Action Allow -RemoteAddress $ConvertedHosts.IP -DisplayName "ISOLATION: Allowed Hosts"
        get-netfirewallprofile | Set-NetFirewallProfile -DefaultOutboundAction Block
    }
    write-host "Adding list of hostnames to host file" -ForegroundColor Green
    foreach ($HostEntry in $ConvertedHosts) {
        Add-Content -Path "$($ENV:windir)/system32/drivers/etc/hosts" -Value "`n$($HostEntry.IP)`t`t$($HostEntry.Hostname)"
        start-sleep -Milliseconds 200
    }
    write-host 'Setting DNS to a static server that does not exist' -ForegroundColor Green
    Get-dnsclientserveraddress | Set-DnsClientServerAddress -ServerAddresses 127.0.0.127
    write-host "Clearing DNS cache" -ForegroundColor Green
    Clear-DnsClientCache
    write-host "Stopping 'client' and 'server' service. and setting to disabled" -ForegroundColor Green

    stop-service -name 'Workstation' -Force
    get-service -name 'Workstation' | Set-Service -StartupType Disabled
    stop-service -name 'Server' -Force 
    get-service -name 'server' | Set-Service -StartupType Disabled

    write-host 'Isolation performed. To undo these actions, please run the script with $Isolation set to false' -ForegroundColor Green
}
else {
    write-host "Undoing isolation process." -ForegroundColor Green
    write-host "Setting existing Windows Firewall isolation rule to allow traffic" -ForegroundColor Green
    Get-NetFirewallRule -Direction Outbound | Set-NetFirewallRule -Enabled:True
    Remove-NetFirewallRule -DisplayName "ISOLATION: Allowed Hosts" -ErrorAction SilentlyContinue
    get-netfirewallprofile | Set-NetFirewallProfile -DefaultOutboundAction Allow

    write-host "Removing list of hostnames from host file" -ForegroundColor Green
    foreach ($HostEntry in $ConvertedHosts) {
        $HostFile = Get-Content "$($ENV:windir)/system32/drivers/etc/hosts"
        $NewHostFile = $HostFile -replace "`n$($HostEntry.IP)`t`t$($HostEntry.Hostname)", ''
        Set-Content -Path "$($ENV:windir)/system32/drivers/etc/hosts" -Value $NewHostFile
        start-sleep -Milliseconds 200
    }
    write-host "Clearing DNS cache" -ForegroundColor Green
    Clear-DnsClientCache
    write-host "Setting DNS back to DHCP" -ForegroundColor Green
    Get-dnsclientserveraddress | Set-DnsClientServerAddress -ResetServerAddresses
    write-host "Starting 'Workstation' and 'server' service. and setting to disabled" -ForegroundColor Green
    get-service -name 'Workstation' | Set-Service -StartupType Automatic
    start-service 'Workstation'
    get-service -name 'server' | Set-Service -StartupType Automatic
    start-service 'Server'
    write-host 'Undo Isolation performed. To re-isolate, run the script with the $Isolation parameter set to true.' -ForegroundColor Green
}

And that’s it! This is of course just an example on how to isolate a host from others in case you want to, you should modify it to fir your environment.

As always, happy PowerShelling!

Monitoring with PowerShell: Notifying users of Windows Updates

With my recently released RunAsUser module there’s been an influx of questions on what it could be used for. I’ve tried to describe as much as possible on the github page and the previous blog about it. But one I wanted to talk about real quick is the ability to create Toast notifications.

Toast notifications are those little OS native notifications you side in the bottom right of your screen when receiving an e-mail. Our RMM system has the ability to create a notification using an application, but to be honest that notification looks like it came straight out of 1990.

To have a bit better user experience, and to also get the ability to do specific things with user-input I’ve decided to use Burnt Toast. Burnt Toast is a module that give you the ability to generate pretty toast messages with just a couple lines of code. Brilliant really!

Combining my RunAsUser module, and Burnt Toast we’re able to send a script to the currently logged on user’s session and get full functionality in there. One example is to reboot the computer after updates. So lets get going!

The script

The following script can be used to create a toast for reboots. It creates a ‘protocol handler’. It then toasts with a nice Gif of my logo to get the users attention. The script assumes you have trusted the PSGallery before hand.

#Checking if ToastReboot:// protocol handler is present
New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -erroraction silentlycontinue | out-null
$ProtocolHandler = get-item 'HKCR:\ToastReboot' -erroraction 'silentlycontinue'
if (!$ProtocolHandler) {
    #create handler for reboot
    New-item 'HKCR:\ToastReboot' -force
    set-itemproperty 'HKCR:\ToastReboot' -name '(DEFAULT)' -value 'url:ToastReboot' -force
    set-itemproperty 'HKCR:\ToastReboot' -name 'URL Protocol' -value '' -force
    new-itemproperty -path 'HKCR:\ToastReboot' -propertytype dword -name 'EditFlags' -value 2162688
    New-item 'HKCR:\ToastReboot\Shell\Open\command' -force
    set-itemproperty 'HKCR:\ToastReboot\Shell\Open\command' -name '(DEFAULT)' -value 'C:\Windows\System32\shutdown.exe -r -t 00' -force
}

Install-Module -Name BurntToast
Install-module -Name RunAsUser
invoke-ascurrentuser -scriptblock {

    $heroimage = New-BTImage -Source 'https://media.giphy.com/media/eiwIMNkeJ2cu5MI2XC/giphy.gif' -HeroImage
    $Text1 = New-BTText -Content  "Message from IT"
    $Text2 = New-BTText -Content "Your IT provider has installed updates on your computer at $(get-date). Please select if you'd like to reboot now, or snooze this message."
    $Button = New-BTButton -Content "Snooze" -snooze -id 'SnoozeTime'
    $Button2 = New-BTButton -Content "Reboot now" -Arguments "ToastReboot:" -ActivationType Protocol
    $5Min = New-BTSelectionBoxItem -Id 5 -Content '5 minutes'
    $10Min = New-BTSelectionBoxItem -Id 10 -Content '10 minutes'
    $1Hour = New-BTSelectionBoxItem -Id 60 -Content '1 hour'
    $4Hour = New-BTSelectionBoxItem -Id 240 -Content '4 hours'
    $1Day = New-BTSelectionBoxItem -Id 1440 -Content '1 day'
    $Items = $5Min, $10Min, $1Hour, $4Hour, $1Day
    $SelectionBox = New-BTInput -Id 'SnoozeTime' -DefaultSelectionBoxItemId 10 -Items $Items
    $action = New-BTAction -Buttons $Button, $Button2 -inputs $SelectionBox
    $Binding = New-BTBinding -Children $text1, $text2 -HeroImage $heroimage
    $Visual = New-BTVisual -BindingGeneric $Binding
    $Content = New-BTContent -Visual $Visual -Actions $action
    Submit-BTNotification -Content $Content
}

And that’s it! you must be wondering how it looks, so lets show you that too!

And that’s it! as always, Happy PowerShelling!

Automating with PowerShell: Impersonating users while running as SYSTEM

I’ve demonstrated in a couple of blogs like the OneDrive Sync Monitoring and the OneDrive File Monitoring that it’s possible to impersonate the current user when a script is actually started by the NT AUTHORITY\SYSTEM account.

My friends asked me if it would not be possible for other scripts to use the same approach. In the previous blogs I’ve shown that by loading the component by MurrayJu we got the ability to impersonate. I converted this into a module which you can find on https://github.com/KelvinTegelaar/RunAsUser.

This module allows you to run any script that is initiated by SYSTEM and execute it as the currently logged on user. This gives us a lot of freedom. Most RMM systems(and intune!) don’t allow monitoring under the currently logged on user. This often means that you have to work around accessing resources directly in their profile.

Some examples would be accessing installers that run in the users AppData folder, or registry items created under HKCU. Another could be scripts that require accessing shared drives or printers that are only mapped in user-space.

This is also super useful for intune scripts, because you just need to present things to the user or install things using their credentials directly.

Using the module

So, using the module is very straight forward. To install the module execute the following command:

install-module RunAsUser

After you’ve installed the module you can jump straight into scripting. There are some things to account for; The script requires SYSTEM credentials or the SeDelegateSessionUserImpersonatePrivilege privilege.

The second thing is that the output can’t be directly captured. If you want to get output from the script you’ll have to write it to a file and pick that up again in the SYSTEM session. This might sound a little confusing so I have an example below.

$scriptblock = {
$IniFiles = Get-ChildItem "$ENV:LOCALAPPDATA\Microsoft\OneDrive\settings\Business1" -Filter 'ClientPolicy*' -ErrorAction SilentlyContinue

if (!$IniFiles) {
    write-host 'No Onedrive configuration files found. Stopping script.'
    exit 1
}
 
$SyncedLibraries = foreach ($inifile in $IniFiles) {
    $IniContent = get-content $inifile.fullname -Encoding Unicode
    [PSCustomObject]@{
        'Item Count' = ($IniContent | Where-Object { $_ -like 'ItemCount*' }) -split '= ' | Select-Object -last 1
        'Site Name'  = ($IniContent | Where-Object { $_ -like 'SiteTitle*' }) -split '= ' | Select-Object -last 1
        'Site URL'   = ($IniContent | Where-Object { $_ -like 'DavUrlNamespace*' }) -split '= ' | Select-Object -last 1
    }
}
$SyncedLibraries | ConvertTo-Json | Out-File 'C:\programdata\Microsoft OneDrive\OneDriveLibraries.txt'
}
try{
Invoke-AsCurrentUser -scriptblock $scriptblock
} catch{
write-error "Something went wrong"
}
start-sleep 2 #Sleeping 2 seconds to allow script to write to disk.
$SyncedLibraries = (get-content "C:\programdata\Microsoft OneDrive\OneDriveLibraries.txt" | convertfrom-json)
if (($SyncedLibraries.'Item count' | Measure-Object -Sum).sum -gt '280000') { 
write-host "Unhealthy - Currently syncing more than 280k files. Please investigate."
$SyncedLibraries
}
else {
write-host "Healthy - Syncing less than 280k files."
}

In the script, we’re executing the Script Block using Invoke-AsCurrentUser command. This runs that entire block of code as the currently logged on user. We then sleep for 2 seconds allowing the script block to finish writing to disk. After this finishes, we pick up the file again under the system account and process the results.

So in short; using this module opens up a lot of user-based monitoring for systems that normally only allow executing under the SYSTEM account. Hopefully this helps people solve some challenges.

As a closing remark I’d like to thank Ben Reader (@Powers_hell) for his help on the module. He assisted in cleaning up the code right after release, making it all look and feel a lot smoother and he assisted in better error handling. Thanks Buddy! ūüôā

As always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring the Onedrive client limitations

I wrote about monitoring the Onedrive sync status some time ago. That blog gained a lot of popularity. Implementing it allows you to monitor Onedrive, which runs in user mode while using your RMM that often runs under system.

So after a while we’ve noticed that we bumped into issues that weren’t getting captured by monitoring just the sync state. Onedrive tends to get performance issues when syncing more than 300k files. This also counts for files on demand. There are some other limitations but the best write-up about this is here. Large libraries just tend to make things a little screwy.

To tackle this you can use the following script. This gets all the synced sites, lists the Item count, checks if the sum is higher than 280k and alerts if that is true. This helped us tackle performance problems and nip them in the bud pretty early on ūüôā

$IniFiles = Get-ChildItem "$ENV:LOCALAPPDATA\Microsoft\OneDrive\settings\Business1" -Filter 'ClientPolicy*' -ErrorAction SilentlyContinue

if (!$IniFiles) {
    write-host "No Onedrive configuration files found. Stopping script."
    exit 1
}

$SyncedLibraries = foreach ($inifile in $IniFiles) {
    $IniContent = get-content $inifile.fullname -Encoding Unicode
    [PSCustomObject]@{
        'Item Count' = ($IniContent | Where-Object { $_ -like 'ItemCount*' }) -split '= ' | Select-Object -last 1
        'Site Name'  = ($IniContent | Where-Object { $_ -like 'SiteTitle*' }) -split '= ' | Select-Object -last 1
        'Site URL'   = ($IniContent | Where-Object { $_ -like 'DavUrlNamespace*' }) -split '= ' | Select-Object -last 1
    }
}

if (($SyncedLibraries.'Item count' | Measure-Object -Sum).sum -gt '280000') { 
    write-host "Unhealthy - Currently syncing more than 280k files. Please investigate." 
    $SyncedLibraries
}
else {
    write-host "Healthy - Syncing less than 280k files."
}

Running this script directly often doesn’t work, because we’ll bump into the same issue we have with the OneDrive monitoring script. To fix that, use the version below. This impersonates the current user. Remember that this should run as NT AUTHORITY\SYSTEM.

$Source = @"
using System;  
using System.Runtime.InteropServices;

namespace murrayju.ProcessExtensions  
{
    public static class ProcessExtensions
    {
        #region Win32 Constants

        private const int CREATE_UNICODE_ENVIRONMENT = 0x00000400;
        private const int CREATE_NO_WINDOW = 0x08000000;

        private const int CREATE_NEW_CONSOLE = 0x00000010;

        private const uint INVALID_SESSION_ID = 0xFFFFFFFF;
        private static readonly IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;

        #endregion

        #region DllImports

        [DllImport("advapi32.dll", EntryPoint = "CreateProcessAsUser", SetLastError = true, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
        private static extern bool CreateProcessAsUser(
            IntPtr hToken,
            String lpApplicationName,
            String lpCommandLine,
            IntPtr lpProcessAttributes,
            IntPtr lpThreadAttributes,
            bool bInheritHandle,
            uint dwCreationFlags,
            IntPtr lpEnvironment,
            String lpCurrentDirectory,
            ref STARTUPINFO lpStartupInfo,
            out PROCESS_INFORMATION lpProcessInformation);

        [DllImport("advapi32.dll", EntryPoint = "DuplicateTokenEx")]
        private static extern bool DuplicateTokenEx(
            IntPtr ExistingTokenHandle,
            uint dwDesiredAccess,
            IntPtr lpThreadAttributes,
            int TokenType,
            int ImpersonationLevel,
            ref IntPtr DuplicateTokenHandle);

        [DllImport("userenv.dll", SetLastError = true)]
        private static extern bool CreateEnvironmentBlock(ref IntPtr lpEnvironment, IntPtr hToken, bool bInherit);

        [DllImport("userenv.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool CloseHandle(IntPtr hSnapshot);

        [DllImport("kernel32.dll")]
        private static extern uint WTSGetActiveConsoleSessionId();

        [DllImport("Wtsapi32.dll")]
        private static extern uint WTSQueryUserToken(uint SessionId, ref IntPtr phToken);

        [DllImport("wtsapi32.dll", SetLastError = true)]
        private static extern int WTSEnumerateSessions(
            IntPtr hServer,
            int Reserved,
            int Version,
            ref IntPtr ppSessionInfo,
            ref int pCount);

        #endregion

        #region Win32 Structs

        private enum SW
        {
            SW_HIDE = 0,
            SW_SHOWNORMAL = 1,
            SW_NORMAL = 1,
            SW_SHOWMINIMIZED = 2,
            SW_SHOWMAXIMIZED = 3,
            SW_MAXIMIZE = 3,
            SW_SHOWNOACTIVATE = 4,
            SW_SHOW = 5,
            SW_MINIMIZE = 6,
            SW_SHOWMINNOACTIVE = 7,
            SW_SHOWNA = 8,
            SW_RESTORE = 9,
            SW_SHOWDEFAULT = 10,
            SW_MAX = 10
        }

        private enum WTS_CONNECTSTATE_CLASS
        {
            WTSActive,
            WTSConnected,
            WTSConnectQuery,
            WTSShadow,
            WTSDisconnected,
            WTSIdle,
            WTSListen,
            WTSReset,
            WTSDown,
            WTSInit
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct PROCESS_INFORMATION
        {
            public IntPtr hProcess;
            public IntPtr hThread;
            public uint dwProcessId;
            public uint dwThreadId;
        }

        private enum SECURITY_IMPERSONATION_LEVEL
        {
            SecurityAnonymous = 0,
            SecurityIdentification = 1,
            SecurityImpersonation = 2,
            SecurityDelegation = 3,
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct STARTUPINFO
        {
            public int cb;
            public String lpReserved;
            public String lpDesktop;
            public String lpTitle;
            public uint dwX;
            public uint dwY;
            public uint dwXSize;
            public uint dwYSize;
            public uint dwXCountChars;
            public uint dwYCountChars;
            public uint dwFillAttribute;
            public uint dwFlags;
            public short wShowWindow;
            public short cbReserved2;
            public IntPtr lpReserved2;
            public IntPtr hStdInput;
            public IntPtr hStdOutput;
            public IntPtr hStdError;
        }

        private enum TOKEN_TYPE
        {
            TokenPrimary = 1,
            TokenImpersonation = 2
        }

        [StructLayout(LayoutKind.Sequential)]
        private struct WTS_SESSION_INFO
        {
            public readonly UInt32 SessionID;

            [MarshalAs(UnmanagedType.LPStr)]
            public readonly String pWinStationName;

            public readonly WTS_CONNECTSTATE_CLASS State;
        }

        #endregion

        // Gets the user token from the currently active session
        private static bool GetSessionUserToken(ref IntPtr phUserToken)
        {
            var bResult = false;
            var hImpersonationToken = IntPtr.Zero;
            var activeSessionId = INVALID_SESSION_ID;
            var pSessionInfo = IntPtr.Zero;
            var sessionCount = 0;

            // Get a handle to the user access token for the current active session.
            if (WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1, ref pSessionInfo, ref sessionCount) != 0)
            {
                var arrayElementSize = Marshal.SizeOf(typeof(WTS_SESSION_INFO));
                var current = pSessionInfo;

                for (var i = 0; i < sessionCount; i++)
                {
                    var si = (WTS_SESSION_INFO)Marshal.PtrToStructure((IntPtr)current, typeof(WTS_SESSION_INFO));
                    current += arrayElementSize;

                    if (si.State == WTS_CONNECTSTATE_CLASS.WTSActive)
                    {
                        activeSessionId = si.SessionID;
                    }
                }
            }

            // If enumerating did not work, fall back to the old method
            if (activeSessionId == INVALID_SESSION_ID)
            {
                activeSessionId = WTSGetActiveConsoleSessionId();
            }

            if (WTSQueryUserToken(activeSessionId, ref hImpersonationToken) != 0)
            {
                // Convert the impersonation token to a primary token
                bResult = DuplicateTokenEx(hImpersonationToken, 0, IntPtr.Zero,
                    (int)SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation, (int)TOKEN_TYPE.TokenPrimary,
                    ref phUserToken);

                CloseHandle(hImpersonationToken);
            }

            return bResult;
        }

        public static bool StartProcessAsCurrentUser(string appPath, string cmdLine = null, string workDir = null, bool visible = true)
        {
            var hUserToken = IntPtr.Zero;
            var startInfo = new STARTUPINFO();
            var procInfo = new PROCESS_INFORMATION();
            var pEnv = IntPtr.Zero;
            int iResultOfCreateProcessAsUser;

            startInfo.cb = Marshal.SizeOf(typeof(STARTUPINFO));

            try
            {
                if (!GetSessionUserToken(ref hUserToken))
                {
                    throw new Exception("StartProcessAsCurrentUser: GetSessionUserToken failed.");
                }

                uint dwCreationFlags = CREATE_UNICODE_ENVIRONMENT | (uint)(visible ? CREATE_NEW_CONSOLE : CREATE_NO_WINDOW);
                startInfo.wShowWindow = (short)(visible ? SW.SW_SHOW : SW.SW_HIDE);
                startInfo.lpDesktop = "winsta0\\default";

                if (!CreateEnvironmentBlock(ref pEnv, hUserToken, false))
                {
                    throw new Exception("StartProcessAsCurrentUser: CreateEnvironmentBlock failed.");
                }

                if (!CreateProcessAsUser(hUserToken,
                    appPath, // Application Name
                    cmdLine, // Command Line
                    IntPtr.Zero,
                    IntPtr.Zero,
                    false,
                    dwCreationFlags,
                    pEnv,
                    workDir, // Working directory
                    ref startInfo,
                    out procInfo))
                {
                    throw new Exception("StartProcessAsCurrentUser: CreateProcessAsUser failed.\n");
                }

                iResultOfCreateProcessAsUser = Marshal.GetLastWin32Error();
            }
            finally
            {
                CloseHandle(hUserToken);
                if (pEnv != IntPtr.Zero)
                {
                    DestroyEnvironmentBlock(pEnv);
                }
                CloseHandle(procInfo.hThread);
                CloseHandle(procInfo.hProcess);
            }
            return true;
        }
    }
}


"@

Add-Type -ReferencedAssemblies 'System', 'System.Runtime.InteropServices' -TypeDefinition $Source -Language CSharp 

$scriptblock = {
    $IniFiles = Get-ChildItem "$ENV:LOCALAPPDATA\Microsoft\OneDrive\settings\Business1" -Filter 'ClientPolicy*' -ErrorAction SilentlyContinue

    if (!$IniFiles) {
        write-host 'No Onedrive configuration files found. Stopping script.'
        exit 1
    }
    
    $SyncedLibraries = foreach ($inifile in $IniFiles) {
        $IniContent = get-content $inifile.fullname -Encoding Unicode
        [PSCustomObject]@{
            'Item Count' = ($IniContent | Where-Object { $_ -like 'ItemCount*' }) -split '= ' | Select-Object -last 1
            'Site Name'  = ($IniContent | Where-Object { $_ -like 'SiteTitle*' }) -split '= ' | Select-Object -last 1
            'Site URL'   = ($IniContent | Where-Object { $_ -like 'DavUrlNamespace*' }) -split '= ' | Select-Object -last 1
        }
    }
    $SyncedLibraries | ConvertTo-Json | Out-File 'C:\programdata\Microsoft OneDrive\OneDriveLibraries.txt'
}


[murrayju.ProcessExtensions.ProcessExtensions]::StartProcessAsCurrentUser("C:\Windows\System32\WindowsPowershell\v1.0\Powershell.exe", "-command $($scriptblock)", "C:\Windows\System32\WindowsPowershell\v1.0", $false)
start-sleep 5

$SyncedLibraries = (get-content "C:\programdata\Microsoft OneDrive\OneDriveLibraries.txt" | convertfrom-json)
if (($SyncedLibraries.'Item count' | Measure-Object -Sum).sum -gt '280000') { 
    write-host "Unhealthy - Currently syncing more than 280k files. Please investigate." 
    $SyncedLibraries
}
else {
    write-host "Healthy - Syncing less than 280k files."
}

And that’s it! as always PowerShelling ūüôā

Automating with PowerShell: Teams Automapping

Something like 3 years ago I wrote a blog about using PowerShell to configure Onedrive sites using the odopen protocol. This was pretty much the only method to configure Onedrive to automatically map sites and have a zero-touch configuration.

Of course over the years the management side has improved and OneDrive usage has exploded. Unfortunately the onedrive automatic mapping structure isn’t where it should be yet. For example the GPO/intune method for automatic mapping configuration can take up to 8 hours to apply on any client.

During migrations and new deployment this is pretty much unacceptable. To make sure that mapping would be instant I’ve decided to create two scripts; One Azure Function which I’ve called AzMapper, and another client based script.

AzMapper requires you to create an Azure Function. To do that follow this manual and replace the script with the one below. This script is compatible with the Secure Application Model, and as such it can check all of your partner tenants too. Meaning you’ll only need to host a single version.

Replace $ApplicationID and $ApplicationSecret with your own from the Secure App Model.

You’ll also need to give your Secure Application Model a little more permissions, specifically to read the groups:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on ‚ÄúApp Registrations‚ÄĚ.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to ‚ÄúAPI Permissions‚ÄĚ and click Add a permission.
  • Choose ‚ÄúMicrosoft Graph‚ÄĚ and ‚ÄúApplication permission‚ÄĚ.
  • Search for ‚ÄúSites‚ÄĚ and click on ‚ÄúSites.Read.All‚ÄĚ. Click on add permission.
  • Search for ‚ÄúTeam‚ÄĚ and click on ‚ÄúTeamMember.Read.All‚ÄĚ. Click on add permission.
  • Search for ‚ÄúTeam‚ÄĚ and click on ‚ÄúTeam.ReadBasic.Alll‚ÄĚ. Click on add permission.
  • Do the same for ‚ÄúDelegate Permissions‚ÄĚ.
  • Finally, click on ‚ÄúGrant Admin Consent for Company Name.

AzMapper

using namespace System.Net
param($Request, $TriggerMetadata)
#
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' 
#
$TenantID = $Request.Query.TenantID
$user = $Request.Query.Username

$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
$ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/token" -Body $body -ErrorAction Stop
$headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
$UserID = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/users/$($user)" -Headers $Headers -Method Get -ContentType "application/json").id

$AllTeamsURI = "https://graph.microsoft.com/beta/users/$($UserID)/JoinedTeams"
$Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
$MemberOf = foreach ($Team in $Teams) {
    $SiteRootUri = "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root"
    $SiteRootReq = Invoke-RestMethod -Uri $SiteRootUri  -Headers $Headers -Method Get -ContentType "application/json"
    $SiteDrivesUri = "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root/Lists"
    $SitesDrivesReq = (Invoke-RestMethod -Uri $SiteDrivesUri -Headers $Headers -Method Get -ContentType "application/json").value | where-object { $_.Name -eq "Shared Documents" }
    $DriveInfo = $SitesDrivesReq.ParentReference.siteid -split ','
    if($SiteRootReq.description -like "*no-auto-map*"){ continue }
    if ($null -eq [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.id)) { continue }
    [pscustomobject] @{
        SiteID    = [System.Web.HttpUtility]::UrlEncode("{$($DriveInfo[1])}")
        WebID     = [System.Web.HttpUtility]::UrlEncode("{$($DriveInfo[2])}")
        ListID    = [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.id)
        WebURL    = [System.Web.HttpUtility]::UrlEncode($SiteRootReq.webUrl)
        Webtitle  = [System.Web.HttpUtility]::UrlEncode($($Team.displayName)).Replace("+", "%20")
        listtitle = [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.name)
    }

}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $MemberOf
})

If you want sites to be skipped, you can add “no-auto-map” in the description in Teams. this will cause the script to skip that site.

Now we can browse to the AzMapper URL to check if our function is working. To test the AzMapper click on “Get Function URL” in the Azure Portal and copy the URL required. You’ll end up with something like:

https://azMapper.azurewebsites.net/api/AzOneMap?code=verylongapicodehere==

an example of a test url would be:

https://azMapper.azurewebsites.net/api/AzOneMap?code=verylongapicodehere==&Tenantid=TENANTIDHERE&Username=USERNAMEHERE

This should return all sites for that specific user, it will contain exactly the information you need to create a odopen:// URL.

Client script

So you can schedule the client script using whatever method you prefer – as a startup script, using your RMM, or just a one-off during migrations. When a site is already configured to be synced it will skip this site. We do assume that OneDrive is already configured and just waiting to sync sites. ūüėČ

#########################
$AutoMapURL = "https://azmapper.azurewebsites.net/api/AzOneMap"
$AutomapAPIKey = "TheAPIKeyFromTheAppAlsoKnownAsTheCode"
#########################

write-host "Grabbing OneDrive info from registry" -ForegroundColor Green
$TenantID = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").ConfiguredTenantId
$TenantDisplayName = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").Displayname
$username = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").userEmail
$CurrentlySynced = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1\Tenants\$($tenantdisplayname)" -ErrorAction SilentlyContinue)
Write-Host "Retrieving all possible teams."  -ForegroundColor Green
$ListOfTeams = Invoke-RestMethod -Method get -uri "$($AutoMapURL)?Tenantid=$($TenantID)&Username=$($Username)&code=$($AutomapAPIKey)" -UseBasicParsing
$Upn = [System.Web.HttpUtility]::Urldecode($username)
foreach ($Team in $ListOfTeams) {
    write-host "Checking if site is not already synced" -ForegroundColor Green
    $sitename = [System.Web.HttpUtility]::Urldecode($Team.Webtitle)
    if ($CurrentlySynced.psobject.Properties.name -like "*$($sitename) -*") {
        write-host "Site $Sitename is already being synced. Skipping." -ForegroundColor Green 
        continue
    }
    else {
        write-host "Mapping Team: $Sitename" -ForegroundColor Green
        Start-Process "odopen://sync/?siteId=$($team.SiteID)&webId=$($team.webid)&amp;listId=$($team.ListID)&userEmail=$upn&webUrl=$($team.Weburl)&webtitle=$($team.Webtitle)"
        start-sleep 5
    }
}

And that’s it! running this script will map all the sites this specific user has access to, it won’t give weird pop-ups for users that do not have access and this should help you ease all Teams deployments by a lot. As always, Happy PowerShelling!

Monitoring with PowerShell: AD KRBTGT & making your own canaries

I decided this time I’m gonna be combining two small blogs, because they’re both pretty small and easy. Both are somewhat security oriented. The first part of the blog we will tackle monitoring the KRBTGT password. This needs to be reset on a regular schedule to ensure bad actors can’t abuse it.

The second part we’ll focus on creating our own ‘Canary’ files. These files can be used for a lot of things but the most common is to detect if ransomware has touched them in someway or the other. So, lets get started!

Monitoring KRBTGT Password age

So it’s actually straight forward to monitor the KRBTGT account, as it’s just a AD account. We’ll monitor this by grabbing the PasswordLastSet Attributes from the Active Directory. If you want to automatically resolve this, I’d strongly suggest to look at the script in this Github.

$Days = (Get-Date).AddDays(-31)
$Account = Get-AdUser krbtgt -property passwordlastset
$Setdate = if($Account.PasswordLastSet -gt $Days){ "Healthy - Password set date $($Account.Passwordlastset)" } else {" Unhealthy - Password set date $($Account.Passwordlastset)" }

You can change the amount of days to what you are comfortable with. I believe the documentation doesn’t have a strong suggestion in how much you should, but as this is a completely automated solution we perform this on a monthly basis.

Creating and monitoring file canaries

So, canaries are files that you place on strategic locations on a machine to check if the files aren’t being touched, corrupted, or encrypted in any way. Primarily they are used to prevent a full encryption of a computer and minimize data loss and lateral movement.

So with this script, we create canaries in a couple of locations;

  • The My Documents folder of each user
  • The Desktop Folder of each user
  • The root of each drive on the machine

You’ll also be able to create them in locations you want by adding to the $CreateLocations variable. We create the files as hidden, so users should not see the file, the file name will be canaryfile.pdf, even though it’s just a simple text file.

So the script creates a file in each location, and immediately starts alerting on two properties; If the file has been edited in the past hour, and if the file contains the correct string. I’d advice to apply the monitoring to the device, wait an hour, and then actually start alerting on it or reacting.

$CreateLocations = @('AllDesktops', 'AllDocuments', 'AllDrives', 'C:\temp')
$FileContent = "This file is a special file created by your managed services provider. For more information contact the IT Support desk."

foreach ($Locations in $CreateLocations) {
    $AllLocations = switch ($Locations) {
        "AllDesktops" { (Get-ChildItem "C:\Users" -Recurse -Force -filter 'Desktop' -Depth 3).FullName }
        "AllDocuments" { (Get-ChildItem "C:\Users" -Recurse -Force -Filter 'Documents' -Depth 3).fullname }
        "AllDrives" { ([System.IO.DriveInfo]::getdrives() | Where-Object { $_.DriveType -eq 'Fixed' }).Name }
        default { $Locations }
    }
  $CanaryStatus = foreach ($Location in $AllLocations) {
        if ((test-path "$Location\CanaryFile.pdf") -eq $false) {
            $File = New-Item $Location -Name "CanaryFile.pdf" -Value $FileContent
            $file.Attributes = 'hidden'
        }
        else {
            $ExistingFile = get-item "$Location\CanaryFile.pdf" -Force
            if ($ExistingFile.LastWriteTime -gt (get-date).AddHours(-1)) { "$Location\CanaryFile.pdf is unhealthy. The LastWriteTime was $($ExistingFile.LastWriteTime)" }
            $ExistingFileContents = get-content $ExistingFile -Force
            if ($ExistingFileContents -ne $FileContent) { "$Location\CanaryFile.pdf is unhealthy. The contents do not match. This is a sign the file has most likely been encrypted" }
        }
    }
}
if(!$CanaryStatus){
    $CanaryStatus = "Healthy"
}

$CanaryStatus

If you feel confident enough about this, you could set up some self-healing like disabling network access, or shutting the device down before the machine is completely encrypted. And that’s it. As always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Shodan results (in-depth)

Sometime ago I made a blog about monitoring your environments by using PowerShell and the Shodan API. This blog was well received but I felt like it could use a lot of improvements. The data returned wasn’t all that useful for some, and sometimes you want to exclude specific ports in case of an actual webserver for example.

So I’ve made an updated version that is able to return more info, This version allows you to add both exclusions, and get more information like who the ISP is, and if known vulnerabilities have been found. It also keeps a history of the previous result and runs a compare against this, to check if something has been changed.

As always these scripts are designed to run with your RMM, on environments where you’d expect no open ports or a very limited subset. Monitoring this on all your devices probably aint the best plan ūüôā

$PortExclusions = @('80', '443')
$CurrentIP = (Invoke-WebRequest -uri "http://ifconfig.me/ip" -UseBasicParsing ).Content
$ListIPs = @($CurrentIP)
$Shodan = foreach ($ip in $ListIPs) {
    try {
        $ReqFull = Invoke-RestMethod -uri "https://api.shodan.io/shodan/host/$($ip)?key=$APIKEY"
    }
    catch {
        write-host "Could not get information for host $IP. Error was:  $($_.Exception.Message)"
        continue
    }
    foreach ($req in $ReqFull.data | Where-Object { $_.port -notin $PortExclusions }) {
        [PSCustomObject]@{
            'IP'                    = $req.ip_str
            'Detected OS'           = $Req.data.OS
            'Detected Port'         = $req.port
            'Detected ISP'          = $req.isp
            'Detected Data'         = $req.data
            'Found vulnerabilities' = $req.opts.vulns
        }
    }
}

$previousResult = get-content "$($Env:Programdata)\ShodanScan\LastScan.txt" -ErrorAction SilentlyContinue | ConvertFrom-Json
if($previousResult) {$CompareObject = Compare-Object $previousresult $Shodan}
if ($CompareObject) { Write-Host "There is a different between the previous result and the current result. Please investigate" }
new-item "$($Env:Programdata)\ShodanScan" -ItemType Directory -Force
ConvertTo-Json $Shodan  | Out-File "$($Env:Programdata)\ShodanScan\LastScan.txt"


if (!$Shodan) { write-host "Healthy - Hosts are not found in Shodan." } else { write-host "Hosts are found in Shodan. Information: $($Shodan | out-string)" } 

And that’s it! as always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring Azure AD Devices and users age.

So we’re managing more and more cloud only clients. This is fantastic because you don’t have to worry about all the old worries like keeping a server online and updated. Another cool thing is that it becomes a lot easier to manage devices and endpoints.

The thing is, even with Azure AD you still have maintenance tasks that never seem to disappear. This time, we’re picking up the age old issue of keeping your Active Directory cleaned up. In this case; The Azure Active Directory.

With the following script we detect a couple of things; any user that has not logged in for 90 days, but also any device that has not logged into the Azure AD for 90 days. Finding these older devices gives you the ability to see if your off-boarding procedures are running well and you’re not having a total mess.

A good real life example came to me recently; one of our employees had a device stolen and I logged into the intune portal to start a remote wipe. The problem was that this user had around 10 devices in the portal and I could not be sure which was the current one. If I had maintained the portal and ran this script more often, finding the device would’ve been much easier.

Lets get to the script! As always I’ll publish two versions

Single tenant script

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
$CustomerTenant = "Customer.onmicrosoft.com"
########################## Script Settings  ############################
$Date = (get-date).AddDays(-90)
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
write-host "$($Tenant.Displayname): Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host " $($Tenant.Displayname): Grabbing all Users that have not logged in for 90 days." -ForegroundColor Green
$UserList = (Invoke-RestMethod -Uri "$baseuri/users/?`$select=displayName,UserPrincipalName,signInActivity" -Headers $Header -Method get -ContentType "application/json").value | select-object DisplayName, UserPrincipalName, @{Name = 'LastLogon'; Expression = { [datetime]::Parse($_.SignInActivity.lastSignInDateTime) } } | Where-Object { $_.LastLogon -lt $Date }
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | select-object Displayname, @{Name = 'LastLogon'; Expression = { [datetime]::Parse($_.approximateLastSignInDateTime) } }

   
$OldObjects = [PSCustomObject]@{
    Users   = $UserList | where-object { $_.LastLogon -ne $null }
    Devices = $devicesList | Where-Object { $_.LastLogon -lt $Date }
}

if (!$OldObjects) { write-host "No old objects found in any tenant" } else { write-host "Old objects found."; $Oldobjects }

All tenants script

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
########################## Script Settings  ############################
$Date = (get-date).AddDays((-90))
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
$OldObjects = foreach ($Tenant in $Tenants) {

    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    write-host "$($Tenant.Displayname): Starting process." -ForegroundColor Green
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }
    write-host " $($Tenant.Displayname): Grabbing all Users that have not logged in for 90 days." -ForegroundColor Green
    $UserList = (Invoke-RestMethod -Uri "$baseuri/users/?`$select=displayName,UserPrincipalName,signInActivity" -Headers $Header -Method get -ContentType "application/json").value | select-object DisplayName,UserPrincipalName,@{Name='LastLogon';Expression={[datetime]::Parse($_.SignInActivity.lastSignInDateTime)}} | Where-Object { $_.LastLogon -lt $Date }
    $devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | select-object Displayname,@{Name='LastLogon';Expression={[datetime]::Parse($_.approximateLastSignInDateTime)}}

   
    [PSCustomObject]@{
        Users = $UserList | where-object {$_.LastLogon -ne $null}
        Devices = $devicesList | Where-Object {$_.LastLogon -lt $Date}
    }
}

if(!$OldObjects) { write-host "No old objects found in any tenant"} else { write-host "Old objects found."; $Oldobjects}

And that’s it! as always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Active Directory Health

Some time ago I wrote a blog about monitoring Active Directory replication. A couple of days ago a friend in Slack asked me if I have anything for monitoring the entire general health of a domain controller, and not just replication.

So I researched some options, I found this blog by Adam, which was 90% of what I needed. I just wanted a script that was slightly more complete – Not just the health is important but also specific settings are.

For example; we want the replication time to be under 30 minutes, we want the recycle bin to always be active, and password complexity should be enabled everywhere. To monitor all of this, I’ve built the script below.

The Script

$DiagInfo = dcdiag
$DCDiagResult = $Diaginfo | select-string -pattern '\. (.*) \b(passed|failed)\b test (.*)' | foreach {
    $obj = @{
        TestName   = $_.Matches.Groups[3].Value
        TestResult = $_.Matches.Groups[2].Value
        Entity     = $_.Matches.Groups[1].Value
    }
    [pscustomobject]$obj
}

$DCDiagStatus = foreach ($FailedResult in $DCDiagResult | Where-Object { $_.Testresult -ne "passed" }) {
    "DC diag test not succesfull on entity $($FailedResult.entity) - $($FailedResult.testname)"
}
if(!$DCDiagStatus){ $DCDiagStatus = "Healthy. No DCDiag Tests failed" }

$ReplicationSchedule = Get-ADReplicationSiteLink -filter *
$ReplicationSchedStatus = foreach ($Replication in $ReplicationSchedule) {
    if ($replication.ReplicationFrequencyInMinutes -gt 30) { "Potentional replication schedulde issue. $($Replication.name) has a replication schedulde of $($replication.ReplicationFrequencyInMinutes)" }
}
if (!$ReplicationSchedStatus) { $ReplicationSchedStatus = "Healthy - Replication Schedulde for all sites is lower than 30 minutes" }

$PasswordPolcy = Get-ADDefaultDomainPasswordPolicy
if ($PasswordPolcy.complexityEnabled -ne $true) { $PasswordComplexityHealthy = "Unhealthy - Password Complexity is disabled" }else {
    $PasswordComplexityHealthy = "Healthy - Password Complxity is enabled" 
}

$ADRecycler = Get-ADOptionalFeature -Filter 'name -like "Recycle Bin Feature"'
if (!$ADRecycler.enabledscopes) { $ADRecyclerHealth = "Unhealthy. AD Recycle Bin Feature is disabled" } else { $ADRecyclerHealth = "Healthy - Recycle bin is enabled." }

$DCDiagStatus 
$ReplicationSchedStatus
$PasswordComplexityHealthy
$ADRecyclerHealth

So load this up in your RMM, check the values of the bottom 4 variables and done. ūüôā Active Directory Monitoring made easy.

As always, Happy PowerShelling!

Automating with PowerShell: Using the new Autotask REST API

So I’m a bit later than normal with blogging, that’s mostly because I was working on this project a little longer than usual. Autotask recently released update 2020.2 and this update includes a new REST API.

This is super cool, because the old API was a SOAP api and terribly inconvenient to actively use. To help people with using the new Autotask API I’ve created a module. The module is still in alpha/beta but you can download it from the PSGallery now.

The project page is here. Feel free to report any issues or do a pull request if you want to help develop the module! Just be prepared for breakage in the first couple of weeks, I’m still working on finding the best methods ūüôā

 Installation instructions

The module is published to the PSGallery, download it using:

   install-module AutotaskAPI

Usage

To¬†get¬†items¬†using¬†the¬†Autotask¬†API¬†you’ll¬†first¬†have¬†to¬†add¬†the¬†authentication¬†headers¬†using¬†the`¬†Add-AutotaskAPIAuth`¬†function.

$Creds = get-credential    Add-AutotaskAPIAuth -ApiIntegrationcode 'ABCDEFGH00100244MMEEE333 -credentials $Creds

When the command runs, You will be asked for credentials. Using these we will try to decide the correct webservices URL for your zone based on the email address. If this fails you must manually set the webservices URL.

Add-AutotaskBaseURI -BaseURI https://webservices1.autotask.net/atservicesrest

The Base URI value has tab completion to help you find the correct one easily.

To find resources using the API, execute the Get-autotaskAPIResource function. For the Get-AutotaskAPIResource function you will need either the ID of the resource you want to retrieve, or the JSON SearchQuery you want to execute. 

Examples


To find the company with ID 12345

Get-AutotaskAPIResource -Resource Companies -ID 12345

 To get all companies that are Active:

Get-AutotaskAPIResource -Resource Companies -SearchQuery "{filter='active -eq True'}"


To create a new company, we can either make the entire JSON body ourselves, or use the New-AutotaskBody function.

$Body = New-AutotaskBody -Definitions CompanyModel 


 This creates a body for the model Company. Definitions can be tab-completed. The body will contain all expected values. If you want an empty body instead use:

  $Body = New-AutotaskBody -Definitions CompanyModel -NoContent

After setting the values for the body you want, execute: New-AutotaskAPIResource -Resource Companies -Body $body

Contributions

Feel free to send pull requests or fill out issues when you encounter any.

Automating with PowerShell: an Azure DynDNS replacement.

We’ve been using DynDNS Managed DNS for a long time. We use Managed DNS to offer dynamically updating DNS records for clients with either on-site services, or where we believe that dynamic updating of records is needed.

Oracle has bought DynDNS somewhere in 2019 and decided to slowly start killing off the DynDNS Managed DNS services. This has caused us to look for a different solution. Of course, I immediately thought of Azure, with an Azure DNS hosted zone.

The only issue was that I had some constraints;

  • I wanted to be able to keep using the clients we are using, sometimes built into devices such as the Unifi USG Gateway
  • I needed to have the ability to dynamically create records, instead of always having to do this manually.
  • I also hoped for a method that would allow us to authenticate with an API key, instead of a username/password.
  • and of course a business requirement; I needed to get this for the same price or lower than Managed DNS.

After looking into it for some moment, I immediately found the business constraint would be no issue. Azure DNS zones are extremely cheap. Combined with an Azure Function I would be able to fill in all other issues. The total price for our solution is less than 2‚ā¨ a month.(Azure Calculator) That’s a big difference with the 200‚ā¨ a month we paid for Managed DNS.

So, lets start the setup and get our own DynDNS service going!

Setup

First we’ll need a domain name. I’ll leave it up to you where you’ll get the domain. After buying the domain, click on this link to create a new Azure DNS Zone. After creating the zone, point your NS servers to the listed NS servers Azure gives you.

Next, we’ll create our Azure function, you can jump to that by clicking this link. You can follow the default manual for this like with our AzGlue function. There are just some small differences:

  • When asked for the language, choose PowerShell Core 6.
  • After creating the application, click on “Identity” in the left hand menu. Enable the system Identity here.
  • Now click on ‘Configuration” and add a property called APIKey. This will be the key clients will use to update their DNS configuration. Put any string you’d like here.
  • Close this blade, and go to the Azure DNS zone we’ve created, and click on “Security”. Add the Function App as a “DNS Zone Contributor”.
  • Close everything, and return to the Function App. Now click on Functions -> Add -> HTTP Trigger
  • Name your trigger. I’ve called mine “AzDynaDNS”. Select the “Anonymous” Authorization level.
  • When the trigger is deployed, click on “Integration” and then on the “HTTP Trigger”. change the route template to “{*path}” and click save.
  • now click on “Code & Test” and paste in the PowerShell code found below.

This PowerShell code simulates the way the DynDNS client works, this means we can use this in conjunction with routers, or IoT devices that support the DynDNS client.

We’ve made some modifications though; we’re dumping the username, and only using an API key instead. If you do want to use username/password based authentication, or 1 API key per client, feel free to modify the script.

The Script

So the process is pretty straight forward; Whenever the Azure Function API is called using a DynDNS client, the script compares the password if it contains the API key, it then checks if the hostname exists, and creates it if not. It also compares the current IP to the new IP and updates this, if required.

using namespace System.Net
param($Request, $TriggerMetadata)
############### Settings ##############
#Only used if Autodetection fails, e.g. multiple DNS zones, etc.
$ResourceGroup = "YOURRESOURCEGROUPNAME"
$ZoneName = "YOURZONENAME.COM"
############### /Settings ##############
if (!$request.headers.authorization) {
    write-host "No API key"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "401 - No API token or token is invalid."
        })
    exit
}

$Base64APIKey = $request.headers.authorization -split " " | select-object -last 1
$APIKey = [Text.Encoding]::Utf8.GetString([Convert]::FromBase64String($Base64APIKey)) -split ":" | select-object -last 1

if ($APIKey -ne $env:APIKey) {
    write-host "Invalid API key"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "401 - No API token or token is invalid."
        })
    exit
}

$AutoDetect = (get-azresource -ResourceType "Microsoft.Network/dnszones")
If ($AutoDetect) { 
    write-host "Using Autodetect."
    $ZoneName = $Autodetect.name
    $ResourceGroup = $AutoDetect.ResourceGroupName
}


$Domain = $request.Query.hostname.split('.') | select-object -first 1
$NewIP = $request.Query.myip
write-host "$domain has $newip. Checking record and creating if required."
$ExistingRecord = Get-AzDnsRecordSet -ResourceGroupName $ResourceGroup -ZoneName $ZoneName -Name $Domain -RecordType A -ErrorAction SilentlyContinue
if (!$ExistingRecord) {
    write-host "Creating new record for $domain"
    New-AzDnsRecordSet -name $domain -Zonename $ZoneName -ResourceGroupName $ResourceGroup -RecordType A -Ttl 60 -DnsRecords (New-AzDnsRecordConfig -Ipv4Address $NewIP)
    Push-OutputBinding -Name Response -Value (  [HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "good $newip"
        })
    exit 
}
else {
    if ($ExistingRecord.Records[-1].Ipv4Address -ne $NewIP) { 
        write-host "Updating record for $domain - new IP is $newIP"
        $ExistingRecord.Records[-1].Ipv4Address = $NewIP
        Set-AzDnsRecordSet -RecordSet $ExistingRecord
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                StatusCode = [HttpStatusCode]::OK
                Body       = "good $newip"
            })
    }
    else {
        write-host "No Change - $domain IP is still $newIP"
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                StatusCode = [HttpStatusCode]::OK
                Body       = "nochg $NewIP"
            })
    }
    exit
}

So, now that we’ve done all of this, we can point our DynDNS settings towards the Azure Function, and you should see records getting created and updated:

And that’s it! an easy way to create a faster, and cheaper alternative to Oracles DynDNS Managed DNS service. As always, Happy PowerShelling.

Automating with PowerShell: Storing Office 365 audit logs longer than 90 days

A friend of mine recently bumped into an issue; his client wanted to know when a specific user logged on for the last time. The problem was that he did not have the unified audit log enabled, but even if he did the time-span was too long. We got lucky at using just the mailbox audit log over the past days so he could help his clients. He still worried about the Unified Audit logs and only having 90 days of logging.

I’ve told him about a couple of SIEM products, including Azure Sentinel which is able to ingest logs from Office 365. The problem is that for SIEM tooling you require a lot of a setup and a team to keep everything running smoothly, next to that most SIEMs aren’t really focused on the MSP-workflow. It was a bit overkill for what he wanted too.

So first I helped him setup the unified audit log for all his clients, and afterwards shared a script with him to store the audit logs in a readable format, with both a .CSV file and a HTML representation.

You can run this script in an Azure Runbook, Function, or just scheduled on a secure workstation.

The script downloads the information over the previous 1.2 days. We do this to cover the gap just in case the script runs a bit longer than expected.

The Script

As always, we’re using the Secure Application model. The script creates the folder structure for you.

# Write to the Azure Functions log stream.
Write-Host "Start at $(get-date)"
##########################################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'YoursecretySecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'verylongtoken'
$ExchangeRefreshToken = 'anotherverylongtoken'
$UPN = "any-valid-partner-upn"
$outputfolder = "D:\home\site\wwwroot\reports"
$ModulePath = "D:\home\site\wwwroot\AuditLogRetrieval\Modules"
##########################################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken
#Logged in. Moving on to creating folders and getting data.
$folderName = (Get-Date).tostring("yyyy-MM-dd")
New-item -Path $outputfolder -ItemType Directory -Name $folderName -Force
$customers = Get-AzureADContract -All:$true
foreach ($customer in $customers) {
    New-item "$($outputfolder)" -name $Customer.DisplayName -ItemType Directory -Force
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.CustomerContextId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    Import-PSSession $session -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog"

    $startDate = (Get-Date).AddDays( - '1.2')
    $endDate = (Get-Date)
    $Logs = @()
    Write-Host "Retrieving logs for $($customer.displayname)" -ForegroundColor Blue
    do {
        $logs += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.displayname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
        Write-Host "Retrieved $($logs.count) logs" -ForegroundColor Yellow
    }while ($Logs.count % 5000 -eq 0 -and $logs.count -ne 0)


    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $ObjLogs = foreach ($Log in $Logs) {
        $log.auditdata | convertfrom-json
    }


    $PreContent = @"
<H1> $($Customer.DisplayName) - Audit Log from $StartDate until $EndDate </H1><br>
 
<br> Please note that this log is not complete - It is a representation where fields have been selected that are most commonly filtered on. .<br>
To analyze the complete log for this day, please click here for the complete CSV file log: <a href="$($folderName).csv"/>CSV Logbook</a>
<br/>
<br/>
 
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
    $head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
    #$ObjLogs
    $Logs | export-csv "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).csv" -NoTypeInformation
    $ObjLogs | Select-object CreationTime, UserID, Operation, ResultStatus, ClientIP, Workload, ClientInfoString | Convertto-html -head $head -PreContent $PreContent | out-file "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).html"
  
    write-host "Generating root HTML file with all dates" -ForegroundColor Green
    $Items = get-childitem "$($outputfolder)\$($Customer.DisplayName)" -Filter "*.html"
    $tableheader = "<table><tr><th>Date</th><th>HTML Report</th><th>CSV Report</th></tr>" 
    $URLS = foreach ($item in $Items) {
        @"
        <tr><td>$($item.basename)</td><td><a href=`"$($item.basename).html`"/>HTML Report</a></td><td><a href=`"$($item.basename).CSV`"/>CSV Report</a></td></tr>
"@
    }

    $Head, $tableheader, $URLS | out-file "$($outputfolder)\$($Customer.DisplayName)\index.html"
}

write-host "Generating index file for all clients." -ForegroundColor Green
$tableheader = "<table><tr><th>Customer Name</th><th>Onmicrosoft domain</th><th>Reports</th></tr>" 
$customerhtml = foreach ($customer in $customers) {
    @"
    <tr><td>$($customer.DisplayName)</td><td>$($customer.DefaultDomainName)</td><td><a href=`"$($customer.DisplayName)\index.html`"/>Reports</a></td></tr>
"@

}

$Head, $tableheader, $customerhtml | out-file "$($outputfolder)\index.html"
write-host "Ended at $(Get-date)"

And that’s it! an easy way to store the log files for longer than 90 days, and not have the investment of a SIEM. I’d still suggest looking into a SIEM for the long run though. As always, Happy PowerShelling!

Automating with PowerShell: Using the Secure Application model updates.

I have a feeling I might be giving people a blogging overdose, but I’ve been playing with so much cool stuff the last couple of days. So lets get the ball rolling; I’ve finally found a method to connect to the SCC succesfully using the Secure Application model.

I’ve also found that non-partners can use the Secure Application Model too, for Exchange and the SCC. This is thanks to some people in the PowerShell Discord that inspired me to get to coding. ūüôā

First off, partners can still use the older blog here to use the secure application model. This script is targeted to Microsoft Partners.

So, what exactly is the Secure Application Model?

The Secure application model is a method of connecting to Office365 services by using oauth instead of a regular username/password combination. By using oauth you use tokens instead. These tokens have a specific life-time and are revoked if they are not used.

The great benefit it gives is that you can run headless scripts, while still having MFA enabled. You won’t need to authenticate with MFA each time the script runs.

Non-Microsoft partners

To get started, you’ll need to give consent to the Exchange Application for your tenant. Microsoft uses the well-known-application-id “a0c73c16-a7e3-4564-9a95-2bdf47383716” for this. You can give consent by executing the following code:

$Exchangetoken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716' -Scopes 'https://outlook.office365.com/.default' -Tenant $TenantID -UseDeviceAuthentication
write-host "Exchange Token: $($ExchangeToken.RefreshToken)"

After this, you should store this token somewhere safe like an Azure Keyvault or password manager. With this token we can start connecting to resources.

Connecting to the Security center can be done with the following code. If you want to connect to both Exchange, and the Security center you will have to use a different token for each, as the token gets invalidated after use by one of the applications.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic
import-session $SccSession -disablenamechecking -allowclobber

To connect to Exchange, you can use this code.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $Exchangetoken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
Import-PSSession $session -AllowClobber -DisableNameChecking

And that’s it for non partners. With this you can run unattended, headless scripts for O365 as a non-microsoft partner. You will need to install the PartnerCenter module to create the authentication tokens.

Partner Methods

After you collect your tokens, you can use this method to connect to the Security Center using the Secure Application Model for partners. This allows your to connect to each tenant under your administration.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach($customer in $customers){
$customerId = $customer.DefaultDomainName
$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
$SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic
import-pssession $SccSession -disablenamechecking -allowclobber
#YourCommands here

#/End of Commands

}

The rest of the partner methods remain the same, unless you want to connect to both the Security center and Exchange at the same time, then use the following code.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {

    $customerId = $customer.DefaultDomainName

    write-host "Connecting to the Security Center for client $($customer.name)"
    $SCCToken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
    $SCCTokenValue = ConvertTo-SecureString "Bearer $($SCCToken.AccessToken)" -AsPlainText -Force
    $SCCcredential = New-Object System.Management.Automation.PSCredential($upn, $SCCTokenValue)
    $SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $SCCcredential -AllowRedirection -Authentication Basic
    import-session $SccSession -disablenamechecking -allowclobber
    #YourCommands here

    #/End of Commands

    Remove-session $SccSession
    write-host "Connecting to the Exchange managed console for client $($customer.name)"

    Write-host "Enabling all settings for $($Customer.Name)" -ForegroundColor Green
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credentialExchange = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

    $ExchangeOnlineSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credentialExchange -Authentication Basic -AllowRedirection -erroraction Stop
    Import-PSSession -Session $ExchangeOnlineSession -AllowClobber -DisableNameChecking
    #YourCommands here

    #/End of Commands
    Remove-PSSession $ExchangeOnlineSession
}

And that’s it! I hope that helps partners and non-partners alike. As always, Happy PowerShelling. ūüôā