Category Archives: Series: PowerShell Monitoring

Documenting and monitoring blogs updates

No new blog today, its officially a bank holiday and I’m enjoying the sun 🙂 I did make sure not to leave my readers empty handed. A bunch of my blogs got a little bit outdated, so I decided to update them.

The following blogs have been updated:

O365 blogs

I’ve updated the Secure Application Model blog with a method to retrieve new tokens, I’ve also fixed small logic bugs 🙂

Also I’ve solved some html encoding issues with the Faster Partner Portal blog. I added the read-only MFA portal so its easier to find out which user has enabled and which not

The easier to read and automatic downloading of the audit logs has also been updated.

Monitoring Blogs

Since Microsoft decided to change the names for Office, I’ve updated the blog to monitor and install office C2R updates. My friend Stan was kind of enough to share all the possible URL’s and names with me.

I’ve also updated the Dell DCU blog so it downloads the latest version from Dell itself, instead of having to create a ZIP file, for some extra ease of use.

The script about monitoring and deploying the client based VPNs has also been updated to allow more types of VPN to be deployed.

After some troubeshooting my friend Isaac found that somehow I copy and pasted some blogs into each other for the WOL enablement and monitoring scripts. We’ve fixed that and the current version also improved on the detection.

Documentation blogs

So a bunch of the documentation blogs have had minor updates. I think its easiest if you use the documentation scripts to grab the latest update by browsing the category. Even the most recent unifi infrastructure had a small logic mistake for the generic version.

Automation blogs

I also updated the warranty lookup script, the GPO Deployment alternative and the IT-Glue backup script. The last one now also generates an HTML file with the passwords per client, instead of just one general password file. I’d still advise you to be really careful with that one of course.

And last but not least; Github

I’ve finally created some public github repos as per popular request. The github repos can be found here. I’ll try to keep it up to date as much as possible and will also included some of my bigger projects in there; right now I am working on the new Autotask REST API that is going to be released in version 2020.2.

So that’s all updates, I hope you enjoy and as always, happy PowerShelling!

Monitoring with PowerShell: Preventing PowerShell based attacks (LoLBas)

In the last Huntress Tradecraft Tuesdays there was some discussion about using “Living of the land” techniques. Living of the land means using the tools available on the operation system to achieve access. There’s a lot of ways that bad actors are now using these system tools to deploy ransomware for example.

PowerShell is one of these tools; during Tradecraft Tuesday I’ve suggested using a technique to block some of these attacks. Some of my friends came to me afterwards for some more information so I figured I’d make a small blog about it 🙂

Most of the LoLBin and LoLBas techniques make use of PowerShell commands that execute a script directly in memory – This is a ‘file-less’ technique. These PowerShell commands can actually be blocked quite easily by changing their alias to something else. This does mean that scripts that utilize these commands might break too. I’ll be showing a way to work around that for your own scripts.

I like using the following steps to try and prevent these type of attacks. Just a small disclaimer: This is a tiny part of your security suite and you should not rely on this 100%. Using this you are locking one of the doors in your house. It won’t help if you keep all the others open.

Deployment script

So first we’ll start with a very small deployment script you can run on all your workstations. The script takes care of the following things:

  • Enables Script Block Logging.
    • Please note that with enabling script block logging, all executed scripts will be logged. If your password is in plain text it will also be shown in these logs. If you have scripts that use plain-text passwords or keys please exit them when script block logging is enabled.
  • Changes the alias of the most dangerous commands to “write-host” – nullifying the risk of these commands.
  • You can add or remove commands as you see fit. The ones in the script are just the most well known.
New-Item -Path "HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" -Force
Set-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" -Name "EnableScriptBlockLogging" -Value 1 -Force
#Set aliasses to something harmless
$DangerousCommands = @("iwr", "irm", "curl", "saps", "iex", "Invoke-Expression", "Invoke-RestMethod", "Invoke-WebRequest", "Invoke-RestMethod")
foreach ($Command in $DangerousCommands) {
    Set-Alias -Name $Command -Value "write-host" -Option AllScope -Force
}

So you’ve probably noticed that we’re actually overwriting these commands with an alias. Aliases take precedence over actual cmdlets. This is great for us but will cause scripts that use these commands to fail.

We can prevent this failure by running this script at the top of our execution scripts:

$DangerousCommands = @("iwr", "irm", "curl", "saps", "iex", "Invoke-Expression", "Invoke-RestMethod", "Invoke-WebRequest", "Invoke-RestMethod")
foreach ($Command in $DangerousCommands) {
    get-item alias:$Command | remove-item -Force
}

This will make the commands available again, except their shortest version such as “iwr” and “iex”. We’re not done yet though – We also want to see when people are doing bad stuff right? That’s why we have a monitoring script to load into your RMM too!

Monitoring script

So this monitoring script picks up the following:

  • If script logging is enabled. The results of this are stored in $ScriptBlockEnable
  • If the most dangerous commands are alliased to write-host, $AliasProtection contains the result.
  • If the PowerShell event logs in the previous hour contain one of the dangerous aliases. You could also add stuff like “HTTPS” to the dangerous aliases.

I’d suggest to run this script as much as possible with your RMM, so you get warned early when something phishy is going on.

$ScriptBlockLogging = get-ItemProperty -Path "HKLM:\SOFTWARE\Wow6432Node\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging"
$ScriptBlockEnable = if ($ScriptBlockLogging.EnableScriptBLockLogging -ne 1) { "Error - Script Block Logging is not enabled" } else { "Healthy - Script Block Logging is enabled" }

$DangerousCommands = @("iwr", "irm", "curl", "saps", "iex", "Invoke-Expression", "Invoke-RestMethod", "Invoke-WebRequest", "Invoke-RestMethod")
$Aliasses = get-alias | Where-Object { $_.name -in $DangerousCommands -and $_.ResolvedCommandName -ne "Write-Host" } 
if (!$Aliasses) {
    $AliasProtection = "Healthy - Dangerous commands are protected."
}
else {
    $AliasProtection = "Unhealthy - Dangerous commands are not protected. Please investigate."
}
$logInfo = @{ 
    ProviderName = "Microsoft-Windows-PowerShell"
    StartTime    = (get-date).AddHours(-2)
}
$PowerShellEvents = Get-WinEvent -FilterHashtable $logInfo | Select-Object TimeCreated, message
$PowerShellLogs = foreach ($Event in $PowerShellEvents) {

    foreach ($command in $DangerousCommands) {
        if ($Event.Message -like "*$Command*") { 
            [pscustomobject] @{
                TimeCreated      = $event.TimeCreated
                EventMessage     = $Event.message
                TriggeredCommand = $command

            } 
        }
    }

}

if(!$PowerShellLogs){
    $PowerShellLogs = "Healthy"
}

And that’s it! This should offer you a bit more protection from Lolbin/Lolbas and fileless attacks. As always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring the used MFA type for O365/Azure.

We all know it’s key to have your security hygiene in order, a large part of that is your multi factor authentication deployment. Having all users use MFA these days is a no-brainer, but not all types of MFA are made equal. For example; MFA via text-message is generally considered unsafe.

But even the entered OTP codes are somewhat unsafe when compared to physical tokens or push-messages. Spear-phishers are now using proxy solutions or simply social engineering to extract the OTP code from users.

I have the philosophy that everything should be secure by design. So I like to offer my clients the most secure solutions. This does mean we also keep an eye on how they configure multi-factor authentication. When we do this for them we enforce that they use the push message option, and don’t use unsafe alternatives. We disable these alternatives in the portal too so that they can’t be setup directly.

This doesn’t always work out. sometimes the client sets up their own MFA, we take over a client or sometimes people just make mistakes. This is why we monitor the actual MFA type they’ve set up and try to assist the client in choosing the more secure alternative. To do that, we use the following script I hope it helps you in securing your environment too.

All Tenants Script

So this script checks if the primary option is set to an unsafe one, and alerts if it is. You could also use it to detect if MFA is configured, and what methods are used.

As always we use the Secure Application Model so we can run this script headless.

$ApplicationId         = 'xxxx-xxxx-xxxx-xxxx-xxx'
$ApplicationSecret     = 'YOURSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'xxxxxx-xxxx-xxx-xxxx--xxx'
$RefreshToken          = 'LongResourcetoken'
$ExchangeRefreshToken  = 'LongExchangeToken'
$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
$MFAType = foreach ($customer in $customers) {
    $users = Get-MsolUser -TenantId $customer.tenantid -all

    foreach ($user in $users) {
        $primaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $true }).methodType } else { "MFA Disabled" } 
        $SecondaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $false }).methodType } else { "No Secondary Option enabled" } 
        [PSCustomObject]@{
            "DisplayName"   = $user.DisplayName
            "user"          = $user.UserPrincipalName
            "Primary MFA"   = $primaryMFA
            "Secondary MFA" = $SecondaryMFA
        }
    }
}

$UnSafeMFAUsers = $MFAType | Where-Object { $_.'Primary MFA' -like "*SMS*" -or $_.'Primary MFA' -like "*voice*" -or $_.'Primary MFA' -like "*OTP*" }

if (!$UnSafeMFAUsers) {
    $UnSafeMFAUsers = "Healthy"
} 

Single Tenant Script

This script pretty much does the same as above, but for a single tenant.

$ApplicationId         = 'xxxx-xxxx-xxxx-xxxx-xxx'
$ApplicationSecret     = 'YOURSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID              = 'xxxxxx-xxxx-xxx-xxxx--xxx'
$RefreshToken          = 'LongResourcetoken'
$ExchangeRefreshToken  = 'LongExchangeToken'
$TenantToCheck         = 'tenant.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 -eq $TenantToCheck}
$MFAType = foreach ($customer in $customers) {
    $users = Get-MsolUser -TenantId $customer.tenantid -all

    foreach ($user in $users) {
        $primaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $true }).methodType } else { "MFA Disabled" } 
        $SecondaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $false }).methodType } else { "No Secondary Option enabled" } 
        [PSCustomObject]@{
            "DisplayName"   = $user.DisplayName
            "user"          = $user.UserPrincipalName
            "Primary MFA"   = $primaryMFA
            "Secondary MFA" = $SecondaryMFA
        }
    }
}

$UnSafeMFAUsers = $MFAType | Where-Object { $_.'Primary MFA' -like "*SMS*" -or $_.'Primary MFA' -like "*voice*" -or $_.'Primary MFA' -like "*OTP*" }

if (!$UnSafeMFAUsers) {
    $UnSafeMFAUsers = "Healthy"
} 

Non Secure App Model script

So if you aren’t a Microsoft Partner but still want to execute this script, just use the following script.

Connect-MsolService
$customers = Get-MsolPartnerContract -All | where-object {$_.DefaultDomainName -eq $TenantToCheck}
$MFAType = foreach ($customer in $customers) {
    $users = Get-MsolUser -TenantId $customer.tenantid -all

    foreach ($user in $users) {
        $primaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $true }).methodType } else { "MFA Disabled" } 
        $SecondaryMFA = if ($null -ne $user.StrongAuthenticationUserDetails) { ($user.StrongAuthenticationMethods | Where-Object { $_.IsDefault -eq $false }).methodType } else { "No Secondary Option enabled" } 
        [PSCustomObject]@{
            "DisplayName"   = $user.DisplayName
            "user"          = $user.UserPrincipalName
            "Primary MFA"   = $primaryMFA
            "Secondary MFA" = $SecondaryMFA
        }
    }
}

$UnSafeMFAUsers = $MFAType | Where-Object { $_.'Primary MFA' -like "*SMS*" -or $_.'Primary MFA' -like "*voice*" -or $_.'Primary MFA' -like "*OTP*" }

if (!$UnSafeMFAUsers) {
    $UnSafeMFAUsers = "Healthy"
} 

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

Monitoring using PowerShell: Getting mailbox rules from the audit log

Some time ago I spoke about monitoring mailbox rules with PowerShell and how we’ve always used the “Get-inboxrule” cmdlet as delegate administrator to retrieve the rules and alert on them. Its been brought to my attention that recently API-created rules are no longer showing up using get-inboxrule.

so to resolve this, I’ve decided to rewrite the monitoring script for this by using the audit log instead. This script grabs the last day of the unified audit log and alerts if a new rule has been found. It will also alert you if the unified audit log is not enabled.

All Tenants Script

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$ExchangeRefreshToken = 'YourExchangeToken'
$RefreshToken = 'YourRefreshToken'
$UPN = "UPN-Used-To-Generate-Token"
##############################
$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
$logs = foreach ($customer in $customers) {

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    $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
    $s = import-PSSession $session -AllowClobber -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
if((Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled -eq $false){
     write-host "AuditLog is disabled for client $($customer.name)"
}

    $LogsTenant = @()
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    do {
        $logsTenant += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations "New-InboxRule", "Set-InboxRule", "UpdateInboxRules"
        Write-Host "Retrieved $($logsTenant.count) logs" -ForegroundColor Yellow
    }while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $LogsTenant
}
foreach($log in $logs){
$AuditData = $log.AuditData | ConvertFrom-Json
Write-Host "A new or changed rule has been found for user $($log.UserIds). The rule has the following info: $($Auditdata.Parameters | out-string)`n"
}
if(!$Logs){
    write-host "Healthy."
}

Single tenant script

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$ExchangeRefreshToken = 'YourExchangeToken'
$RefreshToken = 'YourRefreshToken'
$UPN = "UPN-Used-To-Generate-Token"
$ClientTenantName = "bla.onmicrosoft.com"
##############################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$customers = $ClientTenantName
$logs = foreach ($customer in $customers) {

    $startDate = (Get-Date).AddDays(-1)
    $endDate = (Get-Date)
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $ClientTenantName
    $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
    $s = import-PSSession $session -AllowClobber -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
if((Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled -eq $false){
     write-host "AuditLog is disabled for client $ClientTenantName)"
}

    $LogsTenant = @()
    Write-Host "Retrieving logs for $ClientTenantName)" -ForegroundColor Blue
    do {
        $logsTenant += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -ResultSize 5000 -StartDate $startDate -EndDate $endDate -Operations "New-InboxRule", "Set-InboxRule", "UpdateInboxRules"
        Write-Host "Retrieved $($logsTenant.count) logs" -ForegroundColor Yellow
    }while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0)
    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $LogsTenant
}
foreach($log in $logs){
$AuditData = $log.AuditData | ConvertFrom-Json
Write-Host "A new or changed rule has been found for user $($log.UserIds). The rule has the following info: $($Auditdata.Parameters | out-string)`n"
}
if(!$Logs){
    write-host "Healthy."
}

So that’s it! this should get -all- rules that have been created, including the ones by the old EWS API. Happy PowerShelling!

Automating with PowerShell: Creating named accounts

So this blog is attached to the MSP Security Summit presentation I’ve given. These scripts are some examples on how you could deploy named accounts with your RMM system. How to deploy the scripts as securely as possible really depends on your RMM system – Some systems allow you to pass passwords as secure strings, others require plain text but keep everything in memory to prevent the credentials from leaking.

These scripts are examples, modify them to your own environments as much as you want.

Script One: Creating a named Account

You can schedule this script using your RMM at your clients to create your named account. It will create a domain account if the machine is a domain controller, and it will create a local account if the machine is a server or workstation. I’d strongly recommend to use a different named account for workstations, than servers and domain controllers in case you use this version.

The script will expect a secure string as input for the password field. If your RMM does not support this, you’ll need to account for this.

param(
    $Username,
    $Password
)
function Set-NamedAccount ($Username, $Password, $type) {
    switch ($type) {
        'Local' {
            $ExistingUser = get-localuser $Username -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new user admin $username" -ForegroundColor green
                New-LocalUser -Name $Username -Password $Password -PasswordNeverExpires
                Add-LocalGroupMember -Member $Username -SID 'S-1-5-32-544'
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor Green
                Set-LocalUser -Name $Username -Password $Password
            }
        }
        'Domain' { 
            $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username } -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new domain admin for $username" -ForegroundColor Green
                New-ADUser -Name $Username -SamAccountName $Username -AccountPassword $Password -Enabled $True
                $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username }
                $Groups = @("Domain Admins", "Administrators", "Schema Admins", "Enterprise Admins")
                $groups | Add-ADGroupMember -members $ExistingUser -erroraction SilentlyContinue
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor green
                $ExistingUser | Set-adaccountpassword -newPassword $Password
            }
        }
    }
}

$DomainCheck = Get-CimInstance -ClassName Win32_OperatingSystem
switch ($DomainCheck.ProductType) {
    1 { Set-NamedAccount -Username $Username -Password $Password -type "Local" }
    2 { Set-NamedAccount -Username $Username -Password $Password -type "Domain" }
    3 { Set-NamedAccount -Username $Username -Password $Password -type "Local" }
    Default { write-warning -message "Could not get Server Type. Quitting script." }
}

Script Two: Creating a named Account with extra info

This named account script allows you to create a named account with a pseudo random part directly added to the username. Our RMM system allows us to add the customer ID from an external system. This makes sure you don’t use the same username across your entire client base and are still able remember the username for each client yourself. For example “JohnDoe-2123”

This also prevents a hacker from getting a credentials pair (Username+Password) and spraying them across other services.

param(
    $Username,
    $Password,
    $NameSeed
)
function Set-NamedAccount ($Username, $Password, $type) {
    switch ($type) {
        'Local' {
            $ExistingUser = get-localuser $Username -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new user admin $username" -ForegroundColor green
                New-LocalUser -Name $Username -Password $Password -PasswordNeverExpires
                Add-LocalGroupMember -Member $Username -SID 'S-1-5-32-544'
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor Green
                Set-LocalUser -Name $Username -Password $Password
            }
        }
        'Domain' { 
            $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username } -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "Creating new domain admin for $username" -ForegroundColor Green
                New-ADUser -Name $Username -SamAccountName $Username -AccountPassword $Password -Enabled $True
                $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username }
                $Groups = @("Domain Admins", "Administrators", "Schema Admins", "Enterprise Admins")
                $groups | Add-ADGroupMember -members $ExistingUser -erroraction SilentlyContinue
            }
            else {
                write-host "Setting password for admin $username" -ForegroundColor green
                $ExistingUser | Set-adaccountpassword -newPassword $Password
            }
        }
    }
}

$DomainCheck = Get-CimInstance -ClassName Win32_OperatingSystem
switch ($DomainCheck.ProductType) {
    1 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    2 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Domain" }
    3 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    Default { write-warning -message "Could not get Server Type. Quitting script." }
}

You could also change this script to randomize the password, so you get pseudo-random passwords at your clients. Just heed my warning that I also gave during the presentation; do not think of this as a replacement for MFA.

Script Three: Removing a named account

So after you start using named accounts you’ll probably have someone leave the company, removing the account can be done by executing the following script.

param(
    $Username,
)
function Remove-NamedAccount ($Username, $type) {
    switch ($type) {
        'Local' {
            $ExistingUser = get-localuser $Username -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "No such user found: $username" -ForegroundColor green
            }
            else {
                write-host "Deleting $username" -ForegroundColor Green
                Remove-LocalUser -Name $Username -Confirm:$false
            }
        }
        'Domain' { 
            $ExistingUser = get-aduser -filter * | Where-Object { $_.SamAccountName -eq $Username } -ErrorAction SilentlyContinue
            if (!$ExistingUser) { 
                write-host "No such user found: $username" -ForegroundColor Green
            }
            else {
                write-host "Deleting $username" -ForegroundColor green
                $ExistingUser | remove-aduser 
            }
        }
    }
}

$DomainCheck = Get-CimInstance -ClassName Win32_OperatingSystem
switch ($DomainCheck.ProductType) {
    1 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    2 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Domain" }
    3 { Set-NamedAccount -Username $Username+$NameSeed -Password $Password -type "Local" }
    Default { write-warning -message "Could not get Server Type. Quitting script." }
}

This deletes the entire user from both the local store, and the domain. You can choose to replace the removal with a disable policy using the same script.

And more?

Well, that’s it for named accounts. Just remember to keep good security hygiene and start monitoring for security based threats too; unknown admin logons, forbidden users such as service accounts, new users, shodan results, port scans, Privileged Group Changes and all that kinda stuff 🙂

And that’s it! I hope you enjoyed the presentation and if you have any questions, let me know. As always, Happy PowerShelling.

Automating with PowerShell: Automating Warranty information reporting.

One of the reddits I frequent has been seeing a lot of complaints lately about warranty information being incomplete or there’s complaints about the pricing of warranty information products. Most of these complaints are aimed at a specific product which is showing very shady sales tactics and general bad business practices lately.

I figured I would try to take up these complaints and solve them with a PowerShell script. This script grabs the warranty information for most major manufactures. It will generate a warranty report based on the input data. The input data can either be a CSV file or the Autotask PSA.

You can also upload the warranty date back to Autotask to keep your warranty information in sync too. Currently I’ve only made support for Autotask but I’m willing to create one for CW too if there is enough interest 🙂

So lets get started.

Prerequisites

Before we can dive into the script we’ll have to collect some API keys. These keys will be used to get the warranty information at the vendors, or there are some “gotcha’s” you must know beforehand.

When you have Dell as a vendor:

  • Go to the Dell TechDirect website and register if you do not yet have an account. Complete the enrollment.
  • After registration, browse to the Dell TechDirect API enrollment page and wait for approval. This is a manual procedure so can take a day or two.
  • When the approval has been given, request a new API key and save this in a secure location.

When you have HP as a vendor:

Officially the HP API has been disabled because it was getting hammered by requests. The unofficial API is still available though so this script is based on that API.

When you have Microsoft as a vendor:

So Microsoft officially does not have a warranty lookup tool, but I found the Surface Diagnostic App does have a way to have programmatic access to the warranty environment. I reverse engineered this method. This is an unofficial and unsupported API so it might not function in the future. I’m quite proud of this one because as far as I can see, people have only achieved MS warranty checks by evading the CAPTCHA.

When you have Lenovo as a vendor:

You don’t have to do anything 🙂 Lenovo has an open API.

The Script

So now that we have our API keys and data, we can start feeding the script information. This can be via a CSV file or via Autotask. The CSV file should have the following information:

serialnumber,vendor,client
1234,HP,contoso
1234,Dell,toiletpaperco
1234,MS,contoso
1234,Lenovo,johndoe inc

If you are using Autotask, fill in the API integration key from your API user, and log in when your username/password is requested.

$source = "CSV" #AT, CSV, ITG, CW
##### Sync Settings
$SyncWithSource = $true  #Sync status warranty dates/status back to PSA/Management system. Only works with dynamic sources like ITG and AT.
$OverwriteWarranty = $true #Overwrites the date already found in AT with the one based on this API, unless the API could not find information.
$CreateHTMLReport = $true #Creates an HTML report.
###### File locations
$ReportsLocation = "C:\temp\reports" #Only required if Reporting is enabled.
$sourcefile = "C:\temp\temp.csv" #only required if source is not autotask.
$ATLogPath = "C:\temp\AT.txt" #Only used to log which objects have been synced with AT as AT does not have a audit log.
##### AT API Settings
$ATAPIKey = "Your-API-Key-For-Autotask" #only required if source is Autotask.
##### ITG API Settings
$ITGAPIKey = "Your-API-Key-For-ITG"  #only required if source is ITG
$ITGAPIURL = "https://api.eu.itglue.com" #only required if source is ITG
##### CW API Settings
$CWAPIURL = "https://api-staging.connectwisedev.com/v4_6_release/apis/3.0" #https://developer.connectwise.com/Best_Practices/Manage_Cloud_URL_Formatting?mt-learningpath=manage
$CWApiKeyPublic = "CWPublicKey" #Only required if source is CW
$CWApiKeyPrivate = "CwPrivateKey" #Only required if source is CW
$CWcompanyid = "CompanyID_1" #Only required if source is CW
##### Warranty Vendor API Keys
$DellClientID = "Dell-Client-ID"
$DellClientSecret = "Dell-Client-Secret"



function get-HPWarranty([Parameter(Mandatory = $true)]$SourceDevice, $Client) {
    $MWSID = (invoke-restmethod -uri 'https://support.hp.com/us-en/checkwarranty/multipleproducts/' -SessionVariable 'session' -Method get) -match '.*mwsid":"(?<wssid>.*)".*'
    $HPBody = " { `"gRecaptchaResponse`":`"`", `"obligationServiceRequests`":[ { `"serialNumber`":`"$SourceDevice`", `"isoCountryCde`":`"US`", `"lc`":`"EN`", `"cc`":`"US`", `"modelNumber`":null }] }"
 
    $HPReq = Invoke-RestMethod -Uri "https://support.hp.com/hp-pps-services/os/multiWarranty?ssid=$($matches.wssid)" -WebSession $session -Method "POST" -ContentType "application/json" -Body $HPbody
    if ($HPreq.productWarrantyDetailsVO.warrantyResultList.obligationStartDate) {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = $hpreq.productWarrantyDetailsVO.warrantyResultList.warrantyType | Out-String
            'StartDate'             = $hpreq.productWarrantyDetailsVO.warrantyResultList.obligationStartDate | sort-object | select-object -last 1
            'EndDate'               = $hpreq.productWarrantyDetailsVO.warrantyResultList.obligationEndDate | sort-object | select-object -last 1
            'Warranty Status'       = $hpreq.productWarrantyDetailsVO.obligationStatus
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
}
function get-DellWarranty([Parameter(Mandatory = $true)]$SourceDevice, $client) {
    $today = Get-Date -Format yyyy-MM-dd
    $AuthURI = "https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
    if ($Global:TokenAge -lt (get-date).AddMinutes(-55)) { $global:Token = $null }
    If ($null -eq $global:Token) {
        $OAuth = "$global:DellClientID`:$global:DellClientSecret"
        $Bytes = [System.Text.Encoding]::ASCII.GetBytes($OAuth)
        $EncodedOAuth = [Convert]::ToBase64String($Bytes)
        $headersAuth = @{ "authorization" = "Basic $EncodedOAuth" }
        $Authbody = 'grant_type=client_credentials'
        $AuthResult = Invoke-RESTMethod -Method Post -Uri $AuthURI -Body $AuthBody -Headers $HeadersAuth
        $global:token = $AuthResult.access_token
        $Global:TokenAge = (get-date)
    }

    $headersReq = @{ "Authorization" = "Bearer $global:Token" }
    $ReqBody = @{ servicetags = $SourceDevice }
    $WarReq = Invoke-RestMethod -Uri "https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements" -Headers $headersReq -Body $ReqBody -Method Get -ContentType "application/json"
    $warlatest = $warreq.entitlements.enddate | sort-object | select-object -last 1 
    $WarrantyState = if ($warlatest -le $today) { "Expired" } else { "OK" }
    if ($warreq.entitlements.serviceleveldescription) {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = $warreq.entitlements.serviceleveldescription -join "`n"
            'StartDate'             = (($warreq.entitlements.startdate | sort-object -Descending | select-object -last 1) -split 'T')[0]
            'EndDate'               = (($warreq.entitlements.enddate | sort-object | select-object -last 1) -split 'T')[0]
            'Warranty Status'       = $WarrantyState
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
}
function get-LenovoWarranty([Parameter(Mandatory = $true)]$SourceDevice, $client) {
    $today = Get-Date -Format yyyy-MM-dd
    $APIURL = "https://ibase.lenovo.com/POIRequest.aspx"
    $SourceXML = "xml=<wiInputForm source='ibase'><id>LSC3</id><pw>IBA4LSC3</pw><product></product><serial>$SourceDevice</serial><wiOptions><machine/><parts/><service/><upma/><entitle/></wiOptions></wiInputForm>"
    $Req = Invoke-RestMethod -Uri $APIURL -Method POST -Body $SourceXML -ContentType 'application/x-www-form-urlencoded'
    if ($req.wiOutputForm) {
        $warlatest = $Req.wiOutputForm.warrantyInfo.serviceInfo.wed | sort-object | select-object -last 1 
        $WarrantyState = if ($warlatest -le $today) { "Expired" } else { "OK" }
         
        $WarObj = [PSCustomObject]@{
            'Serial'                = $Req.wiOutputForm.warrantyInfo.machineinfo.serial
            'Warranty Product name' = $Req.wiOutputForm.warrantyInfo.machineinfo.productname -join "`n"
            'StartDate'             = $Req.wiOutputForm.warrantyInfo.serviceInfo.warstart | sort-object -Descending | select-object -last 1
            'EndDate'               = $Req.wiOutputForm.warrantyInfo.serviceInfo.wed | sort-object | select-object -last 1
            'Warranty Status'       = $WarrantyState
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
 
 
}
function Get-MSWarranty([Parameter(Mandatory = $true)]$SourceDevice, $client) {
    $body = ConvertTo-Json @{
        sku          = "Surface_"
        SerialNumber = $SourceDevice
        ForceRefresh = $false
    }
    $today = Get-Date -Format yyyy-MM-dd
    $PublicKey = Invoke-RestMethod -Uri 'https://surfacewarrantyservice.azurewebsites.net/api/key' -Method Get
    $AesCSP = New-Object System.Security.Cryptography.AesCryptoServiceProvider 
    $AesCSP.GenerateIV()
    $AesCSP.GenerateKey()
    $AESIVString = [System.Convert]::ToBase64String($AesCSP.IV)
    $AESKeyString = [System.Convert]::ToBase64String($AesCSP.Key)
    $AesKeyPair = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$AESIVString,$AESKeyString"))
    $bodybytes = [System.Text.Encoding]::UTF8.GetBytes($body)
    $bodyenc = [System.Convert]::ToBase64String($AesCSP.CreateEncryptor().TransformFinalBlock($bodybytes, 0, $bodybytes.Length))
    $RSA = New-Object System.Security.Cryptography.RSACryptoServiceProvider
    $RSA.ImportCspBlob([System.Convert]::FromBase64String($PublicKey))
    $EncKey = [System.Convert]::ToBase64String($rsa.Encrypt([System.Text.Encoding]::UTF8.GetBytes($AesKeyPair), $false))
     
    $FullBody = @{
        Data = $bodyenc
        Key  = $EncKey
    } | ConvertTo-Json
     
    $WarReq = Invoke-RestMethod -uri "https://surfacewarrantyservice.azurewebsites.net/api/v2/warranty" -Method POST -body $FullBody -ContentType "application/json"
    if ($WarReq.warranties) {
        $WarrantyState = foreach ($War in ($WarReq.warranties.effectiveenddate -split 'T')[0]) {
            if ($War -le $today) { "Expired" } else { "OK" }
        }
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = $WarReq.warranties.name -join "`n"
            'StartDate'             = (($WarReq.warranties.effectivestartdate | sort-object -Descending | select-object -last 1) -split 'T')[0]
            'EndDate'               = (($WarReq.warranties.effectiveenddate | sort-object | select-object -last 1) -split 'T')[0]
            'Warranty Status'       = $WarrantyState
            'Client'                = $Client
        }
    }
    else {
        $WarObj = [PSCustomObject]@{
            'Serial'                = $SourceDevice
            'Warranty Product name' = 'Could not get warranty information'
            'StartDate'             = $null
            'EndDate'               = $null
            'Warranty Status'       = 'Could not get warranty information'
            'Client'                = $Client
        }
    }
    return $WarObj
}
 
function  Get-WarrantyCSV($sourcefile) {
    write-host "Source is CSV file. Grabbing all devices." -ForegroundColor Green
    $CSVLines = import-csv -path $sourcefile -Delimiter ","
    $warrantyObject = foreach ($Line in $CSVLines) {
        switch ($line.vendor) {
            HP { get-HPWarranty -SourceDevice $line.SerialNumber -Client $line.client }
            Dell { get-DellWarranty -SourceDevice $line.SerialNumber -Client $line.client }
            Lenovo { get-LenovoWarranty -SourceDevice $line.SerialNumber -Client $line.client }
            MS { Get-MSWarranty -SourceDevice $line.SerialNumber -Client $line.client }
        }
    }
    return $warrantyObject
}
 
function Get-WarrantyAutotask($APIKey) {
    write-host "Source is Autotask." -ForegroundColor Green
    If (Get-Module -ListAvailable -Name "Autotask") { Import-module "Autotask" } Else { install-module "Autotask" -Force }
    $Credential = Get-Credential -Message "Enter your Autotask Credentials"
    remove-module autotask
    Import-Module Autotask -ArgumentList $Credential, $global:ATAPIKey
    write-host "Logging into Autotask. Grabbing all client information." -ForegroundColor "Green"
    $AllClients = $AllAccounts = Get-AtwsAccount -All | Where-Object { $_.Active -eq $true }
    write-host "Client information found. Grabbing all devices" -ForegroundColor "Green"
    $AllDevices = Get-AtwsInstalledProduct -All | Where-Object { $_.Active -eq $true -and $null -ne $_.SerialNumber }
    write-host "Collecting information. This can take a long time." -ForegroundColor "Green"
    $i = 0
    $warrantyObject = foreach ($Device in $AllDevices) {
        $i++
        Write-Progress -Activity "Grabbing Warranty information" -status "Processing $($device.serialnumber). Device $i of $($Alldevices.Count)" -percentComplete ($i / $Alldevices.Count * 100)
        $Client = ($AllClients | Where-Object { $_.id -eq $device.AccountID }).AccountName
        #We use a guess-smart method for serialnumbers. 
        #Dell is always 7, Lenovo is always 8, 10 is HP, 12 is Surface. 
        #This is because we cannot safely find the manafacture in the AT info.
        switch ($device.SerialNumber.Length) {
            7 { $WarState = get-DellWarranty -SourceDevice $device.SerialNumber -client $Client }
            8 { $WarState = get-LenovoWarranty -SourceDevice $device.SerialNumber -client $Client }
            10 { $WarState = get-HPWarranty  -SourceDevice $device.SerialNumber -client $Client }
            12 { $WarState = Get-MSWarranty  -SourceDevice $device.SerialNumber -client $Client }
        }
        if ($script:SyncWithSource -eq $true) {
            switch ($script:OverwriteWarranty) {
                $true {
                    if ($null -ne $warstate.EndDate) {
                        $device | Set-AtwsInstalledProduct -WarrantyExpirationDate $warstate.EndDate
                        "$Client / $($device.SerialNumber) with AT ID $($device.id) warranty has been overwritten to $($warstate.EndDate)" | out-file $script:ATLogPath -Append -Force
                    }
                     
                }
                $false { 
                    if ($null -eq $device.WarrantyExpirationDate -and $null -ne $warstate.EndDate) { 
                        $device | Set-AtwsInstalledProduct -WarrantyExpirationDate $warstate.EndDate 
                        "$Client / $($device.SerialNumber) with AT ID $($device.id) warranty has been set to $($warstate.EndDate)" | out-file $script:ATLogPath -Append -Force
                    } 
                }
            }
        }
        $WarState
    }
 
    return $warrantyObject
}
function  Get-WarrantyITG() {
    write-host "Source is IT-Glue. Grabbing all devices." -ForegroundColor Green
    If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
        Import-module ITGlueAPI 
    }
    Else { 
        Install-Module ITGlueAPI -Force
        Import-Module ITGlueAPI
    }
    #Settings IT-Glue logon information
    Add-ITGlueBaseURI -base_uri $Global:ITGAPIURL
    Add-ITGlueAPIKey  $Global:ITGAPIKey
    write-host "Getting IT-Glue configuration list" -foregroundColor green
    $i = 0
    $AllITGlueConfigs = @()
    do {
        $AllITGlueConfigs += (Get-ITglueconfigurations -page_size 1000 -page_number $i).data
        $i++
        Write-Host "Retrieved $($AllITGlueConfigs.count) configurations" -ForegroundColor Yellow
    }while ($AllITGlueConfigs.count % 1000 -eq 0 -and $AllITGlueConfigs.count -ne 0) 
     
    $warrantyObject = foreach ($device in $AllITGlueConfigs) {
        $i++
        Write-Progress -Activity "Grabbing Warranty information" -status "Processing $($device.attributes.'serial-number'). Device $i of $($AllITGlueConfigs.Count)" -percentComplete ($i / $AllITGlueConfigs.Count * 100)
        $Client = ($AllClients | Where-Object { $_.id -eq $device.AccountID }).AccountName
        $client = $device.attributes.'organization-name'
        switch ($device.attributes.'serial-number'.Length) {
            7 { $WarState = get-DellWarranty -SourceDevice $device.attributes.'serial-number' -client $Client }
            8 { $WarState = get-LenovoWarranty -SourceDevice $device.attributes.'serial-number' -client $Client }
            10 { $WarState = get-HPWarranty  -SourceDevice $device.attributes.'serial-number' -client $Client }
            12 { $WarState = Get-MSWarranty  -SourceDevice $device.attributes.'serial-number' -client $Client }
        }
        if ($script:SyncWithSource -eq $true) {
            $FlexAssetBody = @{
                "type"       = "configurations"
                "attributes" = @{
                    'warranty-expires-at' = $warstate.EndDate
                } 
            }
            switch ($script:OverwriteWarranty) {
                $true {
                    if ($null -ne $warstate.EndDate) {
                        Set-ITGlueConfigurations -id $device.id -data $FlexAssetBody
                    }
                     
                }
                $false { 
                    if ($null -eq $device.WarrantyExpirationDate -and $null -ne $warstate.EndDate) { 
                        Set-ITGlueConfigurations -id $device.id -data $FlexAssetBody
                    } 
                }
            }
        }
        $WarState
    }
    return $warrantyObject
}
 
function  Get-WarrantyCW() {
    write-host "Source is Connectwise Manage. Grabbing all devices." -ForegroundColor Green
    $Base64Key = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($Global:CWcompanyid)+$($Global:CWApiKeyPublic):$($global:CWApiKeyPrivate)"))
 
    $Header = @{
        'clientId'      = '3613dda6-fa25-49b9-85fb-7aa2b628befa' #This is the warranty script client id. Do not change. 
        'Authorization' = "Basic $Base64Key"
        'Content-Type'  = 'application/json'
    }
    $i = 0
    $Devices = @()
    do {
        $Devices += invoke-restmethod -headers $header -method GET -uri "$($Global:CWAPIURL)/company/configurations?pageSize=1000&page=$i"
        $i++
        Write-Host "Retrieved $($devices.count) configurations" -ForegroundColor Yellow
    }while ($devices.count % 1000 -eq 0 -and $devices.count -ne 0) 
 
    $warrantyObject = foreach ($device in $Devices) {
        $i++
        Write-Progress -Activity "Grabbing Warranty information" -status "Processing $($device.serialnumber). Device $i of $($devices.Count)" -percentComplete ($i / $Devices.Count * 100)
        $client = $device.company.name
        switch ($device.serialnumber.Length) {
            7 { $WarState = get-DellWarranty -SourceDevice $device.serialnumber -client $Client }
            8 { $WarState = get-LenovoWarranty -SourceDevice $device.serialnumber -client $Client }
            10 { $WarState = get-HPWarranty  -SourceDevice $device.serialnumber -client $Client }
            12 { $WarState = Get-MSWarranty  -SourceDevice $device.serialnumber -client $Client }
        }
        if ($script:SyncWithSource -eq $true) {
            if (!$device.warrantyExpirationDate) {
                $device | Add-Member -NotePropertyName "warrantyExpirationDate" -NotePropertyValue "$($WarState.enddate)T00:00:00Z"
            }
            else { 
                $device.warrantyExpirationDate = "$($WarState.enddate)T00:00:00Z"
            }
            $CWBody = $device | ConvertTo-Json
            switch ($script:OverwriteWarranty) {
                $true {
                    if ($null -ne $warstate.EndDate) {
                        invoke-restmethod -headers $header -method put -uri "$($Global:CWAPIURL)/company/configurations/$($device.id)" -body $CWBody
                    }
                     
                }
                $false { 
                    if ($null -eq $device.WarrantyExpirationDate -and $null -ne $warstate.EndDate) { 
                        invoke-restmethod -headers $header -method put -uri "$($Global:CWAPIURL)/company/configurations/$($device.id)" -body $CWBody
                    } 
                }
            }
        }
        $WarState
    }
    return $warrantyObject
}
 
 
switch ($source) {
    AT { $warrantyObject = Get-WarrantyAutotask -APIKey $ATAPIKey | Sort-Object -Property Client }
    CSV { $warrantyObject = Get-WarrantyCSV -Sourcefile $sourcefile | Sort-Object -Property Client }
    ITG { $warrantyObject = Get-WarrantyITG | Sort-Object -Property Client }
    CW { $warrantyObject = Get-WarrantyCW | Sort-Object -Property Client }
}
write-host "Done updating warrenties. Generating reports if required." -ForegroundColor Green
$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>Warranty 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>
"@
   
$PreContent = @"
<H1> Warranty Report </H1> <br>
   
Please consult the report for more information. you can use the search window to find a specific device, date, or warranty state.
<br/>
<br/>
    
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
   
 
if ($CreateHTMLReport -eq $true) {
    $CheckReportFolder = Test-Path($ReportsLocation)
    if (!$CheckReportFolder) { new-item -ItemType Directory -Path $ReportsLocation -Force | Out-Null }
    foreach ($client in $warrantyObject.client | Select-Object -Unique) {
        write-host "Generating report for $Client at $($ReportsLocation)\$client.html" -ForegroundColor Green
        $warrantyObject | Where-Object { $_.Client -eq $client } | convertto-html -Head $head -precontent $precontent | out-file "$($ReportsLocation)\$client.html"
    }
 
}

After executing the script, the HTML reports will look like this:

And that’s it! the script isn’t perfect yet and could use some more error handling which I’ll work on in the coming weeks but I really think this is a fantastic solution to get rid of warranty information providers that are asking big bucks. 🙂

So as always, Happy PowerShelling!

Update 1: Added ITGlue as source.

Update 2: Added CW as resource

Update 3: Added some better logic for the script, also the script is on Github now! https://github.com/KelvinTegelaar/PowerShellWarrantyReports

Monitoring with PowerShell: Monitoring Windows Performance Index

First things are first: I just got awarded the new and prestigious Azure Hero award. Im an Azure Content Hero, of which only 250 are awarded. I am super thankful and very excited for this as its a great recognition for my work on this blog. I really want to thank the people that nominated me.

So now that we have that out of the way, lets get to scripting. This time we’re doing some workstation experience monitoring, I like monitoring the actual user experience on machines. This gives us a change to actively improve the lives of users that are working on slow machines by preventively replacing them, or by notifying them we’re seeing performance problems.

The Windows Experience Index was introduced in Windows 7 as a method to gauge how well your computer is running both hardware and software wise. I like to run this script every couple of hours to grab the most recent result of ‘winsat’ test. This shows if a machine is slowing down etc. We also decided that any machine that scores lower than a 6.5 is going to be replaced, because be honest; who would want to work on a slow machine right?

The script

So this one is pretty straight forward, set your “failing score” and let it run. The script prints everything to the console as well, in case you want to run it on demand instead of via your RMM system.

$FailingThreshold = 6.5

$WinSatResults = Get-CimInstance Win32_WinSAT | Select-Object CPUScore, DiskScore, GraphicsScore, MemoryScore, WinSPRLevel

$WinSatHealth = foreach ($Result in $WinSatResults) {
    if ($Result.CPUScore -lt $FailingThreshold) { "CPU Score is $($result.CPUScore). This is less than $FailingThreshold" }
    if ($Result.DiskScore -lt $FailingThreshold) { "Disk Score is $($result.Diskscore). This is less than $FailingThreshold" }
    if ($Result.GraphicsScore -lt $FailingThreshold) { "Graphics Score is $($result.GraphicsScore). This is less than $FailingThreshold" }
    if ($Result.MemoryScore -lt $FailingThreshold) { "RAM Score is $($result.MemoryScore). This is less than $FailingThreshold" }
    if ($Result.WinSPRLevel -lt $FailingThreshold) { "Average WinSPR Score is $($result.winsprlevel). This is less than $FailingThreshold" }
}
if (!$WinSatHealth) {
    $AllResults = ($Winsatresults | out-string)
    $WinSatHealth = "Healthy. $AllResults"
}

And that’s it! short and sweet, and it allows for a bit more user experience monitoring. As always, Happy PowerShelling!

Documenting with PowerShell: Using PowerShell to create faster partner portal

I love having the ability to manage all clients from a single portal. My only issue is that the partner portal is quite error prone and sluggish, and it seems to get worse with each added client.

There are some more problems like only being able to find clients based on their name. So I’ve decided to make a quicker and a mostly simpler partner portal page. The page is a single HTML file with a search option. It allows you to access each of your clients portals using your credentials. The HTML page also allows search on all properties – I’ve added all domains in a hidden column so you can find clients based on the domain name too. Here’s how it looks:

There are two scripts; one that can run headless and uses the Secure App Model. Another that just uses your credentials.

Secure App Model Version

The reason I’ve made two versions is because we’re running a function page on azure that runs this script on a schedule.

Every day this page is updated for us and all my employees can access the page easily by browsing to the hosted page. I’ve of course made this page SSO with the Office365 credentials as described here. This way the page is always secured. Remember to change the last line to your own output folder.

#Related blog: https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/
########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
########################## Secure App Model Settings ############################
   
$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
$CustomerLinks = foreach ($customer in $customers) {
    $domains = (Get-MsolDomain -TenantId $customer.tenantid).Name -join ',' | Out-String
    [pscustomobject]@{
        'Client Name'            = $customer.Name
        'Client tenant domain'   = $customer.DefaultDomainName
        'O365 Admin Portal'      = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($customer.TenantId)&CSDEST=o365admincenter`">O365 Portal</a>"
        'Exchange Admin Portal'  = "<a target=`"_blank`" href=`"https://outlook.office365.com/ecp/?rfr=Admin_o365&exsvurl=1&delegatedOrg=$($Customer.DefaultDomainName)`">Exchange Portal</a>"
        'Azure Active Directory' = "<a target=`"_blank`" href=`"https://aad.portal.azure.com/$($Customer.DefaultDomainName)`" >AAD Portal</a>"
        'MFA Portal (Read Only)' = "<a target=`"_blank`" href=`"https://account.activedirectory.windowsazure.com/usermanagement/multifactorverification.aspx?tenantId=$($Customer.tenantid)&culture=en-us&requestInitiatedContext=users`" >MFA Portal</a>"
        'Sfb Portal'             = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($Customer.TenantId)&CSDEST=MicrosoftCommunicationsOnline`">SfB Portal</a>"
        'Teams Portal'           = "<a target=`"_blank`" href=`"https://admin.teams.microsoft.com/?delegatedOrg=$($Customer.DefaultDomainName)`">Teams Portal</a>"
        'Azure Portal'           = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)`">Azure Portal</a>"
        'Intune portal'          = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)/#blade/Microsoft_Intune_DeviceSettings/ExtensionLandingBlade/overview`">Intune Portal</a>"
        'Domains'                = "Domains: $domains"
    }
}
  
$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>LNPP - Lime Networks Partner Portal</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>
"@
  
$PreContent = @"
<H1> CyberDrain.com faster partner portal</H1> <br>
  
For more information, check <a href="https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/"/>CyberDrain.com</a>
<br/>
<br/>
   
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
  
  
$CustomerHTML = $CustomerLinks | ConvertTo-Html -head $head -PreContent $PreContent | Out-String
  
[System.Web.HttpUtility]::HtmlDecode($CustomerHTML) -replace "<th>Domains", "<th style=display:none;`">" -replace "<td>Domains", "<td style=display:none;`">Domains"  | out-file "C:\temp\index.html"

Non-Secure App Model version

This is a version that you can run on-demand, simply to create the HTML file and host it internally or if you don’t have rapid client expansion just run it whenever a client is added.

Connect-MsolService
$customers = Get-MsolPartnerContract -All
$CustomerLinks = foreach ($customer in $customers) {
    $domains = (Get-MsolDomain -TenantId $customer.tenantid).Name -join ',' | Out-String
    [pscustomobject]@{
        'Client Name'            = $customer.Name
        'Client tenant domain'   = $customer.DefaultDomainName
        'O365 Admin Portal'      = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($customer.TenantId)&CSDEST=o365admincenter`">O365 Portal</a>"
        'Exchange Admin Portal'  = "<a target=`"_blank`" href=`"https://outlook.office365.com/ecp/?rfr=Admin_o365&exsvurl=1&delegatedOrg=$($Customer.DefaultDomainName)`">Exchange Portal</a>"
        'Azure Active Directory' = "<a target=`"_blank`" href=`"https://aad.portal.azure.com/$($Customer.DefaultDomainName)`" >AAD Portal</a>"
        'MFA Portal (Read Only)' = "<a target=`"_blank`" href=`"https://account.activedirectory.windowsazure.com/usermanagement/multifactorverification.aspx?tenantId=$($Customer.tenantid)&culture=en-us&requestInitiatedContext=users`" >MFA Portal</a>"
        'Sfb Portal'             = "<a target=`"_blank`" href=`"https://portal.office.com/Partner/BeginClientSession.aspx?CTID=$($Customer.TenantId)&CSDEST=MicrosoftCommunicationsOnline`">SfB Portal</a>"
        'Teams Portal'           = "<a target=`"_blank`" href=`"https://admin.teams.microsoft.com/?delegatedOrg=$($Customer.DefaultDomainName)`">Teams Portal</a>"
        'Azure Portal'           = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)`">Azure Portal</a>"
        'Intune portal'          = "<a target=`"_blank`" href=`"https://portal.azure.com/$($customer.DefaultDomainName)/#blade/Microsoft_Intune_DeviceSettings/ExtensionLandingBlade/overview`">Intune Portal</a>"
        'Domains'                = "Domains: $domains"
    }
}
  
$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>LNPP - Lime Networks Partner Portal</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>
"@
  
$PreContent = @"
<H1> CyberDrain.com faster partner portal</H1> <br>
  
For more information, check <a href="https://www.cyberdrain.com/documenting-with-powershell-using-powershell-to-create-faster-partner-portal/"/>CyberDrain.com</a>
<br/>
<br/>
   
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
  
  
$CustomerHTML = $CustomerLinks | ConvertTo-Html -head $head -PreContent $PreContent | Out-String
  
[System.Web.HttpUtility]::HtmlDecode($CustomerHTML) -replace "<th>Domains", "<th style=display:none;`">" -replace "<td>Domains", "<td style=display:none;`">Domains"  | out-file "C:\temp\index.html"

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

Automating with PowerShell: Automatically uploading applications to intune tenants

So I’ve been doubting if I should make this blog. I found that others had already done this and maybe my method would just be redundant. After some slight convincing I figured my method does have its merits. One of them being that it uses the secure application model, and thus its easy to apply to all partner tenants for a CSP, the other benefit is that this could run headless as a completely automated solution.

Most MSPs create a baseline of applications for their clients that’s the same across the entire stack, 7zip, Chrome, things that we believe should be installed by default. This script allows you to apply that baseline across all your tenants.

So to get start we’ll have to do a couple of things, just to make sure you have everything:

  • Setup the secure app model, and collect the information you’ll need
  • Grab all the installers you want to use and put them all in a folder, I use C:\intune\Applications, so 7zip would be C:\intune\aplications\7-zip
  • for each application you’ll need a new app.json. To create the app.json, you can use the example below.
  • You’ll need Azcopy, and IntuneWinAppUtil. The script also download it for you, but please host the files yourselves. 🙂

Filling out the JSON actually is not that hard; for most applications you’ll only need to replace the Displayname, InstallCommandLine, UninstallCommandLine, and detection rules. If you need help on all the options I’d suggest the Graph API manual.

If you are using a path, or illegal character you can escape these by adding “\” infront of it.

Example JSON

{

  "displayName": "CyberDrain.com 7Zip",
  "installCommandLine": "ninite.exe /Select \"7-zip\" /silent /disableshortcuts",
  "uninstallCommandLine": "ninite.exe /Select \"7-zip\" /silent /uninstall",
  "description": "Ninite Pro to Install 7zip.",
  "developer": "CyberDrain.com",
  "owner": "Cyberdrain.com",
  "informationUrl": "https://cyberdrain.com",
  "privacyInformationUrl": "https://cyberdrain.com",
  "fileName": "IntunePackage.intunewin",
  "@odata.type": "#microsoft.graph.win32LobApp",
  "applicableArchitectures": "x86, x64",

  "installExperience": {
    "runAsAccount": "user",
    "deviceRestartBehavior": "allow",
    "@odata.type": "microsoft.graph.win32LobAppInstallExperience"
  },
  "detectionRules": [
    {
  "@odata.type": "#microsoft.graph.win32LobAppFileSystemDetection",
  "path": "%programfiles%\\7-zip",
  "fileOrFolderName": "7z.exe",
  "check32BitOn64System": false,
  "detectionType": "exists" }
  ],
      "returncode":  [
                       {
                           "returnCode":  0,
                           "type":  "success",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1707,
                           "type":  "Success",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1641,
                           "type":  "hardReboot",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  1618,
                           "type":  "retry",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       },
                       {
                           "returnCode":  3010,
                           "type":  "softReboot",
                           "@odata.type":  "#microsoft.graph.win32LobAppReturnCode"
                       }
					   ],
  "minimumNumberOfProcessors": "1",
  "minimumFreeDiskSpaceInMB": "8",
  "minimumCpuSpeedInMHz": "4",
  "minimumSupportedOperatingSystem": {
    "@odata.type": "microsoft.graph.windowsMinimumOperatingSystem",
    "v10_1607": true
  },
  "notes": "Loaded via cyberdrain.com application script",
  "minimumMemoryInMB": "1"

  
}

The script: Deploy Intune Applications.

So this script took some figuring out, I’ve been using the examples found here, and Ben Reader’s version right here. There’s some tricks we apply but the one you should be aware of is the padding – We pad the file with a 10mb file to make sure that we can upload using Azcopy. The script is completely headless, so just run it and it will upload all the apps.

The options are straight forward – just fill in all the information and run the script. You can even re-upload apps by changing $ContinueonExistingApp to true. The script currently runs for just the tenant you specify. That way, you can schedulde multiple scripts with different options for different clients. If you’d like a version for all tenants at the same time, let me know!

########################## Secure App Model Settings ############################
$ApplicationId = 'YourAppID'
$ApplicationSecret = 'YourAppSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourCSPTenantID'
$RefreshToken = 'yourverylongrefeshtoken'
$upn = 'UPN-Used-To-Generate-Tokens'
$CustomerTenantID = "YourCustomerTenant.onmicrosoft.com"
########################## Script Settings  ############################
$ApplicationFolder = "C:\intune\Applications"
$Baseuri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps"
$AzCopyUri = "https://cyberdrain.com/wp-content/uploads/2020/04/azcopy.exe"
$IntuneWinAppUri = "https://cyberdrain.com/wp-content/uploads/2020/04/IntuneWinAppUtil.exe"
$ContinueOnExistingApp = $false
###################################################################
write-host "Checking AZCopy prerequisites and downloading these if required" -ForegroundColor Green
try {
    $AzCopyDownloadLocation = Test-Path "$ApplicationFolder\AzCopy.exe"
    if (!$AzCopyDownloadLocation) { 
        Invoke-WebRequest -UseBasicParsing -Uri $AzCopyUri -OutFile "$($ApplicationFolder)\AzCopy.exe" 
    }
}
catch {
    write-host "The download and extraction of AzCopy failed. The script will stop. Error: $($_.Exception.Message)"
    exit 1
}
write-host "Checking IntuneWinAppUtil prerequisites and downloading these if required" -ForegroundColor Green

try {
    $AzCopyDownloadLocation = Test-Path "$ApplicationFolder\IntuneWinAppUtil.exe"
    if (!$AzCopyDownloadLocation) { Invoke-WebRequest -UseBasicParsing -Uri $IntuneWinAppUri -OutFile "$($ApplicationFolder)\IntuneWinAppUtil.exe" }
}
catch {
    write-host "The download and extraction of IntuneWinApp failed. The script will stop. Error: $($_.Exception.Message)"
    exit 1
}

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
write-host "Generating token to log into Intune" -ForegroundColor Green
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $CustomerTenantID
$Header = @{
    Authorization = "Bearer $($graphToken.AccessToken)"
}
$AppFolders = Get-ChildItem $ApplicationFolder -Directory 
foreach ($App in $AppFolders) {
    $intuneBody = get-content "$($app.fullname)\app.json"
    $Settings = $intuneBody | ConvertFrom-Json 
    write-host "Creating if intune package for $($app.name) does not exists." -ForegroundColor Green
    $ApplicationList = (Invoke-RestMethod -Uri $baseuri -Headers $Header -Method get -ContentType "application/json").value | where-object { $_.DisplayName -eq $settings.displayName }
    if ($ApplicationList.count -gt 1 -and $ContinueOnExistingApp -eq $false) { 
        write-host "$($app.name) exists. Skipping this application." -ForegroundColor yellow
        continue
    }
    write-host "Creating intune package for $($App.Name)" -ForegroundColor Green
    $bytes = 10MB
    [System.Security.Cryptography.RNGCryptoServiceProvider] $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
    $rndbytes = New-Object byte[] $bytes
    $rng.GetBytes($rndbytes)
    [System.IO.File]::WriteAllBytes("$($App.fullname)\dummy.dat", $rndbytes)
    $FileToExecute = $Settings.installCommandLine.split(" ")[0]
    start-process "$applicationfolder\IntuneWinAppUtil.exe" -argumentlist "-c $($App.FullName) -s $FileToExecute -o $($App.FullName)" -wait
    write-host "Creating Application on intune platform for $($App.Name)" -ForegroundColor Green
    $InTuneProfileURI = "$($BaseURI)"
    $NewApp = Invoke-RestMethod -Uri $InTuneProfileURI -Headers $Header -body $intuneBody -Method POST -ContentType "application/json"
    write-host "Getting encryption information for intune file for $($App.Name)" -ForegroundColor Green

    $intuneWin = get-childitem $App.fullname -Filter *.intunewin
    #unzip the detection.xml file to get manifest info and encryptioninfo.
    $Directory = [System.IO.Path]::GetDirectoryName("$($intuneWin.fullname)")
    Add-Type -Assembly System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead("$($intuneWin.fullname)")
    $zip.Entries | Where-Object { $_.Name -like "Detection.xml" } | ForEach-Object {
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\Detection.xml", $true)
    }
    $zip.Dispose()
    $intunexml = get-content "$Directory\Detection.xml"
    remove-item  "$Directory\Detection.xml" -Force
    #Unzip the encrypted file to prepare for upload.
    $Directory = [System.IO.Path]::GetDirectoryName("$($intuneWin.fullname)")
    Add-Type -Assembly System.IO.Compression.FileSystem
    $zip = [IO.Compression.ZipFile]::OpenRead("$($intuneWin.fullname)")
    $zip.Entries | Where-Object { $_.Name -like "IntunePackage.intunewin" } | ForEach-Object {
        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($_, "$Directory\IntunePackage.intunewin", $true)
    }
    $zip.Dispose()
    $ExtactedEncFile = (Get-Item "$Directory\IntunePackage.intunewin")
    $intunewinFileSize = (Get-Item "$Directory\IntunePackage.intunewin").Length
  
    $ContentBody = ConvertTo-Json @{
        name          = $intunexml.ApplicationInfo.FileName
        size          = [int64]$intunexml.ApplicationInfo.UnencryptedContentSize
        sizeEncrypted = [int64]$intunewinFileSize
    } 
    write-host "Uploading content information for $($App.Name)." -ForegroundColor Green

    $ContentURI = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/"
    $ContentReq = Invoke-RestMethod -Uri $ContentURI -Headers $Header -body $ContentBody -Method POST -ContentType "application/json"
    write-host "Trying to get file uri for $($App.Name)." -ForegroundColor Green
    do {
        write-host "Still trying to get file uri for $($App.Name) Please wait." -ForegroundColor Green
        $AzFileUriCheck = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)"
        $AzFileUri = Invoke-RestMethod -Uri $AzFileUriCheck -Headers $Header -Method get -ContentType "application/json"
        if ($AZfileuri.uploadState -like "*fail*") { break }
        start-sleep 5
    } while ($AzFileUri.AzureStorageUri -eq $null) 
    write-host "Retrieved upload URL. Uploading package $($App.Name) via AzCopy." -ForegroundColor Green

    $UploadResults = & "$($ApplicationFolder)\azCopy.exe" cp "$($ExtactedEncFile.fullname)" "$($Azfileuri.AzureStorageUri)"  --block-size-mb 4 --output-type 'json'    
    remove-item @($intunewin.fullname, $ExtactedEncFile) -Force
    start-sleep 2

    write-host "File uploaded. Commiting $($App.Name) with Encryption Info" -ForegroundColor Green

    $EncBody = @{
        fileEncryptionInfo = @{
            encryptionKey        = $intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey
            macKey               = $intunexml.ApplicationInfo.EncryptionInfo.MacKey
            initializationVector = $intunexml.ApplicationInfo.EncryptionInfo.InitializationVector
            mac                  = $intunexml.ApplicationInfo.EncryptionInfo.Mac
            profileIdentifier    = $intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier
            fileDigest           = $intunexml.ApplicationInfo.EncryptionInfo.FileDigest
            fileDigestAlgorithm  = $intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm
        }
    } | ConvertTo-Json
    $CommitURI = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit"
    $CommitReq = Invoke-RestMethod -Uri $CommitURI -Headers $Header -body $EncBody -Method POST -ContentType "application/json"

    write-host "Waiting for file commit results for $($App.Name)." -ForegroundColor Green

    do {
        write-host "Still trying to get commit state. Please wait." -ForegroundColor Green

        $CommitStateURL = "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)"
        $CommitStateReq = Invoke-RestMethod -Uri $CommitStateURL -Headers $Header -Method get -ContentType "application/json"
        if ($CommitStateReq.uploadState -like "*fail*") { write-host "Commit Failed for $($App.Name). Moving on to Next application. Manual intervention will be required" -ForegroundColor red; break }
        start-sleep 10
    } while ($CommitStateReq.uploadState -eq "commitFilePending") 
    if ($CommitStateReq.uploadState -like "*fail*") { continue }
    write-host "Commiting application version" -ForegroundColor Green
    $ConfirmBody = @{
        "@odata.type"             = "#microsoft.graph.win32lobapp"
        "committedContentVersion" = "1"
    } | Convertto-Json
    $CommitFinalizeURI = "$($BaseURI)/$($NewApp.id)"
    $CommitFinalizeReq = Invoke-RestMethod -Uri $CommitFinalizeURI -Headers $Header -body $Confirmbody -Method PATCH -ContentType "application/json"
    write-host "Deployment completed for app $($app.name). You can assign this app to users now." -ForegroundColor Green
}

So if you combine this with my earlier autopilot automation blog, you could easily setup the entire autopilot experience, with very little effort. And that’s it! as always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring DFSR status

There were a couple of projects I was working on, one of them being a method to upload intune applications to all tenants in a CSP. Turns out that someone else already made that so I won’t be blogging about it directly. If your interested in that you can check out this link. This did swallow most of my time, so instead I figured I’d publish a smaller blog today.

So instead we’re going to focus on monitoring something else entirely; DFS Replication. DFS is somewhat dated when compared to cool new tech like Azure File Sync, and others like that but still used in a lot of environments. Even in our move to cloud only environments I still think we have a handful of DFS servers running.

This small script will assist you in finding DFS machines that are having an increased backlog, connection issues, or folder issues.

$MaxBackLog = "100"
$DFSFiles = Get-DfsrState
$DFSBackLogHealth = if ($DFSFiles.count -gt $Maxbacklog) { "There are more than $Maxbacklog in the backlog" } else { "Healthy" }

$connections = Get-DfsrConnection | Where-Object {$_.state -ne  'normal'}
$DFSConnectionHealth = if($Connections) { "Fault connections found. Please investigate" } else { "Healthy" }


$Folders = Get-DfsReplicatedFolder | Where-Object {$_.state -ne  'normal'}
$DFSFolderHealth = if($Folders) { "Faulty folder found. Please investigate" } else { "Healthy" }

I know it’s a little shorter than my normal blogs. I just couldn’t find a new cool subject to talk about. If you have any suggestions please let me know. I’m still doing requests 🙂