Category Archives: Series: PowerShell documenting

Ending the year with PowerShell: Retrospective

I always like December because everyone gets all in the mood to do these retrospectives; and 2020 was crazy for everyone! for my personally it had a heavy loss, but also a lot of positivity. I enjoy looking at the raw numbers to see how far I’ve gotten.

So, show me the numbers!

  • 1 Microsoft MVP Award (and still amazed at it!)
  • 1 MSP(that I know of) that says I’ve saved their business
  • 2 blogs a week, a total of 100 so far this year. 🙂
  • 4 MSP community and business awards.
  • 5 RMM vendors that are implementing my blogs into their products.
  • 19 webinars I joined/led and had so much fun with, Special thanks to the last ones I did this year with Huntress.
  • 15000+ people that watched these webinars.
  • 1254 e-mails from other MSPs, asking for advice, support, or just thank you messages.
  • 400+ MSPs that installed one or more of my Azure Functions
  • 7000+ upvotes on reddit posts relating to MSP stuff 🙂
  • Almost 300000 downloads of my PowerShell modules on the PowerShell Gallery this year!
  • 1.7+ million unique hits on CyberDrain.com!

So what’s next?

Well, I have some time off planned for 3 weeks so you’ll all have to miss me for a little while. I will still but blogging but not on my normal schedule of 2 blogs a week. In the coming year I’m going to be working to bring even more value to both my own MSP and others by trying to break open automation for everyone. I also have plans to create a cool educational Capture the Flag competition for Managed Service providers and/or general System Administration, next to of course my regular blogging and normal community efforts.

I’m also working with Datto to look into a vendor neutral automation/RMM user group and of course I have bigger projects like AzPAM that need some love.

I’m not done blogging by far, and I’m still super excited for what’s to come and the feedback I get from the MSP community is immense. I try to respond to all of your e-mails and messages but don’t always get the chance. I’d like to take this moment to thank you all and hope to see you again next year!

Special end of year thanks

I did the same last year and think it should become a little bit of a regular thing. For this entire year I’d like to thank my darling wife for putting up with me of course, my partners at Lime Networks and others in no particular order;

My friends at Datto: Thank you for our cooperation. I really love seeing vendors that actually love their products and that is so true on the RMM side. 🙂

My friends at Solarwinds: Mostly for the N-Central/RMM upper-management team; I had too much laughs and funs doing the MSP Masterclasses this year. Hopefully next year we can make just as much fun! I’m also going to show up on Luis’s Café Con Luis soon!

MSPGeek and MSPRU: Thanks for all community members. I love talking to you all 🙂

and a direct, and very special thanks to the following people, whom I’m able to call my friends; Jonathan Crowe, Kyle Hanslovan, Gavin Stone, Stan Lee, Maarten, Nick, Aad and of course everyone else I work closely with. 🙂

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

Documenting with PowerShell: Documenting Print Servers

Before I start on this; I agree. Printers are the bane of our existence in IT and I am hoping for a paperless environment each day. Unfortunately we’re not at that future just yet, so we have to document print servers and their settings.

So today I’m going to show you how to document your print servers, gather their drivers so you can do future roll backs if required, and get their settings too. To do this we’re using the native printer cmdlets included in Windows Server, and get a little help from the print server management command line utilities.

We’re not using PRINTBRM completely, because we can’t make a per-printer backup with it. It would just be a single huge package and IT-Glue does not support attachments over 100MB. Instead we’re using get-PrinterDriver and we’re backing up all the files we need to restore functionality. As always I’ve made two version: one for IT-Glue and one for generic HTML documentation systems.

IT-Glue version

The IT-Glue version creates the flexible asset for you, documents the settings, and uploads a backup of the drivers. Remember to change the ORGID variable to the one you want to document.

#####################################################################
$APIKEy = "YourITGAPIKey"
$APIEndpoint = "https://api.eu.itglue.com"
$orgID = "ORGID"
$FlexAssetName = "ITGLue AutoDoc - Printers"
$Description = "All configuration settings for printers and a backup of their respective drivers"
$InstallPrintManagement = $true
$BackupDriver = $true
#####################################################################
If (Get-Module -ListAvailable -Name "ITGlueAPI") { Import-module ITGlueAPI } Else { install-module ITGlueAPI -Force; import-module ITGlueAPI }
Add-ITGlueBaseURI -base_uri $APIEndpoint
Add-ITGlueAPIKey $APIKEy
#Checking if the FlexibleAsset exists. If not, create a new one.
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Printer Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Printer Config"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Port Config"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Printer Properties"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Driver backup"
                            kind           = "Upload"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
                
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
} 

$PrintManagementInstalled = get-windowsfeature -name 'RSAT-Print-Services' -ErrorAction SilentlyContinue
if ($InstallPrintManagement -and !$PrintManagementInstalled) {
    Add-WindowsFeature -Name 'RSAT-Print-Services'
}
import-module PrintManagement
$PrinterList = Get-Printer

$PrinterConfigurations = foreach ($Printer in $PrinterList) {
    $PrinterConfig = Get-PrintConfiguration -PrinterName $printer.Name
    $PortConfig = get-printerport -Name $printer.PortName
    $PrinterProperties = Get-PrinterProperty -PrinterName $printer.Name
    if ($BackupDriver) {
        $Driver = Get-PrinterDriver -Name $printer.DriverName | ForEach-Object { $_.InfPath; $_.ConfigFile; $_.DataFile; $_.DependentFiles } | Where-Object { $_ -ne $null }
        $BackupPath = new-item -path "$($ENV:TEMP)\$($printer.name)" -ItemType directory -Force 
        $Driver | foreach-object { copy-item -path $_ -Destination "$($ENV:TEMP)\$($printer.name)" -Force }
        Add-Type -assembly "system.io.compression.filesystem"
        [io.compression.zipfile]::CreateFromDirectory("$($ENV:TEMP)\$($printer.name)", "$($ENV:TEMP)\$($printer.name).zip")
        $ZippedDriver = ([convert]::ToBase64String(([IO.File]::ReadAllBytes("$($ENV:TEMP)\$($printer.name).zip"))))
        remove-item "$($ENV:TEMP)\$($printer.name)" -force -Recurse
        remove-item "$($ENV:TEMP)\$($printer.name).zip" -force -Recurse
    }
    [PSCustomObject]@{
        PrinterName       = $printer.name
        PrinterConfig     = $PrinterConfig | select-object DuplexingMode, PapierSize, Collate, Color | convertto-html -Fragment | Out-String
        PortConfig        = $PortConfig  | Select-Object Description, Name, PortNumber, PrinterHostAddress, snmpcommunity, snmpenabled | convertto-html -Fragment | out-string
        PrinterProperties = $PrinterProperties | Select-Object PropertyName, Value | convertto-html -Fragment | out-string
        ZippedDriver      = $ZippedDriver
    }
}

foreach ($printerconf in $PrinterConfigurations) {
    $FlexAssetBody = 
    @{
        type       = 'flexible-assets'
        attributes = @{
            name   = $FlexAssetName
            traits = @{
                "printer-name"       = $printerconf.Printername
                "printer-config"     = $printerconf.printerconfig
                "port-config"        = $printerconf.portconfig
                "printer-properties" = $printerconfig.properties
                "driver-backup"      = @{
                    "content"   = $printerconf.ZippedDriver
                    "file_name" = "Driver Backup.zip"
                }
            }
        }
    }
    

    #Upload data to IT-Glue. We try to match the Server name to current computer name.
    $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $Filterid.id -filter_organization_id $orgID).data | Where-Object { $_.attributes.traits.name -eq $printerconf.PrinterName }
    #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
    if (!$ExistingFlexAsset) {
        $FlexAssetBody.attributes.add('organization-id', $orgID)
        $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
        Write-Host "Creating new flexible asset"
        $NewID = New-ITGlueFlexibleAssets -data $FlexAssetBody
        Set-ITGlueFlexibleAssets -id $newID.ID -data $Attachment
    }
    else {
        Write-Host "Updating Flexible Asset"
        $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
        Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
    }
}

HTML Version

The HTML version uses PSWriteHTML to create a simple HTML document for you. The backup is stored in C:\Temp instead of in the HTML file.

#####################################################################
$InstallPrintManagement = $true
$BackupDriver = $true
#####################################################################

$PrintManagementInstalled = get-windowsfeature -name 'RSAT-Print-Services' -ErrorAction SilentlyContinue
if ($InstallPrintManagement -and !$PrintManagementInstalled) {
    Add-WindowsFeature -Name 'RSAT-Print-Services'
}
import-module PrintManagement
$PrinterList = Get-Printer

foreach ($Printer in $PrinterList) {
    $PrinterConfig = Get-PrintConfiguration -PrinterName $printer.Name
    $PortConfig = get-printerport -Name $printer.PortName
    $PrinterProperties = Get-PrinterProperty -PrinterName $printer.Name
    if ($BackupDriver) {
        $Driver = Get-PrinterDriver -Name $printer.DriverName | ForEach-Object { $_.InfPath; $_.ConfigFile; $_.DataFile; $_.DependentFiles } | Where-Object { $_ -ne $null }
        $BackupPath = new-item -path "$($ENV:TEMP)\$($printer.name)" -ItemType directory -Force 
        $Driver | foreach-object { copy-item -path $_ -Destination "$($ENV:TEMP)\$($printer.name)" -Force }
        remove-item "$($ENV:TEMP)\$($printer.name)" -force -Recurse
        copy-item "$($ENV:TEMP)\$($printer.name).zip" -Destination "C:\Temp"
        remove-item "$($ENV:TEMP)\$($printer.name).zip" -force -Recurse
    }

    New-HTML {
        New-HTMLTab -Name $printer.name {
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'Configuration' {
                    New-HTMLTable -DataTable $PrinterConfig
                }
            }
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText "Port Config" {
                    New-HTMLTable -DataTable $PortConfig
                }
               
                New-HTMLSection -HeaderText "Printer Properties" { 
                    New-HTMLTable -DataTable $PrinterProperties
                }
            }
        }
                   
    } -FilePath "C:\temp\$($Printer.name) PrinterDocumentation.html" -Online



}

And that’s it! As always, Happy PowerShelling. 🙂

Automating with PowerShell: Checking if you can move to M365 BP from O365 E3

So with M365 BP having nearly all the features that E3 gives you, albeit with some limitations a lot of our clients are moving their E3 licenses over. M365BP has the added benefit of Intune/Autopilot, P1 licenses, etc. So all pretty awesome stuff.

Before you can move a client over, you’ll have to check some small things. We have a fairly lengthy internal script to perform all these checks, but I figured to share a part of this script for the public at large. 😉

The Script

This script is a simplified version of our internal one; It checks the following things for you;

  • If you are currently using more than 300 licenses, because if you are you will have to Mix-and-Match all the overage of the licenses.
  • If there are mailboxes larger than 50GB. If so, these have to be licensed differently or cleaned up first.
  • If your clients connecting are all recent clients. Automatic downgrades of the licenses without reinstallation requires a recent version of the installed O365 application.

There’s some more caveats, but these are the most major ones you can tackle with this script. Licenses are tricky business so customize this script to your wishes if you want to add/remove anything. 🙂

As with most of my scripts you’ll need the secure application model. The resulting files will be written to C:\Temp.

######### Secrets #########
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourAppicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'YourTenantID'
$RefreshToken = 'insanelylongtoken'
######### Secrets #########
install-module PSWriteHTML
$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) {

  
    $UserLicenses = Get-MsolAccountSku -TenantId $customer.TenantId
    if ($UserLicenses.AccountSkuId -like "*ENTERPRISEPACK*") {
        write-host "$($Customer.DefaultDomainName) has ENTERPRISEPACK"
    }
    else {
        write-host "Skipping $($Customer.DefaultDomainName) as they do not have an ENTERPRISEPACK" -ForegroundColor Yellow 
        continue 
    }
    $LicObject = foreach ($UserLicense in $UserLicenses) {
        if ($UserLicense.ConsumedUnits -gt '300') {
            [PSCustomObject]@{
                Name          = ($UserLicense.AccountSkuID -split ':')
                ConsumedUnits = $UserLicense.ConsumedUnits
                Warning       = "Microsoft 365 BP can only be used for the first 300 users. If this license is contained in a package. Please note to buy a seperate license for the users with overage."
            }
        }
    }
    if (!$LicObject) {
        $LicObject = [PSCustomObject]@{
            Report = 'No licensing conflicts found. License migration can be performed without issues.'
        }
    }

    write-host "Generating token for $($Customer.name)" -ForegroundColor Green
    $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $customer.TenantID
    $Header = @{
        Authorization = "Bearer $($graphToken.AccessToken)"
    }
    $MailboxUsage = (Invoke-RestMethod -Uri  "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')" -Headers $Header -Method Get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv
    $LargeMailboxes = foreach ($Mailbox in $MailboxUsage) {
        if ([int64]$Mailbox.'Storage Used (Byte)' -gt 45GB) { 
            [PSCustomObject]@{
                Username      = $Mailbox.'User Principal Name'
                DisplayName   = $Mailbox.'DisplayName'
                StorageUsedGB = [math]::round($Mailbox.'Storage Used (Byte)' / 1gb, 2)
            }
        }
    }
    if (!$LargeMailboxes) {
        $LargeMailboxes = [PSCustomObject]@{
            Report = 'No mailbox sizing issues found. Technical mailbox migration can be performed without issues.'
        }
    }

    $VersionReport = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getEmailAppUsageVersionsUserCounts(period='D7')" -Headers $Header -Method get -ContentType "application/json") -replace "", "" | ConvertFrom-Csv
    $LegacyClients = if ($versionreport.'Outlook 2007' -or $versionreport.'Outlook 2010' -or $versionreport.'Outlook 2013' -or $versionreport.'Outlook 2016') {
        $VersionReport
    }
    if (!$LegacyClients) {
        $LegacyClients = [PSCustomObject]@{
            Report = 'No Legacy outlook clients found. License change can be performed without issues.'
        }
    }

    start-sleep -Milliseconds 500  #sleep to prevent CSV Throttle on Graph API

    New-HTML {
        New-HTMLTab -Name "O365 to M365 Preperation Report" {
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'License Settings' {
                    New-HTMLTable -DataTable $LicObject
                }
            }
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText "Mailbox Settings" {
                    New-HTMLTable -DataTable $LargeMailboxes
                }
              
                New-HTMLSection -HeaderText "O365 Client Usage" { 
                    New-HTMLTable -DataTable $LegacyClients
                }
            }
        }
                  
    } -FilePath "C:\temp\$($customer.DefaultDomainName).html" -Online
}

I hope this helps you migrate your clients over, as always, Happy PowerShelling!

Documenting with PowerShell: Documenting mobile devices

This was a request by a friend in one of the peer groups I operate in. He wanted a method to document which mobile devices exist in a O365 tenant and put them into IT-Glue as configurations. I figured I’d help him real quick and blog about it.

The IT-Glue version creates configurations of each asset in O365 and documents the serial number etc. The HTML version is a simple overview of which devices exist. This should help in localizing which mobile devices are completely managed, or only partially too.

IT-Glue version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'RefreshToken'
$APIKEy = "LangeITGlueAPIKeyHier"
$APIEndpoint = "https://api.eu.itglue.com"
######### Secrets #########
$CustomerTenant = "Client.onmicrosoft.com"
$ITGlueORGID = "123456"
########################## Script Settings  ############################

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 $APIEndpoint
Add-ITGlueAPIKey $APIKEy
$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 "Finding devices in $($CustomerTenant)" -ForegroundColor Green
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.Operatingsystem -ne "Windows" }

$Configurationtype = (Get-ITGlueConfigurationTypes).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a configuration type"

$Configurationstatus = (Get-ITGlueConfigurationStatuses).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a status type"

$manafacture = (Get-ITGlueManufacturers).data | Select-Object ID -ExpandProperty attributes | Out-GridView -PassThru -Title "Select a manufacturer"


foreach ($device in $devicesList) {
    $Existing = Get-ITGlueConfigurations -filter_serial_number $device.deviceId
    if ($Existing) { write-host "$($device.displayname) exists, skipping."; continue }
    $dataobj = @{
        type       = "configurations"
        attributes = @{
            "organization-id"         = $ITGlueOrgID
            "name"                    = $device.displayName
            "configuration-type-id"   = $configurationtype.id
            "configuration-status-id" = $Configurationstatus.id
            "manufacturer-id"         = $manafacture.id
            "serial-number"           = $device.deviceId
        }
    }
    New-ITGlueConfigurations -organization_id $ITGlueORGID -data $dataobj
    $dataobj = $null
}


So this version requires a little manual work – You’ll need to select the device type, and the manufacturer once. I often just select the bulk and fix the few manual ones later on.

HTML version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$RefreshToken = 'RefreshToken'
$CustomerTenant = "ClientDomain.onmicrosoft.com"
########################## Script Settings  ############################


$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 "Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host "Finding devices in $($CustomerTenant)" -ForegroundColor Green
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | Where-Object { $_.Operatingsystem -ne "Windows" }

New-HTML {   
    New-HTMLTab -Name 'Mobile Devices' {

        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Android Devices' {
                New-HTMLTable -DataTable ($devicesList | Where-Object { $_.OperatingSystem -eq "Android" } | Sort-Object profileType, deviceOwnership, deviceVersion, operatingSystem, operatingSystemVersion, registrationDateTime, trustType, deviceId)
            }
                New-HTMLSection -HeaderText "Apple Devices" {
                    New-HTMLTable -DataTable ($devicesList | Where-Object { $_.OperatingSystem -eq "iOS" } | Sort-Object profileType, deviceOwnership, deviceVersion, operatingSystem, operatingSystemVersion, registrationDateTime, trustType, deviceId) 
                }
            }
        }
     
    } -FilePath "C:\temp\doc.html" -Online -ShowHTML

And as requested so many times before; the eventual output looks like this:

So that’s it for today! as always, Happy PowerShelling.

Documenting with PowerShell: Hyper-v and physical server settings

So a while back I helped people documenting their physical servers, the biggest complaint about that blog was that “at a glance” you couldn’t really see what the server did, if it was a cluster member or not, and how the physical layout of the server was.

For this, I decided to rewrite that blog just a little – Especially now that my friend Gavin Stone (gavsto.com) made a pretty cool PowerShell function to get ‘cards’ into IT-Glue. To show you exactly what I mean, I think a screenshot works best.

IT-Glue Cards example

So having these cards opens op wonderful ways to display data in IT-Glue that isn’t just a boring table. I’ve grown to call these “glance cards” internally, but, lets get to the entire script!

IT-Glue version

So the IT-Glue version does the same as always; it creates a Flexible Asset for you, and uploads the data to IT-Glue. The script differs slightly from the earlier Hyper-v script; it also collects the current RAID and physical disk status, as that’s always useful information to have. I’ve only customized this for Dell servers, but it should be straightforward to change that to HP, or others.

########################## IT-Glue ############################
$OrgID = "ORGANIZATIONIDHERE"
$APIKEy = "ITGlueAPIKeyHere"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Physical Host - Autodoc v2"
$Description = "A network one-page document that displays the physical host settings, hyper-v virtual machines, etc."
$logoURL = 'https://google.com/coollogo.png'
#some layout options, change if you want colours to be different or do not like the whitespace.
$TableHeader = "<table class=`"table table-bordered table-hover`" style=`"width:80%`">"
$Whitespace = "<br/>"
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
########################## IT-Glue ############################
#Grabbing ITGlue Module and installing.
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 $APIEndpoint
Add-ITGlueAPIKey $APIKEy
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Host name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "At a glance"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Virtual Machines"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Network Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Replication Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Host Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Physical Host Configuration"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

function New-BootstrapSinglePanel {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]  
        [ValidateSet('active', 'success', 'info', 'warning', 'danger', 'blank')]
        [string]$PanelShading,
    
        [Parameter(Mandatory)]
        [string]$PanelTitle,
    
        [Parameter(Mandatory)]
        [string]$PanelContent,
    
        [switch]$ContentAsBadge,
    
        [string]$PanelAdditionalDetail,
    
        [Parameter(Mandatory)]
        [int]$PanelSize = 3
    )
    
    if ($PanelShading -ne 'Blank') {
        $PanelStart = "<div class=`"col-sm-$PanelSize`"><div class=`"panel panel-$PanelShading`">"
    }
    else {
        $PanelStart = "<div class=`"col-sm-$PanelSize`"><div class=`"panel`">"
    }
    
    $PanelTitle = "<div class=`"panel-heading`"><h3 class=`"panel-title text-center`">$PanelTitle</h3></div>"
    
    
    if ($PSBoundParameters.ContainsKey('ContentAsBadge')) {
        $PanelContent = "<div class=`"panel-body text-center`"><h4><span class=`"label label-$PanelShading`">$PanelContent</span></h4>$PanelAdditionalDetail</div>"
    }
    else {
        $PanelContent = "<div class=`"panel-body text-center`"><h4>$PanelContent</h4>$PanelAdditionalDetail</div>"
    }
    $PanelEnd = "</div></div>"
    $FinalPanelHTML = "{0}{1}{2}{3}" -f $PanelStart, $PanelTitle, $PanelContent, $PanelEnd
    return $FinalPanelHTML
} 

function New-AtAGlancecard {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]  
        [boolean]$Enabled,
    
        [Parameter(Mandatory)]
        [string]$PanelContent
    )
    if ($enabled) {
        New-BootstrapSinglePanel -PanelShading "success" -PanelTitle "<img src='$LogoURL'" -PanelContent $PanelContent  -ContentAsBadge -PanelSize 3
    }
    else {
        New-BootstrapSinglePanel -PanelShading "danger" -PanelTitle "<img src='$LogoURL'" -PanelContent $PanelContent  -ContentAsBadge -PanelSize 3

    }

}

write-host "Start documentation process." -foregroundColor green


$VirtualMachines = get-vm | select-object VMName, Generation, Path, Automatic*, @{n = "Minimum(gb)"; e = { $_.memoryminimum / 1gb } }, @{n = "Maximum(gb)"; e = { $_.memorymaximum / 1gb } }, @{n = "Startup(gb)"; e = { $_.memorystartup / 1gb } }, @{n = "Currently Assigned(gb)"; e = { $_.memoryassigned / 1gb } }, ProcessorCount | ConvertTo-Html -Fragment | Out-String
$VirtualMachines = $TableHeader + ($VirtualMachines -replace $TableStyling) + $Whitespace
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS | convertto-html -Fragment -PreContent "<h3>Network Switches</h3>" | Out-String
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } } | ConvertTo-Html -Fragment -PreContent "<br><h3>VM Network Settings</h3>" | Out-String
$NetworkSettings = $TableHeader + ($NetworkSwitches -replace $TableStyling) + ($VMNetworkSettings -replace $TableStyling) + $Whitespace
$ReplicationSettings = get-vmreplication | Select-Object VMName, State, Mode, FrequencySec, PrimaryServer, ReplicaServer, ReplicaPort, AuthType | convertto-html -Fragment | Out-String
$ReplicationSettings = $TableHeader + ($ReplicationSettings -replace $TableStyling) + $Whitespace
$HostSettings = get-vmhost | Select-Object  Computername, LogicalProcessorCount, iovSupport, EnableEnhancedSessionMode, MacAddressMinimum, *max*, NumaspanningEnabled, VirtualHardDiskPath, VirtualMachinePath, UseAnyNetworkForMigration, VirtualMachineMigrationEnabled | convertto-html -Fragment -as List | Out-String

$AtAGlanceHash = @{
    'Hyper-v server'   = if ((Get-WindowsOptionalFeature -FeatureName *hyper-v* -Online).state -eq 'enabled') { $true } else { $False }
    'Hyper-v Replicas' = if ($ReplicationSetting) { $true } else { $False }
    'Hyper-v Cluster'  = if ($null -ne (Get-CimInstance -Class MSCluster_ResourceGroup -Namespace root\mscluster -ErrorAction SilentlyContinue)) { $true } else { $false }
    'Dell server'      = if ((Get-CimInstance -Class Win32_ComputerSystem).Manufacturer -like '*Dell*') { $true } else { $false }
}
$ATaGlanceHTML = foreach ($Hash in $AtAGlanceHash.GetEnumerator()) {
    New-AtAGlancecard -Enabled $hash.value -PanelContent $hash.name
}

$PhysicalConfig = if ($AtAGlanceHash.'Dell server' -eq $true) {

    $Preferences = omconfig preferences cdvformat delimiter=pipe
    $ControllerList = (omreport storage controller -fmt xml)
    $DiskLayoutRaw = foreach ($Controller in $ControllerList.oma.controllers.DCStorageObject.GlobalNo.'#text') {
        omreport storage pdisk controller=$Controller -fmt cdv
    }

    ($DiskLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($DiskLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object Name, Status, Capacity, State, "Bus Protocol", "Product ID", "Serial No.", "Part Number", Media | convertto-html -Fragment)
    $DiskNumbers = (0..1000)
    $RAIDLayoutRaw = omreport storage vdisk -fmt cdv
    ($RAIDLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($RAIDLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object '> ID', Name, Status, State, Layout, "Device Name", "Read Policy", "Write Policy", Media | Where-Object {$_.'> ID' -in $DiskNumbers} |  convertto-html -Fragment)
}
else {
    "Could not retrieve physical host settings - This server is not a Dell Physical machine"
}



$FlexAssetBody =
@{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'host-name'                   = $env:COMPUTERNAME
            'at-a-glance'                 = ($ATaGlanceHTML | out-string)
            'virtual-machines'            = $VirtualMachines
            'network-settings'            = $NetworkSettings
            'replication-settings'        = $ReplicationSettings
            'host-settings'               = $HostSettings
            'physical-host-configuration' = $PhysicalConfig
        }
    }
}
 

write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $OrgID).data | Where-Object { $_.attributes.traits.'host-name' -eq $ENV:computername } | Select-Object -last 1
#If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
if (!$ExistingFlexAsset) {
    $FlexAssetBody.attributes.add('organization-id', $OrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Hyper-v into IT-Glue organisation $OrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Hyper-v into IT-Glue organisation $OrgID"  -ForegroundColor Green
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

HTML Version

Of course there are people without IT-Glue and for them I have the HTML version right here. We’re using PSWriteHTML to get a nice looking overview. This doesn’t include the glance cards right now.


#Grabbing PsWriteHTML module and documenting
write-host "Start documentation process." -foregroundColor green
install-module PsWriteHtml -force

$VirtualMachines = get-vm | select-object VMName, Generation, Path, Automatic*, @{n = "Minimum(gb)"; e = { $_.memoryminimum / 1gb } }, @{n = "Maximum(gb)"; e = { $_.memorymaximum / 1gb } }, @{n = "Startup(gb)"; e = { $_.memorystartup / 1gb } }, @{n = "Currently Assigned(gb)"; e = { $_.memoryassigned / 1gb } }, ProcessorCount
$NetworkSwitches = Get-VMSwitch | select-object name, switchtype, NetAdapterInterfaceDescription, AllowManagementOS 
$VMNetworkSettings = Get-VMNetworkAdapter * | Select-Object Name, IsManagementOs, VMName, SwitchName, MacAddress, @{Name = 'IP'; Expression = { $_.IPaddresses -join "," } }
$ReplicationSettings = get-vmreplication | Select-Object VMName, State, Mode, FrequencySec, PrimaryServer, ReplicaServer, ReplicaPort, AuthType 
$HostSettings = get-vmhost | Select-Object  Computername, LogicalProcessorCount, iovSupport, EnableEnhancedSessionMode, MacAddressMinimum, *max*, NumaspanningEnabled, VirtualHardDiskPath, VirtualMachinePath, UseAnyNetworkForMigration, VirtualMachineMigrationEnabled

$AtAGlanceHash = @{
    'Hyper-v server'   = if ((Get-WindowsOptionalFeature -FeatureName *hyper-v* -Online).state -eq 'enabled') { $true } else { $False }
    'Hyper-v Replicas' = if ($ReplicationSetting) { $true } else { $False }
    'Hyper-v Cluster'  = if ($null -ne (Get-CimInstance -Class MSCluster_ResourceGroup -Namespace root\mscluster -ErrorAction SilentlyContinue)) { $true } else { $false }
    'Dell server'      = if ((Get-CimInstance -Class Win32_ComputerSystem).Manufacturer -like '*Dell*') { $true } else { $false }
}

$PhysicalConfig = if ($AtAGlanceHash.'Dell server' -eq $true) {
    $Preferences = omconfig preferences cdvformat delimiter=pipe
    $ControllerList = (omreport storage controller -fmt xml)
    $DiskLayoutRaw = foreach ($Controller in $ControllerList.oma.controllers.DCStorageObject.GlobalNo.'#text') {
        omreport storage pdisk controller=$Controller -fmt cdv
    }

    ($DiskLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($DiskLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object Name, Status, Capacity, State, "Bus Protocol", "Product ID", "Serial No.", "Part Number", Media)
    $DiskNumbers = (0..1000)
    $RAIDLayoutRaw = omreport storage vdisk -fmt cdv
    ($RAIDLayoutRaw |  select-string -SimpleMatch "ID|Status|" -context 0, ($RAIDLayoutRaw).Length | convertfrom-csv -Delimiter "|" | Select-Object '> ID', Name, Status, State, Layout, "Device Name", "Read Policy", "Write Policy", Media | Where-Object { $_.'> ID' -in $DiskNumbers })
}
else {
    "Could not retrieve physical host settings - This server is not a Dell Physical machine"
}


New-HTML {
   
    New-HTMLTab -Name 'Hyper-V Settings' {
        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Virtual Machines' {
                New-HTMLTable -DataTable $VirtualMachines
            }
            New-HTMLSection -HeaderText "Virtual Network Settings" {
                New-HTMLTable -DataTable $VMNetworkSettings
            }
        }
    
        New-HTMLSection -Invisible {
            New-HTMLSection -HeaderText 'Replication Settings' {
                New-HTMLTable -DataTable $ReplicationSettings
            }

            New-HTMLSection -HeaderText "Host Settings" {
                New-HTMLTable -DataTable $HostSettings
            }
        }
        New-HTMLSection -HeaderText "Physical Settings" {
            New-HTMLTable -DataTable $PhysicalConfig
        }
    }
    
} -FilePath "C:\temp\doc.html" -Online

And this script gives you this pretty result:

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

Documenting with PowerShell: Office 365 Secure Score PowerShell module

A while back I wrote a blog about the Secure Score and how to increase it. After that blog I got a lot more questions about documenting Secure Score with the Secure Application model.

To make handling the Secure Score easier, I’ve decided to make a PowerShell Module for this. The main reason for the module is to ease the complexity of changing the Secure Score settings over a lot of tenants. It’s a lot of small tweaks and settings. The module also tells you exactly what it’s going to do if you do not use the -confirmed switch, so you can check if you really wanna go on or not.

To keep in line with my other blogs, I’ll give some cool examples. First off we’ll document the Secure score both in HTML files and IT-Glue. I’ll blog about the remediation part later in the week, so I don’t overload everyone with information. 🙂

I do have to have a little disclaimer; the module is still a little rough around the edges. It’s a beta and I’m actively working on it. 🙂

To use the module, your Secure Application Model needs some extra permissions:

  • 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 “Security” and click on “SecurityEvents.Read.All”. Click on add permission.
  • Search for “Security” and click on “and SecurityEvents.ReadWrite.All”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

HTML version

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret'
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
install-module SecureScore
install-module PsWriteHTML
$Scores = get-securescore -AllTenants -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken

foreach ($Score in $scores) {
    New-HTML {
        New-HTMLSection -HeaderText 'Scores compared'{
            New-HTMLTable -DataTable $Score.scores.averageComparativeScores
        }
        New-HTMLSection -HeaderText "Score: $($score.Scores.currentScore) of $($score.Scores.maxScore)"  {
            New-HTMLTable -DataTable $Score.Scores.controlScores
        }
    } -FilePath "C:\temp\$($Score.TenantName) .html" -Online 
} 

And that’s it for the HTML version, lets move on to IT-Glue next.

IT-Glue version

The IT-Glue version creates the flexible asset for you, uploads the Secure Score for each client. It also looks-up all domains for the client so it can find the right client ID within IT-Glue.

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret'
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########

########################## IT-Glue ############################
$APIKEy = "ITGAPIKEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "O365 SecureScore v1"
$Description = "Secure Score v1."
########################## IT-Glue ############################
  
#Grabbing ITGlue Module and installing.
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 $APIEndpoint
Add-ITGlueAPIKey $APIKEy
 

install-module SecureScore
install-module PsWriteHTML


write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Default Domain Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "TenantID"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Current Score"
                            kind           = "Text"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Secure Score Comparetives"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Secure Score Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
  
write-host "Generating unique ID List" -ForegroundColor Green

$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 

$Scores = get-securescore -AllTenants -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken

foreach ($Score in $scores) {
    $FlexAssetBody = 
    @{
        type       = "flexible-assets"
        attributes = @{
            traits = @{
                "default-domain-name"       = $score.TenantName
                "tenantid"                  = $score.TenantID
                "current-score"             = "$($score.Scores.currentScore) of $($score.Scores.maxScore)"
                "secure-score-comparetives" = ($score.Scores.averageComparativeScores | convertto-html -Fragment | out-string) -replace "<th>", "<th style=`"background-color:#4CAF50`">"
                "secure-score-settings"     = ($score.Scores.controlScores | convertto-html -Fragment | out-string) -replace "<th>", "<th style=`"background-color:#4CAF50`">"
            }
        }
    }
      
    write-output "             Finding $($score.TenantName) in IT-Glue"

    $Domains = (Get-MsolDomain -TenantId $Score.TenantID).name
    $ORGId = foreach ($Domain in $Domains) {
        ($domainList | Where-Object { $_.domain -eq $Domain }).'OrgID' | Select-Object -Unique
    }
    write-output "             Uploading Secure Score for $($score.TenantName) into IT-Glue"
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.tenantid -eq $score.TenantID }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new secure score for $($score.TenantName) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody
  
        }
        else {
            write-output "                      Updating secure score $($score.TenantName) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }
  
    }
} 

And that’s it! Somewhere this week I’ll also be talking about using the SecureScore module to apply the suggested remediation. As I said, those are still a little rough around the edges.

As always, Happy PowerShelling!

Documenting with PowerShell: Documenting Office 365 guest access

So a little while ago we’ve had a client that works a lot with external contractors. These contracts are invited as guests into their Teams. This client came up to us recently and asked “Hey, I wanna know what my contractors are doing”. Normally speaking we’d just point them at IT-Glue and tell them to look there, but we didn’t really have anything for guest access yet.

So we’ve introduced this script. The script connects to Office 365 using the MSOL module, it then gets all guest accounts uses the unified audit log to grab all information for each guest. It then either uploads to IT-Glue or creates a HTML file per tenant depending on which version you’re using.

This way, you can present a readable file to your clients and they can see exactly what files have been edited or what meetings have been attended.

HTML Version

So the HTML version uses PsWriteHTML. This module makes a pretty readable file for us. I’ve included a small screenshot so you can see how it looks;

So, lets get to scripting. As with most of my O365 stuff, you will need to implement the Secure application model.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeRefreshToken'
$UPN = "YourPrettyUpnUsedToGenerateTokens"
######### Secrets #########

$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) {
    $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 -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
    $GuestUsers = get-msoluser -TenantId $customer.TenantId -all | Where-Object { $_.Usertype -eq "guest" }
    if (!$GuestUsers) { 
        Write-Host "No guests for $($customer.name)" -ForegroundColor Yellow
        continue 
    }
    $startDate = (Get-Date).AddDays(-31)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    New-HTML {
        foreach ($guest in $GuestUsers) {
         
            $Logs = do {
                $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -UserIds $guest.userprincipalname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
                $log
                Write-Host "    Retrieved $($log.count) logs for user $($guest.UserPrincipalName)" -ForegroundColor Green
            }while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
            if ($logs) {
                $AuditData = $Logs.AuditData | ForEach-Object { ConvertFrom-Json $_ }
                New-HTMLTab -Name $guest.UserPrincipalName {
                    New-HTMLSection -Invisible {
                        New-HTMLSection -HeaderText 'Logbook' {
                            New-HTMLTable -DataTable ($AuditData | select-object CreationTime, Operation, ClientIP, UserID, SiteURL, SourceFilename, UserAgent )
                        }
                    }
                }
            }
        } 
    } -FilePath "C:\temp\$($customer.DefaultDomainName).html" -Online
}

IT-Glue version

The IT-glue version works the same as the HTML version, but creates the flexible asset for you.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$ExchangeRefreshToken = 'LongExchangeRefreshToken'
$UPN = "YourPrettyUpnUsedToGenerateTokens"
######### Secrets #########

######################### IT-Glue ############################
$APIKEy = "ITG-API-KEY"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "O365 Guest logbook"
$Description = "A logbook of actions a external user has performed"
########################## IT-Glue ############################
 
#Grabbing ITGlue Module and installing.
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 $APIEndpoint
Add-ITGlueAPIKey $APIKEy
 
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Guest"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Actions"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
  
write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
 

$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) {
    $domains = Get-MsolDomain -TenantId $customer.TenantId
    $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 -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog", "Get-AdminAuditLogConfig"
    $GuestUsers = get-msoluser -TenantId $customer.TenantId -all | Where-Object { $_.Usertype -eq "guest" }
    if (!$GuestUsers) { 
        Write-Host "No guests for $($customer.name)" -ForegroundColor Yellow
        continue 
    }
    $startDate = (Get-Date).AddDays(-31)
    $endDate = (Get-Date)
    Write-Host "Retrieving logs for $($customer.name)" -ForegroundColor Blue
    foreach ($guest in $GuestUsers) {
        $Logs = do {
            $log = Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.name -UserIds $guest.userprincipalname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
            $log
            Write-Host "    Retrieved $($log.count) logs for user $($guest.UserPrincipalName)" -ForegroundColor Green
        }while ($Log.count % 5000 -eq 0 -and $log.count -ne 0)
        if ($logs) {
            $AuditData = $logs.AuditData | ForEach-Object { ConvertFrom-Json $_ }
            $FlexAssetBody = 
            @{
                type       = "flexible-assets"
                attributes = @{
                    traits = @{
                        "guest"   = $guest.userprincipalname
                        "actions" = ($AuditData | select-object CreationTime, Operation, ClientIP, UserID, SiteURL, SourceFilename, UserAgent | convertto-html -Fragment | Out-String)
                                                  
                    }
                }
            }
            write-output "             Finding $($customer.name) in IT-Glue"
            $orgid = foreach ($customerDomain in $domains) {
                ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
            }
            write-output "             Uploading O365 guest $($guest.userprincipalname) into IT-Glue"
            foreach ($org in $orgID) {
                $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'guest' -eq $guest.UserPrincipalName }
                #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
                if (!$ExistingFlexAsset) {
                    if ($FlexAssetBody.attributes.'organization-id') {
                        $FlexAssetBody.attributes.'organization-id' = $org
                    }
                    else { 
                        $FlexAssetBody.attributes.add('organization-id', $org)
                        $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
                    }
                    write-output "                      Creating new guest $($guest.userprincipalname) into IT-Glue organisation $org"
                    New-ITGlueFlexibleAssets -data $FlexAssetBody
              
                }
                else {
                    write-output "                      Updating guest $($guest.userprincipalname)  into IT-Glue organisation $org"
                    $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
                    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
                }
              
            }
        }
    } 
} 

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

Documenting with PowerShell: Documenting Azure VMs (And lighthouse setup)

So this blog is actually two blogs all wrapped into one lovely package; I’m going to be showing you how to setup Azure Lighthouse, giving you the ability to manage your clients from your own partner portal, or via PowerShell.

I’m also going to demonstrate how to document VMs in both a local HTML file and IT-Glue. So, lets get started shall we?

Setting up Azure Lighthouse

Azure Lighthouse is a method of getting delegated access to the Azure Subscriptions your client has. If you’re a T2 CSP you’ll need to set this up by hand. You could create a package in the partner portal and have users click on that package from within Azure but that is hardly automated. 😉

To use the script below, you’ll have to log in with the credentials that have access to the clients subscription. The script makes the “AdminAgents” which is the Partner Administrators group “Contributor” in the Azure Portal of the client.

We have a reminder for our billing department that this script needs to run when adding a subscription, that way we never miss getting delegate access to our clients.

$MSPName = "Your Good MSP"
$MSPOffering = "Managed Azure by Good MSP"
$TenantID = "YourTentnantID" #Find this in the Azure Portal -> Azure AD -> Tenant ID
$AdminAgentsID = "YourUserGroupID" #Find this in the Azure Portal -> Azure AD -> Groups -> Look for AdminAgents -> Object ID
$Location = "westeurope" #Enter your Azure Location here.

@"
{
    "`$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "mspName": {
            "type": "string",
            "metadata": {
                "description": "Specify the Managed Service Provider name"
            }
        },
        "mspOfferDescription": {
            "type": "string",
            "metadata": {
                "description": "Name of the Managed Service Provider offering"
            }
        },
        "managedByTenantId": {
            "type": "string",
            "metadata": {
                "description": "Specify the tenant id of the Managed Service Provider"
            }
        },
        "authorizations": {
            "type": "array",
            "metadata": {
                "description": "Specify an array of objects, containing tuples of Azure Active Directory principalId, a Azure roleDefinitionId, and an optional principalIdDisplayName. The roleDefinition specified is granted to the principalId in the provider's Active Directory and the principalIdDisplayName is visible to customers."
            }
        }              
    },
    "variables": {
        "mspRegistrationName": "[guid(parameters('mspName'))]",
        "mspAssignmentName": "[guid(parameters('mspName'))]"
    },
    "resources": [
        {
            "type": "Microsoft.ManagedServices/registrationDefinitions",
            "apiVersion": "2019-06-01",
            "name": "[variables('mspRegistrationName')]",
            "properties": {
                "registrationDefinitionName": "[parameters('mspName')]",
                "description": "[parameters('mspOfferDescription')]",
                "managedByTenantId": "[parameters('managedByTenantId')]",
                "authorizations": "[parameters('authorizations')]"
            }
        },
        {
            "type": "Microsoft.ManagedServices/registrationAssignments",
            "apiVersion": "2019-06-01",
            "name": "[variables('mspAssignmentName')]",
            "dependsOn": [
                "[resourceId('Microsoft.ManagedServices/registrationDefinitions/', variables('mspRegistrationName'))]"
            ],
            "properties": {
                "registrationDefinitionId": "[resourceId('Microsoft.ManagedServices/registrationDefinitions/', variables('mspRegistrationName'))]"
            }
        }
    ],
    "outputs": {
        "mspName": {
            "type": "string",
            "value": "[concat('Managed by', ' ', parameters('mspName'))]"
        },
        "authorizations": {
            "type": "array",
            "value": "[parameters('authorizations')]"
        }
    }
}
"@ | Out-File "rgDelegatedResourceManagement.json"

@"
{
    "`$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "mspName": {
            "value": "$MSPName"
        },
        "mspOfferDescription": {
            "value": "$MSPOffering"
        },
        "managedByTenantId": {
            "value": "$TenantID"
        },
        "authorizations": {
            "value": [
                {
                    "principalId": "$AdminAgentsID",
                    "roleDefinitionId": "b24988ac-6180-42a0-ab88-20f7382dd24c",
                    "principalIdDisplayName": "AdminAgents"
                }
            ]
        }
    }
}
"@ | out-file 'rgDelegatedResourceManagement.parameters.json'

Connect-AzAccount
$Subs = Get-AzSubscription
foreach ($sub in $subs) {
    Set-AzContext -Subscription $sub.id
    New-AzDeployment -Name LightHouse -Location $location -TemplateFile "rgDelegatedResourceManagement.json" -TemplateParameterFile "rgDelegatedResourceManagement.parameters.json" -Verbose
}

And that’s the Azure Lighthouse setup, if you connect using the Secure Application Model you’ll have access to your clients Azure subscriptions. To access them via the portal use the following url: https://portal.azure.com/#blade/Microsoft_Azure_CustomerHub/MyCustomersBladeV2/scopeManagement

Documenting the VMs

As always I’ve prepped two version; one plain HTML, and one for IT-Glue. The HTML versions uses PsWriteHTML to create a nice looking page. The IT-Glue version creates the flexible asset for you.

Before we do that though, execute the following code to allow your Secure Application Model to access Azure:

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$UPN = "UPN-Used-To-Generate-Tokens"
######### Secrets #########
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$Azuretoken = New-PartnerAccessToken -ApplicationId $ApplicationID -Scopes 'https://management.azure.com/user_impersonation' -ServicePrincipal -Credential $credential -Tenant $tenantid -UseAuthorizationCode

HTML Version

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$UPN = "limenetworks@limenetworks.nl"
######### 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
foreach ($Sub in $Subscriptions) {
    write-host "Processing client $($sub.name)"
    $null = $Sub | Set-AzContext
    $VMs = Get-azvm -Status | Select-Object PowerState, Name, ProvisioningState, Location, 
    @{Name = 'OS Type'; Expression = { $_.Storageprofile.osdisk.OSType } }, 
    @{Name = 'VM Size'; Expression = { $_.hardwareprofile.vmsize } },
    @{Name = 'OS Disk Type'; Expression = { $_.StorageProfile.osdisk.manageddisk.storageaccounttype } }
    $networks = get-aznetworkinterface | select-object Primary,
    @{Name = 'NSG'; Expression = { ($_.NetworkSecurityGroup).id -split "/" | select-object -last 1 } }, 
    @{Name = 'DNS Settings'; Expression = { ($_.DNSsettings).dnsservers -join ',' } }, 
    @{Name = 'Connected VM'; Expression = { ($_.VirtualMachine).id -split '/' | select-object -last 1 } },
    @{Name = 'Internal IP'; Expression = { ($_.IPConfigurations).PrivateIpAddress -join "," } },
    @{Name = 'External IP'; Expression = { ($_.IPConfigurations).PublicIpAddress.IpAddress -join "," } }, tags
    $NSGs = get-aznetworksecuritygroup | select-object Name, Location,
    @{Name = 'Allowed Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -eq 'allow'}).DestinationPortRange}  } ,
    @{Name = 'Denied Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -ne 'allow'}).DestinationPortRange} }
    
    New-HTML {
        New-HTMLTab -Name 'Azure VM documentation' {
                New-HTMLSection -HeaderText 'Virtual Machines' {
                    New-HTMLTable -DataTable $VMs
                }
                New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'Network Security Groups' {
                    New-HTMLTable -DataTable $NSGs
                }

                New-HTMLSection -HeaderText "Networks" {
                    New-HTMLTable -DataTable $networks
                }
            }
            }
        } -FilePath "C:\temp\$($sub.name) .html" -Online

}

And that’s it for the HTML version, lets move on to IT-Glue next.

IT-Glue version

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'LongRefreshToken'
$UPN = "limenetworks@limenetworks.nl"
######### Secrets #########

########################## IT-Glue ############################
$APIKEy = "ITGlueKey"
$APIEndpoint = "https://api.eu.itglue.com"
$FlexAssetName = "Azure Virtual Machines"
$Description = "A network one-page document that shows the Azure VM Settings."
########################## IT-Glue ############################

#Grabbing ITGlue Module and installing.
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 $APIEndpoint
Add-ITGlueAPIKey $APIKEy

write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Subscription ID"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "VMs"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "NSGs"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Networks"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData 
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
 
write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 

$DomainList = $DomainList | sort-object -Property Combined -Unique

$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
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
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
foreach ($Sub in $Subscriptions) {
    $OrgTenant = ((Invoke-AzRestMethod -path "/subscriptions/$($sub.subscriptionid)/?api-version=2020-06-01" -method GET).content | convertfrom-json).tenantid
    write-host "Processing client $($sub.name)"
    $Domains = get-msoldomain -tenant $OrgTenant
    $null = $Sub | Set-AzContext
    $VMs = Get-azvm -Status | Select-Object PowerState, Name, ProvisioningState, Location, 
    @{Name = 'OS Type'; Expression = { $_.Storageprofile.osdisk.OSType } }, 
    @{Name = 'VM Size'; Expression = { $_.hardwareprofile.vmsize } },
    @{Name = 'OS Disk Type'; Expression = { $_.StorageProfile.osdisk.manageddisk.storageaccounttype } }
    $networks = get-aznetworkinterface | select-object Primary,
    @{Name = 'NSG'; Expression = { ($_.NetworkSecurityGroup).id -split "/" | select-object -last 1 } }, 
    @{Name = 'DNS Settings'; Expression = { ($_.DNSsettings).dnsservers -join ',' } }, 
    @{Name = 'Connected VM'; Expression = { ($_.VirtualMachine).id -split '/' | select-object -last 1 } },
    @{Name = 'Internal IP'; Expression = { ($_.IPConfigurations).PrivateIpAddress -join "," } },
    @{Name = 'External IP'; Expression = { ($_.IPConfigurations).PublicIpAddress.IpAddress -join "," } }, tags
    $NSGs = get-aznetworksecuritygroup | select-object Name, Location,
    @{Name = 'Allowed Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -eq 'allow' }).DestinationPortRange } } ,
    @{Name = 'Denied Destination Ports'; Expression = { ($_.SecurityRules | Where-Object { $_.direction -eq 'inbound' -and $_.Access -ne 'allow' }).DestinationPortRange } }
    
    $FlexAssetBody = 
    @{
        type       = "flexible-assets"
        attributes = @{
            traits = @{
                "subscription-id" = $sub.SubscriptionId
                "vms"             = ($VMs | convertto-html -Fragment | out-string)
                "nsgs"            = ($NSGs | convertto-html -Fragment | out-string)
                "networks"        = ($networks | convertto-html -Fragment | out-string)
                                     
            }
        }
    }
     



    write-output "             Finding $($sub.name) in IT-Glue"
    $orgid = foreach ($customerDomain in $domains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-output "             Uploading Azure VMs for $($sub.name) into IT-Glue"
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'subscription-id' -eq $sub.subscriptionid }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org 
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new Azure VMs for $($sub.name) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody
 
        }
        else {
            write-output "                      Updating Azure VMs $($sub.name) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }
 
    }

}

And that’s it, this will document your Azure VMs for you. Next time we’ll focus on other resources and resource groups. As always, Happy PowerShelling.

Special thanks on this blog go to Andrew Cullen whom helped me figure out some Azure Secure App Model issues I was encountering. 🙂

Documenting with PowerShell: Documenting the O365 portal

A couple of years ago Eliot at GCITS wrote a great script to update the Office365 portal’s within IT-Glue. This script synced a lot of user information to IT-Glue and kept everything up to date if ran by a function.

I loved this script and have been using it pretty much since it’s inception. Some users recently contacted me and asked how to run this script with the Secure Application Model. Also there were some complaints that the script ran pretty slow. I didn’t really like directly modifying the script so I’ve decided to make a rewrite.

My version uses the Graph API to collect data. We also grab some more information such as mailboxes sizes, and the current multi-factor authentication state for the user.

As always, I’ve made two versions, one for IT-Glue, and one plain HTML version. A small difference in here is that I’ve decided to use the PSWriteHTML module. This is a great PowerShell module by Przemysław Kłys at Evotec that makes generating pretty html files a whole lot easier.

So lets get to scripting! AS always, you’ll need the Secure Application Model to connect to O365.

HTML version

This version connects to all tenants, creates a nice looking HTML page, and writes these to C:\Temp\. The report will look like the screenshot below.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'RefreshToken'
$UPN = "Upn-used-to-generate-tokens"
######### Secrets #########

If (Get-Module -ListAvailable -Name "PsWriteHTML") { Import-module PsWriteHTML } Else { install-module PsWriteHTML -Force; import-module PsWriteHTML }
If (Get-Module -ListAvailable -Name "MsOnline") { Import-module "Msonline" } Else { install-module "MsOnline" -Force; import-module "Msonline" }
If (Get-Module -ListAvailable -Name "PartnerCenter") { Import-module "PartnerCenter" } Else { install-module "PartnerCenter" -Force; import-module "PartnerCenter" }
#Account SKUs to transform to normal name.
$AccountSkuIdDecodeData = @{
    "O365_BUSINESS_ESSENTIALS"           = "Office 365 Business Essentials"
    "O365_BUSINESS_PREMIUM"              = "Office 365 Business Premium"
    "DESKLESSPACK"                       = "Office 365 (Plan K1)"
    "DESKLESSWOFFPACK"                   = "Office 365 (Plan K2)"
    "LITEPACK"                           = "Office 365 (Plan P1)"
    "EXCHANGESTANDARD"                   = "Office 365 Exchange Online"
    "STANDARDPACK"                       = "Enterprise Plan E1"
    "STANDARDWOFFPACK"                   = "Office 365 (Plan E2)"
    "ENTERPRISEPACK"                     = "Enterprise Plan E3"
    "ENTERPRISEPACKLRG"                  = "Enterprise Plan E3"
    "ENTERPRISEWITHSCAL"                 = "Enterprise Plan E4"
    "STANDARDPACK_STUDENT"               = "Office 365 (Plan A1) for Students"
    "STANDARDWOFFPACKPACK_STUDENT"       = "Office 365 (Plan A2) for Students"
    "ENTERPRISEPACK_STUDENT"             = "Office 365 (Plan A3) for Students"
    "ENTERPRISEWITHSCAL_STUDENT"         = "Office 365 (Plan A4) for Students"
    "STANDARDPACK_FACULTY"               = "Office 365 (Plan A1) for Faculty"
    "STANDARDWOFFPACKPACK_FACULTY"       = "Office 365 (Plan A2) for Faculty"
    "ENTERPRISEPACK_FACULTY"             = "Office 365 (Plan A3) for Faculty"
    "ENTERPRISEWITHSCAL_FACULTY"         = "Office 365 (Plan A4) for Faculty"
    "ENTERPRISEPACK_B_PILOT"             = "Office 365 (Enterprise Preview)"
    "STANDARD_B_PILOT"                   = "Office 365 (Small Business Preview)"
    "VISIOCLIENT"                        = "Visio Pro Online"
    "POWER_BI_ADDON"                     = "Office 365 Power BI Addon"
    "POWER_BI_INDIVIDUAL_USE"            = "Power BI Individual User"
    "POWER_BI_STANDALONE"                = "Power BI Stand Alone"
    "POWER_BI_STANDARD"                  = "Power-BI Standard"
    "PROJECTESSENTIALS"                  = "Project Lite"
    "PROJECTCLIENT"                      = "Project Professional"
    "PROJECTONLINE_PLAN_1"               = "Project Online"
    "PROJECTONLINE_PLAN_2"               = "Project Online and PRO"
    "ProjectPremium"                     = "Project Online Premium"
    "ECAL_SERVICES"                      = "ECAL"
    "EMS"                                = "Enterprise Mobility Suite"
    "RIGHTSMANAGEMENT_ADHOC"             = "Windows Azure Rights Management"
    "MCOMEETADV"                         = "PSTN conferencing"
    "SHAREPOINTSTORAGE"                  = "SharePoint storage"
    "PLANNERSTANDALONE"                  = "Planner Standalone"
    "CRMIUR"                             = "CMRIUR"
    "BI_AZURE_P1"                        = "Power BI Reporting and Analytics"
    "INTUNE_A"                           = "Windows Intune Plan A"
    "PROJECTWORKMANAGEMENT"              = "Office 365 Planner Preview"
    "ATP_ENTERPRISE"                     = "Exchange Online Advanced Threat Protection"
    "EQUIVIO_ANALYTICS"                  = "Office 365 Advanced eDiscovery"
    "AAD_BASIC"                          = "Azure Active Directory Basic"
    "RMS_S_ENTERPRISE"                   = "Azure Active Directory Rights Management"
    "AAD_PREMIUM"                        = "Azure Active Directory Premium"
    "MFA_PREMIUM"                        = "Azure Multi-Factor Authentication"
    "STANDARDPACK_GOV"                   = "Microsoft Office 365 (Plan G1) for Government"
    "STANDARDWOFFPACK_GOV"               = "Microsoft Office 365 (Plan G2) for Government"
    "ENTERPRISEPACK_GOV"                 = "Microsoft Office 365 (Plan G3) for Government"
    "ENTERPRISEWITHSCAL_GOV"             = "Microsoft Office 365 (Plan G4) for Government"
    "DESKLESSPACK_GOV"                   = "Microsoft Office 365 (Plan K1) for Government"
    "ESKLESSWOFFPACK_GOV"                = "Microsoft Office 365 (Plan K2) for Government"
    "EXCHANGESTANDARD_GOV"               = "Microsoft Office 365 Exchange Online (Plan 1) only for Government"
    "EXCHANGEENTERPRISE_GOV"             = "Microsoft Office 365 Exchange Online (Plan 2) only for Government"
    "SHAREPOINTDESKLESS_GOV"             = "SharePoint Online Kiosk"
    "EXCHANGE_S_DESKLESS_GOV"            = "Exchange Kiosk"
    "RMS_S_ENTERPRISE_GOV"               = "Windows Azure Active Directory Rights Management"
    "OFFICESUBSCRIPTION_GOV"             = "Office ProPlus"
    "MCOSTANDARD_GOV"                    = "Lync Plan 2G"
    "SHAREPOINTWAC_GOV"                  = "Office Online for Government"
    "SHAREPOINTENTERPRISE_GOV"           = "SharePoint Plan 2G"
    "EXCHANGE_S_ENTERPRISE_GOV"          = "Exchange Plan 2G"
    "EXCHANGE_S_ARCHIVE_ADDON_GOV"       = "Exchange Online Archiving"
    "EXCHANGE_S_DESKLESS"                = "Exchange Online Kiosk"
    "SHAREPOINTDESKLESS"                 = "SharePoint Online Kiosk"
    "SHAREPOINTWAC"                      = "Office Online"
    "YAMMER_ENTERPRISE"                  = "Yammer for the Starship Enterprise"
    "EXCHANGE_L_STANDARD"                = "Exchange Online (Plan 1)"
    "MCOLITE"                            = "Lync Online (Plan 1)"
    "SHAREPOINTLITE"                     = "SharePoint Online (Plan 1)"
    "OFFICE_PRO_PLUS_SUBSCRIPTION_SMBIZ" = "Office ProPlus"
    "EXCHANGE_S_STANDARD_MIDMARKET"      = "Exchange Online (Plan 1)"
    "MCOSTANDARD_MIDMARKET"              = "Lync Online (Plan 1)"
    "SHAREPOINTENTERPRISE_MIDMARKET"     = "SharePoint Online (Plan 1)"
    "OFFICESUBSCRIPTION"                 = "Office ProPlus"
    "YAMMER_MIDSIZE"                     = "Yammer"
    "DYN365_ENTERPRISE_PLAN1"            = "Dynamics 365 Customer Engagement Plan Enterprise Edition"
    "ENTERPRISEPREMIUM_NOPSTNCONF"       = "Enterprise E5 (without Audio Conferencing)"
    "ENTERPRISEPREMIUM"                  = "Enterprise E5 (with Audio Conferencing)"
    "MCOSTANDARD"                        = "Skype for Business Online Standalone Plan 2"
    "PROJECT_MADEIRA_PREVIEW_IW_SKU"     = "Dynamics 365 for Financials for IWs"
    "STANDARDWOFFPACK_IW_STUDENT"        = "Office 365 Education for Students"
    "STANDARDWOFFPACK_IW_FACULTY"        = "Office 365 Education for Faculty"
    "EOP_ENTERPRISE_FACULTY"             = "Exchange Online Protection for Faculty"
    "EXCHANGESTANDARD_STUDENT"           = "Exchange Online (Plan 1) for Students"
    "OFFICESUBSCRIPTION_STUDENT"         = "Office ProPlus Student Benefit"
    "STANDARDWOFFPACK_FACULTY"           = "Office 365 Education E1 for Faculty"
    "STANDARDWOFFPACK_STUDENT"           = "Microsoft Office 365 (Plan A2) for Students"
    "DYN365_FINANCIALS_BUSINESS_SKU"     = "Dynamics 365 for Financials Business Edition"
    "DYN365_FINANCIALS_TEAM_MEMBERS_SKU" = "Dynamics 365 for Team Members Business Edition"
    "FLOW_FREE"                          = "Microsoft Flow Free"
    "POWER_BI_PRO"                       = "Power BI Pro"
    "O365_BUSINESS"                      = "Office 365 Business"
    "DYN365_ENTERPRISE_SALES"            = "Dynamics Office 365 Enterprise Sales"
    "RIGHTSMANAGEMENT"                   = "Rights Management"
    "PROJECTPROFESSIONAL"                = "Project Professional"
    "VISIOONLINE_PLAN1"                  = "Visio Online Plan 1"
    "EXCHANGEENTERPRISE"                 = "Exchange Online Plan 2"
    "DYN365_ENTERPRISE_P1_IW"            = "Dynamics 365 P1 Trial for Information Workers"
    "DYN365_ENTERPRISE_TEAM_MEMBERS"     = "Dynamics 365 For Team Members Enterprise Edition"
    "CRMSTANDARD"                        = "Microsoft Dynamics CRM Online Professional"
    "EXCHANGEARCHIVE_ADDON"              = "Exchange Online Archiving For Exchange Online"
    "EXCHANGEDESKLESS"                   = "Exchange Online Kiosk"
    "SPZA_IW"                            = "App Connect"
    "WINDOWS_STORE"                      = "Windows Store for Business"
    "MCOEV"                              = "Microsoft Phone System"
    "VIDEO_INTEROP"                      = "Polycom Skype Meeting Video Interop for Skype for Business"
    "SPE_E5"                             = "Microsoft 365 E5"
    "SPE_E3"                             = "Microsoft 365 E3"
    "ATA"                                = "Advanced Threat Analytics"
    "MCOPSTN2"                           = "Domestic and International Calling Plan"
    "FLOW_P1"                            = "Microsoft Flow Plan 1"
    "FLOW_P2"                            = "Microsoft Flow Plan 2"
    "CRMSTORAGE"                         = "Microsoft Dynamics CRM Online Additional Storage"
    "SMB_APPS"                           = "Microsoft Business Apps"
    "MICROSOFT_BUSINESS_CENTER"          = "Microsoft Business Center"
    "DYN365_TEAM_MEMBERS"                = "Dynamics 365 Team Members"
    "STREAM"                             = "Microsoft Stream Trial"
    "EMSPREMIUM"                         = "ENTERPRISE MOBILITY + SECURITY E5"

}


$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 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default'

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    write-host "Creating body to request Graph access for each client." -ForegroundColor Green
    $CustomerToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -Tenant $customer.TenantID

    $headers = @{ "Authorization" = "Bearer $($CustomerToken.AccessToken)" }
    write-host "Collecting data for $($Customer.defaultdomainname)" -ForegroundColor Green
    $domains = Get-MsolDomain
    $Licenselist = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/subscribedSkus" -Headers $Headers -Method Get -ContentType "application/json").value
    $Licenselist | ForEach-Object { $_.skupartnumber = "$($AccountSkuIdDecodeData.$($_.skupartnumber))" }
    $AdminRole = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/DirectoryRoles' -Headers $Headers -Method Get -ContentType "application/json").value | Where-Object { $_.displayname -eq 'Company Administrator' }
    $Admins = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/DirectoryRoles/$($AdminRole.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, userprincipalname
    $Users = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/users?$top=999' -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, proxyaddresses, AssignedLicenses, userprincipalname
    $MFAStatus = Get-MsolUser -all -TenantId $customer.TenantId | Select-Object DisplayName,UserPrincipalName,@{N="MFA Status"; E={if( $_.StrongAuthenticationRequirements.State -ne $null) {$_.StrongAuthenticationRequirements.State} else { "Disabled"}}}


    $UserObj = foreach ($user in $users) {
        $Addresses = ($user.proxyaddresses | Sort-Object -Descending) -join "`n" -creplace "SMTP:", "Primary:" -creplace "smtp:", "Alias:"
        [PSCustomObject]@{
            'Display name'      = $user.displayname
            'Addresses'         = [System.Web.HttpUtility]::HtmlDecode($Addresses)
            "Licenses Assigned" = ($Licenselist | Where-Object { $_.skuid -in $User.assignedLicenses.skuid }).skupartnumber -join "`n"
            "MFA Enabled"       = ($MFAStatus | Where-Object { $_.UserPrincipalName -eq $user.userPrincipalName}).'MFA Status'
        }
    
    }

    $licenseObj = foreach ($License in $Licenselist) {
      [PSCustomObject]@{
          'License Name' = $license.skupartnumber
          'Active Licenses' = $license.prepaidUnits.enabled - $license.prepaidUnits.suspended
          'Consumed Licenses' = $license.consumedunits
          'unused licenses' =  $license.prepaidUnits.enabled - $license.prepaidUnits.suspended - $license.consumedunits
      }  
    }
    
    New-HTML {
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText 'Administrators' {
                    New-HTMLTable -DataTable $Admins
                }
                New-HTMLSection -HeaderText 'Licenses' {
                    New-HTMLTable -DataTable  $licenseObj
                }
    
            }
            New-HTMLSection -Invisible {
                New-HTMLSection -HeaderText "Licensed Users" {
                    New-HTMLTable -DataTable ($UserObj | Where-Object { $_.'Licenses Assigned' -ne ""})
                }
                New-HTMLSection -HeaderText "Unlicensed Users" {
                    New-HTMLTable -DataTable ($UserObj | Where-Object { $_.'Licenses Assigned' -eq ""})
                }
            }
    } -FilePath "C:\temp\$($Customer.DefaultDomainName).html" -Online
    


}

IT-Glue version

So the IT-Glue version creates the flexible asset for you, and then starts filling it with the data from O365. I’ve compared this version to the original one just to see if Graph was so much faster – and it is. The original version takes about 87 seconds per client. This version takes 17 seconds per client.

######### Secrets #########
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'RefreshToken'
$UPN = "Upn-used-to-generate-tokens"
######### Secrets #########


################# IT-Glue Information ######################################
$ITGkey = "YOUR-ITG-APIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Office365 - Portals"
$Description = "Office365 portal documentation."
################# /IT-Glue Information #####################################
  
  

write-host "Checking if IT-Glue Module is available, and if not install it." -ForegroundColor Green
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Setting IT-Glue logon information
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
   
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 

write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
$DomainList = $DomainList | sort-object -Property Combined -Unique
   
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Tenant name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Tenant ID"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Verified Domains"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Admin Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } , @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Licenses"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } , @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Licensed Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } , @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Unlicensed Users"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } 
   
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
 
#Account SKUs to transform to normal name.
$AccountSkuIdDecodeData = @{
    "O365_BUSINESS_ESSENTIALS"           = "Office 365 Business Essentials"
    "O365_BUSINESS_PREMIUM"              = "Office 365 Business Premium"
    "DESKLESSPACK"                       = "Office 365 (Plan K1)"
    "DESKLESSWOFFPACK"                   = "Office 365 (Plan K2)"
    "LITEPACK"                           = "Office 365 (Plan P1)"
    "EXCHANGESTANDARD"                   = "Office 365 Exchange Online"
    "STANDARDPACK"                       = "Enterprise Plan E1"
    "STANDARDWOFFPACK"                   = "Office 365 (Plan E2)"
    "ENTERPRISEPACK"                     = "Enterprise Plan E3"
    "ENTERPRISEPACKLRG"                  = "Enterprise Plan E3"
    "ENTERPRISEWITHSCAL"                 = "Enterprise Plan E4"
    "STANDARDPACK_STUDENT"               = "Office 365 (Plan A1) for Students"
    "STANDARDWOFFPACKPACK_STUDENT"       = "Office 365 (Plan A2) for Students"
    "ENTERPRISEPACK_STUDENT"             = "Office 365 (Plan A3) for Students"
    "ENTERPRISEWITHSCAL_STUDENT"         = "Office 365 (Plan A4) for Students"
    "STANDARDPACK_FACULTY"               = "Office 365 (Plan A1) for Faculty"
    "STANDARDWOFFPACKPACK_FACULTY"       = "Office 365 (Plan A2) for Faculty"
    "ENTERPRISEPACK_FACULTY"             = "Office 365 (Plan A3) for Faculty"
    "ENTERPRISEWITHSCAL_FACULTY"         = "Office 365 (Plan A4) for Faculty"
    "ENTERPRISEPACK_B_PILOT"             = "Office 365 (Enterprise Preview)"
    "STANDARD_B_PILOT"                   = "Office 365 (Small Business Preview)"
    "VISIOCLIENT"                        = "Visio Pro Online"
    "POWER_BI_ADDON"                     = "Office 365 Power BI Addon"
    "POWER_BI_INDIVIDUAL_USE"            = "Power BI Individual User"
    "POWER_BI_STANDALONE"                = "Power BI Stand Alone"
    "POWER_BI_STANDARD"                  = "Power-BI Standard"
    "PROJECTESSENTIALS"                  = "Project Lite"
    "PROJECTCLIENT"                      = "Project Professional"
    "PROJECTONLINE_PLAN_1"               = "Project Online"
    "PROJECTONLINE_PLAN_2"               = "Project Online and PRO"
    "ProjectPremium"                     = "Project Online Premium"
    "ECAL_SERVICES"                      = "ECAL"
    "EMS"                                = "Enterprise Mobility Suite"
    "RIGHTSMANAGEMENT_ADHOC"             = "Windows Azure Rights Management"
    "MCOMEETADV"                         = "PSTN conferencing"
    "SHAREPOINTSTORAGE"                  = "SharePoint storage"
    "PLANNERSTANDALONE"                  = "Planner Standalone"
    "CRMIUR"                             = "CMRIUR"
    "BI_AZURE_P1"                        = "Power BI Reporting and Analytics"
    "INTUNE_A"                           = "Windows Intune Plan A"
    "PROJECTWORKMANAGEMENT"              = "Office 365 Planner Preview"
    "ATP_ENTERPRISE"                     = "Exchange Online Advanced Threat Protection"
    "EQUIVIO_ANALYTICS"                  = "Office 365 Advanced eDiscovery"
    "AAD_BASIC"                          = "Azure Active Directory Basic"
    "RMS_S_ENTERPRISE"                   = "Azure Active Directory Rights Management"
    "AAD_PREMIUM"                        = "Azure Active Directory Premium"
    "MFA_PREMIUM"                        = "Azure Multi-Factor Authentication"
    "STANDARDPACK_GOV"                   = "Microsoft Office 365 (Plan G1) for Government"
    "STANDARDWOFFPACK_GOV"               = "Microsoft Office 365 (Plan G2) for Government"
    "ENTERPRISEPACK_GOV"                 = "Microsoft Office 365 (Plan G3) for Government"
    "ENTERPRISEWITHSCAL_GOV"             = "Microsoft Office 365 (Plan G4) for Government"
    "DESKLESSPACK_GOV"                   = "Microsoft Office 365 (Plan K1) for Government"
    "ESKLESSWOFFPACK_GOV"                = "Microsoft Office 365 (Plan K2) for Government"
    "EXCHANGESTANDARD_GOV"               = "Microsoft Office 365 Exchange Online (Plan 1) only for Government"
    "EXCHANGEENTERPRISE_GOV"             = "Microsoft Office 365 Exchange Online (Plan 2) only for Government"
    "SHAREPOINTDESKLESS_GOV"             = "SharePoint Online Kiosk"
    "EXCHANGE_S_DESKLESS_GOV"            = "Exchange Kiosk"
    "RMS_S_ENTERPRISE_GOV"               = "Windows Azure Active Directory Rights Management"
    "OFFICESUBSCRIPTION_GOV"             = "Office ProPlus"
    "MCOSTANDARD_GOV"                    = "Lync Plan 2G"
    "SHAREPOINTWAC_GOV"                  = "Office Online for Government"
    "SHAREPOINTENTERPRISE_GOV"           = "SharePoint Plan 2G"
    "EXCHANGE_S_ENTERPRISE_GOV"          = "Exchange Plan 2G"
    "EXCHANGE_S_ARCHIVE_ADDON_GOV"       = "Exchange Online Archiving"
    "EXCHANGE_S_DESKLESS"                = "Exchange Online Kiosk"
    "SHAREPOINTDESKLESS"                 = "SharePoint Online Kiosk"
    "SHAREPOINTWAC"                      = "Office Online"
    "YAMMER_ENTERPRISE"                  = "Yammer for the Starship Enterprise"
    "EXCHANGE_L_STANDARD"                = "Exchange Online (Plan 1)"
    "MCOLITE"                            = "Lync Online (Plan 1)"
    "SHAREPOINTLITE"                     = "SharePoint Online (Plan 1)"
    "OFFICE_PRO_PLUS_SUBSCRIPTION_SMBIZ" = "Office ProPlus"
    "EXCHANGE_S_STANDARD_MIDMARKET"      = "Exchange Online (Plan 1)"
    "MCOSTANDARD_MIDMARKET"              = "Lync Online (Plan 1)"
    "SHAREPOINTENTERPRISE_MIDMARKET"     = "SharePoint Online (Plan 1)"
    "OFFICESUBSCRIPTION"                 = "Office ProPlus"
    "YAMMER_MIDSIZE"                     = "Yammer"
    "DYN365_ENTERPRISE_PLAN1"            = "Dynamics 365 Customer Engagement Plan Enterprise Edition"
    "ENTERPRISEPREMIUM_NOPSTNCONF"       = "Enterprise E5 (without Audio Conferencing)"
    "ENTERPRISEPREMIUM"                  = "Enterprise E5 (with Audio Conferencing)"
    "MCOSTANDARD"                        = "Skype for Business Online Standalone Plan 2"
    "PROJECT_MADEIRA_PREVIEW_IW_SKU"     = "Dynamics 365 for Financials for IWs"
    "STANDARDWOFFPACK_IW_STUDENT"        = "Office 365 Education for Students"
    "STANDARDWOFFPACK_IW_FACULTY"        = "Office 365 Education for Faculty"
    "EOP_ENTERPRISE_FACULTY"             = "Exchange Online Protection for Faculty"
    "EXCHANGESTANDARD_STUDENT"           = "Exchange Online (Plan 1) for Students"
    "OFFICESUBSCRIPTION_STUDENT"         = "Office ProPlus Student Benefit"
    "STANDARDWOFFPACK_FACULTY"           = "Office 365 Education E1 for Faculty"
    "STANDARDWOFFPACK_STUDENT"           = "Microsoft Office 365 (Plan A2) for Students"
    "DYN365_FINANCIALS_BUSINESS_SKU"     = "Dynamics 365 for Financials Business Edition"
    "DYN365_FINANCIALS_TEAM_MEMBERS_SKU" = "Dynamics 365 for Team Members Business Edition"
    "FLOW_FREE"                          = "Microsoft Flow Free"
    "POWER_BI_PRO"                       = "Power BI Pro"
    "O365_BUSINESS"                      = "Office 365 Business"
    "DYN365_ENTERPRISE_SALES"            = "Dynamics Office 365 Enterprise Sales"
    "RIGHTSMANAGEMENT"                   = "Rights Management"
    "PROJECTPROFESSIONAL"                = "Project Professional"
    "VISIOONLINE_PLAN1"                  = "Visio Online Plan 1"
    "EXCHANGEENTERPRISE"                 = "Exchange Online Plan 2"
    "DYN365_ENTERPRISE_P1_IW"            = "Dynamics 365 P1 Trial for Information Workers"
    "DYN365_ENTERPRISE_TEAM_MEMBERS"     = "Dynamics 365 For Team Members Enterprise Edition"
    "CRMSTANDARD"                        = "Microsoft Dynamics CRM Online Professional"
    "EXCHANGEARCHIVE_ADDON"              = "Exchange Online Archiving For Exchange Online"
    "EXCHANGEDESKLESS"                   = "Exchange Online Kiosk"
    "SPZA_IW"                            = "App Connect"
    "WINDOWS_STORE"                      = "Windows Store for Business"
    "MCOEV"                              = "Microsoft Phone System"
    "VIDEO_INTEROP"                      = "Polycom Skype Meeting Video Interop for Skype for Business"
    "SPE_E5"                             = "Microsoft 365 E5"
    "SPE_E3"                             = "Microsoft 365 E3"
    "ATA"                                = "Advanced Threat Analytics"
    "MCOPSTN2"                           = "Domestic and International Calling Plan"
    "FLOW_P1"                            = "Microsoft Flow Plan 1"
    "FLOW_P2"                            = "Microsoft Flow Plan 2"
    "CRMSTORAGE"                         = "Microsoft Dynamics CRM Online Additional Storage"
    "SMB_APPS"                           = "Microsoft Business Apps"
    "MICROSOFT_BUSINESS_CENTER"          = "Microsoft Business Center"
    "DYN365_TEAM_MEMBERS"                = "Dynamics 365 Team Members"
    "STREAM"                             = "Microsoft Stream Trial"
    "EMSPREMIUM"                         = "ENTERPRISE MOBILITY + SECURITY E5"

}


$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 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default'

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    write-host "Creating body to request Graph access for each client." -ForegroundColor Green
    $CustomerToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -Tenant $customer.TenantID

    $headers = @{ "Authorization" = "Bearer $($CustomerToken.AccessToken)" }
    write-host "Collecting data for $($Customer.defaultdomainname)" -ForegroundColor Green
    $domains = Get-MsolDomain -TenantId $customer.TenantId | Where-Object { $_.status -contains "Verified" }
    $Licenselist = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/subscribedSkus" -Headers $Headers -Method Get -ContentType "application/json").value
    $Licenselist | ForEach-Object { $_.skupartnumber = "$($AccountSkuIdDecodeData.$($_.skupartnumber))" }
    $AdminRole = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/DirectoryRoles' -Headers $Headers -Method Get -ContentType "application/json").value | Where-Object { $_.displayname -eq 'Company Administrator' }
    $Admins = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/DirectoryRoles/$($AdminRole.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, userprincipalname
    $Users = (Invoke-RestMethod -Uri 'https://graph.microsoft.com/beta/users?$top=999' -Headers $Headers -Method Get -ContentType "application/json").value | Select-Object DisplayName, proxyaddresses, AssignedLicenses, userprincipalname
    $MFAStatus = Get-MsolUser -all -TenantId $customer.TenantId | Select-Object DisplayName, UserPrincipalName, @{N = "MFA Status"; E = { if ( $_.StrongAuthenticationRequirements.State -ne $null) { $_.StrongAuthenticationRequirements.State } else { "Disabled" } } }


    $UserObj = foreach ($user in $users) {
        $Addresses = ($user.proxyaddresses | Sort-Object -Descending) -join "`n" -creplace "SMTP:", "Primary:" -creplace "smtp:", "Alias:"
        [PSCustomObject]@{
            'Display name'      = $user.displayname
            'Addresses'         = [System.Web.HttpUtility]::HtmlDecode($Addresses)
            "Licenses Assigned" = ($Licenselist | Where-Object { $_.skuid -in $User.assignedLicenses.skuid }).skupartnumber -join "`n"
            "MFA Enabled"       = ($MFAStatus | Where-Object { $_.UserPrincipalName -eq $user.userPrincipalName }).'MFA Status'
        }
    
    }

    $licenseObj = foreach ($License in $Licenselist) {
        [PSCustomObject]@{
            'License Name'      = $license.skupartnumber
            'Active Licenses'   = $license.prepaidUnits.enabled - $license.prepaidUnits.suspended
            'Consumed Licenses' = $license.consumedunits
            'unused licenses'   = $license.prepaidUnits.enabled - $license.prepaidUnits.suspended - $license.consumedunits
        }  
    }
    
    $FlexAssetBody = 
    @{
        type       = "flexible-assets"
        attributes = @{
            traits = @{
                "tenant-name"      = $customer.Name
                "tenant-id"        = $customer.TenantId
                "verified-domains" = ($domains | select-object Name, Status, IsDefault | convertto-html -Fragment | out-string)
                "admin-users"      = ($admins  | select-object DisplayName, UserPrincipalName | convertto-html -Fragment  | out-string)
                "licenses"         = ($licenseObj | select-object 'License Name', 'active licenses', 'consumed licenses', 'unused licenses' | convertto-html -Fragment  | out-string)
                "licensed-users"   = (($UserObj | Where-Object { $_.'Licenses Assigned' -ne "" }) | convertto-html -Fragment  | out-string)
                "unlicensed-users" = (($UserObj | Where-Object { $_.'Licenses Assigned' -eq "" }) | convertto-html -Fragment  | out-string)
                                    
            }
        }
    }
    
    write-output "             Finding $($customer.name) in IT-Glue"
    $DomainList = foreach ($Contact in $AllITGlueContacts) {
        $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
        [PSCustomObject]@{
            Domain   = $ITGDomain
            OrgID    = $Contact.'organization-id'
            Combined = "$($ITGDomain)$($Contact.'organization-id')"
        }
    }
    $domainList = $DomainList | sort-object -Property Combined -Unique

    $orgid = foreach ($customerDomain in $domains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-output "             Uploading Office Portal $($customer.name) into IT-Glue"
    foreach ($org in $orgID) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'tenant-id' -eq $customer.tenantid.guid }
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org 
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new Office Portal $($customer.name) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody

        }
        else {
            write-output "                      Updating Office Portal $($customer.name) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }

    }

}

Documenting with PowerShell: Breaches using the HIBP API

So I was thinking of this idea for a bit. My sales team got approached by a product that gives you information about what breaches you are in. There were a couple of issues we had with this product. The first part is that 90% of its data comes from the public “Have I been Pwned” database, while they claimed it was their own. The second was that the tool did not integrate with our documentation system directly. There were some weird limits like a maximum of one domain per client, so I figured I’d try to build something myself instead.

After some testing I’ve decided to make 3 versions of this script. One that directly uploads the breach information to IT-Glue. Another to create generic HTML files which you could easily send to clients. Lastly I also wanted to help our sales team out a little with a dynamic version. With this one you could enter emails and IP and get a nice looking report back.

So let’s get started! For all three scripts you’ll need 2 API keys. One for Have I been Pwned which will cost you €3,50 a month. You’ll need another for Shodan which can be free, premium, or bought in discount for 1 dollar once in a while.

IT-Glue version

The IT-Glue version creates a flexible asset for you, uploads the data per client. It looks up each e-mail address in the O365 tenant, and does a Shodan search for the registered domain names.

################### Secure Application Model Information ###################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$RefreshToken = 'ExtremelyLongRefreshToken'
################# /Secure Application Model Information ####################

################# API Keys #################################################
$ShodanAPIKey = 'YourShodanAPIKEy'
$HaveIBeenPwnedKey = 'HIBPAPIKey'
################# /API Keys ################################################

################# IT-Glue Information ######################################
$ITGkey = "ITGluekey"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Breach v1 - Autodoc"
$Description = "Automatic Documentation for known breaches."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
################# /IT-Glue Information #####################################
 
 
write-host "Checking if IT-Glue Module is available, and if not install it." -ForegroundColor Green
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Setting IT-Glue logon information
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
  
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.count -ne 0) 
write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
$DomainList = $DomainList | sort-object -Property Combined -Unique
  
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Tenant name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Breaches"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Shodan Info"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    } 
  
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}


write-host "Creating credentials and tokens." -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
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
$HIBPHeader = @{'hibp-api-key' = $HaveIBeenPwnedKey }
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {

    $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "Finding possible organisation IDs for $($customer.name)" -ForegroundColor Green
    $orgid = foreach ($customerDomain in $customerdomains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-host "  Retrieving Breach Info for $($customer.name)" -ForegroundColor Green
    $UserList = get-msoluser -all -TenantId $Customer.TenantId
    $HIBPList = foreach ($User in $UserList) {
        try {
            $Breaches = $null
            $Breaches = Invoke-RestMethod -Uri "https://haveibeenpwned.com/api/v3/breachedaccount/$($user.UserPrincipalName)?truncateResponse=false" -Headers $HIBPHeader -UserAgent 'CyberDrain.com PowerShell Breach Script'
        }
        catch {
            if ($_.Exception.Response.StatusCode.value__ -eq '404') {  } else { write-error "$($_.Exception.message)" }
        }
        start-sleep 1.5
        foreach ($Breach in $Breaches) {
            [PSCustomObject]@{
                Username              = $user.UserPrincipalName
                'Name'                = $Breach.name
                'Domain name'         = $breach.Domain
                'Date'                = $Breach.Breachdate
                'Verified by experts' = if ($Breach.isverified) { 'Yes' } else { 'No' }
                'Leaked data'         = $Breach.DataClasses -join ', '
                'Description'         = $Breach.Description
            }
        }
    }
    $BreachListHTML = $HIBPList | ConvertTo-Html -Fragment -PreContent '<h2>Breaches</h2><br> A "breach" is an incident where data is inadvertently exposed in a vulnerable system, usually due to insufficient access controls or security weaknesses in the software. HIBP aggregates breaches and enables people to assess where their personal data has been exposed.<br>' | Out-String

    write-host "Getting Shodan information for $($Customer.name)'s domains."
    $SHodanInfo = foreach ($Domain in $CustomerDomains.Name) {
        $ShodanQuery = (Invoke-RestMethod -Uri "https://api.shodan.io/shodan/host/search?key=$($ShodanAPIKey)&query=$Domain" -UserAgent 'CyberDrain.com PowerShell Breach Script').matches
        foreach ($FoundItem in $ShodanQuery) {
            [PSCustomObject]@{
                'Searched for'    = $Domain
                'Found Product'   = $FoundItem.product
                'Found open port' = $FoundItem.port
                'Found IP'        = $FoundItem.ip_str
                'Found Domain'    = $FoundItem.domain
            }

        }
    }
    if (!$ShodanInfo -or $SHodanInfo) { $ShodanInfo = @{'Detection' = "No information found for domains on Shodan"} }
    $ShodanHTML = $SHodanInfo | ConvertTo-Html -Fragment -PreContent "<h2>Shodan Information</h2><br>Shodan is a search engine, but one designed specifically for internet connected devices. It scours the invisible parts of the Internet most people won’t ever see. Any internet exposed connected device can show up in a search.<br>" | Out-String
    
    $FlexAssetBody =
    @{
        type       = 'flexible-assets'
        attributes = @{
            traits = @{
                'tenant-name' = $customer.DefaultDomainName
                'breaches'    = [System.Web.HttpUtility]::HtmlDecode($BreachListHTML -replace $TableStyling)
                'shodan-info' = ($ShodanHTML -replace $TableStyling)
            }
        }
    }

    write-host "   Uploading Breach Info $($customer.name) into IT-Glue" -foregroundColor green
    foreach ($org in $orgID | Select-Object -Unique) {
        $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'tenant-name' -eq $($Customer.DefaultDomainName) } | Select-Object -last 1
        #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
        if (!$ExistingFlexAsset) {
            if ($FlexAssetBody.attributes.'organization-id') {
                $FlexAssetBody.attributes.'organization-id' = $org
            }
            else { 
                $FlexAssetBody.attributes.add('organization-id', $org)
                $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
            }
            write-output "                      Creating new Breach Info for $($Customer.name) into IT-Glue organisation $org"
            New-ITGlueFlexibleAssets -data $FlexAssetBody
 
        }
        else {
            write-output "                      Updating Breach Info for $($Customer.name) into IT-Glue organisation $org"
            $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
            Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
        }
 
    }
}

Generic version

So the generic version works the same as the IT-Glue version. The only difference is that it will create a file per tenant in C:\Temp. You can distribute this file or add it to your own documentation system manually or via an API I don’t know about. 🙂 I’ve added a screenshot for how this would look as requested.

################### Secure Application Model Information ###################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$RefreshToken = 'ExtremelyLongRefreshToken'
################# /Secure Application Model Information ####################

################# API Keys #################################################
$ShodanAPIKey = 'YourShodanAPIKEy'
$HaveIBeenPwnedKey = 'HIBPAPIKey'
################# /API Keys ################################################


$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> Breach logbook</H1> <br>
   
This log contains all breaches found for the e-mail addresses in your Microsoft tenant. You can use the search to find specific e-mail addresses.
<br/>
<br/>
    
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
   


write-host "Creating credentials and tokens." -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
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
$HIBPHeader = @{'hibp-api-key' = $HaveIBeenPwnedKey }
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
  $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "  Retrieving Breach Info for $($customer.name)" -ForegroundColor Green
    $UserList = get-msoluser -all -TenantId $Customer.TenantId
    $HIBPList = foreach ($User in $UserList) {
        try {
            $Breaches = $null
            $Breaches = Invoke-RestMethod -Uri "https://haveibeenpwned.com/api/v3/breachedaccount/$($user.UserPrincipalName)?truncateResponse=false" -Headers $HIBPHeader -UserAgent 'CyberDrain.com PowerShell Breach Script'
        }
        catch {
            if ($_.Exception.Response.StatusCode.value__ -eq '404') {  } else { write-error "$($_.Exception.message)" }
        }
        start-sleep 1.5
        foreach ($Breach in $Breaches) {
            [PSCustomObject]@{
                Username              = $user.UserPrincipalName
                'Name'                = $Breach.name
                'Domain name'         = $breach.Domain
                'Date'                = $Breach.Breachdate
                'Verified by experts' = if ($Breach.isverified) { 'Yes' } else { 'No' }
                'Leaked data'         = $Breach.DataClasses -join ', '
                'Description'         = $Breach.Description
            }
        }
    }
    $BreachListHTML = $HIBPList | ConvertTo-Html -Fragment -PreContent '<h2>Breaches</h2><br> A "breach" is an incident where data is inadvertently exposed in a vulnerable system, usually due to insufficient access controls or security weaknesses in the software. HIBP aggregates breaches and enables people to assess where their personal data has been exposed.<br>' | Out-String

    write-host "Getting Shodan information for $($Customer.name)'s domains."
    $SHodanInfo = foreach ($Domain in $CustomerDomains.Name) {
        $ShodanQuery = (Invoke-RestMethod -Uri "https://api.shodan.io/shodan/host/search?key=$($ShodanAPIKey)&query=$Domain" -UserAgent 'CyberDrain.com PowerShell Breach Script').matches
        foreach ($FoundItem in $ShodanQuery) {
            [PSCustomObject]@{
                'Searched for'    = $Domain
                'Found Product'   = $FoundItem.product
                'Found open port' = $FoundItem.port
                'Found IP'        = $FoundItem.ip_str
                'Found Domain'    = $FoundItem.domain
            }

        }
    }
    if (!$ShodanInfo) { $ShodanInfo = @{ 'Detection' = "No information found for domains on Shodan" } }
    $ShodanHTML = $SHodanInfo | ConvertTo-Html -Fragment -PreContent "<h2>Shodan Information</h2><br>Shodan is a search engine, but one designed specifically for internet connected devices. It scours the invisible parts of the Internet most people won’t ever see. Any internet exposed connected device can show up in a search.<br>" | Out-String
    
$head,$PreContent,[System.Web.HttpUtility]::HtmlDecode($BreachListHTML),$ShodanHTML | Out-File "C:\temp\$($customer.name).html"
   
}

On-Demand version

So the on-demand version is transformed into a function. This allows you to enter the e-mail addresses as a list, and any IP addresses you also want to add to the report. An example query could be:

Get-BreachInfo -EmailAddress 'Person2@google.com','Person1@google.com' -IPs '1.1.1.1','cyberdrain.com' -ShodanAPIKey 'YourShodanKey' -HaveIBeenPwnedKey 'YourShodanKey'

We’re hosting this version on a Azure Function that our sales engineers can query whenever they need to. It makes it easy for them to create a report for a client on-demand.

function Get-BreachInfo {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$EmailAddress,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$IPs,
        [Parameter(Mandatory = $true)]$ShodanAPIKey,
        [Parameter(Mandatory = $true)]$HaveIBeenPwnedKey,
        [Parameter(Mandatory = $true)]$Outputfile
    )
    $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> Breach logbook</H1> <br>
   
This log contains all breaches found for the e-mail addresses in your Microsoft tenant. You can use the search to find specific e-mail addresses.
<br/>
<br/>
    
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
    $HIBPHeader = @{'hibp-api-key' = $HaveIBeenPwnedKey }
    write-host "  Retrieving Breach Info" -ForegroundColor Green
    $UserList = $EmailAddress
    $HIBPList = foreach ($User in $UserList) {
        try {
            $Breaches = $null
            $Breaches = Invoke-RestMethod -Uri "https://haveibeenpwned.com/api/v3/breachedaccount/$($user)?truncateResponse=false" -Headers $HIBPHeader -UserAgent 'CyberDrain.com PowerShell Breach Script'
        }
        catch {
            if ($_.Exception.Response.StatusCode.value__ -eq '404') {  } else { write-error "$($_.Exception.message)" }
        }
        start-sleep 1.5
        foreach ($Breach in $Breaches) {
            [PSCustomObject]@{
                Username              = $user
                'Name'                = $Breach.name
                'Domain name'         = $breach.Domain
                'Date'                = $Breach.Breachdate
                'Verified by experts' = if ($Breach.isverified) { 'Yes' } else { 'No' }
                'Leaked data'         = $Breach.DataClasses -join ', '
                'Description'         = $Breach.Description
            }
        }
    }
    $BreachListHTML = $HIBPList | ConvertTo-Html -Fragment -PreContent '<h2>Breaches</h2><br> A "breach" is an incident where data is inadvertently exposed in a vulnerable system, usually due to insufficient access controls or security weaknesses in the software. HIBP aggregates breaches and enables people to assess where their personal data has been exposed.<br>' | Out-String

    write-host "      Getting Shodan information." -ForegroundColor Green
    $SHodanInfo = foreach ($Domain in $IPs) {
        $ShodanQuery = (Invoke-RestMethod -Uri "https://api.shodan.io/shodan/host/search?key=$($ShodanAPIKey)&query=$Domain" -UserAgent 'CyberDrain.com PowerShell Breach Script').matches
        foreach ($FoundItem in $ShodanQuery) {
            [PSCustomObject]@{
                'Searched for'    = $Domain
                'Found Product'   = $FoundItem.product
                'Found open port' = $FoundItem.port
                'Found IP'        = $FoundItem.ip_str
                'Found Domain'    = $FoundItem.domain
            }

        }
    }
    if (!$ShodanInfo) { $ShodanInfo = @{ 'Detection' = "No information found for domains on Shodan" } }
    $ShodanHTML = $SHodanInfo | ConvertTo-Html -Fragment -PreContent "<h2>Shodan Information</h2><br>Shodan is a search engine, but one designed specifically for internet connected devices. It scours the invisible parts of the Internet most people won’t ever see. Any internet exposed connected device can show up in a search.<br>" | Out-String
    
    $head, $PreContent, [System.Web.HttpUtility]::HtmlDecode($BreachListHTML), $ShodanHTML | Out-File $Outputfile
   
}

So that’s it! With this, I hope you can document your breaches a little a better and help clients understand the risks involved. As always, Happy PowerShelling!

Documenting with PowerShell: Documenting DHCP server settings

This script was requested by a friend of mine. She had trouble keeping her IP address management under control. A lot of changes on super and subscopes within networks caused her to lose oversight and she was wondering if there wasn’t a clean and automated way of generating documentation for this.

But before we dive into that script, I want to let you all know that the AzGlue function to protect, and help circumvent rate limitations for IT-Glue has been updated. Angus Warren made an amazing update. He added loads of functionality and security measures. If you are using AzGlue right now, I’d suggest to update to the latest version.

Angus his changes have updated the function entirely, and it’s practically a new product all together. I’ll be updating the documentation for this in the coming weeks.

The scripts

So, now let’s get to the scripts, I am sharing two versions again. One for IT-Glue, and one generic HTML version. The IT-Glue version creates the flexible asset for you at first run, and then documents the DHCP settings.

IT-Glue version

You can run this version by hand, using your RMM, or from a scheduled task.

###############
$ITGkey = "YOURIGLUEKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "DHCP Server - Autodoc"
$ITGlueOrgID = "ITGLUEORGID"
$Description = "A logbook for DHCP server witha ll information about scopes, superscopes, etc.."
##############
#Settings IT-Glue logon information
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 $ITGbaseURI
Add-ITGlueAPIKey $ITGkey

write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "DHCP Server Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "DHCP Server Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "DHCP Server Database Information"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "DHCP Domain Authorisation"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "DHCP Scopes"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "DHCP Scope Information"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "DHCP Statistics"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}

write-host "Starting documentation process." -foregroundColor green


$DCHPServerSettings = Get-DhcpServerSetting | select-object ActivatePolicies, ConflictDetectionAttempts, DynamicBootp, IsAuthorized, IsDomainJoined, NapEnabled, NpsUnreachableAction, RestoreStatus | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server Settings</h1>" | Out-String
$databaseinfo = Get-DhcpServerDatabase | Select-Object BackupInterval, BackupPath, CleanupInterval, FileName, LoggingEnabled, RestoreFromBackup | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Database information</h1>" | Out-String
$DHCPDCAuth = Get-DhcpServerInDC | select-object IPAddress, DnsName  | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Domain Controller Authorisations</h1>" | Out-String
$Scopes = Get-DhcpServerv4Scope
$ScopesAvailable = $Scopes | Select-Object ScopeId, SubnetMask, StartRange, EndRange, ActivatePolicies, Delay, Description, LeaseDuration, MaxBootpClients, Name, NapEnable, NapProfile, State, SuperscopeName, Type | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server scopes</h1>" | Out-String
$ScopeInfo = foreach ($Scope in $scopes) {
    $scope | Get-DhcpServerv4Lease | select-object ScopeId, IPAddress, AddressState, ClientId, ClientType, Description, DnsRegistration, DnsRR, HostName, LeaseExpiryTime |  ConvertTo-Html -Fragment -PreContent "<h1>Scope Information: $($Scope.name) - $($scope.ScopeID) </h1>" | Out-String
}

$DHCPServerStats = Get-DhcpServerv4Statistics | Select-Object InUse, Available, Acks, AddressesAvailable, AddressesInUse, Declines, DelayedOffers, Discovers, Naks, Offers, PendingOffers, PercentageAvailable, PercentageInUse, PercentagePendingOffers, Releases, Requests, ScopesWithDelayConfigured, ServerStartTime, TotalAddresses, TotalScope | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server statistics</h1>" -As List | Out-String


write-host "Uploading to IT-Glue." -foregroundColor green
$FlexAssetBody = @{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'dhcp-server-name'                 = $env:computername
            'dhcp-server-settings'             = $DCHPServerSettings
            'dhcp-server-database-information' = $databaseinfo
            'dhcp-domain-authorisation'        = $DHCPDCAuth
            'dhcp-scopes'                      = $ScopesAvailable
            'dhcp-scope-information'           = $ScopeInfo
            'dhcp-statistics'                  = $DHCPServerStats
        }
    }
}
write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'dhcp-server-name' -eq $env:computername }

#If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
if (!$ExistingFlexAsset) {
    $FlexAssetBody.attributes.add('organization-id', $ITGlueOrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating DHCP Server Log into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing DHCP Server Log into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
    $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}

Generic version

As requested for the generic version I’ve included a screenshot of the end results:

###############
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############


write-host "Starting documentation process." -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>DHCP 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>
"@

$DCHPServerSettings = Get-DhcpServerSetting | select-object ActivatePolicies,ConflictDetectionAttempts,DynamicBootp,IsAuthorized,IsDomainJoined,NapEnabled,NpsUnreachableAction,RestoreStatus | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server Settings</h1>" | Out-String
$databaseinfo = Get-DhcpServerDatabase | Select-Object BackupInterval,BackupPath,CleanupInterval,FileName,LoggingEnabled,RestoreFromBackup | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Database information</h1>" | Out-String
$DHCPDCAuth = Get-DhcpServerInDC | select-object IPAddress,DnsName  |ConvertTo-Html -Fragment -PreContent "<h1>DHCP Domain Controller Authorisations</h1>" | Out-String
$Scopes = Get-DhcpServerv4Scope
$ScopesAvailable = $Scopes | Select-Object ScopeId,SubnetMask,StartRange,EndRange,ActivatePolicies,Delay,Description,LeaseDuration,MaxBootpClients,Name,NapEnable,NapProfile,State,SuperscopeName,Type | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server scopes</h1>" | Out-String
$ScopeInfo = foreach ($Scope in $scopes) {
    $scope | Get-DhcpServerv4Lease | select-object ScopeId, IPAddress, AddressState, ClientId, ClientType, Description, DnsRegistration, DnsRR, HostName, LeaseExpiryTime |  ConvertTo-Html -Fragment -PreContent "<h1>Scope Information: $($Scope.name) - $($scope.ScopeID) </h1>" | Out-String
}

$DHCPServerStats = Get-DhcpServerv4Statistics | Select-Object InUse,Available,Acks,AddressesAvailable,AddressesInUse,Declines,DelayedOffers,Discovers,Naks,Offers,PendingOffers,PercentageAvailable,PercentageInUse,PercentagePendingOffers,Releases,Requests,ScopesWithDelayConfigured,ServerStartTime,TotalAddresses,TotalScope | ConvertTo-Html -Fragment -PreContent "<h1>DHCP Server statistics</h1>" -As List | Out-String


$head, $DCHPServerSettings, $databaseinfo, $DHCPDCAuth, $ScopesAvailable,$ScopeInfo,$DHCPServerStats | out-file "C:\Temp\Auditoutput.html"

And that’s it! Nicole is now using this script to document her DHCP servers, and so can you. As always, Happy PowerShelling.

Documenting with PowerShell: Documenting Microsoft Teams

I was thinking of creating an automated teams mapping tool, which runs when a user logs on to a new machine to automatically sync all the Teams sites required and joined. I mostly wanted to do this because the current implementation of the registry/GPO method can take up to 8 hours.

During the creation of my tool I noticed I didn’t have an up to date document for all teams. So I figured I would create one. To do this, I’m using the Secure Application Model as always. The only difference is the method we are using to log onto the Graph API.

Logging onto the Graph API requires a token, normally we generate this token using the PartnerCenter module. This works fine for most tasks, but for using Teams with the Graph API we need a different claim, namely the “Client_Credentials” claim.

So instead of using the partner center module, we will create our own request for a token. I’ll show you how in the script.

I’ve created two versions again, one generic, and one for IT-Glue.

Generic version

The generic version creates 1 html file per team in C:\Temp. Feel free to modify this for your own documentation system. The result will look something like this:

################### Secure Application Model Information ###################
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' 
$TenantID = 'YourTenantID'
$RefreshToken = 'VeryLongRefreshToken'
################# /Secure Application Model Information ####################
write-host "Creating credentials and tokens." -ForegroundColor Green

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal

write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
write-host "Setting HTML Headers" -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>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>
"@



write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {

    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.accesstoken)" }
    
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    $headers = @{ "Authorization" = "Bearer $($CustgraphToken.AccessToken)" }
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $AllTeamsURI = "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$top=999"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    $TeamSettings = foreach ($Team in $Teams) {
        $Settings = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/Teams/$($team.id)" -Headers $Headers -Method Get -ContentType "application/json")
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/members?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/Owners?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value

        [PSCustomObject]@{
            'Team Name'          = $settings.displayname
            'Team ID'            = $settings.id
            'Description'        = $Settings.description
            'Teams URL'          = $settings.webUrl
            'Messaging Settings' = $settings.messagingSettings
            'Member Settings'    = $Settings.memberSettings
            'Guest Settings'     = $Settings.guestSettings
            'Fun Settings'       = $settings.funSettings
            'Discovery Settings' = $settings.discoverySettings
            'Is Archived'        = $Settings.isArchived
            'Team Owners'        = $Owners | Select-Object Displayname, UserPrincipalname
            'Team Members'       = $Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname
            'Team Guests'        = $Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname
        }
        $SettingsHTML = $Settings | ConvertTo-Html -as List - -Fragment -PreContent "<h1>Settings<h2>" | Out-String
        $OwnersHTML = $Owners | Select-Object Displayname, UserPrincipalname | ConvertTo-Html -Fragment -PreContent "<h1>Owners<h2>" | Out-String
        $MembersHTML = $Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname | ConvertTo-Html  -Fragment -PreContent "<h1>Members<h2>" | Out-String
        $GuestsHTML = $Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname | ConvertTo-Html -Fragment -PreContent "<h1>Guests<h2>" | Out-String
    
        $head, $SettingsHTML, $OwnersHTML, $MembersHTML, $GuestsHTML -replace "<th>", "<th style=`"background-color:#4CAF50`">" | Out-File "C:\Temp\$($Customer.name) - $($Settings.displayname).html"

    }


}

IT-Glue version

So the IT-Glue version is quite a bit longer; I’ve also improved the previous domain matching logic we used to make the script run much faster. This script creates the Flexible Asset for you when running it the first time. We match the teams based on the Team Name. This script can also run from an Azure Function.

################### Secure Application Model Information ###################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret'
$RefreshToken = 'YourVeryLongRefreshToken'
################# /Secure Application Model Information ####################
 
################# IT-Glue Information ######################################
$ITGkey = "YourITGAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Teams - Autodoc"
$Description = "Teams information automatically retrieved."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
################# /IT-Glue Information #####################################
 
 
write-host "Checking if IT-Glue Module is available, and if not install it." -ForegroundColor Green
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
#Setting IT-Glue logon information
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
  
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
do {
    $AllITGlueContacts += (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    Write-Host "Retrieved $($AllITGlueContacts.count) Contacts" -ForegroundColor Yellow
}while ($AllITGlueContacts.count % 1000 -eq 0 -and $AllITGlueContacts.count -ne 0) 
write-host "Generating unique ID List" -ForegroundColor Green
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
$DomainList = $DomainList | sort-object -Property Combined -Unique
  
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Team Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Team URL"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Team Message settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Team Member settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Team Guest settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Team Fun Settings"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 7
                            name           = "Team Owners"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 8
                            name           = "Team Members"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 9
                            name           = "Team Guests"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                      
  
  
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
  
 
 
write-host "Creating credentials and tokens." -ForegroundColor Green
 
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
 
write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
 
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
    write-host "Grabbing domains for client $($Customer.name)." -ForegroundColor Green
    $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "Finding possible organisation IDs" -ForegroundColor Green
    $orgid = foreach ($customerDomain in $customerdomains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-host "Documenting in the following organizations." -ForegroundColor Green
    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $AllTeamsURI = "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$top=999"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($Team in $Teams) {
        $Settings = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/Teams/$($team.id)" -Headers $Headers -Method Get -ContentType "application/json")
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/members?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($team.id)/Owners?`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
 
        $FlexAssetBody =
        @{
            type       = 'flexible-assets'
            attributes = @{
                traits = @{
                    'team-name'             = $settings.displayname
                    'team-url'              = $settings.webUrl
                    'team-message-settings' = ($settings.messagingSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    'team-member-settings'  = ($Settings.memberSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    "team-guest-settings"   = ($Settings.guestSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    "team-fun-settings"     = ($settings.funSettings | convertto-html -Fragment -as list | out-string) -replace $TableStyling
                    'team-owners'           = ($Owners | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    'team-members'          = ($Members | Where-Object { $_.UserType -eq "Member" } | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    'team-guests'           = ($Members | Where-Object { $_.UserType -eq "Guest" } | Select-Object Displayname, UserPrincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                }
            }
        }
 
        write-host "   Uploading $($Settings.displayName) into IT-Glue" -foregroundColor green
        foreach ($org in $orgID | Select-Object -Unique) {
            $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'team-name' -eq $Settings.displayName }
            #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
            if (!$ExistingFlexAsset) {
                if ($FlexAssetBody.attributes.'organization-id') {
                    $FlexAssetBody.attributes.'organization-id' = $org
                }
                else { 
                    $FlexAssetBody.attributes.add('organization-id', $org)
                    $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
                }
                write-output "                      Creating new Team: $($Settings.displayName) into IT-Glue organisation $org"
                New-ITGlueFlexibleAssets -data $FlexAssetBody
     
            }
            else {
                write-output "                      Updating Team: $($Settings.displayName)into IT-Glue organisation $org"
                $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
                Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
            }
     
        }
    }
}

And that’s it! 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. 🙂

Documenting with PowerShell: creating a device audit log

My engineers sometimes have questions about past events on a specific machine, to which I often point them towards our RMM system. The RMM system contains a bulk of data that can be used to make analysis.

The problem is just that – It’s unedited bulk data, often with different locations for different pieces of information.

So to assist them in gathering this info quicker I’ve decided to create an IT-Glue Automatic documentation for a device audit log of sorts. This audit log can be reviewed to check information easier and quicker than in the RMM system. It also contains more legacy information. e.g. “Who logged onto this computer last year”.

As always, I’ve created two scripts. One for IT-Glue users, the other for generic documentation systems. Both scripts collect the following info:

  • All user profiles on the computer
  • Installed updates
  • Installed software
  • Events such as the first time a user logs on, software installations/removal, kb installations and if the WAN IP has changed and to what. You can easily add new events if you’d like.

IT-Glue version

The IT-Glue version grabs the current asset from IT-Glue and tags this. That way you can find the log attached to the correct device. Cool huh!? It also creates the flexible asset for you, if it does not exist.

###############
$ITGkey = "ITGLUEAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Device logbook - Autodoc"
$ITGlueOrgID = "ORGID"
$Description = "A logbook for each device that contains information about the last logged on users, dates of software installation."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############
 

#Settings IT-Glue logon information
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 $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
 
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Device Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 2
                            name           = "Events"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "User Profiles"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Installed Updates"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Installed Software"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Device"
                            kind           = "Tag"
                            "tag-type"     = 'Configurations'
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}


write-host "Starting documentation process." -foregroundColor green

write-host "Getting update history." -foregroundColor green
$date = Get-Date 
$hotfixesInstalled = get-hotfix

write-host "Getting User Profiles." -foregroundColor green

$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
    $profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
    $Profile
}
write-host "Getting Installed applications." -foregroundColor green

$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
    if ($null -eq $application.InstallLocation) { continue }
    if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd')  }
    $Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm') 
    $application
}


write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force

if ($Currentip -ne $previousIP) {
    $Events += [pscustomobject]@{
        date  = $date.ToString('yyyy-MM-dd HH:mm') 
        Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
        type  = "WAN Event"
    }
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
    $Events += [pscustomobject]@{
        date  = $installation.InstallDate
        Event = "New Software: $($Installation.displayname) has been installed or updated."
        type  = "Software Event"
    }
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
    $Events += [pscustomobject]@{
        date  = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm') 
        Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
        type  = "Update Event"
    }

}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
    if ($users.CreationTime -gt $date.AddDays(-1)) { 
        $Events += [pscustomobject]@{
            date  = $users.CreationTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "First time logon: $($Users.name) has logged on for the first time."
            type  = "User event"
        }
    }
    $NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
    if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logoff: $($Users.name) has logged off or restarted the computer."
            type  = "User event"
        }
    }
    if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logon: $($Users.name) has logged on."
            type  = "User event"
                
        }
    }
}
$events = $events | Sort-Object -Property date -Descending
$TaggedResource = (Get-ITGlueConfigurations -organization_id $ITGlueOrgID -filter_serial_number (get-ciminstance win32_bios).serialnumber).data
$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string)  -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy  | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling

write-host "Uploading to IT-Glue." -foregroundColor green
$FlexAssetBody = @{
    type       = 'flexible-assets'
    attributes = @{
        traits = @{
            'device-name'        = $env:computername
            'events'             = $eventshtml
            'user-profiles'      = $ProfilesHTML
            'installed-updates'  = $UpdatesHTML
            'installed-software' = $SoftwareHTML
            'device'             = $TaggedResource.id
        }
    }
}
write-host "Documenting to IT-Glue"  -ForegroundColor Green
$ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $($filterID.ID) -filter_organization_id $ITGlueOrgID).data | Where-Object { $_.attributes.traits.'device-name' -eq $env:computername }

#If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
if (!$ExistingFlexAsset) {
    $FlexAssetBody.attributes.add('organization-id', $ITGlueOrgID)
    $FlexAssetBody.attributes.add('flexible-asset-type-id', $($filterID.ID))
    write-host "  Creating Device Asset Log into IT-Glue organisation $ITGlueOrgID" -ForegroundColor Green
    New-ITGlueFlexibleAssets -data $FlexAssetBody
}
else {
    write-host "  Editing Device Asset Log into IT-Glue organisation $ITGlueOrgID"  -ForegroundColor Green
    $ExistingFlexAsset = $ExistingFlexAsset | select-object -last 1
    write-host "  Adding previous events from asset to current one."  -ForegroundColor Green
    $CombinedList =  "$($Eventshtml)`n$($ExistingFlexAsset.attributes.traits.events)" -split "`n" | Select-Object -Unique | Out-String
    $FlexAssetBody.attributes.traits.events = $CombinedList
    Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id -data $FlexAssetBody
}
 

Generic version

The generic version collects the same info, but outputs this to C:\Temp\Auditoutput.html.

###############
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
##############
 

write-host "Starting documentation process." -foregroundColor green

write-host "Getting update history." -foregroundColor green
$date = Get-Date 
$hotfixesInstalled = get-hotfix

write-host "Getting User Profiles." -foregroundColor green

$UsersProfiles = Get-CimInstance win32_userprofile | Where-Object { $_.special -eq $false } | select-object localpath, LastUseTime, Username
$UsersProfiles = foreach ($Profile in $UsersProfiles) {
    $profile.username = ($profile.localpath -split '\', -1, 'simplematch') | Select-Object -Last 1
    $Profile
}
write-host "Getting Installed applications." -foregroundColor green

$InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) + ($software += Get-ChildItem "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" | Get-ItemProperty) | Select-Object Displayname, Publisher, Displayversion, InstallLocation, InstallDate
$installedSoftware = foreach ($Application in $installedSoftware) {
    if ($null -eq $application.InstallLocation) { continue }
    if ($null -eq $Application.InstallDate) { $application.installdate = (get-item $application.InstallLocation -ErrorAction SilentlyContinue).CreationTime.ToString('yyyyMMdd')  }
    $Application.InstallDate = [datetime]::parseexact($Application.InstallDate, 'yyyyMMdd', $null).ToString('yyyy-MM-dd HH:mm') 
    $application
}


write-host "Checking WAN IP" -foregroundColor green
$events = @()
$previousIP = get-content "$($env:ProgramData)/LastIP.txt" -ErrorAction SilentlyContinue | Select-Object -first 1
if (!$previousIP) { Write-Host "No previous IP found. Compare will fail." }
$Currentip = (Invoke-RestMethod -Uri "https://ipinfo.io/ip") -replace "`n", ""
$Currentip | out-file "$($env:ProgramData)/LastIP.txt" -Force

if ($Currentip -ne $previousIP) {
    $Events += [pscustomobject]@{
        date  = $date.ToString('yyyy-MM-dd HH:mm') 
        Event = "WAN IP has changed from $PreviousIP to $CurrentIP"
        type  = "WAN Event"
    }
}
write-host "Getting Installed applications in last 24 hours for events list" -foregroundColor green
$InstalledInLast24Hours = $installedsoftware | where-object { $_.installDate -ge $date.addhours(-24).tostring('yyyy-MM-dd') }
foreach ($installation in $InstalledInLast24Hours) {
    $Events += [pscustomobject]@{
        date  = $installation.InstallDate
        Event = "New Software: $($Installation.displayname) has been installed or updated."
        type  = "Software Event"
    }
}
write-host "Getting KBs in last 24 hours for events list" -foregroundColor green
$hotfixesInstalled = get-hotfix | where-object { $_.InstalledOn -ge $date.adddays(-2) }
foreach ($InstalledHotfix in $hotfixesInstalled) {
    $Events += [pscustomobject]@{
        date  = $InstalledHotfix.installedOn.tostring('yyyy-MM-dd HH:mm') 
        Event = "Update $($InstalledHotfix.Hotfixid) has been installed."
        type  = "Update Event"
    }

}
write-host "Getting user logon/logoff events of last 24 hours." -foregroundColor green
$UserProfilesDir = get-childitem "C:\Users"
foreach ($Users in $UserProfilesDir) {
    if ($users.CreationTime -gt $date.AddDays(-1)) { 
        $Events += [pscustomobject]@{
            date  = $users.CreationTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "First time logon: $($Users.name) has logged on for the first time."
            type  = "User event"
        }
    }
    $NTUser = get-item "$($users.FullName)\NTUser.dat" -force -ErrorAction SilentlyContinue
    if ($NTUser.LastWriteTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastWriteTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logoff: $($Users.name) has logged off or restarted the computer."
            type  = "User event"
        }
    }
    if ($NTUser.LastAccessTime -gt $date.AddDays(-1)) {
        $Events += [pscustomobject]@{
            date  = $NTUser.LastAccessTime.tostring('yyyy-MM-dd HH:mm') 
            Event = "Logon: $($Users.name) has logged on."
            type  = "User event"
                
        }
    }
}
$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>
"@


$events = $events | Sort-Object -Property date -Descending

$eventshtml = ($Events | convertto-html -fragment | out-string) -replace $TableStyling
$ProfilesHTML = ($UsersProfiles | convertto-html -Fragment | out-string)  -replace $TableStyling
$updatesHTML = ($hotfixesInstalled | select-object InstalledOn, Hotfixid, caption, InstalledBy  | convertto-html -Fragment | out-string) -replace $TableStyling
$SoftwareHTML = ($installedSoftware | convertto-html -Fragment | out-string) -replace $TableStyling


$head,$eventshtml,$ProfilesHTML,$updatesHTML,$SoftwareHTML | out-file "C:\Temp\Auditoutput.html"

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

Documenting with PowerShell: Syncing Unifi devices to IT-Glue

This blog should be used in together with my previous blog about Unifi documentation. This script syncs all devices to IT-Glue and makes sure the configurations are in sync with eachother.

It will overwrite any changes you’ve made to the configurations, so treat with care.

###############
$ITGkey = "ITGlueKey"
$ITGbaseURI = "https://api.eu.itglue.com"
$UnifiBaseUri = "https://Controller.yourdomain.com:8443/api"
$UnifiUser = "APIUSER"
$UnifiPassword = "APIPASSWORD"
##############

#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
write-host "Checking if products exist in IT-Glue, and if not creating them."
$unifiAllModels = @"
[{"c":"BZ2","t":"uap","n":"UniFi AP"},{"c":"BZ2LR","t":"uap","n":"UniFi AP-LR"},{"c":"U2HSR","t":"uap","n":"UniFi AP-Outdoor+"},
{"c":"U2IW","t":"uap","n":"UniFi AP-In Wall"},{"c":"U2L48","t":"uap","n":"UniFi AP-LR"},{"c":"U2Lv2","t":"uap","n":"UniFi AP-LR v2"},
{"c":"U2M","t":"uap","n":"UniFi AP-Mini"},{"c":"U2O","t":"uap","n":"UniFi AP-Outdoor"},{"c":"U2S48","t":"uap","n":"UniFi AP"},
{"c":"U2Sv2","t":"uap","n":"UniFi AP v2"},{"c":"U5O","t":"uap","n":"UniFi AP-Outdoor 5G"},{"c":"U7E","t":"uap","n":"UniFi AP-AC"},
{"c":"U7EDU","t":"uap","n":"UniFi AP-AC-EDU"},{"c":"U7Ev2","t":"uap","n":"UniFi AP-AC v2"},{"c":"U7HD","t":"uap","n":"UniFi AP-HD"},
{"c":"U7SHD","t":"uap","n":"UniFi AP-SHD"},{"c":"U7NHD","t":"uap","n":"UniFi AP-nanoHD"},{"c":"UCXG","t":"uap","n":"UniFi AP-XG"},
{"c":"UXSDM","t":"uap","n":"UniFi AP-BaseStationXG"},{"c":"UCMSH","t":"uap","n":"UniFi AP-MeshXG"},{"c":"U7IW","t":"uap","n":"UniFi AP-AC-In Wall"},
{"c":"U7IWP","t":"uap","n":"UniFi AP-AC-In Wall Pro"},{"c":"U7MP","t":"uap","n":"UniFi AP-AC-Mesh-Pro"},{"c":"U7LR","t":"uap","n":"UniFi AP-AC-LR"},
{"c":"U7LT","t":"uap","n":"UniFi AP-AC-Lite"},{"c":"U7O","t":"uap","n":"UniFi AP-AC Outdoor"},{"c":"U7P","t":"uap","n":"UniFi AP-Pro"},
{"c":"U7MSH","t":"uap","n":"UniFi AP-AC-Mesh"},{"c":"U7PG2","t":"uap","n":"UniFi AP-AC-Pro"},{"c":"p2N","t":"uap","n":"PicoStation M2"},
{"c":"US8","t":"usw","n":"UniFi Switch 8"},{"c":"US8P60","t":"usw","n":"UniFi Switch 8 POE-60W"},{"c":"US8P150","t":"usw","n":"UniFi Switch 8 POE-150W"},
{"c":"S28150","t":"usw","n":"UniFi Switch 8 AT-150W"},{"c":"USC8","t":"usw","n":"UniFi Switch 8"},{"c":"US16P150","t":"usw","n":"UniFi Switch 16 POE-150W"},
{"c":"S216150","t":"usw","n":"UniFi Switch 16 AT-150W"},{"c":"US24","t":"usw","n":"UniFi Switch 24"},{"c":"US24P250","t":"usw","n":"UniFi Switch 24 POE-250W"},
{"c":"US24PL2","t":"usw","n":"UniFi Switch 24 L2 POE"},{"c":"US24P500","t":"usw","n":"UniFi Switch 24 POE-500W"},{"c":"S224250","t":"usw","n":"UniFi Switch 24 AT-250W"},
{"c":"S224500","t":"usw","n":"UniFi Switch 24 AT-500W"},{"c":"US48","t":"usw","n":"UniFi Switch 48"},{"c":"US48P500","t":"usw","n":"UniFi Switch 48 POE-500W"},
{"c":"US48PL2","t":"usw","n":"UniFi Switch 48 L2 POE"},{"c":"US48P750","t":"usw","n":"UniFi Switch 48 POE-750W"},{"c":"S248500","t":"usw","n":"UniFi Switch 48 AT-500W"},
{"c":"S248750","t":"usw","n":"UniFi Switch 48 AT-750W"},{"c":"US6XG150","t":"usw","n":"UniFi Switch 6XG POE-150W"},{"c":"USXG","t":"usw","n":"UniFi Switch 16XG"},
{"c":"UGW3","t":"ugw","n":"UniFi Security Gateway 3P"},{"c":"UGW4","t":"ugw","n":"UniFi Security Gateway 4P"},{"c":"UGWHD4","t":"ugw","n":"UniFi Security Gateway HD"},
{"c":"UGWXG","t":"ugw","n":"UniFi Security Gateway XG-8"},{"c":"UP4","t":"uph","n":"UniFi Phone-X"},{"c":"UP5","t":"uph","n":"UniFi Phone"},
{"c":"UP5t","t":"uph","n":"UniFi Phone-Pro"},{"c":"UP7","t":"uph","n":"UniFi Phone-Executive"},{"c":"UP5c","t":"uph","n":"UniFi Phone"},
{"c":"UP5tc","t":"uph","n":"UniFi Phone-Pro"},{"c":"UP7c","t":"uph","n":"UniFi Phone-Executive"}]
"@
 
$configTypes = @"
[{"t":"uap","n":"Unifi AP"},{"t":"usw","n":"Unifi Switch"},{"t":"ugw","n":"Unifi Gateway"},{"t":"uph","n":"Unifi VOIP"}]
"@ | ConvertFrom-Json

$unifiAllModels = $unifiAllModels | ConvertFrom-Json
$unifiModels = $unifiAllModels | Sort-Object n -Unique
$ITGConfigTypes = (Get-ITGlueConfigurationTypes).data
write-host "Check Config Types and creating if required" -ForegroundColor Green
foreach ($ConfType in $configTypes) {
    if ($ConfType.n -notin $ITGConfigTypes.attributes.name) {
        write-host "Creating $($Model.n)" -ForegroundColor Green
        New-ITGlueConfigurationTypes -data @{
            type       = 'configuration-types'
            attributes = @{
                name = $ConfType.n
            }
        }

    }
}
$ExistingModels = (Get-ITGlueModels -page_size 1000).data
write-host "Checkings manufacture and creating if required" -ForegroundColor Green
$Manafacture = (Get-ITGlueManufacturers -filter_name "UniFi").data
if (!$Manafacture) {
    New-ITGlueManufacturers -data @{
        type       = 'manufacturers'
        attributes = @{
            name = 'uniFi-2'
        }
    }
    $Manafacture = (Get-ITGlueManufacturers -filter_name "UniFi").data
}
write-host "Grabbing active status"
$ConfigurationStatusId = (Get-ITGlueConfigurationStatuses -filter_name 'Active').data.ID | Select-Object -Last 1
write-host "Checkings models and creating if required" -ForegroundColor Green
foreach ($Model in $unifiAllModels) {
    if ($model.n -notin $ExistingModels.attributes.name) {
        write-host "Creating $($Model.n)" -ForegroundColor Green
        New-ITGlueModels -data @{
            type       = 'models'
            attributes = @{
                'manufacturer-id' = $Manafacture.id
                name              = $model.n
            }
        }

    }
}

write-host "Start configuration syncing process." -foregroundColor green


$UniFiCredentials = @{
    username = $UnifiUser
    password = $UnifiPassword
    remember = $true
} | ConvertTo-Json

write-host "Logging in to Unifi API." -ForegroundColor Green
try {
    Invoke-RestMethod -Uri "$UnifiBaseUri/login" -Method POST -Body $uniFiCredentials -SessionVariable websession
}
catch {
    write-host "Failed to log in on the Unifi API. Error was: $($_.Exception.Message)" -ForegroundColor Red
}
write-host "Collecting sites from Unifi API." -ForegroundColor Green
try {
    $sites = (Invoke-RestMethod -Uri "$UnifiBaseUri/self/sites" -WebSession $websession).data
}
catch {
    write-host "Failed to collect the sites. Error was: $($_.Exception.Message)" -ForegroundColor Red
}

foreach ($site in $sites) {
    $ITGlueOrgID = $site.desc.split('()')[1]
    if (!$ITGlueOrgID) {
        write-host "Could not get IT-Glue OrgID for site $($site.desc). Moving on to next site." -ForegroundColor Yellow
        continue
    }
    else {
        write-host "Documenting $($site.desc), using ITGlue ID: $ITGlueOrgID" -ForegroundColor Green
    }

    $unifiDevices = Invoke-RestMethod -Uri "$UnifiBaseUri/s/$($site.name)/stat/device" -WebSession $websession
    foreach ($device in $unifiDevices.data) {
 ($ExistingConfiguration = Get-ITGlueConfigurations -organization_id $ITGlueOrgID -filter_serial_number $device.serial).data.id | select-object -last 1
        $DeviceName = if (!$device.name) { "Unifi Device $($device.serial)" } else { $device.name }
        $ModelName = ($unifiAllModels | Where-Object { $_.c -eq $device.model }).n
        $modelid = $ExistingModels | Where-Object { $_.attributes.name -eq $ModelName } | Select-Object -last 1
        $ConfigName = ($configtypes | Where-Object { $_.t -eq $device.type }).n
        $Configurationtypeid = ($ITGConfigTypes | Where-Object { $_.attributes.name -eq $Configname }).id
        $ConfigurationBody = @{
            type       = "configurations"
            attributes = @{
                "organization-id"         = $ITGlueOrgID
                "name"                    = $DeviceName
                "configuration-type-id"   = $Configurationtypeid
                "configuration-status-id" = $ConfigurationStatusId
                "manufacturer-id"         = $Manafacture.id
                "model-id"                = $ModelID.id
                "primary-ip"              = $device.ip
                "serial-number"           = $device.serial
                "mac-address"             = $device.mac
            }
        }
        if (!$ExistingConfiguration) { 
            write-host "Creating new device" -ForegroundColor Green
            New-ITGlueConfigurations -organization_id $ITGlueOrgID -data $ConfigurationBody
        } 
        else {
            write-host "Editing previous existing device" -ForegroundColor Green
            Set-ITGlueConfigurations -id ($ExistingConfiguration.data.id | Select-Object -last 1) -data $ConfigurationBody

        }
    }


}

And that’s it! this blog will be followed up by another documentation blog pretty quick, so its shorter than normal. As always, Happy PowerShelling.